From ff61d85f54644dc0f80171effbd867b0f3b8a8e5 Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 15 Mar 2026 02:20:36 +0100 Subject: [PATCH] =?UTF-8?q?feat(mtas):=20redise=C3=B1o=20UI,=20correccione?= =?UTF-8?q?s=20de=20bugs=20y=20mejoras=20de=20arquitectura?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arquitectura / glob+ling: - glob_engine: inicialización lazy de módulos (numr, curr, dati, unit) con ??= - glob_types: añadido método register() 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 --- src/glob/lib/glob_engine.ts | 41 +- src/glob/lib/glob_types.ts | 29 +- src/ling/lib/engine.ts | 44 ++- src/ling/lib/guards.ts | 9 +- src/mtas/lib/translations.ts | 15 +- src/mtas/lib/validation.ts | 4 +- .../svelte/components/AddFieldDrawer.svelte | 100 +++-- .../svelte/components/AddFieldMenu.svelte | 122 ++++-- src/mtas/svelte/components/FieldEditor.svelte | 110 +++--- src/mtas/svelte/components/FieldRow.svelte | 369 +++++++++++------- .../svelte/components/FieldValueEditor.svelte | 170 ++++++++ src/mtas/svelte/components/MetaTree.svelte | 346 ++++++++++++++++ .../svelte/components/MetadataEditor.svelte | 209 +++++----- src/mtas/svelte/components/TreeNodes.svelte | 218 +++++++++++ src/mtas/svelte/components/TypeBadge.svelte | 45 +-- src/mtas/svelte/demo/App.svelte | 165 ++++---- 16 files changed, 1477 insertions(+), 519 deletions(-) create mode 100644 src/mtas/svelte/components/FieldValueEditor.svelte create mode 100644 src/mtas/svelte/components/MetaTree.svelte create mode 100644 src/mtas/svelte/components/TreeNodes.svelte diff --git a/src/glob/lib/glob_engine.ts b/src/glob/lib/glob_engine.ts index 4010056..2f07670 100644 --- a/src/glob/lib/glob_engine.ts +++ b/src/glob/lib/glob_engine.ts @@ -21,11 +21,16 @@ export function createGlob(config: GlobConfig): 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 | undefined; + let _curr: ReturnType | undefined; + let _dati: ReturnType | undefined; + let _unit: ReturnType | undefined; function setLocale(locale: GlobLocale): void { ling.setLocale(locale as any); @@ -38,16 +43,30 @@ export function createGlob(config: GlobConfig): 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( + namespace: NS, + module: M + ): GlobInstance { + const newLing = ling.register(namespace, module); + return createGlob({ ...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); }, }; } diff --git a/src/glob/lib/glob_types.ts b/src/glob/lib/glob_types.ts index 977e656..82d9c34 100644 --- a/src/glob/lib/glob_types.ts +++ b/src/glob/lib/glob_types.ts @@ -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 { - locale : GlobLocale; + locale : GlobLocale; + locales?: GlobLocale[]; /** Instancia de `ling` — es la fuente de verdad del locale compartido. */ - ling : LingInstance; - numr? : NumrConfig; - curr? : CurrConfig; - dati? : DatiConfig; - unit? : UnitConfig; + ling : LingInstance; + numr? : NumrConfig; + curr? : CurrConfig; + dati? : DatiConfig; + unit? : UnitConfig; } @@ -28,6 +30,10 @@ export interface GlobInstance { 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 { unit : UnitInstance; /** Instancia `ling` subyacente, accesible para uso directo. */ ling : LingInstance; + /** + * 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( + namespace: NS, + module: M + ): GlobInstance; } diff --git a/src/ling/lib/engine.ts b/src/ling/lib/engine.ts index bc2975b..6468ed2 100644 --- a/src/ling/lib/engine.ts +++ b/src/ling/lib/engine.ts @@ -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( /** * 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), - [namespace]: module, + [root]: deepMerge((currentSchema as any)[root] ?? {}, nested), }; } @@ -214,9 +238,15 @@ export function createLing( * 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(); + const fn = (params: { count: number } & Record): 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; } diff --git a/src/ling/lib/guards.ts b/src/ling/lib/guards.ts index 4a911be..eea4622 100644 --- a/src/ling/lib/guards.ts +++ b/src/ling/lib/guards.ts @@ -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)[DEFAULT_ISO_LOCALE] === 'string' ); } diff --git a/src/mtas/lib/translations.ts b/src/mtas/lib/translations.ts index 6a38f7b..3b7c537 100644 --- a/src/mtas/lib/translations.ts +++ b/src/mtas/lib/translations.ts @@ -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: * @@ -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; diff --git a/src/mtas/lib/validation.ts b/src/mtas/lib/validation.ts index 691c222..bca9883 100644 --- a/src/mtas/lib/validation.ts +++ b/src/mtas/lib/validation.ts @@ -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) diff --git a/src/mtas/svelte/components/AddFieldDrawer.svelte b/src/mtas/svelte/components/AddFieldDrawer.svelte index f7f2e4d..b2002a0 100644 --- a/src/mtas/svelte/components/AddFieldDrawer.svelte +++ b/src/mtas/svelte/components/AddFieldDrawer.svelte @@ -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(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(); } @@ -90,73 +97,80 @@ diff --git a/src/mtas/svelte/components/AddFieldMenu.svelte b/src/mtas/svelte/components/AddFieldMenu.svelte index b0a5e44..c8d0b84 100644 --- a/src/mtas/svelte/components/AddFieldMenu.svelte +++ b/src/mtas/svelte/components/AddFieldMenu.svelte @@ -1,60 +1,110 @@ - diff --git a/src/mtas/svelte/components/FieldEditor.svelte b/src/mtas/svelte/components/FieldEditor.svelte index 822ac1f..275da6c 100644 --- a/src/mtas/svelte/components/FieldEditor.svelte +++ b/src/mtas/svelte/components/FieldEditor.svelte @@ -1,17 +1,18 @@
- +
- Clave del campo - {#if keyError} - {keyError} - {/if} + {glob.t('vcen.editor.mtas.editor.keyLabel')} { 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} + {keyError} + {/if}
- - + + - + {#if attributes.length > 0} -
-
- Atributos -
- {#each attributes as attr (attr)} - {@const active = activeAttrs.includes(attr)} - - {/each} -
+
Atributos
+
+ {#each attributes as attr (attr)} + {@const active = activeAttrs.includes(attr)} + + {/each}
{/if} - +
-
- Ocultar campo - update({ hidden: (e.currentTarget as HTMLInputElement).checked })} - class="toggle toggle-sm" /> - {asBase.hidden ? 'No se muestra en la UI' : 'Visible'} -
+
diff --git a/src/mtas/svelte/components/FieldRow.svelte b/src/mtas/svelte/components/FieldRow.svelte index ffc0005..86a2c4c 100644 --- a/src/mtas/svelte/components/FieldRow.svelte +++ b/src/mtas/svelte/components/FieldRow.svelte @@ -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(null); - let drawerOpen = $state(false); - let drawerType = $state('string'); + let groupCollapseV = $state(0); + let drawerOpen = $state(false); + let drawerType = $state('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)[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)[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('[role="button"]')?.focus(); + document.getElementById(`field-row-${fieldKey}`) + ?.querySelector('[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('[role="button"]')?.focus(); + document.getElementById(`field-row-${fieldKey}`) + ?.querySelector('[role="button"]')?.focus(); })); } } + function asField(v: unknown): MetadataField { return v as MetadataField; }
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(' ')} > - + {#if depth === 0} -
- - -
- - + +
- + {#if !validationResult.valid && validationErrors.length} -
-
+ - {validationErrors[0]} + {validationErrors[0]}
{/if} - + {#if expanded} -
+
onFieldChange(f)} + {fieldKey} {field} + {attributes} {glob} + {onKeyChange} + onFieldChange={(f) => onFieldChange(f)} />
{/if} - + {#if field.type === 'group'} -
-
- - -
- +
+ {#if groupOpen} -
- {#if Object.keys(field.value).length === 0} -
- Grupo vacío — añade campos con los botones de arriba +
+ {#if groupChildCount === 0} +
+ + + + + + + {glob.t('vcen.editor.mtas.field.emptyGroup')}
{:else} {#each Object.keys(field.value) as childKey, ci (`${childKey}-${groupCollapseV}`)} 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}
{/if} -
+ +
- + + + + + + {#if field.type === 'group'} drawerOpen = false} + open={drawerOpen} + selectedType={drawerType} + existingKeys={Object.keys(field.value)} {glob} + onConfirm={handleDrawerConfirm} + onClose={() => drawerOpen = false} /> -{/if} \ No newline at end of file +{/if} diff --git a/src/mtas/svelte/components/FieldValueEditor.svelte b/src/mtas/svelte/components/FieldValueEditor.svelte new file mode 100644 index 0000000..2cb4e2e --- /dev/null +++ b/src/mtas/svelte/components/FieldValueEditor.svelte @@ -0,0 +1,170 @@ + + +{#if field.type === 'string'} +
+ {glob.t('vcen.editor.mtas.types.string')} + update({ value: (e.currentTarget as HTMLInputElement).value })} + class={cls} placeholder="Texto…" /> +
+ +{:else if field.type === 'number'} +
+ {glob.t('vcen.editor.mtas.types.number')} + { const v = parseFloat((e.currentTarget as HTMLInputElement).value); update({ value: isNaN(v) ? 0 : v }); }} + class={cls} /> + {#if numberFormatted} + {numberFormatted} + {/if} +
+ +{:else if field.type === 'boolean'} +
+ update({ value: (e.currentTarget as HTMLInputElement).checked })} + class="toggle toggle-primary" /> + {asBoolean.value + ? glob.t('vcen.editor.mtas.field.boolTrue') + : glob.t('vcen.editor.mtas.field.boolFalse') + } +
+ +{:else if field.type === 'ling'} + update({ value: val })} + /> + +{:else if field.type === 'date'} +
+ {glob.t('vcen.editor.mtas.types.date')} + update({ value: (e.currentTarget as HTMLInputElement).value })} + class={cls} /> + {#if dateFormatted} + {dateFormatted} + {/if} +
+ +{:else if field.type === 'datetime'} +
+ {glob.t('vcen.editor.mtas.types.datetime')} + update({ value: (e.currentTarget as HTMLInputElement).value + 'Z' })} + class={cls} /> + {#if datetimeFormatted} + {datetimeFormatted} + {/if} +
+ +{:else if field.type === 'url'} +
+ {glob.t('vcen.editor.mtas.types.url')} + update({ value: (e.currentTarget as HTMLInputElement).value })} + class="{cls} font-mono" placeholder="https://…" /> +
+ +{:else if field.type === 'currency'} +
+
+ {glob.t('vcen.editor.mtas.editor.amountLabel')} + update({ value: { ...asCurrency.value, amount: parseFloat((e.currentTarget as HTMLInputElement).value) } })} + class={cls} /> + {#if currencyFormatted} + {currencyFormatted} + {/if} +
+
+ {glob.t('vcen.editor.mtas.editor.currencyLabel')} + update({ value: { ...asCurrency.value, currency: (e.currentTarget as HTMLInputElement).value.toUpperCase() } })} + class="{cls} font-mono uppercase" placeholder="EUR" /> +
+
+ +{:else if field.type === 'image' || field.type === 'video' || field.type === 'audio' || field.type === 'pdf' || field.type === 'file'} +
+ {glob.t('vcen.editor.mtas.editor.mediaUrlLabel')} + updateMediaUrl((e.currentTarget as HTMLInputElement).value)} + class="{cls} font-mono" placeholder="/ruta o https://…" /> +
+ {#if field.type === 'image' && asMedia.value.url} +
+ preview +
+ {/if} + +{:else if field.type === 'html'} + update({ value: val })} + /> + +{:else if field.type === 'group'} + {@const childCount = Object.keys(field.value).length} +
+ + + + + {#if childCount > 0} + {glob.t('vcen.editor.mtas.field.nestedCount', { count: childCount })} + {:else} + {glob.t('vcen.editor.mtas.field.emptyGroup')} + {/if} + +
+{/if} diff --git a/src/mtas/svelte/components/MetaTree.svelte b/src/mtas/svelte/components/MetaTree.svelte new file mode 100644 index 0000000..fe07f1b --- /dev/null +++ b/src/mtas/svelte/components/MetaTree.svelte @@ -0,0 +1,346 @@ + + +
+
+ + +
+ + +
+ + +
+ +

+ {glob.t('vcen.editor.mtas.header.title')} +

+ + {glob.t('vcen.editor.mtas.header.fieldCount', { count: keyOrder.length })} + + openDrawer(type, addTargetPath)} iconOnly={true} {glob} placement="start"/> +
+ + +
+ {#if keyOrder.length === 0} +
+ + + + +

{glob.t('vcen.editor.mtas.field.empty')}

+
+ {:else} +
+ selectedPath = p} + onMove={(p, dir) => moveFieldAtPath(p, dir)} + onDelete={(p) => deleteFieldAtPath(p)} + /> +
+ {/if} +
+
+ + +
+ {#if selectedField && selectedPath.length > 0} + +
+ {#each selectedPath as seg, i} + {#if i > 0}/{/if} + {seg} + {/each} +
+ renameFieldAtPath(selectedPath, newKey)} + onFieldChange={(f) => setFieldAtPath(selectedPath, f)} + /> + {:else} +
+ + + +

+ {glob.t('vcen.editor.mtas.tree.selectHint')} +

+
+ {/if} +
+ +
+ + + {#if !validation.valid} +
+
+ + + +
+ {glob.t('vcen.editor.mtas.validation.errorsTitle')} + {#each validation.errors as error} + · {error} + {/each} +
+
+
+ {/if} + +
+
+ + drawerOpen = false} +/> diff --git a/src/mtas/svelte/components/MetadataEditor.svelte b/src/mtas/svelte/components/MetadataEditor.svelte index 50ec773..72c3e14 100644 --- a/src/mtas/svelte/components/MetadataEditor.svelte +++ b/src/mtas/svelte/components/MetadataEditor.svelte @@ -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({}), - 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('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 @@
-
+
+ + +
- -
- -

Metadata

- - {keyOrder.length} {keyOrder.length === 1 ? 'campo' : 'campos'} + +
+
+
+ +

+ {glob.t('vcen.editor.mtas.header.title')} +

+ + + {glob.t('vcen.editor.mtas.header.fieldCount', { count: keyOrder.length })} +
- +
- -
+ +
{#if keyOrder.length === 0} -
- - - +
+ + + + + -

Usa el botón Añadir para crear campos

+
+ {glob.t('vcen.editor.mtas.field.empty')} +
{:else} {#each pageKeys as key (key)} {@const gi = keyOrder.indexOf(key)}
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)} />
{/each} {/if}
+ {#if !validation.valid} -
-
-
- Errores de validación +
+
+ + + +
+ + {glob.t('vcen.editor.mtas.validation.errorsTitle')} + {#each validation.errors as error} - · {error} + · {error} {/each}
{/if} - + {#if pagination && keyOrder.length > 0} -
+
- -
- Filas por página +
+ + {glob.t('vcen.editor.mtas.pagination.rowsPerPage')} +
- {#if totalPages > 1}
- - {#each paginationPages as p (p === -1 ? `ellipsis-${p}` : p)} + + {#each paginationPages as p (p === -1 ? `e-${p}` : p)} {#if p === -1} {:else} - {/if} {/each} - +
{/if} - - - {keyOrder.length === 0 ? '0' : rangeStart}–{rangeEnd} de {keyOrder.length} + + {glob.t('vcen.editor.mtas.pagination.range', { from: rangeStart, to: rangeEnd, total: keyOrder.length })}
@@ -253,12 +275,9 @@
drawerOpen = false} + open={drawerOpen} + selectedType={drawerType} + existingKeys={keyOrder} {dark} {glob} + onConfirm={handleDrawerConfirm} + onClose={() => drawerOpen = false} /> diff --git a/src/mtas/svelte/components/TreeNodes.svelte b/src/mtas/svelte/components/TreeNodes.svelte new file mode 100644 index 0000000..15b89e5 --- /dev/null +++ b/src/mtas/svelte/components/TreeNodes.svelte @@ -0,0 +1,218 @@ + + +{#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) : []} + + +
onSelect(np)} + onkeydown={(e) => { if (e.key === 'Enter') onSelect(np); }} + > + + {#if isGroup} + + {:else} + + {/if} + + +
+ + + + + + + {key} + + + + {#if !sel && !isGroup} + + {summary(field)} + + {/if} + + +
+ + + + + + + +
+
+ + + {#if isGroup && groupOpen && childKeys.length > 0} + + {/if} + + + {#if isGroup && groupOpen && childKeys.length === 0} +
+ {glob.t('vcen.editor.mtas.field.emptyGroup')} +
+ {/if} +{/each} + + +{#if depth === 0} + + + + +{/if} diff --git a/src/mtas/svelte/components/TypeBadge.svelte b/src/mtas/svelte/components/TypeBadge.svelte index c02c0fc..825696a 100644 --- a/src/mtas/svelte/components/TypeBadge.svelte +++ b/src/mtas/svelte/components/TypeBadge.svelte @@ -1,39 +1,40 @@ - + {#if showLabel} - {cfg.label} + {glob.t(`vcen.editor.mtas.types.${type}` as any)} {/if} - \ No newline at end of file + diff --git a/src/mtas/svelte/demo/App.svelte b/src/mtas/svelte/demo/App.svelte index 0ee8c89..1bd6e11 100644 --- a/src/mtas/svelte/demo/App.svelte +++ b/src/mtas/svelte/demo/App.svelte @@ -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({ 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({ 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 = { @@ -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); @@ -78,10 +78,6 @@
- - - -
@@ -146,7 +142,7 @@ Editor

- Campos tipados · Validación en tiempo real · Drag & drop · Dark mode + Campos tipados · Validación en tiempo real · Dark mode

@@ -170,63 +166,58 @@
{:else} -
- - -
-
- - MetadataEditor - -
-
- metadata = m} - /> -
- - -
- - -
-
- - LingInput - -
-
- - -
- - -
-

- Estado actual -

-
- {#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} -
- {stat.label} - {stat.value} -
- {/each} -
-
- + +
+ +
+ + +
+ +
+ {#if activeTab === 'editor'} + metadata = m} + /> + {:else} + metadata = m} + /> + {/if}
+
{/if} @@ -242,10 +233,6 @@
\ No newline at end of file