import { AlertTriangle, BookmarkPlus, Download, Loader2, MapPin, PenLine, Plus, X, } from "lucide-react"; import { useCallback, useEffect, useMemo, 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, exifStr, SKIP_KEYS } from "@/lib/metadata-utils"; import { useFileStore } from "@/stores/file-store"; interface InspectResult { filename: string; fileSize: number; exif?: Record | null; iptc?: Record | null; xmp?: Record | null; gps?: Record | null; keywords?: string[]; } interface FormFields { artist: string; copyright: string; imageDescription: string; software: string; dateTime: string; dateTimeOriginal: string; clearGps: boolean; gpsLatitude: string; gpsLongitude: string; gpsAltitude: string; dateMode: "edit" | "shift"; dateShiftDirection: "+" | "-"; dateShiftValue: string; keywords: string[]; keywordsMode: "add" | "set"; iptcTitle: string; iptcHeadline: string; iptcCity: string; iptcState: string; iptcCountry: string; } const EMPTY_FORM: FormFields = { artist: "", copyright: "", imageDescription: "", software: "", dateTime: "", dateTimeOriginal: "", clearGps: false, gpsLatitude: "", gpsLongitude: "", gpsAltitude: "", dateMode: "edit", dateShiftDirection: "+", dateShiftValue: "", keywords: [], keywordsMode: "add", iptcTitle: "", iptcHeadline: "", iptcCity: "", iptcState: "", iptcCountry: "", }; interface Template { name: string; values: Partial; } const TEMPLATES_KEY = "metadata-templates"; function loadTemplates(): Template[] { try { const raw = localStorage.getItem(TEMPLATES_KEY); return raw ? JSON.parse(raw) : []; } catch { return []; } } function saveTemplates(templates: Template[]) { localStorage.setItem(TEMPLATES_KEY, JSON.stringify(templates)); } function LabeledInput({ label, id, value, onChange, placeholder, hint, type = "text", disabled, }: { label: string; id: string; value: string; onChange: (v: string) => void; placeholder?: string; hint?: string; type?: string; disabled?: boolean; }) { return (
onChange(e.target.value)} placeholder={placeholder} disabled={disabled} className="w-full px-2.5 py-1.5 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-50" /> {hint &&

{hint}

}
); } export function EditMetadataSettings() { const { entries, selectedIndex, files } = useFileStore(); const { processFiles, processAllFiles, processing, error, downloadUrl, originalSize, processedSize, progress, } = useToolProcessor("edit-metadata"); const [form, setForm] = useState(EMPTY_FORM); const [initialForm, setInitialForm] = useState(EMPTY_FORM); const [fieldsToRemove, setFieldsToRemove] = useState>(new Set()); const [inspectData, setInspectData] = useState(null); const [inspecting, setInspecting] = useState(false); const [inspectError, setInspectError] = useState(null); const [inspectCache, setInspectCache] = useState>(new Map()); const [keywordInput, setKeywordInput] = useState(""); const [templates, setTemplates] = useState(loadTemplates); const [templateName, setTemplateName] = useState(""); const currentFile = entries[selectedIndex]?.file ?? null; const fileKey = currentFile ? `${currentFile.name}-${currentFile.size}-${currentFile.lastModified}` : null; const populateForm = useCallback((data: InspectResult) => { setInspectData(data); const exif = data.exif ?? {}; const iptc = data.iptc ?? {}; const gps = data.gps ?? {}; const populated: FormFields = { artist: exifStr(exif, "Artist"), copyright: exifStr(exif, "Copyright"), imageDescription: exifStr(exif, "ImageDescription"), software: exifStr(exif, "Software"), dateTime: exifStr(exif, "ModifyDate") || exifStr(exif, "DateTime"), dateTimeOriginal: exifStr(exif, "DateTimeOriginal"), clearGps: false, gpsLatitude: gps.GPSLatitude != null ? String(gps.GPSLatitude) : "", gpsLongitude: gps.GPSLongitude != null ? String(gps.GPSLongitude) : "", gpsAltitude: gps.GPSAltitude != null ? String(gps.GPSAltitude) : "", dateMode: "edit", dateShiftDirection: "+", dateShiftValue: "", keywords: data.keywords ?? [], keywordsMode: "add", iptcTitle: exifStr(iptc, "ObjectName"), iptcHeadline: exifStr(iptc, "Headline"), iptcCity: exifStr(iptc, "City"), iptcState: exifStr(iptc, "Province-State"), iptcCountry: exifStr(iptc, "Country-PrimaryLocationName"), }; setForm(populated); setInitialForm(populated); setFieldsToRemove(new Set()); }, []); useEffect(() => { if (!currentFile || !fileKey) { setForm(EMPTY_FORM); setInitialForm(EMPTY_FORM); setInspectData(null); setInspectError(null); setFieldsToRemove(new Set()); return; } const cached = inspectCache.get(fileKey); if (cached) { populateForm(cached); return; } const controller = new AbortController(); (async () => { setInspecting(true); setInspectError(null); setInspectData(null); try { const formData = new FormData(); formData.append("file", currentFile); const res = await fetch("/api/v1/tools/edit-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: InspectResult = await res.json(); setInspectCache((prev) => new Map(prev).set(fileKey, data)); populateForm(data); } catch (err) { if ((err as Error).name === "AbortError") return; setInspectError(err instanceof Error ? err.message : "Failed to inspect file"); setForm(EMPTY_FORM); setInitialForm(EMPTY_FORM); } finally { setInspecting(false); } })(); return () => controller.abort(); }, [currentFile, fileKey, inspectCache, populateForm]); const setField = (key: K, value: FormFields[K]) => setForm((prev) => ({ ...prev, [key]: value })); const toggleRemoveField = (key: string) => { setFieldsToRemove((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }; const addKeyword = () => { const kw = keywordInput.trim(); if (kw && !form.keywords.includes(kw)) { setField("keywords", [...form.keywords, kw]); setKeywordInput(""); } }; const removeKeyword = (kw: string) => { setField( "keywords", form.keywords.filter((k) => k !== kw), ); }; const saveTemplate = () => { const name = templateName.trim(); if (!name) return; const { dateMode, dateShiftDirection, dateShiftValue, ...values } = form; const tmpl: Template = { name, values }; const updated = [...templates.filter((t) => t.name !== name), tmpl]; setTemplates(updated); saveTemplates(updated); setTemplateName(""); }; const loadTemplate = (name: string) => { const tmpl = templates.find((t) => t.name === name); if (tmpl) { setForm((prev) => ({ ...prev, ...tmpl.values })); } }; const deleteTemplate = (name: string) => { const updated = templates.filter((t) => t.name !== name); setTemplates(updated); saveTemplates(updated); }; // Changes summary const changes = useMemo(() => { let modified = 0; const removed = fieldsToRemove.size; const simpleFields: (keyof FormFields)[] = [ "artist", "copyright", "imageDescription", "software", "dateTime", "dateTimeOriginal", "iptcTitle", "iptcHeadline", "iptcCity", "iptcState", "iptcCountry", ]; for (const key of simpleFields) { if (form[key] !== initialForm[key]) modified++; } const gpsAdded = !form.clearGps && (form.gpsLatitude !== initialForm.gpsLatitude || form.gpsLongitude !== initialForm.gpsLongitude); const gpsCleared = form.clearGps; const keywordsChanged = JSON.stringify(form.keywords) !== JSON.stringify(initialForm.keywords); const hasShift = form.dateMode === "shift" && form.dateShiftValue.trim() !== ""; if (gpsAdded) modified++; if (keywordsChanged) modified++; if (hasShift) modified++; const total = modified + removed + (gpsCleared ? 1 : 0); return { modified, removed, gpsAdded, gpsCleared, keywordsChanged, hasShift, total }; }, [form, initialForm, fieldsToRemove]); const hasFile = files.length > 0; const gpsLat = inspectData?.gps?.GPSLatitude as number | undefined; const gpsLon = inspectData?.gps?.GPSLongitude as number | undefined; const gpsCoords = gpsLat != null && gpsLon != null ? { lat: gpsLat, lon: gpsLon } : null; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!hasFile || processing) return; const settings: Record = {}; // Basic EXIF fields - only send if changed if (form.artist !== initialForm.artist && form.artist.trim()) settings.artist = form.artist.trim(); if (form.copyright !== initialForm.copyright && form.copyright.trim()) settings.copyright = form.copyright.trim(); if (form.imageDescription !== initialForm.imageDescription && form.imageDescription.trim()) settings.imageDescription = form.imageDescription.trim(); if (form.software !== initialForm.software && form.software.trim()) settings.software = form.software.trim(); // Date fields if (form.dateMode === "shift" && form.dateShiftValue.trim()) { settings.dateShift = `${form.dateShiftDirection}${form.dateShiftValue.trim()}`; } else { if (form.dateTime !== initialForm.dateTime && form.dateTime.trim()) settings.dateTime = form.dateTime.trim(); if (form.dateTimeOriginal !== initialForm.dateTimeOriginal && form.dateTimeOriginal.trim()) settings.dateTimeOriginal = form.dateTimeOriginal.trim(); } // GPS if (form.clearGps) { settings.clearGps = true; } else if ( form.gpsLatitude.trim() && form.gpsLongitude.trim() && (form.gpsLatitude !== initialForm.gpsLatitude || form.gpsLongitude !== initialForm.gpsLongitude || form.gpsAltitude !== initialForm.gpsAltitude) ) { settings.gpsLatitude = parseFloat(form.gpsLatitude); settings.gpsLongitude = parseFloat(form.gpsLongitude); if (form.gpsAltitude.trim()) settings.gpsAltitude = parseFloat(form.gpsAltitude); } // Keywords if (JSON.stringify(form.keywords) !== JSON.stringify(initialForm.keywords)) { settings.keywords = form.keywords; settings.keywordsMode = form.keywordsMode; } // IPTC fields if (form.iptcTitle !== initialForm.iptcTitle && form.iptcTitle.trim()) settings.iptcTitle = form.iptcTitle.trim(); if (form.iptcHeadline !== initialForm.iptcHeadline && form.iptcHeadline.trim()) settings.iptcHeadline = form.iptcHeadline.trim(); if (form.iptcCity !== initialForm.iptcCity && form.iptcCity.trim()) settings.iptcCity = form.iptcCity.trim(); if (form.iptcState !== initialForm.iptcState && form.iptcState.trim()) settings.iptcState = form.iptcState.trim(); if (form.iptcCountry !== initialForm.iptcCountry && form.iptcCountry.trim()) settings.iptcCountry = form.iptcCountry.trim(); // Fields to remove if (fieldsToRemove.size > 0) { settings.fieldsToRemove = Array.from(fieldsToRemove); } if (files.length > 1) { processAllFiles(files, settings); } else { processFiles(files, settings); } }; return (
{/* Inspect status */} {hasFile && inspecting && (
Reading metadata...
)} {hasFile && inspectError && !inspecting && (
Could not read metadata - fields will start empty.
)} {/* Section 1: Basic Info */} {hasFile && (
setField("imageDescription", v)} placeholder="Image description" /> setField("artist", v)} placeholder="Photographer / creator name" /> setField("copyright", v)} placeholder="2026 Example Corp" /> setField("software", v)} placeholder="e.g. Lightroom, Photoshop" />

IPTC

setField("iptcTitle", v)} placeholder="Image title" /> setField("iptcHeadline", v)} placeholder="Short headline" /> setField("iptcCity", v)} placeholder="City name" /> setField("iptcState", v)} placeholder="State or province" /> setField("iptcCountry", v)} placeholder="Country name" />
)} {/* Section 2: Date & Time */} {hasFile && (
{form.dateMode === "edit" ? ( <> setField("dateTime", v)} placeholder="YYYY:MM:DD HH:MM:SS" hint="EXIF format: 2026:04:11 12:00:00" /> setField("dateTimeOriginal", v)} placeholder="YYYY:MM:DD HH:MM:SS" /> ) : (

Shift all date fields by an offset (useful for timezone corrections)

setField("dateShiftValue", v)} placeholder="1:30" hint="e.g. 1:30 for 1 hour 30 minutes" />
)}
)} {/* Section 3: Location (GPS) */} {hasFile && (
{gpsCoords && (

Location data found

{gpsCoords.lat.toFixed(6)}, {gpsCoords.lon.toFixed(6)}

)} {!gpsCoords && (

No GPS data. Add coordinates below.

)} setField("gpsLatitude", v)} placeholder="-90 to 90 (e.g. 51.5074)" disabled={form.clearGps} hint="Decimal degrees. Negative = South" /> setField("gpsLongitude", v)} placeholder="-180 to 180 (e.g. -0.1278)" disabled={form.clearGps} hint="Decimal degrees. Negative = West" /> setField("gpsAltitude", v)} placeholder="Optional (e.g. 25)" disabled={form.clearGps} />
)} {/* Section 4: Keywords */} {hasFile && ( 0 ? `${form.keywords.length}` : undefined} >
{form.keywords.length > 0 && (
{form.keywords.map((kw) => ( {kw} ))}
)}
setKeywordInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addKeyword(); } }} placeholder="Add keyword and press Enter" className="flex-1 px-2.5 py-1.5 rounded-md border border-input bg-background text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" />
)} {/* Section 5: All Metadata (raw view) */} {hasFile && inspectData && (
{inspectData.exif && Object.keys(inspectData.exif).length > 0 && ( !SKIP_KEYS.has(k)).length}`} > )} {inspectData.iptc && Object.keys(inspectData.iptc).length > 0 && ( )} {inspectData.xmp && Object.keys(inspectData.xmp).length > 0 && ( )} {inspectData.gps && Object.keys(inspectData.gps).length > 0 && ( )}
)} {/* Section 6: Templates */} {hasFile && (

Templates

{templates.length > 0 && (
{templates.map((t) => (
))}
)}
setTemplateName(e.target.value)} placeholder="Template name" className="flex-1 px-2.5 py-1.5 rounded-md border border-input bg-background text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring" />
)} {/* No file placeholder */} {!hasFile && (

Upload an image to edit its metadata.

)} {error &&

{error}

} {/* Changes summary */} {hasFile && changes.total > 0 && !processing && (
{changes.total} changes:{" "} {changes.modified > 0 && `${changes.modified} modified`} {changes.removed > 0 && `${changes.modified > 0 ? ", " : ""}${changes.removed} removed`} {changes.gpsCleared && ", GPS cleared"} {changes.gpsAdded && ", GPS added"} {changes.hasShift && ", dates shifted"}
)} {originalSize != null && processedSize != null && (

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

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

)} {processing ? ( ) : ( )} {downloadUrl && ( Download )} ); }