feat(mtas): rediseño UI, correcciones de bugs y mejoras de arquitectura

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
dev 7 months ago
parent 4bf0c9c678
commit ff61d85f54

@ -21,11 +21,16 @@ export function createGlob<S extends LingNode>(config: GlobConfig<S>): GlobInsta
// Fuente de verdad: siempre lee de ling, nunca almacena el locale localmente.
const getLocale = (): GlobLocale => ling.getLocale() as GlobLocale;
// Cada módulo recibe `getLocale` como resolver dinámico de locale.
const numr = createNumr(config.numr ?? {}, getLocale);
const curr = createCurr(config.curr?.definitions ?? {}, ling, config.curr ?? {}, getLocale);
const dati = createDati(config.dati ?? {}, getLocale);
const unit = createUnit(ling,config.unit?.categories ?? {}, config.unit ?? {}, getLocale);
function getLocales(): GlobLocale[] {
return config.locales ?? [config.locale];
}
// Módulos inicializados de forma lazy: solo se crean cuando se accede por primera vez.
// Evita el coste de inicialización (especialmente unit con unit_data) si no se usan.
let _numr: ReturnType<typeof createNumr> | undefined;
let _curr: ReturnType<typeof createCurr> | undefined;
let _dati: ReturnType<typeof createDati> | undefined;
let _unit: ReturnType<typeof createUnit> | undefined;
function setLocale(locale: GlobLocale): void {
ling.setLocale(locale as any);
@ -38,16 +43,30 @@ export function createGlob<S extends LingNode>(config: GlobConfig<S>): GlobInsta
return () => listeners.delete(fn);
}
/**
* Registra un namespace y devuelve una nueva GlobInstance con el tipo actualizado.
* A diferencia de `ling.extend()`, preserva el type-safety de `t()` para el nuevo namespace.
*/
function register<NS extends string, M extends LingNode>(
namespace: NS,
module: M
): GlobInstance<S & { [K in NS]: M }> {
const newLing = ling.register(namespace, module);
return createGlob<S & { [K in NS]: M }>({ ...config, ling: newLing });
}
return {
getLocale,
getLocales,
setLocale,
onLocaleChange,
t : ling.t.bind(ling),
ts : ling.ts.bind(ling),
t : ling.t.bind(ling),
ts : ling.ts.bind(ling),
ling,
numr,
curr,
dati,
unit,
register,
get numr() { return _numr ??= createNumr(config.numr ?? {}, getLocale); },
get curr() { return _curr ??= createCurr(config.curr?.definitions ?? {}, ling, config.curr ?? {}, getLocale); },
get dati() { return _dati ??= createDati(config.dati ?? {}, getLocale); },
get unit() { return _unit ??= createUnit(ling, config.unit?.categories ?? {}, config.unit ?? {}, getLocale); },
};
}

@ -10,15 +10,17 @@ import type { UnitConfig, UnitInstance } from "./unit_types.ts";
import type { GlobLocale } from "./comn_types.ts";
/** Configuración de `createGlob`. Todos los módulos son opcionales. */
export interface GlobConfig<S extends LingNode> {
locale : GlobLocale;
locale : GlobLocale;
locales?: GlobLocale[];
/** Instancia de `ling` — es la fuente de verdad del locale compartido. */
ling : LingInstance<S>;
numr? : NumrConfig;
curr? : CurrConfig;
dati? : DatiConfig;
unit? : UnitConfig;
ling : LingInstance<S>;
numr? : NumrConfig;
curr? : CurrConfig;
dati? : DatiConfig;
unit? : UnitConfig;
}
@ -28,6 +30,10 @@ export interface GlobInstance<S extends LingNode = LingNode> {
setLocale : (locale: GlobLocale) => void;
/** Locale activo, delegado a `ling.getLocale()`. */
getLocale : () => GlobLocale;
getLocales : () => GlobLocale[];
/**
* Suscribe un callback al cambio de locale.
* @returns Función de desuscripción.
@ -43,4 +49,15 @@ export interface GlobInstance<S extends LingNode = LingNode> {
unit : UnitInstance;
/** Instancia `ling` subyacente, accesible para uso directo. */
ling : LingInstance<S>;
/**
* Registra un namespace y devuelve una nueva GlobInstance con el tipo actualizado.
* Usar cuando se necesite type-safety en `t()` para el namespace registrado.
* @example
* const typedGlob = glob.register('shop', shopTranslations);
* typedGlob.t('shop.product.title'); // ✅ tipado
*/
register<NS extends string, M extends LingNode>(
namespace: NS,
module: M
): GlobInstance<S & { [K in NS]: M }>;
}

@ -21,6 +21,24 @@ function isDev(): boolean {
return typeof process !== 'undefined' && process.env?.NODE_ENV === 'development';
}
/** Deep-merge recursivo de objetos planos. Las funciones y primitivos de `source` sobreescriben `target`. */
function deepMerge(target: any, source: any): any {
const result = { ...target };
for (const key of Object.keys(source)) {
const srcVal = source[key];
const tgtVal = result[key];
if (
typeof srcVal === 'object' && srcVal !== null && !Array.isArray(srcVal) && typeof srcVal !== 'function' &&
typeof tgtVal === 'object' && tgtVal !== null && !Array.isArray(tgtVal) && typeof tgtVal !== 'function'
) {
result[key] = deepMerge(tgtVal, srcVal);
} else {
result[key] = srcVal;
}
}
return result;
}
// ==============================
@ -165,17 +183,23 @@ export function createLing<S extends LingNode>(
/**
* Añade un módulo lazy al schema en runtime mutando la instancia actual.
* El type-safety viene de FullSchema declarado en instance.ts — no de esta función.
* extend() solo mueve el runtime para que coincida con lo que TypeScript ya sabe.
* Soporta namespaces con puntos: 'vcen.editor.mtas' crea la jerarquía anidada correcta
* y hace deep-merge con lo que ya existiera en ese path.
*
* @example
* const { shopTranslations } = await import('@/shop/translations');
* ling.extend('shop', shopTranslations);
* ling.extend('vcen.editor.mtas', mtasTranslations);
*/
function extend(namespace: string, module: LingNode): void {
const parts = namespace.split('.');
let nested: any = module;
for (let i = parts.length - 1; i > 0; i--) {
nested = { [parts[i]]: nested };
}
const root = parts[0];
currentSchema = {
...(currentSchema as Record<string, any>),
[namespace]: module,
[root]: deepMerge((currentSchema as any)[root] ?? {}, nested),
};
}
@ -214,9 +238,15 @@ export function createLing<S extends LingNode>(
* parámetros adicionales de interpolación (como {{name}}).
*/
export const p = (config: PluralConfig) => {
// Caché por count: la selección de forma plural solo depende de count,
// la interpolación de {{count}} ocurre después en tsRecord.
const cache = new Map<number, LingRecord>();
const fn = (params: { count: number } & Record<string, any>): LingRecord => {
const result: any = {};
const cached = cache.get(params.count);
if (cached) return cached;
const result: any = {};
for (const [locale, forms] of Object.entries(config)) {
if (!forms) continue;
const rule = pluralRule(locale, params.count);
@ -224,12 +254,12 @@ export const p = (config: PluralConfig) => {
result[locale] = typedForms[rule] || typedForms.other;
}
cache.set(params.count, result as LingRecord);
return result as LingRecord;
};
// 👇 Esencial para que el serializador pueda leer la configuración
(fn as any).__pluralConfig = config;
return fn
return fn;
}

@ -1,4 +1,4 @@
import type {DefaultLocale, IDLing, LingRecord, LingString} from "@/ling/lib/types.ts";
import type {IDLing, LingRecord, LingString} from "@/ling/lib/types.ts";
import {DEFAULT_ISO_LOCALE, ID_PREFIX} from "@/ling/lib/consts.ts";
/**
@ -17,14 +17,17 @@ export function isIDLing(value: unknown): value is IDLing {
/**
* Guard para registros de idioma.
* Valida la existencia de la DefaultLocale definida en el sistema.
* Valida que el objeto tenga la DefaultLocale y que su valor sea string,
* evitando falsos positivos con objetos de dominio que casualmente tengan esa clave.
*/
export function isLingRecord(value: unknown): value is LingRecord {
return (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
DEFAULT_ISO_LOCALE in value // obligatorio según DefaultLocale
typeof value !== 'function' &&
DEFAULT_ISO_LOCALE in value &&
typeof (value as Record<string, unknown>)[DEFAULT_ISO_LOCALE] === 'string'
);
}

@ -1,12 +1,14 @@
import type { LingRecord } from '@/ling/lib';
import { p } from '@/ling/lib';
export const nameSpace = 'vcen.editor.mtas';
/**
* Traducciones del editor de metadata.
* Namespace: vcen.editor.mtas
*
* Para registrar en ling:
* ling.extend('vcen', { editor: { mtas: mtasTranslations } })
* ling.extend('vcen.editor.mtas', translations)
*
* Los componentes reciben la instancia glob completa:
* <MetadataEditor {glob} ... />
@ -75,7 +77,11 @@ export const translations = {
moveUp: { es: 'Subir', en: 'Move up' } satisfies LingRecord,
moveDown: { es: 'Bajar', en: 'Move down' } satisfies LingRecord,
edit: { es: 'Editar', en: 'Edit' } satisfies LingRecord,
delete: { es: 'Eliminar', en: 'Delete' } satisfies LingRecord,
delete: { es: 'Eliminar', en: 'Delete' } satisfies LingRecord,
deleteConfirm: { es: 'Eliminar campo', en: 'Delete field' } satisfies LingRecord,
deleteConfirmMsg: { es: 'Esta acción no se puede deshacer.', en: 'This action cannot be undone.' } satisfies LingRecord,
deleteYes: { es: 'Eliminar', en: 'Delete' } satisfies LingRecord,
deleteNo: { es: 'Cancelar', en: 'Cancel' } satisfies LingRecord,
expand: { es: 'Expandir', en: 'Expand' } satisfies LingRecord,
collapse: { es: 'Colapsar', en: 'Collapse' } satisfies LingRecord,
hidden: { es: 'oculto', en: 'hidden' } satisfies LingRecord,
@ -127,6 +133,11 @@ export const translations = {
requiredLocale: { es: 'El campo {{locale}} es obligatorio', en: 'The {{locale}} field is required' } satisfies LingRecord,
},
// ── Tree ──────────────────────────────────────────────────────────
tree: {
selectHint: { es: 'Selecciona un campo para editarlo', en: 'Select a field to edit it' } satisfies LingRecord,
},
} as const;
export type Translations = typeof translations;

@ -19,7 +19,7 @@ function mergeResults(...results: ValidationResult[]): ValidationResult {
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?/;
@ -50,7 +50,7 @@ function validateByType(key: string, field: MetadataField): ValidationResult {
case 'datetime':
return ISO_DATETIME_RE.test(field.value)
? ok() : fail(`[${key}] value must be an ISO 8601 datetime (YYYY-MM-DDThh:mm:ssZ)`);
? ok() : fail(`[${key}] value must be an ISO 8601 datetime (YYYY-MM-DDThh:mmZ)`);
case 'url':
return isValidUrl(field.value)

@ -4,27 +4,28 @@
import FieldTypeIcon from './FieldTypeIcon.svelte';
import TypeBadge from './TypeBadge.svelte';
import FieldValueEditor from './FieldValueEditor.svelte';
import type {GlobInstance} from "@/glob/lib";
let {
open = false,
selectedType,
existingKeys = [],
locales = ['es'],
defaultLocale = 'es',
dark = false,
glob,
onConfirm,
onClose,
}: {
open?: boolean;
selectedType: MetadataFieldType;
existingKeys?: string[];
locales?: string[];
defaultLocale?: string;
dark?: boolean;
glob: GlobInstance;
onConfirm: (key: string, field: MetadataField) => void;
onClose: () => void;
} = $props();
let defaultLocale = $derived(glob.getLocale());
let fieldKey = $state('');
let fieldValue = $state<MetadataField>(defaultForType(selectedType));
let keyError = $state('');
@ -70,19 +71,25 @@
function validateKey(): boolean {
const trimmed = fieldKey.trim();
if (!trimmed) { keyError = 'La clave no puede estar vacía'; return false; }
if (existingKeys.includes(trimmed)){ keyError = `"${trimmed}" ya existe`; return false; }
if (!trimmed) {
keyError = glob.t('vcen.editor.mtas.editor.keyEmpty');
return false;
}
if (existingKeys.includes(trimmed)) {
keyError = glob.t('vcen.editor.mtas.editor.keyExists', { key: trimmed });
return false;
}
keyError = '';
return true;
}
function handleConfirm() {
function handleConfirm(): void {
if (!validateKey()) return;
onConfirm(fieldKey.trim(), fieldValue);
onClose();
}
function handleKeydown(e: KeyboardEvent) {
function handleKeydown(e: KeyboardEvent): void {
if (open && e.key === 'Escape') onClose();
}
</script>
@ -90,73 +97,80 @@
<svelte:window onkeydown={handleKeydown} />
<dialog
data-theme={dark ? 'dark' : 'light'}
class={['modal', open ? 'modal-open' : ''].join(' ')}
data-theme={dark ? 'dark' : 'light'}
class={['modal modal-bottom sm:modal-middle', open ? 'modal-open' : ''].join(' ')}
>
<div class="modal-box max-w-md flex flex-col gap-0 p-0 overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between px-5 py-4 border-b border-base-200">
<!-- ── Header ──────────────────────────────────────────────── -->
<div class="flex items-center justify-between px-5 py-4 border-b border-base-200 bg-base-200/30">
<div class="flex items-center gap-3">
<div class="text-primary">
<FieldTypeIcon type={selectedType} size={20} />
<div class="p-2 rounded-xl bg-primary/10 text-primary">
<FieldTypeIcon type={selectedType} size={18} />
</div>
<div>
<h3 class="font-semibold text-base-content">Nuevo campo</h3>
<div class="mt-0.5">
<TypeBadge type={selectedType} />
<h3 class="font-semibold text-sm text-base-content">
{glob.t('vcen.editor.mtas.drawer.title')}
</h3>
<div class="mt-1">
<TypeBadge type={selectedType} {glob} />
</div>
</div>
</div>
<button type="button" onclick={onClose} class="btn btn-ghost btn-sm btn-circle">
<button
type="button"
onclick={onClose}
class="tooltip tooltip-left btn btn-ghost btn-sm btn-circle text-base-content/60 hover:text-base-content hover:bg-base-300"
data-tip="Esc"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Body -->
<div class="overflow-y-auto px-5 py-4 flex flex-col gap-4">
<!-- ── Body ────────────────────────────────────────────────── -->
<div class="overflow-y-auto px-5 py-5 flex flex-col gap-4">
<!-- Key -->
<!-- Key input -->
<fieldset class="fieldset w-full">
<legend class="fieldset-legend">
Clave del campo
{#if keyError}
<span class="text-error ml-2 font-normal">{keyError}</span>
{/if}
{glob.t('vcen.editor.mtas.editor.keyLabel')}
</legend>
<input
bind:value={fieldKey}
oninput={() => keyError = ''}
onkeydown={(e) => { if (e.key === 'Enter') handleConfirm(); }}
class={['input input-bordered w-full font-mono', keyError ? 'input-error' : ''].join(' ')}
placeholder="nombre_campo"
autofocus
bind:value={fieldKey}
oninput={() => keyError = ''}
onkeydown={(e) => { if (e.key === 'Enter') handleConfirm(); }}
class={['input input-bordered w-full font-mono text-base-content', keyError ? 'input-error' : ''].join(' ')}
placeholder={glob.t('vcen.editor.mtas.editor.keyPlaceholder')}
autofocus
/>
{#if keyError}
<span class="fieldset-label text-error">{keyError}</span>
{/if}
</fieldset>
<!-- Value editor -->
<FieldValueEditor
field={fieldValue}
{locales} {defaultLocale}
onFieldChange={(f) => fieldValue = f}
field={fieldValue} {glob}
onFieldChange={(f) => fieldValue = f}
/>
</div>
<!-- Footer -->
<div class="flex gap-3 px-5 py-4 border-t border-base-200">
<button type="button" onclick={onClose} class="btn btn-ghost flex-1">
Cancelar
<!-- ── Footer ──────────────────────────────────────────────── -->
<div class="flex gap-2 px-5 py-4 border-t border-base-300 bg-base-200/50">
<button type="button" onclick={onClose} class="btn btn-outline btn-neutral flex-1 text-base-content">
{glob.t('vcen.editor.mtas.drawer.cancel')}
</button>
<button type="button" onclick={handleConfirm} class="btn btn-primary flex-1">
Añadir campo
<button type="button" onclick={handleConfirm} class="btn btn-primary flex-1 gap-2">
<svg class="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M8 2v12M2 8h12"/>
</svg>
{glob.t('vcen.editor.mtas.drawer.confirm')}
</button>
</div>
</div>
<!-- Backdrop -->
<div class="modal-backdrop bg-gray/40" onclick={onClose}></div>
<div class="modal-backdrop bg-black/30" onclick={onClose}></div>
</dialog>

@ -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>

@ -1,17 +1,18 @@
<script lang="ts">
import type { MetadataField } from '../../lib';
import FieldValueEditor from './FieldValueEditor.svelte';
import type {GlobInstance} from "@/glob/lib";
let {
fieldKey, field, locales, defaultLocale,
fieldKey, field,
attributes = [],
glob,
onKeyChange, onFieldChange,
}: {
fieldKey: string;
field: MetadataField;
locales: string[];
defaultLocale: string;
attributes?: string[];
glob: GlobInstance;
onKeyChange: (newKey: string) => void;
onFieldChange: (field: MetadataField) => void;
} = $props();
@ -19,7 +20,6 @@
let editKey = $state(fieldKey);
let keyError = $state('');
// Sincronizar con prop si cambia externamente (p.ej. tras rename en otro sitio)
$effect(() => { editKey = fieldKey; });
let asBase = $derived(field as MetadataField & { hidden?: boolean; attrs?: string[] });
@ -28,8 +28,8 @@
function handleKeyCommit(): void {
const trimmed = editKey.trim();
if (!trimmed) {
keyError = 'La clave no puede estar vacía';
editKey = fieldKey; // revertir
keyError = glob.t('vcen.editor.mtas.editor.keyEmpty');
editKey = fieldKey;
return;
}
keyError = '';
@ -37,8 +37,8 @@
}
function handleKeydown(e: KeyboardEvent): void {
e.stopPropagation(); // evitar que suba al header de la fila
if (e.key === 'Enter') (e.currentTarget as HTMLInputElement).blur();
e.stopPropagation();
if (e.key === 'Enter') (e.currentTarget as HTMLInputElement).blur();
if (e.key === 'Escape') { editKey = fieldKey; keyError = ''; (e.currentTarget as HTMLInputElement).blur(); }
}
@ -48,71 +48,71 @@
function toggleAttr(attr: string): void {
const current = asBase.attrs ?? [];
const next = current.includes(attr)
? current.filter(a => a !== attr)
: [...current, attr];
const next = current.includes(attr) ? current.filter(a => a !== attr) : [...current, attr];
update({ attrs: next });
}
</script>
<div class="flex flex-col gap-4 p-4">
<!-- Key -->
<!-- ── Field key ──────────────────────────────────────────────── -->
<fieldset class="fieldset w-full">
<legend class="fieldset-legend">
Clave del campo
{#if keyError}
<span class="text-error ml-2 font-normal text-xs">{keyError}</span>
{/if}
{glob.t('vcen.editor.mtas.editor.keyLabel')}
</legend>
<input
value={editKey}
oninput={(e) => { editKey = (e.currentTarget as HTMLInputElement).value; keyError = ''; }}
onblur={handleKeyCommit}
onkeydown={handleKeydown}
class={['input input-bordered w-full font-mono', keyError ? 'input-error' : ''].join(' ')}
placeholder="nombre_campo"
value={editKey}
oninput={(e) => { editKey = (e.currentTarget as HTMLInputElement).value; keyError = ''; }}
onblur={handleKeyCommit}
onkeydown={handleKeydown}
class={['input input-bordered w-full font-mono', keyError ? 'input-error' : ''].join(' ')}
placeholder={glob.t('vcen.editor.mtas.editor.keyPlaceholder')}
/>
{#if keyError}
<span class="fieldset-label text-error">{keyError}</span>
{/if}
</fieldset>
<!-- Value editor by type -->
<FieldValueEditor {field} {locales} {defaultLocale} {onFieldChange} />
<!-- ── Value editor ───────────────────────────────────────────── -->
<FieldValueEditor {field} {glob} {onFieldChange} />
<!-- Attributes -->
<!-- ── Attributes ────────────────────────────────────────────── -->
{#if attributes.length > 0}
<div class="divider my-0"></div>
<div class="flex flex-col gap-2">
<span class="text-base-content/50">Atributos</span>
<div class="flex flex-wrap gap-2">
{#each attributes as attr (attr)}
{@const active = activeAttrs.includes(attr)}
<button
type="button"
onclick={() => toggleAttr(attr)}
class={['badge badge-md cursor-pointer select-none transition-colors',
active ? 'badge-primary' : 'badge-ghost border border-base-300'
].join(' ')}
>
{#if active}
<svg class="mr-1 h-3 w-3" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M2 6l3 3 5-5"/>
</svg>
{/if}
{attr}
</button>
{/each}
</div>
<div class="divider my-0 text-xs text-base-content/30">Atributos</div>
<div class="flex flex-wrap gap-1.5">
{#each attributes as attr (attr)}
{@const active = activeAttrs.includes(attr)}
<button
type="button"
onclick={() => toggleAttr(attr)}
class={[
'badge badge-md cursor-pointer select-none transition-all gap-1',
active
? 'badge-primary'
: 'badge-ghost border border-base-300 hover:border-primary/40',
].join(' ')}
>
{#if active}
<svg class="h-3 w-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 6l3 3 5-5"/>
</svg>
{/if}
{attr}
</button>
{/each}
</div>
{/if}
<!-- Hidden toggle -->
<!-- ── Hidden toggle ──────────────────────────────────────────── -->
<div class="divider my-0"></div>
<div class="flex items-center gap-4">
<span>Ocultar campo</span>
<input type="checkbox" checked={asBase.hidden ?? false}
onchange={(e) => update({ hidden: (e.currentTarget as HTMLInputElement).checked })}
class="toggle toggle-sm" />
<span class="text-base-content/50">{asBase.hidden ? 'No se muestra en la UI' : 'Visible'}</span>
</div>
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={asBase.hidden ?? false}
onchange={(e) => update({ hidden: (e.currentTarget as HTMLInputElement).checked })}
class="toggle toggle-sm toggle-primary"
/>
<span class="text-sm text-base-content/70">{glob.t('vcen.editor.mtas.editor.hideField')}</span>
</label>
</div>

@ -6,24 +6,25 @@
import FieldRowRecursive from './FieldRow.svelte';
import AddFieldDrawer from "@/mtas/svelte/components/AddFieldDrawer.svelte";
import AddFieldMenu from "@/mtas/svelte/components/AddFieldMenu.svelte";
import type {GlobInstance} from "@/glob/lib";
let {
fieldKey, field, locales, defaultLocale,
fieldKey, field,
depth = 0,
attributes = [],
canMoveUp = false,
canMoveDown = false,
glob,
onKeyChange, onFieldChange, onDelete,
onMoveUp, onMoveDown,
}: {
fieldKey: string;
field: MetadataField;
locales: string[];
defaultLocale: string;
depth?: number;
attributes?: string[];
canMoveUp?: boolean;
canMoveDown?: boolean;
glob: GlobInstance;
onKeyChange: (newKey: string) => void;
onFieldChange: (field: MetadataField) => void;
onDelete: () => void;
@ -31,41 +32,41 @@
onMoveDown?: () => void;
} = $props();
// ── State ────────────────────────────────────────────────────────
let fieldBase = $derived(field as typeof field & { hidden?: boolean });
let expanded = $state(false);
let fieldBase = $derived(field as typeof field & { hidden?: boolean });
let expanded = $state(false);
let deleteModalOpen = $state(false);
let groupOpen = $state(true);
let groupCollapseV = $state(0); // sube al colapsar → fuerza recrear hijos
let childDragSrc = $state<string | null>(null);
let drawerOpen = $state(false);
let drawerType = $state<MetadataFieldType>('string');
let groupCollapseV = $state(0);
let drawerOpen = $state(false);
let drawerType = $state<MetadataFieldType>('string');
const ALL_TYPES: MetadataFieldType[] = [
'string','number','boolean','ling','date','datetime',
'url','currency','image','video','audio','pdf','file','html','group'
];
// ── Derived ──────────────────────────────────────────────────────
let validationResult = $derived(validateField(fieldKey, field));
let validationErrors = $derived((validationResult as { valid: boolean; errors?: string[] }).errors ?? []);
let groupChildCount = $derived(field.type === 'group' ? Object.keys(field.value).length : 0);
let summary = $derived((() => {
switch (field.type) {
case 'string': return field.value || '—';
case 'number': return String(field.value);
case 'boolean': return field.value ? 'Verdadero' : 'Falso';
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':
case 'datetime': return field.value || '—';
case 'url': return field.value || '—';
case 'currency': return `${field.value.amount} ${field.value.currency}`;
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 `${Object.keys(field.value).length} campos`;
case 'file': return (field.value as { url: string }).url || '—';
case 'group': {
const count = Object.keys(field.value).length;
return glob.t('vcen.editor.mtas.field.nestedCount', { count });
}
}
})());
@ -73,7 +74,6 @@
field.type === 'image' ? (field.value as { url: string }).url || null : null
);
// ── Group operations ─────────────────────────────────────────────
function openChildDrawer(type: MetadataFieldType): void {
drawerType = type;
drawerOpen = true;
@ -114,7 +114,6 @@
onFieldChange({ ...field, value: Object.fromEntries(reordered) } as MetadataField);
}
// ── UI helpers ───────────────────────────────────────────────────
function toggleExpanded(): void {
expanded = !expanded;
if (!expanded && field.type === 'group') {
@ -125,56 +124,58 @@
function handleExpandKey(e: KeyboardEvent): void {
if (e.key === 'Enter') {
expanded = !expanded;
toggleExpanded();
} else if (e.key === 'ArrowUp' && canMoveUp) {
e.preventDefault();
onMoveUp?.();
requestAnimationFrame(() => requestAnimationFrame(() => {
const row = document.getElementById(`field-row-${fieldKey}`);
row?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
row?.querySelector<HTMLElement>('[role="button"]')?.focus();
document.getElementById(`field-row-${fieldKey}`)
?.querySelector<HTMLElement>('[role="button"]')?.focus();
}));
} else if (e.key === 'ArrowDown' && canMoveDown) {
e.preventDefault();
onMoveDown?.();
requestAnimationFrame(() => requestAnimationFrame(() => {
const row = document.getElementById(`field-row-${fieldKey}`);
row?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
row?.querySelector<HTMLElement>('[role="button"]')?.focus();
document.getElementById(`field-row-${fieldKey}`)
?.querySelector<HTMLElement>('[role="button"]')?.focus();
}));
}
}
function asField(v: unknown): MetadataField { return v as MetadataField; }
</script>
<div
id="field-row-{fieldKey}"
class={[
'card bg-base-100 border transition-all duration-150 flex-row focus-within:border-base-content w-full',
depth > 0 ? 'w-full' : '',
!validationResult.valid ? 'border-error'
: expanded ? 'border-base-300 shadow-sm'
: 'border-base-200 hover:border-base-300',
id="field-row-{fieldKey}"
class={[
'card bg-base-100 border transition-all duration-150 flex-row w-full',
!validationResult.valid ? 'border-error/50 shadow-sm'
: expanded ? 'border-primary/40 shadow-sm'
: 'border-base-300 hover:border-base-content/20',
].join(' ')}
>
<!-- ── Move buttons (fuera del header clickable) ──────────────── -->
<!-- ── Move buttons ──────────────────────────────────────────── -->
{#if depth === 0}
<div class="flex shrink-0 flex-col items-center justify-center gap-0.5 border-r border-base-200 px-1">
<button type="button"
onclick={() => onMoveUp?.()}
disabled={!canMoveUp}
class="btn-move-up btn btn-ghost btn-xs btn-square h-5 min-h-0 disabled:opacity-20"
title="Subir">
<svg class="h-3 w-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2" xmlns="http://www.w3.org/2000/svg">
<div class="flex shrink-0 flex-col items-center justify-center gap-0.5 border-r border-base-300 bg-base-200/70 px-1">
<button
type="button"
onclick={() => onMoveUp?.()}
disabled={!canMoveUp}
class="tooltip tooltip-right btn btn-ghost btn-xs btn-square h-5 min-h-0 disabled:opacity-20"
data-tip={glob.t('vcen.editor.mtas.field.moveUp')}
>
<svg class="h-3 w-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<path d="M2 8l4-4 4 4"/>
</svg>
</button>
<button type="button"
onclick={() => onMoveDown?.()}
disabled={!canMoveDown}
class="btn-move-down btn btn-ghost btn-xs btn-square h-5 min-h-0 disabled:opacity-20"
title="Bajar">
<svg class="h-3 w-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2" xmlns="http://www.w3.org/2000/svg">
<button
type="button"
onclick={() => onMoveDown?.()}
disabled={!canMoveDown}
class="tooltip tooltip-right btn btn-ghost btn-xs btn-square h-5 min-h-0 disabled:opacity-20"
data-tip={glob.t('vcen.editor.mtas.field.moveDown')}
>
<svg class="h-3 w-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<path d="M2 4l4 4 4-4"/>
</svg>
</button>
@ -182,141 +183,174 @@
{/if}
<div class="flex-1 min-w-0">
<!-- ── Row header ─────────────────────────────────────────────── -->
<!-- ── Row header ─────────────────────────────────────────── -->
<div
role="button"
tabindex="0"
onkeydown={handleExpandKey}
class={[
'flex cursor-pointer items-center gap-3 px-4 focus-visible:outline-none',
thumbUrl ? 'py-2' : 'py-3',
expanded ? 'bg-base-200/50' : 'hover:bg-base-200/30',
].join(' ')}
role="button"
tabindex="0"
onclick={toggleExpanded}
onkeydown={handleExpandKey}
class={[
'flex cursor-pointer items-center gap-2.5 px-3 focus-visible:outline-none select-none',
thumbUrl ? 'py-2' : 'py-2.5',
expanded ? 'bg-base-200/50' : 'hover:bg-base-200/30',
].join(' ')}
>
<!-- Validation dot -->
<div
role="img"
aria-label={validationResult.valid ? 'Campo válido' : 'Campo inválido'}
class={['h-2 w-2 shrink-0 rounded-full', validationResult.valid ? 'bg-success' : 'bg-error'].join(' ')}
class={[
'tooltip tooltip-right h-1.5 w-1.5 shrink-0 rounded-full transition-colors',
validationResult.valid ? 'bg-success' : 'bg-error animate-pulse',
].join(' ')}
role="img"
aria-label={validationResult.valid
? glob.t('vcen.editor.mtas.field.fieldValid')
: glob.t('vcen.editor.mtas.field.fieldInvalid')}
data-tip={validationResult.valid
? glob.t('vcen.editor.mtas.field.fieldValid')
: glob.t('vcen.editor.mtas.field.fieldInvalid')}
></div>
<!-- Type badge -->
<TypeBadge type={field.type} showLabel={false} />
<!-- Type badge — con indicator de hijos para grupos -->
{#if field.type === 'group' && groupChildCount > 0}
<div class="indicator shrink-0">
<span class="indicator-item indicator-end badge badge-xs badge-primary">{groupChildCount}</span>
<TypeBadge type={field.type} showLabel={false} {glob} />
</div>
{:else}
<TypeBadge type={field.type} showLabel={false} {glob} />
{/if}
<!-- Key -->
<span class="shrink-0 text-base-content">{fieldKey}</span>
<span class="shrink-0 font-mono text-sm font-medium text-base-content">{fieldKey}</span>
<!-- Summary -->
<span class="min-w-0 flex-1 truncate text-base-content/40">{summary}</span>
<span class="min-w-0 flex-1 truncate text-xs text-base-content/55">{summary}</span>
<!-- Image thumb — right side -->
<!-- Image thumbnail -->
{#if thumbUrl}
<div class="h-10 w-10 shrink-0 overflow-hidden rounded-box border border-base-300">
<div class="h-8 w-8 shrink-0 overflow-hidden rounded-lg border border-base-300">
<img src={thumbUrl} alt={fieldKey} class="h-full w-full object-cover" />
</div>
{/if}
<!-- Hidden badge -->
{#if fieldBase.hidden}
<span class="badge badge-sm badge-neutral badge-soft">oculto</span>
<span class="badge badge-xs badge-ghost border border-base-300 gap-1">
<svg class="h-2.5 w-2.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M13.359 11.238C15.06 9.72 16 8 16 8s-3-5.5-8-5.5a7 7 0 0 0-2.79.588l.77.771A5.944 5.944 0 0 1 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.134 13.134 0 0 1 14.828 8a13.68 13.68 0 0 1-1.15 1.475M6.12 6.12A3 3 0 0 0 9.88 9.88M2.343 2.343l11.314 11.314M4.16 4.16A7.024 7.024 0 0 0 1.172 8s3 5.5 8 5.5a7.024 7.024 0 0 0 3.402-.91"/>
</svg>
{glob.t('vcen.editor.mtas.field.hidden')}
</span>
{/if}
<!-- Actions -->
<div role="group" aria-label="Acciones" class="flex shrink-0 items-center gap-1">
<button type="button"
onclick={(e) => {
toggleExpanded(); }}
class="btn btn-ghost btn-xs btn-square" title="Editar">
<svg class="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg">
<!-- Edit + Delete (join) -->
<div class="join shrink-0" role="group">
<button
type="button"
onclick={(e) => { e.stopPropagation(); toggleExpanded(); }}
class="tooltip tooltip-top join-item btn btn-ghost btn-xs btn-square"
data-tip={glob.t('vcen.editor.mtas.field.edit')}
aria-label={glob.t('vcen.editor.mtas.field.edit')}
>
<svg class="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M11.5 2.5l2 2-9 9H2.5v-2l9-9z"/>
</svg>
</button>
<button type="button"
onclick={(e) => { e.stopPropagation(); onDelete(); }}
class="btn btn-ghost btn-xs btn-square text-error" title="Eliminar">
<svg class="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg">
<button
type="button"
onclick={(e) => { e.stopPropagation(); deleteModalOpen = true; }}
class="tooltip tooltip-top join-item btn btn-ghost btn-xs btn-square text-error hover:bg-error/10"
data-tip={glob.t('vcen.editor.mtas.field.delete')}
aria-label={glob.t('vcen.editor.mtas.field.delete')}
>
<svg class="h-3.5 w-3.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>
</button>
</div>
<!-- Chevron -->
<button type="button"
onclick={toggleExpanded}
aria-label={expanded ? 'Colapsar' : 'Expandir'}
class="btn btn-ghost btn-xs btn-square h-5 min-h-0">
<svg
aria-hidden="true"
class={['h-4 w-4 shrink-0 text-base-content/30 transition-transform duration-200', expanded ? 'rotate-180' : ''].join(' ')}
viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" xmlns="http://www.w3.org/2000/svg">
<path d="M4 6l4 4 4-4"/>
</svg>
</button>
<!-- Expand chevron -->
<svg
aria-hidden="true"
class={['h-4 w-4 shrink-0 text-base-content/30 transition-transform duration-200', expanded ? 'rotate-180' : ''].join(' ')}
viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2"
>
<path d="M4 6l4 4 4-4"/>
</svg>
</div>
<!-- ── Validation errors ──────────────────────────────────────── -->
<!-- ── Validation error ───────────────────────────────────── -->
{#if !validationResult.valid && validationErrors.length}
<div class="flex items-center gap-2 border-t border-error/20 bg-error/5 px-4 py-2.5">
<svg aria-hidden="true" class="h-4 w-4 shrink-0 text-error" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" 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"/>
<div class="flex items-center gap-2 border-t border-error/20 bg-error/5 px-4 py-2">
<svg aria-hidden="true" class="h-3.5 w-3.5 shrink-0 text-error" 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>
<span class="text-error">{validationErrors[0]}</span>
<span class="text-error text-xs">{validationErrors[0]}</span>
</div>
{/if}
<!-- ── Inline editor ──────────────────────────────────────────── -->
<!-- ── Inline editor ──────────────────────────────────────── -->
{#if expanded}
<div class="border-t border-base-200">
<div class="border-t border-base-300">
<FieldEditor
{fieldKey} {field} {locales} {defaultLocale}
{attributes}
{onKeyChange}
onFieldChange={(f) => onFieldChange(f)}
{fieldKey} {field}
{attributes} {glob}
{onKeyChange}
onFieldChange={(f) => onFieldChange(f)}
/>
</div>
{/if}
<!-- ── Group children ────────────────────────────────────────── -->
<!-- ── Group children ────────────────────────────────────── -->
{#if field.type === 'group'}
<div class="border-t border-base-200">
<div class="flex items-center gap-2 bg-base-200/40 px-4 py-2">
<!-- Toggle colapsar/expandir grupo -->
<button type="button" onclick={() => groupOpen = !groupOpen}
class="btn btn-ghost btn-xs gap-1.5">
<svg aria-hidden="true"
class={['h-4 w-4 transition-transform duration-150', groupOpen ? 'rotate-90' : ''].join(' ')}
viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" xmlns="http://www.w3.org/2000/svg">
<div class="border-t border-base-300">
<div class="flex items-center gap-2 bg-base-200/60 px-3 py-1.5">
<button
type="button"
onclick={() => groupOpen = !groupOpen}
class="btn btn-ghost btn-xs gap-1.5 font-normal text-base-content/50 hover:text-base-content"
>
<svg
aria-hidden="true"
class={['h-3.5 w-3.5 transition-transform duration-150', groupOpen ? 'rotate-90' : ''].join(' ')}
viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2"
>
<path d="M6 4l4 4-4 4"/>
</svg>
{Object.keys(field.value).length} campos anidados
<span class="text-xs">{glob.t('vcen.editor.mtas.field.nestedCount', { count: groupChildCount })}</span>
</button>
<div class="ml-auto">
<AddFieldMenu onSelect={openChildDrawer} iconOnly={true} />
<AddFieldMenu onSelect={openChildDrawer} iconOnly={true} placement="start" {glob} />
</div>
</div>
{#if groupOpen}
<div class="flex flex-col gap-2 py-3 pl-4 pr-3 overflow-hidden">
{#if Object.keys(field.value).length === 0}
<div class="rounded-box flex items-center justify-center border-2 border-dashed border-base-300 py-6">
<span class="text-base-content/30">Grupo vacío — añade campos con los botones de arriba</span>
<div class="flex flex-col gap-1.5 p-2 pl-3 bg-base-200/10 overflow-hidden">
{#if groupChildCount === 0}
<div class="rounded-xl flex items-center justify-center gap-2 border-2 border-dashed border-base-300 py-5 text-base-content/30">
<svg class="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="2" width="5" height="5" rx="1"/>
<rect x="9" y="2" width="5" height="5" rx="1"/>
<rect x="2" y="9" width="5" height="5" rx="1"/>
<rect x="9" y="9" width="5" height="5" rx="1" opacity=".4"/>
</svg>
<span class="text-xs">{glob.t('vcen.editor.mtas.field.emptyGroup')}</span>
</div>
{:else}
{#each Object.keys(field.value) as childKey, ci (`${childKey}-${groupCollapseV}`)}
<FieldRowRecursive
fieldKey={childKey}
field={asField(field.value[childKey])}
{locales} {defaultLocale}
{attributes}
depth={depth + 1}
canMoveUp={ci > 0}
canMoveDown={ci < Object.keys(field.value).length - 1}
onKeyChange={(newKey) => renameChildField(childKey, newKey)}
onFieldChange={(f) => updateChildField(childKey, f)}
onDelete={() => deleteChildField(childKey)}
onMoveUp={() => moveChildField(childKey, -1)}
onMoveDown={() => moveChildField(childKey, 1)}
fieldKey={childKey}
field={asField(field.value[childKey])}
{attributes} {glob}
depth={depth + 1}
canMoveUp={ci > 0}
canMoveDown={ci < groupChildCount - 1}
onKeyChange={(newKey) => renameChildField(childKey, newKey)}
onFieldChange={(f) => updateChildField(childKey, f)}
onDelete={() => deleteChildField(childKey)}
onMoveUp={() => moveChildField(childKey, -1)}
onMoveDown={() => moveChildField(childKey, 1)}
/>
{/each}
{/if}
@ -324,18 +358,57 @@
{/if}
</div>
{/if}
</div><!-- end flex-1 -->
</div>
</div>
<!-- Drawer for adding child fields -->
<!-- ── Delete confirmation modal ──────────────────────────────────── -->
<dialog class={['modal modal-bottom sm:modal-middle', deleteModalOpen ? '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>
<div class="mt-2 flex items-center gap-2 rounded-lg bg-base-200 px-3 py-2">
<TypeBadge type={field.type} showLabel={false} {glob} />
<span class="font-mono text-sm font-medium text-base-content truncate">{fieldKey}</span>
</div>
</div>
</div>
<div class="flex gap-2">
<button
type="button"
onclick={() => deleteModalOpen = false}
class="btn btn-outline btn-neutral flex-1"
>
{glob.t('vcen.editor.mtas.field.deleteNo')}
</button>
<button
type="button"
onclick={() => { deleteModalOpen = false; onDelete(); }}
class="btn btn-error flex-1"
>
{glob.t('vcen.editor.mtas.field.deleteYes')}
</button>
</div>
</div>
<div class="modal-backdrop bg-black/30" onclick={() => deleteModalOpen = false}></div>
</dialog>
{#if field.type === 'group'}
<AddFieldDrawer
open={drawerOpen}
selectedType={drawerType}
existingKeys={Object.keys(field.value)}
{locales}
{defaultLocale}
onConfirm={handleDrawerConfirm}
onClose={() => drawerOpen = false}
open={drawerOpen}
selectedType={drawerType}
existingKeys={Object.keys(field.value)} {glob}
onConfirm={handleDrawerConfirm}
onClose={() => drawerOpen = false}
/>
{/if}
{/if}

@ -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}
/>

@ -2,34 +2,32 @@
import type { Metadata, MetadataField, MetadataFieldType } from '../../lib';
import { validateMetadata, field as f } from '../../lib';
import FieldRow from './FieldRow.svelte';
import FieldTypeIcon from './FieldTypeIcon.svelte';
import AddFieldDrawer from './AddFieldDrawer.svelte';
import AddFieldMenu from "@/mtas/svelte/components/AddFieldMenu.svelte";
import {makeLingRecord} from "@/ling/lib";
import { flip } from 'svelte/animate';
import AddFieldMenu from "@/mtas/svelte/components/AddFieldMenu.svelte";
import type {GlobInstance} from "@/glob/lib";
let {
value = $bindable<Metadata>({}),
locales = ['es'],
defaultLocale = 'es',
dark = false,
attributes = [],
pagination = true,
glob,
onUpdate,
}: {
value?: Metadata;
locales?: string[];
defaultLocale?: string;
dark?: boolean;
attributes?: string[];
pagination?: boolean;
glob: GlobInstance;
onUpdate?: (metadata: Metadata) => void;
} = $props();
let drawerOpen = $state(false);
let drawerType = $state<MetadataFieldType>('string');
// ── Pagination ───────────────────────────────────────────────────
// ── Pagination ────────────────────────────────────────────────────
let page = $state(1);
let pageSize = $state(10);
const PAGE_SIZES = [5, 10, 20, 50];
@ -37,7 +35,6 @@
let keyOrder = $derived(
value.order ?? Object.keys(value).filter(k => k !== 'order')
);
let totalPages = $derived(
pageSize === 0 ? 1 : Math.max(1, Math.ceil(keyOrder.length / pageSize))
);
@ -52,44 +49,41 @@
let paginationPages = $derived((() => {
if (totalPages <= 7) return Array.from({ length: totalPages }, (_, i) => i + 1);
const pages: number[] = [1];
if (page > 3) pages.push(-1);
if (page > 3) pages.push(-1);
for (let p = Math.max(2, page - 1); p <= Math.min(totalPages - 1, page + 1); p++) pages.push(p);
if (page < totalPages - 2) pages.push(-1);
if (page < totalPages - 2) pages.push(-1);
pages.push(totalPages);
return pages;
})());
let validation = $derived(validateMetadata(value));
function emit(): void { onUpdate?.(value); }
function defaultField(type: MetadataFieldType): MetadataField {
switch (type) {
case 'string': return f.string('');
case 'number': return f.number(0);
case 'boolean': return f.boolean(false);
case 'ling': return f.ling(makeLingRecord(''));
case 'date': return f.date('');
case 'datetime': return f.datetime('');
case 'url': return f.url('');
case 'currency': return f.currency({ amount: 0, currency: 'EUR' });
case 'image': return f.image({ url: '' });
case 'video': return f.video({ url: '' });
case 'audio': return f.audio({ url: '' });
case 'pdf': return f.pdf({ url: '' });
case 'file': return f.file({ url: '' });
case 'html': return f.html(makeLingRecord(''));
case 'group': return f.group({});
case 'string': return f.string('');
case 'number': return f.number(0);
case 'boolean': return f.boolean(false);
case 'ling': return f.ling(makeLingRecord(''));
case 'date': return f.date('');
case 'datetime': return f.datetime('');
case 'url': return f.url('');
case 'currency': return f.currency({ amount: 0, currency: 'EUR' });
case 'image': return f.image({ url: '' });
case 'video': return f.video({ url: '' });
case 'audio': return f.audio({ url: '' });
case 'pdf': return f.pdf({ url: '' });
case 'file': return f.file({ url: '' });
case 'html': return f.html(makeLingRecord(''));
case 'group': return f.group({});
}
}
function openDrawer(type: MetadataFieldType): void { drawerType = type; drawerOpen = true; }
function handleDrawerConfirm(key: string, field: MetadataField): void {
const newOrder = [...keyOrder, key];
value = { ...value, [key]: field, order: newOrder };
value = { ...value, [key]: field, order: [...keyOrder, key] };
emit();
}
@ -137,113 +131,141 @@
</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-200 shadow-sm w-full flex flex-col flex-1 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">
<!-- ── Header ─────────────────────────────────────────────── -->
<div class="flex items-center gap-3 px-4 py-3 border-b border-base-300 bg-base-200/60 shrink-0">
<!-- ── Header ──────────────────────────────────────────────────── -->
<div class="flex items-center gap-2 px-5 py-3 border-b border-base-200 shrink-0">
<div role="img"
aria-label={validation.valid ? 'Válido' : 'Con errores'}
title={validation.valid ? 'Válido' : `${validation.errors.length} errores`}
class={['h-2 w-2 shrink-0 rounded-full', validation.valid ? 'bg-success' : 'bg-error'].join(' ')}
></div>
<h2 class="font-semibold text-base">Metadata</h2>
<span class="text-base-content/40 text-sm">
{keyOrder.length} {keyOrder.length === 1 ? 'campo' : 'campos'}
<!-- Validation indicator -->
<div
class="tooltip tooltip-right"
data-tip={validation.valid
? glob.t('vcen.editor.mtas.validation.valid')
: glob.t('vcen.editor.mtas.validation.errorCount', { count: validation.errors.length })}
>
<div class={[
'h-2 w-2 rounded-full shrink-0 transition-colors',
validation.valid ? 'bg-success' : 'bg-error animate-pulse',
].join(' ')}></div>
</div>
<h2 class="font-semibold text-sm 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>
<div class="ml-auto">
<AddFieldMenu onSelect={openDrawer} />
<AddFieldMenu onSelect={openDrawer} {glob} />
</div>
</div>
<!-- ── Field list ─────────────────────────────────────────────── -->
<div class="flex flex-col gap-2 p-4 overflow-y-auto flex-1 min-h-0">
<!-- ── Field list ──────────────────────────────────────────── -->
<div class="flex flex-col gap-2 p-3 overflow-y-auto flex-1 min-h-0">
{#if keyOrder.length === 0}
<div class="rounded-box flex flex-col items-center justify-center gap-3 border-2 border-dashed border-base-300 py-16">
<svg class="h-10 w-10 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"/>
<div class="rounded-2xl flex flex-col items-center justify-center gap-3 border-2 border-dashed border-base-300 py-16 text-base-content/25">
<svg class="h-10 w-10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<rect x="3" y="3" width="8" height="8" rx="2"/>
<rect x="13" y="3" width="8" height="8" rx="2"/>
<rect x="3" y="13" width="8" height="8" rx="2"/>
<rect x="13" y="13" width="8" height="8" rx="2" opacity=".4"/>
</svg>
<p class="text-base-content/30">Usa el botón Añadir para crear campos</p>
<div class="flex flex-col items-center gap-1">
<span class="text-sm font-medium">{glob.t('vcen.editor.mtas.field.empty')}</span>
</div>
</div>
{:else}
{#each pageKeys as key (key)}
{@const gi = keyOrder.indexOf(key)}
<div animate:flip={{ duration: 180 }} class="w-full">
<FieldRow
fieldKey={key}
field={getField(key)}
{locales} {defaultLocale}
{attributes}
canMoveUp={gi > 0}
canMoveDown={gi < keyOrder.length - 1}
onKeyChange={(newKey) => updateFieldKey(key, newKey)}
onFieldChange={(field) => updateField(key, field)}
onDelete={() => deleteField(key)}
onMoveUp={() => moveField(key, -1)}
onMoveDown={() => moveField(key, 1)}
fieldKey={key}
field={getField(key)}
{attributes} {glob}
canMoveUp={gi > 0}
canMoveDown={gi < keyOrder.length - 1}
onKeyChange={(newKey) => updateFieldKey(key, newKey)}
onFieldChange={(field) => updateField(key, field)}
onDelete={() => deleteField(key)}
onMoveUp={() => moveField(key, -1)}
onMoveDown={() => moveField(key, 1)}
/>
</div>
{/each}
{/if}
</div>
<!-- ── Validation errors ───────────────────────────────────── -->
{#if !validation.valid}
<div class="px-4 pb-3">
<div class="alert alert-error">
<div class="flex flex-col gap-1">
<span>Errores de validación</span>
<div class="px-3 pb-3 shrink-0">
<div class="alert alert-error 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-sm">· {error}</span>
<span class="text-xs opacity-80">· {error}</span>
{/each}
</div>
</div>
</div>
{/if}
<!-- ── Footer: paginación ──────────────────────────────────────── -->
<!-- ── Pagination ──────────────────────────────────────────── -->
{#if pagination && keyOrder.length > 0}
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-base-200 px-5 py-3">
<div class="flex items-center justify-between gap-3 border-t border-base-300 bg-base-200/40 px-4 py-2.5 shrink-0">
<!-- Filas por página -->
<div class="flex items-center gap-2">
<span class="text-xs text-base-content/40">Filas por página</span>
<div class="flex items-center gap-2 shrink-0">
<span class="text-xs text-base-content/40 whitespace-nowrap">
{glob.t('vcen.editor.mtas.pagination.rowsPerPage')}
</span>
<select
class="select select-bordered select-xs"
bind:value={pageSize}
onchange={() => { page = 1; }}
class="select select-bordered select-xs"
bind:value={pageSize}
onchange={() => { page = 1; }}
>
{#each PAGE_SIZES as s}
<option value={s}>{s}</option>
{/each}
<option value={0}>Todos</option>
<option value={0}>{glob.t('vcen.editor.mtas.pagination.all')}</option>
</select>
</div>
<!-- Barra de páginas -->
{#if totalPages > 1}
<div class="join">
<button type="button" class="join-item btn btn-xs" disabled={page === 1}
onclick={() => page--} aria-label="Página anterior">‹</button>
{#each paginationPages as p (p === -1 ? `ellipsis-${p}` : p)}
<button
type="button" class="join-item btn btn-xs" disabled={page === 1}
onclick={() => page--}
aria-label={glob.t('vcen.editor.mtas.pagination.prevPage')}
>‹</button>
{#each paginationPages as p (p === -1 ? `e-${p}` : p)}
{#if p === -1}
<button type="button" class="join-item btn btn-xs btn-disabled" tabindex="-1">…</button>
{:else}
<button type="button"
class={['join-item btn btn-xs', p === page ? 'btn-active' : ''].join(' ')}
onclick={() => page = p}
aria-label="Página {p}"
aria-current={p === page ? 'page' : undefined}
<button
type="button"
class={['join-item btn btn-xs', p === page ? 'btn-active' : ''].join(' ')}
onclick={() => page = p}
aria-label={glob.t('vcen.editor.mtas.pagination.pageN', { page: p })}
aria-current={p === page ? 'page' : undefined}
>{p}</button>
{/if}
{/each}
<button type="button" class="join-item btn btn-xs" disabled={page === totalPages}
onclick={() => page++} aria-label="Página siguiente">›</button>
<button
type="button" class="join-item btn btn-xs" disabled={page === totalPages}
onclick={() => page++}
aria-label={glob.t('vcen.editor.mtas.pagination.nextPage')}
>›</button>
</div>
{/if}
<!-- Rango visible -->
<span class="text-xs text-base-content/30">
{keyOrder.length === 0 ? '0' : rangeStart}–{rangeEnd} de {keyOrder.length}
<span class="text-xs text-base-content/30 shrink-0">
{glob.t('vcen.editor.mtas.pagination.range', { from: rangeStart, to: rangeEnd, total: keyOrder.length })}
</span>
</div>
@ -253,12 +275,9 @@
</div>
<AddFieldDrawer
open={drawerOpen}
selectedType={drawerType}
existingKeys={keyOrder}
{locales}
{defaultLocale}
{dark}
onConfirm={handleDrawerConfirm}
onClose={() => drawerOpen = false}
open={drawerOpen}
selectedType={drawerType}
existingKeys={keyOrder} {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>

@ -3,19 +3,20 @@
import MetadataEditor from '../components/MetadataEditor.svelte';
import { ling as lingInstance } from '@/ling/lib'
import LingInput from '@/ling/svelte/LingInput.svelte';
import {, type Metadata} from '../../lib';
import {type Metadata} from '../../lib';
import {createLing, type LingNode, type LingRecord, p} from '@/ling/lib';
import {createGlob, type CurrDefinition} from '@/glob/lib'
import {unitData} from "@/glob/lib/unit_data.ts";
import { nameSpace, translations} from "@/mtas/lib/translations.ts";
import MetaTree from "@/mtas/svelte/components/MetaTree.svelte";
// ── Demo metadata con todos los tipos ───────────────────────────
// ── Demo metadata ────────────────────────────────────────────────
let metadata = $state<Metadata>({
order: ['nombre','año','disponible','descripcion','lanzamiento','ultima_mod','web','precio','foto','manual','specs'],
nombre: { type: 'string', value: 'Toyota Corolla' },
año: { type: 'number', value: 2024 },
disponible: { type: 'boolean', value: true },
descripcion: { type: 'ling', value: { es: 'Sedán compacto fiable', en: 'Reliable compact sedan' } as LingRecord},
descripcion: { type: 'ling', value: { es: 'Sedán compacto fiable', en: 'Reliable compact sedan' } as LingRecord },
lanzamiento: { type: 'date', value: '2024-01-15' },
ultima_mod: { type: 'datetime', value: '2024-03-09T10:00:00Z' },
web: { type: 'url', value: 'https://toyota.es' },
@ -23,21 +24,16 @@
foto: { type: 'image', value: { url: 'https://images.unsplash.com/photo-1621007947382-bb3c3994e3fb?w=400&h=300&fit=crop', mimeType: 'image/jpeg' } },
manual: { type: 'pdf', value: { url: 'https://example.com/manual.pdf', filename: 'manual.pdf' } },
specs: { type: 'group', value: {
motor: { type: 'string', value: '1.8 VVT-i' },
cv: { type: 'number', value: 122 },
traccion: { type: 'string', value: 'FWD' },
motor: { type: 'string', value: '1.8 VVT-i' },
cv: { type: 'number', value: 122 },
traccion: { type: 'string', value: 'FWD' },
}, order: ['cv', 'motor', 'traccion'] },
});
// ── LingInput demo ───────────────────────────────────────────────
let lingValue = $state<LingRecord>({ es: 'Hola mundo', en: '' });
let lingInvalid = $state(false);
// ── JSON output ─────────────────────────────────────────────────
let showJson = $state(false);
let darkMode = $state(false);
let activeTab = $state<'editor' | 'tree'>('editor');
const locales = ['es', 'en', 'fr'];
const defaultLocale = 'es';
const currDefinitions: Record<string, CurrDefinition> = {
@ -45,28 +41,32 @@
symbol : '€',
code : 'EUR',
name : p({
es: { one: 'euro', other: 'euros' },
en: { one: 'euro', other: 'euros' } }) ,
es: { one: 'euro', other: 'euros' },
en: { one: 'euro', other: 'euros' },
}),
conversionValue: 1,
},
USD: {
symbol : '$',
code : 'USD',
name : p({ es: { one: 'dólar', other: 'dólares' }, en: { one: 'dollar', other: 'dollars' } }),
name : p({
es: { one: 'dólar', other: 'dólares' },
en: { one: 'dollar', other: 'dollars' },
}),
conversionValue: 1.08,
},
};
const glob = createGlob({
locale: defaultLocale,
ling : lingInstance,
numr : { minDecimals: 0, maxDecimals: 2 },
curr : { definitions: currDefinitions, selectedCurrency: 'EUR' },
dati : { selectedTimeFormat: '24h' },
unit : { selectedSystem: 'metric', categories: unitData },
locale : defaultLocale,
locales: ['es', 'en'],
ling : lingInstance,
numr : { minDecimals: 0, maxDecimals: 2 },
curr : { definitions: currDefinitions, selectedCurrency: 'EUR' },
dati : { selectedTimeFormat: '24h' },
unit : { selectedSystem: 'metric', categories: unitData },
});
glob.ling.extend(nameSpace, translations);
</script>
<svelte:head>
@ -78,10 +78,6 @@
<div class={['min-h-screen transition-colors duration-300', darkMode ? 'dark bg-zinc-950' : 'bg-zinc-50'].join(' ')}
style="font-family: 'Poppins', sans-serif;">
<!-- ── Background grid ────────────────────────────────────────── -->
<!-- ── Header ─────────────────────────────────────────────────── -->
<header class="relative border-b border-zinc-200 bg-white/80 backdrop-blur dark:border-zinc-800 dark:bg-zinc-950/80">
<div class="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
@ -146,7 +142,7 @@
<span class="text-zinc-300 dark:text-zinc-700">Editor</span>
</h1>
<p class="mt-3 font-mono text-[12px] text-zinc-400 dark:text-zinc-500">
Campos tipados · Validación en tiempo real · Drag & drop · Dark mode
Campos tipados · Validación en tiempo real · Dark mode
</p>
</div>
@ -170,63 +166,58 @@
</div>
{:else}
<div class="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_320px] h-[600px]">
<!-- ── Left: MetadataEditor ──────────────────────── -->
<div class="flex flex-col gap-2 min-h-0">
<div class="flex items-center gap-2 px-0.5">
<span class="font-mono text-[10px] font-bold uppercase tracking-[0.16em] text-zinc-400 dark:text-zinc-600">
MetadataEditor
</span>
<div class="h-px flex-1 bg-zinc-100 dark:bg-zinc-800 w-full"></div>
</div>
<MetadataEditor
bind:value={metadata}
{locales}
attributes={['hidden','required']}
{defaultLocale}
dark={darkMode}
onUpdate={(m) => metadata = m}
/>
</div>
<!-- ── Right: LingInput demo + stats ─────────────── -->
<div class="flex flex-col gap-6">
<!-- LingInput showcase -->
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2 px-0.5">
<span class="font-mono text-[10px] font-bold uppercase tracking-[0.16em] text-zinc-400 dark:text-zinc-600">
LingInput
</span>
<div class="h-px flex-1 bg-zinc-100 dark:bg-zinc-800"></div>
</div>
</div>
<!-- Stats card -->
<div class="rounded-xl border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900">
<p class="mb-3 font-mono text-[10px] font-bold uppercase tracking-[0.16em] text-zinc-400 dark:text-zinc-600">
Estado actual
</p>
<div class="flex flex-col gap-2">
{#each [
{ label: 'campos', value: Object.keys(metadata).length },
{ label: 'tipos únicos', value: new Set(Object.values(metadata).map((f: any) => f.type)).size },
{ label: 'con hidden', value: Object.values(metadata).filter((f: any) => f.hidden).length },
] as stat}
<div class="flex items-center justify-between">
<span class="font-mono text-[11px] text-zinc-400 dark:text-zinc-500">{stat.label}</span>
<span class="font-mono text-[13px] font-bold text-zinc-700 dark:text-zinc-300">{stat.value}</span>
</div>
{/each}
</div>
</div>
<!-- ── Tabs ───────────────────────────────────────────── -->
<div class="flex flex-col gap-4 h-[640px]">
<!-- Tab bar -->
<div class="flex items-center gap-1 border-b border-zinc-200 dark:border-zinc-800">
<button
type="button"
onclick={() => activeTab = 'editor'}
class={[
'px-4 py-2 font-mono text-[11px] font-medium border-b-2 -mb-px transition-colors',
activeTab === 'editor'
? 'border-zinc-900 text-zinc-900 dark:border-zinc-100 dark:text-zinc-100'
: 'border-transparent text-zinc-400 hover:text-zinc-600 dark:text-zinc-600 dark:hover:text-zinc-400',
].join(' ')}
>
MetadataEditor
</button>
<button
type="button"
onclick={() => activeTab = 'tree'}
class={[
'px-4 py-2 font-mono text-[11px] font-medium border-b-2 -mb-px transition-colors',
activeTab === 'tree'
? 'border-zinc-900 text-zinc-900 dark:border-zinc-100 dark:text-zinc-100'
: 'border-transparent text-zinc-400 hover:text-zinc-600 dark:text-zinc-600 dark:hover:text-zinc-400',
].join(' ')}
>
MetaTree
</button>
</div>
<!-- Tab content -->
<div class="flex-1 min-h-0">
{#if activeTab === 'editor'}
<MetadataEditor
{glob}
bind:value={metadata}
attributes={['hidden', 'required']}
dark={darkMode}
onUpdate={(m) => metadata = m}
/>
{:else}
<MetaTree
{glob}
bind:value={metadata}
attributes={['hidden', 'required']}
dark={darkMode}
onUpdate={(m) => metadata = m}
/>
{/if}
</div>
</div>
{/if}
@ -242,10 +233,6 @@
</div>
<style>
:global(*) {
font-family: 'Poppins', sans-serif !important;
}
:global(code), :global(pre) {
font-family: 'Poppins', sans-serif !important;
}
:global(*) { font-family: 'Poppins', sans-serif !important; }
:global(code), :global(pre) { font-family: 'Poppins', sans-serif !important; }
</style>
Loading…
Cancel
Save

Powered by TurnKey Linux.