feat(tools): 2.0 phase 5 wave 5a - image gap-fill (11 tools) (#225)

This commit is contained in:
SnapOtter
2026-06-13 10:19:16 +08:00
parent fc7c1f850e
commit 6e1b9865f1
79 changed files with 7992 additions and 728 deletions
@@ -0,0 +1,170 @@
import { Download, Loader2 } from "lucide-react";
import { useCallback, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders } from "@/lib/api";
const BARCODE_TYPES = [
{ value: "code128", label: "Code 128" },
{ value: "ean13", label: "EAN-13" },
{ value: "upca", label: "UPC-A" },
{ value: "code39", label: "Code 39" },
{ value: "itf14", label: "ITF-14" },
{ value: "datamatrix", label: "Data Matrix" },
] as const;
const INPUT_CLASS =
"w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground";
export function BarcodeGenerateSettings() {
const { t } = useTranslation();
const ts = t.toolSettings["barcode-generate"];
const [text, setText] = useState("");
const [type, setType] = useState("code128");
const [scale, setScale] = useState(3);
const [includeText, setIncludeText] = useState(true);
const [generating, setGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [resultUrl, setResultUrl] = useState<string | null>(null);
const canGenerate = text.trim().length > 0 && !generating;
const handleGenerate = useCallback(async () => {
setGenerating(true);
setError(null);
setResultUrl(null);
try {
const res = await fetch("/api/v1/tools/barcode-generate", {
method: "POST",
headers: {
...formatHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify({ text: text.trim(), type, scale, includeText }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Request failed: ${res.status}`);
}
const data = await res.json();
if (data.downloadUrl) {
setResultUrl(data.downloadUrl);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to generate barcode");
} finally {
setGenerating(false);
}
}, [text, type, scale, includeText]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (canGenerate) handleGenerate();
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Text input */}
<div>
<label htmlFor="barcode-text" className="text-xs text-muted-foreground">
{ts.text}
</label>
<input
id="barcode-text"
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Enter text or number..."
className={INPUT_CLASS}
data-testid="barcode-input-text"
/>
</div>
{/* Barcode Type */}
<div>
<label htmlFor="barcode-type" className="text-xs text-muted-foreground">
{ts.type}
</label>
<select
id="barcode-type"
value={type}
onChange={(e) => setType(e.target.value)}
className={INPUT_CLASS}
>
{BARCODE_TYPES.map((bt) => (
<option key={bt.value} value={bt.value}>
{bt.label}
</option>
))}
</select>
</div>
{/* Scale */}
<div>
<div className="flex justify-between items-center">
<label htmlFor="barcode-scale" className="text-xs text-muted-foreground">
{ts.scale}
</label>
<span className="text-xs font-mono text-foreground">{scale}x</span>
</div>
<input
id="barcode-scale"
type="range"
min={1}
max={8}
value={scale}
onChange={(e) => setScale(Number(e.target.value))}
className="w-full mt-1"
/>
</div>
{/* Include Text */}
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={includeText}
onChange={(e) => setIncludeText(e.target.checked)}
className="rounded border-border"
/>
{ts.includeText}
</label>
{error && <p className="text-xs text-red-500">{error}</p>}
{/* Result preview */}
{resultUrl && (
<div className="space-y-2">
<img
src={resultUrl}
alt="Generated barcode"
className="w-full rounded border border-border bg-white p-2"
/>
<a
href={resultUrl}
download="barcode.png"
data-testid="barcode-generate-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" />
{ts.download}
</a>
</div>
)}
{/* Generate button */}
<button
type="submit"
data-testid="barcode-generate-submit"
disabled={!canGenerate}
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"
>
{generating && <Loader2 className="h-4 w-4 animate-spin" />}
{ts.submit}
</button>
</form>
);
}
@@ -0,0 +1,160 @@
import { Download } from "lucide-react";
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
const CHART_KINDS = [
{ value: "bar", label: "Bar" },
{ value: "line", label: "Line" },
{ value: "pie", label: "Pie" },
] as const;
const INPUT_CLASS =
"w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground";
export function ChartMakerSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
useToolProcessor("chart-maker");
const [kind, setKind] = useState("bar");
const [title, setTitle] = useState("");
const [width, setWidth] = useState(960);
const [height, setHeight] = useState(540);
const handleProcess = () => {
const settings: Record<string, unknown> = { kind, width, height };
if (title.trim()) settings.title = title.trim();
if (files.length > 1) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
const hasFile = files.length > 0;
const canProcess = hasFile && !processing;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (canProcess) handleProcess();
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Chart Type */}
<div>
<label htmlFor="chart-kind" className="text-xs text-muted-foreground">
{t.toolSettings["chart-maker"].kind}
</label>
<select
id="chart-kind"
value={kind}
onChange={(e) => setKind(e.target.value)}
className={INPUT_CLASS}
>
{CHART_KINDS.map((ck) => (
<option key={ck.value} value={ck.value}>
{ck.label}
</option>
))}
</select>
</div>
{/* Title */}
<div>
<label htmlFor="chart-title" className="text-xs text-muted-foreground">
{t.toolSettings["chart-maker"].title}
</label>
<input
id="chart-title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Optional chart title"
maxLength={120}
className={INPUT_CLASS}
/>
</div>
{/* Width */}
<div>
<div className="flex justify-between items-center">
<label htmlFor="chart-width" className="text-xs text-muted-foreground">
{t.toolSettings["chart-maker"].width}
</label>
<span className="text-xs font-mono text-foreground">{width}px</span>
</div>
<input
id="chart-width"
type="range"
min={320}
max={2048}
step={10}
value={width}
onChange={(e) => setWidth(Number(e.target.value))}
className="w-full mt-1"
/>
</div>
{/* Height */}
<div>
<div className="flex justify-between items-center">
<label htmlFor="chart-height" className="text-xs text-muted-foreground">
{t.toolSettings["chart-maker"].height}
</label>
<span className="text-xs font-mono text-foreground">{height}px</span>
</div>
<input
id="chart-height"
type="range"
min={240}
max={1536}
step={10}
value={height}
onChange={(e) => setHeight(Number(e.target.value))}
className="w-full mt-1"
/>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={t.toolSettings["chart-maker"].progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="submit"
data-testid="chart-maker-submit"
disabled={!canProcess}
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"
>
{files.length > 1
? t.toolSettings["chart-maker"].submitBatch.replace("{count}", String(files.length))
: t.toolSettings["chart-maker"].submit}
</button>
)}
{downloadUrl && (
<a
href={downloadUrl}
download
data-testid="chart-maker-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" />
{t.common.download}
</a>
)}
</form>
);
}
@@ -0,0 +1,72 @@
import { Download } from "lucide-react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
export function CircleCropSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
useToolProcessor("circle-crop");
const handleProcess = () => {
if (files.length > 1) {
processAllFiles(files, {});
} else {
processFiles(files, {});
}
};
const hasFile = files.length > 0;
const canProcess = hasFile && !processing;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (canProcess) handleProcess();
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<p className="text-xs text-muted-foreground">
Crops the image to a centered circle with transparent corners. Output is always PNG.
</p>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={t.toolSettings["circle-crop"].progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="submit"
data-testid="circle-crop-submit"
disabled={!canProcess}
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"
>
{files.length > 1
? t.toolSettings["circle-crop"].submitBatch.replace("{count}", String(files.length))
: t.toolSettings["circle-crop"].submit}
</button>
)}
{downloadUrl && (
<a
href={downloadUrl}
download
data-testid="circle-crop-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" />
{t.common.download}
</a>
)}
</form>
);
}
@@ -20,6 +20,9 @@ const OUTPUT_FORMATS = [
"ico",
"jp2",
"qoi",
"ppm",
"eps",
"tga",
] as const;
const LOSSY_FORMATS = ["jpg", "jpeg", "webp", "avif", "heic", "heif", "jxl", "jp2"];
@@ -37,6 +40,9 @@ const FORMAT_LABELS: Record<string, string> = {
ico: "ICO",
jp2: "JP2",
qoi: "QOI",
ppm: "PPM",
eps: "EPS",
tga: "TGA",
};
export interface ConvertControlsProps {
@@ -0,0 +1,117 @@
import { Download } from "lucide-react";
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
export function DuotoneSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
useToolProcessor("duotone");
const [shadow, setShadow] = useState("#1e3a8a");
const [highlight, setHighlight] = useState("#fbbf24");
const handleProcess = () => {
const settings = { shadow, highlight };
if (files.length > 1) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
const hasFile = files.length > 0;
const canProcess = hasFile && !processing;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (canProcess) handleProcess();
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Shadow Color */}
<div>
<label htmlFor="duotone-shadow" className="text-xs text-muted-foreground">
{t.toolSettings.duotone.shadow}
</label>
<div className="flex items-center gap-2 mt-0.5">
<input
id="duotone-shadow"
type="color"
value={shadow}
onChange={(e) => setShadow(e.target.value)}
className="w-8 h-8 rounded border border-border shrink-0"
/>
<input
type="text"
value={shadow}
onChange={(e) => setShadow(e.target.value)}
className="flex-1 px-2 py-1 rounded border border-border bg-background text-xs text-foreground font-mono"
/>
</div>
</div>
{/* Highlight Color */}
<div>
<label htmlFor="duotone-highlight" className="text-xs text-muted-foreground">
{t.toolSettings.duotone.highlight}
</label>
<div className="flex items-center gap-2 mt-0.5">
<input
id="duotone-highlight"
type="color"
value={highlight}
onChange={(e) => setHighlight(e.target.value)}
className="w-8 h-8 rounded border border-border shrink-0"
/>
<input
type="text"
value={highlight}
onChange={(e) => setHighlight(e.target.value)}
className="flex-1 px-2 py-1 rounded border border-border bg-background text-xs text-foreground font-mono"
/>
</div>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={t.toolSettings.duotone.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="submit"
data-testid="duotone-submit"
disabled={!canProcess}
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"
>
{files.length > 1
? t.toolSettings.duotone.submitBatch.replace("{count}", String(files.length))
: t.toolSettings.duotone.submit}
</button>
)}
{downloadUrl && (
<a
href={downloadUrl}
download
data-testid="duotone-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" />
{t.common.download}
</a>
)}
</form>
);
}
@@ -0,0 +1,73 @@
import { Download } from "lucide-react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
export function GifWebpSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
useToolProcessor("gif-webp");
const handleProcess = () => {
if (files.length > 1) {
processAllFiles(files, {});
} else {
processFiles(files, {});
}
};
const hasFile = files.length > 0;
const canProcess = hasFile && !processing;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (canProcess) handleProcess();
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<p className="text-xs text-muted-foreground">
Converts GIF to WebP and WebP to GIF, preserving all animation frames. Direction is
determined automatically by the input file format.
</p>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={t.toolSettings["gif-webp"].progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="submit"
data-testid="gif-webp-submit"
disabled={!canProcess}
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"
>
{files.length > 1
? t.toolSettings["gif-webp"].submitBatch.replace("{count}", String(files.length))
: t.toolSettings["gif-webp"].submit}
</button>
)}
{downloadUrl && (
<a
href={downloadUrl}
download
data-testid="gif-webp-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" />
{t.common.download}
</a>
)}
</form>
);
}
@@ -0,0 +1,72 @@
import { Download } from "lucide-react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
export function HistogramSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
useToolProcessor("histogram");
const handleProcess = () => {
if (files.length > 1) {
processAllFiles(files, {});
} else {
processFiles(files, {});
}
};
const hasFile = files.length > 0;
const canProcess = hasFile && !processing;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (canProcess) handleProcess();
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<p className="text-xs text-muted-foreground">
Generates an RGB histogram chart showing the color distribution of the image.
</p>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={t.toolSettings.histogram.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="submit"
data-testid="histogram-submit"
disabled={!canProcess}
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"
>
{files.length > 1
? t.toolSettings.histogram.submitBatch.replace("{count}", String(files.length))
: t.toolSettings.histogram.submit}
</button>
)}
{downloadUrl && (
<a
href={downloadUrl}
download
data-testid="histogram-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" />
{t.common.download}
</a>
)}
</form>
);
}
@@ -0,0 +1,122 @@
import { Download } from "lucide-react";
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
const TARGET_OPTIONS = [
{ value: "16:9", label: "16:9" },
{ value: "9:16", label: "9:16" },
{ value: "1:1", label: "1:1" },
{ value: "4:3", label: "4:3" },
{ value: "3:4", label: "3:4" },
] as const;
export function ImagePadSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
useToolProcessor("image-pad");
const [target, setTarget] = useState("1:1");
const [color, setColor] = useState("#ffffff");
const handleProcess = () => {
const settings = { target, color };
if (files.length > 1) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
const hasFile = files.length > 0;
const canProcess = hasFile && !processing;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (canProcess) handleProcess();
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Target Ratio */}
<div>
<label htmlFor="image-pad-target" className="text-xs text-muted-foreground">
{t.toolSettings["image-pad"].target}
</label>
<select
id="image-pad-target"
value={target}
onChange={(e) => setTarget(e.target.value)}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
>
{TARGET_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* Background Color */}
<div>
<label htmlFor="image-pad-color" className="text-xs text-muted-foreground">
{t.toolSettings["image-pad"].color}
</label>
<div className="flex items-center gap-2 mt-0.5">
<input
id="image-pad-color"
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="w-8 h-8 rounded border border-border shrink-0"
/>
<input
type="text"
value={color}
onChange={(e) => setColor(e.target.value)}
className="flex-1 px-2 py-1 rounded border border-border bg-background text-xs text-foreground font-mono"
/>
</div>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={t.toolSettings["image-pad"].progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="submit"
data-testid="image-pad-submit"
disabled={!canProcess}
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"
>
{files.length > 1
? t.toolSettings["image-pad"].submitBatch.replace("{count}", String(files.length))
: t.toolSettings["image-pad"].submit}
</button>
)}
{downloadUrl && (
<a
href={downloadUrl}
download
data-testid="image-pad-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" />
{t.common.download}
</a>
)}
</form>
);
}
@@ -0,0 +1,118 @@
import { Download } from "lucide-react";
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
export function LqipPlaceholderSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
useToolProcessor("lqip-placeholder");
const [width, setWidth] = useState(16);
const [blur, setBlur] = useState(2);
const handleProcess = () => {
const settings = { width, blur };
if (files.length > 1) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
const hasFile = files.length > 0;
const canProcess = hasFile && !processing;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (canProcess) handleProcess();
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Width */}
<div>
<div className="flex justify-between items-center">
<label htmlFor="lqip-width" className="text-xs text-muted-foreground">
{t.toolSettings["lqip-placeholder"].width}
</label>
<span className="text-xs font-mono text-foreground">{width}px</span>
</div>
<input
id="lqip-width"
type="range"
min={4}
max={64}
value={width}
onChange={(e) => setWidth(Number(e.target.value))}
className="w-full mt-1"
/>
</div>
{/* Blur */}
<div>
<div className="flex justify-between items-center">
<label htmlFor="lqip-blur" className="text-xs text-muted-foreground">
{t.toolSettings["lqip-placeholder"].blur}
</label>
<span className="text-xs font-mono text-foreground">{blur}</span>
</div>
<input
id="lqip-blur"
type="range"
min={0}
max={20}
value={blur}
onChange={(e) => setBlur(Number(e.target.value))}
className="w-full mt-1"
/>
</div>
<p className="text-[10px] text-muted-foreground">
The base64 data URI will appear in the result envelope.
</p>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={t.toolSettings["lqip-placeholder"].progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="submit"
data-testid="lqip-placeholder-submit"
disabled={!canProcess}
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"
>
{files.length > 1
? t.toolSettings["lqip-placeholder"].submitBatch.replace(
"{count}",
String(files.length),
)
: t.toolSettings["lqip-placeholder"].submit}
</button>
)}
{downloadUrl && (
<a
href={downloadUrl}
download
data-testid="lqip-placeholder-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" />
{t.common.download}
</a>
)}
</form>
);
}
@@ -0,0 +1,94 @@
import { Download } from "lucide-react";
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
export function PixelateSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
useToolProcessor("pixelate");
const [blockSize, setBlockSize] = useState(12);
const handleProcess = () => {
const settings = { blockSize };
if (files.length > 1) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
const hasFile = files.length > 0;
const canProcess = hasFile && !processing;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (canProcess) handleProcess();
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Block Size */}
<div>
<div className="flex justify-between items-center">
<label htmlFor="pixelate-block-size" className="text-xs text-muted-foreground">
{t.toolSettings.pixelate.blockSize}
</label>
<span className="text-xs font-mono text-foreground">{blockSize}px</span>
</div>
<input
id="pixelate-block-size"
type="range"
min={2}
max={128}
value={blockSize}
onChange={(e) => setBlockSize(Number(e.target.value))}
className="w-full mt-1"
/>
<p className="text-[10px] text-muted-foreground mt-1">
Applies pixelation to the full image
</p>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={t.toolSettings.pixelate.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="submit"
data-testid="pixelate-submit"
disabled={!canProcess}
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"
>
{files.length > 1
? t.toolSettings.pixelate.submitBatch.replace("{count}", String(files.length))
: t.toolSettings.pixelate.submit}
</button>
)}
{downloadUrl && (
<a
href={downloadUrl}
download
data-testid="pixelate-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" />
{t.common.download}
</a>
)}
</form>
);
}
@@ -668,10 +668,11 @@ export function QrGenerateSettings() {
<input
ref={logoInputRef}
type="file"
accept="image/*,.avif,.heic,.heif,.hif"
accept="image/png,image/jpeg"
onChange={handleLogoUpload}
className="hidden"
/>
{store.logoError && <p className="text-xs text-red-500">{store.logoError}</p>}
{store.logoDataUrl ? (
<div className="flex items-center gap-2">
<img
@@ -0,0 +1,132 @@
import { Download } from "lucide-react";
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
export function SpriteSheetSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
useToolProcessor("sprite-sheet");
const [columns, setColumns] = useState(4);
const [padding, setPadding] = useState(0);
const [background, setBackground] = useState("#ffffff");
const handleProcess = () => {
const settings = { columns, padding, background };
if (files.length > 1) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
const hasFile = files.length > 0;
const canProcess = hasFile && !processing;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (canProcess) handleProcess();
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Columns */}
<div>
<div className="flex justify-between items-center">
<label htmlFor="sprite-columns" className="text-xs text-muted-foreground">
{t.toolSettings["sprite-sheet"].columns}
</label>
<span className="text-xs font-mono text-foreground">{columns}</span>
</div>
<input
id="sprite-columns"
type="range"
min={1}
max={16}
value={columns}
onChange={(e) => setColumns(Number(e.target.value))}
className="w-full mt-1"
/>
</div>
{/* Padding */}
<div>
<div className="flex justify-between items-center">
<label htmlFor="sprite-padding" className="text-xs text-muted-foreground">
{t.toolSettings["sprite-sheet"].padding}
</label>
<span className="text-xs font-mono text-foreground">{padding}px</span>
</div>
<input
id="sprite-padding"
type="range"
min={0}
max={64}
value={padding}
onChange={(e) => setPadding(Number(e.target.value))}
className="w-full mt-1"
/>
</div>
{/* Background Color */}
<div>
<label htmlFor="sprite-background" className="text-xs text-muted-foreground">
{t.toolSettings["sprite-sheet"].background}
</label>
<div className="flex items-center gap-2 mt-0.5">
<input
id="sprite-background"
type="color"
value={background}
onChange={(e) => setBackground(e.target.value)}
className="w-8 h-8 rounded border border-border shrink-0"
/>
<input
type="text"
value={background}
onChange={(e) => setBackground(e.target.value)}
className="flex-1 px-2 py-1 rounded border border-border bg-background text-xs text-foreground font-mono"
/>
</div>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={t.toolSettings["sprite-sheet"].progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="submit"
data-testid="sprite-sheet-submit"
disabled={!canProcess}
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"
>
{t.toolSettings["sprite-sheet"].submit}
</button>
)}
{downloadUrl && (
<a
href={downloadUrl}
download
data-testid="sprite-sheet-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" />
{t.common.download}
</a>
)}
</form>
);
}
@@ -0,0 +1,115 @@
import { Download } from "lucide-react";
import { useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
export function VignetteSettings() {
const { t } = useTranslation();
const { files } = useFileStore();
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
useToolProcessor("vignette");
const [strength, setStrength] = useState(0.5);
const [color, setColor] = useState("#000000");
const handleProcess = () => {
const settings = { strength, color };
if (files.length > 1) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
const hasFile = files.length > 0;
const canProcess = hasFile && !processing;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (canProcess) handleProcess();
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Strength */}
<div>
<div className="flex justify-between items-center">
<label htmlFor="vignette-strength" className="text-xs text-muted-foreground">
{t.toolSettings.vignette.strength}
</label>
<span className="text-xs font-mono text-foreground">{strength.toFixed(2)}</span>
</div>
<input
id="vignette-strength"
type="range"
min={0.1}
max={1}
step={0.05}
value={strength}
onChange={(e) => setStrength(Number(e.target.value))}
className="w-full mt-1"
/>
</div>
{/* Vignette Color */}
<div>
<label htmlFor="vignette-color" className="text-xs text-muted-foreground">
{t.toolSettings.vignette.color}
</label>
<div className="flex items-center gap-2 mt-0.5">
<input
id="vignette-color"
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="w-8 h-8 rounded border border-border shrink-0"
/>
<input
type="text"
value={color}
onChange={(e) => setColor(e.target.value)}
className="flex-1 px-2 py-1 rounded border border-border bg-background text-xs text-foreground font-mono"
/>
</div>
</div>
{error && <p className="text-xs text-red-500">{error}</p>}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label={t.toolSettings.vignette.progressLabel}
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="submit"
data-testid="vignette-submit"
disabled={!canProcess}
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"
>
{files.length > 1
? t.toolSettings.vignette.submitBatch.replace("{count}", String(files.length))
: t.toolSettings.vignette.submit}
</button>
)}
{downloadUrl && (
<a
href={downloadUrl}
download
data-testid="vignette-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" />
{t.common.download}
</a>
)}
</form>
);
}
+14
View File
@@ -2,10 +2,13 @@ import type { LucideIcon } from "lucide-react";
import {
Activity,
Anchor,
Aperture,
AppWindow,
Archive,
AudioLines,
AudioWaveform,
BarChart3,
Barcode,
BellRing,
Book,
BookImage,
@@ -13,6 +16,7 @@ import {
Braces,
Captions,
CheckCircle2,
Circle,
Code,
Columns2,
Combine,
@@ -47,9 +51,11 @@ import {
GalleryThumbnails,
Gauge,
Globe,
Grid2x2,
Grid3x3,
Hash,
Image,
ImageDown,
ImagePlus,
Images,
Info,
@@ -72,6 +78,7 @@ import {
Presentation,
QrCode,
RectangleHorizontal,
Repeat,
Rewind,
RotateCw,
Scaling,
@@ -108,10 +115,13 @@ import {
export const ICON_MAP: Record<string, LucideIcon> = {
Activity,
Anchor,
Aperture,
AppWindow,
Archive,
AudioLines,
AudioWaveform,
Barcode,
BarChart3,
BellRing,
Book,
BookImage,
@@ -119,6 +129,7 @@ export const ICON_MAP: Record<string, LucideIcon> = {
Braces,
Captions,
CheckCircle2,
Circle,
Code,
Columns2,
Combine,
@@ -153,9 +164,11 @@ export const ICON_MAP: Record<string, LucideIcon> = {
GalleryThumbnails,
Gauge,
Globe,
Grid2x2,
Grid3x3,
Hash,
Image,
ImageDown,
ImagePlus,
Images,
Info,
@@ -203,6 +216,7 @@ export const ICON_MAP: Record<string, LucideIcon> = {
Wand,
Waves,
Wrench,
Repeat,
Zap,
ZoomIn,
Layers2,
+13
View File
@@ -48,6 +48,7 @@ export const TOOL_DISPLAY_MODES: Record<string, DisplayMode> = {
"qr-generate": "no-dropzone",
"html-to-image": "no-dropzone",
"barcode-read": "before-after",
"barcode-generate": "no-dropzone",
"image-to-base64": "custom-results",
// Layout & composition
@@ -56,6 +57,16 @@ export const TOOL_DISPLAY_MODES: Record<string, DisplayMode> = {
split: "interactive-split",
border: "live-preview",
beautify: "live-preview",
"circle-crop": "live-preview",
duotone: "live-preview",
"image-pad": "live-preview",
pixelate: "live-preview",
vignette: "live-preview",
"gif-webp": "live-preview",
histogram: "no-comparison",
"lqip-placeholder": "no-comparison",
"sprite-sheet": "no-comparison",
// Format & conversion
"svg-to-raster": "before-after",
@@ -177,6 +188,7 @@ export const TOOL_DISPLAY_MODES: Record<string, DisplayMode> = {
"to-epub": "no-comparison",
// Data tools
"chart-maker": "no-comparison",
"csv-excel": "no-comparison",
"csv-json": "no-comparison",
"json-xml": "no-comparison",
@@ -202,4 +214,5 @@ export const MULTI_FILE_TOOLS: ReadonlySet<string> = new Set([
"burn-subtitles",
"embed-subtitles",
"images-to-video",
"sprite-sheet",
]);
+70
View File
@@ -785,6 +785,63 @@ const ExtractZipSettings = lazy(() =>
})),
);
// Wave 5a: Image gap-fill tools
const BarcodeGenerateSettings = lazy(() =>
import("@/components/tools/barcode-generate-settings").then((m) => ({
default: m.BarcodeGenerateSettings,
})),
);
const ChartMakerSettings = lazy(() =>
import("@/components/tools/chart-maker-settings").then((m) => ({
default: m.ChartMakerSettings,
})),
);
const CircleCropSettings = lazy(() =>
import("@/components/tools/circle-crop-settings").then((m) => ({
default: m.CircleCropSettings,
})),
);
const DuotoneSettings = lazy(() =>
import("@/components/tools/duotone-settings").then((m) => ({
default: m.DuotoneSettings,
})),
);
const GifWebpSettings = lazy(() =>
import("@/components/tools/gif-webp-settings").then((m) => ({
default: m.GifWebpSettings,
})),
);
const HistogramSettings = lazy(() =>
import("@/components/tools/histogram-settings").then((m) => ({
default: m.HistogramSettings,
})),
);
const ImagePadSettings = lazy(() =>
import("@/components/tools/image-pad-settings").then((m) => ({
default: m.ImagePadSettings,
})),
);
const LqipPlaceholderSettings = lazy(() =>
import("@/components/tools/lqip-placeholder-settings").then((m) => ({
default: m.LqipPlaceholderSettings,
})),
);
const PixelateSettings = lazy(() =>
import("@/components/tools/pixelate-settings").then((m) => ({
default: m.PixelateSettings,
})),
);
const SpriteSheetSettings = lazy(() =>
import("@/components/tools/sprite-sheet-settings").then((m) => ({
default: m.SpriteSheetSettings,
})),
);
const VignetteSettings = lazy(() =>
import("@/components/tools/vignette-settings").then((m) => ({
default: m.VignetteSettings,
})),
);
// ── Color tool wrapper ─────────────────────────────────────────────
// Color tools share a single component but differ by toolId.
@@ -851,6 +908,7 @@ const ENTRY_CONFIG: ReadonlyArray<[string, RegistryEntryConfig]> = [
["qr-generate", { Settings: QrGenerateSettings, ResultsPanel: QrGeneratePreview }],
["html-to-image", { Settings: HtmlToImageSettings, ResultsPanel: HtmlToImageResults }],
["barcode-read", { Settings: BarcodeReadSettings }],
["barcode-generate", { Settings: BarcodeGenerateSettings }],
["image-to-base64", { Settings: ImageToBase64Settings, ResultsPanel: ImageToBase64Results }],
// Layout & Composition
@@ -859,6 +917,17 @@ const ENTRY_CONFIG: ReadonlyArray<[string, RegistryEntryConfig]> = [
["split", { Settings: SplitSettings, ResultsPanel: SplitCanvas }],
["border", { livePreview: true, Settings: BorderSettings as never }],
["beautify", { livePreview: true, Settings: BeautifySettings as never }],
["circle-crop", { livePreview: true, Settings: CircleCropSettings }],
["duotone", { livePreview: true, Settings: DuotoneSettings }],
["image-pad", { livePreview: true, Settings: ImagePadSettings }],
["pixelate", { livePreview: true, Settings: PixelateSettings }],
["sprite-sheet", { Settings: SpriteSheetSettings }],
["vignette", { livePreview: true, Settings: VignetteSettings }],
// Image utilities (wave 5a)
["gif-webp", { livePreview: true, accept: ".gif,.webp", Settings: GifWebpSettings }],
["histogram", { Settings: HistogramSettings }],
["lqip-placeholder", { Settings: LqipPlaceholderSettings }],
// Format & Conversion
["svg-to-raster", { accept: ".svg,.svgz", Settings: SvgToRasterSettings }],
@@ -1007,6 +1076,7 @@ const ENTRY_CONFIG: ReadonlyArray<[string, RegistryEntryConfig]> = [
["markdown-to-pdf", { accept: ".md,.markdown", Settings: MarkdownToPdfSettings }],
// Data tools
["chart-maker", { accept: ".csv,.json", Settings: ChartMakerSettings }],
["csv-excel", { accept: ".csv,.tsv,.xlsx", Settings: CsvExcelSettings }],
["csv-json", { accept: ".csv,.tsv,.json", Settings: CsvJsonSettings }],
["json-xml", { accept: ".json,.xml", Settings: JsonXmlSettings }],
+15 -2
View File
@@ -67,6 +67,7 @@ interface QrState {
// Logo
logoFile: File | null;
logoDataUrl: string | null;
logoError: string | null;
logoSize: number;
logoMargin: number;
hideBackgroundDots: boolean;
@@ -150,6 +151,7 @@ const DEFAULTS = {
useCustomCornerColors: false,
logoFile: null as File | null,
logoDataUrl: null as string | null,
logoError: null as string | null,
logoSize: 0.4,
logoMargin: 5,
hideBackgroundDots: true,
@@ -184,11 +186,22 @@ export const useQrStore = create<QrState>((set) => ({
setUseCustomCornerColors: (v) => set({ useCustomCornerColors: v }),
setLogoFile: (f) => {
if (!f) {
set({ logoFile: null, logoDataUrl: null });
set({ logoFile: null, logoDataUrl: null, logoError: null });
return;
}
const reader = new FileReader();
reader.onload = () => set({ logoFile: f, logoDataUrl: reader.result as string });
reader.onload = () => {
const dataUri = reader.result as string;
if (dataUri.length > 700000) {
set({
logoFile: null,
logoDataUrl: null,
logoError: "Logo image is too large (max ~500 KB). Please use a smaller image.",
});
return;
}
set({ logoFile: f, logoDataUrl: dataUri, logoError: null });
};
reader.readAsDataURL(f);
},
setLogoSize: (v) => set({ logoSize: v }),