import { useState, useEffect } from "react"; import { useFileStore } from "@/stores/file-store"; import { useToolProcessor } from "@/hooks/use-tool-processor"; import { Download, ChevronDown, ChevronRight, Loader2, MapPin, AlertTriangle } from "lucide-react"; import { ProgressCard } from "@/components/common/progress-card"; function getToken(): string { return localStorage.getItem("stirling-token") || ""; } interface MetadataResult { filename: string; fileSize: number; exif?: Record | null; exifError?: string; gps?: Record | null; icc?: Record | null; xmp?: Record | null; } /** Human-friendly labels for common EXIF keys */ const EXIF_LABELS: Record = { Make: "Camera Make", Model: "Camera Model", Software: "Software", DateTime: "Date/Time", DateTimeOriginal: "Date Taken", DateTimeDigitized: "Date Digitized", ExposureTime: "Exposure Time", FNumber: "F-Number", ISOSpeedRatings: "ISO", FocalLength: "Focal Length", FocalLengthIn35mmFilm: "Focal Length (35mm)", ExposureBiasValue: "Exposure Bias", MeteringMode: "Metering Mode", Flash: "Flash", WhiteBalance: "White Balance", ExposureMode: "Exposure Mode", SceneCaptureType: "Scene Type", Contrast: "Contrast", Saturation: "Saturation", Sharpness: "Sharpness", DigitalZoomRatio: "Digital Zoom", ImageWidth: "Width", ImageLength: "Height", Orientation: "Orientation", XResolution: "X Resolution", YResolution: "Y Resolution", ResolutionUnit: "Resolution Unit", ColorSpace: "Color Space", PixelXDimension: "Pixel Width", PixelYDimension: "Pixel Height", Artist: "Artist", Copyright: "Copyright", ImageDescription: "Description", LensMake: "Lens Make", LensModel: "Lens Model", BodySerialNumber: "Body Serial", CameraOwnerName: "Camera Owner", }; /** Keys to skip in display (internal/binary/redundant) */ const SKIP_KEYS = new Set([ "ExifTag", "GPSTag", "InteroperabilityTag", "MakerNote", "PrintImageMatching", "ComponentsConfiguration", "FlashpixVersion", "ExifVersion", "FileSource", "SceneType", "UserComment", "InteroperabilityIndex", "InteroperabilityVersion", ]); function formatExifValue(key: string, value: unknown): string { if (value === null || value === undefined) return "N/A"; if (typeof value === "string") return value; if (typeof value === "number") { if (key === "ExposureTime" && value > 0 && value < 1) { return `1/${Math.round(1 / value)}s`; } if (key === "FNumber") return `f/${value}`; if (key === "FocalLength") return `${value}mm`; if (key === "FocalLengthIn35mmFilm") return `${value}mm`; return String(value); } if (Array.isArray(value)) { if (typeof value[0] === "number" && value.length <= 4) { return value.join(", "); } return `[${value.length} values]`; } return String(value); } function CollapsibleSection({ title, badge, warning, defaultOpen, children, }: { title: string; badge?: string; warning?: boolean; defaultOpen?: boolean; children: React.ReactNode; }) { const [open, setOpen] = useState(defaultOpen ?? false); return (
{open &&
{children}
}
); } function MetadataGrid({ data, labelMap }: { data: Record; labelMap?: Record }) { const entries = Object.entries(data).filter( ([k, v]) => !SKIP_KEYS.has(k) && !k.startsWith("_") && v !== undefined && v !== null && String(v) !== "" ); if (entries.length === 0) { return

No data

; } return (
{entries.map(([k, v]) => (
{labelMap?.[k] ?? k}
{formatExifValue(k, v)}
))}
); } export function StripMetadataSettings() { const { entries, selectedIndex, files } = useFileStore(); const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } = 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); // Per-file metadata cache (from feature branch) const [metadataCache, setMetadataCache] = useState>(new Map()); const [metadata, setMetadata] = useState(null); const [inspecting, setInspecting] = useState(false); const [inspectError, setInspectError] = useState(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: { Authorization: `Bearer ${getToken()}` }, 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); 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]); 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; 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 (
{/* Metadata Display */} {hasFile && (
{inspecting && (
Reading metadata...
)} {inspectError && (

{inspectError}

)} {metadata && !hasAnyMetadata && !inspecting && (

No metadata found in this image.

)} {metadata && hasAnyMetadata && (
{/* GPS warning banner */} {hasGps && gpsLat != null && gpsLon != null && (
Location data: {gpsLat.toFixed(4)}, {gpsLon.toFixed(4)}
)} {hasExif && ( !SKIP_KEYS.has(k) && !k.startsWith("_")).length} fields`} defaultOpen > )} {metadata?.exifError && (

EXIF: {metadata.exifError}

)} {hasGps && ( !k.startsWith("_")).length} fields`}> )} {hasIcc && ( )} {hasXmp && ( )}

{sectionCount} metadata {sectionCount === 1 ? "section" : "sections"} found

)}
)} {hasFile && hasAnyMetadata &&
} {/* Strip All */}
{/* Individual options */}
{/* Error */} {error &&

{error}

} {/* Size info */} {originalSize != null && processedSize != null && (

Original: {(originalSize / 1024).toFixed(1)} KB

Processed: {(processedSize / 1024).toFixed(1)} KB

Metadata removed: {((originalSize - processedSize) / 1024).toFixed(1)} KB

)} {/* Process */} {processing ? ( ) : ( )} {/* Download */} {downloadUrl && ( Download )} ); }