You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

347 lines
16 KiB

<script lang="ts">
import type { Metadata, MetadataField, MetadataFieldType } from '../../lib';
import type { GlobInstance } from '@/glob/lib';
import { validateMetadata, validateField, field as f } from '../../lib';
import { makeLingRecord } from '@/ling/lib';
import AddFieldMenu from './AddFieldMenu.svelte';
import AddFieldDrawer from './AddFieldDrawer.svelte';
import FieldEditor from './FieldEditor.svelte';
import TreeNodes from './TreeNodes.svelte';
let {
value = $bindable<Metadata>({}),
dark = false,
attributes = [],
glob,
onUpdate,
}: {
value?: Metadata;
dark?: boolean;
attributes?: string[];
glob: GlobInstance;
onUpdate?: (metadata: Metadata) => void;
} = $props();
// ── Selected node ─────────────────────────────────────────────────
// Path is an array of keys, e.g. ['specs', 'motor']
let selectedPath = $state<string[]>([]);
let drawerOpen = $state(false);
let drawerType = $state<MetadataFieldType>('string');
// Path where the new field will be added ([] = root)
let drawerTargetPath = $state<string[]>([]);
let keyOrder = $derived(
value.order ?? Object.keys(value).filter(k => k !== 'order')
);
let validation = $derived(validateMetadata(value));
// ── Helpers ───────────────────────────────────────────────────────
function emit(): void { onUpdate?.(value); }
function getFieldAtPath(path: string[]): MetadataField | null {
let node: Metadata = value;
for (let i = 0; i < path.length; i++) {
const f = node[path[i]];
if (f === undefined) return null;
if (i === path.length - 1) return f as MetadataField;
if ((f as MetadataField).type !== 'group') return null;
node = (f as any).value as Metadata;
}
return null;
}
function setFieldAtPath(path: string[], newField: MetadataField): void {
if (path.length === 1) {
value = { ...value, [path[0]]: newField };
emit();
return;
}
const [head, ...tail] = path;
const parent = value[head] as MetadataField;
if (parent.type !== 'group') return;
const updatedGroup = setFieldAtPathInMetadata(parent.value as Metadata, tail, newField);
value = { ...value, [head]: { ...parent, value: updatedGroup } };
emit();
}
function setFieldAtPathInMetadata(meta: Metadata, path: string[], newField: MetadataField): Metadata {
if (path.length === 1) return { ...meta, [path[0]]: newField };
const [head, ...tail] = path;
const parent = meta[head] as MetadataField;
if (parent.type !== 'group') return meta;
return { ...meta, [head]: { ...parent, value: setFieldAtPathInMetadata(parent.value as Metadata, tail, newField) } };
}
function deleteFieldAtPath(path: string[]): void {
if (path.length === 1) {
const next = { ...value };
delete next[path[0]];
next.order = keyOrder.filter(k => k !== path[0]);
value = next;
if (selectedPath[0] === path[0]) selectedPath = [];
emit();
return;
}
const [head, ...tail] = path;
const parent = value[head] as MetadataField;
if (parent.type !== 'group') return;
value = { ...value, [head]: { ...parent, value: deleteFromMetadata(parent.value as Metadata, tail) } };
if (selectedPath.join('.').startsWith(path.join('.'))) selectedPath = [];
emit();
}
function deleteFromMetadata(meta: Metadata, path: string[]): Metadata {
if (path.length === 1) {
const next = { ...meta };
delete next[path[0]];
return next;
}
const [head, ...tail] = path;
const parent = meta[head] as MetadataField;
if (parent.type !== 'group') return meta;
return { ...meta, [head]: { ...parent, value: deleteFromMetadata(parent.value as Metadata, tail) } };
}
function renameFieldAtPath(path: string[], newKey: string): void {
if (!newKey || newKey === path[path.length - 1]) return;
if (path.length === 1) {
const entries = Object.entries(value)
.filter(([k]) => k !== 'order')
.map(([k, v]) => [k === path[0] ? newKey : k, v] as [string, unknown]);
const next: Metadata = Object.fromEntries(entries);
next.order = keyOrder.map(k => k === path[0] ? newKey : k);
value = next;
selectedPath = [newKey];
emit();
return;
}
const [head, ...tail] = path;
const parent = value[head] as MetadataField;
if (parent.type !== 'group') return;
value = { ...value, [head]: { ...parent, value: renameInMetadata(parent.value as Metadata, tail, newKey) } };
selectedPath = [...path.slice(0, -1), newKey];
emit();
}
function renameInMetadata(meta: Metadata, path: string[], newKey: string): Metadata {
if (path.length === 1) {
const entries = Object.entries(meta).map(([k, v]) => [k === path[0] ? newKey : k, v] as [string, unknown]);
return Object.fromEntries(entries);
}
const [head, ...tail] = path;
const parent = meta[head] as MetadataField;
if (parent.type !== 'group') return meta;
return { ...meta, [head]: { ...parent, value: renameInMetadata(parent.value as Metadata, tail, newKey) } };
}
function moveFieldAtPath(path: string[], dir: -1 | 1): void {
if (path.length === 1) {
const order = [...keyOrder];
const idx = order.indexOf(path[0]);
const newIdx = idx + dir;
if (newIdx < 0 || newIdx >= order.length) return;
order.splice(idx, 1);
order.splice(newIdx, 0, path[0]);
value = { ...value, order };
emit();
return;
}
const [head, ...tail] = path;
const parent = value[head] as MetadataField;
if (parent.type !== 'group') return;
value = { ...value, [head]: { ...parent, value: moveInMetadata(parent.value as Metadata, tail, dir) } };
emit();
}
function moveInMetadata(meta: Metadata, path: string[], dir: -1 | 1): Metadata {
if (path.length === 1) {
const keys = Object.keys(meta);
const idx = keys.indexOf(path[0]);
const newIdx = idx + dir;
if (newIdx < 0 || newIdx >= keys.length) return meta;
keys.splice(idx, 1);
keys.splice(newIdx, 0, path[0]);
return Object.fromEntries(keys.map(k => [k, meta[k]]));
}
const [head, ...tail] = path;
const parent = meta[head] as MetadataField;
if (parent.type !== 'group') return meta;
return { ...meta, [head]: { ...parent, value: moveInMetadata(parent.value as Metadata, tail, dir) } };
}
function addFieldAtPath(targetPath: string[], key: string, field: MetadataField): void {
if (targetPath.length === 0) {
value = { ...value, [key]: field, order: [...keyOrder, key] };
emit();
return;
}
const [head, ...tail] = targetPath;
const parent = value[head] as MetadataField;
if (parent.type !== 'group') return;
value = { ...value, [head]: { ...parent, value: addInMetadata(parent.value as Metadata, tail, key, field) } };
emit();
}
function addInMetadata(meta: Metadata, path: string[], key: string, field: MetadataField): Metadata {
if (path.length === 0) return { ...meta, [key]: field };
const [head, ...tail] = path;
const parent = meta[head] as MetadataField;
if (parent.type !== 'group') return meta;
return { ...meta, [head]: { ...parent, value: addInMetadata(parent.value as Metadata, tail, key, field) } };
}
function openDrawer(type: MetadataFieldType, targetPath: string[]): void {
drawerType = type;
drawerTargetPath = targetPath;
drawerOpen = true;
}
function handleDrawerConfirm(key: string, field: MetadataField): void {
addFieldAtPath(drawerTargetPath, key, field);
selectedPath = [...drawerTargetPath, key];
}
function existingKeysAt(targetPath: string[]): string[] {
if (targetPath.length === 0) return keyOrder;
const f = getFieldAtPath(targetPath);
if (!f || f.type !== 'group') return [];
return Object.keys(f.value);
}
// ── Add target path ──────────────────────────────────────────────
// Where the next field will be created:
// - selected is group → inside it
// - selected is leaf → in its parent (same level)
// - nothing selected → root
let addTargetPath = $derived((() => {
if (selectedPath.length === 0) return [];
const f = getFieldAtPath(selectedPath);
if (f && f.type === 'group') return selectedPath;
return selectedPath.slice(0, -1);
})());
// ── Selected field ────────────────────────────────────────────────
let selectedField = $derived(
selectedPath.length > 0 ? getFieldAtPath(selectedPath) : null
);
let selectedKey = $derived(
selectedPath.length > 0 ? selectedPath[selectedPath.length - 1] : ''
);
</script>
<div data-theme={dark ? 'dark' : 'light'} class="h-full flex flex-col min-h-0">
<div class="card bg-base-100 border border-base-300 shadow-md w-full flex flex-col flex-1 min-h-0">
<!-- Body: left tree col + right editor col -->
<div class="flex flex-1 min-h-0 overflow-hidden">
<!-- Left: header + tree in same column -->
<div class="flex flex-col w-56 shrink-0 border-r border-base-300 min-h-0">
<!-- Tree header -->
<div class="flex items-center gap-2 px-3 py-2.5 border-b border-base-300 bg-base-200/60 shrink-0">
<div role="img"
title={validation.valid
? glob.t('vcen.editor.mtas.validation.valid')
: glob.t('vcen.editor.mtas.validation.errorCount', { count: validation.errors.length })}
class={['h-2 w-2 shrink-0 rounded-full transition-colors', validation.valid ? 'bg-success' : 'bg-error animate-pulse'].join(' ')}
></div>
<h2 class="font-semibold text-sm flex-1 text-base-content">
{glob.t('vcen.editor.mtas.header.title')}
</h2>
<span class="badge badge-ghost badge-sm text-base-content/60 font-normal">
{glob.t('vcen.editor.mtas.header.fieldCount', { count: keyOrder.length })}
</span>
<AddFieldMenu onSelect={(type) => openDrawer(type, addTargetPath)} iconOnly={true} {glob} placement="start"/>
</div>
<!-- Tree nodes -->
<div class="flex-1 overflow-y-auto">
{#if keyOrder.length === 0}
<div class="flex flex-col items-center justify-center gap-2 py-12 px-4 text-center">
<svg class="h-8 w-8 text-base-content/10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="4"/>
<path d="M12 8v8M8 12h8"/>
</svg>
<p class="text-xs text-base-content/30">{glob.t('vcen.editor.mtas.field.empty')}</p>
</div>
{:else}
<div class="flex flex-col py-1">
<TreeNodes
meta={value}
order={keyOrder}
path={[]}
depth={0}
{selectedPath}
{glob}
onSelect={(p) => selectedPath = p}
onMove={(p, dir) => moveFieldAtPath(p, dir)}
onDelete={(p) => deleteFieldAtPath(p)}
/>
</div>
{/if}
</div>
</div>
<!-- Right: editor panel -->
<div class="flex-1 min-w-0 overflow-y-auto">
{#if selectedField && selectedPath.length > 0}
<!-- Breadcrumb -->
<div class="flex items-center gap-1 px-4 py-2 border-b border-base-300 bg-base-200/40 text-xs text-base-content/50 font-mono">
{#each selectedPath as seg, i}
{#if i > 0}<span class="text-base-content/25">/</span>{/if}
<span class={i === selectedPath.length - 1 ? 'text-base-content/80 font-semibold' : ''}>{seg}</span>
{/each}
</div>
<FieldEditor
fieldKey={selectedKey}
field={selectedField}
{attributes} {glob}
onKeyChange={(newKey) => renameFieldAtPath(selectedPath, newKey)}
onFieldChange={(f) => setFieldAtPath(selectedPath, f)}
/>
{:else}
<div class="flex flex-col items-center justify-center h-full gap-3 text-center px-8">
<svg class="h-10 w-10 text-base-content/10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
</svg>
<p class="text-sm text-base-content/30">
{glob.t('vcen.editor.mtas.tree.selectHint')}
</p>
</div>
{/if}
</div>
</div>
<!-- Validation errors -->
{#if !validation.valid}
<div class="px-3 pb-3 shrink-0 border-t border-base-300">
<div class="alert alert-error mt-3 py-2.5">
<svg class="h-4 w-4 shrink-0" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm0 3.5a.75.75 0 0 1 .75.75v3a.75.75 0 0 1-1.5 0v-3A.75.75 0 0 1 8 4.5zm0 7a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
</svg>
<div class="flex flex-col gap-0.5">
<span class="font-medium text-sm">{glob.t('vcen.editor.mtas.validation.errorsTitle')}</span>
{#each validation.errors as error}
<span class="text-xs opacity-80">· {error}</span>
{/each}
</div>
</div>
</div>
{/if}
</div>
</div>
<AddFieldDrawer
open={drawerOpen}
selectedType={drawerType}
existingKeys={existingKeysAt(drawerTargetPath)}
{dark} {glob}
onConfirm={handleDrawerConfirm}
onClose={() => drawerOpen = false}
/>

Powered by TurnKey Linux.