mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(web): add tool settings UI for all core image tools
Adds Zustand file store, useToolProcessor hook for upload/process/download flow, and 7 settings components: resize (with social media presets), crop (with aspect ratio presets), rotate/flip, convert, compress (quality + target size modes), strip-metadata, and color adjustments (brightness, contrast, saturation, channels, effects). Updates tool-page to render the appropriate settings panel based on toolId.
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
|
||||
type Tab = "basic" | "channels" | "effects";
|
||||
type Effect = "none" | "grayscale" | "sepia" | "invert";
|
||||
|
||||
interface ColorSettingsProps {
|
||||
/** The specific tool ID to use for processing */
|
||||
toolId: string;
|
||||
}
|
||||
|
||||
export function ColorSettings({ toolId }: ColorSettingsProps) {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize } =
|
||||
useToolProcessor(toolId);
|
||||
|
||||
const [tab, setTab] = useState<Tab>(() => {
|
||||
if (toolId === "color-channels") return "channels";
|
||||
if (toolId === "color-effects") return "effects";
|
||||
return "basic";
|
||||
});
|
||||
|
||||
// Basic adjustments
|
||||
const [brightness, setBrightness] = useState(0);
|
||||
const [contrast, setContrast] = useState(0);
|
||||
const [saturation, setSaturation] = useState(0);
|
||||
|
||||
// Color channels
|
||||
const [red, setRed] = useState(100);
|
||||
const [green, setGreen] = useState(100);
|
||||
const [blue, setBlue] = useState(100);
|
||||
|
||||
// Effects
|
||||
const [effect, setEffect] = useState<Effect>("none");
|
||||
|
||||
const handleProcess = () => {
|
||||
processFiles(files, {
|
||||
brightness,
|
||||
contrast,
|
||||
saturation,
|
||||
red,
|
||||
green,
|
||||
blue,
|
||||
effect,
|
||||
});
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasChanges =
|
||||
brightness !== 0 ||
|
||||
contrast !== 0 ||
|
||||
saturation !== 0 ||
|
||||
red !== 100 ||
|
||||
green !== 100 ||
|
||||
blue !== 100 ||
|
||||
effect !== "none";
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: "basic", label: "Basic" },
|
||||
{ id: "channels", label: "Channels" },
|
||||
{ id: "effects", label: "Effects" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${
|
||||
tab === t.id
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Basic Adjustments */}
|
||||
{tab === "basic" && (
|
||||
<div className="space-y-3">
|
||||
<SliderControl
|
||||
label="Brightness"
|
||||
value={brightness}
|
||||
onChange={setBrightness}
|
||||
min={-100}
|
||||
max={100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Contrast"
|
||||
value={contrast}
|
||||
onChange={setContrast}
|
||||
min={-100}
|
||||
max={100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Saturation"
|
||||
value={saturation}
|
||||
onChange={setSaturation}
|
||||
min={-100}
|
||||
max={100}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Color Channels */}
|
||||
{tab === "channels" && (
|
||||
<div className="space-y-3">
|
||||
<SliderControl
|
||||
label="Red"
|
||||
value={red}
|
||||
onChange={setRed}
|
||||
min={0}
|
||||
max={200}
|
||||
color="text-red-500"
|
||||
/>
|
||||
<SliderControl
|
||||
label="Green"
|
||||
value={green}
|
||||
onChange={setGreen}
|
||||
min={0}
|
||||
max={200}
|
||||
color="text-green-500"
|
||||
/>
|
||||
<SliderControl
|
||||
label="Blue"
|
||||
value={blue}
|
||||
onChange={setBlue}
|
||||
min={0}
|
||||
max={200}
|
||||
color="text-blue-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Effects */}
|
||||
{tab === "effects" && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-muted-foreground">Color Effect</label>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{(["none", "grayscale", "sepia", "invert"] as const).map((e) => (
|
||||
<button
|
||||
key={e}
|
||||
onClick={() => setEffect(e)}
|
||||
className={`text-xs py-2 rounded capitalize transition-colors ${
|
||||
effect === e
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-primary/10"
|
||||
}`}
|
||||
>
|
||||
{e}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reset button */}
|
||||
{hasChanges && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setBrightness(0);
|
||||
setContrast(0);
|
||||
setSaturation(0);
|
||||
setRed(100);
|
||||
setGreen(100);
|
||||
setBlue(100);
|
||||
setEffect("none");
|
||||
}}
|
||||
className="w-full text-xs py-1.5 rounded border border-border text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Reset All
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{/* Size info */}
|
||||
{originalSize != null && processedSize != null && (
|
||||
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
|
||||
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Process */}
|
||||
<button
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || !hasChanges || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{processing ? "Processing..." : "Apply Adjustments"}
|
||||
</button>
|
||||
|
||||
{/* Download */}
|
||||
{downloadUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Reusable slider control */
|
||||
function SliderControl({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
max,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (v: number) => void;
|
||||
min: number;
|
||||
max: number;
|
||||
color?: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className={`text-xs ${color || "text-muted-foreground"}`}>{label}</label>
|
||||
<span className="text-xs font-mono text-foreground">{value}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="w-full mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
|
||||
type CompressMode = "quality" | "targetSize";
|
||||
|
||||
export function CompressSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize } =
|
||||
useToolProcessor("compress");
|
||||
|
||||
const [mode, setMode] = useState<CompressMode>("quality");
|
||||
const [quality, setQuality] = useState(75);
|
||||
const [targetSizeKb, setTargetSizeKb] = useState("");
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings: Record<string, unknown> = { mode };
|
||||
if (mode === "quality") {
|
||||
settings.quality = quality;
|
||||
} else {
|
||||
settings.targetSizeKb = Number(targetSizeKb);
|
||||
}
|
||||
processFiles(files, settings);
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const canProcess =
|
||||
mode === "quality" || (mode === "targetSize" && Number(targetSizeKb) > 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Mode toggle */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Compression Mode</label>
|
||||
<div className="flex gap-1 mt-1">
|
||||
<button
|
||||
onClick={() => setMode("quality")}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${mode === "quality" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
Quality
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode("targetSize")}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${mode === "targetSize" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
Target Size
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === "quality" ? (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Quality</label>
|
||||
<span className="text-xs font-mono text-foreground">{quality}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={100}
|
||||
value={quality}
|
||||
onChange={(e) => setQuality(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground mt-0.5">
|
||||
<span>Smallest file</span>
|
||||
<span>Best quality</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Target Size (KB)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={targetSizeKb}
|
||||
onChange={(e) => setTargetSizeKb(e.target.value)}
|
||||
min={1}
|
||||
placeholder="e.g. 200"
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{/* Size info */}
|
||||
{originalSize != null && processedSize != null && (
|
||||
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
|
||||
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
|
||||
<p className="font-medium text-foreground">
|
||||
Saved:{" "}
|
||||
{originalSize > 0
|
||||
? ((1 - processedSize / originalSize) * 100).toFixed(1)
|
||||
: "0"}
|
||||
%
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Process */}
|
||||
<button
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || !canProcess || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{processing ? "Compressing..." : "Compress"}
|
||||
</button>
|
||||
|
||||
{/* Download */}
|
||||
{downloadUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
|
||||
const OUTPUT_FORMATS = ["jpg", "png", "webp", "avif", "tiff", "gif"] as const;
|
||||
const LOSSY_FORMATS = new Set(["jpg", "webp", "avif"]);
|
||||
|
||||
export function ConvertSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize } =
|
||||
useToolProcessor("convert");
|
||||
|
||||
const [format, setFormat] = useState<string>("png");
|
||||
const [quality, setQuality] = useState(85);
|
||||
|
||||
// Detect source format from filename
|
||||
const sourceFile = files[0];
|
||||
const sourceExt = sourceFile
|
||||
? sourceFile.name.split(".").pop()?.toLowerCase() || "unknown"
|
||||
: "none";
|
||||
|
||||
const isLossy = LOSSY_FORMATS.has(format);
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings: Record<string, unknown> = { format };
|
||||
if (isLossy) {
|
||||
settings.quality = quality;
|
||||
}
|
||||
processFiles(files, settings);
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Source format */}
|
||||
{hasFile && (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Source Format</label>
|
||||
<div className="mt-0.5 px-2 py-1.5 rounded bg-muted text-sm text-foreground uppercase font-mono">
|
||||
{sourceExt}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Target format */}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Target Format</label>
|
||||
<select
|
||||
value={format}
|
||||
onChange={(e) => setFormat(e.target.value)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
{OUTPUT_FORMATS.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{f.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Quality slider (lossy only) */}
|
||||
{isLossy && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Quality</label>
|
||||
<span className="text-xs font-mono text-foreground">{quality}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={100}
|
||||
value={quality}
|
||||
onChange={(e) => setQuality(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{/* Size info */}
|
||||
{originalSize != null && processedSize != null && (
|
||||
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
|
||||
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
|
||||
<p>
|
||||
Savings:{" "}
|
||||
{originalSize > 0
|
||||
? ((1 - processedSize / originalSize) * 100).toFixed(1)
|
||||
: "0"}
|
||||
%
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Process */}
|
||||
<button
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{processing ? "Converting..." : `Convert to ${format.toUpperCase()}`}
|
||||
</button>
|
||||
|
||||
{/* Download */}
|
||||
{downloadUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
|
||||
const ASPECT_PRESETS = [
|
||||
{ label: "1:1", w: 1, h: 1 },
|
||||
{ label: "4:3", w: 4, h: 3 },
|
||||
{ label: "16:9", w: 16, h: 9 },
|
||||
{ label: "2:3", w: 2, h: 3 },
|
||||
{ label: "4:5", w: 4, h: 5 },
|
||||
];
|
||||
|
||||
export function CropSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize } =
|
||||
useToolProcessor("crop");
|
||||
|
||||
const [left, setLeft] = useState("0");
|
||||
const [top, setTop] = useState("0");
|
||||
const [width, setWidth] = useState("");
|
||||
const [height, setHeight] = useState("");
|
||||
|
||||
const applyAspect = (w: number, h: number) => {
|
||||
// If width is set, calculate height from aspect ratio
|
||||
const currentW = Number(width);
|
||||
if (currentW > 0) {
|
||||
setHeight(String(Math.round((currentW * h) / w)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleProcess = () => {
|
||||
processFiles(files, {
|
||||
left: Number(left),
|
||||
top: Number(top),
|
||||
width: Number(width),
|
||||
height: Number(height),
|
||||
});
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasSize = Number(width) > 0 && Number(height) > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Position */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Left (px)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={left}
|
||||
onChange={(e) => setLeft(e.target.value)}
|
||||
min={0}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Top (px)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={top}
|
||||
onChange={(e) => setTop(e.target.value)}
|
||||
min={0}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Size */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Width (px)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={width}
|
||||
onChange={(e) => setWidth(e.target.value)}
|
||||
min={1}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Height (px)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
min={1}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Aspect ratio presets */}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Aspect Ratio</label>
|
||||
<div className="flex gap-1 mt-1">
|
||||
{ASPECT_PRESETS.map(({ label, w, h }) => (
|
||||
<button
|
||||
key={label}
|
||||
onClick={() => applyAspect(w, h)}
|
||||
className="flex-1 text-xs py-1.5 rounded bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{/* Size info */}
|
||||
{originalSize != null && processedSize != null && (
|
||||
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
|
||||
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Process */}
|
||||
<button
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || !hasSize || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{processing ? "Processing..." : "Crop"}
|
||||
</button>
|
||||
|
||||
{/* Download */}
|
||||
{downloadUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useState } from "react";
|
||||
import { SOCIAL_MEDIA_PRESETS } from "@stirling-image/shared";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { Download, Link, Unlink, Loader2 } from "lucide-react";
|
||||
|
||||
type FitMode = "contain" | "cover" | "fill" | "inside" | "outside";
|
||||
|
||||
export function ResizeSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize } =
|
||||
useToolProcessor("resize");
|
||||
|
||||
const [mode, setMode] = useState<"pixels" | "percentage">("pixels");
|
||||
const [width, setWidth] = useState<string>("");
|
||||
const [height, setHeight] = useState<string>("");
|
||||
const [percentage, setPercentage] = useState<string>("100");
|
||||
const [fit, setFit] = useState<FitMode>("contain");
|
||||
const [lockAspect, setLockAspect] = useState(true);
|
||||
const [withoutEnlargement, setWithoutEnlargement] = useState(false);
|
||||
|
||||
const handlePreset = (w: number, h: number) => {
|
||||
setMode("pixels");
|
||||
setWidth(String(w));
|
||||
setHeight(String(h));
|
||||
};
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings: Record<string, unknown> = { fit, withoutEnlargement };
|
||||
if (mode === "percentage") {
|
||||
settings.percentage = Number(percentage);
|
||||
} else {
|
||||
if (width) settings.width = Number(width);
|
||||
if (height) settings.height = Number(height);
|
||||
}
|
||||
processFiles(files, settings);
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
|
||||
// Group presets by platform
|
||||
const platforms = [...new Set(SOCIAL_MEDIA_PRESETS.map((p) => p.platform))];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Mode toggle */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-muted-foreground">Resize Mode</label>
|
||||
<div className="flex gap-1 mt-1">
|
||||
<button
|
||||
onClick={() => setMode("pixels")}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${mode === "pixels" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
Pixels
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode("percentage")}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${mode === "percentage" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
Percentage
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === "pixels" ? (
|
||||
<>
|
||||
{/* Width / Height */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Width (px)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={width}
|
||||
onChange={(e) => setWidth(e.target.value)}
|
||||
placeholder="Auto"
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setLockAspect(!lockAspect)}
|
||||
className="p-1.5 rounded border border-border text-muted-foreground hover:text-foreground"
|
||||
title={lockAspect ? "Unlock aspect ratio" : "Lock aspect ratio"}
|
||||
>
|
||||
{lockAspect ? <Link className="h-4 w-4" /> : <Unlink className="h-4 w-4" />}
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-muted-foreground">Height (px)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
placeholder="Auto"
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fit mode */}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Fit Mode</label>
|
||||
<select
|
||||
value={fit}
|
||||
onChange={(e) => setFit(e.target.value as FitMode)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
<option value="contain">Contain</option>
|
||||
<option value="cover">Cover</option>
|
||||
<option value="fill">Fill (stretch)</option>
|
||||
<option value="inside">Inside</option>
|
||||
<option value="outside">Outside</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Social media presets */}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Social Media Presets</label>
|
||||
<select
|
||||
onChange={(e) => {
|
||||
const preset = SOCIAL_MEDIA_PRESETS.find(
|
||||
(p) => `${p.platform} - ${p.name}` === e.target.value,
|
||||
);
|
||||
if (preset) handlePreset(preset.width, preset.height);
|
||||
}}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
defaultValue=""
|
||||
>
|
||||
<option value="" disabled>
|
||||
Choose a preset...
|
||||
</option>
|
||||
{platforms.map((platform) => (
|
||||
<optgroup key={platform} label={platform}>
|
||||
{SOCIAL_MEDIA_PRESETS.filter((p) => p.platform === platform).map((p) => (
|
||||
<option key={`${p.platform}-${p.name}`} value={`${p.platform} - ${p.name}`}>
|
||||
{p.name} ({p.width}x{p.height})
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Scale (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={percentage}
|
||||
onChange={(e) => setPercentage(e.target.value)}
|
||||
min={1}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Don't enlarge */}
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={withoutEnlargement}
|
||||
onChange={(e) => setWithoutEnlargement(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Don't enlarge
|
||||
</label>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<p className="text-xs text-red-500">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Size info */}
|
||||
{originalSize != null && processedSize != null && (
|
||||
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
|
||||
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Process button */}
|
||||
<button
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{processing ? "Processing..." : "Resize"}
|
||||
</button>
|
||||
|
||||
{/* Download */}
|
||||
{downloadUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import {
|
||||
Download,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
RotateCw,
|
||||
FlipHorizontal,
|
||||
FlipVertical,
|
||||
} from "lucide-react";
|
||||
|
||||
export function RotateSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize } =
|
||||
useToolProcessor("rotate");
|
||||
|
||||
const [angle, setAngle] = useState(0);
|
||||
const [flipH, setFlipH] = useState(false);
|
||||
const [flipV, setFlipV] = useState(false);
|
||||
|
||||
const rotateLeft = () => setAngle((a) => (a - 90 + 360) % 360);
|
||||
const rotateRight = () => setAngle((a) => (a + 90) % 360);
|
||||
|
||||
const handleProcess = () => {
|
||||
processFiles(files, {
|
||||
angle,
|
||||
horizontal: flipH,
|
||||
vertical: flipV,
|
||||
});
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasChanges = angle !== 0 || flipH || flipV;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Quick rotate buttons */}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Quick Rotate</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<button
|
||||
onClick={rotateLeft}
|
||||
className="flex-1 flex items-center justify-center gap-1 py-2 rounded bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-sm"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
90 Left
|
||||
</button>
|
||||
<button
|
||||
onClick={rotateRight}
|
||||
className="flex-1 flex items-center justify-center gap-1 py-2 rounded bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-sm"
|
||||
>
|
||||
<RotateCw className="h-4 w-4" />
|
||||
90 Right
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Angle slider */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<label className="text-xs text-muted-foreground">Angle</label>
|
||||
<span className="text-xs font-mono text-foreground">{angle} deg</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={360}
|
||||
value={angle}
|
||||
onChange={(e) => setAngle(Number(e.target.value))}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Flip buttons */}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">Flip</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<button
|
||||
onClick={() => setFlipH(!flipH)}
|
||||
className={`flex-1 flex items-center justify-center gap-1 py-2 rounded text-sm transition-colors ${
|
||||
flipH
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-primary/10"
|
||||
}`}
|
||||
>
|
||||
<FlipHorizontal className="h-4 w-4" />
|
||||
Horizontal
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFlipV(!flipV)}
|
||||
className={`flex-1 flex items-center justify-center gap-1 py-2 rounded text-sm transition-colors ${
|
||||
flipV
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-primary/10"
|
||||
}`}
|
||||
>
|
||||
<FlipVertical className="h-4 w-4" />
|
||||
Vertical
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{/* Size info */}
|
||||
{originalSize != null && processedSize != null && (
|
||||
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
|
||||
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Process */}
|
||||
<button
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || !hasChanges || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{processing ? "Processing..." : "Rotate / Flip"}
|
||||
</button>
|
||||
|
||||
{/* Download */}
|
||||
{downloadUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
|
||||
export function StripMetadataSettings() {
|
||||
const { files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize } =
|
||||
useToolProcessor("strip-metadata");
|
||||
|
||||
const [stripAll, setStripAll] = useState(true);
|
||||
const [stripExif, setStripExif] = useState(false);
|
||||
const [stripGps, setStripGps] = useState(false);
|
||||
const [stripIcc, setStripIcc] = useState(false);
|
||||
const [stripXmp, setStripXmp] = useState(false);
|
||||
|
||||
const handleStripAllChange = (checked: boolean) => {
|
||||
setStripAll(checked);
|
||||
if (checked) {
|
||||
setStripExif(false);
|
||||
setStripGps(false);
|
||||
setStripIcc(false);
|
||||
setStripXmp(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProcess = () => {
|
||||
processFiles(files, { stripAll, stripExif, stripGps, stripIcc, stripXmp });
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Strip All */}
|
||||
<label className="flex items-center gap-2 text-sm text-foreground font-medium">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stripAll}
|
||||
onChange={(e) => handleStripAllChange(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Strip All Metadata
|
||||
</label>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Individual options */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-muted-foreground">Or select specific metadata:</label>
|
||||
|
||||
<label className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stripExif}
|
||||
onChange={(e) => setStripExif(e.target.checked)}
|
||||
disabled={stripAll}
|
||||
className="rounded"
|
||||
/>
|
||||
Strip EXIF (camera info, date, exposure)
|
||||
</label>
|
||||
|
||||
<label className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stripGps}
|
||||
onChange={(e) => setStripGps(e.target.checked)}
|
||||
disabled={stripAll}
|
||||
className="rounded"
|
||||
/>
|
||||
Strip GPS (location data)
|
||||
</label>
|
||||
|
||||
<label className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stripIcc}
|
||||
onChange={(e) => setStripIcc(e.target.checked)}
|
||||
disabled={stripAll}
|
||||
className="rounded"
|
||||
/>
|
||||
Strip ICC (color profile)
|
||||
</label>
|
||||
|
||||
<label className={`flex items-center gap-2 text-sm ${stripAll ? "text-muted-foreground" : "text-foreground"}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stripXmp}
|
||||
onChange={(e) => setStripXmp(e.target.checked)}
|
||||
disabled={stripAll}
|
||||
className="rounded"
|
||||
/>
|
||||
Strip XMP (extensible metadata)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{/* Size info */}
|
||||
{originalSize != null && processedSize != null && (
|
||||
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
|
||||
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
|
||||
<p>Metadata removed: {((originalSize - processedSize) / 1024).toFixed(1)} KB</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Process */}
|
||||
<button
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{processing ? "Processing..." : "Strip Metadata"}
|
||||
</button>
|
||||
|
||||
{/* Download */}
|
||||
{downloadUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useCallback } from "react";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
function getToken(): string {
|
||||
return localStorage.getItem("stirling-token") || "";
|
||||
}
|
||||
|
||||
interface ProcessResult {
|
||||
jobId: string;
|
||||
downloadUrl: string;
|
||||
originalSize: number;
|
||||
processedSize: number;
|
||||
}
|
||||
|
||||
export function useToolProcessor(toolId: string) {
|
||||
const {
|
||||
processing,
|
||||
error,
|
||||
processedUrl,
|
||||
originalSize,
|
||||
processedSize,
|
||||
setProcessing,
|
||||
setError,
|
||||
setProcessedUrl,
|
||||
setSizes,
|
||||
setJobId,
|
||||
} = useFileStore();
|
||||
|
||||
const processFiles = useCallback(
|
||||
async (files: File[], settings: Record<string, unknown>) => {
|
||||
if (files.length === 0) {
|
||||
setError("No files selected");
|
||||
return;
|
||||
}
|
||||
|
||||
setProcessing(true);
|
||||
setError(null);
|
||||
setProcessedUrl(null);
|
||||
|
||||
try {
|
||||
// Build multipart form with the file and settings
|
||||
const formData = new FormData();
|
||||
formData.append("file", files[0]);
|
||||
formData.append("settings", JSON.stringify(settings));
|
||||
|
||||
const res = await fetch(`/api/v1/tools/${toolId}`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${getToken()}` },
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
body.error || body.details || `Processing failed: ${res.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const result: ProcessResult = await res.json();
|
||||
|
||||
setJobId(result.jobId);
|
||||
setProcessedUrl(result.downloadUrl);
|
||||
setSizes(result.originalSize, result.processedSize);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Processing failed");
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
},
|
||||
[toolId, setProcessing, setError, setProcessedUrl, setSizes, setJobId],
|
||||
);
|
||||
|
||||
return {
|
||||
processFiles,
|
||||
processing,
|
||||
error,
|
||||
downloadUrl: processedUrl,
|
||||
originalSize,
|
||||
processedSize,
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,53 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useCallback } from "react";
|
||||
import { TOOLS } from "@stirling-image/shared";
|
||||
import { AppLayout } from "@/components/layout/app-layout";
|
||||
import { Dropzone } from "@/components/common/dropzone";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import { ResizeSettings } from "@/components/tools/resize-settings";
|
||||
import { CropSettings } from "@/components/tools/crop-settings";
|
||||
import { RotateSettings } from "@/components/tools/rotate-settings";
|
||||
import { ConvertSettings } from "@/components/tools/convert-settings";
|
||||
import { CompressSettings } from "@/components/tools/compress-settings";
|
||||
import { StripMetadataSettings } from "@/components/tools/strip-metadata-settings";
|
||||
import { ColorSettings } from "@/components/tools/color-settings";
|
||||
import * as icons from "lucide-react";
|
||||
|
||||
const COLOR_TOOL_IDS = new Set([
|
||||
"brightness-contrast",
|
||||
"saturation",
|
||||
"color-channels",
|
||||
"color-effects",
|
||||
]);
|
||||
|
||||
function ToolSettingsPanel({ toolId }: { toolId: string }) {
|
||||
if (toolId === "resize") return <ResizeSettings />;
|
||||
if (toolId === "crop") return <CropSettings />;
|
||||
if (toolId === "rotate") return <RotateSettings />;
|
||||
if (toolId === "convert") return <ConvertSettings />;
|
||||
if (toolId === "compress") return <CompressSettings />;
|
||||
if (toolId === "strip-metadata") return <StripMetadataSettings />;
|
||||
if (COLOR_TOOL_IDS.has(toolId)) return <ColorSettings toolId={toolId} />;
|
||||
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Settings for this tool are coming soon.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolPage() {
|
||||
const { toolId } = useParams<{ toolId: string }>();
|
||||
const tool = useMemo(() => TOOLS.find((t) => t.id === toolId), [toolId]);
|
||||
const { files, setFiles, reset } = useFileStore();
|
||||
|
||||
const handleFiles = useCallback(
|
||||
(newFiles: File[]) => {
|
||||
reset();
|
||||
setFiles(newFiles);
|
||||
},
|
||||
[setFiles, reset],
|
||||
);
|
||||
|
||||
if (!tool) {
|
||||
return (
|
||||
@@ -19,7 +59,15 @@ export function ToolPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const IconComponent = (icons as unknown as Record<string, React.ComponentType<{ className?: string }>>)[tool.icon] || icons.FileImage;
|
||||
const IconComponent =
|
||||
(
|
||||
icons as unknown as Record<
|
||||
string,
|
||||
React.ComponentType<{ className?: string }>
|
||||
>
|
||||
)[tool.icon] || icons.FileImage;
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
|
||||
return (
|
||||
<AppLayout showToolPanel={false}>
|
||||
@@ -30,37 +78,61 @@ export function ToolPage() {
|
||||
<div className="p-2 rounded-lg bg-primary text-primary-foreground">
|
||||
<IconComponent className="h-5 w-5" />
|
||||
</div>
|
||||
<h2 className="font-semibold text-lg text-foreground">{tool.name}</h2>
|
||||
<h2 className="font-semibold text-lg text-foreground">
|
||||
{tool.name}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* File info */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Files</h3>
|
||||
<button className="flex items-center gap-2 text-sm text-primary hover:underline">
|
||||
<icons.Upload className="h-4 w-4" />
|
||||
Upload
|
||||
</button>
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
Files
|
||||
</h3>
|
||||
{hasFile ? (
|
||||
<div className="space-y-1">
|
||||
{files.map((f, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between text-xs text-foreground bg-muted rounded px-2 py-1"
|
||||
>
|
||||
<span className="truncate">{f.name}</span>
|
||||
<span className="text-muted-foreground shrink-0 ml-2">
|
||||
{(f.size / 1024).toFixed(0)} KB
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => reset()}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Drop or upload an image to get started
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Tool-specific settings */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Settings</h3>
|
||||
<p className="text-xs text-muted-foreground italic">{tool.description}</p>
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
Settings
|
||||
</h3>
|
||||
<ToolSettingsPanel toolId={tool.id} />
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
<button
|
||||
disabled
|
||||
className="w-full py-2.5 rounded-lg bg-muted text-muted-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{tool.name}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Dropzone */}
|
||||
{/* Dropzone / Preview */}
|
||||
<div className="flex-1 flex items-center justify-center p-6">
|
||||
<Dropzone />
|
||||
<Dropzone
|
||||
onFiles={handleFiles}
|
||||
accept="image/*"
|
||||
multiple={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
interface FileState {
|
||||
files: File[];
|
||||
jobId: string | null;
|
||||
processedUrl: string | null;
|
||||
processing: boolean;
|
||||
error: string | null;
|
||||
originalSize: number | null;
|
||||
processedSize: number | null;
|
||||
setFiles: (files: File[]) => void;
|
||||
setJobId: (id: string) => void;
|
||||
setProcessedUrl: (url: string | null) => void;
|
||||
setProcessing: (v: boolean) => void;
|
||||
setError: (e: string | null) => void;
|
||||
setSizes: (original: number, processed: number) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useFileStore = create<FileState>((set) => ({
|
||||
files: [],
|
||||
jobId: null,
|
||||
processedUrl: null,
|
||||
processing: false,
|
||||
error: null,
|
||||
originalSize: null,
|
||||
processedSize: null,
|
||||
setFiles: (files) => set({ files, error: null }),
|
||||
setJobId: (id) => set({ jobId: id }),
|
||||
setProcessedUrl: (url) => set({ processedUrl: url }),
|
||||
setProcessing: (v) => set({ processing: v }),
|
||||
setError: (e) => set({ error: e, processing: false }),
|
||||
setSizes: (original, processed) =>
|
||||
set({ originalSize: original, processedSize: processed }),
|
||||
reset: () =>
|
||||
set({
|
||||
files: [],
|
||||
jobId: null,
|
||||
processedUrl: null,
|
||||
processing: false,
|
||||
error: null,
|
||||
originalSize: null,
|
||||
processedSize: null,
|
||||
}),
|
||||
}));
|
||||
Reference in New Issue
Block a user