Arquitectura / glob+ling:
- glob_engine: inicialización lazy de módulos (numr, curr, dati, unit) con ??=
- glob_types: añadido método register<NS, M>() para tipado fuerte de namespaces
- ling engine: extend() soporta namespaces con punto ('vcen.editor.mtas') via deepMerge
- ling guards: isLingRecord reforzado (comprueba typeof value !== 'function' y tipo del locale por defecto)
Validación:
- ISO_DATETIME_RE acepta HH:mm sin segundos (formato real de datetime-local)
Componentes Svelte — correcciones:
- App.svelte: corregido paso de prop glob (glob, → {glob})
- AddFieldMenu: añadida prop placement ('start'|'end') para controlar dirección del dropdown
- AddFieldMenu: reemplazados glob.t(`...` as any) por helper t() para evitar errores de parser Svelte
- FieldValueEditor: grupo muestra cuenta real de hijos en lugar de siempre "Grupo vacío"
- FieldRow: tooltip de editar/borrar cambiado a tooltip-top, eliminado overflow-hidden del card
- FieldRow: diálogo de confirmación antes de eliminar campo (modal DaisyUI)
- TreeNodes: corregido bug summary (group-hover:hidden → group-hover:block), acciones sin flex duplicado
- TreeNodes: diálogo de confirmación antes de eliminar (solo en depth=0 para evitar duplicados)
Estilos (DaisyUI):
- MetadataEditor y MetaTree: border-base-300, shadow-md, bg-base-200/60 en cabeceras
- MetaTree: breadcrumb de ruta en panel derecho, texto placeholder via glob.t()
- MetaTree: errores de validación con icono, alineados con MetadataEditor
- TreeNodes: nodo seleccionado con borde lateral (border-l-2 border-primary)
- AddFieldMenu: colores de icono por tipo, layout horizontal agrupado, contraste dropdown
- AddFieldDrawer: modal-bottom sm:modal-middle, botones Cancel/X visibles, inputs con text-base-content
- TypeBadge: colorMap aplicado correctamente al badge
- Traducciones: deleteConfirm, deleteConfirmMsg, deleteYes, deleteNo, tree.selectHint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
master
parent
4bf0c9c678
commit
ff61d85f54
@ -1,60 +1,110 @@
|
||||
<script lang="ts">
|
||||
import type { MetadataFieldType } from '../../lib';
|
||||
import FieldTypeIcon from './FieldTypeIcon.svelte';
|
||||
import type { GlobInstance } from '@/glob/lib';
|
||||
|
||||
let {
|
||||
onSelect,
|
||||
label = 'Añadir',
|
||||
iconOnly = false,
|
||||
placement = 'end',
|
||||
glob,
|
||||
}: {
|
||||
onSelect: (type: MetadataFieldType) => void;
|
||||
label?: string;
|
||||
iconOnly?: boolean;
|
||||
placement?: 'start' | 'end';
|
||||
glob: GlobInstance;
|
||||
} = $props();
|
||||
|
||||
const typeLabels: Record<MetadataFieldType, string> = {
|
||||
string: 'Texto', number: 'Número', boolean: 'Bool', ling: 'i18n',
|
||||
date: 'Fecha', datetime: 'DateTime', url: 'URL', currency: 'Moneda',
|
||||
image: 'Imagen', video: 'Vídeo', audio: 'Audio', pdf: 'PDF',
|
||||
file: 'Fichero', html: 'HTML', group: 'Grupo',
|
||||
};
|
||||
|
||||
const typeGroups: { label: string; types: MetadataFieldType[] }[] = [
|
||||
{ label: 'Texto', types: ['string', 'ling', 'html', 'url'] },
|
||||
{ label: 'Número', types: ['number', 'currency'] },
|
||||
{ label: 'Fecha', types: ['date', 'datetime'] },
|
||||
{ label: 'Lógico', types: ['boolean'] },
|
||||
{ label: 'Media', types: ['image', 'video', 'audio', 'pdf', 'file'] },
|
||||
{ label: 'Grupo', types: ['group'] },
|
||||
const typeGroups: { key: string; types: MetadataFieldType[] }[] = [
|
||||
{ key: 'text', types: ['string', 'ling', 'html', 'url'] },
|
||||
{ key: 'number', types: ['number', 'currency'] },
|
||||
{ key: 'date', types: ['date', 'datetime'] },
|
||||
{ key: 'logical', types: ['boolean'] },
|
||||
{ key: 'media', types: ['image', 'video', 'audio', 'pdf', 'file'] },
|
||||
{ key: 'group', types: ['group'] },
|
||||
];
|
||||
|
||||
// Helper: evita el cast `as any` dentro de template literals en Svelte
|
||||
const t = glob.t as (key: string, params?: any) => string;
|
||||
|
||||
// Mismos colores que TypeBadge, convertidos a clases de texto para los iconos
|
||||
const iconColor: Record<MetadataFieldType, string> = {
|
||||
string: 'text-info',
|
||||
number: 'text-secondary',
|
||||
boolean: 'text-warning',
|
||||
ling: 'text-success',
|
||||
date: 'text-accent',
|
||||
datetime: 'text-accent',
|
||||
url: 'text-info',
|
||||
currency: 'text-success',
|
||||
image: 'text-error',
|
||||
video: 'text-error',
|
||||
audio: 'text-secondary',
|
||||
pdf: 'text-neutral-content',
|
||||
file: 'text-neutral-content',
|
||||
html: 'text-warning',
|
||||
group: 'text-primary',
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="dropdown dropdown-end">
|
||||
<button tabindex="0" type="button"
|
||||
class={iconOnly ? 'btn btn-ghost btn-xs btn-square' : 'btn btn-sm btn-ghost gap-1'}
|
||||
title="Añadir campo">
|
||||
<svg class="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M8 3v10M3 8h10"/>
|
||||
<div class={['dropdown', placement === 'start' ? 'dropdown-start' : 'dropdown-end'].join(' ')}>
|
||||
<button
|
||||
tabindex="0"
|
||||
type="button"
|
||||
class={[
|
||||
'tooltip tooltip-left',
|
||||
iconOnly
|
||||
? 'btn btn-ghost btn-xs btn-square'
|
||||
: 'btn btn-sm btn-primary gap-1.5',
|
||||
].join(' ')}
|
||||
data-tip={glob.t('vcen.editor.mtas.header.addFieldTitle')}
|
||||
>
|
||||
<svg
|
||||
class={iconOnly ? 'h-3.5 w-3.5' : 'h-4 w-4'}
|
||||
viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
>
|
||||
<path d="M8 2v12M2 8h12"/>
|
||||
</svg>
|
||||
{#if !iconOnly}<span class="text-xs">{label}</span>{/if}
|
||||
{#if !iconOnly}
|
||||
<span class="text-sm">{glob.t('vcen.editor.mtas.header.addField')}</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<div tabindex="0" class="dropdown-content z-10 card card-compact bg-base-100 border border-base-200 shadow-lg p-3 w-124">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
tabindex="0"
|
||||
class="dropdown-content bg-base-200 border border-base-300 shadow-xl rounded-box overflow-hidden z-20 mt-1 w-[360px]"
|
||||
>
|
||||
<div class="flex flex-col divide-y divide-base-100">
|
||||
{#each typeGroups as group}
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<span class="text-base-content/30 text-xs w-10 shrink-0">{group.label}</span>
|
||||
{#each group.types as type}
|
||||
<button type="button"
|
||||
<div class="flex items-stretch">
|
||||
<!-- Label del grupo — columna con fondo propio -->
|
||||
<div class="flex items-center justify-end bg-base-300/60 w-16 shrink-0 px-2 py-1.5">
|
||||
<span class="text-[9px] uppercase tracking-widest font-semibold text-base-content/50">
|
||||
{t(`vcen.editor.mtas.typeGroups.${group.key}`)}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Tipos en horizontal -->
|
||||
<div class="flex flex-wrap gap-0.5 items-center px-2 py-1 bg-base-100 w-full">
|
||||
{#each group.types as type}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onSelect(type)}
|
||||
class="btn btn-ghost btn-xs gap-1 px-1.5"
|
||||
title={typeLabels[type]}>
|
||||
<FieldTypeIcon {type} size={14} />
|
||||
<span class="text-xs">{typeLabels[type]}</span>
|
||||
</button>
|
||||
{/each}
|
||||
class="btn btn-ghost btn-xs gap-1.5 px-2 hover:bg-base-200"
|
||||
title={t(`vcen.editor.mtas.types.${type}`)}
|
||||
>
|
||||
<span class={iconColor[type]}>
|
||||
<FieldTypeIcon {type} size={13} />
|
||||
</span>
|
||||
<span class="text-xs text-base-content/70">
|
||||
{t(`vcen.editor.mtas.types.${type}`)}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,170 @@
|
||||
<script lang="ts">
|
||||
import type {
|
||||
MetadataField, NumberField, BooleanField, LingField, HtmlField,
|
||||
CurrencyField, DateTimeField, ImageField, VideoField, AudioField, PdfField, FileField,
|
||||
} from '../../lib';
|
||||
import LingInput from '@/ling/svelte/LingInput.svelte';
|
||||
import type {GlobInstance} from "@/glob/lib";
|
||||
|
||||
let { field, glob, onFieldChange }: {
|
||||
field: MetadataField;
|
||||
glob: GlobInstance;
|
||||
onFieldChange: (field: MetadataField) => void;
|
||||
} = $props();
|
||||
|
||||
let locales = $derived(glob.getLocales() as string[]);
|
||||
let defaultLocale = $derived(glob.getLocale() as string);
|
||||
|
||||
const cls = 'input input-bordered w-full text-base-content';
|
||||
|
||||
let asNumber = $derived(field as NumberField);
|
||||
let asBoolean = $derived(field as BooleanField);
|
||||
let asLing = $derived(field as LingField);
|
||||
let asHtml = $derived(field as HtmlField);
|
||||
let asCurrency = $derived(field as CurrencyField);
|
||||
let asMedia = $derived(field as ImageField | VideoField | AudioField | PdfField | FileField);
|
||||
let asDT = $derived(field as DateTimeField);
|
||||
|
||||
function update(partial: Partial<MetadataField>): void {
|
||||
onFieldChange({ ...field, ...partial } as MetadataField);
|
||||
}
|
||||
function updateMediaUrl(url: string): void {
|
||||
onFieldChange({ ...field, value: { ...asMedia.value, url } } as MetadataField);
|
||||
}
|
||||
function todayISO(): string { return new Date().toISOString().slice(0, 10); }
|
||||
function nowISO(): string { return new Date().toISOString().slice(0, 16) + 'Z'; }
|
||||
|
||||
let numberFormatted = $derived(field.type === 'number' ? glob.numr.f(asNumber.value) : '');
|
||||
let currencyFormatted = $derived(field.type === 'currency' ? glob.curr.f(asCurrency.value.amount) : '');
|
||||
let dateFormatted = $derived(field.type === 'date' && field.value ? glob.dati.f(new Date(field.value), 'long') : '');
|
||||
let datetimeFormatted = $derived(field.type === 'datetime' && field.value ? glob.dati.f(new Date(field.value), { date: 'long', time: 'short' }) : '');
|
||||
</script>
|
||||
|
||||
{#if field.type === 'string'}
|
||||
<fieldset class="fieldset w-full">
|
||||
<legend class="fieldset-legend">{glob.t('vcen.editor.mtas.types.string')}</legend>
|
||||
<input type="text" value={field.value}
|
||||
oninput={(e) => update({ value: (e.currentTarget as HTMLInputElement).value })}
|
||||
class={cls} placeholder="Texto…" />
|
||||
</fieldset>
|
||||
|
||||
{:else if field.type === 'number'}
|
||||
<fieldset class="fieldset w-full">
|
||||
<legend class="fieldset-legend">{glob.t('vcen.editor.mtas.types.number')}</legend>
|
||||
<input type="number" value={asNumber.value}
|
||||
oninput={(e) => { const v = parseFloat((e.currentTarget as HTMLInputElement).value); update({ value: isNaN(v) ? 0 : v }); }}
|
||||
class={cls} />
|
||||
{#if numberFormatted}
|
||||
<span class="fieldset-label text-base-content/50">{numberFormatted}</span>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
{:else if field.type === 'boolean'}
|
||||
<div class="flex items-center gap-4 py-1">
|
||||
<input type="checkbox" checked={asBoolean.value}
|
||||
onchange={(e) => update({ value: (e.currentTarget as HTMLInputElement).checked })}
|
||||
class="toggle toggle-primary" />
|
||||
<span class="text-sm text-base-content">{asBoolean.value
|
||||
? glob.t('vcen.editor.mtas.field.boolTrue')
|
||||
: glob.t('vcen.editor.mtas.field.boolFalse')
|
||||
}</span>
|
||||
</div>
|
||||
|
||||
{:else if field.type === 'ling'}
|
||||
<LingInput
|
||||
value={asLing.value}
|
||||
{locales}
|
||||
{defaultLocale}
|
||||
label={glob.t('vcen.editor.mtas.editor.valueLing')}
|
||||
as="input"
|
||||
onUpdate={(val) => update({ value: val })}
|
||||
/>
|
||||
|
||||
{:else if field.type === 'date'}
|
||||
<fieldset class="fieldset w-full">
|
||||
<legend class="fieldset-legend">{glob.t('vcen.editor.mtas.types.date')}</legend>
|
||||
<input type="date" value={field.value || todayISO()}
|
||||
oninput={(e) => update({ value: (e.currentTarget as HTMLInputElement).value })}
|
||||
class={cls} />
|
||||
{#if dateFormatted}
|
||||
<span class="fieldset-label text-base-content/50">{dateFormatted}</span>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
{:else if field.type === 'datetime'}
|
||||
<fieldset class="fieldset w-full">
|
||||
<legend class="fieldset-legend">{glob.t('vcen.editor.mtas.types.datetime')}</legend>
|
||||
<input type="datetime-local" value={(asDT.value || nowISO()).replace('Z', '')}
|
||||
oninput={(e) => update({ value: (e.currentTarget as HTMLInputElement).value + 'Z' })}
|
||||
class={cls} />
|
||||
{#if datetimeFormatted}
|
||||
<span class="fieldset-label text-base-content/50">{datetimeFormatted}</span>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
{:else if field.type === 'url'}
|
||||
<fieldset class="fieldset w-full">
|
||||
<legend class="fieldset-legend">{glob.t('vcen.editor.mtas.types.url')}</legend>
|
||||
<input type="url" value={field.value}
|
||||
oninput={(e) => update({ value: (e.currentTarget as HTMLInputElement).value })}
|
||||
class="{cls} font-mono" placeholder="https://…" />
|
||||
</fieldset>
|
||||
|
||||
{:else if field.type === 'currency'}
|
||||
<div class="flex gap-3">
|
||||
<fieldset class="fieldset flex-1">
|
||||
<legend class="fieldset-legend">{glob.t('vcen.editor.mtas.editor.amountLabel')}</legend>
|
||||
<input type="number" value={asCurrency.value.amount}
|
||||
oninput={(e) => update({ value: { ...asCurrency.value, amount: parseFloat((e.currentTarget as HTMLInputElement).value) } })}
|
||||
class={cls} />
|
||||
{#if currencyFormatted}
|
||||
<span class="fieldset-label text-base-content/50">{currencyFormatted}</span>
|
||||
{/if}
|
||||
</fieldset>
|
||||
<fieldset class="fieldset w-28">
|
||||
<legend class="fieldset-legend">{glob.t('vcen.editor.mtas.editor.currencyLabel')}</legend>
|
||||
<input type="text" maxlength="3" value={asCurrency.value.currency}
|
||||
oninput={(e) => update({ value: { ...asCurrency.value, currency: (e.currentTarget as HTMLInputElement).value.toUpperCase() } })}
|
||||
class="{cls} font-mono uppercase" placeholder="EUR" />
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
{:else if field.type === 'image' || field.type === 'video' || field.type === 'audio' || field.type === 'pdf' || field.type === 'file'}
|
||||
<fieldset class="fieldset w-full">
|
||||
<legend class="fieldset-legend">{glob.t('vcen.editor.mtas.editor.mediaUrlLabel')}</legend>
|
||||
<input type="text" value={asMedia.value.url}
|
||||
oninput={(e) => updateMediaUrl((e.currentTarget as HTMLInputElement).value)}
|
||||
class="{cls} font-mono" placeholder="/ruta o https://…" />
|
||||
</fieldset>
|
||||
{#if field.type === 'image' && asMedia.value.url}
|
||||
<figure class="rounded-box overflow-hidden border border-base-300">
|
||||
<img src={asMedia.value.url} alt="preview" class="max-h-48 w-full object-cover" />
|
||||
</figure>
|
||||
{/if}
|
||||
|
||||
{:else if field.type === 'html'}
|
||||
<LingInput
|
||||
value={asHtml.value}
|
||||
{locales}
|
||||
{defaultLocale}
|
||||
label={glob.t('vcen.editor.mtas.editor.valueHtml')}
|
||||
as="textarea"
|
||||
rows={5}
|
||||
onUpdate={(val) => update({ value: val })}
|
||||
/>
|
||||
|
||||
{:else if field.type === 'group'}
|
||||
{@const childCount = Object.keys(field.value).length}
|
||||
<div class="alert alert-info">
|
||||
<svg class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
<span class="text-sm">
|
||||
{#if childCount > 0}
|
||||
{glob.t('vcen.editor.mtas.field.nestedCount', { count: childCount })}
|
||||
{:else}
|
||||
{glob.t('vcen.editor.mtas.field.emptyGroup')}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,346 @@
|
||||
<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}
|
||||
/>
|
||||
@ -0,0 +1,218 @@
|
||||
<script lang="ts">
|
||||
import type { Metadata, MetadataField, MetadataFieldType } from '../../lib';
|
||||
import type { GlobInstance } from '@/glob/lib';
|
||||
import { validateField } from '../../lib';
|
||||
import TypeBadge from './TypeBadge.svelte';
|
||||
import AddFieldMenu from './AddFieldMenu.svelte';
|
||||
import TreeNodesRecursive from './TreeNodes.svelte';
|
||||
|
||||
let {
|
||||
meta,
|
||||
order,
|
||||
path,
|
||||
depth,
|
||||
selectedPath,
|
||||
glob,
|
||||
onSelect,
|
||||
onMove,
|
||||
onDelete,
|
||||
}: {
|
||||
meta: Metadata;
|
||||
order: string[];
|
||||
path: string[];
|
||||
depth: number;
|
||||
selectedPath: string[];
|
||||
glob: GlobInstance;
|
||||
onSelect: (path: string[]) => void;
|
||||
onMove: (path: string[], dir: -1 | 1) => void;
|
||||
onDelete: (path: string[]) => void;
|
||||
} = $props();
|
||||
|
||||
// Track which groups are open
|
||||
let openGroups = $state<Set<string>>(new Set(order));
|
||||
|
||||
// Delete confirmation modal
|
||||
let deleteModalPath = $state<string[] | null>(null);
|
||||
let deleteModalKey = $derived(deleteModalPath ? deleteModalPath[deleteModalPath.length - 1] : '');
|
||||
let deleteModalField = $derived(deleteModalKey ? meta[deleteModalKey] as MetadataField | undefined : undefined);
|
||||
|
||||
function nodePath(key: string): string[] { return [...path, key]; }
|
||||
function isSelected(key: string): boolean { return selectedPath.join('.') === nodePath(key).join('.'); }
|
||||
function isAncestor(key: string): boolean { return selectedPath.join('.').startsWith(nodePath(key).join('.') + '.'); }
|
||||
|
||||
function toggleGroup(key: string): void {
|
||||
const next = new Set(openGroups);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
openGroups = next;
|
||||
}
|
||||
|
||||
function asField(v: unknown): MetadataField { return v as MetadataField; }
|
||||
|
||||
function summary(field: MetadataField): string {
|
||||
switch (field.type) {
|
||||
case 'string': return field.value || '—';
|
||||
case 'number': return glob.numr.f(field.value);
|
||||
case 'boolean': return field.value
|
||||
? glob.t('vcen.editor.mtas.field.boolTrue')
|
||||
: glob.t('vcen.editor.mtas.field.boolFalse');
|
||||
case 'ling':
|
||||
case 'html': return (Object.values(field.value as Record<string,string>)[0]) ?? '—';
|
||||
case 'date': return field.value ? glob.dati.f(new Date(field.value), 'short') : '—';
|
||||
case 'datetime': return field.value ? glob.dati.f(new Date(field.value), { date: 'short', time: 'short' }) : '—';
|
||||
case 'url': return field.value || '—';
|
||||
case 'currency': return glob.curr.f(field.value.amount);
|
||||
case 'image':
|
||||
case 'video':
|
||||
case 'audio':
|
||||
case 'pdf':
|
||||
case 'file': return (field.value as { url: string }).url || '—';
|
||||
case 'group': return glob.t('vcen.editor.mtas.field.nestedCount', { count: Object.keys(field.value).length });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each order as key, i (key)}
|
||||
{@const field = asField(meta[key])}
|
||||
{@const np = nodePath(key)}
|
||||
{@const vResult = validateField(key, field)}
|
||||
{@const sel = isSelected(key)}
|
||||
{@const anc = isAncestor(key)}
|
||||
{@const isGroup = field.type === 'group'}
|
||||
{@const groupOpen = openGroups.has(key)}
|
||||
{@const childKeys = isGroup ? Object.keys(field.value) : []}
|
||||
|
||||
<!-- Node row -->
|
||||
<div
|
||||
class={[
|
||||
'group/node flex items-center gap-1 px-2 py-1.5 cursor-pointer select-none transition-colors',
|
||||
sel ? 'bg-primary/15 border-l-2 border-primary' : anc ? 'bg-base-200/50' : 'hover:bg-base-200/40 border-l-2 border-transparent',
|
||||
].join(' ')}
|
||||
style="padding-left: {depth * 12 + 8}px"
|
||||
role="treeitem"
|
||||
tabindex="0"
|
||||
aria-selected={sel}
|
||||
onclick={() => onSelect(np)}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') onSelect(np); }}
|
||||
>
|
||||
<!-- Group chevron or spacer -->
|
||||
{#if isGroup}
|
||||
<button type="button"
|
||||
onclick={(e) => { e.stopPropagation(); toggleGroup(key); }}
|
||||
class="btn btn-ghost btn-xs btn-square h-4 min-h-0 shrink-0 p-0">
|
||||
<svg class={['h-3 w-3 transition-transform duration-150 text-base-content/40', groupOpen ? 'rotate-90' : ''].join(' ')}
|
||||
viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M4 2l4 4-4 4"/>
|
||||
</svg>
|
||||
</button>
|
||||
{:else}
|
||||
<span class="w-4 shrink-0"></span>
|
||||
{/if}
|
||||
|
||||
<!-- Validation dot -->
|
||||
<div class={['h-1.5 w-1.5 shrink-0 rounded-full', vResult.valid ? 'bg-success/60' : 'bg-error'].join(' ')}></div>
|
||||
|
||||
<!-- Type badge (icon only) -->
|
||||
<TypeBadge type={field.type} showLabel={false} {glob} />
|
||||
|
||||
<!-- Key -->
|
||||
<span class={['text-xs font-mono truncate flex-1', sel ? 'text-primary font-semibold' : 'text-base-content/80'].join(' ')}>
|
||||
{key}
|
||||
</span>
|
||||
|
||||
<!-- Summary (visible at rest, hidden on hover/select to make room for actions) -->
|
||||
{#if !sel && !isGroup}
|
||||
<span class="text-xs text-base-content/30 truncate max-w-16 group-hover/node:hidden">
|
||||
{summary(field)}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Actions (visible on hover or selected) -->
|
||||
<div class={['items-center gap-0.5 shrink-0', sel ? 'flex' : 'hidden group-hover/node:flex'].join(' ')}>
|
||||
<!-- Add child (groups only) -->
|
||||
<!-- Move up -->
|
||||
<button type="button"
|
||||
onclick={(e) => { e.stopPropagation(); onMove(np, -1); }}
|
||||
disabled={i === 0}
|
||||
class="btn btn-ghost btn-xs btn-square h-5 min-h-0 disabled:opacity-20"
|
||||
title={glob.t('vcen.editor.mtas.field.moveUp')}>
|
||||
<svg class="h-3 w-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M2 8l4-4 4 4"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Move down -->
|
||||
<button type="button"
|
||||
onclick={(e) => { e.stopPropagation(); onMove(np, 1); }}
|
||||
disabled={i === order.length - 1}
|
||||
class="btn btn-ghost btn-xs btn-square h-5 min-h-0 disabled:opacity-20"
|
||||
title={glob.t('vcen.editor.mtas.field.moveDown')}>
|
||||
<svg class="h-3 w-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M2 4l4 4 4-4"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Delete -->
|
||||
<button type="button"
|
||||
onclick={(e) => { e.stopPropagation(); deleteModalPath = np; }}
|
||||
class="btn btn-ghost btn-xs btn-square h-5 min-h-0 text-error"
|
||||
title={glob.t('vcen.editor.mtas.field.delete')}>
|
||||
<svg class="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M3 4h10M6 4V2.5h4V4M5 4l.5 9h5l.5-9"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Group children (recursive) -->
|
||||
{#if isGroup && groupOpen && childKeys.length > 0}
|
||||
<TreeNodesRecursive
|
||||
meta={field.value as Metadata}
|
||||
order={childKeys}
|
||||
path={np}
|
||||
depth={depth + 1}
|
||||
{selectedPath} {glob}
|
||||
{onSelect} {onMove} {onDelete}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Empty group placeholder -->
|
||||
{#if isGroup && groupOpen && childKeys.length === 0}
|
||||
<div class="text-xs text-base-content/30 italic py-1.5 border-l-2 border-transparent"
|
||||
style="padding-left: {(depth + 1) * 12 + 8}px">
|
||||
{glob.t('vcen.editor.mtas.field.emptyGroup')}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Delete confirmation modal (only rendered at depth 0 to avoid duplicates) -->
|
||||
{#if depth === 0}
|
||||
<dialog class={['modal modal-bottom sm:modal-middle', deleteModalPath !== null ? 'modal-open' : ''].join(' ')}>
|
||||
<div class="modal-box max-w-sm flex flex-col gap-4 p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="shrink-0 rounded-full bg-error/10 p-2.5 text-error">
|
||||
<svg class="h-5 w-5" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 4h10M6 4V2.5h4V4M5 4l.5 9h5l.5-9"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 min-w-0">
|
||||
<h3 class="font-semibold text-base-content">{glob.t('vcen.editor.mtas.field.deleteConfirm')}</h3>
|
||||
<p class="text-sm text-base-content/60">{glob.t('vcen.editor.mtas.field.deleteConfirmMsg')}</p>
|
||||
{#if deleteModalField}
|
||||
<div class="mt-2 flex items-center gap-2 rounded-lg bg-base-200 px-3 py-2">
|
||||
<TypeBadge type={deleteModalField.type} showLabel={false} {glob} />
|
||||
<span class="font-mono text-sm font-medium text-base-content truncate">{deleteModalKey}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" onclick={() => deleteModalPath = null} class="btn btn-outline btn-neutral flex-1">
|
||||
{glob.t('vcen.editor.mtas.field.deleteNo')}
|
||||
</button>
|
||||
<button type="button" onclick={() => { const p = deleteModalPath; deleteModalPath = null; if (p) onDelete(p); }} class="btn btn-error flex-1">
|
||||
{glob.t('vcen.editor.mtas.field.deleteYes')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop bg-black/30" onclick={() => deleteModalPath = null}></div>
|
||||
</dialog>
|
||||
{/if}
|
||||
@ -1,39 +1,40 @@
|
||||
<script lang="ts">
|
||||
import type {MetadataField, MetadataFieldType} from '../../lib';
|
||||
import type { MetadataFieldType } from '../../lib';
|
||||
import FieldTypeIcon from './FieldTypeIcon.svelte';
|
||||
import type { GlobInstance } from '@/glob/lib';
|
||||
|
||||
let {
|
||||
type,
|
||||
showLabel = true,
|
||||
glob,
|
||||
}: {
|
||||
type: MetadataFieldType;
|
||||
showLabel?: boolean;
|
||||
glob: GlobInstance;
|
||||
} = $props();
|
||||
|
||||
const config: Record<MetadataFieldType, { label: string; classes: string }> = {
|
||||
string: { label: 'Texto', classes: 'badge-info' },
|
||||
number: { label: 'Número', classes: 'badge-secondary' },
|
||||
boolean: { label: 'Bool', classes: 'badge-warning' },
|
||||
ling: { label: 'i18n', classes: 'badge-success' },
|
||||
date: { label: 'Fecha', classes: 'badge-accent' },
|
||||
datetime: { label: 'DateTime', classes: 'badge-accent' },
|
||||
url: { label: 'URL', classes: 'badge-info' },
|
||||
currency: { label: 'Moneda', classes: 'badge-success' },
|
||||
image: { label: 'Imagen', classes: 'badge-error' },
|
||||
video: { label: 'Vídeo', classes: 'badge-error' },
|
||||
audio: { label: 'Audio', classes: 'badge-secondary' },
|
||||
pdf: { label: 'PDF', classes: 'badge-neutral' },
|
||||
file: { label: 'Fichero', classes: 'badge-neutral' },
|
||||
html: { label: 'HTML', classes: 'badge-warning' },
|
||||
group: { label: 'Grupo', classes: 'badge-primary' },
|
||||
const colorMap: Record<MetadataFieldType, string> = {
|
||||
string: 'badge-info',
|
||||
number: 'badge-secondary',
|
||||
boolean: 'badge-warning',
|
||||
ling: 'badge-success',
|
||||
date: 'badge-accent',
|
||||
datetime: 'badge-accent',
|
||||
url: 'badge-info',
|
||||
currency: 'badge-success',
|
||||
image: 'badge-error',
|
||||
video: 'badge-error',
|
||||
audio: 'badge-secondary',
|
||||
pdf: 'badge-neutral',
|
||||
file: 'badge-neutral',
|
||||
html: 'badge-warning',
|
||||
group: 'badge-primary',
|
||||
};
|
||||
|
||||
let cfg = $derived(config[type]);
|
||||
</script>
|
||||
|
||||
<span class={['badge badge-soft badge-sm gap-1'].join(' ')}>
|
||||
<span class={['badge badge-soft badge-sm gap-1', colorMap[type]].join(' ')}>
|
||||
<FieldTypeIcon {type} size={12} />
|
||||
{#if showLabel}
|
||||
<span class="">{cfg.label}</span>
|
||||
<span>{glob.t(`vcen.editor.mtas.types.${type}` as any)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
Loading…
Reference in new issue