mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
API: - Replace string concatenation with template literals (batch, pipeline, tool-factory, content-aware-resize, passport-photo, remove-background) - Remove unused imports (teams, color-adjustments, image-enhancement) - Replace non-null assertions with guard clauses in bg-effects and stitch - Use optional chaining in docs route Image-engine: - Remove unused OutputFormat imports (compress, convert) - Use local variables instead of reassigning parameters (optimize-for-web, sharpen) Web: - Fix useExhaustiveDependencies: remove genuinely redundant deps, add biome-ignore comments for intentional patterns (src prop, cleanup fns) - Replace non-null assertions with null-safe alternatives - Add accessible titles to inline SVGs (color-settings, image-enhancement-settings) - Fix noLabelWithoutControl: associate labels via htmlFor/id or use <span> (image-to-base64-settings, edit-metadata-settings, qr-generate-settings) - Replace div[role="button"] with <button> (pdf-to-image-settings) - Use stable keys instead of array indices (collage-preview, collage-settings) - Fix noStaticElementInteractions in collage-preview (role="none") - Remove unused function parameters and imports
724 lines
24 KiB
TypeScript
724 lines
24 KiB
TypeScript
import { Download, FlipHorizontal2, FlipVertical2, Link, RotateCw, Unlink } from "lucide-react";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { ProgressCard } from "@/components/common/progress-card";
|
|
import { useGifInfo } from "@/hooks/use-gif-info";
|
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
|
import { useFileStore } from "@/stores/file-store";
|
|
|
|
type GifMode = "resize" | "optimize" | "speed" | "reverse" | "extract" | "rotate";
|
|
type LoopMode = "infinite" | "once" | "custom";
|
|
type ResizeTab = "pixel" | "percentage";
|
|
type ExtractTab = "single" | "range" | "all";
|
|
|
|
const MODES: { id: GifMode; label: string; requiresAnimation: boolean }[] = [
|
|
{ id: "resize", label: "Resize", requiresAnimation: false },
|
|
{ id: "optimize", label: "Optimize", requiresAnimation: false },
|
|
{ id: "speed", label: "Speed", requiresAnimation: true },
|
|
{ id: "reverse", label: "Reverse", requiresAnimation: true },
|
|
{ id: "extract", label: "Extract", requiresAnimation: true },
|
|
{ id: "rotate", label: "Rotate", requiresAnimation: false },
|
|
];
|
|
|
|
export interface GifToolsControlsProps {
|
|
settings?: Record<string, unknown>;
|
|
onChange?: (settings: Record<string, unknown>) => void;
|
|
}
|
|
|
|
export function GifToolsControls({ settings: initialSettings, onChange }: GifToolsControlsProps) {
|
|
const { info, loading: infoLoading } = useGifInfo();
|
|
const isAnimated = (info?.pages ?? 0) > 1;
|
|
|
|
// Mode
|
|
const [mode, setMode] = useState<GifMode>("resize");
|
|
|
|
// Resize state
|
|
const [resizeTab, setResizeTab] = useState<ResizeTab>("pixel");
|
|
const [width, setWidth] = useState("");
|
|
const [height, setHeight] = useState("");
|
|
const [percentage, setPercentage] = useState("50");
|
|
const [lockAspect, setLockAspect] = useState(true);
|
|
|
|
// Optimize state
|
|
const [colors, setColors] = useState(256);
|
|
const [dither, setDither] = useState(1.0);
|
|
const [effort, setEffort] = useState(7);
|
|
|
|
// Speed state
|
|
const [speedFactor, setSpeedFactor] = useState(1.0);
|
|
|
|
// Reverse state
|
|
const [reverseAdjustSpeed, setReverseAdjustSpeed] = useState(false);
|
|
const [reverseSpeed, setReverseSpeed] = useState(1.0);
|
|
|
|
// Extract state
|
|
const [extractTab, setExtractTab] = useState<ExtractTab>("single");
|
|
const [frameNumber, setFrameNumber] = useState("0");
|
|
const [frameStart, setFrameStart] = useState("0");
|
|
const [frameEnd, setFrameEnd] = useState("");
|
|
const [extractFormat, setExtractFormat] = useState<"png" | "webp">("png");
|
|
|
|
// Rotate state
|
|
const [angle, setAngle] = useState<number | null>(null);
|
|
const [flipH, setFlipH] = useState(false);
|
|
const [flipV, setFlipV] = useState(false);
|
|
|
|
// Loop control
|
|
const [loopMode, setLoopMode] = useState<LoopMode>("infinite");
|
|
const [loopCount, setLoopCount] = useState("2");
|
|
|
|
// Initialize from saved pipeline settings
|
|
const initializedRef = useRef(false);
|
|
useEffect(() => {
|
|
if (!initialSettings || initializedRef.current) return;
|
|
initializedRef.current = true;
|
|
if (initialSettings.mode != null) setMode(initialSettings.mode as GifMode);
|
|
if (initialSettings.width != null) setWidth(String(initialSettings.width));
|
|
if (initialSettings.height != null) setHeight(String(initialSettings.height));
|
|
if (initialSettings.percentage != null) setPercentage(String(initialSettings.percentage));
|
|
}, [initialSettings]);
|
|
|
|
// Initialize loop from metadata
|
|
useEffect(() => {
|
|
if (info) {
|
|
if (info.loop === 0) setLoopMode("infinite");
|
|
else if (info.loop === 1) setLoopMode("once");
|
|
else {
|
|
setLoopMode("custom");
|
|
setLoopCount(String(info.loop));
|
|
}
|
|
}
|
|
}, [info]);
|
|
|
|
// Emit settings
|
|
const onChangeRef = useRef(onChange);
|
|
useEffect(() => {
|
|
onChangeRef.current = onChange;
|
|
});
|
|
|
|
useEffect(() => {
|
|
const loopValue =
|
|
loopMode === "infinite" ? 0 : loopMode === "once" ? 1 : Number(loopCount) || 2;
|
|
|
|
const settings: Record<string, unknown> = { mode, loop: loopValue };
|
|
|
|
switch (mode) {
|
|
case "resize":
|
|
if (resizeTab === "percentage") {
|
|
settings.percentage = Number(percentage) || 50;
|
|
} else {
|
|
if (width) settings.width = Number(width);
|
|
if (height) settings.height = Number(height);
|
|
}
|
|
break;
|
|
case "optimize":
|
|
settings.colors = colors;
|
|
settings.dither = dither;
|
|
settings.effort = effort;
|
|
break;
|
|
case "speed":
|
|
settings.speedFactor = speedFactor;
|
|
break;
|
|
case "reverse":
|
|
if (reverseAdjustSpeed) {
|
|
settings.speedFactor = reverseSpeed;
|
|
}
|
|
break;
|
|
case "extract":
|
|
settings.extractMode = extractTab;
|
|
settings.extractFormat = extractFormat;
|
|
if (extractTab === "single") {
|
|
settings.frameNumber = Number(frameNumber) || 0;
|
|
} else if (extractTab === "range") {
|
|
settings.frameStart = Number(frameStart) || 0;
|
|
if (frameEnd) settings.frameEnd = Number(frameEnd);
|
|
}
|
|
break;
|
|
case "rotate":
|
|
if (angle) settings.angle = angle;
|
|
settings.flipH = flipH;
|
|
settings.flipV = flipV;
|
|
break;
|
|
}
|
|
|
|
onChangeRef.current?.(settings);
|
|
}, [
|
|
mode,
|
|
resizeTab,
|
|
width,
|
|
height,
|
|
percentage,
|
|
colors,
|
|
dither,
|
|
effort,
|
|
speedFactor,
|
|
reverseAdjustSpeed,
|
|
reverseSpeed,
|
|
extractTab,
|
|
frameNumber,
|
|
frameStart,
|
|
frameEnd,
|
|
extractFormat,
|
|
angle,
|
|
flipH,
|
|
flipV,
|
|
loopMode,
|
|
loopCount,
|
|
]);
|
|
|
|
const tabClass = (active: boolean) =>
|
|
`flex-1 text-xs py-1.5 rounded ${active ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`;
|
|
|
|
const maxFrame = Math.max(0, (info?.pages ?? 1) - 1);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* GIF Info Bar */}
|
|
{infoLoading && (
|
|
<div className="text-xs text-muted-foreground animate-pulse">Reading GIF metadata...</div>
|
|
)}
|
|
{info && !infoLoading && (
|
|
<div className="flex flex-wrap gap-x-2 gap-y-0.5 text-xs text-muted-foreground bg-muted rounded px-2 py-1.5">
|
|
{isAnimated ? (
|
|
<>
|
|
<span>{info.pages} frames</span>
|
|
<span className="text-border">|</span>
|
|
<span>
|
|
{info.width}x{info.height}
|
|
</span>
|
|
<span className="text-border">|</span>
|
|
<span>{(info.duration / 1000).toFixed(1)}s</span>
|
|
<span className="text-border">|</span>
|
|
<span>{(info.fileSize / 1024).toFixed(0)} KB</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<span className="text-amber-500 font-medium">Static image</span>
|
|
<span className="text-border">|</span>
|
|
<span>
|
|
{info.width}x{info.height}
|
|
</span>
|
|
<span className="text-border">|</span>
|
|
<span>{(info.fileSize / 1024).toFixed(0)} KB</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Mode Tabs (3x2 grid) */}
|
|
<div>
|
|
<p className="text-xs text-muted-foreground mb-1">Mode</p>
|
|
<div className="grid grid-cols-3 gap-1">
|
|
{MODES.map((m) => {
|
|
const disabled = m.requiresAnimation && !isAnimated;
|
|
return (
|
|
<button
|
|
key={m.id}
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={() => setMode(m.id)}
|
|
title={disabled ? "Requires animated GIF" : undefined}
|
|
className={`text-xs py-1.5 rounded transition-colors ${
|
|
mode === m.id
|
|
? "bg-primary text-primary-foreground"
|
|
: disabled
|
|
? "bg-muted text-muted-foreground/40 cursor-not-allowed"
|
|
: "bg-muted text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
{m.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mode Controls */}
|
|
|
|
{mode === "resize" && (
|
|
<div className="space-y-3">
|
|
<div className="flex gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setResizeTab("pixel")}
|
|
className={tabClass(resizeTab === "pixel")}
|
|
>
|
|
Pixels
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setResizeTab("percentage")}
|
|
className={tabClass(resizeTab === "percentage")}
|
|
>
|
|
Percentage
|
|
</button>
|
|
</div>
|
|
|
|
{resizeTab === "pixel" ? (
|
|
<div className="flex items-end gap-2">
|
|
<div className="flex-1">
|
|
<label htmlFor="gif-width" className="text-xs text-muted-foreground">
|
|
Width (px)
|
|
</label>
|
|
<input
|
|
id="gif-width"
|
|
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
|
|
type="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 htmlFor="gif-height" className="text-xs text-muted-foreground">
|
|
Height (px)
|
|
</label>
|
|
<input
|
|
id="gif-height"
|
|
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 className="space-y-2">
|
|
<div>
|
|
<div className="flex items-center justify-between">
|
|
<label htmlFor="gif-pct" className="text-xs text-muted-foreground">
|
|
Scale
|
|
</label>
|
|
<span className="text-xs tabular-nums text-muted-foreground">{percentage}%</span>
|
|
</div>
|
|
<input
|
|
id="gif-pct"
|
|
type="range"
|
|
min={10}
|
|
max={200}
|
|
value={percentage}
|
|
onChange={(e) => setPercentage(e.target.value)}
|
|
className="w-full mt-1 h-1.5 rounded-full appearance-none bg-muted accent-primary"
|
|
/>
|
|
</div>
|
|
<div className="flex gap-1">
|
|
{[25, 50, 75, 200].map((pct) => (
|
|
<button
|
|
key={pct}
|
|
type="button"
|
|
onClick={() => setPercentage(String(pct))}
|
|
className={tabClass(percentage === String(pct))}
|
|
>
|
|
{pct}%
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{mode === "optimize" && (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<div className="flex items-center justify-between">
|
|
<label htmlFor="gif-colors" className="text-xs text-muted-foreground">
|
|
Colors
|
|
</label>
|
|
<span className="text-xs tabular-nums text-muted-foreground">{colors}</span>
|
|
</div>
|
|
<input
|
|
id="gif-colors"
|
|
type="range"
|
|
min={2}
|
|
max={256}
|
|
step={2}
|
|
value={colors}
|
|
onChange={(e) => setColors(Number(e.target.value))}
|
|
className="w-full mt-1 h-1.5 rounded-full appearance-none bg-muted accent-primary"
|
|
/>
|
|
<p className="text-[10px] text-muted-foreground mt-0.5">Fewer colors = smaller file</p>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="flex items-center justify-between">
|
|
<label htmlFor="gif-dither" className="text-xs text-muted-foreground">
|
|
Dither
|
|
</label>
|
|
<span className="text-xs tabular-nums text-muted-foreground">
|
|
{dither.toFixed(1)}
|
|
</span>
|
|
</div>
|
|
<input
|
|
id="gif-dither"
|
|
type="range"
|
|
min={0}
|
|
max={1}
|
|
step={0.1}
|
|
value={dither}
|
|
onChange={(e) => setDither(Number(e.target.value))}
|
|
className="w-full mt-1 h-1.5 rounded-full appearance-none bg-muted accent-primary"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="flex items-center justify-between">
|
|
<label htmlFor="gif-effort" className="text-xs text-muted-foreground">
|
|
Effort
|
|
</label>
|
|
<span className="text-xs tabular-nums text-muted-foreground">{effort}</span>
|
|
</div>
|
|
<input
|
|
id="gif-effort"
|
|
type="range"
|
|
min={1}
|
|
max={10}
|
|
value={effort}
|
|
onChange={(e) => setEffort(Number(e.target.value))}
|
|
className="w-full mt-1 h-1.5 rounded-full appearance-none bg-muted accent-primary"
|
|
/>
|
|
<p className="text-[10px] text-muted-foreground mt-0.5">
|
|
Higher effort = slower but smaller
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{mode === "speed" && (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<div className="flex items-center justify-between">
|
|
<label htmlFor="gif-speed" className="text-xs text-muted-foreground">
|
|
Speed
|
|
</label>
|
|
<span className="text-xs tabular-nums text-muted-foreground">
|
|
{speedFactor.toFixed(1)}x
|
|
</span>
|
|
</div>
|
|
<input
|
|
id="gif-speed"
|
|
type="range"
|
|
min={0.1}
|
|
max={10}
|
|
step={0.1}
|
|
value={speedFactor}
|
|
onChange={(e) => setSpeedFactor(Number(e.target.value))}
|
|
className="w-full mt-1 h-1.5 rounded-full appearance-none bg-muted accent-primary"
|
|
/>
|
|
</div>
|
|
<div className="flex gap-1">
|
|
{[0.5, 1, 2, 5].map((s) => (
|
|
<button
|
|
key={s}
|
|
type="button"
|
|
onClick={() => setSpeedFactor(s)}
|
|
className={tabClass(speedFactor === s)}
|
|
>
|
|
{s}x
|
|
</button>
|
|
))}
|
|
</div>
|
|
{info && (
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{(info.duration / 1000).toFixed(1)}s {"->"}{" "}
|
|
{(info.duration / 1000 / speedFactor).toFixed(1)}s
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{mode === "reverse" && (
|
|
<div className="space-y-3">
|
|
<p className="text-xs text-muted-foreground">
|
|
Reverses the playback order of all frames.
|
|
</p>
|
|
<label className="flex items-center gap-2 text-xs text-foreground">
|
|
<input
|
|
type="checkbox"
|
|
checked={reverseAdjustSpeed}
|
|
onChange={(e) => setReverseAdjustSpeed(e.target.checked)}
|
|
className="rounded"
|
|
/>
|
|
Also adjust speed
|
|
</label>
|
|
{reverseAdjustSpeed && (
|
|
<div>
|
|
<div className="flex items-center justify-between">
|
|
<label htmlFor="gif-rev-speed" className="text-xs text-muted-foreground">
|
|
Speed
|
|
</label>
|
|
<span className="text-xs tabular-nums text-muted-foreground">
|
|
{reverseSpeed.toFixed(1)}x
|
|
</span>
|
|
</div>
|
|
<input
|
|
id="gif-rev-speed"
|
|
type="range"
|
|
min={0.1}
|
|
max={10}
|
|
step={0.1}
|
|
value={reverseSpeed}
|
|
onChange={(e) => setReverseSpeed(Number(e.target.value))}
|
|
className="w-full mt-1 h-1.5 rounded-full appearance-none bg-muted accent-primary"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{mode === "extract" && (
|
|
<div className="space-y-3">
|
|
<div className="flex gap-1">
|
|
{(["single", "range", "all"] as const).map((t) => (
|
|
<button
|
|
key={t}
|
|
type="button"
|
|
onClick={() => setExtractTab(t)}
|
|
className={tabClass(extractTab === t)}
|
|
>
|
|
{t === "single" ? "Single" : t === "range" ? "Range" : "All"}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{extractTab === "single" && (
|
|
<div>
|
|
<label htmlFor="gif-frame" className="text-xs text-muted-foreground">
|
|
Frame Number
|
|
</label>
|
|
<input
|
|
id="gif-frame"
|
|
type="number"
|
|
value={frameNumber}
|
|
onChange={(e) => setFrameNumber(e.target.value)}
|
|
min={0}
|
|
max={maxFrame}
|
|
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
|
/>
|
|
<p className="text-[10px] text-muted-foreground mt-0.5">
|
|
0 to {maxFrame} (0 is first frame)
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{extractTab === "range" && (
|
|
<div className="flex gap-2">
|
|
<div className="flex-1">
|
|
<label htmlFor="gif-frame-start" className="text-xs text-muted-foreground">
|
|
Start
|
|
</label>
|
|
<input
|
|
id="gif-frame-start"
|
|
type="number"
|
|
value={frameStart}
|
|
onChange={(e) => setFrameStart(e.target.value)}
|
|
min={0}
|
|
max={maxFrame}
|
|
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
|
/>
|
|
</div>
|
|
<div className="flex-1">
|
|
<label htmlFor="gif-frame-end" className="text-xs text-muted-foreground">
|
|
End
|
|
</label>
|
|
<input
|
|
id="gif-frame-end"
|
|
type="number"
|
|
value={frameEnd}
|
|
onChange={(e) => setFrameEnd(e.target.value)}
|
|
min={0}
|
|
max={maxFrame}
|
|
placeholder={String(maxFrame)}
|
|
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{extractTab === "all" && (
|
|
<p className="text-xs text-muted-foreground">
|
|
Extracts all {info?.pages ?? "?"} frames as individual images in a ZIP.
|
|
</p>
|
|
)}
|
|
|
|
<div>
|
|
<p className="text-xs text-muted-foreground mb-1">Output Format</p>
|
|
<div className="flex gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setExtractFormat("png")}
|
|
className={tabClass(extractFormat === "png")}
|
|
>
|
|
PNG
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setExtractFormat("webp")}
|
|
className={tabClass(extractFormat === "webp")}
|
|
>
|
|
WebP
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{mode === "rotate" && (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<p className="text-xs text-muted-foreground mb-1">Angle</p>
|
|
<div className="flex gap-1">
|
|
{[90, 180, 270].map((a) => (
|
|
<button
|
|
key={a}
|
|
type="button"
|
|
onClick={() => setAngle(angle === a ? null : a)}
|
|
className={tabClass(angle === a)}
|
|
>
|
|
<span className="flex items-center justify-center gap-1">
|
|
<RotateCw className="h-3 w-3" />
|
|
{a}°
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="text-xs text-muted-foreground mb-1">Flip</p>
|
|
<div className="flex gap-1">
|
|
<button type="button" onClick={() => setFlipH(!flipH)} className={tabClass(flipH)}>
|
|
<span className="flex items-center justify-center gap-1">
|
|
<FlipHorizontal2 className="h-3 w-3" />
|
|
Horizontal
|
|
</span>
|
|
</button>
|
|
<button type="button" onClick={() => setFlipV(!flipV)} className={tabClass(flipV)}>
|
|
<span className="flex items-center justify-center gap-1">
|
|
<FlipVertical2 className="h-3 w-3" />
|
|
Vertical
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Loop Control */}
|
|
<div className="border-t border-border pt-3">
|
|
<p className="text-xs text-muted-foreground mb-1">Loop</p>
|
|
<div className="flex gap-1">
|
|
{(["infinite", "once", "custom"] as const).map((l) => (
|
|
<button
|
|
key={l}
|
|
type="button"
|
|
onClick={() => setLoopMode(l)}
|
|
className={tabClass(loopMode === l)}
|
|
>
|
|
{l === "infinite" ? "Infinite" : l === "once" ? "Once" : "Custom"}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{loopMode === "custom" && (
|
|
<input
|
|
type="number"
|
|
value={loopCount}
|
|
onChange={(e) => setLoopCount(e.target.value)}
|
|
min={2}
|
|
max={100}
|
|
className="w-full mt-1.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function GifToolsSettings() {
|
|
const { files } = useFileStore();
|
|
const {
|
|
processFiles,
|
|
processAllFiles,
|
|
processing,
|
|
error,
|
|
downloadUrl,
|
|
originalSize,
|
|
processedSize,
|
|
progress,
|
|
} = useToolProcessor("gif-tools");
|
|
const [settings, setSettings] = useState<Record<string, unknown>>({});
|
|
|
|
const handleProcess = () => {
|
|
if (files.length > 1) {
|
|
processAllFiles(files, settings);
|
|
} else {
|
|
processFiles(files, settings);
|
|
}
|
|
};
|
|
|
|
const hasFile = files.length > 0;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<GifToolsControls onChange={setSettings} />
|
|
|
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
|
|
|
{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
|
|
{originalSize > 0 && (
|
|
<span className="ml-1">
|
|
({Math.round(((processedSize - originalSize) / originalSize) * 100)}%)
|
|
</span>
|
|
)}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{processing ? (
|
|
<ProgressCard
|
|
active={processing}
|
|
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
|
label="Processing GIF"
|
|
stage={progress.stage}
|
|
percent={progress.percent}
|
|
elapsed={progress.elapsed}
|
|
/>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
data-testid="gif-tools-submit"
|
|
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"
|
|
>
|
|
{files.length > 1 ? `Process (${files.length} files)` : "Process"}
|
|
</button>
|
|
)}
|
|
|
|
{downloadUrl && (
|
|
<a
|
|
href={downloadUrl}
|
|
download
|
|
data-testid="gif-tools-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>
|
|
);
|
|
}
|