diff --git a/frontend/src/lib/components/file/FileViewer.svelte b/frontend/src/lib/components/file/FileViewer.svelte index 295bb36..273c1f8 100644 --- a/frontend/src/lib/components/file/FileViewer.svelte +++ b/frontend/src/lib/components/file/FileViewer.svelte @@ -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(null); let fileTags = $state([]); let previewSrc = $state(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([]); - 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([]); 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).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; - } @@ -640,47 +581,10 @@ - +
Metadata
- {#if metadataRows.length > 0} -
- {#each metadataRows as row (row.id)} -
- (dirty = true)} - /> - (dirty = true)} - /> - -
- {/each} -
- {:else} -

No metadata.

- {/if} - + (dirty = true)} />
+ {#if node.kind === 'value'} + + {/if} + + + {#if node.kind === 'object'} +
+ +
+ {/if} + + {/each} + +
+ + +
+ + + diff --git a/frontend/src/lib/utils/metadata.ts b/frontend/src/lib/utils/metadata.ts new file mode 100644 index 0000000..f5b34d2 --- /dev/null +++ b/frontend/src/lib/utils/metadata.ts @@ -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 { + 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 { + const out: Record = {}; + 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 | 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: [] }; +}