diff --git a/package.json b/package.json index 55fd279..63616ea 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "babel-plugin-react-compiler": "0.0.0-experimental-734b737-20241003", + "jszip": "^3.10.1", "next": "15.0.0-rc.1", "next-plausible": "^3.12.2", "react": "19.0.0-rc-cd22717c-20241013", @@ -31,9 +32,9 @@ "concurrently": "^9.1.0", "eslint": "^8", "eslint-config-next": "15.0.0-rc.1", + "postcss": "^8", "prettier": "^3.3.3", "prettier-plugin-tailwindcss": "^0.6.8", - "postcss": "^8", "tailwindcss": "^3.4.1", "typescript": "^5" }, diff --git a/src/app/(tools)/icon-generator/icon-tool.tsx b/src/app/(tools)/icon-generator/icon-tool.tsx new file mode 100644 index 0000000..db9bf7c --- /dev/null +++ b/src/app/(tools)/icon-generator/icon-tool.tsx @@ -0,0 +1,296 @@ +"use client"; + +import { usePlausible } from "next-plausible"; +import { useCallback, useEffect, useState } from "react"; +import { UploadBox } from "@/components/shared/upload-box"; +import { FileDropzone } from "@/components/shared/file-dropzone"; +import { + type FileUploaderResult, + useFileUploader, +} from "@/hooks/use-file-uploader"; +import JSZip from "jszip"; + +const PRESET_SIZES = [16, 32, 48, 64, 128, 256, 512, 640, 800, 1024] as const; +type PresetSize = (typeof PRESET_SIZES)[number]; +type IconSize = PresetSize; + +interface GeneratedIcon { + size: IconSize; + dataUrl: string; +} + +function IconToolCore(props: { fileUploaderProps: FileUploaderResult }) { + const { imageContent, imageMetadata, handleFileUploadEvent, cancel } = + props.fileUploaderProps; + const [generatedIcons, setGeneratedIcons] = useState([]); + const [selectedSizes, setSelectedSizes] = useState>( + new Set(PRESET_SIZES), + ); + const [customSize, setCustomSize] = useState(""); + + const generateIcons = useCallback( + async (sizes: IconSize[]) => { + if (!imageContent) return; + + const icons: GeneratedIcon[] = []; + + for (const size of sizes) { + const canvas = document.createElement("canvas"); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext("2d"); + if (!ctx) continue; + + const img = new Image(); + await new Promise((resolve) => { + img.onload = resolve; + img.src = imageContent; + }); + + const scale = Math.min(size / img.width, size / img.height); + const x = (size - img.width * scale) / 2; + const y = (size - img.height * scale) / 2; + ctx.drawImage(img, x, y, img.width * scale, img.height * scale); + + icons.push({ + size, + dataUrl: canvas.toDataURL("image/png"), + }); + } + + setGeneratedIcons(icons); + }, + [imageContent], + ); + + useEffect(() => { + if (imageContent && imageMetadata) { + void generateIcons(Array.from(selectedSizes)); + } + }, [imageContent, imageMetadata, selectedSizes, generateIcons]); + + const plausible = usePlausible(); + + const handleSizeToggle = (size: IconSize) => { + const newSizes = new Set(selectedSizes); + if (newSizes.has(size)) { + newSizes.delete(size); + } else { + newSizes.add(size); + } + setSelectedSizes(newSizes); + }; + + const handleCustomSizeAdd = (e: React.FormEvent) => { + e.preventDefault(); + const size = parseInt(customSize); + if (size > 0 && size <= 2048) { + handleSizeToggle(size as IconSize); + setCustomSize(""); + } + }; + + const handleSelectAll = () => { + setSelectedSizes(new Set(PRESET_SIZES)); + }; + + const handleSelectNone = () => { + setSelectedSizes(new Set()); + }; + + const handleDownloadAll = () => { + plausible("generate-icons"); + + const zip = new JSZip(); + generatedIcons.forEach((icon) => { + const base64Data = icon.dataUrl.split(",")[1]; + if (base64Data) { + zip.file(`icon-${icon.size}x${icon.size}.png`, base64Data, { + base64: true, + }); + } + }); + + void zip.generateAsync({ type: "blob" }).then((content) => { + const link = document.createElement("a"); + link.href = URL.createObjectURL(content); + link.download = "favicons.zip"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }); + }; + + if (!imageMetadata) { + return ( + + ); + } + + return ( +
+ {/* Left Sidebar - Options */} +
+
+
+

+ Size Options +

+
+ + +
+
+ +
+ {PRESET_SIZES.map((size) => ( + + ))} +
+ + {/* Custom Size Input */} +
+

+ Custom Size +

+
+ setCustomSize(e.target.value)} + placeholder="Enter size" + min="1" + max="2048" + className="w-full rounded-md bg-white/10 px-3 py-1.5 text-sm text-white/90 placeholder:text-white/50" + /> + +
+

Max size: 2048px

+
+ + {/* Custom Sizes List */} + {Array.from(selectedSizes).some( + (size) => !PRESET_SIZES.includes(size), + ) && ( +
+

+ Custom Sizes +

+
+ {Array.from(selectedSizes) + .filter((size) => !PRESET_SIZES.includes(size)) + .sort((a, b) => a - b) + .map((size) => ( +
+ + {size}x{size} + + +
+ ))} +
+
+ )} +
+
+ + {/* Main Content - Preview Grid */} +
+
+ {generatedIcons.map((icon) => ( +
+ {`${icon.size}x${icon.size} + + {icon.size}x{icon.size} + +
+ ))} +
+ + {/* Action Buttons */} +
+ + +
+
+
+ ); +} + +export default function IconTool() { + const fileUploaderProps = useFileUploader(); + + return ( + + + + ); +} diff --git a/src/app/(tools)/icon-generator/page.tsx b/src/app/(tools)/icon-generator/page.tsx new file mode 100644 index 0000000..c7da644 --- /dev/null +++ b/src/app/(tools)/icon-generator/page.tsx @@ -0,0 +1,10 @@ +import IconTool from "./icon-tool"; + +export const metadata = { + title: "Icon Generator - QuickPic", + description: "Generate icons from any image. Free and simple.", +}; + +export default function IconGeneratorPage() { + return ; +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 5d093b4..b827bb3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -26,6 +26,9 @@ export default function Home() { Corner Rounder + + Icon Generator +