Files
notify-bridge/frontend/src/routes/template-configs/+page.svelte
T
alexei.dolgolyov 1bfec521d8 fix(redesign): EntitySelect for language pickers + portal Timezone picker
- Template editors (notification & command) now use EntitySelect for
  locale switching and default to the configured primary locale
  instead of always 'en' when opening, editing, or cloning a config.
- LocaleSelector's add-flow uses EntitySelect for catalog pick;
  custom BCP-47 codes (e.g. de-CH) keep a small dedicated input.
- TimezoneSelector dropdown was being clipped by Card's overflow:hidden
  and backdrop-filter; portalled to <body> with an overlay backdrop and
  styled as a centered modal palette (same pattern as EntitySelect).
- Removed top padding on the timezone scroll list so sticky region
  group headers no longer leak rows above them.
- Extracted shared locale catalog to lib/locales.ts.
2026-04-27 14:18:58 +03:00

691 lines
28 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { slide } from 'svelte/transition';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import { topbarAction } from '$lib/stores/topbar-action.svelte';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n';
import { sanitizePreview } from '$lib/sanitize';
import { templateConfigsCache, capabilitiesCache, supportedLocalesCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import IconPicker from '$lib/components/IconPicker.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import Hint from '$lib/components/Hint.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import { providerTypeItems, providerTypeFilterItems, previewTargetTypeItems } from '$lib/grid-items';
import Modal from '$lib/components/Modal.svelte';
import JinjaEditor from '$lib/components/JinjaEditor.svelte';
import CollapsibleSlot from '$lib/components/CollapsibleSlot.svelte';
import EntitySelect, { type EntityItem } from '$lib/components/EntitySelect.svelte';
import { getLocaleMeta } from '$lib/locales';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import type { TemplateConfig } from '$lib/types';
let allTemplateConfigs = $derived(templateConfigsCache.items);
let filterText = $state('');
let filterType = $state('');
let effectiveType = $derived(globalProviderFilter.providerType || filterType);
let configs = $derived(allTemplateConfigs.filter(c =>
(!filterText || c.name.toLowerCase().includes(filterText.toLowerCase())) &&
(!effectiveType || c.provider_type === effectiveType)
));
let loaded = $state(false);
let varsRef = $state<Record<string, any>>({});
let showVarsFor = $state<string | null>(null);
let showForm = $state(false);
let editing = $state<number | null>(null);
let error = $state('');
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
/**
* Reset-to-default confirmation prompt. ``kind: 'slot'`` confirms a
* single-slot reset (slotKey populated); ``'all'`` confirms a full
* locale-scoped wipe. Split from confirmDelete so the two flows can
* coexist without stomping each other's state mid-dialog.
*/
let confirmReset = $state<{
kind: 'slot' | 'all';
slotKey?: string;
message: string;
} | null>(null);
let slotPreview = $state<Record<string, string>>({});
let slotErrors = $state<Record<string, string>>({});
let slotErrorLines = $state<Record<string, number | null>>({});
let slotErrorTypes = $state<Record<string, string>>({});
let validateTimers: Record<string, ReturnType<typeof setTimeout>> = {};
/** Clean up validate timers on unmount */
onMount(() => {
return () => {
for (const timer of Object.values(validateTimers)) clearTimeout(timer);
};
});
let dateFormatPreview = $state<Record<string, string | null>>({});
let expandedSlots = $state<Set<string>>(new Set());
let slotFilter = $state('');
let showPreviewFor = $state<Set<string>>(new Set());
let LOCALES = $derived(supportedLocalesCache.items);
let primaryLocale = $derived(LOCALES[0] || 'en');
let activeLocale = $state<string>('');
const localeItems = $derived<EntityItem[]>(LOCALES.map((code, i) => {
const m = getLocaleMeta(code);
return {
value: code,
label: m.native,
desc: i === 0 ? `${code.toUpperCase()} · ${t('locales.primary')}` : code.toUpperCase(),
};
}));
/**
* Promote primary to be the active locale once the supported-locales
* cache loads (covers initial mount before openNew/edit ran). Without
* this, opening a form before fetch resolves would stay on '' / 'en'.
*/
$effect(() => {
if (!activeLocale && LOCALES.length > 0) activeLocale = primaryLocale;
});
function toggleSlot(key: string) {
const next = new Set(expandedSlots);
if (next.has(key)) next.delete(key); else next.add(key);
expandedSlots = next;
}
function togglePreview(key: string) {
const next = new Set(showPreviewFor);
if (next.has(key)) next.delete(key); else next.add(key);
showPreviewFor = next;
}
function getSlotStatus(key: string): 'empty' | 'valid' | 'error' | 'warning' {
if (slotErrors[key] && slotErrorTypes[key] !== 'undefined') return 'error';
if (slotErrors[key] && slotErrorTypes[key] === 'undefined') return 'warning';
if (getSlotValue(key)) return 'valid';
return 'empty';
}
/** Get slot template for current locale, with fallback. */
function getSlotValue(slotName: string): string {
return form.slots[slotName]?.[activeLocale] || '';
}
/** Set slot template for current locale (immutable update). */
function setSlotValue(slotName: string, value: string) {
form.slots = {
...form.slots,
[slotName]: { ...(form.slots[slotName] || {}), [activeLocale]: value }
};
}
function refreshDateFormatPreview() {
clearTimeout(validateTimers['_dateFmt']);
validateTimers['_dateFmt'] = setTimeout(async () => {
try {
const res = await api('/template-configs/preview-date-format', {
method: 'POST',
body: JSON.stringify({
date_format: form.date_format,
date_only_format: form.date_only_format,
}),
});
dateFormatPreview = res;
} catch {
dateFormatPreview = {};
}
}, 400);
}
function validateSlot(slotKey: string, template: string, immediate = false) {
if (validateTimers[slotKey]) clearTimeout(validateTimers[slotKey]);
if (!template) {
slotErrors = { ...slotErrors, [slotKey]: '' };
slotErrorLines = { ...slotErrorLines, [slotKey]: null };
slotErrorTypes = { ...slotErrorTypes, [slotKey]: '' };
const { [slotKey]: _, ...rest } = slotPreview;
slotPreview = rest;
return;
}
const doValidate = async () => {
try {
const res = await api('/template-configs/preview-raw', { method: 'POST', body: JSON.stringify({ template, target_type: previewTargetType, date_format: form.date_format, date_only_format: form.date_only_format }) });
slotErrors = { ...slotErrors, [slotKey]: res.error || '' };
slotErrorLines = { ...slotErrorLines, [slotKey]: res.error_line || null };
slotErrorTypes = { ...slotErrorTypes, [slotKey]: res.error_type || '' };
if (res.rendered) {
slotPreview = { ...slotPreview, [slotKey]: res.rendered };
} else {
const { [slotKey]: _, ...rest } = slotPreview;
slotPreview = rest;
}
} catch {
slotErrors = { ...slotErrors, [slotKey]: '' };
slotErrorLines = { ...slotErrorLines, [slotKey]: null };
slotErrorTypes = { ...slotErrorTypes, [slotKey]: '' };
}
};
if (immediate) { doValidate(); }
else { validateTimers[slotKey] = setTimeout(doValidate, 800); }
}
function refreshAllPreviews() {
for (const group of templateSlots) {
for (const slot of group.slots) {
if (slot.isDateFormat) continue;
const template = getSlotValue(slot.key);
if (template) {
validateSlot(slot.key, template, true);
}
}
}
refreshDateFormatPreview();
}
const defaultForm = () => ({
provider_type: '', name: '', description: '', icon: '',
slots: {} as Record<string, Record<string, string>>,
date_format: '%d.%m.%Y, %H:%M UTC',
date_only_format: '%d.%m.%Y',
});
let form = $state(defaultForm());
let previewTargetType = $state('telegram');
// Provider capabilities: from shared cache
let allCapabilities = $derived(capabilitiesCache.items);
let providerTypes = $derived(Object.keys(allCapabilities));
// Dynamic slot definitions based on selected provider_type
let notificationSlots = $derived<{name: string, description: string}[]>(
allCapabilities[form.provider_type]?.notification_slots || []
);
// Group slots into event messages vs scheduled messages based on slot name prefix
function slotDescription(s: {name: string, description: string}): string {
const key = `templateSlot.${s.name}`;
const localized = t(key);
return localized !== key ? localized : s.description;
}
let templateSlots = $derived([
{ group: 'eventMessages', slots: notificationSlots
.filter(s => s.name.startsWith('message_'))
.map(s => ({ key: s.name, label: s.name.replace('message_', '').replace(/_/g, ' '), description: slotDescription(s), rows: s.name === 'message_assets_added' ? 10 : 3, isDateFormat: false }))
},
{ group: 'scheduledMessages', slots: notificationSlots
.filter(s => !s.name.startsWith('message_'))
.map(s => ({ key: s.name, label: s.name.replace(/_/g, ' '), description: slotDescription(s), rows: 6, isDateFormat: false }))
},
{ group: 'settings', slots: [
{ key: 'date_format', label: 'dateFormat', description: 'Date+time format', rows: 1, isDateFormat: true },
{ key: 'date_only_format', label: 'dateOnlyFormat', description: 'Date-only format', rows: 1, isDateFormat: true },
]},
]);
onMount(() => {
topbarAction.set({
label: t('templateConfig.newConfig'),
onclick: () => { showForm ? (showForm = false, editing = null) : openNew(); },
});
load();
});
onDestroy(() => topbarAction.clear());
const headerPills = $derived.by(() => {
const pills: Array<{ label: string; tone: 'sky' }> = [];
const types = new Set(configs.map(c => c.provider_type)).size;
if (types > 0) pills.push({ label: `${types} ${types === 1 ? t('providers.typeSingular') : t('providers.typePlural')}`, tone: 'sky' });
return pills;
});
async function load() {
try {
[, varsRef] = await Promise.all([
templateConfigsCache.fetch(true),
api('/template-configs/variables'),
capabilitiesCache.fetch(),
supportedLocalesCache.fetch(),
]);
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
finally { loaded = true; highlightFromUrl(); handleDeepLink(); }
}
/**
* Respond to ``?edit_slot=<slot_name>&provider=<type>`` deep-links from
* other pages (currently the tracking-configs Preview-template modal).
* Picks the first visible config matching ``provider``, opens it in edit
* mode, and pre-expands the target slot. Strips the param from the URL so
* a subsequent reload doesn't reopen the form unexpectedly.
*/
function handleDeepLink() {
if (typeof window === 'undefined') return;
const params = new URLSearchParams(window.location.search);
const slot = params.get('edit_slot');
if (!slot) return;
const provider = params.get('provider') || '';
const target = allTemplateConfigs.find(
c => !provider || c.provider_type === provider,
);
// Strip the deep-link param so reload/back doesn't replay it.
params.delete('edit_slot');
const qs = params.toString();
window.history.replaceState(null, '', window.location.pathname + (qs ? '?' + qs : ''));
if (!target) {
snackError(t('templateConfig.deepLinkNoConfig'));
return;
}
edit(target);
expandedSlots = new Set([slot]);
// Scroll the slot into view once the form has rendered.
setTimeout(() => {
const el = document.getElementById(`slot-${slot}`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 200);
}
function openNew() {
form = defaultForm();
if (providerTypes.length > 0) form.provider_type = providerTypes[0];
editing = null; showForm = true; activeLocale = primaryLocale; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = '';
refreshDateFormatPreview();
}
function edit(c: TemplateConfig) {
form = {
provider_type: c.provider_type,
name: c.name,
description: c.description || '',
icon: c.icon || '',
slots: Object.fromEntries(Object.entries(c.slots).map(([k, v]) => [k, { ...v }])),
date_format: c.date_format || '%d.%m.%Y, %H:%M UTC',
date_only_format: c.date_only_format || '%d.%m.%Y',
};
editing = c.id; showForm = true; activeLocale = primaryLocale;
slotPreview = {}; slotErrors = {}; dateFormatPreview = {};
expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = '';
setTimeout(() => refreshAllPreviews(), 100);
}
async function save(e: SubmitEvent) {
e.preventDefault(); error = '';
try {
if (editing) await api(`/template-configs/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
else await api('/template-configs', { method: 'POST', body: JSON.stringify(form) });
showForm = false; editing = null; await load();
snackSuccess(t('snack.templateSaved'));
} catch (err: any) { error = err.message; snackError(err.message); }
}
/**
* Ask the user to confirm a reset. The actual fetch+replace runs in
* ``performReset`` after the ConfirmModal's onconfirm fires. Split into
* two steps so we can use the app-wide ConfirmModal (consistent look,
* keyboard handling) instead of ``window.confirm`` (blocks the page).
*/
function resetSlotToDefault(slotKey: string) {
if (!form.provider_type) return;
confirmReset = {
kind: 'slot',
slotKey,
message: t('templateConfig.resetSlotConfirm').replace('{locale}', activeLocale.toUpperCase()),
};
}
function resetAllToDefaults() {
if (!form.provider_type) return;
confirmReset = {
kind: 'all',
message: t('templateConfig.resetAllConfirm').replace(/\{locale\}/g, activeLocale.toUpperCase()),
};
}
async function performReset() {
if (!confirmReset || !form.provider_type) return;
const { kind, slotKey } = confirmReset;
confirmReset = null;
try {
if (kind === 'slot' && slotKey) {
const res = await api<Record<string, Record<string, string>>>(
`/template-configs/defaults?provider_type=${encodeURIComponent(form.provider_type)}&slot_name=${encodeURIComponent(slotKey)}&locale=${encodeURIComponent(activeLocale)}`,
);
const text = res?.[slotKey]?.[activeLocale];
if (!text) {
snackError(t('templateConfig.resetNoDefault'));
return;
}
setSlotValue(slotKey, text);
validateSlot(slotKey, text, true);
} else {
const res = await api<Record<string, Record<string, string>>>(
`/template-configs/defaults?provider_type=${encodeURIComponent(form.provider_type)}&locale=${encodeURIComponent(activeLocale)}`,
);
// Replace current-locale slots; leave other locales' values untouched.
const nextSlots = { ...form.slots };
for (const [key, localeMap] of Object.entries(res || {})) {
const text = localeMap?.[activeLocale];
if (text === undefined) continue;
nextSlots[key] = { ...(nextSlots[key] || {}), [activeLocale]: text };
}
form.slots = nextSlots;
refreshAllPreviews();
}
snackSuccess(t('templateConfig.resetApplied'));
} catch (err: any) {
snackError(err.message);
}
}
function clone(c: TemplateConfig) {
form = {
provider_type: c.provider_type,
name: `${c.name} (Copy)`,
description: c.description || '',
icon: c.icon || '',
slots: Object.fromEntries(Object.entries(c.slots).map(([k, v]) => [k, { ...v }])),
date_format: c.date_format || '%d.%m.%Y, %H:%M UTC',
date_only_format: c.date_only_format || '%d.%m.%Y',
};
editing = null;
showForm = true;
activeLocale = primaryLocale;
slotPreview = {};
slotErrors = {};
setTimeout(() => refreshAllPreviews(), 100);
}
let blockedBy = $state<BlockedByDetail | null>(null);
function remove(id: number) {
confirmDelete = {
id,
onconfirm: async () => {
try { await api(`/template-configs/${id}`, { method: 'DELETE' }); await load(); snackSuccess(t('snack.templateDeleted')); }
catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message; snackError(err.message);
}
finally { confirmDelete = null; }
}
};
}
</script>
<PageHeader
title={t('templateConfig.title')}
emphasis={t('templateConfig.titleEmphasis')}
description={t('templateConfig.description')}
crumb="Routing · Notification"
count={configs.length}
countLabel={t('templateConfig.countLabel')}
pills={headerPills}
>
<Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}>
{showForm ? t('common.cancel') : t('templateConfig.newConfig')}
</Button>
</PageHeader>
{#if !loaded}<Loading />{:else}
{#if showForm}
<div in:slide={{ duration: 200 }}>
<Card class="mb-6">
{#if error}<ErrorBanner message={error} />{/if}
<form onsubmit={save} class="space-y-5">
<div>
<label for="tpc-name" class="block text-sm font-medium mb-1">{t('templateConfig.name')}</label>
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="tpc-name" bind:value={form.name} required placeholder={t('templateConfig.namePlaceholder')}
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
<div>
<label for="tpc-desc" class="block text-sm font-medium mb-1">{t('common.description')}</label>
<input id="tpc-desc" bind:value={form.description} placeholder={t('templateConfig.descriptionPlaceholder')}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{#if !editing}
<div>
<div class="block text-sm font-medium mb-1">{t('templateConfig.providerType')}</div>
<IconGridSelect items={providerTypeItems()} bind:value={form.provider_type} columns={2} />
</div>
{:else}
<div>
<span class="block text-sm font-medium mb-1">{t('templateConfig.providerType')}</span>
<span class="text-sm text-[var(--color-muted-foreground)]">{allCapabilities[form.provider_type]?.display_name || form.provider_type}</span>
</div>
{/if}
<div class="flex items-center gap-2">
<span class="text-sm font-medium">{t('templateConfig.previewAs')}:</span>
<IconGridSelect items={previewTargetTypeItems()} bind:value={previewTargetType} columns={2} />
</div>
<!-- Language picker -->
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-medium text-[var(--color-muted-foreground)] shrink-0">
{t('templateConfig.language')}
</span>
<div class="flex-1 max-w-xs">
<EntitySelect
items={localeItems}
value={activeLocale}
size="sm"
onselect={(v) => { activeLocale = (v as string) || primaryLocale; refreshAllPreviews(); }}
/>
</div>
{#if form.provider_type}
<button type="button" onclick={resetAllToDefaults}
title={t('templateConfig.resetAllToDefaults')}
class="ml-auto flex items-center gap-1 text-xs px-2 py-1 rounded-md border border-[var(--color-border)] text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]">
<MdiIcon name="mdiRefresh" size={12} />
{t('templateConfig.resetAllToDefaults')}
</button>
{/if}
</div>
<!-- Slot filter -->
{#if notificationSlots.length > 4}
<div>
<input type="text" bind:value={slotFilter} placeholder={t('templateConfig.filterSlots')}
class="w-full px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{/if}
{#each templateSlots.filter(g => g.slots.length > 0) as group}
{@const filteredSlots = slotFilter ? group.slots.filter(s => (s.description || s.label).toLowerCase().includes(slotFilter.toLowerCase())) : group.slots}
{#if filteredSlots.length > 0}
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
<legend class="text-sm font-medium px-1">{t(`templateConfig.${group.group}`)}{#if group.group === 'eventMessages'}<Hint text={t('hints.eventMessages')} />{:else if group.group === 'scheduledMessages'}<Hint text={t('hints.scheduledMessages')} />{/if}</legend>
<div class="space-y-2 mt-2">
{#each filteredSlots as slot}
{#if slot.isDateFormat}
<div>
<div class="flex items-center justify-between mb-1">
<label for="datefmt-{slot.key}" class="text-xs text-[var(--color-muted-foreground)]">{slot.description || t(`templateConfig.${slot.label}`, slot.label)}</label>
</div>
<input id="datefmt-{slot.key}" value={(form as any)[slot.key]}
oninput={(e: Event) => { (form as any)[slot.key] = (e.target as HTMLInputElement).value; clearTimeout(validateTimers['_fmt']); validateTimers['_fmt'] = setTimeout(refreshAllPreviews, 600); refreshDateFormatPreview(); }}
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
{#if dateFormatPreview[slot.key]}
<p class="mt-1 text-xs font-mono" style="color: var(--color-muted-foreground);">{t('templateConfig.preview')}: <span style="color: var(--color-foreground);">{dateFormatPreview[slot.key]}</span></p>
{:else if dateFormatPreview[slot.key] === null}
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">{t('templateConfig.invalidFormat')}</p>
{/if}
</div>
{:else}
<div id="slot-{slot.key}">
<CollapsibleSlot
label={slot.key}
description={slot.description || t(`templateConfig.${slot.label}`, slot.label)}
expanded={expandedSlots.has(slot.key)}
status={getSlotStatus(slot.key)}
ontoggle={() => toggleSlot(slot.key)}
>
<div class="flex items-center justify-end gap-2 mb-2">
{#if slotPreview[slot.key] && !slotErrors[slot.key]}
<button type="button" onclick={() => togglePreview(slot.key)}
class="text-xs px-2 py-0.5 rounded-md transition-colors {showPreviewFor.has(slot.key) ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}">
{t('templateConfig.preview')}
</button>
{/if}
{#if varsRef[slot.key]}
<button type="button" onclick={() => showVarsFor = slot.key}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
{/if}
<button type="button" onclick={() => resetSlotToDefault(slot.key)}
title={t('templateConfig.resetToDefault')}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">
{t('templateConfig.resetToDefault')}
</button>
</div>
{#if showPreviewFor.has(slot.key) && slotPreview[slot.key] && !slotErrors[slot.key]}
<div class="p-2 bg-[var(--color-muted)] rounded text-sm preview-html mb-2">
<pre class="whitespace-pre-wrap text-xs">{@html sanitizePreview(slotPreview[slot.key])}</pre>
</div>
{:else}
<JinjaEditor value={getSlotValue(slot.key)} onchange={(v: string) => { setSlotValue(slot.key, v); validateSlot(slot.key, v); }} rows={slot.rows || 3} errorLine={slotErrorLines[slot.key] || null} variables={varsRef[slot.key] || undefined} />
{/if}
{#if slotErrors[slot.key]}
{#if slotErrorTypes[slot.key] === 'undefined'}
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">⚠ {t('common.undefinedVar')}: {slotErrors[slot.key]}</p>
{:else}
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">✕ {t('common.syntaxError')}: {slotErrors[slot.key]}{slotErrorLines[slot.key] ? ` (${t('common.line')} ${slotErrorLines[slot.key]})` : ''}</p>
{/if}
{/if}
</CollapsibleSlot>
</div>
{/if}
{/each}
</div>
</fieldset>
{/if}
{/each}
<Button type="submit">
{editing ? t('common.save') : t('common.create')}
</Button>
</form>
</Card>
</div>
{/if}
{#if !showForm && allTemplateConfigs.length > 0}
<div class="flex items-center gap-2 mb-3">
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{#if !globalProviderFilter.id}
<div class="w-48">
<IconGridSelect items={providerTypeFilterItems()} bind:value={filterType} columns={2} compact />
</div>
{/if}
</div>
{/if}
{#if allTemplateConfigs.length === 0 && !showForm}
<Card>
<EmptyState icon="mdiFileDocumentEdit" message={t('templateConfig.noConfigs')} />
</Card>
{:else if configs.length === 0 && !showForm}
<Card>
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
{#each configs as config}
<Card hover entityId={config.id}>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={config.icon || 'mdiFileDocumentEdit'} size={20} /></span>
<p class="font-medium">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{config.provider_type}</span>
{#if config.user_id === 0}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{t('common.system')}</span>
{/if}
</div>
{#if config.description}
<p class="text-sm text-[var(--color-muted-foreground)] mt-1">{config.description}</p>
{/if}
</div>
<div class="flex items-center gap-1 ml-4">
<IconButton icon="mdiContentCopy" title={t('common.clone')} onclick={() => clone(config)} />
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
</div>
</div>
</Card>
{/each}
</div>
{/if}
{/if}
<ConfirmModal open={confirmDelete !== null} message={t('templateConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<ConfirmModal open={confirmReset !== null}
title={t('templateConfig.resetToDefault')}
message={confirmReset?.message || ''}
confirmLabel={confirmReset?.kind === 'all' ? t('templateConfig.resetAllToDefaults') : t('templateConfig.resetToDefault')}
confirmIcon="mdiRefresh"
onconfirm={performReset}
oncancel={() => confirmReset = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<!-- Variables reference modal -->
<Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: {showVarsFor ? t(`templateConfig.${templateSlots.flatMap(g => g.slots).find(s => s.key === showVarsFor)?.label || showVarsFor}`) : ''}" onclose={() => showVarsFor = null}>
{#if showVarsFor && varsRef[showVarsFor]}
<p class="text-sm text-[var(--color-muted-foreground)] mb-3">{t(`templateVars.${showVarsFor}.description`, varsRef[showVarsFor].description)}</p>
<div class="space-y-1">
<p class="text-xs font-medium mb-1">{t('templateConfig.variables')}:</p>
{#each Object.entries(varsRef[showVarsFor].variables || {}) as [name, desc]}
<div class="flex items-start gap-2 text-sm">
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ ' + name + ' }}'}</code>
<span class="text-xs text-[var(--color-muted-foreground)]">{t(`templateVars.${name}`, desc as string)}</span>
</div>
{/each}
</div>
{#if varsRef[showVarsFor].asset_fields && typeof varsRef[showVarsFor].asset_fields === 'object'}
<div class="mt-3 pt-3 border-t border-[var(--color-border)]">
<p class="text-xs font-medium mb-1">{t('templateConfig.assetFields')}:</p>
{#each Object.entries(varsRef[showVarsFor].asset_fields) as [name, desc]}
<div class="flex items-start gap-2 text-sm">
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ asset.' + name + ' }}'}</code>
<span class="text-xs text-[var(--color-muted-foreground)]">{t(`templateVars.asset_${name}`, desc as string)}</span>
</div>
{/each}
</div>
{/if}
{#if varsRef[showVarsFor].album_fields && typeof varsRef[showVarsFor].album_fields === 'object'}
<div class="mt-3 pt-3 border-t border-[var(--color-border)]">
<p class="text-xs font-medium mb-1">{t('templateConfig.albumFields')}:</p>
{#each Object.entries(varsRef[showVarsFor].album_fields) as [name, desc]}
<div class="flex items-start gap-2 text-sm">
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ album.' + name + ' }}'}</code>
<span class="text-xs text-[var(--color-muted-foreground)]">{t(`templateVars.album_${name}`, desc as string)}</span>
</div>
{/each}
</div>
{/if}
{/if}
</Modal>
<style>
:global(.preview-html a) {
color: var(--color-primary);
text-decoration: underline;
}
:global(.preview-html a:hover) {
opacity: 0.8;
}
</style>