- 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
parent
6657372c96
commit
25e099dcbf
@ -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}
|
||||
@ -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 '';
|
||||
}
|
||||
}
|
||||
@ -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,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
|
||||
}
|
||||
Loading…
Reference in new issue