From fa48c4862673977ab4d88441f0adbf907270c136 Mon Sep 17 00:00:00 2001 From: oiupoyt Date: Sat, 12 Sep 2026 17:02:52 +0530 Subject: [PATCH] feat: add 5 high-impact tools (emoji resizer, favicon generator, secret share, palette extractor, svg to png) --- package-lock.json | 105 +++++ package.json | 2 + .../tools/color-palette-extractor/page.tsx | 275 ++++++++++++ src/app/tools/discord-emoji-resizer/page.tsx | 390 ++++++++++++++++++ src/app/tools/favicon-generator/page.tsx | 326 +++++++++++++++ src/app/tools/secret-share/page.tsx | 272 ++++++++++++ src/app/tools/svg-to-png/page.tsx | 282 +++++++++++++ src/lib/tools.ts | 17 + 8 files changed, 1669 insertions(+) create mode 100644 src/app/tools/color-palette-extractor/page.tsx create mode 100644 src/app/tools/discord-emoji-resizer/page.tsx create mode 100644 src/app/tools/favicon-generator/page.tsx create mode 100644 src/app/tools/secret-share/page.tsx create mode 100644 src/app/tools/svg-to-png/page.tsx diff --git a/package-lock.json b/package-lock.json index 7677aa3..bb4d354 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "exifr": "^7.1.3", + "jszip": "^3.10.2", "lucide-react": "^1.45.0", "marked": "^18.0.11", "next": "16.3.3", @@ -19,6 +20,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/jszip": "^3.4.0", "@types/node": "^20", "@types/qrcode": "^1.5.6", "@types/react": "^19", @@ -1732,6 +1734,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jszip": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@types/jszip/-/jszip-3.4.0.tgz", + "integrity": "sha512-GFHqtQQP3R4NNuvZH3hNCYD0NbyBZ42bkN7kO3NDrU/SnvIZWMS8Bp38XCsRKBT5BXvgm0y1zqpZWp/ZkRzBzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "jszip": "*" + } + }, "node_modules/@types/node": { "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", @@ -2947,6 +2959,12 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4275,6 +4293,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4302,6 +4326,12 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -4892,6 +4922,18 @@ "node": ">=4.0" } }, + "node_modules/jszip": { + "version": "3.10.2", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.2.tgz", + "integrity": "sha512-3l+rb15IOWtUhU0H5MFqES/T6Kh7abYwjosBey/vD6hDt8zoEffkSC5Ws5SGtgVw3gBx2NEbhTeSW1+kWkpyTQ==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4936,6 +4978,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -5842,6 +5893,12 @@ "node": ">= 0.8.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -5930,6 +5987,27 @@ "dev": true, "license": "MIT" }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -6088,6 +6166,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -6194,6 +6278,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/sharp": { "version": "0.35.4", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", @@ -6386,6 +6476,15 @@ "node": ">= 0.4" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -6965,6 +7064,12 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index c1dd63d..bff1e75 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "exifr": "^7.1.3", + "jszip": "^3.10.2", "lucide-react": "^1.45.0", "marked": "^18.0.11", "next": "16.3.3", @@ -20,6 +21,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/jszip": "^3.4.0", "@types/node": "^20", "@types/qrcode": "^1.5.6", "@types/react": "^19", diff --git a/src/app/tools/color-palette-extractor/page.tsx b/src/app/tools/color-palette-extractor/page.tsx new file mode 100644 index 0000000..f5c97a5 --- /dev/null +++ b/src/app/tools/color-palette-extractor/page.tsx @@ -0,0 +1,275 @@ +"use client"; + +import { useState, useRef, useCallback } from "react"; +import ToolLayout from "@/components/ToolLayout"; + +interface ColorItem { + hex: string; + rgb: { r: number; g: number; b: number }; + hsl: { h: number; s: number; l: number }; + population: number; +} + +function rgbToHex(r: number, g: number, b: number): string { + return ( + "#" + + [r, g, b] + .map((x) => { + const hex = Math.round(x).toString(16); + return hex.length === 1 ? "0" + hex : hex; + }) + .join("") + ); +} + +function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } { + r /= 255; + g /= 255; + b /= 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + let h = 0; + let s = 0; + const l = (max + min) / 2; + + if (max !== min) { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0); + break; + case g: + h = (b - r) / d + 2; + break; + case b: + h = (r - g) / d + 4; + break; + } + h /= 6; + } + + return { + h: Math.round(h * 360), + s: Math.round(s * 100), + l: Math.round(l * 100), + }; +} + +// Simple color quantization by bucket clustering +function extractDominantColors(data: Uint8ClampedArray, colorCount = 8): ColorItem[] { + const buckets: { [key: string]: { r: number; g: number; b: number; count: number } } = {}; + const step = 4 * 4; // Sample every 4th pixel for speed + + for (let i = 0; i < data.length; i += step) { + const a = data[i + 3]; + if (a < 128) continue; // Skip transparent pixels + + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + + // Quantize into 32-step buckets + const qr = Math.round(r / 32) * 32; + const qg = Math.round(g / 32) * 32; + const qb = Math.round(b / 32) * 32; + const key = `${qr},${qg},${qb}`; + + if (!buckets[key]) { + buckets[key] = { r: 0, g: 0, b: 0, count: 0 }; + } + buckets[key].r += r; + buckets[key].g += g; + buckets[key].b += b; + buckets[key].count++; + } + + const sorted = Object.values(buckets) + .sort((a, b) => b.count - a.count) + .slice(0, colorCount); + + return sorted.map((item) => { + const r = Math.round(item.r / item.count); + const g = Math.round(item.g / item.count); + const b = Math.round(item.b / item.count); + return { + hex: rgbToHex(r, g, b), + rgb: { r, g, b }, + hsl: rgbToHsl(r, g, b), + population: item.count, + }; + }); +} + +export default function ColorPaletteExtractorPage() { + const [file, setFile] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [palette, setPalette] = useState([]); + const [hoverColor, setHoverColor] = useState(null); + const [copiedText, setCopiedText] = useState(null); + const canvasRef = useRef(null); + const fileInputRef = useRef(null); + + const processImage = useCallback((sourceFile: File) => { + const img = new Image(); + const url = URL.createObjectURL(sourceFile); + img.src = url; + + img.onload = () => { + const canvas = canvasRef.current || document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const maxDim = 400; + const scale = Math.min(maxDim / img.naturalWidth, maxDim / img.naturalHeight, 1); + canvas.width = Math.round(img.naturalWidth * scale); + canvas.height = Math.round(img.naturalHeight * scale); + + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const colors = extractDominantColors(imgData.data, 8); + setPalette(colors); + }; + }, []); + + const handleFileSelect = (selected: File) => { + if (!selected.type.startsWith("image/")) return; + setFile(selected); + if (previewUrl) URL.revokeObjectURL(previewUrl); + const url = URL.createObjectURL(selected); + setPreviewUrl(url); + processImage(selected); + }; + + const copyToClipboard = (text: string, label: string) => { + navigator.clipboard.writeText(text); + setCopiedText(label); + setTimeout(() => setCopiedText(null), 1800); + }; + + const getCssVariables = () => { + return `:root {\n` + palette.map((c, i) => ` --color-palette-${i + 1}: ${c.hex};`).join("\n") + `\n}`; + }; + + const getTailwindConfig = () => { + const obj = palette.reduce((acc, c, i) => { + acc[`color-${i + 1}`] = c.hex; + return acc; + }, {} as Record); + return `// tailwind.config.js\ncolors: ` + JSON.stringify(obj, null, 2); + }; + + return ( + +
+ {/* Upload Box */} +
fileInputRef.current?.click()} + onDragOver={(e) => e.preventDefault()} + onDrop={(e) => { + e.preventDefault(); + if (e.dataTransfer.files?.[0]) handleFileSelect(e.dataTransfer.files[0]); + }} + className="cursor-pointer border-2 border-dashed border-[#222] bg-[#0c0c0c] hover:border-[#444] transition-colors p-8 text-center" + > + { + if (e.target.files?.[0]) handleFileSelect(e.target.files[0]); + }} + /> +

+ {file ? file.name : "Drop photo, graphic, or wallpaper here"} +

+

+ Supports PNG, JPEG, WebP, SVG. +

+
+ + {/* Hidden Canvas for computation */} + + + {previewUrl && palette.length > 0 && ( +
+ {/* Image Preview */} +
+ Uploaded reference + {hoverColor && ( +
+
+ {hoverColor} +
+ )} +
+ + {/* Extracted Palette */} +
+
+

+ Dominant Colors ({palette.length}) +

+ {copiedText && ( + Copied {copiedText}! + )} +
+ + {/* Swatches Grid */} +
+ {palette.map((color, idx) => ( +
setHoverColor(color.hex)} + onMouseLeave={() => setHoverColor(null)} + onClick={() => copyToClipboard(color.hex, color.hex)} + className="cursor-pointer border border-[#181818] bg-[#0e0e0e] hover:border-[#333] transition-all p-2 text-left group" + > +
+
{color.hex}
+
+ rgb({color.rgb.r}, {color.rgb.g}, {color.rgb.b}) +
+
+ ))} +
+ + {/* Export Buttons */} +
+ + + +
+
+
+ )} +
+ + ); +} diff --git a/src/app/tools/discord-emoji-resizer/page.tsx b/src/app/tools/discord-emoji-resizer/page.tsx new file mode 100644 index 0000000..43c59ce --- /dev/null +++ b/src/app/tools/discord-emoji-resizer/page.tsx @@ -0,0 +1,390 @@ +"use client"; + +import { useState, useRef, useCallback } from "react"; +import ToolLayout from "@/components/ToolLayout"; + +interface Preset { + id: string; + name: string; + dimension: number; + maxBytes: number; + format: "image/png" | "image/webp"; + description: string; +} + +const PRESETS: Preset[] = [ + { + id: "emoji", + name: "Discord Emoji", + dimension: 128, + maxBytes: 256 * 1024, + format: "image/png", + description: "128x128 px, under 256 KB limit", + }, + { + id: "sticker", + name: "Discord Sticker", + dimension: 320, + maxBytes: 512 * 1024, + format: "image/png", + description: "320x320 px, under 512 KB limit", + }, + { + id: "custom", + name: "High-Res Emoji (WebP)", + dimension: 256, + maxBytes: 256 * 1024, + format: "image/webp", + description: "256x256 px, WebP compression under 256 KB", + }, +]; + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; +} + +export default function DiscordEmojiResizerPage() { + const [file, setFile] = useState(null); + const [originalUrl, setOriginalUrl] = useState(null); + const [originalDimensions, setOriginalDimensions] = useState<{ w: number; h: number } | null>(null); + const [resultBlob, setResultBlob] = useState(null); + const [resultUrl, setResultUrl] = useState(null); + const [activePreset, setActivePreset] = useState(PRESETS[0]); + const [cropMode, setCropMode] = useState<"crop" | "fit">("crop"); + const [isProcessing, setIsProcessing] = useState(false); + const [statusMessage, setStatusMessage] = useState(""); + const fileInputRef = useRef(null); + + const processImage = useCallback( + async (sourceFile: File, preset: Preset, mode: "crop" | "fit") => { + setIsProcessing(true); + setStatusMessage("Resizing and optimizing..."); + + try { + const img = new Image(); + const objUrl = URL.createObjectURL(sourceFile); + img.src = objUrl; + + await new Promise((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = () => reject(new Error("Unable to load image file")); + }); + + const targetDim = preset.dimension; + const canvas = document.createElement("canvas"); + canvas.width = targetDim; + canvas.height = targetDim; + const ctx = canvas.getContext("2d"); + + if (!ctx) { + throw new Error("2D canvas context unavailable"); + } + + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = "high"; + + if (mode === "crop") { + // Center crop to square + const minSide = Math.min(img.naturalWidth, img.naturalHeight); + const sx = (img.naturalWidth - minSide) / 2; + const sy = (img.naturalHeight - minSide) / 2; + ctx.drawImage(img, sx, sy, minSide, minSide, 0, 0, targetDim, targetDim); + } else { + // Fit with transparent background + const scale = Math.min(targetDim / img.naturalWidth, targetDim / img.naturalHeight); + const drawW = img.naturalWidth * scale; + const drawH = img.naturalHeight * scale; + const dx = (targetDim - drawW) / 2; + const dy = (targetDim - drawH) / 2; + ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight, dx, dy, drawW, drawH); + } + + // Binary search compression for target file size + let finalBlob: Blob | null = null; + let low = 0.1; + let high = 1.0; + let bestUnderLimit: Blob | null = null; + + // Try PNG first if format is PNG + if (preset.format === "image/png") { + const pngBlob = await new Promise((res) => canvas.toBlob(res, "image/png")); + if (pngBlob && pngBlob.size <= preset.maxBytes) { + finalBlob = pngBlob; + } + } + + if (!finalBlob) { + // Compress via WebP or JPEG to meet strict size constraints + const compressionFormat = preset.format === "image/png" ? "image/webp" : preset.format; + for (let iter = 0; iter < 7; iter++) { + const mid = (low + high) / 2; + const testBlob = await new Promise((res) => + canvas.toBlob(res, compressionFormat, mid) + ); + + if (!testBlob) break; + + if (testBlob.size <= preset.maxBytes) { + bestUnderLimit = testBlob; + low = mid; + } else { + high = mid; + } + } + finalBlob = bestUnderLimit; + } + + URL.revokeObjectURL(objUrl); + + if (!finalBlob) { + const fallback = await new Promise((res) => canvas.toBlob(res, "image/webp", 0.5)); + finalBlob = fallback; + } + + if (finalBlob) { + if (resultUrl) URL.revokeObjectURL(resultUrl); + const resUrl = URL.createObjectURL(finalBlob); + setResultBlob(finalBlob); + setResultUrl(resUrl); + setStatusMessage(`Complete: ${preset.dimension}x${preset.dimension} px (${formatBytes(finalBlob.size)})`); + } + } catch (err: unknown) { + const errorMsg = err instanceof Error ? err.message : "Processing failed"; + setStatusMessage(errorMsg); + } finally { + setIsProcessing(false); + } + }, + [resultUrl] + ); + + const handleFileSelect = (selected: File) => { + if (!selected.type.startsWith("image/")) { + setStatusMessage("Please upload a valid image file (PNG, JPG, WebP, SVG, GIF)"); + return; + } + setFile(selected); + const url = URL.createObjectURL(selected); + setOriginalUrl(url); + + const img = new Image(); + img.src = url; + img.onload = () => { + setOriginalDimensions({ w: img.naturalWidth, h: img.naturalHeight }); + }; + + processImage(selected, activePreset, cropMode); + }; + + const handlePresetChange = (preset: Preset) => { + setActivePreset(preset); + if (file) { + processImage(file, preset, cropMode); + } + }; + + const handleCropModeChange = (mode: "crop" | "fit") => { + setCropMode(mode); + if (file) { + processImage(file, activePreset, mode); + } + }; + + const handleDownload = () => { + if (!resultBlob || !resultUrl) return; + const ext = resultBlob.type === "image/webp" ? "webp" : "png"; + const link = document.createElement("a"); + link.href = resultUrl; + link.download = `${activePreset.id}_${activePreset.dimension}x${activePreset.dimension}.${ext}`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + return ( + +
+ {/* Upload Box */} +
fileInputRef.current?.click()} + onDragOver={(e) => e.preventDefault()} + onDrop={(e) => { + e.preventDefault(); + if (e.dataTransfer.files?.[0]) handleFileSelect(e.dataTransfer.files[0]); + }} + className="cursor-pointer border-2 border-dashed border-[#222] bg-[#0c0c0c] hover:border-[#444] transition-colors p-8 text-center" + > + { + if (e.target.files?.[0]) handleFileSelect(e.target.files[0]); + }} + /> +

+ {file ? file.name : "Drop image here or click to browse"} +

+

+ Supports PNG, JPEG, WebP, SVG, and static GIF. +

+ {originalDimensions && ( +

+ Original: {originalDimensions.w}x{originalDimensions.h} px ({formatBytes(file?.size || 0)}) +

+ )} +
+ + {/* Configuration Controls */} +
+
+ +
+ {PRESETS.map((p) => ( + + ))} +
+
+ +
+ +
+ + + +
+ +
+
+ Status: + {statusMessage || "Idle"} +
+
+
+
+ + {/* Live Previews */} + {resultUrl && ( +
+
+
+

Optimized Output

+

+ {activePreset.dimension}x{activePreset.dimension} px | {resultBlob ? formatBytes(resultBlob.size) : ""} | Limit: {formatBytes(activePreset.maxBytes)} +

+
+ + +
+ +
+ {/* Actual Dimension */} +
+

Actual Size

+
+ Actual size +
+
+ + {/* Chat Preview (Dark Background) */} +
+

Discord Dark Theme

+
+
+ U +
+
+ Member: + Discord preview +
+
+
+ + {/* Chat Preview (Light Background) */} +
+

Discord Light Theme

+
+
+ U +
+
+ Member: + Discord light preview +
+
+
+
+
+ )} +
+
+ ); +} diff --git a/src/app/tools/favicon-generator/page.tsx b/src/app/tools/favicon-generator/page.tsx new file mode 100644 index 0000000..7522dba --- /dev/null +++ b/src/app/tools/favicon-generator/page.tsx @@ -0,0 +1,326 @@ +"use client"; + +import { useState, useRef, useCallback } from "react"; +import ToolLayout from "@/components/ToolLayout"; +import JSZip from "jszip"; + +interface IconSize { + name: string; + filename: string; + size: number; + purpose: string; +} + +const ICON_SPECS: IconSize[] = [ + { name: "Favicon 16x16", filename: "favicon-16x16.png", size: 16, purpose: "Browser tabs" }, + { name: "Favicon 32x32", filename: "favicon-32x32.png", size: 32, purpose: "Taskbar / bookmarks" }, + { name: "Favicon 48x48", filename: "favicon-48x48.png", size: 48, purpose: "Desktop shortcut" }, + { name: "Apple Touch Icon", filename: "apple-touch-icon.png", size: 180, purpose: "iOS home screen" }, + { name: "Android Chrome 192", filename: "android-chrome-192x192.png", size: 192, purpose: "PWA icon" }, + { name: "Android Chrome 512", filename: "android-chrome-512x512.png", size: 512, purpose: "PWA splash screen" }, +]; + +function createIcoFromPngs(pngBuffers: { size: number; buffer: ArrayBuffer }[]): Blob { + const numImages = pngBuffers.length; + const headerSize = 6; + const dirEntrySize = 16; + let dataOffset = headerSize + dirEntrySize * numImages; + + const totalBytes = dataOffset + pngBuffers.reduce((acc, p) => acc + p.buffer.byteLength, 0); + const out = new Uint8Array(totalBytes); + const view = new DataView(out.buffer); + + // ICO Header + view.setUint16(0, 0, true); // Reserved + view.setUint16(2, 1, true); // Type 1 = ICO + view.setUint16(4, numImages, true); + + let currentOffset = dataOffset; + for (let i = 0; i < numImages; i++) { + const item = pngBuffers[i]; + const entryPos = headerSize + i * dirEntrySize; + const w = item.size >= 256 ? 0 : item.size; + const h = item.size >= 256 ? 0 : item.size; + + out[entryPos] = w; + out[entryPos + 1] = h; + out[entryPos + 2] = 0; // Colors (0 = no palette) + out[entryPos + 3] = 0; // Reserved + view.setUint16(entryPos + 4, 1, true); // Color planes + view.setUint16(entryPos + 6, 32, true); // Bits per pixel + view.setUint32(entryPos + 8, item.buffer.byteLength, true); // Image data size + view.setUint32(entryPos + 12, currentOffset, true); // Offset + + out.set(new Uint8Array(item.buffer), currentOffset); + currentOffset += item.buffer.byteLength; + } + + return new Blob([out], { type: "image/x-icon" }); +} + +export default function FaviconGeneratorPage() { + const [file, setFile] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [appName, setAppName] = useState("My App"); + const [isGenerating, setIsGenerating] = useState(false); + const [generatedIcons, setGeneratedIcons] = useState<{ filename: string; url: string; size: number }[]>([]); + const [zipBlob, setZipBlob] = useState(null); + const [copiedHtml, setCopiedHtml] = useState(false); + const [statusMessage, setStatusMessage] = useState(""); + const fileInputRef = useRef(null); + + const generateIcons = useCallback( + async (sourceFile: File, name: string) => { + setIsGenerating(true); + setStatusMessage("Generating icon packages..."); + + try { + const img = new Image(); + const objUrl = URL.createObjectURL(sourceFile); + img.src = objUrl; + + await new Promise((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = () => reject(new Error("Unable to load source image")); + }); + + const zip = new JSZip(); + const iconsList: { filename: string; url: string; size: number }[] = []; + const icoInputs: { size: number; buffer: ArrayBuffer }[] = []; + + // Generate each PNG size + for (const spec of ICON_SPECS) { + const canvas = document.createElement("canvas"); + canvas.width = spec.size; + canvas.height = spec.size; + const ctx = canvas.getContext("2d"); + if (!ctx) continue; + + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = "high"; + + // Center crop to square + const minSide = Math.min(img.naturalWidth, img.naturalHeight); + const sx = (img.naturalWidth - minSide) / 2; + const sy = (img.naturalHeight - minSide) / 2; + ctx.drawImage(img, sx, sy, minSide, minSide, 0, 0, spec.size, spec.size); + + const blob = await new Promise((res) => canvas.toBlob(res, "image/png")); + if (!blob) continue; + + const buffer = await blob.arrayBuffer(); + zip.file(spec.filename, buffer); + + const url = URL.createObjectURL(blob); + iconsList.push({ filename: spec.filename, url, size: spec.size }); + + if ([16, 32, 48].includes(spec.size)) { + icoInputs.push({ size: spec.size, buffer }); + } + } + + // Generate favicon.ico + if (icoInputs.length > 0) { + const icoBlob = createIcoFromPngs(icoInputs); + const icoBuffer = await icoBlob.arrayBuffer(); + zip.file("favicon.ico", icoBuffer); + const icoUrl = URL.createObjectURL(icoBlob); + iconsList.unshift({ filename: "favicon.ico", url: icoUrl, size: 32 }); + } + + // Generate site.webmanifest + const webManifest = { + name: name || "App", + short_name: name || "App", + icons: [ + { src: "/android-chrome-192x192.png", sizes: "192x192", type: "image/png" }, + { src: "/android-chrome-512x512.png", sizes: "512x512", type: "image/png" }, + ], + theme_color: "#ffffff", + background_color: "#ffffff", + display: "standalone", + }; + zip.file("site.webmanifest", JSON.stringify(webManifest, null, 2)); + + // Generate README with HTML snippet + const htmlSnippet = getHtmlTags(name); + zip.file("head-tags.html", htmlSnippet); + + const packagedZip = await zip.generateAsync({ type: "blob" }); + setZipBlob(packagedZip); + setGeneratedIcons(iconsList); + setStatusMessage("Package ready for download"); + URL.revokeObjectURL(objUrl); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Generation failed"; + setStatusMessage(msg); + } finally { + setIsGenerating(false); + } + }, + [] + ); + + const handleFileSelect = (selected: File) => { + if (!selected.type.startsWith("image/")) { + setStatusMessage("Please upload a valid image file"); + return; + } + setFile(selected); + if (previewUrl) URL.revokeObjectURL(previewUrl); + const url = URL.createObjectURL(selected); + setPreviewUrl(url); + generateIcons(selected, appName); + }; + + const handleDownloadZip = () => { + if (!zipBlob) return; + const link = document.createElement("a"); + link.href = URL.createObjectURL(zipBlob); + link.download = "favicons.zip"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + const getHtmlTags = (title: string) => { + return ` + + + + + +`; + }; + + const handleCopyHtml = () => { + navigator.clipboard.writeText(getHtmlTags(appName)); + setCopiedHtml(true); + setTimeout(() => setCopiedHtml(false), 2000); + }; + + return ( + +
+ {/* Upload Box */} +
fileInputRef.current?.click()} + onDragOver={(e) => e.preventDefault()} + onDrop={(e) => { + e.preventDefault(); + if (e.dataTransfer.files?.[0]) handleFileSelect(e.dataTransfer.files[0]); + }} + className="cursor-pointer border-2 border-dashed border-[#222] bg-[#0c0c0c] hover:border-[#444] transition-colors p-8 text-center" + > + { + if (e.target.files?.[0]) handleFileSelect(e.target.files[0]); + }} + /> +

+ {file ? file.name : "Drop master image or logo here"} +

+

+ Upload an image of at least 512x512 px for best quality. +

+
+ + {/* Configurations */} +
+
+ + { + setAppName(e.target.value); + if (file) generateIcons(file, e.target.value); + }} + className="px-3 py-1.5 bg-[#141414] border border-[#222] text-xs text-gray-200 focus:outline-none focus:border-blue-500 w-full sm:w-64" + placeholder="e.g. My Company" + /> +
+ +
+ {statusMessage} + {zipBlob && ( + + )} +
+
+ + {/* Results Display */} + {generatedIcons.length > 0 && ( +
+ {/* Grid of Generated Icons */} +
+

+ Generated Assets ({generatedIcons.length}) +

+
+ {generatedIcons.map((icon) => ( +
+
+ {icon.filename} +
+
+

{icon.filename}

+

{icon.size}x{icon.size}

+ + Download + +
+
+ ))} +
+
+ + {/* HTML Code Snippet */} +
+
+

+ HTML Head Tags +

+ +
+
+                {getHtmlTags(appName)}
+              
+
+
+ )} +
+
+ ); +} diff --git a/src/app/tools/secret-share/page.tsx b/src/app/tools/secret-share/page.tsx new file mode 100644 index 0000000..0e414e0 --- /dev/null +++ b/src/app/tools/secret-share/page.tsx @@ -0,0 +1,272 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import ToolLayout from "@/components/ToolLayout"; + +// Utility helpers for Uint8Array <-> Base64URL +function bufferToBase64Url(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +function base64UrlToBuffer(base64url: string): ArrayBuffer { + let base64 = base64url.replace(/-/g, "+").replace(/_/g, "/"); + while (base64.length % 4) { + base64 += "="; + } + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +} + +export default function SecretSharePage() { + const [secretText, setSecretText] = useState(""); + const [generatedLink, setGeneratedLink] = useState(""); + const [isCopied, setIsCopied] = useState(false); + const [mode, setMode] = useState<"create" | "view">("create"); + const [decryptedText, setDecryptedText] = useState(null); + const [decryptError, setDecryptError] = useState(null); + const [isDecrypting, setIsDecrypting] = useState(false); + + // Check URL hash on mount for view mode + useEffect(() => { + if (typeof window === "undefined") return; + const hash = window.location.hash.substring(1); + if (!hash) return; + + const params = new URLSearchParams(hash); + if (params.get("d") && params.get("k")) { + setMode("view"); + } + }, []); + + const handleCreateSecret = async () => { + if (!secretText.trim()) return; + + try { + // 1. Generate 256-bit AES-GCM key + const key = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, + ["encrypt", "decrypt"] + ); + + // 2. Export raw key bytes + const exportedKey = await crypto.subtle.exportKey("raw", key); + const keyB64 = bufferToBase64Url(exportedKey); + + // 3. Random 12-byte initialization vector (IV) + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encoder = new TextEncoder(); + const plaintext = encoder.encode(secretText); + + // 4. Encrypt + const ciphertext = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + key, + plaintext + ); + + // Pack IV (12 bytes) + Ciphertext into single payload + const combined = new Uint8Array(iv.byteLength + ciphertext.byteLength); + combined.set(iv, 0); + combined.set(new Uint8Array(ciphertext), iv.byteLength); + + const dataB64 = bufferToBase64Url(combined.buffer); + + const origin = window.location.origin; + const path = window.location.pathname; + const fullUrl = `${origin}${path}#d=${dataB64}&k=${keyB64}`; + + setGeneratedLink(fullUrl); + } catch (err) { + console.error(err); + } + }; + + const handleDecrypt = useCallback(async () => { + setIsDecrypting(true); + setDecryptError(null); + + try { + const hash = window.location.hash.substring(1); + const params = new URLSearchParams(hash); + const dataB64 = params.get("d"); + const keyB64 = params.get("k"); + + if (!dataB64 || !keyB64) { + throw new Error("Invalid or corrupted secret link parameters"); + } + + const keyBuffer = base64UrlToBuffer(keyB64); + const dataBuffer = base64UrlToBuffer(dataB64); + + if (dataBuffer.byteLength < 13) { + throw new Error("Ciphertext too short or corrupted"); + } + + const iv = new Uint8Array(dataBuffer.slice(0, 12)); + const ciphertext = dataBuffer.slice(12); + + const cryptoKey = await crypto.subtle.importKey( + "raw", + keyBuffer, + "AES-GCM", + false, + ["decrypt"] + ); + + const decrypted = await crypto.subtle.decrypt( + { name: "AES-GCM", iv }, + cryptoKey, + ciphertext + ); + + const decoder = new TextDecoder(); + setDecryptedText(decoder.decode(decrypted)); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Decryption failed"; + setDecryptError(msg); + } finally { + setIsDecrypting(false); + } + }, []); + + const handleDestroy = () => { + setDecryptedText(null); + setDecryptError(null); + window.location.hash = ""; + setMode("create"); + setSecretText(""); + setGeneratedLink(""); + }; + + const handleCopyLink = () => { + navigator.clipboard.writeText(generatedLink); + setIsCopied(true); + setTimeout(() => setIsCopied(false), 2000); + }; + + return ( + +
+ {mode === "create" ? ( + <> +
+ +