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 { useParams } from "react-router-dom";
|
||||||
import { useMemo } from "react";
|
import { useMemo, useCallback } from "react";
|
||||||
import { TOOLS } from "@stirling-image/shared";
|
import { TOOLS } from "@stirling-image/shared";
|
||||||
import { AppLayout } from "@/components/layout/app-layout";
|
import { AppLayout } from "@/components/layout/app-layout";
|
||||||
import { Dropzone } from "@/components/common/dropzone";
|
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";
|
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() {
|
export function ToolPage() {
|
||||||
const { toolId } = useParams<{ toolId: string }>();
|
const { toolId } = useParams<{ toolId: string }>();
|
||||||
const tool = useMemo(() => TOOLS.find((t) => t.id === toolId), [toolId]);
|
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) {
|
if (!tool) {
|
||||||
return (
|
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 (
|
return (
|
||||||
<AppLayout showToolPanel={false}>
|
<AppLayout showToolPanel={false}>
|
||||||
@@ -30,37 +78,61 @@ export function ToolPage() {
|
|||||||
<div className="p-2 rounded-lg bg-primary text-primary-foreground">
|
<div className="p-2 rounded-lg bg-primary text-primary-foreground">
|
||||||
<IconComponent className="h-5 w-5" />
|
<IconComponent className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="font-semibold text-lg text-foreground">{tool.name}</h2>
|
<h2 className="font-semibold text-lg text-foreground">
|
||||||
|
{tool.name}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* File info */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">Files</h3>
|
<h3 className="text-sm font-medium text-muted-foreground">
|
||||||
<button className="flex items-center gap-2 text-sm text-primary hover:underline">
|
Files
|
||||||
<icons.Upload className="h-4 w-4" />
|
</h3>
|
||||||
Upload
|
{hasFile ? (
|
||||||
</button>
|
<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>
|
||||||
|
|
||||||
<div className="border-t border-border" />
|
<div className="border-t border-border" />
|
||||||
|
|
||||||
|
{/* Tool-specific settings */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">Settings</h3>
|
<h3 className="text-sm font-medium text-muted-foreground">
|
||||||
<p className="text-xs text-muted-foreground italic">{tool.description}</p>
|
Settings
|
||||||
|
</h3>
|
||||||
|
<ToolSettingsPanel toolId={tool.id} />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Dropzone */}
|
{/* Dropzone / Preview */}
|
||||||
<div className="flex-1 flex items-center justify-center p-6">
|
<div className="flex-1 flex items-center justify-center p-6">
|
||||||
<Dropzone />
|
<Dropzone
|
||||||
|
onFiles={handleFiles}
|
||||||
|
accept="image/*"
|
||||||
|
multiple={false}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</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