refactor: extract shared metadata UI components from strip-metadata

This commit is contained in:
Siddharth Kumar Sah
2026-04-06 19:30:47 +08:00
parent 5284d5ece5
commit d8bb2f1d61
4 changed files with 215 additions and 155 deletions
@@ -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>
);
}
@@ -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 */
+99
View File
@@ -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 "";
}