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:
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user