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