feat: add 5 high-impact tools (emoji resizer, favicon generator, secret share, palette extractor, svg to png)
This commit is contained in:
parent
6d60115b88
commit
fa48c48626
8 changed files with 1669 additions and 0 deletions
105
package-lock.json
generated
105
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
275
src/app/tools/color-palette-extractor/page.tsx
Normal file
275
src/app/tools/color-palette-extractor/page.tsx
Normal file
|
|
@ -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<File | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [palette, setPalette] = useState<ColorItem[]>([]);
|
||||
const [hoverColor, setHoverColor] = useState<string | null>(null);
|
||||
const [copiedText, setCopiedText] = useState<string | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(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<string, string>);
|
||||
return `// tailwind.config.js\ncolors: ` + JSON.stringify(obj, null, 2);
|
||||
};
|
||||
|
||||
return (
|
||||
<ToolLayout
|
||||
title="Image Color Palette Extractor"
|
||||
description="Extract dominant color palettes and hex codes from any image or artwork client-side. Export directly to CSS variables or Tailwind configs."
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Upload Box */}
|
||||
<div
|
||||
onClick={() => 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"
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.[0]) handleFileSelect(e.target.files[0]);
|
||||
}}
|
||||
/>
|
||||
<p className="text-sm font-medium text-gray-200">
|
||||
{file ? file.name : "Drop photo, graphic, or wallpaper here"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[#666]">
|
||||
Supports PNG, JPEG, WebP, SVG.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Hidden Canvas for computation */}
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
|
||||
{previewUrl && palette.length > 0 && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Image Preview */}
|
||||
<div className="border border-[#181818] bg-[#0a0a0a] p-4 flex flex-col items-center justify-center">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Uploaded reference"
|
||||
className="max-h-80 w-auto object-contain border border-[#1e1e1e]"
|
||||
/>
|
||||
{hoverColor && (
|
||||
<div className="mt-3 flex items-center gap-2 text-xs font-mono text-gray-300">
|
||||
<div className="w-3.5 h-3.5 border border-white/20" style={{ backgroundColor: hoverColor }} />
|
||||
<span>{hoverColor}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Extracted Palette */}
|
||||
<div className="border border-[#181818] bg-[#0a0a0a] p-6 space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-[#181818] pb-3">
|
||||
<h3 className="text-xs font-mono uppercase tracking-wider text-[#666]">
|
||||
Dominant Colors ({palette.length})
|
||||
</h3>
|
||||
{copiedText && (
|
||||
<span className="text-xs font-mono text-blue-400">Copied {copiedText}!</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Swatches Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2.5">
|
||||
{palette.map((color, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
onMouseEnter={() => 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"
|
||||
>
|
||||
<div
|
||||
className="w-full h-14 border border-black/20 mb-2 transition-transform group-hover:scale-[1.02]"
|
||||
style={{ backgroundColor: color.hex }}
|
||||
/>
|
||||
<div className="font-mono text-xs text-gray-200 font-medium">{color.hex}</div>
|
||||
<div className="font-mono text-[10px] text-[#555]">
|
||||
rgb({color.rgb.r}, {color.rgb.g}, {color.rgb.b})
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Export Buttons */}
|
||||
<div className="border-t border-[#181818] pt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => copyToClipboard(getCssVariables(), "CSS Variables")}
|
||||
className="px-3 py-1.5 bg-[#141414] hover:bg-[#1f1f1f] border border-[#222] text-xs font-mono text-gray-300 transition-colors"
|
||||
>
|
||||
Copy CSS Variables
|
||||
</button>
|
||||
<button
|
||||
onClick={() => copyToClipboard(getTailwindConfig(), "Tailwind Config")}
|
||||
className="px-3 py-1.5 bg-[#141414] hover:bg-[#1f1f1f] border border-[#222] text-xs font-mono text-gray-300 transition-colors"
|
||||
>
|
||||
Copy Tailwind
|
||||
</button>
|
||||
<button
|
||||
onClick={() => copyToClipboard(JSON.stringify(palette.map((c) => c.hex)), "JSON")}
|
||||
className="px-3 py-1.5 bg-[#141414] hover:bg-[#1f1f1f] border border-[#222] text-xs font-mono text-gray-300 transition-colors"
|
||||
>
|
||||
Copy Hex Array
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ToolLayout>
|
||||
);
|
||||
}
|
||||
390
src/app/tools/discord-emoji-resizer/page.tsx
Normal file
390
src/app/tools/discord-emoji-resizer/page.tsx
Normal file
|
|
@ -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<File | null>(null);
|
||||
const [originalUrl, setOriginalUrl] = useState<string | null>(null);
|
||||
const [originalDimensions, setOriginalDimensions] = useState<{ w: number; h: number } | null>(null);
|
||||
const [resultBlob, setResultBlob] = useState<Blob | null>(null);
|
||||
const [resultUrl, setResultUrl] = useState<string | null>(null);
|
||||
const [activePreset, setActivePreset] = useState<Preset>(PRESETS[0]);
|
||||
const [cropMode, setCropMode] = useState<"crop" | "fit">("crop");
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [statusMessage, setStatusMessage] = useState("");
|
||||
const fileInputRef = useRef<HTMLInputElement>(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<void>((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<Blob | null>((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<Blob | null>((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<Blob | null>((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 (
|
||||
<ToolLayout
|
||||
title="Discord Emoji & Sticker Resizer"
|
||||
description="Resize, square-crop, and compress images to Discord emoji (128x128, <256KB) and sticker (320x320, <512KB) standards client-side."
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Upload Box */}
|
||||
<div
|
||||
onClick={() => 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"
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.[0]) handleFileSelect(e.target.files[0]);
|
||||
}}
|
||||
/>
|
||||
<p className="text-sm font-medium text-gray-200">
|
||||
{file ? file.name : "Drop image here or click to browse"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[#666]">
|
||||
Supports PNG, JPEG, WebP, SVG, and static GIF.
|
||||
</p>
|
||||
{originalDimensions && (
|
||||
<p className="mt-2 text-xs font-mono text-blue-400">
|
||||
Original: {originalDimensions.w}x{originalDimensions.h} px ({formatBytes(file?.size || 0)})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Configuration Controls */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="border border-[#181818] bg-[#0c0c0c] p-4">
|
||||
<label className="block text-xs font-mono uppercase tracking-wider text-[#666] mb-3">
|
||||
Target Preset
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => handlePresetChange(p)}
|
||||
className={`w-full text-left p-3 border transition-colors ${
|
||||
activePreset.id === p.id
|
||||
? "border-blue-500/60 bg-blue-950/20 text-blue-200"
|
||||
: "border-[#1c1c1c] bg-[#101010] text-gray-300 hover:border-[#282828]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between text-xs font-medium">
|
||||
<span>{p.name}</span>
|
||||
<span className="font-mono text-[11px] text-[#666]">{p.dimension}x{p.dimension} px</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-[#555]">{p.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-[#181818] bg-[#0c0c0c] p-4">
|
||||
<label className="block text-xs font-mono uppercase tracking-wider text-[#666] mb-3">
|
||||
Fit & Framing Mode
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => handleCropModeChange("crop")}
|
||||
className={`p-3 text-center border transition-colors ${
|
||||
cropMode === "crop"
|
||||
? "border-blue-500/60 bg-blue-950/20 text-blue-200"
|
||||
: "border-[#1c1c1c] bg-[#101010] text-gray-400 hover:border-[#282828]"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-medium">Center Crop</div>
|
||||
<div className="mt-1 text-[10px] text-[#555]">Fills square, clips edges</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleCropModeChange("fit")}
|
||||
className={`p-3 text-center border transition-colors ${
|
||||
cropMode === "fit"
|
||||
? "border-blue-500/60 bg-blue-950/20 text-blue-200"
|
||||
: "border-[#1c1c1c] bg-[#101010] text-gray-400 hover:border-[#282828]"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-medium">Fit & Pad</div>
|
||||
<div className="mt-1 text-[10px] text-[#555]">Transparent padding</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border-t border-[#181818] pt-4">
|
||||
<div className="flex items-center justify-between text-xs text-[#666]">
|
||||
<span>Status:</span>
|
||||
<span className="font-mono text-gray-300">{statusMessage || "Idle"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live Previews */}
|
||||
{resultUrl && (
|
||||
<div className="border border-[#181818] bg-[#0a0a0a] p-6 space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-[#181818] pb-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-200">Optimized Output</h3>
|
||||
<p className="text-xs font-mono text-[#666]">
|
||||
{activePreset.dimension}x{activePreset.dimension} px | {resultBlob ? formatBytes(resultBlob.size) : ""} | Limit: {formatBytes(activePreset.maxBytes)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={isProcessing}
|
||||
className="px-5 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white text-xs font-medium transition-colors"
|
||||
>
|
||||
Download {activePreset.name}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 text-center">
|
||||
{/* Actual Dimension */}
|
||||
<div className="flex flex-col items-center justify-center p-4 border border-[#161616] bg-[#0e0e0e]">
|
||||
<p className="text-[11px] font-mono text-[#666] mb-3">Actual Size</p>
|
||||
<div className="p-2 border border-dashed border-[#222] bg-[radial-gradient(#1a1a1a_1px,transparent_1px)] [background-size:8px_8px]">
|
||||
<img
|
||||
src={resultUrl}
|
||||
alt="Actual size"
|
||||
width={activePreset.dimension}
|
||||
height={activePreset.dimension}
|
||||
className="block"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat Preview (Dark Background) */}
|
||||
<div className="flex flex-col items-center justify-center p-4 border border-[#161616] bg-[#2b2d31]">
|
||||
<p className="text-[11px] font-mono text-gray-400 mb-3">Discord Dark Theme</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-[#1e1f22] flex items-center justify-center text-[10px] text-gray-400 font-bold">
|
||||
U
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<span className="text-xs font-semibold text-white">Member: </span>
|
||||
<img
|
||||
src={resultUrl}
|
||||
alt="Discord preview"
|
||||
className="inline-block align-middle ml-1"
|
||||
style={{ width: activePreset.id === "sticker" ? 120 : 32, height: activePreset.id === "sticker" ? 120 : 32 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat Preview (Light Background) */}
|
||||
<div className="flex flex-col items-center justify-center p-4 border border-[#161616] bg-[#f2f3f5]">
|
||||
<p className="text-[11px] font-mono text-gray-600 mb-3">Discord Light Theme</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-[#e0e2e5] flex items-center justify-center text-[10px] text-gray-600 font-bold">
|
||||
U
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<span className="text-xs font-semibold text-[#313338]">Member: </span>
|
||||
<img
|
||||
src={resultUrl}
|
||||
alt="Discord light preview"
|
||||
className="inline-block align-middle ml-1"
|
||||
style={{ width: activePreset.id === "sticker" ? 120 : 32, height: activePreset.id === "sticker" ? 120 : 32 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ToolLayout>
|
||||
);
|
||||
}
|
||||
326
src/app/tools/favicon-generator/page.tsx
Normal file
326
src/app/tools/favicon-generator/page.tsx
Normal file
|
|
@ -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<File | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(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<Blob | null>(null);
|
||||
const [copiedHtml, setCopiedHtml] = useState(false);
|
||||
const [statusMessage, setStatusMessage] = useState("");
|
||||
const fileInputRef = useRef<HTMLInputElement>(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<void>((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<Blob | null>((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 `<!-- Favicon & App Icons -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<meta name="apple-mobile-web-app-title" content="${title || "App"}">
|
||||
<meta name="theme-color" content="#ffffff">`;
|
||||
};
|
||||
|
||||
const handleCopyHtml = () => {
|
||||
navigator.clipboard.writeText(getHtmlTags(appName));
|
||||
setCopiedHtml(true);
|
||||
setTimeout(() => setCopiedHtml(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<ToolLayout
|
||||
title="Favicon & App Icon Generator"
|
||||
description="Convert any square logo or image into standard favicon.ico, Apple touch icons, Android PWA manifests, and HTML tags bundled into a ZIP."
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Upload Box */}
|
||||
<div
|
||||
onClick={() => 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"
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.[0]) handleFileSelect(e.target.files[0]);
|
||||
}}
|
||||
/>
|
||||
<p className="text-sm font-medium text-gray-200">
|
||||
{file ? file.name : "Drop master image or logo here"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[#666]">
|
||||
Upload an image of at least 512x512 px for best quality.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Configurations */}
|
||||
<div className="border border-[#181818] bg-[#0c0c0c] p-4 flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto">
|
||||
<label className="text-xs font-mono text-[#666] uppercase whitespace-nowrap">App Name:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={appName}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-mono text-[#555]">{statusMessage}</span>
|
||||
{zipBlob && (
|
||||
<button
|
||||
onClick={handleDownloadZip}
|
||||
disabled={isGenerating}
|
||||
className="px-5 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-medium transition-colors"
|
||||
>
|
||||
Download Favicons (ZIP)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Display */}
|
||||
{generatedIcons.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
{/* Grid of Generated Icons */}
|
||||
<div className="border border-[#181818] bg-[#0a0a0a] p-6">
|
||||
<h3 className="text-xs font-mono uppercase tracking-wider text-[#666] mb-4">
|
||||
Generated Assets ({generatedIcons.length})
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-3">
|
||||
{generatedIcons.map((icon) => (
|
||||
<div key={icon.filename} className="border border-[#161616] bg-[#0e0e0e] p-3 text-center flex flex-col items-center justify-between">
|
||||
<div className="h-16 flex items-center justify-center mb-2">
|
||||
<img
|
||||
src={icon.url}
|
||||
alt={icon.filename}
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
maxHeight: "100%",
|
||||
width: Math.min(icon.size, 48),
|
||||
height: Math.min(icon.size, 48),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<p className="text-[11px] font-mono text-gray-300 truncate">{icon.filename}</p>
|
||||
<p className="text-[10px] font-mono text-[#555]">{icon.size}x{icon.size}</p>
|
||||
<a
|
||||
href={icon.url}
|
||||
download={icon.filename}
|
||||
className="mt-2 block text-[10px] text-blue-400 hover:underline"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* HTML Code Snippet */}
|
||||
<div className="border border-[#181818] bg-[#0a0a0a] p-6">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xs font-mono uppercase tracking-wider text-[#666]">
|
||||
HTML Head Tags
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleCopyHtml}
|
||||
className="px-3 py-1 bg-[#141414] hover:bg-[#1f1f1f] border border-[#262626] text-xs text-gray-300 transition-colors"
|
||||
>
|
||||
{copiedHtml ? "Copied" : "Copy Tags"}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-4 bg-[#050505] border border-[#141414] font-mono text-xs text-gray-300 overflow-x-auto leading-relaxed">
|
||||
{getHtmlTags(appName)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ToolLayout>
|
||||
);
|
||||
}
|
||||
272
src/app/tools/secret-share/page.tsx
Normal file
272
src/app/tools/secret-share/page.tsx
Normal file
|
|
@ -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<string | null>(null);
|
||||
const [decryptError, setDecryptError] = useState<string | null>(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 (
|
||||
<ToolLayout
|
||||
title="Client-Side Secret Link"
|
||||
description="Zero-knowledge password & token sharing. Encrypted client-side via AES-256-GCM. The decryption key stays strictly in the URL hash and never touches any server."
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{mode === "create" ? (
|
||||
<>
|
||||
<div className="border border-[#181818] bg-[#0c0c0c] p-6 space-y-4">
|
||||
<label className="block text-xs font-mono uppercase tracking-wider text-[#666]">
|
||||
Confidential Secret / Note
|
||||
</label>
|
||||
<textarea
|
||||
value={secretText}
|
||||
onChange={(e) => setSecretText(e.target.value)}
|
||||
rows={6}
|
||||
placeholder="Paste API keys, database credentials, passwords, or private messages..."
|
||||
className="w-full p-4 bg-[#050505] border border-[#222] text-xs font-mono text-gray-200 placeholder-[#444] focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 pt-2">
|
||||
<p className="text-[11px] text-[#555]">
|
||||
Encrypted locally using browser Web Crypto API (AES-GCM 256-bit).
|
||||
</p>
|
||||
<button
|
||||
onClick={handleCreateSecret}
|
||||
disabled={!secretText.trim()}
|
||||
className="px-6 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white text-xs font-medium transition-colors"
|
||||
>
|
||||
Generate Encrypted Link
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{generatedLink && (
|
||||
<div className="border border-[#181818] bg-[#0a0a0a] p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-mono uppercase tracking-wider text-[#666]">
|
||||
Shareable Link
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleCopyLink}
|
||||
className="px-4 py-1.5 bg-[#141414] hover:bg-[#1f1f1f] border border-[#262626] text-xs text-gray-200 transition-colors"
|
||||
>
|
||||
{isCopied ? "Copied to Clipboard" : "Copy Link"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-[#050505] border border-[#141414] font-mono text-xs text-blue-400 break-all select-all">
|
||||
{generatedLink}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-[#1c1c1c] bg-[#101010] text-xs text-[#777] leading-relaxed">
|
||||
<strong className="text-gray-300">How this works:</strong> The URL hash (`#d=...&k=...`) stores the 256-bit encryption key and ciphertext. Per internet standards (RFC 3986), URL hashes are never transmitted across HTTP requests, meaning neither our servers nor your ISP can intercept or read the message.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="border border-[#181818] bg-[#0c0c0c] p-6 space-y-6">
|
||||
<div className="border-b border-[#181818] pb-4">
|
||||
<h3 className="text-sm font-medium text-gray-200">Encrypted Secret Received</h3>
|
||||
<p className="mt-1 text-xs text-[#666]">
|
||||
This message was encrypted using AES-256-GCM. Click decrypt to decode using the key embedded in your URL fragment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{decryptedText === null ? (
|
||||
<div className="text-center py-8">
|
||||
<button
|
||||
onClick={handleDecrypt}
|
||||
disabled={isDecrypting}
|
||||
className="px-8 py-3 bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold tracking-wider uppercase transition-colors"
|
||||
>
|
||||
{isDecrypting ? "Decrypting..." : "Decrypt Secret"}
|
||||
</button>
|
||||
{decryptError && (
|
||||
<p className="mt-4 text-xs font-mono text-red-400">{decryptError}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-mono uppercase tracking-wider text-[#666]">
|
||||
Decrypted Content
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(decryptedText)}
|
||||
className="px-3 py-1 bg-[#141414] hover:bg-[#1f1f1f] border border-[#262626] text-xs text-gray-300 transition-colors"
|
||||
>
|
||||
Copy Secret
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDestroy}
|
||||
className="px-3 py-1 bg-red-950/30 hover:bg-red-900/40 border border-red-800/40 text-xs text-red-300 transition-colors"
|
||||
>
|
||||
Destroy & Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pre className="p-4 bg-[#050505] border border-[#181818] font-mono text-xs text-gray-200 whitespace-pre-wrap break-all select-all leading-relaxed">
|
||||
{decryptedText}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ToolLayout>
|
||||
);
|
||||
}
|
||||
282
src/app/tools/svg-to-png/page.tsx
Normal file
282
src/app/tools/svg-to-png/page.tsx
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import ToolLayout from "@/components/ToolLayout";
|
||||
|
||||
export default function SvgToPngPage() {
|
||||
const [svgSource, setSvgSource] = useState<string>("");
|
||||
const [fileName, setFileName] = useState<string>("vector");
|
||||
const [naturalDimensions, setNaturalDimensions] = useState<{ w: number; h: number } | null>(null);
|
||||
const [scaleFactor, setScaleFactor] = useState<number>(2);
|
||||
const [customWidth, setCustomWidth] = useState<number>(0);
|
||||
const [customHeight, setCustomHeight] = useState<number>(0);
|
||||
const [useCustomSize, setUseCustomSize] = useState<boolean>(false);
|
||||
const [bgType, setBgType] = useState<"transparent" | "white" | "black">("transparent");
|
||||
const [exportFormat, setExportFormat] = useState<"image/png" | "image/webp">("image/png");
|
||||
const [isProcessing, setIsProcessing] = useState<boolean>(false);
|
||||
const [statusMessage, setStatusMessage] = useState<string>("");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const parseSvgDimensions = useCallback((svgText: string): { w: number; h: number } => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(svgText, "image/svg+xml");
|
||||
const svgEl = doc.querySelector("svg");
|
||||
|
||||
if (!svgEl) return { w: 512, h: 512 };
|
||||
|
||||
const viewBox = svgEl.getAttribute("viewBox");
|
||||
if (viewBox) {
|
||||
const parts = viewBox.split(/[\s,]+/).map(Number);
|
||||
if (parts.length >= 4 && parts[2] > 0 && parts[3] > 0) {
|
||||
return { w: parts[2], h: parts[3] };
|
||||
}
|
||||
}
|
||||
|
||||
const wAttr = parseFloat(svgEl.getAttribute("width") || "0");
|
||||
const hAttr = parseFloat(svgEl.getAttribute("height") || "0");
|
||||
if (wAttr > 0 && hAttr > 0) {
|
||||
return { w: wAttr, h: hAttr };
|
||||
}
|
||||
|
||||
return { w: 512, h: 512 };
|
||||
}, []);
|
||||
|
||||
const handleFileSelect = (selected: File) => {
|
||||
if (!selected.name.endsWith(".svg") && selected.type !== "image/svg+xml") {
|
||||
setStatusMessage("Please upload a valid .svg file");
|
||||
return;
|
||||
}
|
||||
|
||||
setFileName(selected.name.replace(/\.svg$/i, ""));
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const text = e.target?.result as string;
|
||||
setSvgSource(text);
|
||||
const dims = parseSvgDimensions(text);
|
||||
setNaturalDimensions(dims);
|
||||
setCustomWidth(dims.w * scaleFactor);
|
||||
setCustomHeight(dims.h * scaleFactor);
|
||||
setStatusMessage(`Loaded SVG (${dims.w}x${dims.h} base)`);
|
||||
};
|
||||
reader.readAsText(selected);
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!svgSource || !naturalDimensions) return;
|
||||
setIsProcessing(true);
|
||||
setStatusMessage("Rendering high-resolution vector...");
|
||||
|
||||
try {
|
||||
const targetW = useCustomSize ? customWidth : naturalDimensions.w * scaleFactor;
|
||||
const targetH = useCustomSize ? customHeight : naturalDimensions.h * scaleFactor;
|
||||
|
||||
const blob = new Blob([svgSource], { type: "image/svg+xml;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => reject(new Error("Unable to rasterize SVG"));
|
||||
});
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = targetW;
|
||||
canvas.height = targetH;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Canvas context unavailable");
|
||||
|
||||
// Fill background if not transparent
|
||||
if (bgType === "white") {
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, targetW, targetH);
|
||||
} else if (bgType === "black") {
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.fillRect(0, 0, targetW, targetH);
|
||||
}
|
||||
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = "high";
|
||||
ctx.drawImage(img, 0, 0, targetW, targetH);
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
const ext = exportFormat === "image/png" ? "png" : "webp";
|
||||
const outBlob = await new Promise<Blob | null>((res) => canvas.toBlob(res, exportFormat, 0.95));
|
||||
|
||||
if (!outBlob) throw new Error("Export failed");
|
||||
|
||||
const downloadUrl = URL.createObjectURL(outBlob);
|
||||
const link = document.createElement("a");
|
||||
link.href = downloadUrl;
|
||||
link.download = `${fileName}_${targetW}x${targetH}.${ext}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(downloadUrl);
|
||||
|
||||
setStatusMessage(`Exported ${targetW}x${targetH} px successfully`);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "Render error";
|
||||
setStatusMessage(msg);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ToolLayout
|
||||
title="SVG to High-Res Raster Converter"
|
||||
description="Rasterize scalable SVG vectors into ultra high-resolution PNG or WebP images (up to 8K) with custom dimensions and transparent backgrounds."
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Upload Box */}
|
||||
<div
|
||||
onClick={() => 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"
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".svg,image/svg+xml"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.[0]) handleFileSelect(e.target.files[0]);
|
||||
}}
|
||||
/>
|
||||
<p className="text-sm font-medium text-gray-200">
|
||||
{svgSource ? `${fileName}.svg loaded` : "Drop .svg vector file here"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[#666]">
|
||||
Supports standard SVG paths, shapes, gradients, and inline styles.
|
||||
</p>
|
||||
{naturalDimensions && (
|
||||
<p className="mt-2 text-xs font-mono text-blue-400">
|
||||
Base dimensions: {naturalDimensions.w}x{naturalDimensions.h} px
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{svgSource && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* SVG Visual Preview */}
|
||||
<div className="border border-[#181818] bg-[#0a0a0a] p-6 flex flex-col items-center justify-center min-h-[300px]">
|
||||
<div
|
||||
className={`w-full h-full min-h-[240px] flex items-center justify-center p-4 border border-[#1e1e1e] ${
|
||||
bgType === "transparent"
|
||||
? "bg-[radial-gradient(#1a1a1a_1px,transparent_1px)] [background-size:12px_12px]"
|
||||
: bgType === "white"
|
||||
? "bg-white"
|
||||
: "bg-black"
|
||||
}`}
|
||||
dangerouslySetInnerHTML={{ __html: svgSource }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Render Settings */}
|
||||
<div className="border border-[#181818] bg-[#0a0a0a] p-6 space-y-6">
|
||||
{/* Scale Presets */}
|
||||
<div>
|
||||
<label className="block text-xs font-mono uppercase tracking-wider text-[#666] mb-3">
|
||||
Resolution Scale
|
||||
</label>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{[1, 2, 4, 8, 16].map((scale) => (
|
||||
<button
|
||||
key={scale}
|
||||
onClick={() => {
|
||||
setScaleFactor(scale);
|
||||
setUseCustomSize(false);
|
||||
}}
|
||||
className={`py-2 text-center border text-xs font-mono transition-colors ${
|
||||
!useCustomSize && scaleFactor === scale
|
||||
? "border-blue-500/60 bg-blue-950/20 text-blue-200"
|
||||
: "border-[#1c1c1c] bg-[#101010] text-gray-400 hover:border-[#282828]"
|
||||
}`}
|
||||
>
|
||||
{scale}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{naturalDimensions && (
|
||||
<p className="mt-2 text-xs font-mono text-[#555]">
|
||||
Output resolution: {naturalDimensions.w * scaleFactor}x{naturalDimensions.h * scaleFactor} px
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Background Color */}
|
||||
<div>
|
||||
<label className="block text-xs font-mono uppercase tracking-wider text-[#666] mb-3">
|
||||
Background
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(["transparent", "white", "black"] as const).map((bg) => (
|
||||
<button
|
||||
key={bg}
|
||||
onClick={() => setBgType(bg)}
|
||||
className={`py-2 text-center border text-xs capitalize transition-colors ${
|
||||
bgType === bg
|
||||
? "border-blue-500/60 bg-blue-950/20 text-blue-200"
|
||||
: "border-[#1c1c1c] bg-[#101010] text-gray-400 hover:border-[#282828]"
|
||||
}`}
|
||||
>
|
||||
{bg}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Format Selection */}
|
||||
<div>
|
||||
<label className="block text-xs font-mono uppercase tracking-wider text-[#666] mb-3">
|
||||
Target Format
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={() => setExportFormat("image/png")}
|
||||
className={`py-2 text-center border text-xs transition-colors ${
|
||||
exportFormat === "image/png"
|
||||
? "border-blue-500/60 bg-blue-950/20 text-blue-200"
|
||||
: "border-[#1c1c1c] bg-[#101010] text-gray-400 hover:border-[#282828]"
|
||||
}`}
|
||||
>
|
||||
PNG (Lossless)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setExportFormat("image/webp")}
|
||||
className={`py-2 text-center border text-xs transition-colors ${
|
||||
exportFormat === "image/webp"
|
||||
? "border-blue-500/60 bg-blue-950/20 text-blue-200"
|
||||
: "border-[#1c1c1c] bg-[#101010] text-gray-400 hover:border-[#282828]"
|
||||
}`}
|
||||
>
|
||||
WebP (Compressed)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
<div className="border-t border-[#181818] pt-4 flex items-center justify-between">
|
||||
<span className="text-xs font-mono text-[#555]">{statusMessage}</span>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={isProcessing}
|
||||
className="px-6 py-2.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white text-xs font-medium transition-colors"
|
||||
>
|
||||
{isProcessing ? "Rendering..." : "Export High-Res Image"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ToolLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -36,6 +36,20 @@ export const tools: Tool[] = [
|
|||
category: "Files & Media",
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
name: "Discord Emoji Resizer",
|
||||
slug: "discord-emoji-resizer",
|
||||
description: "Resize, square-crop, and compress emojis and stickers to Discord limits",
|
||||
category: "Files & Media",
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
name: "SVG to High-Res",
|
||||
slug: "svg-to-png",
|
||||
description: "Rasterize SVG vectors to high-resolution PNG or WebP images up to 8K",
|
||||
category: "Files & Media",
|
||||
featured: true,
|
||||
},
|
||||
|
||||
// Formatters & Validators
|
||||
{ name: "JSON Formatter", slug: "json-formatter", description: "Format, validate, and beautify JSON data", category: "Formatters" },
|
||||
|
|
@ -53,10 +67,12 @@ export const tools: Tool[] = [
|
|||
{ name: "Hash Generator", slug: "hash-generator", description: "Compute MD5, SHA-1, SHA-256, and SHA-512 hashes", category: "Generators" },
|
||||
{ name: "QR Code Generator", slug: "qr-code", description: "Create QR codes from URLs or plain text", category: "Generators" },
|
||||
{ name: "Password Generator", slug: "password-generator", description: "Generate randomized secure passwords", category: "Generators" },
|
||||
{ name: "Secret Link", slug: "secret-share", description: "Client-side AES-256 encrypted password and credential sharing via URL hash", category: "Generators", featured: true },
|
||||
|
||||
// Converters
|
||||
{ name: "Unix Timestamp", slug: "timestamp-converter", description: "Convert between Unix epoch and standard date formats", category: "Converters" },
|
||||
{ name: "Color Converter", slug: "color-converter", description: "Convert colors between HEX, RGB, and HSL formats", category: "Converters" },
|
||||
{ name: "Palette Extractor", slug: "color-palette-extractor", description: "Extract dominant color palettes and hex codes from any image", category: "Converters", featured: true },
|
||||
{ name: "Number Base", slug: "number-base", description: "Convert numbers across binary, octal, decimal, and hex", category: "Converters" },
|
||||
|
||||
// Text Tools
|
||||
|
|
@ -77,6 +93,7 @@ export const tools: Tool[] = [
|
|||
{ name: "XML Formatter", slug: "xml-formatter", description: "Format, indent, and validate XML documents", category: "Data Tools" },
|
||||
|
||||
// Web Tools
|
||||
{ name: "Favicon Generator", slug: "favicon-generator", description: "Convert images into multi-size favicon.ico, Apple touch icons, and PWA manifests", category: "Web Tools", featured: true },
|
||||
{ name: "Meta Tag Generator", slug: "meta-tag-generator", description: "Generate OpenGraph, Twitter, and SEO HTML tags", category: "Web Tools" },
|
||||
{ name: "Slug Generator", slug: "slug-generator", description: "Create URL-safe slugs from plain text strings", category: "Web Tools" },
|
||||
{ name: "Cron Parser", slug: "cron-parser", description: "Parse and explain 5-field cron schedules in plain text", category: "Web Tools" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue