ashim/apps/web/src/components/tools/strip-metadata-settings.tsx
ashim-hq 32239600ae fix: verbose error handling, batch processing, and multi-file support
- Replace [object Object] errors with readable messages across all 20+ API
  routes by normalizing Zod validation errors to strings (formatZodErrors)
- Add parseApiError() on frontend to defensively handle any details type
- Add global Fastify error handler with full stack traces in logs
- Fix image-to-pdf auth: Object.entries(headers) → headers.forEach()
- Fix passport-photo: safeParse + formatZodErrors, safe error extraction
- Fix OCR silent fallbacks: log exception type/message when falling back,
  include actual engine used in API response and Docker logs
- Fix split tool: process all uploaded images, combine into ZIP with
  subfolders per image
- Fix batch support for blur-faces, strip-metadata, edit-metadata,
  vectorize: add processAllFiles branch for multi-file uploads
- Docker: LOG_LEVEL=debug, PYTHONWARNINGS=default for visibility
- Add Playwright e2e tests verifying all fixes against Docker container
2026-04-17 14:15:27 +08:00

450 lines
15 KiB
TypeScript

import L from "leaflet";
import "leaflet/dist/leaflet.css";
import { Download, Loader2, MapPin } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { CollapsibleSection } from "@/components/common/collapsible-section";
import { MetadataGrid } from "@/components/common/metadata-grid";
import { ProgressCard } from "@/components/common/progress-card";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { formatHeaders } from "@/lib/api";
import { EXIF_LABELS, SKIP_KEYS } from "@/lib/metadata-utils";
import { useFileStore } from "@/stores/file-store";
/** Interactive Leaflet map with a red circle marker. */
function MiniMap({ lat, lon, zoom = 15 }: { lat: number; lon: number; zoom?: number }) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null);
useEffect(() => {
if (!containerRef.current || mapRef.current) return;
const map = L.map(containerRef.current, {
zoomControl: false,
attributionControl: false,
}).setView([lat, lon], zoom);
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
}).addTo(map);
L.circleMarker([lat, lon], {
radius: 7,
color: "#fff",
weight: 2,
fillColor: "#ef4444",
fillOpacity: 1,
}).addTo(map);
mapRef.current = map;
return () => {
map.remove();
mapRef.current = null;
};
}, [lat, lon, zoom]);
return (
<div
ref={containerRef}
className="w-full h-36 rounded-md overflow-hidden border border-border"
/>
);
}
interface MetadataResult {
filename: string;
fileSize: number;
exif?: Record<string, unknown> | null;
exifError?: string;
gps?: Record<string, unknown> | null;
icc?: Record<string, string> | null;
xmp?: Record<string, string> | null;
}
interface StripMetadataControlsProps {
settings?: Record<string, unknown>;
onChange?: (settings: Record<string, unknown>) => void;
/** Passed from parent to preserve field-count badges in checkbox labels */
metadata?: MetadataResult | null;
hasExif?: boolean;
hasGps?: boolean;
}
export function StripMetadataControls({
settings: initialSettings,
onChange,
metadata,
hasExif,
hasGps,
}: StripMetadataControlsProps) {
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 initializedRef = useRef(false);
useEffect(() => {
if (!initialSettings || initializedRef.current) return;
initializedRef.current = true;
if (initialSettings.stripAll != null) setStripAll(Boolean(initialSettings.stripAll));
if (initialSettings.stripExif != null) setStripExif(Boolean(initialSettings.stripExif));
if (initialSettings.stripGps != null) setStripGps(Boolean(initialSettings.stripGps));
if (initialSettings.stripIcc != null) setStripIcc(Boolean(initialSettings.stripIcc));
if (initialSettings.stripXmp != null) setStripXmp(Boolean(initialSettings.stripXmp));
}, [initialSettings]);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
// Report settings on change
useEffect(() => {
onChangeRef.current?.({ stripAll, stripExif, stripGps, stripIcc, stripXmp });
}, [stripAll, stripExif, stripGps, stripIcc, stripXmp]);
const handleStripAllChange = (checked: boolean) => {
setStripAll(checked);
if (checked) {
setStripExif(false);
setStripGps(false);
setStripIcc(false);
setStripXmp(false);
}
};
return (
<>
{/* Remove 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"
/>
Remove All Metadata
</label>
<div className="border-t border-border" />
{/* Individual options */}
<div className="space-y-2">
<p className="text-xs text-muted-foreground">Or select specific metadata:</p>
<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)
{hasExif && !stripAll && (
<span className="ml-auto text-[10px] text-muted-foreground">
{Object.keys(metadata?.exif ?? {}).filter((k) => !SKIP_KEYS.has(k)).length} fields
</span>
)}
</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)
{hasGps && !stripAll && (
<span className="ml-auto text-[10px] text-amber-500">location found</span>
)}
</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>
</>
);
}
export function StripMetadataSettings() {
const { entries, selectedIndex, files } = useFileStore();
const {
processFiles,
processAllFiles,
processing,
error,
downloadUrl,
originalSize,
processedSize,
progress,
} = useToolProcessor("strip-metadata");
const [stripSettings, setStripSettings] = useState<Record<string, unknown>>({
stripAll: true,
stripExif: false,
stripGps: false,
stripIcc: false,
stripXmp: false,
});
// Per-file metadata cache (from feature branch)
const [metadataCache, setMetadataCache] = useState<Map<string, MetadataResult>>(new Map());
const [metadata, setMetadata] = useState<MetadataResult | null>(null);
const [inspecting, setInspecting] = useState(false);
const [inspectError, setInspectError] = useState<string | null>(null);
const currentFile = entries[selectedIndex]?.file ?? null;
const fileKey = currentFile
? `${currentFile.name}-${currentFile.size}-${currentFile.lastModified}`
: null;
// Auto-fetch metadata for the selected file (with per-file caching)
useEffect(() => {
if (!currentFile || !fileKey) {
setMetadata(null);
setInspectError(null);
return;
}
// Check cache first
const cached = metadataCache.get(fileKey);
if (cached) {
setMetadata(cached);
return;
}
const controller = new AbortController();
(async () => {
setInspecting(true);
setInspectError(null);
setMetadata(null);
try {
const formData = new FormData();
formData.append("file", currentFile);
const res = await fetch("/api/v1/tools/strip-metadata/inspect", {
method: "POST",
headers: formatHeaders(),
body: formData,
signal: controller.signal,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Failed: ${res.status}`);
}
const data: MetadataResult = await res.json();
setMetadata(data);
if (fileKey) setMetadataCache((prev) => new Map(prev).set(fileKey, data));
} catch (err) {
if ((err as Error).name === "AbortError") return;
setInspectError(err instanceof Error ? err.message : "Failed to inspect metadata");
} finally {
setInspecting(false);
}
})();
return () => controller.abort();
}, [currentFile, fileKey, metadataCache]);
const handleProcess = () => {
if (files.length > 1) {
processAllFiles(files, stripSettings);
} else {
processFiles(files, stripSettings);
}
};
const hasFile = files.length > 0;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (hasFile && !processing) handleProcess();
};
const hasExif = metadata?.exif && Object.keys(metadata.exif).length > 0;
const hasGps = metadata?.gps && Object.keys(metadata.gps).length > 0;
const hasIcc = metadata?.icc && Object.keys(metadata.icc).length > 0;
const hasXmp = metadata?.xmp && Object.keys(metadata.xmp).length > 0;
const hasAnyMetadata = hasExif || hasGps || hasIcc || hasXmp;
const sectionCount = [hasExif, hasGps, hasIcc, hasXmp].filter(Boolean).length;
// GPS coordinates for display
const gpsLat = metadata?.gps?._latitude as number | null | undefined;
const gpsLon = metadata?.gps?._longitude as number | null | undefined;
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Metadata Display */}
{hasFile && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">Current Metadata</p>
{inspecting && (
<div className="flex items-center gap-2 text-xs text-muted-foreground py-2">
<Loader2 className="h-3 w-3 animate-spin" />
Reading metadata...
</div>
)}
{inspectError && <p className="text-[10px] text-red-500">{inspectError}</p>}
{metadata && !hasAnyMetadata && !inspecting && (
<p className="text-xs text-muted-foreground italic py-1">
No metadata found in this image.
</p>
)}
{metadata && hasAnyMetadata && (
<div className="space-y-1.5">
{/* GPS warning banner + map */}
{hasGps && gpsLat != null && gpsLon != null && (
<div className="space-y-2">
<div className="flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-amber-500/10 border border-amber-500/20">
<MapPin className="h-3 w-3 text-amber-500 shrink-0" />
<span className="text-[10px] text-amber-600 dark:text-amber-400 font-medium">
Location data: {gpsLat.toFixed(6)}, {gpsLon.toFixed(6)}
</span>
</div>
<MiniMap lat={gpsLat} lon={gpsLon} />
<p className="text-[10px] text-amber-600 dark:text-amber-400">
This image contains your precise location. Consider removing GPS data before
sharing.
</p>
</div>
)}
{hasExif && metadata.exif && (
<CollapsibleSection
title="EXIF"
badge={`${Object.keys(metadata.exif).filter((k) => !SKIP_KEYS.has(k) && !k.startsWith("_")).length} fields`}
defaultOpen
>
<MetadataGrid data={metadata.exif} labelMap={EXIF_LABELS} />
</CollapsibleSection>
)}
{metadata?.exifError && (
<p className="text-[11px] text-muted-foreground">EXIF: {metadata.exifError}</p>
)}
{hasGps && metadata.gps && (
<CollapsibleSection
title="GPS"
warning
badge={`${Object.keys(metadata.gps).filter((k) => !k.startsWith("_")).length} fields`}
>
<MetadataGrid data={metadata.gps} />
</CollapsibleSection>
)}
{hasIcc && metadata.icc && (
<CollapsibleSection
title="ICC Profile"
badge={`${Object.keys(metadata.icc).length} fields`}
>
<MetadataGrid data={metadata.icc} />
</CollapsibleSection>
)}
{hasXmp && metadata.xmp && (
<CollapsibleSection
title="XMP"
badge={`${Object.keys(metadata.xmp).length} fields`}
>
<MetadataGrid data={metadata.xmp} />
</CollapsibleSection>
)}
<p className="text-[10px] text-muted-foreground">
{sectionCount} metadata {sectionCount === 1 ? "section" : "sections"} found
</p>
</div>
)}
</div>
)}
{hasFile && hasAnyMetadata && <div className="border-t border-border" />}
<StripMetadataControls
onChange={setStripSettings}
metadata={metadata}
hasExif={!!hasExif}
hasGps={!!hasGps}
/>
{/* 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 */}
{processing ? (
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Removing metadata"
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="submit"
data-testid="strip-metadata-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"
>
Remove Metadata
</button>
)}
{/* Download */}
{downloadUrl && (
<a
href={downloadUrl}
download
data-testid="strip-metadata-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>
);
}