feat(vcen-edit): soporte multiidioma en labels de Cat, Obj, Sec y Att

- Añade lang/langs a Cat y label:LingRecord a Obj, Sec, Att
- Crea EditorGlob con createGlobState + createLing para traducciones/moneda/fecha
- CatMetaPanel con selección de idioma principal y lista de idiomas soportados
- Store: setObjLabel, setSecLabel, setAttLabel, setCatAttLabel (LingRecord completo)
- LingInput.svelte local adaptado al tema oscuro del editor (sin bg-white)
- ObjInfoPanel, SecInfoPanel, AttDetail usan LingInput con tabs por locale
- helper labelOf para resolver LingRecord en listas y cabeceras
- Mueve catalog-editor al directorio stores y elimina el fichero antiguo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
master
dev 7 months ago
parent 6657372c96
commit 25e099dcbf

@ -6,6 +6,8 @@
const demoCat: Cat = { const demoCat: Cat = {
id: 'ct_demo' as Cat['id'], id: 'ct_demo' as Cat['id'],
name: 'Catálogo demo', name: 'Catálogo demo',
lang: 'es',
langs: ['es'],
atts: [], atts: [],
opts: [], opts: [],
optCategories: [], optCategories: [],

@ -14,6 +14,8 @@
return { return {
id: toCatID('test'), id: toCatID('test'),
name: 'Nuevo', name: 'Nuevo',
lang: 'es',
langs: ['es'],
optCategories: [], optCategories: [],
opts: [], opts: [],
atts: [], atts: [],

@ -1,13 +1,16 @@
un <script lang="ts"> <script lang="ts">
import { getContext } from 'svelte' import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte.js' import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import { labelOf } from '@/vcen-edit/lib/label.ts'
import type { AttDomain, ObjID, SecID, AttID } from '@/vcen/lib' import type { AttDomain, ObjID, SecID, AttID } from '@/vcen/lib'
import RuleList from './RuleList.svelte' import RuleList from './RuleList.svelte'
import MetaPanel from './MetaPanel.svelte'
import LingInput from './LingInput.svelte'
import { buildEvalCtx, evalRuleSetState } from '@/vcen-edit/lib/rule-eval.ts' import { buildEvalCtx, evalRuleSetState } from '@/vcen-edit/lib/rule-eval.ts'
const editor = getContext<CatalogEditorStore>('catalog-editor') const editor = getContext<CatalogEditorStore>('catalog-editor')
const cat = $derived(editor.cat)
const sel = $derived(editor.selection) const sel = $derived(editor.selection)
const ctx = $derived.by(() => { const ctx = $derived.by(() => {
const s = sel const s = sel
@ -149,17 +152,40 @@ const stateLabel: Record<string, string> = {
available: 'Disponible', required: 'Requerido', available: 'Disponible', required: 'Requerido',
hidden: 'Oculto', forbidden: 'Prohibido', hidden: 'Oculto', forbidden: 'Prohibido',
} }
let showDeleteModal = $state(false)
function deleteAtt() {
if (!ctx) return
if (ctx.isCatLevel) {
editor.removeCatAtt(ctx.attId)
} else {
editor.removeAtt(ctx.objId!, ctx.secId, ctx.attId)
}
showDeleteModal = false
}
function updateMeta(meta: import('@/mtas/lib').Metadata | undefined) {
if (!ctx) return
if (ctx.isCatLevel) {
editor.updateCatAttMeta(ctx.attId, meta)
} else {
editor.updateAttMeta(ctx.objId!, ctx.secId, ctx.attId, meta)
}
}
</script> </script>
{#if ctx} {#if ctx}
{@const { att, isCatLevel, objId, secId, attId, container } = ctx} {@const { att, isCatLevel, objId, secId, attId, container } = ctx}
<div class="flex h-full">
<div class="flex-1 overflow-y-auto">
<div class="p-6 max-w-2xl flex flex-col gap-6"> <div class="p-6 max-w-2xl flex flex-col gap-6">
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="text-sm font-semibold text-gray-200"> <h2 class="text-sm font-semibold text-gray-200">
{att.name || 'Sin nombre'} {labelOf(att.label, cat.lang) || 'Sin nombre'}
</h2> </h2>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if computedState} {#if computedState}
@ -170,19 +196,29 @@ const stateLabel: Record<string, string> = {
<span class="text-xs text-gray-700 italic">→ Disponible</span> <span class="text-xs text-gray-700 italic">→ Disponible</span>
{/if} {/if}
<span class="text-xs text-gray-700 font-mono">{att.id}</span> <span class="text-xs text-gray-700 font-mono">{att.id}</span>
<button
onclick={() => showDeleteModal = true}
class="text-xs text-gray-600 hover:text-red-400 transition-colors ml-1"
title="Eliminar atributo"
>eliminar</button>
</div> </div>
</div> </div>
<!-- Identificación --> <!-- Identificación -->
<section class="flex flex-col gap-3"> <section class="flex flex-col gap-3">
<h3 class="section-title">Identificación</h3> <h3 class="section-title">Identificación</h3>
<div class="flex flex-col gap-1"> <LingInput
<label class="text-xs text-gray-500">Nombre</label> value={att.label}
<input type="text" value={att.name} locales={cat.langs}
oninput={e => patch('name', e.currentTarget.value)} defaultLocale={cat.lang}
placeholder="Color, Suelo, Material..." as="input"
class="field-input" /> label="Nombre"
</div> placeholder="Color, Suelo, Material..."
onUpdate={label => {
if (isCatLevel) editor.setCatAttLabel(attId, label as any)
else editor.setAttLabel(objId!, secId, attId, label as any)
}}
/>
</section> </section>
<!-- Tipo de dominio --> <!-- Tipo de dominio -->
@ -483,4 +519,29 @@ const stateLabel: Record<string, string> = {
</section> </section>
</div> </div>
</div>
<MetaPanel value={(att as any).metadata} onUpdate={updateMeta} />
</div>
<!-- Delete confirmation modal -->
{#if showDeleteModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
onclick={() => showDeleteModal = false}>
<div class="bg-gray-900 border border-gray-700 rounded-lg p-6 flex flex-col gap-4 min-w-64 max-w-sm shadow-xl"
onclick={(e) => e.stopPropagation()}>
<h3 class="text-sm font-semibold text-gray-200">¿Eliminar atributo?</h3>
<p class="text-sm text-gray-400">{labelOf(att.label, cat.lang) || att.id}</p>
<div class="flex gap-2 justify-end">
<button
onclick={() => showDeleteModal = false}
class="px-3 py-1.5 text-xs rounded border border-gray-700 text-gray-400 hover:text-gray-200 transition-colors"
>Cancelar</button>
<button
onclick={deleteAtt}
class="px-3 py-1.5 text-xs rounded bg-red-700 hover:bg-red-600 text-white transition-colors"
>Eliminar</button>
</div>
</div>
</div>
{/if}
{/if} {/if}

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte' import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte.js' import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import { labelOf } from '@/vcen-edit/lib/label.ts'
import AttDetail from './AttDetail.svelte' import AttDetail from './AttDetail.svelte'
const editor = getContext<CatalogEditorStore>('catalog-editor') const editor = getContext<CatalogEditorStore>('catalog-editor')
@ -41,7 +42,7 @@ const domainBadge: Record<string, string> = {
<div class="px-4 py-3 border-b border-gray-800 shrink-0"> <div class="px-4 py-3 border-b border-gray-800 shrink-0">
<input <input
type="text" type="text"
value={sec.name} value={labelOf(sec.label, editor.cat.lang)}
oninput={e => editor.renameSec(objId, secId, e.currentTarget.value)} oninput={e => editor.renameSec(objId, secId, e.currentTarget.value)}
placeholder="Nombre sección" placeholder="Nombre sección"
class="field-input text-xs" class="field-input text-xs"
@ -88,7 +89,7 @@ const domainBadge: Record<string, string> = {
{att.value.domain.kind.slice(0, 3)} {att.value.domain.kind.slice(0, 3)}
</span> </span>
<span class="flex-1 truncate"> <span class="flex-1 truncate">
{#if att.name}{att.name}{:else}<span class="italic text-gray-600">Sin nombre</span>{/if} {#if att.label}{labelOf(att.label, editor.cat.lang)}{:else}<span class="italic text-gray-600">Sin nombre</span>{/if}
</span> </span>
{#if hasRules} {#if hasRules}
<span class="w-1.5 h-1.5 rounded-full bg-indigo-500 shrink-0" title="Tiene reglas"></span> <span class="w-1.5 h-1.5 rounded-full bg-indigo-500 shrink-0" title="Tiene reglas"></span>

@ -1,17 +1,19 @@
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte' import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte.js' import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import { labelOf } from '@/vcen-edit/lib/label.ts'
import AttDetail from './AttDetail.svelte' import AttDetail from './AttDetail.svelte'
import SidebarTree from './SidebarTree.svelte'
const editor = getContext<CatalogEditorStore>('catalog-editor') const editor = getContext<CatalogEditorStore>('catalog-editor')
const sel = $derived(editor.selection) const sel = $derived(editor.selection)
const cat = $derived(editor.cat)
const atts = $derived(editor.cat.atts ?? []) const atts = $derived(editor.cat.atts ?? [])
const attId = $derived(sel.kind === 'cat-atts' ? sel.attId : undefined) const attId = $derived(sel.kind === 'cat-atts' ? sel.attId : undefined)
let creating = $state(false)
let creating = $state(false) let newAttName = $state('')
let newAttName = $state('') let newAttSlug = $state('')
let newAttSlug = $state('')
let attSlugError = $state('') let attSlugError = $state('')
function slugify(text: string): string { function slugify(text: string): string {
@ -33,97 +35,79 @@ function cancelAdd() {
</script> </script>
<div class="flex h-full"> <div class="flex h-full">
<div class="w-56 shrink-0 border-r border-gray-800 flex flex-col">
<div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-800 shrink-0"> <SidebarTree title="Atributos" count={atts.length} onAdd={() => { creating = true; newAttName = '' }}>
<span class="text-xs font-semibold text-gray-400 uppercase tracking-wider"> {#if atts.length === 0 && !creating}
Atributos ({atts.length}) <div class="px-4 py-6 text-center text-gray-700 text-xs italic">
</span> Sin atributos globales
<button onclick={() => { creating = true; newAttName = '' }} <button
class="w-5 h-5 flex items-center justify-center rounded text-gray-500 onclick={() => { creating = true; newAttName = '' }}
hover:text-indigo-400 hover:bg-indigo-500/10 transition-colors">+</button> class="block mx-auto mt-2 px-3 py-1 rounded border border-dashed border-gray-700
</div> text-gray-600 hover:text-indigo-400 hover:border-indigo-700 transition-colors"
<div class="flex-1 overflow-y-auto py-1"> >Añadir atributo</button>
{#if atts.length === 0} </div>
<div class="px-4 py-6 text-center text-gray-700 text-xs italic"> {:else}
Sin atributos globales {#each atts as att (att.id)}
<button onclick={() => { creating = true; newAttName = '' }} {@const isActive = attId === att.id}
class="block mx-auto mt-2 px-3 py-1 rounded border border-dashed border-gray-700 <button
text-gray-600 hover:text-indigo-400 hover:border-indigo-700 transition-colors"> onclick={() => editor.select({ kind: 'cat-atts', attId: att.id })}
Añadir atributo class="w-full text-left px-4 py-2 flex items-center justify-between
</button> cursor-pointer transition-colors border-l-2
</div> {isActive ? 'bg-gray-800 border-blue-500' : 'border-transparent hover:bg-gray-800'}"
{:else} >
{#each atts as att (att.id)} <span class="text-sm truncate flex-1 {isActive ? 'text-gray-100' : 'text-gray-300'}">
{@const isActive = attId === att.id} {labelOf(att.label, cat.lang) || 'Sin nombre'}
<button </span>
onclick={() => editor.select({ kind: 'cat-atts', attId: att.id })} <div class="flex items-center gap-1 shrink-0 ml-2">
class="w-full text-left px-4 py-2.5 flex items-center justify-between <span class="text-xs px-1 py-0.5 rounded bg-gray-800 text-gray-500 border border-gray-700">
cursor-pointer transition-colors border-l-2 {att.value.domain.kind}
{isActive </span>
? 'bg-gray-800 border-blue-500' {#if att.rules?.rules?.length}
: 'border-transparent hover:bg-gray-800'}" <span class="text-xs text-blue-400" title="Tiene reglas">⚡</span>
> {/if}
<div class="flex flex-col gap-0.5 min-w-0">
<span class="text-sm truncate {isActive ? 'text-gray-100' : 'text-gray-300'}">
{att.name || 'Sin nombre'}
</span>
<span class="text-xs text-gray-600 font-mono truncate">{att.id}</span>
</div>
<div class="flex items-center gap-1 shrink-0 ml-2">
<span class="text-xs px-1 py-0.5 rounded bg-gray-800 text-gray-500 border border-gray-700">
{att.value.domain.kind}
</span>
{#if att.rules?.rules?.length}
<span class="text-xs text-blue-400" title="Tiene reglas">⚡</span>
{/if}
</div>
</button>
<div class="px-4 pb-1 flex justify-end">
<button
onclick={() => editor.removeCatAtt(att.id)}
class="text-xs text-gray-700 hover:text-red-400 transition-colors"
>eliminar</button>
</div> </div>
{/each} </button>
{/if} {/each}
{#if creating} {/if}
<div class="px-3 py-2 border-t border-gray-800 bg-gray-900/60 flex flex-col gap-1.5"> {#if creating}
<div class="px-3 py-2 border-t border-gray-800 bg-gray-900/60 flex flex-col gap-1.5 mt-1">
<input
type="text"
bind:value={newAttName}
oninput={e => { if (!newAttSlug || newAttSlug === slugify(newAttName.slice(0,-1))) newAttSlug = slugify(e.currentTarget.value) }}
placeholder="Nombre del atributo"
class="field-input text-xs"
autofocus
/>
<div class="flex items-center gap-1">
<span class="text-[10px] text-gray-600 shrink-0">at:</span>
<input <input
type="text" type="text"
bind:value={newAttName} bind:value={newAttSlug}
oninput={e => { if (!newAttSlug || newAttSlug === slugify(newAttName.slice(0,-1))) newAttSlug = slugify(e.currentTarget.value) }} oninput={() => { attSlugError = '' }}
placeholder="Nombre del atributo" onkeydown={e => { if (e.key === 'Enter') confirmAdd(); if (e.key === 'Escape') cancelAdd() }}
class="field-input text-xs" placeholder="slug-atributo"
autofocus class="field-input text-xs font-mono flex-1"
/> />
<div class="flex items-center gap-1">
<span class="text-[10px] text-gray-600 shrink-0">at:</span>
<input
type="text"
bind:value={newAttSlug}
oninput={() => { attSlugError = '' }}
onkeydown={e => { if (e.key === 'Enter') confirmAdd(); if (e.key === 'Escape') cancelAdd() }}
placeholder="slug-atributo"
class="field-input text-xs font-mono flex-1"
/>
</div>
{#if attSlugError}
<p class="text-[10px] text-red-400">{attSlugError}</p>
{/if}
<div class="flex gap-1">
<button onclick={confirmAdd}
class="flex-1 px-2 py-1 text-xs rounded bg-indigo-600 hover:bg-indigo-500 text-white transition-colors">
Añadir
</button>
<button onclick={cancelAdd}
class="px-2 py-1 text-xs rounded border border-gray-700 text-gray-500 hover:text-gray-300 transition-colors">
✕
</button>
</div>
</div> </div>
{/if} {#if attSlugError}
</div> <p class="text-[10px] text-red-400">{attSlugError}</p>
</div> {/if}
<div class="flex gap-1">
<button onclick={confirmAdd}
class="flex-1 px-2 py-1 text-xs rounded bg-indigo-600 hover:bg-indigo-500 text-white transition-colors">
Añadir
</button>
<button onclick={cancelAdd}
class="px-2 py-1 text-xs rounded border border-gray-700 text-gray-500 hover:text-gray-300 transition-colors">
✕
</button>
</div>
</div>
{/if}
</SidebarTree>
<!-- Att detail -->
<div class="flex-1 overflow-auto"> <div class="flex-1 overflow-auto">
{#if attId} {#if attId}
<AttDetail /> <AttDetail />

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte' import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte.js' import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
const editor = getContext<CatalogEditorStore>('catalog-editor') const editor = getContext<CatalogEditorStore>('catalog-editor')
const cat = $derived(editor.cat) const cat = $derived(editor.cat)
@ -12,8 +12,32 @@ const stats = $derived({
n + o.atts.length + o.secs.reduce((m, s) => m + s.atts.length, 0), 0), n + o.atts.length + o.secs.reduce((m, s) => m + s.atts.length, 0), 0),
opts: cat.opts.length, opts: cat.opts.length,
}) })
const SUPPORTED_LOCALES: { code: string; label: string }[] = [
{ code: 'es', label: 'Español' },
{ code: 'en', label: 'English' },
{ code: 'de', label: 'Deutsch' },
{ code: 'fr', label: 'Français' },
{ code: 'it', label: 'Italiano' },
{ code: 'pt', label: 'Português' },
{ code: 'ca', label: 'Català' },
{ code: 'eu', label: 'Euskara' },
{ code: 'gl', label: 'Galego' },
]
function toggleLang(code: string) {
const current = cat.langs
if (current.includes(code)) {
// no se puede quitar si solo queda uno
if (current.length === 1) return
editor.setCatLangs(current.filter(l => l !== code))
} else {
editor.setCatLangs([...current, code])
}
}
</script> </script>
<div class="overflow-y-auto h-full">
<div class="p-6 max-w-2xl flex flex-col gap-6"> <div class="p-6 max-w-2xl flex flex-col gap-6">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@ -24,10 +48,10 @@ const stats = $derived({
<!-- Stats --> <!-- Stats -->
<div class="grid grid-cols-4 gap-3"> <div class="grid grid-cols-4 gap-3">
{#each [ {#each [
{ label: 'Objetos', value: stats.objs }, { label: 'Objetos', value: stats.objs },
{ label: 'Secciones', value: stats.secs }, { label: 'Secciones', value: stats.secs },
{ label: 'Atributos', value: stats.atts }, { label: 'Atributos', value: stats.atts },
{ label: 'Opciones', value: stats.opts }, { label: 'Opciones', value: stats.opts },
] as s} ] as s}
<div class="flex flex-col items-center gap-1 p-3 rounded-lg bg-gray-900 border border-gray-800"> <div class="flex flex-col items-center gap-1 p-3 rounded-lg bg-gray-900 border border-gray-800">
<span class="text-xl font-bold text-indigo-400">{s.value}</span> <span class="text-xl font-bold text-indigo-400">{s.value}</span>
@ -36,7 +60,7 @@ const stats = $derived({
{/each} {/each}
</div> </div>
<!-- Nombre --> <!-- Identificación -->
<section class="flex flex-col gap-3"> <section class="flex flex-col gap-3">
<h3 class="section-title">Identificación</h3> <h3 class="section-title">Identificación</h3>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
@ -48,4 +72,52 @@ const stats = $derived({
</div> </div>
</section> </section>
<!-- Idioma principal -->
<section class="flex flex-col gap-3">
<h3 class="section-title">Idioma principal</h3>
<p class="text-xs text-gray-600">Idioma por defecto del catálogo. Usado como fallback en campos multilingüe.</p>
<div class="flex flex-wrap gap-1.5">
{#each SUPPORTED_LOCALES as loc}
<button
type="button"
onclick={() => editor.setCatLang(loc.code)}
class="px-3 py-1.5 rounded text-xs border transition-colors
{cat.lang === loc.code
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300 font-medium'
: 'border-gray-700 text-gray-500 hover:border-gray-600 hover:text-gray-300'}"
>
<span class="font-mono mr-1 opacity-60">{loc.code}</span>{loc.label}
</button>
{/each}
</div>
</section>
<!-- Idiomas adicionales -->
<section class="flex flex-col gap-3">
<h3 class="section-title">Idiomas soportados</h3>
<p class="text-xs text-gray-600">Idiomas adicionales disponibles en campos multilingüe. El idioma principal siempre está incluido.</p>
<div class="flex flex-wrap gap-1.5">
{#each SUPPORTED_LOCALES.filter(l => l.code !== cat.lang) as loc}
{@const active = cat.langs.includes(loc.code)}
<button
type="button"
onclick={() => toggleLang(loc.code)}
class="px-3 py-1.5 rounded text-xs border transition-colors
{active
? 'bg-teal-900/30 border-teal-700 text-teal-300 font-medium'
: 'border-gray-700 text-gray-500 hover:border-gray-600 hover:text-gray-300'}"
>
<span class="font-mono mr-1 opacity-60">{loc.code}</span>{loc.label}
{#if active}<span class="ml-1 opacity-50">✓</span>{/if}
</button>
{/each}
</div>
{#if cat.langs.length > 1}
<p class="text-xs text-gray-700">
{cat.langs.length} idiomas: <span class="font-mono text-gray-600">{cat.langs.join(', ')}</span>
</p>
{/if}
</section>
</div>
</div> </div>

@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import { setContext } from 'svelte' import { setContext } from 'svelte'
import { createCatalogEditorStore } from './catalog-editor.svelte.js' import { createCatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import type { CatalogEditorStore } from './catalog-editor.svelte.js' import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import type { Cat } from '@/vcen/lib' import type { Cat } from '@/vcen/lib'
import { createEditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.ts'
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.ts'
import CatalogLayout from './CatalogLayout.svelte' import CatalogLayout from './CatalogLayout.svelte'
let { cat, onsave, oncancel }: { let { cat, onsave, oncancel }: {
@ -13,6 +15,9 @@ let { cat, onsave, oncancel }: {
const editor = createCatalogEditorStore(cat) const editor = createCatalogEditorStore(cat)
setContext<CatalogEditorStore>('catalog-editor', editor) setContext<CatalogEditorStore>('catalog-editor', editor)
const glob = createEditorGlob(cat.langs)
setContext<EditorGlob>('editor-glob', glob)
</script> </script>
<CatalogLayout {onsave} {oncancel} /> <CatalogLayout {onsave} {oncancel} />

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { Cat } from '@/vcen/lib' import type { Cat } from '@/vcen/lib'
import { getContext } from 'svelte' import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte.js' import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import { buildDependencyGraph } from '@/vcen/lib/graph.ts' import { buildDependencyGraph } from '@/vcen/lib/graph.ts'
// Helper: busca nombre legible de un nodo a partir de su key serializada // Helper: busca nombre legible de un nodo a partir de su key serializada
@ -10,28 +10,33 @@ function resolveNodeLabel(cat: Cat, key: string): string {
const last = parts[parts.length - 1] const last = parts[parts.length - 1]
// Try catalog atts // Try catalog atts
const catAtt = cat.atts?.find(a => a.id === last) const catAtt = cat.atts?.find(a => a.id === last)
if (catAtt) return `Catálogo / ${catAtt.name || catAtt.id}` const lang = cat.lang
if (catAtt) return `Catálogo / ${labelOf(catAtt.label, lang) || catAtt.id}`
// Try obj/sec/att // Try obj/sec/att
for (const obj of cat.objs) { for (const obj of cat.objs) {
if (obj.id === last) return obj.name || obj.id const objLabel = labelOf(obj.label, lang) || obj.id
if (obj.id === last) return objLabel
const objAtt = obj.atts.find(a => a.id === last) const objAtt = obj.atts.find(a => a.id === last)
if (objAtt) return `${obj.name || obj.id} / ${objAtt.name || objAtt.id}` if (objAtt) return `${objLabel} / ${labelOf(objAtt.label, lang) || objAtt.id}`
for (const sec of obj.secs) { for (const sec of obj.secs) {
if (sec.id === last) return `${obj.name || obj.id} / ${sec.name || sec.id}` const secLabel = labelOf(sec.label, lang) || sec.id
if (sec.id === last) return `${objLabel} / ${secLabel}`
const secAtt = sec.atts.find(a => a.id === last) const secAtt = sec.atts.find(a => a.id === last)
if (secAtt) return `${obj.name || obj.id} / ${sec.name || sec.id} / ${secAtt.name || secAtt.id}` if (secAtt) return `${objLabel} / ${secLabel} / ${labelOf(secAtt.label, lang) || secAtt.id}`
} }
} }
return key return key
} }
import CatalogSidebar from './CatalogSidebar.svelte' import { labelOf } from '@/vcen-edit/lib/label.ts'
import CatMetaPanel from './CatMetaPanel.svelte' import CatalogSidebar from './CatalogSidebar.svelte'
import CatAttsPanel from './CatAttsPanel.svelte' import CatMetaPanel from './CatMetaPanel.svelte'
import OptsPanel from './OptsPanel.svelte' import CatAttsPanel from './CatAttsPanel.svelte'
import ObjInfoPanel from './ObjInfoPanel.svelte' import MetadataPanel from './MetadataPanel.svelte'
import ObjAttsPanel from './ObjAttsPanel.svelte' import OptsPanel from './OptsPanel.svelte'
import SecInfoPanel from './SecInfoPanel.svelte' import ObjInfoPanel from './ObjInfoPanel.svelte'
import SecAttsPanel from './SecAttsPanel.svelte' import ObjAttsPanel from './ObjAttsPanel.svelte'
import SecInfoPanel from './SecInfoPanel.svelte'
import SecAttsPanel from './SecAttsPanel.svelte'
let { onsave, oncancel }: { let { onsave, oncancel }: {
onsave?: (cat: Cat) => void onsave?: (cat: Cat) => void
@ -151,13 +156,14 @@ const isValid = $derived(graphResult.graph !== null)
<!-- Main content --> <!-- Main content -->
<div class="flex-1 min-w-0 overflow-hidden relative"> <div class="flex-1 min-w-0 overflow-hidden relative">
{#if sel.kind === 'cat-info'} <CatMetaPanel /> {#if sel.kind === 'cat-info'} <CatMetaPanel />
{:else if sel.kind === 'cat-atts'} <CatAttsPanel /> {:else if sel.kind === 'cat-atts'} <CatAttsPanel />
{:else if sel.kind === 'opts'} <OptsPanel /> {:else if sel.kind === 'cat-meta' || sel.kind === 'obj-meta' || sel.kind === 'sec-meta'} <MetadataPanel />
{:else if sel.kind === 'obj-info'} <ObjInfoPanel /> {:else if sel.kind === 'opts'} <OptsPanel />
{:else if sel.kind === 'obj-atts'} <ObjAttsPanel /> {:else if sel.kind === 'obj-info'} <ObjInfoPanel />
{:else if sel.kind === 'sec-info'} <SecInfoPanel /> {:else if sel.kind === 'obj-atts'} <ObjAttsPanel />
{:else if sel.kind === 'sec-atts'} <SecAttsPanel /> {:else if sel.kind === 'sec-info'} <SecInfoPanel />
{:else if sel.kind === 'sec-atts'} <SecAttsPanel />
{/if} {/if}
<!-- JSON overlay --> <!-- JSON overlay -->

@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte' import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte.js' import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import type { ObjID, SecID } from '@/vcen/lib' import type { ObjID, SecID } from '@/vcen/lib'
import { labelOf } from '@/vcen-edit/lib/label.ts'
const editor = getContext<CatalogEditorStore>('catalog-editor') const editor = getContext<CatalogEditorStore>('catalog-editor')
const cat = $derived(editor.cat) const cat = $derived(editor.cat)
@ -125,6 +126,17 @@ const ITEM_INACTIVE = "text-gray-300 hover:text-gray-100 hover:bg-gray-800 borde
{/if} {/if}
</button> </button>
<!-- Metadatos del catálogo -->
<button
onclick={() => editor.select({ kind: 'cat-meta' })}
class="{ITEM_BASE} w-full pl-5 {isNodeActive('cat-meta') ? ITEM_ACTIVE : ITEM_INACTIVE}"
>
<svg class="h-3 w-3 shrink-0" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M1 2h10M1 5h7M1 8h4M9 7l2 2-2 2"/>
</svg>
Metadatos
</button>
<!-- Opciones --> <!-- Opciones -->
<button <button
onclick={() => editor.select({ kind: 'opts' })} onclick={() => editor.select({ kind: 'opts' })}
@ -214,7 +226,7 @@ const ITEM_INACTIVE = "text-gray-300 hover:text-gray-100 hover:bg-gray-800 borde
<svg class="h-3 w-3 shrink-0 text-gray-600" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5"> <svg class="h-3 w-3 shrink-0 text-gray-600" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="1" y="1" width="10" height="10" rx="2"/> <rect x="1" y="1" width="10" height="10" rx="2"/>
</svg> </svg>
<span class="flex-1 truncate font-medium">{obj.name || 'Sin nombre'}</span> <span class="flex-1 truncate font-medium">{labelOf(obj.label, cat.lang) || 'Sin nombre'}</span>
{#if objState} {#if objState}
<span class="h-1.5 w-1.5 rounded-full shrink-0 {STATE_DOT[objState] ?? 'bg-gray-600'}" title={objState}></span> <span class="h-1.5 w-1.5 rounded-full shrink-0 {STATE_DOT[objState] ?? 'bg-gray-600'}" title={objState}></span>
{/if} {/if}
@ -251,6 +263,16 @@ const ITEM_INACTIVE = "text-gray-300 hover:text-gray-100 hover:bg-gray-800 borde
{/if} {/if}
</button> </button>
<button
onclick={() => editor.select({ kind: 'obj-meta', objId: obj.id })}
class="{ITEM_BASE} w-full pl-10 {isNodeActive('obj-meta', obj.id) ? ITEM_ACTIVE : ITEM_INACTIVE}"
>
<svg class="h-3 w-3 shrink-0 text-gray-700" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M1 2h10M1 5h7M1 8h4M9 7l2 2-2 2"/>
</svg>
Metadatos
</button>
<!-- Secciones sub-tree --> <!-- Secciones sub-tree -->
<div class="flex items-center pl-7 pr-3 py-1 border-l-2 border-transparent"> <div class="flex items-center pl-7 pr-3 py-1 border-l-2 border-transparent">
<button <button
@ -326,7 +348,7 @@ const ITEM_INACTIVE = "text-gray-300 hover:text-gray-100 hover:bg-gray-800 borde
class="h-2 w-2 shrink-0 text-gray-700 transition-transform {isSecOpen ? 'rotate-90' : ''}" class="h-2 w-2 shrink-0 text-gray-700 transition-transform {isSecOpen ? 'rotate-90' : ''}"
viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="2"
><path d="M3 2l4 3-4 3"/></svg> ><path d="M3 2l4 3-4 3"/></svg>
<span class="flex-1 truncate">{sec.name || 'Sin nombre'}</span> <span class="flex-1 truncate">{labelOf(sec.label, cat.lang) || 'Sin nombre'}</span>
{#if secState} {#if secState}
<span class="h-1.5 w-1.5 rounded-full shrink-0 {STATE_DOT[secState] ?? 'bg-gray-600'}" title={secState}></span> <span class="h-1.5 w-1.5 rounded-full shrink-0 {STATE_DOT[secState] ?? 'bg-gray-600'}" title={secState}></span>
{/if} {/if}
@ -360,6 +382,16 @@ const ITEM_INACTIVE = "text-gray-300 hover:text-gray-100 hover:bg-gray-800 borde
<span class="text-[10px] px-1 rounded bg-gray-800 text-gray-600">{secAttCount}</span> <span class="text-[10px] px-1 rounded bg-gray-800 text-gray-600">{secAttCount}</span>
{/if} {/if}
</button> </button>
<button
onclick={() => editor.select({ kind: 'sec-meta', objId: obj.id, secId: sec.id })}
class="{ITEM_BASE} w-full pl-[3.75rem] {isNodeActive('sec-meta', obj.id, sec.id) ? ITEM_ACTIVE : ITEM_INACTIVE}"
>
<svg class="h-3 w-3 shrink-0 text-gray-700" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M1 2h10M1 5h7M1 8h4M9 7l2 2-2 2"/>
</svg>
Metadatos
</button>
{/if} {/if}
{/each} {/each}
{/if} {/if}

@ -0,0 +1,317 @@
<script lang="ts">
import type { Metadata, MetadataField, MetadataFieldType } from '@/mtas/lib'
import type { GlobState } from '@/glob/adapters/svelte'
import { validateMetadata, field as f } from '@/mtas/lib'
import AddFieldMenu from '@/mtas/svelte/components/AddFieldMenu.svelte'
import AddFieldModal from '@/mtas/svelte/components/AddFieldModal.svelte'
import FieldEditor from '@/mtas/svelte/components/FieldEditor.svelte'
import TypeBadge from '@/mtas/svelte/components/TypeBadge.svelte'
import SidebarTree from './SidebarTree.svelte'
import EditorTreeNodes from './EditorTreeNodes.svelte'
let {
value = $bindable<Metadata>({}),
attributes = [],
glob,
onUpdate,
}: {
value?: Metadata
attributes?: string[]
glob: GlobState
onUpdate?: (metadata: Metadata) => void
} = $props()
let selectedPath = $state<string[]>([])
let drawerOpen = $state(false)
let drawerType = $state<MetadataFieldType>('string')
let drawerTargetPath = $state<string[]>([])
let showDeleteConfirm = $state(false)
const keyOrder = $derived(value.order ?? Object.keys(value).filter(k => k !== 'order'))
const validation = $derived(validateMetadata(value))
function emit() { onUpdate?.(value) }
function getFieldAtPath(path: string[]): MetadataField | null {
let node: Metadata = value
for (let i = 0; i < path.length; i++) {
const fld = node[path[i]]
if (fld === undefined) return null
if (i === path.length - 1) return fld as MetadataField
if ((fld as MetadataField).type !== 'group') return null
node = (fld 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
value = { ...value, [head]: { ...parent, value: setInMeta(parent.value as Metadata, tail, newField) } }
emit()
}
function setInMeta(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: setInMeta(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: deleteInMeta(parent.value as Metadata, tail) } }
if (selectedPath.join('.').startsWith(path.join('.'))) selectedPath = []
emit()
}
function deleteInMeta(meta: Metadata, path: string[]): Metadata {
if (path.length === 1) { const n = { ...meta }; delete n[path[0]]; return n }
const [head, ...tail] = path
const parent = meta[head] as MetadataField
if (parent.type !== 'group') return meta
return { ...meta, [head]: { ...parent, value: deleteInMeta(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: renameInMeta(parent.value as Metadata, tail, newKey) } }
selectedPath = [...path.slice(0, -1), newKey]; emit()
}
function renameInMeta(meta: Metadata, path: string[], newKey: string): Metadata {
if (path.length === 1) return Object.fromEntries(Object.entries(meta).map(([k, v]) => [k === path[0] ? newKey : k, v]))
const [head, ...tail] = path
const parent = meta[head] as MetadataField
if (parent.type !== 'group') return meta
return { ...meta, [head]: { ...parent, value: renameInMeta(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: moveInMeta(parent.value as Metadata, tail, dir) } }; emit()
}
function moveInMeta(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: moveInMeta(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: addInMeta(parent.value as Metadata, tail, key, field) } }; emit()
}
function addInMeta(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: addInMeta(parent.value as Metadata, tail, key, field) } }
}
function existingKeysAt(targetPath: string[]): string[] {
if (targetPath.length === 0) return keyOrder
const fld = getFieldAtPath(targetPath)
if (!fld || fld.type !== 'group') return []
return Object.keys(fld.value)
}
const addTargetPath = $derived.by(() => {
if (selectedPath.length === 0) return []
const fld = getFieldAtPath(selectedPath)
return (fld && fld.type === 'group') ? selectedPath : selectedPath.slice(0, -1)
})
const selectedField = $derived(selectedPath.length > 0 ? getFieldAtPath(selectedPath) : null)
const selectedKey = $derived(selectedPath.length > 0 ? selectedPath[selectedPath.length - 1] : '')
</script>
<div class="h-full flex flex-col min-h-0">
<div class="border border-gray-800 bg-gray-900 rounded-lg w-full flex flex-col flex-1 min-h-0 overflow-hidden">
<div class="flex flex-1 min-h-0 overflow-hidden">
<!-- Left sidebar -->
<SidebarTree
title={glob.t('vcen.editor.mtas.header.title')}
count={keyOrder.length}
>
{#snippet actions()}
<div class="flex items-center gap-2">
<div class="h-1.5 w-1.5 rounded-full shrink-0 {validation.valid ? 'bg-green-500' : 'bg-red-500 animate-pulse'}"
title={validation.valid
? glob.t('vcen.editor.mtas.validation.valid')
: glob.t('vcen.editor.mtas.validation.errorCount', { count: validation.errors.length })}
></div>
<AddFieldMenu onSelect={type => { drawerType = type; drawerTargetPath = addTargetPath; drawerOpen = true }} iconOnly={true} {glob} placement="start" />
</div>
{/snippet}
{#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-gray-700" 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-gray-600">{glob.t('vcen.editor.mtas.field.empty')}</p>
</div>
{:else}
<div class="flex flex-col">
<EditorTreeNodes
meta={value}
order={keyOrder}
path={[]}
depth={0}
{selectedPath} {glob}
onSelect={p => selectedPath = p}
onMove={(p, dir) => moveFieldAtPath(p, dir)}
/>
</div>
{/if}
</SidebarTree>
<!-- Right panel -->
<div class="flex-1 min-w-0 overflow-y-auto">
{#if selectedField && selectedPath.length > 0}
<!-- Breadcrumb + delete -->
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-800 bg-gray-800/40 shrink-0">
<div class="flex items-center gap-1 text-xs text-gray-500 font-mono">
{#each selectedPath as seg, i}
{#if i > 0}<span class="text-gray-700">/</span>{/if}
<span class="{i === selectedPath.length - 1 ? 'text-gray-300 font-semibold' : ''}">{seg}</span>
{/each}
</div>
<button
type="button"
onclick={() => showDeleteConfirm = true}
class="text-xs text-gray-600 hover:text-red-400 transition-colors"
>{glob.t('vcen.editor.mtas.field.delete')}</button>
</div>
<div class="max-w-xl">
<FieldEditor
fieldKey={selectedKey}
field={selectedField}
{attributes} {glob}
onKeyChange={newKey => renameFieldAtPath(selectedPath, newKey)}
onFieldChange={fld => setFieldAtPath(selectedPath, fld)}
/>
</div>
{: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-gray-700" 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-gray-600">{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-gray-800">
<div class="flex items-start gap-2 rounded border border-red-900 bg-red-950 px-3 py-2.5 text-red-400 mt-3">
<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>
<!-- Add field modal -->
<AddFieldModal
open={drawerOpen}
selectedType={drawerType}
existingKeys={existingKeysAt(drawerTargetPath)}
{attributes} {glob}
onConfirm={(key, fld) => { addFieldAtPath(drawerTargetPath, key, fld); selectedPath = [...drawerTargetPath, key] }}
onClose={() => drawerOpen = false}
/>
<!-- Delete confirmation modal -->
{#if showDeleteConfirm && selectedField}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/60" role="presentation" onclick={() => showDeleteConfirm = false} onkeydown={() => {}}></div>
<div class="relative z-10 w-full max-w-sm rounded-xl border border-gray-800 bg-gray-900 shadow-2xl flex flex-col gap-4 p-6">
<div class="flex items-start gap-3">
<div class="shrink-0 rounded-full bg-red-950 p-2.5 text-red-400">
<svg class="h-5 w-5" 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>
</div>
<div class="flex flex-col gap-1 min-w-0">
<h3 class="font-semibold text-gray-200">{glob.t('vcen.editor.mtas.field.deleteConfirm')}</h3>
<p class="text-sm text-gray-500">{glob.t('vcen.editor.mtas.field.deleteConfirmMsg')}</p>
<div class="mt-2 flex items-center gap-2 rounded-lg bg-gray-800 px-3 py-2">
<TypeBadge type={selectedField.type} showLabel={false} {glob} />
<span class="font-mono text-sm font-medium text-gray-200 truncate">{selectedKey}</span>
</div>
</div>
</div>
<div class="flex gap-2">
<button type="button" onclick={() => showDeleteConfirm = false}
class="flex-1 inline-flex items-center justify-center px-4 py-2 rounded border border-gray-700 text-gray-300 hover:bg-gray-800 transition-colors">
{glob.t('vcen.editor.mtas.field.deleteNo')}
</button>
<button type="button" onclick={() => { deleteFieldAtPath(selectedPath); showDeleteConfirm = false }}
class="flex-1 inline-flex items-center justify-center px-4 py-2 rounded bg-red-900 text-red-200 hover:bg-red-800 transition-colors">
{glob.t('vcen.editor.mtas.field.deleteYes')}
</button>
</div>
</div>
</div>
{/if}

@ -0,0 +1,120 @@
<script lang="ts">
import type { Metadata, MetadataField } from '@/mtas/lib'
import type { GlobState } from '@/glob/adapters/svelte'
import { validateField } from '@/mtas/lib'
import TypeBadge from '@/mtas/svelte/components/TypeBadge.svelte'
import EditorTreeNodesRecursive from './EditorTreeNodes.svelte'
let {
meta,
order,
path,
depth,
selectedPath,
glob,
onSelect,
onMove,
}: {
meta: Metadata
order: string[]
path: string[]
depth: number
selectedPath: string[]
glob: GlobState
onSelect: (path: string[]) => void
onMove: (path: string[], dir: -1 | 1) => void
} = $props()
let openGroups = $state<Set<string>>(new Set(order))
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)
next.has(key) ? next.delete(key) : next.add(key)
openGroups = next
}
function asField(v: unknown): MetadataField { return v as MetadataField }
function groupValue(f: MetadataField): Metadata { return f.type === 'group' ? f.value : {} }
</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) : []}
<div
class="group/node flex items-center gap-1 px-2 py-1.5 cursor-pointer select-none transition-colors border-l-2
{sel ? 'bg-indigo-950/50 border-indigo-500' : anc ? 'bg-gray-800/50 border-transparent' : 'hover:bg-gray-800/40 border-transparent'}"
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) }}
>
{#if isGroup}
<button type="button"
onclick={e => { e.stopPropagation(); toggleGroup(key) }}
class="flex items-center justify-center w-4 h-4 rounded text-gray-600 hover:text-gray-400 hover:bg-gray-700 transition-colors shrink-0 p-0">
<svg class="h-3 w-3 transition-transform {groupOpen ? 'rotate-90' : ''}"
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}
<div class="h-1.5 w-1.5 shrink-0 rounded-full {vResult.valid ? 'bg-green-500/60' : 'bg-red-500'}"></div>
<TypeBadge type={field.type} showLabel={false} {glob} />
<span class="text-xs font-mono truncate flex-1 {sel ? 'text-indigo-400 font-semibold' : 'text-gray-300'}">
{key}
</span>
<div class="items-center gap-0.5 shrink-0 {sel ? 'flex' : 'hidden group-hover/node:flex'}">
<button type="button"
onclick={e => { e.stopPropagation(); onMove(np, -1) }}
disabled={i === 0}
class="flex items-center justify-center w-5 h-5 rounded text-gray-500 hover:text-gray-300 hover:bg-gray-700 transition-colors 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>
<button type="button"
onclick={e => { e.stopPropagation(); onMove(np, 1) }}
disabled={i === order.length - 1}
class="flex items-center justify-center w-5 h-5 rounded text-gray-500 hover:text-gray-300 hover:bg-gray-700 transition-colors 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>
</div>
</div>
{#if isGroup && groupOpen && childKeys.length > 0}
<EditorTreeNodesRecursive
meta={groupValue(field)}
order={childKeys}
path={np}
depth={depth + 1}
{selectedPath} {glob}
{onSelect} {onMove}
/>
{/if}
{#if isGroup && groupOpen && childKeys.length === 0}
<div class="text-xs text-gray-600 italic py-1.5"
style="padding-left: {(depth + 1) * 12 + 8}px">
{glob.t('vcen.editor.mtas.field.emptyGroup')}
</div>
{/if}
{/each}

@ -0,0 +1,190 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { LingRecord, LingString, SupportedLocale } from '@/ling/lib';
type AsType = 'input' | 'textarea';
let {
value,
locales = ['es'],
defaultLocale,
label = '',
placeholder = '',
required = false,
invalid = false,
rows = 3,
as: asType = 'input' as AsType,
localeTab,
onUpdate,
}: {
value: LingRecord;
locales?: string[];
defaultLocale: string;
label?: string;
placeholder?: string;
required?: boolean;
invalid?: boolean;
rows?: number;
as?: AsType;
localeTab?: Snippet<[{ locale: string; active: boolean; hasContent: boolean; activate: () => void }]>;
onUpdate: (value: LingString) => void;
} = $props();
const get = (locale: string): string => value[locale as SupportedLocale] ?? '';
let currentLocale = $state(defaultLocale);
let inputText = $state(get(defaultLocale));
let previousLocale = $state(defaultLocale);
$effect(() => {
if (currentLocale !== previousLocale) {
previousLocale = currentLocale;
inputText = get(currentLocale);
}
});
let isOptional = $derived(currentLocale !== defaultLocale);
let charCount = $derived(inputText.length);
let dynamicPlaceholder = $derived(placeholder || `${currentLocale.toUpperCase()}…`);
function activateLocale(locale: string) {
currentLocale = locale;
}
function handleInput() {
const key = currentLocale as SupportedLocale;
const defKey = defaultLocale as SupportedLocale;
let next: LingRecord;
if (currentLocale === defaultLocale) {
next = { ...value, [defKey]: inputText };
} else {
if (inputText.trim() === '') {
const { [key]: _, ...rest } = value;
next = rest as LingRecord;
} else {
next = { ...value, [key]: inputText };
}
}
onUpdate(next);
}
function hasContent(locale: string): boolean {
if (locale === currentLocale) return !!(inputText.trim());
return !!(get(locale).trim());
}
</script>
<div class="flex w-full flex-col gap-1.5">
{#if label}
<div class="flex items-baseline justify-between px-0.5">
<label class="text-[11px] font-semibold uppercase tracking-[0.1em] {invalid ? 'text-red-400' : 'text-gray-500'}">
{label}{#if required}<span class="ml-0.5 text-red-400">*</span>{/if}
</label>
{#if locales.length > 1}
<span class="text-[10px] font-medium {invalid ? 'text-red-400' : isOptional ? 'text-gray-400' : 'text-indigo-400'}">
{#if invalid}requerido{:else if isOptional}opcional{:else}requerido{/if}
</span>
{/if}
</div>
{/if}
<div class="relative rounded border transition-all duration-200
{invalid
? 'border-red-800 bg-gray-900'
: 'border-gray-800 bg-gray-900 focus-within:border-indigo-600'}">
<!-- Tab bar — solo si hay más de un locale -->
{#if locales.length > 1}
<div class="flex items-center gap-1 px-2 pt-2 pb-0 h-7">
<div role="tablist" aria-label="Seleccionar idioma" class="flex items-center gap-1">
{#each locales as locale (locale)}
{#if localeTab}
{@render localeTab({
locale,
active: currentLocale === locale,
hasContent: hasContent(locale),
activate: () => activateLocale(locale),
})}
{:else}
{@const active = currentLocale === locale}
{@const filled = hasContent(locale)}
<button
type="button"
role="tab"
aria-selected={active}
aria-label={`Idioma ${locale}`}
onclick={() => activateLocale(locale)}
class="relative flex h-4 w-8 items-center rounded px-1.5 font-mono text-[10px] font-bold uppercase
tracking-wider transition-all duration-150 focus:outline-none
{active
? invalid ? 'bg-red-700 text-white' : 'bg-indigo-600 text-white'
: filled ? 'bg-gray-700 text-gray-300 hover:bg-gray-600'
: 'text-gray-600 hover:bg-gray-800 hover:text-gray-400'}"
>
{locale}
{#if locale === defaultLocale && !active}
<span aria-hidden="true" class="ml-0.5 h-1 w-1 rounded-full opacity-70
{invalid ? 'bg-red-400' : 'bg-green-500'}"></span>
{:else if filled && !active}
<span aria-hidden="true" class="ml-1 h-1 w-1 rounded-full bg-indigo-400 opacity-80"></span>
{/if}
</button>
{/if}
{/each}
</div>
<div class="ml-auto min-w-[2rem] text-right">
<span class="font-mono text-[10px] text-gray-700">
{charCount > 0 ? charCount : ''}
</span>
</div>
</div>
<div class="mx-2 mt-1.5 h-px {invalid ? 'bg-red-900' : 'bg-gray-800'}"></div>
{/if}
{#if asType === 'textarea'}
<textarea
aria-label={`Texto en ${currentLocale}`}
aria-required={currentLocale === defaultLocale && required}
aria-invalid={invalid}
required={currentLocale === defaultLocale && required}
{rows}
placeholder={dynamicPlaceholder}
bind:value={inputText}
oninput={handleInput}
class="w-full resize-none bg-transparent px-2.5 py-2 text-[13px] leading-relaxed
text-gray-200 placeholder:text-gray-700 focus:outline-none"
style="height: calc({rows} * 1.625rem + 1.25rem)"
></textarea>
{:else}
<input
type="text"
aria-label={`Texto en ${currentLocale}`}
aria-required={currentLocale === defaultLocale && required}
aria-invalid={invalid}
required={currentLocale === defaultLocale && required}
placeholder={dynamicPlaceholder}
bind:value={inputText}
oninput={handleInput}
class="w-full bg-transparent px-2.5 py-1.5 text-[13px] text-gray-200
placeholder:text-gray-700 focus:outline-none h-8"
/>
{/if}
{#if invalid}
<div class="flex items-center gap-1.5 border-t border-red-900 bg-red-950/40 px-2.5 py-1.5 rounded-b">
<svg class="h-3 w-3 shrink-0 text-red-400" 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-[10px] font-medium text-red-400">
El campo <span class="font-bold">{defaultLocale.toUpperCase()}</span> es obligatorio
</span>
</div>
{/if}
</div>
</div>

@ -0,0 +1,25 @@
<script lang="ts">
import type { Metadata } from '@/mtas/lib'
import { getContext } from 'svelte'
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.ts'
import EditorMetaTree from './EditorMetaTree.svelte'
let {
value = $bindable<Metadata | undefined>(undefined),
onUpdate,
}: {
value?: Metadata
onUpdate: (meta: Metadata | undefined) => void
} = $props()
const glob = getContext<EditorGlob>('editor-glob')
const meta = $derived(value ?? {})
</script>
<div class="flex flex-col h-full min-h-0">
<EditorMetaTree
value={meta}
{glob}
onUpdate={(m) => onUpdate(Object.keys(m).filter(k => k !== 'order').length === 0 ? undefined : m)}
/>
</div>

@ -0,0 +1,54 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import { labelOf } from '@/vcen-edit/lib/label.ts'
import MetaPanel from './MetaPanel.svelte'
const editor = getContext<CatalogEditorStore>('catalog-editor')
const sel = $derived(editor.selection)
const ctx = $derived.by(() => {
if (sel.kind === 'cat-meta') {
const cat = editor.cat
return {
title: cat.name,
id: cat.id,
meta: (cat as any).metadata,
onUpdate: (m: any) => editor.updateCatMeta(m),
}
}
if (sel.kind === 'obj-meta') {
const obj = editor.findObj(sel.objId)
if (!obj) return null
return {
title: labelOf(obj.label, editor.cat.lang) || 'Sin nombre',
id: obj.id,
meta: (obj as any).metadata,
onUpdate: (m: any) => editor.updateObjMeta(sel.objId, m),
}
}
if (sel.kind === 'sec-meta') {
const sec = editor.findSec(sel.objId, sel.secId)
if (!sec) return null
return {
title: labelOf(sec.label, editor.cat.lang) || 'Sin nombre',
id: sec.id,
meta: (sec as any).metadata,
onUpdate: (m: any) => editor.updateSecMeta(sel.objId, sel.secId, m),
}
}
return null
})
</script>
{#if ctx}
<div class="h-full flex flex-col">
<div class="px-6 py-4 border-b border-gray-800 shrink-0">
<h2 class="text-sm font-semibold text-gray-200">Metadatos — {ctx.title}</h2>
<p class="text-xs text-gray-600 mt-0.5 font-mono">{ctx.id}</p>
</div>
<div class="flex-1 min-h-0">
<MetaPanel value={ctx.meta} onUpdate={ctx.onUpdate} />
</div>
</div>
{/if}

@ -1,15 +1,17 @@
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte' import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte.js' import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import { labelOf } from '@/vcen-edit/lib/label.ts'
import AttDetail from './AttDetail.svelte' import AttDetail from './AttDetail.svelte'
import SidebarTree from './SidebarTree.svelte'
const editor = getContext<CatalogEditorStore>('catalog-editor') const editor = getContext<CatalogEditorStore>('catalog-editor')
const sel = $derived(editor.selection) const sel = $derived(editor.selection)
const cat = $derived(editor.cat)
const ctx = $derived(sel.kind === 'obj-atts' const ctx = $derived(sel.kind === 'obj-atts'
? { objId: sel.objId, obj: editor.findObj(sel.objId), attId: sel.attId } ? { objId: sel.objId, obj: editor.findObj(sel.objId), attId: sel.attId }
: null) : null)
let creating = $state(false) let creating = $state(false)
let newAttName = $state('') let newAttName = $state('')
let newAttSlug = $state('') let newAttSlug = $state('')
@ -37,100 +39,77 @@ function cancelAdd() {
{#if ctx?.obj} {#if ctx?.obj}
{@const { objId, obj, attId } = ctx} {@const { objId, obj, attId } = ctx}
<div class="flex h-full"> <div class="flex h-full">
<!-- Att list -->
<div class="w-56 shrink-0 border-r border-gray-800 flex flex-col"> <SidebarTree title="Atributos" count={obj.atts.length} onAdd={() => { creating = true; newAttName = '' }}>
<div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-800 shrink-0"> {#if obj.atts.length === 0 && !creating}
<span class="text-xs font-semibold text-gray-400 uppercase tracking-wider"> <div class="px-4 py-6 text-center text-gray-700 text-xs italic">
Atributos ({obj.atts.length}) Sin atributos
</span> <button
<button onclick={() => { creating = true; newAttName = '' }}
onclick={() => { creating = true; newAttName = '' }} class="block mx-auto mt-2 px-3 py-1 rounded border border-dashed border-gray-700
class="w-5 h-5 flex items-center justify-center rounded text-gray-500 text-gray-600 hover:text-indigo-400 hover:border-indigo-700 transition-colors"
hover:text-indigo-400 hover:bg-indigo-500/10 transition-colors" >Añadir atributo</button>
>+</button> </div>
</div> {:else}
<div class="flex-1 overflow-y-auto py-1"> {#each obj.atts as att (att.id)}
{#if obj.atts.length === 0} {@const isActive = attId === att.id}
<div class="px-4 py-6 text-center text-gray-700 text-xs italic"> <button
Sin atributos onclick={() => editor.select({ kind: 'obj-atts', objId, attId: att.id })}
<button class="w-full text-left px-4 py-2 flex items-center justify-between
onclick={() => { creating = true; newAttName = '' }} cursor-pointer transition-colors border-l-2
class="block mx-auto mt-2 px-3 py-1 rounded border border-dashed border-gray-700 {isActive ? 'bg-gray-800 border-blue-500' : 'border-transparent hover:bg-gray-800'}"
text-gray-600 hover:text-indigo-400 hover:border-indigo-700 transition-colors" >
>Añadir atributo</button> <span class="text-sm truncate flex-1 {isActive ? 'text-gray-100' : 'text-gray-300'}">
</div> {labelOf(att.label, cat.lang) || 'Sin nombre'}
{:else} </span>
{#each obj.atts as att (att.id)} <div class="flex items-center gap-1 shrink-0 ml-2">
{@const isActive = attId === att.id} <span class="text-xs px-1 py-0.5 rounded bg-gray-800 text-gray-500 border border-gray-700">
<button {att.value.domain.kind}
onclick={() => editor.select({ kind: 'obj-atts', objId, attId: att.id })} </span>
class="w-full text-left px-4 py-2.5 flex items-center justify-between {#if att.rules?.rules?.length}
cursor-pointer transition-colors border-l-2 <span class="text-xs text-blue-400" title="Tiene reglas">⚡</span>
{isActive {/if}
? 'bg-gray-800 border-blue-500'
: 'border-transparent hover:bg-gray-800'}"
>
<div class="flex flex-col gap-0.5 min-w-0">
<span class="text-sm truncate {isActive ? 'text-gray-100' : 'text-gray-300'}">
{att.name || 'Sin nombre'}
</span>
<span class="text-xs text-gray-600 font-mono truncate">{att.id}</span>
</div>
<div class="flex items-center gap-1 shrink-0 ml-2">
<span class="text-xs px-1 py-0.5 rounded bg-gray-800 text-gray-500 border border-gray-700">
{att.value.domain.kind}
</span>
{#if att.rules?.rules?.length}
<span class="text-xs text-blue-400" title="Tiene reglas">⚡</span>
{/if}
</div>
</button>
<div class="px-4 pb-1 flex justify-end">
<button
onclick={() => editor.removeAtt(objId, undefined, att.id)}
class="text-xs text-gray-700 hover:text-red-400 transition-colors"
>eliminar</button>
</div> </div>
{/each} </button>
{/if} {/each}
{#if creating} {/if}
<div class="px-3 py-2 border-t border-gray-800 bg-gray-900/60 flex flex-col gap-1.5"> {#if creating}
<div class="px-3 py-2 border-t border-gray-800 bg-gray-900/60 flex flex-col gap-1.5 mt-1">
<input
type="text"
bind:value={newAttName}
oninput={e => { if (!newAttSlug || newAttSlug === slugify(newAttName.slice(0,-1))) newAttSlug = slugify(e.currentTarget.value) }}
placeholder="Nombre del atributo"
class="field-input text-xs"
autofocus
/>
<div class="flex items-center gap-1">
<span class="text-[10px] text-gray-600 shrink-0">at:</span>
<input <input
type="text" type="text"
bind:value={newAttName} bind:value={newAttSlug}
oninput={e => { if (!newAttSlug || newAttSlug === slugify(newAttName.slice(0,-1))) newAttSlug = slugify(e.currentTarget.value) }} oninput={() => { attSlugError = '' }}
placeholder="Nombre del atributo" onkeydown={e => { if (e.key === 'Enter') confirmAdd(); if (e.key === 'Escape') cancelAdd() }}
class="field-input text-xs" placeholder="slug-atributo"
autofocus class="field-input text-xs font-mono flex-1"
/> />
<div class="flex items-center gap-1">
<span class="text-[10px] text-gray-600 shrink-0">at:</span>
<input
type="text"
bind:value={newAttSlug}
oninput={() => { attSlugError = '' }}
onkeydown={e => { if (e.key === 'Enter') confirmAdd(); if (e.key === 'Escape') cancelAdd() }}
placeholder="slug-atributo"
class="field-input text-xs font-mono flex-1"
/>
</div>
{#if attSlugError}
<p class="text-[10px] text-red-400">{attSlugError}</p>
{/if}
<div class="flex gap-1">
<button onclick={confirmAdd}
class="flex-1 px-2 py-1 text-xs rounded bg-indigo-600 hover:bg-indigo-500 text-white transition-colors">
Añadir
</button>
<button onclick={cancelAdd}
class="px-2 py-1 text-xs rounded border border-gray-700 text-gray-500 hover:text-gray-300 transition-colors">
✕
</button>
</div>
</div> </div>
{/if} {#if attSlugError}
</div> <p class="text-[10px] text-red-400">{attSlugError}</p>
</div> {/if}
<div class="flex gap-1">
<button onclick={confirmAdd}
class="flex-1 px-2 py-1 text-xs rounded bg-indigo-600 hover:bg-indigo-500 text-white transition-colors">
Añadir
</button>
<button onclick={cancelAdd}
class="px-2 py-1 text-xs rounded border border-gray-700 text-gray-500 hover:text-gray-300 transition-colors">
✕
</button>
</div>
</div>
{/if}
</SidebarTree>
<!-- Att detail --> <!-- Att detail -->
<div class="flex-1 overflow-auto"> <div class="flex-1 overflow-auto">

@ -1,11 +1,14 @@
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte' import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte.js' import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import { labelOf } from '@/vcen-edit/lib/label.ts'
import RuleList from './RuleList.svelte' import RuleList from './RuleList.svelte'
import LingInput from './LingInput.svelte'
import { evalRuleSetState } from '@/vcen-edit/lib/rule-eval.ts' import { evalRuleSetState } from '@/vcen-edit/lib/rule-eval.ts'
const editor = getContext<CatalogEditorStore>('catalog-editor') const editor = getContext<CatalogEditorStore>('catalog-editor')
const sel = $derived(editor.selection) const sel = $derived(editor.selection)
const cat = $derived(editor.cat)
const obj = $derived(sel.kind === 'obj-info' ? editor.findObj(sel.objId) ?? null : null) const obj = $derived(sel.kind === 'obj-info' ? editor.findObj(sel.objId) ?? null : null)
const objId = $derived(sel.kind === 'obj-info' ? sel.objId : null) const objId = $derived(sel.kind === 'obj-info' ? sel.objId : null)
@ -29,9 +32,10 @@ const stateLabel: Record<string, string> = {
</script> </script>
{#if obj && objId} {#if obj && objId}
<div class="p-6 max-w-2xl flex flex-col gap-6 overflow-y-auto h-full"> <div class="overflow-y-auto h-full">
<div class="p-6 max-w-2xl flex flex-col gap-6">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="text-sm font-semibold text-gray-200">{obj.name || 'Sin nombre'}</h2> <h2 class="text-sm font-semibold text-gray-200">{labelOf(obj.label, cat.lang) || 'Sin nombre'}</h2>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if computedState} {#if computedState}
<span class="text-xs px-2 py-0.5 rounded border font-medium {stateBadge[computedState] ?? ''}"> <span class="text-xs px-2 py-0.5 rounded border font-medium {stateBadge[computedState] ?? ''}">
@ -61,13 +65,15 @@ const stateLabel: Record<string, string> = {
<!-- Nombre --> <!-- Nombre -->
<section class="flex flex-col gap-3"> <section class="flex flex-col gap-3">
<h3 class="section-title">Identificación</h3> <h3 class="section-title">Identificación</h3>
<div class="flex flex-col gap-1"> <LingInput
<label class="text-xs text-gray-500">Nombre</label> value={obj.label}
<input type="text" value={obj.name} locales={cat.langs}
oninput={e => editor.renameObj(objId, e.currentTarget.value)} defaultLocale={cat.lang}
placeholder="Habitación, Cocina..." as="input"
class="field-input" /> label="Nombre"
</div> placeholder="Habitación, Cocina..."
onUpdate={label => editor.setObjLabel(objId, label as any)}
/>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label class="text-xs text-gray-500">ID (solo lectura)</label> <label class="text-xs text-gray-500">ID (solo lectura)</label>
<input type="text" value={obj.id} readonly <input type="text" value={obj.id} readonly
@ -98,4 +104,5 @@ const stateLabel: Record<string, string> = {
{/if} {/if}
</section> </section>
</div> </div>
</div>
{/if} {/if}

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte' import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte.js' import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import { labelOf } from '@/vcen-edit/lib/label.ts'
const editor = getContext<CatalogEditorStore>('catalog-editor') const editor = getContext<CatalogEditorStore>('catalog-editor')
const cat = $derived(editor.cat) const cat = $derived(editor.cat)
@ -49,7 +50,7 @@ const activeObjId = $derived(
? 'bg-indigo-600/20 text-indigo-300 border-indigo-500' ? 'bg-indigo-600/20 text-indigo-300 border-indigo-500'
: 'text-gray-400 hover:bg-gray-800 hover:text-gray-200 border-transparent'}" : 'text-gray-400 hover:bg-gray-800 hover:text-gray-200 border-transparent'}"
> >
<span class="flex-1 truncate">{obj.name || 'Sin nombre'}</span> <span class="flex-1 truncate">{labelOf(obj.label, cat.lang) || 'Sin nombre'}</span>
<span class="text-xs text-gray-600 shrink-0">{attCount}a {obj.secs.length}s</span> <span class="text-xs text-gray-600 shrink-0">{attCount}a {obj.secs.length}s</span>
<button <button
onclick={e => { e.stopPropagation(); editor.removeObj(obj.id) }} onclick={e => { e.stopPropagation(); editor.removeObj(obj.id) }}

@ -6,7 +6,7 @@
* Col 3: AttList + AttDetail (si hay sec o att activo) * Col 3: AttList + AttDetail (si hay sec o att activo)
*/ */
import { getContext } from 'svelte' import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte.ts' import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import ObjList from './ObjList.svelte' import ObjList from './ObjList.svelte'
import SecList from './SecList.svelte' import SecList from './SecList.svelte'
import AttList from './AttList.svelte' import AttList from './AttList.svelte'

@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte' import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte.js' import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import { labelOf } from '@/vcen-edit/lib/label.ts'
const editor = getContext<CatalogEditorStore>('catalog-editor') const editor = getContext<CatalogEditorStore>('catalog-editor')
@ -20,7 +21,7 @@ const usages = $derived.by(() => {
const d = att.value.domain const d = att.value.domain
if (d.kind === 'option' && d.mode === 'list') { if (d.kind === 'option' && d.mode === 'list') {
if ((d as any).list?.some((ao: any) => ao.optId === opt.id)) { if ((d as any).list?.some((ao: any) => ao.optId === opt.id)) {
uses.push({ objName: obj.name, attName: att.name }) uses.push({ objName: labelOf(obj.label, cat.lang), attName: labelOf(att.label, cat.lang) })
} }
} }
} }
@ -29,7 +30,7 @@ const usages = $derived.by(() => {
const d = att.value.domain const d = att.value.domain
if (d.kind === 'option' && d.mode === 'list') { if (d.kind === 'option' && d.mode === 'list') {
if ((d as any).list?.some((ao: any) => ao.optId === opt.id)) { if ((d as any).list?.some((ao: any) => ao.optId === opt.id)) {
uses.push({ objName: obj.name, secName: sec.name, attName: att.name }) uses.push({ objName: labelOf(obj.label, cat.lang), secName: labelOf(sec.label, cat.lang), attName: labelOf(att.label, cat.lang) })
} }
} }
} }

@ -2,6 +2,7 @@
import { getContext } from 'svelte' import { getContext } from 'svelte'
import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js' import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import OptDetail from './OptDetail.svelte' import OptDetail from './OptDetail.svelte'
import SidebarTree from './SidebarTree.svelte'
const editor = getContext<CatalogEditorStore>('catalog-editor') const editor = getContext<CatalogEditorStore>('catalog-editor')
@ -18,7 +19,6 @@ const filtered = $derived(
) )
) )
// Agrupar por categoría
const grouped = $derived.by(() => { const grouped = $derived.by(() => {
const map = new Map<string, typeof filtered>() const map = new Map<string, typeof filtered>()
const cats = new Map(cat.optCategories.map(c => [c.id, c.name])) const cats = new Map(cat.optCategories.map(c => [c.id, c.name]))
@ -35,112 +35,90 @@ const activeOptId = $derived(editor.activeOptId)
<div class="flex h-full"> <div class="flex h-full">
<!-- Lista de opts --> <SidebarTree title="Opciones" count={opts.length} width="w-64" onAdd={() => editor.addOpt()}>
<div class="w-64 shrink-0 border-r border-gray-800 flex flex-col">
<!-- Header --> {#snippet extra()}
<div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-800 shrink-0"> <!-- Búsqueda -->
<span class="text-xs font-semibold text-gray-400 uppercase tracking-wider"> <div class="px-3 py-2 border-b border-gray-800 shrink-0">
Opciones ({opts.length}) <input type="text" bind:value={search} placeholder="Buscar..."
</span> class="w-full bg-gray-800 text-xs text-gray-300 px-2.5 py-1.5 rounded
<button border border-gray-700 focus:outline-none focus:border-indigo-500" />
onclick={() => editor.addOpt()}
class="w-6 h-6 flex items-center justify-center rounded text-gray-500
hover:text-indigo-400 hover:bg-indigo-500/10 transition-colors"
>+</button>
</div>
<!-- Búsqueda -->
<div class="px-3 py-2 border-b border-gray-800 shrink-0">
<input type="text" bind:value={search} placeholder="Buscar..."
class="w-full bg-gray-800 text-xs text-gray-300 px-2.5 py-1.5 rounded
border border-gray-700 focus:outline-none focus:border-indigo-500" />
</div>
<!-- Categorías -->
{#if cat.optCategories.length === 0}
<div class="px-3 py-1.5 border-b border-gray-800 shrink-0">
<button
onclick={() => editor.addOptCategory('Nueva categoría')}
class="text-xs text-gray-600 hover:text-indigo-400 transition-colors"
>+ Añadir categoría</button>
</div>
{:else}
<div class="px-3 py-1.5 border-b border-gray-800 shrink-0 flex flex-wrap gap-1 items-center">
{#each cat.optCategories as cat_ (cat_.id)}
<span class="flex items-center gap-1 px-2 py-0.5 rounded-full bg-gray-800
border border-gray-700 text-xs text-gray-400 group">
<input
type="text"
value={cat_.name}
oninput={e => editor.renameOptCategory(cat_.id, e.currentTarget.value)}
class="bg-transparent outline-none w-16 text-xs"
/>
<button
onclick={() => editor.removeOptCategory(cat_.id)}
class="text-gray-600 hover:text-red-400 transition-colors opacity-0 group-hover:opacity-100"
>✕</button>
</span>
{/each}
<button
onclick={() => editor.addOptCategory('Nueva')}
class="text-xs text-gray-600 hover:text-indigo-400 transition-colors px-1"
>+</button>
</div> </div>
{/if}
<!-- Lista --> <!-- Categorías -->
<div class="flex-1 overflow-y-auto py-1"> {#if cat.optCategories.length === 0}
{#if opts.length === 0} <div class="px-3 py-1.5 border-b border-gray-800 shrink-0">
<div class="px-4 py-8 text-center text-gray-600 text-xs">
<p>Sin opciones</p>
<p class="mt-1 text-gray-700">Mármol, parquet, lacado blanco...</p>
<button <button
onclick={() => editor.addOpt()} onclick={() => editor.addOptCategory('Nueva categoría')}
class="mt-3 text-xs px-3 py-1.5 rounded border border-dashed border-gray-700 class="text-xs text-gray-600 hover:text-indigo-400 transition-colors"
text-gray-500 hover:text-indigo-400 hover:border-indigo-700 transition-colors" >+ Añadir categoría</button>
>Crear opción</button>
</div> </div>
{:else if filtered.length === 0}
<div class="px-4 py-6 text-center text-xs text-gray-600">Sin resultados</div>
{:else} {:else}
{#each [...grouped] as [catName, groupOpts]} <div class="px-3 py-1.5 border-b border-gray-800 shrink-0 flex flex-wrap gap-1 items-center">
<div class="px-3 py-1.5 text-xs text-gray-600 uppercase tracking-wider {#each cat.optCategories as cat_ (cat_.id)}
bg-gray-900/50 sticky top-0 z-10"> <span class="flex items-center gap-1 px-2 py-0.5 rounded-full bg-gray-800
{catName} border border-gray-700 text-xs text-gray-400 group">
</div> <input
{#each groupOpts as opt (opt.id)} type="text"
{@const isActive = activeOptId === opt.id} value={cat_.name}
<div oninput={e => editor.renameOptCategory(cat_.id, e.currentTarget.value)}
role="button" tabindex="0" class="bg-transparent outline-none w-16 text-xs"
onclick={() => editor.setActiveOpt(opt.id)} />
onkeydown={e => e.key === 'Enter' && editor.setActiveOpt(opt.id)}
class="px-3 py-2 flex items-center gap-2 cursor-pointer transition-colors group
{isActive
? 'bg-indigo-600/20 text-indigo-300'
: 'text-gray-400 hover:bg-gray-800 hover:text-gray-200'}"
>
<span class="w-1.5 h-1.5 rounded-full shrink-0
{isActive ? 'bg-indigo-400' : 'bg-transparent'}"></span>
<span class="flex-1 truncate text-sm">
{String(opt.value.value) || 'Sin valor'}
</span>
{#if opt.label}
<span class="text-xs text-gray-600 truncate max-w-[4rem]">
{typeof opt.label === 'string' ? opt.label : (opt.label as any)?.es ?? ''}
</span>
{/if}
<button <button
onclick={e => { e.stopPropagation(); editor.removeOpt(opt.id) }} onclick={() => editor.removeOptCategory(cat_.id)}
class="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 class="text-gray-600 hover:text-red-400 transition-colors opacity-0 group-hover:opacity-100"
transition-opacity text-xs"
>✕</button> >✕</button>
</div> </span>
{/each} {/each}
{/each} <button
onclick={() => editor.addOptCategory('Nueva')}
class="text-xs text-gray-600 hover:text-indigo-400 transition-colors px-1"
>+</button>
</div>
{/if} {/if}
</div> {/snippet}
</div>
<!-- Lista -->
{#if opts.length === 0}
<div class="px-4 py-8 text-center text-gray-600 text-xs">
<p>Sin opciones</p>
<p class="mt-1 text-gray-700">Mármol, parquet, lacado blanco...</p>
<button
onclick={() => editor.addOpt()}
class="mt-3 text-xs px-3 py-1.5 rounded border border-dashed border-gray-700
text-gray-500 hover:text-indigo-400 hover:border-indigo-700 transition-colors"
>Crear opción</button>
</div>
{:else if filtered.length === 0}
<div class="px-4 py-6 text-center text-xs text-gray-600">Sin resultados</div>
{:else}
{#each [...grouped] as [catName, groupOpts]}
<div class="px-4 py-1 text-[10px] font-semibold text-gray-600 uppercase tracking-wider
bg-gray-900/60 sticky top-0 z-10">
{catName}
</div>
{#each groupOpts as opt (opt.id)}
{@const isActive = activeOptId === opt.id}
<button
onclick={() => editor.setActiveOpt(opt.id)}
class="w-full text-left px-4 py-2 flex items-center justify-between
cursor-pointer transition-colors border-l-2
{isActive ? 'bg-gray-800 border-blue-500' : 'border-transparent hover:bg-gray-800'}"
>
<span class="text-sm truncate flex-1 {isActive ? 'text-gray-100' : 'text-gray-300'}">
{String(opt.value.value) || 'Sin valor'}
</span>
{#if opt.label}
<span class="text-xs text-gray-600 truncate max-w-[4rem] ml-2">
{typeof opt.label === 'string' ? opt.label : (opt.label as any)?.es ?? ''}
</span>
{/if}
</button>
{/each}
{/each}
{/if}
</SidebarTree>
<!-- Detalle --> <!-- Detalle -->
<div class="flex-1 overflow-auto"> <div class="flex-1 overflow-auto">

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte' import { getContext } from 'svelte'
import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js' import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import { labelOf } from '@/vcen-edit/lib/label.ts'
import type { RuleSet, Rule, Obj, Sec, AttState } from '@/vcen/lib' import type { RuleSet, Rule, Obj, Sec, AttState } from '@/vcen/lib'
import { createConditionBuilderStore } from '@/vcen-edit/stores/condition-builder.svelte.ts' import { createConditionBuilderStore } from '@/vcen-edit/stores/condition-builder.svelte.ts'
import ConditionBuilder from './ConditionBuilder.svelte' import ConditionBuilder from './ConditionBuilder.svelte'
@ -34,16 +35,16 @@ const containerAttrs = $derived.by(() => {
const attrs: Array<{ id: string; label: string }> = [] const attrs: Array<{ id: string; label: string }> = []
if ('secs' in container) { if ('secs' in container) {
for (const att of container.atts) { for (const att of container.atts) {
attrs.push({ id: att.id, label: att.name || att.id }) attrs.push({ id: att.id, label: labelOf(att.label, editor.cat.lang) || att.id })
} }
for (const sec of (container as Obj).secs) { for (const sec of (container as Obj).secs) {
for (const att of sec.atts) { for (const att of sec.atts) {
attrs.push({ id: att.id, label: `${sec.name || sec.id} / ${att.name || att.id}` }) attrs.push({ id: att.id, label: `${labelOf(sec.label, editor.cat.lang) || sec.id} / ${labelOf(att.label, editor.cat.lang) || att.id}` })
} }
} }
} else { } else {
for (const att of container.atts) { for (const att of container.atts) {
attrs.push({ id: att.id, label: att.name || att.id }) attrs.push({ id: att.id, label: labelOf(att.label, editor.cat.lang) || att.id })
} }
} }
return selfAttId ? attrs.filter(a => a.id !== selfAttId) : attrs return selfAttId ? attrs.filter(a => a.id !== selfAttId) : attrs

@ -4,7 +4,7 @@
* Sigue exactamente el patrón de RuleList.svelte + RuleEditor.svelte de referencia * Sigue exactamente el patrón de RuleList.svelte + RuleEditor.svelte de referencia
*/ */
import { getContext } from 'svelte' import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte.js' import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import type { RuleSet , Obj, Sec } from '@/vcen/lib' import type { RuleSet , Obj, Sec } from '@/vcen/lib'
import RuleEditor from './RuleEditor.svelte' import RuleEditor from './RuleEditor.svelte'

@ -1,15 +1,17 @@
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte' import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte.js' import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import { labelOf } from '@/vcen-edit/lib/label.ts'
import AttDetail from './AttDetail.svelte' import AttDetail from './AttDetail.svelte'
import SidebarTree from './SidebarTree.svelte'
const editor = getContext<CatalogEditorStore>('catalog-editor') const editor = getContext<CatalogEditorStore>('catalog-editor')
const sel = $derived(editor.selection) const sel = $derived(editor.selection)
const cat = $derived(editor.cat)
const ctx = $derived(sel.kind === 'sec-atts' const ctx = $derived(sel.kind === 'sec-atts'
? { objId: sel.objId, secId: sel.secId, sec: editor.findSec(sel.objId, sel.secId), attId: sel.attId } ? { objId: sel.objId, secId: sel.secId, sec: editor.findSec(sel.objId, sel.secId), attId: sel.attId }
: null) : null)
let creating = $state(false) let creating = $state(false)
let newAttName = $state('') let newAttName = $state('')
let newAttSlug = $state('') let newAttSlug = $state('')
@ -37,103 +39,79 @@ function cancelAdd() {
{#if ctx?.sec} {#if ctx?.sec}
{@const { objId, secId, sec, attId } = ctx} {@const { objId, secId, sec, attId } = ctx}
<div class="flex h-full"> <div class="flex h-full">
<div class="w-56 shrink-0 border-r border-gray-800 flex flex-col">
<!-- sec name header --> <SidebarTree title="Atributos" count={sec.atts.length} onAdd={() => { creating = true; newAttName = '' }}>
<div class="px-4 py-2.5 border-b border-gray-800 shrink-0"> {#if sec.atts.length === 0 && !creating}
<input type="text" value={sec.name} <div class="px-4 py-6 text-center text-gray-700 text-xs italic">
oninput={e => editor.renameSec(objId, secId, e.currentTarget.value)} Sin atributos
placeholder="Nombre sección" class="field-input text-xs" /> <button
</div> onclick={() => { creating = true; newAttName = '' }}
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-800 shrink-0"> class="block mx-auto mt-2 px-3 py-1 rounded border border-dashed border-gray-700
<span class="text-xs font-semibold text-gray-400 uppercase tracking-wider"> text-gray-600 hover:text-indigo-400 hover:border-indigo-700 transition-colors"
Atributos ({sec.atts.length}) >Añadir atributo</button>
</span> </div>
<button onclick={() => { creating = true; newAttName = '' }} {:else}
class="w-5 h-5 flex items-center justify-center rounded text-gray-500 {#each sec.atts as att (att.id)}
hover:text-indigo-400 hover:bg-indigo-500/10 transition-colors">+</button> {@const isActive = attId === att.id}
</div> <button
<div class="flex-1 overflow-y-auto py-1"> onclick={() => editor.select({ kind: 'sec-atts', objId, secId, attId: att.id })}
{#if sec.atts.length === 0} class="w-full text-left px-4 py-2 flex items-center justify-between
<div class="px-4 py-6 text-center text-gray-700 text-xs italic"> cursor-pointer transition-colors border-l-2
Sin atributos {isActive ? 'bg-gray-800 border-blue-500' : 'border-transparent hover:bg-gray-800'}"
<button onclick={() => { creating = true; newAttName = '' }} >
class="block mx-auto mt-2 px-3 py-1 rounded border border-dashed border-gray-700 <span class="text-sm truncate flex-1 {isActive ? 'text-gray-100' : 'text-gray-300'}">
text-gray-600 hover:text-indigo-400 hover:border-indigo-700 transition-colors"> {labelOf(att.label, cat.lang) || 'Sin nombre'}
Añadir atributo </span>
</button> <div class="flex items-center gap-1 shrink-0 ml-2">
</div> <span class="text-xs px-1 py-0.5 rounded bg-gray-800 text-gray-500 border border-gray-700">
{:else} {att.value.domain.kind}
{#each sec.atts as att (att.id)} </span>
{@const isActive = attId === att.id} {#if att.rules?.rules?.length}
<button <span class="text-xs text-blue-400" title="Tiene reglas">⚡</span>
onclick={() => editor.select({ kind: 'sec-atts', objId, secId, attId: att.id })} {/if}
class="w-full text-left px-4 py-2.5 flex items-center justify-between
cursor-pointer transition-colors border-l-2
{isActive
? 'bg-gray-800 border-blue-500'
: 'border-transparent hover:bg-gray-800'}"
>
<div class="flex flex-col gap-0.5 min-w-0">
<span class="text-sm truncate {isActive ? 'text-gray-100' : 'text-gray-300'}">
{att.name || 'Sin nombre'}
</span>
<span class="text-xs text-gray-600 font-mono truncate">{att.id}</span>
</div>
<div class="flex items-center gap-1 shrink-0 ml-2">
<span class="text-xs px-1 py-0.5 rounded bg-gray-800 text-gray-500 border border-gray-700">
{att.value.domain.kind}
</span>
{#if att.rules?.rules?.length}
<span class="text-xs text-blue-400" title="Tiene reglas">⚡</span>
{/if}
</div>
</button>
<div class="px-4 pb-1 flex justify-end">
<button
onclick={() => editor.removeAtt(objId, secId, att.id)}
class="text-xs text-gray-700 hover:text-red-400 transition-colors"
>eliminar</button>
</div> </div>
{/each} </button>
{/if} {/each}
{#if creating} {/if}
<div class="px-3 py-2 border-t border-gray-800 bg-gray-900/60 flex flex-col gap-1.5"> {#if creating}
<div class="px-3 py-2 border-t border-gray-800 bg-gray-900/60 flex flex-col gap-1.5 mt-1">
<input
type="text"
bind:value={newAttName}
oninput={e => { if (!newAttSlug || newAttSlug === slugify(newAttName.slice(0,-1))) newAttSlug = slugify(e.currentTarget.value) }}
placeholder="Nombre del atributo"
class="field-input text-xs"
autofocus
/>
<div class="flex items-center gap-1">
<span class="text-[10px] text-gray-600 shrink-0">at:</span>
<input <input
type="text" type="text"
bind:value={newAttName} bind:value={newAttSlug}
oninput={e => { if (!newAttSlug || newAttSlug === slugify(newAttName.slice(0,-1))) newAttSlug = slugify(e.currentTarget.value) }} oninput={() => { attSlugError = '' }}
placeholder="Nombre del atributo" onkeydown={e => { if (e.key === 'Enter') confirmAdd(); if (e.key === 'Escape') cancelAdd() }}
class="field-input text-xs" placeholder="slug-atributo"
autofocus class="field-input text-xs font-mono flex-1"
/> />
<div class="flex items-center gap-1">
<span class="text-[10px] text-gray-600 shrink-0">at:</span>
<input
type="text"
bind:value={newAttSlug}
oninput={() => { attSlugError = '' }}
onkeydown={e => { if (e.key === 'Enter') confirmAdd(); if (e.key === 'Escape') cancelAdd() }}
placeholder="slug-atributo"
class="field-input text-xs font-mono flex-1"
/>
</div>
{#if attSlugError}
<p class="text-[10px] text-red-400">{attSlugError}</p>
{/if}
<div class="flex gap-1">
<button onclick={confirmAdd}
class="flex-1 px-2 py-1 text-xs rounded bg-indigo-600 hover:bg-indigo-500 text-white transition-colors">
Añadir
</button>
<button onclick={cancelAdd}
class="px-2 py-1 text-xs rounded border border-gray-700 text-gray-500 hover:text-gray-300 transition-colors">
✕
</button>
</div>
</div> </div>
{/if} {#if attSlugError}
</div> <p class="text-[10px] text-red-400">{attSlugError}</p>
</div> {/if}
<div class="flex gap-1">
<button onclick={confirmAdd}
class="flex-1 px-2 py-1 text-xs rounded bg-indigo-600 hover:bg-indigo-500 text-white transition-colors">
Añadir
</button>
<button onclick={cancelAdd}
class="px-2 py-1 text-xs rounded border border-gray-700 text-gray-500 hover:text-gray-300 transition-colors">
✕
</button>
</div>
</div>
{/if}
</SidebarTree>
<!-- Att detail -->
<div class="flex-1 overflow-auto"> <div class="flex-1 overflow-auto">
{#if attId} {#if attId}
<AttDetail /> <AttDetail />

@ -1,10 +1,13 @@
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte' import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte.js' import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import { labelOf } from '@/vcen-edit/lib/label.ts'
import RuleList from './RuleList.svelte' import RuleList from './RuleList.svelte'
import LingInput from './LingInput.svelte'
const editor = getContext<CatalogEditorStore>('catalog-editor') const editor = getContext<CatalogEditorStore>('catalog-editor')
const sel = $derived(editor.selection) const sel = $derived(editor.selection)
const cat = $derived(editor.cat)
const ctx = $derived(sel.kind === 'sec-info' const ctx = $derived(sel.kind === 'sec-info'
? { objId: sel.objId, secId: sel.secId, sec: editor.findSec(sel.objId, sel.secId) ?? null } ? { objId: sel.objId, secId: sel.secId, sec: editor.findSec(sel.objId, sel.secId) ?? null }
: null) : null)
@ -12,9 +15,10 @@ const ctx = $derived(sel.kind === 'sec-info'
{#if ctx?.sec} {#if ctx?.sec}
{@const { objId, secId, sec } = ctx} {@const { objId, secId, sec } = ctx}
<div class="p-6 max-w-2xl flex flex-col gap-6 overflow-y-auto h-full"> <div class="overflow-y-auto h-full">
<div class="p-6 max-w-2xl flex flex-col gap-6">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="text-sm font-semibold text-gray-200">{sec.name || 'Sin nombre'}</h2> <h2 class="text-sm font-semibold text-gray-200">{labelOf(sec.label, cat.lang) || 'Sin nombre'}</h2>
<span class="text-xs text-gray-700 font-mono">{sec.id}</span> <span class="text-xs text-gray-700 font-mono">{sec.id}</span>
</div> </div>
@ -32,13 +36,15 @@ const ctx = $derived(sel.kind === 'sec-info'
<section class="flex flex-col gap-3"> <section class="flex flex-col gap-3">
<h3 class="section-title">Identificación</h3> <h3 class="section-title">Identificación</h3>
<div class="flex flex-col gap-1"> <LingInput
<label class="text-xs text-gray-500">Nombre</label> value={sec.label}
<input type="text" value={sec.name} locales={cat.langs}
oninput={e => editor.renameSec(objId, secId, e.currentTarget.value)} defaultLocale={cat.lang}
placeholder="Suelo, Paredes..." as="input"
class="field-input" /> label="Nombre"
</div> placeholder="Suelo, Paredes..."
onUpdate={label => editor.setSecLabel(objId, secId, label as any)}
/>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label class="text-xs text-gray-500">ID (solo lectura)</label> <label class="text-xs text-gray-500">ID (solo lectura)</label>
<input type="text" value={sec.id} readonly <input type="text" value={sec.id} readonly
@ -68,4 +74,5 @@ const ctx = $derived(sel.kind === 'sec-info'
{/if} {/if}
</section> </section>
</div> </div>
</div>
{/if} {/if}

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { getContext } from 'svelte' import { getContext } from 'svelte'
import type { CatalogEditorStore } from './catalog-editor.svelte.js' import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import { labelOf } from '@/vcen-edit/lib/label.ts'
const editor = getContext<CatalogEditorStore>('catalog-editor') const editor = getContext<CatalogEditorStore>('catalog-editor')
const sel = $derived(editor.selection) const sel = $derived(editor.selection)
@ -20,7 +21,7 @@ let objNameDraft = $state('')
function startEditObj() { function startEditObj() {
if (!obj) return if (!obj) return
objNameDraft = obj.name objNameDraft = labelOf(obj.label, editor.cat.lang)
editingObjName = true editingObjName = true
} }
@ -50,7 +51,7 @@ function saveObjName() {
hover:text-gray-100 transition-colors w-full text-left" hover:text-gray-100 transition-colors w-full text-left"
title="Doble clic para renombrar" title="Doble clic para renombrar"
> >
{obj.name || 'Sin nombre'} {labelOf(obj.label, editor.cat.lang) || 'Sin nombre'}
</button> </button>
{/if} {/if}
</div> </div>
@ -81,7 +82,7 @@ function saveObjName() {
<span class="text-xs px-1 py-0.5 rounded bg-gray-800 text-gray-500 shrink-0 font-mono"> <span class="text-xs px-1 py-0.5 rounded bg-gray-800 text-gray-500 shrink-0 font-mono">
{att.value.domain.kind.slice(0, 3)} {att.value.domain.kind.slice(0, 3)}
</span> </span>
<span class="flex-1 truncate">{att.name || 'Sin nombre'}</span> <span class="flex-1 truncate">{labelOf(att.label, editor.cat.lang) || 'Sin nombre'}</span>
<button <button
onclick={e => { e.stopPropagation(); editor.removeAtt(objId, undefined, att.id) }} onclick={e => { e.stopPropagation(); editor.removeAtt(objId, undefined, att.id) }}
class="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 class="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400
@ -127,7 +128,7 @@ function saveObjName() {
: 'text-gray-400 hover:bg-gray-800 hover:text-gray-200 border-transparent'}" : 'text-gray-400 hover:bg-gray-800 hover:text-gray-200 border-transparent'}"
> >
<span class="text-xs text-gray-600 shrink-0">▤</span> <span class="text-xs text-gray-600 shrink-0">▤</span>
<span class="flex-1 truncate">{sec.name || 'Sin nombre'}</span> <span class="flex-1 truncate">{labelOf(sec.label, editor.cat.lang) || 'Sin nombre'}</span>
<span class="text-xs text-gray-600 shrink-0">{sec.atts.length}</span> <span class="text-xs text-gray-600 shrink-0">{sec.atts.length}</span>
<button <button
onclick={e => { e.stopPropagation(); editor.removeSec(objId, sec.id) }} onclick={e => { e.stopPropagation(); editor.removeSec(objId, sec.id) }}

@ -0,0 +1,45 @@
<script lang="ts">
import type { Snippet } from 'svelte'
let {
title,
count,
onAdd,
width = 'w-56',
actions,
extra,
children,
}: {
title: string
count: number
onAdd?: () => void
width?: string
actions?: Snippet
extra?: Snippet
children?: Snippet
} = $props()
</script>
<div class="{width} shrink-0 border-r border-gray-800 flex flex-col">
<div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-800 shrink-0">
<span class="text-xs font-semibold text-gray-400 uppercase tracking-wider">
{title} <span class="text-gray-600 font-normal normal-case">({count})</span>
</span>
{#if actions}
{@render actions()}
{:else if onAdd}
<button
type="button"
onclick={onAdd}
class="w-5 h-5 flex items-center justify-center rounded text-gray-500
hover:text-indigo-400 hover:bg-indigo-500/10 transition-colors"
>+</button>
{/if}
</div>
{#if extra}
{@render extra()}
{/if}
<div class="flex-1 overflow-y-auto py-1">
{@render children?.()}
</div>
</div>

@ -1,709 +0,0 @@
import type { Cat, Obj, Sec, Att, AttDomain, AttValue,
Opt, OptCategory, AttOpt,
OptID, OptCatID, RuleSet, Rule, RuleAction,
RuleID, AttState, RelativePath,
DomainValueRef, DomainValueState,
RuleLevel, CatID, ObjID, SecID, AttID } from '@/vcen/lib';
import { toAttID, toObjID, toOptID, toRuleID, toSecID } from '@/vcen/lib/node_util';
import { catOptIDPrefix } from '@/vcen/lib/types/opt';
import { buildEvalCtx, evalRuleSetState } from '@/vcen-edit/lib/rule-eval.ts';
// ─────────────────────────────────────────────────────────────────────────────
// SELECTION — nodo activo en el árbol
// ─────────────────────────────────────────────────────────────────────────────
export type SelectionKind =
| 'cat-info' | 'cat-atts' | 'opts'
| 'obj-info' | 'obj-atts'
| 'sec-info' | 'sec-atts';
export type Selection =
| { kind: 'cat-info' }
| { kind: 'cat-atts'; attId?: AttID }
| { kind: 'opts' }
| { kind: 'obj-info'; objId: ObjID }
| { kind: 'obj-atts'; objId: ObjID; attId?: AttID }
| { kind: 'sec-info'; objId: ObjID; secId: SecID }
| { kind: 'sec-atts'; objId: ObjID; secId: SecID; attId?: AttID };
// ─────────────────────────────────────────────────────────────────────────────
// ID GENERATORS
// ─────────────────────────────────────────────────────────────────────────────
let _seq = Date.now();
function nextSeq(): string { return (++_seq).toString(36); }
// ─────────────────────────────────────────────────────────────────────────────
// DEFAULT FACTORIES
// ─────────────────────────────────────────────────────────────────────────────
function defaultAttValue(): AttValue {
return { domain: { kind: 'string', mode: 'input' }, value: '' };
}
function defaultAtt(name = 'Nuevo atributo', slug?: string): Att {
const id = toAttID(slug ?? nextSeq());
return { id, name, value: defaultAttValue() };
}
function defaultSec(name = 'Nueva sección', slug?: string): Sec {
const id = toSecID(slug ?? nextSeq());
return { id, name, atts: [] };
}
function defaultObj(name = 'Nuevo objeto', slug?: string): Obj {
const id = toObjID(slug ?? nextSeq());
return { id, name, atts: [], secs: [] };
}
function defaultOpt(value = ''): Opt {
return {
id: toOptID(nextSeq()),
value: { kind: 'string', value },
};
}
function defaultOptCategory(name = 'Nueva categoría'): OptCategory {
return { id: `${catOptIDPrefix}_${nextSeq()}` as OptCatID, name };
}
function defaultRule(): Rule {
return {
id: toRuleID(nextSeq()),
priority: 10,
condition: true,
actions: [{ type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' }],
};
}
// ─────────────────────────────────────────────────────────────────────────────
// STORE
// ─────────────────────────────────────────────────────────────────────────────
/**
* Store Svelte 5 para el editor de catálogo.
*
* Gestiona el Cat en modo borrador con mutaciones granulares.
* La UI lee `cat` directamente (reactivo) y llama a las mutaciones
* para cada operación de edición.
*
* ## Uso
*
* ```svelte
* <script>
* import { createCatalogEditorStore } from './catalog-editor.svelte';
* const editor = createCatalogEditorStore(cat);
* </script>
*
* <CatalogTree {editor} />
* ```
*/
export function createCatalogEditorStore(initial: Cat) {
let cat = $state<Cat>(JSON.parse(JSON.stringify(initial)));
let selection = $state<Selection>({ kind: 'cat-info' });
let dirty = $state(false);
let rev = $state(0); // incrementa en cada mutación — fuerza reactividad global
// Estados calculados por reglas.
// Usamos $effect + $state en lugar de $derived.by para garantizar que
// el efecto se re-ejecuta ante cualquier cambio profundo de `cat`
// (incluyendo att.value.value dentro de buildEvalCtx).
let objStates = $state(new Map<ObjID, AttState | null>())
let secStates = $state(new Map<SecID, AttState | null>())
/** Valor efectivo de un atributo (mode:fixed → domain.fixed, resto → value.value). */
function attVal(att: Att): unknown {
const d = att.value.domain as any
return d?.mode === 'fixed' ? (d.fixed ?? att.value.value) : att.value.value
}
$effect(() => {
const catCtx: Record<string, unknown> = {}
for (const att of cat.atts) catCtx[att.id] = attVal(att)
const m = new Map<ObjID, AttState | null>()
for (const obj of cat.objs) {
if (!obj.rules?.rules?.length) { m.set(obj.id, null); continue }
const ctx: Record<string, any> = { cat: catCtx }
for (const a of obj.atts) ctx[a.id] = attVal(a)
for (const sec of obj.secs) {
ctx[sec.id] = {}
for (const a of sec.atts) ctx[sec.id][a.id] = attVal(a)
}
m.set(obj.id, evalRuleSetState(obj.rules, ctx))
}
objStates = m
})
$effect(() => {
const catCtx: Record<string, unknown> = {}
for (const att of cat.atts) catCtx[att.id] = attVal(att)
const m = new Map<SecID, AttState | null>()
for (const obj of cat.objs) {
for (const sec of obj.secs) {
if (!sec.rules?.rules?.length) { m.set(sec.id, null); continue }
const ctx: Record<string, any> = { cat: catCtx }
for (const a of obj.atts) ctx[a.id] = attVal(a)
ctx[sec.id] = {}
for (const a of sec.atts) ctx[sec.id][a.id] = attVal(a)
m.set(sec.id, evalRuleSetState(sec.rules, ctx))
}
}
secStates = m
})
// ── Helpers internos ────────────────────────────────────────────────────
function mutate(fn: () => void) {
fn();
dirty = true;
rev++;
}
function findObj(objId: ObjID): Obj | undefined {
return cat.objs.find(o => o.id === objId);
}
function findSec(objId: ObjID, secId: SecID): Sec | undefined {
return findObj(objId)?.secs.find(s => s.id === secId);
}
function findAtt(objId: ObjID, secId: SecID | undefined, attId: AttID): Att | undefined {
if (secId) return findSec(objId, secId)?.atts.find(a => a.id === attId);
return findObj(objId)?.atts.find(a => a.id === attId);
}
function findCatAtt(attId: AttID): Att | undefined {
return cat.atts.find(a => a.id === attId);
}
// ── Selección ───────────────────────────────────────────────────────────
function select(s: Selection) {
selection = s;
}
// ── Cat ─────────────────────────────────────────────────────────────────
function renameCat(name: string) {
mutate(() => { cat.name = name; });
}
// ── Obj ─────────────────────────────────────────────────────────────────
function addObj(name?: string, slug?: string): ObjID {
const obj = defaultObj(name, slug);
mutate(() => { cat.objs.push(obj); });
select({ kind: 'obj-info', objId: obj.id });
return obj.id;
}
function renameObj(objId: ObjID, name: string) {
mutate(() => {
const obj = findObj(objId);
if (obj) obj.name = name;
});
}
function removeObj(objId: ObjID) {
mutate(() => {
cat.objs = cat.objs.filter(o => o.id !== objId);
});
// Limpiar selección si apuntaba al obj eliminado
const s = selection;
if (
(s.kind === 'obj-info' || s.kind === 'obj-atts') && s.objId === objId ||
(s.kind === 'sec-info' || s.kind === 'sec-atts') && s.objId === objId
) {
select({ kind: 'cat-info' });
}
}
function moveObj(objId: ObjID, direction: 'up' | 'down') {
mutate(() => {
const idx = cat.objs.findIndex(o => o.id === objId);
if (idx === -1) return;
const target = direction === 'up' ? idx - 1 : idx + 1;
if (target < 0 || target >= cat.objs.length) return;
[cat.objs[idx], cat.objs[target]] = [cat.objs[target], cat.objs[idx]];
});
}
// ── Sec ─────────────────────────────────────────────────────────────────
function addSec(objId: ObjID, name?: string, slug?: string): SecID {
const sec = defaultSec(name, slug);
mutate(() => {
const obj = findObj(objId);
if (obj) obj.secs.push(sec);
});
select({ kind: 'sec-info', objId, secId: sec.id });
return sec.id;
}
function renameSec(objId: ObjID, secId: SecID, name: string) {
mutate(() => {
const sec = findSec(objId, secId);
if (sec) sec.name = name;
});
}
function removeSec(objId: ObjID, secId: SecID) {
mutate(() => {
const obj = findObj(objId);
if (obj) obj.secs = obj.secs.filter(s => s.id !== secId);
});
const s = selection;
if (
(s.kind === 'sec-info' || s.kind === 'sec-atts') && s.secId === secId
) {
select({ kind: 'obj-atts', objId });
}
}
function moveSec(objId: ObjID, secId: SecID, direction: 'up' | 'down') {
mutate(() => {
const obj = findObj(objId);
if (!obj) return;
const idx = obj.secs.findIndex(s => s.id === secId);
if (idx === -1) return;
const target = direction === 'up' ? idx - 1 : idx + 1;
if (target < 0 || target >= obj.secs.length) return;
[obj.secs[idx], obj.secs[target]] = [obj.secs[target], obj.secs[idx]];
});
}
function setSecRuleSet(objId: ObjID, secId: SecID, ruleSet: RuleSet | undefined) {
mutate(() => {
const sec = findSec(objId, secId);
if (sec) sec.rules = ruleSet;
});
}
// ── Att (en Obj o Sec) ──────────────────────────────────────────────────
function addAtt(objId: ObjID, secId: SecID | undefined, name?: string, slug?: string): AttID {
const att = defaultAtt(name, slug);
mutate(() => {
if (secId) {
const sec = findSec(objId, secId);
if (sec) sec.atts.push(att);
} else {
const obj = findObj(objId);
if (obj) obj.atts.push(att);
}
});
if (secId) {
select({ kind: 'sec-atts', objId, secId, attId: att.id });
} else {
select({ kind: 'obj-atts', objId, attId: att.id });
}
return att.id;
}
function addCatAtt(name?: string, slug?: string): AttID {
const att = defaultAtt(name, slug);
mutate(() => { cat.atts.push(att); });
select({ kind: 'cat-atts', attId: att.id });
return att.id;
}
function renameAtt(objId: ObjID, secId: SecID | undefined, attId: AttID, name: string) {
mutate(() => {
const att = findAtt(objId, secId, attId);
if (att) att.name = name;
});
}
function renameCatAtt(attId: AttID, name: string) {
mutate(() => {
const att = findCatAtt(attId);
if (att) att.name = name;
});
}
function removeAtt(objId: ObjID, secId: SecID | undefined, attId: AttID) {
mutate(() => {
if (secId) {
const sec = findSec(objId, secId);
if (sec) sec.atts = sec.atts.filter(a => a.id !== attId);
} else {
const obj = findObj(objId);
if (obj) obj.atts = obj.atts.filter(a => a.id !== attId);
}
});
const s = selection;
if (s.kind === 'obj-atts' && s.attId === attId) {
select({ kind: 'obj-atts', objId });
} else if (s.kind === 'sec-atts' && s.attId === attId) {
select({ kind: 'sec-atts', objId, secId: secId! });
}
}
function removeCatAtt(attId: AttID) {
mutate(() => {
cat.atts = cat.atts.filter(a => a.id !== attId);
});
const s = selection;
if (s.kind === 'cat-atts' && s.attId === attId) {
select({ kind: 'cat-atts' });
}
}
function moveAtt(objId: ObjID, secId: SecID | undefined, attId: AttID, direction: 'up' | 'down') {
mutate(() => {
const list = secId ? findSec(objId, secId)?.atts : findObj(objId)?.atts;
if (!list) return;
const idx = list.findIndex(a => a.id === attId);
if (idx === -1) return;
const target = direction === 'up' ? idx - 1 : idx + 1;
if (target < 0 || target >= list.length) return;
[list[idx], list[target]] = [list[target], list[idx]];
});
}
function updateAttDomain(objId: ObjID, secId: SecID | undefined, attId: AttID, domain: AttDomain) {
mutate(() => {
const att = findAtt(objId, secId, attId);
if (!att) return;
// Cambiar dominio resetea el valor al default del nuevo kind
att.value = { domain, value: defaultValueForDomain(domain) } as AttValue;
});
}
function updateCatAttDomain(attId: AttID, domain: AttDomain) {
mutate(() => {
const att = findCatAtt(attId);
if (!att) return;
att.value = { domain, value: defaultValueForDomain(domain) } as AttValue;
});
}
function updateAttDefaultValue(objId: ObjID, secId: SecID | undefined, attId: AttID, value: unknown) {
mutate(() => {
const att = findAtt(objId, secId, attId);
if (att) att.value = { ...att.value, value } as typeof att.value;
});
}
function updateCatAttDefaultValue(attId: AttID, value: unknown) {
mutate(() => {
const att = findCatAtt(attId);
if (att) att.value = { ...att.value, value } as typeof att.value;
});
}
function setAttRuleSet(objId: ObjID, secId: SecID | undefined, attId: AttID, ruleSet: RuleSet | undefined) {
mutate(() => {
const att = findAtt(objId, secId, attId);
if (att) att.rules = ruleSet;
});
}
function setCatAttRuleSet(attId: AttID, ruleSet: RuleSet | undefined) {
mutate(() => {
const att = findCatAtt(attId);
if (att) att.rules = ruleSet;
});
}
function setObjRuleSet(objId: ObjID, ruleSet: RuleSet | undefined) {
mutate(() => {
const obj = findObj(objId);
if (obj) obj.rules = ruleSet;
});
}
// ── Opts globales ───────────────────────────────────────────────────────
function addOpt(value = '', categoryId?: OptCatID): OptID {
const opt = defaultOpt(value);
if (categoryId) opt.categoryId = categoryId;
mutate(() => { cat.opts.push(opt); });
return opt.id;
}
function updateOpt(optId: OptID, patch: Partial<Pick<Opt, 'value' | 'label' | 'categoryId' | 'rules'>>) {
mutate(() => {
const opt = cat.opts.find(o => o.id === optId);
if (!opt) return;
if (patch.value !== undefined) opt.value = patch.value;
if (patch.label !== undefined) opt.label = patch.label;
if (patch.categoryId !== undefined) opt.categoryId = patch.categoryId;
if (patch.rules !== undefined) opt.rules = patch.rules;
});
}
function removeOpt(optId: OptID) {
mutate(() => {
cat.opts = cat.opts.filter(o => o.id !== optId);
// Limpiar referencias en dominios option/list de todos los atts
removeOptFromAllDomains(optId);
});
}
function removeOptFromAllDomains(optId: OptID) {
// Cat-level atts
for (const att of cat.atts) pruneAttOpt(att, optId);
// Obj/Sec atts
for (const obj of cat.objs) {
for (const att of obj.atts) pruneAttOpt(att, optId);
for (const sec of obj.secs) {
for (const att of sec.atts) pruneAttOpt(att, optId);
}
}
}
function pruneAttOpt(att: Att, optId: OptID) {
if (att.value.domain.kind === 'option' && att.value.domain.mode === 'list') {
(att.value.domain as any).list = (att.value.domain as any).list.filter(
(ao: AttOpt) => ao.optId !== optId
);
}
}
function addAttOpt(objId: ObjID, secId: SecID | undefined, attId: AttID, optId: OptID) {
mutate(() => {
const att = findAtt(objId, secId, attId);
if (!att || att.value.domain.kind !== 'option' || att.value.domain.mode !== 'list') return;
const list = (att.value.domain as any).list as AttOpt[];
if (!list.find(ao => ao.optId === optId)) {
list.push({ optId });
}
});
}
function removeAttOpt(objId: ObjID, secId: SecID | undefined, attId: AttID, optId: OptID) {
mutate(() => {
const att = findAtt(objId, secId, attId);
if (!att || att.value.domain.kind !== 'option' || att.value.domain.mode !== 'list') return;
(att.value.domain as any).list = ((att.value.domain as any).list as AttOpt[])
.filter(ao => ao.optId !== optId);
});
}
function addCatAttOpt(attId: AttID, optId: OptID) {
mutate(() => {
const att = findCatAtt(attId)
if (!att || att.value.domain.kind !== 'option' || (att.value.domain as any).mode !== 'list') return
const list = (att.value.domain as any).list as AttOpt[]
if (!list.find((ao: AttOpt) => ao.optId === optId)) list.push({ optId })
})
}
function removeCatAttOpt(attId: AttID, optId: OptID) {
mutate(() => {
const att = findCatAtt(attId)
if (!att || att.value.domain.kind !== 'option' || (att.value.domain as any).mode !== 'list') return
;(att.value.domain as any).list = ((att.value.domain as any).list as AttOpt[]).filter((ao: AttOpt) => ao.optId !== optId)
})
}
function updateAttOptLabel(objId: ObjID, secId: SecID | undefined, attId: AttID, optId: OptID, label: string) {
mutate(() => {
const att = findAtt(objId, secId, attId)
if (!att || att.value.domain.kind !== 'option' || (att.value.domain as any).mode !== 'list') return
const attOpt = ((att.value.domain as any).list as AttOpt[]).find((ao: AttOpt) => ao.optId === optId)
if (attOpt) attOpt.label = label || undefined
})
}
function updateCatAttOptLabel(attId: AttID, optId: OptID, label: string) {
mutate(() => {
const att = findCatAtt(attId)
if (!att || att.value.domain.kind !== 'option' || (att.value.domain as any).mode !== 'list') return
const attOpt = ((att.value.domain as any).list as AttOpt[]).find((ao: AttOpt) => ao.optId === optId)
if (attOpt) attOpt.label = label || undefined
})
}
// ── OptCategories ───────────────────────────────────────────────────────
function addOptCategory(name?: string): OptCatID {
const oc = defaultOptCategory(name);
mutate(() => { cat.optCategories.push(oc); });
return oc.id;
}
function renameOptCategory(ocId: OptCatID, name: string) {
mutate(() => {
const oc = cat.optCategories.find(c => c.id === ocId);
if (oc) oc.name = name;
});
}
function removeOptCategory(ocId: OptCatID) {
mutate(() => {
cat.optCategories = cat.optCategories.filter(c => c.id !== ocId);
// Opts que apuntaban a esta categoría quedan sin categoría
for (const opt of cat.opts) {
if (opt.categoryId === ocId) delete opt.categoryId;
}
});
}
// ── RuleSet helpers ─────────────────────────────────────────────────────
function addRule(ruleSet: RuleSet): RuleID {
const rule = defaultRule();
mutate(() => { ruleSet.rules.push(rule); });
return rule.id;
}
function updateRule(ruleSet: RuleSet, ruleId: RuleID, patch: Partial<Rule>) {
mutate(() => {
const rule = ruleSet.rules.find(r => r.id === ruleId);
if (!rule) return;
if (patch.condition !== undefined) rule.condition = patch.condition;
if (patch.priority !== undefined) rule.priority = patch.priority;
if ((patch as any).label !== undefined) (rule as any).label = (patch as any).label;
});
}
function removeRule(ruleSet: RuleSet, ruleId: RuleID) {
mutate(() => {
ruleSet.rules = ruleSet.rules.filter(r => r.id !== ruleId);
});
}
function addAction(ruleSet: RuleSet, ruleId: RuleID, action: RuleAction) {
mutate(() => {
const rule = ruleSet.rules.find(r => r.id === ruleId);
if (rule) rule.actions.push(action);
});
}
function updateAction(ruleSet: RuleSet, ruleId: RuleID, actionIdx: number, action: RuleAction) {
mutate(() => {
const rule = ruleSet.rules.find(r => r.id === ruleId);
if (rule && actionIdx < rule.actions.length) rule.actions[actionIdx] = action;
});
}
function removeAction(ruleSet: RuleSet, ruleId: RuleID, actionIdx: number) {
mutate(() => {
const rule = ruleSet.rules.find(r => r.id === ruleId);
if (rule) rule.actions.splice(actionIdx, 1);
});
}
// ── Slug validators ─────────────────────────────────────────────────────
function isObjSlugTaken(slug: string): boolean {
return cat.objs.some(o => o.id === toObjID(slug));
}
function isSecSlugTaken(slug: string): boolean {
return cat.objs.some(o => o.secs.some(s => s.id === toSecID(slug)));
}
function isAttSlugTaken(slug: string): boolean {
const id = toAttID(slug);
if (cat.atts.some(a => a.id === id)) return true;
return cat.objs.some(o =>
o.atts.some(a => a.id === id) ||
o.secs.some(s => s.atts.some(a => a.id === id))
);
}
// ── Persistencia ────────────────────────────────────────────────────────
/** Devuelve el Cat en estado actual. Útil para guardar. */
function getCat(): Cat {
return cat;
}
/** Marca el store como limpio (p.ej. tras guardar). */
function markClean() {
dirty = false;
}
/** Descarta todos los cambios y vuelve al Cat inicial. */
function discard() {
cat = JSON.parse(JSON.stringify(initial));
dirty = false;
selection = { kind: 'cat-info' };
}
// ── Opt activa (para OptsPanel) ──────────────────────────────────────────
let activeOptId = $state<string | null>(null);
// ── API pública ──────────────────────────────────────────────────────────
return {
// Estado reactivo
get cat() { return cat; },
get selection() { return selection; },
get dirty() { return dirty; },
get rev() { return rev; },
get objStates() { return objStates; },
get secStates() { return secStates; },
get activeOptId() { return activeOptId; },
get activeOpt() { return activeOptId ? cat.opts.find(o => o.id === activeOptId) ?? null : null; },
setActiveOpt(id: string) { activeOptId = id; },
// Selección
select,
// Cat
renameCat,
// Obj
addObj, renameObj, removeObj, moveObj,
setObjRuleSet,
// Sec
addSec, renameSec, removeSec, moveSec,
setSecRuleSet,
// Att (obj/sec level)
addAtt, addCatAtt, renameAtt, removeAtt, moveAtt,
updateAttDomain, updateAttDefaultValue, setAttRuleSet,
// Att (cat level)
renameCatAtt, removeCatAtt,
updateCatAttDomain, updateCatAttDefaultValue, setCatAttRuleSet,
// Opts globales
addOpt, updateOpt, removeOpt, addAttOpt, removeAttOpt,
addCatAttOpt, removeCatAttOpt,
updateAttOptLabel, updateCatAttOptLabel,
// OptCategories
addOptCategory, renameOptCategory, removeOptCategory,
// RuleSet helpers (operan sobre cualquier RuleSet pasado por ref)
addRule, updateRule, removeRule,
addAction, updateAction, removeAction,
// Slug validators
isObjSlugTaken, isSecSlugTaken, isAttSlugTaken,
// Persistencia
getCat, markClean, discard,
// Acceso a finders para los paneles
findObj, findSec, findAtt, findCatAtt,
};
}
export type CatalogEditorStore = ReturnType<typeof createCatalogEditorStore>;
// ─────────────────────────────────────────────────────────────────────────────
// HELPERS
// ─────────────────────────────────────────────────────────────────────────────
function defaultValueForDomain(domain: AttDomain): unknown {
switch (domain.kind) {
case 'string': return '';
case 'number': return 0;
case 'currency': return 0;
case 'boolean': return false;
case 'option': return null;
case 'date': return '';
case 'time': return '';
case 'datetime': return '';
}
}

@ -238,6 +238,8 @@ function makeFreshCat(): Cat {
optCategories: [], optCategories: [],
id: 'ct_demo' as Cat['id'], id: 'ct_demo' as Cat['id'],
name: 'Catálogo demo', name: 'Catálogo demo',
lang: 'es',
langs: ['es'],
atts: [], atts: [],
objs: [{ objs: [{
id: 'ob_demo' as Obj['id'], id: 'ob_demo' as Obj['id'],

@ -72,8 +72,9 @@ export interface ScopeEntry {
* @param catOpts - Opts globales del catálogo (Cat.opts) * @param catOpts - Opts globales del catálogo (Cat.opts)
* @param catAtts - Atts de nivel catálogo (Cat.atts) * @param catAtts - Atts de nivel catálogo (Cat.atts)
*/ */
function nameStr(name: unknown, fallback: string): string { function nameStr(label: { es?: string; [k: string]: string | undefined } | unknown, fallback: string): string {
return typeof name === 'string' ? (name || fallback) : fallback; if (label && typeof label === 'object' && 'es' in label) return (label as any).es || fallback;
return typeof label === 'string' ? (label || fallback) : fallback;
} }
export function buildScope(container: Obj | Sec, catOpts: Opt[] = [], catAtts: Att[] = []): ScopeEntry[] { export function buildScope(container: Obj | Sec, catOpts: Opt[] = [], catAtts: Att[] = []): ScopeEntry[] {
@ -83,7 +84,7 @@ export function buildScope(container: Obj | Sec, catOpts: Opt[] = [], catAtts: A
for (const att of catAtts) { for (const att of catAtts) {
entries.push({ entries.push({
path: `cat/${att.id}`, path: `cat/${att.id}`,
label: `Catálogo / ${nameStr(att.name, att.id)}`, label: `Catálogo / ${nameStr(att.label, att.id)}`,
att, att,
domain: att.value.domain, domain: att.value.domain,
resolvedOpts: resolveOpts(att.value.domain, catOpts), resolvedOpts: resolveOpts(att.value.domain, catOpts),
@ -94,7 +95,7 @@ export function buildScope(container: Obj | Sec, catOpts: Opt[] = [], catAtts: A
for (const att of atts) { for (const att of atts) {
entries.push({ entries.push({
path: att.id, path: att.id,
label: nameStr(att.name, att.id), label: nameStr(att.label, att.id),
att, att,
domain: att.value.domain, domain: att.value.domain,
resolvedOpts: resolveOpts(att.value.domain, catOpts), resolvedOpts: resolveOpts(att.value.domain, catOpts),
@ -103,11 +104,11 @@ export function buildScope(container: Obj | Sec, catOpts: Opt[] = [], catAtts: A
if ('secs' in container) { if ('secs' in container) {
for (const sec of container.secs) { for (const sec of container.secs) {
const secLabel = nameStr(sec.name, sec.id); const secLabel = nameStr(sec.label, sec.id);
for (const att of sec.atts) { for (const att of sec.atts) {
entries.push({ entries.push({
path: `${sec.id}/${att.id}`, path: `${sec.id}/${att.id}`,
label: `${secLabel} / ${nameStr(att.name, att.id)}`, label: `${secLabel} / ${nameStr(att.label, att.id)}`,
att, att,
domain: att.value.domain, domain: att.value.domain,
resolvedOpts: resolveOpts(att.value.domain, catOpts), resolvedOpts: resolveOpts(att.value.domain, catOpts),

@ -0,0 +1,21 @@
import { createGlobState } from '@/glob/adapters/svelte'
import { createLing } from '@/ling/lib'
import { EDITOR_TRANSLATIONS } from './editor-translations.ts'
import type { GlobLocale } from '@/glob/lib'
/**
* Crea una instancia reactiva de glob para el editor de catálogos.
*
* @param locales Lista de locales que soporta el catálogo (para campos multilingüe).
* Por defecto `['es']`.
*/
export function createEditorGlob(locales: GlobLocale[] = ['es']) {
const ling = createLing(EDITOR_TRANSLATIONS, 'es')
return createGlobState({
ling,
locale : 'es',
locales,
})
}
export type EditorGlob = ReturnType<typeof createEditorGlob>

@ -0,0 +1,105 @@
import type { LingNode } from '@/ling/lib'
/**
* Traducciones del editor de catálogos (vcen-edit).
* Estructuradas como árbol anidado compatible con createLing().
*/
export const EDITOR_TRANSLATIONS = {
vcen: {
editor: {
mtas: {
header: {
title: { es: 'Metadatos' },
addField: { es: 'Añadir campo' },
addFieldTitle:{ es: 'Añadir campo' },
fieldCount: (p: { count: number }) => ({ es: `${p.count} campo${p.count !== 1 ? 's' : ''}` }),
},
field: {
empty: { es: 'Sin campos' },
emptyGroup: { es: 'Grupo vacío' },
fieldValid: { es: 'Válido' },
fieldInvalid: { es: 'Inválido' },
hidden: { es: 'Oculto' },
edit: { es: 'Editar' },
delete: { es: 'Eliminar' },
deleteConfirm: { es: '¿Eliminar campo?' },
deleteConfirmMsg: { es: 'Esta acción no se puede deshacer.' },
deleteYes: { es: 'Eliminar' },
deleteNo: { es: 'Cancelar' },
moveUp: { es: 'Subir' },
moveDown: { es: 'Bajar' },
boolTrue: { es: 'Sí' },
boolFalse: { es: 'No' },
nestedCount: (p: { count: number }) => ({ es: `${p.count} campo${p.count !== 1 ? 's' : ''}` }),
},
tree: {
selectHint: { es: 'Selecciona un campo para editarlo' },
},
drawer: {
title: { es: 'Nuevo campo' },
confirm: { es: 'Añadir' },
cancel: { es: 'Cancelar' },
},
types: {
string: { es: 'Texto' },
number: { es: 'Número' },
date: { es: 'Fecha' },
datetime: { es: 'Fecha y hora' },
email: { es: 'Email' },
url: { es: 'URL' },
slug: { es: 'Slug' },
},
editor: {
keyLabel: { es: 'Clave' },
keyPlaceholder: { es: 'nombre_clave' },
keyEmpty: { es: 'La clave no puede estar vacía' },
keyExists: { es: 'Esta clave ya existe' },
hideField: { es: 'Ocultar campo' },
attributes: { es: 'Atributos' },
valueHtml: { es: 'HTML' },
valueLing: { es: 'Texto multilingual' },
markdownLabel: { es: 'Markdown' },
amountLabel: { es: 'Importe' },
currencyLabel: { es: 'Moneda' },
imgWidth: { es: 'Ancho' },
imgHeight: { es: 'Alto' },
imgFormat: { es: 'Formato' },
mediaUrlLabel: { es: 'URL del medio' },
vidDuration: { es: 'Duración' },
vidPoster: { es: 'Miniatura' },
audDuration: { es: 'Duración' },
audBitrate: { es: 'Bitrate' },
audChannels: { es: 'Canales' },
coordLat: { es: 'Latitud' },
coordLng: { es: 'Longitud' },
coordZoom: { es: 'Zoom' },
phoneCode: { es: 'Código' },
phoneNumber: { es: 'Número' },
colorHex: { es: 'Hex' },
pdfTitle: { es: 'Título' },
pdfAuthor: { es: 'Autor' },
pdfPages: { es: 'Páginas' },
selectOptions: { es: 'Opciones' },
selectValue: { es: 'Valor' },
selectNoOpts: { es: 'Sin opciones' },
selectAddOpt: { es: '+ Opción' },
selectOptLabel: { es: 'Etiqueta' },
selectOptValue: { es: 'Valor' },
},
validation: {
valid: { es: 'Válido' },
errorCount: (p: { count: number }) => ({ es: `${p.count} error${p.count !== 1 ? 'es' : ''}` }),
errorsTitle: { es: 'Errores de validación' },
},
pagination: {
all: { es: 'Todos' },
rowsPerPage:{ es: 'Filas por página' },
range: (p: { from: number; to: number; total: number }) => ({ es: `${p.from}–${p.to} de ${p.total}` }),
pageN: (p: { page: number }) => ({ es: `Página ${p.page}` }),
prevPage: { es: 'Página anterior' },
nextPage: { es: 'Página siguiente' },
},
},
},
},
} satisfies LingNode

@ -0,0 +1,9 @@
import type { LingRecord } from '@/ling/lib'
/**
* Resuelve un LingRecord al string del idioma solicitado.
* Fallback: es → fallback string.
*/
export function labelOf(label: LingRecord, lang: string = 'es', fallback = ''): string {
return label[lang as keyof LingRecord] ?? label.es ?? fallback
}

@ -1,31 +1,41 @@
import type { Cat, Obj, Sec , Att, AttDomain, AttValue, import type { Cat, Obj, Sec, Att, AttDomain, AttValue,
Opt, OptCategory, AttOpt, Opt, OptCategory, AttOpt,
OptID, OptCatID , RuleSet, Rule, RuleAction, OptID, OptCatID, RuleSet, Rule, RuleAction,
RuleID, AttState, RelativePath, RuleID, AttState, RelativePath,
DomainValueRef, DomainValueState, DomainValueRef, DomainValueState,
RuleLevel , CatID, ObjID, SecID, AttID } from '@/vcen/lib'; RuleLevel, CatID, ObjID, SecID, AttID } from '@/vcen/lib';
import type { LingRecord } from '@/ling/lib';
import { toAttID, toObjID, toOptID, toRuleID, toSecID } from '@/vcen/lib/node_util';
import { catOptIDPrefix } from '@/vcen/lib/types/opt';
import { buildEvalCtx, evalRuleSetState } from '@/vcen-edit/lib/rule-eval.ts';
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// SELECTION — nodo activo en el árbol // SELECTION — nodo activo en el árbol
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
export type SelectionKind = 'cat' | 'obj' | 'sec' | 'att' | 'opts'; export type SelectionKind =
| 'cat-info' | 'cat-atts' | 'cat-meta' | 'opts'
| 'obj-info' | 'obj-atts' | 'obj-meta'
| 'sec-info' | 'sec-atts' | 'sec-meta';
export type Selection = export type Selection =
| { kind: 'cat' } | { kind: 'cat-info' }
| { kind: 'opts' } // panel Opts/OptCategories | { kind: 'cat-atts'; attId?: AttID }
| { kind: 'obj'; objId: ObjID } | { kind: 'cat-meta' }
| { kind: 'sec'; objId: ObjID; secId: SecID } | { kind: 'opts' }
| { kind: 'att'; objId: ObjID; secId?: SecID; attId: AttID }; | { kind: 'obj-info'; objId: ObjID }
| { kind: 'obj-atts'; objId: ObjID; attId?: AttID }
| { kind: 'obj-meta'; objId: ObjID }
| { kind: 'sec-info'; objId: ObjID; secId: SecID }
| { kind: 'sec-atts'; objId: ObjID; secId: SecID; attId?: AttID }
| { kind: 'sec-meta'; objId: ObjID; secId: SecID };
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// ID GENERATORS // ID GENERATORS
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
let _seq = Date.now(); let _seq = Date.now();
function nextId<T extends string>(prefix: string): T { function nextSeq(): string { return (++_seq).toString(36); }
return `${prefix}:${(++_seq).toString(36)}` as T;
}
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// DEFAULT FACTORIES // DEFAULT FACTORIES
@ -35,32 +45,35 @@ function defaultAttValue(): AttValue {
return { domain: { kind: 'string', mode: 'input' }, value: '' }; return { domain: { kind: 'string', mode: 'input' }, value: '' };
} }
function defaultAtt(name = 'Nuevo atributo'): Att { function defaultAtt(name = 'Nuevo atributo', slug?: string): Att {
return { id: nextId<AttID>('at'), name, value: defaultAttValue() }; const id = toAttID(slug ?? nextSeq());
return { id, label: { es: name }, value: defaultAttValue() };
} }
function defaultSec(name = 'Nueva sección'): Sec { function defaultSec(name = 'Nueva sección', slug?: string): Sec {
return { id: nextId<SecID>('sc'), name, atts: [] }; const id = toSecID(slug ?? nextSeq());
return { id, label: { es: name }, atts: [] };
} }
function defaultObj(name = 'Nuevo objeto'): Obj { function defaultObj(name = 'Nuevo objeto', slug?: string): Obj {
return { id: nextId<ObjID>('ob'), name, atts: [], secs: [] }; const id = toObjID(slug ?? nextSeq());
return { id, label: { es: name }, atts: [], secs: [] };
} }
function defaultOpt(value = ''): Opt { function defaultOpt(value = ''): Opt {
return { return {
id: nextId<OptID>('op'), id: toOptID(nextSeq()),
value: { kind: 'string', value }, value: { kind: 'string', value },
}; };
} }
function defaultOptCategory(name = 'Nueva categoría'): OptCategory { function defaultOptCategory(name = 'Nueva categoría'): OptCategory {
return { id: nextId<OptCatID>('oc'), name }; return { id: `${catOptIDPrefix}_${nextSeq()}` as OptCatID, name };
} }
function defaultRule(): Rule { function defaultRule(): Rule {
return { return {
id: nextId<RuleID>('rl'), id: toRuleID(nextSeq()),
priority: 10, priority: 10,
condition: true, condition: true,
actions: [{ type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' }], actions: [{ type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' }],
@ -90,15 +103,66 @@ function defaultRule(): Rule {
* ``` * ```
*/ */
export function createCatalogEditorStore(initial: Cat) { export function createCatalogEditorStore(initial: Cat) {
let cat = $state<Cat>(structuredClone(initial)); let cat = $state<Cat>(JSON.parse(JSON.stringify(initial)));
let selection = $state<Selection>({ kind: 'cat' }); let selection = $state<Selection>({ kind: 'cat-info' });
let dirty = $state(false); let dirty = $state(false);
let rev = $state(0); // incrementa en cada mutación — fuerza reactividad global
// Estados calculados por reglas.
// Usamos $effect + $state en lugar de $derived.by para garantizar que
// el efecto se re-ejecuta ante cualquier cambio profundo de `cat`
// (incluyendo att.value.value dentro de buildEvalCtx).
let objStates = $state(new Map<ObjID, AttState | null>())
let secStates = $state(new Map<SecID, AttState | null>())
/** Valor efectivo de un atributo (mode:fixed → domain.fixed, resto → value.value). */
function attVal(att: Att): unknown {
const d = att.value.domain as any
return d?.mode === 'fixed' ? (d.fixed ?? att.value.value) : att.value.value
}
$effect(() => {
const catCtx: Record<string, unknown> = {}
for (const att of cat.atts) catCtx[att.id] = attVal(att)
const m = new Map<ObjID, AttState | null>()
for (const obj of cat.objs) {
if (!obj.rules?.rules?.length) { m.set(obj.id, null); continue }
const ctx: Record<string, any> = { cat: catCtx }
for (const a of obj.atts) ctx[a.id] = attVal(a)
for (const sec of obj.secs) {
ctx[sec.id] = {}
for (const a of sec.atts) ctx[sec.id][a.id] = attVal(a)
}
m.set(obj.id, evalRuleSetState(obj.rules, ctx))
}
objStates = m
})
$effect(() => {
const catCtx: Record<string, unknown> = {}
for (const att of cat.atts) catCtx[att.id] = attVal(att)
const m = new Map<SecID, AttState | null>()
for (const obj of cat.objs) {
for (const sec of obj.secs) {
if (!sec.rules?.rules?.length) { m.set(sec.id, null); continue }
const ctx: Record<string, any> = { cat: catCtx }
for (const a of obj.atts) ctx[a.id] = attVal(a)
ctx[sec.id] = {}
for (const a of sec.atts) ctx[sec.id][a.id] = attVal(a)
m.set(sec.id, evalRuleSetState(sec.rules, ctx))
}
}
secStates = m
})
// ── Helpers internos ──────────────────────────────────────────────────── // ── Helpers internos ────────────────────────────────────────────────────
function mutate(fn: () => void) { function mutate(fn: () => void) {
fn(); fn();
dirty = true; dirty = true;
rev++;
} }
function findObj(objId: ObjID): Obj | undefined { function findObj(objId: ObjID): Obj | undefined {
@ -130,19 +194,66 @@ export function createCatalogEditorStore(initial: Cat) {
mutate(() => { cat.name = name; }); mutate(() => { cat.name = name; });
} }
function setCatLang(lang: string) {
mutate(() => {
cat.lang = lang;
if (!cat.langs.includes(lang)) cat.langs = [lang, ...cat.langs];
});
}
function setCatLangs(langs: string[]) {
mutate(() => {
// lang principal siempre primero y siempre presente
const next = langs.includes(cat.lang) ? langs : [cat.lang, ...langs];
cat.langs = next as [string, ...string[]];
});
}
function updateCatMeta(meta: import('@/mtas/lib').Metadata | undefined) {
mutate(() => { (cat as any).metadata = meta })
}
// ── Obj ───────────────────────────────────────────────────────────────── // ── Obj ─────────────────────────────────────────────────────────────────
function addObj(name?: string): ObjID { function addObj(name?: string, slug?: string): ObjID {
const obj = defaultObj(name); const obj = defaultObj(name, slug);
mutate(() => { cat.objs.push(obj); }); mutate(() => { cat.objs.push(obj); });
select({ kind: 'obj', objId: obj.id }); select({ kind: 'obj-info', objId: obj.id });
return obj.id; return obj.id;
} }
function renameObj(objId: ObjID, name: string) { function renameObj(objId: ObjID, name: string) {
mutate(() => { mutate(() => {
const obj = findObj(objId); const obj = findObj(objId);
if (obj) obj.name = name; if (obj) obj.label = { ...obj.label, [cat.lang]: name };
});
}
function setObjLabel(objId: ObjID, label: LingRecord) {
mutate(() => {
const obj = findObj(objId);
if (obj) obj.label = label;
});
}
function setSecLabel(objId: ObjID, secId: SecID, label: LingRecord) {
mutate(() => {
const sec = findSec(objId, secId);
if (sec) sec.label = label;
});
}
function setAttLabel(objId: ObjID, secId: SecID | undefined, attId: AttID, label: LingRecord) {
mutate(() => {
const att = findAtt(objId, secId, attId);
if (att) att.label = label;
});
}
function setCatAttLabel(attId: AttID, label: LingRecord) {
mutate(() => {
const att = findCatAtt(attId);
if (att) att.label = label;
}); });
} }
@ -151,8 +262,13 @@ export function createCatalogEditorStore(initial: Cat) {
cat.objs = cat.objs.filter(o => o.id !== objId); cat.objs = cat.objs.filter(o => o.id !== objId);
}); });
// Limpiar selección si apuntaba al obj eliminado // Limpiar selección si apuntaba al obj eliminado
if (selection.kind === 'obj' && selection.objId === objId) select({ kind: 'cat' }); const s = selection;
if ((selection.kind === 'sec' || selection.kind === 'att') && selection.objId === objId) select({ kind: 'cat' }); if (
(s.kind === 'obj-info' || s.kind === 'obj-atts') && s.objId === objId ||
(s.kind === 'sec-info' || s.kind === 'sec-atts') && s.objId === objId
) {
select({ kind: 'cat-info' });
}
} }
function moveObj(objId: ObjID, direction: 'up' | 'down') { function moveObj(objId: ObjID, direction: 'up' | 'down') {
@ -165,22 +281,29 @@ export function createCatalogEditorStore(initial: Cat) {
}); });
} }
function updateObjMeta(objId: ObjID, meta: import('@/mtas/lib').Metadata | undefined) {
mutate(() => {
const obj = findObj(objId)
if (obj) (obj as any).metadata = meta
})
}
// ── Sec ───────────────────────────────────────────────────────────────── // ── Sec ─────────────────────────────────────────────────────────────────
function addSec(objId: ObjID, name?: string): SecID { function addSec(objId: ObjID, name?: string, slug?: string): SecID {
const sec = defaultSec(name); const sec = defaultSec(name, slug);
mutate(() => { mutate(() => {
const obj = findObj(objId); const obj = findObj(objId);
if (obj) obj.secs.push(sec); if (obj) obj.secs.push(sec);
}); });
select({ kind: 'sec', objId, secId: sec.id }); select({ kind: 'sec-info', objId, secId: sec.id });
return sec.id; return sec.id;
} }
function renameSec(objId: ObjID, secId: SecID, name: string) { function renameSec(objId: ObjID, secId: SecID, name: string) {
mutate(() => { mutate(() => {
const sec = findSec(objId, secId); const sec = findSec(objId, secId);
if (sec) sec.name = name; if (sec) sec.label = { ...sec.label, [cat.lang]: name };
}); });
} }
@ -189,8 +312,12 @@ export function createCatalogEditorStore(initial: Cat) {
const obj = findObj(objId); const obj = findObj(objId);
if (obj) obj.secs = obj.secs.filter(s => s.id !== secId); if (obj) obj.secs = obj.secs.filter(s => s.id !== secId);
}); });
if (selection.kind === 'sec' && selection.secId === secId) select({ kind: 'obj', objId }); const s = selection;
if (selection.kind === 'att' && selection.secId === secId) select({ kind: 'obj', objId }); if (
(s.kind === 'sec-info' || s.kind === 'sec-atts') && s.secId === secId
) {
select({ kind: 'obj-atts', objId });
}
} }
function moveSec(objId: ObjID, secId: SecID, direction: 'up' | 'down') { function moveSec(objId: ObjID, secId: SecID, direction: 'up' | 'down') {
@ -212,10 +339,17 @@ export function createCatalogEditorStore(initial: Cat) {
}); });
} }
function updateSecMeta(objId: ObjID, secId: SecID, meta: import('@/mtas/lib').Metadata | undefined) {
mutate(() => {
const sec = findSec(objId, secId)
if (sec) (sec as any).metadata = meta
})
}
// ── Att (en Obj o Sec) ────────────────────────────────────────────────── // ── Att (en Obj o Sec) ──────────────────────────────────────────────────
function addAtt(objId: ObjID, secId: SecID | undefined, name?: string): AttID { function addAtt(objId: ObjID, secId: SecID | undefined, name?: string, slug?: string): AttID {
const att = defaultAtt(name); const att = defaultAtt(name, slug);
mutate(() => { mutate(() => {
if (secId) { if (secId) {
const sec = findSec(objId, secId); const sec = findSec(objId, secId);
@ -225,21 +359,32 @@ export function createCatalogEditorStore(initial: Cat) {
if (obj) obj.atts.push(att); if (obj) obj.atts.push(att);
} }
}); });
select({ kind: 'att', objId, secId, attId: att.id }); if (secId) {
select({ kind: 'sec-atts', objId, secId, attId: att.id });
} else {
select({ kind: 'obj-atts', objId, attId: att.id });
}
return att.id; return att.id;
} }
function addCatAtt(name?: string): AttID { function addCatAtt(name?: string, slug?: string): AttID {
const att = defaultAtt(name); const att = defaultAtt(name, slug);
mutate(() => { cat.atts.push(att); }); mutate(() => { cat.atts.push(att); });
select({ kind: 'att', objId: '' as ObjID, secId: undefined, attId: att.id }); select({ kind: 'cat-atts', attId: att.id });
return att.id; return att.id;
} }
function renameAtt(objId: ObjID, secId: SecID | undefined, attId: AttID, name: string) { function renameAtt(objId: ObjID, secId: SecID | undefined, attId: AttID, name: string) {
mutate(() => { mutate(() => {
const att = findAtt(objId, secId, attId); const att = findAtt(objId, secId, attId);
if (att) att.name = name; if (att) att.label = { ...att.label, [cat.lang]: name };
});
}
function renameCatAtt(attId: AttID, name: string) {
mutate(() => {
const att = findCatAtt(attId);
if (att) att.label = { ...att.label, [cat.lang]: name };
}); });
} }
@ -253,8 +398,21 @@ export function createCatalogEditorStore(initial: Cat) {
if (obj) obj.atts = obj.atts.filter(a => a.id !== attId); if (obj) obj.atts = obj.atts.filter(a => a.id !== attId);
} }
}); });
if (selection.kind === 'att' && selection.attId === attId) { const s = selection;
select(secId ? { kind: 'sec', objId, secId } : { kind: 'obj', objId }); if (s.kind === 'obj-atts' && s.attId === attId) {
select({ kind: 'obj-atts', objId });
} else if (s.kind === 'sec-atts' && s.attId === attId) {
select({ kind: 'sec-atts', objId, secId: secId! });
}
}
function removeCatAtt(attId: AttID) {
mutate(() => {
cat.atts = cat.atts.filter(a => a.id !== attId);
});
const s = selection;
if (s.kind === 'cat-atts' && s.attId === attId) {
select({ kind: 'cat-atts' });
} }
} }
@ -279,6 +437,14 @@ export function createCatalogEditorStore(initial: Cat) {
}); });
} }
function updateCatAttDomain(attId: AttID, domain: AttDomain) {
mutate(() => {
const att = findCatAtt(attId);
if (!att) return;
att.value = { domain, value: defaultValueForDomain(domain) } as AttValue;
});
}
function updateAttDefaultValue(objId: ObjID, secId: SecID | undefined, attId: AttID, value: unknown) { function updateAttDefaultValue(objId: ObjID, secId: SecID | undefined, attId: AttID, value: unknown) {
mutate(() => { mutate(() => {
const att = findAtt(objId, secId, attId); const att = findAtt(objId, secId, attId);
@ -286,6 +452,27 @@ export function createCatalogEditorStore(initial: Cat) {
}); });
} }
function updateCatAttDefaultValue(attId: AttID, value: unknown) {
mutate(() => {
const att = findCatAtt(attId);
if (att) att.value = { ...att.value, value } as typeof att.value;
});
}
function updateAttMeta(objId: ObjID, secId: SecID | undefined, attId: AttID, meta: import('@/mtas/lib').Metadata | undefined) {
mutate(() => {
const att = findAtt(objId, secId, attId)
if (att) (att as any).metadata = meta
})
}
function updateCatAttMeta(attId: AttID, meta: import('@/mtas/lib').Metadata | undefined) {
mutate(() => {
const att = findCatAtt(attId)
if (att) (att as any).metadata = meta
})
}
function setAttRuleSet(objId: ObjID, secId: SecID | undefined, attId: AttID, ruleSet: RuleSet | undefined) { function setAttRuleSet(objId: ObjID, secId: SecID | undefined, attId: AttID, ruleSet: RuleSet | undefined) {
mutate(() => { mutate(() => {
const att = findAtt(objId, secId, attId); const att = findAtt(objId, secId, attId);
@ -293,6 +480,13 @@ export function createCatalogEditorStore(initial: Cat) {
}); });
} }
function setCatAttRuleSet(attId: AttID, ruleSet: RuleSet | undefined) {
mutate(() => {
const att = findCatAtt(attId);
if (att) att.rules = ruleSet;
});
}
function setObjRuleSet(objId: ObjID, ruleSet: RuleSet | undefined) { function setObjRuleSet(objId: ObjID, ruleSet: RuleSet | undefined) {
mutate(() => { mutate(() => {
const obj = findObj(objId); const obj = findObj(objId);
@ -368,6 +562,41 @@ export function createCatalogEditorStore(initial: Cat) {
}); });
} }
function addCatAttOpt(attId: AttID, optId: OptID) {
mutate(() => {
const att = findCatAtt(attId)
if (!att || att.value.domain.kind !== 'option' || (att.value.domain as any).mode !== 'list') return
const list = (att.value.domain as any).list as AttOpt[]
if (!list.find((ao: AttOpt) => ao.optId === optId)) list.push({ optId })
})
}
function removeCatAttOpt(attId: AttID, optId: OptID) {
mutate(() => {
const att = findCatAtt(attId)
if (!att || att.value.domain.kind !== 'option' || (att.value.domain as any).mode !== 'list') return
;(att.value.domain as any).list = ((att.value.domain as any).list as AttOpt[]).filter((ao: AttOpt) => ao.optId !== optId)
})
}
function updateAttOptLabel(objId: ObjID, secId: SecID | undefined, attId: AttID, optId: OptID, label: string) {
mutate(() => {
const att = findAtt(objId, secId, attId)
if (!att || att.value.domain.kind !== 'option' || (att.value.domain as any).mode !== 'list') return
const attOpt = ((att.value.domain as any).list as AttOpt[]).find((ao: AttOpt) => ao.optId === optId)
if (attOpt) attOpt.label = label || undefined
})
}
function updateCatAttOptLabel(attId: AttID, optId: OptID, label: string) {
mutate(() => {
const att = findCatAtt(attId)
if (!att || att.value.domain.kind !== 'option' || (att.value.domain as any).mode !== 'list') return
const attOpt = ((att.value.domain as any).list as AttOpt[]).find((ao: AttOpt) => ao.optId === optId)
if (attOpt) attOpt.label = label || undefined
})
}
// ── OptCategories ─────────────────────────────────────────────────────── // ── OptCategories ───────────────────────────────────────────────────────
function addOptCategory(name?: string): OptCatID { function addOptCategory(name?: string): OptCatID {
@ -438,6 +667,25 @@ export function createCatalogEditorStore(initial: Cat) {
}); });
} }
// ── Slug validators ─────────────────────────────────────────────────────
function isObjSlugTaken(slug: string): boolean {
return cat.objs.some(o => o.id === toObjID(slug));
}
function isSecSlugTaken(slug: string): boolean {
return cat.objs.some(o => o.secs.some(s => s.id === toSecID(slug)));
}
function isAttSlugTaken(slug: string): boolean {
const id = toAttID(slug);
if (cat.atts.some(a => a.id === id)) return true;
return cat.objs.some(o =>
o.atts.some(a => a.id === id) ||
o.secs.some(s => s.atts.some(a => a.id === id))
);
}
// ── Persistencia ──────────────────────────────────────────────────────── // ── Persistencia ────────────────────────────────────────────────────────
/** Devuelve el Cat en estado actual. Útil para guardar. */ /** Devuelve el Cat en estado actual. Útil para guardar. */
@ -452,12 +700,12 @@ export function createCatalogEditorStore(initial: Cat) {
/** Descarta todos los cambios y vuelve al Cat inicial. */ /** Descarta todos los cambios y vuelve al Cat inicial. */
function discard() { function discard() {
cat = structuredClone(initial); cat = JSON.parse(JSON.stringify(initial));
dirty = false; dirty = false;
selection = { kind: 'cat' }; selection = { kind: 'cat-info' };
} }
// ── Opt activa (para OptsPanel / OptDetail) ────────────────────────────── // ── Opt activa (para OptsPanel) ──────────────────────────────────────────
let activeOptId = $state<string | null>(null); let activeOptId = $state<string | null>(null);
@ -465,33 +713,44 @@ export function createCatalogEditorStore(initial: Cat) {
return { return {
// Estado reactivo // Estado reactivo
get cat() { return cat; }, get cat() { return cat; },
get selection() { return selection; }, get selection() { return selection; },
get dirty() { return dirty; }, get dirty() { return dirty; },
get activeOptId() { return activeOptId; }, get rev() { return rev; },
get activeOpt() { return activeOptId ? cat.opts.find(o => o.id === activeOptId) ?? null : null; }, get objStates() { return objStates; },
get secStates() { return secStates; },
get activeOptId() { return activeOptId; },
get activeOpt() { return activeOptId ? cat.opts.find(o => o.id === activeOptId) ?? null : null; },
setActiveOpt(id: string) { activeOptId = id; }, setActiveOpt(id: string) { activeOptId = id; },
// Selección // Selección
select, select,
// Cat // Cat
renameCat, renameCat, setCatLang, setCatLangs, updateCatMeta,
// Obj // Obj
addObj, renameObj, removeObj, moveObj, addObj, renameObj, setObjLabel, removeObj, moveObj,
setObjRuleSet, setObjRuleSet, updateObjMeta,
// Sec // Sec
addSec, renameSec, removeSec, moveSec, addSec, renameSec, setSecLabel, removeSec, moveSec,
setSecRuleSet, setSecRuleSet, updateSecMeta,
// Att // Att (obj/sec level)
addAtt, addCatAtt, renameAtt, removeAtt, moveAtt, addAtt, addCatAtt, renameAtt, setAttLabel, removeAtt, moveAtt,
updateAttDomain, updateAttDefaultValue, setAttRuleSet, updateAttDomain, updateAttDefaultValue, setAttRuleSet,
updateAttMeta,
// Att (cat level)
renameCatAtt, setCatAttLabel, removeCatAtt,
updateCatAttDomain, updateCatAttDefaultValue, setCatAttRuleSet,
updateCatAttMeta,
// Opts globales // Opts globales
addOpt, updateOpt, removeOpt, addAttOpt, removeAttOpt, addOpt, updateOpt, removeOpt, addAttOpt, removeAttOpt,
addCatAttOpt, removeCatAttOpt,
updateAttOptLabel, updateCatAttOptLabel,
// OptCategories // OptCategories
addOptCategory, renameOptCategory, removeOptCategory, addOptCategory, renameOptCategory, removeOptCategory,
@ -500,6 +759,9 @@ export function createCatalogEditorStore(initial: Cat) {
addRule, updateRule, removeRule, addRule, updateRule, removeRule,
addAction, updateAction, removeAction, addAction, updateAction, removeAction,
// Slug validators
isObjSlugTaken, isSecSlugTaken, isAttSlugTaken,
// Persistencia // Persistencia
getCat, markClean, discard, getCat, markClean, discard,

@ -14,7 +14,7 @@ function objAtt(slug: string, value: unknown) {
} }
function emptyCat(atts: ReturnType<typeof catAtt>[] = []): Cat { function emptyCat(atts: ReturnType<typeof catAtt>[] = []): Cat {
return { id: toObjID('test') as any, name: 'Test', atts, objs: [], opts: [], optCategories: [] } return { id: toObjID('test') as any, name: 'Test', lang: 'es', langs: ['es'], atts, objs: [], opts: [], optCategories: [] }
} }
function emptyObj(atts: ReturnType<typeof objAtt>[] = [], secs: any[] = []): Obj { function emptyObj(atts: ReturnType<typeof objAtt>[] = [], secs: any[] = []): Obj {

@ -1,8 +1,8 @@
import type {ID, ISODate, ISODatetime, ISOTime} from "@/tsat/lib/types"; import type {ID, ISODate, ISODatetime, ISOTime} from "@/tsat/lib/types";
import type {RuleSet} from "@/vcen/lib/types/rule.ts"; import type {RuleSet} from "@/vcen/lib/types/rule.ts";
import type {LingString} from "@/ling/lib"; import type {LingRecord} from "@/ling/lib";
import type {Metadata, MetadataOverride} from "@/vcen/lib/types/metadata.ts"; import type { MetadataOverride} from "@/vcen/lib/types/metadata.ts";
import type {CatID} from "@/vcen/lib/types/cat.ts"; import type {CatID} from "@/vcen/lib/types/cat.ts";
import type {ObjID} from "@/vcen/lib/types/obj.ts"; import type {ObjID} from "@/vcen/lib/types/obj.ts";
import type {SecID} from "@/vcen/lib/types/sec.ts"; import type {SecID} from "@/vcen/lib/types/sec.ts";
@ -10,6 +10,7 @@ import type {OptID} from "@/vcen/lib/types/opt.ts";
import type {Pricing} from "@/vcen/lib/types/pricing.ts"; import type {Pricing} from "@/vcen/lib/types/pricing.ts";
import type {Warn} from "@/vcen/lib/types/warn.ts"; import type {Warn} from "@/vcen/lib/types/warn.ts";
import type {JsonLogicValue} from "@/jslg/lib"; import type {JsonLogicValue} from "@/jslg/lib";
import type {Metadata} from "@/mtas/lib";
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@ -364,8 +365,8 @@ export interface AttDisplay {
export interface Att { export interface Att {
id: AttID; id: AttID;
/** Nombre localizado del atributo */ /** Etiqueta localizada del atributo */
name: LingString; label: LingRecord;
/** Valor actual del atributo y definición de su dominio */ /** Valor actual del atributo y definición de su dominio */
value: AttValue; value: AttValue;

@ -3,7 +3,8 @@
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
import type {ID} from "@/tsat/lib"; import type {ID} from "@/tsat/lib";
import type {Att,Obj,Opt, OptCategory,Metadata, PricingDefaults, VisualDefaults, Warn} from "./index.ts"; import type {Att,Obj,Opt, OptCategory,PricingDefaults, VisualDefaults, Warn} from "./index.ts";
import type {Metadata} from "@/mtas/lib";
@ -49,6 +50,19 @@ export interface Cat {
id: CatID; id: CatID;
name: string; name: string;
/**
* Idioma principal del catálogo (BCP 47, ej: 'es', 'en', 'fr').
* Es siempre el primer elemento de `langs`.
*/
lang: string;
/**
* Lista de idiomas que soporta el catálogo (BCP 47).
* Debe incluir siempre `lang` como mínimo.
* Usada para campos multilingüe en atributos, opciones y metadatos.
*/
langs: [string, ...string[]];
/** /**
* Valores por defecto de pricing para todo el catálogo. * Valores por defecto de pricing para todo el catálogo.
* Todos los nodos heredan currency, vat y vatIncluded de aquí * Todos los nodos heredan currency, vat y vatIncluded de aquí

@ -6,7 +6,9 @@ import type {ID} from "@/tsat/lib";
import type {Att} from "@/vcen/lib/types/att.ts"; import type {Att} from "@/vcen/lib/types/att.ts";
import type {Sec} from "@/vcen/lib/types/sec.ts"; import type {Sec} from "@/vcen/lib/types/sec.ts";
import type {RuleSet} from "@/vcen/lib/types/rule.ts"; import type {RuleSet} from "@/vcen/lib/types/rule.ts";
import type {Metadata, Pricing, Visual, Warn} from "@/vcen/lib"; import type {Pricing, Visual, Warn} from "@/vcen/lib";
import type {LingRecord} from "@/ling/lib";
import type {Metadata} from "@/mtas/lib";
@ -53,7 +55,7 @@ export interface Obj {
id: ObjID; id: ObjID;
/** Nombre localizado del objeto */ /** Nombre localizado del objeto */
name: string; label: LingRecord;
/** Atributos directos del objeto (fuera de cualquier sección) */ /** Atributos directos del objeto (fuera de cualquier sección) */
atts: Att[]; atts: Att[];

@ -1,5 +1,5 @@
import type {Metadata} from "@/vcen/lib/types/metadata.ts"; import type {Metadata} from "@/mtas/lib";
import type {LingString} from "@/ling/lib"; import type {LingRecord } from "@/ling/lib";
import type { RuleSet} from "@/vcen/lib/types/rule.ts"; import type { RuleSet} from "@/vcen/lib/types/rule.ts";
import type {ID} from "@/tsat/lib"; import type {ID} from "@/tsat/lib";
import type {Warn} from "@/vcen/lib/types/warn.ts"; import type {Warn} from "@/vcen/lib/types/warn.ts";
@ -84,7 +84,7 @@ export interface Opt {
value: OptValue; value: OptValue;
/** Etiqueta localizada para mostrar al usuario */ /** Etiqueta localizada para mostrar al usuario */
label?: LingString; label?: LingRecord;
/** valor para usarse en las configuraciones visuales */ /** valor para usarse en las configuraciones visuales */

@ -4,7 +4,9 @@
import type {Att} from "@/vcen/lib/types/att.ts"; import type {Att} from "@/vcen/lib/types/att.ts";
import type {RuleSet} from "@/vcen/lib/types/rule.ts"; import type {RuleSet} from "@/vcen/lib/types/rule.ts";
import type {ID} from "@/tsat/lib"; import type {ID} from "@/tsat/lib";
import type {Metadata, Pricing, Visual, Warn} from "@/vcen/lib"; import type { Pricing, Visual, Warn} from "@/vcen/lib";
import type {LingRecord} from "@/ling/lib";
import type {Metadata} from "@/mtas/lib";
@ -47,8 +49,8 @@ export type SecID = ID<typeof secIDPrefix>;
export interface Sec { export interface Sec {
id: SecID; id: SecID;
/** Nombre localizado de la sección */ /** Etiqueta localizada de la sección */
name: string; label: LingRecord;
/** Atributos directos de esta sección */ /** Atributos directos de esta sección */
atts: Att[]; atts: Att[];

@ -1,8 +1,9 @@
// view.ts // view.ts
import type {ID} from "@/tsat/lib"; import type {ID} from "@/tsat/lib";
import type {AttID, Metadata} from "@/vcen/lib"; import type {AttID } from "@/vcen/lib";
import type {LingString} from "@/ling/lib"; import type {LingRecord } from "@/ling/lib";
import type {JsonLogicExpression, JsonLogicValue} from "@/jslg/lib"; import type { JsonLogicValue} from "@/jslg/lib";
import type {Metadata} from "@/mtas/lib";
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// IDENTITY // IDENTITY
@ -294,13 +295,13 @@ export type HotspotMode = 'tooltip' | 'popup';
*/ */
export interface Hotspot { export interface Hotspot {
id: HotspotID; id: HotspotID;
name: LingString; label: LingRecord;
mode : HotspotMode; mode : HotspotMode;
/** Att que se activa/focaliza al interactuar con el hotspot */ /** Att que se activa/focaliza al interactuar con el hotspot */
targetAtt?: AttID; targetAtt?: AttID;
/** Si false o condición que evalúa false, el hotspot no se muestra */ /** Si false o condición que evalúa false, el hotspot no se muestra */
visible?: boolean | JsonLogicValue; visible?: boolean | JsonLogicValue;
tooltip?: LingString; tooltip?: LingRecord;
metadata?: Metadata; metadata?: Metadata;
/** Posición X — absoluta (px) o relativa ('22%') */ /** Posición X — absoluta (px) o relativa ('22%') */
posX: number | string; posX: number | string;
@ -324,8 +325,8 @@ export interface Hotspot {
* El usuario puede cambiar entre ellas en la UI. * El usuario puede cambiar entre ellas en la UI.
*/ */
export interface View { export interface View {
id: ViewID; id : ViewID;
name: LingString; label: LingRecord;
/** /**
* Código corto de la vista para referenciarla desde reglas u otras vistas. * Código corto de la vista para referenciarla desde reglas u otras vistas.
* @example 'front' | 'detail-wheel' | 'interior' * @example 'front' | 'detail-wheel' | 'interior'

Loading…
Cancel
Save

Powered by TurnKey Linux.