dev 7 months ago
parent 1db6022233
commit 9c0e24dab1

@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="es"> <html lang="es" data-theme="dark">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />

@ -16,24 +16,23 @@
], ],
"author": "ACTIVE THING", "author": "ACTIVE THING",
"license": "EULA", "license": "EULA",
"dependencies": { "dependencies": {
"svelte": "^5.53.6", "@tailwindcss/vite": "^4.1.18",
"@tailwindcss/vite": "^4.1.18" "daisyui": "^5.5.19",
"svelte": "^5.53.6"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.4", "@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@tsconfig/svelte": "^5.0.6",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/svelte": "^5.3.1", "@testing-library/svelte": "^5.3.1",
"@tsconfig/svelte": "^5.0.6",
"@types/node": "^25.3.3", "@types/node": "^25.3.3",
"jsdom": "^28.1.0",
"ts-node": "^10.9.2",
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
"jsdom": "^28.1.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"ts-node": "^10.9.2",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.1", "vite": "^7.3.1",
"vite-plugin-singlefile": "^2.3.0", "vite-plugin-singlefile": "^2.3.0",

@ -2,7 +2,7 @@ import type { CurrInstance, CurrDefinition, CurrDisplay, CurrConfig, ConversionV
import type { LingInstance, LingNode } from '@/ling/lib/types.ts'; import type { LingInstance, LingNode } from '@/ling/lib/types.ts';
import type { LocaleResolver, GlobLocale } from './comn_types.ts'; import type { LocaleResolver, GlobLocale } from './comn_types.ts';
import { resolveLocale } from './comn_engine.ts'; import { resolveLocale } from './comn_engine.ts';
import { defaultISOLocale } from '@/ling/lib/consts.ts'; import { DEFAULT_ISO_LOCALE } from '@/ling/lib/consts.ts';
/** /**
@ -17,7 +17,7 @@ export function createCurr<S extends LingNode>(
definitions : Record<string, CurrDefinition>, definitions : Record<string, CurrDefinition>,
ling : LingInstance<S>, ling : LingInstance<S>,
opts : CurrConfig = {}, opts : CurrConfig = {},
defaultLocale: LocaleResolver = defaultISOLocale defaultLocale: LocaleResolver = DEFAULT_ISO_LOCALE
): CurrInstance { ): CurrInstance {
let selectedCurrency: string = opts.selectedCurrency ?? Object.keys(definitions)[0] ?? ''; let selectedCurrency: string = opts.selectedCurrency ?? Object.keys(definitions)[0] ?? '';

@ -1,7 +1,7 @@
import type { DatiInstance, DatiOptions, DatiStyleType, DatiTimeFormat, DatiDateOrder } from './dati_types.ts'; import type { DatiInstance, DatiOptions, DatiStyleType, DatiTimeFormat, DatiDateOrder } from './dati_types.ts';
import type { LocaleResolver, GlobLocale } from './comn_types.ts'; import type { LocaleResolver, GlobLocale } from './comn_types.ts';
import { resolveLocale } from './comn_engine.ts'; import { resolveLocale } from './comn_engine.ts';
import { defaultISOLocale } from '@/ling/lib/consts.ts'; import { DEFAULT_ISO_LOCALE } from '@/ling/lib/consts.ts';
// ============================== // ==============================
@ -37,7 +37,7 @@ function resolveOpts(opts?: DatiOptions): {
*/ */
export function createDati( export function createDati(
opts : { selectedTimeFormat?: DatiTimeFormat; selectedDateOrder?: DatiDateOrder } = {}, opts : { selectedTimeFormat?: DatiTimeFormat; selectedDateOrder?: DatiDateOrder } = {},
defaultLocale: LocaleResolver = defaultISOLocale defaultLocale: LocaleResolver = DEFAULT_ISO_LOCALE
): DatiInstance { ): DatiInstance {
let timeFormat: DatiTimeFormat = opts.selectedTimeFormat ?? '24h'; let timeFormat: DatiTimeFormat = opts.selectedTimeFormat ?? '24h';

@ -1,4 +1,4 @@
import { defaultISOLocale } from '@/ling/lib/consts.ts'; import { DEFAULT_ISO_LOCALE } from '@/ling/lib/consts.ts';
import { resolveLocale } from './comn_engine.ts'; import { resolveLocale } from './comn_engine.ts';
import type { LocaleResolver } from './comn_types.ts'; import type { LocaleResolver } from './comn_types.ts';
import type { NumrInstance } from './numr_types.ts'; import type { NumrInstance } from './numr_types.ts';
@ -16,7 +16,7 @@ import type { NumrInstance } from './numr_types.ts';
*/ */
export function createNumr( export function createNumr(
opts : { minDecimals?: number; maxDecimals?: number } = {}, opts : { minDecimals?: number; maxDecimals?: number } = {},
defaultLocale: LocaleResolver = defaultISOLocale defaultLocale: LocaleResolver = DEFAULT_ISO_LOCALE
): NumrInstance { ): NumrInstance {
const defaults = { const defaults = {

@ -2,7 +2,7 @@
import type { LocaleResolver, GlobLocale } from "./comn_types.ts"; import type { LocaleResolver, GlobLocale } from "./comn_types.ts";
import type { UnitCategoryData, UnitCategoryInstance, UnitCategoryKey, UnitDisplay, UnitInstance, UnitSystem } from "./unit_types.ts"; import type { UnitCategoryData, UnitCategoryInstance, UnitCategoryKey, UnitDisplay, UnitInstance, UnitSystem } from "./unit_types.ts";
import { resolveLocale } from "./comn_engine.ts"; import { resolveLocale } from "./comn_engine.ts";
import { defaultISOLocale } from "@/ling/lib/consts.ts"; import { DEFAULT_ISO_LOCALE } from "@/ling/lib/consts.ts";
import { type LingNode, type LingInstance, ling } from "@/ling/lib"; import { type LingNode, type LingInstance, ling } from "@/ling/lib";
import { unitData } from "./unit_data.ts"; import { unitData } from "./unit_data.ts";
import { unitsLingNode } from "./unit_langs.ts"; import { unitsLingNode } from "./unit_langs.ts";

@ -1,12 +1,12 @@
import type {LingLogger} from "@/ling/lib/types.ts"; import type {LingLogger} from "@/ling/lib/types.ts";
export const idPrefix = '#?'; export const ID_PREFIX = '#?';
export const defaultISOLocale = 'es' ; export const DEFAULT_ISO_LOCALE = 'es' as const;
export const maxResolveDeep = 3; export const MAX_RESOLVE_DEEP = 3;
export const loggerCategory = 'ling'; export const LOGGER_CATEGORY = 'ling';

@ -9,18 +9,13 @@ import type {
LingString, LingInstance, LingLogger, LingNode, PluralConfig, LingString, LingInstance, LingLogger, LingNode, PluralConfig,
} from './types.ts'; } from './types.ts';
import {LING_ERRORS} from "@/ling/lib/errors.ts"; import {LING_ERRORS} from "@/ling/lib/errors.ts";
import {idPrefix, loggerCategory, maxResolveDeep} from "@/ling/lib/consts.ts"; import {ID_PREFIX, LOGGER_CATEGORY, MAX_RESOLVE_DEEP} from "@/ling/lib/consts.ts";
import {isIDLing, isLingRecord} from "@/ling/lib/guards.ts"; import {isIDLing, isLingRecord} from "@/ling/lib/guards.ts";
import {pluralRule} from "@/ling/lib/plural-rules.ts"; import {pluralRule} from "@/ling/lib/plural-rules.ts";
import {resolvePath} from "@/ling/lib/helpers.ts";
// ==============================
// HELPERS
// ==============================
function resolvePath(obj: any, path: string): any {
return path.split('.').reduce((acc, key) => acc?.[key], obj);
}
function isDev(): boolean { function isDev(): boolean {
return typeof process !== 'undefined' && process.env?.NODE_ENV === 'development'; return typeof process !== 'undefined' && process.env?.NODE_ENV === 'development';
@ -95,7 +90,7 @@ export function createLing<S extends LingNode>(
const msg = path const msg = path
? LING_ERRORS.MISSING_TRANSLATION(path, currentLocale, defaultLocale) ? LING_ERRORS.MISSING_TRANSLATION(path, currentLocale, defaultLocale)
: LING_ERRORS.MISSING_TRANSLATION_RECORD(currentLocale, defaultLocale); : LING_ERRORS.MISSING_TRANSLATION_RECORD(currentLocale, defaultLocale);
logger.warn(loggerCategory, msg); logger.warn(LOGGER_CATEGORY, msg);
} }
return translation; return translation;
@ -106,13 +101,13 @@ export function createLing<S extends LingNode>(
* de forma recursiva. Lanza si se supera el límite de profundidad. * de forma recursiva. Lanza si se supera el límite de profundidad.
*/ */
function resolveValue(value: any, args: any[], depth: number): any { function resolveValue(value: any, args: any[], depth: number): any {
if (depth > maxResolveDeep) { if (depth > MAX_RESOLVE_DEEP) {
logger.error(loggerCategory, LING_ERRORS.CIRCULAR_REFERENCE(String(value))); logger.error(LOGGER_CATEGORY, LING_ERRORS.CIRCULAR_REFERENCE(String(value)));
throw new Error("Circular reference in ling"); throw new Error("Circular reference in ling");
} }
if (isIDLing(value)) { if (isIDLing(value)) {
const path = value.substring(idPrefix.length); const path = value.substring(ID_PREFIX.length);
const resolved = resolvePath(currentSchema, path); const resolved = resolvePath(currentSchema, path);
return resolveValue(resolved, args, depth + 1); return resolveValue(resolved, args, depth + 1);
} }
@ -132,7 +127,7 @@ export function createLing<S extends LingNode>(
const rawValue = resolvePath(currentSchema, path); const rawValue = resolvePath(currentSchema, path);
if (rawValue === undefined) { if (rawValue === undefined) {
if (isDev()) logger.error(loggerCategory, LING_ERRORS.KEY_NOT_FOUND(path)); if (isDev()) logger.error(LOGGER_CATEGORY, LING_ERRORS.KEY_NOT_FOUND(path));
return path; return path;
} }

@ -1,5 +1,5 @@
import type {DefaultLocale, IDLing, LingRecord, LingString} from "@/ling/lib/types.ts"; import type {DefaultLocale, IDLing, LingRecord, LingString} from "@/ling/lib/types.ts";
import {defaultISOLocale, idPrefix} from "@/ling/lib/consts.ts"; import {DEFAULT_ISO_LOCALE, ID_PREFIX} from "@/ling/lib/consts.ts";
/** /**
* Guard para identificar referencias. * Guard para identificar referencias.
@ -8,7 +8,7 @@ import {defaultISOLocale, idPrefix} from "@/ling/lib/consts.ts";
export function isIDLing(value: unknown): value is IDLing { export function isIDLing(value: unknown): value is IDLing {
return ( return (
typeof value === 'string' && typeof value === 'string' &&
value.startsWith(idPrefix) && value.startsWith(ID_PREFIX) &&
value.length > 2 value.length > 2
); );
} }
@ -24,7 +24,7 @@ export function isLingRecord(value: unknown): value is LingRecord {
typeof value === 'object' && typeof value === 'object' &&
value !== null && value !== null &&
!Array.isArray(value) && !Array.isArray(value) &&
defaultISOLocale in value // obligatorio según DefaultLocale DEFAULT_ISO_LOCALE in value // obligatorio según DefaultLocale
); );
} }

@ -0,0 +1,18 @@
// ==============================
// HELPERS
// ==============================
import type {LingRecord} from "./types.ts";
import {DEFAULT_ISO_LOCALE} from "@/ling/lib/consts.ts";
export function resolvePath(obj: any, path: string): any {
return path.split('.').reduce((acc, key) => acc?.[key], obj);
}
export function makeLingRecord (text?: string) : LingRecord {
return {
[DEFAULT_ISO_LOCALE]: text ?? ''
} as LingRecord;
}

@ -3,6 +3,7 @@ export { translations } from './translations.ts';
export * from './types.ts'; export * from './types.ts';
export * from './engine.ts'; export * from './engine.ts';
export * from './helpers.ts';
export * from './instance.ts'; export * from './instance.ts';
export * from './translations.ts'; export * from './translations.ts';

@ -2,13 +2,13 @@
// LOCALES // LOCALES
// ============================== // ==============================
import type {idPrefix} from "@/ling/lib/consts.ts"; import type {ID_PREFIX} from "@/ling/lib/consts.ts";
import type {PluralCategory} from "@/ling/lib/plural-rules.ts"; import type {PluralCategory} from "@/ling/lib/plural-rules.ts";
/** /**
* Referencia interna: #?path.del.schema * Referencia interna: #?path.del.schema
*/ */
export type IDLing = `${typeof idPrefix}${string}`; export type IDLing = `${typeof ID_PREFIX}${string}`;
export type DefaultLocale = 'es'; export type DefaultLocale = 'es';

@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { LingRecord, SupportedLocale } from "@/ling/lib"; import type {LingRecord, LingString, SupportedLocale} from "@/ling/lib";
type AsType = 'input' | 'textarea'; type AsType = 'input' | 'textarea';
@ -27,7 +27,7 @@
rows?: number; rows?: number;
as?: AsType; as?: AsType;
localeTab?: Snippet<[{ locale: string; active: boolean; hasContent: boolean; activate: () => void }]>; localeTab?: Snippet<[{ locale: string; active: boolean; hasContent: boolean; activate: () => void }]>;
onUpdate: (value: LingRecord) => void; onUpdate: (value: LingString) => void;
} = $props(); } = $props();
// Helper para indexar LingRecord con string dinámico // Helper para indexar LingRecord con string dinámico

@ -35,7 +35,6 @@
let currentLocale = $state(defaultLocale); let currentLocale = $state(defaultLocale);
let inputText = $state(get(defaultLocale)); let inputText = $state(get(defaultLocale));
let previousLocale = $state(defaultLocale); let previousLocale = $state(defaultLocale);
let focused = $state(false);
$effect(() => { $effect(() => {
if (currentLocale !== previousLocale) { if (currentLocale !== previousLocale) {
@ -74,68 +73,56 @@
} }
</script> </script>
<div class="flex w-full flex-col gap-2"> <div class="flex w-full flex-col gap-1.5">
{#if label} {#if label}
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<label class="flex items-center gap-1.5"> <label class="label">
<span class={['text-[13px] font-semibold', invalid ? 'text-red-500' : 'text-zinc-700 dark:text-zinc-200'].join(' ')}> <span class={['label-text', invalid ? 'text-error' : ''].join(' ')}>
{label} {label}
{#if required}<span class="text-error ml-0.5">*</span>{/if}
</span> </span>
{#if required}<span class="text-red-400">*</span>{/if}
</label> </label>
<span class="text-[12px] text-zinc-400 dark:text-zinc-500"> <span class="label-text-alt-content/50">
{filledCount}/{locales.length} idiomas {filledCount}/{locales.length} idiomas
</span> </span>
</div> </div>
{/if} {/if}
<div class={[ <div class={['rounded-box border-2 overflow-hidden transition-colors',
'overflow-hidden rounded-xl border-2 transition-all duration-200 bg-white dark:bg-zinc-900', invalid ? 'border-error' : 'border-base-300 focus-within:border-primary'].join(' ')}>
focused && !invalid ? 'border-zinc-900 dark:border-zinc-100'
: invalid ? 'border-red-400'
: 'border-zinc-200 dark:border-zinc-700',
].join(' ')}>
<!-- Locale tabs --> <!-- Locale tabs -->
<div class="flex items-center border-b border-zinc-100 dark:border-zinc-800"> <div class="tabs tabs-border border-b border-base-200 bg-base-200/40">
<div role="tablist" class="flex flex-1"> {#each locales as locale (locale)}
{#each locales as locale (locale)} {#if localeTab}
{#if localeTab} {@render localeTab({ locale, active: currentLocale === locale, hasContent: hasContent(locale), activate: () => activateLocale(locale) })}
{@render localeTab({ locale, active: currentLocale === locale, hasContent: hasContent(locale), activate: () => activateLocale(locale) })} {:else}
{:else} {@const active = currentLocale === locale}
{@const active = currentLocale === locale} {@const filled = hasContent(locale)}
{@const filled = hasContent(locale)} <button
{@const isDef = locale === defaultLocale} type="button"
<button role="tab"
type="button" role="tab" aria-selected={active} aria-selected={active}
onclick={() => activateLocale(locale)} onclick={() => activateLocale(locale)}
class={[ class={['tab', active ? 'tab-active' : '', !filled && !active ? 'opacity-40' : ''].join(' ')}
'relative flex items-center gap-1.5 px-4 py-2.5 text-[13px] font-medium transition-colors duration-150 focus:outline-none', >
active {locale.toUpperCase()}
? invalid ? 'text-red-500' : 'text-zinc-900 dark:text-zinc-100' {#if filled && !active}
: filled ? 'text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200' <span class="badge badge-xs badge-success ml-1"></span>
: 'text-zinc-300 hover:text-zinc-500 dark:text-zinc-600 dark:hover:text-zinc-400', {/if}
].join(' ')} </button>
> {/if}
{locale.toUpperCase()} {/each}
{#if isDef && !active}
<span class={['h-1.5 w-1.5 rounded-full', filled ? 'bg-emerald-400' : 'bg-zinc-200 dark:bg-zinc-700'].join(' ')}></span>
{:else if filled && !active && !isDef}
<span class="h-1.5 w-1.5 rounded-full bg-violet-400"></span>
{/if}
{#if active}
<span class={['absolute bottom-0 left-0 right-0 h-0.5', invalid ? 'bg-red-400' : 'bg-zinc-900 dark:bg-zinc-100'].join(' ')}></span>
{/if}
</button>
{/if}
{/each}
</div>
{#if charCount > 0} {#if charCount > 0}
<span class="px-4 text-[12px] tabular-nums text-zinc-300 dark:text-zinc-600">{charCount}</span> <span class="ml-auto flex items-center px-3 tabular-nums-content/30">
{charCount}
</span>
{/if} {/if}
</div> </div>
<!-- Input / Textarea -->
{#if asType === 'textarea'} {#if asType === 'textarea'}
<textarea <textarea
aria-label={`Texto en ${currentLocale}`} aria-label={`Texto en ${currentLocale}`}
@ -146,12 +133,8 @@
placeholder={dynamicPH} placeholder={dynamicPH}
bind:value={inputText} bind:value={inputText}
oninput={handleInput} oninput={handleInput}
onfocus={() => focused = true}
onblur={() => focused = false}
style="height: calc({rows} * 1.65rem + 1.75rem)" style="height: calc({rows} * 1.65rem + 1.75rem)"
class="w-full resize-none bg-transparent px-4 py-3 text-[14px] leading-relaxed class="textarea w-full resize-none rounded-none border-none bg-base-100 focus:outline-none"
text-zinc-800 placeholder:text-zinc-300 focus:outline-none
dark:text-zinc-200 dark:placeholder:text-zinc-600"
></textarea> ></textarea>
{:else} {:else}
<input <input
@ -163,20 +146,17 @@
placeholder={dynamicPH} placeholder={dynamicPH}
bind:value={inputText} bind:value={inputText}
oninput={handleInput} oninput={handleInput}
onfocus={() => focused = true} class="input w-full rounded-none border-none bg-base-100 focus:outline-none"
onblur={() => focused = false}
class="h-11 w-full bg-transparent px-4 text-[14px] text-zinc-800
placeholder:text-zinc-300 focus:outline-none
dark:text-zinc-200 dark:placeholder:text-zinc-600"
/> />
{/if} {/if}
<!-- Invalid message -->
{#if invalid} {#if invalid}
<div class="flex items-center gap-2 border-t border-red-100 bg-red-50 px-4 py-2.5 dark:border-red-900/30 dark:bg-red-900/10"> <div class="flex items-center gap-2 border-t border-error/20 bg-error/5 px-4 py-2.5">
<svg class="h-4 w-4 shrink-0 text-red-400" viewBox="0 0 16 16" fill="currentColor"> <svg class="h-4 w-4 shrink-0 text-error" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm0 3.5a.75.75 0 0 1 .75.75v3a.75.75 0 0 1-1.5 0v-3A.75.75 0 0 1 8 4.5zm0 7a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/> <path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm0 3.5a.75.75 0 0 1 .75.75v3a.75.75 0 0 1-1.5 0v-3A.75.75 0 0 1 8 4.5zm0 7a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
</svg> </svg>
<span class="text-[13px] text-red-500"> <span class="text-[13px] text-error">
El campo <strong>{defaultLocale.toUpperCase()}</strong> es obligatorio El campo <strong>{defaultLocale.toUpperCase()}</strong> es obligatorio
</span> </span>
</div> </div>

@ -1,10 +1,10 @@
import type {LingString} from "@/ling/lib"; import type {LingRecord, LingString} from "@/ling/lib";
import type { import type {
Metadata, MetadataField, Metadata, MetadataField,
CurrencyValue, MediaRef, CurrencyValue, MediaRef,
StringField, NumberField, BooleanField, LingStringField, StringField, NumberField, BooleanField, LingField,
DateField, DateTimeField, UrlField, CurrencyField, DateField, DateTimeField, UrlField, CurrencyField,
ImageField, VideoField, AudioField, PdfField, FileField, GroupField, ImageField, VideoField, AudioField, PdfField, FileField, GroupField, HtmlField,
} from './types.ts'; } from './types.ts';
@ -15,7 +15,7 @@ import type {
export const string = (value: string): StringField => ({ type: 'string', value }); export const string = (value: string): StringField => ({ type: 'string', value });
export const number = (value: number): NumberField => ({ type: 'number', value }); export const number = (value: number): NumberField => ({ type: 'number', value });
export const boolean = (value: boolean): BooleanField => ({ type: 'boolean', value }); export const boolean = (value: boolean): BooleanField => ({ type: 'boolean', value });
export const lingString = (value: LingString): LingStringField => ({ type: 'lingstring', value }); export const ling = (value: LingRecord): LingField => ({ type: 'ling', value });
export const date = (value: string): DateField => ({ type: 'date', value }); export const date = (value: string): DateField => ({ type: 'date', value });
export const datetime = (value: string): DateTimeField => ({ type: 'datetime', value }); export const datetime = (value: string): DateTimeField => ({ type: 'datetime', value });
export const url = (value: string): UrlField => ({ type: 'url', value }); export const url = (value: string): UrlField => ({ type: 'url', value });
@ -25,12 +25,13 @@ export const video = (value: MediaRef): VideoField => ({ type: 'v
export const audio = (value: MediaRef): AudioField => ({ type: 'audio', value }); export const audio = (value: MediaRef): AudioField => ({ type: 'audio', value });
export const pdf = (value: MediaRef): PdfField => ({ type: 'pdf', value }); export const pdf = (value: MediaRef): PdfField => ({ type: 'pdf', value });
export const file = (value: MediaRef): FileField => ({ type: 'file', value }); export const file = (value: MediaRef): FileField => ({ type: 'file', value });
export const html = (value: LingRecord): HtmlField => ({ type: 'html', value });
export const group = (value: Metadata, order?: string[]): GroupField => ({ type: 'group', value, ...(order !== undefined && { order }) }); export const group = (value: Metadata, order?: string[]): GroupField => ({ type: 'group', value, ...(order !== undefined && { order }) });
/** Namespace agrupado para importar como `field.string(...)`, `field.group(...)`, etc. */ /** Namespace agrupado para importar como `field.string(...)`, `field.group(...)`, etc. */
export const field = { export const field = {
string, number, boolean, lingstring: lingString, date, datetime, string, number, boolean, ling, date, datetime,
url, currency, image, video, audio, pdf, file, group, url, currency, image, video, audio, pdf, file, html, group,
} as const; } as const;
// ───────────────────────────────────────────── // ─────────────────────────────────────────────

@ -23,7 +23,7 @@ export function isValidUrl(value: string): boolean {
} }
const KNOWN_TYPES: MetadataFieldType[] = [ const KNOWN_TYPES: MetadataFieldType[] = [
'string','number','boolean','lingstring','date','datetime', 'string','number','boolean','ling','date','datetime',
'url','currency','image','video','audio','pdf','file','group', 'url','currency','image','video','audio','pdf','file','group',
]; ];

@ -1,4 +1,4 @@
import type {LingString} from "@/ling/lib"; import type {LingRecord, LingString} from "@/ling/lib";
import type {CURRENT_VERSION} from "@/mtas/lib/version.ts"; import type {CURRENT_VERSION} from "@/mtas/lib/version.ts";
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
@ -12,7 +12,10 @@ import type {CURRENT_VERSION} from "@/mtas/lib/version.ts";
* Los valores son MetadataField (discriminated union por 'type'). * Los valores son MetadataField (discriminated union por 'type').
* Un valor puede ser otro Metadata (grupo anidado via GroupField). * Un valor puede ser otro Metadata (grupo anidado via GroupField).
*/ */
export type Metadata = Record<string, unknown>; export type Metadata = {
[key: string]: unknown;
order?: string[];
};
@ -33,7 +36,7 @@ export interface MediaRef {
mimeType?: string; mimeType?: string;
size? : number; // bytes size? : number; // bytes
filename?: string; filename?: string;
alt? : LingString; alt? : LingRecord;
} }
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
@ -44,14 +47,15 @@ export interface MediaRef {
interface FieldBase { interface FieldBase {
type : MetadataFieldType; type : MetadataFieldType;
hidden? : boolean; hidden? : boolean;
// futuras: required?, disabled?, label?... /** Atributos aplicados a este campo (subconjunto de los disponibles en el editor) */
attrs?: string[];
} }
export interface StringField extends FieldBase { type: 'string'; value: string } export interface StringField extends FieldBase { type: 'string'; value: string }
export interface NumberField extends FieldBase { type: 'number'; value: number } export interface NumberField extends FieldBase { type: 'number'; value: number }
export interface BooleanField extends FieldBase { type: 'boolean'; value: boolean } export interface BooleanField extends FieldBase { type: 'boolean'; value: boolean }
export interface LingStringField extends FieldBase { type: 'lingstring'; value: LingString } export interface LingField extends FieldBase { type: 'ling'; value: LingRecord }
export interface DateField extends FieldBase { type: 'date'; value: string } // ISO 8601: "YYYY-MM-DD" export interface DateField extends FieldBase { type: 'date'; value: string } // ISO 8601: "YYYY-MM-DD"
export interface DateTimeField extends FieldBase { type: 'datetime'; value: string } // ISO 8601: "YYYY-MM-DDThh:mm:ssZ" export interface DateTimeField extends FieldBase { type: 'datetime'; value: string } // ISO 8601: "YYYY-MM-DDThh:mm:ssZ"
export interface UrlField extends FieldBase { type: 'url'; value: string } export interface UrlField extends FieldBase { type: 'url'; value: string }
@ -61,6 +65,7 @@ export interface VideoField extends FieldBase { type: 'video'; value:
export interface AudioField extends FieldBase { type: 'audio'; value: MediaRef } export interface AudioField extends FieldBase { type: 'audio'; value: MediaRef }
export interface PdfField extends FieldBase { type: 'pdf'; value: MediaRef } export interface PdfField extends FieldBase { type: 'pdf'; value: MediaRef }
export interface FileField extends FieldBase { type: 'file'; value: MediaRef } export interface FileField extends FieldBase { type: 'file'; value: MediaRef }
export interface HtmlField extends FieldBase { type: 'html'; value: LingRecord }
/** /**
* Grupo anidado — su value ES otro Metadata. * Grupo anidado — su value ES otro Metadata.
@ -86,7 +91,7 @@ export type MetadataField =
| StringField | StringField
| NumberField | NumberField
| BooleanField | BooleanField
| LingStringField | LingField
| DateField | DateField
| DateTimeField | DateTimeField
| UrlField | UrlField
@ -96,6 +101,7 @@ export type MetadataField =
| AudioField | AudioField
| PdfField | PdfField
| FileField | FileField
| HtmlField
| GroupField; | GroupField;

@ -29,57 +29,61 @@ const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
function validateByType(key: string, field: MetadataField): ValidationResult { function validateByType(key: string, field: MetadataField): ValidationResult {
switch (field.type) { switch (field.type) {
case 'string': case 'string':
return ok(); return ok();
case 'number': case 'number':
return !isNaN(field.value) return !isNaN(field.value)
? ok() : fail(`[${key}] value must be a valid number`); ? ok() : fail(`[${key}] value must be a valid number`);
case 'boolean': case 'boolean':
return ok(); return ok();
case 'lingstring': case 'ling':
return isLingString(field.value) return isLingString(field.value)
? ok() : fail(`[${key}] value must be a LingString (string | LingRecord | IDLing)`); ? ok() : fail(`[${key}] value must be a LingString (string | LingRecord | IDLing)`);
case 'date': case 'date':
return ISO_DATE_RE.test(field.value) return ISO_DATE_RE.test(field.value)
? ok() : fail(`[${key}] value must be an ISO 8601 date (YYYY-MM-DD)`); ? ok() : fail(`[${key}] value must be an ISO 8601 date (YYYY-MM-DD)`);
case 'datetime': case 'datetime':
return ISO_DATETIME_RE.test(field.value) return ISO_DATETIME_RE.test(field.value)
? ok() : fail(`[${key}] value must be an ISO 8601 datetime (YYYY-MM-DDThh:mm:ssZ)`); ? ok() : fail(`[${key}] value must be an ISO 8601 datetime (YYYY-MM-DDThh:mm:ssZ)`);
case 'url': case 'url':
return isValidUrl(field.value) return isValidUrl(field.value)
? ok() : fail(`[${key}] value must be a valid URL`); ? ok() : fail(`[${key}] value must be a valid URL`);
case 'currency': { case 'currency': {
if (!isCurrencyValue(field.value)) return fail(`[${key}] value must be { amount: number, currency: string }`); if (!isCurrencyValue(field.value)) return fail(`[${key}] value must be { amount: number, currency: string }`);
if (field.value.currency.length !== 3) return fail(`[${key}] currency must be a 3-letter ISO 4217 code`); if (field.value.currency.length !== 3) return fail(`[${key}] currency must be a 3-letter ISO 4217 code`);
return ok(); return ok();
}
case 'image':
case 'video':
case 'audio':
case 'pdf':
case 'file': {
if (!isMediaRef(field.value)) return fail(`[${key}] value must be a MediaRef with a non-empty 'url'`);
if (!isValidUrl(field.value.url)) return fail(`[${key}] MediaRef.url must be a valid URL`);
return ok();
}
case 'group':
return validateMetadata(field.value, key);
default: {
const exhaustive: never = field;
return fail(`Unknown field type: ${(exhaustive as MetadataField).type}`);
}
} }
case 'image':
case 'video':
case 'audio':
case 'pdf':
case 'file': {
if (!isMediaRef(field.value)) return fail(`[${key}] value must be a MediaRef with a non-empty 'url'`);
return ok(); // acepta cualquier string: relativa, absoluta, data:…
}
case 'html': {
if (!isLingString(field.value)) return fail(`[${key}] html value must be a LingString`);
return ok();
}
case 'group':
return validateMetadata(field.value, key);
default: {
const exhaustive: never = field;
return fail(`Unknown field type: ${(exhaustive as MetadataField).type}`);
}
}
} }
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
@ -92,11 +96,11 @@ function validateByType(key: string, field: MetadataField): ValidationResult {
* @param value - el valor en bruto (unknown) almacenado en el Record * @param value - el valor en bruto (unknown) almacenado en el Record
*/ */
export function validateField(key: string, value: unknown): ValidationResult { export function validateField(key: string, value: unknown): ValidationResult {
if (typeof key !== 'string' || key.trim() === '') if (typeof key !== 'string' || key.trim() === '')
return fail('Field key must be a non-empty string'); return fail('Field key must be a non-empty string');
if (!isMetadataField(value)) if (!isMetadataField(value))
return fail(`[${key}] value must be a MetadataField with a known 'type'`); return fail(`[${key}] value must be a MetadataField with a known 'type'`);
return validateByType(key, value); return validateByType(key, value);
} }
/** /**
@ -105,13 +109,15 @@ export function validateField(key: string, value: unknown): ValidationResult {
* @param prefix - prefijo para los mensajes de error en grupos anidados * @param prefix - prefijo para los mensajes de error en grupos anidados
*/ */
export function validateMetadata(metadata: Metadata, prefix = ''): ValidationResult { export function validateMetadata(metadata: Metadata, prefix = ''): ValidationResult {
if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata))
return fail(`${prefix ? `[${prefix}] ` : ''}Metadata must be a plain object`); return fail(`${prefix ? `[${prefix}] ` : ''}Metadata must be a plain object`);
const results = Object.entries(metadata).map(([key, value]) => { const results = Object.entries(metadata)
const fullKey = prefix ? `${prefix}.${key}` : key; .filter(([key]) => key !== 'order')
return validateField(fullKey, value); .map(([key, value]) => {
const fullKey = prefix ? `${prefix}.${key}` : key;
return validateField(fullKey, value);
}); });
return mergeResults(...results); return mergeResults(...results);
} }

@ -0,0 +1,162 @@
<script lang="ts">
import type { MetadataField, MetadataFieldType } from '../../lib';
import { field as f } from '../../lib';
import FieldTypeIcon from './FieldTypeIcon.svelte';
import TypeBadge from './TypeBadge.svelte';
import FieldValueEditor from './FieldValueEditor.svelte';
let {
open = false,
selectedType,
existingKeys = [],
locales = ['es'],
defaultLocale = 'es',
dark = false,
onConfirm,
onClose,
}: {
open?: boolean;
selectedType: MetadataFieldType;
existingKeys?: string[];
locales?: string[];
defaultLocale?: string;
dark?: boolean;
onConfirm: (key: string, field: MetadataField) => void;
onClose: () => void;
} = $props();
let fieldKey = $state('');
let fieldValue = $state<MetadataField>(defaultForType(selectedType));
let keyError = $state('');
let wasOpen = $state(false);
$effect(() => {
if (open && !wasOpen) {
fieldKey = suggestKey(selectedType);
fieldValue = defaultForType(selectedType);
keyError = '';
}
wasOpen = open;
});
function suggestKey(type: MetadataFieldType): string {
let n = 1;
while (existingKeys.includes(`${type}_${n}`)) n++;
return `${type}_${n}`;
}
function todayISO(): string { return new Date().toISOString().slice(0, 10); }
function nowISO(): string { return new Date().toISOString().slice(0, 16) + 'Z'; }
function defaultForType(type: MetadataFieldType): MetadataField {
switch (type) {
case 'string': return f.string('');
case 'number': return f.number(0);
case 'boolean': return f.boolean(false);
case 'ling': return f.ling({ [defaultLocale]: '' } as any);
case 'date': return f.date(todayISO());
case 'datetime': return f.datetime(nowISO());
case 'url': return f.url('');
case 'currency': return f.currency({ amount: 0, currency: 'EUR' });
case 'image': return f.image({ url: '' });
case 'video': return f.video({ url: '' });
case 'audio': return f.audio({ url: '' });
case 'pdf': return f.pdf({ url: '' });
case 'file': return f.file({ url: '' });
case 'html': return f.html({ [defaultLocale]: '' } as any);
case 'group': return f.group({});
}
}
function validateKey(): boolean {
const trimmed = fieldKey.trim();
if (!trimmed) { keyError = 'La clave no puede estar vacía'; return false; }
if (existingKeys.includes(trimmed)){ keyError = `"${trimmed}" ya existe`; return false; }
keyError = '';
return true;
}
function handleConfirm() {
if (!validateKey()) return;
onConfirm(fieldKey.trim(), fieldValue);
onClose();
}
function handleKeydown(e: KeyboardEvent) {
if (open && e.key === 'Escape') onClose();
}
</script>
<svelte:window onkeydown={handleKeydown} />
<dialog
data-theme={dark ? 'dark' : 'light'}
class={['modal', open ? 'modal-open' : ''].join(' ')}
>
<div class="modal-box max-w-md flex flex-col gap-0 p-0 overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between px-5 py-4 border-b border-base-200">
<div class="flex items-center gap-3">
<div class="text-primary">
<FieldTypeIcon type={selectedType} size={20} />
</div>
<div>
<h3 class="font-semibold text-base-content">Nuevo campo</h3>
<div class="mt-0.5">
<TypeBadge type={selectedType} />
</div>
</div>
</div>
<button type="button" onclick={onClose} class="btn btn-ghost btn-sm btn-circle">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Body -->
<div class="overflow-y-auto px-5 py-4 flex flex-col gap-4">
<!-- Key -->
<fieldset class="fieldset w-full">
<legend class="fieldset-legend">
Clave del campo
{#if keyError}
<span class="text-error ml-2 font-normal">{keyError}</span>
{/if}
</legend>
<input
bind:value={fieldKey}
oninput={() => keyError = ''}
onkeydown={(e) => { if (e.key === 'Enter') handleConfirm(); }}
class={['input input-bordered w-full font-mono', keyError ? 'input-error' : ''].join(' ')}
placeholder="nombre_campo"
autofocus
/>
</fieldset>
<!-- Value editor -->
<FieldValueEditor
field={fieldValue}
{locales} {defaultLocale}
onFieldChange={(f) => fieldValue = f}
/>
</div>
<!-- Footer -->
<div class="flex gap-3 px-5 py-4 border-t border-base-200">
<button type="button" onclick={onClose} class="btn btn-ghost flex-1">
Cancelar
</button>
<button type="button" onclick={handleConfirm} class="btn btn-primary flex-1">
Añadir campo
</button>
</div>
</div>
<!-- Backdrop -->
<div class="modal-backdrop bg-black/40" onclick={onClose}></div>
</dialog>

@ -1,168 +1,118 @@
<script lang="ts"> <script lang="ts">
import type { MetadataField, MetadataFieldType } from '../../lib'; import type { MetadataField } from '../../lib';
import LingInput from '@/ling/svelte/LingInput2.svelte'; import FieldValueEditor from './FieldValueEditor.svelte';
let { let {
fieldKey, field, locales, defaultLocale, onKeyChange, onFieldChange, fieldKey, field, locales, defaultLocale,
attributes = [],
onKeyChange, onFieldChange,
}: { }: {
fieldKey: string; fieldKey: string;
field: MetadataField; field: MetadataField;
locales: string[]; locales: string[];
defaultLocale: string; defaultLocale: string;
attributes?: string[];
onKeyChange: (newKey: string) => void; onKeyChange: (newKey: string) => void;
onFieldChange: (field: MetadataField) => void; onFieldChange: (field: MetadataField) => void;
} = $props(); } = $props();
let editKey = $state(fieldKey); let editKey = $state(fieldKey);
let keyError = $state('');
function handleKeyBlur() { // Sincronizar con prop si cambia externamente (p.ej. tras rename en otro sitio)
$effect(() => { editKey = fieldKey; });
let asBase = $derived(field as MetadataField & { hidden?: boolean; attrs?: string[] });
let activeAttrs = $derived(asBase.attrs ?? []);
function handleKeyCommit(): void {
const trimmed = editKey.trim(); const trimmed = editKey.trim();
if (trimmed && trimmed !== fieldKey) onKeyChange(trimmed); if (!trimmed) {
else editKey = fieldKey; keyError = 'La clave no puede estar vacía';
editKey = fieldKey; // revertir
return;
}
keyError = '';
if (trimmed !== fieldKey) onKeyChange(trimmed);
} }
function update(partial: Partial<MetadataField>) { function handleKeydown(e: KeyboardEvent): void {
onFieldChange({ ...field, ...partial } as MetadataField); e.stopPropagation(); // evitar que suba al header de la fila
if (e.key === 'Enter') (e.currentTarget as HTMLInputElement).blur();
if (e.key === 'Escape') { editKey = fieldKey; keyError = ''; (e.currentTarget as HTMLInputElement).blur(); }
} }
function updateMediaUrl(url: string) { function update(partial: Partial<MetadataField & { hidden?: boolean; attrs?: string[] }>): void {
onFieldChange({ ...field, value: { ...(field as any).value, url } } as MetadataField); onFieldChange({ ...field, ...partial } as MetadataField);
} }
// Clases comunes para inputs function toggleAttr(attr: string): void {
const inputCls = 'w-full rounded-lg border-2 border-zinc-200 bg-white px-3.5 py-2.5 text-[14px] text-zinc-800 transition-colors focus:border-zinc-900 focus:outline-none dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200 dark:focus:border-zinc-100'; const current = asBase.attrs ?? [];
const labelCls = 'mb-1.5 block text-[12px] font-semibold uppercase tracking-wide text-zinc-400 dark:text-zinc-500'; const next = current.includes(attr)
? current.filter(a => a !== attr)
: [...current, attr];
update({ attrs: next });
}
</script> </script>
<div class="flex flex-col gap-4 px-5 py-4"> <div class="flex flex-col gap-4 p-4">
<!-- Key --> <!-- Key -->
<div> <fieldset class="fieldset w-full">
<label class={labelCls}>Clave del campo</label> <legend class="fieldset-legend">
Clave del campo
{#if keyError}
<span class="text-error ml-2 font-normal text-xs">{keyError}</span>
{/if}
</legend>
<input <input
bind:value={editKey} value={editKey}
onblur={handleKeyBlur} oninput={(e) => { editKey = (e.currentTarget as HTMLInputElement).value; keyError = ''; }}
onkeydown={(e) => e.key === 'Enter' && (e.currentTarget as HTMLInputElement).blur()} onblur={handleKeyCommit}
class="{inputCls} font-mono" onkeydown={handleKeydown}
class={['input input-bordered w-full font-mono', keyError ? 'input-error' : ''].join(' ')}
placeholder="nombre_campo" placeholder="nombre_campo"
/> />
</div> </fieldset>
<!-- Value per type --> <!-- Value editor by type -->
{#if field.type === 'string'} <FieldValueEditor {field} {locales} {defaultLocale} {onFieldChange} />
<div>
<label class={labelCls}>Valor</label> <!-- Attributes -->
<input type="text" value={field.value} {#if attributes.length > 0}
oninput={(e) => update({ value: (e.currentTarget as HTMLInputElement).value })} <div class="divider my-0"></div>
class={inputCls} placeholder="Texto…" /> <div class="flex flex-col gap-2">
</div> <span class="text-base-content/50">Atributos</span>
<div class="flex flex-wrap gap-2">
{:else if field.type === 'number'} {#each attributes as attr (attr)}
<div> {@const active = activeAttrs.includes(attr)}
<label class={labelCls}>Valor numérico</label> <button
<input type="number" value={field.value} type="button"
oninput={(e) => update({ value: parseFloat((e.currentTarget as HTMLInputElement).value) })} onclick={() => toggleAttr(attr)}
class={inputCls} /> class={['badge badge-md cursor-pointer select-none transition-colors',
</div> active ? 'badge-primary' : 'badge-ghost border border-base-300'
].join(' ')}
{:else if field.type === 'boolean'} >
<div> {#if active}
<label class={labelCls}>Valor</label> <svg class="mr-1 h-3 w-3" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
<div class="flex items-center gap-3"> <path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M2 6l3 3 5-5"/>
<button type="button" onclick={() => update({ value: !field.value })} </svg>
class={['relative h-7 w-12 rounded-full transition-colors duration-200', {/if}
field.value ? 'bg-zinc-900 dark:bg-zinc-100' : 'bg-zinc-200 dark:bg-zinc-700'].join(' ')}> {attr}
<span class={['absolute top-1 h-5 w-5 rounded-full bg-white shadow-md transition-transform duration-200', </button>
field.value ? 'translate-x-6 dark:bg-zinc-900' : 'translate-x-1'].join(' ')}></span> {/each}
</button>
<span class="text-[14px] font-medium text-zinc-700 dark:text-zinc-300">
{field.value ? 'Verdadero' : 'Falso'}
</span>
</div>
</div>
{:else if field.type === 'lingstring'}
<LingInput
value={field.value as any}
{locales}
{defaultLocale}
label="Valor multiidioma"
as="input"
onUpdate={(val) => update({ value: val })}
/>
{:else if field.type === 'date'}
<div>
<label class={labelCls}>Fecha</label>
<input type="date" value={field.value}
oninput={(e) => update({ value: (e.currentTarget as HTMLInputElement).value })}
class={inputCls} />
</div>
{:else if field.type === 'datetime'}
<div>
<label class={labelCls}>Fecha y hora</label>
<input type="datetime-local" value={field.value?.replace('Z', '')}
oninput={(e) => update({ value: (e.currentTarget as HTMLInputElement).value + 'Z' })}
class={inputCls} />
</div>
{:else if field.type === 'url'}
<div>
<label class={labelCls}>URL</label>
<input type="text" value={field.value}
oninput={(e) => update({ value: (e.currentTarget as HTMLInputElement).value })}
class={inputCls} placeholder="https://… o /ruta/relativa" />
</div>
{:else if field.type === 'currency'}
<div class="flex gap-3">
<div class="flex-1">
<label class={labelCls}>Cantidad</label>
<input type="number" value={field.value.amount}
oninput={(e) => update({ value: { ...field.value, amount: parseFloat((e.currentTarget as HTMLInputElement).value) } })}
class={inputCls} />
</div>
<div class="w-28">
<label class={labelCls}>Moneda (ISO)</label>
<input type="text" maxlength="3" value={field.value.currency}
oninput={(e) => update({ value: { ...field.value, currency: (e.currentTarget as HTMLInputElement).value.toUpperCase() } })}
class="{inputCls} font-mono uppercase" placeholder="EUR" />
</div> </div>
</div> </div>
{:else if field.type === 'image' || field.type === 'video' || field.type === 'audio' || field.type === 'pdf' || field.type === 'file'}
<div>
<label class={labelCls}>Ruta o URL</label>
<input type="text" value={field.value.url}
oninput={(e) => updateMediaUrl((e.currentTarget as HTMLInputElement).value)}
class={inputCls} placeholder="/ruta/relativa o https://…" />
</div>
{#if field.type === 'image' && field.value.url}
<div class="overflow-hidden rounded-xl border-2 border-zinc-100 dark:border-zinc-800">
<img src={field.value.url} alt={fieldKey}
class="max-h-52 w-full object-cover" />
</div>
{/if}
{:else if field.type === 'group'}
<p class="text-[13px] text-zinc-400">
Edita los campos del grupo expandiéndolos en la lista.
</p>
{/if} {/if}
<!-- Hidden toggle --> <!-- Hidden toggle -->
<div class="flex items-center justify-between border-t border-zinc-100 pt-4 dark:border-zinc-800"> <div class="divider my-0"></div>
<div> <div class="flex items-center gap-4">
<p class="text-[13px] font-semibold text-zinc-700 dark:text-zinc-300">Ocultar campo</p> <span>Ocultar campo</span>
<p class="text-[12px] text-zinc-400">El campo no se mostrará en la UI</p> <input type="checkbox" checked={asBase.hidden ?? false}
</div> onchange={(e) => update({ hidden: (e.currentTarget as HTMLInputElement).checked })}
<button type="button" onclick={() => update({ hidden: !field.hidden })} class="toggle toggle-sm" />
class={['relative h-7 w-12 rounded-full transition-colors duration-200', <span class="text-base-content/50">{asBase.hidden ? 'No se muestra en la UI' : 'Visible'}</span>
field.hidden ? 'bg-zinc-900 dark:bg-zinc-100' : 'bg-zinc-200 dark:bg-zinc-700'].join(' ')}>
<span class={['absolute top-1 h-5 w-5 rounded-full bg-white shadow-md transition-transform duration-200',
field.hidden ? 'translate-x-6 dark:bg-zinc-900' : 'translate-x-1'].join(' ')}></span>
</button>
</div> </div>
</div> </div>

@ -3,44 +3,58 @@
import { validateField, field as f } from '../../lib'; import { validateField, field as f } from '../../lib';
import TypeBadge from './TypeBadge.svelte'; import TypeBadge from './TypeBadge.svelte';
import FieldEditor from './FieldEditor.svelte'; import FieldEditor from './FieldEditor.svelte';
import FieldRowR from './FieldRow.svelte'; import FieldRowRecursive from './FieldRow.svelte';
import AddFieldDrawer from "@/mtas/svelte/components/AddFieldDrawer.svelte";
let { let {
fieldKey, field, locales, defaultLocale, fieldKey, field, locales, defaultLocale,
depth = 0, depth = 0,
attributes = [],
canMoveUp = false,
canMoveDown = false,
onKeyChange, onFieldChange, onDelete, onKeyChange, onFieldChange, onDelete,
onDragStart, onDragOver, onDrop, onMoveUp, onMoveDown,
}: { }: {
fieldKey: string; fieldKey: string;
field: MetadataField; field: MetadataField;
locales: string[]; locales: string[];
defaultLocale: string; defaultLocale: string;
depth?: number; depth?: number;
attributes?: string[];
canMoveUp?: boolean;
canMoveDown?: boolean;
onKeyChange: (newKey: string) => void; onKeyChange: (newKey: string) => void;
onFieldChange: (field: MetadataField) => void; onFieldChange: (field: MetadataField) => void;
onDelete: () => void; onDelete: () => void;
onDragStart: (e: DragEvent) => void; onMoveUp?: () => void;
onDragOver: (e: DragEvent) => void; onMoveDown?: () => void;
onDrop: (e: DragEvent) => void;
} = $props(); } = $props();
let expanded = $state(false); // ── State ────────────────────────────────────────────────────────
let dragOver = $state(false); let fieldBase = $derived(field as typeof field & { hidden?: boolean });
let groupOpen = $state(true); let expanded = $state(false);
let groupOpen = $state(true);
let groupCollapseV = $state(0); // sube al colapsar → fuerza recrear hijos
let childDragSrc = $state<string | null>(null); let childDragSrc = $state<string | null>(null);
let childDragOver = $state<string | null>(null); let drawerOpen = $state(false);
let drawerType = $state<MetadataFieldType>('string');
let validation = $derived(validateField(fieldKey, field)); const ALL_TYPES: MetadataFieldType[] = [
'string','number','boolean','ling','date','datetime',
'url','currency','image','video','audio','pdf','file','html','group'
];
// ── Derived ──────────────────────────────────────────────────────
let validationResult = $derived(validateField(fieldKey, field));
let validationErrors = $derived((validationResult as { valid: boolean; errors?: string[] }).errors ?? []);
// ── Summary value for collapsed row ─────────────────────────────
let summary = $derived((() => { let summary = $derived((() => {
switch (field.type) { switch (field.type) {
case 'string': return field.value || '—'; case 'string': return field.value || '—';
case 'number': return String(field.value); case 'number': return String(field.value);
case 'boolean': return field.value ? 'Verdadero' : 'Falso'; case 'boolean': return field.value ? 'Verdadero' : 'Falso';
case 'lingstring': return typeof field.value === 'string' case 'ling':
? field.value case 'html': return Object.values(field.value as Record<string, string>)[0] ?? '—';
: (Object.values(field.value as Record<string, string>)[0] ?? '—');
case 'date': case 'date':
case 'datetime': return field.value || '—'; case 'datetime': return field.value || '—';
case 'url': return field.value || '—'; case 'url': return field.value || '—';
@ -55,246 +69,277 @@
})()); })());
let thumbUrl = $derived( let thumbUrl = $derived(
field.type === 'image' && (field.value as { url: string }).url field.type === 'image' ? (field.value as { url: string }).url || null : null
? (field.value as { url: string }).url : null
); );
// ── Child group operations ─────────────────────────────────────── // ── Group operations ─────────────────────────────────────────────
function defaultChild(type: MetadataFieldType): MetadataField { function openChildDrawer(type: MetadataFieldType): void {
switch (type) { drawerType = type;
case 'string': return f.string(''); drawerOpen = true;
case 'number': return f.number(0); groupOpen = true;
case 'boolean': return f.boolean(false);
case 'lingstring': return f.lingstring({ [defaultLocale]: '' } as any);
case 'date': return f.date('');
case 'datetime': return f.datetime('');
case 'url': return f.url('');
case 'currency': return f.currency({ amount: 0, currency: 'EUR' });
case 'image': return f.image({ url: '' });
case 'video': return f.video({ url: '' });
case 'audio': return f.audio({ url: '' });
case 'pdf': return f.pdf({ url: '' });
case 'file': return f.file({ url: '' });
case 'group': return f.group({});
}
}
function uniqueChildKey(type: MetadataFieldType): string {
if (field.type !== 'group') return type;
let n = 1;
while (`${type}_${n}` in field.value) n++;
return `${type}_${n}`;
} }
function addChildField(type: MetadataFieldType) { function handleDrawerConfirm(key: string, childField: MetadataField): void {
if (field.type !== 'group') return; if (field.type !== 'group') return;
const key = uniqueChildKey(type); onFieldChange({ ...field, value: { ...field.value, [key]: childField } } as MetadataField);
onFieldChange({ ...field, value: { ...field.value, [key]: defaultChild(type) } } as MetadataField);
groupOpen = true;
} }
function updateChildField(childKey: string, childField: MetadataField) { function updateChildField(childKey: string, childField: MetadataField): void {
if (field.type !== 'group') return; if (field.type !== 'group') return;
onFieldChange({ ...field, value: { ...field.value, [childKey]: childField } } as MetadataField); onFieldChange({ ...field, value: { ...field.value, [childKey]: childField } } as MetadataField);
} }
function renameChildField(oldKey: string, newKey: string) { function renameChildField(oldKey: string, newKey: string): void {
if (field.type !== 'group' || newKey === oldKey || newKey in field.value) return; if (field.type !== 'group' || newKey === oldKey || newKey in field.value) return;
const entries = Object.entries(field.value).map(([k, v]) => k === oldKey ? [newKey, v] : [k, v]); const entries = Object.entries(field.value).map(([k, v]) => k === oldKey ? [newKey, v] : [k, v]);
onFieldChange({ ...field, value: Object.fromEntries(entries) } as MetadataField); onFieldChange({ ...field, value: Object.fromEntries(entries) } as MetadataField);
} }
function deleteChildField(childKey: string) { function deleteChildField(childKey: string): void {
if (field.type !== 'group') return; if (field.type !== 'group') return;
const { [childKey]: _, ...rest } = field.value; const { [childKey]: _, ...rest } = field.value;
onFieldChange({ ...field, value: rest } as MetadataField); onFieldChange({ ...field, value: rest } as MetadataField);
} }
function dropChildField(targetKey: string) { function moveChildField(childKey: string, dir: -1 | 1): void {
if (field.type !== 'group' || !childDragSrc || childDragSrc === targetKey) return; if (field.type !== 'group') return;
const entries = Object.entries(field.value); const entries = Object.entries(field.value);
const srcIdx = entries.findIndex(([k]) => k === childDragSrc); const idx = entries.findIndex(([k]) => k === childKey);
const tgtIdx = entries.findIndex(([k]) => k === targetKey); const newIdx = idx + dir;
if (srcIdx === -1 || tgtIdx === -1) return; if (newIdx < 0 || newIdx >= entries.length) return;
const reordered = [...entries]; const reordered = [...entries];
const [moved] = reordered.splice(srcIdx, 1); const [moved] = reordered.splice(idx, 1);
reordered.splice(tgtIdx, 0, moved); reordered.splice(newIdx, 0, moved);
childDragSrc = null; childDragOver = null;
onFieldChange({ ...field, value: Object.fromEntries(reordered) } as MetadataField); onFieldChange({ ...field, value: Object.fromEntries(reordered) } as MetadataField);
} }
// ── UI helpers ───────────────────────────────────────────────────
function toggleExpanded(): void {
expanded = !expanded;
if (!expanded && field.type === 'group') {
groupOpen = false;
groupCollapseV++;
}
}
function handleExpandKey(e: KeyboardEvent): void {
if (e.key === 'Enter') {
expanded = !expanded;
} else if (e.key === 'ArrowUp' && canMoveUp) {
e.preventDefault();
onMoveUp?.();
requestAnimationFrame(() => requestAnimationFrame(() => {
const row = document.getElementById(`field-row-${fieldKey}`);
row?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
row?.querySelector<HTMLElement>('[role="button"]')?.focus();
}));
} else if (e.key === 'ArrowDown' && canMoveDown) {
e.preventDefault();
onMoveDown?.();
requestAnimationFrame(() => requestAnimationFrame(() => {
const row = document.getElementById(`field-row-${fieldKey}`);
row?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
row?.querySelector<HTMLElement>('[role="button"]')?.focus();
}));
}
}
function asField(v: unknown): MetadataField { return v as MetadataField; }
</script> </script>
<div <div
draggable="true" id="field-row-{fieldKey}"
ondragstart={onDragStart} role="listitem"
ondragover={(e) => { e.preventDefault(); dragOver = true; onDragOver(e); }} aria-label={fieldKey}
ondragleave={() => dragOver = false}
ondrop={(e) => { dragOver = false; onDrop(e); }}
class={[ class={[
'group/row overflow-hidden rounded-xl border-2 bg-white transition-all duration-150 dark:bg-zinc-900', 'card bg-base-100 border transition-all duration-150 flex-row focus-within:border-base-content w-full',
depth > 0 ? 'ml-5' : '', depth > 0 ? 'ml-5' : '',
dragOver !validationResult.valid ? 'border-error'
? 'border-zinc-400 dark:border-zinc-500' : expanded ? 'border-base-300 shadow-sm'
: validation.valid : 'border-base-200 hover:border-base-300',
? expanded
? 'border-zinc-900 dark:border-zinc-100'
: 'border-zinc-200 hover:border-zinc-300 dark:border-zinc-700 dark:hover:border-zinc-600'
: 'border-red-300 dark:border-red-700',
].join(' ')} ].join(' ')}
> >
<!-- ── Row header ─────────────────────────────────────────────── --> <!-- ── Move buttons (fuera del header clickable) ──────────────── -->
<div {#if depth === 0}
role="button" tabindex="0" <div class="flex shrink-0 flex-col items-center justify-center gap-0.5 border-r border-base-200 px-1">
onclick={() => expanded = !expanded} <button type="button"
onkeydown={(e) => e.key === 'Enter' && (expanded = !expanded)} onclick={() => onMoveUp?.()}
class={[ disabled={!canMoveUp}
'flex cursor-pointer items-center gap-3 px-4 transition-colors duration-100', class="btn-move-up btn btn-ghost btn-xs btn-square h-5 min-h-0 disabled:opacity-20"
thumbUrl ? 'py-2' : 'py-3', title="Subir">
expanded ? 'bg-zinc-50 dark:bg-zinc-800/50' : '', <svg class="h-3 w-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2" xmlns="http://www.w3.org/2000/svg">
].join(' ')} <path d="M2 8l4-4 4 4"/>
>
<!-- Drag handle -->
<div class="shrink-0 cursor-grab opacity-0 transition-opacity group-hover/row:opacity-30 active:cursor-grabbing"
onclick={(e) => e.stopPropagation()}>
<svg class="h-4 w-4 text-zinc-400" viewBox="0 0 16 16" fill="currentColor">
<circle cx="5" cy="4" r="1.3"/><circle cx="5" cy="8" r="1.3"/><circle cx="5" cy="12" r="1.3"/>
<circle cx="11" cy="4" r="1.3"/><circle cx="11" cy="8" r="1.3"/><circle cx="11" cy="12" r="1.3"/>
</svg>
</div>
<!-- Image thumb -->
{#if thumbUrl}
<div class="h-12 w-12 shrink-0 overflow-hidden rounded-lg border border-zinc-100 dark:border-zinc-800">
<img src={thumbUrl} alt={fieldKey} class="h-full w-full object-cover" />
</div>
{/if}
<!-- Type badge -->
<TypeBadge type={field.type} />
<!-- Key -->
<span class="shrink-0 text-[14px] font-semibold text-zinc-800 dark:text-zinc-200">
{fieldKey}
</span>
<!-- Summary -->
<span class="min-w-0 flex-1 truncate text-[13px] text-zinc-400 dark:text-zinc-500">
{summary}
</span>
<!-- Hidden badge -->
{#if field.hidden}
<span class="shrink-0 rounded-md bg-zinc-100 px-2 py-0.5 text-[12px] text-zinc-400 dark:bg-zinc-800 dark:text-zinc-500">
oculto
</span>
{/if}
<!-- Validation indicator -->
<div class={[
'h-2 w-2 shrink-0 rounded-full',
validation.valid ? 'bg-emerald-400' : 'bg-red-400',
].join(' ')}></div>
<!-- Actions -->
<div class="flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover/row:opacity-100"
onclick={(e) => e.stopPropagation()}>
<button type="button" onclick={() => expanded = !expanded}
class="rounded-lg p-1.5 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-700 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
title="Editar">
<svg class="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M11.5 2.5l2 2-9 9H2.5v-2l9-9z"/>
</svg> </svg>
</button> </button>
<button type="button" onclick={onDelete} <button type="button"
class="rounded-lg p-1.5 text-zinc-400 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20" onclick={() => onMoveDown?.()}
title="Eliminar"> disabled={!canMoveDown}
<svg class="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"> class="btn-move-down btn btn-ghost btn-xs btn-square h-5 min-h-0 disabled:opacity-20"
<path d="M3 4h10M6 4V2.5h4V4M5 4l.5 9h5l.5-9"/> title="Bajar">
<svg class="h-3 w-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2" xmlns="http://www.w3.org/2000/svg">
<path d="M2 4l4 4 4-4"/>
</svg> </svg>
</button> </button>
</div> </div>
{/if}
<!-- Chevron --> <div class="flex-1 min-w-0">
<svg class={['h-4 w-4 shrink-0 text-zinc-300 transition-transform duration-200 dark:text-zinc-600', expanded ? 'rotate-180' : ''].join(' ')} <!-- ── Row header ─────────────────────────────────────────────── -->
viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2"> <div
<path d="M4 6l4 4 4-4"/> role="button"
</svg> tabindex="0"
</div>
<!-- ── Validation errors ──────────────────────────────────────── --> onkeydown={handleExpandKey}
{#if !validation.valid && (validation as any).errors?.length} class={[
<div class="flex items-center gap-2 border-t border-red-100 bg-red-50/80 px-4 py-2.5 dark:border-red-900/30 dark:bg-red-900/10"> 'flex cursor-pointer items-center gap-3 px-4 focus-visible:outline-none',
<svg class="h-4 w-4 shrink-0 text-red-400" viewBox="0 0 16 16" fill="currentColor"> thumbUrl ? 'py-2' : 'py-3',
<path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm0 3.5a.75.75 0 0 1 .75.75v3a.75.75 0 0 1-1.5 0v-3A.75.75 0 0 1 8 4.5zm0 7a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/> expanded ? 'bg-base-200/50' : 'hover:bg-base-200/30',
</svg> ].join(' ')}
<span class="text-[13px] text-red-500">{(validation as any).errors[0]}</span> >
</div>
{/if}
<!-- ── Inline editor ──────────────────────────────────────────── --> <!-- Validation dot -->
{#if expanded} <div
<div class="border-t border-zinc-100 dark:border-zinc-800"> role="img"
<FieldEditor aria-label={validationResult.valid ? 'Campo válido' : 'Campo inválido'}
{fieldKey} {field} {locales} {defaultLocale} class={['h-2 w-2 shrink-0 rounded-full', validationResult.valid ? 'bg-success' : 'bg-error'].join(' ')}
{onKeyChange} ></div>
onFieldChange={(f) => onFieldChange(f)}
/> <!-- Type badge -->
</div> <TypeBadge type={field.type} showLabel={false} />
{/if}
<!-- Key -->
<span class="shrink-0 text-base-content">{fieldKey}</span>
<!-- Summary -->
<span class="min-w-0 flex-1 truncate text-base-content/40">{summary}</span>
<!-- Image thumb — right side -->
{#if thumbUrl}
<div class="h-10 w-10 shrink-0 overflow-hidden rounded-box border border-base-300">
<img src={thumbUrl} alt={fieldKey} class="h-full w-full object-cover" />
</div>
{/if}
<!-- ── Group children ────────────────────────────────────────── --> <!-- Hidden badge -->
{#if field.type === 'group'} {#if fieldBase.hidden}
<div class="border-t border-zinc-100 dark:border-zinc-800"> <span class="badge badge-sm badge-neutral badge-soft">oculto</span>
{/if}
<!-- Group toolbar: toggle + add type buttons --> <!-- Actions -->
<div class="flex flex-wrap items-center gap-2 bg-zinc-50/70 px-4 py-2.5 dark:bg-zinc-800/30"> <div role="group" aria-label="Acciones" class="flex shrink-0 items-center gap-1">
<button type="button" onclick={() => groupOpen = !groupOpen} <button type="button"
class="flex items-center gap-2 text-[13px] font-medium text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200"> onclick={(e) => {
<svg class={['h-4 w-4 transition-transform duration-150', groupOpen ? 'rotate-90' : ''].join(' ')} toggleExpanded(); }}
viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2"> class="btn btn-ghost btn-xs btn-square" title="Editar">
<path d="M6 4l4 4-4 4"/> <svg class="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg">
<path d="M11.5 2.5l2 2-9 9H2.5v-2l9-9z"/>
</svg>
</button>
<button type="button"
onclick={(e) => { e.stopPropagation(); onDelete(); }}
class="btn btn-ghost btn-xs btn-square text-error" title="Eliminar">
<svg class="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg">
<path d="M3 4h10M6 4V2.5h4V4M5 4l.5 9h5l.5-9"/>
</svg> </svg>
{Object.keys(field.value).length} campos anidados
</button> </button>
</div>
<!-- Chevron -->
<button type="button"
onclick={toggleExpanded}
aria-label={expanded ? 'Colapsar' : 'Expandir'}
class="btn btn-ghost btn-xs btn-square h-5 min-h-0">
<svg
aria-hidden="true"
class={['h-4 w-4 shrink-0 text-base-content/30 transition-transform duration-200', expanded ? 'rotate-180' : ''].join(' ')}
viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" xmlns="http://www.w3.org/2000/svg">
<path d="M4 6l4 4 4-4"/>
</svg>
</button>
</div>
<div class="h-4 w-px bg-zinc-200 dark:bg-zinc-700"></div> <!-- ── Validation errors ──────────────────────────────────────── -->
{#if !validationResult.valid && validationErrors.length}
<div class="flex items-center gap-2 border-t border-error/20 bg-error/5 px-4 py-2.5">
<svg aria-hidden="true" class="h-4 w-4 shrink-0 text-error" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm0 3.5a.75.75 0 0 1 .75.75v3a.75.75 0 0 1-1.5 0v-3A.75.75 0 0 1 8 4.5zm0 7a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
</svg>
<span class="text-error">{validationErrors[0]}</span>
</div>
{/if}
<!-- Add type buttons --> <!-- ── Inline editor ──────────────────────────────────────────── -->
{#each (['string','number','boolean','lingstring','date','datetime','url','currency','image','video','audio','pdf','file','group'] as MetadataFieldType[]) as type} {#if expanded}
<button type="button" title="Añadir {type}" onclick={() => addChildField(type)} <div class="border-t border-base-200">
class="rounded-lg p-0.5 transition-colors hover:bg-zinc-200 dark:hover:bg-zinc-700"> <FieldEditor
<TypeBadge {type} /> {fieldKey} {field} {locales} {defaultLocale}
</button> {attributes}
{/each} {onKeyChange}
onFieldChange={(f) => onFieldChange(f)}
/>
</div> </div>
{/if}
<!-- Child rows --> <!-- ── Group children ────────────────────────────────────────── -->
{#if groupOpen} {#if field.type === 'group'}
<div class="flex flex-col gap-2 p-3"> <div class="border-t border-base-200">
{#if Object.keys(field.value).length === 0} <div class="flex flex-wrap items-center gap-2 bg-base-200/40 px-4 py-2.5">
<div class="flex items-center justify-center rounded-xl border-2 border-dashed border-zinc-200 py-6 dark:border-zinc-700"> <button type="button" onclick={() => groupOpen = !groupOpen}
<span class="text-[13px] text-zinc-300 dark:text-zinc-600"> class="btn btn-ghost btn-xs gap-1.5">
Grupo vacío — añade campos con los botones de arriba <svg aria-hidden="true"
</span> class={['h-4 w-4 transition-transform duration-150', groupOpen ? 'rotate-90' : ''].join(' ')}
</div> viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" xmlns="http://www.w3.org/2000/svg">
{:else} <path d="M6 4l4 4-4 4"/>
{#each Object.keys(field.value) as childKey (childKey)} </svg>
<FieldRowR {Object.keys(field.value).length} campos anidados
fieldKey={childKey} </button>
field={field.value[childKey] as MetadataField} <div class="divider divider-horizontal mx-0 h-4"></div>
{locales} {defaultLocale} {#each ALL_TYPES as type (type)}
depth={depth + 1} <button type="button" title="Añadir {type}" onclick={() => openChildDrawer(type)}
onKeyChange={(newKey) => renameChildField(childKey, newKey)} class="btn btn-ghost btn-xs p-0.5">
onFieldChange={(f) => updateChildField(childKey, f)} <TypeBadge {type} showLabel={false} />
onDelete={() => deleteChildField(childKey)} </button>
onDragStart={(e) => { e.dataTransfer?.setData('text/plain', `${fieldKey}::${childKey}`); childDragSrc = childKey; }} {/each}
onDragOver={() => { childDragOver = childKey; }}
onDrop={() => dropChildField(childKey)}
/>
{/each}
{/if}
</div> </div>
{/if} {#if groupOpen}
</div> <div class="flex flex-col gap-2 p-3">
{/if} {#if Object.keys(field.value).length === 0}
<div class="rounded-box flex items-center justify-center border-2 border-dashed border-base-300 py-6">
<span class="text-base-content/30">Grupo vacío — añade campos con los botones de arriba</span>
</div>
{:else}
{#each Object.keys(field.value) as childKey, ci (`${childKey}-${groupCollapseV}`)}
<FieldRowRecursive
fieldKey={childKey}
field={asField(field.value[childKey])}
{locales} {defaultLocale}
{attributes}
depth={depth + 1}
canMoveUp={ci > 0}
canMoveDown={ci < Object.keys(field.value).length - 1}
onKeyChange={(newKey) => renameChildField(childKey, newKey)}
onFieldChange={(f) => updateChildField(childKey, f)}
onDelete={() => deleteChildField(childKey)}
onMoveUp={() => moveChildField(childKey, -1)}
onMoveDown={() => moveChildField(childKey, 1)}
/>
{/each}
{/if}
</div>
{/if}
</div>
{/if}
</div><!-- end flex-1 -->
</div> </div>
<!-- Drawer for adding child fields -->
{#if field.type === 'group'}
<AddFieldDrawer
open={drawerOpen}
selectedType={drawerType}
existingKeys={Object.keys(field.value)}
{locales}
{defaultLocale}
onConfirm={handleDrawerConfirm}
onClose={() => drawerOpen = false}
/>
{/if}

@ -0,0 +1,96 @@
<!-- FieldTypeIcon.svelte — icono SVG por tipo de campo -->
<script lang="ts">
import type { MetadataFieldType } from 'meta-content';
let {
type,
size = 16,
class: cls = '',
}: {
type: MetadataFieldType;
size?: number;
class?: string;
} = $props();
</script>
{#if type === 'string'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/>
</svg>
{:else if type === 'number'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
</svg>
{:else if type === 'boolean'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<rect x="1" y="5" width="22" height="14" rx="7"/><circle cx="16" cy="12" r="3" fill="currentColor" stroke="none"/>
</svg>
{:else if type === 'ling'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
{:else if type === 'date'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
</svg>
{:else if type === 'datetime'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
<polyline points="12 14 12 17 14 17"/>
</svg>
{:else if type === 'url'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
{:else if type === 'currency'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/>
</svg>
{:else if type === 'image'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
{:else if type === 'video'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2"/>
</svg>
{:else if type === 'audio'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>
</svg>
{:else if type === 'pdf'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
<line x1="9" y1="15" x2="15" y2="15"/>
</svg>
{:else if type === 'file'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
</svg>
{:else if type === 'html'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>
</svg>
{:else if type === 'group'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<rect x="2" y="3" width="8" height="8" rx="1"/><rect x="14" y="3" width="8" height="8" rx="1"/>
<rect x="2" y="13" width="8" height="8" rx="1"/><rect x="14" y="13" width="8" height="8" rx="1"/>
</svg>
{/if}

@ -2,29 +2,73 @@
import type { Metadata, MetadataField, MetadataFieldType } from '../../lib'; import type { Metadata, MetadataField, MetadataFieldType } from '../../lib';
import { validateMetadata, field as f } from '../../lib'; import { validateMetadata, field as f } from '../../lib';
import FieldRow from './FieldRow.svelte'; import FieldRow from './FieldRow.svelte';
import TypeBadge from './TypeBadge.svelte'; import FieldTypeIcon from './FieldTypeIcon.svelte';
import AddFieldDrawer from './AddFieldDrawer.svelte';
import {makeLingRecord} from "@/ling/lib";
import { flip } from 'svelte/animate';
let { let {
value = {}, value = $bindable<Metadata>({}),
locales = ['es'], locales = ['es'],
defaultLocale = 'es', defaultLocale = 'es',
dark = false, dark = false,
attributes = [],
onUpdate, onUpdate,
}: { }: {
value?: Metadata; value?: Metadata;
locales?: string[]; locales?: string[];
defaultLocale?: string; defaultLocale?: string;
dark?: boolean; dark?: boolean;
attributes?: string[];
onUpdate?: (metadata: Metadata) => void; onUpdate?: (metadata: Metadata) => void;
} = $props(); } = $props();
let dragSrcKey = $state<string | null>(null); let drawerOpen = $state(false);
let drawerType = $state<MetadataFieldType>('string');
// ── Pagination ───────────────────────────────────────────────────
let page = $state(1);
let pageSize = $state(10);
const PAGE_SIZES = [5, 10, 20, 50];
let keyOrder = $derived(
value.order ?? Object.keys(value).filter(k => k !== 'order')
);
let totalPages = $derived(
pageSize === 0 ? 1 : Math.max(1, Math.ceil(keyOrder.length / pageSize))
);
let pageKeys = $derived(
pageSize === 0 ? keyOrder : keyOrder.slice((page - 1) * pageSize, page * pageSize)
);
let rangeStart = $derived(pageSize === 0 ? 1 : (page - 1) * pageSize + 1);
let rangeEnd = $derived(pageSize === 0 ? keyOrder.length : Math.min(page * pageSize, keyOrder.length));
// Mantener page dentro de los límites
$effect(() => { if (page > totalPages) page = Math.max(1, totalPages); });
// Páginas a mostrar en la barra (con elipsis = -1)
let paginationPages = $derived((() => {
if (totalPages <= 7) return Array.from({ length: totalPages }, (_, i) => i + 1);
const pages: number[] = [1];
if (page > 3) pages.push(-1);
for (let p = Math.max(2, page - 1); p <= Math.min(totalPages - 1, page + 1); p++) pages.push(p);
if (page < totalPages - 2) pages.push(-1);
pages.push(totalPages);
return pages;
})());
let validation = $derived(validateMetadata(value)); let validation = $derived(validateMetadata(value));
let fieldKeys = $derived(Object.keys(value));
const typeLabels: Record<MetadataFieldType, string> = {
string: 'Texto', number: 'Número', boolean: 'Bool', ling: 'i18n',
date: 'Fecha', datetime: 'DateTime', url: 'URL', currency: 'Moneda',
image: 'Imagen', video: 'Vídeo', audio: 'Audio', pdf: 'PDF',
file: 'Fichero', html: 'HTML', group: 'Grupo',
};
const typeGroups: { label: string; types: MetadataFieldType[] }[] = [ const typeGroups: { label: string; types: MetadataFieldType[] }[] = [
{ label: 'Texto', types: ['string', 'lingstring', 'url'] }, { label: 'Texto', types: ['string', 'ling', 'html', 'url'] },
{ label: 'Número', types: ['number', 'currency'] }, { label: 'Número', types: ['number', 'currency'] },
{ label: 'Fecha', types: ['date', 'datetime'] }, { label: 'Fecha', types: ['date', 'datetime'] },
{ label: 'Lógico', types: ['boolean'] }, { label: 'Lógico', types: ['boolean'] },
@ -32,12 +76,14 @@
{ label: 'Grupo', types: ['group'] }, { label: 'Grupo', types: ['group'] },
]; ];
function emit(): void { onUpdate?.(value); }
function defaultField(type: MetadataFieldType): MetadataField { function defaultField(type: MetadataFieldType): MetadataField {
switch (type) { switch (type) {
case 'string': return f.string(''); case 'string': return f.string('');
case 'number': return f.number(0); case 'number': return f.number(0);
case 'boolean': return f.boolean(false); case 'boolean': return f.boolean(false);
case 'lingstring': return f.lingstring({ [defaultLocale]: '' } as any); case 'ling': return f.ling(makeLingRecord(''));
case 'date': return f.date(''); case 'date': return f.date('');
case 'datetime': return f.datetime(''); case 'datetime': return f.datetime('');
case 'url': return f.url(''); case 'url': return f.url('');
@ -47,145 +93,215 @@
case 'audio': return f.audio({ url: '' }); case 'audio': return f.audio({ url: '' });
case 'pdf': return f.pdf({ url: '' }); case 'pdf': return f.pdf({ url: '' });
case 'file': return f.file({ url: '' }); case 'file': return f.file({ url: '' });
case 'html': return f.html(makeLingRecord(''));
case 'group': return f.group({}); case 'group': return f.group({});
} }
} }
function uniqueKey(type: MetadataFieldType): string { function openDrawer(type: MetadataFieldType): void { drawerType = type; drawerOpen = true; }
let n = 1;
while (`${type}_${n}` in value) n++;
return `${type}_${n}`;
}
function addField(type: MetadataFieldType) { function handleDrawerConfirm(key: string, field: MetadataField): void {
emit({ ...value, [uniqueKey(type)]: defaultField(type) }); const newOrder = [...keyOrder, key];
value = { ...value, [key]: field, order: newOrder };
emit();
// Ir a la última página donde aparece el nuevo campo
if (pageSize > 0) page = Math.ceil(newOrder.length / pageSize);
} }
function updateFieldKey(oldKey: string, newKey: string) { function updateFieldKey(oldKey: string, newKey: string): void {
if (newKey === oldKey || newKey in value) return; if (newKey === oldKey || newKey in value) return;
const entries = Object.entries(value).map(([k, v]) => k === oldKey ? [newKey, v] : [k, v]); const next: Metadata = {};
emit(Object.fromEntries(entries)); for (const k of Object.keys(value)) {
} if (k === 'order') continue;
next[k === oldKey ? newKey : k] = value[k];
function updateField(key: string, field: MetadataField) { }
emit({ ...value, [key]: field }); next.order = keyOrder.map(k => k === oldKey ? newKey : k);
value = next;
emit();
} }
function deleteField(key: string) { function updateField(key: string, field: MetadataField): void {
const { [key]: _, ...rest } = value; value = { ...value, [key]: field };
emit(rest); emit();
} }
function emit(next: Metadata) { function deleteField(key: string): void {
const next = { ...value };
delete next[key];
next.order = keyOrder.filter(k => k !== key);
value = next; value = next;
onUpdate?.(next); emit();
} }
function handleDrop(targetKey: string) { function getField(key: string): MetadataField { return value[key] as MetadataField; }
if (!dragSrcKey || dragSrcKey === targetKey) return;
const entries = Object.entries(value); function moveField(key: string, dir: -1 | 1): void {
const srcIdx = entries.findIndex(([k]) => k === dragSrcKey); const idx = keyOrder.indexOf(key);
const tgtIdx = entries.findIndex(([k]) => k === targetKey); const newIdx = idx + dir;
if (srcIdx === -1 || tgtIdx === -1) return; if (newIdx < 0 || newIdx >= keyOrder.length) return;
const reordered = [...entries]; const newOrder = [...keyOrder];
const [moved] = reordered.splice(srcIdx, 1); newOrder.splice(idx, 1);
reordered.splice(tgtIdx, 0, moved); newOrder.splice(newIdx, 0, key);
dragSrcKey = null; value = { ...value, order: newOrder };
emit(Object.fromEntries(reordered)); emit();
// Seguir al campo si cruza un límite de página
if (pageSize > 0) {
const newPage = Math.floor(newIdx / pageSize) + 1;
if (newPage !== page) page = newPage;
}
} }
</script> </script>
<div data-theme={dark ? 'dark' : 'light'}> <div data-theme={dark ? 'dark' : 'light'} class="card bg-base-100 border border-base-200 shadow-sm w-full">
<div class="flex w-full flex-col overflow-hidden rounded-2xl border-2 border-zinc-200 bg-white
dark:border-zinc-700 dark:bg-zinc-950">
<!-- ── Header ──────────────────────────────────────────────────── --> <!-- ── Header ──────────────────────────────────────────────────── -->
<div class="border-b border-zinc-100 bg-white px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900"> <div class="card-body gap-4 pb-3 border-b border-base-200">
<div class="flex items-center gap-3">
<!-- Title row --> <h2 class="card-title">Metadata</h2>
<div class="mb-4 flex items-center gap-3"> <span class="text-base-content/50">
<h2 class="text-[15px] font-bold text-zinc-900 dark:text-zinc-100"> {keyOrder.length} {keyOrder.length === 1 ? 'campo' : 'campos'}
Metadata </span>
</h2> <div class={['badge badge-sm badge-soft ml-auto', validation.valid ? 'badge-success' : 'badge-error'].join(' ')}>
<span class="text-[13px] text-zinc-400 dark:text-zinc-500"> <div class={['h-1.5 w-1.5 rounded-full mr-1', validation.valid ? 'bg-success' : 'bg-error'].join(' ')}></div>
{fieldKeys.length} {fieldKeys.length === 1 ? 'campo' : 'campos'} {validation.valid ? 'Válido' : `${validation.errors.length} errores`}
</span>
<!-- Validation pill -->
<div class={[
'flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[12px] font-medium',
validation.valid
? 'bg-emerald-50 text-emerald-600 dark:bg-emerald-900/20 dark:text-emerald-400'
: 'bg-red-50 text-red-600 dark:bg-red-900/20 dark:text-red-400',
].join(' ')}>
<div class={['h-1.5 w-1.5 rounded-full', validation.valid ? 'bg-emerald-500' : 'bg-red-500'].join(' ')}></div>
{validation.valid ? 'Válido' : `${(validation as any).errors?.length} errores`}
</div> </div>
</div> </div>
<!-- Add field buttons grouped by category --> <div class="flex flex-wrap gap-x-4 gap-y-2">
<div class="flex flex-wrap items-center gap-3">
{#each typeGroups as group} {#each typeGroups as group}
<div class="flex flex-col gap-1"> <div class="flex items-center gap-1">
<span class="text-[11px] font-semibold uppercase tracking-wide text-zinc-300 dark:text-zinc-600"> <span class="text-base-content/30 text-xs">{group.label}</span>
{group.label} {#each group.types as type}
</span> <button type="button"
<div class="flex items-center gap-1"> onclick={() => openDrawer(type)}
{#each group.types as type} class="btn btn-ghost btn-xs gap-1 px-1.5"
<button type="button" onclick={() => addField(type)} title="Añadir {type}" title="Añadir {typeLabels[type]}">
class="rounded-lg border-2 border-transparent p-0.5 transition-all <FieldTypeIcon {type} size={14} />
hover:border-zinc-200 hover:bg-zinc-50 <span class="text-xs">{typeLabels[type]}</span>
dark:hover:border-zinc-700 dark:hover:bg-zinc-800"> </button>
<TypeBadge {type} /> {/each}
</button>
{/each}
</div>
</div> </div>
{/each} {/each}
</div> </div>
</div> </div>
<!-- ── Field list ────────────────────────────────────────────────── --> <!-- ── Field list ─────────────────────────────────────────────── -->
<div class="flex flex-col gap-2 p-4"> <div class="card-body gap-2 pt-3">
{#if fieldKeys.length === 0} {#if keyOrder.length === 0}
<div class="flex flex-col items-center justify-center gap-3 rounded-2xl border-2 border-dashed border-zinc-200 py-16 dark:border-zinc-800"> <div class="rounded-box flex flex-col items-center justify-center gap-3 border-2 border-dashed border-base-300 py-16">
<svg class="h-10 w-10 text-zinc-200 dark:text-zinc-800" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <svg class="h-10 w-10 text-base-content/10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="4"/> <rect x="3" y="3" width="18" height="18" rx="4"/>
<path d="M12 8v8M8 12h8"/> <path d="M12 8v8M8 12h8"/>
</svg> </svg>
<p class="text-[14px] font-medium text-zinc-300 dark:text-zinc-600"> <p class="text-base-content/30">Añade campos usando los botones de arriba</p>
Añade campos usando los botones de arriba
</p>
</div> </div>
{:else} {:else}
{#each fieldKeys as key (key)} {#each pageKeys as key (key)}
<FieldRow {@const gi = keyOrder.indexOf(key)}
fieldKey={key} <div animate:flip={{ duration: 180 }} class="w-full">
field={value[key] as MetadataField} <FieldRow
{locales} {defaultLocale} fieldKey={key}
onKeyChange={(newKey) => updateFieldKey(key, newKey)} field={getField(key)}
onFieldChange={(field) => updateField(key, field)} {locales} {defaultLocale}
onDelete={() => deleteField(key)} {attributes}
onDragStart={(e) => { e.dataTransfer?.setData('text/plain', key); dragSrcKey = key; }} canMoveUp={gi > 0}
onDragOver={() => {}} canMoveDown={gi < keyOrder.length - 1}
onDrop={() => handleDrop(key)} onKeyChange={(newKey) => updateFieldKey(key, newKey)}
/> onFieldChange={(field) => updateField(key, field)}
onDelete={() => deleteField(key)}
onMoveUp={() => moveField(key, -1)}
onMoveDown={() => moveField(key, 1)}
/>
</div>
{/each} {/each}
{/if} {/if}
</div> </div>
<!-- ── Global validation errors ──────────────────────────────────── --> {#if !validation.valid}
{#if !validation.valid && (validation as any).errors?.length} <div class="card-body pt-0">
<div class="border-t border-red-100 bg-red-50/60 px-5 py-4 dark:border-red-900/30 dark:bg-red-900/10"> <div class="alert alert-error">
<p class="mb-2 text-[13px] font-semibold text-red-600 dark:text-red-400"> <div class="flex flex-col gap-1">
Errores de validación <span>Errores de validación</span>
</p> {#each validation.errors as error}
<ul class="flex flex-col gap-1"> <span class="text-sm">· {error}</span>
{#each (validation as any).errors as error} {/each}
<li class="text-[13px] text-red-500">· {error}</li> </div>
{/each} </div>
</ul> </div>
{/if}
<!-- ── Footer: paginación ──────────────────────────────────────── -->
{#if keyOrder.length > 0}
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-base-200 px-5 py-3 bg-amber-50 border-gray-300">
<!-- Filas por página -->
<div class="flex items-center gap-2">
<span class="text-xs text-base-content/40">Filas por página</span>
<select
class="select select-bordered select-xs"
value={pageSize}
onchange={(e) => { pageSize = parseInt((e.currentTarget as HTMLSelectElement).value); page = 1; }}
>
{#each PAGE_SIZES as s}
<option value={s}>{s}</option>
{/each}
<option value={0}>Todos</option>
</select>
</div>
<!-- Barra de páginas -->
{#if totalPages > 1}
<div class="join">
<button
type="button"
class="join-item btn btn-xs"
disabled={page === 1}
onclick={() => page--}
aria-label="Página anterior"
>‹</button>
{#each paginationPages as p (p === -1 ? `ellipsis-${p}` : p)}
{#if p === -1}
<button type="button" class="join-item btn btn-xs btn-disabled" tabindex="-1">…</button>
{:else}
<button
type="button"
class={['join-item btn btn-xs', p === page ? 'btn-active' : ''].join(' ')}
onclick={() => page = p}
aria-label="Página {p}"
aria-current={p === page ? 'page' : undefined}
>{p}</button>
{/if}
{/each}
<button
type="button"
class="join-item btn btn-xs"
disabled={page === totalPages}
onclick={() => page++}
aria-label="Página siguiente"
>›</button>
</div>
{/if}
<!-- Rango visible -->
<span class="text-xs text-base-content/30">
{keyOrder.length === 0 ? '0' : rangeStart}–{rangeEnd} de {keyOrder.length}
</span>
</div> </div>
{/if} {/if}
</div>
</div> </div>
<AddFieldDrawer
open={drawerOpen}
selectedType={drawerType}
existingKeys={keyOrder}
{locales}
{defaultLocale}
{dark}
onConfirm={handleDrawerConfirm}
onClose={() => drawerOpen = false}
/>

@ -1,32 +1,39 @@
<script lang="ts"> <script lang="ts">
import type { MetadataFieldType } from '../../lib'; import type {MetadataField, MetadataFieldType} from '../../lib';
import FieldTypeIcon from './FieldTypeIcon.svelte';
let { type }: { type: MetadataFieldType } = $props(); let {
type,
showLabel = true,
}: {
type: MetadataFieldType;
showLabel?: boolean;
} = $props();
const config: Record<MetadataFieldType, { label: string; classes: string }> = { const config: Record<MetadataFieldType, { label: string; classes: string }> = {
string: { label: 'STR', classes: 'bg-sky-500/10 text-sky-400 border-sky-500/20' }, string: { label: 'Texto', classes: 'badge-info' },
number: { label: 'NUM', classes: 'bg-violet-500/10 text-violet-400 border-violet-500/20' }, number: { label: 'Número', classes: 'badge-secondary' },
boolean: { label: 'BOOL', classes: 'bg-amber-500/10 text-amber-400 border-amber-500/20' }, boolean: { label: 'Bool', classes: 'badge-warning' },
lingstring: { label: 'LING', classes: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' }, ling: { label: 'i18n', classes: 'badge-success' },
date: { label: 'DATE', classes: 'bg-orange-500/10 text-orange-400 border-orange-500/20' }, date: { label: 'Fecha', classes: 'badge-accent' },
datetime: { label: 'DTIME', classes: 'bg-rose-500/10 text-rose-400 border-rose-500/20' }, datetime: { label: 'DateTime', classes: 'badge-accent' },
url: { label: 'URL', classes: 'bg-cyan-500/10 text-cyan-400 border-cyan-500/20' }, url: { label: 'URL', classes: 'badge-info' },
currency: { label: 'CUR', classes: 'bg-lime-500/10 text-lime-400 border-lime-500/20' }, currency: { label: 'Moneda', classes: 'badge-success' },
image: { label: 'IMG', classes: 'bg-pink-500/10 text-pink-400 border-pink-500/20' }, image: { label: 'Imagen', classes: 'badge-error' },
video: { label: 'VID', classes: 'bg-red-500/10 text-red-400 border-red-500/20' }, video: { label: 'Vídeo', classes: 'badge-error' },
audio: { label: 'AUD', classes: 'bg-purple-500/10 text-purple-400 border-purple-500/20' }, audio: { label: 'Audio', classes: 'badge-secondary' },
pdf: { label: 'PDF', classes: 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20' }, pdf: { label: 'PDF', classes: 'badge-neutral' },
file: { label: 'FILE', classes: 'bg-teal-500/10 text-teal-400 border-teal-500/20' }, file: { label: 'Fichero', classes: 'badge-neutral' },
group: { label: 'GROUP', classes: 'bg-indigo-500/10 text-indigo-400 border-indigo-500/20' }, html: { label: 'HTML', classes: 'badge-warning' },
group: { label: 'Grupo', classes: 'badge-primary' },
}; };
let cfg = $derived(config[type]); let cfg = $derived(config[type]);
</script> </script>
<span class={[ <span class={['badge badge-soft badge-sm gap-1'].join(' ')}>
'inline-flex items-center rounded border px-1.5 py-0.5', <FieldTypeIcon {type} size={12} />
'font-mono text-[9px] font-bold uppercase tracking-widest', {#if showLabel}
cfg.classes, <span class="">{cfg.label}</span>
].join(' ')}> {/if}
{cfg.label}
</span> </span>

@ -5,12 +5,15 @@
import type { Metadata } from '../../lib'; import type { Metadata } from '../../lib';
import type { LingRecord } from '@/ling/lib'; import type { LingRecord } from '@/ling/lib';
// ── Demo metadata con todos los tipos ─────────────────────────── // ── Demo metadata con todos los tipos ───────────────────────────
let metadata = $state<Metadata>({ let metadata = $state<Metadata>({
order: ['nombre','año','disponible','descripcion','lanzamiento','ultima_mod','web','precio','foto','manual','specs'],
nombre: { type: 'string', value: 'Toyota Corolla' }, nombre: { type: 'string', value: 'Toyota Corolla' },
año: { type: 'number', value: 2024 }, año: { type: 'number', value: 2024 },
disponible: { type: 'boolean', value: true }, disponible: { type: 'boolean', value: true },
descripcion: { type: 'lingstring', value: { es: 'Sedán compacto fiable', en: 'Reliable compact sedan' } }, descripcion: { type: 'ling', value: { es: 'Sedán compacto fiable', en: 'Reliable compact sedan' } as LingRecord},
lanzamiento: { type: 'date', value: '2024-01-15' }, lanzamiento: { type: 'date', value: '2024-01-15' },
ultima_mod: { type: 'datetime', value: '2024-03-09T10:00:00Z' }, ultima_mod: { type: 'datetime', value: '2024-03-09T10:00:00Z' },
web: { type: 'url', value: 'https://toyota.es' }, web: { type: 'url', value: 'https://toyota.es' },
@ -18,10 +21,10 @@
foto: { type: 'image', value: { url: 'https://images.unsplash.com/photo-1621007947382-bb3c3994e3fb?w=400&h=300&fit=crop', mimeType: 'image/jpeg' } }, foto: { type: 'image', value: { url: 'https://images.unsplash.com/photo-1621007947382-bb3c3994e3fb?w=400&h=300&fit=crop', mimeType: 'image/jpeg' } },
manual: { type: 'pdf', value: { url: 'https://example.com/manual.pdf', filename: 'manual.pdf' } }, manual: { type: 'pdf', value: { url: 'https://example.com/manual.pdf', filename: 'manual.pdf' } },
specs: { type: 'group', value: { specs: { type: 'group', value: {
motor: { type: 'string', value: '1.8 VVT-i' }, motor: { type: 'string', value: '1.8 VVT-i' },
cv: { type: 'number', value: 122 }, cv: { type: 'number', value: 122 },
traccion: { type: 'string', value: 'FWD' }, traccion: { type: 'string', value: 'FWD' },
}, order: ['cv', 'motor', 'traccion'] }, }, order: ['cv', 'motor', 'traccion'] },
}); });
// ── LingInput demo ─────────────────────────────────────────────── // ── LingInput demo ───────────────────────────────────────────────
@ -39,18 +42,16 @@
<svelte:head> <svelte:head>
<title>meta-content · Demo</title> <title>meta-content · Demo</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Syne:wght@400;600;700;800&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" rel="stylesheet" />
</svelte:head> </svelte:head>
<div class={['min-h-screen transition-colors duration-300', darkMode ? 'dark bg-zinc-950' : 'bg-zinc-50'].join(' ')} <div class={['min-h-screen transition-colors duration-300', darkMode ? 'dark bg-zinc-950' : 'bg-zinc-50'].join(' ')}
style="font-family: 'Syne', sans-serif;"> style="font-family: 'Poppins', sans-serif;">
<!-- ── Background grid ────────────────────────────────────────── --> <!-- ── Background grid ────────────────────────────────────────── -->
<!--
<div class="pointer-events-none fixed inset-0 opacity-[0.03] dark:opacity-[0.06]" <div class="pointer-events-none fixed inset-0 opacity-[0.03] dark:opacity-[0.06]"
style="background-image: linear-gradient(#000 1px, transparent 1px), linear-gradient(90deg, #000 1px, transparent 1px); background-size: 32px 32px;"> style="background-image: linear-gradient(#000 1px, transparent 1px), linear-gradient(90deg, #000 1px, transparent 1px); background-size: 32px 32px;">
</div> </div>
-->
<!-- ── Header ─────────────────────────────────────────────────── --> <!-- ── Header ─────────────────────────────────────────────────── -->
<header class="relative border-b border-zinc-200 bg-white/80 backdrop-blur dark:border-zinc-800 dark:bg-zinc-950/80"> <header class="relative border-b border-zinc-200 bg-white/80 backdrop-blur dark:border-zinc-800 dark:bg-zinc-950/80">
@ -76,8 +77,8 @@
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<button <button
onclick={() => showJson = !showJson} onclick={() => showJson = !showJson}
class={[ class={[
'rounded-lg border px-3 py-1.5 font-mono text-[11px] font-medium transition-all duration-150', 'rounded-lg border px-3 py-1.5 font-mono text-[11px] font-medium transition-all duration-150',
showJson showJson
? 'border-zinc-800 bg-zinc-900 text-white dark:border-zinc-200 dark:bg-white dark:text-zinc-900' ? 'border-zinc-800 bg-zinc-900 text-white dark:border-zinc-200 dark:bg-white dark:text-zinc-900'
@ -87,8 +88,8 @@
{showJson ? '← editor' : 'JSON →'} {showJson ? '← editor' : 'JSON →'}
</button> </button>
<button <button
onclick={() => darkMode = !darkMode} onclick={() => darkMode = !darkMode}
class="flex h-8 w-8 items-center justify-center rounded-lg border border-zinc-200 class="flex h-8 w-8 items-center justify-center rounded-lg border border-zinc-200
text-zinc-400 transition-colors hover:border-zinc-300 hover:text-zinc-600 text-zinc-400 transition-colors hover:border-zinc-300 hover:text-zinc-600
dark:border-zinc-700 dark:text-zinc-500 dark:hover:border-zinc-600 dark:hover:text-zinc-300" dark:border-zinc-700 dark:text-zinc-500 dark:hover:border-zinc-600 dark:hover:text-zinc-300"
> >
@ -128,8 +129,8 @@
metadata · serializado metadata · serializado
</span> </span>
<button <button
onclick={() => navigator.clipboard.writeText(JSON.stringify(metadata, null, 2))} onclick={() => navigator.clipboard.writeText(JSON.stringify(metadata, null, 2))}
class="rounded border border-zinc-200 px-2 py-1 font-mono text-[10px] text-zinc-400 class="rounded border border-zinc-200 px-2 py-1 font-mono text-[10px] text-zinc-400
hover:border-zinc-300 hover:text-zinc-600 dark:border-zinc-700 dark:hover:border-zinc-600" hover:border-zinc-300 hover:text-zinc-600 dark:border-zinc-700 dark:hover:border-zinc-600"
> >
copiar copiar
@ -140,7 +141,7 @@
</div> </div>
{:else} {:else}
<div class="grid grid-cols-1 gap-8 lg:grid-cols-[1fr_320px]"> <div class="grid grid-cols-1 gap-8 lg:grid-cols-[minmax(0,1fr)_320px]">
<!-- ── Left: MetadataEditor ──────────────────────── --> <!-- ── Left: MetadataEditor ──────────────────────── -->
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
@ -148,14 +149,15 @@
<span class="font-mono text-[10px] font-bold uppercase tracking-[0.16em] text-zinc-400 dark:text-zinc-600"> <span class="font-mono text-[10px] font-bold uppercase tracking-[0.16em] text-zinc-400 dark:text-zinc-600">
MetadataEditor MetadataEditor
</span> </span>
<div class="h-px flex-1 bg-zinc-100 dark:bg-zinc-800"></div> <div class="h-px flex-1 bg-zinc-100 dark:bg-zinc-800 w-full"></div>
</div> </div>
<MetadataEditor <MetadataEditor
bind:value={metadata} bind:value={metadata}
{locales} {locales}
{defaultLocale} attributes={['hidden','required']}
dark={darkMode} {defaultLocale}
onUpdate={(m) => metadata = m} dark={darkMode}
onUpdate={(m) => metadata = m}
/> />
</div> </div>
@ -176,34 +178,34 @@
<!-- textarea mode --> <!-- textarea mode -->
<LingInput <LingInput
value={lingValue} value={lingValue}
{locales} {locales}
{defaultLocale} {defaultLocale}
label="Descripción" label="Descripción"
placeholder="Escribe aquí…" placeholder="Escribe aquí…"
required={true} required={true}
invalid={lingInvalid} invalid={lingInvalid}
as="textarea" as="textarea"
rows={3} rows={3}
onUpdate={(v) => lingValue = v} onUpdate={(v) => lingValue = v}
/> />
<!-- input mode --> <!-- input mode -->
<LingInput <LingInput
value={lingValue} value={lingValue}
{locales} {locales}
{defaultLocale} {defaultLocale}
label="Título" label="Título"
as="input" as="input"
onUpdate={(v) => lingValue = v} onUpdate={(v) => lingValue = v}
/> />
<!-- Invalid toggle --> <!-- Invalid toggle -->
<div class="flex items-center gap-2 border-t border-zinc-100 pt-3 dark:border-zinc-800"> <div class="flex items-center gap-2 border-t border-zinc-100 pt-3 dark:border-zinc-800">
<span class="font-mono text-[10px] text-zinc-400">Simular inválido</span> <span class="font-mono text-[10px] text-zinc-400">Simular inválido</span>
<button <button
onclick={() => lingInvalid = !lingInvalid} onclick={() => lingInvalid = !lingInvalid}
class={[ class={[
'relative h-5 w-9 rounded-full transition-colors duration-200', 'relative h-5 w-9 rounded-full transition-colors duration-200',
lingInvalid ? 'bg-red-400' : 'bg-zinc-200 dark:bg-zinc-700', lingInvalid ? 'bg-red-400' : 'bg-zinc-200 dark:bg-zinc-700',
].join(' ')} ].join(' ')}
@ -242,12 +244,12 @@
Tipos disponibles Tipos disponibles
</p> </p>
<div class="flex flex-wrap gap-1.5"> <div class="flex flex-wrap gap-1.5">
{#each ['string','number','boolean','lingstring','date','datetime','url','currency','image','video','audio','pdf','file','group'] as type} {#each ['string','number','boolean','ling','date','datetime','url','currency','image','video','audio','pdf','file','group'] as type}
{@const colors: Record<string, string> = { {@const colors: Record<string, string> = {
string: 'bg-sky-50 text-sky-500 border-sky-200 dark:bg-sky-900/20 dark:text-sky-400 dark:border-sky-800', string: 'bg-sky-50 text-sky-500 border-sky-200 dark:bg-sky-900/20 dark:text-sky-400 dark:border-sky-800',
number: 'bg-violet-50 text-violet-500 border-violet-200 dark:bg-violet-900/20 dark:text-violet-400 dark:border-violet-800', number: 'bg-violet-50 text-violet-500 border-violet-200 dark:bg-violet-900/20 dark:text-violet-400 dark:border-violet-800',
boolean: 'bg-amber-50 text-amber-500 border-amber-200 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-800', boolean: 'bg-amber-50 text-amber-500 border-amber-200 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-800',
lingstring: 'bg-emerald-50 text-emerald-500 border-emerald-200 dark:bg-emerald-900/20 dark:text-emerald-400 dark:border-emerald-800', ling: 'bg-emerald-50 text-emerald-500 border-emerald-200 dark:bg-emerald-900/20 dark:text-emerald-400 dark:border-emerald-800',
date: 'bg-orange-50 text-orange-500 border-orange-200 dark:bg-orange-900/20 dark:text-orange-400 dark:border-orange-800', date: 'bg-orange-50 text-orange-500 border-orange-200 dark:bg-orange-900/20 dark:text-orange-400 dark:border-orange-800',
datetime: 'bg-rose-50 text-rose-500 border-rose-200 dark:bg-rose-900/20 dark:text-rose-400 dark:border-rose-800', datetime: 'bg-rose-50 text-rose-500 border-rose-200 dark:bg-rose-900/20 dark:text-rose-400 dark:border-rose-800',
url: 'bg-cyan-50 text-cyan-500 border-cyan-200 dark:bg-cyan-900/20 dark:text-cyan-400 dark:border-cyan-800', url: 'bg-cyan-50 text-cyan-500 border-cyan-200 dark:bg-cyan-900/20 dark:text-cyan-400 dark:border-cyan-800',
@ -283,3 +285,12 @@
</footer> </footer>
</div> </div>
<style>
:global(*) {
font-family: 'Poppins', sans-serif !important;
}
:global(code), :global(pre) {
font-family: 'Poppins', sans-serif !important;
}
</style>

@ -1,4 +1,5 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "daisyui";
/* Smooth scroll para navegación */ /* Smooth scroll para navegación */

@ -1,138 +0,0 @@
// ─────────────────────────────────────────────────────────────────────────────
// 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>

@ -1,223 +0,0 @@
// ─────────────────────────────────────────────────────────────────────────────
// 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)
})
})

@ -1,378 +0,0 @@
<script lang="ts">
/**
* ATTRIBUTE DETAIL — vice2
*/
import { getContext } from 'svelte'
import type { EditorStore } from '../stores/editor.svelte'
import type {
RequiredAttribute,
OptionalAttribute,
FixedAttribute,
OptionID,
} from '@/vice/libs/types'
import RuleList from './RuleList.svelte'
const editor = getContext<EditorStore>('editor')
const attr = $derived(editor.activeAttribute!)
const group = $derived(editor.activeGroup)
const entity = $derived(editor.activeEntity)
const langs = $derived(editor.collection.langs)
// Helper: obtiene el nombre localizado
function ling(l: Record<string, string> | undefined, lang: string): string {
if (!l) return ''
return l[lang] ?? l['es'] ?? Object.values(l)[0] ?? ''
}
function hasOptions(a: typeof attr): a is RequiredAttribute | OptionalAttribute {
return a.type === 'required' || a.type === 'optional'
}
function patch(key: string, value: unknown) {
if (entity) {
editor.updateAttribute(entity.id, group?.id, attr.id, { [key]: value })
} else {
editor.updateCatalogAttribute(attr.id, { [key]: value })
}
}
function patchI18n(field: 'name' | 'description', lang: string, value: string) {
const current = (attr[field] as Record<string, string>) ?? {}
const update = { [field]: { ...current, [lang]: value } }
if (entity) {
editor.updateAttribute(entity.id, group?.id, attr.id, update)
} else {
editor.updateCatalogAttribute(attr.id, update)
}
if (field === 'name' && lang === langs[0] && !attr.code) {
const codeUpdate = {
code: value.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, ''),
}
if (entity) {
editor.updateAttribute(entity.id, group?.id, attr.id, codeUpdate)
} else {
editor.updateCatalogAttribute(attr.id, codeUpdate)
}
}
}
function patchDisplay(key: string, value: boolean) {
const update = { display: { ...attr.display, [key]: value } }
if (entity) {
editor.updateAttribute(entity.id, group?.id, attr.id, update)
} else {
editor.updateCatalogAttribute(attr.id, update)
}
}
// options es Record<OptionID, OptionDefinition>
const allOptions = $derived(Object.values(editor.collection.options))
const assignedOptIds = $derived.by(() => {
if (!hasOptions(attr)) return new Set<string>()
return new Set((attr as RequiredAttribute).options.map(o => o.optionId))
})
const availableOptions = $derived.by(() => allOptions.filter(o => !assignedOptIds.has(o.id)))
let optPickerOpen = $state(false)
let optPickerSearch = $state('')
const filteredPicker = $derived.by(() => {
const s = optPickerSearch.toLowerCase()
return availableOptions.filter(o =>
!s ||
ling(o.name as Record<string, string>, langs[0]).toLowerCase().includes(s) ||
o.code.toLowerCase().includes(s)
)
})
function addOption(optionId: OptionID) {
if (entity) editor.addOptionToAttribute(entity.id, group?.id, attr.id, optionId)
optPickerOpen = false
optPickerSearch = ''
}
function removeOption(optionId: OptionID) {
if (entity) editor.removeOptionFromAttribute(entity.id, group?.id, attr.id, optionId)
}
function optName(id: string): string {
const opt = editor.collection.options[id as OptionID]
if (!opt) return id
return ling(opt.name as Record<string, string>, langs[0]) || opt.code
}
function optPrice(id: string): string | null {
const opt = editor.collection.options[id as OptionID]
if (!opt?.pricing) return null
if (opt.pricing.dynamicExpression) return 'din'
const amount = opt.pricing.baseAmount
return amount === 'consultation' ? '?' : `${amount}€`
}
const attrDisplayName = $derived(
ling(attr.name as Record<string, string>, langs[0]) || 'Atributo sin nombre'
)
</script>
<div class="p-6 max-w-2xl flex flex-col gap-6">
<!-- Header -->
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold text-gray-200">{attrDisplayName}</h2>
<span class="text-xs text-gray-600 font-mono">{attr.id}</span>
</div>
<!-- Identificación -->
<section class="flex flex-col gap-3">
<h3 class="section-title">Identificación</h3>
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500">
Código {#if !attr.code}<span class="text-red-400 ml-1">*</span>{/if}
</label>
<input
type="text"
value={attr.code}
oninput={(e) => patch('code', e.currentTarget.value)}
placeholder="suelo"
class="field-input font-mono text-xs {!attr.code ? 'border-red-800' : ''}"
/>
</div>
<div class="grid grid-cols-2 gap-3">
{#each langs as lang (lang)}
{@const val = (attr.name as Record<string, string>)?.[lang] ?? ''}
{@const required = lang === langs[0] && !val}
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500">
Nombre ({lang}){#if required}<span class="text-red-400 ml-1">*</span>{/if}
</label>
<input
type="text"
value={val}
oninput={(e) => patchI18n('name', lang, e.currentTarget.value)}
placeholder="Suelo"
class="field-input {required ? 'border-red-800' : ''}"
/>
</div>
{/each}
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500">Descripción ({langs[0]})</label>
<textarea
oninput={(e) => patchI18n('description', langs[0], e.currentTarget.value)}
placeholder="Material del suelo..."
rows="2"
class="field-input resize-none"
>{(attr.description as Record<string, string>)?.[langs[0]] ?? ''}</textarea>
</div>
</section>
<!-- Tipo -->
<section class="flex flex-col gap-3">
<h3 class="section-title">Tipo</h3>
<div class="flex gap-2">
{#each (['required', 'optional', 'fixed'] as const) as t}
<button
onclick={() => patch('type', t)}
class="flex-1 py-2 text-xs rounded-md border transition-colors
{attr.type === t
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
: 'border-gray-700 text-gray-500 hover:border-gray-600 hover:text-gray-300'}"
>
{t === 'required' ? 'Required' : t === 'optional' ? 'Optional' : 'Fixed'}
</button>
{/each}
</div>
<p class="text-xs text-gray-600 italic">
{#if attr.type === 'required'}El usuario debe elegir. null = configuración incompleta.
{:else if attr.type === 'optional'}El usuario activa o desactiva.
{:else}Valor constante. Puede ser fuente de constraints.{/if}
</p>
</section>
<!-- Comportamiento -->
<section class="flex flex-col gap-3">
<h3 class="section-title">Comportamiento</h3>
{#each ([
['uiVisible', 'Visible en UI', '— Mostrar en el configurador'],
['affectsVisual', 'Afecta a la imagen', '— Cambiar esta opción actualiza el render'],
['affectsPrice', 'Afecta al precio', '— Cambiar esta opción recalcula el precio'],
] as const) as [key, label, hint]}
<label class="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={!!(attr.display as Record<string, unknown>)?.[key]}
onchange={(e) => patchDisplay(key, e.currentTarget.checked)}
class="accent-indigo-500"
/>
<span class="text-sm text-gray-300">{label}</span>
<span class="text-xs text-gray-600">{hint}</span>
</label>
{/each}
</section>
<!-- Opciones (required / optional) -->
{#if hasOptions(attr)}
{@const attrWithOpts = attr as RequiredAttribute}
<section class="flex flex-col gap-3">
<h3 class="section-title">
Opciones <span class="text-gray-600 font-normal">({attrWithOpts.options.length})</span>
</h3>
{#if attrWithOpts.options.length === 0}
<p class="text-xs text-gray-600 italic">Sin opciones — añade desde la librería</p>
{:else}
<div class="flex flex-col gap-1">
{#each attrWithOpts.options as attrOpt (attrOpt.optionId)}
{@const price = optPrice(attrOpt.optionId)}
<div class="rounded-md border border-gray-800 bg-gray-900/50 px-3 py-2
flex items-center justify-between gap-2">
<div class="flex items-center gap-2 min-w-0">
<span class="text-sm text-gray-200 truncate">{optName(attrOpt.optionId)}</span>
<span class="text-xs text-gray-600 font-mono shrink-0">
{editor.collection.options[attrOpt.optionId]?.code ?? ''}
</span>
</div>
<div class="flex items-center gap-2 shrink-0">
{#if price}
<span class="text-xs {price === 'din' ? 'text-indigo-400/70' : 'text-gray-500'}">{price}</span>
{/if}
<button
onclick={() => removeOption(attrOpt.optionId)}
class="text-gray-600 hover:text-red-400 transition-colors text-xs"
>✕</button>
</div>
</div>
{/each}
</div>
{/if}
<!-- Picker -->
<div class="relative">
<button
onclick={() => optPickerOpen = !optPickerOpen}
class="w-full flex items-center justify-between px-3 py-2 rounded-md border
border-dashed border-gray-700 text-xs text-gray-500 hover:text-gray-300
hover:border-gray-600 transition-colors"
>
<span>+ Añadir opción de la librería</span>
<span class="text-gray-600">{availableOptions.length} disponibles</span>
</button>
{#if optPickerOpen}
<button
class="fixed inset-0 z-40 cursor-default"
onclick={() => optPickerOpen = false}
aria-label="Cerrar"
></button>
<div class="absolute top-full left-0 right-0 mt-1 z-50 bg-gray-900 border
border-gray-700 rounded-md shadow-xl overflow-hidden">
<div class="p-2 border-b border-gray-800">
<input
type="text"
bind:value={optPickerSearch}
placeholder="Buscar opción..."
class="w-full bg-gray-800 text-sm text-gray-200 px-3 py-1.5 rounded
border border-gray-700 focus:outline-none focus:border-indigo-500"
/>
</div>
<ul class="max-h-52 overflow-y-auto">
{#if filteredPicker.length === 0}
<li class="px-4 py-3 text-xs text-gray-600 text-center">
{allOptions.length === 0 ? 'No hay opciones — créalas en el panel Opciones' : 'Sin resultados'}
</li>
{:else}
{#each filteredPicker as opt (opt.id)}
<li>
<button
onclick={() => addOption(opt.id)}
class="w-full text-left px-4 py-2 flex items-center justify-between
text-sm text-gray-300 hover:bg-gray-800 transition-colors"
>
<span>{ling(opt.name as Record<string, string>, langs[0])}</span>
<span class="text-xs text-gray-600 font-mono">{opt.code}</span>
</button>
</li>
{/each}
{/if}
</ul>
</div>
{/if}
</div>
<!-- Valor por defecto -->
{#if attrWithOpts.options.length > 0}
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500">
Valor por defecto
{#if attr.type === 'required'}<span class="text-red-400 ml-1">*</span>{/if}
</label>
<select
onchange={(e) => patch('default', e.currentTarget.value || null)}
class="field-input"
>
{#if attr.type === 'optional'}
<option value="" selected={!attrWithOpts.default}>Sin valor por defecto</option>
{/if}
{#each attrWithOpts.options as attrOpt (attrOpt.optionId)}
<option value={attrOpt.optionId} selected={attrWithOpts.default === attrOpt.optionId}>
{optName(attrOpt.optionId)}
</option>
{/each}
</select>
</div>
{/if}
</section>
{/if}
<!-- ── Valor fijo -->
{#if attr.type === 'fixed'}
{@const fixedAttr = attr as FixedAttribute}
<section class="flex flex-col gap-3">
<h3 class="section-title">Valor fijo</h3>
<select
onchange={(e) => patch('option', { optionId: e.currentTarget.value })}
class="field-input"
>
<option value="" selected={!fixedAttr.option?.optionId}>Sin opción...</option>
{#each allOptions as opt (opt.id)}
<option value={opt.id} selected={fixedAttr.option?.optionId === opt.id}>
{ling(opt.name as Record<string, string>, langs[0]) || opt.id} — {opt.code}
</option>
{/each}
</select>
<p class="text-xs text-gray-600 italic">
El valor constante no puede ser modificado por el usuario.
</p>
</section>
{/if}
<!-- ── Reglas ────────────────────────────────────────────────────────── -->
{#if entity}
<section class="flex flex-col gap-3">
<h3 class="section-title">
Reglas
<span class="text-gray-600 font-normal">
({((attr as any).rules ?? []).length})
</span>
</h3>
<RuleList
rules={(attr as any).rules ?? []}
entityId={entity.id}
groupId={group?.id}
attrId={attr.id}
/>
</section>
{:else}
<section class="flex flex-col gap-3">
<h3 class="section-title">Reglas</h3>
<p class="text-xs text-gray-600 italic">
Los atributos de catálogo no tienen reglas — son el nivel raíz.
</p>
</section>
{/if}
</div>

@ -1,141 +0,0 @@
<script lang="ts">
/**
* ATTRIBUTE LIST — tres niveles
*
* Muestra atributos de:
* 1. Catálogo (collection.attributes) — solo si no hay entidad activa
* 2. Entidad (entity.attributes) — si hay entidad activa
* 3. Grupo (group.attributes) — si hay grupo activo
*
* La columna izquierda muestra la lista.
* La columna derecha muestra AttributeDetail del atributo activo.
*/
import { getContext } from 'svelte'
import type { EditorStore } from '../stores/editor.svelte'
import type { Attribute, AttrID, EntityID, GroupID } from '@/vice/libs/types'
import GroupMetaForm from './GroupMetaForm.svelte'
import AttributeDetail from './AttributeDetail.svelte'
const editor = getContext<EditorStore>('editor')
const langs = $derived(editor.collection.langs)
const entity = $derived(editor.activeEntity!)
const group = $derived(editor.activeGroup!)
function ling(l: Record<string, string> | undefined, lang: string): string {
if (!l) return ''
return l[lang] ?? l['es'] ?? Object.values(l)[0] ?? ''
}
const typeLabel: Record<string, string> = {
required: 'req',
optional: 'opt',
fixed: 'fix',
literal: 'lit',
}
const typeBadgeClass: Record<string, string> = {
required: 'bg-indigo-900/50 text-indigo-400',
optional: 'bg-amber-900/50 text-amber-400',
fixed: 'bg-gray-800 text-gray-500',
literal: 'bg-teal-900/50 text-teal-400',
}
</script>
<div class="flex h-full">
<!-- Lista -->
<div class="w-72 shrink-0 border-r border-gray-800 flex flex-col">
<!-- Meta del grupo -->
<div class="px-4 py-3 border-b border-gray-800 shrink-0">
<GroupMetaForm />
</div>
<!-- Header -->
<div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-800 shrink-0">
<span class="text-xs font-semibold text-gray-400 uppercase tracking-wider">
Atributos ({group.attributes.length})
</span>
<button
onclick={() => editor.addAttribute(entity.id, group.id)}
class="text-gray-500 hover:text-indigo-400 transition-colors text-sm
w-6 h-6 flex items-center justify-center rounded hover:bg-indigo-500/10"
title="Añadir atributo"
>+</button>
</div>
<div class="flex-1 overflow-y-auto py-1">
{#if group.attributes.length === 0}
<div class="px-4 py-8 text-center text-gray-600 text-xs">
<p>Sin atributos</p>
<p class="mt-1 text-gray-700">Suelo, color, material...</p>
<button
onclick={() => editor.addAttribute(entity.id, group.id)}
class="mt-3 text-xs px-3 py-1.5 rounded border border-dashed border-gray-700
text-gray-500 hover:text-indigo-400 hover:border-indigo-700 transition-colors"
>Crear atributo</button>
</div>
{:else}
{#each group.attributes as attr (attr.id)}
{@const isActive = editor.activeAttrId === attr.id}
{@const hasError = editor.validation.errors.some(e => e.path.includes(attr.id))}
{@const hasWarn = editor.validation.warnings.some(e => e.path.includes(attr.id))}
{@const name = ling(attr.name as Record<string,string>, langs[0])}
<div
role="button"
tabindex="0"
onclick={() => editor.setActiveAttribute(attr.id)}
onkeydown={(e) => e.key === 'Enter' && editor.setActiveAttribute(attr.id)}
class="
w-full text-left px-4 py-2.5 flex items-center gap-2 text-sm cursor-pointer
transition-colors group border-l-2
{isActive
? 'bg-indigo-600/20 text-indigo-300 border-indigo-500'
: 'text-gray-400 hover:bg-gray-800 hover:text-gray-200 border-transparent'}
"
>
<span class="text-xs px-1.5 py-0.5 rounded shrink-0
{typeBadgeClass[attr.type] ?? 'bg-gray-800 text-gray-500'}">
{typeLabel[attr.type] ?? attr.type}
</span>
{#if hasError}
<span class="w-1.5 h-1.5 rounded-full bg-red-400 shrink-0"></span>
{:else if hasWarn}
<span class="w-1.5 h-1.5 rounded-full bg-amber-400 shrink-0"></span>
{/if}
<span class="flex-1 truncate">
{#if name}{name}{:else}<span class="italic text-gray-600">Sin nombre</span>{/if}
</span>
{#if attr.type === 'required' || attr.type === 'optional'}
<span class="text-xs text-gray-600 shrink-0">
{attr.options?.length ?? 0}
</span>
{/if}
<button
onclick={(e) => { e.stopPropagation(); editor.deleteAttribute(entity.id, group.id, attr.id) }}
class="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400
transition-opacity text-xs"
>✕</button>
</div>
{/each}
{/if}
</div>
</div>
<!-- Detalle -->
<div class="flex-1 overflow-auto">
{#if editor.activeAttribute}
<AttributeDetail />
{:else}
<div class="flex items-center justify-center h-full text-gray-600 text-sm">
Selecciona un atributo para editarlo
</div>
{/if}
</div>
</div>

@ -1,339 +0,0 @@
<script lang="ts">
import { getContext } from 'svelte'
import {ling, type SupportedLocale} from '@/ling/lib'
import type { EditorStore } from '../stores/editor.svelte.ts'
import type {Attribute, LiteralAttribute} from "@/vice/libs";
const editor = getContext<EditorStore>('editor')
const VALUE_KINDS = ['string', 'number', 'boolean', 'currency'] as const
function asLiteral(attr: Attribute): LiteralAttribute {
return attr as LiteralAttribute
}
function getLiteralName(attr: Attribute, lang: SupportedLocale): string {
return (attr.name as Record<string, string>)?.[lang] ?? ''
}
function patchLiteralName(attr: Attribute, lang: SupportedLocale, value: string) {
const current = attr.name as Record<string, string> ?? {}
editor.updateCatalogAttribute(attr.id, { name: { ...current, [lang]: value } })
}
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>
<!-- Atributos del catálogo -->
<section class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<h3 class="text-xs font-semibold text-gray-500 uppercase tracking-wider">
Atributos de contexto
<span class="text-gray-700 font-normal ml-1">({col.attributes.length})</span>
</h3>
<button
onclick={() => editor.addCatalogAttribute()}
class="text-xs px-2 py-1 rounded border border-dashed border-gray-700
text-gray-500 hover:text-indigo-400 hover:border-indigo-700 transition-colors"
>+ Añadir</button>
</div>
{#if col.attributes.length === 0}
<p class="text-xs text-gray-700 italic">
Sin atributos — añade variables de contexto como país, normativa fiscal, año...
</p>
{:else}
<div class="flex flex-col gap-2">
{#each col.attributes as attr}
{@const lit = asLiteral(attr)}
<div class="rounded-md border border-gray-800 bg-gray-900/50 p-3 flex flex-col gap-2">
<div class="flex items-center gap-2">
<!-- Código -->
<input type="text" value={attr.code}
oninput={(e) => editor.updateCatalogAttribute(attr.id, { code: e.currentTarget.value })}
placeholder="country"
class="field-input font-mono text-xs w-32" />
<!-- Kind -->
<select
onchange={(e) => editor.updateCatalogAttribute(attr.id, { kind: e.currentTarget.value })}
class="field-input text-xs w-28"
>
{#each VALUE_KINDS as k}
<option value={k} selected={lit.kind === k}>{k}</option>
{/each}
</select>
<!-- Valor -->
{#if lit.kind === 'boolean'}
<button
onclick={() => editor.updateCatalogAttribute(attr.id, { value: !lit.value })}
class="relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent
transition-colors cursor-pointer
{lit.value ? 'bg-indigo-600' : 'bg-gray-700'}"
role="switch" aria-checked={!!lit.value}
>
<span class="pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow
transition-transform
{lit.value ? 'translate-x-4' : 'translate-x-0'}"></span>
</button>
{:else}
<input
type={lit.kind === 'number' ? 'number' : 'text'}
value={lit.value ?? ''}
oninput={(e) => editor.updateCatalogAttribute(attr.id, {
value: lit.kind === 'number'
? Number(e.currentTarget.value)
: e.currentTarget.value
})}
class="field-input text-xs w-24"
/>
{/if}
<button
onclick={() => editor.deleteCatalogAttribute(attr.id)}
class="text-gray-600 hover:text-red-400 transition-colors text-xs shrink-0"
>✕</button>
</div>
<!-- Nombre por idioma -->
<div class="grid grid-cols-2 gap-2 pl-1">
{#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={getLiteralName(attr, lang)}
oninput={(e) => patchLiteralName(attr, lang, e.currentTarget.value)}
placeholder={lang === 'es' ? 'País' : ''}
class="field-input text-xs flex-1" />
</div>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</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>

@ -1,431 +0,0 @@
<script lang="ts">
/**
* CONDITION BUILDER
*
* Editor visual para condiciones JsonLogic en el contexto VICE.
* Soporta:
* - Cláusulas simples: attr op value
* - Cláusulas compuestas: AND / OR de N cláusulas
* - Fallback raw (textarea) si el JsonLogic no es parseable
*
* Props:
* condition — JsonLogic actual (undefined = siempre activa)
* allAttrs — attrs disponibles como variables { id, label }
* onchange — callback con el nuevo JsonLogic
*/
import { getContext } from 'svelte'
import type { EditorStore } from '../stores/editor.svelte'
import type { JsonLogic } from '@/tsat/lib/types'
import type { OptionDefinition, OptionID } from '@/vice/libs/types'
const editor = getContext<EditorStore>('editor')
let {
condition,
allAttrs,
onchange,
}: {
condition: JsonLogic | undefined
allAttrs: Array<{ id: string; label: string }>
onchange: (jl: JsonLogic | undefined) => void
} = $props()
// ── Types ────────────────────────────────────────────────────────────────────
type ClauseOp = '==' | '!=' | 'in' | '!in' | '>' | '>=' | '<' | '<='
type Clause = {
attrId: string
op: ClauseOp
optValues: string[] // OptionIDs (== usa [0], in usa todos)
numValue: number
strValue: string
boolValue: boolean
}
type ConditionDraft = {
logic: 'and' | 'or'
clauses: Clause[]
}
type AttrKind = 'options' | 'number' | 'boolean' | 'string' | 'unknown'
// ── Attr meta ────────────────────────────────────────────────────────────────
function getAllAttrsFlat() {
const entity = editor.activeEntity
return [
...editor.collection.attributes,
...(entity ? entity.attributes : []),
...(entity ? Object.values(entity.groups).flatMap(g => g.attributes) : []),
]
}
function getAttrKind(attrId: string): AttrKind {
if (!attrId) return 'unknown'
const attr = getAllAttrsFlat().find(a => a.id === attrId)
if (!attr) return 'unknown'
if (attr.type === 'required' || attr.type === 'optional') return 'options'
if (attr.type === 'literal') {
if (attr.kind === 'number' || attr.kind === 'currency') return 'number'
if (attr.kind === 'boolean') return 'boolean'
return 'string'
}
return 'unknown'
}
function getAttrOptions(attrId: string): OptionDefinition[] {
if (!attrId) return []
const attr = getAllAttrsFlat().find(a => a.id === attrId)
if (!attr || (attr.type !== 'required' && attr.type !== 'optional')) return []
return attr.options
.map(ao => editor.collection.options[ao.optionId as OptionID])
.filter((o): o is OptionDefinition => Boolean(o))
}
const OPS: Record<AttrKind, Array<{ op: ClauseOp; label: string }>> = {
options: [
{ op: '==', label: 'es' },
{ op: '!=', label: 'no es' },
{ op: 'in', label: 'es alguno de' },
{ op: '!in', label: 'no es ninguno de' },
],
number: [
{ op: '==', label: '=' },
{ op: '!=', label: '≠' },
{ op: '>', label: '>' },
{ op: '>=', label: '≥' },
{ op: '<', label: '<' },
{ op: '<=', label: '≤' },
],
string: [{ op: '==', label: 'es' }, { op: '!=', label: 'no es' }],
boolean: [],
unknown: [],
}
// ── JsonLogic ↔ Draft ────────────────────────────────────────────────────────
function emptyClause(): Clause {
return { attrId: '', op: '==', optValues: [], numValue: 0, strValue: '', boolValue: true }
}
function jlToClause(jl: JsonLogic): Clause | null {
if (typeof jl !== 'object' || jl === null || 'var' in jl) return null
const obj = jl as Record<string, unknown>
const key = Object.keys(obj)[0]
// { "!": { "in": [{"var": "..."}, [...]] } }
if (key === '!') {
const inner = obj['!'] as Record<string, unknown>
if (typeof inner === 'object' && inner !== null && 'in' in inner) {
const parts = inner['in'] as unknown[]
if (!Array.isArray(parts) || parts.length !== 2) return null
const [left, right] = parts
if (typeof left !== 'object' || left === null || !('var' in (left as object))) return null
const opts = Array.isArray(right) ? (right as string[]) : [right as string]
return { attrId: (left as any).var, op: '!in', optValues: opts, numValue: 0, strValue: '', boolValue: true }
}
return null
}
const parts = obj[key]
if (!Array.isArray(parts) || parts.length !== 2) return null
const [left, right] = parts as [JsonLogic, JsonLogic]
if (typeof left !== 'object' || left === null || !('var' in (left as object))) return null
const attrId = (left as any).var as string
if (key === 'in') {
const opts = Array.isArray(right) ? (right as string[]) : [right as string]
return { attrId, op: 'in', optValues: opts, numValue: 0, strValue: '', boolValue: true }
}
if (['==', '!=', '>', '>=', '<', '<='].includes(key)) {
const op = key as ClauseOp
if (typeof right === 'number') return { attrId, op, optValues: [], numValue: right, strValue: '', boolValue: true }
if (typeof right === 'boolean') return { attrId, op: '==', optValues: [], numValue: 0, strValue: '', boolValue: right }
if (typeof right === 'string') return { attrId, op, optValues: [right], numValue: 0, strValue: right, boolValue: true }
}
return null
}
function jlToDraft(jl: JsonLogic | undefined): ConditionDraft | null {
if (!jl) return { logic: 'and', clauses: [] }
if (typeof jl !== 'object' || jl === null) return null
const obj = jl as Record<string, unknown>
if ('and' in obj || 'or' in obj) {
const logic: 'and' | 'or' = 'and' in obj ? 'and' : 'or'
const children = obj[logic] as JsonLogic[]
if (!Array.isArray(children)) return null
const clauses = children.map(jlToClause)
if (clauses.some(c => c === null)) return null
return { logic, clauses: clauses as Clause[] }
}
const clause = jlToClause(jl)
return clause ? { logic: 'and', clauses: [clause] } : null
}
function clauseToJl(c: Clause): JsonLogic {
const left: JsonLogic = { var: c.attrId }
const kind = getAttrKind(c.attrId)
if (kind === 'boolean') return { '==': [left, c.boolValue] }
if (kind === 'number') return { [c.op]: [left, c.numValue] }
if (kind === 'string') return { [c.op]: [left, c.strValue] }
// options (default)
if (c.op === 'in') return { in: [left, c.optValues] }
if (c.op === '!in') return { '!': { in: [left, c.optValues] } }
return { [c.op]: [left, c.optValues[0] ?? ''] }
}
function draftToJl(d: ConditionDraft): JsonLogic | undefined {
const valid = d.clauses.filter(c => c.attrId !== '')
if (valid.length === 0) return undefined
const exprs = valid.map(clauseToJl)
if (exprs.length === 1) return exprs[0]
return { [d.logic]: exprs }
}
// ── State ────────────────────────────────────────────────────────────────────
const initialDraft = jlToDraft(condition)
let parseFailed = $state(initialDraft === null)
let rawMode = $state(initialDraft === null)
let draft = $state<ConditionDraft>(initialDraft ?? { logic: 'and', clauses: [] })
// ── Mutations ────────────────────────────────────────────────────────────────
function emit() {
onchange(draftToJl(draft))
}
function setLogic(logic: 'and' | 'or') {
draft = { ...draft, logic }
emit()
}
function addClause() {
draft = { ...draft, clauses: [...draft.clauses, emptyClause()] }
emit()
}
function removeClause(i: number) {
draft = { ...draft, clauses: draft.clauses.filter((_, idx) => idx !== i) }
emit()
}
function patchClause(i: number, patch: Partial<Clause>) {
draft = { ...draft, clauses: draft.clauses.map((c, idx) => idx === i ? { ...c, ...patch } : c) }
emit()
}
function setAttr(i: number, attrId: string) {
const kind = getAttrKind(attrId)
const op = OPS[kind][0]?.op ?? '=='
patchClause(i, { attrId, op, optValues: [], numValue: 0, strValue: '', boolValue: true })
}
function toggleOpt(i: number, optId: string, multi: boolean) {
const current = draft.clauses[i]?.optValues ?? []
const next = multi
? (current.includes(optId) ? current.filter(v => v !== optId) : [...current, optId])
: [optId]
patchClause(i, { optValues: next })
}
// ── Helpers ──────────────────────────────────────────────────────────────────
const langs = $derived(editor.collection.langs)
function optLabel(opt: OptionDefinition): string {
const n = opt.name as Record<string, string> | string
if (typeof n === 'string') return n || opt.code
return n[langs[0]] ?? n['es'] ?? opt.code
}
const activeClauseCount = $derived(draft.clauses.filter(c => c.attrId).length)
</script>
<div class="flex flex-col gap-2">
<!-- Header con toggle raw/visual -->
<div class="flex items-center justify-between min-h-5">
<span class="text-xs text-gray-600 uppercase tracking-wider">Condición</span>
<div class="flex items-center gap-2">
{#if !rawMode && activeClauseCount === 0}
<span class="text-xs text-gray-700 italic">siempre activa</span>
{/if}
{#if parseFailed}
<span class="text-xs text-amber-700 italic">JsonLogic complejo</span>
{:else}
<button
onclick={() => rawMode = !rawMode}
class="text-xs text-gray-600 hover:text-gray-400 transition-colors underline underline-offset-2"
>{rawMode ? 'visual' : 'raw'}</button>
{/if}
</div>
</div>
{#if rawMode || parseFailed}
<!-- ── Raw textarea ────────────────────────────────────────────── -->
<textarea
rows="3"
placeholder={'{\n "==": [{"var": "at:suelo"}, "op:marmol"]\n}'}
value={condition ? JSON.stringify(condition, null, 2) : ''}
oninput={(e) => {
const v = e.currentTarget.value.trim()
try {
const parsed = v ? JSON.parse(v) : undefined
const d = jlToDraft(parsed)
if (d) { draft = d; parseFailed = false }
onchange(parsed)
} catch { /* JSON inválido — esperar */ }
}}
class="field-input font-mono text-xs resize-none"
spellcheck={false}
></textarea>
<p class="text-xs text-gray-700">
JsonLogic — variables son AttrIDs. Vacío = siempre activa.
</p>
{:else}
<!-- ── Visual builder ──────────────────────────────────────────── -->
<!-- AND / OR (solo con 2+ cláusulas) -->
{#if draft.clauses.length > 1}
<div class="flex items-center gap-1.5 text-xs">
<span class="text-gray-600 shrink-0">Combinar con</span>
{#each (['and', 'or'] as const) as l}
<button
onclick={() => setLogic(l)}
class="px-2 py-0.5 rounded border text-xs transition-colors
{draft.logic === l
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
: 'border-gray-700 text-gray-500 hover:border-gray-600'}"
>{l.toUpperCase()}</button>
{/each}
</div>
{/if}
<!-- Cláusulas -->
{#each draft.clauses as clause, i (i)}
{@const kind = getAttrKind(clause.attrId)}
{@const ops = OPS[kind]}
{@const options = getAttrOptions(clause.attrId)}
{@const multi = clause.op === 'in' || clause.op === '!in'}
<div class="flex flex-col gap-1.5 rounded border border-gray-800 bg-gray-900/40 p-2">
<!-- Fila de controles -->
<div class="flex items-center gap-1.5 flex-wrap">
<!-- Atributo -->
<select
value={clause.attrId}
onchange={(e) => setAttr(i, e.currentTarget.value)}
class="flex-1 min-w-0 text-xs rounded border border-gray-700 bg-gray-800
text-gray-300 px-1.5 py-1"
>
<option value="">— atributo —</option>
{#each allAttrs as a}
<option value={a.id}>{a.label}</option>
{/each}
</select>
<!-- Operador (omitir para boolean) -->
{#if clause.attrId && kind !== 'boolean' && kind !== 'unknown' && ops.length > 0}
<select
value={clause.op}
onchange={(e) => patchClause(i, { op: e.currentTarget.value as ClauseOp })}
class="shrink-0 text-xs rounded border border-gray-700 bg-gray-800
text-gray-300 px-1.5 py-1"
>
{#each ops as o}
<option value={o.op}>{o.label}</option>
{/each}
</select>
{/if}
<!-- Boolean: verdadero / falso -->
{#if clause.attrId && kind === 'boolean'}
<select
value={String(clause.boolValue)}
onchange={(e) => patchClause(i, { boolValue: e.currentTarget.value === 'true' })}
class="shrink-0 text-xs rounded border border-gray-700 bg-gray-800
text-gray-300 px-1.5 py-1"
>
<option value="true">es verdadero</option>
<option value="false">es falso</option>
</select>
{/if}
<!-- Number -->
{#if clause.attrId && kind === 'number'}
<input
type="number"
value={clause.numValue}
oninput={(e) => patchClause(i, { numValue: Number(e.currentTarget.value) })}
class="w-20 shrink-0 text-xs rounded border border-gray-700 bg-gray-800
text-gray-300 px-1.5 py-1"
/>
{/if}
<!-- String -->
{#if clause.attrId && kind === 'string'}
<input
type="text"
value={clause.strValue}
oninput={(e) => patchClause(i, { strValue: e.currentTarget.value })}
placeholder="valor..."
class="w-28 shrink-0 text-xs rounded border border-gray-700 bg-gray-800
text-gray-300 px-1.5 py-1"
/>
{/if}
<!-- Eliminar -->
<button
onclick={() => removeClause(i)}
class="shrink-0 text-gray-600 hover:text-red-400 transition-colors text-xs px-1"
title="Eliminar cláusula"
>✕</button>
</div>
<!-- Chips de opciones (required / optional) -->
{#if clause.attrId && kind === 'options'}
<div class="flex flex-wrap gap-1 pt-0.5 pl-0.5">
{#if options.length === 0}
<span class="text-xs text-gray-700 italic">Sin opciones en este atributo</span>
{:else}
{#each options as opt (opt.id)}
{@const active = clause.optValues.includes(opt.id)}
<button
onclick={() => toggleOpt(i, opt.id, multi)}
class="text-xs px-2 py-0.5 rounded-full border transition-colors
{active
? 'bg-indigo-600/30 border-indigo-500 text-indigo-300'
: 'border-gray-700 text-gray-500 hover:border-gray-600 hover:text-gray-300'}"
>
{optLabel(opt)}
</button>
{/each}
{/if}
</div>
{/if}
</div>
{/each}
<!-- Añadir cláusula -->
<button
onclick={addClause}
class="flex items-center gap-1.5 px-3 py-1.5 rounded border border-dashed
border-gray-700 text-xs text-gray-600 hover:text-indigo-400
hover:border-indigo-700 transition-colors"
>+ Añadir cláusula</button>
{/if}
</div>

@ -1,55 +0,0 @@
<script lang="ts">
/**
* ============================================================================
* ENTITIES PANEL
* ============================================================================
*
* Layout de tres columnas:
* EntityList | GroupList | AttributeList / Detail
*/
import { getContext } from 'svelte'
import type { EditorStore } from '../stores//editor.svelte'
import EntityList from './EntityList.svelte'
import GroupList from './GroupList.svelte'
import AttributeList from './AttributeList.svelte'
import AttributeDetail from './AttributeDetail.svelte'
const editor = getContext<EditorStore>('editor')
</script>
<div class="flex h-full">
<!-- Columna 1 — Entidades -->
<div class="w-56 shrink-0 border-r border-gray-800 flex flex-col">
<EntityList />
</div>
<!-- Columna 2 — Grupos (solo si hay entidad activa) -->
{#if editor.activeEntity}
<div class="w-56 shrink-0 border-r border-gray-800 flex flex-col">
<GroupList />
</div>
{/if}
<!-- Columna 3 — Atributos / detalle -->
<div class="flex-1 overflow-hidden flex flex-col">
{#if editor.activeGroup}
<AttributeList />
{:else if editor.activeAttrId && editor.activeEntity}
<!-- Atributo de entidad seleccionado desde GroupList -->
<div class="flex-1 overflow-auto">
<AttributeDetail />
</div>
{:else if editor.activeEntity}
<div class="flex items-center justify-center h-full text-gray-600 text-sm">
Selecciona un grupo o un atributo de entidad
</div>
{:else}
<div class="flex items-center justify-center h-full text-gray-600 text-sm">
Selecciona o crea una entidad
</div>
{/if}
</div>
</div>

@ -1,160 +0,0 @@
<script lang="ts">
/**
* ENTITY ATTRIBUTE LIST
*
* Muestra y edita los atributos en dos niveles:
* 1. Entidad (entity.attributes)
* 2. Catálogo (collection.attributes)
*
* Se muestra en col-3 cuando hay entidad activa pero no grupo activo.
*/
import { getContext } from 'svelte'
import type { EditorStore } from '../stores/editor.svelte'
import type { Attribute, AttrID } from '@/vice/libs/types'
import AttributeDetail from './AttributeDetail.svelte'
const editor = getContext<EditorStore>('editor')
const langs = $derived(editor.collection.langs)
const entity = $derived(editor.activeEntity!)
function ling(l: Record<string, string> | undefined, lang: string): string {
if (!l) return ''
return l[lang] ?? l['es'] ?? Object.values(l)[0] ?? ''
}
type Section = {
key: string
label: string
color: string
attrs: Attribute[]
onAdd: () => void
onDelete: (id: AttrID) => void
}
const sections = $derived((() => {
const e = entity // track entity reactively
return [
{
key: 'entity',
label: 'Entidad',
color: 'text-amber-500',
attrs: e.attributes,
onAdd: () => editor.addAttribute(e.id, undefined),
onDelete: (id: AttrID) => editor.deleteAttribute(e.id, undefined, id),
},
{
key: 'catalog',
label: 'Catálogo',
color: 'text-gray-500',
attrs: editor.collection.attributes,
onAdd: () => editor.addCatalogAttribute(),
onDelete: (id: AttrID) => editor.deleteCatalogAttribute(id),
},
] satisfies Section[]
})())
const typeLabel: Record<string, string> = {
required: 'req', optional: 'opt', fixed: 'fix', literal: 'lit',
}
const typeBadgeClass: Record<string, string> = {
required: 'bg-indigo-900/50 text-indigo-400',
optional: 'bg-amber-900/50 text-amber-400',
fixed: 'bg-gray-800 text-gray-500',
literal: 'bg-teal-900/50 text-teal-400',
}
</script>
<div class="flex h-full">
<!-- Lista -->
<div class="w-72 shrink-0 border-r border-gray-800 flex flex-col overflow-hidden">
<div class="flex-1 overflow-y-auto">
{#each sections as section (section.key)}
<!-- Cabecera de sección -->
<div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-800
sticky top-0 bg-gray-950 z-10">
<span class="text-xs font-semibold uppercase tracking-wider {section.color}">
{section.label}
<span class="text-gray-700 font-normal ml-1">({section.attrs.length})</span>
</span>
<button
onclick={section.onAdd}
class="text-gray-600 hover:text-indigo-400 transition-colors text-sm
w-5 h-5 flex items-center justify-center rounded hover:bg-indigo-500/10"
title="Añadir atributo"
>+</button>
</div>
<!-- Atributos -->
{#if section.attrs.length === 0}
<div class="px-4 py-5 text-center text-gray-700 text-xs italic">
Sin atributos
</div>
{:else}
{#each section.attrs as attr (attr.id)}
{@const isActive = editor.activeAttrId === attr.id}
{@const hasError = editor.validation.errors.some(e => e.path.includes(attr.id))}
{@const hasWarn = editor.validation.warnings.some(e => e.path.includes(attr.id))}
{@const name = ling(attr.name as Record<string,string>, langs[0])}
<div
role="button"
tabindex="0"
onclick={() => editor.setActiveAttribute(attr.id)}
onkeydown={(e) => e.key === 'Enter' && editor.setActiveAttribute(attr.id)}
class="
w-full text-left px-4 py-2.5 flex items-center gap-2 text-sm cursor-pointer
transition-colors group border-l-2
{isActive
? 'bg-indigo-600/20 text-indigo-300 border-indigo-500'
: 'text-gray-400 hover:bg-gray-800 hover:text-gray-200 border-transparent'}
"
>
<span class="text-xs px-1.5 py-0.5 rounded shrink-0
{typeBadgeClass[attr.type] ?? 'bg-gray-800 text-gray-500'}">
{typeLabel[attr.type] ?? attr.type}
</span>
{#if hasError}
<span class="w-1.5 h-1.5 rounded-full bg-red-400 shrink-0"></span>
{:else if hasWarn}
<span class="w-1.5 h-1.5 rounded-full bg-amber-400 shrink-0"></span>
{/if}
<span class="flex-1 truncate">
{#if name}{name}{:else}<span class="italic text-gray-600">Sin nombre</span>{/if}
</span>
{#if attr.type === 'required' || attr.type === 'optional'}
<span class="text-xs text-gray-600 shrink-0">
{(attr as any).options?.length ?? 0}
</span>
{/if}
<button
onclick={(e) => { e.stopPropagation(); section.onDelete(attr.id) }}
class="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400
transition-opacity text-xs"
>✕</button>
</div>
{/each}
{/if}
{/each}
</div>
</div>
<!-- Detalle -->
<div class="flex-1 overflow-auto">
{#if editor.activeAttribute}
<AttributeDetail />
{:else}
<div class="flex items-center justify-center h-full text-gray-600 text-sm">
Selecciona un atributo para editarlo
</div>
{/if}
</div>
</div>

@ -1,89 +0,0 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { EditorStore } from '../../editor.svelte'
const editor = getContext<EditorStore>('editor')
const entities = $derived(Object.values(editor.collection.entities))
</script>
<!-- Header -->
<div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-800">
<span class="text-xs font-semibold text-gray-400 uppercase tracking-wider">
Entidades ({entities.length})
</span>
<button
onclick={() => editor.addEntity()}
class="text-gray-500 hover:text-indigo-400 transition-colors text-sm
w-6 h-6 flex items-center justify-center rounded hover:bg-indigo-500/10"
title="Nueva entidad"
>+</button>
</div>
<div class="flex-1 overflow-y-auto">
{#if entities.length === 0}
<div class="px-4 py-8 text-center text-gray-600 text-xs">
<p>Sin entidades</p>
<p class="mt-1 text-gray-700">
Una entidad es un producto configurable — apartamento, coche, habitación...
</p>
<button
onclick={() => editor.addEntity()}
class="mt-3 text-xs px-3 py-1.5 rounded border border-dashed border-gray-700
text-gray-500 hover:text-indigo-400 hover:border-indigo-700 transition-colors"
>
Crear entidad
</button>
</div>
{:else}
<ul class="py-1">
{#each entities as entity (entity.id)}
<li>
<div
role="button"
tabindex="0"
onclick={() => editor.setActiveEntity(entity.id)}
onkeydown={(e) => e.key === 'Enter' && editor.setActiveEntity(entity.id)}
class="
w-full text-left px-4 py-2.5 flex items-center gap-2 text-sm cursor-pointer
transition-colors group
{editor.activeEntityId === entity.id
? 'bg-indigo-600/20 text-indigo-300'
: 'text-gray-400 hover:bg-gray-800 hover:text-gray-200'}
"
>
<!-- Indicador de error -->
{#if editor.validation.errors.some(e => e.path.startsWith(`entities.${entity.id}`))}
<span class="w-1.5 h-1.5 rounded-full bg-red-400 shrink-0"></span>
{:else if editor.validation.warnings.some(e => e.path.startsWith(`entities.${entity.id}`))}
<span class="w-1.5 h-1.5 rounded-full bg-amber-400 shrink-0"></span>
{:else}
<span class="w-1.5 h-1.5 rounded-full bg-transparent shrink-0"></span>
{/if}
<span class="flex-1 truncate">
{#if entity.name.es}
{entity.name.es}
{:else}
<span class="italic text-gray-600">Sin nombre</span>
{/if}
</span>
<!-- Nº grupos -->
<span class="text-xs text-gray-600 shrink-0">
{Object.keys(entity.groups).length}g
</span>
<!-- Botón eliminar -->
<button
onclick={(e) => { e.stopPropagation(); editor.deleteEntity(entity.id) }}
class="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400
transition-opacity text-xs px-1"
title="Eliminar entidad"
>✕</button>
</div>
</li>
{/each}
</ul>
{/if}
</div>

@ -1,64 +0,0 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { EditorStore } from '../../editor.svelte'
import type { SupportedLocale } from '../../types'
const editor = getContext<EditorStore>('editor')
const entity = $derived(editor.activeEntity!)
const langs = $derived(editor.collection.langs)
function patch(key: string, value: unknown) {
editor.updateEntity(entity.id, { [key]: value })
}
function patchI18n(field: 'name' | 'description', lang: SupportedLocale, value: string) {
const current = entity[field]
editor.updateEntity(entity.id, { [field]: { ...current, [lang]: value } })
if (field === 'name' && lang === 'es' && !entity.code) {
editor.updateEntity(entity.id, {
code: value.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '')
})
}
}
</script>
<div class="flex flex-col gap-2">
<!-- Código -->
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500">
Código
{#if !entity.code}
<span class="text-red-400 ml-1">*</span>
{/if}
</label>
<input
type="text"
value={entity.code}
oninput={(e) => patch('code', e.currentTarget.value)}
placeholder="apartamento"
class="field-input font-mono text-xs {!entity.code ? 'border-red-800' : ''}"
/>
</div>
<!-- Nombre por lang -->
{#each langs as lang (lang)}
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500">
Nombre ({lang})
{#if lang === 'es' && !entity.name.es}
<span class="text-red-400 ml-1">*</span>
{/if}
</label>
<input
type="text"
value={entity.name[lang] ?? ''}
oninput={(e) => patchI18n('name', lang, e.currentTarget.value)}
placeholder={lang === 'es' ? 'Apartamento' : 'Apartment'}
class="field-input {lang === 'es' && !entity.name.es ? 'border-red-800' : ''}"
/>
</div>
{/each}
</div>

@ -1,187 +0,0 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { EditorStore } from '../stores/editor.svelte'
import type { GroupID } from '@/vice/libs/types'
import EntityMetaForm from './EntityMetaForm.svelte'
const editor = getContext<EditorStore>('editor')
const langs = $derived(editor.collection.langs)
const entity = $derived(editor.activeEntity!)
function ling(l: Record<string, string> | undefined): string {
if (!l) return ''
return l[langs[0]] ?? l['es'] ?? Object.values(l)[0] ?? ''
}
const groups = $derived(
(entity.groupOrder ?? (Object.keys(entity.groups) as GroupID[]))
.map(id => entity.groups[id])
.filter(Boolean)
)
let showMeta = $state(false)
const typeLabel: Record<string, string> = {
required: 'req', optional: 'opt', fixed: 'fix', literal: 'lit',
}
const typeBadgeClass: Record<string, string> = {
required: 'bg-indigo-900/50 text-indigo-400',
optional: 'bg-amber-900/50 text-amber-400',
fixed: 'bg-gray-800 text-gray-500',
literal: 'bg-teal-900/50 text-teal-400',
}
</script>
<!-- Nombre de la entidad activa + toggle meta -->
<div class="px-4 py-3 border-b border-gray-800 shrink-0">
<button
onclick={() => showMeta = !showMeta}
class="w-full text-left flex items-center justify-between group"
>
<span class="text-xs font-semibold text-gray-300 uppercase tracking-wider truncate">
{ling(entity.name as Record<string,string>) || 'Entidad sin nombre'}
</span>
<span class="text-gray-600 text-xs group-hover:text-gray-400 transition-colors">
{showMeta ? '▲' : '▼'}
</span>
</button>
{#if showMeta}
<div class="mt-3">
<EntityMetaForm />
</div>
{/if}
</div>
<div class="flex-1 overflow-y-auto">
<!-- ── Grupos ─────────────────────────────────────────────────────────── -->
<div class="flex items-center justify-between px-4 py-2 border-b border-gray-800 sticky top-0 bg-gray-900 z-10">
<span class="text-xs font-semibold text-gray-400 uppercase tracking-wider">
Grupos <span class="text-gray-700 font-normal">({groups.length})</span>
</span>
<div class="flex items-center gap-1">
<button
onclick={() => editor.addGroup(entity.id, 'fixed')}
class="text-gray-500 hover:text-indigo-400 transition-colors text-xs px-1.5 py-0.5
rounded border border-gray-700 hover:border-indigo-700"
title="Grupo fijo"
>fx</button>
<button
onclick={() => editor.addGroup(entity.id, 'optional')}
class="text-gray-500 hover:text-amber-400 transition-colors text-xs px-1.5 py-0.5
rounded border border-gray-700 hover:border-amber-700"
title="Grupo opcional"
>opt</button>
</div>
</div>
{#if groups.length === 0}
<div class="px-4 py-6 text-center text-gray-700 text-xs italic">
Sin grupos
<button
onclick={() => editor.addGroup(entity.id, 'fixed')}
class="block mx-auto mt-2 px-3 py-1 rounded border border-dashed border-gray-700
text-gray-600 hover:text-indigo-400 hover:border-indigo-700 transition-colors"
>Crear grupo</button>
</div>
{:else}
<ul>
{#each groups as group (group.id)}
<li>
<div
role="button"
tabindex="0"
onclick={() => editor.setActiveGroup(group.id)}
onkeydown={(e) => e.key === 'Enter' && editor.setActiveGroup(group.id)}
class="
w-full text-left px-4 py-2.5 flex items-center gap-2 text-sm cursor-pointer
transition-colors group
{editor.activeGroupId === group.id
? 'bg-indigo-600/20 text-indigo-300'
: 'text-gray-400 hover:bg-gray-800 hover:text-gray-200'}
"
>
<span class="text-xs text-gray-600 shrink-0 w-3">
{group.kind === 'fixed' ? '◉' : '◈'}
</span>
{#if editor.validation.errors.some(e => e.path.includes(group.id))}
<span class="w-1.5 h-1.5 rounded-full bg-red-400 shrink-0"></span>
{:else if editor.validation.warnings.some(e => e.path.includes(group.id))}
<span class="w-1.5 h-1.5 rounded-full bg-amber-400 shrink-0"></span>
{/if}
<span class="flex-1 truncate">
{#if ling(group.name as Record<string,string>)}
{ling(group.name as Record<string,string>)}
{:else}
<span class="italic text-gray-600">Sin nombre</span>
{/if}
</span>
<span class="text-xs text-gray-600 shrink-0">{group.attributes.length}</span>
<button
onclick={(e) => { e.stopPropagation(); editor.deleteGroup(entity.id, group.id) }}
class="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400
transition-opacity text-xs px-1"
>✕</button>
</div>
</li>
{/each}
</ul>
{/if}
<!-- ── Atributos de entidad ───────────────────────────────────────────── -->
<div class="flex items-center justify-between px-4 py-2 border-t border-b border-gray-800 sticky top-0 bg-gray-900 z-10 mt-1">
<span class="text-xs font-semibold text-amber-600 uppercase tracking-wider">
Attrs entidad <span class="text-gray-700 font-normal">({entity.attributes.length})</span>
</span>
<button
onclick={() => editor.addAttribute(entity.id, undefined)}
class="text-gray-600 hover:text-amber-400 transition-colors text-sm
w-5 h-5 flex items-center justify-center rounded hover:bg-amber-500/10"
title="Añadir atributo de entidad"
>+</button>
</div>
{#if entity.attributes.length === 0}
<div class="px-4 py-4 text-center text-gray-700 text-xs italic">
Sin atributos de entidad
</div>
{:else}
{#each entity.attributes as attr (attr.id)}
{@const isActive = editor.activeAttrId === attr.id}
{@const name = ling(attr.name as Record<string,string>)}
<div
role="button"
tabindex="0"
onclick={() => editor.setActiveEntityAttribute(attr.id)}
onkeydown={(e) => e.key === 'Enter' && editor.setActiveEntityAttribute(attr.id)}
class="
w-full text-left px-4 py-2 flex items-center gap-2 text-sm cursor-pointer
transition-colors group border-l-2
{isActive
? 'bg-amber-600/20 text-amber-300 border-amber-500'
: 'text-gray-400 hover:bg-gray-800 hover:text-gray-200 border-transparent'}
"
>
<span class="text-xs px-1.5 py-0.5 rounded shrink-0
{typeBadgeClass[attr.type] ?? 'bg-gray-800 text-gray-500'}">
{typeLabel[attr.type] ?? attr.type}
</span>
<span class="flex-1 truncate">
{#if name}{name}{:else}<span class="italic text-gray-600">Sin nombre</span>{/if}
</span>
<button
onclick={(e) => { e.stopPropagation(); editor.deleteAttribute(entity.id, undefined, attr.id) }}
class="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400
transition-opacity text-xs"
>✕</button>
</div>
{/each}
{/if}
</div>

@ -1,79 +0,0 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { EditorStore } from '../../editor.svelte'
import type { SupportedLocale } from '../../types'
const editor = getContext<EditorStore>('editor')
const group = $derived(editor.activeGroup!)
const entity = $derived(editor.activeEntity!)
const langs = $derived(editor.collection.langs)
function patch(key: string, value: unknown) {
editor.updateGroup(entity.id, group.id, { [key]: value })
}
function patchI18n(field: 'name' | 'description', lang: SupportedLocale, value: string) {
const current = group[field]
editor.updateGroup(entity.id, group.id, { [field]: { ...current, [lang]: value } })
if (field === 'name' && lang === 'es' && !group.code) {
editor.updateGroup(entity.id, group.id, {
code: value.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '')
})
}
}
</script>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<!-- Código -->
<div class="flex flex-col gap-1 flex-1">
<label class="text-xs text-gray-500">
Código
{#if !group.code}
<span class="text-red-400 ml-1">*</span>
{/if}
</label>
<input
type="text"
value={group.code}
oninput={(e) => patch('code', e.currentTarget.value)}
placeholder="salon"
class="field-input font-mono text-xs {!group.code ? 'border-red-800' : ''}"
/>
</div>
<!-- Tipo (solo lectura en visual, editable en kind) -->
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500">Tipo</label>
<select
onchange={(e) => patch('kind', e.currentTarget.value)}
class="field-input text-xs"
>
<option value="fixed" selected={group.kind === 'fixed'}>Fijo</option>
<option value="optional" selected={group.kind === 'optional'}>Opcional</option>
</select>
</div>
</div>
<!-- Nombre por lang -->
{#each langs as lang (lang)}
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500">
Nombre ({lang})
{#if lang === 'es' && !group.name.es}
<span class="text-red-400 ml-1">*</span>
{/if}
</label>
<input
type="text"
value={group.name[lang] ?? ''}
oninput={(e) => patchI18n('name', lang, e.currentTarget.value)}
placeholder={lang === 'es' ? 'Salón' : 'Living room'}
class="field-input {lang === 'es' && !group.name.es ? 'border-red-800' : ''}"
/>
</div>
{/each}
</div>

@ -1,219 +0,0 @@
<script lang="ts">
import { getContext } from 'svelte'
import { ling } from '@/ling/lib'
import type { SupportedLocale } from '@/ling/lib'
import type { EditorStore } from '../stores/editor.svelte.ts'
const editor = getContext<EditorStore>('editor')
const opt = $derived(editor.activeOption!)
const langs = $derived(editor.collection.langs)
function patch(key: string, value: unknown) {
editor.updateOption(opt.id, { [key]: value } as any)
}
function patchI18n(field: 'name' | 'description', lang: SupportedLocale, value: string) {
const current = (opt[field] ?? {}) as any
editor.updateOption(opt.id, { [field]: { ...current, [lang]: value } } as any)
}
function setPricingType(type: 'fixed' | 'dynamic') {
patch('pricing', type === 'fixed'
? { baseAmount: 0 }
: { baseAmount: 0, dynamicExpression: {} }
)
}
function setBaseAmount(value: number) {
patch('pricing', { ...opt.pricing, baseAmount: value })
}
// Tags
let tagInput = $state('')
function addTag() {
const tag = tagInput.trim()
if (!tag || (opt.tags ?? []).includes(tag)) return
patch('tags', [...(opt.tags ?? []), tag])
tagInput = ''
}
function removeTag(tag: string) {
patch('tags', (opt.tags ?? []).filter(t => t !== tag))
}
function handleTagKey(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addTag() }
}
// Usos — en qué atributos está referenciada esta opción
const usages = $derived.by(() => {
const uses: Array<{ entityName: string; groupName: string; attrName: string }> = []
for (const entity of Object.values(editor.collection.entities)) {
for (const group of Object.values(entity.groups)) {
for (const attr of group.attributes) {
if (attr.type !== 'fixed' && attr.type !== 'literal' && attr.options.some(o => o.optionId === opt.id)) {
uses.push({
entityName: ling.ts(entity.name),
groupName: ling.ts(group.name),
attrName: ling.ts(attr.name),
})
}
}
}
}
return uses
})
const name = $derived((opt.name ?? {}) as any)
const desc = $derived((opt.description ?? {}) as any)
const pricingType = $derived(opt.pricing?.dynamicExpression ? 'dynamic' : 'fixed')
</script>
<div class="p-6 max-w-2xl flex flex-col gap-6">
<!-- Header -->
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold text-gray-200">{ling.ts(opt.name) || 'Opción sin nombre'}</h2>
<div class="flex items-center gap-2">
<button onclick={() => editor.duplicateOption(opt.id)}
class="text-xs px-2 py-1 rounded border border-gray-700 text-gray-500
hover:text-gray-300 hover:border-gray-600 transition-colors">
Duplicar
</button>
<span class="text-xs text-gray-600 font-mono">{opt.id}</span>
</div>
</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>
<!-- Código -->
<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">— usado en templates de imagen</span>
{#if !opt.code}<span class="text-red-400 ml-1">*</span>{/if}
</label>
<input type="text" value={opt.code}
oninput={(e) => patch('code', e.currentTarget.value)}
placeholder="parquet_roble"
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</label>
<div class="grid grid-cols-2 gap-2">
{#each 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)}
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 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>
<!-- Precio -->
<section class="flex flex-col gap-3">
<h3 class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Precio</h3>
<div class="flex gap-2">
{#each (['fixed', 'dynamic'] as const) as type}
<button onclick={() => setPricingType(type)}
class="flex-1 py-2 text-xs rounded-md border transition-colors
{pricingType === type
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
: 'border-gray-700 text-gray-500 hover:border-gray-600 hover:text-gray-300'}">
{type === 'fixed' ? 'Precio fijo' : 'Precio dinámico'}
</button>
{/each}
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500">Precio base (€)</label>
<div class="relative">
<input type="number" value={opt.pricing?.baseAmount ?? 0}
oninput={(e) => setBaseAmount(Number(e.currentTarget.value))}
min="0" step="0.01" class="field-input pr-8" />
<span class="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-600">€</span>
</div>
</div>
{#if pricingType === 'dynamic'}
<div class="rounded-md bg-amber-500/10 border border-amber-500/20 p-3 text-xs text-amber-300">
El precio dinámico usa una expresión JsonLogic. Edítala en el JSON exportado.
</div>
{/if}
</section>
<!-- Tags -->
<section class="flex flex-col gap-3">
<h3 class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Tags</h3>
<div class="flex flex-wrap gap-1.5 min-h-6">
{#each (opt.tags ?? []) as tag (tag)}
<span class="flex items-center gap-1 px-2 py-0.5 rounded-full bg-gray-800
border border-gray-700 text-xs text-gray-300">
{tag}
<button onclick={() => removeTag(tag)}
class="text-gray-600 hover:text-red-400 transition-colors ml-0.5">✕</button>
</span>
{:else}
<span class="text-xs text-gray-700 italic">Sin tags</span>
{/each}
</div>
<div class="flex gap-2">
<input type="text" bind:value={tagInput} onkeydown={handleTagKey}
placeholder="suelo, material... (Enter para añadir)"
class="flex-1 field-input text-xs" />
<button onclick={addTag} disabled={!tagInput.trim()}
class="px-3 py-1.5 text-xs rounded-md border border-gray-700 text-gray-400
hover:text-indigo-400 hover:border-indigo-700 transition-colors
disabled:opacity-40 disabled:cursor-not-allowed">
Añadir
</button>
</div>
</section>
<!-- Usos -->
{#if usages.length > 0}
<section class="flex flex-col gap-3">
<h3 class="text-xs font-semibold text-gray-500 uppercase tracking-wider">
Usada en <span class="text-gray-600 font-normal">({usages.length})</span>
</h3>
<div class="flex flex-col gap-1">
{#each usages as use}
<div class="flex items-center gap-2 text-xs text-gray-500 px-3 py-1.5
rounded bg-gray-900/50 border border-gray-800">
<span class="text-gray-600">{use.entityName}</span>
<span class="text-gray-700">›</span>
<span class="text-gray-600">{use.groupName}</span>
<span class="text-gray-700">›</span>
<span class="text-gray-400">{use.attrName}</span>
</div>
{/each}
</div>
</section>
{/if}
</div>

@ -1,130 +0,0 @@
<script lang="ts">
import { getContext } from 'svelte'
import { ling } from '@/ling/lib'
import type { EditorStore } from '../stores/editor.svelte.ts'
import OptionDetail from './OptionDetail.svelte'
const editor = getContext<EditorStore>('editor')
let search = $state('')
const options = $derived(Object.values(editor.collection.options))
const filtered = $derived(
options.filter(o =>
!search ||
ling.ts(o.name).toLowerCase().includes(search.toLowerCase()) ||
o.code.toLowerCase().includes(search.toLowerCase()) ||
(o.tags ?? []).some(t => t.toLowerCase().includes(search.toLowerCase()))
)
)
// Agrupar por primer tag
const grouped = $derived.by(() => {
const groups = new Map<string, typeof filtered>()
for (const opt of filtered) {
const tag = (opt.tags ?? [])[0] ?? '—'
if (!groups.has(tag)) groups.set(tag, [])
groups.get(tag)!.push(opt)
}
return groups
})
</script>
<div class="flex h-full">
<!-- Lista -->
<div class="w-64 shrink-0 border-r border-gray-800 flex flex-col">
<!-- Header -->
<div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-800">
<span class="text-xs font-semibold text-gray-400 uppercase tracking-wider">
Opciones ({options.length})
</span>
<button
onclick={() => editor.addOption()}
class="text-gray-500 hover:text-indigo-400 transition-colors text-sm
w-6 h-6 flex items-center justify-center rounded hover:bg-indigo-500/10"
>+</button>
</div>
<!-- Búsqueda -->
<div class="px-3 py-2 border-b border-gray-800">
<input type="text" bind:value={search} placeholder="Buscar..."
class="w-full bg-gray-800 text-xs text-gray-300 px-2.5 py-1.5 rounded
border border-gray-700 focus:outline-none focus:border-indigo-500" />
</div>
<!-- Lista -->
<div class="flex-1 overflow-y-auto py-1">
{#if options.length === 0}
<div class="px-4 py-8 text-center text-gray-600 text-xs">
<p>Sin opciones</p>
<p class="mt-1 text-gray-700">Parquet, mármol, lacado blanco...</p>
</div>
{:else if filtered.length === 0}
<div class="px-4 py-6 text-center text-xs text-gray-600">Sin resultados</div>
{:else}
{#each [...grouped] as [tag, opts]}
<div class="px-3 py-1.5 text-xs text-gray-600 uppercase tracking-wider bg-gray-900/50 sticky top-0">
{tag}
</div>
{#each opts as opt (opt.id)}
<div
role="button" tabindex="0"
onclick={() => editor.setActiveOption(opt.id)}
onkeydown={(e) => e.key === 'Enter' && editor.setActiveOption(opt.id)}
class="px-3 py-2 flex items-center gap-2 text-sm cursor-pointer transition-colors group
{editor.activeOptionId === opt.id
? 'bg-indigo-600/20 text-indigo-300'
: 'text-gray-400 hover:bg-gray-800 hover:text-gray-200'}"
>
{#if editor.validation.errors.some(e => e.path.startsWith(`options.${opt.id}`))}
<span class="w-1.5 h-1.5 rounded-full bg-red-400 shrink-0"></span>
{:else if editor.validation.warnings.some(e => e.path.startsWith(`options.${opt.id}`))}
<span class="w-1.5 h-1.5 rounded-full bg-amber-400 shrink-0"></span>
{:else}
<span class="w-1.5 h-1.5 shrink-0"></span>
{/if}
<span class="flex-1 truncate text-xs">{ling.ts(opt.name) || 'Sin nombre'}</span>
{#if opt.pricing && !opt.pricing.dynamicExpression}
<span class="text-xs text-gray-600 shrink-0">{opt.pricing.baseAmount}€</span>
{/if}
<button
onclick={(e) => { e.stopPropagation(); editor.deleteOption(opt.id) }}
class="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400
transition-opacity text-xs"
>✕</button>
</div>
{/each}
{/each}
{/if}
</div>
</div>
<!-- Detalle -->
<div class="flex-1 overflow-auto">
{#if editor.activeOption}
<OptionDetail />
{:else}
<div class="flex flex-col items-center justify-center h-full gap-3 text-gray-600">
<p class="text-sm">Selecciona o crea una opción</p>
<p class="text-xs text-gray-700 max-w-xs text-center">
Las opciones son los valores seleccionables — parquet, mármol, lacado blanco...
Se definen una vez y se reutilizan en múltiples atributos.
</p>
<button
onclick={() => editor.addOption()}
class="mt-2 text-xs px-3 py-1.5 rounded border border-dashed border-gray-700
text-gray-500 hover:text-indigo-400 hover:border-indigo-700 transition-colors"
>
Crear primera opción
</button>
</div>
{/if}
</div>
</div>

@ -1,240 +0,0 @@
<script lang="ts">
/**
* RULE EDITOR
*
* Editor de una regla individual. Embebible en cualquier contexto
* (atributo, grupo, entidad).
*
* Props:
* rule — la regla actual
* onchange — callback cuando cambia (recibe el patch)
* ondelete — callback para eliminar
*/
import { getContext } from 'svelte'
import type { EditorStore } from '../stores/editor.svelte'
import type { Rule, RuleActionType, NodeId, OptionID, Severity } from '@/vice/libs/types'
import ConditionBuilder from './ConditionBuilder.svelte'
const editor = getContext<EditorStore>('editor')
let {
rule,
onchange,
ondelete,
}: {
rule: Rule
onchange: (patch: Partial<Rule>) => void
ondelete: () => void
} = $props()
const langs = $derived(editor.collection.langs)
const allOptions = $derived(Object.values(editor.collection.options))
// Todos los nodos disponibles como targets (atributos de la entidad activa)
const allAttrs = $derived((() => {
const entity = editor.activeEntity
const attrs: Array<{ id: string; label: string }> = []
// Atributos de catálogo
for (const a of editor.collection.attributes) {
const name = (a.name as Record<string, string>)?.[langs[0]] || a.code || a.id
attrs.push({ id: a.id, label: `catálogo / ${name}` })
}
if (!entity) return attrs
// Atributos de entidad
for (const a of entity.attributes) {
const name = (a.name as Record<string, string>)?.[langs[0]] || a.code || a.id
attrs.push({ id: a.id, label: `entidad / ${name}` })
}
// Atributos de todos los grupos
for (const grp of Object.values(entity.groups)) {
for (const a of grp.attributes) {
const name = (a.name as Record<string, string>)?.[langs[0]] || a.code || a.id
const grpName = (grp.name as Record<string, string>)?.[langs[0]] || grp.code
attrs.push({ id: a.id, label: `${grpName} / ${name}` })
}
}
return attrs
})())
const allGroups = $derived((() => {
const entity = editor.activeEntity
if (!entity) return []
return Object.values(entity.groups).map(g => ({
id: g.id,
label: (g.name as Record<string, string>)?.[langs[0]] || g.code || g.id,
}))
})())
// Action types que aplican a attrs vs grupos
const attrActions: RuleActionType[] = ['allow', 'forbid', 'require', 'suggest']
const groupActions: RuleActionType[] = ['activate', 'deactivate']
function ling(l: Record<string, string> | undefined, lang: string): string {
if (!l) return ''
return l[lang] ?? l['es'] ?? Object.values(l)[0] ?? ''
}
function patchAction(key: string, value: unknown) {
onchange({ action: { ...rule.action, [key]: value } })
}
function patchMessage(lang: string, value: string) {
const current = (rule.message as Record<string, string>) ?? {}
onchange({ message: { ...current, [lang]: value } as any })
}
function toggleValue(optionId: OptionID) {
const current = rule.action.values ?? []
const next = current.includes(optionId)
? current.filter(v => v !== optionId)
: [...current, optionId]
patchAction('values', next)
}
const needsValues = $derived(
rule.action.type === 'allow' ||
rule.action.type === 'forbid' ||
rule.action.type === 'suggest'
)
// Opciones disponibles en el target attr (si aplica)
const targetAttrOptions = $derived((() => {
if (!needsValues) return []
const entity = editor.activeEntity
// Buscar el atributo target en todos los niveles
const allAttrsList = [
...editor.collection.attributes,
...(entity ? entity.attributes : []),
...(entity ? Object.values(entity.groups).flatMap(g => g.attributes) : []),
]
const target = allAttrsList.find(a => a.id === rule.action.target)
if (target && (target.type === 'required' || target.type === 'optional')) {
return (target as any).options
.map((ao: any) => editor.collection.options[ao.optionId])
.filter(Boolean)
}
return allOptions
})())
</script>
<div class="rounded-md border border-gray-800 bg-gray-900/60 flex flex-col gap-0 overflow-hidden">
<!-- Header de la regla -->
<div class="flex items-center gap-2 px-3 py-2 border-b border-gray-800 bg-gray-900">
<!-- Severity -->
<select
value={rule.severity}
onchange={(e) => onchange({ severity: e.currentTarget.value as Severity })}
class="text-xs rounded border border-gray-700 bg-gray-800 px-1.5 py-1
{rule.severity === 'error' ? 'text-red-400' :
rule.severity === 'warning' ? 'text-amber-400' : 'text-blue-400'}"
>
<option value="error">error</option>
<option value="warning">warning</option>
<option value="info">info</option>
</select>
<!-- Action type -->
<select
value={rule.action.type}
onchange={(e) => patchAction('type', e.currentTarget.value)}
class="text-xs rounded border border-gray-700 bg-gray-800 text-gray-300 px-1.5 py-1 flex-1"
>
<optgroup label="Atributo">
{#each attrActions as t}
<option value={t}>{t}</option>
{/each}
</optgroup>
<optgroup label="Grupo">
{#each groupActions as t}
<option value={t}>{t}</option>
{/each}
</optgroup>
</select>
<!-- Target -->
{#if groupActions.includes(rule.action.type)}
<select
value={rule.action.target ?? ''}
onchange={(e) => patchAction('target', e.currentTarget.value)}
class="text-xs rounded border border-gray-700 bg-gray-800 text-gray-300 px-1.5 py-1 flex-1"
>
<option value="">— grupo —</option>
{#each allGroups as g}
<option value={g.id}>{g.label}</option>
{/each}
</select>
{:else}
<select
value={rule.action.target ?? ''}
onchange={(e) => patchAction('target', e.currentTarget.value)}
class="text-xs rounded border border-gray-700 bg-gray-800 text-gray-300 px-1.5 py-1 flex-1"
>
<option value="">— atributo —</option>
{#each allAttrs as a}
<option value={a.id}>{a.label}</option>
{/each}
</select>
{/if}
<!-- Eliminar -->
<button
onclick={ondelete}
class="text-gray-600 hover:text-red-400 transition-colors text-xs px-1"
title="Eliminar regla"
>✕</button>
</div>
<!-- Valores (allow/forbid/suggest) -->
{#if needsValues && targetAttrOptions.length > 0}
<div class="px-3 py-2 border-b border-gray-800 flex flex-wrap gap-1">
{#each targetAttrOptions as opt (opt.id)}
{@const active = (rule.action.values ?? []).includes(opt.id)}
<button
onclick={() => toggleValue(opt.id)}
class="text-xs px-2 py-0.5 rounded-full border transition-colors
{active
? 'bg-indigo-600/30 border-indigo-500 text-indigo-300'
: 'border-gray-700 text-gray-500 hover:border-gray-600 hover:text-gray-300'}"
>
{ling(opt.name as Record<string, string>, langs[0]) || opt.code}
</button>
{/each}
{#if needsValues && targetAttrOptions.length === 0}
<span class="text-xs text-gray-600 italic">Selecciona un atributo target primero</span>
{/if}
</div>
{/if}
<!-- Condition (ConditionBuilder) -->
<div class="px-3 py-2 border-b border-gray-800">
<ConditionBuilder
condition={rule.condition}
{allAttrs}
onchange={(jl) => onchange({ condition: jl })}
/>
</div>
<!-- Mensaje -->
<div class="px-3 py-2 flex flex-col gap-1">
<label class="text-xs text-gray-600 uppercase tracking-wider">Mensaje</label>
{#each langs as lang (lang)}
<input
type="text"
value={(rule.message as Record<string, string>)?.[lang] ?? ''}
oninput={(e) => patchMessage(lang, e.currentTarget.value)}
placeholder="Esta opción requiere..."
class="field-input text-xs"
/>
{/each}
</div>
</div>

@ -1,78 +0,0 @@
<script lang="ts">
/**
* RULE LIST
*
* Lista de reglas embebible en AttributeDetail, GroupList, etc.
*
* Props:
* rules — array de reglas actuales
* entityId — contexto
* groupId — opcional
* attrId — opcional
*/
import { getContext } from 'svelte'
import type { EditorStore } from '../stores/editor.svelte'
import type { Rule, RuleID, EntityID, GroupID, AttrID } from '@/vice/libs/types'
import RuleEditor from './RuleEditor.svelte'
const editor = getContext<EditorStore>('editor')
let {
rules = [],
entityId,
groupId,
attrId,
}: {
rules?: Rule[]
entityId: EntityID
groupId?: GroupID
attrId?: AttrID
} = $props()
function uid(): string {
return 'rl:' + Math.random().toString(36).slice(2, 9)
}
function addRule() {
const rule: Rule = {
id: uid() as RuleID,
severity: 'warning',
action: { type: 'forbid', target: '' as any },
message: {} as any,
}
editor.addRule(entityId, groupId, attrId, rule)
}
function updateRule(ruleId: string, patch: Partial<Rule>) {
editor.updateRule(entityId, groupId, attrId, ruleId, patch)
}
function deleteRule(ruleId: string) {
editor.deleteRule(entityId, groupId, attrId, ruleId)
}
</script>
<div class="flex flex-col gap-2">
{#if rules.length === 0}
<p class="text-xs text-gray-700 italic px-1">Sin reglas</p>
{:else}
{#each rules as rule (rule.id)}
<RuleEditor
{rule}
onchange={(patch) => updateRule(rule.id!, patch)}
ondelete={() => deleteRule(rule.id!)}
/>
{/each}
{/if}
<button
onclick={addRule}
class="flex items-center gap-2 px-3 py-2 rounded-md border border-dashed
border-gray-700 text-xs text-gray-600 hover:text-indigo-400
hover:border-indigo-700 transition-colors"
>
+ Añadir regla
</button>
</div>

@ -1,35 +0,0 @@
<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>

@ -1,74 +0,0 @@
<script lang="ts">
import { getContext } from 'svelte'
import { ling } from '@/ling/lib'
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>

@ -1,191 +0,0 @@
<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>
/* ── Utilidades globales del editor ─────────────────────────────────── */
:global(.field-input) {
width: 100%;
background: #111827;
border: 1px solid #1f2937;
border-radius: 0.375rem;
padding: 0.375rem 0.625rem;
font-size: 0.8125rem;
color: #e2e8f0;
outline: none;
transition: border-color 120ms;
font-family: inherit;
}
:global(.field-input:focus) {
border-color: #4f46e5;
}
:global(.field-input.border-red-800) {
border-color: #991b1b;
}
:global(select.field-input) {
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.5rem center;
padding-right: 1.75rem;
}
:global(textarea.field-input) {
resize: vertical;
}
:global(.section-title) {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #6b7280;
padding-bottom: 0.5rem;
border-bottom: 1px solid #1f2937;
}
:global(.section-title-inline) {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #6b7280;
}
/* ── Editor root ─────────────────────────────────────────────────────── */
.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>

@ -1,44 +0,0 @@
<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";
import OptionsPanel from "@/vice/editor/comps/OptionsPanel.svelte";
import EntitiesPanel from "@/vice/editor/comps/EntitiesPanel.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'}
<EntitiesPanel />
<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>

@ -1,606 +0,0 @@
// ─────────────────────────────────────────────────────────────────────────────
// 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 {
Attribute,
AttributeOption,
AttrID,
Collection,
CollectionID,
Entity,
EntityID,
FixedGroup,
Group,
GroupID,
OptionalGroup,
OptionDefinition,
OptionID,
RequiredAttribute,
} from '@/vice/libs/types'
import type {LingString} from "@/ling";
// ─────────────────────────────────────────────────────────────────────────────
// 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: '',
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 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)
// ── Derived ────────────────────────────────────────────────────────────────
const validation = $derived(validateCollection(collection))
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 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'>>) {
collection = { ...collection, ...patch }
}
// ── Options library ────────────────────────────────────────────────────────
function addOption(): OptionID {
const id = uid('op') as OptionID
collection = {
...collection,
options: { ...collection.options, [id]: emptyOption(id) },
}
activeOptionId = id
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 } },
}
}
function deleteOption(id: OptionID) {
const { [id]: _, ...rest } = collection.options
collection = { ...collection, options: rest as Record<OptionID, OptionDefinition> }
if (activeOptionId === id) activeOptionId = null
}
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
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 },
}
}
function deleteEntity(id: EntityID) {
const { [id]: _, ...rest } = collection.entities
collection = { ...collection, entities: rest as Record<EntityID, Entity> }
if (activeEntityId === id) setActiveEntity(null)
}
// ── 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
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 },
},
},
}
}
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 }
}
// ── 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)
}
function deleteAttribute(entityId: EntityID, groupId: GroupID | undefined, attrId: AttrID) {
collection = produceAttr(collection, entityId, groupId, attrId, null)
if (activeAttrId === attrId) activeAttrId = null
}
// ── 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]
// Si es la primera opción, ponerla como default
const updated = { ...attr, options }
if (!attr.default && options.length === 1) (updated as any).default = optionId
return updated
})
}
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
})
}
// ── 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) {
collection = JSON.parse(json)
activeEntityId = null
activeGroupId = null
activeAttrId = null
activeOptionId = null
}
function reset() {
collection = emptyCollection()
activeEntityId = null
activeGroupId = null
activeAttrId = null
activeOptionId = null
}
// ── Public API ─────────────────────────────────────────────────────────────
return {
// State (getters)
get collection() { return collection },
get validation() { return validation },
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
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) },
},
}
}

@ -1,812 +0,0 @@
// ─────────────────────────────────────────────────────────────────────────────
// 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 {
Collection, CollectionID,
Entity, EntityID,
Group, GroupID,
Attribute, AttrID,
AttributeOption,
OptionDefinition, OptionID,
Rule,
EntityPath,
EntityPathKey,
LiteralValue,
} from '@/vice/libs/types'
import type {FixedGroup, OptionalGroup} from "@/vice/libs";
import type {LingString} from "@/ling/lib";
// ─────────────────────────────────────────────────────────────────────────────
// 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: '',
name: emptyLing(),
description: emptyLing(),
langs: ['es'],
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): import('@/vice/libs/types.ts').RequiredAttribute {
return {
id,
code: '',
name: emptyLing(),
description: emptyLing(),
type: 'required',
domain: { kind: 'options', options: [], default: '' as OptionID },
rules: [],
display: {},
}
}
function emptyFixedAttribute(id: AttrID): import('@/vice/libs/types.ts').FixedAttribute {
return {
id,
code: '',
name: emptyLing(),
description: emptyLing(),
type: 'fixed',
domain: { kind: 'fixed', value: { kind: 'string', value: '' } },
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' })
// Validar nombre en todos los langs declarados
const name = collection.name as any
for (const lang of collection.langs) {
if (!name?.[lang]) {
errors.push({ severity: 'error', message: `El catálogo necesita nombre en "${lang}"`, path: `collection.name.${lang}` })
}
}
// 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` })
const optName = opt.name as any
for (const lang of collection.langs) {
if (!optName?.[lang]) {
warnings.push({ severity: 'warning', message: `Opción "${opt.id}" sin nombre en "${lang}"`, path: `options.${opt.id}.name.${lang}` })
}
}
}
// 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` })
const entityName = entity.name as any
for (const lang of collection.langs) {
if (!entityName?.[lang]) {
warnings.push({ severity: 'warning', message: `Entidad "${entity.id}" sin nombre en "${lang}"`, path: `entities.${entity.id}.name.${lang}` })
}
}
// 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` })
const groupName = group.name as any
for (const lang of collection.langs) {
if (!groupName?.[lang]) {
warnings.push({ severity: 'warning', message: `Grupo "${groupId}" sin nombre en "${lang}"`, path: `entities.${entity.id}.groups.${groupId}.name.${lang}` })
}
}
// 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` })
const attrName = attr.name as any
for (const lang of collection.langs) {
if (!attrName?.[lang]) {
warnings.push({ severity: 'warning', message: `Atributo "${attr.id}" sin nombre en "${lang}"`, path: `${path}.name.${lang}` })
}
}
if (attr.type !== 'fixed') {
const domain = attr.domain
if (domain.kind === 'options') {
if (domain.options.length === 0) {
warnings.push({ severity: 'warning', message: `Atributo "${attr.id}" sin opciones`, path: `${path}.domain.options` })
} else {
const hasDefault = domain.options.some(o => o.optionId === domain.default)
if (!hasDefault) {
errors.push({ severity: 'error', message: `Atributo "${attr.id}": el valor por defecto no está en las opciones`, path: `${path}.domain.default` })
}
for (const attrOpt of domain.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}.domain.options.${attrOpt.optionId}` })
}
}
}
} else if (domain.kind === 'literals') {
if (domain.literals.length === 0) {
warnings.push({ severity: 'warning', message: `Atributo "${attr.id}" sin literales en el dominio`, path: `${path}.domain.literals` })
}
}
}
}
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(validateCollection(collection))
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 setActiveEntityAttribute(id: AttrID | null) {
activeGroupId = 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) {
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
markDirty()
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' || attr.type === 'literal') 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' || attr.type === 'literal' ) 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 = emptyLiteralAttribute(id)
collection = { ...collection, attributes: [...collection.attributes, attr] }
activeAttrId = id
activeEntityId = null
activeGroupId = null
markDirty()
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
}
// ── Rules ──────────────────────────────────────────────────────────────────
function addRule(
entityId: EntityID,
groupId: GroupID | undefined,
attrId: AttrID | undefined,
rule: Rule,
) {
if (attrId) {
collection = produceAttr(collection, entityId, groupId, attrId, (a) => ({
...a,
rules: [...((a as any).rules ?? []), rule],
}))
} else if (groupId) {
collection = produceGroup(collection, entityId, groupId, (g) => ({
...g,
rules: [...((g as any).rules ?? []), rule],
}))
} else {
collection = produceEntity(collection, entityId, (e) => ({
...e,
rules: [...(e.rules ?? []), rule],
}))
}
isDirty = true
}
function updateRule(
entityId: EntityID,
groupId: GroupID | undefined,
attrId: AttrID | undefined,
ruleId: string,
patch: Partial<Rule>,
) {
const mapRules = (rules: Rule[]) =>
rules.map(r => (r.id === ruleId ? { ...r, ...patch } : r))
if (attrId) {
collection = produceAttr(collection, entityId, groupId, attrId, (a) => ({
...a,
rules: mapRules((a as any).rules ?? []),
}))
} else if (groupId) {
collection = produceGroup(collection, entityId, groupId, (g) => ({
...g,
rules: mapRules((g as any).rules ?? []),
}))
} else {
collection = produceEntity(collection, entityId, (e) => ({
...e,
rules: mapRules(e.rules ?? []),
}))
}
isDirty = true
}
function deleteRule(
entityId: EntityID,
groupId: GroupID | undefined,
attrId: AttrID | undefined,
ruleId: string,
) {
const filterRules = (rules: Rule[]) =>
rules.filter(r => r.id !== ruleId)
if (attrId) {
collection = produceAttr(collection, entityId, groupId, attrId, (a) => ({
...a,
rules: filterRules((a as any).rules ?? []),
}))
} else if (groupId) {
collection = produceGroup(collection, entityId, groupId, (g) => ({
...g,
rules: filterRules((g as any).rules ?? []),
}))
} else {
collection = produceEntity(collection, entityId, (e) => ({
...e,
rules: filterRules(e.rules ?? []),
}))
}
isDirty = true
}
// ── 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,
setActiveEntityAttribute,
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,
// Rules
addRule,
updateRule,
deleteRule,
// 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) },
},
}
}
function produceEntity(
collection: Collection,
entityId: EntityID,
fn: (entity: Entity) => Entity,
): Collection {
const entity = collection.entities[entityId]
if (!entity) return collection
return {
...collection,
entities: { ...collection.entities, [entityId]: fn(entity) },
}
}
function produceGroup(
collection: Collection,
entityId: EntityID,
groupId: GroupID,
fn: (group: Group) => Group,
): Collection {
const entity = collection.entities[entityId]
if (!entity) return collection
const group = entity.groups[groupId]
if (!group) return collection
return {
...collection,
entities: {
...collection.entities,
[entityId]: {
...entity,
groups: { ...entity.groups, [groupId]: fn(group) },
},
},
}
}

@ -1,121 +0,0 @@
// ─────────────────────────────────────────────────────────────────────────────
// VICE · Engine Implementation
//
// Implementación de ViceEngine.
// Junta GacGraphBuilder, GacValidator, GacEngine y Session.
// ─────────────────────────────────────────────────────────────────────────────
import type { Collection, EntityID } from './types.ts'
import type { ViceEngine, ViceSession, LoadResult } from './engine_types.ts'
import { GacGraphBuilder } from './graph_builder.ts'
import { GacValidator } from './validator.ts'
import { GacEngine } from './gac.ts'
import { Session } from './session.ts'
import type { GacGraph } from './gac.ts'
import type {JsonLogic} from "@/tsat/lib/types";
// ─────────────────────────────────────────────────────────────────────────────
// JSON LOGIC EVALUATOR
// Implementación mínima — reemplazable por json-logic-js en producción.
// ─────────────────────────────────────────────────────────────────────────────
function evalLogic(logic: JsonLogic, data: Record<string, unknown>): unknown {
if (logic === null || typeof logic !== 'object') return logic
if (Array.isArray(logic)) return logic
const obj = logic as Record<string, unknown>
if ('var' in obj) {
const key = obj['var'] as string
return key in data ? data[key] : null
}
if ('==' in obj) {
const [a, b] = obj['=='] as [JsonLogic, JsonLogic]
return evalLogic(a, data) === evalLogic(b, data)
}
if ('!=' in obj) {
const [a, b] = obj['!='] as [JsonLogic, JsonLogic]
return evalLogic(a, data) !== evalLogic(b, data)
}
if ('>' in obj) {
const [a, b] = obj['>'] as [JsonLogic, JsonLogic]
return (evalLogic(a, data) as number) > (evalLogic(b, data) as number)
}
if ('>=' in obj) {
const [a, b] = obj['>='] as [JsonLogic, JsonLogic]
return (evalLogic(a, data) as number) >= (evalLogic(b, data) as number)
}
if ('<' in obj) {
const [a, b] = obj['<'] as [JsonLogic, JsonLogic]
return (evalLogic(a, data) as number) < (evalLogic(b, data) as number)
}
if ('<=' in obj) {
const [a, b] = obj['<='] as [JsonLogic, JsonLogic]
return (evalLogic(a, data) as number) <= (evalLogic(b, data) as number)
}
if ('and' in obj) {
return (obj['and'] as JsonLogic[]).every(c => Boolean(evalLogic(c, data)))
}
if ('or' in obj) {
return (obj['or'] as JsonLogic[]).some(c => Boolean(evalLogic(c, data)))
}
if ('!' in obj) {
return !evalLogic(obj['!'] as JsonLogic, data)
}
if ('in' in obj) {
const [val, arr] = obj['in'] as [JsonLogic, JsonLogic]
const resolved = evalLogic(arr, data)
return Array.isArray(resolved) && resolved.includes(evalLogic(val, data))
}
return false
}
// ─────────────────────────────────────────────────────────────────────────────
// ENGINE IMPL
// ─────────────────────────────────────────────────────────────────────────────
export class ViceEngineImpl implements ViceEngine {
private gacEngine = new GacEngine(evalLogic)
private graph: GacGraph | null = null
private result: LoadResult | null = null
load(collection: Collection, entityId: EntityID): LoadResult {
const graph = new GacGraphBuilder().build(collection, entityId)
const validator = new GacValidator()
const validation = validator.validate(graph)
this.graph = graph
this.result = {
valid: validation.valid,
issues: validation.issues,
entityId,
}
return this.result
}
createSession(): ViceSession {
if (!this.graph) throw new Error('[VICE] No graph loaded — call load() first')
if (!this.result) throw new Error('[VICE] No result — call load() first')
if (!this.result.valid) {
const errors = this.result.issues
.filter(i => i.severity === 'error')
.map(i => i.message)
.join('\n')
throw new Error(`[VICE] Cannot create session — graph has errors:\n${errors}`)
}
return new Session(this.graph, this.gacEngine)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// FACTORY
// ─────────────────────────────────────────────────────────────────────────────
export function createViceEngine(): ViceEngine {
return new ViceEngineImpl()
}

@ -1,27 +0,0 @@
// ─────────────────────────────────────────────────────────────────────────────
// VICE · Engine — API pública
// ─────────────────────────────────────────────────────────────────────────────
import type { Collection, EntityID, OptionID, EntityPathKey } from './types.ts'
import type { ValidationResult } from './validator.ts'
import type { SessionState, SelectResult, DeselectResult } from './state_types.ts'
export interface LoadResult {
valid: boolean
issues: ValidationResult['issues']
entityId: EntityID
}
export interface ViceSession {
select(path: EntityPathKey, optionId: OptionID, quantity?: number): SelectResult
deselect(path: EntityPathKey): DeselectResult
getState(): SessionState
reset(): SessionState
}
export interface ViceEngine {
load(collection: Collection, entityId: EntityID): LoadResult
createSession(): ViceSession
}
export type { ViceEngine as default }

@ -1,343 +0,0 @@
// ─────────────────────────────────────────────────────────────────────────────
// VICE · GAC (Generalized Arc Consistency)
//
// Responsabilidades:
// - Definir las estructuras del grafo (GacGraph, GacState)
// - Propagar cambios de dominio mediante GAC
// - Devolver el estado arc-consistent resultante
//
// No conoce Collection, Session ni ningún tipo de runtime externo.
// Opera exclusivamente sobre GacGraph y GacState.
// ─────────────────────────────────────────────────────────────────────────────
import type { LingString } from '@/ling/lib'
import type {
AttrID,
GroupID,
EntityID,
OptionID,
EntityPathKey,
RuleAction,
AttributeType,
Severity,
OptionSelection,
} from './types.ts'
import type {JsonLogic} from "@/jslg/lib";
// ─────────────────────────────────────────────────────────────────────────────
// VARIABLE
// Un atributo en el grafo. Su dominio se reduce por GAC.
// ─────────────────────────────────────────────────────────────────────────────
export interface GacVariable {
path: EntityPathKey
attrId: AttrID
type: AttributeType
fullDomain: ReadonlySet<OptionID> // dominio del catálogo — nunca muta
defaultOption: OptionID
dependants: EntityPathKey[] // paths de constraints que dependen de esta variable
}
// ─────────────────────────────────────────────────────────────────────────────
// GROUP
// Un grupo en el grafo. Solo importa su kind para saber el estado inicial.
// ─────────────────────────────────────────────────────────────────────────────
export interface GacGroup {
path: EntityPathKey
groupId: GroupID
kind: 'fixed' | 'optional'
}
// ─────────────────────────────────────────────────────────────────────────────
// CONSTRAINT
// Una regla compilada y lista para evaluar. Inmutable.
// ─────────────────────────────────────────────────────────────────────────────
export interface GacConstraint {
id: string
scope: EntityPathKey
dependsOn: EntityPathKey[] // variables que condition referencia
condition: JsonLogic | null // null = siempre activa
action: RuleAction
message: LingString
severity: Severity
mode: 'after' | 'override'
}
// ─────────────────────────────────────────────────────────────────────────────
// GAC GRAPH
// Construido una vez en load(). Inmutable en runtime.
// ─────────────────────────────────────────────────────────────────────────────
export interface GacGraph {
entityId: EntityID
variables: Map<EntityPathKey, GacVariable>
groups: Map<EntityPathKey, GacGroup>
constraints: Map<string, GacConstraint>
/** Índice inverso: variable/group → IDs de constraints que la referencian en dependsOn */
dependantIndex: Map<EntityPathKey, Set<string>>
}
// ─────────────────────────────────────────────────────────────────────────────
// GAC STATE
// Copia mutable por sesión. Nunca muta GacGraph.
// ─────────────────────────────────────────────────────────────────────────────
export interface GacVariableState {
path: EntityPathKey
domain: Set<OptionID> // dominio reducido por GAC
current: OptionSelection | null
active: boolean
violations: GacViolation[]
}
export interface GacGroupState {
path: EntityPathKey
active: boolean
}
export interface GacViolation {
constraintId: string
optionId?: OptionID
reason: LingString
severity: Severity
}
export interface GacState {
variables: Map<EntityPathKey, GacVariableState>
groups: Map<EntityPathKey, GacGroupState>
consistent: boolean
}
// ─────────────────────────────────────────────────────────────────────────────
// GAC RESULT
// ─────────────────────────────────────────────────────────────────────────────
export interface GacResult {
state: GacState
consistent: boolean
active: Set<string> // IDs de constraints activos en esta propagación
}
// ─────────────────────────────────────────────────────────────────────────────
// JSON LOGIC EVALUATOR
// Inyectado desde fuera — el motor no depende de la librería directamente.
// ─────────────────────────────────────────────────────────────────────────────
export type JsonLogicEvaluator = (logic: JsonLogic, data: Record<string, unknown>) => unknown
// ─────────────────────────────────────────────────────────────────────────────
// GAC ENGINE
// ─────────────────────────────────────────────────────────────────────────────
export class GacEngine {
constructor(private readonly evalLogic: JsonLogicEvaluator) {}
// ── Propagación ─────────────────────────────────────────────────────────────
propagate(graph: GacGraph, state: GacState): GacResult {
const active = new Set<string>()
const queue = new Set<string>(graph.constraints.keys())
while (queue.size > 0) {
const cid = this.dequeue(queue)
const constraint = graph.constraints.get(cid)
if (!constraint) continue
if (!this.evalCondition(constraint, state)) continue
active.add(cid)
const reduced = this.applyAction(constraint, state, graph)
const targetPath = this.resolveTarget(constraint, graph)
if (reduced && targetPath) {
for (const dep of graph.dependantIndex.get(targetPath) ?? new Set()) {
queue.add(dep)
}
}
// dominio vacío = estado inconsistente
if (targetPath) {
const vs = state.variables.get(targetPath)
if (vs && vs.domain.size === 0) state.consistent = false
}
}
return { state, consistent: state.consistent, active }
}
// ── Evaluación de condición ──────────────────────────────────────────────────
private evalCondition(constraint: GacConstraint, state: GacState): boolean {
if (!constraint.condition) return true
const data = this.buildContext(constraint.dependsOn, state)
return Boolean(this.evalLogic(constraint.condition, data))
}
private buildContext(paths: EntityPathKey[], state: GacState): Record<string, unknown> {
const data: Record<string, unknown> = {}
for (const path of paths) {
const vs = state.variables.get(path)
if (!vs) continue
const attrId = path.split('/').pop() ?? path
data[attrId] = vs.current?.optionId ?? null
if (vs.current?.quantity !== undefined) {
data[`${attrId}:quantity`] = vs.current.quantity
}
}
return data
}
// ── Aplicación de acción ─────────────────────────────────────────────────────
private applyAction(constraint: GacConstraint, state: GacState, graph: GacGraph): boolean {
const targetPath = this.resolveTarget(constraint, graph)
if (!targetPath) return false
const { type, values } = constraint.action
// activate / deactivate pueden apuntar a grupo o atributo opcional
if (type === 'activate' || type === 'deactivate') {
const gs = state.groups.get(targetPath)
if (gs) {
const before = gs.active
gs.active = type === 'activate'
return gs.active !== before
}
const vs = state.variables.get(targetPath)
if (!vs) return false
const before = vs.active
vs.active = type === 'activate'
if (!vs.active) vs.domain.clear()
return vs.active !== before
}
const vs = state.variables.get(targetPath)
if (!vs) return false
const before = vs.domain.size
switch (type) {
case 'forbid': {
for (const optId of values ?? []) {
vs.domain.delete(optId)
if (vs.current?.optionId === optId) {
vs.violations.push({ constraintId: constraint.id, optionId: optId, reason: constraint.message, severity: constraint.severity })
}
}
break
}
case 'allow': {
const allowed = new Set(values ?? [])
for (const optId of [...vs.domain]) {
if (!allowed.has(optId)) {
vs.domain.delete(optId)
if (vs.current?.optionId === optId) {
vs.violations.push({ constraintId: constraint.id, optionId: optId, reason: constraint.message, severity: constraint.severity })
}
}
}
break
}
case 'require': {
if (!vs.current) {
vs.violations.push({ constraintId: constraint.id, reason: constraint.message, severity: constraint.severity })
}
if ((values?.length ?? 0) > 0) {
const required = new Set(values)
if (vs.current && !required.has(vs.current.optionId)) {
vs.violations.push({ constraintId: constraint.id, optionId: vs.current.optionId, reason: constraint.message, severity: constraint.severity })
}
}
break
}
case 'suggest': {
const suggested = new Set(values ?? [])
if (vs.current && !suggested.has(vs.current.optionId)) {
vs.violations.push({ constraintId: constraint.id, optionId: vs.current.optionId, reason: constraint.message, severity: 'info' })
}
break
}
}
return vs.domain.size < before
}
// ── resolveTarget ────────────────────────────────────────────────────────────
private resolveTarget(constraint: GacConstraint, graph: GacGraph): EntityPathKey | null {
const target = constraint.action.target
if (!target) return null
for (const path of graph.variables.keys()) {
if (path.endsWith(`/${target}`) || path === target) return path
}
for (const path of graph.groups.keys()) {
if (path.endsWith(`/${target}`) || path === target) return path
}
return null
}
private dequeue(queue: Set<string>): string {
const first = queue.values().next().value as string
queue.delete(first)
return first
}
// ── Estado ───────────────────────────────────────────────────────────────────
initState(graph: GacGraph): GacState {
const variables = new Map<EntityPathKey, GacVariableState>()
const groups = new Map<EntityPathKey, GacGroupState>()
for (const [path, v] of graph.variables) {
variables.set(path, {
path,
domain: new Set(v.fullDomain),
current: v.type === 'required' ? { optionId: v.defaultOption } : null,
active: v.type !== 'optional',
violations: [],
})
}
for (const [path, g] of graph.groups) {
groups.set(path, { path, active: g.kind === 'fixed' })
}
return { variables, groups, consistent: true }
}
applySelection(state: GacState, path: EntityPathKey, optionId: OptionID, quantity?: number): GacState {
const next = this.cloneState(state)
const vs = next.variables.get(path)
if (!vs) return next
vs.current = { optionId, ...(quantity !== undefined ? { quantity } : {}) }
vs.active = true
vs.violations = []
return next
}
applyDeselection(state: GacState, path: EntityPathKey): GacState {
const next = this.cloneState(state)
const vs = next.variables.get(path)
if (!vs) return next
vs.current = null
vs.active = false
vs.violations = []
return next
}
cloneState(state: GacState): GacState {
const variables = new Map<EntityPathKey, GacVariableState>()
const groups = new Map<EntityPathKey, GacGroupState>()
for (const [path, v] of state.variables) {
variables.set(path, { ...v, domain: new Set(v.domain), violations: [...v.violations] })
}
for (const [path, g] of state.groups) {
groups.set(path, { ...g })
}
return { variables, groups, consistent: state.consistent }
}
}

@ -1,252 +0,0 @@
// ─────────────────────────────────────────────────────────────────────────────
// VICE · Graph Builder
//
// Construye el GacGraph desde el modelo del catálogo.
// Se ejecuta una vez en load() — el grafo es inmutable en runtime.
//
// Responsabilidades:
// - Recorrer Collection → Entity → Group → Attribute → Rule
// - Crear GacVariable por cada Attribute
// - Crear GacGroup por cada Group
// - Crear GacConstraint por cada Rule en cualquier nivel
// - Inferir Rule.dependsOn desde JsonLogic si no está declarado
// - Construir dependantIndex (grafo inverso)
// ─────────────────────────────────────────────────────────────────────────────
import type {
Collection,
Entity,
Group,
Attribute,
Rule,
EntityID,
GroupID,
EntityPath,
EntityPathKey,
} from './types.ts'
import type {
GacGraph,
GacVariable,
GacGroup,
GacConstraint,
} from './gac.ts'
import type {JsonLogic} from "@/tsat/lib/types";
// ─────────────────────────────────────────────────────────────────────────────
// HELPERS
// ─────────────────────────────────────────────────────────────────────────────
function toKey(path: EntityPath): EntityPathKey {
return path.join('/')
}
/**
* Extrae las referencias { var: 'at_xxx' } de un JsonLogic.
* Usado para inferir Rule.dependsOn cuando no está declarado.
*/
function extractVars(logic: JsonLogic, found: Set<string> = new Set()): Set<string> {
if (logic === null || typeof logic !== 'object') return found
if (Array.isArray(logic)) {
for (const item of logic) extractVars(item as JsonLogic, found)
return found
}
const obj = logic as Record<string, unknown>
if ('var' in obj && typeof obj['var'] === 'string') {
found.add(obj['var'])
return found
}
for (const val of Object.values(obj)) {
extractVars(val as JsonLogic, found)
}
return found
}
// ─────────────────────────────────────────────────────────────────────────────
// BUILDER
// ─────────────────────────────────────────────────────────────────────────────
export class GacGraphBuilder {
private variables = new Map<EntityPathKey, GacVariable>()
private groups = new Map<EntityPathKey, GacGroup>()
private constraints = new Map<string, GacConstraint>()
private constraintSeq = 0
build(collection: Collection, entityId: EntityID): GacGraph {
const entity = collection.entities[entityId]
if (!entity) throw new Error(`[VICE] Entity "${entityId}" not found`)
// 1 — Atributos globales del catálogo (contexto — fuente de constraints)
for (const attr of collection.attributes) {
this.registerVariable(attr, [attr.id])
}
// 2 — Atributos globales de la entidad
for (const attr of entity.attributes) {
this.registerVariable(attr, [entityId, attr.id])
}
// 3 — Reglas de la entidad
for (const rule of entity.rules ?? []) {
this.registerConstraint(rule, [entityId])
}
// 4 — Grupos y sus atributos
for (const [groupId, group] of Object.entries(entity.groups) as [GroupID, Group][]) {
const groupPath: EntityPath = [entityId, groupId]
// Registrar grupo
this.groups.set(toKey(groupPath), {
path: toKey(groupPath),
groupId: groupId as GroupID,
kind: group.kind,
})
// Atributos del grupo
for (const attr of group.attributes) {
this.registerVariable(attr, [entityId, groupId, attr.id])
}
// Reglas del grupo (solo OptionalGroup)
if (group.kind === 'optional') {
for (const rule of group.rules ?? []) {
this.registerConstraint(rule, groupPath)
}
}
}
// 5 — Inferir dependsOn desde JsonLogic donde no está declarado
this.resolveInferredDependsOn()
// 6 — Construir dependantIndex
const dependantIndex = this.buildDependantIndex()
return {
entityId,
variables: this.variables,
groups: this.groups,
constraints: this.constraints,
dependantIndex,
}
}
// ── Registro de variables ──────────────────────────────────────────────────
private registerVariable(attr: Attribute, path: EntityPath): void {
const key = toKey(path)
const fullDomain = this.extractDomain(attr)
const defaultOption = this.extractDefault(attr)
this.variables.set(key, {
path : key,
attrId: attr.id,
type: attr.type,
fullDomain: new Set(fullDomain),
defaultOption,
dependants: (attr.dependants ?? []).map(toKey),
})
// Reglas del atributo
const rules = attr.type !== 'fixed' ? (attr.rules ?? []) : []
for (const rule of rules) {
this.registerConstraint(rule, path)
}
// Reglas de las opciones
if (attr.type === 'optional' || attr.type === 'required') {
for (const attrOption of attr.options) {
for (const rule of attrOption.rules ?? []) {
this.registerConstraint(rule, [...path, attrOption.optionId])
}
}
}
}
// ── Registro de constraints ────────────────────────────────────────────────
private registerConstraint(rule: Rule, scope: EntityPath): void {
const id = rule.id ?? (`cn_auto_${++this.constraintSeq}` as any)
const dependsOn = rule.dependsOn
? rule.dependsOn.map(toKey)
: [] // se infiere en resolveInferredDependsOn()
this.constraints.set(id, {
id,
scope: toKey(scope),
dependsOn,
condition: rule.condition ?? null,
action: rule.action,
message: rule.message,
severity: rule.severity,
mode: rule.mode ?? 'after',
})
}
// ── Inferencia de dependsOn ────────────────────────────────────────────────
private resolveInferredDependsOn(): void {
for (const [id, constraint] of this.constraints) {
if (constraint.dependsOn.length > 0) continue // ya declarado
if (!constraint.condition) continue // sin condición = sin dependencias
const varNames = extractVars(constraint.condition)
const resolved: EntityPathKey[] = []
for (const varName of varNames) {
// Buscar variable cuyo attrId coincide con varName
for (const [key, variable] of this.variables) {
if (variable.attrId === varName) {
resolved.push(key)
break
}
}
}
this.constraints.set(id, { ...constraint, dependsOn: resolved })
}
}
// ── dependantIndex ─────────────────────────────────────────────────────────
private buildDependantIndex(): Map<EntityPathKey, Set<string>> {
const index = new Map<EntityPathKey, Set<string>>()
// Desde dependsOn de cada constraint
for (const [constraintId, constraint] of this.constraints) {
for (const varPath of constraint.dependsOn) {
if (!index.has(varPath)) index.set(varPath, new Set())
index.get(varPath)!.add(constraintId)
}
}
// Desde dependants declarados en las variables
for (const [varPath, variable] of this.variables) {
for (const dep of variable.dependants) {
if (!index.has(varPath)) index.set(varPath, new Set())
index.get(varPath)!.add(dep)
}
}
return index
}
// ── Helpers de extracción ──────────────────────────────────────────────────
private extractDomain(attr: Attribute) {
if (attr.type === 'fixed') return [attr.option.optionId]
if (attr.type === 'optional') return attr.options.map(o => o.optionId)
if (attr.type === 'required') return attr.options.map(o => o.optionId)
return []
}
private extractDefault(attr: Attribute) {
if (attr.type === 'fixed') return attr.option.optionId
if (attr.type === 'optional') return attr.default
if (attr.type === 'required') return attr.default
const _: never = attr
throw new Error('[VICE] Unknown attribute type')
}
}

@ -1,8 +0,0 @@
export * from './types.ts';
export * from './engine_types.ts';
export * from './state_types.ts'
export * from './validator_types.ts';

@ -1,121 +0,0 @@
// ─────────────────────────────────────────────────────────────────────────────
// VICE · Session
// ─────────────────────────────────────────────────────────────────────────────
import type {OptionID, EntityPathKey} from './types.ts'
import type {GacGraph, GacState} from './gac.ts'
import type {SessionState, PropagationResult, SelectResult, DeselectResult} from './state_types.ts'
import type {ViceSession} from './engine_types.ts'
import {GacEngine} from './gac.ts'
export class Session implements ViceSession {
private state: GacState
constructor(
private readonly graph: GacGraph,
private readonly engine: GacEngine,
) {
this.state = engine.initState(graph)
this.state = engine.propagate(graph, this.state).state
}
select(path: EntityPathKey, optionId: OptionID, quantity?: number): SelectResult {
const vs = this.state.variables.get(path)
if (!vs) return {ok: false, reason: 'INVALID_PATH'}
if (!vs.active) return {ok: false, reason: 'INACTIVE_ATTRIBUTE'}
if (!vs.domain.has(optionId)) return {ok: false, reason: 'OPTION_NOT_IN_DOMAIN'}
const prev = this.state
const next = this.engine.applySelection(this.state, path, optionId, quantity)
const result = this.engine.propagate(this.graph, next)
this.state = result.state
return {ok: true, result: this.toPropagationResult(result.state, prev)}
}
deselect(path: EntityPathKey): DeselectResult {
const vs = this.state.variables.get(path)
if (!vs) return {ok: false, reason: 'INVALID_PATH'}
if (!vs.active) return {ok: false, reason: 'INACTIVE_ATTRIBUTE'}
const prev = this.state
const next = this.engine.applyDeselection(this.state, path)
const result = this.engine.propagate(this.graph, next)
this.state = result.state
return {ok: true, result: this.toPropagationResult(result.state, prev)}
}
getState(): SessionState {
return this.toSessionState(this.state)
}
reset(): SessionState {
this.state = this.engine.initState(this.graph)
this.state = this.engine.propagate(this.graph, this.state).state
return this.toSessionState(this.state)
}
private toSessionState(state: GacState): SessionState {
const attributes = new Map<EntityPathKey, import('./state_types.ts').AttributeState>()
const groups = new Map<EntityPathKey, import('./state_types.ts').GroupState>()
for (const [path, vs] of state.variables) {
const variable = this.graph.variables.get(path)
if (!variable) continue
attributes.set(path, {
path,
attrId: variable.attrId,
active: vs.active,
domain: vs.domain,
current: vs.current,
violations: vs.violations.map(v => ({
constraintId: v.constraintId,
optionId: v.optionId,
reason: v.reason,
severity: v.severity,
})),
})
}
for (const [path, gs] of state.groups) {
const group = this.graph.groups.get(path)
if (!group) continue
groups.set(path, {path, groupId: group.groupId, active: gs.active})
}
return {entityId: this.graph.entityId, attributes, groups, consistent: state.consistent}
}
private toPropagationResult(next: GacState, prev: GacState): PropagationResult {
const changed = new Set<EntityPathKey>()
for (const [path, vs] of next.variables) {
const prevVs = prev.variables.get(path)
if (!prevVs) {
changed.add(path);
continue
}
if (
vs.active !== prevVs.active ||
vs.current?.optionId !== prevVs.current?.optionId ||
!setsEqual(vs.domain, prevVs.domain)
) changed.add(path)
}
for (const [path, gs] of next.groups) {
const prevGs = prev.groups.get(path)
if (!prevGs || gs.active !== prevGs.active) changed.add(path)
}
return {state: this.toSessionState(next), consistent: next.consistent, changed}
}
}
function setsEqual<T>(a: Set<T>, b: Set<T>): boolean {
if (a.size !== b.size) return false
for (const v of a) if (!b.has(v)) return false
return true
}

@ -1,95 +0,0 @@
// ─────────────────────────────────────────────────────────────────────────────
// VICE · Runtime State
//
// Tipos del estado en runtime expuestos hacia fuera.
// Son la traducción del GacState interno al contrato público de la sesión.
// ─────────────────────────────────────────────────────────────────────────────
import type { LingString } from '@/ling/lib'
import type {
AttrID,
GroupID,
OptionID,
EntityPathKey,
Severity,
OptionSelection,
} from './types.ts'
// ─────────────────────────────────────────────────────────────────────────────
// VIOLATION
// Una restricción activa sobre un atributo o una opción concreta.
// ─────────────────────────────────────────────────────────────────────────────
export interface Violation {
constraintId: string
optionId?: OptionID // undefined = violación sobre el atributo completo
reason: LingString
severity: Severity
}
// ─────────────────────────────────────────────────────────────────────────────
// ATTRIBUTE STATE
// Estado de un atributo en un momento dado.
// ─────────────────────────────────────────────────────────────────────────────
export interface AttributeState {
path: EntityPathKey
attrId: AttrID
active: boolean
domain: ReadonlySet<OptionID> // opciones disponibles tras GAC
current: OptionSelection | null
violations: Violation[]
}
// ─────────────────────────────────────────────────────────────────────────────
// GROUP STATE
// Estado de un grupo en un momento dado.
// ─────────────────────────────────────────────────────────────────────────────
export interface GroupState {
path: EntityPathKey
groupId: GroupID
active: boolean
}
// ─────────────────────────────────────────────────────────────────────────────
// SESSION STATE
// Snapshot completo de una sesión — serializable.
// ─────────────────────────────────────────────────────────────────────────────
export interface SessionState {
entityId: string
attributes: Map<EntityPathKey, AttributeState>
groups: Map<EntityPathKey, GroupState>
consistent: boolean
}
// ─────────────────────────────────────────────────────────────────────────────
// PROPAGATION RESULT
// Lo que devuelve cada select() — qué cambió.
// ─────────────────────────────────────────────────────────────────────────────
export interface PropagationResult {
state: SessionState
consistent: boolean
/** Paths de atributos cuyo dominio o estado cambió en esta propagación */
changed: Set<EntityPathKey>
}
// ─────────────────────────────────────────────────────────────────────────────
// SELECT / DESELECT RESULT
// ─────────────────────────────────────────────────────────────────────────────
export type SelectErrorReason =
| 'INVALID_PATH'
| 'INACTIVE_ATTRIBUTE'
| 'OPTION_NOT_IN_DOMAIN'
export type SelectResult =
| { ok: true; result: PropagationResult }
| { ok: false; reason: SelectErrorReason }
export type DeselectResult =
| { ok: true; result: PropagationResult }
| { ok: false; reason: 'INVALID_PATH' | 'INACTIVE_ATTRIBUTE' }

@ -1,164 +0,0 @@
// ─────────────────────────────────────────────────────────────────────────────
// VICE · Validator
//
// Validación design time del GacGraph.
// Se ejecuta después de GacGraphBuilder.build() en load().
// Solo opera sobre GacGraph — no conoce Collection.
//
// Errores (bloquean la sesión):
// - MISSING_REFERENCE — target o dependsOn inexistente en el grafo
// - INVALID_DEFAULT — default no está en el dominio
// - EMPTY_DOMAIN — atributo sin opciones
// - DUPLICATE_ID — ID repetido en el mismo scope
//
// Warnings (no bloquean):
// - DEAD_OPTION — opción prohibida incondicionalmente
// - REDUNDANT_RULE — regla que nunca puede activarse
// ─────────────────────────────────────────────────────────────────────────────
import type { GacGraph } from './gac.ts'
import type {DesignIssue, ValidationResult} from "@/vice/libs/validator_types.ts";
// ─────────────────────────────────────────────────────────────────────────────
// VALIDATOR
// ─────────────────────────────────────────────────────────────────────────────
export class GacValidator {
validate(graph: GacGraph): ValidationResult {
const issues: DesignIssue[] = []
this.checkMissingReferences(graph, issues)
this.checkInvalidDefaults(graph, issues)
this.checkEmptyDomains(graph, issues)
this.checkDeadOptions(graph, issues)
this.checkRedundantRules(graph, issues)
return {
valid: !issues.some(i => i.severity === 'error'),
issues,
}
}
// ── MISSING_REFERENCE ──────────────────────────────────────────────────────
private checkMissingReferences(graph: GacGraph, issues: DesignIssue[]): void {
for (const constraint of graph.constraints.values()) {
// target debe existir como variable o grupo
const tid = constraint.action.target
if (tid) {
const found =
[...graph.variables.keys()].some(k => k.endsWith(`/${tid}`) || k === tid) ||
[...graph.groups.keys()].some(k => k.endsWith(`/${tid}`) || k === tid)
if (!found) {
issues.push({
code: 'MISSING_REFERENCE',
severity: 'error',
message: `Rule "${constraint.id}" references unknown target: "${tid}"`,
path: constraint.scope,
ruleId: constraint.id,
})
}
}
// dependsOn deben existir como variables
for (const dep of constraint.dependsOn) {
if (!graph.variables.has(dep)) {
issues.push({
code: 'MISSING_REFERENCE',
severity: 'error',
message: `Rule "${constraint.id}" references unknown dependency: "${dep}"`,
path: constraint.scope,
ruleId: constraint.id,
})
}
}
}
}
// ── INVALID_DEFAULT ────────────────────────────────────────────────────────
private checkInvalidDefaults(graph: GacGraph, issues: DesignIssue[]): void {
for (const v of graph.variables.values()) {
if (!v.fullDomain.has(v.defaultOption)) {
issues.push({
code: 'INVALID_DEFAULT',
severity: 'error',
message: `Attribute "${v.attrId}" default "${v.defaultOption}" is not in its domain`,
path: v.path,
})
}
}
}
// ── EMPTY_DOMAIN ───────────────────────────────────────────────────────────
private checkEmptyDomains(graph: GacGraph, issues: DesignIssue[]): void {
for (const v of graph.variables.values()) {
if (v.fullDomain.size === 0) {
issues.push({
code: 'EMPTY_DOMAIN',
severity: 'error',
message: `Attribute "${v.attrId}" has no options`,
path: v.path,
})
}
}
}
// ── DEAD_OPTION ────────────────────────────────────────────────────────────
// Una opción está muerta si una regla incondicional la prohíbe siempre.
private checkDeadOptions(graph: GacGraph, issues: DesignIssue[]): void {
for (const v of graph.variables.values()) {
for (const optionId of v.fullDomain) {
const dead = [...graph.constraints.values()].some(c => {
const tid = c.action.target
if (!tid) return false
const targetsThis = v.path.endsWith(`/${tid}`) || v.path === tid
return targetsThis &&
!c.condition &&
c.action.type === 'forbid' &&
c.action.values?.includes(optionId)
})
if (dead) {
issues.push({
code: 'DEAD_OPTION',
severity: 'warning',
message: `Option "${optionId}" in "${v.attrId}" is unconditionally forbidden`,
path: v.path,
})
}
}
}
}
// ── REDUNDANT_RULE ─────────────────────────────────────────────────────────
// Una regla es redundante si alguna de sus dependencias tiene dominio vacío.
private checkRedundantRules(graph: GacGraph, issues: DesignIssue[]): void {
for (const c of graph.constraints.values()) {
if (!c.condition) continue
const hasDead = c.dependsOn.some(dep => {
const v = graph.variables.get(dep)
return v && v.fullDomain.size === 0
})
if (hasDead) {
issues.push({
code: 'REDUNDANT_RULE',
severity: 'warning',
message: `Rule "${c.id}" can never activate — depends on an empty domain`,
path: c.scope,
ruleId: c.id,
})
}
}
}
}

@ -1,27 +0,0 @@
// ─────────────────────────────────────────────────────────────────────────────
// TIPOS
// ─────────────────────────────────────────────────────────────────────────────
import type {EntityPathKey} from "@/vice/libs/types.ts";
export type DesignIssueCode =
| 'MISSING_REFERENCE'
| 'INVALID_DEFAULT'
| 'EMPTY_DOMAIN'
| 'DUPLICATE_ID'
| 'DEAD_OPTION'
| 'REDUNDANT_RULE'
export interface DesignIssue {
code: DesignIssueCode
severity: 'error' | 'warning'
message: string
path: EntityPathKey // dónde ocurre el problema
ruleId?: string
}
export interface ValidationResult {
valid: boolean // false si hay algún error
issues: DesignIssue[]
}
Loading…
Cancel
Save

Powered by TurnKey Linux.