feat(frontend): show and edit file metadata
deploy / deploy (push) Successful in 1m1s

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 00:37:41 +03:00
parent 71cf79eeaa
commit 76d9c35363
2 changed files with 291 additions and 6 deletions
@@ -51,6 +51,14 @@
function metaCount(m: unknown): number { function metaCount(m: unknown): number {
return m && typeof m === 'object' ? Object.keys(m as object).length : 0; 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<string, unknown>) : [];
}
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() { async function submit() {
if (busy) return; if (busy) return;
@@ -88,7 +96,12 @@
<span class="title">Merge duplicates</span> <span class="title">Merge duplicates</span>
<button class="x" onclick={onClose} aria-label="Close"> <button class="x" onclick={onClose} aria-label="Close">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true"> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3 3l10 10M13 3L3 13" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /> <path
d="M3 3l10 10M13 3L3 13"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
/>
</svg> </svg>
</button> </button>
</div> </div>
@@ -120,7 +133,13 @@
</div> </div>
<!-- Scalar fields: keep vs discard --> <!-- Scalar fields: keep vs discard -->
{#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
)}
<div class="row"> <div class="row">
<span class="label">{label}</span> <span class="label">{label}</span>
<div class="seg"> <div class="seg">
@@ -171,6 +190,27 @@
</div> </div>
</div> </div>
<!-- Compact side-by-side view of each side's metadata -->
{#if metaCount(a.metadata) > 0 || metaCount(b.metadata) > 0}
<div class="meta-preview">
{#each [{ side: 'Keep', m: a.metadata }, { side: 'Other', m: b.metadata }] as col (col.side)}
<div class="meta-col">
<div class="meta-col-head">{col.side}</div>
{#if metaEntries(col.m).length > 0}
<dl class="meta-list">
{#each metaEntries(col.m) as [k, v]}
<dt title={k}>{k}</dt>
<dd title={fmtMeta(v)}>{fmtMeta(v)}</dd>
{/each}
</dl>
{:else}
<span class="meta-none"></span>
{/if}
</div>
{/each}
</div>
{/if}
<!-- Relations: keep vs union both --> <!-- Relations: keep vs union both -->
<div class="row"> <div class="row">
<span class="label">Tags</span> <span class="label">Tags</span>
@@ -357,6 +397,51 @@
color: var(--color-accent); color: var(--color-accent);
border-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 { .del {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -22,8 +22,21 @@
onReviewChange?: (id: string, needsReview: boolean) => void; onReviewChange?: (id: string, needsReview: boolean) => void;
} }
let { fileId, prevId = null, nextId = null, onNavigate, onClose, onReviewChange }: Props = let {
$props(); 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<File | null>(null); let file = $state<File | null>(null);
let fileTags = $state<Tag[]>([]); let fileTags = $state<Tag[]>([]);
@@ -55,6 +68,11 @@
let notes = $state(''); let notes = $state('');
let contentDatetime = $state(''); let contentDatetime = $state('');
let isPublic = $state(false); 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<MetaRow[]>([]);
let metaRowId = 0;
let dirty = $state(false); let dirty = $state(false);
let exifEntries = $derived( let exifEntries = $derived(
@@ -93,6 +111,7 @@
? fileData.content_datetime.slice(0, 16) // YYYY-MM-DDTHH:mm ? fileData.content_datetime.slice(0, 16) // YYYY-MM-DDTHH:mm
: ''; : '';
isPublic = fileData.is_public ?? false; isPublic = fileData.is_public ?? false;
metadataRows = metadataToRows(fileData.metadata);
dirty = false; dirty = false;
void fetchPreview(id); void fetchPreview(id);
void fetchContentToken(id); void fetchContentToken(id);
@@ -227,9 +246,11 @@
const updated = await api.patch<File>(`/files/${file.id}`, { const updated = await api.patch<File>(`/files/${file.id}`, {
notes: notes.trim() || null, notes: notes.trim() || null,
content_datetime: contentDatetime ? new Date(contentDatetime).toISOString() : undefined, content_datetime: contentDatetime ? new Date(contentDatetime).toISOString() : undefined,
is_public: isPublic is_public: isPublic,
metadata: rowsToMetadata(metadataRows)
}); });
file = updated; file = updated;
metadataRows = metadataToRows(updated.metadata);
dirty = false; dirty = false;
} catch (e) { } catch (e) {
error = e instanceof ApiError ? e.message : 'Failed to save'; error = e instanceof ApiError ? e.message : 'Failed to save';
@@ -351,6 +372,59 @@
if (typeof val === 'object') return JSON.stringify(val); if (typeof val === 'object') return JSON.stringify(val);
return String(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<string, unknown>).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<string, unknown> {
const out: Record<string, unknown> = {};
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;
}
</script> </script>
<svelte:window onkeydown={handleKeydown} /> <svelte:window onkeydown={handleKeydown} />
@@ -376,7 +450,9 @@
class:needs={file.needs_review} class:needs={file.needs_review}
onclick={toggleReview} onclick={toggleReview}
aria-label={file.needs_review ? 'Mark as reviewed' : 'Mark as needs review'} 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'}
> >
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true"> <svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
<circle cx="10" cy="10" r="7.5" stroke="currentColor" stroke-width="1.6" /> <circle cx="10" cy="10" r="7.5" stroke="currentColor" stroke-width="1.6" />
@@ -564,6 +640,49 @@
</button> </button>
</section> </section>
<!-- Metadata (free-form key/value pairs) -->
<section class="section">
<div class="field-label">Metadata</div>
{#if metadataRows.length > 0}
<div class="kv-list">
{#each metadataRows as row (row.id)}
<div class="kv-row">
<input
class="kv-input kv-key"
placeholder="key"
bind:value={row.key}
oninput={() => (dirty = true)}
/>
<input
class="kv-input kv-val"
placeholder="value"
bind:value={row.value}
oninput={() => (dirty = true)}
/>
<button
class="kv-del"
onclick={() => removeMetaRow(row.id)}
aria-label="Remove field"
title="Remove field"
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path
d="M3 3l8 8M11 3l-8 8"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
/>
</svg>
</button>
</div>
{/each}
</div>
{:else}
<p class="kv-empty">No metadata.</p>
{/if}
<button class="kv-add" onclick={addMetaRow}>+ Add field</button>
</section>
<button class="save-btn" onclick={save} disabled={!dirty || saving}> <button class="save-btn" onclick={save} disabled={!dirty || saving}>
{saving ? 'Saving…' : 'Save changes'} {saving ? 'Saving…' : 'Save changes'}
</button> </button>
@@ -967,6 +1086,87 @@
opacity: 0.7; 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 ---- */
.exif { .exif {
display: grid; display: grid;