feat(vcen-edit): fixes PanelHotspot — as-casts, aria, variant dark LingInput

- Extrae helpers asLingRec/asHotspotMode/asLingRecOpt para evitar `as` en template
- Corrige select: elimina value={}, usa selected={} en cada option
- Añade for/id en todos los pares label-input y aria-label en color picker
- Añade variant="dark" al LingInput de tooltip (evita fondo blanco en editor oscuro)
- Incluye renombrados de componentes y demás cambios de la sesión

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
master
dev 7 months ago
parent 006a0f0d63
commit f956144577

@ -1,78 +1,7 @@
<script lang="ts">
import './app.css';
import CatalogEditor from '@/vcen-edit/comps/CatalogEditor.svelte';
import ZMenu from '@/vcen-edit/comps/ZMenu.svelte';
import type { Cat } from '@/vcen/lib';
import { logr } from '@/vcen-edit/lib/logr.ts';
import {loadCatalogs, saveCatalog} from "@/vcen-edit/services/configurador_db.ts";
import Editor from "@/vcen-edit/canvas/Editor.svelte";
const demoCat: Cat = {
pricingDefaults: { currency: 'EUR', vat: 21, vatIncluded: true },
id: `ct_${crypto.randomUUID()}` as Cat['id'],
name: 'Catálogo demo',
lang: 'es',
langs: ['es'],
atts: [],
opts: [],
optCategories: [],
objs: []
};
let catalogs = $state<Cat[]>([]);
let loading = $state(true);
let saving = $state(false);
let activeCat = $state<Cat>(demoCat);
$effect(() => {
loadCatalogs()
.then(list => {
catalogs = list;
if (list.length > 0) activeCat = list[0];
})
.catch(e => logr.error('db', 'Error cargando', e))
.finally(() => loading = false);
});
function loadCatalog(cat: Cat) {
activeCat = JSON.parse(JSON.stringify(cat));
}
async function onSave(updated: Cat) {
saving = true;
try {
await saveCatalog(updated);
catalogs = await loadCatalogs();
logr.info('editor', 'Guardado', { id: updated.id });
} catch (e) {
logr.error('editor', 'Error guardando', { error: String(e) });
} finally {
saving = false;
}
}
</script>
{#if loading}
<div class="h-screen flex items-center justify-center text-sm text-neutral-400">
Cargando...
</div>
{:else}
<div class="h-screen w-screen overflow-hidden flex flex-col">
<ZMenu
{catalogs}
current={activeCat}
onload={loadCatalog}
/>
<div class="flex-1 overflow-hidden relative">
{#key activeCat.id}
<CatalogEditor cat={activeCat} onsave={onSave} />
{/key}
{#if saving}
<div class="fixed bottom-4 right-4 bg-black text-white text-sm px-3 py-1 rounded-full shadow">
Guardando...
</div>
{/if}
</div>
</div>
{/if}
<Editor />

@ -1,44 +1,82 @@
<script lang="ts">
import {type Cat, toCatID} from "@/vcen/lib";
import type {Cat} from "@/vcen/lib";
import {loadCatalogs, saveCatalog} from "@/vcen-edit/services/configurador_db.ts";
import {logr} from "@/vcen-edit/lib/logr.ts";
import CatalogEditor from "@/vcen-edit/comps/CatalogEditor.svelte";
import ZMenu from "@/vcen-edit/comps/ZMenu.svelte";
const STORAGE_KEY = 'vcen-editor-cat'
const demoCat: Cat = {
pricingDefaults: { currency: 'EUR', vat: 21, vatIncluded: true },
id: `ct_${crypto.randomUUID()}` as Cat['id'],
name: 'Catálogo demo',
lang: 'es',
langs: ['es'],
atts: [],
opts: [],
optCategories: [],
objs: []
};
function loadCat(): Cat {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) return JSON.parse(stored)
} catch {}
return {
id: toCatID('test'),
name: 'Nuevo',
lang: 'es',
langs: ['es'],
optCategories: [],
opts: [],
atts: [],
objs: [],
}
let catalogs = $state<Cat[]>([]);
let loading = $state(true);
let saving = $state(false);
let activeCat = $state<Cat>(demoCat);
$effect(() => {
loadCatalogs()
.then(list => {
catalogs = list;
if (list.length > 0) activeCat = list[0];
})
.catch(e => logr.error('db', 'Error cargando', e))
.finally(() => loading = false);
});
function loadCatalog(cat: Cat) {
activeCat = JSON.parse(JSON.stringify(cat));
}
function saveCat(cat: Cat) {
async function onsave(updated: Cat) {
saving = true;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(cat))
} catch {}
await saveCatalog(updated);
catalogs = await loadCatalogs();
logr.info('editor', 'Guardado', { id: updated.id });
} catch (e) {
logr.error('editor', 'Error guardando', { error: String(e) });
} finally {
saving = false;
}
}
const cat = loadCat()
</script>
const oncancel = () => void {}
<div class="h-full">
<CatalogEditor {cat} onsave={saveCat} />
</div>
</script>
{#if loading}
<div class="h-screen flex items-center justify-center text-sm text-neutral-400">
Cargando...
</div>
{:else}
<div class="h-screen w-screen overflow-hidden flex flex-col">
<ZMenu
{catalogs}
current={activeCat}
onload={loadCatalog}
/>
<style>
<div class="flex-1 overflow-hidden relative">
{#key activeCat.id}
<CatalogEditor cat={activeCat} {onsave} {oncancel} />
{/key}
</style>
{#if saving}
<div class="fixed bottom-4 right-4 bg-black text-white text-sm px-3 py-1 rounded-full shadow">
Guardando...
</div>
{/if}
</div>
</div>
{/if}

@ -5,13 +5,13 @@ import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.js'
import { labelOf } from '@/vcen-edit/lib/label.ts'
import type { AttDomain, ObjID, SecID, AttID } from '@/vcen/lib'
import RuleList from './RuleList.svelte'
import DeleteButton from './DeleteButton.svelte'
import ButtonDelete from './ButtonDelete.svelte'
import { buildEvalCtx, evalRuleSetState } from '@/vcen-edit/lib/rule-eval.ts'
import { stateStatus } from '@/vcen-edit/lib/state-badge.ts'
import { isValidId } from '@/vcen-edit/lib/validate'
import EntityIdName from './EntityIdName.svelte'
import InputIdName from './InputIdName.svelte'
import Panel from './Panel.svelte'
import CountBadge from './CountBadge.svelte'
import BadgeCount from './BadgeCount.svelte'
const editor = getContext<CatalogEditorStore>('catalog-editor')
const glob = getContext<EditorGlob>('editor-glob')
@ -221,10 +221,10 @@ function deleteAtt() {
<button onclick={cancelNew} class="ve-action-btn">{glob.t('vcen.editor.common.cancel')}</button>
<button onclick={saveNew} class="ve-action-btn primary">{glob.t('vcen.editor.common.save')}</button>
{:else}
<DeleteButton size="md" confirm
confirmTitle={glob.t('vcen.editor.att.deleteTitle')}
confirmBody={labelOf(att.label, cat.lang) || att.id}
onconfirm={deleteAtt}
<ButtonDelete size="md" confirm
confirmTitle={glob.t('vcen.editor.att.deleteTitle')}
confirmBody={labelOf(att.label, cat.lang) || att.id}
onconfirm={deleteAtt}
/>
{/if}
{/snippet}
@ -234,7 +234,7 @@ function deleteAtt() {
<!-- Identificación -->
<section class="flex flex-col gap-3">
<h3 class="section-title">{glob.t('vcen.editor.att.sectionId')}</h3>
<EntityIdName
<InputIdName
prefix="at"
{isNew}
fullId={att.id}
@ -548,7 +548,7 @@ function deleteAtt() {
<section class="flex flex-col gap-3">
<div class="flex items-center justify-between section-title" style="margin-bottom:0">
<span>{glob.t('vcen.editor.att.sectionRules')}
<CountBadge value={att.rules?.rules?.length ?? 0} />
<BadgeCount value={att.rules?.rules?.length ?? 0} />
</span>
{#if att.rules}
<button onclick={clearRules} class="ve-btn-link danger">{glob.t('vcen.editor.att.removeAllRules')}</button>

@ -1,7 +1,7 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.js'
import DeleteModal from './DeleteModal.svelte'
import ModalDelete from './ModalDelete.svelte'
import Icon from './Icon.svelte'
let {
@ -62,7 +62,7 @@ const iconSize = $derived(
</button>
{#if confirm && showModal}
<DeleteModal
<ModalDelete
bind:show={showModal}
title={confirmTitle ?? glob.t('vcen.editor.common.deleteBtn')}
body={confirmBody}

@ -17,6 +17,7 @@ import type {AttState} from "@/vcen/lib";
isActive,
state,
icon,
iconColor,
label,
activeClass,
class: className = ''
@ -25,6 +26,7 @@ import type {AttState} from "@/vcen/lib";
isActive: boolean;
state? : AttState,
icon: IconName,
iconColor?: string,
label: string,
activeClass? :string,
class? :string,
@ -41,7 +43,7 @@ import type {AttState} from "@/vcen/lib";
}
${className}`}
>
<Icon name={icon} class="h-4 w-4 shrink-0 {isActive ? 'text-indigo-400' : 'text-gray-600'}" />
<Icon name={icon} class="h-4 w-4 shrink-0 {isActive ? 'text-indigo-400' : 'text-gray-600'}" color={iconColor} />
<span class="flex-1 truncate font-medium">{label}</span>
{#if state}
<span class={`h-1.5 w-1.5 rounded-full shrink-0 ${STATE_DOT[state] ?? 'bg-gray-600'}`} title={state}></span>

@ -6,7 +6,7 @@ import type { Cat } from '@/vcen/lib'
import { createEditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.ts'
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.ts'
import { logr } from '@/vcen-edit/lib/logr.ts'
import CatalogLayout from './CatalogLayout.svelte'
import CatalogLayout from '../layouts/CatalogLayout.svelte'
let { cat, onsave, oncancel }: {
cat: Cat

@ -4,8 +4,6 @@ import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelt
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.js'
import type { ObjID, SecID, ViewID, HotspotID } from '@/vcen/lib'
import { labelOf } from '@/vcen-edit/lib/label.ts'
import CountBadge from './CountBadge.svelte'
import Icon from './Icon.svelte'
import ButtonSidebar from "@/vcen-edit/comps/ButtonSidebar.svelte";
import ButtonSidebarExpand from "@/vcen-edit/comps/ButtonSidebarExpand.svelte";
import ButtonSidebarMove from "@/vcen-edit/comps/ButtonSidebarMove.svelte";
@ -19,12 +17,6 @@ const sel = $derived(editor.selection)
const objStates = $derived(editor.objStates)
const secStates = $derived(editor.secStates)
const STATE_DOT: Record<string, string> = {
available: 'bg-green-500',
required: 'bg-red-500',
hidden: 'bg-gray-500',
forbidden: 'bg-orange-500',
}
let openObjs = $state<Set<ObjID>>(new Set())
let openSecs = $state<Set<SecID>>(new Set())
@ -98,14 +90,11 @@ function getLabelStr(label: any): string {
}
// ── Constantes de estilo uniformes ────────────────────────────────────────────
const ROW_H = 'py-1.5'
const ICON_COLOR = 'cyan'
const ITEM_BASE = `flex items-center gap-1.5 px-3 ${ROW_H} cursor-pointer select-none transition-colors border-l-2`
const ITEM_ACTIVE = 'bg-gray-800/70 text-gray-100 border-indigo-500'
const ITEM_INACTIVE = 'text-gray-400 hover:text-gray-100 hover:bg-gray-800/40 border-transparent'
const CSS_ITEM_ACTIVE = 'bg-gray-800/70 text-gray-100 border-indigo-500'
const CSS_ITEM_INACTIVE = 'text-gray-400 hover:text-gray-100 hover:bg-gray-800/40 border-transparent'
const CHV = `shrink-0 cursor-pointer ${ROW_H} text-gray-700 hover:text-gray-400 transition-colors`
const MOVE_BTN = 'flex items-center justify-center w-5 h-5 rounded cursor-pointer text-gray-600 hover:text-gray-300 hover:bg-gray-700 transition-colors disabled:opacity-20 disabled:pointer-events-none'
</script>
<!-- 13px uniforme en todo el sidebar -->
@ -116,35 +105,58 @@ const MOVE_BTN = 'flex items-center justify-center w-5 h-5 rounded cursor-pointe
<span class="text-[10px] font-bold uppercase tracking-widest text-gray-700">{glob.t('vcen.editor.sidebar.catSection')}</span>
</div>
<button onclick={() => editor.requestAction(() => editor.select({ kind: 'cat-info' }))}
class="{ITEM_BASE} w-full pl-5 {sel.kind === 'cat-info' ? ITEM_ACTIVE : ITEM_INACTIVE}">
<Icon name="info" class="h-3 w-3 shrink-0" />
{glob.t('vcen.editor.sidebar.catInfo')}
</button>
<button onclick={() => editor.requestAction(() => editor.select({ kind: 'cat-atts' }))}
class="{ITEM_BASE} w-full pl-5 {sel.kind === 'cat-atts' ? ITEM_ACTIVE : ITEM_INACTIVE}">
<Icon name="att" class="h-3 w-3 shrink-0" />
{glob.t('vcen.editor.sidebar.catAtts')}
</button>
<button onclick={() => editor.requestAction(() => editor.select({ kind: 'cat-meta' }))}
class="{ITEM_BASE} w-full pl-5 {sel.kind === 'cat-meta' ? ITEM_ACTIVE : ITEM_INACTIVE}">
<Icon name="meta" class="h-3 w-3 shrink-0" />
{glob.t('vcen.editor.sidebar.catMeta')}
</button>
<button onclick={() => editor.requestAction(() => editor.select({ kind: 'opts' }))}
class="{ITEM_BASE} w-full pl-5 {sel.kind === 'opts' ? ITEM_ACTIVE : ITEM_INACTIVE}">
<Icon name="opt" class="h-3 w-3 shrink-0" />
{glob.t('vcen.editor.sidebar.catOpts')}
</button>
<ButtonSidebarGo
onclick={() => editor.requestAction(() => editor.select({ kind: 'cat-info' }))}
isActive={sel.kind === 'cat-info'}
icon="info"
iconColor={ICON_COLOR}
label={glob.t('vcen.editor.sidebar.catInfo')}
class="flex items-center gap-1.5 px-3 cursor-pointer select-none transition-colors border-l-2 w-full pl-5 {
sel.kind === 'cat-info'
? CSS_ITEM_ACTIVE
: CSS_ITEM_INACTIVE}"
/>
<ButtonSidebarGo
onclick={() => editor.requestAction(() => editor.select({ kind: 'cat-atts' }))}
isActive={sel.kind === 'cat-atts'}
icon="att"
iconColor={ICON_COLOR}
label={glob.t('vcen.editor.sidebar.catAtts')}
class="flex items-center gap-1.5 px-3 cursor-pointer select-none transition-colors border-l-2 w-full pl-5 {
sel.kind === 'cat-atts'
? CSS_ITEM_ACTIVE
: CSS_ITEM_INACTIVE}"
/>
<ButtonSidebarGo
onclick={() => editor.requestAction(() => editor.select({ kind: 'cat-meta' }))}
isActive={sel.kind === 'cat-meta'}
icon="meta"
iconColor={ICON_COLOR}
label={glob.t('vcen.editor.sidebar.catMeta')}
class="flex items-center gap-1.5 px-3 cursor-pointer select-none transition-colors border-l-2 w-full pl-5 {
sel.kind === 'cat-meta'
? CSS_ITEM_ACTIVE
: CSS_ITEM_INACTIVE}"
/>
<ButtonSidebarGo
onclick={() => editor.requestAction(() => editor.select({ kind: 'cat-opts' }))}
isActive={sel.kind === 'cat-opts'}
icon="opt"
iconColor={ICON_COLOR}
label={glob.t('vcen.editor.sidebar.catOpts')}
class="flex items-center gap-1.5 px-3 cursor-pointer select-none transition-colors border-l-2 w-full pl-5 {
sel.kind === 'cat-opts'
? CSS_ITEM_ACTIVE
: CSS_ITEM_INACTIVE}"
/>
<!-- ── OBJETOS ──────────────────────────────────────────────── -->
<div class="px-3 py-3 mt-1 mb-0.5 flex items-center justify-between border-t border-b border-gray-800/60">
<!--
<span class="text-[10px] font-bold uppercase tracking-widest text-gray-700">{glob.t('vcen.editor.sidebar.objsSection')}</span>
-->
<ButtonSidebar
onclick={() => editor.requestAction(() => editor.addObj())}
@ -175,6 +187,7 @@ const MOVE_BTN = 'flex items-center justify-center w-5 h-5 rounded cursor-pointe
<ButtonSidebarGo
onclick={() => navigateToObj(obj.id)}
icon="obj"
iconColor={ICON_COLOR}
isActive={objActive}
label={labelOf(obj.label, cat.lang) || glob.t('vcen.editor.sidebar.noName')}
state={objState || undefined}
@ -216,6 +229,7 @@ const MOVE_BTN = 'flex items-center justify-center w-5 h-5 rounded cursor-pointe
<ButtonSidebarGo
onclick={() => navigateToSec(obj.id, sec.id)}
icon="sec"
iconColor={ICON_COLOR}
isActive={secActive}
label={labelOf(sec.label, cat.lang) || glob.t('vcen.editor.sidebar.noName')}
state={secState || undefined}
@ -253,6 +267,7 @@ const MOVE_BTN = 'flex items-center justify-center w-5 h-5 rounded cursor-pointe
<ButtonSidebarGo
onclick={() => navigateToView(obj.id, sec.id, view.id)}
icon="view"
iconColor={ICON_COLOR}
isActive={viewActive}
label={getLabelStr(view.label) || view.code || view.id}
/>
@ -277,6 +292,7 @@ const MOVE_BTN = 'flex items-center justify-center w-5 h-5 rounded cursor-pointe
<ButtonSidebarGo
onclick={() => navigateToHotspot(obj.id, sec.id, view.id, hs.id) }
icon="hotspot"
iconColor={ICON_COLOR}
isActive={hsActive}
label={getLabelStr(hs.label) || hs.id}
class="pl-16"

@ -7,10 +7,12 @@ let {
name,
class: cls = '',
strokeWidth = 1.5,
color = 'currentColor',
}: {
name: IconName
class?: string
strokeWidth?: number
color?: string
} = $props()
// Contenido interno SVG (viewBox 0 0 24 24, stroke="currentColor")
@ -95,7 +97,7 @@ const ICONS: Record<IconName, string> = {
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke={color}
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-linejoin="round"

@ -1,13 +1,13 @@
<script lang="ts">
/**
* EntityIdName — bloque de identificación normalizado: ID + Nombre.
* InputIdName — bloque de identificación normalizado: ID + Nombre.
*
* Agrupa InputEntityID y LingInput (label "Nombre") en el orden canónico.
* Agrupa InputId y LingInput (label "Nombre") en el orden canónico.
* Úsalo en todos los formularios de entidad como primer bloque.
*/
import type { LingRecord, LingString } from '@/ling/lib'
import LingInput from '@/ling/svelte/LingInput.svelte'
import InputEntityID from './InputEntityID.svelte'
import InputId from './InputID.svelte'
let {
// ── ID ──────────────────────────────────────────────────────────
@ -60,7 +60,7 @@ let {
<div class="flex flex-col gap-3">
<!-- 1. ID — siempre primero -->
<InputEntityID
<InputId
{prefix}
{isNew}
{fullId}

@ -3,7 +3,7 @@ import { getContext } from 'svelte'
import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.js'
import { labelOf } from '@/vcen-edit/lib/label.ts'
import MetaPanel from './MetaPanel.svelte'
import PanelMeta from './PanelMeta.svelte'
const editor = getContext<CatalogEditorStore>('catalog-editor')
const glob = getContext<EditorGlob>('editor-glob')
@ -45,6 +45,6 @@ const ctx = $derived.by(() => {
{#if ctx}
<div class="absolute inset-0 flex flex-col overflow-hidden">
<MetaPanel value={ctx.meta} onUpdate={ctx.onUpdate} />
<PanelMeta value={ctx.meta} onUpdate={ctx.onUpdate} />
</div>
{/if}

@ -41,11 +41,11 @@ let {
<!-- Tipo · Título + subtítulo/id -->
<div class="flex items-center gap-2 min-w-0 flex-1">
{#if kind}
<span class="text-[9px] font-bold uppercase tracking-[0.12em] text-gray-700 shrink-0 bg-gray-800 px-1.5 py-0.5 rounded">{kind}</span>
<span class="text-[9px] font-semibold uppercase tracking-[0.12em] text-white shrink-0 bg-cyan-700 px-1.5 py-0.5 rounded">{kind}</span>
{/if}
<h2 class="text-sm font-semibold text-gray-100 truncate">{title}</h2>
{#if subtitle}
<span class="text-xs text-amber-500/80 shrink-0">{subtitle}</span>
<span class="text-xs text-cyan-500/7/0 shrink-0">{subtitle}</span>
{/if}
</div>

@ -5,7 +5,7 @@ import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.js'
import { labelOf } from '@/vcen-edit/lib/label.ts'
import AttDetail from './AttDetail.svelte'
import SidebarTree from './SidebarTree.svelte'
import EditorButton from "@/vcen-edit/comps/EditorButton.svelte";
import ButtonSidebar from "@/vcen-edit/comps/ButtonSidebar.svelte";
const editor = getContext<CatalogEditorStore>('catalog-editor')
const glob = getContext<EditorGlob>('editor-glob')
@ -19,13 +19,13 @@ const attId = $derived(sel.kind === 'cat-atts' ? sel.attId : undefined)
<SidebarTree title={glob.t('vcen.editor.attsPanel.title')} count={atts.length} onAdd={() => editor.requestAction(() => editor.addCatAtt())}>
{#if atts.length === 0}
<div class="px-4 py-2 text-center text-gray-700 text-xs italic">
<EditorButton
<div class="px-4 py-2 text-center text-gray-700 text-xs">
<ButtonSidebar
class="w-full"
onclick={() => editor.requestAction(() => editor.addCatAtt())}
>
{glob.t('vcen.editor.attsPanel.addAtt')}
</EditorButton>
</ButtonSidebar>
</div>
{:else}

@ -4,7 +4,8 @@ import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelt
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.js'
import LingInput from '@/ling/svelte/LingInput.svelte'
import Panel from './Panel.svelte'
import IssueSection from './IssueSection.svelte'
import SectionIssue from './SectionIssue.svelte'
import Tabs from './Tabs.svelte'
const editor = getContext<CatalogEditorStore>('catalog-editor')
const glob = getContext<EditorGlob>('editor-glob')
@ -66,25 +67,16 @@ let catTab = $state<'info' | 'issues'>('info')
<Panel title={cat.name || 'Catálogo'} kind="Catálogo" subtitle={cat.id}>
<!-- Tab bar -->
<div class="shrink-0 flex border-b border-gray-800 px-4">
{#each [
{#snippet tabs()}
<Tabs active={catTab} onTab={t => catTab = t as typeof catTab} tabs={[
{ id: 'info', label: 'General' },
{ id: 'issues', label: 'Incidencias' },
] as t (t.id)}
{@const isActive = catTab === t.id}
<button
onclick={() => catTab = t.id as any}
class="px-4 py-2.5 text-xs font-medium uppercase tracking-wide border-b-2 -mb-px transition-colors
{isActive ? 'text-indigo-400 border-indigo-500'
: 'text-gray-500 border-transparent hover:text-gray-300'}"
>{t.label}</button>
{/each}
</div>
]} />
{/snippet}
{#if catTab === 'issues'}
<div class="ve-panel-content overflow-y-auto">
<IssueSection
<SectionIssue
issues={(cat as any).issues ?? []}
locales={cat.langs}
defaultLocale={cat.lang}

@ -5,10 +5,10 @@ import { resolveView, buildConfigState } from '@/vcen/lib'
import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.js'
import LingInput from '@/ling/svelte/LingInput.svelte'
import EntityIdName from './EntityIdName.svelte'
import InputIdName from './InputIdName.svelte'
import Panel from './Panel.svelte'
import VisualPreview from './VisualPreview.svelte'
import DeleteButton from './DeleteButton.svelte'
import ButtonDelete from './ButtonDelete.svelte'
import Icon from './Icon.svelte'
import { isValidId } from '@/vcen-edit/lib/validate'
@ -86,6 +86,11 @@ function handlePick(x: number, y: number) {
}
function toAttId(s: string): AttID { return s as AttID }
function asHotspotMode(s: string): HotspotMode { return s as HotspotMode }
function asLingRec(v: unknown): LingRec { return v as LingRec }
function asLingRecOpt(v: unknown): LingRec | undefined {
return v && Object.keys(v as object).length ? v as LingRec : undefined
}
// ── Preview ──────────────────────────────────────────────────────────────────
function fillDefaultValues(atts: Att[]): Att[] {
@ -119,9 +124,9 @@ function getLabelStr(label: any): string {
}
</script>
{#if ctx?.hs}
{@const hs = ctx.hs}
{@const view = ctx.view!}
{#if ctx?.hs && ctx.view}
{@const hs = ctx.hs}
{@const view = ctx.view}
<Panel
title={isNew ? glob.t('vcen.editor.hotspot.newTitle') : (getLabelStr(hs.label) || hs.id)}
@ -135,10 +140,10 @@ function getLabelStr(label: any): string {
<button onclick={cancelNew} class="ve-action-btn">{glob.t('vcen.editor.common.cancel')}</button>
<button onclick={saveNew} class="ve-action-btn primary">{glob.t('vcen.editor.common.save')}</button>
{:else}
<DeleteButton size="md" confirm
confirmTitle={glob.t('vcen.editor.hotspot.deleteTitle')}
confirmBody={getLabelStr(hs.label) || hs.id}
onconfirm={() => { if (ctx) editor.removeHotspot(ctx.objId, ctx.secId, ctx.viewId, ctx.hotspotId) }}
<ButtonDelete size="md" confirm
confirmTitle={glob.t('vcen.editor.hotspot.deleteTitle')}
confirmBody={getLabelStr(hs.label) || hs.id}
onconfirm={() => { if (ctx) editor.removeHotspot(ctx.objId, ctx.secId, ctx.viewId, ctx.hotspotId) }}
/>
{/if}
{/snippet}
@ -147,7 +152,7 @@ function getLabelStr(label: any): string {
<div class="flex-1 min-w-0 overflow-y-auto ve-panel-content border-r border-gray-800">
<!-- ID + Nombre -->
<EntityIdName
<InputIdName
prefix="hs"
{isNew}
fullId={hs.id}
@ -159,25 +164,25 @@ function getLabelStr(label: any): string {
name={hs.label ?? {}}
locales={cat.langs}
defaultLocale={cat.lang}
onNameUpdate={v => patch({ label: v as LingRec })}
onNameUpdate={v => patch({ label: asLingRec(v) })}
/>
<!-- Modo -->
<div class="flex flex-col gap-1">
<label class="ve-label">{glob.t('vcen.editor.hotspot.mode')}</label>
<select value={hs.mode}
onchange={e => patch({ mode: e.currentTarget.value as HotspotMode })}
<label for="hs-mode" class="ve-label">{glob.t('vcen.editor.hotspot.mode')}</label>
<select id="hs-mode"
onchange={e => patch({ mode: asHotspotMode(e.currentTarget.value) })}
class="field-input text-xs">
{#each HOTSPOT_MODES as m (m.value)}
<option value={m.value}>{m.label}</option>
<option value={m.value} selected={hs.mode === m.value}>{m.label}</option>
{/each}
</select>
</div>
<!-- Atributo destino -->
<div class="flex flex-col gap-1">
<label class="ve-label">{glob.t('vcen.editor.hotspot.targetAtt')} <span class="text-gray-700">{glob.t('vcen.editor.common.optional')}</span></label>
<input type="text" value={hs.targetAtt ?? ''}
<label for="hs-target-att" class="ve-label">{glob.t('vcen.editor.hotspot.targetAtt')} <span class="text-gray-700">{glob.t('vcen.editor.common.optional')}</span></label>
<input id="hs-target-att" type="text" value={hs.targetAtt ?? ''}
oninput={e => patch({ targetAtt: e.currentTarget.value ? toAttId(e.currentTarget.value) : undefined })}
placeholder="at:color, sc:exterior/at:llantas…"
class="field-input font-mono text-xs" />
@ -199,15 +204,15 @@ function getLabelStr(label: any): string {
</div>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1">
<label class="ve-label">{glob.t('vcen.editor.hotspot.posX')}</label>
<input type="number" min="0" max="100" step="0.1"
<label for="hs-pos-x" class="ve-label">{glob.t('vcen.editor.hotspot.posX')}</label>
<input id="hs-pos-x" type="number" min="0" max="100" step="0.1"
value={typeof hs.posX === 'number' ? hs.posX : parseFloat(String(hs.posX)) || 0}
oninput={e => patch({ posX: parseFloat(e.currentTarget.value) || 0 })}
class="field-input text-xs" />
</div>
<div class="flex flex-col gap-1">
<label class="ve-label">{glob.t('vcen.editor.hotspot.posY')}</label>
<input type="number" min="0" max="100" step="0.1"
<label for="hs-pos-y" class="ve-label">{glob.t('vcen.editor.hotspot.posY')}</label>
<input id="hs-pos-y" type="number" min="0" max="100" step="0.1"
value={typeof hs.posY === 'number' ? hs.posY : parseFloat(String(hs.posY)) || 0}
oninput={e => patch({ posY: parseFloat(e.currentTarget.value) || 0 })}
class="field-input text-xs" />
@ -218,27 +223,27 @@ function getLabelStr(label: any): string {
<!-- Apariencia -->
<div class="grid grid-cols-3 gap-3">
<div class="flex flex-col gap-1">
<label class="ve-label">{glob.t('vcen.editor.hotspot.color')}</label>
<label for="hs-color-text" class="ve-label">{glob.t('vcen.editor.hotspot.color')}</label>
<div class="flex items-center gap-1.5">
<input type="color" value={hs.color ?? '#4f46e5'}
<input aria-label={glob.t('vcen.editor.hotspot.color')} type="color" value={hs.color ?? '#4f46e5'}
oninput={e => patch({ color: e.currentTarget.value })}
class="h-7 w-8 rounded border border-gray-700 bg-gray-800 cursor-pointer p-0.5" />
<input type="text" value={hs.color ?? ''}
<input id="hs-color-text" type="text" value={hs.color ?? ''}
oninput={e => patch({ color: e.currentTarget.value || undefined })}
placeholder="#4f46e5"
class="field-input font-mono text-xs flex-1 min-w-0" />
</div>
</div>
<div class="flex flex-col gap-1">
<label class="ve-label">{glob.t('vcen.editor.hotspot.radius')}</label>
<input type="number" min="1" value={hs.radius ?? ''}
<label for="hs-radius" class="ve-label">{glob.t('vcen.editor.hotspot.radius')}</label>
<input id="hs-radius" type="number" min="1" value={hs.radius ?? ''}
oninput={e => patch({ radius: parseInt(e.currentTarget.value) || undefined })}
placeholder="8"
class="field-input text-xs" />
</div>
<div class="flex flex-col gap-1">
<label class="ve-label">{glob.t('vcen.editor.hotspot.zIndex')}</label>
<input type="number" value={hs.zIndex ?? ''}
<label for="hs-zindex" class="ve-label">{glob.t('vcen.editor.hotspot.zIndex')}</label>
<input id="hs-zindex" type="number" value={hs.zIndex ?? ''}
oninput={e => patch({ zIndex: parseInt(e.currentTarget.value) || undefined })}
placeholder="1"
class="field-input text-xs" />
@ -247,14 +252,17 @@ function getLabelStr(label: any): string {
<!-- Tooltip -->
<div class="flex flex-col gap-1">
<label class="ve-label">{glob.t('vcen.editor.hotspot.tooltip')} <span class="text-gray-700">{glob.t('vcen.editor.common.optional')}</span></label>
<label for="hs-tooltip" class="ve-label">{glob.t('vcen.editor.hotspot.tooltip')} <span class="text-gray-700">{glob.t('vcen.editor.common.optional')}</span></label>
<LingInput
value={(hs.tooltip ?? {}) as LingRec}
value={asLingRec(hs.tooltip ?? {})}
locales={cat.langs}
defaultLocale={cat.lang}
onUpdate={v => patch({ tooltip: v && Object.keys(v).length ? v as LingRec : undefined })}
variant="dark"
onUpdate={v => patch({ tooltip: asLingRecOpt(v) })}
placeholder="Texto al pasar el cursor…"
/>
</div>
</div>

@ -7,16 +7,16 @@ import { stateStatus } from '@/vcen-edit/lib/state-badge.ts'
import { isValidId } from '@/vcen-edit/lib/validate'
import type { LingRecord } from '@/ling/lib'
import LingInput from '@/ling/svelte/LingInput.svelte'
import EntityIdName from './EntityIdName.svelte'
import InputIdName from './InputIdName.svelte'
import Panel from './Panel.svelte'
import RuleList from './RuleList.svelte'
import IssueSection from './IssueSection.svelte'
import ObjAttsPanel from './ObjAttsPanel.svelte'
import MetaPanel from './MetaPanel.svelte'
import VisualPanel from './VisualPanel.svelte'
import DeleteButton from './DeleteButton.svelte'
import PricingSection from './PricingSection.svelte'
import CountBadge from './CountBadge.svelte'
import SectionIssue from './SectionIssue.svelte'
import PanelObjectAtts from './PanelObjectAtts.svelte'
import PanelMeta from './PanelMeta.svelte'
import PanelVisual from './PanelVisual.svelte'
import ButtonDelete from './ButtonDelete.svelte'
import SectionPricing from './SectionPricing.svelte'
import BadgeCount from './BadgeCount.svelte'
import Tabs from './Tabs.svelte'
import { createEntityTabs, entityTabsArray } from '@/vcen-edit/lib/entity-tabs.svelte.ts'
@ -85,12 +85,12 @@ function cancelNew() {
+ {glob.t('vcen.editor.sidebar.newSec')}
</button>
<button onclick={() => editor.cloneObj(objId)} class="ve-action-btn">{glob.t('vcen.editor.common.clone')}</button>
<DeleteButton size="md" confirm
<ButtonDelete size="md" confirm
text={glob.t('vcen.editor.obj.deleteBtn')}
confirmTitle={glob.t('vcen.editor.obj.deleteTitle')}
confirmBody={labelOf(obj.label, cat.lang) || obj.id}
confirmWarning={glob.t('vcen.editor.obj.deleteWarn')}
onconfirm={() => editor.removeObj(objId)}
confirmTitle={glob.t('vcen.editor.obj.deleteTitle')}
confirmBody={labelOf(obj.label, cat.lang) || obj.id}
confirmWarning={glob.t('vcen.editor.obj.deleteWarn')}
onconfirm={() => editor.removeObj(objId)}
/>
{/if}
{/snippet}
@ -121,7 +121,7 @@ function cancelNew() {
<!-- Identificación -->
<section class="flex flex-col gap-3">
<h3 class="section-title">{glob.t('vcen.editor.entity.id')}</h3>
<EntityIdName
<InputIdName
prefix="ob"
{isNew}
fullId={obj.id}
@ -160,7 +160,7 @@ function cancelNew() {
</section>
<!-- Precio base -->
<PricingSection
<SectionPricing
pricing={obj.pricing}
{currencies}
defaultCurrency={cat.pricingDefaults.currency ?? 'EUR'}
@ -170,11 +170,11 @@ function cancelNew() {
</div>
{:else if tab === 'atts'}
<ObjAttsPanel />
<PanelObjectAtts />
{:else if tab === 'visual'}
<div class="flex-1 min-h-0 overflow-hidden">
<VisualPanel
<PanelVisual
visual={obj.visual}
catDefaults={cat.visualDefaults ?? {}}
locales={cat.langs}
@ -192,7 +192,7 @@ function cancelNew() {
<section class="flex flex-col gap-3">
<h3 class="section-title">
{glob.t('vcen.editor.entity.rules')}
<CountBadge value={obj.rules?.rules?.length ?? 0} />
<BadgeCount value={obj.rules?.rules?.length ?? 0} />
</h3>
{#if obj.rules}
<RuleList ruleSet={obj.rules} container={obj} catOpts={editor.cat.opts} />
@ -216,7 +216,7 @@ function cancelNew() {
{glob.t('vcen.editor.entity.issues')}
<span class="text-gray-700 font-normal normal-case tracking-normal">({obj.issues?.length ?? 0})</span>
</h3>
<IssueSection
<SectionIssue
issues={obj.issues ?? []}
locales={cat.langs}
defaultLocale={cat.lang}
@ -229,7 +229,7 @@ function cancelNew() {
{:else if tab === 'meta'}
<div class="flex-1 min-h-0 overflow-hidden">
<MetaPanel
<PanelMeta
value={obj.metadata}
onUpdate={m => editor.updateObjMeta(objId, m)}
/>

@ -5,7 +5,7 @@ import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.js'
import { labelOf } from '@/vcen-edit/lib/label.ts'
import AttDetail from './AttDetail.svelte'
import SidebarTree from './SidebarTree.svelte'
import EditorButton from "@/vcen-edit/comps/EditorButton.svelte";
import ButtonSidebar from "@/vcen-edit/comps/ButtonSidebar.svelte";
const editor = getContext<CatalogEditorStore>('catalog-editor')
const glob = getContext<EditorGlob>('editor-glob')
@ -23,11 +23,11 @@ const ctx = $derived(sel.kind === 'obj-atts'
<SidebarTree title={glob.t('vcen.editor.attsPanel.title')} count={obj.atts.length} onAdd={() => editor.requestAction(() => editor.addAtt(objId, undefined))}>
{#if obj.atts.length === 0}
<div class="px-4 py-2 text-center text-gray-700 text-xs italic">
<EditorButton
<ButtonSidebar
onclick={() => editor.requestAction(() => editor.addAtt(objId, undefined))}
class="w-full">
{glob.t('vcen.editor.attsPanel.addAtt')}
</EditorButton>
</ButtonSidebar>
</div>

@ -7,14 +7,15 @@ import type { LingRecord } from '@/ling/lib'
import { labelOf } from '@/vcen-edit/lib/label.ts'
import { isValidId } from '@/vcen-edit/lib/validate'
import LingInput from '@/ling/svelte/LingInput.svelte'
import EntityIdName from './EntityIdName.svelte'
import InputIdName from './InputIdName.svelte'
import Panel from './Panel.svelte'
import DeleteButton from './DeleteButton.svelte'
import PricingSection from './PricingSection.svelte'
import CountBadge from './CountBadge.svelte'
import MetaPanel from './MetaPanel.svelte'
import IssueSection from './IssueSection.svelte'
import ButtonDelete from './ButtonDelete.svelte'
import SectionPricing from './SectionPricing.svelte'
import BadgeCount from './BadgeCount.svelte'
import PanelMeta from './PanelMeta.svelte'
import SectionIssue from './SectionIssue.svelte'
import RuleList from './RuleList.svelte'
import Tabs from './Tabs.svelte'
const editor = getContext<CatalogEditorStore>('catalog-editor')
const glob = getContext<EditorGlob>('editor-glob')
@ -92,6 +93,14 @@ const usages = $derived.by(() => {
})
const dummyContainer = $derived({ id: opt.id, label: optLabel, atts: [], secs: [] } as any)
const OPT_TABS = $derived([
{ id: 'general', label: glob.t('vcen.editor.opt.tabs.general') },
{ id: 'usages', label: `${glob.t('vcen.editor.opt.tabs.usages')} (${usages.length})` },
{ id: 'rules', label: glob.t('vcen.editor.opt.tabs.rules') },
{ id: 'issues', label: glob.t('vcen.editor.opt.tabs.issues') },
{ id: 'meta', label: glob.t('vcen.editor.opt.tabs.meta') },
])
</script>
<Panel
@ -106,31 +115,17 @@ const dummyContainer = $derived({ id: opt.id, label: optLabel, atts: [], secs: [
<button onclick={cancelNew} class="ve-action-btn">{glob.t('vcen.editor.common.cancel')}</button>
<button onclick={saveNew} class="ve-action-btn primary">{glob.t('vcen.editor.common.save')}</button>
{:else}
<DeleteButton size="md" confirm
confirmTitle={glob.t('vcen.editor.opt.deleteTitle')}
confirmBody={displayName}
confirmWarning={glob.t('vcen.editor.opt.deleteWarn')}
onconfirm={() => editor.removeOpt(opt.id)}
<ButtonDelete size="md" confirm
confirmTitle={glob.t('vcen.editor.opt.deleteTitle')}
confirmBody={displayName}
confirmWarning={glob.t('vcen.editor.opt.deleteWarn')}
onconfirm={() => editor.removeOpt(opt.id)}
/>
{/if}
{/snippet}
{#snippet tabs()}
<div class="shrink-0 flex border-b border-gray-800 px-4">
{#each ([
{ id: 'general', label: glob.t('vcen.editor.opt.tabs.general') },
{ id: 'usages', label: `${glob.t('vcen.editor.opt.tabs.usages')} (${usages.length})` },
{ id: 'rules', label: glob.t('vcen.editor.opt.tabs.rules') },
{ id: 'issues', label: glob.t('vcen.editor.opt.tabs.issues') },
{ id: 'meta', label: glob.t('vcen.editor.opt.tabs.meta') },
] as { id: typeof tab; label: string }[]) as t (t.id)}
<button
onclick={() => tab = t.id}
class="px-4 py-2.5 text-xs font-medium uppercase tracking-wide border-b-2 -mb-px transition-colors
{tab === t.id ? 'text-indigo-400 border-indigo-500' : 'text-gray-500 border-transparent hover:text-gray-300'}"
>{t.label}</button>
{/each}
</div>
<Tabs active={tab} onTab={t => tab = t as typeof tab} tabs={OPT_TABS} />
{/snippet}
<div class="flex-1 min-h-0 overflow-hidden flex flex-col">
@ -141,7 +136,7 @@ const dummyContainer = $derived({ id: opt.id, label: optLabel, atts: [], secs: [
<section class="flex flex-col gap-3">
<h3 class="section-title">{glob.t('vcen.editor.entity.id')}</h3>
<EntityIdName
<InputIdName
prefix="op"
{isNew}
fullId={opt.id}
@ -251,7 +246,7 @@ const dummyContainer = $derived({ id: opt.id, label: optLabel, atts: [], secs: [
</div>
</section>
<PricingSection
<SectionPricing
pricing={opt.pricing}
{currencies}
defaultCurrency={cat.pricingDefaults?.currency ?? 'EUR'}
@ -295,7 +290,7 @@ const dummyContainer = $derived({ id: opt.id, label: optLabel, atts: [], secs: [
<div class="flex items-center justify-between section-title" style="margin-bottom:0">
<span>
{glob.t('vcen.editor.entity.rules')}
<CountBadge value={opt.rules?.rules?.length ?? 0} />
<BadgeCount value={opt.rules?.rules?.length ?? 0} />
</span>
{#if opt.rules}
<button onclick={() => editor.updateOpt(opt.id, { rules: undefined })} class="ve-btn-link danger">
@ -324,7 +319,7 @@ const dummyContainer = $derived({ id: opt.id, label: optLabel, atts: [], secs: [
{glob.t('vcen.editor.entity.issues')}
<span class="text-gray-700 font-normal normal-case tracking-normal">({opt.issues?.length ?? 0})</span>
</h3>
<IssueSection
<SectionIssue
issues={opt.issues ?? []}
locales={cat.langs}
defaultLocale={cat.lang}
@ -337,7 +332,7 @@ const dummyContainer = $derived({ id: opt.id, label: optLabel, atts: [], secs: [
<!-- ── Meta ─────────────────────────────────────────────────────── -->
{:else if tab === 'meta'}
<div class="flex-1 min-h-0 overflow-hidden">
<MetaPanel
<PanelMeta
value={opt.metadata}
onUpdate={m => editor.updateOpt(opt.id, { metadata: m })}
/>

@ -3,10 +3,10 @@ import { getContext } from 'svelte'
import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.js'
import { labelOf } from '@/vcen-edit/lib/label.ts'
import OptDetail from './OptDetail.svelte'
import PanelOptionDetail from './PanelOptionDetail.svelte'
import SidebarTree from './SidebarTree.svelte'
import CountBadge from './CountBadge.svelte'
import EditorButton from "./EditorButton.svelte";
import BadgeCount from './BadgeCount.svelte'
import ButtonSidebar from "./ButtonSidebar.svelte";
const editor = getContext<CatalogEditorStore>('catalog-editor')
const glob = getContext<EditorGlob>('editor-glob')
@ -76,12 +76,12 @@ function focusOnMount(node: HTMLInputElement) {
<!-- Botón añadir categoría -->
{#snippet extra()}
<div class="py-2 px-4 shrink-0 text-center flex">
<EditorButton
<ButtonSidebar
onclick={() => editor.addOptCategory(glob.t('vcen.editor.opts.newCategoryName'))}
class="w-full"
>
{glob.t('vcen.editor.opts.addCategory')}
</EditorButton>
</ButtonSidebar>
</div>
{/snippet}
@ -90,9 +90,9 @@ function focusOnMount(node: HTMLInputElement) {
{#if opts.length === 0 && !hasCats}
<div class="px-4 py-8 text-center text-gray-600 text-xs">
<p>{glob.t('vcen.editor.opts.empty')}</p>
<EditorButton class="w-full" onclick={() => editor.addOpt()}>
<ButtonSidebar class="w-full" onclick={() => editor.addOpt()}>
{glob.t('vcen.editor.opts.createOpt')}
</EditorButton>
</ButtonSidebar>
</div>
@ -192,7 +192,7 @@ function focusOnMount(node: HTMLInputElement) {
<!-- Contador -->
<CountBadge value={group.opts.length} />
<BadgeCount value={group.opts.length} />
</div>
<!-- Opciones del grupo (visibles si abierto) -->
@ -227,7 +227,7 @@ function focusOnMount(node: HTMLInputElement) {
<!-- Detalle -->
<div class="flex-1 min-w-0 overflow-hidden">
{#if editor.activeOpt}
<OptDetail />
<PanelOptionDetail />
{:else}
<div class="flex flex-col items-center justify-center h-full gap-3 text-gray-600">
<p class="text-sm">{glob.t('vcen.editor.opts.selectOrCreate')}</p>

@ -6,16 +6,16 @@ import { labelOf } from '@/vcen-edit/lib/label.ts'
import { isValidId } from '@/vcen-edit/lib/validate'
import type { LingRecord } from '@/ling/lib'
import LingInput from '@/ling/svelte/LingInput.svelte'
import EntityIdName from './EntityIdName.svelte'
import InputIdName from './InputIdName.svelte'
import Panel from './Panel.svelte'
import RuleList from './RuleList.svelte'
import IssueSection from './IssueSection.svelte'
import SecAttsPanel from './SecAttsPanel.svelte'
import MetaPanel from './MetaPanel.svelte'
import VisualPanel from './VisualPanel.svelte'
import DeleteButton from './DeleteButton.svelte'
import PricingSection from './PricingSection.svelte'
import CountBadge from './CountBadge.svelte'
import SectionIssue from './SectionIssue.svelte'
import PanelSectionAtts from './PanelSectionAtts.svelte'
import PanelMeta from './PanelMeta.svelte'
import PanelVisual from './PanelVisual.svelte'
import ButtonDelete from './ButtonDelete.svelte'
import SectionPricing from './SectionPricing.svelte'
import BadgeCount from './BadgeCount.svelte'
import Tabs from './Tabs.svelte'
import { createEntityTabs, entityTabsArray } from '@/vcen-edit/lib/entity-tabs.svelte.ts'
@ -86,11 +86,11 @@ function cancelNew() {
+ {glob.t('vcen.editor.sidebar.newView')}
</button>
<button onclick={() => editor.cloneSec(objId, secId)} class="ve-action-btn">{glob.t('vcen.editor.common.clone')}</button>
<DeleteButton size="md" confirm
confirmTitle={glob.t('vcen.editor.sec.deleteTitle')}
confirmBody={labelOf(sec.label, cat.lang) || sec.id}
confirmWarning={glob.t('vcen.editor.sec.deleteWarn')}
onconfirm={() => editor.removeSec(objId, secId)}
<ButtonDelete size="md" confirm
confirmTitle={glob.t('vcen.editor.sec.deleteTitle')}
confirmBody={labelOf(sec.label, cat.lang) || sec.id}
confirmWarning={glob.t('vcen.editor.sec.deleteWarn')}
onconfirm={() => editor.removeSec(objId, secId)}
/>
{/if}
{/snippet}
@ -120,7 +120,7 @@ function cancelNew() {
<!-- Identificación -->
<section class="flex flex-col gap-3">
<h3 class="section-title">{glob.t('vcen.editor.entity.id')}</h3>
<EntityIdName
<InputIdName
prefix="se"
{isNew}
fullId={sec.id}
@ -159,7 +159,7 @@ function cancelNew() {
</section>
<!-- Precio base -->
<PricingSection
<SectionPricing
pricing={sec.pricing}
{currencies}
defaultCurrency={cat.pricingDefaults.currency ?? 'EUR'}
@ -169,11 +169,11 @@ function cancelNew() {
</div>
{:else if tab === 'atts'}
<SecAttsPanel />
<PanelSectionAtts />
{:else if tab === 'visual'}
<div class="flex-1 min-h-0 overflow-hidden">
<VisualPanel
<PanelVisual
visual={sec.visual}
catDefaults={cat.visualDefaults ?? {}}
locales={cat.langs}
@ -190,7 +190,7 @@ function cancelNew() {
<section class="flex flex-col gap-3">
<h3 class="section-title">
{glob.t('vcen.editor.entity.rules')}
<CountBadge value={sec.rules?.rules?.length ?? 0} />
<BadgeCount value={sec.rules?.rules?.length ?? 0} />
</h3>
{#if sec.rules}
<RuleList ruleSet={sec.rules} container={sec} catOpts={editor.cat.opts} />
@ -215,7 +215,7 @@ function cancelNew() {
{glob.t('vcen.editor.entity.issues')}
<span class="text-gray-700 font-normal normal-case tracking-normal">({sec.issues?.length ?? 0})</span>
</h3>
<IssueSection
<SectionIssue
issues={sec.issues ?? []}
locales={cat.langs}
defaultLocale={cat.lang}
@ -228,7 +228,7 @@ function cancelNew() {
{:else if tab === 'meta'}
<div class="flex-1 min-h-0 overflow-hidden">
<MetaPanel
<PanelMeta
value={sec.metadata}
onUpdate={m => editor.updateSecMeta(objId, secId, m)}
/>

@ -8,13 +8,13 @@ import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelt
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.js'
import { createConditionBuilderStore } from '@/vcen-edit/stores/condition-builder.svelte.js'
import LingInput from '@/ling/svelte/LingInput.svelte'
import EntityIdName from './EntityIdName.svelte'
import InputIdName from './InputIdName.svelte'
import Panel from './Panel.svelte'
import ConditionBuilder from './ConditionBuilder.svelte'
import TemplateBuilder from './TemplateBuilder.svelte'
import VisualPreview from './VisualPreview.svelte'
import IconPicker from './IconPicker.svelte'
import DeleteButton from './DeleteButton.svelte'
import ButtonDelete from './ButtonDelete.svelte'
import TransactionEditor from './TransactionEditor.svelte'
import type { LingRecord, LingString } from '@/ling/lib'
import { buildScope } from '@/vcen-edit/lib/condition-editor.ts'
@ -264,11 +264,11 @@ function getLabelStr(label: any): string {
<button onclick={() => { if (ctx) editor.requestAction(() => editor.addHotspot(ctx.objId, ctx.secId, ctx.viewId)) }} class="ve-action-btn">
+ {glob.t('vcen.editor.sidebar.newHotspot')}
</button>
<DeleteButton size="md" confirm
confirmTitle={glob.t('vcen.editor.view.deleteTitle')}
confirmBody={ctx ? (getLabelStr(view.label) || view.id) : undefined}
confirmWarning={glob.t('vcen.editor.view.deleteWarn')}
onconfirm={() => { if (ctx) editor.removeView(ctx.objId, ctx.secId, ctx.viewId) }}
<ButtonDelete size="md" confirm
confirmTitle={glob.t('vcen.editor.view.deleteTitle')}
confirmBody={ctx ? (getLabelStr(view.label) || view.id) : undefined}
confirmWarning={glob.t('vcen.editor.view.deleteWarn')}
onconfirm={() => { if (ctx) editor.removeView(ctx.objId, ctx.secId, ctx.viewId) }}
/>
{/if}
{/snippet}
@ -277,7 +277,7 @@ function getLabelStr(label: any): string {
<div class="flex-1 min-w-0 overflow-y-auto ve-panel-content border-r border-gray-800">
<!-- ID + Nombre -->
<EntityIdName
<InputIdName
prefix="vw"
{isNew}
fullId={view.id}
@ -409,7 +409,7 @@ function getLabelStr(label: any): string {
<div class="border border-gray-800 rounded-lg p-3 flex flex-col gap-2 bg-gray-900/30">
<div class="flex items-center justify-between">
<span class="text-[10px] font-bold uppercase tracking-widest text-gray-600">{glob.t('vcen.editor.view.paramN', { n: pi + 1 })}</span>
<DeleteButton size="xs" onconfirm={() => removeApiParam(pi)} />
<ButtonDelete size="xs" onconfirm={() => removeApiParam(pi)} />
</div>
<div class="flex flex-col gap-1">
<label class="ve-label">{glob.t('vcen.editor.view.paramKey')}</label>
@ -464,7 +464,7 @@ function getLabelStr(label: any): string {
class="flex items-center justify-center w-5 h-5 rounded text-xs text-gray-600 hover:text-gray-300 hover:bg-gray-700 transition-colors disabled:opacity-20 disabled:pointer-events-none">↑</button>
<button onclick={() => moveLayer(li, 'down')} disabled={li === cfgLayers().length - 1}
class="flex items-center justify-center w-5 h-5 rounded text-xs text-gray-600 hover:text-gray-300 hover:bg-gray-700 transition-colors disabled:opacity-20 disabled:pointer-events-none">↓</button>
<DeleteButton size="xs" onconfirm={() => removeLayer(li)} />
<ButtonDelete size="xs" onconfirm={() => removeLayer(li)} />
</div>
</div>
<!-- Body: sólo cuando está abierto -->

@ -8,10 +8,10 @@ import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.s
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.js'
import { createConditionBuilderStore } from '@/vcen-edit/stores/condition-builder.svelte.js'
import LingInput from '@/ling/svelte/LingInput.svelte'
import EntityIdName from './EntityIdName.svelte'
import InputIdName from './InputIdName.svelte'
import ConditionBuilder from './ConditionBuilder.svelte'
import TemplateBuilder from './TemplateBuilder.svelte'
import DeleteButton from './DeleteButton.svelte'
import ButtonDelete from './ButtonDelete.svelte'
import { buildScope } from '@/vcen-edit/lib/condition-editor.ts'
import { isValidId } from '@/vcen-edit/lib/validate'
@ -736,18 +736,18 @@ $effect(() => {
<button onclick={cancelViewNew} class="ve-action-btn">{glob.t('vcen.editor.common.cancel')}</button>
<button onclick={saveViewNew} class="ve-action-btn primary">{glob.t('vcen.editor.common.save')}</button>
{:else}
<DeleteButton size="sm" confirm
confirmTitle={glob.t('vcen.editor.view.deleteTitle')}
confirmBody={getLabelStr(view.label) || view.code || view.id}
confirmWarning={glob.t('vcen.editor.view.deleteWarn')}
onconfirm={() => removeView(view.id)}
<ButtonDelete size="sm" confirm
confirmTitle={glob.t('vcen.editor.view.deleteTitle')}
confirmBody={getLabelStr(view.label) || view.code || view.id}
confirmWarning={glob.t('vcen.editor.view.deleteWarn')}
onconfirm={() => removeView(view.id)}
/>
{/if}
</div>
</div>
<!-- ID + Nombre -->
<EntityIdName
<InputIdName
prefix="vw"
{isNew}
fullId={view.id}
@ -925,9 +925,9 @@ $effect(() => {
<span class="text-[10px] font-bold uppercase tracking-widest text-gray-600">
{glob.t('vcen.editor.view.paramN', { n: pi + 1 })}
</span>
<DeleteButton size="xs" confirm
confirmTitle={glob.t('vcen.editor.view.deleteParam')}
onconfirm={() => removeApiParam(view.id, pi)}
<ButtonDelete size="xs" confirm
confirmTitle={glob.t('vcen.editor.view.deleteParam')}
onconfirm={() => removeApiParam(view.id, pi)}
/>
</div>
<div class="flex flex-col gap-1">
@ -992,10 +992,10 @@ $effect(() => {
class="flex items-center justify-center w-5 h-5 rounded text-xs text-gray-600
hover:text-gray-300 hover:bg-gray-700 transition-colors
disabled:opacity-20 disabled:pointer-events-none">↓</button>
<DeleteButton size="xs" confirm
confirmTitle={glob.t('vcen.editor.view.deleteLayer')}
confirmBody={glob.t('vcen.editor.view.layerN', { n: li + 1 })}
onconfirm={() => removeLayer(view.id, li)}
<ButtonDelete size="xs" confirm
confirmTitle={glob.t('vcen.editor.view.deleteLayer')}
confirmBody={glob.t('vcen.editor.view.layerN', { n: li + 1 })}
onconfirm={() => removeLayer(view.id, li)}
/>
</div>
</div>
@ -1098,17 +1098,17 @@ $effect(() => {
<button onclick={cancelHotspotNew} class="ve-action-btn">{glob.t('vcen.editor.common.cancel')}</button>
<button onclick={saveHotspotNew} class="ve-action-btn primary">{glob.t('vcen.editor.common.save')}</button>
{:else}
<DeleteButton size="sm" confirm
confirmTitle={glob.t('vcen.editor.hotspot.deleteTitle')}
confirmBody={getLabelStr(hs.label) || hs.id}
onconfirm={() => removeHotspot(view.id, hs.id)}
<ButtonDelete size="sm" confirm
confirmTitle={glob.t('vcen.editor.hotspot.deleteTitle')}
confirmBody={getLabelStr(hs.label) || hs.id}
onconfirm={() => removeHotspot(view.id, hs.id)}
/>
{/if}
</div>
</div>
<!-- ID + Nombre -->
<EntityIdName
<InputIdName
prefix="hs"
{isNew}
fullId={hs.id}

@ -7,8 +7,8 @@ import type { RuleSet, Rule, Obj, Sec, AttState } from '@/vcen/lib'
import { createConditionBuilderStore } from '@/vcen-edit/stores/condition-builder.svelte.ts'
import { buildEvalCtx, evalCondition } from '@/vcen-edit/lib/rule-eval.ts'
import ConditionBuilder from './ConditionBuilder.svelte'
import DeleteButton from './DeleteButton.svelte'
import DeleteModal from './DeleteModal.svelte'
import ButtonDelete from './ButtonDelete.svelte'
import ModalDelete from './ModalDelete.svelte'
const editor = getContext<CatalogEditorStore>('catalog-editor')
const glob = getContext<EditorGlob>('editor-glob')
@ -161,10 +161,10 @@ const actionSummary = $derived.by(() => {
<button onclick={() => open = !open} class="ve-card-btn">
{open ? glob.t('vcen.editor.rule.closeBtn') : glob.t('vcen.editor.rule.editBtn')}
</button>
<DeleteButton size="xs" confirm
confirmTitle={glob.t('vcen.editor.rule.deleteTitle')}
confirmBody="{(rule as any).label || glob.t('vcen.editor.rule.noDesc')} · P{rule.priority ?? 0}"
onconfirm={() => editor.removeRule(ruleSet, rule.id)}
<ButtonDelete size="xs" confirm
confirmTitle={glob.t('vcen.editor.rule.deleteTitle')}
confirmBody="{(rule as any).label || glob.t('vcen.editor.rule.noDesc')} · P{rule.priority ?? 0}"
onconfirm={() => editor.removeRule(ruleSet, rule.id)}
/>
</div>
</div>

@ -10,8 +10,8 @@ import { EDITOR_ICON_MAP } from '@/vcen-edit/lib/editor-icons.ts'
import LingInput from '@/ling/svelte/LingInput.svelte'
import ConditionBuilder from './ConditionBuilder.svelte'
import IconPicker from './IconPicker.svelte'
import DeleteButton from './DeleteButton.svelte'
import DeleteModal from './DeleteModal.svelte'
import ButtonDelete from './ButtonDelete.svelte'
import ModalDelete from './ModalDelete.svelte'
const editor = getContext<CatalogEditorStore>('catalog-editor')
const glob = getContext<EditorGlob>('editor-glob')
@ -203,10 +203,10 @@ function moveDown(idx: number) {
onclick={() => isEditing ? cancelEdit() : openEdit(idx)}
class="ve-card-btn"
>{isEditing ? glob.t('vcen.editor.issue.closeBtn') : glob.t('vcen.editor.issue.editBtn')}</button>
<DeleteButton size="xs" confirm
confirmTitle={glob.t('vcen.editor.issue.deleteTitle')}
confirmBody="{lvl.label} · {displayText(issue)}"
onconfirm={() => remove(idx)}
<ButtonDelete size="xs" confirm
confirmTitle={glob.t('vcen.editor.issue.deleteTitle')}
confirmBody="{lvl.label} · {displayText(issue)}"
onconfirm={() => remove(idx)}
/>
</div>
</div>

@ -0,0 +1,65 @@
<script lang="ts">
import type { Pricing } from '@/vcen/lib'
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.js'
import { getContext } from 'svelte'
const glob = getContext<EditorGlob>('editor-glob')
let {
pricing,
currencies,
defaultCurrency,
onUpdate,
}: {
pricing?: Pricing
currencies: { code: string; symbol: string }[]
defaultCurrency: string
onUpdate: (p: Pricing | undefined) => void
} = $props()
</script>
<section class="flex flex-col gap-3">
<div class="flex items-center justify-between section-title" style="margin-bottom:0">
<span>{glob.t('vcen.editor.entity.pricing')}</span>
{#if pricing}
<button onclick={() => onUpdate(undefined)} class="ve-btn-link danger">{glob.t('vcen.editor.entity.pricingRm')}</button>
{:else}
<button onclick={() => onUpdate({ baseAmount: 0 })} class="ve-btn-link accent">{glob.t('vcen.editor.entity.pricingAdd')}</button>
{/if}
</div>
{#if pricing}
{@const p = pricing}
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1">
<label class="ve-label">{glob.t('vcen.editor.entity.baseAmount')}</label>
<div class="flex gap-1.5">
{#if p.baseAmount === 'consultation'}
<span class="field-input text-xs text-gray-500 italic flex-1">{glob.t('vcen.editor.entity.consultation')}</span>
{:else}
<input type="number" value={p.baseAmount} min="0" step="0.01"
oninput={e => onUpdate({ ...p, baseAmount: parseFloat(e.currentTarget.value) || 0 })}
class="field-input text-xs flex-1" />
{/if}
<button
onclick={() => onUpdate({ ...p, baseAmount: p.baseAmount === 'consultation' ? 0 : 'consultation' })}
class="ve-card-btn shrink-0"
title={p.baseAmount === 'consultation' ? glob.t('vcen.editor.entity.toNumTitle') : glob.t('vcen.editor.entity.toConsultTitle')}
>{p.baseAmount === 'consultation' ? glob.t('vcen.editor.entity.toNum') : glob.t('vcen.editor.entity.toConsult')}</button>
</div>
</div>
<div class="flex flex-col gap-1">
<label class="ve-label">{glob.t('vcen.editor.entity.currency')} <span class="text-gray-700">{glob.t('vcen.editor.entity.currencyOverride')}</span></label>
<select value={p.currency ?? ''}
onchange={e => onUpdate({ ...p, currency: e.currentTarget.value || undefined })}
class="field-input font-mono text-xs">
<option value="">{glob.t('vcen.editor.entity.currencyInherit', { code: defaultCurrency })}</option>
{#each currencies as c (c.code)}
<option value={c.code}>{c.code} {c.symbol}</option>
{/each}
</select>
</div>
</div>
{:else}
<p class="text-xs text-gray-700 italic">{glob.t('vcen.editor.entity.noBasePricing')}</p>
{/if}
</section>

@ -1,6 +1,6 @@
<script lang="ts">
import type { Snippet } from 'svelte'
import CountBadge from './CountBadge.svelte'
import BadgeCount from './BadgeCount.svelte'
let {
title,
@ -25,7 +25,7 @@ let {
<div class="ve-sidebar-header">
<span class="flex items-center gap-1.5 min-w-0">
<span class="ve-sidebar-title">{title}</span>
<CountBadge value={count} />
<BadgeCount value={count} />
</span>
{#if actions}
{@render actions()}

@ -0,0 +1,190 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.js'
import type { Transaction, TransactionEasing } from '@/vcen/lib'
import { KNOWN_TRANSACTIONS } from '@/vcen-edit/lib/transactions.ts'
const glob = getContext<EditorGlob>('editor-glob')
let {
value,
onUpdate,
}: {
value?: Transaction
onUpdate: (t: Transaction | undefined) => void
} = $props()
const EASINGS: TransactionEasing[] = ['linear', 'ease', 'ease-in', 'ease-out', 'ease-in-out']
const selectedDef = $derived(KNOWN_TRANSACTIONS.find(d => d.name === value?.name) ?? null)
const hasDirection = $derived(selectedDef?.hasDirection ?? false)
let open = $state(false)
function add() {
onUpdate({ name: '', duration: 400, easing: 'ease' })
open = true
}
function remove() {
onUpdate(undefined)
open = false
}
function patch(p: Partial<Transaction>) {
if (!value) return
onUpdate({ ...value, ...p })
}
</script>
<!-- ── Cabecera colapsable ─────────────────────────────────────────────────── -->
<div class="border border-gray-800 rounded overflow-hidden">
<div
role="button" tabindex="0"
onclick={() => { if (value) open = !open }}
onkeydown={e => { if (e.key === 'Enter' && value) open = !open }}
class="flex items-center gap-2 px-3 py-2 select-none {value ? 'cursor-pointer hover:bg-gray-800/40' : ''} transition-colors"
>
{#if value}
<svg class="h-2.5 w-2.5 shrink-0 transition-transform text-gray-600 {open ? 'rotate-90' : ''}"
viewBox="0 0 8 8" fill="currentColor"><polygon points="2,1 7,4 2,7"/></svg>
{/if}
<span class="text-[10px] font-bold uppercase tracking-widest text-gray-600 shrink-0">
{glob.t('vcen.editor.transaction.section')}
</span>
{#if value}
<span class="text-xs text-gray-500 font-mono truncate">{value.name || '—'}</span>
<span class="text-xs text-gray-700 shrink-0">{value.duration}ms</span>
<button
onclick={e => { e.stopPropagation(); remove() }}
class="ve-card-btn danger ml-auto px-1 py-0.5 shrink-0"
title={glob.t('vcen.editor.transaction.removeBtn')}
>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round" class="h-3 w-3">
<path d="M2 4h12M5 4V2.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 .5.5V4M6 7v5M10 7v5M3 4l.8 9a.5.5 0 0 0 .5.45h7.4a.5.5 0 0 0 .5-.45L13 4"/>
</svg>
</button>
{:else}
<span class="text-xs text-gray-700 italic">{glob.t('vcen.editor.transaction.noTransaction')}</span>
<button
onclick={e => { e.stopPropagation(); add() }}
class="ve-card-btn ml-auto shrink-0"
>{glob.t('vcen.editor.transaction.addBtn')}</button>
{/if}
</div>
<!-- ── Formulario ──────────────────────────────────────────────────────── -->
{#if value && open}
<div class="flex flex-col gap-3 px-3 pb-3 pt-2 border-t border-gray-800/60">
<div class="grid grid-cols-2 gap-3">
<!-- Nombre / tipo -->
<div class="flex flex-col gap-1">
<label class="ve-label">{glob.t('vcen.editor.transaction.name')}</label>
<select
value={value.name}
onchange={e => patch({ name: e.currentTarget.value, direction: undefined })}
class="field-input text-xs"
>
<option value="">—</option>
{#each KNOWN_TRANSACTIONS as def (def.name)}
<option value={def.name} title={def.description}>{def.label}</option>
{/each}
</select>
{#if selectedDef?.description}
<span class="text-[10px] text-gray-700 italic">{selectedDef.description}</span>
{/if}
</div>
<!-- Duración -->
<div class="flex flex-col gap-1">
<label class="ve-label">{glob.t('vcen.editor.transaction.duration')}</label>
<input
type="number" min="0" step="50"
value={value.duration}
oninput={e => patch({ duration: Number(e.currentTarget.value) || 0 })}
placeholder="400"
class="field-input text-xs"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<!-- Easing -->
<div class="flex flex-col gap-1">
<label class="ve-label">{glob.t('vcen.editor.transaction.easing')}</label>
<select
value={value.easing ?? ''}
onchange={e => patch({ easing: (e.currentTarget.value || undefined) as TransactionEasing | undefined })}
class="field-input text-xs"
>
<option value="">—</option>
{#each EASINGS as eg (eg)}
<option value={eg}>{glob.t(`vcen.editor.transaction.easings.${eg}`)}</option>
{/each}
</select>
</div>
<!-- Dirección — solo si la transición la soporta -->
{#if hasDirection}
<div class="flex flex-col gap-1">
<label class="ve-label">{glob.t('vcen.editor.transaction.direction')}</label>
<select
value={value.direction ?? ''}
onchange={e => patch({ direction: e.currentTarget.value || undefined })}
class="field-input text-xs"
>
<option value="">—</option>
<option value="left">Left</option>
<option value="right">Right</option>
<option value="up">Up</option>
<option value="down">Down</option>
</select>
</div>
{/if}
</div>
<div class="grid grid-cols-2 gap-3">
<!-- Delay entrada -->
<div class="flex flex-col gap-1">
<label class="ve-label">{glob.t('vcen.editor.transaction.delayIn')}</label>
<input
type="number" min="0" step="50"
value={value.delayIn ?? ''}
oninput={e => patch({ delayIn: Number(e.currentTarget.value) || undefined })}
placeholder="0"
class="field-input text-xs"
/>
</div>
<!-- Delay salida -->
<div class="flex flex-col gap-1">
<label class="ve-label">{glob.t('vcen.editor.transaction.delayOut')}</label>
<input
type="number" min="0" step="50"
value={value.delayOut ?? ''}
oninput={e => patch({ delayOut: Number(e.currentTarget.value) || undefined })}
placeholder="0"
class="field-input text-xs"
disabled={value.persistent === true}
/>
</div>
</div>
<!-- Persistente -->
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={value.persistent ?? false}
onchange={e => patch({ persistent: e.currentTarget.checked || undefined })}
/>
<span class="text-xs text-gray-400">{glob.t('vcen.editor.transaction.persistent')}</span>
<span class="text-xs text-gray-600 italic">{glob.t('vcen.editor.transaction.persistentHint')}</span>
</label>
</div>
{/if}
</div>

@ -0,0 +1,351 @@
<script lang="ts">
import { untrack, getContext } from 'svelte'
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.js'
import type { View, HotspotID, LayerDefinition,
ViewResolution, ResolvedLayersView } from '@/vcen/lib'
const glob = getContext<EditorGlob>('editor-glob')
const STRATEGY_LABELS = $derived<Record<string, string>>({
static: glob.t('vcen.editor.view.strategies.static'),
dynamic: glob.t('vcen.editor.view.strategies.dynamic'),
layers: glob.t('vcen.editor.view.strategies.layers'),
api: glob.t('vcen.editor.view.strategies.api'),
d3: glob.t('vcen.editor.view.strategies.d3'),
})
let {
view,
resolution = null,
activeHotspotId = null,
pickingPos = false,
onPick,
getLabelStr,
}: {
view: View
resolution?: ViewResolution | null
activeHotspotId?: HotspotID | null
pickingPos?: boolean
onPick?: (x: number, y: number) => void
getLabelStr: (label: any) => string
} = $props()
const strategy = $derived(view.config.strategy)
// URLs resueltas
const resolvedUrl = $derived.by((): string => {
const r = resolution?.resolved
if (r?.strategy === 'dynamic') return (r as any).url
return ''
})
const resolvedLayers = $derived.by((): Array<{ url: string; zIndex: number }> => {
const r = resolution?.resolved
if (r?.strategy !== 'layers') return []
return (r as ResolvedLayersView).layers
})
const apiEp = $derived.by((): string => {
const r = resolution?.resolved
if (r?.strategy === 'api') return (r as any).endpoint
return ''
})
// Fallbacks sin resolución
function cfgGet(key: string): string {
return ((view.config as any)[key] as string) ?? ''
}
const staticUrl = $derived(strategy === 'static' ? cfgGet('url') : '')
const dynTpl = $derived(strategy === 'dynamic' ? cfgGet('template') : '')
const d3Model = $derived(strategy === 'd3' ? cfgGet('modelUrl') : '')
const layers = $derived(strategy === 'layers'
? (((view.config as any).layers ?? []) as LayerDefinition[])
: [] as LayerDefinition[])
let imgError = $state(false)
let imgLoaded = $state(false)
$effect(() => {
void staticUrl; void resolvedUrl; void apiEp
imgError = false; imgLoaded = false
})
function handleClick(e: MouseEvent) {
if (!pickingPos || !onPick) return
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const x = Math.round(Math.min(100, Math.max(0, ((e.clientX - rect.left) / rect.width) * 100)) * 10) / 10
const y = Math.round(Math.min(100, Math.max(0, ((e.clientY - rect.top) / rect.height) * 100)) * 10) / 10
onPick(x, y)
}
// ── Panel de capas ────────────────────────────────────────────────────────────
// Capas ocultas por el usuario (índice en resolvedLayers, ordenado por zIndex desc)
let hiddenLayers = $state<Set<number>>(new Set())
// Cuando cambian las capas resueltas, limpiar ocultas que ya no existen
$effect(() => {
const n = resolvedLayers.length
const current = untrack(() => hiddenLayers)
const filtered = new Set([...current].filter(i => i < n))
if (filtered.size !== current.size) hiddenLayers = filtered
})
// Ordenar capas de mayor a menor zIndex para el panel (igual que Photoshop)
const sortedLayerIndices = $derived(
resolvedLayers
.map((l, i) => ({ i, zIndex: l.zIndex }))
.sort((a, b) => b.zIndex - a.zIndex)
.map(x => x.i)
)
function toggleLayer(i: number) {
const next = new Set(hiddenLayers)
if (next.has(i)) next.delete(i)
else next.add(i)
hiddenLayers = next
}
// Panel de capas — mostrar si hay capas resueltas O capas sin resolver
const showLayersPanel = $derived(strategy === 'layers' && (resolvedLayers.length > 0 || layers.length > 0))
let layersPanelOpen = $state(true)
</script>
<div class="flex-1 border-l border-gray-800 flex flex-col overflow-y-auto min-w-0">
<div class="px-3 py-2 border-b border-gray-800 flex items-center justify-between shrink-0">
<span class="text-[10px] font-bold uppercase tracking-widest text-gray-700">preview</span>
<span class="text-[10px] text-gray-700 font-mono">{STRATEGY_LABELS[strategy] ?? strategy}</span>
</div>
<div class="p-3 flex flex-col gap-3">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="relative w-full rounded border border-gray-800 bg-gray-900 overflow-hidden
{pickingPos ? 'ring-2 ring-indigo-500/60' : ''}"
style="aspect-ratio: {view.width || 4} / {view.height || 3};
cursor: {pickingPos ? 'crosshair' : 'default'}"
onclick={handleClick}
>
{#if strategy === 'static' && staticUrl}
{#if !imgError}
<img src={staticUrl} alt={getLabelStr(view.label) || view.code}
class="absolute inset-0 w-full h-full object-contain object-top pointer-events-none"
onload={() => imgLoaded = true} onerror={() => imgError = true} />
{:else}
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-600 pointer-events-none px-4">
<svg class="h-8 w-8 text-red-800" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="3" x2="21" y2="21"/>
</svg>
<span class="text-[10px] text-red-400">{glob.t('vcen.editor.view.imgLoadError')}</span>
<span class="text-[10px] font-mono text-gray-700 break-all text-center">{staticUrl}</span>
</div>
{/if}
{:else if strategy === 'dynamic' && resolvedUrl}
{#if !imgError}
<img src={resolvedUrl} alt={getLabelStr(view.label) || view.code}
class="absolute inset-0 w-full h-full object-contain object-top pointer-events-none"
onload={() => imgLoaded = true} onerror={() => imgError = true} />
{:else}
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-600 pointer-events-none px-4">
<svg class="h-8 w-8 text-red-800" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="3" x2="21" y2="21"/>
</svg>
<span class="text-[10px] text-red-400">{glob.t('vcen.editor.view.imgLoadError')}</span>
<span class="text-[10px] font-mono text-gray-700 break-all text-center">{resolvedUrl}</span>
</div>
{/if}
{#if resolution?.hasFallback}
<div class="absolute top-1 right-1 px-1.5 py-0.5 rounded bg-amber-900/80 text-amber-300 text-[9px] font-medium">fallback</div>
{/if}
{:else if strategy === 'layers' && resolvedLayers.length > 0}
{#each resolvedLayers as layer, li (li)}
{#if !hiddenLayers.has(li)}
<img src={layer.url} alt={glob.t('vcen.editor.view.layerN', { n: li + 1 })}
class="absolute inset-0 w-full h-full object-contain object-top pointer-events-none"
style="z-index: {layer.zIndex}" />
{/if}
{/each}
{#if resolution?.hasFallback}
<div class="absolute top-1 right-1 px-1.5 py-0.5 rounded bg-amber-900/80 text-amber-300 text-[9px] font-medium z-50">fallback</div>
{/if}
{:else if strategy === 'layers' && layers.length === 0}
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-700 pointer-events-none">
<svg class="h-8 w-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<path d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
<span class="text-[10px] italic">{glob.t('vcen.editor.view.noLayers')}</span>
</div>
{:else if strategy === 'dynamic'}
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-700 pointer-events-none px-4">
<svg class="h-8 w-8 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/>
</svg>
{#if dynTpl}
<span class="text-[10px] font-mono text-center break-all text-gray-600">{dynTpl}</span>
{:else}
<span class="text-[10px] italic">{glob.t('vcen.editor.view.noTemplate')}</span>
{/if}
</div>
{:else if strategy === 'api'}
{#if apiEp && !imgError}
<img src={apiEp} alt={getLabelStr(view.label) || view.code}
class="absolute inset-0 w-full h-full object-contain object-top pointer-events-none"
onload={() => imgLoaded = true} onerror={() => imgError = true} />
{:else if apiEp && imgError}
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-600 pointer-events-none px-4">
<svg class="h-8 w-8 text-red-800" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="3" x2="21" y2="21"/>
</svg>
<span class="text-[10px] text-red-400">{glob.t('vcen.editor.view.imgLoadError')}</span>
<span class="text-[10px] font-mono text-gray-700 break-all text-center">{apiEp}</span>
</div>
{:else}
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-700 pointer-events-none px-4">
<svg class="h-8 w-8 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<path d="M21 12h-4m-2-7 2 7-2 7M3 12h4m2-7-2 7 2 7"/>
</svg>
<span class="text-[10px] italic">{glob.t('vcen.editor.view.noEndpoint')}</span>
</div>
{/if}
{:else if strategy === 'd3'}
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-700 pointer-events-none px-4">
<svg class="h-8 w-8 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<path d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
{#if d3Model}
<span class="text-[10px] font-mono text-center break-all text-gray-600">{d3Model}</span>
{:else}
<span class="text-[10px] italic">{glob.t('vcen.editor.view.noModel')}</span>
{/if}
</div>
{:else}
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-700 pointer-events-none">
<svg class="h-10 w-10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/>
</svg>
<span class="text-[10px]">{pickingPos ? glob.t('vcen.editor.hotspot.clickToPosition') : glob.t('vcen.editor.view.noImage')}</span>
</div>
{/if}
<!-- Hotspot markers -->
{#each (view.hotspots ?? []) as marker (marker.id)}
{@const isActive = activeHotspotId === marker.id}
{@const mX = typeof marker.posX === 'number' ? marker.posX : parseFloat(String(marker.posX)) || 0}
{@const mY = typeof marker.posY === 'number' ? marker.posY : parseFloat(String(marker.posY)) || 0}
{@const size = (marker.radius ?? 8) * 2}
<div
class="absolute rounded-full border-2 transition-all pointer-events-none
{isActive ? 'border-white shadow-lg' : 'border-white/50'}"
style="left:{mX}%;top:{mY}%;width:{size}px;height:{size}px;
transform:translate(-50%,-50%){isActive ? ' scale(1.25)' : ''};
background-color:{marker.color ?? '#4f46e5'};
opacity:{isActive ? 1 : 0.7};z-index:{(marker.zIndex ?? 1) + 10};"
title={getLabelStr(marker.label) || marker.id}
></div>
{/each}
<!-- Panel flotante de capas -->
{#if showLayersPanel}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="absolute bottom-2 right-2 z-50 rounded border border-gray-700 bg-gray-950/90
backdrop-blur-sm shadow-xl min-w-[140px] max-w-[200px]"
onclick={e => e.stopPropagation()}
>
<!-- Cabecera del panel -->
<button
onclick={() => layersPanelOpen = !layersPanelOpen}
class="flex items-center gap-1.5 w-full px-2 py-1.5 hover:bg-gray-800/50
transition-colors rounded-t border-b border-gray-800/60"
>
<svg class="h-3 w-3 text-gray-500 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
<span class="text-[9px] font-bold uppercase tracking-widest text-gray-500 flex-1 text-left">{glob.t('vcen.editor.view.layers')}</span>
{#if resolvedLayers.length > 0 && hiddenLayers.size > 0}
<span class="text-[9px] text-amber-600">{hiddenLayers.size} oculta{hiddenLayers.size !== 1 ? 's' : ''}</span>
{/if}
<svg class="h-2.5 w-2.5 text-gray-600 shrink-0 transition-transform {layersPanelOpen ? '' : 'rotate-180'}"
viewBox="0 0 8 8" fill="currentColor"><polygon points="1,6 7,6 4,2"/></svg>
</button>
{#if layersPanelOpen}
<div class="flex flex-col py-0.5">
{#if resolvedLayers.length > 0}
<!-- Capas resueltas: toggle de visibilidad -->
{#each sortedLayerIndices as li (li)}
{@const layer = resolvedLayers[li]}
{@const hidden = hiddenLayers.has(li)}
<button
onclick={() => toggleLayer(li)}
class="flex items-center gap-1.5 px-2 py-1 hover:bg-gray-800/60
transition-colors w-full text-left group"
>
<!-- Eye icon -->
<svg class="h-3 w-3 shrink-0 transition-colors
{hidden ? 'text-gray-700' : 'text-gray-400 group-hover:text-gray-200'}"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
{#if hidden}
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/>
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/>
<line x1="1" y1="1" x2="23" y2="23"/>
{:else}
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
{/if}
</svg>
<!-- z-index badge -->
<span class="text-[9px] font-mono shrink-0
{hidden ? 'text-gray-700' : 'text-indigo-500'}">z{layer.zIndex}</span>
<!-- URL truncada -->
<span class="text-[9px] font-mono truncate flex-1
{hidden ? 'text-gray-700 line-through' : 'text-gray-500'}">
{layer.url.split('/').pop() ?? layer.url}
</span>
</button>
{/each}
{:else}
<!-- Capas sin resolver: info estática -->
{#each [...layers].sort((a, b) => (b.zIndex ?? 0) - (a.zIndex ?? 0)) as layer, li (li)}
<div class="flex items-center gap-1.5 px-2 py-1">
<svg class="h-3 w-3 text-gray-700 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
<span class="text-[9px] font-mono text-indigo-500/50 shrink-0">z{layer.zIndex ?? 0}</span>
<span class="text-[9px] font-mono text-gray-700 truncate flex-1">{layer.attId || '—'}</span>
{#if layer.condition}
<span class="text-[9px] text-amber-700 shrink-0">if</span>
{/if}
</div>
{/each}
{/if}
</div>
{/if}
</div>
{/if}
</div>
<!-- Info inferior -->
<div class="flex flex-col gap-1 text-[10px] text-gray-700 font-mono">
<div class="flex items-center justify-between">
<span>{view.width || '?'} × {view.height || '?'} px</span>
{#if pickingPos}
<span class="text-indigo-400 font-sans font-medium">{glob.t('vcen.editor.hotspot.clickToPosition')}</span>
{/if}
</div>
{#if resolvedUrl || (strategy === 'static' && staticUrl)}
<span class="text-gray-600 break-all">{resolvedUrl || staticUrl}</span>
{/if}
</div>
</div>
</div>

@ -1,6 +1,6 @@
<script lang="ts">
import InputEntityID from '@/vcen-edit/comps/InputEntityID.svelte'
import EntityIdName from '@/vcen-edit/comps/EntityIdName.svelte'
import InputId from '@/vcen-edit/comps/InputID.svelte'
import InputIdName from '@/vcen-edit/comps/InputIdName.svelte'
import { isValidId } from '@/vcen-edit/lib/validate'
import type { LingRecord, LingString } from '@/ling/lib'
@ -14,7 +14,7 @@ const ENTITIES = [
{ prefix: 'hs', label: 'Hotspot', displayId: 'hs_rueda-derecha' },
]
// ── Demo A: InputEntityID individual ───────────────────────────────────────
// ── Demo A: InputId individual ───────────────────────────────────────
let demoSlug = $state('')
let demoError = $state('')
@ -38,7 +38,7 @@ function demoReset() {
demoConfirmed = null
}
// ── Demo B: EntityIdName combinado ─────────────────────────────────────────
// ── Demo B: InputIdName combinado ─────────────────────────────────────────
let combinedSlug = $state('')
let combinedIdError = $state('')
@ -74,7 +74,7 @@ function combinedReset() {
</div>
<!-- ═══════════════════════════════════════════════════════════════════════
SECCIÓN 1 — InputEntityID: variantes de display
SECCIÓN 1 — InputId: variantes de display
═══════════════════════════════════════════════════════════════════════ -->
<section class="flex flex-col gap-4">
<h2 class="text-xs font-bold uppercase tracking-widest text-gray-600 border-b border-gray-800 pb-2">
@ -88,7 +88,7 @@ function combinedReset() {
{#each ENTITIES as e (e.prefix)}
<div class="flex flex-col gap-1">
<span class="text-[10px] text-gray-700">{e.label}</span>
<InputEntityID
<InputId
prefix={e.prefix}
isNew={false}
fullId={e.displayId}
@ -105,7 +105,7 @@ function combinedReset() {
{#each ENTITIES as e (e.prefix)}
<div class="flex flex-col gap-1">
<span class="text-[10px] text-gray-700">{e.label}</span>
<InputEntityID
<InputId
prefix={e.prefix}
isNew={true}
placeholder="mi-identificador…"
@ -121,7 +121,7 @@ function combinedReset() {
<div class="grid grid-cols-2 gap-x-8 gap-y-4">
<div class="flex flex-col gap-1">
<span class="text-[10px] text-gray-700">ID obligatorio</span>
<InputEntityID
<InputId
prefix="ob"
isNew={true}
error="El ID es obligatorio"
@ -130,7 +130,7 @@ function combinedReset() {
</div>
<div class="flex flex-col gap-1">
<span class="text-[10px] text-gray-700">Formato inválido</span>
<InputEntityID
<InputId
prefix="at"
isNew={true}
slug="--malo--"
@ -139,7 +139,7 @@ function combinedReset() {
</div>
<div class="flex flex-col gap-1">
<span class="text-[10px] text-gray-700">Ya existe</span>
<InputEntityID
<InputId
prefix="vw"
isNew={true}
slug="frontal"
@ -151,7 +151,7 @@ function combinedReset() {
</section>
<!-- ═══════════════════════════════════════════════════════════════════════
SECCIÓN 2 — InputEntityID interactivo
SECCIÓN 2 — InputId interactivo
═══════════════════════════════════════════════════════════════════════ -->
<section class="flex flex-col gap-4">
<h2 class="text-xs font-bold uppercase tracking-widest text-gray-600 border-b border-gray-800 pb-2">
@ -165,7 +165,7 @@ function combinedReset() {
{#if demoConfirmed === null}
<!-- Modo new -->
<InputEntityID
<InputId
prefix="vw"
isNew={true}
bind:slug={demoSlug}
@ -180,7 +180,7 @@ function combinedReset() {
</div>
{:else}
<!-- Modo display tras confirmar -->
<InputEntityID
<InputId
prefix="vw"
isNew={false}
fullId={demoConfirmed}
@ -203,7 +203,7 @@ function combinedReset() {
</section>
<!-- ═══════════════════════════════════════════════════════════════════════
SECCIÓN 3 — EntityIdName combinado
SECCIÓN 3 — InputIdName combinado
═══════════════════════════════════════════════════════════════════════ -->
<section class="flex flex-col gap-4">
<h2 class="text-xs font-bold uppercase tracking-widest text-gray-600 border-b border-gray-800 pb-2">
@ -213,7 +213,7 @@ function combinedReset() {
<!-- Display estático -->
<div class="bg-gray-900 border border-gray-800 rounded-lg p-5 flex flex-col gap-4">
<p class="text-xs text-gray-600 uppercase tracking-wider font-medium">Modo display</p>
<EntityIdName
<InputIdName
prefix="ob"
isNew={false}
fullId="ob_bmw-serie-3"
@ -232,7 +232,7 @@ function combinedReset() {
</p>
{#if combinedConfirmed === null}
<EntityIdName
<InputIdName
prefix="ob"
isNew={true}
bind:slug={combinedSlug}
@ -251,7 +251,7 @@ function combinedReset() {
<button onclick={combinedReset} class="ve-action-btn">Cancelar</button>
</div>
{:else}
<EntityIdName
<InputIdName
prefix="ob"
isNew={false}
fullId={combinedConfirmed.id}

@ -1,9 +1,19 @@
<script lang="ts">
import type { Cat } from '@/vcen/lib'
import { getContext } from 'svelte'
import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.js'
import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.ts'
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.ts'
import { buildDependencyGraph } from '@/vcen/lib/graph.ts'
import { labelOf } from '@/vcen-edit/lib/label.ts'
import CatalogSidebar from '../comps/CatalogSidebar.svelte'
import PanelCatalogoMeta from '../comps/PanelCatalogoMeta.svelte'
import PanelCatalogoAtts from '../comps/PanelCatalogoAtts.svelte'
import MetadataPanel from '../comps/MetadataPanel.svelte'
import PanelOptions from '../comps/PanelOptions.svelte'
import PanelObject from '../comps/PanelObject.svelte'
import PanelSection from '../comps/PanelSection.svelte'
import PanelView from '../comps/PanelView.svelte'
import PanelHotspot from '../comps/PanelHotspot.svelte'
// Helper: busca nombre legible de un nodo a partir de su key serializada
function resolveNodeLabel(cat: Cat, key: string): string {
@ -28,16 +38,6 @@ function resolveNodeLabel(cat: Cat, key: string): string {
}
return key
}
import { labelOf } from '@/vcen-edit/lib/label.ts'
import CatalogSidebar from './CatalogSidebar.svelte'
import CatMetaPanel from './CatMetaPanel.svelte'
import CatAttsPanel from './CatAttsPanel.svelte'
import MetadataPanel from './MetadataPanel.svelte'
import OptsPanel from './OptsPanel.svelte'
import ObjPanel from './ObjPanel.svelte'
import SecPanel from './SecPanel.svelte'
import ViewPanel from './ViewPanel.svelte'
import HotspotPanel from './HotspotPanel.svelte'
let { onsave, oncancel }: {
onsave?: (cat: Cat) => void
@ -153,14 +153,14 @@ const isValid = $derived(graphResult.graph !== null)
<!-- Main content -->
<div class="flex-1 min-w-0 overflow-hidden relative">
{#if sel.kind === 'cat-info'} <CatMetaPanel />
{:else if sel.kind === 'cat-atts'} <CatAttsPanel />
{#if sel.kind === 'cat-info'} <PanelCatalogoMeta />
{:else if sel.kind === 'cat-atts'} <PanelCatalogoAtts />
{:else if sel.kind === 'cat-meta'} <MetadataPanel />
{:else if sel.kind === 'opts'} <OptsPanel />
{:else if sel.kind === 'obj-info' || sel.kind === 'obj-atts' || sel.kind === 'obj-meta' || sel.kind === 'obj-issues' || sel.kind === 'obj-rules'} <ObjPanel />
{:else if sel.kind === 'sec-info' || sel.kind === 'sec-atts' || sel.kind === 'sec-meta' || sel.kind === 'sec-issues'} <SecPanel />
{:else if sel.kind === 'view-info'} <ViewPanel />
{:else if sel.kind === 'hotspot-info'} <HotspotPanel />
{:else if sel.kind === 'cat-opts'} <PanelOptions />
{:else if sel.kind === 'obj-info' || sel.kind === 'obj-atts' || sel.kind === 'obj-meta' || sel.kind === 'obj-issues' || sel.kind === 'obj-rules'} <PanelObject />
{:else if sel.kind === 'sec-info' || sel.kind === 'sec-atts' || sel.kind === 'sec-meta' || sel.kind === 'sec-rules' || sel.kind === 'sec-issues'} <PanelSection />
{:else if sel.kind === 'view-info'} <PanelView />
{:else if sel.kind === 'hotspot-info'} <PanelHotspot />
{/if}
<!-- JSON overlay -->

@ -1,3 +1,17 @@
export type IconName =
// Entidades
| 'catalog' | 'obj' | 'sec' | 'att' | 'opt' | 'view' | 'hotspot'
// Paneles / tabs
| 'info' | 'meta' | 'issues' | 'pricing' | 'transaction'
// Navegación
| 'chevron-right' | 'chevron-down' | 'chevron-up'
// Acciones
| 'add' | 'delete' | 'move-up' | 'move-down' | 'drag' | 'edit'
/**
* Iconos disponibles en el editor de catálogos.
* Formato: { key, label, svg } donde `svg` es el innerHTML del elemento SVG (viewBox="0 0 24 24").

@ -244,6 +244,7 @@ export const EDITOR_TRANSLATIONS = {
hotspot: {
newTitle: { es: 'Nuevo hotspot' },
kind: { es: 'Hotspot' },
deleteBtn: { es: 'Eliminar hotspot' },
deleteTitle: { es: '¿Eliminar hotspot?' },
mode: { es: 'Modo' },
targetAtt: { es: 'Att objetivo' },
@ -270,16 +271,9 @@ export const EDITOR_TRANSLATIONS = {
obj: {
newTitle: { es: 'Nuevo objeto' },
kind: { es: 'Objeto' },
deleteBtn: { es: 'Eliminar objeto' },
deleteTitle: { es: '¿Eliminar objeto?' },
deleteWarn: { es: 'Se eliminarán también todas sus secciones y atributos.' },
tabs: {
info: { es: 'General' },
atts: { es: 'Atributos' },
visual: { es: 'Visual' },
rules: { es: 'Reglas' },
issues: { es: 'Incidencias' },
meta: { es: 'Metadatos' },
},
stats: {
atts: { es: 'Atributos directos' },
secs: { es: 'Secciones' },
@ -290,15 +284,9 @@ export const EDITOR_TRANSLATIONS = {
sec: {
newTitle: { es: 'Nueva sección' },
kind: { es: 'Sección' },
deleteBtn: { es: 'Eliminar sección' },
deleteTitle: { es: '¿Eliminar sección?' },
deleteWarn: { es: 'Se eliminarán también todos sus atributos.' },
tabs: {
info: { es: 'General' },
atts: { es: 'Atributos' },
visual: { es: 'Visual' },
issues: { es: 'Reglas/Incidencias' },
meta: { es: 'Metadatos' },
},
stats: {
atts: { es: 'Atributos' },
issues: { es: 'Incidencias' },
@ -306,6 +294,14 @@ export const EDITOR_TRANSLATIONS = {
},
entity: {
tabs: {
info: { es: 'General' },
atts: { es: 'Atributos' },
visual: { es: 'Visual' },
rules: { es: 'Reglas' },
issues: { es: 'Incidencias' },
meta: { es: 'Metadatos' },
},
id: { es: 'Identificación' },
info: { es: 'Información' },
title: { es: 'Título' },
@ -397,6 +393,7 @@ export const EDITOR_TRANSLATIONS = {
condLabel: { es: 'Condición' },
condHint: { es: 'opcional — vacío = siempre visible' },
addBtn: { es: '+ Añadir incidencia' },
deleteBtn: { es: 'Eliminar esta incidencia' },
deleteTitle: { es: '¿Eliminar esta incidencia?' },
levels: {
info: { es: 'Info' },
@ -414,6 +411,7 @@ export const EDITOR_TRANSLATIONS = {
ifFalse: { es: 'Si no se cumple →' },
applyTo: { es: 'Aplicar a' },
selfAtt: { es: '— este atributo —' },
deleteBtn: { es: 'Eliminar esta regla' },
deleteTitle: { es: '¿Eliminar esta regla?' },
noDesc: { es: 'Sin descripción' },
states: {
@ -470,6 +468,7 @@ export const EDITOR_TRANSLATIONS = {
opt: {
kind: { es: 'Opción' },
newTitle: { es: 'Nueva opción' },
deleteOpt: { es: 'Eliminar opción' },
deleteTitle: { es: '¿Eliminar opción?' },
deleteWarn: { es: 'Se eliminará de todos los atributos que la referencien.' },
tabs: {
@ -519,11 +518,13 @@ export const EDITOR_TRANSLATIONS = {
valuePh: { es: 'marmol_blanco' },
labelPh: { es: 'Mármol blanco' },
deleteTitle: { es: '¿Eliminar opción?' },
deleteOpt: { es: 'Eliminar opción' },
},
att: {
kind: { es: 'Atributo' },
deleteTitle: { es: '¿Eliminar atributo?' },
deleteAtt: { es: '¿Eliminar atributo?' },
sectionId: { es: 'Identificación' },
sectionType: { es: 'Tipo' },
sectionDefault: { es: 'Valor por defecto' },

@ -0,0 +1,50 @@
import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.ts'
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.ts'
export type EntityTab = 'info' | 'atts' | 'visual' | 'rules' | 'issues' | 'meta'
export function entityTabsArray(glob: EditorGlob): { id: EntityTab; label: string }[] {
return [
{ id: 'info', label: glob.t('vcen.editor.entity.tabs.info') },
{ id: 'atts', label: glob.t('vcen.editor.entity.tabs.atts') },
{ id: 'visual', label: glob.t('vcen.editor.entity.tabs.visual') },
{ id: 'rules', label: glob.t('vcen.editor.entity.tabs.rules') },
{ id: 'issues', label: glob.t('vcen.editor.entity.tabs.issues') },
{ id: 'meta', label: glob.t('vcen.editor.entity.tabs.meta') },
]
}
/**
* Centraliza la lógica reactiva de tabs para paneles de entidad (obj, sec).
*
* @param prefix Prefijo del kind de selección: 'obj' | 'sec'
* @param editor Store del editor
* @param getSel Getter reactivo de la selección actual
* @param getIds Getter de los IDs a pasar en editor.select (null si no hay contexto)
*/
export function createEntityTabs(
prefix: string,
editor: CatalogEditorStore,
getSel: () => { kind: string },
getIds: () => Record<string, unknown> | null,
) {
const tabFromSel = $derived(getSel().kind.split('-')[1] as EntityTab)
let extraTab = $state<'visual' | null>(null)
$effect(() => { void getSel(); extraTab = null })
const tab = $derived((extraTab ?? tabFromSel) as EntityTab)
function goTab(t: string) {
if (t === 'visual') { extraTab = 'visual'; return }
extraTab = null
const ids = getIds()
if (!ids) return
editor.select({ kind: `${prefix}-${t}`, ...ids } as any)
}
return {
get tab() { return tab },
goTab,
}
}

@ -0,0 +1,69 @@
/**
* Catálogo de transiciones conocidas por el editor.
* Cada entrada define el nombre (identificador que usará el motor de render)
* y los campos opcionales que tienen sentido para esa animación.
*
* Añadir aquí una entrada la hace disponible en el selector del TransactionEditor.
* El motor de visualización es responsable de interpretar el `name`.
*/
export interface TransactionDef {
/** Identificador — coincide con Transaction.name */
name: string;
/** Etiqueta legible para la UI */
label: string;
/** Si true, el campo `direction` es relevante para esta transición */
hasDirection?: boolean;
/** Descripción corta para tooltip o ayuda contextual */
description?: string;
}
export const KNOWN_TRANSACTIONS: TransactionDef[] = [
{
name: 'fade',
label: 'Fade',
description: 'Fundido de opacidad. La capa aparece y desaparece gradualmente.',
},
{
name: 'crossfade',
label: 'Crossfade',
description: 'La imagen antigua y la nueva se solapan durante la transición.',
},
{
name: 'slide',
label: 'Slide',
hasDirection: true,
description: 'La capa entra deslizándose desde un lado.',
},
{
name: 'zoom',
label: 'Zoom',
description: 'La capa aparece con un efecto de escala.',
},
{
name: 'flip',
label: 'Flip',
hasDirection: true,
description: 'Giro 3D sobre el eje horizontal o vertical.',
},
{
name: 'blur',
label: 'Blur',
description: 'La imagen se desenfoca al salir y se enfoca al entrar.',
},
{
name: 'wipe',
label: 'Wipe',
hasDirection: true,
description: 'Barrido: la nueva imagen barre a la anterior en una dirección.',
},
{
name: 'dissolve',
label: 'Dissolve',
description: 'Disolución píxel a píxel entre imagen antigua y nueva.',
},
{
name: 'morph',
label: 'Morph',
description: 'Transformación fluida entre dos estados de la capa.',
},
]

@ -22,17 +22,17 @@ import { logr } from '@/vcen-edit/lib/logr.ts';
// ─────────────────────────────────────────────────────────────────────────────
export type SelectionKind =
| 'cat-info' | 'cat-atts' | 'cat-meta' | 'opts'
| 'cat-info' | 'cat-atts' | 'cat-meta' | 'cat-opts'
| 'obj-info' | 'obj-atts' | 'obj-meta' | 'obj-issues'
| 'obj-rules'
| 'sec-info' | 'sec-atts' | 'sec-meta' | 'sec-issues'
| 'sec-info' | 'sec-atts' | 'sec-meta' | 'sec-rules' | 'sec-issues'
| 'view-info' | 'hotspot-info';
export type Selection =
| { kind: 'cat-info' }
| { kind: 'cat-atts'; attId?: AttID }
| { kind: 'cat-meta' }
| { kind: 'opts' }
| { kind: 'cat-opts' }
| { kind: 'obj-info'; objId: ObjID }
| { kind: 'obj-atts'; objId: ObjID; attId?: AttID }
| { kind: 'obj-meta'; objId: ObjID }
@ -41,6 +41,7 @@ export type Selection =
| { kind: 'sec-info'; objId: ObjID; secId: SecID }
| { kind: 'sec-atts'; objId: ObjID; secId: SecID; attId?: AttID }
| { kind: 'sec-meta'; objId: ObjID; secId: SecID }
| { kind: 'sec-rules'; objId: ObjID; secId: SecID }
| { kind: 'sec-issues'; objId: ObjID; secId: SecID }
| { kind: 'view-info'; objId: ObjID; secId: SecID; viewId: ViewID }
| { kind: 'hotspot-info'; objId: ObjID; secId: SecID; viewId: ViewID; hotspotId: HotspotID };
@ -1160,7 +1161,7 @@ export function createCatalogEditorStore(initial: Cat) {
newEntityId = null;
}
// ── Opt activa (para OptsPanel) ──────────────────────────────────────────
// ── Opt activa (para PanelOptions) ──────────────────────────────────────────
let activeOptId = $state<string | null>(null);

@ -1 +1,5 @@
<svg height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg"><g id="_01_align_center" data-name="01 align center"><path d="m10 6a3.99 3.99 0 0 1 3.8 2.828l2.177.791a6 6 0 1 0 -6.362 6.362l-.787-2.181a3.988 3.988 0 0 1 1.172-7.8z"/><path d="m10 18a8 8 0 1 1 8-8c0 .117-.013.231-.018.347l1.96.713a10.016 10.016 0 1 0 -8.882 8.882l-.713-1.96c-.116.005-.23.018-.347.018z"/><path d="m24 14.779-10.462-3.8a2 2 0 0 0 -2.563 2.562l3.804 10.459 3.9-3.9 2.793 2.793 1.414-1.414-2.786-2.796zm-8.414 5.586-2.731-7.51 7.51 2.731z"/></g></svg>
<svg height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg">
<g id="_01_align_center" data-name="01 align center"><path d="m10 6a3.99 3.99 0 0 1 3.8 2.828l2.177.791a6 6 0 1 0 -6.362 6.362l-.787-2.181a3.988 3.988 0 0 1 1.172-7.8z"/><path d="m10 18a8 8 0 1 1 8-8c0 .117-.013.231-.018.347l1.96.713a10.016 10.016 0 1 0 -8.882 8.882l-.713-1.96c-.116.005-.23.018-.347.018z"/><path d="m24 14.779-10.462-3.8a2 2 0 0 0 -2.563 2.562l3.804 10.459 3.9-3.9 2.793 2.793 1.414-1.414-2.786-2.796zm-8.414 5.586-2.731-7.51 7.51 2.731z"/></g></svg>

Before

Width:  |  Height:  |  Size: 553 B

After

Width:  |  Height:  |  Size: 561 B

Loading…
Cancel
Save

Powered by TurnKey Linux.