refactor(vcen-edit): elimina MetadataPanel, usa PanelMeta directamente

MetadataPanel era solo un wrapper que determinaba contexto,
PanelMeta es más genérico y reutilizable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
glm
dev 7 months ago
parent 030bf3a765
commit 0e85c236d5

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

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

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

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -3,9 +3,9 @@ import type { Metadata, MetadataField, MetadataFieldType } from '@/mtas/lib'
import type { GlobState } from '@/glob/adapters/svelte'
import { validateMetadata, field as f } from '@/mtas/lib'
import { makeLingRecord } from '@/ling/lib'
import AddFieldMenu from '@/mtas/svelte/components/MetaAddFieldMenu.svelte'
import FieldEditor from '@/mtas/svelte/components/MetaFieldEditor.svelte'
import TypeBadge from '@/mtas/svelte/components/MetaTypeBadge.svelte'
import AddFieldMenu from './metas/MetaAddFieldMenu.svelte'
import FieldEditor from './metas/MetaFieldEditor.svelte'
import TypeBadge from './metas/MetaTypeBadge.svelte'
import SidebarTree from './SidebarTree.svelte'
import EditorTreeNodes from './EditorTreeNodes.svelte'
@ -253,7 +253,7 @@ const selectedKey = $derived(selectedPath.length > 0 ? selectedPath[selectedPa
{#if keyOrder.length === 0}
<div class="flex flex-col items-center justify-center gap-2 py-12 px-4 text-center">
<svg class="h-8 w-8 text-gray-700" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<svg class="h-8 w-8 text-gray-700 fill-current" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="4"/>
<path d="M12 8v8M8 12h8"/>
</svg>
@ -326,7 +326,7 @@ const selectedKey = $derived(selectedPath.length > 0 ? selectedPath[selectedPa
{#if !validation.valid}
<div class="shrink-0 border-t border-gray-800 px-3 py-2">
<div class="flex items-start gap-2 rounded border border-red-900 bg-red-950 px-3 py-2.5 text-red-400">
<svg class="h-4 w-4 shrink-0 mt-0.5" viewBox="0 0 16 16" fill="currentColor">
<svg class="h-4 w-4 shrink-0 mt-0.5 fill-current" viewBox="0 0 16 16">
<path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm0 3.5a.75.75 0 0 1 .75.75v3a.75.75 0 0 1-1.5 0v-3A.75.75 0 0 1 8 4.5zm0 7a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
</svg>
<div class="flex flex-col gap-0.5">

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

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

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

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

@ -82,7 +82,7 @@ function cancelNew() {
<button onclick={saveNew} class="ve-action-btn primary">{glob.t('vcen.editor.common.save')}</button>
{:else}
<button onclick={() => editor.requestAction(() => editor.addSec(objId))} class="ve-action-btn">
+ {glob.t('vcen.editor.sidebar.newSec')}
{glob.t('vcen.editor.common.add')} {glob.t('vcen.editor.sidebar.newSec')}
</button>
<button onclick={() => editor.cloneObj(objId)} class="ve-action-btn">{glob.t('vcen.editor.common.clone')}</button>
<ButtonDelete size="md" confirm
@ -126,15 +126,15 @@ function cancelNew() {
{isNew}
fullId={obj.id}
bind:slug={editId}
idPlaceholder="habitacion, cocina…"
idPlaceholder="Ej: habitacion, cocina…"
idError={idError}
onIdInput={() => idError = ''}
onIdKeydown={e => { if (e.key === 'Enter') saveNew() }}
name={obj.label}
locales={cat.langs}
defaultLocale={cat.lang}
namePlaceholder="Habitación, Cocina..."
onNameUpdate={label => editor.setObjLabel(objId, label as LingRecord)}
namePlaceholder="Ej: Hábitación, Cocina..."
onNameUpdate={label => editor.setObjLabel(objId, label)}
/>
</section>

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

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

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

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

@ -1,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,7 +1,6 @@
<script lang="ts">
import type { ColorField } from '../../../lib';
import type { MetadataField } from '../../../lib';
import type { GlobState } from '@/glob/adapters/svelte';
import type {ColorField, MetadataField} from "@/mtas/lib";
let { field, glob, onFieldChange }: {
field: ColorField;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -1,7 +1,6 @@
<script lang="ts">
import type { PdfField } from '../../../lib';
import type { MetadataField } from '../../../lib';
import type { GlobState } from '@/glob/adapters/svelte';
import type {MetadataField, PdfField} from "@/mtas/lib";
let { field, glob, onFieldChange }: {
field: PdfField;
@ -24,19 +23,37 @@
<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://…" />
<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} />
<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} />
<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} />
<input
type="text"
value={field.value.title ?? ''}
oninput={(e) => handle(e, 'title')}
class={cls}
/>
</div>
</div>

@ -1,7 +1,6 @@
<script lang="ts">
import type { PhoneField } from '../../../lib';
import type { MetadataField } from '../../../lib';
import type { GlobState } from '@/glob/adapters/svelte';
import type {MetadataField, PhoneField} from "@/mtas/lib";
let { field, glob, onFieldChange }: {
field: PhoneField;
@ -23,10 +22,21 @@
<div class="flex gap-3">
<div class="flex flex-col gap-1 w-28">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.editor.phoneCode')}</label>
<input type="text" value={field.value.countryCode ?? ''} oninput={(e) => handle(e, 'code')} class="{cls} font-mono" placeholder="+34" />
<input
type="text"
value={field.value.countryCode ?? ''}
oninput={(e) => handle(e, 'code')}
class="{cls} font-mono"
placeholder="+34"
/>
</div>
<div class="flex flex-col gap-1 flex-1">
<label class="text-xs font-medium text-gray-500">{glob.t('vcen.editor.mtas.editor.phoneNumber')}</label>
<input type="tel" value={field.value.number} oninput={(e) => handle(e, 'number')} class={cls} placeholder="600 000 000" />
<input
type="tel"
value={field.value.number}
oninput={(e) => handle(e, 'number')}
class={cls} placeholder="600 000 000"
/>
</div>
</div>

@ -1,7 +1,6 @@
<script lang="ts">
import type { SelectField } from '../../../lib';
import type { MetadataField } from '../../../lib';
import type { GlobState } from '@/glob/adapters/svelte';
import type {MetadataField, SelectField} from "@/mtas/lib";
let { field, glob, onFieldChange }: {
field: SelectField;
@ -34,9 +33,14 @@
<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>
<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 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>
@ -45,11 +49,28 @@
{: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>
<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 fill-current" viewBox="0 0 16 16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<path d="M3 4h10M6 4V2.5h4V4M5 4l.5 9h5l.5-9"/>
</svg>
</button>
</div>
{/each}

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

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

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

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

@ -7,3 +7,27 @@ import type { LingRecord } from '@/ling/lib'
export function labelOf(label: LingRecord, lang: string = 'es', fallback = ''): string {
return label[lang as keyof LingRecord] ?? label.es ?? fallback
}
/**
* Versión mejorada de getLabelStr que maneja null/undefined, string y LingRecord.
* Consolidación de las 5 implementaciones duplicadas en:
* - comps/PanelHotspot.svelte
* - comps/PanelVisual.svelte
* - comps/PanelView.svelte
* - comps/CatalogSidebar.svelte
* - comps/VisualPreview.svelte
*
* @param label - String o LingRecord a procesar
* @param lang - Idioma solicitado (default: 'es')
* @param fallback - String de fallback si no hay valor (default: '')
* @returns String resuelto del label
*/
export function getLabelStr(
label: LingRecord | string | null | undefined,
lang: string = 'es',
fallback = ''
): string {
if (!label) return fallback;
if (typeof label === 'string') return label;
return label[lang as keyof LingRecord] ?? label.es ?? Object.values(label)[0] ?? fallback;
}

@ -8,6 +8,19 @@ import { EDITOR_TRANSLATIONS } from './editor-translations.ts'
*/
const _ling = createLing(EDITOR_TRANSLATIONS, 'es')
/**
* Obtiene nivel de logging desde variable de entorno
* Fallback: DEBUG en desarrollo, WARN en producción
*/
function getLogLevelFromEnv(): LogLevel {
const meta = import.meta as any;
const envLevel = meta.env?.VITE_LOG_LEVEL?.toUpperCase();
if (envLevel && envLevel in LogLevel) {
return LogLevel[envLevel as keyof typeof LogLevel];
}
return meta.env?.DEV ? LogLevel.DEBUG : LogLevel.WARN;
}
export const logr = createLogr(_ling, {
level: (import.meta as any).env?.DEV ? LogLevel.DEBUG : LogLevel.WARN,
level: getLogLevelFromEnv(),
})

@ -3,7 +3,9 @@
* Permite mostrar el estado calculado de un objeto/atributo en el editor.
*/
import type { Cat, RuleSet, AttState, Obj, Sec } from '@/vcen/lib'
import type { JsonLogicValue } from '@/jslg/lib'
import { JsonLogicEngine } from '@/jslg/lib'
import { logr } from './logr.ts'
const _engine = new JsonLogicEngine()
@ -93,8 +95,17 @@ export function evalCondition(condition: unknown, ctx: Record<string, any>): boo
if (!condition || condition === true) return true
try {
const resolved = resolveTokens(condition)
return Boolean(_engine.evaluate(resolved as any, ctx))
} catch { return null }
return Boolean(_engine.evaluate(resolved as JsonLogicValue, ctx))
} catch (error) {
logr.warn('rule-eval',
{ es: 'Error evaluando condición de regla' },
{
error: error instanceof Error ? error.message : String(error),
condition: String(condition).substring(0, 100)
}
);
return null;
}
}
/** Evalúa un RuleSet y retorna el AttState que gana (primera regla activa), o null. */
@ -115,8 +126,17 @@ export function evalRuleSetState(
const cond = rule.condition === true ? true : resolveTokens(rule.condition)
fires = cond === true
? true
: Boolean(_engine.evaluate(cond as any, ctx))
} catch { continue }
: Boolean(_engine.evaluate(cond as JsonLogicValue, ctx))
} catch (error) {
logr.warn('rule-eval',
{ es: 'Error evaluando regla' },
{
error: error instanceof Error ? error.message : String(error),
ruleId: rule.id
}
);
continue;
}
const action = rule.actions.find(
a => a.type === 'attribute' && (!a.target || a.target.length === 0)

@ -4,7 +4,9 @@
import PocketBase from 'pocketbase';
import type { Cat } from '@/vcen/lib';
const pb = new PocketBase('http://192.168.18.131:8090');
// URL de PocketBase desde variables de entorno con fallback
const POCKETBASE_URL = (import.meta.env as any)?.VITE_POCKETBASE_URL || 'http://192.168.18.131:8090';
const pb = new PocketBase(POCKETBASE_URL);
interface CatalogRecord {
id: string;

Loading…
Cancel
Save

Powered by TurnKey Linux.