feat(frontend): support nested objects in the metadata editor
deploy / deploy (push) Successful in 22s

Replace the flat key/value metadata editor with a recursive tree editor.
Each entry is a leaf (scalar edited as text) or a nested object with its own
children; a per-row toggle switches between the two, expanding pasted JSON
into rows and collapsing groups back to JSON text without losing data. Leaf
values still round-trip as JSON where possible, so numbers and booleans keep
their type.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 00:09:11 +03:00
parent 76d9c35363
commit bf7fa49a16
3 changed files with 333 additions and 188 deletions
@@ -5,7 +5,9 @@
import { authStore } from '$lib/stores/auth';
import TagPicker from '$lib/components/file/TagPicker.svelte';
import PoolPicker from '$lib/components/file/PoolPicker.svelte';
import MetadataEditor from '$lib/components/file/MetadataEditor.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import { type MetaNode, objectToNodes, nodesToObject } from '$lib/utils/metadata';
import type { File, Tag } from '$lib/api/types';
interface Props {
@@ -31,13 +33,6 @@
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 fileTags = $state<Tag[]>([]);
let previewSrc = $state<string | null>(null);
@@ -68,11 +63,10 @@
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<MetaRow[]>([]);
let metaRowId = 0;
// User-editable metadata (the API field is a free-form, possibly nested JSON
// object). Held as a node tree that MetadataEditor renders; converted to/from
// the plain object on load and save.
let metadataNodes = $state<MetaNode[]>([]);
let dirty = $state(false);
let exifEntries = $derived(
@@ -111,7 +105,7 @@
? fileData.content_datetime.slice(0, 16) // YYYY-MM-DDTHH:mm
: '';
isPublic = fileData.is_public ?? false;
metadataRows = metadataToRows(fileData.metadata);
metadataNodes = objectToNodes(fileData.metadata);
dirty = false;
void fetchPreview(id);
void fetchContentToken(id);
@@ -247,10 +241,10 @@
notes: notes.trim() || null,
content_datetime: contentDatetime ? new Date(contentDatetime).toISOString() : undefined,
is_public: isPublic,
metadata: rowsToMetadata(metadataRows)
metadata: nodesToObject(metadataNodes)
});
file = updated;
metadataRows = metadataToRows(updated.metadata);
metadataNodes = objectToNodes(updated.metadata);
dirty = false;
} catch (e) {
error = e instanceof ApiError ? e.message : 'Failed to save';
@@ -372,59 +366,6 @@
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<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>
<svelte:window onkeydown={handleKeydown} />
@@ -640,47 +581,10 @@
</button>
</section>
<!-- Metadata (free-form key/value pairs) -->
<!-- Metadata (free-form, possibly nested JSON object) -->
<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>
<MetadataEditor bind:nodes={metadataNodes} onchange={() => (dirty = true)} />
</section>
<button class="save-btn" onclick={save} disabled={!dirty || saving}>
@@ -1086,87 +990,6 @@
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;
@@ -0,0 +1,223 @@
<script lang="ts">
// Recursive editor for one level of the metadata object. Nested objects render
// another instance of this component (self-import), indented under their key.
import Self from './MetadataEditor.svelte';
import {
type MetaNode,
newValueNode,
newObjectNode,
nodesToObject,
objectToNodes,
parseObject
} from '$lib/utils/metadata';
interface Props {
/** Entries at this level; bound so nested edits flow back to the parent. */
nodes: MetaNode[];
/** Fired on any structural or value change (parent marks the form dirty). */
onchange: () => void;
}
let { nodes = $bindable(), onchange }: Props = $props();
function addValue() {
nodes = [...nodes, newValueNode()];
onchange();
}
function addObject() {
nodes = [...nodes, newObjectNode()];
onchange();
}
function remove(id: number) {
nodes = nodes.filter((n) => n.id !== id);
onchange();
}
// Flip a leaf to a nested object and back. Converting keeps the data where it
// can: a leaf whose text is a JSON object expands into rows; a group collapses
// back to its JSON text.
function toggleKind(node: MetaNode) {
if (node.kind === 'value') {
const obj = parseObject(node.value);
node.children = obj ? objectToNodes(obj) : [];
node.value = '';
node.kind = 'object';
} else {
node.value = node.children.length ? JSON.stringify(nodesToObject(node.children)) : '';
node.children = [];
node.kind = 'value';
}
onchange();
}
</script>
<div class="meta-editor">
{#each nodes as node (node.id)}
<div class="node">
<div class="node-head">
<input class="key" placeholder="key" bind:value={node.key} oninput={onchange} />
<button
class="kind"
class:obj={node.kind === 'object'}
onclick={() => toggleKind(node)}
title={node.kind === 'object'
? 'Nested object — click for a plain value'
: 'Plain value — click to nest an object'}
aria-label="Toggle value / nested object"
>
{node.kind === 'object' ? '{ }' : 'a'}
</button>
{#if node.kind === 'value'}
<input class="val" placeholder="value" bind:value={node.value} oninput={onchange} />
{/if}
<button
class="del"
onclick={() => remove(node.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>
{#if node.kind === 'object'}
<div class="children">
<Self bind:nodes={node.children} {onchange} />
</div>
{/if}
</div>
{/each}
<div class="add-row">
<button class="add" onclick={addValue}>+ Field</button>
<button class="add" onclick={addObject}>+ Group</button>
</div>
</div>
<style>
.meta-editor {
display: flex;
flex-direction: column;
gap: 6px;
}
.node {
display: flex;
flex-direction: column;
gap: 6px;
}
.node-head {
display: flex;
gap: 6px;
align-items: center;
}
.key,
.val {
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;
}
.key:focus,
.val:focus {
border-color: var(--color-accent);
}
.key {
flex: 0 0 38%;
}
.val {
flex: 1 1 auto;
}
/* Type toggle: shows 'a' for a plain value, '{ }' for a nested object. */
.kind {
flex-shrink: 0;
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
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-muted);
font-size: 0.8rem;
font-family: inherit;
cursor: pointer;
}
.kind.obj {
color: var(--color-accent);
border-color: var(--color-accent);
}
.kind:hover {
border-color: var(--color-accent);
}
.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;
}
.del:hover {
color: var(--color-danger);
background-color: color-mix(in srgb, var(--color-danger) 12%, transparent);
}
/* Nested level: indent and hang a rail off the parent key. */
.children {
margin-left: 12px;
padding-left: 12px;
border-left: 2px solid color-mix(in srgb, var(--color-accent) 20%, transparent);
}
.add-row {
display: flex;
gap: 6px;
}
.add {
padding: 5px 11px;
border-radius: 6px;
border: 1px dashed color-mix(in srgb, var(--color-accent) 40%, transparent);
background: none;
color: var(--color-accent);
font-size: 0.78rem;
font-family: inherit;
cursor: pointer;
}
.add:hover {
background-color: color-mix(in srgb, var(--color-accent) 12%, transparent);
}
</style>
+99
View File
@@ -0,0 +1,99 @@
/**
* File metadata tree model.
*
* The `metadata` API field is a free-form JSON object. The editor renders it as
* a tree of nodes: each node is either a leaf (a scalar edited as text) or a
* branch (a nested object with its own children). Arrays and other non-object
* values stay leaves and round-trip as JSON text.
*/
/** One entry at some level of the metadata object. */
export interface MetaNode {
/** Local-only id, for keyed list rendering. Not persisted. */
id: number;
key: string;
/** `value` holds a leaf's text; `object` nests `children`. */
kind: 'value' | 'object';
value: string;
children: MetaNode[];
}
let counter = 0;
/** Monotonic id for keyed rendering; uniqueness within a list is all that matters. */
export function nextMetaId(): number {
return counter++;
}
/** A plain object (not null, not an array). */
function isPlainObject(v: unknown): v is Record<string, unknown> {
return !!v && typeof v === 'object' && !Array.isArray(v);
}
/** Expand a stored object into editor nodes. Non-object input yields no nodes. */
export function objectToNodes(m: unknown): MetaNode[] {
if (!isPlainObject(m)) return [];
return Object.entries(m).map(([key, val]) => valueToNode(key, val));
}
function valueToNode(key: string, val: unknown): MetaNode {
if (isPlainObject(val)) {
return { id: nextMetaId(), key, kind: 'object', value: '', children: objectToNodes(val) };
}
return { id: nextMetaId(), key, kind: 'value', value: valueToString(val), children: [] };
}
/** Render a leaf value for the text input. Strings pass through; everything else
* (numbers, booleans, arrays) shows as JSON so it survives a round-trip. */
export function valueToString(val: unknown): string {
if (val === null || val === undefined) return '';
if (typeof val === 'string') return val;
return JSON.stringify(val);
}
/** Collapse the node tree back into a plain object for the PATCH body. Blank keys
* are dropped; a later duplicate key wins. */
export function nodesToObject(nodes: MetaNode[]): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const n of nodes) {
const k = n.key.trim();
if (!k) continue;
out[k] = n.kind === 'object' ? nodesToObject(n.children) : parseValue(n.value);
}
return out;
}
/** Parse a leaf's text: JSON-typed values (number, boolean, array, object) keep
* their type; anything else stays a plain string. */
export function parseValue(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;
}
/** If the text is a JSON object, return it (used when expanding a leaf into a
* nested group); otherwise null. */
export function parseObject(value: string): Record<string, unknown> | null {
const v = value.trim();
if (!v) return null;
try {
const parsed: unknown = JSON.parse(v);
if (isPlainObject(parsed)) return parsed;
} catch {
// not JSON
}
return null;
}
export function newValueNode(): MetaNode {
return { id: nextMetaId(), key: '', kind: 'value', value: '', children: [] };
}
export function newObjectNode(): MetaNode {
return { id: nextMetaId(), key: '', kind: 'object', value: '', children: [] };
}