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>
);
}