From 76d9c3536367867155ae21a6d151283553366c35 Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Tue, 23 Jun 2026 00:37:41 +0300 Subject: [PATCH] feat(frontend): show and edit file metadata Add a key/value metadata editor to the file viewer (display, add, edit and remove fields; values round-trip as JSON where possible, otherwise as plain strings) and a compact side-by-side metadata preview to the duplicate merge dialog so each side's keys and values are visible while choosing. Co-Authored-By: Claude Opus 4.8 --- .../file/DuplicateMergeDialog.svelte | 89 +++++++- .../src/lib/components/file/FileViewer.svelte | 208 +++++++++++++++++- 2 files changed, 291 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/components/file/DuplicateMergeDialog.svelte b/frontend/src/lib/components/file/DuplicateMergeDialog.svelte index 22883aa..8840ffc 100644 --- a/frontend/src/lib/components/file/DuplicateMergeDialog.svelte +++ b/frontend/src/lib/components/file/DuplicateMergeDialog.svelte @@ -51,6 +51,14 @@ function metaCount(m: unknown): number { return m && typeof m === 'object' ? Object.keys(m as object).length : 0; } + function metaEntries(m: unknown): [string, unknown][] { + return m && typeof m === 'object' ? Object.entries(m as Record) : []; + } + function fmtMeta(v: unknown): string { + if (v === null || v === undefined) return '—'; + if (typeof v === 'object') return JSON.stringify(v); + return String(v); + } async function submit() { if (busy) return; @@ -88,7 +96,12 @@ Merge duplicates @@ -120,7 +133,13 @@ - {#snippet scalarRow(label: string, value: ScalarChoice, set: (v: ScalarChoice) => void, keepVal: string, otherVal: string)} + {#snippet scalarRow( + label: string, + value: ScalarChoice, + set: (v: ScalarChoice) => void, + keepVal: string, + otherVal: string + )}
{label}
@@ -171,6 +190,27 @@
+ + {#if metaCount(a.metadata) > 0 || metaCount(b.metadata) > 0} +
+ {#each [{ side: 'Keep', m: a.metadata }, { side: 'Other', m: b.metadata }] as col (col.side)} +
+
{col.side}
+ {#if metaEntries(col.m).length > 0} +
+ {#each metaEntries(col.m) as [k, v]} +
{k}
+
{fmtMeta(v)}
+ {/each} +
+ {:else} + + {/if} +
+ {/each} +
+ {/if} +
Tags @@ -357,6 +397,51 @@ color: var(--color-accent); border-color: var(--color-accent); } + .meta-preview { + display: flex; + gap: 8px; + padding: 2px 0 4px; + } + .meta-col { + flex: 1; + min-width: 0; + background-color: var(--color-bg-elevated); + border-radius: 7px; + padding: 6px 8px; + } + .meta-col-head { + font-size: 0.66rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-muted); + margin-bottom: 4px; + } + .meta-list { + display: grid; + grid-template-columns: minmax(0, auto) minmax(0, 1fr); + gap: 2px 8px; + margin: 0; + font-size: 0.72rem; + } + .meta-list dt { + color: var(--color-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .meta-list dd { + margin: 0; + color: var(--color-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .meta-none { + font-size: 0.72rem; + color: var(--color-text-muted); + opacity: 0.6; + } .del { display: flex; align-items: center; diff --git a/frontend/src/lib/components/file/FileViewer.svelte b/frontend/src/lib/components/file/FileViewer.svelte index ab3d971..295bb36 100644 --- a/frontend/src/lib/components/file/FileViewer.svelte +++ b/frontend/src/lib/components/file/FileViewer.svelte @@ -22,8 +22,21 @@ onReviewChange?: (id: string, needsReview: boolean) => void; } - let { fileId, prevId = null, nextId = null, onNavigate, onClose, onReviewChange }: Props = - $props(); + let { + fileId, + prevId = null, + nextId = null, + onNavigate, + onClose, + onReviewChange + }: Props = $props(); + + /** One editable metadata entry. `id` is local-only, for keyed list rendering. */ + interface MetaRow { + id: number; + key: string; + value: string; + } let file = $state(null); let fileTags = $state([]); @@ -55,6 +68,11 @@ let notes = $state(''); let contentDatetime = $state(''); let isPublic = $state(false); + // User-editable metadata, held as ordered key/value rows (the API field is a + // free-form JSON object). Stable ids key the {#each} so removing a middle row + // keeps the bound inputs aligned with their data. + let metadataRows = $state([]); + let metaRowId = 0; let dirty = $state(false); let exifEntries = $derived( @@ -93,6 +111,7 @@ ? fileData.content_datetime.slice(0, 16) // YYYY-MM-DDTHH:mm : ''; isPublic = fileData.is_public ?? false; + metadataRows = metadataToRows(fileData.metadata); dirty = false; void fetchPreview(id); void fetchContentToken(id); @@ -227,9 +246,11 @@ const updated = await api.patch(`/files/${file.id}`, { notes: notes.trim() || null, content_datetime: contentDatetime ? new Date(contentDatetime).toISOString() : undefined, - is_public: isPublic + is_public: isPublic, + metadata: rowsToMetadata(metadataRows) }); file = updated; + metadataRows = metadataToRows(updated.metadata); dirty = false; } catch (e) { error = e instanceof ApiError ? e.message : 'Failed to save'; @@ -351,6 +372,59 @@ if (typeof val === 'object') return JSON.stringify(val); return String(val); } + + // ---- Metadata (free-form key/value object) ---- + // Expand the stored object into editable rows. Non-string values (numbers, + // booleans, nested objects) are shown as their JSON text so they survive a + // round-trip even when the user never touches them. + function metadataToRows(m: unknown): MetaRow[] { + if (!m || typeof m !== 'object') return []; + return Object.entries(m as Record).map(([key, val]) => ({ + id: metaRowId++, + key, + value: metaValueToString(val) + })); + } + + function metaValueToString(val: unknown): string { + if (val === null || val === undefined) return ''; + if (typeof val === 'string') return val; + return JSON.stringify(val); + } + + // Rebuild the object for the PATCH body. Blank keys are dropped. A value that + // parses as JSON (number, boolean, object, array) is stored as that type; + // everything else is kept as a plain string. + function rowsToMetadata(rows: MetaRow[]): Record { + const out: Record = {}; + for (const { key, value } of rows) { + const k = key.trim(); + if (k) out[k] = parseMetaValue(value); + } + return out; + } + + function parseMetaValue(value: string): unknown { + const v = value.trim(); + if (v === '') return ''; + try { + const parsed: unknown = JSON.parse(v); + if (parsed !== null && typeof parsed !== 'string') return parsed; + } catch { + // not JSON — keep the raw string + } + return value; + } + + function addMetaRow() { + metadataRows = [...metadataRows, { id: metaRowId++, key: '', value: '' }]; + dirty = true; + } + + function removeMetaRow(id: number) { + metadataRows = metadataRows.filter((r) => r.id !== id); + dirty = true; + } @@ -376,7 +450,9 @@ class:needs={file.needs_review} onclick={toggleReview} aria-label={file.needs_review ? 'Mark as reviewed' : 'Mark as needs review'} - title={file.needs_review ? 'Tagging not done — mark reviewed' : 'Reviewed — mark as needs review'} + title={file.needs_review + ? 'Tagging not done — mark reviewed' + : 'Reviewed — mark as needs review'} >
Metadata
+ {#if metadataRows.length > 0} +
+ {#each metadataRows as row (row.id)} +
+ (dirty = true)} + /> + (dirty = true)} + /> + +
+ {/each} +
+ {:else} +

No metadata.

+ {/if} + + + @@ -967,6 +1086,87 @@ opacity: 0.7; } + /* ---- Metadata editor ---- */ + .kv-list { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 8px; + } + + .kv-row { + display: flex; + gap: 6px; + align-items: center; + } + + .kv-input { + box-sizing: border-box; + height: 34px; + padding: 0 9px; + border-radius: 6px; + border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent); + background-color: var(--color-bg-elevated); + color: var(--color-text-primary); + font-size: 0.85rem; + font-family: inherit; + outline: none; + min-width: 0; + } + + .kv-input:focus { + border-color: var(--color-accent); + } + + .kv-key { + flex: 0 0 38%; + } + + .kv-val { + flex: 1 1 auto; + } + + .kv-del { + flex-shrink: 0; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + border: none; + background: none; + color: var(--color-text-muted); + cursor: pointer; + } + + .kv-del:hover { + color: var(--color-danger); + background-color: color-mix(in srgb, var(--color-danger) 12%, transparent); + } + + .kv-empty { + margin: 0 0 8px; + font-size: 0.8rem; + color: var(--color-text-muted); + opacity: 0.7; + } + + .kv-add { + padding: 6px 12px; + border-radius: 6px; + border: 1px dashed color-mix(in srgb, var(--color-accent) 40%, transparent); + background: none; + color: var(--color-accent); + font-size: 0.8rem; + font-family: inherit; + cursor: pointer; + } + + .kv-add:hover { + background-color: color-mix(in srgb, var(--color-accent) 12%, transparent); + } + /* ---- EXIF ---- */ .exif { display: grid;