feat(vcen-edit): panel visual con hotspots, posicionamiento interactivo y mejoras UI

- Nuevo VisualPanel: editor completo de vistas y hotspots con sidebar, formulario y preview
- Botón diana para posicionar hotspots haciendo click en la imagen de preview
- Posición X/Y de hotspots como número 0-100 (porcentaje), con validación
- Preview de imagen con markers de hotspots superpuestos y aspect-ratio dinámico
- IDs editables en creación de vistas y hotspots (patrón newEntityId)
- Corrección de newEntityId en addAtt/addCatAtt para edición de ID en creación
- Tabs en mayúsculas en ObjPanel, SecPanel y CatMetaPanel
- Mejora contraste botones de locale secundario en modo light (LingInput)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
master
dev 7 months ago
parent 50e50cc190
commit 8fdfc89b33

@ -85,7 +85,7 @@
{#if label} {#if label}
<div class="flex items-baseline justify-between px-0.5"> <div class="flex items-baseline justify-between px-0.5">
<label class={[ <label class={[
'text-[11px] font-semibold uppercase tracking-[0.1em] transition-colors duration-200', 'text-xs font-semibold uppercase tracking-[0.08em] transition-colors duration-200',
invalid invalid
? (dark ? 'text-red-400' : 'text-rose-400') ? (dark ? 'text-red-400' : 'text-rose-400')
: (dark ? 'text-gray-500' : 'text-zinc-400'), : (dark ? 'text-gray-500' : 'text-zinc-400'),
@ -101,7 +101,7 @@
? (dark ? 'text-gray-400' : 'text-zinc-300') ? (dark ? 'text-gray-400' : 'text-zinc-300')
: (dark ? 'text-indigo-400' : 'text-violet-400'), : (dark ? 'text-indigo-400' : 'text-violet-400'),
].join(' ')}> ].join(' ')}>
{#if invalid}requerido{:else if isOptional}opcional{:else}requerido{/if} {#if invalid}requerido{:else if isOptional}opcional{:else if required}requerido{/if}
</span> </span>
{/if} {/if}
</div> </div>
@ -140,15 +140,15 @@
aria-label={`Idioma ${locale}`} aria-label={`Idioma ${locale}`}
onclick={() => activateLocale(locale)} onclick={() => activateLocale(locale)}
class={[ class={[
'relative flex h-4 w-8 items-center rounded px-1.5 font-mono text-[10px] font-bold uppercase', 'li-locale-btn relative flex h-4 w-8 items-center rounded px-1.5 font-mono text-[10px] font-bold uppercase',
'tracking-wider transition-all duration-150 focus:outline-none', 'tracking-wider transition-all duration-150 focus:outline-none',
active active
? invalid ? invalid
? (dark ? 'bg-red-700 text-white' : 'bg-rose-500 text-white') ? (dark ? 'bg-red-700 text-white' : 'bg-rose-500 text-white')
: (dark ? 'bg-indigo-600 text-white' : 'bg-violet-600 text-white') : (dark ? 'bg-indigo-600 text-white' : 'bg-violet-600 text-white')
: filled : filled
? (dark ? 'bg-gray-700 text-gray-300 hover:bg-gray-600' : 'bg-zinc-100 text-zinc-600 hover:bg-zinc-200') ? (dark ? 'bg-gray-700 text-gray-300 hover:bg-gray-600' : 'bg-zinc-200 text-zinc-700 hover:bg-zinc-300')
: (dark ? 'text-gray-600 hover:bg-gray-800 hover:text-gray-400' : 'text-zinc-300 hover:bg-zinc-100 hover:text-zinc-500'), : (dark ? 'bg-gray-800 text-gray-500 hover:bg-gray-700 hover:text-gray-300' : 'bg-zinc-100 text-zinc-500 hover:bg-zinc-200 hover:text-zinc-700'),
].join(' ')} ].join(' ')}
> >
{locale} {locale}
@ -227,3 +227,23 @@
</div> </div>
</div> </div>
<style>
/* Botones de locale en contexto ve-light (dark editor en modo claro).
:global(.ve-light) hace que la regla incluya el hash de Svelte en
.li-locale-btn, dando especificidad suficiente para superar Tailwind. */
:global(.ve-light) .li-locale-btn:not([aria-selected="true"]) {
background-color: #d4e0ec !important;
color: #1a2d3e !important;
border-color: #a8c4d8 !important;
}
:global(.ve-light) .li-locale-btn:not([aria-selected="true"]):hover {
background-color: #b8ccd8 !important;
color: #0d1520 !important;
}
:global(.ve-light) .li-locale-btn[aria-selected="true"] {
background-color: #4f46e5 !important;
color: #ffffff !important;
border-color: #4f46e5 !important;
}
</style>

@ -179,3 +179,147 @@ html { scroll-behavior: smooth; }
.catalog-editor *::-webkit-scrollbar-thumb:hover { background: #374151; } .catalog-editor *::-webkit-scrollbar-thumb:hover { background: #374151; }
.catalog-editor *::-webkit-scrollbar-corner { background: transparent; } .catalog-editor *::-webkit-scrollbar-corner { background: transparent; }
.catalog-editor * { scrollbar-width: thin; scrollbar-color: #1f2937 transparent; } .catalog-editor * { scrollbar-width: thin; scrollbar-color: #1f2937 transparent; }
/* ═══════════════════════════════════════════════════════════════════
TEMA LIGHT
Estrategia: overrides explícitos por clase + CSS tokens.
Los overrides de clase funcionan independientemente de cómo
Tailwind compila los utilities (con var() o valores estáticos).
═══════════════════════════════════════════════════════════════════ */
/* ── Tokens y raíz ───────────────────────────────────────────────── */
.catalog-editor.ve-light {
--ve-bg: #ffffff;
--ve-surface: #ffffff;
--ve-surface-alt: #f4f6f9;
--ve-border: #dde3ec;
--ve-border-mid: #b8c8d8;
--ve-text: #0d1c2a;
--ve-text-muted: #4d6880;
--ve-text-subtle: #dde3ec;
--ve-accent-light: #4338ca;
background: #f4f6f9;
color: #0d1c2a;
}
/* ── Fondos: grises (bg-gray-*) ──────────────────────────────────── */
.catalog-editor.ve-light [class*="bg-gray-950"] { background-color: #f4f6f9 !important; }
.catalog-editor.ve-light [class*="bg-gray-900"] { background-color: #ffffff !important; }
.catalog-editor.ve-light [class*="bg-gray-800"] { background-color: #edf1f7 !important; }
/* bg-gray-700 más oscuro para que los botones secundarios de locale destaquen sobre el fondo blanco */
.catalog-editor.ve-light [class*="bg-gray-700"] { background-color: #c3d3e0 !important; }
/* ── Texto: grises (text-gray-*) — contraste mínimo 4.5:1 sobre blanco */
.catalog-editor.ve-light [class*="text-gray-100"] { color: #0d1c2a !important; } /* 17:1 */
.catalog-editor.ve-light [class*="text-gray-200"] { color: #1a2d3e !important; } /* 13:1 */
.catalog-editor.ve-light [class*="text-gray-300"] { color: #2d4255 !important; } /* 9:1 */
.catalog-editor.ve-light [class*="text-gray-400"] { color: #415d76 !important; } /* 6:1 */
.catalog-editor.ve-light [class*="text-gray-500"] { color: #4d6880 !important; } /* 5.2:1 */
.catalog-editor.ve-light [class*="text-gray-600"] { color: #607d95 !important; } /* 4.5:1 */
.catalog-editor.ve-light [class*="text-gray-700"] { color: #8fa8bf !important; } /* decorativo */
/* ── Bordes: grises (border-gray-*) ─────────────────────────────── */
.catalog-editor.ve-light [class*="border-gray-800"] { border-color: #dde3ec !important; }
.catalog-editor.ve-light [class*="border-gray-700"] { border-color: #b8c8d8 !important; }
.catalog-editor.ve-light [class*="border-gray-600"] { border-color: #94afc4 !important; }
/* ── Divide ──────────────────────────────────────────────────────── */
.catalog-editor.ve-light [class*="divide-gray-800"] > * + * { border-color: #dde3ec !important; }
/* ── Indigo: activos y acentos ───────────────────────────────────── */
.catalog-editor.ve-light [class*="text-indigo-400"] { color: #4f46e5 !important; }
.catalog-editor.ve-light [class*="text-indigo-300"] { color: #4338ca !important; }
.catalog-editor.ve-light [class*="bg-indigo-950"] { background-color: rgba(238,242,255,.8) !important; }
.catalog-editor.ve-light [class*="bg-indigo-900"] { background-color: rgba(224,231,255,.7) !important; }
/* NOTA: NO se override bg-indigo-600 ni border-indigo-600:
- bg-indigo-600 se usa en botones activos (locale, tabs…) y funciona correcto sin override.
- border-indigo-600 aparece como "focus-within:border-indigo-600" en el class attr de LingInput,
y overridearlo con [class*="border-indigo-600"] haría que el borde sea siempre azul. */
.catalog-editor.ve-light [class*="border-indigo-900"] { border-color: rgba(165,180,252,.5) !important; }
.catalog-editor.ve-light [class*="border-indigo-700"] { border-color: #6366f1 !important; }
/* ── Verde: disponible / activo ──────────────────────────────────── */
.catalog-editor.ve-light [class*="text-green-400"] { color: #16a34a !important; }
.catalog-editor.ve-light [class*="text-green-500"] { color: #15803d !important; }
.catalog-editor.ve-light [class*="bg-green-900"] { background-color: rgba(220,252,231,.7) !important; }
.catalog-editor.ve-light [class*="bg-green-500"] { background-color: #22c55e !important; }
.catalog-editor.ve-light [class*="border-green-800"] { border-color: #86efac !important; }
.catalog-editor.ve-light [class*="border-green-700"] { border-color: #4ade80 !important; }
/* ── Rojo: prohibido / peligro ───────────────────────────────────── */
.catalog-editor.ve-light [class*="text-red-400"] { color: #dc2626 !important; }
.catalog-editor.ve-light [class*="text-red-200"] { color: #991b1b !important; }
.catalog-editor.ve-light [class*="bg-red-950"] { background-color: #fff1f2 !important; }
.catalog-editor.ve-light [class*="bg-red-900"] { background-color: rgba(254,226,226,.8) !important; }
.catalog-editor.ve-light [class*="border-red-900"] { border-color: #fca5a5 !important; }
/* ── Azul: requerido ─────────────────────────────────────────────── */
.catalog-editor.ve-light [class*="text-blue-400"] { color: #2563eb !important; }
.catalog-editor.ve-light [class*="bg-blue-900"] { background-color: rgba(219,234,254,.8) !important; }
.catalog-editor.ve-light [class*="border-blue-800"] { border-color: #bfdbfe !important; }
.catalog-editor.ve-light [class*="border-blue-700"] { border-color: #93c5fd !important; }
/* ── Ámbar: modificado / advertencia ─────────────────────────────── */
.catalog-editor.ve-light [class*="text-amber-400"] { color: #b45309 !important; }
.catalog-editor.ve-light [class*="bg-amber-900"] { background-color: rgba(254,243,199,.9) !important; }
.catalog-editor.ve-light [class*="border-amber-900"] { border-color: rgba(251,191,36,.6) !important; }
.catalog-editor.ve-light [class*="border-amber-800"] { border-color: rgba(251,191,36,.5) !important; }
/* ── Botones: light ──────────────────────────────────────────────── */
.catalog-editor.ve-light .ve-action-btn,
.catalog-editor.ve-light .ve-btn,
.catalog-editor.ve-light .ve-card-btn { border-color: #b8c8d8; }
.catalog-editor.ve-light .ve-action-btn { color: #4d6880; }
.catalog-editor.ve-light .ve-action-btn:hover { color: #0d1c2a; border-color: #7a9ab5; background: #edf1f7; }
.catalog-editor.ve-light .ve-action-btn.primary { background: #4f46e5; border-color: #4f46e5; color: #fff; }
.catalog-editor.ve-light .ve-action-btn.primary:hover { background: #4338ca; border-color: #4338ca; }
.catalog-editor.ve-light .ve-action-btn.danger:hover { color: #dc2626; border-color: #fca5a5; background: rgba(220,38,38,.08); }
.catalog-editor.ve-light .ve-action-btn.discard:hover { color: #dc2626; border-color: #fca5a5; background: rgba(220,38,38,.08); }
.catalog-editor.ve-light .ve-action-btn.active { border-color: #4f46e5; background: rgba(79,70,229,.1); color: #4338ca; }
.catalog-editor.ve-light .ve-btn { color: #4d6880; }
.catalog-editor.ve-light .ve-btn:hover { color: #0d1c2a; border-color: #7a9ab5; background: #edf1f7; }
.catalog-editor.ve-light .ve-btn.primary { background: #4f46e5; border-color: #4f46e5; color: #fff; }
.catalog-editor.ve-light .ve-btn.primary:hover { background: #4338ca; border-color: #4338ca; }
.catalog-editor.ve-light .ve-btn.danger { background: #fee2e2; border-color: #fca5a5; color: #dc2626; }
.catalog-editor.ve-light .ve-btn.danger:hover { background: #dc2626; border-color: #dc2626; color: #fff; }
.catalog-editor.ve-light .ve-btn.discard:hover { color: #dc2626; border-color: #fca5a5; background: rgba(220,38,38,.08); }
.catalog-editor.ve-light .ve-btn.active { border-color: #4f46e5; background: rgba(79,70,229,.1); color: #4338ca; }
.catalog-editor.ve-light .ve-card-btn { color: #4d6880; }
.catalog-editor.ve-light .ve-card-btn:hover { color: #0d1c2a; border-color: #7a9ab5; background: #edf1f7; }
.catalog-editor.ve-light .ve-card-btn.danger { border-color: #b8c8d8; color: #4d6880; }
.catalog-editor.ve-light .ve-card-btn.danger:hover { color: #dc2626; border-color: #fca5a5; background: rgba(220,38,38,.08); }
.catalog-editor.ve-light .ve-btn-link { color: #4d6880; }
.catalog-editor.ve-light .ve-btn-link:hover { color: #0d1c2a; }
.catalog-editor.ve-light .ve-btn-link.accent { color: #4f46e5; }
.catalog-editor.ve-light .ve-btn-link.accent:hover { color: #4338ca; }
.catalog-editor.ve-light .ve-btn-link.danger:hover { color: #dc2626; }
/* ── Sidebar add btn ─────────────────────────────────────────────── */
.catalog-editor.ve-light .ve-sidebar-add-btn { color: #7a9ab5; }
.catalog-editor.ve-light .ve-sidebar-add-btn:hover { color: #4338ca; background: rgba(79,70,229,.1); }
/* ── Status badges ───────────────────────────────────────────────── */
.catalog-editor.ve-light .ve-status.available { background: rgba(20,83,45,.08); color: #16a34a; border-color: rgba(20,83,45,.25); }
.catalog-editor.ve-light .ve-status.required { background: rgba(30,58,138,.08); color: #2563eb; border-color: rgba(30,58,138,.25); }
.catalog-editor.ve-light .ve-status.hidden { background: #f4f6f9; color: #4d6880; border-color: #dde3ec; }
.catalog-editor.ve-light .ve-status.forbidden { background: rgba(153,27,27,.08); color: #dc2626; border-color: rgba(153,27,27,.25); }
.catalog-editor.ve-light .ve-status.error { background: rgba(153,27,27,.08); color: #dc2626; border-color: rgba(153,27,27,.25); }
/* ── Label ───────────────────────────────────────────────────────── */
.catalog-editor.ve-light .ve-label { color: #4d6880; }
/* ── select chevron ──────────────────────────────────────────────── */
.catalog-editor.ve-light select.field-input {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%234d6880' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
}
/* Locale tab buttons — gestionados en LingInput.svelte via <style> */
/* ── Scrollbar ───────────────────────────────────────────────────── */
.catalog-editor.ve-light *::-webkit-scrollbar-thumb { background: #b8c8d8; }
.catalog-editor.ve-light *::-webkit-scrollbar-thumb:hover { background: #7a9ab5; }
.catalog-editor.ve-light * { scrollbar-color: #b8c8d8 transparent; }

@ -144,7 +144,7 @@ const computedState = $derived.by(() => {
return evalRuleSetState(ctx.att.rules, evalCtx) return evalRuleSetState(ctx.att.rules, evalCtx)
}) })
const isNew = $derived((sel as any).isNew === true) const isNew = $derived(editor.newEntityId !== null && editor.newEntityId === ctx?.attId)
let editId = $state('') let editId = $state('')
let idError = $state('') let idError = $state('')

@ -75,7 +75,7 @@ let catTab = $state<'info' | 'issues'>('info')
{@const isActive = catTab === t.id} {@const isActive = catTab === t.id}
<button <button
onclick={() => catTab = t.id as any} onclick={() => catTab = t.id as any}
class="px-4 py-2.5 text-xs font-medium border-b-2 -mb-px transition-colors 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' {isActive ? 'text-indigo-400 border-indigo-500'
: 'text-gray-500 border-transparent hover:text-gray-300'}" : 'text-gray-500 border-transparent hover:text-gray-300'}"
>{t.label}</button> >{t.label}</button>

@ -26,20 +26,30 @@ logr.debug('CatalogEditor', { es: 'Editor inicializado' }, { catId: cat.id, name
<CatalogLayout {onsave} {oncancel} /> <CatalogLayout {onsave} {oncancel} />
<style> <style>
:global(.catalog-editor) {
display: flex;
height: 100vh;
background: var(--ve-bg);
color: var(--ve-text);
font-size: 0.8125rem;
font-family: var(--ve-font);
overflow: hidden;
}
:global(.field-input) { :global(.field-input) {
width: 100%; width: 100%;
background: #111827; background: var(--ve-surface);
border: 1px solid #1f2937; border: 1px solid var(--ve-border);
border-radius: 0.375rem; border-radius: 0.375rem;
padding: 0.375rem 0.625rem; padding: 0.375rem 0.625rem;
font-size: 0.8125rem; font-size: 0.8125rem;
color: #e2e8f0; color: var(--ve-text);
outline: none; outline: none;
transition: border-color 150ms; transition: border-color 150ms;
font-family: inherit; font-family: inherit;
} }
:global(.field-input:hover) { border-color: #374151; } :global(.field-input:hover) { border-color: var(--ve-border-mid); }
:global(.field-input:focus) { border-color: #4f46e5; } :global(.field-input:focus) { border-color: var(--ve-accent); }
:global(.field-input.invalid) { border-color: #991b1b; } :global(.field-input.invalid) { border-color: #991b1b; }
:global(select.field-input) { :global(select.field-input) {
cursor: pointer; cursor: pointer;
@ -59,9 +69,9 @@ logr.debug('CatalogEditor', { es: 'Editor inicializado' }, { catId: cat.id, name
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em; letter-spacing: 0.06em;
color: #4b5563; color: var(--ve-text-muted);
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
border-bottom: 1px solid #1f2937; border-bottom: 1px solid var(--ve-border);
margin-bottom: 0; margin-bottom: 0;
} }
:global(.section-title::before) { :global(.section-title::before) {
@ -69,7 +79,7 @@ logr.debug('CatalogEditor', { es: 'Editor inicializado' }, { catId: cat.id, name
display: inline-block; display: inline-block;
width: 2px; width: 2px;
height: 0.75rem; height: 0.75rem;
background: #374151; background: var(--ve-border-mid);
border-radius: 1px; border-radius: 1px;
flex-shrink: 0; flex-shrink: 0;
} }
@ -78,16 +88,6 @@ logr.debug('CatalogEditor', { es: 'Editor inicializado' }, { catId: cat.id, name
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.06em; letter-spacing: 0.06em;
color: #4b5563; color: var(--ve-text-muted);
}
:global(.catalog-editor) {
display: flex;
height: 100vh;
background: #0a0f1a;
color: #e2e8f0;
font-size: 0.8125rem;
font-family: var(--ve-font);
overflow: hidden;
} }
</style> </style>

@ -47,6 +47,15 @@ const sel = $derived(editor.selection)
let showJson = $state(false) let showJson = $state(false)
let showGraph = $state(false) let showGraph = $state(false)
const THEME_KEY = 've-theme'
let theme = $state<'dark' | 'light'>(
(typeof localStorage !== 'undefined' ? localStorage.getItem(THEME_KEY) : null) === 'light' ? 'light' : 'dark'
)
$effect(() => {
if (typeof localStorage !== 'undefined') localStorage.setItem(THEME_KEY, theme)
})
function toggleTheme() { theme = theme === 'dark' ? 'light' : 'dark' }
let sidebarW = $state(256) let sidebarW = $state(256)
let dragging = $state(false) let dragging = $state(false)
@ -78,7 +87,7 @@ const graphResult = $derived.by(() => {
const isValid = $derived(graphResult.graph !== null) const isValid = $derived(graphResult.graph !== null)
</script> </script>
<div class="catalog-editor h-full flex flex-col"> <div class="catalog-editor h-full flex flex-col {theme === 'light' ? 've-light' : ''}">
<!-- Header --> <!-- Header -->
<header class="flex items-center gap-3 px-4 py-2.5 bg-gray-900 border-b border-gray-800 shrink-0"> <header class="flex items-center gap-3 px-4 py-2.5 bg-gray-900 border-b border-gray-800 shrink-0">
@ -94,6 +103,17 @@ const isValid = $derived(graphResult.graph !== null)
<!-- Right --> <!-- Right -->
<div class="ml-auto flex items-center gap-1.5 shrink-0"> <div class="ml-auto flex items-center gap-1.5 shrink-0">
<button onclick={toggleTheme} class="ve-action-btn" title={theme === 'dark' ? 'Modo claro' : 'Modo oscuro'}>
{#if theme === 'dark'}
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>
</svg>
{:else}
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
{/if}
</button>
<button onclick={() => showGraph = !showGraph} class="ve-action-btn {showGraph ? 'active' : ''}">Grafo</button> <button onclick={() => showGraph = !showGraph} class="ve-action-btn {showGraph ? 'active' : ''}">Grafo</button>
<button onclick={() => showJson = !showJson} class="ve-action-btn {showJson ? 'active' : ''}">JSON</button> <button onclick={() => showJson = !showJson} class="ve-action-btn {showJson ? 'active' : ''}">JSON</button>

@ -229,9 +229,9 @@ const selectedField = $derived(selectedPath.length > 0 ? getFieldAtPath(selected
const selectedKey = $derived(selectedPath.length > 0 ? selectedPath[selectedPath.length - 1] : '') const selectedKey = $derived(selectedPath.length > 0 ? selectedPath[selectedPath.length - 1] : '')
</script> </script>
<div class="flex-1 flex flex-col min-h-0"> <div class="h-full flex flex-col">
<div class="flex flex-1 min-h-0 overflow-hidden"> <div class="flex h-full min-h-0 overflow-hidden">
<!-- Left sidebar (resizable) --> <!-- Left sidebar (resizable) -->
<div style="width: {sidebarWidth}px" class="shrink-0 flex flex-col min-h-0"> <div style="width: {sidebarWidth}px" class="shrink-0 flex flex-col min-h-0">
@ -300,7 +300,7 @@ const selectedKey = $derived(selectedPath.length > 0 ? selectedPath[selectedPa
<button <button
type="button" type="button"
onclick={() => showDeleteConfirm = true} onclick={() => showDeleteConfirm = true}
class="text-xs text-gray-600 hover:text-red-400 transition-colors" class="ve-btn-link danger"
>{glob.t('vcen.editor.mtas.field.delete')}</button> >{glob.t('vcen.editor.mtas.field.delete')}</button>
</div> </div>
<div class="max-w-xl"> <div class="max-w-xl">
@ -347,31 +347,20 @@ const selectedKey = $derived(selectedPath.length > 0 ? selectedPath[selectedPa
<!-- Delete confirmation modal --> <!-- Delete confirmation modal -->
{#if showDeleteConfirm && selectedField} {#if showDeleteConfirm && selectedField}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4"> <div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
<div class="absolute inset-0 bg-black/60" role="presentation" onclick={() => showDeleteConfirm = false} onkeydown={() => {}}></div> onclick={() => showDeleteConfirm = false}>
<div class="relative z-10 w-full max-w-sm rounded-xl border border-gray-800 bg-gray-900 shadow-2xl flex flex-col gap-4 p-6"> <div class="bg-gray-900 border border-gray-700 rounded-lg p-5 flex flex-col gap-4 min-w-60 max-w-xs shadow-xl"
<div class="flex items-start gap-3"> onclick={e => e.stopPropagation()}>
<div class="shrink-0 rounded-full bg-red-950 p-2.5 text-red-400"> <p class="text-sm font-semibold text-gray-200">{glob.t('vcen.editor.mtas.field.deleteConfirm')}</p>
<svg class="h-5 w-5" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"> <div class="flex items-center gap-2 rounded bg-gray-800 px-2.5 py-1.5 -mt-2">
<path d="M3 4h10M6 4V2.5h4V4M5 4l.5 9h5l.5-9"/> <TypeBadge type={selectedField.type} showLabel={false} {glob} />
</svg> <span class="font-mono text-xs text-gray-300 truncate">{selectedKey}</span>
</div>
<div class="flex flex-col gap-1 min-w-0">
<h3 class="font-semibold text-gray-200">{glob.t('vcen.editor.mtas.field.deleteConfirm')}</h3>
<p class="text-sm text-gray-500">{glob.t('vcen.editor.mtas.field.deleteConfirmMsg')}</p>
<div class="mt-2 flex items-center gap-2 rounded-lg bg-gray-800 px-3 py-2">
<TypeBadge type={selectedField.type} showLabel={false} {glob} />
<span class="font-mono text-sm font-medium text-gray-200 truncate">{selectedKey}</span>
</div>
</div>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2 justify-end">
<button type="button" onclick={() => showDeleteConfirm = false} <button type="button" onclick={() => showDeleteConfirm = false} class="ve-btn">
class="flex-1 inline-flex items-center justify-center px-4 py-2 rounded border border-gray-700 text-gray-300 hover:bg-gray-800 transition-colors">
{glob.t('vcen.editor.mtas.field.deleteNo')} {glob.t('vcen.editor.mtas.field.deleteNo')}
</button> </button>
<button type="button" onclick={() => { deleteFieldAtPath(selectedPath); showDeleteConfirm = false }} <button type="button" onclick={() => { deleteFieldAtPath(selectedPath); showDeleteConfirm = false }} class="ve-btn danger">
class="flex-1 inline-flex items-center justify-center px-4 py-2 rounded bg-red-900 text-red-200 hover:bg-red-800 transition-colors">
{glob.t('vcen.editor.mtas.field.deleteYes')} {glob.t('vcen.editor.mtas.field.deleteYes')}
</button> </button>
</div> </div>

@ -86,17 +86,13 @@ function groupValue(f: MetadataField): Metadata { return f.type === 'group' ? f.
<button type="button" <button type="button"
onclick={e => { e.stopPropagation(); onMove(np, -1) }} onclick={e => { e.stopPropagation(); onMove(np, -1) }}
disabled={i === 0} disabled={i === 0}
class="flex items-center justify-center w-5 h-5 rounded text-gray-500 hover:text-gray-300 hover:bg-gray-700 transition-colors disabled:opacity-20" class="flex items-center justify-center w-5 h-5 rounded text-xs text-gray-500 hover:text-gray-300 hover:bg-gray-700 transition-colors disabled:opacity-20"
title={glob.t('vcen.editor.mtas.field.moveUp')}> title={glob.t('vcen.editor.mtas.field.moveUp')}>↑</button>
<svg class="h-3 w-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 8l4-4 4 4"/></svg>
</button>
<button type="button" <button type="button"
onclick={e => { e.stopPropagation(); onMove(np, 1) }} onclick={e => { e.stopPropagation(); onMove(np, 1) }}
disabled={i === order.length - 1} disabled={i === order.length - 1}
class="flex items-center justify-center w-5 h-5 rounded text-gray-500 hover:text-gray-300 hover:bg-gray-700 transition-colors disabled:opacity-20" class="flex items-center justify-center w-5 h-5 rounded text-xs text-gray-500 hover:text-gray-300 hover:bg-gray-700 transition-colors disabled:opacity-20"
title={glob.t('vcen.editor.mtas.field.moveDown')}> title={glob.t('vcen.editor.mtas.field.moveDown')}>↓</button>
<svg class="h-3 w-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 4l4 4 4-4"/></svg>
</button>
</div> </div>
</div> </div>

@ -22,7 +22,7 @@ const defaultLocale = $derived(editor.cat.lang as string)
const tags = $derived((editor.cat as any).metaTags as string[] ?? []) const tags = $derived((editor.cat as any).metaTags as string[] ?? [])
</script> </script>
<div class="flex flex-col flex-1 min-h-0"> <div class="h-full flex flex-col">
<EditorMetaTree <EditorMetaTree
value={meta} value={meta}
{glob} {glob}

@ -10,6 +10,7 @@ import RuleList from './RuleList.svelte'
import IssueSection from './IssueSection.svelte' import IssueSection from './IssueSection.svelte'
import ObjAttsPanel from './ObjAttsPanel.svelte' import ObjAttsPanel from './ObjAttsPanel.svelte'
import MetaPanel from './MetaPanel.svelte' import MetaPanel from './MetaPanel.svelte'
import VisualPanel from './VisualPanel.svelte'
const editor = getContext<CatalogEditorStore>('catalog-editor') const editor = getContext<CatalogEditorStore>('catalog-editor')
const glob = getContext<EditorGlob>('editor-glob') const glob = getContext<EditorGlob>('editor-glob')
@ -22,7 +23,10 @@ const objId = $derived(
? (sel as any).objId : null) ? (sel as any).objId : null)
const obj = $derived(objId ? editor.findObj(objId) ?? null : null) const obj = $derived(objId ? editor.findObj(objId) ?? null : null)
const isNew = $derived(editor.newEntityId !== null && editor.newEntityId === (objId as string)) const isNew = $derived(editor.newEntityId !== null && editor.newEntityId === (objId as string))
const tab = $derived(sel.kind === 'obj-atts' ? 'atts' : sel.kind === 'obj-meta' ? 'meta' : sel.kind === 'obj-issues' ? 'issues' : 'info') const tabFromSel = $derived(sel.kind === 'obj-atts' ? 'atts' : sel.kind === 'obj-meta' ? 'meta' : sel.kind === 'obj-issues' ? 'issues' : 'info')
let extraTab = $state<'visual' | null>(null)
$effect(() => { void sel; extraTab = null })
const tab = $derived(extraTab ?? tabFromSel)
const computedState = $derived.by(() => { const computedState = $derived.by(() => {
if (!obj) return null if (!obj) return null
@ -52,7 +56,9 @@ function cancelNew() {
editor.select({ kind: 'cat-info' }) editor.select({ kind: 'cat-info' })
} }
function goTab(t: 'info' | 'atts' | 'issues' | 'meta') { function goTab(t: 'info' | 'atts' | 'issues' | 'meta' | 'visual') {
if (t === 'visual') { extraTab = 'visual'; return }
extraTab = null
if (!objId) return if (!objId) return
if (t === 'info') editor.select({ kind: 'obj-info', objId }) if (t === 'info') editor.select({ kind: 'obj-info', objId })
else if (t === 'atts') editor.select({ kind: 'obj-atts', objId }) else if (t === 'atts') editor.select({ kind: 'obj-atts', objId })
@ -97,13 +103,14 @@ function goTab(t: 'info' | 'atts' | 'issues' | 'meta') {
{#each [ {#each [
{ id: 'info', label: 'General' }, { id: 'info', label: 'General' },
{ id: 'atts', label: 'Atributos' }, { id: 'atts', label: 'Atributos' },
{ id: 'visual', label: 'Visual' },
{ id: 'issues', label: 'Reglas/Incidencias' }, { id: 'issues', label: 'Reglas/Incidencias' },
{ id: 'meta', label: 'Metadatos' }, { id: 'meta', label: 'Metadatos' },
] as t (t.id)} ] as t (t.id)}
{@const isActive = tab === t.id} {@const isActive = tab === t.id}
<button <button
onclick={() => goTab(t.id as any)} onclick={() => goTab(t.id as any)}
class="px-4 py-2.5 text-xs font-medium border-b-2 -mb-px transition-colors 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' {isActive ? 'text-indigo-400 border-indigo-500'
: 'text-gray-500 border-transparent hover:text-gray-300'}" : 'text-gray-500 border-transparent hover:text-gray-300'}"
>{t.label}</button> >{t.label}</button>
@ -235,30 +242,22 @@ function goTab(t: 'info' | 'atts' | 'issues' | 'meta') {
{/if} {/if}
</section> </section>
<!-- Visual -->
<section class="flex flex-col gap-3">
<h3 class="section-title">Visual</h3>
<div class="flex flex-col gap-1">
<label class="ve-label">Base path <span class="text-gray-700">(override)</span></label>
<input type="text" value={obj.visual?.basePath ?? ''}
oninput={e => editor.updateObjVisual(objId, { basePath: e.currentTarget.value || undefined })}
placeholder={(cat as any).visualDefaults?.basePath || 'Heredado del catálogo'}
class="field-input text-xs font-mono" />
</div>
<div class="flex flex-col gap-1">
<label class="ve-label">Fallback <span class="text-gray-700">(override)</span></label>
<input type="text" value={obj.visual?.fallback ?? ''}
oninput={e => editor.updateObjVisual(objId, { fallback: e.currentTarget.value || undefined })}
placeholder={(cat as any).visualDefaults?.fallback || 'Heredado del catálogo'}
class="field-input text-xs font-mono" />
</div>
</section>
</div> </div>
{:else if tab === 'atts'} {:else if tab === 'atts'}
<ObjAttsPanel /> <ObjAttsPanel />
{:else if tab === 'visual'}
<div class="h-full overflow-hidden">
<VisualPanel
visual={obj.visual}
catDefaults={(cat as any).visualDefaults ?? {}}
locales={cat.langs}
defaultLocale={cat.lang}
onUpdate={v => editor.setObjVisual(objId, v)}
/>
</div>
{:else if tab === 'issues'} {:else if tab === 'issues'}
<div class="ve-panel-content overflow-y-auto h-full"> <div class="ve-panel-content overflow-y-auto h-full">

@ -9,6 +9,7 @@ import RuleList from './RuleList.svelte'
import IssueSection from './IssueSection.svelte' import IssueSection from './IssueSection.svelte'
import SecAttsPanel from './SecAttsPanel.svelte' import SecAttsPanel from './SecAttsPanel.svelte'
import MetaPanel from './MetaPanel.svelte' import MetaPanel from './MetaPanel.svelte'
import VisualPanel from './VisualPanel.svelte'
const editor = getContext<CatalogEditorStore>('catalog-editor') const editor = getContext<CatalogEditorStore>('catalog-editor')
const glob = getContext<EditorGlob>('editor-glob') const glob = getContext<EditorGlob>('editor-glob')
@ -23,7 +24,10 @@ const ctx = $derived(
: null) : null)
const isNew = $derived(editor.newEntityId !== null && editor.newEntityId === (ctx?.secId as string | undefined)) const isNew = $derived(editor.newEntityId !== null && editor.newEntityId === (ctx?.secId as string | undefined))
const tab = $derived(sel.kind === 'sec-atts' ? 'atts' : sel.kind === 'sec-meta' ? 'meta' : sel.kind === 'sec-issues' ? 'issues' : 'info') const tabFromSel = $derived(sel.kind === 'sec-atts' ? 'atts' : sel.kind === 'sec-meta' ? 'meta' : sel.kind === 'sec-issues' ? 'issues' : 'info')
let extraTab = $state<'visual' | null>(null)
$effect(() => { void sel; extraTab = null })
const tab = $derived(extraTab ?? tabFromSel)
let editId = $state('') let editId = $state('')
let idError = $state('') let idError = $state('')
@ -50,7 +54,9 @@ function cancelNew() {
editor.select({ kind: 'obj-info', objId: ctx.objId }) editor.select({ kind: 'obj-info', objId: ctx.objId })
} }
function goTab(t: 'info' | 'atts' | 'issues' | 'meta') { function goTab(t: 'info' | 'atts' | 'issues' | 'meta' | 'visual') {
if (t === 'visual') { extraTab = 'visual'; return }
extraTab = null
if (!ctx) return if (!ctx) return
const { objId, secId } = ctx const { objId, secId } = ctx
if (t === 'info') editor.select({ kind: 'sec-info', objId, secId }) if (t === 'info') editor.select({ kind: 'sec-info', objId, secId })
@ -93,13 +99,14 @@ function goTab(t: 'info' | 'atts' | 'issues' | 'meta') {
{#each [ {#each [
{ id: 'info', label: 'General' }, { id: 'info', label: 'General' },
{ id: 'atts', label: 'Atributos' }, { id: 'atts', label: 'Atributos' },
{ id: 'visual', label: 'Visual' },
{ id: 'issues', label: 'Reglas/Incidencias' }, { id: 'issues', label: 'Reglas/Incidencias' },
{ id: 'meta', label: 'Metadatos' }, { id: 'meta', label: 'Metadatos' },
] as t (t.id)} ] as t (t.id)}
{@const isActive = tab === t.id} {@const isActive = tab === t.id}
<button <button
onclick={() => goTab(t.id as any)} onclick={() => goTab(t.id as any)}
class="px-4 py-2.5 text-xs font-medium border-b-2 -mb-px transition-colors 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' {isActive ? 'text-indigo-400 border-indigo-500'
: 'text-gray-500 border-transparent hover:text-gray-300'}" : 'text-gray-500 border-transparent hover:text-gray-300'}"
>{t.label}</button> >{t.label}</button>
@ -229,30 +236,22 @@ function goTab(t: 'info' | 'atts' | 'issues' | 'meta') {
{/if} {/if}
</section> </section>
<!-- Visual -->
<section class="flex flex-col gap-3">
<h3 class="section-title">Visual</h3>
<div class="flex flex-col gap-1">
<label class="ve-label">Base path <span class="text-gray-700">(override)</span></label>
<input type="text" value={sec.visual?.basePath ?? ''}
oninput={e => editor.updateSecVisual(objId, secId, { basePath: e.currentTarget.value || undefined })}
placeholder={(cat as any).visualDefaults?.basePath || 'Heredado del catálogo'}
class="field-input text-xs font-mono" />
</div>
<div class="flex flex-col gap-1">
<label class="ve-label">Fallback <span class="text-gray-700">(override)</span></label>
<input type="text" value={sec.visual?.fallback ?? ''}
oninput={e => editor.updateSecVisual(objId, secId, { fallback: e.currentTarget.value || undefined })}
placeholder={(cat as any).visualDefaults?.fallback || 'Heredado del catálogo'}
class="field-input text-xs font-mono" />
</div>
</section>
</div> </div>
{:else if tab === 'atts'} {:else if tab === 'atts'}
<SecAttsPanel /> <SecAttsPanel />
{:else if tab === 'visual'}
<div class="h-full overflow-hidden">
<VisualPanel
visual={sec.visual}
catDefaults={(cat as any).visualDefaults ?? {}}
locales={cat.langs}
defaultLocale={cat.lang}
onUpdate={v => editor.setSecVisual(objId, secId, v)}
/>
</div>
{:else if tab === 'issues'} {:else if tab === 'issues'}
<div class="ve-panel-content overflow-y-auto h-full"> <div class="ve-panel-content overflow-y-auto h-full">

@ -0,0 +1,879 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { Visual, View, ViewType, ViewID, Hotspot, HotspotID, HotspotMode } 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 MetadataEditor from '@/mtas/svelte/components/MetadataEditor.svelte'
import { isValidId } from '@/vcen-edit/lib/validate'
const editor = getContext<CatalogEditorStore>('catalog-editor')
const glob = getContext<EditorGlob>('editor-glob')
const VIEW_TYPES: { value: ViewType; label: string }[] = [
{ value: 'front', label: 'Frontal' },
{ value: 'side', label: 'Lateral' },
{ value: 'rear', label: 'Trasera' },
{ value: 'top', label: 'Superior' },
{ value: 'isometric', label: 'Isométrica' },
{ value: 'panoramic', label: 'Panorámica' },
{ value: 'detail', label: 'Detalle' },
{ value: 'blueprint', label: 'Plano' },
{ value: 'custom', label: 'Personalizada' },
]
const STRATEGY_LABELS: Record<string, string> = {
static: 'Estática', dynamic: 'Dinámica', layers: 'Capas', api: 'API', d3: '3D',
}
const STRATEGY_VALUES = ['static', 'dynamic', 'layers', 'api', 'd3'] as const
const HOTSPOT_MODES: { value: HotspotMode; label: string }[] = [
{ value: 'tooltip', label: 'Tooltip' },
{ value: 'popup', label: 'Popup' },
]
let {
visual,
catDefaults = {},
locales = ['es'],
defaultLocale,
onUpdate,
}: {
visual: Visual | undefined
catDefaults?: { basePath?: string; fallback?: string }
locales?: string[]
defaultLocale: string
onUpdate: (v: Visual | undefined) => void
} = $props()
const views = $derived(visual?.views ?? [])
// ── Selección ──────────────────────────────────────────────────────────────
type Sel =
| 'general'
| { kind: 'view'; viewId: ViewID }
| { kind: 'hotspot'; viewId: ViewID; hotspotId: HotspotID }
let selected = $state<Sel>('general')
$effect(() => {
if (selected === 'general') return
const s = selected as Exclude<Sel, 'general'>
const view = views.find(v => v.id === s.viewId)
if (!view) { selected = 'general'; return }
if (s.kind === 'hotspot' && !view.hotspots?.find(h => h.id === s.hotspotId)) {
selected = { kind: 'view', viewId: s.viewId }
}
})
// ── "New" edit state ───────────────────────────────────────────────────────
let newViewId = $state<ViewID | null>(null)
let editViewId = $state('')
let viewIdError = $state('')
let newHotspotId = $state<HotspotID | null>(null)
let editHotspotId = $state('')
let hotspotIdError = $state('')
// ── View validation errors ─────────────────────────────────────────────────
let codeError = $state('')
let widthError = $state('')
let heightError = $state('')
// ── Hotspot validation errors ──────────────────────────────────────────────
let posXError = $state('')
let posYError = $state('')
let colorError = $state('')
// ── Pick-position mode (diana) ────────────────────────────────────────
let pickingPos = $state(false)
// ── ID generators ──────────────────────────────────────────────────────────
function genViewId(): ViewID { return `vw_${Date.now().toString(36)}_${Math.random().toString(36).slice(2,5)}` as ViewID }
function genHotspotId(): HotspotID { return `hs_${Date.now().toString(36)}_${Math.random().toString(36).slice(2,5)}` as HotspotID }
// ── Visual mutations ───────────────────────────────────────────────────────
function patchVisual(patch: Partial<Visual>) {
const next: Visual = { ...(visual ?? {}), ...patch }
const empty = !next.basePath && !next.fallback && !next.defaultViewId
&& (!next.views || next.views.length === 0)
onUpdate(empty ? undefined : next)
}
function addView() {
const id = genViewId()
const newView: View = {
id, label: { [defaultLocale]: '' }, code: `vista-${views.length + 1}`,
viewType: 'front', config: { strategy: 'static', url: '' },
width: 800, height: 600,
}
patchVisual({ views: [...views, newView] })
newViewId = id; editViewId = ''; viewIdError = ''
selected = { kind: 'view', viewId: id }
}
function saveViewNew() {
if (!newViewId) return
const trimmed = editViewId.trim()
if (!trimmed) { viewIdError = 'El ID es obligatorio'; return }
if (!isValidId(trimmed)) { viewIdError = 'Solo letras, números y guiones'; return }
const fullId = `vw_${trimmed}` as ViewID
if (fullId !== newViewId && views.some(v => v.id === fullId)) { viewIdError = 'ID ya existe'; return }
if (fullId !== newViewId) {
const updatedViews = views.map(v => v.id === newViewId ? { ...v, id: fullId } : v)
const patch: Partial<Visual> = { views: updatedViews }
if (visual?.defaultViewId === newViewId) patch.defaultViewId = fullId
patchVisual(patch)
selected = { kind: 'view', viewId: fullId }
}
newViewId = null
}
function cancelViewNew() {
if (!newViewId) return
removeView(newViewId); newViewId = null
}
function removeView(viewId: ViewID) {
const newViews = views.filter(v => v.id !== viewId)
const patch: Partial<Visual> = { views: newViews.length ? newViews : undefined }
if (visual?.defaultViewId === viewId) patch.defaultViewId = undefined
patchVisual(patch)
}
function updateView(viewId: ViewID, patch: Partial<View>) {
patchVisual({ views: views.map(v => v.id === viewId ? { ...v, ...patch } : v) })
}
function moveView(viewId: ViewID, dir: 'up' | 'down') {
const arr = [...views]
const idx = arr.findIndex(v => v.id === viewId)
if (idx === -1) return
const target = dir === 'up' ? idx - 1 : idx + 1
if (target < 0 || target >= arr.length) return
;[arr[idx], arr[target]] = [arr[target], arr[idx]]
patchVisual({ views: arr })
}
function defaultConfig(strategy: string): any {
if (strategy === 'static') return { strategy, url: '' }
if (strategy === 'dynamic') return { strategy, template: '' }
if (strategy === 'layers') return { strategy, layers: [] }
if (strategy === 'api') return { strategy, endpoint: '' }
if (strategy === 'd3') return { strategy, modelUrl: '', textureAttrs: [] }
return { strategy }
}
// ── Hotspot mutations ──────────────────────────────────────────────────────
function addHotspot(viewId: ViewID) {
const id = genHotspotId()
const hs: Hotspot = { id, label: { [defaultLocale]: '' }, mode: 'tooltip', posX: 50, posY: 50 }
const view = views.find(v => v.id === viewId)
if (!view) return
updateView(viewId, { hotspots: [...(view.hotspots ?? []), hs] })
newHotspotId = id; editHotspotId = ''; hotspotIdError = ''
selected = { kind: 'hotspot', viewId, hotspotId: id }
}
function saveHotspotNew() {
if (!newHotspotId || selected === 'general' || (selected as any).kind !== 'hotspot') return
const viewId = (selected as any).viewId as ViewID
const trimmed = editHotspotId.trim()
if (!trimmed) { hotspotIdError = 'El ID es obligatorio'; return }
if (!isValidId(trimmed)) { hotspotIdError = 'Solo letras, números y guiones'; return }
const fullId = `hs_${trimmed}` as HotspotID
const view = views.find(v => v.id === viewId)
if (view?.hotspots?.some(h => h.id === fullId && h.id !== newHotspotId)) {
hotspotIdError = 'ID ya existe'; return
}
if (fullId !== newHotspotId) {
updateView(viewId, {
hotspots: (view?.hotspots ?? []).map(h => h.id === newHotspotId ? { ...h, id: fullId } : h)
})
selected = { kind: 'hotspot', viewId, hotspotId: fullId }
}
newHotspotId = null
}
function cancelHotspotNew() {
if (!newHotspotId || selected === 'general' || (selected as any).kind !== 'hotspot') return
const viewId = (selected as any).viewId as ViewID
removeHotspot(viewId, newHotspotId)
newHotspotId = null
selected = { kind: 'view', viewId }
}
function removeHotspot(viewId: ViewID, hotspotId: HotspotID) {
const view = views.find(v => v.id === viewId)
if (!view) return
updateView(viewId, { hotspots: (view.hotspots ?? []).filter(h => h.id !== hotspotId) })
}
function updateHotspot(viewId: ViewID, hotspotId: HotspotID, patch: Partial<Hotspot>) {
const view = views.find(v => v.id === viewId)
if (!view) return
updateView(viewId, {
hotspots: (view.hotspots ?? []).map(h => h.id === hotspotId ? { ...h, ...patch } : h)
})
}
// ── Validation helpers ─────────────────────────────────────────────────────
function isValidHexColor(v: string): boolean {
return /^#[0-9a-fA-F]{6}$/.test(v)
}
function clampPos(v: number): number {
return Math.round(Math.min(100, Math.max(0, v)) * 10) / 10
}
function validateViewField(field: 'code' | 'width' | 'height', val: any) {
if (field === 'code') codeError = String(val).trim() ? '' : 'El código es obligatorio'
if (field === 'width') widthError = (Number(val) > 0) ? '' : 'Debe ser un número positivo'
if (field === 'height') heightError = (Number(val) > 0) ? '' : 'Debe ser un número positivo'
}
function validateHotspotField(field: 'posX' | 'posY' | 'color', val: any) {
if (field === 'posX') posXError = (typeof val === 'number' && val >= 0 && val <= 100) ? '' : 'Debe ser 0–100'
if (field === 'posY') posYError = (typeof val === 'number' && val >= 0 && val <= 100) ? '' : 'Debe ser 0–100'
if (field === 'color') colorError = (!val || isValidHexColor(val)) ? '' : 'Color hex inválido (#rrggbb)'
}
// ── Preview click → pick hotspot position ─────────────────────────────
function handlePreviewClick(e: MouseEvent) {
if (!pickingPos) return
if (selected === 'general' || (selected as any).kind !== 'hotspot') return
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const x = clampPos(((e.clientX - rect.left) / rect.width) * 100)
const y = clampPos(((e.clientY - rect.top) / rect.height) * 100)
const viewId = (selected as any).viewId as ViewID
const hotspotId = (selected as any).hotspotId as HotspotID
updateHotspot(viewId, hotspotId, { posX: x, posY: y })
pickingPos = false
}
// ── Helpers ────────────────────────────────────────────────────────────────
function getLabelStr(record: Record<string, string> | undefined): string {
return record?.[defaultLocale] ?? ''
}
// ── Derived selection ──────────────────────────────────────────────────────
const selectedView = $derived(
selected !== 'general' ? views.find(v => v.id === (selected as any).viewId) ?? null : null
)
const selectedHotspot = $derived(
selected !== 'general' && (selected as any).kind === 'hotspot' && selectedView
? selectedView.hotspots?.find(h => h.id === (selected as any).hotspotId) ?? null
: null
)
// ── Preview ────────────────────────────────────────────────────────────────
const previewView = $derived(selectedView)
const previewUrl = $derived.by(() => {
if (!previewView) return null
const c = previewView.config
if (c.strategy === 'static' && (c as any).url) return (c as any).url as string
return null
})
const showPreview = $derived(selected !== 'general' && selectedView !== null)
</script>
<div class="h-full flex min-h-0">
<!-- ── Sidebar ───────────────────────────────────────────────── -->
<div class="w-44 shrink-0 border-r border-gray-800 flex flex-col overflow-y-auto py-2">
<!-- General -->
<button
onclick={() => selected = 'general'}
class="flex items-center gap-1.5 px-3 py-2 text-sm cursor-pointer select-none transition-colors border-l-2 w-full
{selected === 'general'
? 'bg-gray-800/70 text-gray-100 border-indigo-500'
: 'text-gray-400 hover:text-gray-100 hover:bg-gray-800/40 border-transparent'}">
<svg class="h-3 w-3 shrink-0" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M1 2h10M1 5h7M1 8h4"/>
</svg>
General
</button>
<!-- Vistas header -->
<div class="px-3 pt-3 pb-1 mt-1 mb-0.5 flex items-center justify-between border-t border-gray-800/60">
<span class="text-[10px] font-bold uppercase tracking-widest text-gray-700">Vistas</span>
<button
onclick={addView}
class="text-gray-700 hover:text-indigo-400 transition-colors text-sm leading-none"
title="Nueva vista">+</button>
</div>
{#if views.length === 0}
<div class="pl-7 pr-3 py-1.5 text-[11px] text-gray-700 italic">Sin vistas</div>
{:else}
{#each views as view, idx (view.id)}
{@const isViewSel = selected !== 'general' && (selected as any).viewId === view.id}
{@const isViewActive = isViewSel && (selected as any).kind === 'view'}
<!-- View row -->
<div class="group/view w-full flex items-center border-l-2
{isViewActive
? 'bg-gray-800/70 border-indigo-500'
: isViewSel
? 'border-indigo-500/30'
: 'border-transparent hover:bg-gray-800/40'}">
<button
onclick={() => selected = { kind: 'view', viewId: view.id }}
class="flex-1 min-w-0 pl-3 pr-1 py-1.5 text-left select-none transition-colors">
<span class="block text-sm truncate
{isViewActive ? 'text-gray-100' : 'text-gray-400 group-hover/view:text-gray-100'}">
{getLabelStr(view.label) || view.code}
</span>
</button>
<!-- Botón añadir hotspot (siempre visible) + move buttons (hover) -->
<div class="flex items-center shrink-0 pr-1 gap-0.5">
<div class="hidden group-hover/view:flex items-center gap-0.5">
<button
onclick={e => { e.stopPropagation(); moveView(view.id, 'up') }}
disabled={idx === 0}
title="Subir"
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={e => { e.stopPropagation(); moveView(view.id, 'down') }}
disabled={idx === views.length - 1}
title="Bajar"
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>
</div>
<button
onclick={e => { e.stopPropagation(); addHotspot(view.id) }}
title="Nuevo hotspot"
class="flex items-center justify-center w-5 h-5 rounded text-xs text-gray-600
hover:text-indigo-400 hover:bg-gray-700 transition-colors"
>+</button>
</div>
</div>
{#each (view.hotspots ?? []) as hs (hs.id)}
{@const isHsSel = selected !== 'general'
&& (selected as any).kind === 'hotspot'
&& (selected as any).viewId === view.id
&& (selected as any).hotspotId === hs.id}
{@const hsLabel = getLabelStr(hs.label)}
<div class="w-full border-l-2
{isHsSel
? 'bg-gray-800/70 border-indigo-500'
: 'border-transparent hover:bg-gray-800/40'}">
<button
onclick={() => selected = { kind: 'hotspot', viewId: view.id, hotspotId: hs.id }}
class="w-full pl-7 pr-3 py-1 text-left select-none transition-colors">
<span class="block text-sm truncate
{isHsSel ? 'text-gray-100' : 'text-gray-400 hover:text-gray-100'}">
{hsLabel || 'Sin etiqueta'}
</span>
</button>
</div>
{/each}
{/each}
{/if}
</div>
<!-- ── Detail panel ──────────────────────────────────────────── -->
<div class="w-[40%] shrink-0 min-w-0 overflow-y-auto">
{#if selected === 'general'}
<div class="ve-panel-content">
<h3 class="section-title">Configuración general</h3>
<div class="flex flex-col gap-1">
<label class="ve-label">Base path <span class="text-gray-700">(override)</span></label>
<input type="text"
value={visual?.basePath ?? ''}
oninput={e => patchVisual({ basePath: e.currentTarget.value || undefined })}
placeholder={catDefaults.basePath || 'Heredado del catálogo'}
class="field-input text-xs font-mono" />
</div>
<div class="flex flex-col gap-1">
<label class="ve-label">Fallback <span class="text-gray-700">(override)</span></label>
<input type="text"
value={visual?.fallback ?? ''}
oninput={e => patchVisual({ fallback: e.currentTarget.value || undefined })}
placeholder={catDefaults.fallback || 'Heredado del catálogo'}
class="field-input text-xs font-mono" />
</div>
{#if views.length > 0}
<div class="flex flex-col gap-1">
<label class="ve-label">Vista por defecto</label>
<select
value={visual?.defaultViewId ?? ''}
onchange={e => patchVisual({ defaultViewId: (e.currentTarget.value || undefined) as ViewID | undefined })}
class="field-input text-xs">
<option value="">— primera vista</option>
{#each views as v (v.id)}
<option value={v.id}>{getLabelStr(v.label) || v.code}</option>
{/each}
</select>
</div>
{:else}
<p class="text-xs text-gray-700 italic">Añade vistas desde el panel lateral.</p>
{/if}
</div>
{:else if selectedView && (selected as any).kind === 'view'}
{@const view = selectedView}
{@const isNew = newViewId === view.id}
<div class="ve-panel-content">
<!-- Header -->
<div class="flex w-full items-center justify-between section-title" style="margin-bottom:0">
<span class="flex-1 text-left truncate pr-4"> {isNew ? 'Nueva vista' : (getLabelStr(view.label) || view.code)}</span>
<div class="flex items-center gap-2">
{#if isNew}
<button onclick={cancelViewNew} class="ve-action-btn">Cancelar</button>
<button onclick={saveViewNew} class="ve-action-btn primary">Guardar</button>
{:else}
<button onclick={() => removeView(view.id)} class="ve-btn-link danger">Eliminar</button>
{/if}
</div>
</div>
<!-- ID -->
<div class="flex flex-col gap-1">
<label class="ve-label">ID</label>
{#if isNew}
<div class="flex items-center gap-1.5">
<span class="font-mono text-xs text-gray-600 shrink-0">vw_</span>
<input type="text"
bind:value={editViewId}
oninput={() => viewIdError = ''}
onkeydown={e => { if (e.key === 'Enter') saveViewNew() }}
placeholder="frontal, detalle-rueda…"
class={['field-input font-mono text-xs flex-1', viewIdError ? 'border-red-600' : ''].join(' ')} />
</div>
{#if viewIdError}<span class="text-xs text-red-400">{viewIdError}</span>{/if}
{:else}
<span class="text-xs text-gray-500 py-1">{view.id}</span>
{/if}
</div>
<!-- Code + Type -->
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1">
<label class="ve-label">Código <span class="text-red-500">*</span></label>
<input type="text"
value={view.code}
oninput={e => {
updateView(view.id, { code: e.currentTarget.value })
validateViewField('code', e.currentTarget.value)
}}
placeholder="front, detail-wheel…"
class={['field-input font-mono text-xs', codeError ? 'border-red-600' : ''].join(' ')} />
{#if codeError}<span class="text-xs text-red-400">{codeError}</span>{/if}
</div>
<div class="flex flex-col gap-1">
<label class="ve-label">Tipo</label>
<select
value={view.viewType}
onchange={e => updateView(view.id, { viewType: e.currentTarget.value as ViewType })}
class="field-input text-xs">
{#each VIEW_TYPES as vt (vt.value)}
<option value={vt.value}>{vt.label}</option>
{/each}
</select>
</div>
</div>
<!-- Width + Height -->
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1">
<label class="ve-label">Ancho (px) <span class="text-red-500">*</span></label>
<input type="number" min="1"
value={view.width ?? ''}
oninput={e => {
const v = parseInt(e.currentTarget.value) || 0
updateView(view.id, { width: v })
validateViewField('width', v)
}}
placeholder="800"
class={['field-input text-xs', widthError ? 'border-red-600' : ''].join(' ')} />
{#if widthError}<span class="text-xs text-red-400">{widthError}</span>{/if}
</div>
<div class="flex flex-col gap-1">
<label class="ve-label">Alto (px) <span class="text-red-500">*</span></label>
<input type="number" min="1"
value={view.height ?? ''}
oninput={e => {
const v = parseInt(e.currentTarget.value) || 0
updateView(view.id, { height: v })
validateViewField('height', v)
}}
placeholder="600"
class={['field-input text-xs', heightError ? 'border-red-600' : ''].join(' ')} />
{#if heightError}<span class="text-xs text-red-400">{heightError}</span>{/if}
</div>
</div>
<!-- Label -->
<LingInput
value={view.label}
{locales}
{defaultLocale}
label="Etiqueta"
as="input"
variant="dark"
onUpdate={v => updateView(view.id, { label: v as any })} />
<!-- Icon -->
<div class="flex flex-col gap-1">
<label class="ve-label">Icono <span class="text-gray-700">(opcional)</span></label>
<input type="text"
value={view.icon ?? ''}
oninput={e => updateView(view.id, { icon: e.currentTarget.value || undefined })}
placeholder="car-front, image, eye…"
class="field-input text-xs font-mono" />
</div>
<!-- Strategy + config -->
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1">
<label class="ve-label">Estrategia</label>
<select
value={view.config.strategy}
onchange={e => updateView(view.id, { config: defaultConfig(e.currentTarget.value) })}
class="field-input text-xs">
{#each STRATEGY_VALUES as s (s)}
<option value={s}>{STRATEGY_LABELS[s]}</option>
{/each}
</select>
</div>
{#if view.config.strategy === 'static'}
<div class="flex flex-col gap-1">
<label class="ve-label">URL</label>
<input type="text"
value={(view.config as any).url ?? ''}
oninput={e => updateView(view.id, { config: { ...view.config, url: e.currentTarget.value } as any })}
placeholder="images/front.jpg"
class="field-input font-mono text-xs" />
</div>
{:else if view.config.strategy === 'dynamic'}
<div class="flex flex-col gap-1">
<label class="ve-label">Template</label>
<input type="text"
value={(view.config as any).template ?? ''}
oninput={e => updateView(view.id, { config: { ...view.config, template: e.currentTarget.value } as any })}
placeholder={"car_{at_color}.jpg"}
class="field-input font-mono text-xs" />
</div>
{:else if view.config.strategy === 'api'}
<div class="flex flex-col gap-1">
<label class="ve-label">Endpoint</label>
<input type="text"
value={(view.config as any).endpoint ?? ''}
oninput={e => updateView(view.id, { config: { ...view.config, endpoint: e.currentTarget.value } as any })}
placeholder="https://render.example.com"
class="field-input font-mono text-xs" />
</div>
{:else if view.config.strategy === 'd3'}
<div class="flex flex-col gap-1">
<label class="ve-label">Modelo URL</label>
<input type="text"
value={(view.config as any).modelUrl ?? ''}
oninput={e => updateView(view.id, { config: { ...view.config, modelUrl: e.currentTarget.value } as any })}
placeholder="models/object.glb"
class="field-input font-mono text-xs" />
</div>
{:else}
<div class="flex flex-col gap-1">
<label class="ve-label">Configuración</label>
<span class="text-xs text-gray-600 italic pt-1">Editable en modo avanzado</span>
</div>
{/if}
</div>
<!-- Metadata -->
<div class="flex flex-col gap-1">
<label class="ve-label">Metadatos</label>
<div class="h-64 border border-gray-800 rounded-lg overflow-hidden">
<MetadataEditor
value={view.metadata ?? {}}
dark={true}
{glob}
{locales}
{defaultLocale}
onUpdate={m => updateView(view.id, {
metadata: Object.keys(m).filter(k => k !== 'order').length === 0 ? undefined : m
})} />
</div>
</div>
</div>
{:else if selectedView && selectedHotspot && (selected as any).kind === 'hotspot'}
{@const view = selectedView}
{@const hs = selectedHotspot}
{@const isNew = newHotspotId === hs.id}
<div class="ve-panel-content">
<!-- Header -->
<div class="flex items-center justify-between section-title" style="margin-bottom:0">
<span>{isNew ? 'Nuevo hotspot' : (getLabelStr(hs.label) || hs.id)}</span>
<div class="flex items-center gap-2">
{#if isNew}
<button onclick={cancelHotspotNew} class="ve-action-btn">Cancelar</button>
<button onclick={saveHotspotNew} class="ve-action-btn primary">Guardar</button>
{:else}
<button onclick={() => removeHotspot(view.id, hs.id)} class="ve-btn-link danger">Eliminar</button>
{/if}
</div>
</div>
<!-- ID -->
<div class="flex flex-col gap-1">
<label class="ve-label">ID</label>
{#if isNew}
<div class="flex items-center gap-1.5">
<span class="font-mono text-xs text-gray-600 shrink-0">hs_</span>
<input type="text"
bind:value={editHotspotId}
oninput={() => hotspotIdError = ''}
onkeydown={e => { if (e.key === 'Enter') saveHotspotNew() }}
placeholder="rueda, interior…"
class={['field-input font-mono text-xs flex-1', hotspotIdError ? 'border-red-600' : ''].join(' ')} />
</div>
{#if hotspotIdError}<span class="text-xs text-red-400">{hotspotIdError}</span>{/if}
{:else}
<span class="font-mono text-xs text-gray-500 py-1">{hs.id}</span>
{/if}
</div>
<!-- Label -->
<LingInput
value={hs.label}
{locales}
{defaultLocale}
label="Etiqueta"
as="input"
variant="dark"
onUpdate={v => updateHotspot(view.id, hs.id, { label: v as any })} />
<!-- Mode + targetAtt -->
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1">
<label class="ve-label">Modo</label>
<select
value={hs.mode}
onchange={e => updateHotspot(view.id, hs.id, { mode: e.currentTarget.value as HotspotMode })}
class="field-input text-xs">
{#each HOTSPOT_MODES as m (m.value)}
<option value={m.value}>{m.label}</option>
{/each}
</select>
</div>
<div class="flex flex-col gap-1">
<label class="ve-label">Att objetivo <span class="text-gray-700">(opcional)</span></label>
<input type="text"
value={hs.targetAtt ?? ''}
oninput={e => updateHotspot(view.id, hs.id, { targetAtt: (e.currentTarget.value || undefined) as any })}
placeholder="at_color…"
class="field-input font-mono text-xs" />
</div>
</div>
<!-- Posición -->
<div class="flex items-end gap-3">
<div class="flex flex-col gap-1 flex-1">
<label class="ve-label">X (%) <span class="text-red-500">*</span></label>
<input type="number" min="0" max="100" step="0.1"
value={typeof hs.posX === 'number' ? hs.posX : parseFloat(String(hs.posX)) || 0}
oninput={e => {
const v = clampPos(parseFloat(e.currentTarget.value) || 0)
updateHotspot(view.id, hs.id, { posX: v })
validateHotspotField('posX', v)
}}
placeholder="50"
class={['field-input font-mono text-xs', posXError ? 'border-red-600' : ''].join(' ')} />
{#if posXError}<span class="text-xs text-red-400">{posXError}</span>{/if}
</div>
<div class="flex flex-col gap-1 flex-1">
<label class="ve-label">Y (%) <span class="text-red-500">*</span></label>
<input type="number" min="0" max="100" step="0.1"
value={typeof hs.posY === 'number' ? hs.posY : parseFloat(String(hs.posY)) || 0}
oninput={e => {
const v = clampPos(parseFloat(e.currentTarget.value) || 0)
updateHotspot(view.id, hs.id, { posY: v })
validateHotspotField('posY', v)
}}
placeholder="50"
class={['field-input font-mono text-xs', posYError ? 'border-red-600' : ''].join(' ')} />
{#if posYError}<span class="text-xs text-red-400">{posYError}</span>{/if}
</div>
<button
type="button"
onclick={() => pickingPos = !pickingPos}
title={pickingPos ? 'Cancelar posicionamiento' : 'Posicionar en imagen'}
class="flex items-center justify-center w-8 h-8 mb-0.5 rounded border transition-colors
{pickingPos
? 'bg-indigo-600 border-indigo-500 text-white'
: 'border-gray-700 text-gray-500 hover:border-indigo-600 hover:text-indigo-400'}"
>
<svg class="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="8" cy="8" r="3"/>
<circle cx="8" cy="8" r="6"/>
<line x1="8" y1="0" x2="8" y2="3"/>
<line x1="8" y1="13" x2="8" y2="16"/>
<line x1="0" y1="8" x2="3" y2="8"/>
<line x1="13" y1="8" x2="16" y2="8"/>
</svg>
</button>
</div>
<!-- Color + Radio + zIndex -->
<div class="grid grid-cols-3 gap-3">
<div class="flex flex-col gap-1">
<label class="ve-label">Color</label>
<div class="flex gap-1.5 items-center">
<input type="color"
value={hs.color ?? '#4f46e5'}
oninput={e => {
updateHotspot(view.id, hs.id, { color: e.currentTarget.value })
validateHotspotField('color', e.currentTarget.value)
}}
class="h-8 w-10 rounded border border-gray-800 bg-transparent cursor-pointer p-0.5" />
<input type="text"
value={hs.color ?? ''}
oninput={e => {
updateHotspot(view.id, hs.id, { color: e.currentTarget.value || undefined })
validateHotspotField('color', e.currentTarget.value)
}}
placeholder="#4f46e5"
class={['field-input font-mono text-xs flex-1', colorError ? 'border-red-600' : ''].join(' ')} />
</div>
{#if colorError}<span class="text-xs text-red-400">{colorError}</span>{/if}
</div>
<div class="flex flex-col gap-1">
<label class="ve-label">Radio (px)</label>
<input type="number" min="1"
value={hs.radius ?? ''}
oninput={e => updateHotspot(view.id, hs.id, { radius: parseInt(e.currentTarget.value) || undefined })}
placeholder="16"
class="field-input text-xs" />
</div>
<div class="flex flex-col gap-1">
<label class="ve-label">z-Index</label>
<input type="number"
value={hs.zIndex ?? ''}
oninput={e => updateHotspot(view.id, hs.id, { zIndex: parseInt(e.currentTarget.value) || undefined })}
placeholder="10"
class="field-input text-xs" />
</div>
</div>
<!-- Tooltip label -->
<LingInput
value={hs.tooltip ?? {}}
{locales}
{defaultLocale}
label="Tooltip"
as="input"
variant="dark"
onUpdate={v => updateHotspot(view.id, hs.id, { tooltip: (Object.keys(v).length ? v : undefined) as any })} />
<!-- Metadata -->
<div class="flex flex-col gap-1">
<label class="ve-label">Metadatos</label>
<div class="h-64 border border-gray-800 rounded-lg overflow-hidden">
<MetadataEditor
value={hs.metadata ?? {}}
dark={true}
{glob}
{locales}
{defaultLocale}
onUpdate={m => updateHotspot(view.id, hs.id, {
metadata: Object.keys(m).filter(k => k !== 'order').length === 0 ? undefined : m
})} />
</div>
</div>
</div>
{/if}
</div>
<!-- ── Preview panel (vista / hotspot) ─────────────────────── -->
{#if showPreview && previewView}
<div class="flex-1 w-[50%] min-w-0 border-l border-gray-800 flex flex-col overflow-y-auto">
<div class="px-3 py-2 border-b border-gray-800">
<span class="text-[10px] font-bold uppercase tracking-widest text-gray-700">Preview</span>
</div>
<div class="p-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: {previewView.width || 4} / {previewView.height || 3};
cursor: {pickingPos ? 'crosshair' : 'default'}"
onclick={handlePreviewClick}
>
{#if previewUrl}
<img
src={previewUrl}
alt={getLabelStr(previewView.label) || previewView.code}
class="absolute inset-0 w-full h-full object-contain pointer-events-none"
/>
{: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 ? 'Haz click para posicionar' : 'Sin imagen'}</span>
</div>
{/if}
<!-- Hotspot markers overlay -->
{#each (previewView.hotspots ?? []) as marker (marker.id)}
{@const isActive = selectedHotspot?.id === 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 z-10' : '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};
"
title={getLabelStr(marker.label) || marker.id}
></div>
{/each}
</div>
<!-- Info -->
<div class="mt-2 flex items-center justify-between text-[10px] text-gray-700 font-mono">
<span>{previewView.width || '?'} x {previewView.height || '?'} px</span>
{#if pickingPos}
<span class="text-indigo-400 font-sans font-medium">Click en imagen para posicionar</span>
{/if}
</div>
</div>
</div>
{/if}
</div>

@ -348,6 +348,13 @@ export function createCatalogEditorStore(initial: Cat) {
}); });
} }
function setObjVisual(objId: ObjID, visual: Visual | undefined) {
mutate(() => {
const obj = findObj(objId);
if (obj) obj.visual = visual;
});
}
function setObjIssues(objId: ObjID, issues: Issue[]) { function setObjIssues(objId: ObjID, issues: Issue[]) {
mutate(() => { mutate(() => {
const obj = findObj(objId); const obj = findObj(objId);
@ -458,6 +465,13 @@ export function createCatalogEditorStore(initial: Cat) {
}); });
} }
function setSecVisual(objId: ObjID, secId: SecID, visual: Visual | undefined) {
mutate(() => {
const sec = findSec(objId, secId);
if (sec) sec.visual = visual;
});
}
function setSecIssues(objId: ObjID, secId: SecID, issues: Issue[]) { function setSecIssues(objId: ObjID, secId: SecID, issues: Issue[]) {
mutate(() => { mutate(() => {
const sec = findSec(objId, secId); const sec = findSec(objId, secId);
@ -501,6 +515,7 @@ export function createCatalogEditorStore(initial: Cat) {
} else { } else {
select({ kind: 'obj-atts', objId, attId: att.id }); select({ kind: 'obj-atts', objId, attId: att.id });
} }
newEntityId = att.id;
return att.id; return att.id;
} }
@ -509,6 +524,7 @@ export function createCatalogEditorStore(initial: Cat) {
mutate(() => { cat.atts.push(att); }); mutate(() => { cat.atts.push(att); });
logr.info('catalog-editor', { es: 'Atributo de catálogo creado' }, { id: att.id }); logr.info('catalog-editor', { es: 'Atributo de catálogo creado' }, { id: att.id });
select({ kind: 'cat-atts', attId: att.id }); select({ kind: 'cat-atts', attId: att.id });
newEntityId = att.id;
return att.id; return att.id;
} }
@ -731,6 +747,7 @@ export function createCatalogEditorStore(initial: Cat) {
if ('attId' in s && s.attId === attId) { if ('attId' in s && s.attId === attId) {
selection = { ...s, attId: newId } as Selection; selection = { ...s, attId: newId } as Selection;
} }
if (newEntityId === (attId as string)) newEntityId = newId as string;
} }
// ── Opts globales ─────────────────────────────────────────────────────── // ── Opts globales ───────────────────────────────────────────────────────
@ -977,12 +994,12 @@ export function createCatalogEditorStore(initial: Cat) {
// Obj // Obj
addObj, cloneObj, renameObj, setObjLabel, removeObj, moveObj, addObj, cloneObj, renameObj, setObjLabel, removeObj, moveObj,
setObjRuleSet, updateObjMeta, setObjRuleSet, updateObjMeta,
updateObjInfo, updateObjPricing, updateObjVisual, setObjIssues, updateObjInfo, updateObjPricing, updateObjVisual, setObjVisual, setObjIssues,
// Sec // Sec
addSec, cloneSec, renameSec, setSecLabel, removeSec, moveSec, addSec, cloneSec, renameSec, setSecLabel, removeSec, moveSec,
setSecRuleSet, updateSecMeta, setSecRuleSet, updateSecMeta,
updateSecInfo, updateSecPricing, updateSecVisual, setSecIssues, updateSecInfo, updateSecPricing, updateSecVisual, setSecVisual, setSecIssues,
// Att (obj/sec level) // Att (obj/sec level)
addAtt, addCatAtt, renameAtt, setAttLabel, removeAtt, moveAtt, addAtt, addCatAtt, renameAtt, setAttLabel, removeAtt, moveAtt,

@ -89,7 +89,7 @@ export interface AttOpt {
optId: OptID; optId: OptID;
/** Override de etiqueta para este att concreto */ /** Override de etiqueta para este att concreto */
label?: LingString; label?: LingRecord;
/** override el code para este att concreto */ /** override el code para este att concreto */
code?: string; code?: string;

@ -334,6 +334,10 @@ export interface View {
code: string; code: string;
viewType: ViewType; viewType: ViewType;
config: ViewConfig; config: ViewConfig;
width : number;
height: number;
/** Orden de presentación en la UI. Menor = primero */ /** Orden de presentación en la UI. Menor = primero */
order?: number; order?: number;
icon?: string; icon?: string;

Loading…
Cancel
Save

Powered by TurnKey Linux.