diff --git a/apps/web/src/components/common/collapsible-section.tsx b/apps/web/src/components/common/collapsible-section.tsx new file mode 100644 index 00000000..20e26d23 --- /dev/null +++ b/apps/web/src/components/common/collapsible-section.tsx @@ -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 ( +
+ + {open &&
{children}
} +
+ ); +} diff --git a/apps/web/src/components/common/metadata-grid.tsx b/apps/web/src/components/common/metadata-grid.tsx new file mode 100644 index 00000000..31db3cea --- /dev/null +++ b/apps/web/src/components/common/metadata-grid.tsx @@ -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; + labelMap?: Record; + onRemove?: (key: string) => void; + removedKeys?: Set; +}) { + 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

; + } + + const hasRemoveColumn = !!onRemove; + + return ( +
+ {entries.map(([k, v]) => { + const isRemoved = removedKeys?.has(k); + const canRemove = onRemove && !UNSAFE_ROUND_TRIP_KEYS.has(k); + return ( +
+
+ {labelMap?.[k] ?? k} +
+
+ {formatExifValue(k, v)} +
+ {hasRemoveColumn && ( +
+ {canRemove ? ( + + ) : ( +
+ )} +
+ )} +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/components/tools/strip-metadata-settings.tsx b/apps/web/src/components/tools/strip-metadata-settings.tsx index aac6f3a0..ebe65b2f 100644 --- a/apps/web/src/components/tools/strip-metadata-settings.tsx +++ b/apps/web/src/components/tools/strip-metadata-settings.tsx @@ -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 | 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)} -
-
- ))} -
- ); -} - interface StripMetadataControlsProps { onChange?: (settings: Record) => void; /** Passed from parent to preserve field-count badges in checkbox labels */ diff --git a/apps/web/src/lib/metadata-utils.ts b/apps/web/src/lib/metadata-utils.ts new file mode 100644 index 00000000..93db39c9 --- /dev/null +++ b/apps/web/src/lib/metadata-utils.ts @@ -0,0 +1,99 @@ +/** Human-friendly labels for common EXIF keys */ +export 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) */ +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 | null | undefined, key: string): string { + const v = exif?.[key]; + if (typeof v === "string") return v; + if (typeof v === "number") return String(v); + return ""; +}