feat: add Optimize for Web tool (#68)

* feat(image-engine): add OptimizeForWebOptions type

* feat(image-engine): add optimizeForWeb operation

* feat(shared): add optimize-for-web tool definition and i18n

* feat(api): add optimize-for-web route with preview endpoint

* feat(web): add optimize-for-web settings component with live preview

* feat(web): register optimize-for-web in tool registry

* fix(web): align toggle switch translate with codebase pattern

---------

Co-authored-by: stirling-image <stirling-image@users.noreply.github.com>
This commit is contained in:
stirling-image
2026-04-14 16:16:04 +08:00
committed by GitHub
co-authored by stirling-image
parent c2c104e887
commit 5be8be3dc3
9 changed files with 590 additions and 0 deletions
@@ -0,0 +1,358 @@
import { ChevronDown, ChevronRight, Download, Loader2 } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ProgressCard } from "@/components/common/progress-card";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { formatHeaders } from "@/lib/api";
import { useFileStore } from "@/stores/file-store";
type WebFormat = "webp" | "jpeg" | "avif" | "png";
interface PreviewState {
loading: boolean;
previewUrl: string | null;
processedSize: number | null;
originalSize: number | null;
}
const FORMAT_LABELS: Record<WebFormat, string> = {
webp: "WebP",
jpeg: "JPEG",
avif: "AVIF",
png: "PNG",
};
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function OptimizeForWebSettings() {
const { files, entries, selectedIndex } = useFileStore();
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
useToolProcessor("optimize-for-web");
// Settings state
const [format, setFormat] = useState<WebFormat>("webp");
const [quality, setQuality] = useState(80);
const [maxWidth, setMaxWidth] = useState("");
const [maxHeight, setMaxHeight] = useState("");
const [stripMetadata, setStripMetadata] = useState(true);
const [showDimensions, setShowDimensions] = useState(false);
// Preview state
const [preview, setPreview] = useState<PreviewState>({
loading: false,
previewUrl: null,
processedSize: null,
originalSize: null,
});
const abortRef = useRef<AbortController | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const prevPreviewUrlRef = useRef<string | null>(null);
const hasFile = files.length > 0;
const currentEntry = entries[selectedIndex];
// Build settings object
const buildSettings = useCallback(() => {
const settings: Record<string, unknown> = {
format,
quality,
progressive: true,
stripMetadata,
};
const mw = Number(maxWidth);
const mh = Number(maxHeight);
if (mw > 0) settings.maxWidth = mw;
if (mh > 0) settings.maxHeight = mh;
return settings;
}, [format, quality, maxWidth, maxHeight, stripMetadata]);
// Live preview - debounced request on parameter change
const fetchPreview = useCallback(() => {
if (!hasFile || !currentEntry) return;
// Cancel any in-flight request
if (abortRef.current) abortRef.current.abort();
const controller = new AbortController();
abortRef.current = controller;
setPreview((prev) => ({ ...prev, loading: true }));
const formData = new FormData();
formData.append("file", currentEntry.file);
formData.append("settings", JSON.stringify(buildSettings()));
fetch("/api/v1/tools/optimize-for-web/preview", {
method: "POST",
headers: formatHeaders(),
body: formData,
signal: controller.signal,
})
.then(async (response) => {
if (!response.ok) throw new Error(`Preview failed: ${response.status}`);
const originalSize = Number(response.headers.get("X-Original-Size") ?? "0");
const processedSize = Number(response.headers.get("X-Processed-Size") ?? "0");
const blob = await response.blob();
const previewUrl = URL.createObjectURL(blob);
// Revoke previous preview URL
if (prevPreviewUrlRef.current) {
URL.revokeObjectURL(prevPreviewUrlRef.current);
}
prevPreviewUrlRef.current = previewUrl;
// Write the preview into the file store so BeforeAfterSlider picks it up
useFileStore.getState().updateEntry(selectedIndex, {
processedUrl: previewUrl,
processedPreviewUrl: null,
processedFilename: null,
status: "completed",
originalSize,
processedSize,
});
setPreview({
loading: false,
previewUrl,
processedSize,
originalSize,
});
})
.catch((err) => {
if (err instanceof Error && err.name === "AbortError") return;
setPreview((prev) => ({ ...prev, loading: false }));
});
}, [hasFile, currentEntry, selectedIndex, buildSettings]);
// Debounce preview on settings change
useEffect(() => {
if (!hasFile) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
const debounceMs = currentEntry && currentEntry.file.size > 20 * 1024 * 1024 ? 800 : 300;
debounceRef.current = setTimeout(fetchPreview, debounceMs);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [hasFile, currentEntry, fetchPreview]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (abortRef.current) abortRef.current.abort();
if (debounceRef.current) clearTimeout(debounceRef.current);
if (prevPreviewUrlRef.current) URL.revokeObjectURL(prevPreviewUrlRef.current);
};
}, []);
// Final process handler (creates workspace + download link)
const handleProcess = () => {
const settings = buildSettings();
if (files.length > 1) {
processAllFiles(files, settings);
} else {
processFiles(files, settings);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (hasFile && !processing) handleProcess();
};
const savings =
preview.originalSize && preview.processedSize
? ((1 - preview.processedSize / preview.originalSize) * 100).toFixed(1)
: null;
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Format selector */}
<div>
<p className="text-sm font-medium text-muted-foreground">Output Format</p>
<div className="grid grid-cols-4 gap-1 mt-1">
{(["webp", "jpeg", "avif", "png"] as const).map((f) => (
<button
key={f}
type="button"
onClick={() => setFormat(f)}
className={`text-xs py-1.5 rounded font-medium transition-colors ${
format === f
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:text-foreground"
}`}
>
{FORMAT_LABELS[f]}
</button>
))}
</div>
</div>
{/* Quality slider - hidden for PNG */}
{format !== "png" && (
<div>
<div className="flex justify-between items-center">
<label htmlFor="web-quality" className="text-xs text-muted-foreground">
Quality
</label>
<span className="text-xs font-mono text-foreground">{quality}</span>
</div>
<input
id="web-quality"
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>
)}
{/* Max dimensions - collapsible */}
<div>
<button
type="button"
onClick={() => setShowDimensions(!showDimensions)}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground w-full"
>
{showDimensions ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
<span>Max Dimensions</span>
</button>
{showDimensions && (
<div className="grid grid-cols-2 gap-2 mt-2">
<div>
<label htmlFor="max-width" className="text-[10px] text-muted-foreground">
Max Width
</label>
<input
id="max-width"
type="number"
value={maxWidth}
onChange={(e) => setMaxWidth(e.target.value)}
min={1}
placeholder="px"
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div>
<label htmlFor="max-height" className="text-[10px] text-muted-foreground">
Max Height
</label>
<input
id="max-height"
type="number"
value={maxHeight}
onChange={(e) => setMaxHeight(e.target.value)}
min={1}
placeholder="px"
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
</div>
)}
</div>
{/* Strip metadata toggle */}
<div className="flex items-center justify-between">
<label htmlFor="strip-meta" className="text-xs text-muted-foreground">
Strip Metadata
</label>
<button
id="strip-meta"
type="button"
role="switch"
aria-checked={stripMetadata}
onClick={() => setStripMetadata(!stripMetadata)}
className={`relative inline-flex h-4 w-7 items-center rounded-full transition-colors ${
stripMetadata ? "bg-primary" : "bg-muted"
}`}
>
<span
className={`inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform ${
stripMetadata ? "translate-x-3.5" : "translate-x-0.5"
}`}
/>
</button>
</div>
{/* Size comparison card */}
{(preview.originalSize || preview.loading) && (
<div className="rounded-lg border border-border bg-muted/50 p-3 space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">Size Comparison</span>
{preview.loading && <Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />}
</div>
{preview.originalSize != null && (
<div className="text-xs text-muted-foreground">
Original: {formatSize(preview.originalSize)}
</div>
)}
{preview.processedSize != null && (
<div className="text-xs text-muted-foreground">
Optimized: {formatSize(preview.processedSize)}
<span className="ml-1 font-medium uppercase text-[10px]">
{FORMAT_LABELS[format]}
</span>
</div>
)}
{savings != null && (
<div
className={`text-sm font-semibold ${
Number(savings) > 0 ? "text-green-500" : "text-red-500"
}`}
>
{Number(savings) > 0 ? `${savings}% smaller` : `${Math.abs(Number(savings))}% larger`}
</div>
)}
</div>
)}
{/* Error */}
{error && <p className="text-xs text-red-500">{error}</p>}
{/* Process / Download */}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Optimizing"
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="submit"
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 & Download (${files.length} files)` : "Process & Download"}
</button>
)}
{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>
)}
</form>
);
}
+6
View File
@@ -83,6 +83,11 @@ const ConvertSettings = lazy(() =>
const CompressSettings = lazy(() =>
import("@/components/tools/compress-settings").then((m) => ({ default: m.CompressSettings })),
);
const OptimizeForWebSettings = lazy(() =>
import("@/components/tools/optimize-for-web-settings").then((m) => ({
default: m.OptimizeForWebSettings,
})),
);
const StripMetadataSettings = lazy(() =>
import("@/components/tools/strip-metadata-settings").then((m) => ({
default: m.StripMetadataSettings,
@@ -396,6 +401,7 @@ export const toolRegistry = new Map<string, ToolRegistryEntry>([
["bulk-rename", { displayMode: "before-after", Settings: BulkRenameSettings }],
["favicon", { displayMode: "before-after", Settings: FaviconSettings }],
["image-to-pdf", { displayMode: "before-after", Settings: ImageToPdfSettings }],
["optimize-for-web", { displayMode: "before-after", Settings: OptimizeForWebSettings }],
[
"pdf-to-image",
{ displayMode: "no-dropzone", Settings: PdfToImageSettings, ResultsPanel: PdfToImagePreview },