|
|
<script lang="ts">
|
|
|
import type { Metadata, MetadataField, MetadataFieldType } from '../../lib';
|
|
|
import { validateMetadata, field as f } from '../../lib';
|
|
|
import FieldRow from './FieldRow.svelte';
|
|
|
import AddFieldModal from './MetaAddFieldModal.svelte';
|
|
|
import AddFieldMenu from "@/mtas/svelte/components/MetaAddFieldMenu.svelte";
|
|
|
import {makeLingRecord} from "@/ling/lib";
|
|
|
import { flip } from 'svelte/animate';
|
|
|
import type { GlobState } from '@/glob/adapters/svelte';
|
|
|
|
|
|
let {
|
|
|
value = $bindable<Metadata>({}),
|
|
|
dark = false,
|
|
|
attributes = [],
|
|
|
pagination = true,
|
|
|
glob,
|
|
|
locales = ['es'],
|
|
|
defaultLocale = 'es',
|
|
|
onUpdate,
|
|
|
}: {
|
|
|
value?: Metadata;
|
|
|
dark?: boolean;
|
|
|
attributes?: string[];
|
|
|
pagination?: boolean;
|
|
|
glob: GlobState;
|
|
|
locales?: string[];
|
|
|
defaultLocale?: string;
|
|
|
onUpdate?: (metadata: Metadata) => void;
|
|
|
} = $props();
|
|
|
|
|
|
let drawerOpen = $state(false);
|
|
|
let drawerType = $state<MetadataFieldType>('string');
|
|
|
|
|
|
// ── Pagination ────────────────────────────────────────────────
|
|
|
let page = $state(1);
|
|
|
let pageSize = $state(10);
|
|
|
const PAGE_SIZES = [5, 10, 20, 50];
|
|
|
|
|
|
let keyOrder = $derived(
|
|
|
value.order ?? Object.keys(value).filter(k => k !== 'order')
|
|
|
);
|
|
|
let totalPages = $derived(
|
|
|
pageSize === 0 ? 1 : Math.max(1, Math.ceil(keyOrder.length / pageSize))
|
|
|
);
|
|
|
let pageKeys = $derived(
|
|
|
pageSize === 0 ? keyOrder : keyOrder.slice((page - 1) * pageSize, page * pageSize)
|
|
|
);
|
|
|
let rangeStart = $derived(pageSize === 0 ? 1 : (page - 1) * pageSize + 1);
|
|
|
let rangeEnd = $derived(pageSize === 0 ? keyOrder.length : Math.min(page * pageSize, keyOrder.length));
|
|
|
|
|
|
$effect(() => { if (page > totalPages) page = Math.max(1, totalPages); });
|
|
|
|
|
|
let paginationPages = $derived((() => {
|
|
|
if (totalPages <= 7) return Array.from({ length: totalPages }, (_, i) => i + 1);
|
|
|
const pages: number[] = [1];
|
|
|
if (page > 3) pages.push(-1);
|
|
|
for (let p = Math.max(2, page - 1); p <= Math.min(totalPages - 1, page + 1); p++) pages.push(p);
|
|
|
if (page < totalPages - 2) pages.push(-1);
|
|
|
pages.push(totalPages);
|
|
|
return pages;
|
|
|
})());
|
|
|
|
|
|
let validation = $derived(validateMetadata(value));
|
|
|
|
|
|
function emit(): void { onUpdate?.(value); }
|
|
|
|
|
|
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 'ling': return f.ling(makeLingRecord(''));
|
|
|
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 'html': return f.html(makeLingRecord(''));
|
|
|
case 'color': return f.color({ hex: '#000000' });
|
|
|
case 'select': return f.select(null, []);
|
|
|
case 'multiselect': return f.multiselect([], []);
|
|
|
case 'email': return f.email('');
|
|
|
case 'phone': return f.phone({ number: '' });
|
|
|
case 'slug': return f.slug('');
|
|
|
case 'coordinates': return f.coordinates({ lat: 0, lng: 0 });
|
|
|
case 'markdown': return f.markdown(makeLingRecord(''));
|
|
|
case 'group': return f.group({});
|
|
|
}
|
|
|
}
|
|
|
|
|
|
function openDrawer(type: MetadataFieldType): void { drawerType = type; drawerOpen = true; }
|
|
|
|
|
|
function handleDrawerConfirm(key: string, field: MetadataField): void {
|
|
|
value = { ...value, [key]: field, order: [...keyOrder, key] };
|
|
|
emit();
|
|
|
}
|
|
|
|
|
|
function updateFieldKey(oldKey: string, newKey: string): void {
|
|
|
if (newKey === oldKey || newKey in value) return;
|
|
|
const next: Metadata = {};
|
|
|
for (const k of Object.keys(value)) {
|
|
|
if (k === 'order') continue;
|
|
|
next[k === oldKey ? newKey : k] = value[k];
|
|
|
}
|
|
|
next.order = keyOrder.map(k => k === oldKey ? newKey : k);
|
|
|
value = next;
|
|
|
emit();
|
|
|
}
|
|
|
|
|
|
function updateField(key: string, field: MetadataField): void {
|
|
|
value = { ...value, [key]: field };
|
|
|
emit();
|
|
|
}
|
|
|
|
|
|
function deleteField(key: string): void {
|
|
|
const next = { ...value };
|
|
|
delete next[key];
|
|
|
next.order = keyOrder.filter(k => k !== key);
|
|
|
value = next;
|
|
|
emit();
|
|
|
}
|
|
|
|
|
|
function getField(key: string): MetadataField { return value[key] as MetadataField; }
|
|
|
|
|
|
function moveField(key: string, dir: -1 | 1): void {
|
|
|
const idx = keyOrder.indexOf(key);
|
|
|
const newIdx = idx + dir;
|
|
|
if (newIdx < 0 || newIdx >= keyOrder.length) return;
|
|
|
const newOrder = [...keyOrder];
|
|
|
newOrder.splice(idx, 1);
|
|
|
newOrder.splice(newIdx, 0, key);
|
|
|
value = { ...value, order: newOrder };
|
|
|
emit();
|
|
|
if (pageSize > 0) {
|
|
|
const newPage = Math.floor(newIdx / pageSize) + 1;
|
|
|
if (newPage !== page) page = newPage;
|
|
|
}
|
|
|
}
|
|
|
</script>
|
|
|
|
|
|
<div class="h-full flex flex-col min-h-0">
|
|
|
<div class="rounded-lg border border-gray-800 bg-gray-900 shadow-md w-full flex flex-col flex-1 min-h-0">
|
|
|
|
|
|
<!-- ── Header ─────────────────────────────────────────────── -->
|
|
|
<div class="flex items-center gap-3 px-4 py-3 border-b border-gray-700 bg-gray-800/60 shrink-0">
|
|
|
|
|
|
<!-- Validation indicator -->
|
|
|
<div
|
|
|
title={validation.valid
|
|
|
? glob.t('vcen.editor.mtas.validation.valid')
|
|
|
: glob.t('vcen.editor.mtas.validation.errorCount', { count: validation.errors.length })}
|
|
|
>
|
|
|
<div class={[
|
|
|
'h-2 w-2 rounded-full shrink-0 transition-colors',
|
|
|
validation.valid ? 'bg-green-500' : 'bg-red-500 animate-pulse',
|
|
|
].join(' ')}></div>
|
|
|
</div>
|
|
|
|
|
|
<h2 class="font-semibold text-sm text-gray-200">
|
|
|
{glob.t('vcen.editor.mtas.header.title')}
|
|
|
</h2>
|
|
|
|
|
|
<span class="inline-flex items-center rounded px-1.5 py-0.5 text-xs bg-gray-800 text-gray-500 border border-gray-700">
|
|
|
{glob.t('vcen.editor.mtas.header.fieldCount', { count: keyOrder.length })}
|
|
|
</span>
|
|
|
|
|
|
<div class="ml-auto">
|
|
|
<AddFieldMenu onSelect={openDrawer} {glob} />
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<!-- ── Field list ──────────────────────────────────────────── -->
|
|
|
<div class="flex flex-col gap-2 p-3 overflow-y-auto flex-1 min-h-0">
|
|
|
{#if keyOrder.length === 0}
|
|
|
<div class="rounded-2xl flex flex-col items-center justify-center gap-3 border-2 border-dashed border-gray-700 py-16 text-gray-700">
|
|
|
<svg class="h-10 w-10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
|
|
|
<rect x="3" y="3" width="8" height="8" rx="2"/>
|
|
|
<rect x="13" y="3" width="8" height="8" rx="2"/>
|
|
|
<rect x="3" y="13" width="8" height="8" rx="2"/>
|
|
|
<rect x="13" y="13" width="8" height="8" rx="2" opacity=".4"/>
|
|
|
</svg>
|
|
|
<div class="flex flex-col items-center gap-1">
|
|
|
<span class="text-sm font-medium">{glob.t('vcen.editor.mtas.field.empty')}</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
{:else}
|
|
|
{#each pageKeys as key (key)}
|
|
|
{@const gi = keyOrder.indexOf(key)}
|
|
|
<div animate:flip={{ duration: 180 }} class="w-full">
|
|
|
<FieldRow
|
|
|
fieldKey={key}
|
|
|
field={getField(key)}
|
|
|
{attributes} {glob} {locales} {defaultLocale}
|
|
|
canMoveUp={gi > 0}
|
|
|
canMoveDown={gi < keyOrder.length - 1}
|
|
|
onKeyChange={(newKey) => updateFieldKey(key, newKey)}
|
|
|
onFieldChange={(field) => updateField(key, field)}
|
|
|
onDelete={() => deleteField(key)}
|
|
|
onMoveUp={() => moveField(key, -1)}
|
|
|
onMoveDown={() => moveField(key, 1)}
|
|
|
/>
|
|
|
</div>
|
|
|
{/each}
|
|
|
{/if}
|
|
|
</div>
|
|
|
|
|
|
<!-- ── Validation errors ───────────────────────────────────── -->
|
|
|
{#if !validation.valid}
|
|
|
<div class="px-3 pb-3 shrink-0">
|
|
|
<div class="flex items-start gap-2 rounded border border-red-900 bg-red-950 px-3 py-2.5 text-red-400">
|
|
|
<svg class="h-4 w-4 shrink-0" viewBox="0 0 16 16" fill="currentColor">
|
|
|
<path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm0 3.5a.75.75 0 0 1 .75.75v3a.75.75 0 0 1-1.5 0v-3A.75.75 0 0 1 8 4.5zm0 7a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
|
|
|
</svg>
|
|
|
<div class="flex flex-col gap-0.5">
|
|
|
<span class="font-medium text-sm">
|
|
|
{glob.t('vcen.editor.mtas.validation.errorsTitle')}
|
|
|
</span>
|
|
|
{#each validation.errors as error}
|
|
|
<span class="text-xs opacity-80">· {error}</span>
|
|
|
{/each}
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
{/if}
|
|
|
|
|
|
<!-- ── Pagination ──────────────────────────────────────────── -->
|
|
|
{#if pagination && keyOrder.length > 0}
|
|
|
<div class="flex items-center justify-between gap-3 border-t border-gray-700 bg-gray-800/40 px-4 py-2.5 shrink-0">
|
|
|
|
|
|
<div class="flex items-center gap-2 shrink-0">
|
|
|
<span class="text-xs text-gray-600 whitespace-nowrap">
|
|
|
{glob.t('vcen.editor.mtas.pagination.rowsPerPage')}
|
|
|
</span>
|
|
|
<select
|
|
|
class="rounded border border-gray-700 bg-gray-800 px-1.5 py-1 text-xs text-gray-200 focus:outline-none"
|
|
|
bind:value={pageSize}
|
|
|
onchange={() => { page = 1; }}
|
|
|
>
|
|
|
{#each PAGE_SIZES as s}
|
|
|
<option value={s}>{s}</option>
|
|
|
{/each}
|
|
|
<option value={0}>{glob.t('vcen.editor.mtas.pagination.all')}</option>
|
|
|
</select>
|
|
|
</div>
|
|
|
|
|
|
{#if totalPages > 1}
|
|
|
<div class="flex">
|
|
|
<button
|
|
|
type="button"
|
|
|
class="px-2 py-1 text-xs border border-gray-700 text-gray-400 hover:bg-gray-800 hover:text-gray-200 transition-colors first:rounded-l last:rounded-r -ml-px disabled:opacity-40"
|
|
|
disabled={page === 1}
|
|
|
onclick={() => page--}
|
|
|
aria-label={glob.t('vcen.editor.mtas.pagination.prevPage')}
|
|
|
>‹</button>
|
|
|
{#each paginationPages as p (p === -1 ? `e-${p}` : p)}
|
|
|
{#if p === -1}
|
|
|
<button type="button" class="px-2 py-1 text-xs border border-gray-700 text-gray-400 -ml-px opacity-40 cursor-default" tabindex="-1">…</button>
|
|
|
{:else}
|
|
|
<button
|
|
|
type="button"
|
|
|
class={['px-2 py-1 text-xs border border-gray-700 text-gray-400 hover:bg-gray-800 hover:text-gray-200 transition-colors first:rounded-l last:rounded-r -ml-px', p === page ? 'bg-gray-700 text-gray-200' : ''].join(' ')}
|
|
|
onclick={() => page = p}
|
|
|
aria-label={glob.t('vcen.editor.mtas.pagination.pageN', { page: p })}
|
|
|
aria-current={p === page ? 'page' : undefined}
|
|
|
>{p}</button>
|
|
|
{/if}
|
|
|
{/each}
|
|
|
<button
|
|
|
type="button"
|
|
|
class="px-2 py-1 text-xs border border-gray-700 text-gray-400 hover:bg-gray-800 hover:text-gray-200 transition-colors first:rounded-l last:rounded-r -ml-px disabled:opacity-40"
|
|
|
disabled={page === totalPages}
|
|
|
onclick={() => page++}
|
|
|
aria-label={glob.t('vcen.editor.mtas.pagination.nextPage')}
|
|
|
>›</button>
|
|
|
</div>
|
|
|
{/if}
|
|
|
|
|
|
<span class="text-xs text-gray-600 shrink-0">
|
|
|
{glob.t('vcen.editor.mtas.pagination.range', { from: rangeStart, to: rangeEnd, total: keyOrder.length })}
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
{/if}
|
|
|
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<AddFieldModal
|
|
|
open={drawerOpen}
|
|
|
selectedType={drawerType}
|
|
|
existingKeys={keyOrder} {attributes} {dark} {glob} {locales} {defaultLocale}
|
|
|
onConfirm={handleDrawerConfirm}
|
|
|
onClose={() => drawerOpen = false}
|
|
|
/>
|