feat(frontend): support nested objects in the metadata editor
deploy / deploy (push) Successful in 22s
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:
@@ -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>
|
||||
@@ -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: [] };
|
||||
}
|
||||
Reference in New Issue
Block a user