mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Merge pull request #22 from stirling-image/feat/edit-metadata
feat: add Edit Metadata tool
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { AlertTriangle, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export 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 (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-medium text-foreground hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 text-left">{title}</span>
|
||||
{warning && <AlertTriangle className="h-3 w-3 text-amber-500 shrink-0" />}
|
||||
{badge && (
|
||||
<span className="px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-[10px]">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{open && <div className="px-3 pb-2">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { formatExifValue, SKIP_KEYS, UNSAFE_ROUND_TRIP_KEYS } from "@/lib/metadata-utils";
|
||||
|
||||
export function MetadataGrid({
|
||||
data,
|
||||
labelMap,
|
||||
onRemove,
|
||||
removedKeys,
|
||||
}: {
|
||||
data: Record<string, unknown>;
|
||||
labelMap?: Record<string, string>;
|
||||
onRemove?: (key: string) => void;
|
||||
removedKeys?: Set<string>;
|
||||
}) {
|
||||
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 <p className="text-[10px] text-muted-foreground italic">No data</p>;
|
||||
}
|
||||
|
||||
const hasRemoveColumn = !!onRemove;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`grid ${hasRemoveColumn ? "grid-cols-[minmax(0,2fr)_minmax(0,3fr)_auto]" : "grid-cols-[minmax(0,2fr)_minmax(0,3fr)]"} gap-x-2 gap-y-0.5`}
|
||||
>
|
||||
{entries.map(([k, v]) => {
|
||||
const isRemoved = removedKeys?.has(k);
|
||||
const canRemove = onRemove && !UNSAFE_ROUND_TRIP_KEYS.has(k);
|
||||
return (
|
||||
<div key={k} className="contents">
|
||||
<div
|
||||
className={`text-[10px] text-muted-foreground truncate ${isRemoved ? "line-through opacity-50" : ""}`}
|
||||
title={k}
|
||||
>
|
||||
{labelMap?.[k] ?? k}
|
||||
</div>
|
||||
<div
|
||||
className={`text-[10px] text-foreground font-mono truncate ${isRemoved ? "line-through opacity-50" : ""}`}
|
||||
title={formatExifValue(k, v)}
|
||||
>
|
||||
{formatExifValue(k, v)}
|
||||
</div>
|
||||
{hasRemoveColumn && (
|
||||
<div className="flex items-center">
|
||||
{canRemove ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(k)}
|
||||
className={`p-0.5 rounded hover:bg-muted/50 transition-colors ${isRemoved ? "text-red-500" : "text-muted-foreground hover:text-red-500"}`}
|
||||
title={
|
||||
isRemoved ? `Restore ${labelMap?.[k] ?? k}` : `Remove ${labelMap?.[k] ?? k}`
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-3.5" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import { AlertTriangle, Download, Loader2, MapPin, PenLine } from "lucide-react";
|
||||
import { useCallback, useEffect, 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<string, unknown> | null;
|
||||
exifError?: string;
|
||||
gps?: Record<string, unknown> | null;
|
||||
xmp?: Record<string, string> | null;
|
||||
}
|
||||
|
||||
interface FormFields {
|
||||
artist: string;
|
||||
copyright: string;
|
||||
imageDescription: string;
|
||||
software: string;
|
||||
dateTime: string;
|
||||
dateTimeOriginal: string;
|
||||
clearGps: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormFields = {
|
||||
artist: "",
|
||||
copyright: "",
|
||||
imageDescription: "",
|
||||
software: "",
|
||||
dateTime: "",
|
||||
dateTimeOriginal: "",
|
||||
clearGps: false,
|
||||
};
|
||||
|
||||
function LabeledInput({
|
||||
label,
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
hint,
|
||||
}: {
|
||||
label: string;
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label htmlFor={id} className="text-xs font-medium text-foreground">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
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"
|
||||
/>
|
||||
{hint && <p className="text-[10px] text-muted-foreground">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditMetadataSettings() {
|
||||
const { entries, selectedIndex, files } = useFileStore();
|
||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||
useToolProcessor("edit-metadata");
|
||||
|
||||
const [form, setForm] = useState<FormFields>(EMPTY_FORM);
|
||||
const [initialForm, setInitialForm] = useState<FormFields>(EMPTY_FORM);
|
||||
const [fieldsToRemove, setFieldsToRemove] = useState<Set<string>>(new Set());
|
||||
const [inspectData, setInspectData] = useState<InspectResult | null>(null);
|
||||
const [inspecting, setInspecting] = useState(false);
|
||||
const [inspectError, setInspectError] = useState<string | null>(null);
|
||||
const [inspectCache, setInspectCache] = useState<Map<string, InspectResult>>(new Map());
|
||||
|
||||
const currentFile = entries[selectedIndex]?.file ?? null;
|
||||
const fileKey = currentFile
|
||||
? `${currentFile.name}-${currentFile.size}-${currentFile.lastModified}`
|
||||
: null;
|
||||
|
||||
const populateForm = useCallback((data: InspectResult) => {
|
||||
const exif = data.exif ?? {};
|
||||
setInspectData(data);
|
||||
const populated: FormFields = {
|
||||
artist: exifStr(exif, "Artist"),
|
||||
copyright: exifStr(exif, "Copyright"),
|
||||
imageDescription: exifStr(exif, "ImageDescription"),
|
||||
software: exifStr(exif, "Software"),
|
||||
dateTime: exifStr(exif, "DateTime"),
|
||||
dateTimeOriginal: exifStr(exif, "DateTimeOriginal"),
|
||||
clearGps: false,
|
||||
};
|
||||
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 = <K extends keyof FormFields>(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 hasFile = files.length > 0;
|
||||
const gpsLat = inspectData?.gps?._latitude as number | undefined;
|
||||
const gpsLon = inspectData?.gps?._longitude 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 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",
|
||||
},
|
||||
];
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (removeSet.size > 0) {
|
||||
settings.fieldsToRemove = Array.from(removeSet);
|
||||
}
|
||||
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Fields */}
|
||||
{hasFile && (
|
||||
<div className="space-y-3">
|
||||
<div className="border-t border-border" />
|
||||
<p className="text-xs font-medium text-muted-foreground">Edit Fields</p>
|
||||
|
||||
<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"
|
||||
/>
|
||||
|
||||
{/* GPS */}
|
||||
<div className="space-y-2">
|
||||
<div className="border-t border-border" />
|
||||
{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">
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400 font-medium">
|
||||
Location data found
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground font-mono">
|
||||
{gpsCoords.lat.toFixed(5)}, {gpsCoords.lon.toFixed(5)}
|
||||
</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">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.clearGps}
|
||||
onChange={(e) => setField("clearGps", e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Remove GPS location data
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!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" />
|
||||
<p className="text-sm">Upload an image to edit its metadata.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label="Writing metadata"
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="edit-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"
|
||||
>
|
||||
Apply Metadata
|
||||
</button>
|
||||
)}
|
||||
|
||||
{downloadUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
data-testid="edit-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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { AlertTriangle, ChevronDown, ChevronRight, Download, Loader2, MapPin } from "lucide-react";
|
||||
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";
|
||||
|
||||
interface MetadataResult {
|
||||
@@ -15,160 +18,6 @@ interface MetadataResult {
|
||||
xmp?: Record<string, string> | null;
|
||||
}
|
||||
|
||||
/** Human-friendly labels for common EXIF keys */
|
||||
const EXIF_LABELS: Record<string, string> = {
|
||||
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 (
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-medium text-foreground hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 text-left">{title}</span>
|
||||
{warning && <AlertTriangle className="h-3 w-3 text-amber-500 shrink-0" />}
|
||||
{badge && (
|
||||
<span className="px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-[10px]">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{open && <div className="px-3 pb-2">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetadataGrid({
|
||||
data,
|
||||
labelMap,
|
||||
}: {
|
||||
data: Record<string, unknown>;
|
||||
labelMap?: Record<string, string>;
|
||||
}) {
|
||||
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 <p className="text-[10px] text-muted-foreground italic">No data</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[minmax(0,2fr)_minmax(0,3fr)] gap-x-2 gap-y-0.5">
|
||||
{entries.map(([k, v]) => (
|
||||
<div key={k} className="contents">
|
||||
<div className="text-[10px] text-muted-foreground truncate" title={k}>
|
||||
{labelMap?.[k] ?? k}
|
||||
</div>
|
||||
<div
|
||||
className="text-[10px] text-foreground font-mono truncate"
|
||||
title={formatExifValue(k, v)}
|
||||
>
|
||||
{formatExifValue(k, v)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StripMetadataControlsProps {
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
/** Passed from parent to preserve field-count badges in checkbox labels */
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/** Human-friendly labels for common EXIF keys */
|
||||
export const EXIF_LABELS: Record<string, string> = {
|
||||
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) */
|
||||
export const SKIP_KEYS = new Set([
|
||||
"ExifTag",
|
||||
"GPSTag",
|
||||
"InteroperabilityTag",
|
||||
"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 {
|
||||
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);
|
||||
}
|
||||
|
||||
export function exifStr(exif: Record<string, unknown> | null | undefined, key: string): string {
|
||||
const v = exif?.[key];
|
||||
if (typeof v === "string") return v;
|
||||
if (typeof v === "number") return String(v);
|
||||
return "";
|
||||
}
|
||||
@@ -81,6 +81,11 @@ const StripMetadataSettings = lazy(() =>
|
||||
default: m.StripMetadataSettings,
|
||||
})),
|
||||
);
|
||||
const EditMetadataSettings = lazy(() =>
|
||||
import("@/components/tools/edit-metadata-settings").then((m) => ({
|
||||
default: m.EditMetadataSettings,
|
||||
})),
|
||||
);
|
||||
const ColorSettings = lazy(() =>
|
||||
import("@/components/tools/color-settings").then((m) => ({ default: m.ColorSettings })),
|
||||
);
|
||||
@@ -238,6 +243,7 @@ export const toolRegistry = new Map<string, ToolRegistryEntry>([
|
||||
["convert", { displayMode: "no-comparison", Settings: ConvertSettings }],
|
||||
["compress", { displayMode: "before-after", Settings: CompressSettings }],
|
||||
["strip-metadata", { displayMode: "no-comparison", Settings: StripMetadataSettings }],
|
||||
["edit-metadata", { displayMode: "no-comparison", Settings: EditMetadataSettings }],
|
||||
|
||||
// Color adjustments (all share ColorSettings with different toolId)
|
||||
...(["brightness-contrast", "saturation", "color-channels", "color-effects"] as const).map(
|
||||
|
||||
Reference in New Issue
Block a user