feat: comprehensive HEIC/HEIF support and edit-metadata ExifTool overhaul

- Add ensureSharpCompat() helper for automatic HEIC detection and decode
- Fix HEIC support in all 14 custom-route tools (image-to-pdf, split,
  barcode-read, compose, collage, stitch, compare, find-duplicates,
  color-palette, watermark-image, vectorize, favicon, info, branding)
- Fix PdfPagePreview using store's decoded blobUrl instead of raw File
- Add onError fallback in ImageViewer for unrenderable formats
- Fix image-to-pdf progress bar with flushSync for reliable rendering
- Add ExifTool backend for edit-metadata (GPS, keywords, IPTC, dates)
- Rename Strip Metadata to Remove Metadata with interactive Leaflet map
- Fix user-files thumbnail generation for stored HEIC files
- Fix info tool stats() histogram for HEIC via decoded buffer
- Skip HEIC preprocessing in batch route for metadata tools
This commit is contained in:
Siddharth Kumar Sah
2026-04-12 08:50:19 +08:00
parent 6f5283019b
commit dde70f70ad
32 changed files with 1423 additions and 347 deletions
+18 -13
View File
@@ -28,18 +28,24 @@ export function ImageViewer({
const [naturalWidth, setNaturalWidth] = useState<number | null>(null);
const [naturalHeight, setNaturalHeight] = useState<number | null>(null);
const [fitMode, setFitMode] = useState<"fit" | "actual">("fit");
const [loadError, setLoadError] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const imgRef = useRef<HTMLImageElement>(null);
const isSvg = filename.toLowerCase().endsWith(".svg");
const handleImageLoad = useCallback(() => {
setLoadError(false);
if (imgRef.current) {
setNaturalWidth(imgRef.current.naturalWidth);
setNaturalHeight(imgRef.current.naturalHeight);
}
}, []);
const handleImageError = useCallback(() => {
setLoadError(true);
}, []);
const zoomIn = useCallback(() => {
setZoom((prev) => {
const next = ZOOM_STEPS.find((s) => s > prev);
@@ -66,13 +72,14 @@ export function ImageViewer({
setZoom(100);
}, []);
// Reset zoom on src change
// Reset state on src change
useEffect(() => {
setZoom(DEFAULT_ZOOM);
setFitMode("fit");
setNaturalWidth(null);
setNaturalHeight(null);
}, []);
setLoadError(false);
}, [src]);
const previewTransform = [
cssRotate ? `rotate(${cssRotate}deg)` : "",
@@ -150,23 +157,21 @@ export function ImageViewer({
ref={containerRef}
className="flex-1 flex items-center justify-center overflow-auto bg-muted/20 p-4"
>
{isSvg ? (
<img
ref={imgRef}
src={src}
alt={filename}
onLoad={handleImageLoad}
className="select-none"
style={imageStyle}
draggable={false}
/>
{loadError ? (
<div className="flex flex-col items-center gap-2 text-center">
<p className="text-sm text-muted-foreground">Preview not available</p>
<p className="text-xs text-muted-foreground/60">
This format cannot be displayed in the browser
</p>
</div>
) : (
<img
ref={imgRef}
src={src}
alt={filename}
onLoad={handleImageLoad}
className="select-none rounded-sm"
onError={handleImageError}
className={`select-none${isSvg ? "" : " rounded-sm"}`}
style={imageStyle}
draggable={false}
/>
@@ -1,5 +1,5 @@
import { Trash2 } from "lucide-react";
import { formatExifValue, SKIP_KEYS, UNSAFE_ROUND_TRIP_KEYS } from "@/lib/metadata-utils";
import { formatExifValue, SKIP_KEYS } from "@/lib/metadata-utils";
export function MetadataGrid({
data,
@@ -29,7 +29,7 @@ export function MetadataGrid({
>
{entries.map(([k, v]) => {
const isRemoved = removedKeys?.has(k);
const canRemove = onRemove && !UNSAFE_ROUND_TRIP_KEYS.has(k);
const canRemove = !!onRemove;
return (
<div key={k} className="contents">
<div
@@ -1,5 +1,14 @@
import { AlertTriangle, Download, Loader2, MapPin, PenLine } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
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";
@@ -12,9 +21,10 @@ interface InspectResult {
filename: string;
fileSize: number;
exif?: Record<string, unknown> | null;
exifError?: string;
iptc?: Record<string, unknown> | null;
xmp?: Record<string, unknown> | null;
gps?: Record<string, unknown> | null;
xmp?: Record<string, string> | null;
keywords?: string[];
}
interface FormFields {
@@ -25,6 +35,19 @@ interface FormFields {
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 = {
@@ -35,8 +58,41 @@ const EMPTY_FORM: FormFields = {
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<FormFields>;
}
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,
@@ -44,6 +100,8 @@ function LabeledInput({
onChange,
placeholder,
hint,
type = "text",
disabled,
}: {
label: string;
id: string;
@@ -51,6 +109,8 @@ function LabeledInput({
onChange: (v: string) => void;
placeholder?: string;
hint?: string;
type?: string;
disabled?: boolean;
}) {
return (
<div className="space-y-1">
@@ -59,11 +119,12 @@ function LabeledInput({
</label>
<input
id={id}
type="text"
type={type}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
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={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 && <p className="text-[10px] text-muted-foreground">{hint}</p>}
</div>
@@ -82,6 +143,9 @@ export function EditMetadataSettings() {
const [inspecting, setInspecting] = useState(false);
const [inspectError, setInspectError] = useState<string | null>(null);
const [inspectCache, setInspectCache] = useState<Map<string, InspectResult>>(new Map());
const [keywordInput, setKeywordInput] = useState("");
const [templates, setTemplates] = useState<Template[]>(loadTemplates);
const [templateName, setTemplateName] = useState("");
const currentFile = entries[selectedIndex]?.file ?? null;
const fileKey = currentFile
@@ -89,16 +153,32 @@ export function EditMetadataSettings() {
: null;
const populateForm = useCallback((data: InspectResult) => {
const exif = data.exif ?? {};
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, "DateTime"),
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);
@@ -167,168 +247,327 @@ export function EditMetadataSettings() {
});
};
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?._latitude as number | undefined;
const gpsLon = inspectData?.gps?._longitude as number | undefined;
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 exifEntryCount = inspectData?.exif
? Object.keys(inspectData.exif).filter((k) => !SKIP_KEYS.has(k) && !k.startsWith("_")).length
: 0;
const hasGps =
!!inspectData?.gps && Object.keys(inspectData.gps).filter((k) => !k.startsWith("_")).length > 0;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!hasFile || processing) return;
const settings: Record<string, unknown> = { clearGps: form.clearGps };
const settings: Record<string, unknown> = {};
const fieldMap: Array<{
formKey: keyof FormFields;
settingsKey: string;
exifTag: string;
}> = [
{ formKey: "artist", settingsKey: "artist", exifTag: "Artist" },
{ formKey: "copyright", settingsKey: "copyright", exifTag: "Copyright" },
{
formKey: "imageDescription",
settingsKey: "imageDescription",
exifTag: "ImageDescription",
},
{ formKey: "software", settingsKey: "software", exifTag: "Software" },
{ formKey: "dateTime", settingsKey: "dateTime", exifTag: "DateTime" },
{
formKey: "dateTimeOriginal",
settingsKey: "dateTimeOriginal",
exifTag: "DateTimeOriginal",
},
];
// 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();
const removeSet = new Set(fieldsToRemove);
for (const { formKey, settingsKey, exifTag } of fieldMap) {
const current = form[formKey] as string;
const initial = initialForm[formKey] as string;
if (current !== initial) {
if (current.trim()) {
settings[settingsKey] = current.trim();
removeSet.delete(exifTag);
} else {
removeSet.add(exifTag);
}
}
// 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();
}
if (removeSet.size > 0) {
settings.fieldsToRemove = Array.from(removeSet);
// 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);
}
processFiles(files, settings);
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Current Metadata */}
{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 && !inspecting && (
<div className="flex items-center gap-2 text-xs text-muted-foreground py-1">
<AlertTriangle className="h-3 w-3 shrink-0" />
Could not read metadata - fields will start empty.
</div>
)}
{inspectData && (
<div className="space-y-1.5">
{exifEntryCount > 0 && inspectData.exif ? (
<CollapsibleSection title="EXIF" badge={`${exifEntryCount} fields`}>
<MetadataGrid
data={inspectData.exif}
labelMap={EXIF_LABELS}
onRemove={toggleRemoveField}
removedKeys={fieldsToRemove}
/>
</CollapsibleSection>
) : (
<p className="text-[11px] text-muted-foreground italic">No EXIF data found.</p>
)}
{hasGps && inspectData.gps && (
<CollapsibleSection title="GPS" warning>
<MetadataGrid
data={Object.fromEntries(
Object.entries(inspectData.gps).filter(([k]) => !k.startsWith("_")),
)}
/>
</CollapsibleSection>
)}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-3">
{/* Inspect status */}
{hasFile && 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>
)}
{hasFile && inspectError && !inspecting && (
<div className="flex items-center gap-2 text-xs text-muted-foreground py-1">
<AlertTriangle className="h-3 w-3 shrink-0" />
Could not read metadata - fields will start empty.
</div>
)}
{/* Edit Fields */}
{/* Section 1: Basic Info */}
{hasFile && (
<div className="space-y-3">
<div className="border-t border-border" />
<p className="text-xs font-medium text-muted-foreground">Edit Fields</p>
<CollapsibleSection
title="Basic Info"
defaultOpen
badge={inspectData ? "EXIF/IPTC" : undefined}
>
<div className="space-y-2.5">
<LabeledInput
id="em-description"
label="Description"
value={form.imageDescription}
onChange={(v) => setField("imageDescription", v)}
placeholder="Image description"
/>
<LabeledInput
id="em-artist"
label="Artist"
value={form.artist}
onChange={(v) => setField("artist", v)}
placeholder="Photographer / creator name"
/>
<LabeledInput
id="em-copyright"
label="Copyright"
value={form.copyright}
onChange={(v) => setField("copyright", v)}
placeholder="2026 Example Corp"
/>
<LabeledInput
id="em-software"
label="Software"
value={form.software}
onChange={(v) => setField("software", v)}
placeholder="e.g. Lightroom, Photoshop"
/>
<div className="border-t border-border pt-2 mt-2">
<p className="text-[10px] font-medium text-muted-foreground mb-2">IPTC</p>
<div className="space-y-2.5">
<LabeledInput
id="em-iptc-title"
label="Title"
value={form.iptcTitle}
onChange={(v) => setField("iptcTitle", v)}
placeholder="Image title"
/>
<LabeledInput
id="em-iptc-headline"
label="Headline"
value={form.iptcHeadline}
onChange={(v) => setField("iptcHeadline", v)}
placeholder="Short headline"
/>
<LabeledInput
id="em-iptc-city"
label="City"
value={form.iptcCity}
onChange={(v) => setField("iptcCity", v)}
placeholder="City name"
/>
<LabeledInput
id="em-iptc-state"
label="State/Province"
value={form.iptcState}
onChange={(v) => setField("iptcState", v)}
placeholder="State or province"
/>
<LabeledInput
id="em-iptc-country"
label="Country"
value={form.iptcCountry}
onChange={(v) => setField("iptcCountry", v)}
placeholder="Country name"
/>
</div>
</div>
</div>
</CollapsibleSection>
)}
<LabeledInput
id="em-description"
label="Description"
value={form.imageDescription}
onChange={(v) => setField("imageDescription", v)}
placeholder="Image description"
/>
<LabeledInput
id="em-artist"
label="Artist"
value={form.artist}
onChange={(v) => setField("artist", v)}
placeholder="Photographer / creator name"
/>
<LabeledInput
id="em-copyright"
label="Copyright"
value={form.copyright}
onChange={(v) => setField("copyright", v)}
placeholder="2026 Example"
/>
<LabeledInput
id="em-software"
label="Software"
value={form.software}
onChange={(v) => setField("software", v)}
placeholder="e.g. Lightroom, Photoshop"
/>
<LabeledInput
id="em-datetime"
label="Date Modified"
value={form.dateTime}
onChange={(v) => setField("dateTime", v)}
placeholder="YYYY:MM:DD HH:MM:SS"
hint="EXIF date format: 2026:04:06 12:00:00"
/>
<LabeledInput
id="em-datetime-original"
label="Date Taken"
value={form.dateTimeOriginal}
onChange={(v) => setField("dateTimeOriginal", v)}
placeholder="YYYY:MM:DD HH:MM:SS"
/>
{/* Section 2: Date & Time */}
{hasFile && (
<CollapsibleSection title="Date & Time">
<div className="space-y-2.5">
<div className="flex gap-2">
<button
type="button"
onClick={() => setField("dateMode", "edit")}
className={`flex-1 text-xs py-1.5 rounded-md border ${form.dateMode === "edit" ? "bg-primary text-primary-foreground border-primary" : "border-input text-foreground"}`}
>
Edit Dates
</button>
<button
type="button"
onClick={() => setField("dateMode", "shift")}
className={`flex-1 text-xs py-1.5 rounded-md border ${form.dateMode === "shift" ? "bg-primary text-primary-foreground border-primary" : "border-input text-foreground"}`}
>
Shift All Dates
</button>
</div>
{/* GPS */}
<div className="space-y-2">
<div className="border-t border-border" />
{gpsCoords ? (
{form.dateMode === "edit" ? (
<>
<LabeledInput
id="em-datetime"
label="Date Modified"
value={form.dateTime}
onChange={(v) => setField("dateTime", v)}
placeholder="YYYY:MM:DD HH:MM:SS"
hint="EXIF format: 2026:04:11 12:00:00"
/>
<LabeledInput
id="em-datetime-original"
label="Date Taken"
value={form.dateTimeOriginal}
onChange={(v) => setField("dateTimeOriginal", v)}
placeholder="YYYY:MM:DD HH:MM:SS"
/>
</>
) : (
<div className="space-y-2">
<p className="text-[10px] text-muted-foreground">
Shift all date fields by an offset (useful for timezone corrections)
</p>
<div className="flex gap-2 items-end">
<div className="space-y-1">
<label className="text-xs font-medium text-foreground">Direction</label>
<select
value={form.dateShiftDirection}
onChange={(e) => setField("dateShiftDirection", e.target.value as "+" | "-")}
className="px-2.5 py-1.5 rounded-md border border-input bg-background text-sm"
>
<option value="+">+ Forward</option>
<option value="-">- Backward</option>
</select>
</div>
<div className="flex-1">
<LabeledInput
id="em-date-shift"
label="Hours:Minutes"
value={form.dateShiftValue}
onChange={(v) => setField("dateShiftValue", v)}
placeholder="1:30"
hint="e.g. 1:30 for 1 hour 30 minutes"
/>
</div>
</div>
</div>
)}
</div>
</CollapsibleSection>
)}
{/* Section 3: Location (GPS) */}
{hasFile && (
<CollapsibleSection title="Location (GPS)" warning={!!gpsCoords}>
<div className="space-y-2.5">
{gpsCoords && (
<div className="flex items-start gap-2 px-2.5 py-2 rounded-md bg-amber-500/10 border border-amber-500/20">
<MapPin className="h-3.5 w-3.5 text-amber-500 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
@@ -336,26 +575,226 @@ export function EditMetadataSettings() {
Location data found
</p>
<p className="text-[10px] text-muted-foreground font-mono">
{gpsCoords.lat.toFixed(5)}, {gpsCoords.lon.toFixed(5)}
{gpsCoords.lat.toFixed(6)}, {gpsCoords.lon.toFixed(6)}
</p>
</div>
</div>
) : (
<p className="text-[11px] text-muted-foreground italic">No GPS data in this image.</p>
)}
<label className="flex items-center gap-2 text-sm text-foreground">
{!gpsCoords && (
<p className="text-[11px] text-muted-foreground italic">
No GPS data. Add coordinates below.
</p>
)}
<LabeledInput
id="em-gps-lat"
label="Latitude"
type="number"
value={form.gpsLatitude}
onChange={(v) => setField("gpsLatitude", v)}
placeholder="-90 to 90 (e.g. 51.5074)"
disabled={form.clearGps}
hint="Decimal degrees. Negative = South"
/>
<LabeledInput
id="em-gps-lon"
label="Longitude"
type="number"
value={form.gpsLongitude}
onChange={(v) => setField("gpsLongitude", v)}
placeholder="-180 to 180 (e.g. -0.1278)"
disabled={form.clearGps}
hint="Decimal degrees. Negative = West"
/>
<LabeledInput
id="em-gps-alt"
label="Altitude (meters)"
type="number"
value={form.gpsAltitude}
onChange={(v) => setField("gpsAltitude", v)}
placeholder="Optional (e.g. 25)"
disabled={form.clearGps}
/>
<label className="flex items-center gap-2 text-xs text-foreground pt-1">
<input
type="checkbox"
checked={form.clearGps}
onChange={(e) => setField("clearGps", e.target.checked)}
className="rounded"
/>
Remove GPS location data
Remove all GPS data
</label>
</div>
</CollapsibleSection>
)}
{/* Section 4: Keywords */}
{hasFile && (
<CollapsibleSection
title="Keywords"
badge={form.keywords.length > 0 ? `${form.keywords.length}` : undefined}
>
<div className="space-y-2.5">
{form.keywords.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{form.keywords.map((kw) => (
<span
key={kw}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-primary text-[11px]"
>
{kw}
<button
type="button"
onClick={() => removeKeyword(kw)}
className="hover:text-red-500"
>
<X className="h-2.5 w-2.5" />
</button>
</span>
))}
</div>
)}
<div className="flex gap-1.5">
<input
type="text"
value={keywordInput}
onChange={(e) => 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"
/>
<button
type="button"
onClick={addKeyword}
className="px-2 py-1.5 rounded-md border border-input hover:bg-muted/50"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex gap-2">
<label className="flex items-center gap-1.5 text-[11px] text-foreground">
<input
type="radio"
name="kw-mode"
checked={form.keywordsMode === "add"}
onChange={() => setField("keywordsMode", "add")}
/>
Add to existing
</label>
<label className="flex items-center gap-1.5 text-[11px] text-foreground">
<input
type="radio"
name="kw-mode"
checked={form.keywordsMode === "set"}
onChange={() => setField("keywordsMode", "set")}
/>
Replace all
</label>
</div>
</div>
</CollapsibleSection>
)}
{/* Section 5: All Metadata (raw view) */}
{hasFile && inspectData && (
<CollapsibleSection title="All Metadata">
<div className="space-y-2">
{inspectData.exif && Object.keys(inspectData.exif).length > 0 && (
<CollapsibleSection
title="EXIF"
badge={`${Object.keys(inspectData.exif).filter((k) => !SKIP_KEYS.has(k)).length}`}
>
<MetadataGrid
data={inspectData.exif}
labelMap={EXIF_LABELS}
onRemove={toggleRemoveField}
removedKeys={fieldsToRemove}
/>
</CollapsibleSection>
)}
{inspectData.iptc && Object.keys(inspectData.iptc).length > 0 && (
<CollapsibleSection title="IPTC" badge={`${Object.keys(inspectData.iptc).length}`}>
<MetadataGrid
data={inspectData.iptc}
labelMap={EXIF_LABELS}
onRemove={toggleRemoveField}
removedKeys={fieldsToRemove}
/>
</CollapsibleSection>
)}
{inspectData.xmp && Object.keys(inspectData.xmp).length > 0 && (
<CollapsibleSection title="XMP" badge={`${Object.keys(inspectData.xmp).length}`}>
<MetadataGrid
data={inspectData.xmp}
labelMap={EXIF_LABELS}
onRemove={toggleRemoveField}
removedKeys={fieldsToRemove}
/>
</CollapsibleSection>
)}
{inspectData.gps && Object.keys(inspectData.gps).length > 0 && (
<CollapsibleSection title="GPS">
<MetadataGrid data={inspectData.gps} labelMap={EXIF_LABELS} />
</CollapsibleSection>
)}
</div>
</CollapsibleSection>
)}
{/* Section 6: Templates */}
{hasFile && (
<div className="space-y-2 border-t border-border pt-3">
<p className="text-xs font-medium text-muted-foreground">Templates</p>
{templates.length > 0 && (
<div className="space-y-1">
{templates.map((t) => (
<div key={t.name} className="flex items-center gap-1.5">
<button
type="button"
onClick={() => loadTemplate(t.name)}
className="flex-1 text-left text-xs px-2 py-1 rounded-md border border-input hover:bg-muted/50 truncate"
>
{t.name}
</button>
<button
type="button"
onClick={() => deleteTemplate(t.name)}
className="p-1 text-muted-foreground hover:text-red-500"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
<div className="flex gap-1.5">
<input
type="text"
value={templateName}
onChange={(e) => 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"
/>
<button
type="button"
onClick={saveTemplate}
disabled={!templateName.trim()}
className="px-2 py-1.5 rounded-md border border-input hover:bg-muted/50 disabled:opacity-50"
title="Save current values as template"
>
<BookmarkPlus className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
{/* No file placeholder */}
{!hasFile && (
<div className="flex flex-col items-center gap-2 py-6 text-center text-muted-foreground">
<PenLine className="h-8 w-8 opacity-30" />
@@ -365,6 +804,18 @@ export function EditMetadataSettings() {
{error && <p className="text-xs text-red-500">{error}</p>}
{/* Changes summary */}
{hasFile && changes.total > 0 && !processing && (
<div className="text-[11px] text-muted-foreground bg-muted/30 px-2.5 py-2 rounded-md">
<span className="font-medium text-foreground">{changes.total} changes:</span>{" "}
{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"}
</div>
)}
{originalSize != null && processedSize != null && (
<div className="text-xs text-muted-foreground space-y-0.5">
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
@@ -1,5 +1,7 @@
import { Download, Loader2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Download } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { ProgressCard } from "@/components/common/progress-card";
import { formatHeaders } from "@/lib/api";
import { useFileStore } from "@/stores/file-store";
@@ -16,21 +18,14 @@ function PdfPagePreview({
pageSize,
orientation,
margin,
file,
imgUrl,
}: {
pageSize: string;
orientation: "portrait" | "landscape";
margin: number;
file: File | null;
imgUrl: string | null;
}) {
const [imgSize, setImgSize] = useState<{ w: number; h: number } | null>(null);
const imgUrl = useMemo(() => (file ? URL.createObjectURL(file) : null), [file]);
useEffect(() => {
return () => {
if (imgUrl) URL.revokeObjectURL(imgUrl);
};
}, [imgUrl]);
useEffect(() => {
if (!imgUrl) {
@@ -39,6 +34,7 @@ function PdfPagePreview({
}
const img = new Image();
img.onload = () => setImgSize({ w: img.naturalWidth, h: img.naturalHeight });
img.onerror = () => setImgSize(null);
img.src = imgUrl;
}, [imgUrl]);
@@ -110,45 +106,125 @@ function PdfPagePreview({
);
}
export function ImageToPdfSettings() {
const { files, selectedIndex, processing, error, setProcessing, setError } = useFileStore();
const { files, selectedIndex, entries, error, setProcessing, setError } = useFileStore();
const [pageSize, setPageSize] = useState<"A4" | "Letter" | "A3" | "A5">("A4");
const [orientation, setOrientation] = useState<"portrait" | "landscape">("portrait");
const [margin, setMargin] = useState(20);
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
const handleProcess = async () => {
// Local processing state so the ProgressCard renders reliably
const [busy, setBusy] = useState(false);
const [progress, setProgress] = useState({
phase: "idle" as "idle" | "uploading" | "processing" | "complete",
percent: 0,
elapsed: 0,
});
const elapsedRef = useRef<ReturnType<typeof setInterval> | null>(null);
const processingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const xhrRef = useRef<XMLHttpRequest | null>(null);
useEffect(() => {
return () => {
if (elapsedRef.current) clearInterval(elapsedRef.current);
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
if (xhrRef.current) xhrRef.current.abort();
};
}, []);
const cleanup = () => {
if (elapsedRef.current) clearInterval(elapsedRef.current);
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
elapsedRef.current = null;
processingTimerRef.current = null;
setBusy(false);
setProcessing(false);
};
const handleProcess = useCallback(() => {
if (files.length === 0) return;
setProcessing(true);
setError(null);
setDownloadUrl(null);
// flushSync forces React to paint the ProgressCard before the XHR starts,
// so users always see feedback even if the request completes quickly.
flushSync(() => {
setBusy(true);
setProcessing(true);
setError(null);
setDownloadUrl(null);
setProgress({ phase: "uploading", percent: 0, elapsed: 0 });
});
try {
const formData = new FormData();
for (const file of files) {
formData.append("file", file);
}
formData.append("settings", JSON.stringify({ pageSize, orientation, margin }));
const startTime = Date.now();
elapsedRef.current = setInterval(() => {
setProgress((prev) => ({ ...prev, elapsed: Math.floor((Date.now() - startTime) / 1000) }));
}, 1000);
const res = await fetch("/api/v1/tools/image-to-pdf", {
method: "POST",
headers: formatHeaders(),
body: formData,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Failed: ${res.status}`);
}
const result = await res.json();
setDownloadUrl(result.downloadUrl);
} catch (err) {
setError(err instanceof Error ? err.message : "PDF creation failed");
} finally {
setProcessing(false);
const formData = new FormData();
for (const file of files) {
formData.append("file", file);
}
};
formData.append("settings", JSON.stringify({ pageSize, orientation, margin }));
const xhr = new XMLHttpRequest();
xhrRef.current = xhr;
xhr.timeout = 180_000;
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const uploadPercent = (event.loaded / event.total) * 40;
setProgress((prev) =>
prev.phase === "uploading" ? { ...prev, percent: uploadPercent } : prev,
);
}
};
xhr.upload.onload = () => {
setProgress((prev) => ({ ...prev, phase: "processing", percent: 40 }));
const step = (95 - 40) / 90;
processingTimerRef.current = setInterval(() => {
setProgress((prev) => {
if (prev.phase !== "processing") return prev;
return { ...prev, percent: Math.min(95, prev.percent + step) };
});
}, 500);
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const result = JSON.parse(xhr.responseText);
setDownloadUrl(result.downloadUrl);
setProgress((prev) => ({ ...prev, phase: "complete", percent: 100 }));
} catch {
setError("Failed to parse server response");
}
} else {
try {
const body = JSON.parse(xhr.responseText);
setError(body.error || `Failed: ${xhr.status}`);
} catch {
setError(`PDF creation failed: ${xhr.status}`);
}
}
cleanup();
};
xhr.onerror = () => {
setError("Network error during PDF creation");
cleanup();
};
xhr.ontimeout = () => {
setError("Request timed out - the server may be overloaded");
cleanup();
};
xhr.open("POST", "/api/v1/tools/image-to-pdf");
const headers = formatHeaders();
for (const [key, value] of Object.entries(headers)) {
xhr.setRequestHeader(key, value as string);
}
xhr.send(formData);
}, [files, pageSize, orientation, margin, setProcessing, setError]);
const hasFiles = files.length > 0;
@@ -218,21 +294,35 @@ export function ImageToPdfSettings() {
pageSize={pageSize}
orientation={orientation}
margin={margin}
file={files.length > 0 ? (files[selectedIndex] ?? files[0]) : null}
imgUrl={entries[selectedIndex]?.blobUrl ?? entries[0]?.blobUrl ?? null}
/>
{error && <p className="text-xs text-red-500">{error}</p>}
<button
type="button"
data-testid="image-to-pdf-submit"
onClick={handleProcess}
disabled={!hasFiles || 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"
>
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
{processing ? "Creating PDF..." : `Create PDF (${files.length} pages)`}
</button>
{busy ? (
<ProgressCard
active={busy}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Creating PDF"
stage={
progress.phase === "uploading"
? "Uploading images..."
: `Processing ${files.length} pages...`
}
percent={progress.percent}
elapsed={progress.elapsed}
/>
) : (
<button
type="button"
data-testid="image-to-pdf-submit"
onClick={handleProcess}
disabled={!hasFiles || busy}
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"
>
Create PDF ({files.length} pages)
</button>
)}
{downloadUrl && (
<a
@@ -1,3 +1,5 @@
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";
@@ -8,6 +10,47 @@ 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;
@@ -58,7 +101,7 @@ export function StripMetadataControls({
return (
<>
{/* Strip All */}
{/* Remove All */}
<label className="flex items-center gap-2 text-sm text-foreground font-medium">
<input
type="checkbox"
@@ -66,7 +109,7 @@ export function StripMetadataControls({
onChange={(e) => handleStripAllChange(e.target.checked)}
className="rounded"
/>
Strip All Metadata
Remove All Metadata
</label>
<div className="border-t border-border" />
@@ -256,13 +299,20 @@ export function StripMetadataSettings() {
{metadata && hasAnyMetadata && (
<div className="space-y-1.5">
{/* GPS warning banner */}
{/* GPS warning banner + map */}
{hasGps && gpsLat != null && gpsLon != null && (
<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(4)}, {gpsLon.toFixed(4)}
</span>
<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>
)}
@@ -342,7 +392,7 @@ export function StripMetadataSettings() {
<ProgressCard
active={processing}
phase={progress.phase === "idle" ? "uploading" : progress.phase}
label="Stripping metadata"
label="Removing metadata"
stage={progress.stage}
percent={progress.percent}
elapsed={progress.elapsed}
@@ -354,7 +404,7 @@ export function StripMetadataSettings() {
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"
>
Strip Metadata
Remove Metadata
</button>
)}
+45 -29
View File
@@ -4,13 +4,18 @@ export const EXIF_LABELS: Record<string, string> = {
Model: "Camera Model",
Software: "Software",
DateTime: "Date/Time",
ModifyDate: "Date Modified",
DateTimeOriginal: "Date Taken",
CreateDate: "Date Created",
DateTimeDigitized: "Date Digitized",
ExposureTime: "Exposure Time",
FNumber: "F-Number",
ISO: "ISO",
ISOSpeedRatings: "ISO",
FocalLength: "Focal Length",
FocalLengthIn35mmFormat: "Focal Length (35mm)",
FocalLengthIn35mmFilm: "Focal Length (35mm)",
ExposureCompensation: "Exposure Bias",
ExposureBiasValue: "Exposure Bias",
MeteringMode: "Metering Mode",
Flash: "Flash",
@@ -22,12 +27,15 @@ export const EXIF_LABELS: Record<string, string> = {
Sharpness: "Sharpness",
DigitalZoomRatio: "Digital Zoom",
ImageWidth: "Width",
ImageHeight: "Height",
ImageLength: "Height",
Orientation: "Orientation",
XResolution: "X Resolution",
YResolution: "Y Resolution",
ResolutionUnit: "Resolution Unit",
ColorSpace: "Color Space",
ExifImageWidth: "Pixel Width",
ExifImageHeight: "Pixel Height",
PixelXDimension: "Pixel Width",
PixelYDimension: "Pixel Height",
Artist: "Artist",
@@ -35,39 +43,48 @@ export const EXIF_LABELS: Record<string, string> = {
ImageDescription: "Description",
LensMake: "Lens Make",
LensModel: "Lens Model",
LensInfo: "Lens Info",
BodySerialNumber: "Body Serial",
CameraOwnerName: "Camera Owner",
// IPTC
ObjectName: "Title",
Headline: "Headline",
Keywords: "Keywords",
City: "City",
"Province-State": "State/Province",
"Country-PrimaryLocationName": "Country",
CopyrightNotice: "Copyright Notice",
"By-line": "Creator",
Caption: "Caption",
// XMP
Subject: "Subject/Keywords",
Title: "Title",
Description: "Description",
Creator: "Creator",
Rights: "Rights",
};
/** Keys to skip in display (internal/binary/redundant) */
export const SKIP_KEYS = new Set([
"ExifTag",
"GPSTag",
"InteroperabilityTag",
"ExifToolVersion",
"FileName",
"Directory",
"FileSize",
"FileModifyDate",
"FileAccessDate",
"FileInodeChangeDate",
"FilePermissions",
"FileType",
"FileTypeExtension",
"MIMEType",
"SourceFile",
"ExifByteOrder",
"ThumbnailImage",
"ThumbnailOffset",
"ThumbnailLength",
"PreviewImage",
"MakerNote",
"PrintImageMatching",
"ComponentsConfiguration",
"FlashpixVersion",
"ExifVersion",
"FileSource",
"SceneType",
"UserComment",
"InteroperabilityIndex",
"InteroperabilityVersion",
]);
/** Keys that are binary/complex and NOT safe for EXIF round-trip via withExif() */
export const UNSAFE_ROUND_TRIP_KEYS = new Set([
"MakerNote",
"PrintImageMatching",
"ComponentsConfiguration",
"FlashpixVersion",
"ExifVersion",
"FileSource",
"SceneType",
"UserComment",
"InteroperabilityIndex",
"InteroperabilityVersion",
]);
export function formatExifValue(key: string, value: unknown): string {
@@ -78,13 +95,12 @@ export function formatExifValue(key: string, value: unknown): string {
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`;
if (key === "FocalLength" || key === "FocalLengthIn35mmFormat") return `${value}mm`;
return String(value);
}
if (Array.isArray(value)) {
if (typeof value[0] === "number" && value.length <= 4) {
return value.join(", ");
if (value.length <= 6) {
return value.map(String).join(", ");
}
return `[${value.length} values]`;
}