mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
refactor: extract shared metadata UI components from strip-metadata
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 { 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 { ProgressCard } from "@/components/common/progress-card";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
import { formatHeaders } from "@/lib/api";
|
import { formatHeaders } from "@/lib/api";
|
||||||
|
import { EXIF_LABELS, SKIP_KEYS } from "@/lib/metadata-utils";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
interface MetadataResult {
|
interface MetadataResult {
|
||||||
@@ -15,160 +18,6 @@ interface MetadataResult {
|
|||||||
xmp?: Record<string, string> | null;
|
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 {
|
interface StripMetadataControlsProps {
|
||||||
onChange?: (settings: Record<string, unknown>) => void;
|
onChange?: (settings: Record<string, unknown>) => void;
|
||||||
/** Passed from parent to preserve field-count badges in checkbox labels */
|
/** 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 "";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user