feat: collapsible accordion slots for template editing UX
Template slot editors (notification + command) now use collapsible accordion rows instead of showing all editors at once. Each slot displays a compact header with status pill (empty/valid/warning/error). Adds slot name filtering and a preview toggle button that swaps between editor and rendered preview views.
This commit is contained in:
@@ -0,0 +1,117 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
|
import MdiIcon from './MdiIcon.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
label,
|
||||||
|
description = '',
|
||||||
|
expanded = false,
|
||||||
|
status = 'empty',
|
||||||
|
ontoggle,
|
||||||
|
children,
|
||||||
|
} = $props<{
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
expanded: boolean;
|
||||||
|
status: 'empty' | 'valid' | 'error' | 'warning';
|
||||||
|
ontoggle: () => void;
|
||||||
|
children: import('svelte').Snippet;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const STATUS_MAP: Record<string, { icon: string; color: string; bg: string }> = {
|
||||||
|
empty: { icon: 'mdiCircleOutline', color: 'var(--color-muted-foreground)', bg: 'transparent' },
|
||||||
|
valid: { icon: 'mdiCheckCircle', color: 'var(--color-success-fg)', bg: 'var(--color-success-bg)' },
|
||||||
|
warning: { icon: 'mdiAlert', color: '#d97706', bg: 'rgba(217, 119, 6, 0.1)' },
|
||||||
|
error: { icon: 'mdiAlertCircle', color: 'var(--color-error-fg)', bg: 'var(--color-error-bg)' },
|
||||||
|
};
|
||||||
|
const statusConfig = $derived(STATUS_MAP[status]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="slot-root" class:slot-expanded={expanded}>
|
||||||
|
<button type="button" class="slot-header" onclick={ontoggle}>
|
||||||
|
<span class="slot-chevron" class:slot-chevron-open={expanded}>
|
||||||
|
<MdiIcon name="mdiChevronRight" size={18} />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="slot-label">{description || label}</span>
|
||||||
|
|
||||||
|
<span class="slot-status-pill" style="color: {statusConfig.color}; background: {statusConfig.bg};">
|
||||||
|
<MdiIcon name={statusConfig.icon} size={14} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if expanded}
|
||||||
|
<div class="slot-body" transition:slide={{ duration: 150 }}>
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.slot-root {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-root:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--color-border) 60%, var(--color-primary) 40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-expanded {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--color-foreground);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-header:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-muted) 50%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-chevron {
|
||||||
|
display: inline-flex;
|
||||||
|
color: var(--color-muted-foreground);
|
||||||
|
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-chevron-open {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-label {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.125rem 0.25rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-body {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -539,7 +539,8 @@
|
|||||||
"assetFields": "Asset fields (in {% for asset in added_assets %})",
|
"assetFields": "Asset fields (in {% for asset in added_assets %})",
|
||||||
"albumFields": "Album fields (in {% for album in albums %})",
|
"albumFields": "Album fields (in {% for album in albums %})",
|
||||||
"confirmDelete": "Delete this template config?",
|
"confirmDelete": "Delete this template config?",
|
||||||
"invalidFormat": "Invalid format string"
|
"invalidFormat": "Invalid format string",
|
||||||
|
"filterSlots": "Filter slots..."
|
||||||
},
|
},
|
||||||
"templateVars": {
|
"templateVars": {
|
||||||
"message_assets_added": {
|
"message_assets_added": {
|
||||||
|
|||||||
@@ -539,7 +539,8 @@
|
|||||||
"assetFields": "Поля файла (в {% for asset in added_assets %})",
|
"assetFields": "Поля файла (в {% for asset in added_assets %})",
|
||||||
"albumFields": "Поля альбома (в {% for album in albums %})",
|
"albumFields": "Поля альбома (в {% for album in albums %})",
|
||||||
"confirmDelete": "Удалить эту конфигурацию шаблона?",
|
"confirmDelete": "Удалить эту конфигурацию шаблона?",
|
||||||
"invalidFormat": "Некорректная строка формата"
|
"invalidFormat": "Некорректная строка формата",
|
||||||
|
"filterSlots": "Фильтр слотов..."
|
||||||
},
|
},
|
||||||
"templateVars": {
|
"templateVars": {
|
||||||
"message_assets_added": {
|
"message_assets_added": {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
import { providerTypeItems as providerTypeItemsFn, providerTypeFilterItems } from '$lib/grid-items';
|
import { providerTypeItems as providerTypeItemsFn, providerTypeFilterItems } from '$lib/grid-items';
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
import JinjaEditor from '$lib/components/JinjaEditor.svelte';
|
import JinjaEditor from '$lib/components/JinjaEditor.svelte';
|
||||||
|
import CollapsibleSlot from '$lib/components/CollapsibleSlot.svelte';
|
||||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||||
import { highlightFromUrl } from '$lib/highlight';
|
import { highlightFromUrl } from '$lib/highlight';
|
||||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||||
@@ -60,6 +61,28 @@
|
|||||||
let varsRef = $state<Record<string, any>>({});
|
let varsRef = $state<Record<string, any>>({});
|
||||||
let showVarsFor = $state<string | null>(null);
|
let showVarsFor = $state<string | null>(null);
|
||||||
let activeLocale = $state<string>('en');
|
let activeLocale = $state<string>('en');
|
||||||
|
let expandedSlots = $state<Set<string>>(new Set());
|
||||||
|
let slotFilter = $state('');
|
||||||
|
let showPreviewFor = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
// Provider capabilities
|
// Provider capabilities
|
||||||
let allCapabilities = $state<Record<string, any>>({});
|
let allCapabilities = $state<Record<string, any>>({});
|
||||||
@@ -67,6 +90,11 @@
|
|||||||
let commandSlots = $derived<SlotDef[]>(
|
let commandSlots = $derived<SlotDef[]>(
|
||||||
allCapabilities[form.provider_type]?.command_slots || []
|
allCapabilities[form.provider_type]?.command_slots || []
|
||||||
);
|
);
|
||||||
|
let filteredCmdSlots = $derived(
|
||||||
|
slotFilter
|
||||||
|
? commandSlots.filter(s => s.name.toLowerCase().includes(slotFilter.toLowerCase()) || s.description.toLowerCase().includes(slotFilter.toLowerCase()))
|
||||||
|
: commandSlots
|
||||||
|
);
|
||||||
|
|
||||||
const defaultForm = () => ({
|
const defaultForm = () => ({
|
||||||
provider_type: '',
|
provider_type: '',
|
||||||
@@ -157,6 +185,9 @@
|
|||||||
activeLocale = 'en';
|
activeLocale = 'en';
|
||||||
slotPreview = {};
|
slotPreview = {};
|
||||||
slotErrors = {};
|
slotErrors = {};
|
||||||
|
expandedSlots = new Set();
|
||||||
|
showPreviewFor = new Set();
|
||||||
|
slotFilter = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function edit(c: CmdTemplateConfig) {
|
function edit(c: CmdTemplateConfig) {
|
||||||
@@ -177,6 +208,9 @@
|
|||||||
activeLocale = 'en';
|
activeLocale = 'en';
|
||||||
slotPreview = {};
|
slotPreview = {};
|
||||||
slotErrors = {};
|
slotErrors = {};
|
||||||
|
expandedSlots = new Set();
|
||||||
|
showPreviewFor = new Set();
|
||||||
|
slotFilter = '';
|
||||||
setTimeout(() => refreshAllPreviews(), 100);
|
setTimeout(() => refreshAllPreviews(), 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,6 +250,9 @@
|
|||||||
activeLocale = 'en';
|
activeLocale = 'en';
|
||||||
slotPreview = {};
|
slotPreview = {};
|
||||||
slotErrors = {};
|
slotErrors = {};
|
||||||
|
expandedSlots = new Set();
|
||||||
|
showPreviewFor = new Set();
|
||||||
|
slotFilter = '';
|
||||||
setTimeout(() => refreshAllPreviews(), 100);
|
setTimeout(() => refreshAllPreviews(), 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,23 +330,50 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<!-- Slot filter -->
|
||||||
{#each commandSlots as slot}
|
{#if commandSlots.length > 4}
|
||||||
<div>
|
<div class="mb-3">
|
||||||
<div class="flex items-center justify-between mb-1">
|
<input type="text" bind:value={slotFilter} placeholder={t('templateConfig.filterSlots')}
|
||||||
<label class="text-xs text-[var(--color-muted-foreground)]">/{slot.name} — {slot.description}</label>
|
class="w-full px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each filteredCmdSlots as slot}
|
||||||
|
<CollapsibleSlot
|
||||||
|
label={slot.name}
|
||||||
|
description="/{slot.name} — {slot.description}"
|
||||||
|
expanded={expandedSlots.has(slot.name)}
|
||||||
|
status={getSlotStatus(slot.name)}
|
||||||
|
ontoggle={() => toggleSlot(slot.name)}
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-end gap-2 mb-2">
|
||||||
|
{#if slotPreview[slot.name] && !slotErrors[slot.name]}
|
||||||
|
<button type="button" onclick={() => togglePreview(slot.name)}
|
||||||
|
class="text-xs px-2 py-0.5 rounded-md transition-colors {showPreviewFor.has(slot.name) ? '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.name]}
|
{#if varsRef[slot.name]}
|
||||||
<button type="button" onclick={() => showVarsFor = slot.name}
|
<button type="button" onclick={() => showVarsFor = slot.name}
|
||||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
|
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<JinjaEditor
|
|
||||||
value={getSlotValue(slot.name)}
|
{#if showPreviewFor.has(slot.name) && slotPreview[slot.name] && !slotErrors[slot.name]}
|
||||||
onchange={(v: string) => { setSlotValue(slot.name, v); validateSlot(slot.name, v); }}
|
<div class="p-2 bg-[var(--color-muted)] rounded text-sm mb-2">
|
||||||
rows={3}
|
<pre class="whitespace-pre-wrap text-xs">{@html sanitizePreview(slotPreview[slot.name])}</pre>
|
||||||
errorLine={slotErrorLines[slot.name] || null}
|
</div>
|
||||||
variables={varsRef[slot.name] || undefined}
|
{:else}
|
||||||
/>
|
<JinjaEditor
|
||||||
|
value={getSlotValue(slot.name)}
|
||||||
|
onchange={(v: string) => { setSlotValue(slot.name, v); validateSlot(slot.name, v); }}
|
||||||
|
rows={3}
|
||||||
|
errorLine={slotErrorLines[slot.name] || null}
|
||||||
|
variables={varsRef[slot.name] || undefined}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if slotErrors[slot.name]}
|
{#if slotErrors[slot.name]}
|
||||||
{#if slotErrorTypes[slot.name] === 'undefined'}
|
{#if slotErrorTypes[slot.name] === 'undefined'}
|
||||||
<p class="mt-1 text-xs" style="color: #d97706;">⚠ {t('common.undefinedVar')}: {slotErrors[slot.name]}</p>
|
<p class="mt-1 text-xs" style="color: #d97706;">⚠ {t('common.undefinedVar')}: {slotErrors[slot.name]}</p>
|
||||||
@@ -317,12 +381,7 @@
|
|||||||
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">✕ {t('common.syntaxError')}: {slotErrors[slot.name]}{slotErrorLines[slot.name] ? ` (${t('common.line')} ${slotErrorLines[slot.name]})` : ''}</p>
|
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">✕ {t('common.syntaxError')}: {slotErrors[slot.name]}{slotErrorLines[slot.name] ? ` (${t('common.line')} ${slotErrorLines[slot.name]})` : ''}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{#if slotPreview[slot.name] && !slotErrors[slot.name]}
|
</CollapsibleSlot>
|
||||||
<div class="mt-1 p-2 bg-[var(--color-muted)] rounded text-sm">
|
|
||||||
<pre class="whitespace-pre-wrap text-xs">{@html sanitizePreview(slotPreview[slot.name])}</pre>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
import { providerTypeItems, providerTypeFilterItems, previewTargetTypeItems } from '$lib/grid-items';
|
import { providerTypeItems, providerTypeFilterItems, previewTargetTypeItems } from '$lib/grid-items';
|
||||||
import Modal from '$lib/components/Modal.svelte';
|
import Modal from '$lib/components/Modal.svelte';
|
||||||
import JinjaEditor from '$lib/components/JinjaEditor.svelte';
|
import JinjaEditor from '$lib/components/JinjaEditor.svelte';
|
||||||
|
import CollapsibleSlot from '$lib/components/CollapsibleSlot.svelte';
|
||||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||||
import { highlightFromUrl } from '$lib/highlight';
|
import { highlightFromUrl } from '$lib/highlight';
|
||||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||||
@@ -44,10 +45,32 @@
|
|||||||
let slotErrorTypes = $state<Record<string, string>>({});
|
let slotErrorTypes = $state<Record<string, string>>({});
|
||||||
let validateTimers: Record<string, ReturnType<typeof setTimeout>> = {};
|
let validateTimers: Record<string, ReturnType<typeof setTimeout>> = {};
|
||||||
let dateFormatPreview = $state<Record<string, string | null>>({});
|
let dateFormatPreview = $state<Record<string, string | null>>({});
|
||||||
|
let expandedSlots = $state<Set<string>>(new Set());
|
||||||
|
let slotFilter = $state('');
|
||||||
|
let showPreviewFor = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
const LOCALES = ['en', 'ru'] as const;
|
const LOCALES = ['en', 'ru'] as const;
|
||||||
let activeLocale = $state<string>('en');
|
let activeLocale = $state<string>('en');
|
||||||
|
|
||||||
|
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. */
|
/** Get slot template for current locale, with fallback. */
|
||||||
function getSlotValue(slotName: string): string {
|
function getSlotValue(slotName: string): string {
|
||||||
return form.slots[slotName]?.[activeLocale] || '';
|
return form.slots[slotName]?.[activeLocale] || '';
|
||||||
@@ -146,11 +169,11 @@
|
|||||||
let templateSlots = $derived([
|
let templateSlots = $derived([
|
||||||
{ group: 'eventMessages', slots: notificationSlots
|
{ group: 'eventMessages', slots: notificationSlots
|
||||||
.filter(s => s.name.startsWith('message_'))
|
.filter(s => s.name.startsWith('message_'))
|
||||||
.map(s => ({ key: s.name, label: s.name.replace('message_', '').replace(/_/g, ' '), description: s.description, rows: s.name === 'message_assets_added' ? 10 : 3 }))
|
.map(s => ({ key: s.name, label: s.name.replace('message_', '').replace(/_/g, ' '), description: s.description, rows: s.name === 'message_assets_added' ? 10 : 3, isDateFormat: false }))
|
||||||
},
|
},
|
||||||
{ group: 'scheduledMessages', slots: notificationSlots
|
{ group: 'scheduledMessages', slots: notificationSlots
|
||||||
.filter(s => !s.name.startsWith('message_'))
|
.filter(s => !s.name.startsWith('message_'))
|
||||||
.map(s => ({ key: s.name, label: s.name.replace(/_/g, ' '), description: s.description, rows: 6 }))
|
.map(s => ({ key: s.name, label: s.name.replace(/_/g, ' '), description: s.description, rows: 6, isDateFormat: false }))
|
||||||
},
|
},
|
||||||
{ group: 'settings', slots: [
|
{ group: 'settings', slots: [
|
||||||
{ key: 'date_format', label: 'dateFormat', description: 'Date+time format', rows: 1, isDateFormat: true },
|
{ key: 'date_format', label: 'dateFormat', description: 'Date+time format', rows: 1, isDateFormat: true },
|
||||||
@@ -170,7 +193,7 @@
|
|||||||
finally { loaded = true; highlightFromUrl(); }
|
finally { loaded = true; highlightFromUrl(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function openNew() { form = defaultForm(); editing = null; showForm = true; activeLocale = 'en'; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; refreshDateFormatPreview(); }
|
function openNew() { form = defaultForm(); editing = null; showForm = true; activeLocale = 'en'; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = ''; refreshDateFormatPreview(); }
|
||||||
function edit(c: TemplateConfig) {
|
function edit(c: TemplateConfig) {
|
||||||
form = {
|
form = {
|
||||||
provider_type: c.provider_type,
|
provider_type: c.provider_type,
|
||||||
@@ -183,6 +206,7 @@
|
|||||||
};
|
};
|
||||||
editing = c.id; showForm = true; activeLocale = 'en';
|
editing = c.id; showForm = true; activeLocale = 'en';
|
||||||
slotPreview = {}; slotErrors = {}; dateFormatPreview = {};
|
slotPreview = {}; slotErrors = {}; dateFormatPreview = {};
|
||||||
|
expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = '';
|
||||||
setTimeout(() => refreshAllPreviews(), 100);
|
setTimeout(() => refreshAllPreviews(), 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,22 +306,26 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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}
|
{#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">
|
<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>
|
<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-3 mt-2">
|
<div class="space-y-2 mt-2">
|
||||||
{#each group.slots as slot}
|
{#each filteredSlots as slot}
|
||||||
<div>
|
{#if slot.isDateFormat}
|
||||||
<div class="flex items-center justify-between mb-1">
|
<div>
|
||||||
<label class="text-xs text-[var(--color-muted-foreground)]">{slot.description || t(`templateConfig.${slot.label}`, slot.label)}</label>
|
<div class="flex items-center justify-between mb-1">
|
||||||
<div class="flex items-center gap-2">
|
<label class="text-xs text-[var(--color-muted-foreground)]">{slot.description || t(`templateConfig.${slot.label}`, slot.label)}</label>
|
||||||
{#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}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{#if slot.isDateFormat}
|
|
||||||
<input value={(form as any)[slot.key]}
|
<input 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(); }}
|
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" />
|
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
|
||||||
@@ -306,8 +334,36 @@
|
|||||||
{:else if dateFormatPreview[slot.key] === null}
|
{:else if dateFormatPreview[slot.key] === null}
|
||||||
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">{t('templateConfig.invalidFormat')}</p>
|
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">{t('templateConfig.invalidFormat')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
</div>
|
||||||
<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} />
|
{:else}
|
||||||
|
<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}
|
||||||
|
</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 slotErrors[slot.key]}
|
||||||
{#if slotErrorTypes[slot.key] === 'undefined'}
|
{#if slotErrorTypes[slot.key] === 'undefined'}
|
||||||
<p class="mt-1 text-xs" style="color: #d97706;">⚠ {t('common.undefinedVar')}: {slotErrors[slot.key]}</p>
|
<p class="mt-1 text-xs" style="color: #d97706;">⚠ {t('common.undefinedVar')}: {slotErrors[slot.key]}</p>
|
||||||
@@ -315,16 +371,12 @@
|
|||||||
<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>
|
<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}
|
||||||
{/if}
|
{/if}
|
||||||
{#if slotPreview[slot.key] && !slotErrors[slot.key]}
|
</CollapsibleSlot>
|
||||||
<div class="mt-1 p-2 bg-[var(--color-muted)] rounded text-sm preview-html">
|
{/if}
|
||||||
<pre class="whitespace-pre-wrap text-xs">{@html sanitizePreview(slotPreview[slot.key])}</pre>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
|
||||||
|
|||||||
Reference in New Issue
Block a user