From b1ab5b884ff9357442c6934b3b948871dc8e674e Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 24 Mar 2026 17:06:03 +0300 Subject: [PATCH] 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. --- .../src/lib/components/CollapsibleSlot.svelte | 117 ++++++++++++++++++ frontend/src/lib/i18n/en.json | 3 +- frontend/src/lib/i18n/ru.json | 3 +- .../command-template-configs/+page.svelte | 95 +++++++++++--- .../src/routes/template-configs/+page.svelte | 100 +++++++++++---- 5 files changed, 274 insertions(+), 44 deletions(-) create mode 100644 frontend/src/lib/components/CollapsibleSlot.svelte diff --git a/frontend/src/lib/components/CollapsibleSlot.svelte b/frontend/src/lib/components/CollapsibleSlot.svelte new file mode 100644 index 0000000..e6c1175 --- /dev/null +++ b/frontend/src/lib/components/CollapsibleSlot.svelte @@ -0,0 +1,117 @@ + + +
+ + + {#if expanded} +
+ {@render children()} +
+ {/if} +
+ + diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json index ddb8ad7..4219788 100644 --- a/frontend/src/lib/i18n/en.json +++ b/frontend/src/lib/i18n/en.json @@ -539,7 +539,8 @@ "assetFields": "Asset fields (in {% for asset in added_assets %})", "albumFields": "Album fields (in {% for album in albums %})", "confirmDelete": "Delete this template config?", - "invalidFormat": "Invalid format string" + "invalidFormat": "Invalid format string", + "filterSlots": "Filter slots..." }, "templateVars": { "message_assets_added": { diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json index cd48bef..7d82532 100644 --- a/frontend/src/lib/i18n/ru.json +++ b/frontend/src/lib/i18n/ru.json @@ -539,7 +539,8 @@ "assetFields": "Поля файла (в {% for asset in added_assets %})", "albumFields": "Поля альбома (в {% for album in albums %})", "confirmDelete": "Удалить эту конфигурацию шаблона?", - "invalidFormat": "Некорректная строка формата" + "invalidFormat": "Некорректная строка формата", + "filterSlots": "Фильтр слотов..." }, "templateVars": { "message_assets_added": { diff --git a/frontend/src/routes/command-template-configs/+page.svelte b/frontend/src/routes/command-template-configs/+page.svelte index e7b4bff..bf97b36 100644 --- a/frontend/src/routes/command-template-configs/+page.svelte +++ b/frontend/src/routes/command-template-configs/+page.svelte @@ -17,6 +17,7 @@ import { providerTypeItems as providerTypeItemsFn, providerTypeFilterItems } 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 { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; import { highlightFromUrl } from '$lib/highlight'; import { globalProviderFilter } from '$lib/stores/provider-filter.svelte'; @@ -60,6 +61,28 @@ let varsRef = $state>({}); let showVarsFor = $state(null); let activeLocale = $state('en'); + let expandedSlots = $state>(new Set()); + let slotFilter = $state(''); + let showPreviewFor = $state>(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 let allCapabilities = $state>({}); @@ -67,6 +90,11 @@ let commandSlots = $derived( 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 = () => ({ provider_type: '', @@ -157,6 +185,9 @@ activeLocale = 'en'; slotPreview = {}; slotErrors = {}; + expandedSlots = new Set(); + showPreviewFor = new Set(); + slotFilter = ''; } function edit(c: CmdTemplateConfig) { @@ -177,6 +208,9 @@ activeLocale = 'en'; slotPreview = {}; slotErrors = {}; + expandedSlots = new Set(); + showPreviewFor = new Set(); + slotFilter = ''; setTimeout(() => refreshAllPreviews(), 100); } @@ -216,6 +250,9 @@ activeLocale = 'en'; slotPreview = {}; slotErrors = {}; + expandedSlots = new Set(); + showPreviewFor = new Set(); + slotFilter = ''; setTimeout(() => refreshAllPreviews(), 100); } @@ -293,23 +330,50 @@ {/each} -
- {#each commandSlots as slot} -
-
- + + {#if commandSlots.length > 4} +
+ +
+ {/if} + +
+ {#each filteredCmdSlots as slot} + toggleSlot(slot.name)} + > +
+ {#if slotPreview[slot.name] && !slotErrors[slot.name]} + + {/if} {#if varsRef[slot.name]} {/if}
- { setSlotValue(slot.name, v); validateSlot(slot.name, v); }} - rows={3} - errorLine={slotErrorLines[slot.name] || null} - variables={varsRef[slot.name] || undefined} - /> + + {#if showPreviewFor.has(slot.name) && slotPreview[slot.name] && !slotErrors[slot.name]} +
+
{@html sanitizePreview(slotPreview[slot.name])}
+
+ {:else} + { 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 slotErrorTypes[slot.name] === 'undefined'}

⚠ {t('common.undefinedVar')}: {slotErrors[slot.name]}

@@ -317,12 +381,7 @@

✕ {t('common.syntaxError')}: {slotErrors[slot.name]}{slotErrorLines[slot.name] ? ` (${t('common.line')} ${slotErrorLines[slot.name]})` : ''}

{/if} {/if} - {#if slotPreview[slot.name] && !slotErrors[slot.name]} -
-
{@html sanitizePreview(slotPreview[slot.name])}
-
- {/if} -
+ {/each}
diff --git a/frontend/src/routes/template-configs/+page.svelte b/frontend/src/routes/template-configs/+page.svelte index 37f357c..4b978e4 100644 --- a/frontend/src/routes/template-configs/+page.svelte +++ b/frontend/src/routes/template-configs/+page.svelte @@ -18,6 +18,7 @@ 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 { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; import { highlightFromUrl } from '$lib/highlight'; import { globalProviderFilter } from '$lib/stores/provider-filter.svelte'; @@ -44,10 +45,32 @@ let slotErrorTypes = $state>({}); let validateTimers: Record> = {}; let dateFormatPreview = $state>({}); + let expandedSlots = $state>(new Set()); + let slotFilter = $state(''); + let showPreviewFor = $state>(new Set()); const LOCALES = ['en', 'ru'] as const; let activeLocale = $state('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. */ function getSlotValue(slotName: string): string { return form.slots[slotName]?.[activeLocale] || ''; @@ -146,11 +169,11 @@ 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: 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 .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: [ { key: 'date_format', label: 'dateFormat', description: 'Date+time format', rows: 1, isDateFormat: true }, @@ -170,7 +193,7 @@ 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) { form = { provider_type: c.provider_type, @@ -183,6 +206,7 @@ }; editing = c.id; showForm = true; activeLocale = 'en'; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; + expandedSlots = new Set(); showPreviewFor = new Set(); slotFilter = ''; setTimeout(() => refreshAllPreviews(), 100); } @@ -282,22 +306,26 @@ {/each}
+ + {#if notificationSlots.length > 4} +
+ +
+ {/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}
{t(`templateConfig.${group.group}`)}{#if group.group === 'eventMessages'}{:else if group.group === 'scheduledMessages'}{/if} -
- {#each group.slots as slot} -
-
- -
- {#if varsRef[slot.key]} - - {/if} +
+ {#each filteredSlots as slot} + {#if slot.isDateFormat} +
+
+
-
- {#if slot.isDateFormat} { (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" /> @@ -306,8 +334,36 @@ {:else if dateFormatPreview[slot.key] === null}

{t('templateConfig.invalidFormat')}

{/if} - {:else} - { setSlotValue(slot.key, v); validateSlot(slot.key, v); }} rows={slot.rows || 3} errorLine={slotErrorLines[slot.key] || null} variables={varsRef[slot.key] || undefined} /> +
+ {:else} + toggleSlot(slot.key)} + > +
+ {#if slotPreview[slot.key] && !slotErrors[slot.key]} + + {/if} + {#if varsRef[slot.key]} + + {/if} +
+ + {#if showPreviewFor.has(slot.key) && slotPreview[slot.key] && !slotErrors[slot.key]} +
+
{@html sanitizePreview(slotPreview[slot.key])}
+
+ {:else} + { 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'}

⚠ {t('common.undefinedVar')}: {slotErrors[slot.key]}

@@ -315,16 +371,12 @@

✕ {t('common.syntaxError')}: {slotErrors[slot.key]}{slotErrorLines[slot.key] ? ` (${t('common.line')} ${slotErrorLines[slot.key]})` : ''}

{/if} {/if} - {#if slotPreview[slot.key] && !slotErrors[slot.key]} -
-
{@html sanitizePreview(slotPreview[slot.key])}
-
- {/if} - {/if} -
+ + {/if} {/each}
+ {/if} {/each}