mirror of
https://github.com/ashim-hq/ashim
synced 2026-04-21 13:37:52 +00:00
- 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
450 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|