dev 7 months ago
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…
Cancel
Save

Powered by TurnKey Linux.