- 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