You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
v/src/vice/editor/comps/CollectionPanel.svelte

339 lines
15 KiB

<script lang="ts">
import { getContext } from 'svelte'
import {ling, type SupportedLocale} from '@/ling/lib'
import type { EditorStore } from '../stores/editor.svelte.ts'
import type {Attribute, LiteralAttribute} from "@/vice/libs";
const editor = getContext<EditorStore>('editor')
const VALUE_KINDS = ['string', 'number', 'boolean', 'currency'] as const
function asLiteral(attr: Attribute): LiteralAttribute {
return attr as LiteralAttribute
}
function getLiteralName(attr: Attribute, lang: SupportedLocale): string {
return (attr.name as Record<string, string>)?.[lang] ?? ''
}
function patchLiteralName(attr: Attribute, lang: SupportedLocale, value: string) {
const current = attr.name as Record<string, string> ?? {}
editor.updateCatalogAttribute(attr.id, { name: { ...current, [lang]: value } })
}
const col = $derived(editor.collection)
const name = $derived(col.name as any)
const desc = $derived((col.description ?? {}) as any)
const ALL_LANGS: SupportedLocale[] = ['es', 'en', 'de', 'fr', 'it', 'pt', 'ca', 'eu', 'gl']
function patch(key: string, value: unknown) {
editor.updateCollectionMeta({ [key]: value } as any)
}
function patchI18n(field: 'name' | 'description', lang: SupportedLocale, value: string) {
const current = col[field] as any
editor.updateCollectionMeta({ [field]: { ...current, [lang]: value } } as any)
}
function toggleLang(lang: SupportedLocale) {
const current = col.langs
if (lang === 'es') return // es es obligatorio
const next = current.includes(lang)
? current.filter(l => l !== lang)
: [...current, lang]
patch('langs', next)
}
// Stats
const stats = $derived({
entities: Object.keys(col.entities).length,
groups: Object.values(col.entities).reduce((acc, e) => acc + Object.keys(e.groups).length, 0),
attributes: Object.values(col.entities).reduce((acc, e) =>
acc + e.attributes.length + Object.values(e.groups).reduce((a, g) => a + g.attributes.length, 0), 0
),
options: Object.keys(col.options).length,
})
// Import JSON
let importText = $state('')
let importError = $state('')
let showImport = $state(false)
function handleImport() {
try {
editor.importFromJson(importText)
showImport = false
importText = ''
importError = ''
} catch {
importError = 'JSON inválido'
}
}
</script>
<div class="p-6 max-w-2xl flex flex-col gap-8">
<!-- Header -->
<div class="flex items-center justify-between">
<h2 class="text-base font-semibold text-gray-200">Configuración de la colección</h2>
<span class="text-xs text-gray-600 font-mono">{col.id}</span>
</div>
<!-- Stats -->
<div class="grid grid-cols-4 gap-3">
{#each [
{ label: 'Entidades', value: stats.entities },
{ label: 'Grupos', value: stats.groups },
{ label: 'Atributos', value: stats.attributes },
{ label: 'Opciones', value: stats.options },
] as stat}
<div class="flex flex-col items-center gap-1 p-3 rounded-lg bg-gray-900 border border-gray-800">
<span class="text-xl font-bold text-indigo-400">{stat.value}</span>
<span class="text-xs text-gray-600">{stat.label}</span>
</div>
{/each}
</div>
<!-- Identificación -->
<section class="flex flex-col gap-3">
<h3 class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Identificación</h3>
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500">
Código
<span class="text-gray-600 font-normal ml-1">— identificador único sin espacios</span>
{#if !col.code}<span class="text-red-400 ml-1">*</span>{/if}
</label>
<input type="text" value={col.code}
oninput={(e) => patch('code', e.currentTarget.value)}
placeholder="viviendas_vice"
class="field-input font-mono text-xs" />
</div>
<!-- Nombre por idioma -->
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500">
Nombre
{#if !ling.ts(col.name)}<span class="text-red-400 ml-1">*</span>{/if}
</label>
<div class="grid grid-cols-2 gap-2">
{#each col.langs as lang}
<div class="flex items-center gap-2">
<span class="text-xs text-gray-600 w-5 shrink-0">{lang}</span>
<input type="text" value={name[lang] ?? ''}
oninput={(e) => patchI18n('name', lang, e.currentTarget.value)}
placeholder={lang === 'es' ? 'Viviendas VICE' : ''}
class="field-input flex-1" />
</div>
{/each}
</div>
</div>
<!-- Descripción por idioma -->
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500">Descripción</label>
<div class="flex flex-col gap-2">
{#each col.langs as lang}
<div class="flex items-start gap-2">
<span class="text-xs text-gray-600 w-5 shrink-0 pt-2">{lang}</span>
<textarea
oninput={(e) => patchI18n('description', lang, e.currentTarget.value)}
rows="2" class="field-input resize-none flex-1"
>{desc[lang] ?? ''}</textarea>
</div>
{/each}
</div>
</div>
</section>
<!-- Idiomas -->
<section class="flex flex-col gap-3">
<h3 class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Idiomas soportados</h3>
<div class="flex flex-wrap gap-2">
{#each ALL_LANGS as lang}
{@const active = col.langs.includes(lang)}
{@const required = lang === 'es'}
<button
onclick={() => toggleLang(lang)}
disabled={required}
class="px-3 py-1 text-xs rounded border transition-colors
{active
? 'bg-indigo-600/20 border-indigo-500 text-indigo-300'
: 'border-gray-700 text-gray-500 hover:border-gray-600 hover:text-gray-300'}
{required ? 'opacity-60 cursor-not-allowed' : ''}"
title={required ? 'Español es obligatorio' : ''}
>
{lang}
{#if required}<span class="ml-1 text-gray-600">*</span>{/if}
</button>
{/each}
</div>
<p class="text-xs text-gray-700">* Español es obligatorio y no puede desactivarse</p>
</section>
<!-- Atributos del catálogo -->
<section class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<h3 class="text-xs font-semibold text-gray-500 uppercase tracking-wider">
Atributos de contexto
<span class="text-gray-700 font-normal ml-1">({col.attributes.length})</span>
</h3>
<button
onclick={() => editor.addCatalogAttribute()}
class="text-xs px-2 py-1 rounded border border-dashed border-gray-700
text-gray-500 hover:text-indigo-400 hover:border-indigo-700 transition-colors"
>+ Añadir</button>
</div>
{#if col.attributes.length === 0}
<p class="text-xs text-gray-700 italic">
Sin atributos — añade variables de contexto como país, normativa fiscal, año...
</p>
{:else}
<div class="flex flex-col gap-2">
{#each col.attributes as attr}
{@const lit = asLiteral(attr)}
<div class="rounded-md border border-gray-800 bg-gray-900/50 p-3 flex flex-col gap-2">
<div class="flex items-center gap-2">
<!-- Código -->
<input type="text" value={attr.code}
oninput={(e) => editor.updateCatalogAttribute(attr.id, { code: e.currentTarget.value })}
placeholder="country"
class="field-input font-mono text-xs w-32" />
<!-- Kind -->
<select
onchange={(e) => editor.updateCatalogAttribute(attr.id, { kind: e.currentTarget.value })}
class="field-input text-xs w-28"
>
{#each VALUE_KINDS as k}
<option value={k} selected={lit.kind === k}>{k}</option>
{/each}
</select>
<!-- Valor -->
{#if lit.kind === 'boolean'}
<button
onclick={() => editor.updateCatalogAttribute(attr.id, { value: !lit.value })}
class="relative inline-flex h-5 w-9 shrink-0 rounded-full border-2 border-transparent
transition-colors cursor-pointer
{lit.value ? 'bg-indigo-600' : 'bg-gray-700'}"
role="switch" aria-checked={!!lit.value}
>
<span class="pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow
transition-transform
{lit.value ? 'translate-x-4' : 'translate-x-0'}"></span>
</button>
{:else}
<input
type={lit.kind === 'number' ? 'number' : 'text'}
value={lit.value ?? ''}
oninput={(e) => editor.updateCatalogAttribute(attr.id, {
value: lit.kind === 'number'
? Number(e.currentTarget.value)
: e.currentTarget.value
})}
class="field-input text-xs w-24"
/>
{/if}
<button
onclick={() => editor.deleteCatalogAttribute(attr.id)}
class="text-gray-600 hover:text-red-400 transition-colors text-xs shrink-0"
>✕</button>
</div>
<!-- Nombre por idioma -->
<div class="grid grid-cols-2 gap-2 pl-1">
{#each col.langs as lang}
<div class="flex items-center gap-2">
<span class="text-xs text-gray-600 w-5 shrink-0">{lang}</span>
<input type="text" value={getLiteralName(attr, lang)}
oninput={(e) => patchLiteralName(attr, lang, e.currentTarget.value)}
placeholder={lang === 'es' ? 'País' : ''}
class="field-input text-xs flex-1" />
</div>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</section>
<!-- Base Path -->
<section class="flex flex-col gap-3">
<h3 class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Rutas de imagen</h3>
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500">
Base Path
<span class="text-gray-600 font-normal ml-1">— ruta raíz para templates de imagen</span>
</label>
<input type="text" value={col.basePath ?? ''}
oninput={(e) => patch('basePath', e.currentTarget.value)}
placeholder="/renders/"
class="field-input font-mono text-xs" />
</div>
<div class="rounded-md bg-gray-900/50 border border-gray-800 p-3 text-xs text-gray-500">
Los templates de imagen se resuelven como
<code class="text-indigo-400 ml-1">basePath + template</code>.
Cada grupo puede sobreescribir este valor.
</div>
</section>
<!-- Errores de validación -->
{#if editor.validation.errors.filter(e => e.path.startsWith('collection')).length > 0}
<section class="flex flex-col gap-2">
<h3 class="text-xs font-semibold text-red-400 uppercase tracking-wider">Errores</h3>
{#each editor.validation.errors.filter(e => e.path.startsWith('collection')) as error}
<div class="flex flex-col gap-0.5 p-2 rounded bg-red-500/10 border border-red-500/20">
<p class="text-xs text-red-300">{error.message}</p>
<p class="text-xs text-red-400/50 font-mono">{error.path}</p>
</div>
{/each}
</section>
{/if}
<!-- Importar / Resetear -->
<section class="flex flex-col gap-3">
<h3 class="text-xs font-semibold text-gray-500 uppercase tracking-wider">Importar / Resetear</h3>
<div class="flex gap-2">
<button onclick={() => showImport = !showImport}
class="text-xs px-3 py-1.5 rounded border border-gray-700 text-gray-400
hover:text-indigo-400 hover:border-indigo-700 transition-colors">
Importar JSON
</button>
<button
onclick={() => { if (confirm('¿Resetear la colección? Se perderán todos los cambios.')) editor.reset() }}
class="text-xs px-3 py-1.5 rounded border border-gray-700 text-gray-500
hover:text-red-400 hover:border-red-800 transition-colors">
Resetear
</button>
</div>
{#if showImport}
<div class="flex flex-col gap-2">
<textarea bind:value={importText}
placeholder="Pega aquí el JSON de la colección..."
rows="8" class="field-input resize-none font-mono text-xs"></textarea>
{#if importError}
<p class="text-xs text-red-400">{importError}</p>
{/if}
<div class="flex gap-2">
<button onclick={handleImport} disabled={!importText.trim()}
class="text-xs px-3 py-1.5 rounded bg-indigo-600 hover:bg-indigo-500
text-white transition-colors disabled:opacity-40">
Importar
</button>
<button onclick={() => { showImport = false; importText = ''; importError = '' }}
class="text-xs px-3 py-1.5 rounded border border-gray-700 text-gray-400
hover:text-gray-200 transition-colors">
Cancelar
</button>
</div>
</div>
{/if}
</section>
</div>

Powered by TurnKey Linux.