feat: add format tools (SVG-to-raster, vectorize, GIF) and optimization (rename, favicon, image-to-PDF)

Add 6 tools for format conversion and optimization extras:
- svg-to-raster: SVG to PNG/JPG/WebP at custom resolution
- vectorize: raster to SVG via potrace (B&W and color modes)
- gif-tools: animated GIF resize, frame extraction, optimization
- bulk-rename: pattern-based file renaming with ZIP output
- favicon: generate all favicon/app icon sizes with manifest.json
- image-to-pdf: combine images into PDF using pdfkit
This commit is contained in:
Siddharth Kumar Sah
2026-03-22 04:20:54 +08:00
parent b8aba1719c
commit b88d0d0af8
12 changed files with 1374 additions and 0 deletions
@@ -0,0 +1,125 @@
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
import { Download, Loader2 } from "lucide-react";
function getToken(): string {
return localStorage.getItem("stirling-token") || "";
}
export function BulkRenameSettings() {
const { files, processing, error, setProcessing, setError } = useFileStore();
const [pattern, setPattern] = useState("image-{{index}}");
const [startIndex, setStartIndex] = useState(1);
const [downloadReady, setDownloadReady] = useState(false);
const handleProcess = async () => {
if (files.length === 0) return;
setProcessing(true);
setError(null);
setDownloadReady(false);
try {
const formData = new FormData();
for (const file of files) {
formData.append("file", file);
}
formData.append("settings", JSON.stringify({ pattern, startIndex }));
const res = await fetch("/api/v1/tools/bulk-rename", {
method: "POST",
headers: { Authorization: `Bearer ${getToken()}` },
body: formData,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Failed: ${res.status}`);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "renamed.zip";
a.click();
URL.revokeObjectURL(url);
setDownloadReady(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Rename failed");
} finally {
setProcessing(false);
}
};
const hasFiles = files.length > 0;
// Preview names
const previewNames = hasFiles
? files.slice(0, 5).map((f, i) => {
const ext = f.name.includes(".") ? f.name.slice(f.name.lastIndexOf(".")) : "";
const idx = startIndex + i;
const padded = String(idx).padStart(String(files.length + startIndex).length, "0");
return pattern
.replace(/\{\{index\}\}/g, String(idx))
.replace(/\{\{padded\}\}/g, padded)
.replace(/\{\{original\}\}/g, f.name.replace(ext, "")) + ext;
})
: [];
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Pattern</label>
<input
type="text"
value={pattern}
onChange={(e) => setPattern(e.target.value)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
<p className="text-[10px] text-muted-foreground mt-0.5">
Variables: {"{{index}}"}, {"{{padded}}"}, {"{{original}}"}
</p>
</div>
<div>
<label className="text-xs text-muted-foreground">Start Index</label>
<input type="number" value={startIndex} onChange={(e) => setStartIndex(Number(e.target.value))} min={0}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" />
</div>
{previewNames.length > 0 && (
<div>
<label className="text-xs text-muted-foreground">Preview</label>
<div className="mt-1 space-y-0.5">
{previewNames.map((name, i) => (
<div key={i} className="text-xs font-mono text-foreground bg-muted px-2 py-0.5 rounded truncate">
{name}
</div>
))}
{files.length > 5 && (
<p className="text-[10px] text-muted-foreground">... and {files.length - 5} more</p>
)}
</div>
</div>
)}
{error && <p className="text-xs text-red-500">{error}</p>}
<button
onClick={handleProcess}
disabled={!hasFiles || processing || !pattern}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing ? "Renaming..." : `Rename ${files.length} Files`}
</button>
{downloadReady && (
<p className="text-xs text-green-600 flex items-center gap-1">
<Download className="h-3 w-3" /> ZIP downloaded successfully
</p>
)}
</div>
);
}
@@ -0,0 +1,102 @@
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
import { Download, Loader2 } from "lucide-react";
function getToken(): string {
return localStorage.getItem("stirling-token") || "";
}
const SIZES = [
{ name: "favicon-16x16.png", size: "16x16" },
{ name: "favicon-32x32.png", size: "32x32" },
{ name: "favicon-48x48.png", size: "48x48" },
{ name: "apple-touch-icon.png", size: "180x180" },
{ name: "android-chrome-192x192.png", size: "192x192" },
{ name: "android-chrome-512x512.png", size: "512x512" },
{ name: "favicon.ico", size: "32x32" },
];
export function FaviconSettings() {
const { files, processing, error, setProcessing, setError } = useFileStore();
const [downloadReady, setDownloadReady] = useState(false);
const handleProcess = async () => {
if (files.length === 0) return;
setProcessing(true);
setError(null);
setDownloadReady(false);
try {
const formData = new FormData();
formData.append("file", files[0]);
const res = await fetch("/api/v1/tools/favicon", {
method: "POST",
headers: { Authorization: `Bearer ${getToken()}` },
body: formData,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `Failed: ${res.status}`);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "favicons.zip";
a.click();
URL.revokeObjectURL(url);
setDownloadReady(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Generation failed");
} finally {
setProcessing(false);
}
};
const hasFile = files.length > 0;
return (
<div className="space-y-4">
<p className="text-xs text-muted-foreground">
Upload a square image (recommended 512x512 or larger) to generate all
favicon and app icon sizes.
</p>
<div>
<label className="text-xs font-medium text-muted-foreground">Generated Sizes</label>
<div className="mt-1 space-y-0.5">
{SIZES.map((s) => (
<div key={s.name} className="flex justify-between text-xs text-foreground">
<span className="font-mono">{s.name}</span>
<span className="text-muted-foreground">{s.size}</span>
</div>
))}
</div>
<p className="text-[10px] text-muted-foreground mt-1">
+ manifest.json + HTML snippet
</p>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
<button
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing ? "Generating..." : "Generate Favicons"}
</button>
{downloadReady && (
<p className="text-xs text-green-600 flex items-center gap-1">
<Download className="h-3 w-3" /> ZIP downloaded successfully
</p>
)}
</div>
);
}
@@ -0,0 +1,106 @@
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { Download, Loader2 } from "lucide-react";
export function GifToolsSettings() {
const { files } = useFileStore();
const { processFiles, processing, error, downloadUrl, originalSize, processedSize } =
useToolProcessor("gif-tools");
const [mode, setMode] = useState<"resize" | "extract">("resize");
const [width, setWidth] = useState("");
const [height, setHeight] = useState("");
const [extractFrame, setExtractFrame] = useState("0");
const [optimize, setOptimize] = useState(false);
const handleProcess = () => {
const settings: Record<string, unknown> = {};
if (mode === "extract") {
settings.extractFrame = Number(extractFrame);
} else {
if (width) settings.width = Number(width);
if (height) settings.height = Number(height);
settings.optimize = optimize;
}
processFiles(files, settings);
};
const hasFile = files.length > 0;
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Mode</label>
<div className="flex gap-1 mt-1">
<button
onClick={() => setMode("resize")}
className={`flex-1 text-xs py-1.5 rounded ${mode === "resize" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
Resize
</button>
<button
onClick={() => setMode("extract")}
className={`flex-1 text-xs py-1.5 rounded ${mode === "extract" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
Extract Frame
</button>
</div>
</div>
{mode === "resize" ? (
<>
<div className="flex gap-2">
<div className="flex-1">
<label className="text-xs text-muted-foreground">Width (px)</label>
<input type="number" value={width} onChange={(e) => setWidth(e.target.value)} placeholder="Auto"
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" />
</div>
<div className="flex-1">
<label className="text-xs text-muted-foreground">Height (px)</label>
<input type="number" value={height} onChange={(e) => setHeight(e.target.value)} placeholder="Auto"
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" />
</div>
</div>
<label className="flex items-center gap-2 text-sm text-foreground">
<input type="checkbox" checked={optimize} onChange={(e) => setOptimize(e.target.checked)} className="rounded" />
Optimize file size
</label>
</>
) : (
<div>
<label className="text-xs text-muted-foreground">Frame Number</label>
<input type="number" value={extractFrame} onChange={(e) => setExtractFrame(e.target.value)} min={0}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" />
<p className="text-[10px] text-muted-foreground mt-0.5">Frame 0 is the first frame</p>
</div>
)}
{error && <p className="text-xs text-red-500">{error}</p>}
{originalSize != null && processedSize != null && (
<div className="text-xs text-muted-foreground space-y-0.5">
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
</div>
)}
<button
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing ? "Processing..." : "Process GIF"}
</button>
{downloadUrl && (
<a href={downloadUrl} download className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5">
<Download className="h-4 w-4" />
Download
</a>
)}
</div>
);
}
@@ -0,0 +1,118 @@
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
import { Download, Loader2 } from "lucide-react";
function getToken(): string {
return localStorage.getItem("stirling-token") || "";
}
export function ImageToPdfSettings() {
const { files, processing, error, setProcessing, setError } = useFileStore();
const [pageSize, setPageSize] = useState<"A4" | "Letter" | "A3" | "A5">("A4");
const [orientation, setOrientation] = useState<"portrait" | "landscape">("portrait");
const [margin, setMargin] = useState(20);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const handleProcess = async () => {
if (files.length === 0) return;
setProcessing(true);
setError(null);
setDownloadUrl(null);
try {
const formData = new FormData();
for (const file of files) {
formData.append("file", file);
}
formData.append("settings", JSON.stringify({ pageSize, orientation, margin }));
const res = await fetch("/api/v1/tools/image-to-pdf", {
method: "POST",
headers: { Authorization: `Bearer ${getToken()}` },
body: formData,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Failed: ${res.status}`);
}
const result = await res.json();
setDownloadUrl(result.downloadUrl);
} catch (err) {
setError(err instanceof Error ? err.message : "PDF creation failed");
} finally {
setProcessing(false);
}
};
const hasFiles = files.length > 0;
return (
<div className="space-y-4">
<p className="text-xs text-muted-foreground">
{files.length} image{files.length !== 1 ? "s" : ""} will be combined
into a PDF, one image per page.
</p>
<div>
<label className="text-xs text-muted-foreground">Page Size</label>
<select
value={pageSize}
onChange={(e) => setPageSize(e.target.value as typeof pageSize)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
<option value="A4">A4</option>
<option value="Letter">Letter</option>
<option value="A3">A3</option>
<option value="A5">A5</option>
</select>
</div>
<div>
<label className="text-xs text-muted-foreground">Orientation</label>
<div className="flex gap-1 mt-1">
<button
onClick={() => setOrientation("portrait")}
className={`flex-1 text-xs py-1.5 rounded ${orientation === "portrait" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
Portrait
</button>
<button
onClick={() => setOrientation("landscape")}
className={`flex-1 text-xs py-1.5 rounded ${orientation === "landscape" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
Landscape
</button>
</div>
</div>
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Margin</label>
<span className="text-xs font-mono text-foreground">{margin}pt</span>
</div>
<input type="range" min={0} max={100} value={margin} onChange={(e) => setMargin(Number(e.target.value))} className="w-full mt-1" />
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
<button
onClick={handleProcess}
disabled={!hasFiles || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing ? "Creating PDF..." : `Create PDF (${files.length} pages)`}
</button>
{downloadUrl && (
<a href={downloadUrl} download className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5">
<Download className="h-4 w-4" />
Download PDF
</a>
)}
</div>
);
}
@@ -0,0 +1,137 @@
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
import { Download, Loader2 } from "lucide-react";
function getToken(): string {
return localStorage.getItem("stirling-token") || "";
}
export function SvgToRasterSettings() {
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } = useFileStore();
const [width, setWidth] = useState(1024);
const [height, setHeight] = useState("");
const [backgroundColor, setBackgroundColor] = useState("#00000000");
const [outputFormat, setOutputFormat] = useState<"png" | "jpg" | "webp">("png");
const [transparent, setTransparent] = useState(true);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [originalSize, setOriginalSize] = useState<number | null>(null);
const [processedSize, setProcessedSize] = useState<number | null>(null);
const handleProcess = async () => {
if (files.length === 0) return;
setProcessing(true);
setError(null);
setDownloadUrl(null);
try {
const formData = new FormData();
formData.append("file", files[0]);
const settings: Record<string, unknown> = {
width,
outputFormat,
backgroundColor: transparent ? "#00000000" : backgroundColor,
};
if (height) settings.height = Number(height);
formData.append("settings", JSON.stringify(settings));
const res = await fetch("/api/v1/tools/svg-to-raster", {
method: "POST",
headers: { Authorization: `Bearer ${getToken()}` },
body: formData,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Failed: ${res.status}`);
}
const result = await res.json();
setJobId(result.jobId);
setProcessedUrl(result.downloadUrl);
setDownloadUrl(result.downloadUrl);
setOriginalSize(result.originalSize);
setProcessedSize(result.processedSize);
setSizes(result.originalSize, result.processedSize);
} catch (err) {
setError(err instanceof Error ? err.message : "Conversion failed");
} finally {
setProcessing(false);
}
};
const hasFile = files.length > 0;
return (
<div className="space-y-4">
<div className="flex gap-2">
<div className="flex-1">
<label className="text-xs text-muted-foreground">Width (px)</label>
<input type="number" value={width} onChange={(e) => setWidth(Number(e.target.value))} min={1} max={8192}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" />
</div>
<div className="flex-1">
<label className="text-xs text-muted-foreground">Height (px)</label>
<input type="number" value={height} onChange={(e) => setHeight(e.target.value)} placeholder="Auto"
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" />
</div>
</div>
<div>
<label className="text-xs text-muted-foreground">Output Format</label>
<select
value={outputFormat}
onChange={(e) => setOutputFormat(e.target.value as "png" | "jpg" | "webp")}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
<option value="png">PNG</option>
<option value="jpg">JPEG</option>
<option value="webp">WebP</option>
</select>
</div>
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={transparent}
onChange={(e) => setTransparent(e.target.checked)}
disabled={outputFormat === "jpg"}
className="rounded"
/>
Transparent background
</label>
{!transparent && (
<div>
<label className="text-xs text-muted-foreground">Background Color</label>
<input type="color" value={backgroundColor.slice(0, 7)} onChange={(e) => setBackgroundColor(e.target.value)} className="w-full mt-0.5 h-8 rounded border border-border" />
</div>
)}
{error && <p className="text-xs text-red-500">{error}</p>}
{originalSize != null && processedSize != null && (
<div className="text-xs text-muted-foreground space-y-0.5">
<p>SVG: {(originalSize / 1024).toFixed(1)} KB</p>
<p>Output: {(processedSize / 1024).toFixed(1)} KB</p>
</div>
)}
<button
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing ? "Converting..." : "Convert SVG"}
</button>
{downloadUrl && (
<a href={downloadUrl} download className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5">
<Download className="h-4 w-4" />
Download
</a>
)}
</div>
);
}
@@ -0,0 +1,124 @@
import { useState } from "react";
import { useFileStore } from "@/stores/file-store";
import { Download, Loader2 } from "lucide-react";
function getToken(): string {
return localStorage.getItem("stirling-token") || "";
}
export function VectorizeSettings() {
const { files, processing, error, setProcessing, setError, setProcessedUrl, setSizes, setJobId } = useFileStore();
const [colorMode, setColorMode] = useState<"bw" | "color">("bw");
const [threshold, setThreshold] = useState(128);
const [detail, setDetail] = useState<"low" | "medium" | "high">("medium");
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const [originalSize, setOriginalSize] = useState<number | null>(null);
const [processedSize, setProcessedSize] = useState<number | null>(null);
const handleProcess = async () => {
if (files.length === 0) return;
setProcessing(true);
setError(null);
setDownloadUrl(null);
try {
const formData = new FormData();
formData.append("file", files[0]);
formData.append("settings", JSON.stringify({ colorMode, threshold, detail }));
const res = await fetch("/api/v1/tools/vectorize", {
method: "POST",
headers: { Authorization: `Bearer ${getToken()}` },
body: formData,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Failed: ${res.status}`);
}
const result = await res.json();
setJobId(result.jobId);
setProcessedUrl(result.downloadUrl);
setDownloadUrl(result.downloadUrl);
setOriginalSize(result.originalSize);
setProcessedSize(result.processedSize);
setSizes(result.originalSize, result.processedSize);
} catch (err) {
setError(err instanceof Error ? err.message : "Vectorization failed");
} finally {
setProcessing(false);
}
};
const hasFile = files.length > 0;
return (
<div className="space-y-4">
<div>
<label className="text-xs text-muted-foreground">Color Mode</label>
<div className="flex gap-1 mt-1">
<button
onClick={() => setColorMode("bw")}
className={`flex-1 text-xs py-1.5 rounded ${colorMode === "bw" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
Black & White
</button>
<button
onClick={() => setColorMode("color")}
className={`flex-1 text-xs py-1.5 rounded ${colorMode === "color" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
Color
</button>
</div>
</div>
<div>
<div className="flex justify-between items-center">
<label className="text-xs text-muted-foreground">Threshold</label>
<span className="text-xs font-mono text-foreground">{threshold}</span>
</div>
<input type="range" min={0} max={255} value={threshold} onChange={(e) => setThreshold(Number(e.target.value))} className="w-full mt-1" />
</div>
<div>
<label className="text-xs text-muted-foreground">Detail Level</label>
<select
value={detail}
onChange={(e) => setDetail(e.target.value as "low" | "medium" | "high")}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
<option value="low">Low (simpler, smaller SVG)</option>
<option value="medium">Medium</option>
<option value="high">High (detailed, larger SVG)</option>
</select>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{originalSize != null && processedSize != null && (
<div className="text-xs text-muted-foreground space-y-0.5">
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
<p>SVG: {(processedSize / 1024).toFixed(1)} KB</p>
</div>
)}
<button
onClick={handleProcess}
disabled={!hasFile || processing}
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing ? "Vectorizing..." : "Vectorize"}
</button>
{downloadUrl && (
<a href={downloadUrl} download className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5">
<Download className="h-4 w-4" />
Download SVG
</a>
)}
</div>
);
}