Compare commits

...

5 Commits
master ... glm

Author SHA1 Message Date
dev f4c8fb04ef refactor(vcen-edit): elimina PanelMeta, EditorMetaTree obtiene contexto
7 months ago
dev 02fdf3b4c9 fix(vcen-edit): corrige estilo del campo clave en MetaFieldEditor
7 months ago
dev 4212e8168c style(vcen-edit): unifica estilos de metas fields con el editor
7 months ago
dev 0e85c236d5 refactor(vcen-edit): elimina MetadataPanel, usa PanelMeta directamente
7 months ago
dev 030bf3a765 refactor(vcen-edit): modulariza catalog-editor y PanelVisual
7 months ago

@ -20,7 +20,16 @@
"Bash(npx tsc:*)",
"Bash(find G:/dev/TypeScript/v/dist -name *.css)",
"Bash(npx svelte-check:*)",
"Bash(ls -la \"G:\\\\dev\\\\TypeScript\\\\v\\\\src\\\\vcen-edit\\\\comps\"/*.svelte)"
"Bash(ls -la \"G:\\\\dev\\\\TypeScript\\\\v\\\\src\\\\vcen-edit\\\\comps\"/*.svelte)",
"Bash(find /g/dev/TypeScript/v -type d -name *vcen*)",
"Bash(find /g/dev/TypeScript/v/src/vcen-edit -name \"*.svelte\" -exec wc -l {} +)",
"Bash(find \"G:\\\\dev\\\\TypeScript\\\\v\\\\src\\\\vcen-edit\\\\comps\" -name \"*.svelte\" -exec wc -l {} +)",
"Bash(xargs wc:*)",
"Bash(find /g/dev/TypeScript/v/src/vcen-edit/comps/fields -name *.svelte -type f)",
"Bash(ls -la /g/dev/TypeScript/v/.env*)",
"Bash(npm test:*)",
"Bash(npm run:*)",
"Bash(wc:*)"
],
"additionalDirectories": [
"g:\\dev\\TypeScript\\v\\src\\adapters\\svelte"

@ -0,0 +1,5 @@
# Configuración de Desarrollo Local
VITE_POCKETBASE_URL=http://192.168.18.131:8090
VITE_APP_ENV=development
VITE_LOG_LEVEL=debug
VITE_APP_VERSION=1.0.0

@ -0,0 +1,14 @@
# Variables de Entorno para vcen-edit
# Copia este archivo a .env.development para desarrollo local
# PocketBase URL
VITE_POCKETBASE_URL=http://192.168.18.131:8090
# Entorno de aplicación
VITE_APP_ENV=development
# Nivel de logging (debug|info|warn|error)
VITE_LOG_LEVEL=debug
# Versión de aplicación
VITE_APP_VERSION=1.0.0

@ -0,0 +1,6 @@
# Configuración de Producción
# Reemplaza la URL con la de tu servidor de producción
VITE_POCKETBASE_URL=https://api.production.com
VITE_APP_ENV=production
VITE_LOG_LEVEL=warn
VITE_APP_VERSION=1.0.0

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

@ -0,0 +1,120 @@
# JsonLogicEngine
> Motor de evaluación y compilación de reglas JsonLogic personalizadas.
Este motor permite ejecutar lógica condicional compleja definida en formato JSON, optimizada para rendimiento y flexibilidad.
---
## 🚀 Características Principales
A diferencia del estándar clásico, este motor incluye mejoras específicas:
- **Normalización de Argumentos**: Los argumentos de los operadores siempre se tratan como arrays, simplificando la lógica interna.
- **Paths con Separador `/`**: Soporte nativo para rutas de datos usando `/` (ej. `user/profile/name`) en lugar del punto `.` clásico.
- **Compilación a JS Puro**: Permite compilar reglas a funciones de JavaScript real para un rendimiento superior en evaluaciones masivas.
- **Seguridad**: Manejo robusto de valores `undefined` o `null` en los paths de datos.
- **Operadores de Fecha**: Comparación lexicográfica ISO incluida por defecto.
---
## 💻 Instalación y Uso
### Ejemplo Básico
```typescript
const engine = new JsonLogicEngine();
const rule = {
"and": [
{ ">": [{ "var": "age" }, 18] },
{ "<": [{ "var": "age" }, 65] }
]
};
const data = { age: 30 };
// Evaluación directa
console.log(engine.evaluate(rule, data)); // true
// Evaluación compilada (Recomendado para bucles o alto tráfico)
const checkAge = engine.compile(rule);
console.log(checkAge(data)); // true
```
### Rutas Anidadas (Paths)
El operador `var` utiliza el separador `/` por defecto:
```typescript
engine.evaluate(
{ "var": "user/profile/name" },
{ user: { profile: { name: "Ana" } } }
); // → "Ana"
```
---
## 🛠️ Operadores Soportados
El motor registra automáticamente los siguientes operadores "core":
| Tipo | Operadores |
|-------------|--------------------------------------------------|
| Lógicos | `and`, `or` |
| Comparación | `==`, `!=`, `<`, `>`, `<=`, `>=` |
| Acceso | `var` (usa `/` como separador) |
| Fechas | `date-before`, `date-after`, `date-between` |
### Agregar Operadores Personalizados
Puedes extender el motor fácilmente usando `addOperation`:
```typescript
engine.addOperation(
"sum",
(_, a, b) => a + b, // Función de evaluación
([a, b]) => `(${a} + ${b})` // (Opcional) Lógica de compilación
);
```
---
## 📖 Referencia de la API
### `evaluate(rule, data)`
Evalúa una regla contra un objeto de datos.
| Parámetro | Tipo | Descripción |
|-----------|------------------|----------------------------------------------|
| `rule` | `JsonLogicValue` | Primitivo, array o expresión JsonLogic. |
| `data` | `any` | El contexto de datos contra el que se evalúa.|
**Retorna:** El resultado de la evaluación.
---
### `compile(rule)`
Transforma la regla en una función de JavaScript optimizada.
| Parámetro | Tipo | Descripción |
|-----------|------------------|------------------------------|
| `rule` | `JsonLogicValue` | La regla a compilar. |
**Retorna:** `(data: any) => any`
---
## 🧩 Tipos de Datos
El motor define los siguientes tipos para asegurar la integridad de las reglas:
| Tipo | Definición |
|-----------------------|---------------------------------------------------------------|
| `JsonPrimitive` | `string \| number \| boolean \| null` |
| `JsonLogicValue` | Primitivos, arrays de valores o expresiones JsonLogic. |
| `JsonLogicExpression` | Objeto con una única clave que representa el operador. |

@ -0,0 +1,351 @@
/**
* Motor de evaluación y compilación de reglas JsonLogic.
*
* Permite ejecutar reglas escritas con sintaxis JsonLogic y también
* compilarlas a funciones JavaScript nativas para evaluaciones repetidas
* con mejor rendimiento.
*
* ## Características
*
* - Evaluación compatible con la sintaxis JsonLogic
* - Normalización automática de argumentos (los operadores reciben arrays)
* - Acceso a variables anidadas usando separador `/`
* - Operadores lógicos y de comparación incluidos
* - Operadores básicos de fechas
* - Compilación de reglas a funciones JavaScript
* - Acceso seguro a paths (evita errores con `undefined`)
*
* ## Sintaxis de paths
*
* El operador `var` usa `/` como separador en lugar del `.` clásico.
*
* Ejemplo:
*
* ```
* user/profile/name
* ```
*
* ## Ejemplo básico
*
* @example
* const engine = new JsonLogicEngine();
*
* const rule = {
* and: [
* { ">": [{ var: "age" }, 18] },
* { "<": [{ var: "age" }, 65] }
* ]
* };
*
* engine.evaluate(rule, { age: 30 }); // true
*
* ## Compilación de reglas
*
* Cuando una regla se evalúa muchas veces, es recomendable compilarla.
*
* @example
* const esAdulto = engine.compile({
* ">=": [{ var: "age" }, 18]
* });
*
* esAdulto({ age: 20 }); // true
* esAdulto({ age: 15 }); // false
*
* @see https://jsonlogic.com
*/
import type { JsonLogicExpression, JsonLogicValue } from "@/jslg/lib/index.ts"
/**
* Separador de paths usado por el operador `var`.
*
* Ejemplo:
*
* user/profile/name
*/
export const JSON_LOGIC_PATH_SEPARATOR = "/" as const
// TIPOS INTERNOS
type OperationEval = (engine: JsonLogicEngine, ...args: any[]) => any
type OperationCompile = (args: string[]) => string
type Operation = {
eval: OperationEval
compile?: OperationCompile
}
/**
* Convierte una expresión JsonLogic en string JSON a JsonLogicValue.
* Útil en testsass y en la UI para parsear condiciones desde texto.
*
* @throws SyntaxError si el string no es JSON válido
*/
export function toJsonCondition(expr: string): JsonLogicValue {
return JSON.parse(expr) as JsonLogicValue;
}
export function asJsonCondition(value: unknown): JsonLogicValue {
return value as JsonLogicValue;
}
// CLASE PRINCIPAL
export class JsonLogicEngine {
private operations: Record<string, Operation> = {}
private currentData: any = null
/**
* Crea una nueva instancia del motor JsonLogic.
*
* Registra automáticamente los operadores básicos:
*
* - `var`
* - `==`, `!=`
* - `<`, `>`, `<=`, `>=`
* - `and`, `or`
* - `date-before`
* - `date-after`
* - `date-between`
*/
constructor() {
this.registerCore()
}
/**
* Registra un operador personalizado.
*
* @param name Nombre del operador
* @param evalFn Función que ejecuta la lógica del operador
* @param compileFn Función opcional para generar código durante la compilación
*
* @example
* engine.addOperation(
* "sum",
* (_, a, b) => a + b,
* ([a, b]) => `(${a} + ${b})`
* );
*/
addOperation(
name: string,
evalFn: OperationEval,
compileFn?: OperationCompile
) {
this.operations[name] = { eval: evalFn, compile: compileFn }
}
/**
* Evalúa una regla JsonLogic usando un contexto de datos.
*
* @param rule Regla JsonLogic
* @param data Datos sobre los que se ejecuta la regla
*
* @returns Resultado de la evaluación
*
* @throws Error si se usa un operador desconocido
*
* @example
* engine.evaluate(
* { ">": [{ var: "age" }, 18] },
* { age: 21 }
* ); // true
*/
evaluate(rule: JsonLogicValue, data: any): any {
this.currentData = data
try {
return this._evaluate(rule)
} finally {
this.currentData = null
}
}
private _evaluate(rule: JsonLogicValue): any {
if (rule === null || typeof rule !== "object") return rule
if (Array.isArray(rule)) {
return rule.map(r => this._evaluate(r))
}
const op = Object.keys(rule)[0]
let args = (rule as JsonLogicExpression)[op]
// ← NORMALIZACIÓN CLAVE (esto arregla todos los testsass)
if (!Array.isArray(args)) {
args = args === undefined ? [] : [args]
}
const operation = this.operations[op]
if (!operation) {
throw new Error(`Unknown operator: ${op}`)
}
const evaluatedArgs = args.map(a => this._evaluate(a))
return operation.eval(this, ...evaluatedArgs)
}
/**
* Compila una regla JsonLogic a una función JavaScript.
*
* Esto evita interpretar la estructura de la regla en cada ejecución
* y mejora mucho el rendimiento en evaluaciones repetidas.
*
* @param rule Regla a compilar
*
* @returns Función que recibe los datos y devuelve el resultado
*
* @example
* const regla = engine.compile({
* ">": [{ var: "score" }, 80]
* });
*
* regla({ score: 90 }); // true
*/
compile(rule: JsonLogicValue): (data: any) => any {
const expr = this.compileExpr(rule)
/* old
return new Function("data", `return ${expr};`) as (data: any) => any
*/
const source = `/* Compiled JsonLogic */ return ${expr};`;
try {
return new Function("data", source) as (data: any) => any;
} catch (e) {
console.error("Error crítico compilando regla:", expr);
throw e;
}
}
/**
* Extrae los paths de variables usados en una regla JsonLogic.
* Útil para construir grafos de dependencias.
*
* @example
* engine.usesData({ "==": [{ var: "at:color" }, "rojo"] })
* // → ["at:color"]
*
* engine.usesData({ "and": [
* { "==": [{ var: "sc:motor/at:fuel" }, "electrico"] },
* { ">": [{ var: "at:cv" }, 100] }
* ]})
* // → ["sc:motor/at:fuel", "at:cv"]
*/
usesData(rule: JsonLogicValue): string[] {
const vars: string[] = []
const walk = (node: JsonLogicValue) => {
if (node === null || typeof node !== 'object') return
if (Array.isArray(node)) { node.forEach(walk); return }
const op = Object.keys(node)[0]
let args = (node as JsonLogicExpression)[op]
if (!Array.isArray(args)) args = args === undefined ? [] : [args]
if (op === 'var') {
const path = args[0]
if (typeof path === 'string' && path) vars.push(path)
return
}
args.forEach(walk)
}
walk(rule)
return [...new Set(vars)]
}
private compileExpr(rule: JsonLogicValue): string {
if (rule === null) return "null"
if (typeof rule === "number" || typeof rule === "boolean") return String(rule)
if (typeof rule === "string") return JSON.stringify(rule)
if (Array.isArray(rule)) {
return `[${rule.map(v => this.compileExpr(v)).join(",")}]`
}
const op = Object.keys(rule)[0]
let args = (rule as JsonLogicExpression)[op]
if (!Array.isArray(args)) {
args = args === undefined ? [] : [args]
}
if (op === "var") {
return this.compileVar(args[0] as string)
}
const operation = this.operations[op]
if (!operation?.compile) {
throw new Error(`Operator '${op}' cannot be compiled`)
}
const compiledArgs = args.map(a => this.compileExpr(a))
return operation.compile(compiledArgs)
}
private compileVar(path: string): string {
const clean = (path ?? "").trim()
if (!clean) return "data"
const parts = clean.split(JSON_LOGIC_PATH_SEPARATOR)
let expr = "data"
/*
for (const p of parts) {
const safe = p.replace(/"/g, '\\"')
expr += `?.["${safe}"]`
}
*/
for (const p of parts) {
// JSON.stringify pone las comillas automáticamente y escapa
// CUALQUIER carácter raro (saltos de línea, backslashes, etc.)
expr += `?.[${JSON.stringify(p)}]`;
}
return expr
}
private getVar(data: any, path: string): any {
if (!path || data == null) return undefined
let current = data
for (const p of path.split(JSON_LOGIC_PATH_SEPARATOR)) {
if (current == null) return undefined
current = current[p]
}
return current
}
private registerCore() {
// var
this.addOperation(
"var",
(engine, path = "") => engine.getVar(engine.currentData, path),
([path = ""]) => this.compileVar(path)
)
// comparaciones
this.addOperation("==", (_, a, b) => a === b, ([a, b]) => `(${a} === ${b})`)
this.addOperation("!=", (_, a, b) => a !== b, ([a, b]) => `(${a} !== ${b})`)
this.addOperation(">", (_, a, b) => a > b, ([a, b]) => `(${a} > ${b})`)
this.addOperation("<", (_, a, b) => a < b, ([a, b]) => `(${a} < ${b})`)
this.addOperation(">=", (_, a, b) => a >= b, ([a, b]) => `(${a} >= ${b})`)
this.addOperation("<=", (_, a, b) => a <= b, ([a, b]) => `(${a} <= ${b})`)
// lógicos
this.addOperation("and", (_, ...args) => args.every(Boolean), args => `(${args.join(" && ")})`)
this.addOperation("or", (_, ...args) => args.some(Boolean), args => `(${args.join(" || ")})`)
// fechas (comparación lexicográfica segura para ISO strings)
this.addOperation("date-before", (_, a, b) => String(a) < String(b), ([a, b]) => `(${a} < ${b})`)
this.addOperation("date-after", (_, a, b) => String(a) > String(b), ([a, b]) => `(${a} > ${b})`)
this.addOperation(
"date-between",
(_, v, s, e) => String(v) >= String(s) && String(v) <= String(e),
([v, s, e]) => `(${v} >= ${s} && ${v} <= ${e})`
)
}
}

@ -0,0 +1,7 @@
// index.ts
export * from './types.ts'
export * from './engine.ts'

@ -0,0 +1,33 @@
/**
* Valores primitivos permitidos en JsonLogic.
*/
export type JsonPrimitive =
| string
| number
| boolean
| null
/**
* Valor válido dentro de una regla JsonLogic.
*/
export type JsonLogicValue =
| JsonPrimitive
| JsonLogicExpression
| JsonLogicValue[]
/**
* Expresión JsonLogic.
*
* Un objeto cuya clave es el operador
* y cuyo valor son los argumentos.
*/
export type JsonLogicExpression = {
[operator: string]: JsonLogicValue | JsonLogicValue[]
}

@ -0,0 +1,314 @@
import { describe, it, expect, beforeEach } from "vitest"
import { JsonLogicEngine, JSON_LOGIC_PATH_SEPARATOR } from "../lib/engine.ts"
import type {JsonLogicValue} from "@/tsat/libs/json_logic/types.ts";
describe("JsonLogicEngine", () => {
let engine: JsonLogicEngine
beforeEach(() => {
engine = new JsonLogicEngine()
})
describe("PATH_SEPARATOR", () => {
it("should use '/' as separator", () => {
expect(JSON_LOGIC_PATH_SEPARATOR).toBe("/")
})
})
describe("var operator", () => {
it("should resolve simple path", () => {
const rule = { "var": "user/name" }
const data = {
user: { name: "John" }
}
const result = engine.evaluate(rule, data)
expect(result).toBe("John")
})
it("should resolve nested path", () => {
const rule = { "var": "order/customer/name" }
const data = {
order: {
customer: {
name: "Alice"
}
}
}
expect(engine.evaluate(rule as JsonLogicValue, data)).toBe("Alice")
})
it("should support segments with ':'", () => {
const rule = { "var": "ct:vehicles/0/id" }
const data = {
"ct:vehicles": [
{ id: 42 }
]
}
expect(engine.evaluate(rule as JsonLogicValue, data)).toBe(42)
})
it("should return undefined for missing paths", () => {
const rule = { "var": "user/address/city" }
const data = {
user: {}
}
const result = engine.evaluate(rule as JsonLogicValue, data)
expect(result).toBeUndefined()
})
})
describe("comparison operators", () => {
it("should evaluate ==", () => {
const rule = {
"==": [
{ "var": "age" },
18
]
}
const data = { age: 18 }
expect(engine.evaluate(rule as JsonLogicValue, data)).toBe(true)
})
it("should evaluate !=", () => {
const rule = {
"!=": [
{ "var": "age" },
18
]
}
const data = { age: 20 }
expect(engine.evaluate(rule as JsonLogicValue, data)).toBe(true)
})
it("should evaluate <", () => {
const rule = {
"<": [
{ "var": "age" },
30
]
}
const data = { age: 25 }
expect(engine.evaluate(rule as JsonLogicValue, data)).toBe(true)
})
it("should evaluate >", () => {
const rule = {
">": [
{ "var": "age" },
18
]
}
const data = { age: 20 }
expect(engine.evaluate(rule as JsonLogicValue, data)).toBe(true)
})
})
describe("logical operators", () => {
it("should evaluate and", () => {
const rule = {
"and": [
{ ">": [ { "var": "age" }, 18 ] },
{ "<": [ { "var": "age" }, 30 ] }
]
} as JsonLogicValue
const data = { age: 25 }
expect(engine.evaluate(rule, data)).toBe(true)
})
it("should evaluate or", () => {
const rule = {
"or": [
{ "==": [ { "var": "role" }, "admin" ] },
{ "==": [ { "var": "role" }, "editor" ] }
]
}
const data = { role: "editor" }
expect(engine.evaluate(rule as JsonLogicValue, data)).toBe(true)
})
})
describe("custom operators", () => {
it("should allow adding custom operation", () => {
engine.addOperation(
"sum",
(_, a: number, b: number) => a + b,
([a, b]) => `(${a} + ${b})`
)
const rule = {
"sum": [2, 3]
}
expect(engine.evaluate(rule as JsonLogicValue, {})).toBe(5)
})
})
describe("date operators", () => {
beforeEach(() => {
engine.addOperation(
"date-before",
(_, a: string, b: string) => a < b,
([a, b]) => `(${a} < ${b})`
)
engine.addOperation(
"date-after",
(_, a: string, b: string) => a > b,
([a, b]) => `(${a} > ${b})`
)
})
it("should evaluate date-before", () => {
const rule = {
"date-before": [
{ "var": "date" },
"2025-01-01"
]
}
const data = {
date: "2024-12-31"
}
expect(engine.evaluate(rule as JsonLogicValue, data)).toBe(true)
})
it("should evaluate date-after", () => {
const rule = {
"date-after": [
{ "var": "date" },
"2025-01-01"
]
}
const data = {
date: "2025-06-01"
}
expect(engine.evaluate(rule as JsonLogicValue, data)).toBe(true)
})
})
describe("compile", () => {
it("should compile expression to function", () => {
const rule = {
"==": [
{ "var": "user/name" },
"John"
]
}
const fn = engine.compile(rule as JsonLogicValue)
const result = fn({
user: { name: "John" }
})
expect(result).toBe(true)
})
it("compiled function should match evaluate()", () => {
const rule = {
"and": [
{ ">": [ { "var": "age" }, 18 ] },
{ "<": [ { "var": "age" }, 65 ] }
]
} as JsonLogicValue
const data = { age: 30 }
const compiled = engine.compile(rule)
const r1 = engine.evaluate(rule, data)
const r2 = compiled(data)
expect(r1).toBe(r2)
})
})
})

@ -32,7 +32,7 @@
/** 'light' (default) para fondos claros · 'dark' para el editor oscuro */
variant?: Variant;
localeTab?: Snippet<[{ locale: string; active: boolean; hasContent: boolean; activate: () => void }]>;
onUpdate: (value: LingString) => void;
onUpdate: (value: LingRecord) => void;
} = $props();
const get = (locale: string): string => value[locale as SupportedLocale] ?? '';

@ -68,7 +68,7 @@ export const translations = {
addFieldTitle: { es: 'Añadir campo', en: 'Add field' } satisfies LingRecord,
fieldCount: p({
es: { one: '{{count}} campo', other: '{{count}} campos' },
en: { one: '{{count}} field', other: '{{count}} fields' },
en: { one: '{{count}} field', other: '{{count}} metas' },
}),
},
@ -99,11 +99,11 @@ export const translations = {
fieldInvalid: { es: 'Campo inválido', en: 'Invalid field' } satisfies LingRecord,
boolTrue: { es: 'Verdadero', en: 'True' } satisfies LingRecord,
boolFalse: { es: 'Falso', en: 'False' } satisfies LingRecord,
empty: { es: 'Usa el botón Añadir para crear campos', en: 'Use the Add button to create fields' } satisfies LingRecord,
emptyGroup: { es: 'Grupo vacío — añade campos con el botón +', en: 'Empty group — add fields with the + button' } satisfies LingRecord,
empty: { es: 'Usa el botón Añadir para crear campos', en: 'Use the Add button to create metas' } satisfies LingRecord,
emptyGroup: { es: 'Grupo vacío — añade campos con el botón +', en: 'Empty group — add metas with the + button' } satisfies LingRecord,
nestedCount: p({
es: { one: '{{count}} campo anidado', other: '{{count}} campos anidados' },
en: { one: '{{count}} nested field', other: '{{count}} nested fields' },
en: { one: '{{count}} nested field', other: '{{count}} nested metas' },
}),
},

@ -11,7 +11,7 @@
let {
fieldKey, field,
depth = 0,
attributes = [],
tags = [],
canMoveUp = false,
canMoveDown = false,
glob,
@ -23,7 +23,7 @@
fieldKey: string;
field: MetadataField;
depth?: number;
attributes?: string[];
tags?: string[];
canMoveUp?: boolean;
canMoveDown?: boolean;
glob: GlobState;
@ -191,7 +191,7 @@
title={glob.t('vcen.editor.mtas.field.moveDown')}
class="flex items-center justify-center w-5 h-5 rounded text-gray-500 hover:text-gray-300 hover:bg-gray-800 transition-colors disabled:opacity-20"
>
<svg class="h-3 w-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<svg class="h-3 w-3 fill-currentColor" viewBox="0 0 12 12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<path d="M2 4l4 4 4-4"/>
</svg>
</button>
@ -258,7 +258,7 @@
<!-- Hidden badge -->
{#if fieldBase.hidden}
<span class="inline-flex items-center rounded px-1.5 py-0.5 text-xs bg-gray-800 text-gray-500 border border-gray-700 gap-1">
<svg class="h-2.5 w-2.5" viewBox="0 0 16 16" fill="currentColor">
<svg class="h-2.5 w-2.5 fill-current" viewBox="0 0 16 16">
<path d="M13.359 11.238C15.06 9.72 16 8 16 8s-3-5.5-8-5.5a7 7 0 0 0-2.79.588l.77.771A5.944 5.944 0 0 1 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.134 13.134 0 0 1 14.828 8a13.68 13.68 0 0 1-1.15 1.475M6.12 6.12A3 3 0 0 0 9.88 9.88M2.343 2.343l11.314 11.314M4.16 4.16A7.024 7.024 0 0 0 1.172 8s3 5.5 8 5.5a7.024 7.024 0 0 0 3.402-.91"/>
</svg>
{glob.t('vcen.editor.mtas.field.hidden')}
@ -285,7 +285,7 @@
aria-label={glob.t('vcen.editor.mtas.field.delete')}
class="flex items-center justify-center w-6 h-6 rounded text-red-400 hover:bg-red-950 transition-colors"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<svg class="h-3.5 w-3.5 fill-current" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 4h10M6 4V2.5h4V4M5 4l.5 9h5l.5-9"/>
</svg>
</button>
@ -294,8 +294,8 @@
<!-- Expand chevron -->
<svg
aria-hidden="true"
class={['h-4 w-4 shrink-0 text-gray-600 transition-transform duration-200', expanded ? 'rotate-180' : ''].join(' ')}
viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2"
class={['h-4 w-4 shrink-0 text-gray-600 transition-transform duration-200 fill-current', expanded ? 'rotate-180' : ''].join(' ')}
viewBox="0 0 16 16" stroke="currentColor" stroke-width="2"
>
<path d="M4 6l4 4 4-4"/>
</svg>
@ -304,7 +304,7 @@
<!-- ── Validation error ───────────────────────────────────── -->
{#if !validationResult.valid && validationErrors.length}
<div class="flex items-center gap-2 border-t border-red-900 bg-red-950/50 px-4 py-2">
<svg aria-hidden="true" class="h-3.5 w-3.5 shrink-0 text-red-400" viewBox="0 0 16 16" fill="currentColor">
<svg aria-hidden="true" class="h-3.5 w-3.5 shrink-0 text-red-400 fill-current" viewBox="0 0 16 16">
<path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm0 3.5a.75.75 0 0 1 .75.75v3a.75.75 0 0 1-1.5 0v-3A.75.75 0 0 1 8 4.5zm0 7a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
</svg>
<span class="text-red-400 text-xs">{validationErrors[0]}</span>
@ -316,7 +316,7 @@
<div class="border-t border-gray-800">
<FieldEditor
{fieldKey} {field}
{attributes} {glob} {locales} {defaultLocale}
{tags} {glob} {locales} {defaultLocale}
{onKeyChange}
onFieldChange={(f) => onFieldChange(f)}
/>
@ -363,7 +363,7 @@
<FieldRowRecursive
fieldKey={childKey}
field={asField(field.value[childKey])}
{attributes} {glob} {locales} {defaultLocale}
{tags} {glob} {locales} {defaultLocale}
depth={depth + 1}
canMoveUp={ci > 0}
canMoveDown={ci < groupChildCount - 1}
@ -430,7 +430,7 @@
<AddFieldModal
open={drawerOpen}
selectedType={drawerType}
existingKeys={Object.keys(field.value)} {attributes} {glob} {locales} {defaultLocale}
existingKeys={Object.keys(field.value)} {tags} {glob} {locales} {defaultLocale}
onConfirm={handleDrawerConfirm}
onClose={() => drawerOpen = false}
/>

@ -9,7 +9,7 @@
open = false,
selectedType,
existingKeys = [],
attributes = [],
tags = [],
dark = false,
glob,
locales = ['es'],
@ -20,7 +20,7 @@
open?: boolean;
selectedType: MetadataFieldType;
existingKeys?: string[];
attributes?: string[];
tags?: string[];
dark?: boolean;
glob: GlobState;
locales?: string[];
@ -159,7 +159,7 @@
</div>
<!-- Field form (value + attributes + hidden) -->
<FieldForm field={fieldValue} {glob} {locales} {defaultLocale} {attributes} onFieldChange={(v) => fieldValue = v} />
<FieldForm field={fieldValue} {glob} {locales} {defaultLocale} {tags} onFieldChange={(v) => fieldValue = v} />
</div>
@ -169,7 +169,7 @@
{glob.t('vcen.editor.mtas.drawer.cancel')}
</button>
<button type="button" onclick={handleConfirm} class="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-500 transition-colors">
<svg class="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<svg class="h-4 w-4" viewBox="0 0 16 16 fill-current" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M8 2v12M2 8h12"/>
</svg>
{glob.t('vcen.editor.mtas.drawer.confirm')}

@ -14,126 +14,126 @@
</script>
{#if type === 'string'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/>
</svg>
{:else if type === 'number'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/>
<line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/>
</svg>
{:else if type === 'boolean'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<rect x="1" y="5" width="22" height="14" rx="7"/><circle cx="16" cy="12" r="3" fill="currentColor" stroke="none"/>
</svg>
{:else if type === 'ling'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
{:else if type === 'date'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
</svg>
{:else if type === 'datetime'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
<polyline points="12 14 12 17 14 17"/>
</svg>
{:else if type === 'url'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
{:else if type === 'currency'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
</svg>
{:else if type === 'image'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
{:else if type === 'video'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2"/>
</svg>
{:else if type === 'audio'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>
</svg>
{:else if type === 'pdf'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
<line x1="9" y1="15" x2="15" y2="15"/>
</svg>
{:else if type === 'file'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
</svg>
{:else if type === 'html'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>
</svg>
{:else if type === 'color'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/>
<line x1="12" y1="3" x2="12" y2="9"/><line x1="12" y1="15" x2="12" y2="21"/>
<line x1="3" y1="12" x2="9" y2="12"/><line x1="15" y1="12" x2="21" y2="12"/>
</svg>
{:else if type === 'select'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<rect x="3" y="6" width="18" height="12" rx="2"/>
<polyline points="8 12 12 16 16 12"/>
</svg>
{:else if type === 'multiselect'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<rect x="3" y="4" width="5" height="5" rx="1"/><polyline points="4 6.5 5.5 8 8 5"/>
<rect x="3" y="14" width="5" height="5" rx="1"/>
<line x1="11" y1="6.5" x2="21" y2="6.5"/><line x1="11" y1="16.5" x2="21" y2="16.5"/>
</svg>
{:else if type === 'email'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<polyline points="22 6 12 13 2 6"/>
</svg>
{:else if type === 'phone'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.14 9a19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 3.05 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/>
</svg>
{:else if type === 'slug'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/>
<line x1="7" y1="7" x2="7.01" y2="7"/>
</svg>
{:else if type === 'coordinates'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<path d="M21 10c0 7-9 13-9 13S3 17 3 10a9 9 0 0 1 18 0z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
{:else if type === 'markdown'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<polyline points="8 13 10 11 12 13 14 11 16 13"/>
@ -141,7 +141,7 @@
</svg>
{:else if type === 'group'}
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={cls}>
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<rect x="2" y="3" width="8" height="8" rx="1"/><rect x="14" y="3" width="8" height="8" rx="1"/>
<rect x="2" y="13" width="8" height="8" rx="1"/><rect x="14" y="13" width="8" height="8" rx="1"/>
</svg>

@ -261,7 +261,7 @@
<div class="flex-1 overflow-y-auto">
{#if keyOrder.length === 0}
<div class="flex flex-col items-center justify-center gap-2 py-12 px-4 text-center">
<svg class="h-8 w-8 text-base-content/10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<svg class="h-8 w-8 text-base-content/10 fill-current" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="4"/>
<path d="M12 8v8M8 12h8"/>
</svg>
@ -320,7 +320,7 @@
{#if !validation.valid}
<div class="px-3 pb-3 shrink-0 border-t border-base-300">
<div class="alert alert-error mt-3 py-2.5">
<svg class="h-4 w-4 shrink-0" viewBox="0 0 16 16" fill="currentColor">
<svg class="h-4 w-4 shrink-0 fill-current" viewBox="0 0 16 16">
<path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm0 3.5a.75.75 0 0 1 .75.75v3a.75.75 0 0 1-1.5 0v-3A.75.75 0 0 1 8 4.5zm0 7a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
</svg>
<div class="flex flex-col gap-0.5">

@ -11,7 +11,7 @@
let {
value = $bindable<Metadata>({}),
dark = false,
attributes = [],
tags = [],
pagination = true,
glob,
locales = ['es'],
@ -20,7 +20,7 @@
}: {
value?: Metadata;
dark?: boolean;
attributes?: string[];
tags?: string[];
pagination?: boolean;
glob: GlobState;
locales?: string[];
@ -177,7 +177,7 @@
<div class="flex flex-col gap-2 p-3 overflow-y-auto flex-1 min-h-0">
{#if keyOrder.length === 0}
<div class="rounded-2xl flex flex-col items-center justify-center gap-3 border-2 border-dashed border-gray-700 py-16 text-gray-700">
<svg class="h-10 w-10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<svg class="h-10 w-10 fill-current" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
<rect x="3" y="3" width="8" height="8" rx="2"/>
<rect x="13" y="3" width="8" height="8" rx="2"/>
<rect x="3" y="13" width="8" height="8" rx="2"/>
@ -194,7 +194,7 @@
<FieldRow
fieldKey={key}
field={getField(key)}
{attributes} {glob} {locales} {defaultLocale}
{tags} {glob} {locales} {defaultLocale}
canMoveUp={gi > 0}
canMoveDown={gi < keyOrder.length - 1}
onKeyChange={(newKey) => updateFieldKey(key, newKey)}
@ -212,7 +212,7 @@
{#if !validation.valid}
<div class="px-3 pb-3 shrink-0">
<div class="flex items-start gap-2 rounded border border-red-900 bg-red-950 px-3 py-2.5 text-red-400">
<svg class="h-4 w-4 shrink-0" viewBox="0 0 16 16" fill="currentColor">
<svg class="h-4 w-4 shrink-0 fill-current" viewBox="0 0 16 16">
<path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm0 3.5a.75.75 0 0 1 .75.75v3a.75.75 0 0 1-1.5 0v-3A.75.75 0 0 1 8 4.5zm0 7a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
</svg>
<div class="flex flex-col gap-0.5">
@ -292,7 +292,7 @@
<AddFieldModal
open={drawerOpen}
selectedType={drawerType}
existingKeys={keyOrder} {attributes} {dark} {glob} {locales} {defaultLocale}
existingKeys={keyOrder} {tags} {dark} {glob} {locales} {defaultLocale}
onConfirm={handleDrawerConfirm}
onClose={() => drawerOpen = false}
/>

@ -15,7 +15,7 @@
let metadata = $state<Metadata>({
order: ['nombre','año','disponible','descripcion','lanzamiento','ultima_mod','web','precio','foto','manual','specs'],
nombre: { type: 'string', value: 'Toyota Corolla' },
año: { type: 'number', value: 2024 },
ano: { type: 'number', value: 2024 },
disponible: { type: 'boolean', value: true },
descripcion: { type: 'ling', value: { es: 'Sedán compacto fiable', en: 'Reliable compact sedan' } as LingRecord },
lanzamiento: { type: 'date', value: '2024-01-15' },
@ -87,7 +87,7 @@
<div class="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-zinc-900 dark:bg-white">
<svg class="h-4 w-4 text-white dark:text-zinc-900" viewBox="0 0 16 16" fill="currentColor">
<svg class="h-4 w-4 fill-current text-white dark:text-zinc-900" viewBox="0 0 16 16">
<rect x="1" y="1" width="6" height="6" rx="1"/>
<rect x="9" y="1" width="6" height="6" rx="1"/>
<rect x="1" y="9" width="6" height="6" rx="1"/>
@ -138,11 +138,11 @@
dark:border-zinc-700 dark:text-zinc-500 dark:hover:border-zinc-600 dark:hover:text-zinc-300"
>
{#if darkMode}
<svg class="h-4 w-4" viewBox="0 0 16 16" fill="currentColor">
<svg class="h-4 w-4 fill-current" viewBox="0 0 16 16" >
<path d="M8 1a.75.75 0 0 1 .75.75v1a.75.75 0 0 1-1.5 0v-1A.75.75 0 0 1 8 1zM8 11a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm5.657-8.157a.75.75 0 0 1 0 1.06l-.707.708a.75.75 0 0 1-1.06-1.06l.707-.708a.75.75 0 0 1 1.06 0zM3.05 4.904a.75.75 0 0 1-1.06-1.06l.707-.708a.75.75 0 0 1 1.06 1.06l-.707.708zM15 8a.75.75 0 0 1-.75.75h-1a.75.75 0 0 1 0-1.5h1A.75.75 0 0 1 15 8zM2.75 8a.75.75 0 0 1-.75.75H.75a.75.75 0 0 1 0-1.5H2A.75.75 0 0 1 2.75 8zM12.95 11.096a.75.75 0 0 1 1.06 1.06l-.707.708a.75.75 0 0 1-1.06-1.06l.707-.708zm-9.9 0l.707.708a.75.75 0 0 1-1.06 1.06l-.707-.707a.75.75 0 0 1 1.06-1.061z"/>
</svg>
{:else}
<svg class="h-4 w-4" viewBox="0 0 16 16" fill="currentColor">
<svg class="h-4 w-4 fill-current" viewBox="0 0 16 16">
<path d="M6 .278a.75.75 0 0 1 .18.83A7 7 0 1 0 14.85 9.5a.75.75 0 0 1 .83.18A8.5 8.5 0 1 1 6 .278z"/>
</svg>
{/if}

@ -3,7 +3,8 @@ import { getContext } from 'svelte'
import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.js'
import { labelOf } from '@/vcen-edit/lib/label.ts'
import type { AttDomain, ObjID, SecID, AttID } from '@/vcen/lib'
import type { AttDomain, ObjID, SecID, AttID, Obj, Att } from '@/vcen/lib'
import type { LingRecord, LingString } from '@/ling/lib'
import RuleList from './RuleList.svelte'
import ButtonDelete from './ButtonDelete.svelte'
import { buildEvalCtx, evalRuleSetState } from '@/vcen-edit/lib/rule-eval.ts'
@ -80,13 +81,88 @@ function defaultDomain(kind: AttDomain['kind']): AttDomain {
}
}
// Helpers para evitar casts en template
function getDomainMode(domain: AttDomain): string {
return 'mode' in domain ? domain.mode : 'input'
}
function asStringDomain(domain: AttDomain) {
if (domain.kind === 'string') return domain
return null
}
function asNumberDomain(domain: AttDomain) {
if (domain.kind === 'number' || domain.kind === 'currency') return domain
return null
}
function asOptionDomain(domain: AttDomain) {
if (domain.kind === 'option') return domain
return null
}
function setAttLabel(label: LingString) {
if (!ctx) return
const { isCatLevel, objId, secId, attId } = ctx
if (isCatLevel) {
editor.setCatAttLabel(attId, label as LingRecord)
} else {
editor.setAttLabel(objId!, secId, attId, label as LingRecord)
}
}
// Helpers para actualizar domain - reciben el kind actual
function updateStringDomain(mode: 'input' | 'list' | 'pattern' | 'fixed', extra?: Record<string, unknown>) {
updateDomain({ kind: 'string', mode, ...extra } as AttDomain)
}
function updateNumberDomain(kind: AttDomain['kind'], mode: 'input' | 'range' | 'step' | 'list' | 'fixed', extra?: Record<string, unknown>) {
updateDomain({ kind: kind as 'number' | 'currency', mode, ...extra } as AttDomain)
}
function updateOptionDomain(mode: 'list' | 'fixed', extra?: Record<string, unknown>) {
updateDomain({ kind: 'option', mode, ...extra } as AttDomain)
}
function updateOptionFixed(value: string) {
updateDomain({ kind: 'option', mode: 'fixed', fixed: (value || undefined) as `op_${string}` | undefined } as AttDomain)
}
// Helpers para acceder a propiedades específicas de domain en template
function getOptionListDomain(domain: AttDomain) {
const d = asOptionDomain(domain)
if (d && d.mode === 'list') return d.list
return null
}
function getStringListDomain(domain: AttDomain) {
const d = asStringDomain(domain)
if (d && d.mode === 'list') return d.list
return null
}
function getNumberListDomain(domain: AttDomain) {
const d = asNumberDomain(domain)
if (d && d.mode === 'list') return d.list
return null
}
// Helpers para evitar casts en template (Svelte no permite 'as' en templates)
function asStringMode(mode: string): 'input' | 'list' | 'pattern' | 'fixed' {
return mode as any
}
function asNumberMode(mode: string): 'input' | 'range' | 'step' | 'list' | 'fixed' {
return mode as any
}
// Opts
const allOpts = $derived(editor.cat.opts)
const attOptIds = $derived.by(() => {
if (!ctx) return new Set<string>()
const d = ctx.att.value.domain
if (d.kind !== 'option' || d.mode !== 'list') return new Set<string>()
return new Set(((d as any).list ?? []).map((ao: any) => ao.optId as string))
const d = asOptionDomain(ctx.att.value.domain)
if (!d || d.mode !== 'list') return new Set<string>()
return new Set((d.list ?? []).map(ao => ao.optId))
})
function toggleOpt(optId: string) {
@ -142,7 +218,11 @@ const computedState = $derived.by(() => {
void editor.rev
if (!ctx?.att.rules) return null
// For cat-level atts, use the cat itself as container proxy
const container = ctx.container ?? (ctx.isCatLevel ? { atts: editor.cat.atts ?? [], id: editor.cat.id } as any : null)
const container = ctx.container ?? (ctx.isCatLevel ? {
atts: editor.cat.atts ?? [],
id: editor.cat.id,
label: {}
} as unknown as Obj : null)
if (!container) return null
const evalCtx = buildEvalCtx(editor.cat, container)
return evalRuleSetState(ctx.att.rules, evalCtx)
@ -208,7 +288,7 @@ function deleteAtt() {
</script>
{#if ctx}
{@const { att, isCatLevel, objId, secId, attId, container } = ctx}
{@const { att, isCatLevel, attId, container } = ctx}
<Panel
title={labelOf(att.label, cat.lang) || glob.t('vcen.editor.common.noName')}
@ -247,10 +327,7 @@ function deleteAtt() {
locales={cat.langs}
defaultLocale={cat.lang}
namePlaceholder="Color, Suelo, Material..."
onNameUpdate={label => {
if (isCatLevel) editor.setCatAttLabel(attId, label as any)
else editor.setAttLabel(objId!, secId, attId, label as any)
}}
onNameUpdate={setAttLabel}
/>
</section>
@ -272,13 +349,14 @@ function deleteAtt() {
<!-- Config extra según kind -->
{#if att.value.domain.kind === 'string'}
{@const d = att.value.domain as any}
{@const d = asStringDomain(att.value.domain)}
{#if d}
<div class="flex flex-col gap-2">
<label class="text-xs text-gray-500">{glob.t('vcen.editor.att.mode')}</label>
<div class="flex gap-2">
{#each ['input', 'list', 'pattern', 'fixed'] as mode}
<button
onclick={() => updateDomain({ kind: 'string', mode: mode as any })}
onclick={() => updateStringDomain(asStringMode(mode))}
class="px-2.5 py-1 text-xs rounded border transition-colors
{d.mode === mode
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
@ -288,38 +366,36 @@ function deleteAtt() {
</div>
{#if d.mode === 'list'}
<textarea
value={(d.list ?? []).join('\n')}
oninput={e => updateDomain(
{ kind: 'string', mode: 'list',
list: e.currentTarget.value.split('\n').map((s:string) => s.trim()).filter(Boolean) })}
oninput={e => updateStringDomain('list', {
list: e.currentTarget.value.split('\n').map(s => s.trim()).filter(Boolean)
})}
placeholder={glob.t('vcen.editor.att.listPlaceholder')}
rows={4} class="field-input"
></textarea>
>{(d.list ?? []).join('\n')}</textarea>
{:else if d.mode === 'pattern'}
<input type="text" value={d.pattern ?? ''}
oninput={e => updateDomain(
{ kind: 'string', mode: 'pattern', pattern: e.currentTarget.value })}
oninput={e => updateStringDomain('pattern', { pattern: e.currentTarget.value })}
placeholder="^[A-Z]{2}[0-9]+$" class="field-input" />
{:else if d.mode === 'fixed'}
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500">{glob.t('vcen.editor.att.fixedValue')}</label>
<input type="text" value={d.fixed ?? ''}
oninput={e => updateDomain(
{ kind: 'string', mode: 'fixed', fixed: e.currentTarget.value })}
oninput={e => updateStringDomain('fixed', { fixed: e.currentTarget.value })}
class="field-input" />
</div>
{/if}
</div>
{/if}
{:else if att.value.domain.kind === 'number' || att.value.domain.kind === 'currency'}
{@const d = att.value.domain as any}
{@const d = asNumberDomain(att.value.domain)}
{#if d}
<div class="flex flex-col gap-2">
<label class="text-xs text-gray-500">{glob.t('vcen.editor.att.mode')}</label>
<div class="flex gap-2 flex-wrap">
{#each ['input', 'range', 'step', 'list', 'fixed'] as mode}
<button
onclick={() => updateDomain(
{ kind: att.value.domain.kind, mode: mode as any } as any)}
onclick={() => updateNumberDomain(att.value.domain.kind, asNumberMode(mode))}
class="px-2.5 py-1 text-xs rounded border transition-colors
{d.mode === mode
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
@ -332,57 +408,56 @@ function deleteAtt() {
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-600">{glob.t('vcen.editor.att.min')}</span>
<input type="number" value={d.min ?? 0}
oninput={e => updateDomain({ ...d, min: Number(e.currentTarget.value) })}
oninput={e => updateNumberDomain(att.value.domain.kind, d.mode, { min: Number(e.currentTarget.value) })}
class="field-input" />
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-600">{glob.t('vcen.editor.att.max')}</span>
<input type="number" value={d.max ?? 100}
oninput={e => updateDomain({ ...d, max: Number(e.currentTarget.value) })}
oninput={e => updateNumberDomain(att.value.domain.kind, d.mode, { max: Number(e.currentTarget.value) })}
class="field-input" />
</div>
{#if d.mode === 'step'}
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-600">{glob.t('vcen.editor.att.step')}</span>
<input type="number" value={d.step ?? 1}
oninput={e => updateDomain({ ...d, step: Number(e.currentTarget.value) })}
oninput={e => updateNumberDomain(att.value.domain.kind, d.mode, { step: Number(e.currentTarget.value) })}
class="field-input" />
</div>
{/if}
</div>
{:else if d.mode === 'list'}
<textarea
value={(d.list ?? []).join('\n')}
oninput={e => {
const list = e.currentTarget.value
.split('\n')
.map((s: string) => s.trim())
.filter((s: string) => s !== '' && !isNaN(Number(s)))
.map(s => s.trim())
.filter(s => s !== '' && !isNaN(Number(s)))
.map(Number)
updateDomain({ kind: att.value.domain.kind, mode: 'list', list } as any)
updateNumberDomain(att.value.domain.kind, 'list', { list })
}}
placeholder={glob.t('vcen.editor.att.numListPlaceholder')}
rows={4} class="field-input"
></textarea>
>{(d.list ?? []).join('\n')}</textarea>
{:else if d.mode === 'fixed'}
<input type="number" value={d.fixed ?? 0}
oninput={e => updateDomain(
{ kind: att.value.domain.kind, mode: 'fixed', fixed: Number(e.currentTarget.value) } as any)}
oninput={e => updateNumberDomain(att.value.domain.kind, 'fixed', { fixed: Number(e.currentTarget.value) })}
class="field-input" />
{/if}
</div>
{/if}
{:else if att.value.domain.kind === 'option'}
{@const d = att.value.domain as any}
{@const d = asOptionDomain(att.value.domain)}
{#if d}
<div class="flex flex-col gap-2">
<div class="flex gap-2">
{#each [['list', glob.t('vcen.editor.att.modeListLabel')], ['fixed', glob.t('vcen.editor.att.modeFixedLabel')]] as [mode, modeLabel]}
<button
onclick={() => updateDomain(
mode === 'list'
? { kind: 'option', mode: 'list', list: d.list ?? [] }
: { kind: 'option', mode: 'fixed', fixed: d.fixed ?? '' }
)}
onclick={() => mode === 'list'
? updateOptionDomain('list', { list: d.mode === 'list' ? d.list ?? [] : [] })
: updateOptionDomain('fixed', { fixed: d.mode === 'fixed' ? d.fixed ?? '' : '' })
}
class="px-2.5 py-1 text-xs rounded border transition-colors
{d.mode === mode
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
@ -405,7 +480,7 @@ function deleteAtt() {
<div class="flex flex-col gap-0.5 rounded border border-gray-800 bg-gray-900/50">
{#each allOpts as opt (opt.id)}
{@const checked = attOptIds.has(opt.id)}
{@const attOpt = checked ? (d.list ?? []).find((ao: any) => ao.optId === opt.id) : null}
{@const attOpt = checked && d.mode === 'list' ? (d.list ?? []).find(ao => ao.optId === opt.id) : null}
<div class="px-3 py-2 hover:bg-gray-800 transition-colors">
<label class="flex items-center gap-3 cursor-pointer group">
<input type="checkbox" {checked}
@ -420,9 +495,9 @@ function deleteAtt() {
</span>
{/if}
</label>
{#if checked}
{#if checked && attOpt}
<input type="text"
value={typeof attOpt?.label === 'string' ? attOpt.label : (attOpt?.label as any)?.['es'] ?? ''}
value={typeof attOpt.label === 'string' ? attOpt.label : (attOpt.label?.['es'] ?? '')}
oninput={e => updateOptLabel(opt.id, e.currentTarget.value)}
placeholder={glob.t('vcen.editor.att.customLabel')}
class="mt-1.5 ml-7 field-input text-xs" />
@ -439,12 +514,11 @@ function deleteAtt() {
<p class="text-xs text-gray-700 italic">{glob.t('vcen.editor.att.noGlobalOptsSimple')}</p>
{:else}
<select
value={d.fixed ?? ''}
onchange={e => updateDomain({ kind: 'option', mode: 'fixed', fixed: e.currentTarget.value as any })}
onchange={e => updateOptionFixed(e.currentTarget.value)}
class="field-input">
<option value="">{glob.t('vcen.editor.att.selectOption')}</option>
<option value="" selected={!d.fixed}>{glob.t('vcen.editor.att.selectOption')}</option>
{#each allOpts as opt (opt.id)}
<option value={opt.id}>{String(opt.value.value)}</option>
<option value={opt.id} selected={d.fixed === opt.id}>{String(opt.value.value)}</option>
{/each}
</select>
{/if}
@ -452,10 +526,11 @@ function deleteAtt() {
{/if}
</div>
{/if}
{/if}
</section>
<!-- Valor por defecto -->
{#if (att.value.domain as any).mode !== 'fixed'}
{#if getDomainMode(att.value.domain) !== 'fixed'}
<section class="flex flex-col gap-3">
<h3 class="section-title">{glob.t('vcen.editor.att.sectionDefault')}</h3>
@ -472,14 +547,15 @@ function deleteAtt() {
{/each}
</div>
{:else if att.value.domain.kind === 'option' && (att.value.domain as any).mode === 'list'}
{@const d = att.value.domain as any}
{:else if att.value.domain.kind === 'option' && getDomainMode(att.value.domain) === 'list'}
{@const list = getOptionListDomain(att.value.domain)}
{#if list}
<select
onchange={e => patch('default', e.currentTarget.value || null)}
class="field-input"
>
<option value="">{glob.t('vcen.editor.att.noDefault')}</option>
{#each (d.list ?? []) as ao (ao.optId)}
{#each list as ao (ao.optId)}
{@const opt = allOpts.find(o => o.id === ao.optId)}
{#if opt}
<option value={opt.id} selected={att.value.value === opt.id}>
@ -488,32 +564,37 @@ function deleteAtt() {
{/if}
{/each}
</select>
{/if}
{:else if (att.value.domain.kind === 'string') && (att.value.domain as any).mode === 'list'}
{@const d = att.value.domain as any}
{:else if att.value.domain.kind === 'string' && getDomainMode(att.value.domain) === 'list'}
{@const list = getStringListDomain(att.value.domain)}
{@const currentVal = String(att.value.value ?? '')}
{#if list}
<select
value={att.value.value ?? ''}
onchange={e => patch('default', e.currentTarget.value || null)}
class="field-input"
>
<option value="">{glob.t('vcen.editor.att.noDefault')}</option>
{#each (d.list ?? []) as item, i (i)}
<option value={item}>{item}</option>
<option value="" selected={!currentVal}>{glob.t('vcen.editor.att.noDefault')}</option>
{#each list as item, i (i)}
<option value={item} selected={currentVal === item}>{item}</option>
{/each}
</select>
{/if}
{:else if (att.value.domain.kind === 'number' || att.value.domain.kind === 'currency') && (att.value.domain as any).mode === 'list'}
{@const d = att.value.domain as any}
{:else if (att.value.domain.kind === 'number' || att.value.domain.kind === 'currency') && getDomainMode(att.value.domain) === 'list'}
{@const list = getNumberListDomain(att.value.domain)}
{@const currentVal = att.value.value}
{#if list}
<select
value={att.value.value ?? ''}
onchange={e => patch('default', e.currentTarget.value ? Number(e.currentTarget.value) : null)}
class="field-input"
>
<option value="">{glob.t('vcen.editor.att.noDefault')}</option>
{#each (d.list ?? []) as item, i (i)}
<option value={item}>{String(item)}</option>
<option value="" selected={currentVal == null}>{glob.t('vcen.editor.att.noDefault')}</option>
{#each list as item, i (i)}
<option value={item} selected={currentVal === item}>{String(item)}</option>
{/each}
</select>
{/if}
{:else if att.value.domain.kind === 'number' || att.value.domain.kind === 'currency'}
<input type="number" value={String(att.value.value ?? 0)}

@ -1,7 +1,7 @@
<script lang="ts">
import Icon from "@/vcen-edit/comps/Icon.svelte";
import {getContext, type Snippet} from "svelte";
import {getContext} from "svelte";
import type {EditorGlob} from "@/vcen-edit/lib/editor-glob.svelte.ts";
const glob = getContext<EditorGlob>('editor-glob')

@ -13,7 +13,7 @@ import type {AttState} from "@/vcen/lib";
let {
onclick = (e: MouseEvent) => {},
onclick = (_: MouseEvent) => {},
isActive,
state,
icon,

@ -3,7 +3,7 @@ import { getContext, untrack } from 'svelte'
import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.js'
import type { ObjID, SecID, ViewID, HotspotID } from '@/vcen/lib'
import { labelOf } from '@/vcen-edit/lib/label.ts'
import { labelOf, getLabelStr } from '@/vcen-edit/lib/label.ts'
import ButtonSidebar from "@/vcen-edit/comps/ButtonSidebar.svelte";
import ButtonSidebarExpand from "@/vcen-edit/comps/ButtonSidebarExpand.svelte";
import ButtonSidebarMove from "@/vcen-edit/comps/ButtonSidebarMove.svelte";
@ -83,12 +83,6 @@ function navigateToHotspot(objId: ObjID, secId: SecID, viewId: ViewID, hotspotId
editor.requestAction(() => editor.select({ kind: 'hotspot-info', objId, secId, viewId, hotspotId }))
}
function getLabelStr(label: any): string {
if (!label) return ''
if (typeof label === 'string') return label
return label[cat.lang] ?? Object.values(label)[0] ?? ''
}
// ── Constantes de estilo uniformes ────────────────────────────────────────────
const ICON_COLOR = 'cyan'

@ -189,7 +189,7 @@ function getRightValue(node: ConditionNode): unknown {
<!-- Operador -->
{#if leftPath && ops.length > 0}
<select
value={node.op}
bind:value={node.op}
onchange={e => setOp(node.id, e.currentTarget.value as CompareOp)}
class="shrink-0 text-xs rounded border border-gray-700 bg-gray-800
text-gray-300 px-1.5 py-1 focus:outline-none focus:border-indigo-500"

@ -1,30 +1,42 @@
<script lang="ts">
import type { Metadata, MetadataField, MetadataFieldType } from '@/mtas/lib'
import type { GlobState } from '@/glob/adapters/svelte'
import { getContext } from 'svelte'
import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.ts'
import { validateMetadata, field as f } from '@/mtas/lib'
import { makeLingRecord } from '@/ling/lib'
import AddFieldMenu from '@/mtas/svelte/components/MetaAddFieldMenu.svelte'
import FieldEditor from '@/mtas/svelte/components/MetaFieldEditor.svelte'
import TypeBadge from '@/mtas/svelte/components/MetaTypeBadge.svelte'
import AddFieldMenu from './metas/MetaAddFieldMenu.svelte'
import FieldEditor from './metas/MetaFieldEditor.svelte'
import TypeBadge from './metas/MetaTypeBadge.svelte'
import SidebarTree from './SidebarTree.svelte'
import EditorTreeNodes from './EditorTreeNodes.svelte'
let {
value = $bindable<Metadata>({}),
tags = [],
glob,
locales,
defaultLocale,
tags: tagsProp,
glob: globProp,
locales: localesProp,
defaultLocale: defaultLocaleProp,
onUpdate,
}: {
value?: Metadata
tags?: string[]
glob: GlobState
locales: string[]
defaultLocale: string
glob?: GlobState
locales?: string[]
defaultLocale?: string
onUpdate?: (metadata: Metadata) => void
} = $props()
// Obtener del contexto si no se pasan como props
const editor = getContext<CatalogEditorStore>('catalog-editor')
const globCtx = getContext<EditorGlob>('editor-glob')
const glob = $derived(globProp ?? globCtx)
const locales = $derived(localesProp ?? (editor?.cat?.langs as string[]) ?? [])
const defaultLocale = $derived(defaultLocaleProp ?? editor?.cat?.lang ?? 'es')
const tags = $derived(tagsProp ?? ((editor?.cat as any)?.metaTags as string[]) ?? [])
let selectedPath = $state<string[]>([])
let showDeleteConfirm = $state(false)
let sidebarWidth = $state(200)
@ -253,7 +265,7 @@ const selectedKey = $derived(selectedPath.length > 0 ? selectedPath[selectedPa
{#if keyOrder.length === 0}
<div class="flex flex-col items-center justify-center gap-2 py-12 px-4 text-center">
<svg class="h-8 w-8 text-gray-700" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<svg class="h-8 w-8 text-gray-700 fill-current" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="4"/>
<path d="M12 8v8M8 12h8"/>
</svg>
@ -326,7 +338,7 @@ const selectedKey = $derived(selectedPath.length > 0 ? selectedPath[selectedPa
{#if !validation.valid}
<div class="shrink-0 border-t border-gray-800 px-3 py-2">
<div class="flex items-start gap-2 rounded border border-red-900 bg-red-950 px-3 py-2.5 text-red-400">
<svg class="h-4 w-4 shrink-0 mt-0.5" viewBox="0 0 16 16" fill="currentColor">
<svg class="h-4 w-4 shrink-0 mt-0.5 fill-current" viewBox="0 0 16 16">
<path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm0 3.5a.75.75 0 0 1 .75.75v3a.75.75 0 0 1-1.5 0v-3A.75.75 0 0 1 8 4.5zm0 7a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
</svg>
<div class="flex flex-col gap-0.5">

@ -65,8 +65,8 @@ function groupValue(f: MetadataField): Metadata { return f.type === 'group' ? f.
<button type="button"
onclick={e => { e.stopPropagation(); toggleGroup(key) }}
class="flex items-center justify-center w-4 h-4 rounded text-gray-600 hover:text-gray-400 hover:bg-gray-700 transition-colors shrink-0 p-0">
<svg class="h-3 w-3 transition-transform {groupOpen ? 'rotate-90' : ''}"
viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2">
<svg class="h-3 w-3 fill-current transition-transform {groupOpen ? 'rotate-90' : ''}"
viewBox="0 0 12 12" stroke="currentColor" stroke-width="2">
<path d="M4 2l4 4-4 4"/>
</svg>
</button>

@ -0,0 +1,153 @@
<script lang="ts">
import { logr } from '../lib/logr';
import type { Snippet } from 'svelte';
let { children } = $props<{
children: Snippet
}>();
let error = $state<Error | null>(null);
let errorInfo = $state<unknown | null>(null);
/**
* Manejador de errores global para componentes hijos
* Captura errores que ocurren durante el renderizado
*/
function handleError(err: Error, info: unknown) {
error = err;
errorInfo = info;
logr.error('error-boundary',
{ es: 'Error capturado en componente' },
{
error: err.message,
stack: err.stack,
info: info ? String(info) : undefined
}
);
}
/**
* Reinicia el estado del error para intentar recuperación
*/
function reset() {
error = null;
errorInfo = null;
}
</script>
{#if error}
<div class="error-boundary">
<h2 class="error-boundary__title">Algo salió mal</h2>
<div class="error-boundary__message">
<strong>Error:</strong> {error.message}
</div>
{#if errorInfo}
<div class="error-boundary__info">
<strong>Información adicional:</strong>
<pre class="error-boundary__info-text">{String(errorInfo)}</pre>
</div>
{/if}
<button onclick={reset} class="error-boundary__button">
Intentar de nuevo
</button>
<details class="error-boundary__stack">
<summary class="error-boundary__summary">Stack trace (desarrollo)</summary>
<pre class="error-boundary__stack-text">{error.stack}</pre>
</details>
</div>
{:else}
{@render children()}
{/if}
<style>
.error-boundary {
padding: 24px;
background-color: #fef2f2;
border: 2px solid #ef4444;
border-radius: 12px;
color: #991b1b;
font-family: system-ui, -apple-system, sans-serif;
margin: 16px;
}
.error-boundary__title {
margin: 0 0 16px 0;
font-size: 20px;
font-weight: 600;
color: #dc2626;
}
.error-boundary__message {
margin: 0 0 12px 0;
padding: 12px;
background-color: #fee2e2;
border-radius: 6px;
font-size: 14px;
}
.error-boundary__info {
margin: 0 0 16px 0;
}
.error-boundary__info-text {
margin-top: 8px;
padding: 8px;
background-color: #fff;
border-radius: 4px;
overflow-x: auto;
font-size: 12px;
}
.error-boundary__button {
padding: 10px 20px;
background-color: #ef4444;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.error-boundary__button:hover {
background-color: #dc2626;
}
.error-boundary__button:active {
background-color: #b91c1c;
}
.error-boundary__stack {
margin-top: 16px;
}
.error-boundary__summary {
cursor: pointer;
font-weight: 500;
color: #991b1b;
padding: 8px 12px;
background-color: #fef2f2;
border-radius: 4px;
margin-bottom: 8px;
}
.error-boundary__summary:hover {
background-color: #fee2e2;
}
.error-boundary__stack-text {
margin: 0;
padding: 12px;
background-color: #fff;
border-radius: 4px;
overflow-x: auto;
font-size: 11px;
line-height: 1.5;
}
</style>

@ -96,11 +96,10 @@ const ICONS: Record<IconName, string> = {
<svg
viewBox="0 0 24 24"
fill="none"
stroke={color}
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-linejoin="round"
class={cls}
class={`fill-current ${cls}`}
aria-hidden="true"
>{@html ICONS[name]}</svg>

@ -62,9 +62,9 @@ function onKeydown(e: KeyboardEvent) {
{disabled ? 'opacity-50 cursor-not-allowed' : 'hover:border-gray-600'}"
>
{#if selected}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"
<svg viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round"
class="h-3.5 w-3.5 shrink-0 text-indigo-400">
class="h-3.5 w-3.5 shrink-0 text-indigo-400 fill-current">
{@html selected.svg}
</svg>
<span class="text-xs text-gray-300 flex-1 text-left">{selected.label}</span>
@ -72,8 +72,8 @@ function onKeydown(e: KeyboardEvent) {
{:else}
<span class="text-xs text-gray-600 flex-1 text-left italic">{glob.t('vcen.editor.iconPicker.noIcon')}</span>
{/if}
<svg viewBox="0 0 10 10" fill="none" stroke="currentColor" stroke-width="2"
class="h-2 w-2 shrink-0 text-gray-600 transition-transform {open ? 'rotate-180' : ''}">
<svg viewBox="0 0 10 10" stroke="currentColor" stroke-width="2"
class="h-2 w-2 shrink-0 fill-current text-gray-600 transition-transform {open ? 'rotate-180' : ''}">
<path d="M2 3.5l3 3 3-3"/>
</svg>
</button>
@ -98,8 +98,8 @@ function onKeydown(e: KeyboardEvent) {
title={glob.t('vcen.editor.iconPicker.clearIcon')}
class="flex items-center gap-1 text-[10px] text-gray-600 hover:text-red-400 transition-colors"
>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round" class="h-3 w-3">
<svg viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round" class="h-3 w-3 fill-current">
<path d="M2 4h12M5 4V2.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 .5.5V4M6 7v5M10 7v5M3 4l.8 9a.5.5 0 0 0 .5.45h7.4a.5.5 0 0 0 .5-.45L13 4"/>
</svg>
{glob.t('vcen.editor.iconPicker.clearIcon')}
@ -124,9 +124,9 @@ function onKeydown(e: KeyboardEvent) {
? 'bg-indigo-600/20 border border-indigo-600/50 text-indigo-400'
: 'border border-transparent text-gray-500 hover:bg-gray-800 hover:text-amber-600'}"
>
<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="1.5"
<svg viewBox="0 0 32 32" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round"
class="h-8 w-8 shrink-0">
class="h-8 w-8 shrink-0 fill-current">
{@html icon.svg}
</svg>
<span class="text-[11px] text-center leading-tight max-w-full truncate w-full">{icon.label}</span>

@ -1,50 +0,0 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.js'
import { labelOf } from '@/vcen-edit/lib/label.ts'
import PanelMeta from './PanelMeta.svelte'
const editor = getContext<CatalogEditorStore>('catalog-editor')
const glob = getContext<EditorGlob>('editor-glob')
const sel = $derived(editor.selection)
const ctx = $derived.by(() => {
if (sel.kind === 'cat-meta') {
const cat = editor.cat
return {
title: cat.name || glob.t('vcen.editor.sidebar.catSection'),
id: cat.id,
meta: cat.metadata,
onUpdate: (m: any) => editor.updateCatMeta(m),
}
}
if (sel.kind === 'obj-meta') {
const obj = editor.findObj(sel.objId)
if (!obj) return null
return {
title: labelOf(obj.label, editor.cat.lang) || glob.t('vcen.editor.common.noName'),
id: obj.id,
meta: obj.metadata,
onUpdate: (m: any) => editor.updateObjMeta(sel.objId, m),
}
}
if (sel.kind === 'sec-meta') {
const sec = editor.findSec(sel.objId, sel.secId)
if (!sec) return null
return {
title: labelOf(sec.label, editor.cat.lang) || glob.t('vcen.editor.common.noName'),
id: sec.id,
meta: sec.metadata,
onUpdate: (m: any) => editor.updateSecMeta(sel.objId, sel.secId, m),
}
}
return null
})
</script>
{#if ctx}
<div class="absolute inset-0 flex flex-col overflow-hidden">
<PanelMeta value={ctx.meta} onUpdate={ctx.onUpdate} />
</div>
{/if}

@ -11,6 +11,8 @@ import VisualPreview from './VisualPreview.svelte'
import ButtonDelete from './ButtonDelete.svelte'
import Icon from './Icon.svelte'
import { isValidId } from '@/vcen-edit/lib/validate'
import { getLabelStr } from '@/vcen-edit/lib/label.ts'
import { logr } from '@/vcen-edit/lib/logr.ts'
type LingRec = NonNullable<Hotspot['label']>
@ -114,14 +116,19 @@ const previewResolution = $derived.by((): ViewResolution | null => {
const view = ctx?.view
const visual = ctx?.sec?.visual
if (!view || !previewObj) return null
try { return resolveView(view, previewObj, visual, visual, cat, configState) } catch { return null }
})
function getLabelStr(label: any): string {
if (!label) return ''
if (typeof label === 'string') return label
return label[cat.lang] ?? Object.values(label)[0] ?? ''
try {
return resolveView(view, previewObj, visual, visual, cat, configState)
} catch (error) {
logr.warn('hotspot-preview',
{ es: 'Error resolviendo vista para hotspot' },
{
hotspotId: ctx?.hotspot?.id,
error: error instanceof Error ? error.message : String(error)
}
);
return null;
}
})
</script>
{#if ctx?.hs && ctx.view}

@ -1,34 +0,0 @@
<script lang="ts">
import type { Metadata } from '@/mtas/lib'
import { getContext } from 'svelte'
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.ts'
import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import EditorMetaTree from './EditorMetaTree.svelte'
let {
value = $bindable<Metadata | undefined>(undefined),
onUpdate,
}: {
value?: Metadata
onUpdate: (meta: Metadata | undefined) => void
} = $props()
const glob = getContext<EditorGlob>('editor-glob')
const editor = getContext<CatalogEditorStore>('catalog-editor')
const meta = $derived(value ?? {})
const locales = $derived(editor.cat.langs as string[])
const defaultLocale = $derived(editor.cat.lang as string)
const tags = $derived((editor.cat as any).metaTags as string[] ?? [])
</script>
<div class="h-full flex flex-col">
<EditorMetaTree
value={meta}
{glob}
{locales}
{defaultLocale}
{tags}
onUpdate={(m) => onUpdate(Object.keys(m).filter(k => k !== 'order').length === 0 ? undefined : m)}
/>
</div>

@ -12,7 +12,7 @@ import Panel from './Panel.svelte'
import RuleList from './RuleList.svelte'
import SectionIssue from './SectionIssue.svelte'
import PanelObjectAtts from './PanelObjectAtts.svelte'
import PanelMeta from './PanelMeta.svelte'
import EditorMetaTree from './EditorMetaTree.svelte'
import PanelVisual from './PanelVisual.svelte'
import ButtonDelete from './ButtonDelete.svelte'
import SectionPricing from './SectionPricing.svelte'
@ -82,7 +82,7 @@ function cancelNew() {
<button onclick={saveNew} class="ve-action-btn primary">{glob.t('vcen.editor.common.save')}</button>
{:else}
<button onclick={() => editor.requestAction(() => editor.addSec(objId))} class="ve-action-btn">
+ {glob.t('vcen.editor.sidebar.newSec')}
{glob.t('vcen.editor.common.add')} {glob.t('vcen.editor.sidebar.newSec')}
</button>
<button onclick={() => editor.cloneObj(objId)} class="ve-action-btn">{glob.t('vcen.editor.common.clone')}</button>
<ButtonDelete size="md" confirm
@ -126,15 +126,15 @@ function cancelNew() {
{isNew}
fullId={obj.id}
bind:slug={editId}
idPlaceholder="habitacion, cocina…"
idPlaceholder="Ej: habitacion, cocina…"
idError={idError}
onIdInput={() => idError = ''}
onIdKeydown={e => { if (e.key === 'Enter') saveNew() }}
name={obj.label}
locales={cat.langs}
defaultLocale={cat.lang}
namePlaceholder="Habitación, Cocina..."
onNameUpdate={label => editor.setObjLabel(objId, label as LingRecord)}
namePlaceholder="Ej: Hábitación, Cocina..."
onNameUpdate={label => editor.setObjLabel(objId, label)}
/>
</section>
@ -229,9 +229,9 @@ function cancelNew() {
{:else if tab === 'meta'}
<div class="flex-1 min-h-0 overflow-hidden">
<PanelMeta
value={obj.metadata}
onUpdate={m => editor.updateObjMeta(objId, m)}
<EditorMetaTree
value={obj.metadata ?? {}}
onUpdate={m => editor.updateObjMeta(objId, Object.keys(m).filter(k => k !== 'order').length === 0 ? undefined : m)}
/>
</div>
{/if}

@ -12,7 +12,7 @@ import Panel from './Panel.svelte'
import ButtonDelete from './ButtonDelete.svelte'
import SectionPricing from './SectionPricing.svelte'
import BadgeCount from './BadgeCount.svelte'
import PanelMeta from './PanelMeta.svelte'
import EditorMetaTree from './EditorMetaTree.svelte'
import SectionIssue from './SectionIssue.svelte'
import RuleList from './RuleList.svelte'
import Tabs from './Tabs.svelte'
@ -332,9 +332,9 @@ const OPT_TABS = $derived([
<!-- ── Meta ─────────────────────────────────────────────────────── -->
{:else if tab === 'meta'}
<div class="flex-1 min-h-0 overflow-hidden">
<PanelMeta
value={opt.metadata}
onUpdate={m => editor.updateOpt(opt.id, { metadata: m })}
<EditorMetaTree
value={opt.metadata ?? {}}
onUpdate={m => editor.updateOpt(opt.id, { metadata: Object.keys(m).filter(k => k !== 'order').length === 0 ? undefined : m })}
/>
</div>
{/if}

@ -130,8 +130,8 @@ function focusOnMount(node: HTMLInputElement) {
onkeydown={e => { if (e.key === 'Enter' || e.key === ' ') toggleGroup(group.catId) }}
>
<!-- Flecha -->
<svg class="h-2.5 w-2.5 shrink-0 text-gray-600 transition-transform {isOpen ? 'rotate-90' : ''}"
viewBox="0 0 8 8" fill="currentColor"><polygon points="2,1 7,4 2,7"/></svg>
<svg class="h-2.5 w-2.5 shrink-0 text-gray-600 transition-transform {isOpen ? 'rotate-90' : ''} fill-current"
viewBox="0 0 8 8"><polygon points="2,1 7,4 2,7"/></svg>
<!-- Nombre -->
{#if isNoCat}
@ -171,8 +171,8 @@ function focusOnMount(node: HTMLInputElement) {
class="flex items-center justify-center w-5 h-5 rounded text-gray-600 hover:text-indigo-400 transition-colors"
title="Renombrar categoría"
>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round" class="h-3 w-3">
<svg viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round" class="h-3 w-3 fill-current">
<path d="M11.5 2.5a1.414 1.414 0 0 1 2 2L5 13H3v-2L11.5 2.5z"/>
</svg>
</button>
@ -182,8 +182,8 @@ function focusOnMount(node: HTMLInputElement) {
class="flex items-center justify-center w-5 h-5 rounded text-gray-600 hover:text-red-400 transition-colors"
title="Eliminar categoría"
>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round" class="h-3 w-3">
<svg viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round" class="h-3 w-3 fill-current">
<path d="M2 4h12M5 4V2.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 .5.5V4M6 7v5M10 7v5M3 4l.8 9a.5.5 0 0 0 .5.45h7.4a.5.5 0 0 0 .5-.45L13 4"/>
</svg>
</button>

@ -1,17 +1,18 @@
<script lang="ts">
import type { LingRecord} from "@/ling/lib";
import { getContext } from 'svelte'
import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.js'
import { labelOf } from '@/vcen-edit/lib/label.ts'
import { isValidId } from '@/vcen-edit/lib/validate'
import type { LingRecord } from '@/ling/lib'
import LingInput from '@/ling/svelte/LingInput.svelte'
import InputIdName from './InputIdName.svelte'
import Panel from './Panel.svelte'
import RuleList from './RuleList.svelte'
import SectionIssue from './SectionIssue.svelte'
import PanelSectionAtts from './PanelSectionAtts.svelte'
import PanelMeta from './PanelMeta.svelte'
import EditorMetaTree from './EditorMetaTree.svelte'
import PanelVisual from './PanelVisual.svelte'
import ButtonDelete from './ButtonDelete.svelte'
import SectionPricing from './SectionPricing.svelte'
@ -47,6 +48,10 @@ $effect(() => {
if (ctx?.sec) { editId = String(ctx.secId).replace(/^[a-z]+_/, ''); idError = '' }
})
// Helper para LingInput - evita cast en template
function orLing(v: LingRecord | undefined): LingRecord {
return v ?? ({} as LingRecord)
}
function saveNew() {
if (!ctx?.sec) return
@ -132,30 +137,30 @@ function cancelNew() {
name={sec.label}
locales={cat.langs}
defaultLocale={cat.lang}
namePlaceholder="Suelo, Paredes..."
onNameUpdate={label => editor.setSecLabel(objId, secId, label as LingRecord)}
namePlaceholder="Ej: Suelo, Paredes..."
onNameUpdate={label => editor.setSecLabel(objId, secId, label)}
/>
</section>
<!-- Info -->
<section class="flex flex-col gap-3">
<h3 class="section-title">{glob.t('vcen.editor.entity.info')}</h3>
<LingInput value={(sec.title ?? {}) as LingRecord} locales={cat.langs} defaultLocale={cat.lang}
<LingInput value={orLing(sec.title)} locales={cat.langs} defaultLocale={cat.lang}
as="input" variant="dark" label={glob.t('vcen.editor.entity.title')}
placeholder="Título público de la sección"
onUpdate={v => editor.updateSecInfo(objId, secId, 'title', (v as LingRecord) || undefined)} />
<LingInput value={(sec.Summary ?? {}) as LingRecord} locales={cat.langs} defaultLocale={cat.lang}
onUpdate={v => editor.updateSecInfo(objId, secId, 'title', v || undefined)} />
<LingInput value={orLing(sec.Summary)} locales={cat.langs} defaultLocale={cat.lang}
as="input" variant="dark" label={glob.t('vcen.editor.entity.summary')}
placeholder="Descripción breve"
onUpdate={v => editor.updateSecInfo(objId, secId, 'Summary', (v as LingRecord) || undefined)} />
<LingInput value={(sec.Description ?? {}) as LingRecord} locales={cat.langs} defaultLocale={cat.lang}
onUpdate={v => editor.updateSecInfo(objId, secId, 'Summary', v || undefined)} />
<LingInput value={orLing(sec.Description)} locales={cat.langs} defaultLocale={cat.lang}
as="textarea" variant="dark" label={glob.t('vcen.editor.entity.description')}
placeholder="Descripción detallada"
onUpdate={v => editor.updateSecInfo(objId, secId, 'Description', (v as LingRecord) || undefined)} />
<LingInput value={(sec.Help ?? {}) as LingRecord} locales={cat.langs} defaultLocale={cat.lang}
onUpdate={v => editor.updateSecInfo(objId, secId, 'Description', v || undefined)} />
<LingInput value={orLing(sec.Help)} locales={cat.langs} defaultLocale={cat.lang}
as="textarea" variant="dark" label={glob.t('vcen.editor.entity.help')}
placeholder="Texto de ayuda o instrucciones"
onUpdate={v => editor.updateSecInfo(objId, secId, 'Help', (v as LingRecord) || undefined)} />
onUpdate={v => editor.updateSecInfo(objId, secId, 'Help', v || undefined)} />
</section>
<!-- Precio base -->
@ -228,9 +233,9 @@ function cancelNew() {
{:else if tab === 'meta'}
<div class="flex-1 min-h-0 overflow-hidden">
<PanelMeta
value={sec.metadata}
onUpdate={m => editor.updateSecMeta(objId, secId, m)}
<EditorMetaTree
value={sec.metadata ?? {}}
onUpdate={m => editor.updateSecMeta(objId, secId, Object.keys(m).filter(k => k !== 'order').length === 0 ? undefined : m)}
/>
</div>
{/if}

@ -19,6 +19,8 @@ import TransactionEditor from './TransactionEditor.svelte'
import type { LingRecord, LingString } from '@/ling/lib'
import { buildScope } from '@/vcen-edit/lib/condition-editor.ts'
import { isValidId } from '@/vcen-edit/lib/validate'
import { getLabelStr } from '@/vcen-edit/lib/label.ts'
import { logr } from '@/vcen-edit/lib/logr.ts'
const editor = getContext<CatalogEditorStore>('catalog-editor')
const glob = getContext<EditorGlob>('editor-glob')
@ -234,16 +236,21 @@ const previewResolution = $derived.by((): ViewResolution | null => {
const view = ctx?.view
const visual = ctx?.sec?.visual
if (!view || !previewObj) return null
try { return resolveView(view, previewObj, visual, visual, cat, configState) } catch { return null }
try {
return resolveView(view, previewObj, visual, visual, cat, configState)
} catch (error) {
logr.warn('view-preview',
{ es: 'Error resolviendo vista' },
{
viewId: view.id,
error: error instanceof Error ? error.message : String(error)
}
);
return null;
}
})
function updateViewName(v: LingString) { patch({ label: v as LingRecord }) }
function getLabelStr(label: any): string {
if (!label) return ''
if (typeof label === 'string') return label
return label[cat.lang] ?? Object.values(label)[0] ?? ''
}
</script>
{#if ctx?.view}
@ -304,7 +311,7 @@ function getLabelStr(label: any): string {
</div>
<div class="flex flex-col gap-1">
<label class="ve-label">{glob.t('vcen.editor.view.type')}</label>
<select value={view.viewType}
<select bind:value={view.viewType}
onchange={e => patch({ viewType: toViewType(e.currentTarget.value) })}
class="field-input text-xs">
{#each VIEW_TYPE_VALUES as vt (vt)}
@ -344,7 +351,7 @@ function getLabelStr(label: any): string {
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1">
<label class="ve-label">{glob.t('vcen.editor.view.strategy')}</label>
<select value={view.config.strategy}
<select bind:value={view.config.strategy}
onchange={e => patch({ config: defaultConfig(e.currentTarget.value) })}
class="field-input text-xs">
{#each STRATEGY_VALUES as s (s)}
@ -386,11 +393,11 @@ function getLabelStr(label: any): string {
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-1">
<label class="ve-label">{glob.t('vcen.editor.view.method')}</label>
<select value={apiMethod}
<select
onchange={e => patch({ config: { ...apiCfg, method: toApiMethod(e.currentTarget.value) } })}
class="field-input text-xs w-28">
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="GET" selected={apiMethod === 'GET'}>GET</option>
<option value="POST" selected={apiMethod === 'POST'}>POST</option>
</select>
</div>
<div class="flex flex-col gap-1">
@ -449,8 +456,8 @@ function getLabelStr(label: any): string {
onkeydown={e => { if (e.key === 'Enter') openLayers = isOpen ? openLayers.filter(x => x !== li) : [...openLayers, li] }}
class="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-gray-800/40 transition-colors select-none"
>
<svg class="h-2.5 w-2.5 shrink-0 transition-transform text-gray-600 {isOpen ? 'rotate-90' : ''}"
viewBox="0 0 8 8" fill="currentColor"><polygon points="2,1 7,4 2,7"/></svg>
<svg class="h-2.5 w-2.5 shrink-0 fill-current transition-transform text-gray-600 {isOpen ? 'rotate-90' : ''}"
viewBox="0 0 8 8"><polygon points="2,1 7,4 2,7"/></svg>
<span class="text-[10px] font-bold uppercase tracking-widest text-gray-600 shrink-0">
{glob.t('vcen.editor.view.layerN', { n: li + 1 })}
</span>
@ -508,8 +515,8 @@ function getLabelStr(label: any): string {
<div class="border-t border-gray-800/50 pt-2">
<button onclick={() => condOpenLayer === li ? condOpenLayer = null : openLayerCond(li, layer)}
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-gray-600 hover:text-gray-400 transition-colors w-full text-left">
<svg class="h-2.5 w-2.5 transition-transform {condOpenLayer === li ? 'rotate-90' : ''}"
viewBox="0 0 8 8" fill="currentColor"><polygon points="2,1 7,4 2,7"/></svg>
<svg class="h-2.5 w-2.5 fill-current transition-transform {condOpenLayer === li ? 'rotate-90' : ''}"
viewBox="0 0 8 8"><polygon points="2,1 7,4 2,7"/></svg>
{glob.t('vcen.editor.view.condition')}
{#if layer.condition}
<span class="ml-auto text-amber-600 normal-case tracking-normal">{glob.t('vcen.editor.view.condActive')}</span>

File diff suppressed because it is too large Load Diff

@ -36,7 +36,7 @@ let {
class="ve-sidebar-add-btn"
aria-label="Añadir"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor"
<svg class="h-3.5 w-3.5 fill-current" viewBox="0 0 16 16" stroke="currentColor"
stroke-width="2.5" stroke-linecap="round">
<path d="M8 2v12M2 8h12"/>
</svg>

@ -21,6 +21,24 @@ const hasDirection = $derived(selectedDef?.hasDirection ?? false)
let open = $state(false)
// Local state for selects (props are readonly in Svelte 5)
let selectedName = $state(value?.name ?? '')
let selectedEasing = $state(value?.easing ?? '')
let selectedDirection = $state(value?.direction ?? '')
// Sync when prop changes
$effect(() => {
if (value) {
selectedName = value.name ?? ''
selectedEasing = value.easing ?? ''
selectedDirection = value.direction ?? ''
} else {
selectedName = ''
selectedEasing = ''
selectedDirection = ''
}
})
function add() {
onUpdate({ name: '', duration: 400, easing: 'ease' })
open = true
@ -35,6 +53,19 @@ function patch(p: Partial<Transaction>) {
if (!value) return
onUpdate({ ...value, ...p })
}
// Update helpers sin casts en template
function updateName(name: string) {
patch({ name })
}
function updateEasing(easing: string) {
patch({ easing: (easing || undefined) as TransactionEasing | undefined })
}
function updateDirection(dir: string) {
patch({ direction: dir || undefined })
}
</script>
<!-- ── Cabecera colapsable ─────────────────────────────────────────────────── -->
@ -47,8 +78,8 @@ function patch(p: Partial<Transaction>) {
class="flex items-center gap-2 px-3 py-2 select-none {value ? 'cursor-pointer hover:bg-gray-800/40' : ''} transition-colors"
>
{#if value}
<svg class="h-2.5 w-2.5 shrink-0 transition-transform text-gray-600 {open ? 'rotate-90' : ''}"
viewBox="0 0 8 8" fill="currentColor"><polygon points="2,1 7,4 2,7"/></svg>
<svg class="h-2.5 w-2.5 fill-current shrink-0 transition-transform text-gray-600 {open ? 'rotate-90' : ''}"
viewBox="0 0 8 8"><polygon points="2,1 7,4 2,7"/></svg>
{/if}
<span class="text-[10px] font-bold uppercase tracking-widest text-gray-600 shrink-0">
{glob.t('vcen.editor.transaction.section')}
@ -61,8 +92,8 @@ function patch(p: Partial<Transaction>) {
class="ve-card-btn danger ml-auto px-1 py-0.5 shrink-0"
title={glob.t('vcen.editor.transaction.removeBtn')}
>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round" class="h-3 w-3">
<svg viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round" class="h-3 w-3 fill-current">
<path d="M2 4h12M5 4V2.5a.5.5 0 0 1 .5-.5h5a.5.5 0 0 1 .5.5V4M6 7v5M10 7v5M3 4l.8 9a.5.5 0 0 0 .5.45h7.4a.5.5 0 0 0 .5-.45L13 4"/>
</svg>
</button>
@ -84,8 +115,8 @@ function patch(p: Partial<Transaction>) {
<div class="flex flex-col gap-1">
<label class="ve-label">{glob.t('vcen.editor.transaction.name')}</label>
<select
value={value.name}
onchange={e => patch({ name: e.currentTarget.value, direction: undefined })}
bind:value={selectedName}
onchange={() => patch({ name: selectedName, direction: undefined })}
class="field-input text-xs"
>
<option value="">—</option>
@ -116,8 +147,8 @@ function patch(p: Partial<Transaction>) {
<div class="flex flex-col gap-1">
<label class="ve-label">{glob.t('vcen.editor.transaction.easing')}</label>
<select
value={value.easing ?? ''}
onchange={e => patch({ easing: (e.currentTarget.value || undefined) as TransactionEasing | undefined })}
bind:value={selectedEasing}
onchange={() => patch({ easing: (selectedEasing || undefined) as TransactionEasing | undefined })}
class="field-input text-xs"
>
<option value="">—</option>
@ -132,8 +163,8 @@ function patch(p: Partial<Transaction>) {
<div class="flex flex-col gap-1">
<label class="ve-label">{glob.t('vcen.editor.transaction.direction')}</label>
<select
value={value.direction ?? ''}
onchange={e => patch({ direction: e.currentTarget.value || undefined })}
bind:value={selectedDirection}
onchange={() => patch({ direction: selectedDirection || undefined })}
class="field-input text-xs"
>
<option value="">—</option>
@ -161,6 +192,7 @@ function patch(p: Partial<Transaction>) {
<!-- Delay salida -->
<div class="flex flex-col gap-1">
<label class="ve-label">{glob.t('vcen.editor.transaction.delayOut')}</label>
<input
type="number" min="0" step="50"

@ -3,6 +3,7 @@ import { untrack, getContext } from 'svelte'
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.js'
import type { View, HotspotID, LayerDefinition,
ViewResolution, ResolvedLayersView } from '@/vcen/lib'
import { HOTSPOT_DEFAULT_RADIUS } from '@/vcen-edit/lib/constants'
const glob = getContext<EditorGlob>('editor-glob')
const STRATEGY_LABELS = $derived<Record<string, string>>({
@ -238,7 +239,7 @@ let layersPanelOpen = $state(true)
{@const isActive = activeHotspotId === marker.id}
{@const mX = typeof marker.posX === 'number' ? marker.posX : parseFloat(String(marker.posX)) || 0}
{@const mY = typeof marker.posY === 'number' ? marker.posY : parseFloat(String(marker.posY)) || 0}
{@const size = (marker.radius ?? 8) * 2}
{@const size = (marker.radius ?? HOTSPOT_DEFAULT_RADIUS) * 2}
<div
class="absolute rounded-full border-2 transition-all pointer-events-none
{isActive ? 'border-white shadow-lg' : 'border-white/50'}"

@ -1,42 +0,0 @@
<script lang="ts">
import type { PdfField } from '../../../lib';
import type { MetadataField } from '../../../lib';
import type { GlobState } from '@/glob/adapters/svelte';
let { field, glob, onFieldChange }: {
field: PdfField;
glob: GlobState;
onFieldChange: (field: MetadataField) => void;
} = $props();
const cls = 'rounded border border-gray-700 bg-gray-800 px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 w-full';
function handle(e: Event, action: string): void {
const str = (e.target as HTMLInputElement).value;
switch (action) {
case 'url': onFieldChange({ ...field, value: { ...field.value, url: str } }); break;
case 'pages': { const v = parseInt(str); onFieldChange({ ...field, value: { ...field.value, ...(isNaN(v) ? {} : { pages: v }) } }); break; }
case 'author': onFieldChange({ ...field, value: { ...field.value, author: str } }); break;
case 'title': onFieldChange({ ...field, value: { ...field.value, title: str } }); break;
}
}
</script>
<div class="flex flex-col gap-1 w-full">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.editor.mediaUrlLabel')}</label>
<input type="text" value={field.value.url} oninput={(e) => handle(e, 'url')} class="{cls} font-mono" placeholder="/ruta o https://…" />
</div>
<div class="flex gap-3">
<div class="flex flex-col gap-1 w-28">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.editor.pdfPages')}</label>
<input type="number" value={field.value.pages ?? ''} oninput={(e) => handle(e, 'pages')} class={cls} />
</div>
<div class="flex flex-col gap-1 flex-1">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.editor.pdfAuthor')}</label>
<input type="text" value={field.value.author ?? ''} oninput={(e) => handle(e, 'author')} class={cls} />
</div>
<div class="flex flex-col gap-1 flex-1">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.editor.pdfTitle')}</label>
<input type="text" value={field.value.title ?? ''} oninput={(e) => handle(e, 'title')} class={cls} />
</div>
</div>

@ -1,57 +0,0 @@
<script lang="ts">
import type { SelectField } from '../../../lib';
import type { MetadataField } from '../../../lib';
import type { GlobState } from '@/glob/adapters/svelte';
let { field, glob, onFieldChange }: {
field: SelectField;
glob: GlobState;
onFieldChange: (field: MetadataField) => void;
} = $props();
function handle(e: Event, action: string, extra: number = 0): void {
const inp = e.target as HTMLInputElement;
const sel = e.target as HTMLSelectElement;
const str = inp.value;
const opts = field.options;
switch (action) {
case 'val': onFieldChange({ ...field, value: sel.value || null }); break;
case 'opt-val': onFieldChange({ ...field, options: opts.map((o, j) => j === extra ? { ...o, value: str } : o) }); break;
case 'opt-lbl': onFieldChange({ ...field, options: opts.map((o, j) => j === extra ? { ...o, label: str } : o) }); break;
}
}
</script>
<div class="flex flex-col gap-1 w-full">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.editor.selectValue')}</label>
<select class="rounded border border-gray-700 bg-gray-800 px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 w-full" value={field.value ?? ''} onchange={(e) => handle(e, 'val')}>
<option value="">—</option>
{#each field.options as opt}
<option value={opt.value}>{opt.label || opt.value}</option>
{/each}
</select>
</div>
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500">{glob.t('vcen.editor.mtas.editor.selectOptions')}</span>
<button type="button" class="inline-flex items-center gap-1 px-2 py-1 text-xs rounded text-gray-500 hover:text-gray-300 hover:bg-gray-800 transition-colors"
onclick={() => onFieldChange({ ...field, options: [...field.options, { value: `opt${field.options.length + 1}`, label: '' }] })}>
<svg class="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M8 2v12M2 8h12"/></svg>
{glob.t('vcen.editor.mtas.editor.selectAddOpt')}
</button>
</div>
{#if field.options.length === 0}
<p class="text-xs text-gray-600 text-center py-2">{glob.t('vcen.editor.mtas.editor.selectNoOpts')}</p>
{:else}
{#each field.options as opt, i}
<div class="flex gap-2 items-center">
<input type="text" value={opt.value} oninput={(e) => handle(e, 'opt-val', i)} class="rounded border border-gray-700 bg-gray-800 px-2 py-1 text-xs text-gray-200 focus:outline-none focus:ring-1 focus:ring-blue-500 flex-1 font-mono" placeholder={glob.t('vcen.editor.mtas.editor.selectOptValue')} />
<input type="text" value={opt.label} oninput={(e) => handle(e, 'opt-lbl', i)} class="rounded border border-gray-700 bg-gray-800 px-2 py-1 text-xs text-gray-200 focus:outline-none focus:ring-1 focus:ring-blue-500 flex-1" placeholder={glob.t('vcen.editor.mtas.editor.selectOptLabel')} />
<button type="button" class="flex items-center justify-center w-6 h-6 rounded text-red-400 hover:bg-gray-800 transition-colors"
onclick={() => onFieldChange({ ...field, options: field.options.filter((_, j) => j !== i), value: field.value === opt.value ? null : field.value })}>
<svg class="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M3 4h10M6 4V2.5h4V4M5 4l.5 9h5l.5-9"/></svg>
</button>
</div>
{/each}
{/if}
</div>

@ -1,20 +0,0 @@
<script lang="ts">
import type { StringField } from '../../../lib';
import type { MetadataField } from '../../../lib';
import type { GlobState } from '@/glob/adapters/svelte';
let { field, glob, onFieldChange }: {
field: StringField;
glob: GlobState;
onFieldChange: (field: MetadataField) => void;
} = $props();
function onInput(e: Event): void {
onFieldChange({ ...field, value: (e.target as HTMLInputElement).value });
}
</script>
<div class="flex flex-col gap-1 w-full">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.types.string')}</label>
<input type="text" value={field.value} oninput={onInput} class="rounded border border-gray-700 bg-gray-800 px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 w-full" placeholder="Texto…" />
</div>

@ -9,8 +9,6 @@
onFieldChange: (field: MetadataField) => void;
} = $props();
const cls = 'rounded border border-gray-700 bg-gray-800 px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 w-full';
function handle(e: Event, action: string): void {
const str = (e.target as HTMLInputElement).value;
switch (action) {
@ -23,20 +21,20 @@
</script>
<div class="flex flex-col gap-1 w-full">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.editor.mediaUrlLabel')}</label>
<input type="text" value={field.value.url} oninput={(e) => handle(e, 'url')} class="{cls} font-mono" placeholder="/ruta o https://…" />
<label class="ve-label">{glob.t('vcen.editor.mtas.editor.mediaUrlLabel')}</label>
<input type="text" value={field.value.url} oninput={(e) => handle(e, 'url')} class="field-input font-mono" placeholder="/ruta o https://..." />
</div>
<div class="flex gap-3">
<div class="flex flex-col gap-1 flex-1">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.editor.audDuration')}</label>
<input type="number" value={field.value.duration ?? ''} oninput={(e) => handle(e, 'duration')} class={cls} placeholder="s" />
<label class="ve-label">{glob.t('vcen.editor.mtas.editor.audDuration')}</label>
<input type="number" value={field.value.duration ?? ''} oninput={(e) => handle(e, 'duration')} class="field-input" placeholder="s" />
</div>
<div class="flex flex-col gap-1 flex-1">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.editor.audBitrate')}</label>
<input type="number" value={field.value.bitrate ?? ''} oninput={(e) => handle(e, 'bitrate')} class={cls} placeholder="kbps" />
<label class="ve-label">{glob.t('vcen.editor.mtas.editor.audBitrate')}</label>
<input type="number" value={field.value.bitrate ?? ''} oninput={(e) => handle(e, 'bitrate')} class="field-input" placeholder="kbps" />
</div>
<div class="flex flex-col gap-1 w-28">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.editor.audChannels')}</label>
<input type="number" value={field.value.channels ?? ''} oninput={(e) => handle(e, 'channels')} class={cls} placeholder="1/2" />
<label class="ve-label">{glob.t('vcen.editor.mtas.editor.audChannels')}</label>
<input type="number" value={field.value.channels ?? ''} oninput={(e) => handle(e, 'channels')} class="field-input" placeholder="1/2" />
</div>
</div>

@ -14,7 +14,7 @@
</script>
<div class="flex items-center gap-4 py-1">
<input type="checkbox" checked={field.value} onchange={onChange} class="h-4 w-8 rounded-full cursor-pointer appearance-none bg-gray-700 checked:bg-indigo-600 transition-colors" />
<input type="checkbox" checked={field.value} onchange={onChange} class="ve-checkbox" />
<span class="text-sm text-gray-200">{field.value
? glob.t('vcen.editor.mtas.field.boolTrue')
: glob.t('vcen.editor.mtas.field.boolFalse')

@ -1,7 +1,6 @@
<script lang="ts">
import type { ColorField } from '../../../lib';
import type { MetadataField } from '../../../lib';
import type { GlobState } from '@/glob/adapters/svelte';
import type {ColorField, MetadataField} from "@/mtas/lib";
let { field, glob, onFieldChange }: {
field: ColorField;
@ -16,8 +15,8 @@
<div class="flex items-end gap-4">
<div class="flex flex-col gap-1 flex-1">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.editor.colorHex')}</label>
<input type="text" value={field.value.hex} oninput={onInput} class="rounded border border-gray-700 bg-gray-800 px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 w-full font-mono uppercase" placeholder="#000000" maxlength="9" />
<label class="ve-label">{glob.t('vcen.editor.mtas.editor.colorHex')}</label>
<input type="text" value={field.value.hex} oninput={onInput} class="field-input font-mono uppercase" placeholder="#000000" maxlength="9" />
</div>
<input type="color" value={field.value.hex} oninput={onInput}
class="h-10 w-14 cursor-pointer rounded-lg border border-gray-700 bg-transparent p-0.5" />

@ -1,7 +1,6 @@
<script lang="ts">
import type { CoordinatesField } from '../../../lib';
import type { MetadataField } from '../../../lib';
import type { GlobState } from '@/glob/adapters/svelte';
import type {CoordinatesField, MetadataField} from "@/mtas/lib";
let { field, glob, onFieldChange }: {
field: CoordinatesField;
@ -9,8 +8,6 @@
onFieldChange: (field: MetadataField) => void;
} = $props();
const cls = 'rounded border border-gray-700 bg-gray-800 px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 w-full';
function handle(e: Event, action: string): void {
const str = (e.target as HTMLInputElement).value;
switch (action) {
@ -23,15 +20,15 @@
<div class="flex gap-3">
<div class="flex flex-col gap-1 flex-1">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.editor.coordLat')}</label>
<input type="number" step="any" value={field.value.lat} oninput={(e) => handle(e, 'lat')} class={cls} placeholder="0" />
<label class="ve-label">{glob.t('vcen.editor.mtas.editor.coordLat')}</label>
<input type="number" step="any" value={field.value.lat} oninput={(e) => handle(e, 'lat')} class="field-input" placeholder="0" />
</div>
<div class="flex flex-col gap-1 flex-1">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.editor.coordLng')}</label>
<input type="number" step="any" value={field.value.lng} oninput={(e) => handle(e, 'lng')} class={cls} placeholder="0" />
<label class="ve-label">{glob.t('vcen.editor.mtas.editor.coordLng')}</label>
<input type="number" step="any" value={field.value.lng} oninput={(e) => handle(e, 'lng')} class="field-input" placeholder="0" />
</div>
<div class="flex flex-col gap-1 w-24">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.editor.coordZoom')}</label>
<input type="number" value={field.value.zoom ?? ''} oninput={(e) => handle(e, 'zoom')} class={cls} placeholder="12" />
<label class="ve-label">{glob.t('vcen.editor.mtas.editor.coordZoom')}</label>
<input type="number" value={field.value.zoom ?? ''} oninput={(e) => handle(e, 'zoom')} class="field-input" placeholder="12" />
</div>
</div>

@ -1,7 +1,6 @@
<script lang="ts">
import type { CurrencyField } from '../../../lib';
import type { MetadataField } from '../../../lib';
import type { GlobState } from '@/glob/adapters/svelte';
import type {CurrencyField, MetadataField} from "@/mtas/lib";
let { field, glob, onFieldChange }: {
field: CurrencyField;
@ -9,8 +8,6 @@
onFieldChange: (field: MetadataField) => void;
} = $props();
const cls = 'rounded border border-gray-700 bg-gray-800 px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 w-full';
let currencies = $derived(glob.curr.getCurrencies());
let formatted = $derived((() => {
const { amount, currency } = field.value;
@ -32,20 +29,20 @@
<div class="flex gap-3">
<div class="flex flex-col gap-1 flex-1">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.editor.amountLabel')}</label>
<input type="number" value={field.value.amount} oninput={(e) => handle(e, 'amount')} class={cls} />
<label class="ve-label">{glob.t('vcen.editor.mtas.editor.amountLabel')}</label>
<input type="number" value={field.value.amount} oninput={(e) => handle(e, 'amount')} class="field-input" />
{#if formatted}
<span class="text-xs text-gray-500 mt-0.5">{formatted}</span>
{/if}
</div>
<div class="flex flex-col gap-1 w-28">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.editor.currencyLabel')}</label>
<label class="ve-label">{glob.t('vcen.editor.mtas.editor.currencyLabel')}</label>
{#if currencies.length <= 1}
<div class="{cls} flex items-center font-mono text-gray-500 cursor-default select-none">
<div class="field-input flex items-center font-mono text-gray-500 cursor-default select-none">
{field.value.currency}
</div>
{:else}
<select class="rounded border border-gray-700 bg-gray-800 px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 w-full font-mono uppercase"
<select class="field-input font-mono uppercase"
value={field.value.currency}
onchange={(e) => handle(e, 'currency')}>
{#each currencies as code}

@ -1,7 +1,6 @@
<script lang="ts">
import type { DateField } from '../../../lib';
import type { MetadataField } from '../../../lib';
import type { GlobState } from '@/glob/adapters/svelte';
import type {DateField, MetadataField} from "@/mtas/lib";
let { field, glob, onFieldChange }: {
field: DateField;
@ -19,8 +18,8 @@
</script>
<div class="flex flex-col gap-1 w-full">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.types.date')}</label>
<input type="date" value={field.value || todayISO()} oninput={onInput} class="rounded border border-gray-700 bg-gray-800 px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 w-full" />
<label class="ve-label">{glob.t('vcen.editor.mtas.types.date')}</label>
<input type="date" value={field.value || todayISO()} oninput={onInput} class="field-input" />
{#if formatted}
<span class="text-xs text-gray-500 mt-0.5">{formatted}</span>
{/if}

@ -1,7 +1,6 @@
<script lang="ts">
import type { DateTimeField } from '../../../lib';
import type { MetadataField } from '../../../lib';
import type { GlobState } from '@/glob/adapters/svelte';
import type {DateTimeField, MetadataField} from "@/mtas/lib";
let { field, glob, onFieldChange }: {
field: DateTimeField;
@ -19,8 +18,8 @@
</script>
<div class="flex flex-col gap-1 w-full">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.types.datetime')}</label>
<input type="datetime-local" value={(field.value || nowISO()).replace('Z', '')} oninput={onInput} class="rounded border border-gray-700 bg-gray-800 px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 w-full" />
<label class="ve-label">{glob.t('vcen.editor.mtas.types.datetime')}</label>
<input type="datetime-local" value={(field.value || nowISO()).replace('Z', '')} oninput={onInput} class="field-input" />
{#if formatted}
<span class="text-xs text-gray-500 mt-0.5">{formatted}</span>
{/if}

@ -1,7 +1,6 @@
<script lang="ts">
import type { EmailField } from '../../../lib';
import type { MetadataField } from '../../../lib';
import type { GlobState } from '@/glob/adapters/svelte';
import type {EmailField, MetadataField} from "@/mtas/lib";
let { field, glob, onFieldChange }: {
field: EmailField;
@ -15,6 +14,6 @@
</script>
<div class="flex flex-col gap-1 w-full">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.types.email')}</label>
<input type="email" value={field.value} oninput={onInput} class="rounded border border-gray-700 bg-gray-800 px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 w-full" placeholder="user@example.com" />
<label class="ve-label">{glob.t('vcen.editor.mtas.types.email')}</label>
<input type="email" value={field.value} oninput={onInput} class="field-input" placeholder="user@example.com" />
</div>

@ -1,7 +1,6 @@
<script lang="ts">
import type { FileField } from '../../../lib';
import type { MetadataField } from '../../../lib';
import type { GlobState } from '@/glob/adapters/svelte';
import type {FileField, MetadataField} from "@/mtas/lib";
let { field, glob, onFieldChange }: {
field: FileField;
@ -15,6 +14,6 @@
</script>
<div class="flex flex-col gap-1 w-full">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.editor.mediaUrlLabel')}</label>
<input type="text" value={field.value.url} oninput={onInput} class="rounded border border-gray-700 bg-gray-800 px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 w-full font-mono" placeholder="/ruta o https://…" />
<label class="ve-label">{glob.t('vcen.editor.mtas.editor.mediaUrlLabel')}</label>
<input type="text" value={field.value.url} oninput={onInput} class="field-input font-mono" placeholder="/ruta o https://..." />
</div>

@ -1,6 +1,6 @@
<script lang="ts">
import type { GroupField } from '../../../lib';
import type { GlobState } from '@/glob/adapters/svelte';
import type {GroupField} from "@/mtas/lib";
let { field, glob }: {
field: GroupField;

@ -1,8 +1,7 @@
<script lang="ts">
import type { HtmlField } from '../../../lib';
import type { MetadataField } from '../../../lib';
import type { GlobState } from '@/glob/adapters/svelte';
import LingInput from '@/ling/svelte/LingInput.svelte';
import type {HtmlField, MetadataField} from "@/mtas/lib";
let { field, glob, onFieldChange }: {
field: HtmlField;

@ -1,7 +1,6 @@
<script lang="ts">
import type { ImageField, ImageRef } from '../../../lib';
import type { MetadataField } from '../../../lib';
import type { GlobState } from '@/glob/adapters/svelte';
import type {ImageField, ImageRef, MetadataField} from "@/mtas/lib";
let { field, glob, onFieldChange }: {
field: ImageField;
@ -9,8 +8,6 @@
onFieldChange: (field: MetadataField) => void;
} = $props();
const cls = 'rounded border border-gray-700 bg-gray-800 px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 w-full';
function handle(e: Event, action: string): void {
const inp = e.target as HTMLInputElement;
const sel = e.target as HTMLSelectElement;
@ -25,8 +22,8 @@
</script>
<div class="flex flex-col gap-1 w-full">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.editor.mediaUrlLabel')}</label>
<input type="text" value={field.value.url} oninput={(e) => handle(e, 'url')} class="{cls} font-mono" placeholder="/ruta o https://…" />
<label class="ve-label">{glob.t('vcen.editor.mtas.editor.mediaUrlLabel')}</label>
<input type="text" value={field.value.url} oninput={(e) => handle(e, 'url')} class="field-input font-mono" placeholder="/ruta o https://..." />
</div>
{#if field.value.url}
<figure class="rounded-lg overflow-hidden border border-gray-800">
@ -35,16 +32,16 @@
{/if}
<div class="flex gap-3">
<div class="flex flex-col gap-1 flex-1">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.editor.imgWidth')}</label>
<input type="number" value={field.value.width ?? ''} oninput={(e) => handle(e, 'width')} class={cls} placeholder="px" />
<label class="ve-label">{glob.t('vcen.editor.mtas.editor.imgWidth')}</label>
<input type="number" value={field.value.width ?? ''} oninput={(e) => handle(e, 'width')} class="field-input" placeholder="px" />
</div>
<div class="flex flex-col gap-1 flex-1">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.editor.imgHeight')}</label>
<input type="number" value={field.value.height ?? ''} oninput={(e) => handle(e, 'height')} class={cls} placeholder="px" />
<label class="ve-label">{glob.t('vcen.editor.mtas.editor.imgHeight')}</label>
<input type="number" value={field.value.height ?? ''} oninput={(e) => handle(e, 'height')} class="field-input" placeholder="px" />
</div>
<div class="flex flex-col gap-1 w-32">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.editor.imgFormat')}</label>
<select class="rounded border border-gray-700 bg-gray-800 px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 w-full" value={field.value.format ?? ''} onchange={(e) => handle(e, 'format')}>
<label class="ve-label">{glob.t('vcen.editor.mtas.editor.imgFormat')}</label>
<select class="field-input" value={field.value.format ?? ''} onchange={(e) => handle(e, 'format')}>
<option value="">—</option>
<option value="jpeg">JPEG</option>
<option value="png">PNG</option>

@ -2,16 +2,22 @@
import type { LingField } from '@/mtas/lib';
import type { MetadataField } from '@/mtas/lib';
import type { GlobState } from '@/glob/adapters/svelte';
import LingInput from '../LingInput.svelte';
import LingInput from "@/ling/svelte/LingInput.svelte";
let { field, glob, onFieldChange }: {
let {
field,
glob,
onFieldChange,
locales,
defaultLocale,
}: {
field: LingField;
glob: GlobState;
locales: string[];
defaultLocale: string;
onFieldChange: (field: MetadataField) => void;
} = $props();
let locales = $derived(glob.getLocales() as string[]);
let defaultLocale = $derived(glob.getLocale() as string);
</script>
<LingInput

@ -1,8 +1,7 @@
<script lang="ts">
import type { MarkdownField } from '../../../lib';
import type { MetadataField } from '../../../lib';
import type { GlobState } from '@/glob/adapters/svelte';
import LingInput from '@/ling/svelte/LingInput.svelte';
import type {MarkdownField, MetadataField} from "@/mtas/lib";
let { field, glob, onFieldChange }: {
field: MarkdownField;

@ -0,0 +1,132 @@
<script lang="ts">
import type { GlobState } from '@/glob/adapters/svelte';
import type {MetadataFieldType} from "@/mtas/lib";
import MetaFieldTypeIcon from "./MetaFieldTypeIcon.svelte";
let {
onSelect,
iconOnly = false,
placement = 'end',
glob,
}: {
onSelect: (type: MetadataFieldType) => void;
iconOnly?: boolean;
placement?: 'start' | 'end';
glob: GlobState;
} = $props();
let open = $state(false);
let buttonEl = $state<HTMLButtonElement | null>(null);
function dropdownStyle(): string {
if (!buttonEl) return '';
const r = buttonEl.getBoundingClientRect();
const top = r.bottom + 4;
const dropW = 360;
const center = r.left + r.width / 2;
const left = Math.max(8, Math.min(center - dropW / 2, window.innerWidth - dropW - 8));
return `position:fixed;top:${top}px;left:${left}px;z-index:50`;
}
const typeGroups: { key: string; types: MetadataFieldType[] }[] = [
{ key: 'text', types: ['string', 'ling', 'html', 'url', 'slug', 'markdown'] },
{ key: 'number', types: ['number', 'currency'] },
{ key: 'date', types: ['date', 'datetime'] },
{ key: 'logical', types: ['boolean'] },
{ key: 'media', types: ['image', 'video', 'audio', 'pdf', 'file'] },
{ key: 'choice', types: ['select', 'multiselect', 'color'] },
{ key: 'contact', types: ['email', 'phone', 'coordinates'] },
{ key: 'group', types: ['group'] },
];
// Mismos colores que TypeBadge, convertidos a clases de texto para los iconos
const iconColor: Record<MetadataFieldType, string> = {
string: 'text-blue-400',
number: 'text-purple-400',
boolean: 'text-amber-400',
ling: 'text-green-400',
date: 'text-teal-400',
datetime: 'text-teal-400',
url: 'text-blue-400',
currency: 'text-green-400',
image: 'text-red-400',
video: 'text-red-400',
audio: 'text-purple-400',
pdf: 'text-gray-400',
file: 'text-gray-400',
html: 'text-amber-400',
color: 'text-amber-400',
select: 'text-purple-400',
multiselect: 'text-purple-400',
email: 'text-blue-400',
phone: 'text-blue-400',
slug: 'text-gray-400',
coordinates: 'text-green-400',
markdown: 'text-amber-400',
group: 'text-indigo-400',
};
</script>
<div class="relative">
<button
bind:this={buttonEl}
type="button"
onclick={() => open = !open}
title={glob.t('vcen.editor.mtas.header.addFieldTitle')}
class={[
iconOnly
? 'flex items-center justify-center w-5 h-5 rounded text-gray-500 hover:text-indigo-400 hover:bg-indigo-500/10 transition-colors'
: 'inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-500 transition-colors',
].join(' ')}
>
<svg
class={iconOnly ? 'h-3.5 w-3.5' : 'h-4 w-4'}
viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5"
stroke-linecap="round"
>
<path d="M8 2v12M2 8h12"/>
</svg>
{#if !iconOnly}
<span class="text-sm">{glob.t('vcen.editor.mtas.header.addField')}</span>
{/if}
</button>
{#if open}
<div class="fixed inset-0 z-40" onclick={() => open = false}></div>
<div
style={dropdownStyle()}
class="w-[360px] rounded-lg border border-gray-700 bg-gray-800 shadow-xl overflow-hidden"
>
<div class="flex flex-col divide-y divide-gray-900">
{#each typeGroups as group}
<div class="flex items-stretch">
<!-- Label del grupo — columna con fondo propio -->
<div class="flex items-center justify-end bg-gray-700/60 w-16 shrink-0 px-2 py-1.5">
<span class="text-[9px] uppercase tracking-widest font-semibold text-gray-500">
{glob.t(`vcen.editor.mtas.typeGroups.${group.key}` as any)}
</span>
</div>
<!-- Tipos en horizontal -->
<div class="flex flex-wrap gap-0.5 items-center px-2 py-1 bg-gray-900 w-full">
{#each group.types as type}
<button
type="button"
onclick={() => { onSelect(type); open = false; }}
class="inline-flex items-center gap-1 px-2 py-1 text-xs rounded text-gray-500 hover:text-gray-300 hover:bg-gray-800 transition-colors"
title={glob.t(`vcen.editor.mtas.types.${type}` as any)}
>
<span class={iconColor[type]}>
<MetaFieldTypeIcon {type} size={13} />
</span>
<span class="text-xs text-gray-400">
{glob.t(`vcen.editor.mtas.types.${type}` as any)}
</span>
</button>
{/each}
</div>
</div>
{/each}
</div>
</div>
{/if}
</div>

@ -0,0 +1,80 @@
<script lang="ts">
import type { MetadataField } from '../../lib';
import FieldForm from './MetaFieldForm.svelte';
import type { GlobState } from '@/glob/adapters/svelte';
import { isValidId } from '@/vcen-edit/lib/validate';
let {
fieldKey, field,
tags = [],
glob,
locales,
defaultLocale,
onKeyChange, onFieldChange,
}: {
fieldKey: string;
field: MetadataField;
tags?: string[];
glob: GlobState;
locales: string[];
defaultLocale: string;
onKeyChange: (newKey: string) => void;
onFieldChange: (field: MetadataField) => void;
} = $props();
let editKey = $state(fieldKey);
let keyError = $state('');
$effect(() => { editKey = fieldKey; });
function handleKeyCommit(): void {
const trimmed = editKey.trim();
if (!trimmed) {
keyError = glob.t('vcen.editor.mtas.editor.keyEmpty');
editKey = fieldKey;
return;
}
if (!isValidId(trimmed)) {
keyError = glob.t('vcen.editor.mtas.editor.keyInvalid');
return;
}
keyError = '';
if (trimmed !== fieldKey) onKeyChange(trimmed);
}
function handleKeydown(e: KeyboardEvent): void {
e.stopPropagation();
if (e.key === 'Enter') (e.currentTarget as HTMLInputElement).blur();
if (e.key === 'Escape') { editKey = fieldKey; keyError = ''; (e.currentTarget as HTMLInputElement).blur(); }
}
function handleKeyInput(e: Event): void {
editKey = (e.target as HTMLInputElement).value;
keyError = '';
}
</script>
<div class="flex flex-col gap-4 p-4">
<!-- ── Field key ──────────────────────────────────────────────── -->
<div class="flex flex-col gap-1 w-full">
<label class="ve-label">
{glob.t('vcen.editor.mtas.editor.keyLabel')}
</label>
<input
value={editKey}
oninput={handleKeyInput}
onblur={handleKeyCommit}
onkeydown={handleKeydown}
class={['field-input font-mono', keyError ? 'invalid' : ''].join(' ')}
placeholder={glob.t('vcen.editor.mtas.editor.keyPlaceholder')}
/>
{#if keyError}
<span class="text-xs text-red-400 mt-0.5">{keyError}</span>
{/if}
</div>
<!-- ── Field form (value + attributes + hidden) ───────────────── -->
<FieldForm {field} {glob} {locales} {defaultLocale} {tags} {onFieldChange} />
</div>

@ -0,0 +1,154 @@
<script lang="ts">
import type { GlobState } from '@/glob/adapters/svelte';
import StringField from './StringField.svelte';
import NumberField from './NumberField.svelte';
import BooleanField from './BooleanField.svelte';
import LingField from './LingField.svelte';
import DateField from './DateField.svelte';
import DateTimeField from './DateTimeField.svelte';
import UrlField from './UrlField.svelte';
import CurrencyField from './CurrencyField.svelte';
import ImageField from './ImageField.svelte';
import VideoField from './VideoField.svelte';
import AudioField from './AudioField.svelte';
import PdfField from './PdfField.svelte';
import FileField from './FileField.svelte';
import HtmlField from './HtmlField.svelte';
import ColorField from './ColorField.svelte';
import SelectField from './SelectField.svelte';
import MultiSelectField from './MultiSelectField.svelte';
import EmailField from './EmailField.svelte';
import PhoneField from './PhoneField.svelte';
import SlugField from './SlugField.svelte';
import CoordinatesField from './CoordinatesField.svelte';
import MarkdownField from './MarkdownField.svelte';
import GroupField from './GroupField.svelte';
import type {MetadataField} from "@/mtas/lib";
let {
field,
glob,
locales,
defaultLocale,
tags = [],
onFieldChange,
}: {
field: MetadataField;
glob: GlobState;
locales: string[];
defaultLocale: string;
tags?: string[];
onFieldChange: (field: MetadataField) => void;
} = $props();
let asBase = $derived(field as MetadataField & { hidden?: boolean; tags?: string[] });
let activeAttrs = $derived(asBase.tags ?? []);
function update(partial: Partial<MetadataField & { hidden?: boolean; attrs?: string[] }>): void {
onFieldChange({ ...field, ...partial } as MetadataField);
}
function toggleAttr(attr: string): void {
const current = asBase.tags ?? [];
const next = current.includes(attr) ? current.filter(a => a !== attr) : [...current, attr];
update({ tags: next });
}
function onHiddenChange(e: Event): void {
update({ hidden: (e.target as HTMLInputElement).checked });
}
</script>
<!-- ── Value editor ──────────────────────────────────────────────── -->
{#if field.type === 'string'}
<StringField {field} {glob} {onFieldChange} />
{:else if field.type === 'number'}
<NumberField {field} {glob} {onFieldChange} />
{:else if field.type === 'boolean'}
<BooleanField {field} {glob} {onFieldChange} />
{:else if field.type === 'ling'}
<LingField {field} {glob} {locales} {defaultLocale} {onFieldChange} />
{:else if field.type === 'date'}
<DateField {field} {glob} {onFieldChange} />
{:else if field.type === 'datetime'}
<DateTimeField {field} {glob} {onFieldChange} />
{:else if field.type === 'url'}
<UrlField {field} {glob} {onFieldChange} />
{:else if field.type === 'currency'}
<CurrencyField {field} {glob} {onFieldChange} />
{:else if field.type === 'image'}
<ImageField {field} {glob} {onFieldChange} />
{:else if field.type === 'video'}
<VideoField {field} {glob} {onFieldChange} />
{:else if field.type === 'audio'}
<AudioField {field} {glob} {onFieldChange} />
{:else if field.type === 'pdf'}
<PdfField {field} {glob} {onFieldChange} />
{:else if field.type === 'file'}
<FileField {field} {glob} {onFieldChange} />
{:else if field.type === 'html'}
<HtmlField {field} {glob} {locales} {defaultLocale} {onFieldChange} />
{:else if field.type === 'color'}
<ColorField {field} {glob} {onFieldChange} />
{:else if field.type === 'select'}
<SelectField {field} {glob} {onFieldChange} />
{:else if field.type === 'multiselect'}
<MultiSelectField {field} {glob} {onFieldChange} />
{:else if field.type === 'email'}
<EmailField {field} {glob} {onFieldChange} />
{:else if field.type === 'phone'}
<PhoneField {field} {glob} {onFieldChange} />
{:else if field.type === 'slug'}
<SlugField {field} {glob} {onFieldChange} />
{:else if field.type === 'coordinates'}
<CoordinatesField {field} {glob} {onFieldChange} />
{:else if field.type === 'markdown'}
<MarkdownField {field} {glob} {locales} {defaultLocale} {onFieldChange} />
{:else if field.type === 'group'}
<GroupField {field} {glob} />
{/if}
<!-- ── Attributes ──────────────────────────────────────────────────── -->
{#if tags.length > 0}
<div class="border-t border-gray-800 my-2 flex items-center gap-2 text-xs text-gray-600 before:flex-1 before:border-t before:border-gray-800 after:flex-1 after:border-t after:border-gray-800">{glob.t('vcen.editor.mtas.editor.attributes')}</div>
<div class="flex flex-wrap gap-1.5">
{#each tags as attr (attr)}
{@const active = activeAttrs.includes(attr)}
<button
type="button"
onclick={() => toggleAttr(attr)}
class={[
'px-2 py-1 text-xs rounded border cursor-pointer select-none transition-colors gap-1 inline-flex items-center',
active
? 'bg-indigo-950 text-indigo-400 border-indigo-900 hover:bg-indigo-900'
: 'bg-gray-800 text-gray-500 border-gray-700 hover:border-indigo-700',
].join(' ')}
>
{#if active}
<svg class="h-3 w-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 6l3 3 5-5"/>
</svg>
{/if}
{attr}
</button>
{/each}
</div>
{/if}
<!-- ── Hidden toggle ──────────────────────────────────────────────── -->
<div class="border-t border-gray-800 my-2"></div>
<label class="flex items-center gap-3 cursor-pointer">
<span class="relative inline-block w-8 h-4 shrink-0">
<input
type="checkbox"
checked={asBase.hidden ?? false}
onchange={onHiddenChange}
class="sr-only peer"
/>
<span class="absolute inset-0 rounded-full bg-gray-700 peer-checked:bg-indigo-600 transition-colors
after:absolute after:top-0.5 after:left-0.5 after:h-3 after:w-3
after:rounded-full after:bg-white after:shadow
after:transition-transform peer-checked:after:translate-x-4"></span>
</span>
<span class="text-sm text-gray-400">{glob.t('vcen.editor.mtas.editor.hideField')}</span>
</label>

@ -0,0 +1,150 @@
<!-- MetaFieldTypeIcon.svelte — icono SVG por tipo de campo -->
<script lang="ts">
import type {MetadataFieldType} from "@/mtas/lib";
let {
type,
size = 16,
class: cls = '',
}: {
type: MetadataFieldType;
size?: number;
class?: string;
} = $props();
</script>
{#if type === 'string'}
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/>
</svg>
{:else if type === 'number'}
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/>
<line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/>
</svg>
{:else if type === 'boolean'}
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<rect x="1" y="5" width="22" height="14" rx="7"/><circle cx="16" cy="12" r="3" fill="currentColor" stroke="none"/>
</svg>
{:else if type === 'ling'}
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
{:else if type === 'date'}
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
</svg>
{:else if type === 'datetime'}
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
<polyline points="12 14 12 17 14 17"/>
</svg>
{:else if type === 'url'}
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
{:else if type === 'currency'}
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
</svg>
{:else if type === 'image'}
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
{:else if type === 'video'}
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2"/>
</svg>
{:else if type === 'audio'}
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/>
</svg>
{:else if type === 'pdf'}
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
<line x1="9" y1="15" x2="15" y2="15"/>
</svg>
{:else if type === 'file'}
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
</svg>
{:else if type === 'html'}
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>
</svg>
{:else if type === 'color'}
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3" fill="currentColor" stroke="none"/>
<line x1="12" y1="3" x2="12" y2="9"/><line x1="12" y1="15" x2="12" y2="21"/>
<line x1="3" y1="12" x2="9" y2="12"/><line x1="15" y1="12" x2="21" y2="12"/>
</svg>
{:else if type === 'select'}
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<rect x="3" y="6" width="18" height="12" rx="2"/>
<polyline points="8 12 12 16 16 12"/>
</svg>
{:else if type === 'multiselect'}
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<rect x="3" y="4" width="5" height="5" rx="1"/><polyline points="4 6.5 5.5 8 8 5"/>
<rect x="3" y="14" width="5" height="5" rx="1"/>
<line x1="11" y1="6.5" x2="21" y2="6.5"/><line x1="11" y1="16.5" x2="21" y2="16.5"/>
</svg>
{:else if type === 'email'}
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/>
<polyline points="22 6 12 13 2 6"/>
</svg>
{:else if type === 'phone'}
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.14 9a19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 3.05 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/>
</svg>
{:else if type === 'slug'}
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/>
<line x1="7" y1="7" x2="7.01" y2="7"/>
</svg>
{:else if type === 'coordinates'}
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<path d="M21 10c0 7-9 13-9 13S3 17 3 10a9 9 0 0 1 18 0z"/>
<circle cx="12" cy="10" r="3"/>
</svg>
{:else if type === 'markdown'}
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<polyline points="8 13 10 11 12 13 14 11 16 13"/>
<line x1="8" y1="17" x2="12" y2="17"/>
</svg>
{:else if type === 'group'}
<svg width={size} height={size} viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`fill-current ${cls}`}>
<rect x="2" y="3" width="8" height="8" rx="1"/><rect x="14" y="3" width="8" height="8" rx="1"/>
<rect x="2" y="13" width="8" height="8" rx="1"/><rect x="14" y="13" width="8" height="8" rx="1"/>
</svg>
{/if}

@ -0,0 +1,48 @@
<script lang="ts">
import FieldTypeIcon from './MetaFieldTypeIcon.svelte';
import type { GlobState } from '@/glob/adapters/svelte';
import type {MetadataFieldType} from "@/mtas/lib";
let {
type,
showLabel = true,
glob,
}: {
type: MetadataFieldType;
showLabel?: boolean;
glob: GlobState;
} = $props();
const colorMap: Record<MetadataFieldType, string> = {
string: 'bg-blue-950 text-blue-400 border-blue-900',
number: 'bg-purple-950 text-purple-400 border-purple-900',
boolean: 'bg-amber-950 text-amber-400 border-amber-900',
ling: 'bg-green-950 text-green-400 border-green-900',
date: 'bg-teal-950 text-teal-400 border-teal-900',
datetime: 'bg-teal-950 text-teal-400 border-teal-900',
url: 'bg-blue-950 text-blue-400 border-blue-900',
currency: 'bg-green-950 text-green-400 border-green-900',
image: 'bg-red-950 text-red-400 border-red-900',
video: 'bg-red-950 text-red-400 border-red-900',
audio: 'bg-purple-950 text-purple-400 border-purple-900',
pdf: 'bg-gray-800 text-gray-400 border-gray-700',
file: 'bg-gray-800 text-gray-400 border-gray-700',
html: 'bg-amber-950 text-amber-400 border-amber-900',
color: 'bg-amber-950 text-amber-400 border-amber-900',
select: 'bg-purple-950 text-purple-400 border-purple-900',
multiselect: 'bg-purple-950 text-purple-400 border-purple-900',
email: 'bg-blue-950 text-blue-400 border-blue-900',
phone: 'bg-blue-950 text-blue-400 border-blue-900',
slug: 'bg-gray-800 text-gray-400 border-gray-700',
coordinates: 'bg-green-950 text-green-400 border-green-900',
markdown: 'bg-amber-950 text-amber-400 border-amber-900',
group: 'bg-indigo-950 text-indigo-400 border-indigo-900',
};
</script>
<span class={['inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs border', colorMap[type]].join(' ')}>
<FieldTypeIcon {type} size={12} />
{#if showLabel}
<span>{glob.t(`vcen.editor.mtas.types.${type}` as any)}</span>
{/if}
</span>

@ -1,7 +1,6 @@
<script lang="ts">
import type { MultiSelectField } from '../../../lib';
import type { MetadataField } from '../../../lib';
import type { GlobState } from '@/glob/adapters/svelte';
import type {MetadataField, MultiSelectField} from "@/mtas/lib";
let { field, glob, onFieldChange }: {
field: MultiSelectField;
@ -23,10 +22,10 @@
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500">{glob.t('vcen.editor.mtas.editor.selectOptions')}</span>
<button type="button" class="inline-flex items-center gap-1 px-2 py-1 text-xs rounded text-gray-500 hover:text-gray-300 hover:bg-gray-800 transition-colors"
<span class="ve-label">{glob.t('vcen.editor.mtas.editor.selectOptions')}</span>
<button type="button" class="text-gray-500 hover:text-gray-300 text-xs"
onclick={() => onFieldChange({ ...field, options: [...field.options, { value: `opt${field.options.length + 1}`, label: '' }] })}>
<svg class="h-3 w-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M8 2v12M2 8h12"/></svg>
<svg class="h-3 w-3 fill-current" viewBox="0 0 16 16" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M8 2v12M2 8h12"/></svg>
{glob.t('vcen.editor.mtas.editor.selectAddOpt')}
</button>
</div>
@ -35,12 +34,27 @@
{:else}
{#each field.options as opt, i}
<div class="flex gap-2 items-center">
<input type="checkbox" checked={field.value.includes(opt.value)} onchange={(e) => handle(e, 'chk', opt.value)} class="h-3.5 w-3.5 rounded border border-gray-600 bg-gray-800 checked:bg-indigo-600 checked:border-indigo-600 cursor-pointer appearance-none shrink-0" />
<input type="text" value={opt.value} oninput={(e) => handle(e, 'opt-val', i)} class="rounded border border-gray-700 bg-gray-800 px-2 py-1 text-xs text-gray-200 focus:outline-none focus:ring-1 focus:ring-blue-500 flex-1 font-mono" placeholder={glob.t('vcen.editor.mtas.editor.selectOptValue')} />
<input type="text" value={opt.label} oninput={(e) => handle(e, 'opt-lbl', i)} class="rounded border border-gray-700 bg-gray-800 px-2 py-1 text-xs text-gray-200 focus:outline-none focus:ring-1 focus:ring-blue-500 flex-1" placeholder={glob.t('vcen.editor.mtas.editor.selectOptLabel')} />
<button type="button" class="flex items-center justify-center w-6 h-6 rounded text-red-400 hover:bg-gray-800 transition-colors"
onclick={() => onFieldChange({ ...field, options: field.options.filter((_, j) => j !== i), value: field.value.filter(v => v !== opt.value) })}>
<svg class="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M3 4h10M6 4V2.5h4V4M5 4l.5 9h5l.5-9"/></svg>
<input
type="checkbox"
checked={field.value.includes(opt.value)}
onchange={(e) => handle(e, 'chk', opt.value)}
class="ve-checkbox"
/>
<input
type="text"
value={opt.value} oninput={(e) => handle(e, 'opt-val', i)}
class="field-input text-xs flex-1 font-mono" placeholder={glob.t('vcen.editor.mtas.editor.selectOptValue')}
/>
<input
type="text"
value={opt.label} oninput={(e) => handle(e, 'opt-lbl', i)}
class="field-input text-xs flex-1" placeholder={glob.t('vcen.editor.mtas.editor.selectOptLabel')}
/>
<button
type="button" class="flex items-center justify-center w-6 h-6 rounded text-red-400 hover:bg-gray-800 transition-colors"
onclick={() => onFieldChange({ ...field, options: field.options.filter((_, j) => j !== i), value: field.value.filter(v => v !== opt.value) })}
>
<svg class="h-3.5 w-3.5 fill-current" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M3 4h10M6 4V2.5h4V4M5 4l.5 9h5l.5-9"/></svg>
</button>
</div>
{/each}

@ -1,7 +1,6 @@
<script lang="ts">
import type { NumberField } from '../../../lib';
import type { MetadataField } from '../../../lib';
import type { GlobState } from '@/glob/adapters/svelte';
import type {MetadataField, NumberField} from "@/mtas/lib";
let { field, glob, onFieldChange }: {
field: NumberField;
@ -18,8 +17,13 @@
</script>
<div class="flex flex-col gap-1 w-full">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.types.number')}</label>
<input type="number" value={field.value} oninput={onInput} class="rounded border border-gray-700 bg-gray-800 px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 w-full" />
<label class="ve-label">{glob.t('vcen.editor.mtas.types.number')}</label>
<input
type="number"
value={field.value}
oninput={onInput}
class="field-input"
/>
{#if formatted}
<span class="text-xs text-gray-500 mt-0.5">{formatted}</span>
{/if}

@ -0,0 +1,57 @@
<script lang="ts">
import type { GlobState } from '@/glob/adapters/svelte';
import type {MetadataField, PdfField} from "@/mtas/lib";
let { field, glob, onFieldChange }: {
field: PdfField;
glob: GlobState;
onFieldChange: (field: MetadataField) => void;
} = $props();
function handle(e: Event, action: string): void {
const str = (e.target as HTMLInputElement).value;
switch (action) {
case 'url': onFieldChange({ ...field, value: { ...field.value, url: str } }); break;
case 'pages': { const v = parseInt(str); onFieldChange({ ...field, value: { ...field.value, ...(isNaN(v) ? {} : { pages: v }) } }); break; }
case 'author': onFieldChange({ ...field, value: { ...field.value, author: str } }); break;
case 'title': onFieldChange({ ...field, value: { ...field.value, title: str } }); break;
}
}
</script>
<div class="flex flex-col gap-1 w-full">
<label class="ve-label">{glob.t('vcen.editor.mtas.editor.mediaUrlLabel')}</label>
<input
type="text"
value={field.value.url} oninput={(e) => handle(e, 'url')}
class="field-input font-mono"
placeholder="/ruta o https://..."
/>
</div>
<div class="flex gap-3">
<div class="flex flex-col gap-1 w-28">
<label class="ve-label">{glob.t('vcen.editor.mtas.editor.pdfPages')}</label>
<input
type="number"
value={field.value.pages ?? ''}
oninput={(e) => handle(e, 'pages')} class="field-input"
/>
</div>
<div class="flex flex-col gap-1 flex-1">
<label class="ve-label">{glob.t('vcen.editor.mtas.editor.pdfAuthor')}</label>
<input
type="text"
value={field.value.author ?? ''}
oninput={(e) => handle(e, 'author')} class="field-input"
/>
</div>
<div class="flex flex-col gap-1 flex-1">
<label class="ve-label">{glob.t('vcen.editor.mtas.editor.pdfTitle')}</label>
<input
type="text"
value={field.value.title ?? ''}
oninput={(e) => handle(e, 'title')}
class="field-input"
/>
</div>
</div>

@ -1,7 +1,6 @@
<script lang="ts">
import type { PhoneField } from '../../../lib';
import type { MetadataField } from '../../../lib';
import type { GlobState } from '@/glob/adapters/svelte';
import type {MetadataField, PhoneField} from "@/mtas/lib";
let { field, glob, onFieldChange }: {
field: PhoneField;
@ -9,8 +8,6 @@
onFieldChange: (field: MetadataField) => void;
} = $props();
const cls = 'rounded border border-gray-700 bg-gray-800 px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 w-full';
function handle(e: Event, action: string): void {
const str = (e.target as HTMLInputElement).value;
switch (action) {
@ -22,11 +19,22 @@
<div class="flex gap-3">
<div class="flex flex-col gap-1 w-28">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.editor.phoneCode')}</label>
<input type="text" value={field.value.countryCode ?? ''} oninput={(e) => handle(e, 'code')} class="{cls} font-mono" placeholder="+34" />
<label class="ve-label">{glob.t('vcen.editor.mtas.editor.phoneCode')}</label>
<input
type="text"
value={field.value.countryCode ?? ''}
oninput={(e) => handle(e, 'code')}
class="field-input font-mono"
placeholder="+34"
/>
</div>
<div class="flex flex-col gap-1 flex-1">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.editor.phoneNumber')}</label>
<input type="tel" value={field.value.number} oninput={(e) => handle(e, 'number')} class={cls} placeholder="600 000 000" />
<label class="ve-label">{glob.t('vcen.editor.mtas.editor.phoneNumber')}</label>
<input
type="tel"
value={field.value.number}
oninput={(e) => handle(e, 'number')}
class="field-input" placeholder="600 000 000"
/>
</div>
</div>

@ -0,0 +1,78 @@
<script lang="ts">
import type { GlobState } from '@/glob/adapters/svelte';
import type {MetadataField, SelectField} from "@/mtas/lib";
let { field, glob, onFieldChange }: {
field: SelectField;
glob: GlobState;
onFieldChange: (field: MetadataField) => void;
} = $props();
function handle(e: Event, action: string, extra: number = 0): void {
const inp = e.target as HTMLInputElement;
const sel = e.target as HTMLSelectElement;
const str = inp.value;
const opts = field.options;
switch (action) {
case 'val': onFieldChange({ ...field, value: sel.value || null }); break;
case 'opt-val': onFieldChange({ ...field, options: opts.map((o, j) => j === extra ? { ...o, value: str } : o) }); break;
case 'opt-lbl': onFieldChange({ ...field, options: opts.map((o, j) => j === extra ? { ...o, label: str } : o) }); break;
}
}
</script>
<div class="flex flex-col gap-1 w-full">
<label class="ve-label">{glob.t('vcen.editor.mtas.editor.selectValue')}</label>
<select class="field-input" value={field.value ?? ''} onchange={(e) => handle(e, 'val')}>
<option value="">—</option>
{#each field.options as opt}
<option value={opt.value}>{opt.label || opt.value}</option>
{/each}
</select>
</div>
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between">
<span class="ve-label">{glob.t('vcen.editor.mtas.editor.selectOptions')}</span>
<button
type="button"
class="text-gray-500 hover:text-gray-300 text-xs"
onclick={() => onFieldChange({ ...field, options: [...field.options, { value: `opt${field.options.length + 1}`, label: '' }] })}
>
<svg class="h-3 w-3 fill-current" viewBox="0 0 16 16" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M8 2v12M2 8h12"/>
</svg>
{glob.t('vcen.editor.mtas.editor.selectAddOpt')}
</button>
</div>
{#if field.options.length === 0}
<p class="text-xs text-gray-600 text-center py-2">{glob.t('vcen.editor.mtas.editor.selectNoOpts')}</p>
{:else}
{#each field.options as opt, i}
<div class="flex gap-2 items-center">
<input
type="text"
value={opt.value}
oninput={(e) => handle(e, 'opt-val', i)}
class="field-input text-xs flex-1 font-mono"
placeholder={glob.t('vcen.editor.mtas.editor.selectOptValue')}
/>
<input
type="text"
value={opt.label}
oninput={(e) => handle(e, 'opt-lbl', i)}
class="field-input text-xs flex-1"
placeholder={glob.t('vcen.editor.mtas.editor.selectOptLabel')}
/>
<button
type="button"
class="flex items-center justify-center w-6 h-6 rounded text-red-400 hover:bg-gray-800 transition-colors"
onclick={() => onFieldChange({ ...field, options: field.options.filter((_, j) => j !== i), value: field.value === opt.value ? null : field.value })}
>
<svg class="h-3.5 w-3.5 fill-current" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<path d="M3 4h10M6 4V2.5h4V4M5 4l.5 9h5l.5-9"/>
</svg>
</button>
</div>
{/each}
{/if}
</div>

@ -1,7 +1,6 @@
<script lang="ts">
import type { SlugField } from '../../../lib';
import type { MetadataField } from '../../../lib';
import type { GlobState } from '@/glob/adapters/svelte';
import type {MetadataField, SlugField} from "@/mtas/lib";
let { field, glob, onFieldChange }: {
field: SlugField;
@ -16,6 +15,11 @@
</script>
<div class="flex flex-col gap-1 w-full">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.types.slug')}</label>
<input type="text" value={field.value} oninput={onInput} class="rounded border border-gray-700 bg-gray-800 px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 w-full font-mono" placeholder="my-slug" />
<label class="ve-label">{glob.t('vcen.editor.mtas.types.slug')}</label>
<input
type="text"
value={field.value} oninput={onInput}
class="field-input font-mono"
placeholder="my-slug"
/>
</div>

@ -0,0 +1,26 @@
<script lang="ts">
import type { GlobState } from '@/glob/adapters/svelte';
import type {MetadataField, StringField} from "@/mtas/lib";
let { field, glob, onFieldChange }: {
field: StringField;
glob: GlobState;
onFieldChange: (field: MetadataField) => void;
} = $props();
function onInput(e: Event): void {
onFieldChange({ ...field, value: (e.target as HTMLInputElement).value });
}
</script>
<div class="flex flex-col gap-1 w-full">
<label class="ve-label">
{glob.t('vcen.editor.mtas.types.string')}
</label>
<input
type="text"
value={field.value}
oninput={onInput} class="field-input"
placeholder="Texto…"
/>
</div>

@ -1,7 +1,6 @@
<script lang="ts">
import type { UrlField } from '../../../lib';
import type { MetadataField } from '../../../lib';
import type { GlobState } from '@/glob/adapters/svelte';
import type {MetadataField, UrlField} from "@/mtas/lib";
let { field, glob, onFieldChange }: {
field: UrlField;
@ -15,6 +14,6 @@
</script>
<div class="flex flex-col gap-1 w-full">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.types.url')}</label>
<input type="url" value={field.value} oninput={onInput} class="rounded border border-gray-700 bg-gray-800 px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 w-full font-mono" placeholder="https://…" />
<label class="ve-label">{glob.t('vcen.editor.mtas.types.url')}</label>
<input type="url" value={field.value} oninput={onInput} class="field-input font-mono" placeholder="https://..." />
</div>

@ -1,7 +1,6 @@
<script lang="ts">
import type { VideoField } from '../../../lib';
import type { MetadataField } from '../../../lib';
import type { GlobState } from '@/glob/adapters/svelte';
import type {MetadataField, VideoField} from "@/mtas/lib";
let { field, glob, onFieldChange }: {
field: VideoField;
@ -9,8 +8,6 @@
onFieldChange: (field: MetadataField) => void;
} = $props();
const cls = 'rounded border border-gray-700 bg-gray-800 px-2 py-1.5 text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 w-full';
function handle(e: Event, action: string): void {
const str = (e.target as HTMLInputElement).value;
switch (action) {
@ -22,16 +19,16 @@
</script>
<div class="flex flex-col gap-1 w-full">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.editor.mediaUrlLabel')}</label>
<input type="text" value={field.value.url} oninput={(e) => handle(e, 'url')} class="{cls} font-mono" placeholder="/ruta o https://…" />
<label class="ve-label">{glob.t('vcen.editor.mtas.editor.mediaUrlLabel')}</label>
<input type="text" value={field.value.url} oninput={(e) => handle(e, 'url')} class="field-input font-mono" placeholder="/ruta o https://..." />
</div>
<div class="flex gap-3">
<div class="flex flex-col gap-1 flex-1">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.editor.vidDuration')}</label>
<input type="number" value={field.value.duration ?? ''} oninput={(e) => handle(e, 'duration')} class={cls} placeholder="s" />
<label class="ve-label">{glob.t('vcen.editor.mtas.editor.vidDuration')}</label>
<input type="number" value={field.value.duration ?? ''} oninput={(e) => handle(e, 'duration')} class="field-input" placeholder="s" />
</div>
<div class="flex flex-col gap-1 flex-1">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.editor.vidPoster')}</label>
<input type="text" value={field.value.poster ?? ''} oninput={(e) => handle(e, 'poster')} class="{cls} font-mono" placeholder="https://…" />
<label class="ve-label">{glob.t('vcen.editor.mtas.editor.vidPoster')}</label>
<input type="text" value={field.value.poster ?? ''} oninput={(e) => handle(e, 'poster')} class="field-input font-mono" placeholder="https://..." />
</div>
</div>

@ -0,0 +1,232 @@
<script lang="ts">
import { getContext } from 'svelte'
import type {AttID, Hotspot, HotspotMode} from '@/vcen/lib'
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.js'
import type { LingString, LingRecord } from '@/ling/lib'
import LingInput from '@/ling/svelte/LingInput.svelte'
import InputIdName from '../InputIdName.svelte'
import ButtonDelete from '../ButtonDelete.svelte'
import { getLabelStr } from '@/vcen-edit/lib/label.ts'
// Type conversion helper (cast)
function toOptAttId(s: string): AttID | undefined { return (s || undefined) as AttID | undefined }
let {
viewId,
hotspot,
isNew = false,
editId = '',
idError = '',
locales = ['es'],
defaultLocale,
onUpdate,
onSaveNew,
onCancelNew,
onDelete,
onIdInput,
onNameUpdate,
}: {
viewId: string
hotspot: Hotspot
isNew?: boolean
editId: string
idError: string
locales: string[]
defaultLocale: string
onUpdate: (patch: Partial<Hotspot>) => void
onSaveNew: () => void
onCancelNew: () => void
onDelete: () => void
onIdInput: () => void
onNameUpdate: (label: LingString) => void
} = $props()
const glob = getContext<EditorGlob>('editor-glob')
const HOTSPOT_MODES = $derived([
{ value: 'tooltip' as HotspotMode, label: glob.t('vcen.editor.hotspot.modes.tooltip') },
{ value: 'popup' as HotspotMode, label: glob.t('vcen.editor.hotspot.modes.popup') },
])
// Validation state
let posXError = $state('')
let posYError = $state('')
let colorError = $state('')
// Local state for selects (props are readonly in Svelte 5)
let selectedMode = $state<HotspotMode>('tooltip')
// Sync local state when prop changes
$effect(() => {
selectedMode = hotspot.mode
})
function clampPos(v: number): number {
return Math.round(Math.min(100, Math.max(0, v)) * 10) / 10
}
function isValidHexColor(v: string): boolean {
return /^#[0-9a-fA-F]{6}$/.test(v)
}
function validateHotspotField(field: 'posX' | 'posY' | 'color', val: unknown) {
if (field === 'posX') posXError = (typeof val === 'number' && val >= 0 && val <= 100) ? '' : glob.t('vcen.editor.hotspot.posRange')
if (field === 'posY') posYError = (typeof val === 'number' && val >= 0 && val <= 100) ? '' : glob.t('vcen.editor.hotspot.posRange')
if (field === 'color') colorError = (!val || isValidHexColor(String(val))) ? '' : glob.t('vcen.editor.hotspot.colorInvalid')
}
// Alias para LingInput
function orLing(v: LingRecord | undefined): LingRecord {
return v ?? ({} as LingRecord)
}
// Handler para tooltip (puede ser undefined)
function onTooltipUpdate(v: unknown) {
const rec = v as LingRecord
const tooltip = Object.keys(rec).length ? rec : undefined
onUpdate({ tooltip })
}
</script>
<div class="ve-panel-content">
<!-- Header -->
<div class="flex items-center justify-between section-title" style="margin-bottom:0">
<span>{isNew ? glob.t('vcen.editor.hotspot.newTitle') : (getLabelStr(hotspot.label) || hotspot.id)}</span>
<div class="flex items-center gap-2">
{#if isNew}
<button onclick={onCancelNew} class="ve-action-btn">{glob.t('vcen.editor.common.cancel')}</button>
<button onclick={onSaveNew} class="ve-action-btn primary">{glob.t('vcen.editor.common.save')}</button>
{:else}
<ButtonDelete size="sm" confirm
confirmTitle={glob.t('vcen.editor.hotspot.deleteTitle')}
confirmBody={getLabelStr(hotspot.label) || hotspot.id}
onconfirm={onDelete}
/>
{/if}
</div>
</div>
<!-- ID + Nombre -->
<InputIdName
prefix="hs"
{isNew}
fullId={hotspot.id}
bind:slug={editId}
idPlaceholder="rueda, interior…"
idError={idError}
onIdInput={onIdInput}
onIdKeydown={e => { if (e.key === 'Enter') onSaveNew() }}
name={hotspot.label ?? {}}
{locales}
{defaultLocale}
onNameUpdate={onNameUpdate}
/>
<!-- Mode + targetAtt -->
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1">
<label class="ve-label" for="vp-hs-mode">{glob.t('vcen.editor.hotspot.mode')}</label>
<select
id="vp-hs-mode"
bind:value={selectedMode}
onchange={() => onUpdate({ mode: selectedMode })}
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" for="vp-hs-targetatt">{glob.t('vcen.editor.hotspot.targetAtt')} <span class="text-gray-700">{glob.t('vcen.editor.hotspot.targetAttOptional')}</span></label>
<input id="vp-hs-targetatt" type="text"
value={hotspot.targetAtt ?? ''}
oninput={e => onUpdate({ targetAtt: toOptAttId(e.currentTarget.value) })}
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" for="vp-hs-posx">X (%) <span class="text-red-500">*</span></label>
<input id="vp-hs-posx" type="number" min="0" max="100" step="0.1"
value={typeof hotspot.posX === 'number' ? hotspot.posX : parseFloat(String(hotspot.posX)) || 0}
oninput={e => {
const v = clampPos(parseFloat(e.currentTarget.value) || 0)
onUpdate({ 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" for="vp-hs-posy">Y (%) <span class="text-red-500">*</span></label>
<input id="vp-hs-posy" type="number" min="0" max="100" step="0.1"
value={typeof hotspot.posY === 'number' ? hotspot.posY : parseFloat(String(hotspot.posY)) || 0}
oninput={e => {
const v = clampPos(parseFloat(e.currentTarget.value) || 0)
onUpdate({ 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>
</div>
<!-- Color + Radio + zIndex -->
<div class="grid grid-cols-3 gap-3">
<div class="flex flex-col gap-1">
<label class="ve-label" for="vp-hs-color">{glob.t('vcen.editor.hotspot.color')}</label>
<div class="flex gap-1.5 items-center">
<input type="color"
value={hotspot.color ?? '#4f46e5'}
oninput={e => {
onUpdate({ 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 id="vp-hs-color" type="text"
value={hotspot.color ?? ''}
oninput={e => {
onUpdate({ 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" for="vp-hs-radius">{glob.t('vcen.editor.hotspot.radius')}</label>
<input id="vp-hs-radius" type="number" min="1"
value={hotspot.radius ?? ''}
oninput={e => onUpdate({ 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" for="vp-hs-zindex">z-Index</label>
<input id="vp-hs-zindex" type="number"
value={hotspot.zIndex ?? ''}
oninput={e => onUpdate({ zIndex: parseInt(e.currentTarget.value) || undefined })}
placeholder="10"
class="field-input text-xs" />
</div>
</div>
<!-- Tooltip label -->
<LingInput
value={orLing(hotspot.tooltip)}
{locales}
{defaultLocale}
label={glob.t('vcen.editor.hotspot.tooltip')}
as="input"
variant="dark"
onUpdate={onTooltipUpdate} />
</div>

@ -0,0 +1,570 @@
<script lang="ts">
import { getContext } from 'svelte'
import {
type View, type ViewID, type ViewType, type LayerDefinition, type LayersConfig, type ApiConfig,
type AttID, type ImageTemplate, toAttID
} from '@/vcen/lib'
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.js'
import type { LingString } from '@/ling/lib'
import { createConditionBuilderStore } from '@/vcen-edit/stores/condition-builder.svelte.js'
import InputIdName from '../InputIdName.svelte'
import ButtonDelete from '../ButtonDelete.svelte'
import ConditionBuilder from '../ConditionBuilder.svelte'
import TemplateBuilder from '../TemplateBuilder.svelte'
import { getLabelStr } from '@/vcen-edit/lib/label.ts'
type AttrOption = { path: string; label: string; group?: string }
let {
view,
isNew = false,
editId = '',
idError = '',
locales = ['es'],
defaultLocale,
templateAttrs = [],
condStore,
condOpenLayer,
condLayerViewId,
onViewUpdate,
onSaveNew,
onCancelNew,
onDelete,
onIdInput,
onNameUpdate,
}: {
view: View
isNew?: boolean
editId: string
idError: string
locales: string[]
defaultLocale: string
templateAttrs: AttrOption[]
condStore: ReturnType<typeof createConditionBuilderStore>
condOpenLayer: number | null
condLayerViewId: ViewID | null
onViewUpdate: (patch: Partial<View>) => void
onSaveNew: () => void
onCancelNew: () => void
onDelete: () => void
onIdInput: () => void
onNameUpdate: (label: LingString) => void
} = $props()
const glob = getContext<EditorGlob>('editor-glob')
const VIEW_TYPES = $derived([
{ value: 'front' as ViewType, label: glob.t('vcen.editor.view.types.front') },
{ value: 'side' as ViewType, label: glob.t('vcen.editor.view.types.side') },
{ value: 'rear' as ViewType, label: glob.t('vcen.editor.view.types.rear') },
{ value: 'top' as ViewType, label: glob.t('vcen.editor.view.types.top') },
{ value: 'isometric' as ViewType, label: glob.t('vcen.editor.view.types.isometric') },
{ value: 'panoramic' as ViewType, label: glob.t('vcen.editor.view.types.panoramic') },
{ value: 'detail' as ViewType, label: glob.t('vcen.editor.view.types.detail') },
{ value: 'blueprint' as ViewType, label: glob.t('vcen.editor.view.types.blueprint') },
{ value: 'custom' as ViewType, label: glob.t('vcen.editor.view.types.custom') },
])
const STRATEGY_LABELS = $derived<Record<string, string>>({
static: glob.t('vcen.editor.view.strategies.static'),
dynamic: glob.t('vcen.editor.view.strategies.dynamic'),
layers: glob.t('vcen.editor.view.strategies.layers'),
api: glob.t('vcen.editor.view.strategies.api'),
d3: glob.t('vcen.editor.view.strategies.d3'),
})
const STRATEGY_VALUES = ['static', 'dynamic', 'layers', 'api', 'd3'] as const
// Validation state
let codeError = $state('')
let widthError = $state('')
let heightError = $state('')
// Local state for selects (props are readonly in Svelte 5)
let selectedViewType = $state<ViewType>('front')
let selectedStrategy = $state<View['config']['strategy']>('static')
let selectedApiMethod = $state<'GET' | 'POST'>('GET')
// Sync local state when props change
$effect(() => {
selectedViewType = view.viewType
selectedStrategy = view.config.strategy
if (view.config.strategy === 'api') {
selectedApiMethod = (view.config as unknown as ApiConfig).method ?? 'GET'
}
})
function cfgGet(config: View['config'], key: string): string {
return ((config as unknown) as Record<string, unknown>)[key] as string ?? ''
}
// Default config factory
function defaultConfig(strategy: string): View['config'] {
if (strategy === 'static') return { strategy, url: '' } as View['config']
if (strategy === 'dynamic') return { strategy, template: '' } as View['config']
if (strategy === 'layers') return { strategy, layers: [] } as View['config']
if (strategy === 'api') return { strategy, endpoint: '', method: 'GET', params: [] } as View['config']
if (strategy === 'd3') return { strategy, modelUrl: '', textureAttrs: [] } as View['config']
return { strategy } as View['config']
}
// Handlers para config - usando as unknown as para type-safety
function cfgSet(key: string, val: string) {
onViewUpdate({ config: { ...view.config, [key]: val } as View['config'] })
}
function setViewTemplate(tpl: ImageTemplate) {
onViewUpdate({ config: { ...view.config, template: tpl } as View['config'] })
}
// API helpers
function getApiCfg(): ApiConfig {
return view.config as unknown as ApiConfig
}
function setApiMethod() {
const cfg = getApiCfg()
onViewUpdate({ config: { ...cfg, method: selectedApiMethod } as View['config'] })
}
function setApiEndpointTemplate(tpl: ImageTemplate) {
const cfg = getApiCfg()
onViewUpdate({ config: { ...cfg, endpoint: tpl } as View['config'] })
}
function addApiParam() {
const cfg = getApiCfg()
onViewUpdate({ config: { ...cfg, params: [...(cfg.params ?? []), { key: '', value: '' }] } as View['config'] })
}
function removeApiParam(idx: number) {
const cfg = getApiCfg()
onViewUpdate({ config: { ...cfg, params: (cfg.params ?? []).filter((_, i) => i !== idx) } as View['config'] })
}
function updateApiParam(idx: number, key: string) {
const cfg = getApiCfg()
const params = cfg.params ?? []
onViewUpdate({ config: { ...cfg, params: params.map((p, i) => i === idx ? { ...p, key } : p) } as View['config'] })
}
function setApiParamTemplate(idx: number, tpl: ImageTemplate) {
const cfg = getApiCfg()
const params = cfg.params ?? []
onViewUpdate({ config: { ...cfg, params: params.map((p, i) => i === idx ? { ...p, value: tpl } : p) } as View['config'] })
}
// Layers helpers
function getLayersCfg(): LayersConfig {
return view.config as unknown as LayersConfig
}
function addLayer() {
const cfg = getLayersCfg()
const newLayer: LayerDefinition = { attId: '' as AttID, template: '' }
onViewUpdate({ config: { ...cfg, layers: [...(cfg.layers ?? []), newLayer] } as View['config'] })
}
function removeLayer(idx: number) {
const cfg = getLayersCfg()
onViewUpdate({ config: { ...cfg, layers: (cfg.layers ?? []).filter((_, i) => i !== idx) } as View['config'] })
}
function moveLayer(idx: number, dir: 'up' | 'down') {
const cfg = getLayersCfg()
const arr = [...(cfg.layers ?? [])]
const target = dir === 'up' ? idx - 1 : idx + 1
if (target < 0 || target >= arr.length) return
;[arr[idx], arr[target]] = [arr[target], arr[idx]]
onViewUpdate({ config: { ...cfg, layers: arr } as View['config'] })
}
function updateLayer(idx: number, patch: Partial<LayerDefinition>) {
const cfg = getLayersCfg()
const layers = cfg.layers ?? []
onViewUpdate({ config: { ...cfg, layers: layers.map((l, i) => i === idx ? { ...l, ...patch } : l) } as View['config'] })
}
function setLayerTemplate(idx: number, tpl: ImageTemplate) {
updateLayer(idx, { template: tpl })
}
// Layer condition handlers
function openLayerCond(idx: number, layer: LayerDefinition) {
condLayerViewId = view.id
condOpenLayer = idx
if (layer.condition) condStore.load(layer.condition)
else condStore.reset()
}
function closeLayerCond() { condOpenLayer = null }
function onLayerCondChange() {
if (condOpenLayer === null || !condLayerViewId) return
const compiled = condStore.compiled
const hasCondition = condStore.root.kind === 'logic' ? condStore.root.children.length > 0 : true
updateLayer(condOpenLayer, { condition: (hasCondition && compiled) ? compiled : undefined })
}
</script>
<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 ? glob.t('vcen.editor.view.newTitle') : (getLabelStr(view.label) || view.code)}</span>
<div class="flex items-center gap-2">
{#if isNew}
<button onclick={onCancelNew} class="ve-action-btn">{glob.t('vcen.editor.common.cancel')}</button>
<button onclick={onSaveNew} class="ve-action-btn primary">{glob.t('vcen.editor.common.save')}</button>
{:else}
<ButtonDelete size="sm" confirm
confirmTitle={glob.t('vcen.editor.view.deleteTitle')}
confirmBody={getLabelStr(view.label) || view.code || view.id}
confirmWarning={glob.t('vcen.editor.view.deleteWarn')}
onconfirm={onDelete}
/>
{/if}
</div>
</div>
<!-- ID + Nombre -->
<InputIdName
prefix="vw"
{isNew}
fullId={view.id}
bind:slug={editId}
idPlaceholder="frontal, detalle-rueda…"
idError={idError}
onIdInput={onIdInput}
onIdKeydown={e => { if (e.key === 'Enter') onSaveNew() }}
name={view.label ?? {}}
{locales}
{defaultLocale}
onNameUpdate={onNameUpdate}
/>
<!-- Code + Type -->
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1">
<label class="ve-label" for="vp-view-code">{glob.t('vcen.editor.view.code')} <span class="text-red-500">*</span></label>
<input id="vp-view-code" type="text"
value={view.code}
oninput={e => {
const val = e.currentTarget.value
onViewUpdate({ code: val })
codeError = val.trim() ? '' : glob.t('vcen.editor.view.codeRequired')
}}
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" for="vp-view-type">{glob.t('vcen.editor.view.type')}</label>
<select
id="vp-view-type"
bind:value={selectedViewType}
onchange={() => onViewUpdate({ viewType: selectedViewType })}
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" for="vp-view-width">{glob.t('vcen.editor.view.width')} <span class="text-red-500">*</span></label>
<input id="vp-view-width" type="number" min="1"
value={view.width ?? ''}
oninput={e => {
const v = parseInt(e.currentTarget.value) || 0
onViewUpdate({ width: v })
widthError = (v > 0) ? '' : glob.t('vcen.editor.view.dimPositive')
}}
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" for="vp-view-height">{glob.t('vcen.editor.view.height')} <span class="text-red-500">*</span></label>
<input id="vp-view-height" type="number" min="1"
value={view.height ?? ''}
oninput={e => {
const v = parseInt(e.currentTarget.value) || 0
onViewUpdate({ height: v })
heightError = (v > 0) ? '' : glob.t('vcen.editor.view.dimPositive')
}}
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>
<!-- Icon -->
<div class="flex flex-col gap-1">
<label class="ve-label" for="vp-view-icon">{glob.t('vcen.editor.view.icon')} <span class="text-gray-700">{glob.t('vcen.editor.view.optional')}</span></label>
<input id="vp-view-icon" type="text"
value={view.icon ?? ''}
oninput={e => onViewUpdate({ icon: e.currentTarget.value || undefined })}
placeholder="car-front, image, eye…"
class="field-input text-xs font-mono" />
</div>
<!-- Strategy + config inline -->
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1">
<label class="ve-label" for="vp-view-strategy">{glob.t('vcen.editor.view.strategy')}</label>
<select
id="vp-view-strategy"
bind:value={selectedStrategy}
onchange={() => onViewUpdate({ config: defaultConfig(selectedStrategy) })}
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" for="vp-cfg-url">{glob.t('vcen.editor.view.url')}</label>
<input id="vp-cfg-url" type="text"
value={cfgGet(view.config, 'url')}
oninput={e => cfgSet('url', e.currentTarget.value)}
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">
<span class="ve-label">{glob.t('vcen.editor.view.template')}</span>
<TemplateBuilder
value={cfgGet(view.config, 'template')}
attrs={templateAttrs}
onUpdate={setViewTemplate} />
</div>
{:else if view.config.strategy === 'api'}
<div></div>
{:else if view.config.strategy === 'd3'}
<div class="flex flex-col gap-1">
<label class="ve-label" for="vp-cfg-modelurl">{glob.t('vcen.editor.view.modelUrl')}</label>
<input id="vp-cfg-modelurl" type="text"
value={cfgGet(view.config, 'modelUrl')}
oninput={e => cfgSet('modelUrl', e.currentTarget.value)}
placeholder="models/object.glb"
class="field-input font-mono text-xs" />
</div>
{:else}
<div></div>
{/if}
</div>
<!-- ── Editor API (strategy = 'api') ─────────────────────────── -->
{#if view.config.strategy === 'api'}
{@const apiCfg = getApiCfg()}
{@const apiMethod = apiCfg.method ?? 'GET'}
<div class="flex flex-col gap-2">
<!-- Método -->
<div class="flex flex-col gap-1">
<label class="ve-label" for="vp-api-method">{glob.t('vcen.editor.view.method')}</label>
<select
id="vp-api-method"
bind:value={selectedApiMethod}
onchange={setApiMethod}
class="field-input text-xs w-28">
<option value="GET">GET</option>
<option value="POST">POST</option>
</select>
</div>
<!-- Endpoint -->
<div class="flex flex-col gap-1">
<span class="ve-label">{glob.t('vcen.editor.view.endpoint')}</span>
<TemplateBuilder
value={apiCfg.endpoint}
attrs={templateAttrs}
onUpdate={setApiEndpointTemplate} />
</div>
<!-- Parámetros (solo POST) -->
{#if apiMethod === 'POST'}
<div class="flex items-center justify-between">
<span class="ve-label">{glob.t('vcen.editor.view.params')}</span>
<button
onclick={addApiParam}
class="text-xs text-indigo-400 hover:text-indigo-300 transition-colors font-medium">
{glob.t('vcen.editor.view.addParam')}
</button>
</div>
{#if (apiCfg.params?.length ?? 0) === 0}
<p class="text-xs text-gray-700 italic px-1">{glob.t('vcen.editor.view.noParams')}</p>
{:else}
{#each (apiCfg.params ?? []) as param, pi (pi)}
<div class="border border-gray-800 rounded-lg p-3 flex flex-col gap-2 bg-gray-900/30">
<div class="flex items-center justify-between">
<span class="text-[10px] font-bold uppercase tracking-widest text-gray-600">
{glob.t('vcen.editor.view.paramN', { n: pi + 1 })}
</span>
<ButtonDelete size="xs" confirm
confirmTitle={glob.t('vcen.editor.view.deleteParam')}
onconfirm={() => removeApiParam(pi)}
/>
</div>
<div class="flex flex-col gap-1">
<label class="ve-label" for="vp-api-key-{pi}">{glob.t('vcen.editor.view.paramKey')}</label>
<input id="vp-api-key-{pi}" type="text"
value={param.key}
oninput={e => updateApiParam(pi, e.currentTarget.value)}
placeholder="color, trim, env…"
class="field-input font-mono text-xs" />
</div>
<div class="flex flex-col gap-1">
<span class="ve-label">{glob.t('vcen.editor.view.paramVal')}</span>
<TemplateBuilder
value={param.value}
attrs={templateAttrs}
onUpdate={tpl => setApiParamTemplate(pi, tpl)} />
</div>
</div>
{/each}
{/if}
{/if}
</div>
{/if}
<!-- ── Editor de capas (strategy = 'layers') ──────────────────── -->
{#if view.config.strategy === 'layers'}
{@const layersCfg = getLayersCfg()}
{@const layers = layersCfg.layers ?? []}
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="ve-label">{glob.t('vcen.editor.view.layers')}</span>
<button
onclick={addLayer}
class="text-xs text-indigo-400 hover:text-indigo-300 transition-colors font-medium">
{glob.t('vcen.editor.view.addLayer')}
</button>
</div>
{#if layers.length === 0}
<p class="text-xs text-gray-700 italic px-1">{glob.t('vcen.editor.view.noLayersHint')}</p>
{:else}
{#each layers as layer, li (li)}
<div class="border border-gray-800 rounded-lg p-3 flex flex-col gap-2 bg-gray-900/30">
<!-- Cabecera capa -->
<div class="flex items-center justify-between">
<span class="text-[10px] font-bold uppercase tracking-widest text-gray-600">
{glob.t('vcen.editor.view.layerN', { n: li + 1 })}
</span>
<div class="flex items-center gap-0.5">
<button
onclick={() => moveLayer(li, 'up')}
disabled={li === 0}
title={glob.t('vcen.editor.sidebar.moveUp')}
class="flex items-center justify-center w-5 h-5 rounded text-xs text-gray-600
hover:text-gray-300 hover:bg-gray-700 transition-colors
disabled:opacity-20 disabled:pointer-events-none">↑</button>
<button
onclick={() => moveLayer(li, 'down')}
disabled={li === layers.length - 1}
title={glob.t('vcen.editor.sidebar.moveDown')}
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>
<ButtonDelete size="xs" confirm
confirmTitle={glob.t('vcen.editor.view.deleteLayer')}
confirmBody={glob.t('vcen.editor.view.layerN', { n: li + 1 })}
onconfirm={() => removeLayer(li)}
/>
</div>
</div>
<!-- attId + template -->
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-1">
<label class="ve-label" for="vp-layer-attid-{li}">{glob.t('vcen.editor.view.mainAtt')}</label>
{#if templateAttrs.length > 0}
<select
id="vp-layer-attid-{li}"
onchange={(e) => updateLayer(li, { attId: toAttID(e.currentTarget.value) })}
class="field-input text-xs font-mono">
<option value="">{glob.t('vcen.editor.view.selectAtt')}</option>
{#each templateAttrs as a (a.path)}
<option value={a.path} selected={layer.attId === a.path}>{a.label} ({a.path})</option>
{/each}
</select>
{:else}
<input id="vp-layer-attid-{li}" type="text"
value={layer.attId}
oninput={e => updateLayer(li, { attId: toAttID(e.currentTarget.value) })}
placeholder="at:color…"
class="field-input font-mono text-xs" />
{/if}
</div>
<div class="flex flex-col gap-1">
<span class="ve-label">{glob.t('vcen.editor.view.imageTemplate')}</span>
<TemplateBuilder
value={layer.template}
attrs={templateAttrs}
onUpdate={tpl => setLayerTemplate(li, tpl)} />
</div>
</div>
<!-- zIndex + opcional -->
<div class="grid grid-cols-2 gap-2">
<div class="flex flex-col gap-1">
<label class="ve-label" for="vp-layer-zi-{li}">z-Index</label>
<input id="vp-layer-zi-{li}" type="number"
value={layer.zIndex ?? ''}
oninput={e => updateLayer(li, { zIndex: parseInt(e.currentTarget.value) || undefined })}
placeholder="0, 10, 20…"
class="field-input text-xs" />
</div>
<div class="flex items-center gap-2 pt-5">
<input type="checkbox"
id="vp-layer-opt-{li}"
checked={layer.optional ?? false}
onchange={e => updateLayer(li, { optional: e.currentTarget.checked || undefined })} />
<label class="text-xs text-gray-400 cursor-pointer select-none" for="vp-layer-opt-{li}">
{glob.t('vcen.editor.view.optional')}
</label>
</div>
</div>
<!-- Condición (ConditionBuilder) -->
<div class="border-t border-gray-800/50 pt-2">
<button
onclick={() => condOpenLayer === li ? closeLayerCond() : openLayerCond(li, layer)}
class="flex items-center gap-1.5 text-[10px] uppercase tracking-wider
text-gray-600 hover:text-gray-400 transition-colors w-full text-left">
<svg class="h-2.5 w-2.5 transition-transform fill-current {condOpenLayer === li ? 'rotate-90' : ''}"
viewBox="0 0 8 8">
<polygon points="2,1 7,4 2,7"/>
</svg>
{glob.t('vcen.editor.view.condition')}
{#if layer.condition}
<span class="ml-auto text-amber-600 normal-case tracking-normal">{glob.t('vcen.editor.view.condActive')}</span>
{:else}
<span class="ml-auto normal-case tracking-normal text-gray-700 italic">{glob.t('vcen.editor.view.condAlways')}</span>
{/if}
</button>
{#if condOpenLayer === li}
<div class="mt-2 rounded border border-gray-800 p-2 bg-gray-900/60">
<ConditionBuilder store={condStore} onchange={onLayerCondChange} />
</div>
{/if}
</div>
</div>
{/each}
{/if}
</div>
{/if}
</div>

@ -0,0 +1,72 @@
<script lang="ts">
import type { Visual, ViewID } from '@/vcen/lib'
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.js'
import {getContext} from "svelte";
let {
visual,
catDefaults = {},
views = [],
onUpdate,
}: {
visual: Visual | undefined
catDefaults?: { basePath?: string; fallback?: string }
views: { id: ViewID; label: Record<string, string>; code: string }[]
onUpdate: (patch: Partial<Visual>) => void
} = $props()
const glob = $derived(getContext<EditorGlob>('editor-glob'))
// Local state for select
let selectedDefaultView = $state('')
// Sync when prop changes
$effect(() => {
selectedDefaultView = visual?.defaultViewId ?? ''
})
function toOptViewId(s: string): ViewID | undefined {
return (s || undefined) as ViewID | undefined
}
</script>
<div class="ve-panel-content">
<h3 class="section-title">{glob.t('vcen.editor.view.generalConfig')}</h3>
<div class="flex flex-col gap-1">
<label class="ve-label" for="vp-basepath">Base path <span class="text-gray-700">(override)</span></label>
<input id="vp-basepath" type="text"
value={visual?.basePath ?? ''}
oninput={e => onUpdate({ 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" for="vp-fallback">Fallback <span class="text-gray-700">(override)</span></label>
<input id="vp-fallback" type="text"
value={visual?.fallback ?? ''}
oninput={e => onUpdate({ 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" for="vp-default-view">{glob.t('vcen.editor.view.defaultView')}</label>
<select
id="vp-default-view"
bind:value={selectedDefaultView}
onchange={() => onUpdate({ defaultViewId: toOptViewId(selectedDefaultView) })}
class="field-input text-xs">
<option value="">{glob.t('vcen.editor.view.firstView')}</option>
{#each views as v (v.id)}
<option value={v.id}>{v.label || v.code}</option>
{/each}
</select>
</div>
{:else}
<p class="text-xs text-gray-700 italic">{glob.t('vcen.editor.view.addFromSidebar')}</p>
{/if}
</div>

@ -0,0 +1,281 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { View, ViewResolution, Hotspot, ViewTemplate } from '@/vcen/lib'
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.js'
import { getLabelStr } from '@/vcen-edit/lib/label.ts'
import { HOTSPOT_DEFAULT_RADIUS } from '@/vcen-edit/lib/constants'
let {
previewView,
selectedHotspot,
pickingPos = false,
previewResolution,
previewResolvedUrl,
previewResolvedLayers,
previewStaticUrl,
previewDynTpl,
previewApiEp,
previewD3Model,
previewLayers,
onPreviewClick,
}: {
previewView: View | null
selectedHotspot: Hotspot | null
pickingPos?: boolean
previewResolution: ViewResolution | null
previewResolvedUrl: string
previewResolvedLayers: { url: string; zIndex: number }[]
previewStaticUrl: string
previewDynTpl: string
previewApiEp: string
previewD3Model: string
previewLayers: { template: ViewTemplate; zIndex?: number; condition?: unknown; optional?: boolean }[]
onPreviewClick: (e: MouseEvent) => void
} = $props()
const glob = getContext<EditorGlob>('editor-glob')
// Helper para mostrar ViewTemplate como string
function templateDisplay(tpl: ViewTemplate): string {
if (!tpl) return ''
if (typeof tpl === 'string') return tpl
if (tpl.length === 0) return ''
if (tpl.length === 1 && tpl[0].kind === 'text') return tpl[0].value
return JSON.stringify(tpl)
}
const STRATEGY_LABELS = $derived<Record<string, string>>({
static: glob.t('vcen.editor.view.strategies.static'),
dynamic: glob.t('vcen.editor.view.strategies.dynamic'),
layers: glob.t('vcen.editor.view.strategies.layers'),
api: glob.t('vcen.editor.view.strategies.api'),
d3: glob.t('vcen.editor.view.strategies.d3'),
})
const previewStrategy = $derived(previewView?.config.strategy ?? null)
let previewImgError = $state(false)
let previewImgLoaded = $state(false)
// Resetear estado cuando cambia la URL
$effect(() => {
void previewResolvedUrl
void previewStaticUrl
void previewApiEp
previewImgError = false
previewImgLoaded = false
})
</script>
<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 flex items-center justify-between">
<span class="text-[10px] font-bold uppercase tracking-widest text-gray-700">Preview</span>
{#if previewStrategy}
<span class="text-[10px] text-gray-700 font-mono">{STRATEGY_LABELS[previewStrategy] ?? previewStrategy}</span>
{/if}
</div>
<div class="p-3 flex flex-col gap-3">
<!-- ── Contenedor visual interactivo ──────────────────── -->
<!-- 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={onPreviewClick}
>
{#if previewStrategy === 'static' && previewStaticUrl}
<!-- Imagen estática (URL directa del config) -->
{#if !previewImgError}
<img
src={previewStaticUrl}
alt={getLabelStr(previewView?.label) || previewView?.code}
class="absolute inset-0 w-full h-full object-contain pointer-events-none"
onload={() => previewImgLoaded = true}
onerror={() => previewImgError = true}
/>
{:else}
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-600 pointer-events-none px-4">
<svg class="h-8 w-8 text-red-800" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<line x1="3" y1="3" x2="21" y2="21"/>
</svg>
<span class="text-[10px] text-red-400">{glob.t('vcen.editor.view.imgLoadError')}</span>
<span class="text-[10px] font-mono text-gray-700 break-all text-center">{previewStaticUrl}</span>
</div>
{/if}
{:else if previewStrategy === 'dynamic' && previewResolvedUrl}
<!-- Imagen dinámica resuelta -->
{#if !previewImgError}
<img
src={previewResolvedUrl}
alt={getLabelStr(previewView?.label) || previewView?.code}
class="absolute inset-0 w-full h-full object-contain pointer-events-none"
onload={() => previewImgLoaded = true}
onerror={() => previewImgError = true}
/>
{:else}
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-600 pointer-events-none px-4">
<svg class="h-8 w-8 text-red-800" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<line x1="3" y1="3" x2="21" y2="21"/>
</svg>
<span class="text-[10px] text-red-400">{glob.t('vcen.editor.view.imgLoadError')}</span>
<span class="text-[10px] font-mono text-gray-700 break-all text-center">{previewResolvedUrl}</span>
</div>
{/if}
{#if previewResolution?.hasFallback}
<div class="absolute top-1 right-1 px-1.5 py-0.5 rounded bg-amber-900/80 text-amber-300 text-[9px] font-medium">
fallback
</div>
{/if}
{:else if previewStrategy === 'layers' && previewResolvedLayers.length > 0}
<!-- Capas resueltas: imágenes apiladas -->
{#each previewResolvedLayers as layer, li (li)}
<img
src={layer.url}
alt={glob.t('vcen.editor.view.layerN', { n: li + 1 })}
class="absolute inset-0 w-full h-full object-contain pointer-events-none"
style="z-index: {layer.zIndex}"
/>
{/each}
{#if previewResolution?.hasFallback}
<div class="absolute top-1 right-1 px-1.5 py-0.5 rounded bg-amber-900/80 text-amber-300 text-[9px] font-medium z-50">
fallback
</div>
{/if}
{:else if previewStrategy === 'layers' && previewLayers.length > 0}
<!-- Capas sin resolver: placeholder -->
<div class="absolute inset-0 flex flex-col items-center justify-center gap-3 pointer-events-none">
<div class="flex flex-col items-center gap-1">
{#each previewLayers as layer, li (li)}
<div class="flex items-center gap-2 px-2 py-1 rounded bg-gray-800/60 border border-gray-700 w-full max-w-48">
<span class="text-[10px] font-mono text-indigo-400 shrink-0">z{layer.zIndex ?? 0}</span>
<span class="text-[10px] font-mono text-gray-500 truncate flex-1">{templateDisplay(layer.template) || '—'}</span>
{#if layer.condition}
<span class="text-[10px] text-amber-600 shrink-0" title={glob.t('vcen.editor.view.condActive')}>if</span>
{/if}
{#if layer.optional}
<span class="text-[10px] text-gray-700 shrink-0">opt</span>
{/if}
</div>
{/each}
</div>
<span class="text-[10px] text-gray-700">{previewLayers.length} capa{previewLayers.length !== 1 ? 's' : ''}</span>
</div>
{:else if previewStrategy === 'dynamic'}
<!-- Template dinámico sin resolver -->
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-700 pointer-events-none px-4">
<svg class="h-8 w-8 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<path d="m21 15-5-5L5 21"/>
</svg>
{#if previewDynTpl}
<span class="text-[10px] font-mono text-center break-all text-gray-600">{previewDynTpl}</span>
{:else}
<span class="text-[10px] italic">{glob.t('vcen.editor.view.noTemplate')}</span>
{/if}
</div>
{:else if previewStrategy === 'api'}
{#if previewApiEp && !previewImgError}
<!-- Imagen vía API endpoint -->
<img
src={previewApiEp}
alt={getLabelStr(previewView?.label) || previewView?.code}
class="absolute inset-0 w-full h-full object-contain pointer-events-none"
onload={() => previewImgLoaded = true}
onerror={() => previewImgError = true}
/>
{:else if previewApiEp && previewImgError}
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-600 pointer-events-none px-4">
<svg class="h-8 w-8 text-red-800" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<line x1="3" y1="3" x2="21" y2="21"/>
</svg>
<span class="text-[10px] text-red-400">{glob.t('vcen.editor.view.imgLoadError')}</span>
<span class="text-[10px] font-mono text-gray-700 break-all text-center">{previewApiEp}</span>
</div>
{:else}
<!-- Sin endpoint -->
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-700 pointer-events-none px-4">
<svg class="h-8 w-8 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<path d="M21 12h-4m-2-7 2 7-2 7M3 12h4m2-7-2 7 2 7"/>
</svg>
<span class="text-[10px] italic">{glob.t('vcen.editor.view.noEndpoint')}</span>
</div>
{/if}
{:else if previewStrategy === 'd3'}
<!-- Modelo 3D -->
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-700 pointer-events-none px-4">
<svg class="h-8 w-8 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<path d="M12 2 2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
</svg>
{#if previewD3Model}
<span class="text-[10px] font-mono text-center break-all text-gray-600">{previewD3Model}</span>
{:else}
<span class="text-[10px] italic">{glob.t('vcen.editor.view.noModel')}</span>
{/if}
</div>
{:else}
<!-- Sin imagen / placeholder genérico -->
<div class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-gray-700 pointer-events-none">
<svg class="h-10 w-10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<path d="m21 15-5-5L5 21"/>
</svg>
<span class="text-[10px]">{pickingPos ? glob.t('vcen.editor.hotspot.clickToPosition') : glob.t('vcen.editor.view.noImage')}</span>
</div>
{/if}
<!-- Hotspot markers 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 ?? HOTSPOT_DEFAULT_RADIUS) * 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 inferior -->
<div class="flex flex-col gap-1 text-[10px] text-gray-700 font-mono">
<div class="flex items-center justify-between">
<span>{previewView?.width || '?'} × {previewView?.height || '?'} px</span>
{#if pickingPos}
<span class="text-indigo-400 font-sans font-medium">{glob.t('vcen.editor.hotspot.clickToPosition')}</span>
{/if}
</div>
{#if previewResolvedUrl || (previewStrategy === 'static' && previewStaticUrl)}
<span class="text-gray-600 break-all">{previewResolvedUrl || previewStaticUrl}</span>
{/if}
</div>
</div>
</div>

@ -0,0 +1,133 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { View, ViewID, Hotspot, HotspotID } from '@/vcen/lib'
import type { EditorGlob } from '@/vcen-edit/lib/editor-glob.svelte.js'
import { getLabelStr } from '@/vcen-edit/lib/label.ts'
type Sel =
| 'general'
| { kind: 'view'; viewId: ViewID }
| { kind: 'hotspot'; viewId: ViewID; hotspotId: HotspotID }
let {
views,
selected,
onSelect,
onAddView,
onAddHotspot,
onMoveView,
}: {
views: View[]
selected: Sel
onSelect: (sel: Sel) => void
onAddView: () => void
onAddHotspot: (viewId: ViewID) => void
onMoveView: (viewId: ViewID, dir: 'up' | 'down') => void
} = $props()
const glob = getContext<EditorGlob>('editor-glob')
// Derived helpers para evitar casts en template
const selKind = $derived(selected === 'general' ? null : selected.kind)
const selViewId = $derived(selected === 'general' ? null : (selected as any).viewId)
const selHotspotId = $derived(selected !== 'general' && (selected as any).kind === 'hotspot' ? (selected as any).hotspotId : null)
</script>
<div class="w-44 shrink-0 border-r border-gray-800 flex flex-col overflow-y-auto py-2">
<!-- General -->
<button
onclick={() => onSelect('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 fill-current" viewBox="0 0 12 12" stroke="currentColor" stroke-width="1.5">
<path d="M1 2h10M1 5h7M1 8h4"/>
</svg>
{glob.t('vcen.editor.view.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">{glob.t('vcen.editor.view.viewsSection')}</span>
<button
onclick={onAddView}
class="text-gray-700 hover:text-indigo-400 transition-colors text-sm leading-none"
title={glob.t('vcen.editor.view.newViewBtn')}>+</button>
</div>
{#if views.length === 0}
<div class="pl-7 pr-3 py-1.5 text-[11px] text-gray-700 italic">{glob.t('vcen.editor.view.noViews')}</div>
{:else}
{#each views as view, idx (view.id)}
{@const isViewSel = selViewId === view.id}
{@const isViewActive = isViewSel && selKind === '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={() => onSelect({ 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(); onMoveView(view.id, 'up') }}
disabled={idx === 0}
title={glob.t('vcen.editor.sidebar.moveUp')}
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(); onMoveView(view.id, 'down') }}
disabled={idx === views.length - 1}
title={glob.t('vcen.editor.sidebar.moveDown')}
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(); onAddHotspot(view.id) }}
title={glob.t('vcen.editor.hotspot.newTitle')}
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 = selHotspotId === hs.id && selViewId === view.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={() => onSelect({ 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 || glob.t('vcen.editor.hotspot.noLabel')}
</span>
</button>
</div>
{/each}
{/each}
{/if}
</div>

@ -8,7 +8,7 @@ import { labelOf } from '@/vcen-edit/lib/label.ts'
import CatalogSidebar from '../comps/CatalogSidebar.svelte'
import PanelCatalogoMeta from '../comps/PanelCatalogoMeta.svelte'
import PanelCatalogoAtts from '../comps/PanelCatalogoAtts.svelte'
import MetadataPanel from '../comps/MetadataPanel.svelte'
import EditorMetaTree from '../comps/EditorMetaTree.svelte'
import PanelOptions from '../comps/PanelOptions.svelte'
import PanelObject from '../comps/PanelObject.svelte'
import PanelSection from '../comps/PanelSection.svelte'
@ -155,7 +155,7 @@ const isValid = $derived(graphResult.graph !== null)
<div class="flex-1 min-w-0 overflow-hidden relative">
{#if sel.kind === 'cat-info'} <PanelCatalogoMeta />
{:else if sel.kind === 'cat-atts'} <PanelCatalogoAtts />
{:else if sel.kind === 'cat-meta'} <MetadataPanel />
{:else if sel.kind === 'cat-meta'} <EditorMetaTree value={editor.cat.metadata ?? {}} onUpdate={m => editor.updateCatMeta(Object.keys(m).filter(k => k !== 'order').length === 0 ? undefined : m)} />
{:else if sel.kind === 'cat-opts'} <PanelOptions />
{:else if sel.kind === 'obj-info' || sel.kind === 'obj-atts' || sel.kind === 'obj-meta' || sel.kind === 'obj-issues' || sel.kind === 'obj-rules'} <PanelObject />
{:else if sel.kind === 'sec-info' || sel.kind === 'sec-atts' || sel.kind === 'sec-meta' || sel.kind === 'sec-rules' || sel.kind === 'sec-issues'} <PanelSection />

@ -0,0 +1,140 @@
// ─────────────────────────────────────────────────────────────────────────────
// CLONE - vcen-edit/lib/clone.ts
// ─────────────────────────────────────────────────────────────────────────────
/**
* Funciones de clonación eficiente para el editor de catálogos.
* Reemplaza JSON.stringify/parse por clonación manual más eficiente.
*/
import type { Cat, Obj, Sec, View, Hotspot, Att, Opt, OptCategory } from '@/vcen/lib';
/**
* Clona un objeto Cat de forma más eficiente que JSON.stringify/parse
*/
export function cloneCat(cat: Cat): Cat {
return {
...cat,
atts: cat.atts.map(att => cloneAtt(att)),
objs: cat.objs.map(obj => cloneObj(obj)),
opts: cat.opts.map(opt => cloneOpt(opt)),
optCategories: cat.optCategories.map(oc => ({ ...oc })),
};
}
/**
* Clona un objeto Obj
*/
export function cloneObj(obj: Obj): Obj {
return {
...obj,
atts: obj.atts.map(att => cloneAtt(att)),
secs: obj.secs.map(sec => cloneSec(sec)),
};
}
/**
* Clona un objeto Sec
*/
export function cloneSec(sec: Sec): Sec {
return {
...sec,
atts: sec.atts.map(att => cloneAtt(att)),
visual: sec.visual ? cloneVisual(sec.visual) : undefined,
};
}
/**
* Clona un objeto Att
*/
export function cloneAtt(att: Att): Att {
const attValue = att.value as any;
const attDomain = attValue.domain as any;
return {
...att,
value: {
...attValue,
domain: { ...attDomain }
}
} as Att;
}
/**
* Clona una Vista
*/
export function cloneView(view: View): View {
return {
...view,
hotspots: view.hotspots?.map(hs => cloneHotspot(hs)),
};
}
/**
* Clona un Hotspot
*/
export function cloneHotspot(hotspot: Hotspot): Hotspot {
return { ...hotspot };
}
/**
* Clona una Opción
*/
export function cloneOpt(opt: Opt): Opt {
const optValue = opt.value as any;
const optDomain = optValue.domain as any;
return {
...opt,
value: {
...optValue,
domain: { ...optDomain }
}
} as Opt;
}
/**
* Clona una etiqueta LingRecord
*/
export function cloneLabel(label: Record<string, string>): Record<string, string> {
return { ...label };
}
/**
* Clona un objeto Visual (puede contener views)
*/
function cloneVisual(visual: any): any {
return {
...visual,
views: visual.views?.map((v: View) => cloneView(v)),
};
}
/**
* Clona cualquier valor usando spread operator para objetos primitivos
*/
export function shallowClone<T>(obj: T): T {
if (typeof obj !== 'object' || obj === null) return obj;
if (Array.isArray(obj)) return [...obj] as unknown as T;
return { ...obj } as unknown as T;
}
/**
* Clona un array manteniendo sus elementos
*/
export function cloneArray<T>(array: T[]): T[] {
return [...array];
}
/**
* Clona un Map manteniendo sus entradas
*/
export function cloneMap<K, V>(map: Map<K, V>): Map<K, V> {
return new Map(map);
}
/**
* Clona un Set manteniendo sus elementos
*/
export function cloneSet<T>(set: Set<T>): Set<T> {
return new Set(set);
}

@ -454,6 +454,9 @@ export function validateCondition(
function walk(n: ConditionNode) {
if (n.kind === 'logic') {
if (n.children.length === 0) {
errors.push({ nodeId: n.id, message: `LogicNode "${n.op}" debe tener al menos un hijo` });
}
n.children.forEach(walk);
return;
}

@ -0,0 +1,169 @@
// ─────────────────────────────────────────────────────────────────────────────
// CONSTANTS - vcen-edit/lib/constants.ts
// ─────────────────────────────────────────────────────────────────────────────
/**
* Constantes de dominio del editor de catálogos.
* Centraliza magic numbers y valores hardcoded para mejor mantenibilidad.
*/
// ─────────────────────────────────────────────────────────────────────────────
// VIEW DIMENSIONS
// ─────────────────────────────────────────────────────────────────────────────
export const VIEW_DEFAULT_WIDTH = 800;
export const VIEW_DEFAULT_HEIGHT = 600;
export const VIEW_MIN_WIDTH = 100;
export const VIEW_MAX_WIDTH = 4000;
export const VIEW_MIN_HEIGHT = 100;
export const VIEW_MAX_HEIGHT = 4000;
// ─────────────────────────────────────────────────────────────────────────────
// RULE CONFIGURATION
// ─────────────────────────────────────────────────────────────────────────────
export const RULE_DEFAULT_PRIORITY = 10;
export const RULE_MIN_PRIORITY = 1;
export const RULE_MAX_PRIORITY = 100;
// ─────────────────────────────────────────────────────────────────────────────
// HOTSPOT CONFIGURATION
// ─────────────────────────────────────────────────────────────────────────────
export const HOTSPOT_DEFAULT_RADIUS = 8;
export const HOTSPOT_MIN_RADIUS = 4;
export const HOTSPOT_MAX_RADIUS = 20;
export const HOTSPOT_MIN_POSITION = 0;
export const HOTSPOT_MAX_POSITION = 100;
export const HOTSPOT_MAX_Z_INDEX = 1000;
// ─────────────────────────────────────────────────────────────────────────────
// VIEW TYPES
// ─────────────────────────────────────────────────────────────────────────────
export const VIEW_TYPES = [
'front',
'side',
'rear',
'top',
'isometric',
'panoramic',
'detail',
'blueprint',
'custom'
] as const;
export type ViewType = typeof VIEW_TYPES[number];
// ─────────────────────────────────────────────────────────────────────────────
// VIEW STRATEGIES
// ─────────────────────────────────────────────────────────────────────────────
export const VIEW_STRATEGIES = [
'static',
'dynamic',
'layers',
'api',
'd3'
] as const;
export type ViewStrategy = typeof VIEW_STRATEGIES[number];
// ─────────────────────────────────────────────────────────────────────────────
// HOTSPOT MODES
// ─────────────────────────────────────────────────────────────────────────────
export const HOTSPOT_MODES = [
'tooltip',
'popup'
] as const;
export type HotspotMode = typeof HOTSPOT_MODES[number];
// ─────────────────────────────────────────────────────────────────────────────
// DOMAIN DEFAULT VALUES
// ─────────────────────────────────────────────────────────────────────────────
export const DOMAIN_DEFAULT_NUMBER = 0;
export const DOMAIN_DEFAULT_BOOLEAN = false;
export const DOMAIN_DEFAULT_STRING = '';
// ─────────────────────────────────────────────────────────────────────────────
// UI LIMITS
// ─────────────────────────────────────────────────────────────────────────────
export const UI_MAX_ID_LENGTH = 50;
export const UI_MAX_CODE_LENGTH = 100;
export const UI_MAX_LABEL_LENGTH = 200;
export const UI_MAX_DESC_LENGTH = 1000;
// ─────────────────────────────────────────────────────────────────────────────
// SIDEBAR CONFIGURATION
// ─────────────────────────────────────────────────────────────────────────────
export const SIDEBAR_MIN_WIDTH = 160;
export const SIDEBAR_MAX_WIDTH = 480;
export const SIDEBAR_DEFAULT_WIDTH = 320;
// ─────────────────────────────────────────────────────────────────────────────
// Z-INDEX VALUES
// ─────────────────────────────────────────────────────────────────────────────
export const Z_INDEX_BACKGROUND = 0;
export const Z_INDEX_BASE = 1;
export const Z_INDEX_OVERLAY = 10;
export const Z_INDEX_MODAL = 100;
export const Z_INDEX_DROPDOWN = 200;
export const Z_INDEX_TOAST = 300;
// ─────────────────────────────────────────────────────────────────────────────
// COLOR VALUES
// ─────────────────────────────────────────────────────────────────────────────
export const COLOR_DEFAULT_HOTSPOT = '#4f46e5';
export const COLOR_DEFAULT_PRIMARY = '#3b82f6';
export const COLOR_DEFAULT_SUCCESS = '#10b981';
export const COLOR_DEFAULT_WARNING = '#f59e0b';
export const COLOR_DEFAULT_ERROR = '#ef4444';
// ─────────────────────────────────────────────────────────────────────────────
// ANIMATION DURATIONS
// ─────────────────────────────────────────────────────────────────────────────
export const ANIMATION_DURATION_FAST = 150;
export const ANIMATION_DURATION_NORMAL = 300;
export const ANIMATION_DURATION_SLOW = 600;
// ─────────────────────────────────────────────────────────────────────────────
// SEQUENCE GENERATION
// ─────────────────────────────────────────────────────────────────────────────
/**
* Prefijos de ID para generación de IDs únicos
*/
export const ID_PREFIXES = {
CATALOG: 'ct',
OBJECT: 'ob',
SECTION: 'sc',
ATTRIBUTE: 'at',
OPTION: 'op',
RULE: 'rl',
VIEW: 'vw',
HOTSPOT: 'hs',
OPT_CATEGORY: 'oc'
} as const;
// ─────────────────────────────────────────────────────────────────────────────
// FORM VALIDATION LIMITS
// ─────────────────────────────────────────────────────────────────────────────
export const VALIDATION_MIN_CODE_LENGTH = 1;
export const VALIDATION_MAX_CODE_LENGTH = 50;
export const VALIDATION_MIN_NAME_LENGTH = 1;
export const VALIDATION_MAX_NAME_LENGTH = 200;
// ─────────────────────────────────────────────────────────────────────────────
// CACHE CONFIGURATION
// ─────────────────────────────────────────────────────────────────────────────
export const CACHE_MAX_SIZE = 50;
export const CACHE_TTL_MS = 30000; // 30 segundos

@ -132,6 +132,7 @@ export const EDITOR_TRANSLATIONS = {
cancel: { es: 'Cancelar' },
save: { es: 'Guardar' },
add: { es: 'Añadir' },
addEntity: { es: 'Nuevo' },
deleteBtn: { es: 'eliminar' },
remove: { es: 'Eliminar' },
clone: { es: 'clonar' },

@ -0,0 +1,268 @@
// ─────────────────────────────────────────────────────────────────────────────
// TYPE GUARDS - vcen-edit/lib/guards.ts
// ─────────────────────────────────────────────────────────────────────────────
/**
* Type guards específicos para el editor de catálogos vcen-edit.
* Proporciona validación adicional para estructuras usadas en la UI del editor.
*/
import type { AttDomain, AttValue, ViewConfig, Visual, Hotspot } from '@/vcen/lib';
// ─────────────────────────────────────────────────────────────────────────────
// ATTRIBUTE VALUE GUARDS
// ─────────────────────────────────────────────────────────────────────────────
/**
* Guard para validar AttValue
*/
export function isAttValue(value: unknown): value is AttValue {
if (typeof value !== 'object' || value === null) return false;
const v = value as Record<string, unknown>;
return 'value' in v && 'domain' in v;
}
/**
* Guard para validar que un AttDomain tiene modo 'fixed'
*/
export function isAttDomainWithFixedMode(value: unknown): value is AttDomain & { mode: 'fixed'; fixed?: string | number | boolean } {
if (typeof value !== 'object' || value === null) return false;
const d = value as Record<string, unknown>;
return typeof d.kind === 'string' &&
d.mode === 'fixed' &&
(d.fixed === undefined ||
typeof d.fixed === 'string' ||
typeof d.fixed === 'number' ||
typeof d.fixed === 'boolean');
}
/**
* Guard para validar AttDomain con modo específico
*/
export function isAttDomainWithMode<K extends AttDomain['mode']>(
value: unknown,
mode: K
): value is AttDomain & { mode: K } {
if (typeof value !== 'object' || value === null) return false;
const d = value as AttDomain;
return d.mode === mode;
}
// ─────────────────────────────────────────────────────────────────────────────
// VIEW EDITOR GUARDS
// ─────────────────────────────────────────────────────────────────────────────
/**
* Guard para validar ViewConfig con estrategia 'static'
*/
export function isViewConfigStatic(value: unknown): value is ViewConfig & { strategy: 'static'; url: string } {
if (typeof value !== 'object' || value === null) return false;
const config = value as ViewConfig;
return config.strategy === 'static' && 'url' in config && typeof config.url === 'string';
}
/**
* Guard para validar ViewConfig con estrategia 'dynamic'
*/
export function isViewConfigDynamic(value: unknown): value is ViewConfig & { strategy: 'dynamic'; template: string | object } {
if (typeof value !== 'object' || value === null) return false;
const config = value as ViewConfig;
return config.strategy === 'dynamic' && 'template' in config;
}
/**
* Guard para validar ViewConfig con estrategia 'layers'
*/
export function isViewConfigLayers(value: unknown): value is ViewConfig & { strategy: 'layers'; layers: any[] } {
if (typeof value !== 'object' || value === null) return false;
const config = value as ViewConfig;
return config.strategy === 'layers' && 'layers' in config && Array.isArray(config.layers);
}
/**
* Guard para validar ViewConfig con estrategia 'api'
*/
export function isViewConfigApi(value: unknown): value is ViewConfig & { strategy: 'api'; endpoint: string | object } {
if (typeof value !== 'object' || value === null) return false;
const config = value as ViewConfig;
return config.strategy === 'api' && 'endpoint' in config;
}
/**
* Guard para validar ViewConfig con estrategia 'd3'
*/
export function isViewConfigD3(value: unknown): value is ViewConfig & { strategy: 'd3'; modelUrl: string } {
if (typeof value !== 'object' || value === null) return false;
const config = value as ViewConfig;
return config.strategy === 'd3' && 'modelUrl' in config && typeof config.modelUrl === 'string';
}
// ─────────────────────────────────────────────────────────────────────────────
// VISUAL EDITOR GUARDS
// ─────────────────────────────────────────────────────────────────────────────
/**
* Guard para validar Visual con vistas
*/
export function isVisualWithViews(value: unknown): value is Visual & { views: any[] } {
if (typeof value !== 'object' || value === null) return false;
const visual = value as Visual;
return 'views' in visual && Array.isArray(visual.views);
}
/**
* Guard para validar Visual con basePath
*/
export function isVisualWithBasePath(value: unknown): value is Visual & { basePath: string } {
if (typeof value !== 'object' || value === null) return false;
const visual = value as Visual;
return 'basePath' in visual && typeof visual.basePath === 'string';
}
/**
* Guard para validar Visual con fallback
*/
export function isVisualWithFallback(value: unknown): value is Visual & { fallback: string } {
if (typeof value !== 'object' || value === null) return false;
const visual = value as Visual;
return 'fallback' in visual && typeof visual.fallback === 'string';
}
// ─────────────────────────────────────────────────────────────────────────────
// HOTSPOT EDITOR GUARDS
// ─────────────────────────────────────────────────────────────────────────────
/**
* Guard para validar Hotspot con targetAtt
*/
export function isHotspotWithTargetAtt(value: unknown): value is Hotspot & { targetAtt: string } {
if (typeof value !== 'object' || value === null) return false;
const hotspot = value as Hotspot;
return 'targetAtt' in hotspot && typeof hotspot.targetAtt === 'string';
}
/**
* Guard para validar Hotspot con tooltip
*/
export function isHotspotWithTooltip(value: unknown): value is Hotspot & { tooltip: Record<string, string> } {
if (typeof value !== 'object' || value === null) return false;
const hotspot = value as Hotspot;
return 'tooltip' in hotspot &&
typeof hotspot.tooltip === 'object' &&
hotspot.tooltip !== null;
}
/**
* Guard para validar Hotspot con condición de visibilidad
*/
export function isHotspotWithVisibilityCondition(value: unknown): value is Hotspot & { visible: boolean | object } {
if (typeof value !== 'object' || value === null) return false;
const hotspot = value as Hotspot;
return 'visible' in hotspot &&
(typeof hotspot.visible === 'boolean' ||
(typeof hotspot.visible === 'object' && hotspot.visible !== null));
}
/**
* Guard para validar Hotspot con color
*/
export function isHotspotWithColor(value: unknown): value is Hotspot & { color: string } {
if (typeof value !== 'object' || value === null) return false;
const hotspot = value as Hotspot;
return 'color' in hotspot && typeof hotspot.color === 'string';
}
/**
* Guard para validar Hotspot con zIndex
*/
export function isHotspotWithZIndex(value: unknown): value is Hotspot & { zIndex: number } {
if (typeof value !== 'object' || value === null) return false;
const hotspot = value as Hotspot;
return 'zIndex' in hotspot && typeof hotspot.zIndex === 'number';
}
// ─────────────────────────────────────────────────────────────────────────────
// UI STATE GUARDS
// ─────────────────────────────────────────────────────────────────────────────
/**
* Guard para validar selección de vista en el editor visual
*/
export interface VisualSelection {
kind: 'general' | 'view' | 'hotspot';
viewId?: string;
hotspotId?: string;
}
export function isVisualSelection(value: unknown): value is VisualSelection {
if (typeof value !== 'object' || value === null) return false;
const sel = value as Record<string, unknown>;
return typeof sel.kind === 'string' &&
['general', 'view', 'hotspot'].includes(sel.kind);
}
/**
* Guard para validar selección de vista específica
*/
export function isViewSelection(value: unknown): value is VisualSelection & { kind: 'view'; viewId: string } {
if (!isVisualSelection(value)) return false;
return value.kind === 'view' && typeof value.viewId === 'string';
}
/**
* Guard para validar selección de hotspot específico
*/
export function isHotspotSelection(value: unknown): value is VisualSelection & { kind: 'hotspot'; viewId: string; hotspotId: string } {
if (!isVisualSelection(value)) return false;
return value.kind === 'hotspot' &&
typeof value.viewId === 'string' &&
typeof value.hotspotId === 'string';
}
/**
* Guard para validar selección general (configuración del visual)
*/
export function isGeneralSelection(value: unknown): value is VisualSelection & { kind: 'general' } {
if (!isVisualSelection(value)) return false;
return value.kind === 'general';
}
// ─────────────────────────────────────────────────────────────────────────────
// FORM STATE GUARDS
// ─────────────────────────────────────────────────────────────────────────────
/**
* Guard para validar estado de formulario con errores
*/
export interface FormErrors {
[key: string]: string | undefined;
}
export function isFormErrors(value: unknown): value is FormErrors {
if (typeof value !== 'object' || value === null) return false;
const errors = value as Record<string, unknown>;
return Object.values(errors).every(v => v === undefined || typeof v === 'string');
}
/**
* Guard para validar estado de formulario válido
*/
export function isFormValid(errors: FormErrors): boolean {
return Object.values(errors).every(v => !v);
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save

Powered by TurnKey Linux.