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.
339 lines
15 KiB
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> |