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 "";
+}