Compare commits
5 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
f4c8fb04ef | 7 months ago |
|
|
02fdf3b4c9 | 7 months ago |
|
|
4212e8168c | 7 months ago |
|
|
0e85c236d5 | 7 months ago |
|
|
030bf3a765 | 7 months ago |
@ -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 |
|
After Width: | Height: | Size: 102 KiB |
|
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)
|
||||
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
})
|
||||
@ -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>
|
||||
@ -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}
|
||||
@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
@ -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,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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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,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>
|
||||
@ -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);
|
||||
}
|
||||
@ -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
|
||||
@ -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…
Reference in new issue