mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: multi-file metadata display with per-file caching
This commit is contained in:
@@ -1,11 +1,25 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
import { Download } from "lucide-react";
|
import { Download, Loader2, ChevronDown, ChevronRight, AlertTriangle } from "lucide-react";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
|
|
||||||
|
function getToken(): string {
|
||||||
|
return localStorage.getItem("stirling-token") || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetadataResult {
|
||||||
|
filename: string;
|
||||||
|
fileSize: number;
|
||||||
|
exif?: Record<string, unknown> | null;
|
||||||
|
exifError?: string;
|
||||||
|
gps?: Record<string, unknown> | null;
|
||||||
|
icc?: Record<string, string> | null;
|
||||||
|
xmp?: Record<string, string> | null;
|
||||||
|
}
|
||||||
|
|
||||||
export function StripMetadataSettings() {
|
export function StripMetadataSettings() {
|
||||||
const { files } = useFileStore();
|
const { entries, selectedIndex, files } = useFileStore();
|
||||||
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } =
|
||||||
useToolProcessor("strip-metadata");
|
useToolProcessor("strip-metadata");
|
||||||
|
|
||||||
@@ -15,6 +29,74 @@ export function StripMetadataSettings() {
|
|||||||
const [stripIcc, setStripIcc] = useState(false);
|
const [stripIcc, setStripIcc] = useState(false);
|
||||||
const [stripXmp, setStripXmp] = useState(false);
|
const [stripXmp, setStripXmp] = useState(false);
|
||||||
|
|
||||||
|
// Metadata inspection state
|
||||||
|
const [metadataCache, setMetadataCache] = useState<Map<string, MetadataResult>>(new Map());
|
||||||
|
const [metadata, setMetadata] = useState<MetadataResult | null>(null);
|
||||||
|
const [inspecting, setInspecting] = useState(false);
|
||||||
|
const [inspectError, setInspectError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Collapsible sections
|
||||||
|
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const currentFile = entries[selectedIndex]?.file ?? null;
|
||||||
|
const fileKey = currentFile ? `${currentFile.name}-${currentFile.size}-${currentFile.lastModified}` : null;
|
||||||
|
|
||||||
|
// Auto-fetch metadata for the selected file
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentFile || !fileKey) {
|
||||||
|
setMetadata(null);
|
||||||
|
setInspectError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
const cached = metadataCache.get(fileKey);
|
||||||
|
if (cached) {
|
||||||
|
setMetadata(cached);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
(async () => {
|
||||||
|
setInspecting(true);
|
||||||
|
setInspectError(null);
|
||||||
|
setMetadata(null);
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", currentFile);
|
||||||
|
const res = await fetch("/api/v1/tools/strip-metadata/inspect", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${getToken()}` },
|
||||||
|
body: formData,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || `Failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
const data: MetadataResult = await res.json();
|
||||||
|
setMetadata(data);
|
||||||
|
setMetadataCache((prev) => new Map(prev).set(fileKey!, data));
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).name === "AbortError") return;
|
||||||
|
setInspectError(err instanceof Error ? err.message : "Failed to inspect metadata");
|
||||||
|
} finally {
|
||||||
|
setInspecting(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [currentFile, fileKey]);
|
||||||
|
|
||||||
|
const toggleSection = (section: string) => {
|
||||||
|
setExpandedSections((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(section)) next.delete(section);
|
||||||
|
else next.add(section);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleStripAllChange = (checked: boolean) => {
|
const handleStripAllChange = (checked: boolean) => {
|
||||||
setStripAll(checked);
|
setStripAll(checked);
|
||||||
if (checked) {
|
if (checked) {
|
||||||
@@ -36,8 +118,77 @@ export function StripMetadataSettings() {
|
|||||||
if (hasFile && !processing) handleProcess();
|
if (hasFile && !processing) handleProcess();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasGps = metadata?.gps && Object.keys(metadata.gps).length > 0;
|
||||||
|
|
||||||
|
const renderMetadataSection = (title: string, key: string, data: Record<string, unknown> | null | undefined) => {
|
||||||
|
if (!data || Object.keys(data).length === 0) return null;
|
||||||
|
const expanded = expandedSections.has(key);
|
||||||
|
return (
|
||||||
|
<div className="border border-border rounded-lg overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleSection(key)}
|
||||||
|
className="w-full flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-foreground hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
{expanded ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||||
|
{title}
|
||||||
|
<span className="text-muted-foreground ml-auto">{Object.keys(data).length} fields</span>
|
||||||
|
</button>
|
||||||
|
{expanded && (
|
||||||
|
<div className="border-t border-border px-2.5 py-1.5 space-y-0.5">
|
||||||
|
{Object.entries(data).map(([k, v]) => (
|
||||||
|
<div key={k} className="flex gap-2 text-[11px]">
|
||||||
|
<span className="text-muted-foreground shrink-0">{k}:</span>
|
||||||
|
<span className="text-foreground font-mono break-all">{String(v)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* Metadata inspection */}
|
||||||
|
{inspecting && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
Inspecting metadata...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{inspectError && <p className="text-xs text-red-500">{inspectError}</p>}
|
||||||
|
|
||||||
|
{metadata && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground">Current File Metadata</label>
|
||||||
|
|
||||||
|
{hasGps && (
|
||||||
|
<div className="flex items-start gap-1.5 p-2 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5 text-amber-500 shrink-0 mt-0.5" />
|
||||||
|
<p className="text-[11px] text-amber-600 dark:text-amber-400">
|
||||||
|
This image contains GPS location data. Consider stripping it for privacy.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderMetadataSection("EXIF", "exif", metadata.exif)}
|
||||||
|
{metadata.exifError && (
|
||||||
|
<p className="text-[11px] text-muted-foreground">EXIF: {metadata.exifError}</p>
|
||||||
|
)}
|
||||||
|
{renderMetadataSection("GPS", "gps", metadata.gps)}
|
||||||
|
{renderMetadataSection("ICC Profile", "icc", metadata.icc)}
|
||||||
|
{renderMetadataSection("XMP", "xmp", metadata.xmp)}
|
||||||
|
|
||||||
|
{!metadata.exif && !metadata.gps && !metadata.icc && !metadata.xmp && !metadata.exifError && (
|
||||||
|
<p className="text-xs text-muted-foreground">No metadata found in this file.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="border-t border-border" />
|
||||||
|
|
||||||
{/* Strip All */}
|
{/* Strip All */}
|
||||||
<label className="flex items-center gap-2 text-sm text-foreground font-medium">
|
<label className="flex items-center gap-2 text-sm text-foreground font-medium">
|
||||||
<input
|
<input
|
||||||
|
|||||||
Reference in New Issue
Block a user