master
parent
d0a8e30368
commit
cb74f27c5a
@ -1,15 +1,15 @@
|
||||
<script lang="ts">
|
||||
import './app.css';
|
||||
import GlobDemo from './components/GlobDemo.svelte';
|
||||
import EditorLayout from "@/vice/editor/layouts/EditorLayout.svelte";
|
||||
import ViceEditor from "@/vice/editor/comps/ViceEditor.svelte";
|
||||
// import GlobDemo from './components/GlobDemo.svelte';
|
||||
|
||||
</script>
|
||||
|
||||
<main>
|
||||
|
||||
<GlobDemo />
|
||||
<ViceEditor />
|
||||
|
||||
</main>
|
||||
|
||||
|
||||
<style global>
|
||||
main { font-family: sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem; }
|
||||
</style>
|
||||
@ -0,0 +1,138 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// VICE · Svelte Adapter
|
||||
//
|
||||
// Envuelve ViceEngine con $state de Svelte 5.
|
||||
// Proporciona un store reactivo para el configurador.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { createViceEngine } from '@/vice/libs/./engine.ts'
|
||||
import type {
|
||||
ViceSession,
|
||||
Collection,
|
||||
EntityID,
|
||||
OptionID,
|
||||
EntityPathKey,
|
||||
LoadResult,
|
||||
SessionState,
|
||||
SelectResult,
|
||||
DeselectResult,
|
||||
PropagationResult,
|
||||
DesignIssue
|
||||
} from '@/vice/libs'
|
||||
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// VICE STORE
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function createViceStore(collection: Collection, entityId: EntityID) {
|
||||
|
||||
const engine = createViceEngine()
|
||||
const loadResult = engine.load(collection, entityId)
|
||||
|
||||
if (!loadResult.valid) {
|
||||
const errors = loadResult.issues
|
||||
.filter((i : DesignIssue) => i.severity === 'error')
|
||||
.map((i: DesignIssue) => i.message)
|
||||
.join('\n')
|
||||
throw new Error(`[VICE] Cannot create store — graph has errors:\n${errors}`)
|
||||
}
|
||||
|
||||
const session: ViceSession = engine.createSession()
|
||||
|
||||
// ── Reactive state ─────────────────────────────────────────────────────────
|
||||
|
||||
let state = $state<SessionState>(session.getState())
|
||||
let lastResult = $state<PropagationResult | null>(null)
|
||||
let lastError = $state<SelectResult | DeselectResult | null>(null)
|
||||
|
||||
// ── Actions ────────────────────────────────────────────────────────────────
|
||||
|
||||
function select(path: EntityPathKey, optionId: OptionID, quantity?: number): SelectResult {
|
||||
const result = session.select(path, optionId, quantity)
|
||||
if (result.ok) {
|
||||
state = result.result.state
|
||||
lastResult = result.result
|
||||
lastError = null
|
||||
} else {
|
||||
lastError = result
|
||||
lastResult = null
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function deselect(path: EntityPathKey): DeselectResult {
|
||||
const result = session.deselect(path)
|
||||
if (result.ok) {
|
||||
state = result.result.state
|
||||
lastResult = result.result
|
||||
lastError = null
|
||||
} else {
|
||||
lastError = result
|
||||
lastResult = null
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function reset(): SessionState {
|
||||
const s = session.reset()
|
||||
state = s
|
||||
lastResult = null
|
||||
lastError = null
|
||||
return s
|
||||
}
|
||||
|
||||
// ── Derived helpers ────────────────────────────────────────────────────────
|
||||
|
||||
const consistent = $derived(state.consistent)
|
||||
const hasViolations = $derived(
|
||||
[...state.attributes.values()].some(a => a.violations.length > 0)
|
||||
)
|
||||
const violations = $derived(
|
||||
[...state.attributes.values()].flatMap(a =>
|
||||
a.violations.map(v => ({ ...v, path: a.path, attrId: a.attrId }))
|
||||
)
|
||||
)
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
return {
|
||||
// Load info
|
||||
loadResult: loadResult as LoadResult,
|
||||
|
||||
// Reactive state
|
||||
get state() { return state },
|
||||
get consistent() { return consistent },
|
||||
get hasViolations() { return hasViolations },
|
||||
get violations() { return violations },
|
||||
get lastResult() { return lastResult },
|
||||
get lastError() { return lastError },
|
||||
|
||||
// Actions
|
||||
select,
|
||||
deselect,
|
||||
reset,
|
||||
|
||||
// Convenience accessors
|
||||
getAttribute(path: EntityPathKey) {
|
||||
return state.attributes.get(path) ?? null
|
||||
},
|
||||
getGroup(path: EntityPathKey) {
|
||||
return state.groups.get(path) ?? null
|
||||
},
|
||||
isActive(path: EntityPathKey): boolean {
|
||||
return state.attributes.get(path)?.active
|
||||
?? state.groups.get(path)?.active
|
||||
?? false
|
||||
},
|
||||
currentOption(path: EntityPathKey): OptionID | null {
|
||||
return state.attributes.get(path)?.current?.optionId ?? null
|
||||
},
|
||||
domain(path: EntityPathKey): ReadonlySet<OptionID> {
|
||||
return state.attributes.get(path)?.domain ?? new Set()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type ViceStore = ReturnType<typeof createViceStore>
|
||||
@ -0,0 +1,223 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// VICE · ViceStore Tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { fixture } from '@/vice/libs//fixture.ts'
|
||||
import { createViceStore } from './ViceStore.svelte.ts'
|
||||
import type { ViceStore } from './ViceStore.svelte.ts'
|
||||
|
||||
let vice: ViceStore
|
||||
|
||||
beforeEach(() => {
|
||||
vice = createViceStore(fixture, 'en_bmw430')
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Inicialización
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('inicialización', () => {
|
||||
it('DEBUG — violaciones tras seleccionar op_coupe', () => {
|
||||
vice.select('en_bmw430/at_version', 'op_coupe' as any)
|
||||
console.log('consistent:', vice.consistent)
|
||||
console.log('violations:', vice.violations)
|
||||
console.log('state attributes:')
|
||||
for (const [path, attr] of vice.state.attributes) {
|
||||
if (attr.violations.length > 0 || !attr.active) {
|
||||
console.log(' ', path, '| active:', attr.active, '| domain:', [...attr.domain], '| violations:', attr.violations)
|
||||
}
|
||||
}
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
it('loadResult.valid = true para fixture válido', () => {
|
||||
expect(vice.loadResult.valid).toBe(true)
|
||||
})
|
||||
|
||||
it('lanza si el catálogo tiene errores de diseño', () => {
|
||||
const bad = {
|
||||
...fixture,
|
||||
entities: {
|
||||
'en_bad': {
|
||||
id: 'en_bad' as any,
|
||||
code: 'bad',
|
||||
name: { es: 'Bad' },
|
||||
description: { es: '' },
|
||||
attributes: [{
|
||||
id: 'at_x' as any,
|
||||
code: 'x',
|
||||
type: 'required' as const,
|
||||
name: { es: 'X' },
|
||||
description: { es: '' },
|
||||
display: {},
|
||||
default: 'op_inexistente' as any,
|
||||
options: [{ optionId: 'op_blanco' as any }],
|
||||
}],
|
||||
groups: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
expect(() => createViceStore(bad as any, 'en_bad' as any)).toThrow('[VICE]')
|
||||
})
|
||||
|
||||
it('estado inicial es consistente', () => {
|
||||
expect(vice.consistent).toBe(true)
|
||||
})
|
||||
|
||||
it('estado inicial sin violaciones', () => {
|
||||
expect(vice.hasViolations).toBe(false)
|
||||
})
|
||||
|
||||
it('at_version empieza con default op_berlina', () => {
|
||||
expect(vice.currentOption('en_bmw430/at_version')).toBe('op_berlina')
|
||||
})
|
||||
|
||||
it('gr_extras empieza inactivo', () => {
|
||||
expect(vice.isActive('en_bmw430/gr_extras')).toBe(false)
|
||||
})
|
||||
|
||||
it('gr_motor empieza activo', () => {
|
||||
expect(vice.isActive('en_bmw430/gr_motor')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// select
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('select', () => {
|
||||
|
||||
it('devuelve ok=true para selección válida', () => {
|
||||
const result = vice.select('en_bmw430/at_version', 'op_coupe' as any)
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('actualiza state tras selección', () => {
|
||||
vice.select('en_bmw430/at_version', 'op_coupe' as any)
|
||||
expect(vice.currentOption('en_bmw430/at_version')).toBe('op_coupe')
|
||||
})
|
||||
|
||||
it('propaga — seleccionar coupe activa gr_extras', () => {
|
||||
vice.select('en_bmw430/at_version', 'op_coupe' as any)
|
||||
expect(vice.isActive('en_bmw430/gr_extras')).toBe(true)
|
||||
})
|
||||
|
||||
it('propaga — seleccionar electrico restringe potencia', () => {
|
||||
vice.select('en_bmw430/gr_motor/at_combustible', 'op_electrico' as any)
|
||||
const domain = vice.domain('en_bmw430/gr_motor/at_potencia')
|
||||
expect(domain.has('op_150cv' as any)).toBe(false)
|
||||
expect(domain.has('op_190cv' as any)).toBe(false)
|
||||
})
|
||||
|
||||
it('devuelve ok=false con INVALID_PATH', () => {
|
||||
const result = vice.select('en_bmw430/at_inexistente', 'op_x' as any)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toBe('INVALID_PATH')
|
||||
})
|
||||
|
||||
it('devuelve ok=false con INACTIVE_ATTRIBUTE', () => {
|
||||
const result = vice.select('en_bmw430/gr_tecnologia/at_sonido', 'op_harman' as any)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toBe('INACTIVE_ATTRIBUTE')
|
||||
})
|
||||
|
||||
it('devuelve ok=false con OPTION_NOT_IN_DOMAIN', () => {
|
||||
vice.select('en_bmw430/gr_motor/at_combustible', 'op_electrico' as any)
|
||||
const result = vice.select('en_bmw430/gr_motor/at_potencia', 'op_150cv' as any)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toBe('OPTION_NOT_IN_DOMAIN')
|
||||
})
|
||||
|
||||
it('guarda lastError tras selección fallida', () => {
|
||||
vice.select('en_bmw430/at_inexistente', 'op_x' as any)
|
||||
expect(vice.lastError).not.toBeNull()
|
||||
expect(vice.lastError?.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('limpia lastError tras selección exitosa', () => {
|
||||
vice.select('en_bmw430/at_inexistente', 'op_x' as any)
|
||||
vice.select('en_bmw430/at_version', 'op_coupe' as any)
|
||||
expect(vice.lastError).toBeNull()
|
||||
})
|
||||
|
||||
it('guarda lastResult tras selección exitosa', () => {
|
||||
vice.select('en_bmw430/at_version', 'op_coupe' as any)
|
||||
expect(vice.lastResult).not.toBeNull()
|
||||
expect(vice.lastResult?.consistent).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// deselect
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('deselect', () => {
|
||||
|
||||
it('devuelve ok=false con INVALID_PATH', () => {
|
||||
const result = vice.deselect('en_bmw430/at_inexistente')
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.reason).toBe('INVALID_PATH')
|
||||
})
|
||||
|
||||
it('pone current = null tras deselect', () => {
|
||||
vice.deselect('en_bmw430/at_version')
|
||||
expect(vice.currentOption('en_bmw430/at_version')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// reset
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('reset', () => {
|
||||
|
||||
it('vuelve al estado inicial', () => {
|
||||
vice.select('en_bmw430/at_version', 'op_coupe' as any)
|
||||
vice.select('en_bmw430/gr_motor/at_combustible', 'op_electrico' as any)
|
||||
vice.reset()
|
||||
|
||||
expect(vice.currentOption('en_bmw430/at_version')).toBe('op_berlina')
|
||||
expect(vice.currentOption('en_bmw430/gr_motor/at_combustible')).toBe('op_gasolina')
|
||||
expect(vice.isActive('en_bmw430/gr_extras')).toBe(false)
|
||||
})
|
||||
|
||||
it('limpia lastResult y lastError tras reset', () => {
|
||||
vice.select('en_bmw430/at_version', 'op_coupe' as any)
|
||||
vice.reset()
|
||||
expect(vice.lastResult).toBeNull()
|
||||
expect(vice.lastError).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('helpers', () => {
|
||||
|
||||
it('getAttribute devuelve el estado del atributo', () => {
|
||||
const attr = vice.getAttribute('en_bmw430/at_version')
|
||||
expect(attr).not.toBeNull()
|
||||
expect(attr?.attrId).toBe('at_version')
|
||||
})
|
||||
|
||||
it('getAttribute devuelve null para path inexistente', () => {
|
||||
expect(vice.getAttribute('en_bmw430/at_inexistente')).toBeNull()
|
||||
})
|
||||
|
||||
it('getGroup devuelve el estado del grupo', () => {
|
||||
const group = vice.getGroup('en_bmw430/gr_motor')
|
||||
expect(group).not.toBeNull()
|
||||
expect(group?.groupId).toBe('gr_motor')
|
||||
})
|
||||
|
||||
it('domain devuelve set vacío para path inexistente', () => {
|
||||
expect(vice.domain('en_bmw430/at_inexistente').size).toBe(0)
|
||||
})
|
||||
|
||||
it('violations agrupa todas las violaciones con path', () => {
|
||||
expect(Array.isArray(vice.violations)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@ -0,0 +1,233 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import { ling } from '@/ling'
|
||||
import type { EditorStore } from '../stores/editor.svelte.ts'
|
||||
|
||||
const editor = getContext<EditorStore>('editor')
|
||||
|
||||
const col = $derived(editor.collection)
|
||||
const name = $derived(col.name as any)
|
||||
const desc = $derived((col.description ?? {}) as any)
|
||||
|
||||
const ALL_LANGS: SupportedLocale[] = ['es', 'en', 'de', 'fr', 'it', 'pt', 'ca', 'eu', 'gl']
|
||||
|
||||
function patch(key: string, value: unknown) {
|
||||
editor.updateCollectionMeta({ [key]: value } as any)
|
||||
}
|
||||
|
||||
function patchI18n(field: 'name' | 'description', lang: SupportedLocale, value: string) {
|
||||
const current = col[field] as any
|
||||
editor.updateCollectionMeta({ [field]: { ...current, [lang]: value } } as any)
|
||||
}
|
||||
|
||||
function toggleLang(lang: SupportedLocale) {
|
||||
const current = col.langs
|
||||
if (lang === 'es') return // es es obligatorio
|
||||
const next = current.includes(lang)
|
||||
? current.filter(l => l !== lang)
|
||||
: [...current, lang]
|
||||
patch('langs', next)
|
||||
}
|
||||
|
||||
// Stats
|
||||
const stats = $derived({
|
||||
entities: Object.keys(col.entities).length,
|
||||
groups: Object.values(col.entities).reduce((acc, e) => acc + Object.keys(e.groups).length, 0),
|
||||
attributes: Object.values(col.entities).reduce((acc, e) =>
|
||||
acc + e.attributes.length + Object.values(e.groups).reduce((a, g) => a + g.attributes.length, 0), 0
|
||||
),
|
||||
options: Object.keys(col.options).length,
|
||||
})
|
||||
|
||||
// Import JSON
|
||||
let importText = $state('')
|
||||
let importError = $state('')
|
||||
let showImport = $state(false)
|
||||
|
||||
function handleImport() {
|
||||
try {
|
||||
editor.importFromJson(importText)
|
||||
showImport = false
|
||||
importText = ''
|
||||
importError = ''
|
||||
} catch {
|
||||
importError = 'JSON inválido'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6 max-w-2xl flex flex-col gap-8">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-base font-semibold text-gray-200">Configuración de la colección</h2>
|
||||
<span class="text-xs text-gray-600 font-mono">{col.id}</span>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-4 gap-3">
|
||||
{#each [
|
||||
{ label: 'Entidades', value: stats.entities },
|
||||
{ label: 'Grupos', value: stats.groups },
|
||||
{ label: 'Atributos', value: stats.attributes },
|
||||
{ label: 'Opciones', value: stats.options },
|
||||
] as stat}
|
||||
<div class="flex flex-col items-center gap-1 p-3 rounded-lg bg-gray-900 border border-gray-800">
|
||||
<span class="text-xl font-bold text-indigo-400">{stat.value}</span>
|
||||
<span class="text-xs text-gray-600">{stat.label}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Identificación -->
|
||||
<section class="flex flex-col gap-3">
|
||||
<h3 class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Identificación</h3>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-gray-500">
|
||||
Código
|
||||
<span class="text-gray-600 font-normal ml-1">— identificador único sin espacios</span>
|
||||
{#if !col.code}<span class="text-red-400 ml-1">*</span>{/if}
|
||||
</label>
|
||||
<input type="text" value={col.code}
|
||||
oninput={(e) => patch('code', e.currentTarget.value)}
|
||||
placeholder="viviendas_vice"
|
||||
class="field-input font-mono text-xs" />
|
||||
</div>
|
||||
|
||||
<!-- Nombre por idioma -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-gray-500">
|
||||
Nombre
|
||||
{#if !ling.ts(col.name)}<span class="text-red-400 ml-1">*</span>{/if}
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{#each col.langs as lang}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-600 w-5 shrink-0">{lang}</span>
|
||||
<input type="text" value={name[lang] ?? ''}
|
||||
oninput={(e) => patchI18n('name', lang, e.currentTarget.value)}
|
||||
placeholder={lang === 'es' ? 'Viviendas VICE' : ''}
|
||||
class="field-input flex-1" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Descripción por idioma -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-gray-500">Descripción</label>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each col.langs as lang}
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-xs text-gray-600 w-5 shrink-0 pt-2">{lang}</span>
|
||||
<textarea
|
||||
oninput={(e) => patchI18n('description', lang, e.currentTarget.value)}
|
||||
rows="2" class="field-input resize-none flex-1"
|
||||
>{desc[lang] ?? ''}</textarea>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Idiomas -->
|
||||
<section class="flex flex-col gap-3">
|
||||
<h3 class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Idiomas soportados</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each ALL_LANGS as lang}
|
||||
{@const active = col.langs.includes(lang)}
|
||||
{@const required = lang === 'es'}
|
||||
<button
|
||||
onclick={() => toggleLang(lang)}
|
||||
disabled={required}
|
||||
class="px-3 py-1 text-xs rounded border transition-colors
|
||||
{active
|
||||
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
|
||||
: 'border-gray-700 text-gray-500 hover:border-gray-600 hover:text-gray-300'}
|
||||
{required ? 'opacity-60 cursor-not-allowed' : ''}"
|
||||
title={required ? 'Español es obligatorio' : ''}
|
||||
>
|
||||
{lang}
|
||||
{#if required}<span class="ml-1 text-gray-600">*</span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs text-gray-700">* Español es obligatorio y no puede desactivarse</p>
|
||||
</section>
|
||||
|
||||
<!-- Base Path -->
|
||||
<section class="flex flex-col gap-3">
|
||||
<h3 class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Rutas de imagen</h3>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-gray-500">
|
||||
Base Path
|
||||
<span class="text-gray-600 font-normal ml-1">— ruta raíz para templates de imagen</span>
|
||||
</label>
|
||||
<input type="text" value={col.basePath ?? ''}
|
||||
oninput={(e) => patch('basePath', e.currentTarget.value)}
|
||||
placeholder="/renders/"
|
||||
class="field-input font-mono text-xs" />
|
||||
</div>
|
||||
<div class="rounded-md bg-gray-900/50 border border-gray-800 p-3 text-xs text-gray-500">
|
||||
Los templates de imagen se resuelven como
|
||||
<code class="text-indigo-400 ml-1">basePath + template</code>.
|
||||
Cada grupo puede sobreescribir este valor.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Errores de validación -->
|
||||
{#if editor.validation.errors.filter(e => e.path.startsWith('collection')).length > 0}
|
||||
<section class="flex flex-col gap-2">
|
||||
<h3 class="text-xs font-semibold text-red-400 uppercase tracking-wider">Errores</h3>
|
||||
{#each editor.validation.errors.filter(e => e.path.startsWith('collection')) as error}
|
||||
<div class="flex flex-col gap-0.5 p-2 rounded bg-red-500/10 border border-red-500/20">
|
||||
<p class="text-xs text-red-300">{error.message}</p>
|
||||
<p class="text-xs text-red-400/50 font-mono">{error.path}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Importar / Resetear -->
|
||||
<section class="flex flex-col gap-3">
|
||||
<h3 class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Importar / Resetear</h3>
|
||||
<div class="flex gap-2">
|
||||
<button onclick={() => showImport = !showImport}
|
||||
class="text-xs px-3 py-1.5 rounded border border-gray-700 text-gray-400
|
||||
hover:text-indigo-400 hover:border-indigo-700 transition-colors">
|
||||
Importar JSON
|
||||
</button>
|
||||
<button
|
||||
onclick={() => { if (confirm('¿Resetear la colección? Se perderán todos los cambios.')) editor.reset() }}
|
||||
class="text-xs px-3 py-1.5 rounded border border-gray-700 text-gray-500
|
||||
hover:text-red-400 hover:border-red-800 transition-colors">
|
||||
Resetear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showImport}
|
||||
<div class="flex flex-col gap-2">
|
||||
<textarea bind:value={importText}
|
||||
placeholder="Pega aquí el JSON de la colección..."
|
||||
rows="8" class="field-input resize-none font-mono text-xs"></textarea>
|
||||
{#if importError}
|
||||
<p class="text-xs text-red-400">{importError}</p>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
<button onclick={handleImport} disabled={!importText.trim()}
|
||||
class="text-xs px-3 py-1.5 rounded bg-indigo-600 hover:bg-indigo-500
|
||||
text-white transition-colors disabled:opacity-40">
|
||||
Importar
|
||||
</button>
|
||||
<button onclick={() => { showImport = false; importText = ''; importError = '' }}
|
||||
class="text-xs px-3 py-1.5 rounded border border-gray-700 text-gray-400
|
||||
hover:text-gray-200 transition-colors">
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
</div>
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import type { EditorStore, EditorPanel } from '../stores/editor.svelte.ts'
|
||||
|
||||
const editor = getContext<EditorStore>('editor')
|
||||
|
||||
const nav: Array<{ panel: EditorPanel; label: string; icon: string }> = [
|
||||
{ panel: 'catalog', label: 'Catálogo', icon: '⬡' },
|
||||
{ panel: 'objects', label: 'Objetos', icon: '▣' },
|
||||
{ panel: 'options', label: 'Opciones', icon: '◈' },
|
||||
{ panel: 'rules', label: 'Reglas', icon: '⚖' },
|
||||
{ panel: 'preview', label: 'Preview', icon: '◉' },
|
||||
{ panel: 'export', label: 'Exportar', icon: '⬡' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<nav class="w-48 shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col py-4 gap-1">
|
||||
{#each nav as item}
|
||||
<button
|
||||
onclick={() => editor.setPanel(item.panel)}
|
||||
class="flex items-center gap-3 px-4 py-2.5 text-sm text-left transition-colors
|
||||
{editor.activePanel === item.panel
|
||||
? 'bg-indigo-600/20 text-indigo-300 border-r-2 border-indigo-500'
|
||||
: 'text-gray-400 hover:text-gray-200 hover:bg-gray-800'}"
|
||||
>
|
||||
<span class="text-base">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
|
||||
<!-- Badge errores -->
|
||||
{#if editor.validation.errors.some(e => e.path.startsWith(item.panel))}
|
||||
<span class="ml-auto w-1.5 h-1.5 rounded-full bg-red-400"></span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import { ling } from '@/ling'
|
||||
import type { EditorStore } from '../stores/editor.svelte.ts'
|
||||
|
||||
const editor = getContext<EditorStore>('editor')
|
||||
|
||||
|
||||
console.log(editor.validation)
|
||||
console.log('isDirty ',editor.isDirty);
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<header class="flex items-center justify-between px-6 py-3 bg-gray-900 border-b border-gray-800 shrink-0">
|
||||
|
||||
<!-- Logo + nombre colección -->
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-bold tracking-widest text-indigo-400 uppercase">VICE</span>
|
||||
<span class="text-gray-600">|</span>
|
||||
<span class="text-sm text-gray-300">
|
||||
{ling.ts(editor.collection.name) || 'Nueva colección'}
|
||||
</span>
|
||||
{#if editor.isDirty}
|
||||
<span class="text-xs text-amber-400">● Sin guardar</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Acciones -->
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
<!-- Validación -->
|
||||
{#if !editor.validation.valid}
|
||||
<span class="flex items-center gap-1 text-xs text-red-400">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-red-400 inline-block"></span>
|
||||
{editor.validation.errors.length} error{editor.validation.errors.length !== 1 ? 'es' : ''}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="flex items-center gap-1 text-xs text-emerald-400">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400 inline-block"></span>
|
||||
Válido
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Preview -->
|
||||
<button
|
||||
onclick={() => editor.setPanel('preview')}
|
||||
disabled={!editor.canPreview}
|
||||
class="px-3 py-1.5 text-xs rounded-md border border-gray-700 text-gray-300
|
||||
hover:bg-gray-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Vista previa
|
||||
</button>
|
||||
|
||||
<!-- Export -->
|
||||
<button
|
||||
onclick={() => editor.setPanel('export')}
|
||||
class="px-3 py-1.5 text-xs rounded-md bg-indigo-600 hover:bg-indigo-500
|
||||
text-white font-medium transition-colors"
|
||||
>
|
||||
Exportar JSON
|
||||
</button>
|
||||
|
||||
<!-- Reset -->
|
||||
<button
|
||||
onclick={() => { if (confirm('¿Resetear el editor? Se perderán todos los cambios.')) editor.reset(); }}
|
||||
class="px-3 py-1.5 text-xs rounded-md border border-gray-700 text-gray-500
|
||||
hover:text-red-400 hover:border-red-800 transition-colors"
|
||||
>
|
||||
Nuevo
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
@ -0,0 +1,138 @@
|
||||
<script lang="ts">
|
||||
import { setContext } from 'svelte'
|
||||
import { createEditorStore } from '../stores/editor.svelte.ts'
|
||||
import type { EditorStore } from '../stores/editor.svelte.ts'
|
||||
import type { Collection } from '@/vice/libs/types'
|
||||
import EditorLayout from '../layouts/EditorLayout.svelte'
|
||||
|
||||
let { initial }: { initial?: Collection } = $props()
|
||||
|
||||
const editor = createEditorStore(initial)
|
||||
setContext<EditorStore>('editor', editor)
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<EditorLayout />
|
||||
|
||||
<style>
|
||||
.editor {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background: #0a0f1a;
|
||||
color: #e2e8f0;
|
||||
font-family: 'IBM Plex Sans', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
/* Nav */
|
||||
.editor-nav {
|
||||
width: 11rem;
|
||||
shrink: 0;
|
||||
border-right: 1px solid #1e293b;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #080d17;
|
||||
}
|
||||
|
||||
.editor-nav-header {
|
||||
padding: 1.25rem 1rem 1rem;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.editor-logo {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.editor-logo-sub {
|
||||
font-size: 0.6875rem;
|
||||
color: #334155;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.editor-tabs {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.5rem 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.editor-tab {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #475569;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: color 120ms, background 120ms;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
|
||||
.editor-tab:hover {
|
||||
color: #94a3b8;
|
||||
background: rgb(255 255 255 / 0.02);
|
||||
}
|
||||
|
||||
.editor-tab-active {
|
||||
color: #a5b4fc;
|
||||
border-left-color: #6366f1;
|
||||
background: rgb(99 102 241 / 0.08);
|
||||
}
|
||||
|
||||
.editor-tab-error {
|
||||
font-size: 0.625rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 9999px;
|
||||
background: rgb(239 68 68 / 0.2);
|
||||
color: #fca5a5;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.editor-nav-footer {
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid #1e293b;
|
||||
}
|
||||
|
||||
.editor-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.editor-status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
shrink: 0;
|
||||
}
|
||||
|
||||
.editor-status-error { color: #f87171; }
|
||||
.editor-status-error .editor-status-dot { background: #ef4444; }
|
||||
|
||||
.editor-status-warn { color: #fbbf24; }
|
||||
.editor-status-warn .editor-status-dot { background: #f59e0b; }
|
||||
|
||||
.editor-status-ok { color: #4ade80; }
|
||||
.editor-status-ok .editor-status-dot { background: #22c55e; }
|
||||
|
||||
/* Main */
|
||||
.editor-main {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import type { EditorStore } from '../stores/editor.svelte.ts'
|
||||
import TopBar from '../comps/TopBar.svelte'
|
||||
import Sidebar from '../comps/Sidebar.svelte'
|
||||
import CollectionPanel from "@/vice/editor/comps/CollectionPanel.svelte";
|
||||
|
||||
const editor = getContext<EditorStore>('editor')
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-screen bg-gray-950 text-gray-100 overflow-hidden">
|
||||
|
||||
<TopBar />
|
||||
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
|
||||
<Sidebar />
|
||||
|
||||
<main class="flex-1 overflow-auto">
|
||||
{#if editor.activePanel === 'catalog'}
|
||||
<CollectionPanel />
|
||||
<div class="p-8 text-gray-600 text-sm">CatalogPanel — en construcción</div>
|
||||
{:else if editor.activePanel === 'objects'}
|
||||
<!-- <ObjectsPanel /> -->
|
||||
<div class="p-8 text-gray-600 text-sm">ObjectsPanel — en construcción</div>
|
||||
{:else if editor.activePanel === 'options'}
|
||||
<!-- <OptionsPanel /> -->
|
||||
<div class="p-8 text-gray-600 text-sm">OptionsPanel — en construcción</div>
|
||||
{:else if editor.activePanel === 'rules'}
|
||||
<!-- <RulesPanel /> -->
|
||||
<div class="p-8 text-gray-600 text-sm">RulesPanel — en construcción</div>
|
||||
{:else if editor.activePanel === 'preview'}
|
||||
<!-- <PreviewPanel /> -->
|
||||
<div class="p-8 text-gray-600 text-sm">PreviewPanel — en construcción</div>
|
||||
{:else if editor.activePanel === 'export'}
|
||||
<!-- <ExportPanel /> -->
|
||||
<div class="p-8 text-gray-600 text-sm">ExportPanel — en construcción</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,639 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// VICE · Editor Store
|
||||
//
|
||||
// Store principal del editor de catálogos.
|
||||
// Gestiona el estado de edición de una Collection completa.
|
||||
// Usa runas de Svelte 5 ($state, $derived).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
import type {LingString} from "@/ling";
|
||||
import type {
|
||||
Collection, CollectionID,
|
||||
Entity, EntityID,
|
||||
Group, GroupID,
|
||||
Attribute, AttrID,
|
||||
AttributeOption,
|
||||
OptionDefinition, OptionID,
|
||||
Rule,
|
||||
EntityPath,
|
||||
EntityPathKey, OptionalGroup, FixedGroup, RequiredAttribute,
|
||||
} from '@/vice/libs/types.ts'
|
||||
import {defaultISOLocale} from "@/ling/consts.ts";
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// HELPERS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function uid(prefix: string): string {
|
||||
return `${prefix}_${Math.random().toString(36).slice(2, 8)}`
|
||||
}
|
||||
|
||||
function emptyLing(es = '', en = ''): LingString {
|
||||
return { es, en }
|
||||
}
|
||||
|
||||
function emptyCollection(): Collection {
|
||||
return {
|
||||
id: uid('col') as CollectionID,
|
||||
code: '',
|
||||
langs: [defaultISOLocale],
|
||||
name: emptyLing(),
|
||||
description: emptyLing(),
|
||||
attributes: [],
|
||||
options: {} as Record<OptionID, OptionDefinition>,
|
||||
entities: {} as Record<EntityID, Entity>,
|
||||
}
|
||||
}
|
||||
|
||||
function emptyEntity(id: EntityID): Entity {
|
||||
return {
|
||||
id,
|
||||
code: '',
|
||||
name: emptyLing(),
|
||||
description: emptyLing(),
|
||||
attributes: [],
|
||||
groups: {} as Record<GroupID, Group>,
|
||||
rules: [],
|
||||
}
|
||||
}
|
||||
|
||||
function emptyGroup(id: GroupID, kind: 'fixed' | 'optional' = 'fixed'): Group {
|
||||
const base = {
|
||||
id,
|
||||
code: '',
|
||||
name: emptyLing(),
|
||||
description: emptyLing(),
|
||||
attributes: [],
|
||||
}
|
||||
if (kind === 'optional') {
|
||||
return { ...base, kind: 'optional', rules: [] } satisfies OptionalGroup
|
||||
}
|
||||
return { ...base, kind: 'fixed' } satisfies FixedGroup
|
||||
}
|
||||
|
||||
function emptyAttribute(id: AttrID): RequiredAttribute {
|
||||
return {
|
||||
id,
|
||||
code: '',
|
||||
name: emptyLing(),
|
||||
description: emptyLing(),
|
||||
type: 'required',
|
||||
default: '' as OptionID,
|
||||
options: [],
|
||||
rules: [],
|
||||
display: {},
|
||||
}
|
||||
}
|
||||
|
||||
function emptyOption(id: OptionID): OptionDefinition {
|
||||
return {
|
||||
id,
|
||||
code: '',
|
||||
name: emptyLing(),
|
||||
description: emptyLing(),
|
||||
tags: [],
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// VALIDATION
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type EditorPanel = 'catalog' | 'objects' | 'options' | 'rules' | 'preview' | 'export'
|
||||
|
||||
export interface EditorIssue {
|
||||
severity: 'error' | 'warning'
|
||||
message: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface EditorValidation {
|
||||
valid: boolean
|
||||
errors: EditorIssue[]
|
||||
warnings: EditorIssue[]
|
||||
}
|
||||
|
||||
function validateCollection(collection: Collection): EditorValidation {
|
||||
const errors: EditorIssue[] = []
|
||||
const warnings: EditorIssue[] = []
|
||||
|
||||
// Catálogo
|
||||
if (!collection.code) errors.push({ severity: 'error', message: 'El catálogo necesita un código', path: 'collection.code' })
|
||||
if (!getLing(collection.name)) errors.push({ severity: 'error', message: 'El catálogo necesita un nombre', path: 'collection.name' })
|
||||
|
||||
// Opciones globales
|
||||
for (const opt of Object.values(collection.options)) {
|
||||
if (!opt.code) errors.push({ severity: 'error', message: `Opción "${opt.id}" sin código`, path: `options.${opt.id}.code` })
|
||||
if (!getLing(opt.name)) errors.push({ severity: 'error', message: `Opción "${opt.id}" sin nombre`, path: `options.${opt.id}.name` })
|
||||
}
|
||||
|
||||
// Entidades
|
||||
for (const entity of Object.values(collection.entities)) {
|
||||
if (!entity.code) errors.push({ severity: 'error', message: `Entidad "${entity.id}" sin código`, path: `entities.${entity.id}.code` })
|
||||
if (!getLing(entity.name)) errors.push({ severity: 'error', message: `Entidad "${entity.id}" sin nombre`, path: `entities.${entity.id}.name` })
|
||||
|
||||
// Grupos
|
||||
for (const [groupId, group] of Object.entries(entity.groups)) {
|
||||
if (!group.code) errors.push({ severity: 'error', message: `Grupo "${groupId}" sin código`, path: `entities.${entity.id}.groups.${groupId}.code` })
|
||||
if (!getLing(group.name)) errors.push({ severity: 'error', message: `Grupo "${groupId}" sin nombre`, path: `entities.${entity.id}.groups.${groupId}.name` })
|
||||
|
||||
// Atributos del grupo
|
||||
for (const attr of group.attributes) {
|
||||
validateAttribute(attr, `entities.${entity.id}.groups.${groupId}`, collection, errors, warnings)
|
||||
}
|
||||
}
|
||||
|
||||
// Atributos globales de la entidad
|
||||
for (const attr of entity.attributes) {
|
||||
validateAttribute(attr, `entities.${entity.id}`, collection, errors, warnings)
|
||||
}
|
||||
}
|
||||
|
||||
// Atributos globales del catálogo
|
||||
for (const attr of collection.attributes) {
|
||||
validateAttribute(attr, 'collection', collection, errors, warnings)
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors, warnings }
|
||||
}
|
||||
|
||||
function validateAttribute(
|
||||
attr: Attribute,
|
||||
scope: string,
|
||||
collection: Collection,
|
||||
errors: EditorIssue[],
|
||||
warnings: EditorIssue[],
|
||||
): void {
|
||||
const path = `${scope}.attributes.${attr.id}`
|
||||
if (!attr.code) errors.push({ severity: 'error', message: `Atributo "${attr.id}" sin código`, path: `${path}.code` })
|
||||
if (!getLing(attr.name)) errors.push({ severity: 'error', message: `Atributo "${attr.id}" sin nombre`, path: `${path}.name` })
|
||||
|
||||
if (attr.type !== 'fixed') {
|
||||
if (attr.options.length === 0) {
|
||||
warnings.push({ severity: 'warning', message: `Atributo "${attr.id}" sin opciones`, path: `${path}.options` })
|
||||
} else {
|
||||
// Verificar que el default existe en las opciones
|
||||
const hasDefault = attr.options.some(o => o.optionId === attr.default)
|
||||
if (!hasDefault) {
|
||||
errors.push({ severity: 'error', message: `Atributo "${attr.id}": el valor por defecto no está en las opciones`, path: `${path}.default` })
|
||||
}
|
||||
// Verificar que todas las opciones existen en la librería
|
||||
for (const attrOpt of attr.options) {
|
||||
if (!collection.options[attrOpt.optionId]) {
|
||||
errors.push({ severity: 'error', message: `Atributo "${attr.id}": opción "${attrOpt.optionId}" no existe en la librería`, path: `${path}.options.${attrOpt.optionId}` })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getLing(l: LingString): string {
|
||||
return typeof l === 'string' ? l : (l as any)?.es ?? ''
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// EDITOR STORE
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function createEditorStore(initial?: Collection) {
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let collection = $state<Collection>(initial ?? emptyCollection())
|
||||
let activeEntityId = $state<EntityID | null>(null)
|
||||
let activeGroupId = $state<GroupID | null>(null)
|
||||
let activeAttrId = $state<AttrID | null>(null)
|
||||
let activeOptionId = $state<OptionID | null>(null)
|
||||
let activeRuleIdx = $state<number | null>(null)
|
||||
let activePanel = $state<EditorPanel>('objects')
|
||||
let isDirty = $state(false)
|
||||
|
||||
function markDirty() { isDirty = true }
|
||||
|
||||
// ── Derived ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
const validation = $derived(isDirty
|
||||
? validateCollection(collection)
|
||||
: { valid: true, errors: [] as EditorIssue[], warnings: [] as EditorIssue[] }
|
||||
)
|
||||
|
||||
|
||||
const canPreview = $derived(
|
||||
validation.valid && Object.keys(collection.entities).length > 0
|
||||
)
|
||||
const activeEntity = $derived(activeEntityId ? collection.entities[activeEntityId] ?? null : null)
|
||||
const activeGroup = $derived(
|
||||
activeEntity && activeGroupId ? (activeEntity.groups[activeGroupId] ?? null) : null
|
||||
)
|
||||
const activeAttribute = $derived(() => {
|
||||
if (!activeAttrId) return null
|
||||
if (activeGroup) return activeGroup.attributes.find(a => a.id === activeAttrId) ?? null
|
||||
if (activeEntity) return activeEntity.attributes.find(a => a.id === activeAttrId) ?? null
|
||||
return collection.attributes.find(a => a.id === activeAttrId) ?? null
|
||||
})
|
||||
const activeOption = $derived(activeOptionId ? collection.options[activeOptionId] ?? null : null)
|
||||
|
||||
// ── Navigation ─────────────────────────────────────────────────────────────
|
||||
|
||||
function setPanel(panel: EditorPanel) {
|
||||
activePanel = panel
|
||||
}
|
||||
|
||||
function setActiveEntity(id: EntityID | null) {
|
||||
activeEntityId = id
|
||||
activeGroupId = null
|
||||
activeAttrId = null
|
||||
}
|
||||
|
||||
function setActiveGroup(id: GroupID | null) {
|
||||
activeGroupId = id
|
||||
activeAttrId = null
|
||||
}
|
||||
|
||||
function setActiveAttribute(id: AttrID | null) {
|
||||
activeAttrId = id
|
||||
}
|
||||
|
||||
function setActiveOption(id: OptionID | null) {
|
||||
activeOptionId = id
|
||||
}
|
||||
|
||||
// ── Collection meta ────────────────────────────────────────────────────────
|
||||
|
||||
function updateCollectionMeta(patch: Partial<Pick<Collection, 'code' | 'name' | 'description' | 'basePath' | 'langs' >>) {
|
||||
collection = { ...collection, ...patch }
|
||||
isDirty = true
|
||||
}
|
||||
|
||||
// ── Options library ────────────────────────────────────────────────────────
|
||||
|
||||
function addOption(): OptionID {
|
||||
const id = uid('op') as OptionID
|
||||
collection = {
|
||||
...collection,
|
||||
options: { ...collection.options, [id]: emptyOption(id) },
|
||||
}
|
||||
activeOptionId = id
|
||||
markDirty()
|
||||
return id
|
||||
}
|
||||
|
||||
function updateOption(id: OptionID, patch: Partial<OptionDefinition>) {
|
||||
const opt = collection.options[id]
|
||||
if (!opt) return
|
||||
collection = {
|
||||
...collection,
|
||||
options: { ...collection.options, [id]: { ...opt, ...patch } },
|
||||
}
|
||||
markDirty()
|
||||
}
|
||||
|
||||
function deleteOption(id: OptionID) {
|
||||
const { [id]: _, ...rest } = collection.options
|
||||
collection = { ...collection, options: rest as Record<OptionID, OptionDefinition> }
|
||||
if (activeOptionId === id) activeOptionId = null
|
||||
markDirty()
|
||||
}
|
||||
|
||||
function duplicateOption(id: OptionID): OptionID {
|
||||
const opt = collection.options[id]
|
||||
if (!opt) return id
|
||||
const newId = uid('op') as OptionID
|
||||
const name = typeof opt.name === 'string'
|
||||
? opt.name + ' (copia)'
|
||||
: { ...(opt.name as any), es: ((opt.name as any).es ?? '') + ' (copia)' }
|
||||
collection = {
|
||||
...collection,
|
||||
options: { ...collection.options, [newId]: { ...opt, id: newId, code: opt.code + '_copy', name } },
|
||||
}
|
||||
activeOptionId = newId
|
||||
return newId
|
||||
}
|
||||
|
||||
// ── Entities ───────────────────────────────────────────────────────────────
|
||||
|
||||
function addEntity(): EntityID {
|
||||
const id = uid('en') as EntityID
|
||||
collection = {
|
||||
...collection,
|
||||
entities: { ...collection.entities, [id]: emptyEntity(id) },
|
||||
}
|
||||
activeEntityId = id
|
||||
activeGroupId = null
|
||||
activeAttrId = null
|
||||
markDirty()
|
||||
return id
|
||||
}
|
||||
|
||||
function updateEntity(id: EntityID, patch: Record<string, unknown>) {
|
||||
const entity = collection.entities[id]
|
||||
if (!entity) return
|
||||
collection = {
|
||||
...collection,
|
||||
entities: { ...collection.entities, [id]: { ...entity, ...patch } as Entity },
|
||||
}
|
||||
markDirty()
|
||||
}
|
||||
|
||||
function deleteEntity(id: EntityID) {
|
||||
const { [id]: _, ...rest } = collection.entities
|
||||
collection = { ...collection, entities: rest as Record<EntityID, Entity> }
|
||||
if (activeEntityId === id) setActiveEntity(null)
|
||||
markDirty()
|
||||
}
|
||||
|
||||
// ── Groups ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function addGroup(entityId: EntityID, kind: 'fixed' | 'optional' = 'fixed'): GroupID {
|
||||
const entity = collection.entities[entityId]
|
||||
if (!entity) return '' as GroupID
|
||||
const id = uid('gr') as GroupID
|
||||
collection = {
|
||||
...collection,
|
||||
entities: {
|
||||
...collection.entities,
|
||||
[entityId]: {
|
||||
...entity,
|
||||
groups: { ...entity.groups, [id]: emptyGroup(id, kind) },
|
||||
},
|
||||
},
|
||||
}
|
||||
activeGroupId = id
|
||||
activeAttrId = null
|
||||
markDirty()
|
||||
return id
|
||||
}
|
||||
|
||||
function updateGroup(entityId: EntityID, groupId: GroupID, patch: Record<string, unknown>) {
|
||||
const entity = collection.entities[entityId]
|
||||
const group = entity?.groups[groupId]
|
||||
if (!entity || !group) return
|
||||
collection = {
|
||||
...collection,
|
||||
entities: {
|
||||
...collection.entities,
|
||||
[entityId]: {
|
||||
...entity,
|
||||
groups: { ...entity.groups, [groupId]: { ...group, ...patch } as Group },
|
||||
},
|
||||
},
|
||||
}
|
||||
markDirty()
|
||||
}
|
||||
|
||||
function deleteGroup(entityId: EntityID, groupId: GroupID) {
|
||||
const entity = collection.entities[entityId]
|
||||
if (!entity) return
|
||||
const { [groupId]: _, ...rest } = entity.groups
|
||||
collection = {
|
||||
...collection,
|
||||
entities: {
|
||||
...collection.entities,
|
||||
[entityId]: { ...entity, groups: rest as Record<GroupID, Group> },
|
||||
},
|
||||
}
|
||||
if (activeGroupId === groupId) { activeGroupId = null; activeAttrId = null }
|
||||
markDirty()
|
||||
}
|
||||
|
||||
// ── Attributes ─────────────────────────────────────────────────────────────
|
||||
|
||||
function addAttribute(entityId: EntityID, groupId?: GroupID): AttrID {
|
||||
const id = uid('at') as AttrID
|
||||
const attr = emptyAttribute(id)
|
||||
|
||||
if (groupId) {
|
||||
collection = produceAttr(collection, entityId, groupId, id, () => attr)
|
||||
// produceAttr filtra por attrId que no existe aún — necesitamos append
|
||||
const entity = collection.entities[entityId]
|
||||
const group = entity?.groups[groupId]
|
||||
if (entity && group) {
|
||||
collection = {
|
||||
...collection,
|
||||
entities: {
|
||||
...collection.entities,
|
||||
[entityId]: {
|
||||
...entity,
|
||||
groups: {
|
||||
...entity.groups,
|
||||
[groupId]: { ...group, attributes: [...group.attributes, attr] },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const entity = collection.entities[entityId]
|
||||
if (entity) {
|
||||
collection = {
|
||||
...collection,
|
||||
entities: {
|
||||
...collection.entities,
|
||||
[entityId]: { ...entity, attributes: [...entity.attributes, attr] },
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activeAttrId = id
|
||||
return id
|
||||
}
|
||||
|
||||
function updateAttribute(entityId: EntityID, groupId: GroupID | undefined, attrId: AttrID, patch: Record<string, unknown>) {
|
||||
collection = produceAttr(collection, entityId, groupId, attrId, attr => ({ ...attr, ...patch }) as Attribute)
|
||||
markDirty()
|
||||
}
|
||||
|
||||
function deleteAttribute(entityId: EntityID, groupId: GroupID | undefined, attrId: AttrID) {
|
||||
collection = produceAttr(collection, entityId, groupId, attrId, null)
|
||||
if (activeAttrId === attrId) activeAttrId = null
|
||||
markDirty()
|
||||
}
|
||||
|
||||
// ── Attribute options ──────────────────────────────────────────────────────
|
||||
|
||||
function addOptionToAttribute(entityId: EntityID, groupId: GroupID | undefined, attrId: AttrID, optionId: OptionID) {
|
||||
collection = produceAttr(collection, entityId, groupId, attrId, attr => {
|
||||
if (attr.type === 'fixed') return attr
|
||||
if (attr.options.some(o => o.optionId === optionId)) return attr
|
||||
const newOpt: AttributeOption = { optionId }
|
||||
const options = [...attr.options, newOpt]
|
||||
const updated = { ...attr, options }
|
||||
if (!attr.default && options.length === 1) (updated as any).default = optionId
|
||||
return updated
|
||||
})
|
||||
markDirty()
|
||||
}
|
||||
|
||||
function removeOptionFromAttribute(entityId: EntityID, groupId: GroupID | undefined, attrId: AttrID, optionId: OptionID) {
|
||||
collection = produceAttr(collection, entityId, groupId, attrId, attr => {
|
||||
if (attr.type === 'fixed') return attr
|
||||
const options = attr.options.filter(o => o.optionId !== optionId)
|
||||
const updated = { ...attr, options }
|
||||
if ((attr as any).default === optionId) (updated as any).default = options[0]?.optionId ?? ''
|
||||
return updated
|
||||
})
|
||||
markDirty()
|
||||
}
|
||||
|
||||
// ── Catalog attributes ─────────────────────────────────────────────────────
|
||||
|
||||
function addCatalogAttribute(): AttrID {
|
||||
const id = uid('at') as AttrID
|
||||
const attr = emptyAttribute(id)
|
||||
collection = { ...collection, attributes: [...collection.attributes, attr] }
|
||||
activeAttrId = id
|
||||
activeEntityId = null
|
||||
activeGroupId = null
|
||||
return id
|
||||
}
|
||||
|
||||
function updateCatalogAttribute(attrId: AttrID, patch: Record<string, unknown>) {
|
||||
collection = {
|
||||
...collection,
|
||||
attributes: collection.attributes.map(a =>
|
||||
a.id === attrId ? { ...a, ...patch } as Attribute : a
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function deleteCatalogAttribute(attrId: AttrID) {
|
||||
collection = { ...collection, attributes: collection.attributes.filter(a => a.id !== attrId) }
|
||||
if (activeAttrId === attrId) activeAttrId = null
|
||||
}
|
||||
|
||||
// ── Import / Export ────────────────────────────────────────────────────────
|
||||
|
||||
function exportToJson(): string {
|
||||
return JSON.stringify(collection, null, 2)
|
||||
}
|
||||
|
||||
function importFromJson(json: string) {
|
||||
const parsed = JSON.parse(json)
|
||||
collection = parsed
|
||||
activeEntityId = null
|
||||
activeGroupId = null
|
||||
activeAttrId = null
|
||||
activeOptionId = null
|
||||
isDirty = false
|
||||
}
|
||||
|
||||
function reset() {
|
||||
collection = emptyCollection()
|
||||
activeEntityId = null
|
||||
activeGroupId = null
|
||||
activeAttrId = null
|
||||
activeOptionId = null
|
||||
isDirty = false
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────────────
|
||||
|
||||
return {
|
||||
// State (getters)
|
||||
get collection() { return collection },
|
||||
get validation() { return validation },
|
||||
get activePanel() { return activePanel },
|
||||
get isDirty() { return isDirty },
|
||||
get canPreview() { return canPreview },
|
||||
get activeEntityId() { return activeEntityId },
|
||||
get activeGroupId() { return activeGroupId },
|
||||
get activeAttrId() { return activeAttrId },
|
||||
get activeOptionId() { return activeOptionId },
|
||||
get activeEntity() { return activeEntity },
|
||||
get activeGroup() { return activeGroup },
|
||||
get activeAttribute() { return activeAttribute() },
|
||||
get activeOption() { return activeOption },
|
||||
|
||||
// Navigation
|
||||
setPanel,
|
||||
setActiveEntity,
|
||||
setActiveGroup,
|
||||
setActiveAttribute,
|
||||
setActiveOption,
|
||||
|
||||
// Collection
|
||||
updateCollectionMeta,
|
||||
|
||||
// Options
|
||||
addOption,
|
||||
updateOption,
|
||||
deleteOption,
|
||||
duplicateOption,
|
||||
|
||||
// Entities
|
||||
addEntity,
|
||||
updateEntity,
|
||||
deleteEntity,
|
||||
|
||||
// Groups
|
||||
addGroup,
|
||||
updateGroup,
|
||||
deleteGroup,
|
||||
|
||||
// Attributes
|
||||
addAttribute,
|
||||
updateAttribute,
|
||||
deleteAttribute,
|
||||
addCatalogAttribute,
|
||||
updateCatalogAttribute,
|
||||
deleteCatalogAttribute,
|
||||
|
||||
// Attribute options
|
||||
addOptionToAttribute,
|
||||
removeOptionFromAttribute,
|
||||
|
||||
// Import/Export
|
||||
exportToJson,
|
||||
importFromJson,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
export type EditorStore = ReturnType<typeof createEditorStore>
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// HELPERS INTERNOS — produce inmutable
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function produceAttr(
|
||||
collection: Collection,
|
||||
entityId: EntityID,
|
||||
groupId: GroupID | undefined,
|
||||
attrId: AttrID,
|
||||
fn: ((attr: Attribute) => Attribute) | null,
|
||||
): Collection {
|
||||
const entity = collection.entities[entityId]
|
||||
if (!entity) return collection
|
||||
|
||||
function mapAttrs(attrs: Attribute[]): Attribute[] {
|
||||
if (fn === null) return attrs.filter(a => a.id !== attrId)
|
||||
return attrs.map(a => a.id === attrId ? fn(a) as Attribute : a)
|
||||
}
|
||||
|
||||
if (groupId) {
|
||||
const group = entity.groups[groupId]
|
||||
if (!group) return collection
|
||||
return {
|
||||
...collection,
|
||||
entities: {
|
||||
...collection.entities,
|
||||
[entityId]: {
|
||||
...entity,
|
||||
groups: {
|
||||
...entity.groups,
|
||||
[groupId]: { ...group, attributes: mapAttrs(group.attributes) },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...collection,
|
||||
entities: {
|
||||
...collection.entities,
|
||||
[entityId]: { ...entity, attributes: mapAttrs(entity.attributes) },
|
||||
},
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue