master
parent
828abd3000
commit
1db6022233
@ -1,13 +0,0 @@
|
||||
export * from './comn_types';
|
||||
export * from './curr_types';
|
||||
export * from './dati_types';
|
||||
export * from './glob_types';
|
||||
export * from './numr_types';
|
||||
export * from './unit_types';
|
||||
|
||||
export * from './comn_engine';
|
||||
export * from './curr_engine';
|
||||
export * from './dati_engine';
|
||||
export * from './glob_engine';
|
||||
export * from './numr_engine';
|
||||
export * from './unit_engine';
|
||||
@ -1,4 +1,4 @@
|
||||
import type { LingInstance, LingNode, LingRecord, LingPluralFn, SupportedLocale } from '@/ling/types';
|
||||
import type { LingInstance, LingNode, LingRecord, LingPluralFn, SupportedLocale } from '@/ling/lib/types.ts';
|
||||
|
||||
// ==============================
|
||||
// LOCALE
|
||||
@ -1,8 +1,8 @@
|
||||
import type { CurrInstance, CurrDefinition, CurrDisplay, CurrConfig, ConversionValue } from './curr_types';
|
||||
import type { LingInstance, LingNode } from '@/ling/types';
|
||||
import type { LocaleResolver, GlobLocale } from './comn_types';
|
||||
import { resolveLocale } from './comn_engine';
|
||||
import { defaultISOLocale } from '@/ling/consts';
|
||||
import type { CurrInstance, CurrDefinition, CurrDisplay, CurrConfig, ConversionValue } from './curr_types.ts';
|
||||
import type { LingInstance, LingNode } from '@/ling/lib/types.ts';
|
||||
import type { LocaleResolver, GlobLocale } from './comn_types.ts';
|
||||
import { resolveLocale } from './comn_engine.ts';
|
||||
import { defaultISOLocale } from '@/ling/lib/consts.ts';
|
||||
|
||||
|
||||
/**
|
||||
@ -1,7 +1,7 @@
|
||||
import type { DatiInstance, DatiOptions, DatiStyleType, DatiTimeFormat, DatiDateOrder } from './dati_types';
|
||||
import type { LocaleResolver, GlobLocale } from './comn_types';
|
||||
import { resolveLocale } from './comn_engine';
|
||||
import { defaultISOLocale } from '@/ling/consts';
|
||||
import type { DatiInstance, DatiOptions, DatiStyleType, DatiTimeFormat, DatiDateOrder } from './dati_types.ts';
|
||||
import type { LocaleResolver, GlobLocale } from './comn_types.ts';
|
||||
import { resolveLocale } from './comn_engine.ts';
|
||||
import { defaultISOLocale } from '@/ling/lib/consts.ts';
|
||||
|
||||
|
||||
// ==============================
|
||||
@ -1,10 +1,10 @@
|
||||
import type { GlobConfig, GlobInstance } from './glob_types';
|
||||
import type { LingNode } from '@/ling/types';
|
||||
import { createNumr } from './numr_engine';
|
||||
import { createCurr } from './curr_engine';
|
||||
import { createDati } from './dati_engine';
|
||||
import { createUnit } from './unit_engine';
|
||||
import type { GlobLocale } from './comn_types';
|
||||
import type { GlobConfig, GlobInstance } from './glob_types.ts';
|
||||
import type { LingNode } from '@/ling/lib/types.ts';
|
||||
import { createNumr } from './numr_engine.ts';
|
||||
import { createCurr } from './curr_engine.ts';
|
||||
import { createDati } from './dati_engine.ts';
|
||||
import { createUnit } from './unit_engine.ts';
|
||||
import type { GlobLocale } from './comn_types.ts';
|
||||
|
||||
/**
|
||||
* Crea una instancia integrada de globalización.
|
||||
@ -0,0 +1,13 @@
|
||||
export * from './comn_types.ts';
|
||||
export * from './curr_types.ts';
|
||||
export * from './dati_types.ts';
|
||||
export * from './glob_types.ts';
|
||||
export * from './numr_types.ts';
|
||||
export * from './unit_types.ts';
|
||||
|
||||
export * from './comn_engine.ts';
|
||||
export * from './curr_engine.ts';
|
||||
export * from './dati_engine.ts';
|
||||
export * from './glob_engine.ts';
|
||||
export * from './numr_engine.ts';
|
||||
export * from './unit_engine.ts';
|
||||
@ -1,7 +1,7 @@
|
||||
import { defaultISOLocale } from '@/ling/consts';
|
||||
import { resolveLocale } from './comn_engine';
|
||||
import type { LocaleResolver } from './comn_types';
|
||||
import type { NumrInstance } from './numr_types';
|
||||
import { defaultISOLocale } from '@/ling/lib/consts.ts';
|
||||
import { resolveLocale } from './comn_engine.ts';
|
||||
import type { LocaleResolver } from './comn_types.ts';
|
||||
import type { NumrInstance } from './numr_types.ts';
|
||||
|
||||
|
||||
// ==============================
|
||||
@ -1,4 +1,4 @@
|
||||
import type { UnitCategoryKey, UnitCategoryData } from './unit_types';
|
||||
import type { UnitCategoryKey, UnitCategoryData } from './unit_types.ts';
|
||||
|
||||
/**
|
||||
* Datos numéricos de las unidades — factores de conversión y sistemas.
|
||||
@ -1,11 +1,11 @@
|
||||
|
||||
import type { LocaleResolver, GlobLocale } from "./comn_types";
|
||||
import type { UnitCategoryData, UnitCategoryInstance, UnitCategoryKey, UnitDisplay, UnitInstance, UnitSystem } from "./unit_types";
|
||||
import { resolveLocale } from "./comn_engine";
|
||||
import { defaultISOLocale } from "@/ling/consts";
|
||||
import { type LingNode, type LingInstance, ling } from "../ling";
|
||||
import { unitData } from "./unit_data";
|
||||
import { unitsLingNode } from "./unit_langs";
|
||||
import type { LocaleResolver, GlobLocale } from "./comn_types.ts";
|
||||
import type { UnitCategoryData, UnitCategoryInstance, UnitCategoryKey, UnitDisplay, UnitInstance, UnitSystem } from "./unit_types.ts";
|
||||
import { resolveLocale } from "./comn_engine.ts";
|
||||
import { defaultISOLocale } from "@/ling/lib/consts.ts";
|
||||
import { type LingNode, type LingInstance, ling } from "@/ling/lib";
|
||||
import { unitData } from "./unit_data.ts";
|
||||
import { unitsLingNode } from "./unit_langs.ts";
|
||||
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { p } from '@/ling/engine';
|
||||
import type { LingNode } from '@/ling/types';
|
||||
import { p } from '@/ling/lib/engine.ts';
|
||||
import type { LingNode } from '@/ling/lib/types.ts';
|
||||
|
||||
/**
|
||||
* Traducciones de unidades de medida.
|
||||
@ -1,7 +1,7 @@
|
||||
// ==============================
|
||||
// UNIT TYPES
|
||||
// ==============================
|
||||
import type { LocaleResolver } from "./comn_types";
|
||||
import type { LocaleResolver } from "./comn_types.ts";
|
||||
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type {LingLogger} from "@/ling/types.ts";
|
||||
import type {LingLogger} from "@/ling/lib/types.ts";
|
||||
|
||||
|
||||
export const idPrefix = '#?';
|
||||
@ -1,8 +1,8 @@
|
||||
export { translations } from './translations';
|
||||
export { translations } from './translations.ts';
|
||||
|
||||
|
||||
export * from './types.ts';
|
||||
export * from './engine.ts';
|
||||
export * from './instance.ts';
|
||||
export * from './translations';
|
||||
export * from './translations.ts';
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
|
||||
import { createLing } from './engine.ts';
|
||||
import type { TranslationSchema } from './translations';
|
||||
import {translations} from './translations';
|
||||
import type { TranslationSchema } from './translations.ts';
|
||||
import {translations} from './translations.ts';
|
||||
|
||||
// ─── CASO EAGER (esquema conocido en build time) ────────────────────────────────
|
||||
export const ling = createLing<TranslationSchema>(translations, 'es');
|
||||
@ -0,0 +1,185 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { LingRecord, SupportedLocale } from "@/ling/lib";
|
||||
|
||||
type AsType = 'input' | 'textarea';
|
||||
|
||||
let {
|
||||
value,
|
||||
locales = ['es'],
|
||||
defaultLocale,
|
||||
label = '',
|
||||
placeholder = '',
|
||||
required = false,
|
||||
invalid = false,
|
||||
rows = 3,
|
||||
as: asType = 'textarea' as AsType,
|
||||
localeTab,
|
||||
onUpdate,
|
||||
}: {
|
||||
value: LingRecord;
|
||||
locales?: string[];
|
||||
defaultLocale: string;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
invalid?: boolean;
|
||||
rows?: number;
|
||||
as?: AsType;
|
||||
localeTab?: Snippet<[{ locale: string; active: boolean; hasContent: boolean; activate: () => void }]>;
|
||||
onUpdate: (value: LingRecord) => void;
|
||||
} = $props();
|
||||
|
||||
const get = (locale: string): string => value[locale as SupportedLocale] ?? '';
|
||||
|
||||
let currentLocale = $state(defaultLocale);
|
||||
let inputText = $state(get(defaultLocale));
|
||||
let previousLocale = $state(defaultLocale);
|
||||
let focused = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (currentLocale !== previousLocale) {
|
||||
previousLocale = currentLocale;
|
||||
inputText = get(currentLocale);
|
||||
}
|
||||
});
|
||||
|
||||
let isDefault = $derived(currentLocale === defaultLocale);
|
||||
let charCount = $derived(inputText.length);
|
||||
let filledCount = $derived(locales.filter(l => get(l).trim().length > 0).length);
|
||||
let dynamicPH = $derived(placeholder || `Escribe en ${currentLocale.toUpperCase()}…`);
|
||||
|
||||
function activateLocale(locale: string) { currentLocale = locale; }
|
||||
|
||||
function handleInput() {
|
||||
const key = currentLocale as SupportedLocale;
|
||||
const defKey = defaultLocale as SupportedLocale;
|
||||
let next: LingRecord;
|
||||
if (isDefault) {
|
||||
next = { ...value, [defKey]: inputText };
|
||||
} else {
|
||||
if (inputText.trim() === '') {
|
||||
const { [key]: _, ...rest } = value;
|
||||
next = rest as LingRecord;
|
||||
} else {
|
||||
next = { ...value, [key]: inputText };
|
||||
}
|
||||
}
|
||||
onUpdate(next);
|
||||
}
|
||||
|
||||
function hasContent(locale: string): boolean {
|
||||
if (locale === currentLocale) return !!(inputText.trim());
|
||||
return !!(get(locale).trim());
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
|
||||
{#if label}
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="flex items-center gap-1.5">
|
||||
<span class={['text-[13px] font-semibold', invalid ? 'text-red-500' : 'text-zinc-700 dark:text-zinc-200'].join(' ')}>
|
||||
{label}
|
||||
</span>
|
||||
{#if required}<span class="text-red-400">*</span>{/if}
|
||||
</label>
|
||||
<span class="text-[12px] text-zinc-400 dark:text-zinc-500">
|
||||
{filledCount}/{locales.length} idiomas
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class={[
|
||||
'overflow-hidden rounded-xl border-2 transition-all duration-200 bg-white dark:bg-zinc-900',
|
||||
focused && !invalid ? 'border-zinc-900 dark:border-zinc-100'
|
||||
: invalid ? 'border-red-400'
|
||||
: 'border-zinc-200 dark:border-zinc-700',
|
||||
].join(' ')}>
|
||||
|
||||
<!-- Locale tabs -->
|
||||
<div class="flex items-center border-b border-zinc-100 dark:border-zinc-800">
|
||||
<div role="tablist" class="flex flex-1">
|
||||
{#each locales as locale (locale)}
|
||||
{#if localeTab}
|
||||
{@render localeTab({ locale, active: currentLocale === locale, hasContent: hasContent(locale), activate: () => activateLocale(locale) })}
|
||||
{:else}
|
||||
{@const active = currentLocale === locale}
|
||||
{@const filled = hasContent(locale)}
|
||||
{@const isDef = locale === defaultLocale}
|
||||
<button
|
||||
type="button" role="tab" aria-selected={active}
|
||||
onclick={() => activateLocale(locale)}
|
||||
class={[
|
||||
'relative flex items-center gap-1.5 px-4 py-2.5 text-[13px] font-medium transition-colors duration-150 focus:outline-none',
|
||||
active
|
||||
? invalid ? 'text-red-500' : 'text-zinc-900 dark:text-zinc-100'
|
||||
: filled ? 'text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200'
|
||||
: 'text-zinc-300 hover:text-zinc-500 dark:text-zinc-600 dark:hover:text-zinc-400',
|
||||
].join(' ')}
|
||||
>
|
||||
{locale.toUpperCase()}
|
||||
{#if isDef && !active}
|
||||
<span class={['h-1.5 w-1.5 rounded-full', filled ? 'bg-emerald-400' : 'bg-zinc-200 dark:bg-zinc-700'].join(' ')}></span>
|
||||
{:else if filled && !active && !isDef}
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-violet-400"></span>
|
||||
{/if}
|
||||
{#if active}
|
||||
<span class={['absolute bottom-0 left-0 right-0 h-0.5', invalid ? 'bg-red-400' : 'bg-zinc-900 dark:bg-zinc-100'].join(' ')}></span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{#if charCount > 0}
|
||||
<span class="px-4 text-[12px] tabular-nums text-zinc-300 dark:text-zinc-600">{charCount}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if asType === 'textarea'}
|
||||
<textarea
|
||||
aria-label={`Texto en ${currentLocale}`}
|
||||
aria-required={isDefault && required}
|
||||
aria-invalid={invalid}
|
||||
required={isDefault && required}
|
||||
{rows}
|
||||
placeholder={dynamicPH}
|
||||
bind:value={inputText}
|
||||
oninput={handleInput}
|
||||
onfocus={() => focused = true}
|
||||
onblur={() => focused = false}
|
||||
style="height: calc({rows} * 1.65rem + 1.75rem)"
|
||||
class="w-full resize-none bg-transparent px-4 py-3 text-[14px] leading-relaxed
|
||||
text-zinc-800 placeholder:text-zinc-300 focus:outline-none
|
||||
dark:text-zinc-200 dark:placeholder:text-zinc-600"
|
||||
></textarea>
|
||||
{:else}
|
||||
<input
|
||||
type="text"
|
||||
aria-label={`Texto en ${currentLocale}`}
|
||||
aria-required={isDefault && required}
|
||||
aria-invalid={invalid}
|
||||
required={isDefault && required}
|
||||
placeholder={dynamicPH}
|
||||
bind:value={inputText}
|
||||
oninput={handleInput}
|
||||
onfocus={() => focused = true}
|
||||
onblur={() => focused = false}
|
||||
class="h-11 w-full bg-transparent px-4 text-[14px] text-zinc-800
|
||||
placeholder:text-zinc-300 focus:outline-none
|
||||
dark:text-zinc-200 dark:placeholder:text-zinc-600"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if invalid}
|
||||
<div class="flex items-center gap-2 border-t border-red-100 bg-red-50 px-4 py-2.5 dark:border-red-900/30 dark:bg-red-900/10">
|
||||
<svg class="h-4 w-4 shrink-0 text-red-400" viewBox="0 0 16 16" fill="currentColor">
|
||||
<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-[13px] text-red-500">
|
||||
El campo <strong>{defaultLocale.toUpperCase()}</strong> es obligatorio
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,139 @@
|
||||
import type {LingString} from "@/ling/lib";
|
||||
import type {
|
||||
Metadata, MetadataField,
|
||||
CurrencyValue, MediaRef,
|
||||
StringField, NumberField, BooleanField, LingStringField,
|
||||
DateField, DateTimeField, UrlField, CurrencyField,
|
||||
ImageField, VideoField, AudioField, PdfField, FileField, GroupField,
|
||||
} from './types.ts';
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Builders de campo (devuelven MetadataField)
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export const string = (value: string): StringField => ({ type: 'string', value });
|
||||
export const number = (value: number): NumberField => ({ type: 'number', value });
|
||||
export const boolean = (value: boolean): BooleanField => ({ type: 'boolean', value });
|
||||
export const lingString = (value: LingString): LingStringField => ({ type: 'lingstring', value });
|
||||
export const date = (value: string): DateField => ({ type: 'date', value });
|
||||
export const datetime = (value: string): DateTimeField => ({ type: 'datetime', value });
|
||||
export const url = (value: string): UrlField => ({ type: 'url', value });
|
||||
export const currency = (value: CurrencyValue): CurrencyField => ({ type: 'currency', value });
|
||||
export const image = (value: MediaRef): ImageField => ({ type: 'image', value });
|
||||
export const video = (value: MediaRef): VideoField => ({ type: 'video', value });
|
||||
export const audio = (value: MediaRef): AudioField => ({ type: 'audio', value });
|
||||
export const pdf = (value: MediaRef): PdfField => ({ type: 'pdf', value });
|
||||
export const file = (value: MediaRef): FileField => ({ type: 'file', value });
|
||||
export const group = (value: Metadata, order?: string[]): GroupField => ({ type: 'group', value, ...(order !== undefined && { order }) });
|
||||
|
||||
/** Namespace agrupado para importar como `field.string(...)`, `field.group(...)`, etc. */
|
||||
export const field = {
|
||||
string, number, boolean, lingstring: lingString, date, datetime,
|
||||
url, currency, image, video, audio, pdf, file, group,
|
||||
} as const;
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Operaciones sobre Metadata (inmutables)
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Obtiene el MetadataField asociado a una clave (solo nivel raíz).
|
||||
* Devuelve undefined si la clave no existe o el valor no es un MetadataField.
|
||||
*/
|
||||
export function get(metadata: Metadata, key: string): MetadataField | undefined {
|
||||
const value = metadata[key];
|
||||
if (typeof value !== 'object' || value === null) return undefined;
|
||||
const v = value as Record<string, unknown>;
|
||||
if (typeof v['type'] !== 'string') return undefined;
|
||||
return value as MetadataField;
|
||||
}
|
||||
|
||||
/**
|
||||
* Búsqueda recursiva por clave en toda la jerarquía de grupos.
|
||||
* Devuelve el primer MetadataField cuya clave coincida.
|
||||
*/
|
||||
export function find(metadata: Metadata, key: string): MetadataField | undefined {
|
||||
for (const [k, value] of Object.entries(metadata)) {
|
||||
if (k === key) return value as MetadataField;
|
||||
// Si es un grupo, buscar dentro recursivamente
|
||||
if (
|
||||
typeof value === 'object' && value !== null &&
|
||||
(value as Record<string, unknown>)['type'] === 'group'
|
||||
) {
|
||||
const groupValue = (value as GroupField).value;
|
||||
const found = find(groupValue, key);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Añade una clave al Metadata. Lanza error si la clave ya existe.
|
||||
* Devuelve un nuevo Metadata (inmutable).
|
||||
*/
|
||||
export function add(metadata: Metadata, key: string, value: MetadataField): Metadata {
|
||||
if (key in metadata) throw new Error(`Key "${key}" already exists in Metadata`);
|
||||
return { ...metadata, [key]: value };
|
||||
}
|
||||
|
||||
/**
|
||||
* Establece (crea o reemplaza) una clave en el Metadata.
|
||||
* Devuelve un nuevo Metadata (inmutable).
|
||||
*/
|
||||
export function set(metadata: Metadata, key: string, value: MetadataField): Metadata {
|
||||
return { ...metadata, [key]: value };
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina una clave del Metadata (no-op si no existe).
|
||||
* Devuelve un nuevo Metadata (inmutable).
|
||||
*/
|
||||
export function remove(metadata: Metadata, key: string): Metadata {
|
||||
const { [key]: _, ...rest } = metadata;
|
||||
return rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convierte un Metadata a un objeto plano con los valores resueltos.
|
||||
* Los grupos se convierten en objetos anidados.
|
||||
*
|
||||
* @example
|
||||
* toPlain({ brand: { type: 'string', value: 'Toyota' } })
|
||||
* // → { brand: 'Toyota' }
|
||||
*/
|
||||
export function toPlain(metadata: Metadata): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(metadata)) {
|
||||
if (typeof value !== 'object' || value === null) { result[key] = value; continue; }
|
||||
const field = value as MetadataField;
|
||||
result[key] = field.type === 'group' ? toPlain(field.value) : field.value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Resolución de orden en GroupField
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Devuelve las claves de un GroupField en el orden correcto.
|
||||
*
|
||||
* - Si `order` está definido: primero las claves de `order` (ignorando las que
|
||||
* no existan en `value`), luego las claves restantes en orden natural.
|
||||
* - Si `order` es undefined: orden natural del Record.
|
||||
*
|
||||
* @example
|
||||
* resolveOrder({ type: 'group', value: { hp: ..., fuel: ... }, order: ['fuel', 'hp'] })
|
||||
* // → ['fuel', 'hp']
|
||||
*/
|
||||
export function resolveOrder(groupField: GroupField): string[] {
|
||||
const allKeys = Object.keys(groupField.value);
|
||||
if (!groupField.order || groupField.order.length === 0) return allKeys;
|
||||
|
||||
const ordered = groupField.order.filter(k => k in groupField.value);
|
||||
const rest = allKeys.filter(k => !ordered.includes(k));
|
||||
return [...ordered, ...rest];
|
||||
}
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
// ─────────────────────────────────────────────
|
||||
// Type guards de primitivos
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
import type {CurrencyValue, MediaRef, MetadataField, MetadataFieldType} from "@/mtas/lib/types.ts";
|
||||
|
||||
export function isCurrencyValue(v: unknown): v is CurrencyValue {
|
||||
if (typeof v !== 'object' || v === null) return false;
|
||||
const c = v as Record<string, unknown>;
|
||||
return typeof c['amount'] === 'number' && typeof c['currency'] === 'string' && c['currency'].length > 0;
|
||||
}
|
||||
|
||||
export function isMediaRef(v: unknown): v is MediaRef {
|
||||
if (typeof v !== 'object' || v === null) return false;
|
||||
const m = v as Record<string, unknown>;
|
||||
return typeof m['url'] === 'string' && m['url'].length > 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function isValidUrl(value: string): boolean {
|
||||
try { new URL(value); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
const KNOWN_TYPES: MetadataFieldType[] = [
|
||||
'string','number','boolean','lingstring','date','datetime',
|
||||
'url','currency','image','video','audio','pdf','file','group',
|
||||
];
|
||||
|
||||
|
||||
|
||||
export function isMetadataField(v: unknown): v is MetadataField {
|
||||
if (typeof v !== 'object' || v === null) return false;
|
||||
const f = v as Record<string, unknown>;
|
||||
return KNOWN_TYPES.includes(f['type'] as MetadataFieldType);
|
||||
}
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
|
||||
|
||||
export * from './builders.ts'
|
||||
export * from './guards.ts'
|
||||
export * from './validation.ts'
|
||||
export * from './builders.ts'
|
||||
export * from './serialization.ts'
|
||||
export * from './types.ts'
|
||||
export * from './version.ts'
|
||||
@ -0,0 +1,99 @@
|
||||
import type {DeserializeResult, Metadata, SerializedMetadata} from './types';
|
||||
import { validateMetadata } from './validation.ts';
|
||||
import {CURRENT_VERSION} from "@/mtas/lib/version.ts";
|
||||
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Migraciones por versión
|
||||
// Añadir aquí cuando __v cambie en el futuro.
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
type Migrator = (data: unknown) => Metadata;
|
||||
|
||||
const migrations: Record<number, Migrator> = {
|
||||
1: (data) => data as Metadata, // v1 → v1: sin transformación
|
||||
};
|
||||
|
||||
function migrate(version: number, data: unknown): Metadata | undefined {
|
||||
const migrator = migrations[version];
|
||||
if (!migrator) return undefined;
|
||||
return migrator(data);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// serialization
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Serializa un Metadata a string JSON versionado.
|
||||
*
|
||||
* @example
|
||||
* const json = serialization({ brand: { type: 'string', value: 'Toyota' } });
|
||||
* // '{"__v":1,"data":{"brand":{"type":"string","value":"Toyota"}}}'
|
||||
*/
|
||||
export function serialization(metadata: Metadata): string {
|
||||
const payload: SerializedMetadata = {
|
||||
__v: CURRENT_VERSION,
|
||||
data: metadata,
|
||||
};
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// deserialize
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Deserializa un string JSON versionado a Metadata.
|
||||
* Valida el resultado antes de devolverlo.
|
||||
* Devuelve DeserializeError si el JSON es inválido o la validación falla.
|
||||
*
|
||||
* @example
|
||||
* const result = deserialize('{"__v":1,"data":{...}}');
|
||||
* if (result.ok) {
|
||||
* const metadata = result.metadata;
|
||||
* } else {
|
||||
* console.error(result.error, result.validation?.errors);
|
||||
* }
|
||||
*/
|
||||
export function deserialize(json: string): DeserializeResult {
|
||||
// 1. Parse JSON
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(json);
|
||||
} catch {
|
||||
return { ok: false, error: 'Invalid JSON string' };
|
||||
}
|
||||
|
||||
// 2. Comprobar estructura mínima
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
return { ok: false, error: 'Serialized payload must be a plain object' };
|
||||
}
|
||||
|
||||
const payload = parsed as Record<string, unknown>;
|
||||
|
||||
if (typeof payload['__v'] !== 'number') {
|
||||
return { ok: false, error: 'Missing or invalid "__v" version field' };
|
||||
}
|
||||
|
||||
if (!('data' in payload)) {
|
||||
return { ok: false, error: 'Missing "data" field' };
|
||||
}
|
||||
|
||||
// 3. Migrar según versión
|
||||
const version = payload['__v'] as number;
|
||||
const migrated = migrate(version, payload['data']);
|
||||
|
||||
if (!migrated) {
|
||||
return { ok: false, error: `Unsupported version: ${version}` };
|
||||
}
|
||||
|
||||
// 4. Validar el Metadata resultante
|
||||
const validation = validateMetadata(migrated);
|
||||
if (!validation.valid) {
|
||||
return { ok: false, error: 'Metadata validation failed', validation };
|
||||
}
|
||||
|
||||
return { ok: true, metadata: migrated };
|
||||
}
|
||||
@ -0,0 +1,183 @@
|
||||
import type {LingString} from "@/ling/lib";
|
||||
import type {CURRENT_VERSION} from "@/mtas/lib/version.ts";
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Tipos base — tal como fueron definidos
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Metadata arbitraria — pares clave/valor para datos adicionales.
|
||||
* Los valores son MetadataField (discriminated union por 'type').
|
||||
* Un valor puede ser otro Metadata (grupo anidado via GroupField).
|
||||
*/
|
||||
export type Metadata = Record<string, unknown>;
|
||||
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Primitivos de soporte
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
|
||||
/** Valor monetario con código ISO 4217 */
|
||||
export interface CurrencyValue {
|
||||
amount : number;
|
||||
currency: string; // "EUR", "USD"…
|
||||
}
|
||||
|
||||
/** Referencia a un recurso externo (imagen, vídeo, audio, pdf, fichero) */
|
||||
export interface MediaRef {
|
||||
url : string;
|
||||
mimeType?: string;
|
||||
size? : number; // bytes
|
||||
filename?: string;
|
||||
alt? : LingString;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Campos tipados — Discriminated Union
|
||||
// Los valores del Record<string, unknown> de Metadata son de este tipo.
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
interface FieldBase {
|
||||
type : MetadataFieldType;
|
||||
hidden? : boolean;
|
||||
// futuras: required?, disabled?, label?...
|
||||
}
|
||||
|
||||
|
||||
export interface StringField extends FieldBase { type: 'string'; value: string }
|
||||
export interface NumberField extends FieldBase { type: 'number'; value: number }
|
||||
export interface BooleanField extends FieldBase { type: 'boolean'; value: boolean }
|
||||
export interface LingStringField extends FieldBase { type: 'lingstring'; value: LingString }
|
||||
export interface DateField extends FieldBase { type: 'date'; value: string } // ISO 8601: "YYYY-MM-DD"
|
||||
export interface DateTimeField extends FieldBase { type: 'datetime'; value: string } // ISO 8601: "YYYY-MM-DDThh:mm:ssZ"
|
||||
export interface UrlField extends FieldBase { type: 'url'; value: string }
|
||||
export interface CurrencyField extends FieldBase { type: 'currency'; value: CurrencyValue }
|
||||
export interface ImageField extends FieldBase { type: 'image'; value: MediaRef }
|
||||
export interface VideoField extends FieldBase { type: 'video'; value: MediaRef }
|
||||
export interface AudioField extends FieldBase { type: 'audio'; value: MediaRef }
|
||||
export interface PdfField extends FieldBase { type: 'pdf'; value: MediaRef }
|
||||
export interface FileField extends FieldBase { type: 'file'; value: MediaRef }
|
||||
|
||||
/**
|
||||
* Grupo anidado — su value ES otro Metadata.
|
||||
* Permite construir estructuras jerárquicas de profundidad arbitraria.
|
||||
*
|
||||
* @example
|
||||
* const field: GroupField = {
|
||||
* type: 'group',
|
||||
* value: {
|
||||
* hp: { type: 'number', value: 120 },
|
||||
* fuel: { type: 'string', value: 'diesel' },
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export interface GroupField {
|
||||
type: 'group';
|
||||
order?: string[]; // claves en el orden deseado
|
||||
value: Metadata;
|
||||
}
|
||||
|
||||
/** Unión discriminada de todos los tipos de campo */
|
||||
export type MetadataField =
|
||||
| StringField
|
||||
| NumberField
|
||||
| BooleanField
|
||||
| LingStringField
|
||||
| DateField
|
||||
| DateTimeField
|
||||
| UrlField
|
||||
| CurrencyField
|
||||
| ImageField
|
||||
| VideoField
|
||||
| AudioField
|
||||
| PdfField
|
||||
| FileField
|
||||
| GroupField;
|
||||
|
||||
|
||||
|
||||
/** Todos los discriminantes posibles */
|
||||
export type MetadataFieldType = MetadataField['type'];
|
||||
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Mixin — adjunta Metadata a cualquier entidad
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @example
|
||||
* interface Car extends WithMetadata {
|
||||
* id: string;
|
||||
* model: string;
|
||||
* }
|
||||
*
|
||||
* const car: Car = {
|
||||
* id: '1',
|
||||
* model: 'Corolla',
|
||||
* metadata: {
|
||||
* brand: { type: 'string', value: 'Toyota' },
|
||||
* price: { type: 'currency', value: { amount: 25000, currency: 'EUR' } },
|
||||
* specs: { type: 'group', value: {
|
||||
* hp: { type: 'number', value: 120 },
|
||||
* }},
|
||||
* }
|
||||
* };
|
||||
*/
|
||||
export type WithMetadata<T extends object = object> = T & {
|
||||
metadata?: Metadata;
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Resultado de validación
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export interface ValidationOk { valid: true }
|
||||
|
||||
export interface ValidationError { valid: false; errors: string[] }
|
||||
|
||||
|
||||
export type ValidationResult = ValidationOk | ValidationError;
|
||||
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Formato serializado con versión
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
|
||||
export type SerializedVersion = typeof CURRENT_VERSION;
|
||||
|
||||
/**
|
||||
* Formato wire de un Metadata serializado.
|
||||
* El campo `__v` permite migrar el schema en el futuro
|
||||
* sin romper consumidores que manejen versiones anteriores.
|
||||
*/
|
||||
export interface SerializedMetadata {
|
||||
__v: SerializedVersion;
|
||||
data: Metadata;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Resultado de deserialización
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
export interface DeserializeOk {
|
||||
ok: true;
|
||||
metadata: Metadata;
|
||||
}
|
||||
|
||||
export interface DeserializeError {
|
||||
ok: false;
|
||||
error: string;
|
||||
validation?: ValidationError;
|
||||
}
|
||||
|
||||
export type DeserializeResult = DeserializeOk | DeserializeError;
|
||||
@ -0,0 +1,117 @@
|
||||
import type {
|
||||
Metadata, MetadataField, ValidationError, ValidationOk, ValidationResult
|
||||
} from './types.ts';
|
||||
import {isLingString} from "@/ling/lib/guards.ts";
|
||||
import {isCurrencyValue, isMediaRef, isMetadataField, isValidUrl} from "@/mtas/lib/guards.ts";
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const ok = (): ValidationOk => ({ valid: true });
|
||||
const fail = (...errors: string[]): ValidationError => ({ valid: false, errors });
|
||||
|
||||
|
||||
function mergeResults(...results: ValidationResult[]): ValidationResult {
|
||||
const errors = results.flatMap(r => r.valid ? [] : r.errors);
|
||||
return errors.length > 0 ? fail(...errors) : ok();
|
||||
}
|
||||
|
||||
|
||||
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/;
|
||||
|
||||
|
||||
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Validadores por tipo
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
function validateByType(key: string, field: MetadataField): ValidationResult {
|
||||
switch (field.type) {
|
||||
case 'string':
|
||||
return ok();
|
||||
|
||||
case 'number':
|
||||
return !isNaN(field.value)
|
||||
? ok() : fail(`[${key}] value must be a valid number`);
|
||||
|
||||
case 'boolean':
|
||||
return ok();
|
||||
|
||||
case 'lingstring':
|
||||
return isLingString(field.value)
|
||||
? ok() : fail(`[${key}] value must be a LingString (string | LingRecord | IDLing)`);
|
||||
|
||||
case 'date':
|
||||
return ISO_DATE_RE.test(field.value)
|
||||
? ok() : fail(`[${key}] value must be an ISO 8601 date (YYYY-MM-DD)`);
|
||||
|
||||
case 'datetime':
|
||||
return ISO_DATETIME_RE.test(field.value)
|
||||
? ok() : fail(`[${key}] value must be an ISO 8601 datetime (YYYY-MM-DDThh:mm:ssZ)`);
|
||||
|
||||
case 'url':
|
||||
return isValidUrl(field.value)
|
||||
? ok() : fail(`[${key}] value must be a valid URL`);
|
||||
|
||||
case 'currency': {
|
||||
if (!isCurrencyValue(field.value)) return fail(`[${key}] value must be { amount: number, currency: string }`);
|
||||
if (field.value.currency.length !== 3) return fail(`[${key}] currency must be a 3-letter ISO 4217 code`);
|
||||
return ok();
|
||||
}
|
||||
|
||||
case 'image':
|
||||
case 'video':
|
||||
case 'audio':
|
||||
case 'pdf':
|
||||
case 'file': {
|
||||
if (!isMediaRef(field.value)) return fail(`[${key}] value must be a MediaRef with a non-empty 'url'`);
|
||||
if (!isValidUrl(field.value.url)) return fail(`[${key}] MediaRef.url must be a valid URL`);
|
||||
return ok();
|
||||
}
|
||||
|
||||
case 'group':
|
||||
return validateMetadata(field.value, key);
|
||||
|
||||
default: {
|
||||
const exhaustive: never = field;
|
||||
return fail(`Unknown field type: ${(exhaustive as MetadataField).type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Validadores públicos
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Valida un único valor extraído de un Metadata.
|
||||
* @param key - clave dentro del Metadata (para mensajes de error)
|
||||
* @param value - el valor en bruto (unknown) almacenado en el Record
|
||||
*/
|
||||
export function validateField(key: string, value: unknown): ValidationResult {
|
||||
if (typeof key !== 'string' || key.trim() === '')
|
||||
return fail('Field key must be a non-empty string');
|
||||
if (!isMetadataField(value))
|
||||
return fail(`[${key}] value must be a MetadataField with a known 'type'`);
|
||||
return validateByType(key, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida un objeto Metadata completo (todos sus pares clave/valor).
|
||||
* @param metadata - el Record<string, unknown> a validar
|
||||
* @param prefix - prefijo para los mensajes de error en grupos anidados
|
||||
*/
|
||||
export function validateMetadata(metadata: Metadata, prefix = ''): ValidationResult {
|
||||
if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata))
|
||||
return fail(`${prefix ? `[${prefix}] ` : ''}Metadata must be a plain object`);
|
||||
|
||||
const results = Object.entries(metadata).map(([key, value]) => {
|
||||
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||
return validateField(fullKey, value);
|
||||
});
|
||||
|
||||
return mergeResults(...results);
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export const CURRENT_VERSION = 1 as const;
|
||||
|
||||
|
||||
@ -0,0 +1,168 @@
|
||||
<script lang="ts">
|
||||
import type { MetadataField, MetadataFieldType } from '../../lib';
|
||||
import LingInput from '@/ling/svelte/LingInput2.svelte';
|
||||
|
||||
let {
|
||||
fieldKey, field, locales, defaultLocale, onKeyChange, onFieldChange,
|
||||
}: {
|
||||
fieldKey: string;
|
||||
field: MetadataField;
|
||||
locales: string[];
|
||||
defaultLocale: string;
|
||||
onKeyChange: (newKey: string) => void;
|
||||
onFieldChange: (field: MetadataField) => void;
|
||||
} = $props();
|
||||
|
||||
let editKey = $state(fieldKey);
|
||||
|
||||
function handleKeyBlur() {
|
||||
const trimmed = editKey.trim();
|
||||
if (trimmed && trimmed !== fieldKey) onKeyChange(trimmed);
|
||||
else editKey = fieldKey;
|
||||
}
|
||||
|
||||
function update(partial: Partial<MetadataField>) {
|
||||
onFieldChange({ ...field, ...partial } as MetadataField);
|
||||
}
|
||||
|
||||
function updateMediaUrl(url: string) {
|
||||
onFieldChange({ ...field, value: { ...(field as any).value, url } } as MetadataField);
|
||||
}
|
||||
|
||||
// Clases comunes para inputs
|
||||
const inputCls = 'w-full rounded-lg border-2 border-zinc-200 bg-white px-3.5 py-2.5 text-[14px] text-zinc-800 transition-colors focus:border-zinc-900 focus:outline-none dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200 dark:focus:border-zinc-100';
|
||||
const labelCls = 'mb-1.5 block text-[12px] font-semibold uppercase tracking-wide text-zinc-400 dark:text-zinc-500';
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4 px-5 py-4">
|
||||
|
||||
<!-- Key -->
|
||||
<div>
|
||||
<label class={labelCls}>Clave del campo</label>
|
||||
<input
|
||||
bind:value={editKey}
|
||||
onblur={handleKeyBlur}
|
||||
onkeydown={(e) => e.key === 'Enter' && (e.currentTarget as HTMLInputElement).blur()}
|
||||
class="{inputCls} font-mono"
|
||||
placeholder="nombre_campo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Value per type -->
|
||||
{#if field.type === 'string'}
|
||||
<div>
|
||||
<label class={labelCls}>Valor</label>
|
||||
<input type="text" value={field.value}
|
||||
oninput={(e) => update({ value: (e.currentTarget as HTMLInputElement).value })}
|
||||
class={inputCls} placeholder="Texto…" />
|
||||
</div>
|
||||
|
||||
{:else if field.type === 'number'}
|
||||
<div>
|
||||
<label class={labelCls}>Valor numérico</label>
|
||||
<input type="number" value={field.value}
|
||||
oninput={(e) => update({ value: parseFloat((e.currentTarget as HTMLInputElement).value) })}
|
||||
class={inputCls} />
|
||||
</div>
|
||||
|
||||
{:else if field.type === 'boolean'}
|
||||
<div>
|
||||
<label class={labelCls}>Valor</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="button" onclick={() => update({ value: !field.value })}
|
||||
class={['relative h-7 w-12 rounded-full transition-colors duration-200',
|
||||
field.value ? 'bg-zinc-900 dark:bg-zinc-100' : 'bg-zinc-200 dark:bg-zinc-700'].join(' ')}>
|
||||
<span class={['absolute top-1 h-5 w-5 rounded-full bg-white shadow-md transition-transform duration-200',
|
||||
field.value ? 'translate-x-6 dark:bg-zinc-900' : 'translate-x-1'].join(' ')}></span>
|
||||
</button>
|
||||
<span class="text-[14px] font-medium text-zinc-700 dark:text-zinc-300">
|
||||
{field.value ? 'Verdadero' : 'Falso'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if field.type === 'lingstring'}
|
||||
<LingInput
|
||||
value={field.value as any}
|
||||
{locales}
|
||||
{defaultLocale}
|
||||
label="Valor multiidioma"
|
||||
as="input"
|
||||
onUpdate={(val) => update({ value: val })}
|
||||
/>
|
||||
|
||||
{:else if field.type === 'date'}
|
||||
<div>
|
||||
<label class={labelCls}>Fecha</label>
|
||||
<input type="date" value={field.value}
|
||||
oninput={(e) => update({ value: (e.currentTarget as HTMLInputElement).value })}
|
||||
class={inputCls} />
|
||||
</div>
|
||||
|
||||
{:else if field.type === 'datetime'}
|
||||
<div>
|
||||
<label class={labelCls}>Fecha y hora</label>
|
||||
<input type="datetime-local" value={field.value?.replace('Z', '')}
|
||||
oninput={(e) => update({ value: (e.currentTarget as HTMLInputElement).value + 'Z' })}
|
||||
class={inputCls} />
|
||||
</div>
|
||||
|
||||
{:else if field.type === 'url'}
|
||||
<div>
|
||||
<label class={labelCls}>URL</label>
|
||||
<input type="text" value={field.value}
|
||||
oninput={(e) => update({ value: (e.currentTarget as HTMLInputElement).value })}
|
||||
class={inputCls} placeholder="https://… o /ruta/relativa" />
|
||||
</div>
|
||||
|
||||
{:else if field.type === 'currency'}
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1">
|
||||
<label class={labelCls}>Cantidad</label>
|
||||
<input type="number" value={field.value.amount}
|
||||
oninput={(e) => update({ value: { ...field.value, amount: parseFloat((e.currentTarget as HTMLInputElement).value) } })}
|
||||
class={inputCls} />
|
||||
</div>
|
||||
<div class="w-28">
|
||||
<label class={labelCls}>Moneda (ISO)</label>
|
||||
<input type="text" maxlength="3" value={field.value.currency}
|
||||
oninput={(e) => update({ value: { ...field.value, currency: (e.currentTarget as HTMLInputElement).value.toUpperCase() } })}
|
||||
class="{inputCls} font-mono uppercase" placeholder="EUR" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if field.type === 'image' || field.type === 'video' || field.type === 'audio' || field.type === 'pdf' || field.type === 'file'}
|
||||
<div>
|
||||
<label class={labelCls}>Ruta o URL</label>
|
||||
<input type="text" value={field.value.url}
|
||||
oninput={(e) => updateMediaUrl((e.currentTarget as HTMLInputElement).value)}
|
||||
class={inputCls} placeholder="/ruta/relativa o https://…" />
|
||||
</div>
|
||||
{#if field.type === 'image' && field.value.url}
|
||||
<div class="overflow-hidden rounded-xl border-2 border-zinc-100 dark:border-zinc-800">
|
||||
<img src={field.value.url} alt={fieldKey}
|
||||
class="max-h-52 w-full object-cover" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{:else if field.type === 'group'}
|
||||
<p class="text-[13px] text-zinc-400">
|
||||
Edita los campos del grupo expandiéndolos en la lista.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Hidden toggle -->
|
||||
<div class="flex items-center justify-between border-t border-zinc-100 pt-4 dark:border-zinc-800">
|
||||
<div>
|
||||
<p class="text-[13px] font-semibold text-zinc-700 dark:text-zinc-300">Ocultar campo</p>
|
||||
<p class="text-[12px] text-zinc-400">El campo no se mostrará en la UI</p>
|
||||
</div>
|
||||
<button type="button" onclick={() => update({ hidden: !field.hidden })}
|
||||
class={['relative h-7 w-12 rounded-full transition-colors duration-200',
|
||||
field.hidden ? 'bg-zinc-900 dark:bg-zinc-100' : 'bg-zinc-200 dark:bg-zinc-700'].join(' ')}>
|
||||
<span class={['absolute top-1 h-5 w-5 rounded-full bg-white shadow-md transition-transform duration-200',
|
||||
field.hidden ? 'translate-x-6 dark:bg-zinc-900' : 'translate-x-1'].join(' ')}></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@ -0,0 +1,300 @@
|
||||
<script lang="ts">
|
||||
import type { MetadataField, MetadataFieldType } from '../../lib';
|
||||
import { validateField, field as f } from '../../lib';
|
||||
import TypeBadge from './TypeBadge.svelte';
|
||||
import FieldEditor from './FieldEditor.svelte';
|
||||
import FieldRowR from './FieldRow.svelte';
|
||||
|
||||
let {
|
||||
fieldKey, field, locales, defaultLocale,
|
||||
depth = 0,
|
||||
onKeyChange, onFieldChange, onDelete,
|
||||
onDragStart, onDragOver, onDrop,
|
||||
}: {
|
||||
fieldKey: string;
|
||||
field: MetadataField;
|
||||
locales: string[];
|
||||
defaultLocale: string;
|
||||
depth?: number;
|
||||
onKeyChange: (newKey: string) => void;
|
||||
onFieldChange: (field: MetadataField) => void;
|
||||
onDelete: () => void;
|
||||
onDragStart: (e: DragEvent) => void;
|
||||
onDragOver: (e: DragEvent) => void;
|
||||
onDrop: (e: DragEvent) => void;
|
||||
} = $props();
|
||||
|
||||
let expanded = $state(false);
|
||||
let dragOver = $state(false);
|
||||
let groupOpen = $state(true);
|
||||
let childDragSrc = $state<string | null>(null);
|
||||
let childDragOver = $state<string | null>(null);
|
||||
|
||||
let validation = $derived(validateField(fieldKey, field));
|
||||
|
||||
// ── Summary value for collapsed row ─────────────────────────────
|
||||
let summary = $derived((() => {
|
||||
switch (field.type) {
|
||||
case 'string': return field.value || '—';
|
||||
case 'number': return String(field.value);
|
||||
case 'boolean': return field.value ? 'Verdadero' : 'Falso';
|
||||
case 'lingstring': return typeof field.value === 'string'
|
||||
? field.value
|
||||
: (Object.values(field.value as Record<string, string>)[0] ?? '—');
|
||||
case 'date':
|
||||
case 'datetime': return field.value || '—';
|
||||
case 'url': return field.value || '—';
|
||||
case 'currency': return `${field.value.amount} ${field.value.currency}`;
|
||||
case 'image':
|
||||
case 'video':
|
||||
case 'audio':
|
||||
case 'pdf':
|
||||
case 'file': return (field.value as { url: string }).url || '—';
|
||||
case 'group': return `${Object.keys(field.value).length} campos`;
|
||||
}
|
||||
})());
|
||||
|
||||
let thumbUrl = $derived(
|
||||
field.type === 'image' && (field.value as { url: string }).url
|
||||
? (field.value as { url: string }).url : null
|
||||
);
|
||||
|
||||
// ── Child group operations ───────────────────────────────────────
|
||||
function defaultChild(type: MetadataFieldType): MetadataField {
|
||||
switch (type) {
|
||||
case 'string': return f.string('');
|
||||
case 'number': return f.number(0);
|
||||
case 'boolean': return f.boolean(false);
|
||||
case 'lingstring': return f.lingstring({ [defaultLocale]: '' } as any);
|
||||
case 'date': return f.date('');
|
||||
case 'datetime': return f.datetime('');
|
||||
case 'url': return f.url('');
|
||||
case 'currency': return f.currency({ amount: 0, currency: 'EUR' });
|
||||
case 'image': return f.image({ url: '' });
|
||||
case 'video': return f.video({ url: '' });
|
||||
case 'audio': return f.audio({ url: '' });
|
||||
case 'pdf': return f.pdf({ url: '' });
|
||||
case 'file': return f.file({ url: '' });
|
||||
case 'group': return f.group({});
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueChildKey(type: MetadataFieldType): string {
|
||||
if (field.type !== 'group') return type;
|
||||
let n = 1;
|
||||
while (`${type}_${n}` in field.value) n++;
|
||||
return `${type}_${n}`;
|
||||
}
|
||||
|
||||
function addChildField(type: MetadataFieldType) {
|
||||
if (field.type !== 'group') return;
|
||||
const key = uniqueChildKey(type);
|
||||
onFieldChange({ ...field, value: { ...field.value, [key]: defaultChild(type) } } as MetadataField);
|
||||
groupOpen = true;
|
||||
}
|
||||
|
||||
function updateChildField(childKey: string, childField: MetadataField) {
|
||||
if (field.type !== 'group') return;
|
||||
onFieldChange({ ...field, value: { ...field.value, [childKey]: childField } } as MetadataField);
|
||||
}
|
||||
|
||||
function renameChildField(oldKey: string, newKey: string) {
|
||||
if (field.type !== 'group' || newKey === oldKey || newKey in field.value) return;
|
||||
const entries = Object.entries(field.value).map(([k, v]) => k === oldKey ? [newKey, v] : [k, v]);
|
||||
onFieldChange({ ...field, value: Object.fromEntries(entries) } as MetadataField);
|
||||
}
|
||||
|
||||
function deleteChildField(childKey: string) {
|
||||
if (field.type !== 'group') return;
|
||||
const { [childKey]: _, ...rest } = field.value;
|
||||
onFieldChange({ ...field, value: rest } as MetadataField);
|
||||
}
|
||||
|
||||
function dropChildField(targetKey: string) {
|
||||
if (field.type !== 'group' || !childDragSrc || childDragSrc === targetKey) return;
|
||||
const entries = Object.entries(field.value);
|
||||
const srcIdx = entries.findIndex(([k]) => k === childDragSrc);
|
||||
const tgtIdx = entries.findIndex(([k]) => k === targetKey);
|
||||
if (srcIdx === -1 || tgtIdx === -1) return;
|
||||
const reordered = [...entries];
|
||||
const [moved] = reordered.splice(srcIdx, 1);
|
||||
reordered.splice(tgtIdx, 0, moved);
|
||||
childDragSrc = null; childDragOver = null;
|
||||
onFieldChange({ ...field, value: Object.fromEntries(reordered) } as MetadataField);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
draggable="true"
|
||||
ondragstart={onDragStart}
|
||||
ondragover={(e) => { e.preventDefault(); dragOver = true; onDragOver(e); }}
|
||||
ondragleave={() => dragOver = false}
|
||||
ondrop={(e) => { dragOver = false; onDrop(e); }}
|
||||
class={[
|
||||
'group/row overflow-hidden rounded-xl border-2 bg-white transition-all duration-150 dark:bg-zinc-900',
|
||||
depth > 0 ? 'ml-5' : '',
|
||||
dragOver
|
||||
? 'border-zinc-400 dark:border-zinc-500'
|
||||
: validation.valid
|
||||
? expanded
|
||||
? 'border-zinc-900 dark:border-zinc-100'
|
||||
: 'border-zinc-200 hover:border-zinc-300 dark:border-zinc-700 dark:hover:border-zinc-600'
|
||||
: 'border-red-300 dark:border-red-700',
|
||||
].join(' ')}
|
||||
>
|
||||
<!-- ── Row header ─────────────────────────────────────────────── -->
|
||||
<div
|
||||
role="button" tabindex="0"
|
||||
onclick={() => expanded = !expanded}
|
||||
onkeydown={(e) => e.key === 'Enter' && (expanded = !expanded)}
|
||||
class={[
|
||||
'flex cursor-pointer items-center gap-3 px-4 transition-colors duration-100',
|
||||
thumbUrl ? 'py-2' : 'py-3',
|
||||
expanded ? 'bg-zinc-50 dark:bg-zinc-800/50' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
<!-- Drag handle -->
|
||||
<div class="shrink-0 cursor-grab opacity-0 transition-opacity group-hover/row:opacity-30 active:cursor-grabbing"
|
||||
onclick={(e) => e.stopPropagation()}>
|
||||
<svg class="h-4 w-4 text-zinc-400" viewBox="0 0 16 16" fill="currentColor">
|
||||
<circle cx="5" cy="4" r="1.3"/><circle cx="5" cy="8" r="1.3"/><circle cx="5" cy="12" r="1.3"/>
|
||||
<circle cx="11" cy="4" r="1.3"/><circle cx="11" cy="8" r="1.3"/><circle cx="11" cy="12" r="1.3"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Image thumb -->
|
||||
{#if thumbUrl}
|
||||
<div class="h-12 w-12 shrink-0 overflow-hidden rounded-lg border border-zinc-100 dark:border-zinc-800">
|
||||
<img src={thumbUrl} alt={fieldKey} class="h-full w-full object-cover" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Type badge -->
|
||||
<TypeBadge type={field.type} />
|
||||
|
||||
<!-- Key -->
|
||||
<span class="shrink-0 text-[14px] font-semibold text-zinc-800 dark:text-zinc-200">
|
||||
{fieldKey}
|
||||
</span>
|
||||
|
||||
<!-- Summary -->
|
||||
<span class="min-w-0 flex-1 truncate text-[13px] text-zinc-400 dark:text-zinc-500">
|
||||
{summary}
|
||||
</span>
|
||||
|
||||
<!-- Hidden badge -->
|
||||
{#if field.hidden}
|
||||
<span class="shrink-0 rounded-md bg-zinc-100 px-2 py-0.5 text-[12px] text-zinc-400 dark:bg-zinc-800 dark:text-zinc-500">
|
||||
oculto
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<!-- Validation indicator -->
|
||||
<div class={[
|
||||
'h-2 w-2 shrink-0 rounded-full',
|
||||
validation.valid ? 'bg-emerald-400' : 'bg-red-400',
|
||||
].join(' ')}></div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover/row:opacity-100"
|
||||
onclick={(e) => e.stopPropagation()}>
|
||||
<button type="button" onclick={() => expanded = !expanded}
|
||||
class="rounded-lg p-1.5 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-700 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
|
||||
title="Editar">
|
||||
<svg class="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M11.5 2.5l2 2-9 9H2.5v-2l9-9z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" onclick={onDelete}
|
||||
class="rounded-lg p-1.5 text-zinc-400 hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-900/20"
|
||||
title="Eliminar">
|
||||
<svg class="h-4 w-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M3 4h10M6 4V2.5h4V4M5 4l.5 9h5l.5-9"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Chevron -->
|
||||
<svg class={['h-4 w-4 shrink-0 text-zinc-300 transition-transform duration-200 dark:text-zinc-600', expanded ? 'rotate-180' : ''].join(' ')}
|
||||
viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M4 6l4 4 4-4"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- ── Validation errors ──────────────────────────────────────── -->
|
||||
{#if !validation.valid && (validation as any).errors?.length}
|
||||
<div class="flex items-center gap-2 border-t border-red-100 bg-red-50/80 px-4 py-2.5 dark:border-red-900/30 dark:bg-red-900/10">
|
||||
<svg class="h-4 w-4 shrink-0 text-red-400" viewBox="0 0 16 16" fill="currentColor">
|
||||
<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-[13px] text-red-500">{(validation as any).errors[0]}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Inline editor ──────────────────────────────────────────── -->
|
||||
{#if expanded}
|
||||
<div class="border-t border-zinc-100 dark:border-zinc-800">
|
||||
<FieldEditor
|
||||
{fieldKey} {field} {locales} {defaultLocale}
|
||||
{onKeyChange}
|
||||
onFieldChange={(f) => onFieldChange(f)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Group children ────────────────────────────────────────── -->
|
||||
{#if field.type === 'group'}
|
||||
<div class="border-t border-zinc-100 dark:border-zinc-800">
|
||||
|
||||
<!-- Group toolbar: toggle + add type buttons -->
|
||||
<div class="flex flex-wrap items-center gap-2 bg-zinc-50/70 px-4 py-2.5 dark:bg-zinc-800/30">
|
||||
<button type="button" onclick={() => groupOpen = !groupOpen}
|
||||
class="flex items-center gap-2 text-[13px] font-medium text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200">
|
||||
<svg class={['h-4 w-4 transition-transform duration-150', groupOpen ? 'rotate-90' : ''].join(' ')}
|
||||
viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M6 4l4 4-4 4"/>
|
||||
</svg>
|
||||
{Object.keys(field.value).length} campos anidados
|
||||
</button>
|
||||
|
||||
<div class="h-4 w-px bg-zinc-200 dark:bg-zinc-700"></div>
|
||||
|
||||
<!-- Add type buttons -->
|
||||
{#each (['string','number','boolean','lingstring','date','datetime','url','currency','image','video','audio','pdf','file','group'] as MetadataFieldType[]) as type}
|
||||
<button type="button" title="Añadir {type}" onclick={() => addChildField(type)}
|
||||
class="rounded-lg p-0.5 transition-colors hover:bg-zinc-200 dark:hover:bg-zinc-700">
|
||||
<TypeBadge {type} />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Child rows -->
|
||||
{#if groupOpen}
|
||||
<div class="flex flex-col gap-2 p-3">
|
||||
{#if Object.keys(field.value).length === 0}
|
||||
<div class="flex items-center justify-center rounded-xl border-2 border-dashed border-zinc-200 py-6 dark:border-zinc-700">
|
||||
<span class="text-[13px] text-zinc-300 dark:text-zinc-600">
|
||||
Grupo vacío — añade campos con los botones de arriba
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
{#each Object.keys(field.value) as childKey (childKey)}
|
||||
<FieldRowR
|
||||
fieldKey={childKey}
|
||||
field={field.value[childKey] as MetadataField}
|
||||
{locales} {defaultLocale}
|
||||
depth={depth + 1}
|
||||
onKeyChange={(newKey) => renameChildField(childKey, newKey)}
|
||||
onFieldChange={(f) => updateChildField(childKey, f)}
|
||||
onDelete={() => deleteChildField(childKey)}
|
||||
onDragStart={(e) => { e.dataTransfer?.setData('text/plain', `${fieldKey}::${childKey}`); childDragSrc = childKey; }}
|
||||
onDragOver={() => { childDragOver = childKey; }}
|
||||
onDrop={() => dropChildField(childKey)}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@ -0,0 +1,191 @@
|
||||
<script lang="ts">
|
||||
import type { Metadata, MetadataField, MetadataFieldType } from '../../lib';
|
||||
import { validateMetadata, field as f } from '../../lib';
|
||||
import FieldRow from './FieldRow.svelte';
|
||||
import TypeBadge from './TypeBadge.svelte';
|
||||
|
||||
let {
|
||||
value = {},
|
||||
locales = ['es'],
|
||||
defaultLocale = 'es',
|
||||
dark = false,
|
||||
onUpdate,
|
||||
}: {
|
||||
value?: Metadata;
|
||||
locales?: string[];
|
||||
defaultLocale?: string;
|
||||
dark?: boolean;
|
||||
onUpdate?: (metadata: Metadata) => void;
|
||||
} = $props();
|
||||
|
||||
let dragSrcKey = $state<string | null>(null);
|
||||
|
||||
let validation = $derived(validateMetadata(value));
|
||||
let fieldKeys = $derived(Object.keys(value));
|
||||
|
||||
const typeGroups: { label: string; types: MetadataFieldType[] }[] = [
|
||||
{ label: 'Texto', types: ['string', 'lingstring', 'url'] },
|
||||
{ label: 'Número', types: ['number', 'currency'] },
|
||||
{ label: 'Fecha', types: ['date', 'datetime'] },
|
||||
{ label: 'Lógico', types: ['boolean'] },
|
||||
{ label: 'Media', types: ['image', 'video', 'audio', 'pdf', 'file'] },
|
||||
{ label: 'Grupo', types: ['group'] },
|
||||
];
|
||||
|
||||
function defaultField(type: MetadataFieldType): MetadataField {
|
||||
switch (type) {
|
||||
case 'string': return f.string('');
|
||||
case 'number': return f.number(0);
|
||||
case 'boolean': return f.boolean(false);
|
||||
case 'lingstring': return f.lingstring({ [defaultLocale]: '' } as any);
|
||||
case 'date': return f.date('');
|
||||
case 'datetime': return f.datetime('');
|
||||
case 'url': return f.url('');
|
||||
case 'currency': return f.currency({ amount: 0, currency: 'EUR' });
|
||||
case 'image': return f.image({ url: '' });
|
||||
case 'video': return f.video({ url: '' });
|
||||
case 'audio': return f.audio({ url: '' });
|
||||
case 'pdf': return f.pdf({ url: '' });
|
||||
case 'file': return f.file({ url: '' });
|
||||
case 'group': return f.group({});
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueKey(type: MetadataFieldType): string {
|
||||
let n = 1;
|
||||
while (`${type}_${n}` in value) n++;
|
||||
return `${type}_${n}`;
|
||||
}
|
||||
|
||||
function addField(type: MetadataFieldType) {
|
||||
emit({ ...value, [uniqueKey(type)]: defaultField(type) });
|
||||
}
|
||||
|
||||
function updateFieldKey(oldKey: string, newKey: string) {
|
||||
if (newKey === oldKey || newKey in value) return;
|
||||
const entries = Object.entries(value).map(([k, v]) => k === oldKey ? [newKey, v] : [k, v]);
|
||||
emit(Object.fromEntries(entries));
|
||||
}
|
||||
|
||||
function updateField(key: string, field: MetadataField) {
|
||||
emit({ ...value, [key]: field });
|
||||
}
|
||||
|
||||
function deleteField(key: string) {
|
||||
const { [key]: _, ...rest } = value;
|
||||
emit(rest);
|
||||
}
|
||||
|
||||
function emit(next: Metadata) {
|
||||
value = next;
|
||||
onUpdate?.(next);
|
||||
}
|
||||
|
||||
function handleDrop(targetKey: string) {
|
||||
if (!dragSrcKey || dragSrcKey === targetKey) return;
|
||||
const entries = Object.entries(value);
|
||||
const srcIdx = entries.findIndex(([k]) => k === dragSrcKey);
|
||||
const tgtIdx = entries.findIndex(([k]) => k === targetKey);
|
||||
if (srcIdx === -1 || tgtIdx === -1) return;
|
||||
const reordered = [...entries];
|
||||
const [moved] = reordered.splice(srcIdx, 1);
|
||||
reordered.splice(tgtIdx, 0, moved);
|
||||
dragSrcKey = null;
|
||||
emit(Object.fromEntries(reordered));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div data-theme={dark ? 'dark' : 'light'}>
|
||||
<div class="flex w-full flex-col overflow-hidden rounded-2xl border-2 border-zinc-200 bg-white
|
||||
dark:border-zinc-700 dark:bg-zinc-950">
|
||||
|
||||
<!-- ── Header ──────────────────────────────────────────────────── -->
|
||||
<div class="border-b border-zinc-100 bg-white px-5 py-4 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
|
||||
<!-- Title row -->
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<h2 class="text-[15px] font-bold text-zinc-900 dark:text-zinc-100">
|
||||
Metadata
|
||||
</h2>
|
||||
<span class="text-[13px] text-zinc-400 dark:text-zinc-500">
|
||||
{fieldKeys.length} {fieldKeys.length === 1 ? 'campo' : 'campos'}
|
||||
</span>
|
||||
<!-- Validation pill -->
|
||||
<div class={[
|
||||
'flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[12px] font-medium',
|
||||
validation.valid
|
||||
? 'bg-emerald-50 text-emerald-600 dark:bg-emerald-900/20 dark:text-emerald-400'
|
||||
: 'bg-red-50 text-red-600 dark:bg-red-900/20 dark:text-red-400',
|
||||
].join(' ')}>
|
||||
<div class={['h-1.5 w-1.5 rounded-full', validation.valid ? 'bg-emerald-500' : 'bg-red-500'].join(' ')}></div>
|
||||
{validation.valid ? 'Válido' : `${(validation as any).errors?.length} errores`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add field buttons grouped by category -->
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
{#each typeGroups as group}
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-[11px] font-semibold uppercase tracking-wide text-zinc-300 dark:text-zinc-600">
|
||||
{group.label}
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
{#each group.types as type}
|
||||
<button type="button" onclick={() => addField(type)} title="Añadir {type}"
|
||||
class="rounded-lg border-2 border-transparent p-0.5 transition-all
|
||||
hover:border-zinc-200 hover:bg-zinc-50
|
||||
dark:hover:border-zinc-700 dark:hover:bg-zinc-800">
|
||||
<TypeBadge {type} />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Field list ────────────────────────────────────────────────── -->
|
||||
<div class="flex flex-col gap-2 p-4">
|
||||
{#if fieldKeys.length === 0}
|
||||
<div class="flex flex-col items-center justify-center gap-3 rounded-2xl border-2 border-dashed border-zinc-200 py-16 dark:border-zinc-800">
|
||||
<svg class="h-10 w-10 text-zinc-200 dark:text-zinc-800" 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>
|
||||
<p class="text-[14px] font-medium text-zinc-300 dark:text-zinc-600">
|
||||
Añade campos usando los botones de arriba
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each fieldKeys as key (key)}
|
||||
<FieldRow
|
||||
fieldKey={key}
|
||||
field={value[key] as MetadataField}
|
||||
{locales} {defaultLocale}
|
||||
onKeyChange={(newKey) => updateFieldKey(key, newKey)}
|
||||
onFieldChange={(field) => updateField(key, field)}
|
||||
onDelete={() => deleteField(key)}
|
||||
onDragStart={(e) => { e.dataTransfer?.setData('text/plain', key); dragSrcKey = key; }}
|
||||
onDragOver={() => {}}
|
||||
onDrop={() => handleDrop(key)}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Global validation errors ──────────────────────────────────── -->
|
||||
{#if !validation.valid && (validation as any).errors?.length}
|
||||
<div class="border-t border-red-100 bg-red-50/60 px-5 py-4 dark:border-red-900/30 dark:bg-red-900/10">
|
||||
<p class="mb-2 text-[13px] font-semibold text-red-600 dark:text-red-400">
|
||||
Errores de validación
|
||||
</p>
|
||||
<ul class="flex flex-col gap-1">
|
||||
{#each (validation as any).errors as error}
|
||||
<li class="text-[13px] text-red-500">· {error}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import type { MetadataFieldType } from '../../lib';
|
||||
|
||||
let { type }: { type: MetadataFieldType } = $props();
|
||||
|
||||
const config: Record<MetadataFieldType, { label: string; classes: string }> = {
|
||||
string: { label: 'STR', classes: 'bg-sky-500/10 text-sky-400 border-sky-500/20' },
|
||||
number: { label: 'NUM', classes: 'bg-violet-500/10 text-violet-400 border-violet-500/20' },
|
||||
boolean: { label: 'BOOL', classes: 'bg-amber-500/10 text-amber-400 border-amber-500/20' },
|
||||
lingstring: { label: 'LING', classes: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' },
|
||||
date: { label: 'DATE', classes: 'bg-orange-500/10 text-orange-400 border-orange-500/20' },
|
||||
datetime: { label: 'DTIME', classes: 'bg-rose-500/10 text-rose-400 border-rose-500/20' },
|
||||
url: { label: 'URL', classes: 'bg-cyan-500/10 text-cyan-400 border-cyan-500/20' },
|
||||
currency: { label: 'CUR', classes: 'bg-lime-500/10 text-lime-400 border-lime-500/20' },
|
||||
image: { label: 'IMG', classes: 'bg-pink-500/10 text-pink-400 border-pink-500/20' },
|
||||
video: { label: 'VID', classes: 'bg-red-500/10 text-red-400 border-red-500/20' },
|
||||
audio: { label: 'AUD', classes: 'bg-purple-500/10 text-purple-400 border-purple-500/20' },
|
||||
pdf: { label: 'PDF', classes: 'bg-zinc-500/10 text-zinc-400 border-zinc-500/20' },
|
||||
file: { label: 'FILE', classes: 'bg-teal-500/10 text-teal-400 border-teal-500/20' },
|
||||
group: { label: 'GROUP', classes: 'bg-indigo-500/10 text-indigo-400 border-indigo-500/20' },
|
||||
};
|
||||
|
||||
let cfg = $derived(config[type]);
|
||||
</script>
|
||||
|
||||
<span class={[
|
||||
'inline-flex items-center rounded border px-1.5 py-0.5',
|
||||
'font-mono text-[9px] font-bold uppercase tracking-widest',
|
||||
cfg.classes,
|
||||
].join(' ')}>
|
||||
{cfg.label}
|
||||
</span>
|
||||
@ -0,0 +1,285 @@
|
||||
<script lang="ts">
|
||||
import './app.css';
|
||||
import MetadataEditor from '../components/MetadataEditor.svelte';
|
||||
import LingInput from '@/ling/svelte/LingInput.svelte';
|
||||
import type { Metadata } from '../../lib';
|
||||
import type { LingRecord } from '@/ling/lib';
|
||||
|
||||
// ── Demo metadata con todos los tipos ───────────────────────────
|
||||
let metadata = $state<Metadata>({
|
||||
nombre: { type: 'string', value: 'Toyota Corolla' },
|
||||
año: { type: 'number', value: 2024 },
|
||||
disponible: { type: 'boolean', value: true },
|
||||
descripcion: { type: 'lingstring', value: { es: 'Sedán compacto fiable', en: 'Reliable compact sedan' } },
|
||||
lanzamiento: { type: 'date', value: '2024-01-15' },
|
||||
ultima_mod: { type: 'datetime', value: '2024-03-09T10:00:00Z' },
|
||||
web: { type: 'url', value: 'https://toyota.es' },
|
||||
precio: { type: 'currency', value: { amount: 24990, currency: 'EUR' } },
|
||||
foto: { type: 'image', value: { url: 'https://images.unsplash.com/photo-1621007947382-bb3c3994e3fb?w=400&h=300&fit=crop', mimeType: 'image/jpeg' } },
|
||||
manual: { type: 'pdf', value: { url: 'https://example.com/manual.pdf', filename: 'manual.pdf' } },
|
||||
specs: { type: 'group', value: {
|
||||
motor: { type: 'string', value: '1.8 VVT-i' },
|
||||
cv: { type: 'number', value: 122 },
|
||||
traccion: { type: 'string', value: 'FWD' },
|
||||
}, order: ['cv', 'motor', 'traccion'] },
|
||||
});
|
||||
|
||||
// ── LingInput demo ───────────────────────────────────────────────
|
||||
let lingValue = $state<LingRecord>({ es: 'Hola mundo', en: '' });
|
||||
let lingInvalid = $state(false);
|
||||
|
||||
// ── JSON output ─────────────────────────────────────────────────
|
||||
let showJson = $state(false);
|
||||
let darkMode = $state(false);
|
||||
|
||||
const locales = ['es', 'en', 'fr'];
|
||||
const defaultLocale = 'es';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>meta-content · Demo</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Syne:wght@400;600;700;800&display=swap" rel="stylesheet" />
|
||||
</svelte:head>
|
||||
|
||||
<div class={['min-h-screen transition-colors duration-300', darkMode ? 'dark bg-zinc-950' : 'bg-zinc-50'].join(' ')}
|
||||
style="font-family: 'Syne', sans-serif;">
|
||||
|
||||
<!-- ── Background grid ────────────────────────────────────────── -->
|
||||
<!--
|
||||
<div class="pointer-events-none fixed inset-0 opacity-[0.03] dark:opacity-[0.06]"
|
||||
style="background-image: linear-gradient(#000 1px, transparent 1px), linear-gradient(90deg, #000 1px, transparent 1px); background-size: 32px 32px;">
|
||||
</div>
|
||||
-->
|
||||
|
||||
<!-- ── Header ─────────────────────────────────────────────────── -->
|
||||
<header class="relative border-b border-zinc-200 bg-white/80 backdrop-blur dark:border-zinc-800 dark:bg-zinc-950/80">
|
||||
<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">
|
||||
<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"/>
|
||||
<rect x="9" y="9" width="6" height="6" rx="1" opacity="0.4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-mono text-[11px] font-bold uppercase tracking-[0.2em] text-zinc-800 dark:text-zinc-200">
|
||||
meta-content
|
||||
</span>
|
||||
<span class="ml-2 rounded border border-zinc-200 px-1.5 py-0.5 font-mono text-[9px] text-zinc-400 dark:border-zinc-700 dark:text-zinc-500">
|
||||
v0.1.0
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
onclick={() => showJson = !showJson}
|
||||
class={[
|
||||
'rounded-lg border px-3 py-1.5 font-mono text-[11px] font-medium transition-all duration-150',
|
||||
showJson
|
||||
? 'border-zinc-800 bg-zinc-900 text-white dark:border-zinc-200 dark:bg-white dark:text-zinc-900'
|
||||
: 'border-zinc-200 text-zinc-500 hover:border-zinc-300 dark:border-zinc-700 dark:text-zinc-400',
|
||||
].join(' ')}
|
||||
>
|
||||
{showJson ? '← editor' : 'JSON →'}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => darkMode = !darkMode}
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg border border-zinc-200
|
||||
text-zinc-400 transition-colors hover:border-zinc-300 hover:text-zinc-600
|
||||
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">
|
||||
<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">
|
||||
<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}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ── Main ───────────────────────────────────────────────────── -->
|
||||
<main class="mx-auto max-w-5xl px-6 py-10">
|
||||
|
||||
<!-- Hero -->
|
||||
<div class="mb-10">
|
||||
<h1 class="text-[42px] font-extrabold leading-none tracking-tight text-zinc-900 dark:text-zinc-100">
|
||||
Metadata<br/>
|
||||
<span class="text-zinc-300 dark:text-zinc-700">Editor</span>
|
||||
</h1>
|
||||
<p class="mt-3 font-mono text-[12px] text-zinc-400 dark:text-zinc-500">
|
||||
Campos tipados · Validación en tiempo real · Drag & drop · Dark mode
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if showJson}
|
||||
<!-- ── JSON view ─────────────────────────────────────── -->
|
||||
<div class="rounded-xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<div class="flex items-center justify-between border-b border-zinc-100 px-4 py-3 dark:border-zinc-800">
|
||||
<span class="font-mono text-[10px] font-bold uppercase tracking-widest text-zinc-400">
|
||||
metadata · serializado
|
||||
</span>
|
||||
<button
|
||||
onclick={() => navigator.clipboard.writeText(JSON.stringify(metadata, null, 2))}
|
||||
class="rounded border border-zinc-200 px-2 py-1 font-mono text-[10px] text-zinc-400
|
||||
hover:border-zinc-300 hover:text-zinc-600 dark:border-zinc-700 dark:hover:border-zinc-600"
|
||||
>
|
||||
copiar
|
||||
</button>
|
||||
</div>
|
||||
<pre class="overflow-auto p-4 font-mono text-[11px] leading-relaxed text-zinc-600 dark:text-zinc-400"
|
||||
>{JSON.stringify(metadata, null, 2)}</pre>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-8 lg:grid-cols-[1fr_320px]">
|
||||
|
||||
<!-- ── Left: MetadataEditor ──────────────────────── -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2 px-0.5">
|
||||
<span class="font-mono text-[10px] font-bold uppercase tracking-[0.16em] text-zinc-400 dark:text-zinc-600">
|
||||
MetadataEditor
|
||||
</span>
|
||||
<div class="h-px flex-1 bg-zinc-100 dark:bg-zinc-800"></div>
|
||||
</div>
|
||||
<MetadataEditor
|
||||
bind:value={metadata}
|
||||
{locales}
|
||||
{defaultLocale}
|
||||
dark={darkMode}
|
||||
onUpdate={(m) => metadata = m}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Right: LingInput demo + stats ─────────────── -->
|
||||
<div class="flex flex-col gap-6">
|
||||
|
||||
<!-- LingInput showcase -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2 px-0.5">
|
||||
<span class="font-mono text-[10px] font-bold uppercase tracking-[0.16em] text-zinc-400 dark:text-zinc-600">
|
||||
LingInput
|
||||
</span>
|
||||
<div class="h-px flex-1 bg-zinc-100 dark:bg-zinc-800"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 rounded-xl border border-zinc-200 bg-white p-4
|
||||
dark:border-zinc-800 dark:bg-zinc-900">
|
||||
|
||||
<!-- textarea mode -->
|
||||
<LingInput
|
||||
value={lingValue}
|
||||
{locales}
|
||||
{defaultLocale}
|
||||
label="Descripción"
|
||||
placeholder="Escribe aquí…"
|
||||
required={true}
|
||||
invalid={lingInvalid}
|
||||
as="textarea"
|
||||
rows={3}
|
||||
onUpdate={(v) => lingValue = v}
|
||||
/>
|
||||
|
||||
<!-- input mode -->
|
||||
<LingInput
|
||||
value={lingValue}
|
||||
{locales}
|
||||
{defaultLocale}
|
||||
label="Título"
|
||||
as="input"
|
||||
onUpdate={(v) => lingValue = v}
|
||||
/>
|
||||
|
||||
<!-- Invalid toggle -->
|
||||
<div class="flex items-center gap-2 border-t border-zinc-100 pt-3 dark:border-zinc-800">
|
||||
<span class="font-mono text-[10px] text-zinc-400">Simular inválido</span>
|
||||
<button
|
||||
onclick={() => lingInvalid = !lingInvalid}
|
||||
class={[
|
||||
'relative h-5 w-9 rounded-full transition-colors duration-200',
|
||||
lingInvalid ? 'bg-red-400' : 'bg-zinc-200 dark:bg-zinc-700',
|
||||
].join(' ')}
|
||||
>
|
||||
<span class={[
|
||||
'absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition-transform duration-200',
|
||||
lingInvalid ? 'translate-x-4' : 'translate-x-0.5',
|
||||
].join(' ')}></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats card -->
|
||||
<div class="rounded-xl border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<p class="mb-3 font-mono text-[10px] font-bold uppercase tracking-[0.16em] text-zinc-400 dark:text-zinc-600">
|
||||
Estado actual
|
||||
</p>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each [
|
||||
{ label: 'campos', value: Object.keys(metadata).length },
|
||||
{ label: 'tipos únicos', value: new Set(Object.values(metadata).map((f: any) => f.type)).size },
|
||||
{ label: 'con hidden', value: Object.values(metadata).filter((f: any) => f.hidden).length },
|
||||
] as stat}
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-mono text-[11px] text-zinc-400 dark:text-zinc-500">{stat.label}</span>
|
||||
<span class="font-mono text-[13px] font-bold text-zinc-700 dark:text-zinc-300">{stat.value}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Type legend -->
|
||||
<div class="rounded-xl border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<p class="mb-3 font-mono text-[10px] font-bold uppercase tracking-[0.16em] text-zinc-400 dark:text-zinc-600">
|
||||
Tipos disponibles
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each ['string','number','boolean','lingstring','date','datetime','url','currency','image','video','audio','pdf','file','group'] as type}
|
||||
{@const colors: Record<string, string> = {
|
||||
string: 'bg-sky-50 text-sky-500 border-sky-200 dark:bg-sky-900/20 dark:text-sky-400 dark:border-sky-800',
|
||||
number: 'bg-violet-50 text-violet-500 border-violet-200 dark:bg-violet-900/20 dark:text-violet-400 dark:border-violet-800',
|
||||
boolean: 'bg-amber-50 text-amber-500 border-amber-200 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-800',
|
||||
lingstring: 'bg-emerald-50 text-emerald-500 border-emerald-200 dark:bg-emerald-900/20 dark:text-emerald-400 dark:border-emerald-800',
|
||||
date: 'bg-orange-50 text-orange-500 border-orange-200 dark:bg-orange-900/20 dark:text-orange-400 dark:border-orange-800',
|
||||
datetime: 'bg-rose-50 text-rose-500 border-rose-200 dark:bg-rose-900/20 dark:text-rose-400 dark:border-rose-800',
|
||||
url: 'bg-cyan-50 text-cyan-500 border-cyan-200 dark:bg-cyan-900/20 dark:text-cyan-400 dark:border-cyan-800',
|
||||
currency: 'bg-lime-50 text-lime-500 border-lime-200 dark:bg-lime-900/20 dark:text-lime-400 dark:border-lime-800',
|
||||
image: 'bg-pink-50 text-pink-500 border-pink-200 dark:bg-pink-900/20 dark:text-pink-400 dark:border-pink-800',
|
||||
video: 'bg-red-50 text-red-500 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800',
|
||||
audio: 'bg-purple-50 text-purple-500 border-purple-200 dark:bg-purple-900/20 dark:text-purple-400 dark:border-purple-800',
|
||||
pdf: 'bg-zinc-100 text-zinc-500 border-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:border-zinc-700',
|
||||
file: 'bg-teal-50 text-teal-500 border-teal-200 dark:bg-teal-900/20 dark:text-teal-400 dark:border-teal-800',
|
||||
group: 'bg-indigo-50 text-indigo-500 border-indigo-200 dark:bg-indigo-900/20 dark:text-indigo-400 dark:border-indigo-800',
|
||||
}}
|
||||
<span class={[
|
||||
'rounded border px-1.5 py-0.5 font-mono text-[9px] font-bold uppercase tracking-widest',
|
||||
colors[type] ?? '',
|
||||
].join(' ')}>
|
||||
{type}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</main>
|
||||
|
||||
<!-- ── Footer ─────────────────────────────────────────────────── -->
|
||||
<footer class="border-t border-zinc-100 py-6 dark:border-zinc-800">
|
||||
<p class="text-center font-mono text-[10px] text-zinc-300 dark:text-zinc-700">
|
||||
meta-content · TypeScript · Svelte 5 · Tailwind CSS
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
@ -0,0 +1,10 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
|
||||
/* Smooth scroll para navegación */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
|
||||
@variant dark (&:where([data-theme=dark], [data-theme=dark] *));
|
||||
@ -0,0 +1,178 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {deserialize, serialization} from '../lib';
|
||||
import { field } from '../lib';
|
||||
import type { Metadata } from '../lib';
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// serialization
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
describe('serialization', () => {
|
||||
it('produce un JSON con __v y data', () => {
|
||||
const meta: Metadata = { brand: field.string('Toyota') };
|
||||
const json = serialization(meta);
|
||||
const parsed = JSON.parse(json);
|
||||
expect(parsed.__v).toBe(1);
|
||||
expect(parsed.data).toEqual(meta);
|
||||
});
|
||||
|
||||
it('serializa metadata vacía', () => {
|
||||
const json = serialization({});
|
||||
const parsed = JSON.parse(json);
|
||||
expect(parsed.data).toEqual({});
|
||||
});
|
||||
|
||||
it('serializa grupos anidados', () => {
|
||||
const meta: Metadata = {
|
||||
specs: field.group({
|
||||
hp: field.number(120),
|
||||
fuel: field.string('diesel'),
|
||||
}),
|
||||
};
|
||||
const json = serialization(meta);
|
||||
const parsed = JSON.parse(json);
|
||||
expect(parsed.data.specs.type).toBe('group');
|
||||
expect(parsed.data.specs.value.hp.value).toBe(120);
|
||||
});
|
||||
|
||||
it('serializa todos los tipos de campo', () => {
|
||||
const meta: Metadata = {
|
||||
a: field.string('hello'),
|
||||
b: field.number(42),
|
||||
c: field.boolean(true),
|
||||
d: field.date('2024-01-15'),
|
||||
e: field.datetime('2024-01-15T10:00:00Z'),
|
||||
f: field.url('https://example.com'),
|
||||
g: field.currency({ amount: 99, currency: 'EUR' }),
|
||||
h: field.image({ url: 'https://cdn.example.com/img.jpg' }),
|
||||
i: field.video({ url: 'https://cdn.example.com/v.mp4' }),
|
||||
j: field.audio({ url: 'https://cdn.example.com/s.mp3' }),
|
||||
k: field.pdf({ url: 'https://cdn.example.com/d.pdf' }),
|
||||
l: field.file({ url: 'https://cdn.example.com/d.zip' }),
|
||||
};
|
||||
const json = serialization(meta);
|
||||
const result = deserialize(json);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// deserialize — casos de éxito
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
describe('deserialize — éxito', () => {
|
||||
it('recupera exactamente el Metadata original', () => {
|
||||
const meta: Metadata = {
|
||||
brand: field.string('Toyota'),
|
||||
price: field.currency({ amount: 25000, currency: 'EUR' }),
|
||||
};
|
||||
const result = deserialize(serialization(meta));
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.metadata).toEqual(meta);
|
||||
});
|
||||
|
||||
it('roundtrip con grupo anidado y order', () => {
|
||||
const meta: Metadata = {
|
||||
specs: field.group(
|
||||
{ hp: field.number(120), fuel: field.string('diesel') },
|
||||
['fuel', 'hp']
|
||||
),
|
||||
};
|
||||
const result = deserialize(serialization(meta));
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.metadata).toEqual(meta);
|
||||
});
|
||||
|
||||
it('roundtrip con hidden: true', () => {
|
||||
const meta: Metadata = {
|
||||
internal: { ...field.string('secret'), hidden: true },
|
||||
};
|
||||
const result = deserialize(serialization(meta));
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
const f = result.metadata['internal'] as { hidden: boolean };
|
||||
expect(f.hidden).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('roundtrip con metadata vacía', () => {
|
||||
const result = deserialize(serialization({}));
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.metadata).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// deserialize — casos de error
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
describe('deserialize — errores', () => {
|
||||
it('falla con JSON malformado', () => {
|
||||
const result = deserialize('{invalid json}');
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toBe('Invalid JSON string');
|
||||
});
|
||||
|
||||
it('falla si el payload es un array', () => {
|
||||
const result = deserialize('[]');
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('falla si falta __v', () => {
|
||||
const result = deserialize(JSON.stringify({ data: {} }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toContain('__v');
|
||||
});
|
||||
|
||||
it('falla si falta data', () => {
|
||||
const result = deserialize(JSON.stringify({ __v: 1 }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toContain('data');
|
||||
});
|
||||
|
||||
it('falla con versión desconocida', () => {
|
||||
const result = deserialize(JSON.stringify({ __v: 999, data: {} }));
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error).toContain('999');
|
||||
});
|
||||
|
||||
it('falla y devuelve ValidationError si data es Metadata inválido', () => {
|
||||
const invalid = JSON.stringify({
|
||||
__v: 1,
|
||||
data: { price: { type: 'currency', value: { amount: 100, currency: 'EU' } } },
|
||||
});
|
||||
const result = deserialize(invalid);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.validation).toBeDefined();
|
||||
expect(result.validation?.errors.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('falla si un campo tiene type desconocido', () => {
|
||||
const invalid = JSON.stringify({
|
||||
__v: 1,
|
||||
data: { x: { type: 'unknown', value: 'test' } },
|
||||
});
|
||||
const result = deserialize(invalid);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// Propiedades de roundtrip
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
describe('roundtrip serialization → deserialize', () => {
|
||||
it('es idempotente: serializar dos veces produce el mismo resultado', () => {
|
||||
const meta: Metadata = { x: field.number(1) };
|
||||
expect(serialization(meta)).toBe(serialization(meta));
|
||||
});
|
||||
|
||||
it('deserializar dos veces el mismo JSON produce el mismo Metadata', () => {
|
||||
const json = serialization({ x: field.string('a') });
|
||||
const r1 = deserialize(json);
|
||||
const r2 = deserialize(json);
|
||||
expect(r1).toEqual(r2);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,150 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validateField, validateMetadata } from '../lib';
|
||||
import { field } from '../lib';
|
||||
import type { Metadata } from '../lib';
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// validateField — edge cases
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
describe('validateField — key edge cases', () => {
|
||||
it('falla con key de solo espacios', () => {
|
||||
expect(validateField(' ', field.string('x')).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('acepta key con caracteres especiales', () => {
|
||||
expect(validateField('my-field_1', field.string('x'))).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('falla si el valor no tiene type', () => {
|
||||
expect(validateField('x', { value: 'hello' } as unknown as never).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('falla si el valor es null', () => {
|
||||
expect(validateField('x', null).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('falla si el valor es un array', () => {
|
||||
expect(validateField('x', []).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('falla si el valor es un primitivo', () => {
|
||||
expect(validateField('x', 42).valid).toBe(false);
|
||||
expect(validateField('x', 'string').valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateField — number edge cases', () => {
|
||||
it('acepta 0', () => expect(validateField('n', field.number(0))).toEqual({ valid: true }));
|
||||
it('acepta negativos', () => expect(validateField('n', field.number(-99))).toEqual({ valid: true }));
|
||||
it('acepta Infinity', () => expect(validateField('n', field.number(Infinity))).toEqual({ valid: true }));
|
||||
it('falla con NaN', () => expect(validateField('n', field.number(NaN)).valid).toBe(false));
|
||||
});
|
||||
|
||||
describe('validateField — date edge cases', () => {
|
||||
it('falla con fecha inválida que cumple el formato', () => {
|
||||
// ISO_DATE_RE solo comprueba formato, no si la fecha existe
|
||||
expect(validateField('d', field.date('9999-99-99')).valid).toBe(true); // formato ok
|
||||
});
|
||||
|
||||
it('falla con datetime en campo date', () => {
|
||||
expect(validateField('d', field.date('2024-01-15T10:00:00Z')).valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateField — url edge cases', () => {
|
||||
it('acepta URL con path y query', () => expect(validateField('u', field.url('https://example.com/path?q=1'))).toEqual({ valid: true }));
|
||||
it('acepta URL con puerto', () => expect(validateField('u', field.url('http://localhost:3000'))).toEqual({ valid: true }));
|
||||
it('falla con URL relativa', () => expect(validateField('u', field.url('/relative/path')).valid).toBe(false));
|
||||
it('falla con string vacío', () => expect(validateField('u', field.url('')).valid).toBe(false));
|
||||
});
|
||||
|
||||
describe('validateField — currency edge cases', () => {
|
||||
it('acepta amount 0', () => expect(validateField('p', field.currency({ amount: 0, currency: 'EUR' }))).toEqual({ valid: true }));
|
||||
it('acepta amount negativo', () => expect(validateField('p', field.currency({ amount: -10, currency: 'EUR' }))).toEqual({ valid: true }));
|
||||
it('falla con currency vacío', () => expect(validateField('p', field.currency({ amount: 10, currency: '' })).valid).toBe(false));
|
||||
it('falla con currency 2 letras', () => expect(validateField('p', field.currency({ amount: 10, currency: 'EU' })).valid).toBe(false));
|
||||
it('falla con currency 4 letras', () => expect(validateField('p', field.currency({ amount: 10, currency: 'EURO' })).valid).toBe(false));
|
||||
});
|
||||
|
||||
describe('validateField — media edge cases', () => {
|
||||
it('acepta MediaRef con campos opcionales', () => {
|
||||
expect(validateField('img', field.image({
|
||||
url: 'https://cdn.example.com/img.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
size: 1024,
|
||||
filename: 'img.jpg',
|
||||
}))).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
it('falla con url relativa en MediaRef', () => {
|
||||
expect(validateField('img', field.image({ url: '/images/photo.jpg' })).valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateField — group edge cases', () => {
|
||||
it('acepta grupo vacío', () => expect(validateField('g', field.group({}))).toEqual({ valid: true }));
|
||||
|
||||
it('acumula errores de múltiples campos internos', () => {
|
||||
const g = field.group({
|
||||
hp: field.number(NaN),
|
||||
price: field.currency({ amount: 10, currency: 'EU' }),
|
||||
});
|
||||
const result = validateField('specs', g);
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) expect(result.errors.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('los errores anidados incluyen el prefijo de la clave padre', () => {
|
||||
const g = field.group({ hp: field.number(NaN) });
|
||||
const result = validateField('specs', g);
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) expect(result.errors[0]).toContain('specs');
|
||||
});
|
||||
|
||||
it('soporta triple anidamiento', () => {
|
||||
const deep = field.group({
|
||||
l1: field.group({
|
||||
l2: field.group({
|
||||
value: field.boolean(false),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
expect(validateField('root', deep)).toEqual({ valid: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// validateMetadata — edge cases
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
describe('validateMetadata — edge cases', () => {
|
||||
it('acepta metadata vacía', () => expect(validateMetadata({})).toEqual({ valid: true }));
|
||||
|
||||
it('falla si metadata es null', () => expect(validateMetadata(null as unknown as Metadata).valid).toBe(false));
|
||||
|
||||
it('falla si metadata es un array', () => expect(validateMetadata([] as unknown as Metadata).valid).toBe(false));
|
||||
|
||||
it('falla si un valor no es MetadataField', () => {
|
||||
const meta: Metadata = { x: 'not a field' };
|
||||
expect(validateMetadata(meta).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('acumula todos los errores sin parar en el primero', () => {
|
||||
const meta: Metadata = {
|
||||
a: field.number(NaN),
|
||||
b: field.currency({ amount: 10, currency: 'EU' }),
|
||||
c: field.url('bad-url'),
|
||||
};
|
||||
const result = validateMetadata(meta);
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) expect(result.errors.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('los mensajes de error identifican la clave afectada', () => {
|
||||
const meta: Metadata = { myKey: field.number(NaN) };
|
||||
const result = validateMetadata(meta);
|
||||
expect(result.valid).toBe(false);
|
||||
if (!result.valid) expect(result.errors[0]).toContain('myKey');
|
||||
});
|
||||
});
|
||||
@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import './app.css';
|
||||
import ConditionBuilderDemo from "@/vcen/editor/demos/ConditionBuilderDemo.svelte";
|
||||
import CatalogDemo from "@/vcen/editor/demos/CatalogDemo.svelte";
|
||||
import Editor from "@/vcen/editor/canvas/Editor.svelte";
|
||||
import LingInput from "@/ling/svelte/LingInput.svelte";
|
||||
import type {LingRecord, SupportedLocale} from "@/ling";
|
||||
import ConditionBuilderDemo from "@/vcen-edit/demos/ConditionBuilderDemo.svelte";
|
||||
import CatalogDemo from "@/vcen-edit/demos/CatalogDemo.svelte";
|
||||
import Editor from "@/vcen-edit/canvas/Editor.svelte";
|
||||
import LingInput from "@/ling/svelte/LingInput2.svelte";
|
||||
import type {LingRecord, SupportedLocale} from "@/ling/lib";
|
||||
|
||||
|
||||
const locales4 = ['es', 'en', 'fr', 'de'];
|
||||
@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import type { CatalogEditorStore } from './catalog-editor.svelte'
|
||||
import type { CatalogEditorStore } from './catalog-editor.svelte.js'
|
||||
import type { AttDomain } from '@/vcen/lib'
|
||||
import RuleList from './RuleList.svelte'
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import type { CatalogEditorStore } from './catalog-editor.svelte'
|
||||
import type { CatalogEditorStore } from './catalog-editor.svelte.js'
|
||||
import AttDetail from './AttDetail.svelte'
|
||||
|
||||
const editor = getContext<CatalogEditorStore>('catalog-editor')
|
||||
@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import type { CatalogEditorStore } from './catalog-editor.svelte'
|
||||
import type { CatalogEditorStore } from './catalog-editor.svelte.js'
|
||||
|
||||
const editor = getContext<CatalogEditorStore>('catalog-editor')
|
||||
const cat = $derived(editor.cat)
|
||||
@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { setContext } from 'svelte'
|
||||
import { createCatalogEditorStore } from './catalog-editor.svelte'
|
||||
import type { CatalogEditorStore } from './catalog-editor.svelte'
|
||||
import { createCatalogEditorStore } from './catalog-editor.svelte.js'
|
||||
import type { CatalogEditorStore } from './catalog-editor.svelte.js'
|
||||
import type { Cat } from '@/vcen/lib'
|
||||
import CatalogLayout from './CatalogLayout.svelte'
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { Cat } from '@/vcen/lib'
|
||||
import { getContext } from 'svelte'
|
||||
import type { CatalogEditorStore } from './catalog-editor.svelte'
|
||||
import type { CatalogEditorStore } from './catalog-editor.svelte.js'
|
||||
import CatalogSidebar from './CatalogSidebar.svelte'
|
||||
import CatMetaPanel from './CatMetaPanel.svelte'
|
||||
import ObjsPanel from './ObjsPanel.svelte'
|
||||
@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import type { CatalogEditorStore } from './catalog-editor.svelte'
|
||||
import type { CatalogEditorStore } from './catalog-editor.svelte.js'
|
||||
|
||||
const editor = getContext<CatalogEditorStore>('catalog-editor')
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import type { CatalogEditorStore } from './catalog-editor.svelte'
|
||||
import type { CatalogEditorStore } from './catalog-editor.svelte.js'
|
||||
|
||||
const editor = getContext<CatalogEditorStore>('catalog-editor')
|
||||
const cat = $derived(editor.cat)
|
||||
@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
|
||||
import { getContext } from 'svelte'
|
||||
import type { CatalogEditorStore } from './catalog-editor.svelte'
|
||||
import type { CatalogEditorStore } from './catalog-editor.svelte.js'
|
||||
|
||||
const editor = getContext<CatalogEditorStore>('catalog-editor')
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import type { CatalogEditorStore } from '../stores/catalog-editor.svelte'
|
||||
import type { CatalogEditorStore } from '@/vcen-edit/stores/catalog-editor.svelte.js'
|
||||
import OptDetail from './OptDetail.svelte'
|
||||
|
||||
const editor = getContext<CatalogEditorStore>('catalog-editor')
|
||||
@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import type { CatalogEditorStore } from './catalog-editor.svelte'
|
||||
import type { CatalogEditorStore } from './catalog-editor.svelte.js'
|
||||
|
||||
const editor = getContext<CatalogEditorStore>('catalog-editor')
|
||||
const sel = $derived(editor.selection)
|
||||
@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import type { DemoStore, DomainKind } from './demo.store.svelte.js';
|
||||
import type { DemoStore, DomainKind } from './demo.store.svelte.ts';
|
||||
import type {Att, AttDomain} from '@/vcen/lib/types';
|
||||
import { createConditionBuilderStore } from '../stores/condition-builder.svelte.ts';
|
||||
import ConditionBuilder from '../comps/ConditionBuilder.svelte';
|
||||
import { createConditionBuilderStore } from '@/vcen-edit/stores/condition-builder.svelte.ts';
|
||||
import ConditionBuilder from '@/vcen-edit/comps/ConditionBuilder.svelte';
|
||||
|
||||
interface Props { store: DemoStore; }
|
||||
let { store }: Props = $props();
|
||||
@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { createDemoStore } from './demo.store.svelte.js';
|
||||
import { createDemoStore } from './demo.store.svelte.ts';
|
||||
import AttEditor from './AttEditor.svelte';
|
||||
import RuntimePanel from './RuntimePanel.svelte';
|
||||
import GraphPanel from './GraphPanel.svelte';
|
||||
import type { DomainKind } from './demo.store.svelte.js';
|
||||
import type { DomainKind } from './demo.store.svelte.ts';
|
||||
|
||||
const store = createDemoStore();
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { createConditionBuilderStore } from '../stores/condition-builder.svelte.ts';
|
||||
import ConditionBuilder from '../comps/ConditionBuilder.svelte';
|
||||
import { createConditionBuilderStore } from '@/vcen-edit/stores/condition-builder.svelte.ts';
|
||||
import ConditionBuilder from '@/vcen-edit/comps/ConditionBuilder.svelte';
|
||||
import type { Obj } from '@/vcen/lib/types';
|
||||
import type { JsonLogicValue } from '@/jslg';
|
||||
import type { JsonLogicValue } from '@/jslg/lib';
|
||||
|
||||
// ── Mock Obj con atributos de ejemplo ────────────────────────────────────
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { DemoStore } from './demo.store.svelte.js';
|
||||
import type { DemoStore } from './demo.store.svelte.ts';
|
||||
import { getTransitiveAffected, getDependencies } from '@/vcen/lib/graph.ts';
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { DemoStore } from './demo.store.svelte.js';
|
||||
import type { DemoStore } from './demo.store.svelte.ts';
|
||||
|
||||
interface Props { store: DemoStore; }
|
||||
let { store }: Props = $props();
|
||||
@ -1,4 +1,4 @@
|
||||
import type { JsonLogicValue } from '@/jslg';
|
||||
import type { JsonLogicValue } from '@/jslg/lib';
|
||||
import type {Att, Sec, Obj, AttDomain, OptID, Opt, AttOptionDomain} from '@/vcen/lib/types';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@ -0,0 +1,11 @@
|
||||
|
||||
import { mount } from 'svelte';
|
||||
import App from './App.svelte';
|
||||
|
||||
const app = mount(App, {
|
||||
target: document.getElementById('app')!
|
||||
});
|
||||
|
||||
|
||||
|
||||
export default app;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue