You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

880 lines
45 KiB

<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>

Powered by TurnKey Linux.