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

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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