mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(tools): 2.0 phase 5 wave 5a - image gap-fill (11 tools) (#225)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
Reference in New Issue
Block a user