feat(frontend): group command template slots into 4 logical fieldsets

Mirrors the notification-template page's group layout. Command slots
now split by name prefix into Command Responses, Error Messages
(rate_limited/no_results), Command Descriptions (desc_*), and Usage
Examples (usage_*). Language picker, reset-all, and slot filter are
hoisted above the groups so they apply across all fieldsets, and
empty groups are hidden so providers without usage_* don't render
empty headers.

Drops the orphan cmdTemplateConfig.commandResponsesHint i18n key —
hints.commandResponses replaces it.
This commit is contained in:
2026-04-28 19:06:39 +03:00
parent 9afd38e50e
commit 04c8e3c8b2
3 changed files with 135 additions and 88 deletions
+8 -2
View File
@@ -823,7 +823,11 @@
"defaultCount": "How many results to return when the user doesn't specify a count (1-20).", "defaultCount": "How many results to return when the user doesn't specify a count (1-20).",
"responseMode": "Media: send actual photos. Text: send filenames/links only. Media mode uses more bandwidth.", "responseMode": "Media: send actual photos. Text: send filenames/links only. Media mode uses more bandwidth.",
"botLocale": "Language for command descriptions in Telegram's menu and bot response messages.", "botLocale": "Language for command descriptions in Telegram's menu and bot response messages.",
"rateLimits": "Cooldown in seconds between uses of each command category per chat. 0 = no limit." "rateLimits": "Cooldown in seconds between uses of each command category per chat. 0 = no limit.",
"commandResponses": "Reply templates for each /command. Use {variables} to inject dynamic data.",
"commandErrors": "Fallback messages shown when a command can't run (rate-limited) or returns nothing.",
"commandDescriptions": "Short menu blurbs Telegram shows next to each /command in the chat command picker.",
"commandUsage": "Example invocations rendered inside /help to show users how to call each command."
}, },
"matrixBot": { "matrixBot": {
"titleEmphasis": "matrix", "titleEmphasis": "matrix",
@@ -876,7 +880,9 @@
"noConfigs": "No command template configs yet.", "noConfigs": "No command template configs yet.",
"confirmDelete": "Delete this command template config?", "confirmDelete": "Delete this command template config?",
"commandResponses": "Command Responses", "commandResponses": "Command Responses",
"commandResponsesHint": "Leave a slot empty to use the default hardcoded response." "commandErrors": "Error Messages",
"commandDescriptions": "Command Descriptions",
"commandUsage": "Usage Examples"
}, },
"commandConfig": { "commandConfig": {
"titleEmphasis": "configs", "titleEmphasis": "configs",
+8 -2
View File
@@ -823,7 +823,11 @@
"defaultCount": "Сколько результатов возвращать, если пользователь не указал количество (1-20).", "defaultCount": "Сколько результатов возвращать, если пользователь не указал количество (1-20).",
"responseMode": "Медиа: отправка фото. Текст: только имена файлов/ссылки. Медиа-режим использует больше трафика.", "responseMode": "Медиа: отправка фото. Текст: только имена файлов/ссылки. Медиа-режим использует больше трафика.",
"botLocale": "Язык описаний команд в меню Telegram и ответов бота.", "botLocale": "Язык описаний команд в меню Telegram и ответов бота.",
"rateLimits": "Кулдаун в секундах между использованиями команд в каждом чате. 0 = без ограничений." "rateLimits": "Кулдаун в секундах между использованиями команд в каждом чате. 0 = без ограничений.",
"commandResponses": "Шаблоны ответов на каждую /команду. Используйте {переменные} для динамических данных.",
"commandErrors": "Резервные сообщения, когда команда не может выполниться (превышен лимит) или ничего не возвращает.",
"commandDescriptions": "Короткие подписи в меню команд Telegram, которые показываются рядом с каждой /командой.",
"commandUsage": "Примеры вызовов, отображаемые в /help, чтобы показать пользователям как вызывать каждую команду."
}, },
"matrixBot": { "matrixBot": {
"titleEmphasis": "matrix", "titleEmphasis": "matrix",
@@ -876,7 +880,9 @@
"noConfigs": "Шаблонов команд пока нет.", "noConfigs": "Шаблонов команд пока нет.",
"confirmDelete": "Удалить этот шаблон команд?", "confirmDelete": "Удалить этот шаблон команд?",
"commandResponses": "Ответы команд", "commandResponses": "Ответы команд",
"commandResponsesHint": "Оставьте слот пустым, чтобы использовать ответ по умолчанию." "commandErrors": "Сообщения об ошибках",
"commandDescriptions": "Описания команд",
"commandUsage": "Примеры использования"
}, },
"commandConfig": { "commandConfig": {
"titleEmphasis": "конфигурации", "titleEmphasis": "конфигурации",
@@ -20,6 +20,7 @@
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 CollapsibleSlot from '$lib/components/CollapsibleSlot.svelte';
import Hint from '$lib/components/Hint.svelte';
import EntitySelect, { type EntityItem } from '$lib/components/EntitySelect.svelte'; import EntitySelect, { type EntityItem } from '$lib/components/EntitySelect.svelte';
import { getLocaleMeta } from '$lib/locales'; import { getLocaleMeta } from '$lib/locales';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
@@ -126,11 +127,40 @@
let commandSlots = $derived<SlotDef[]>( let commandSlots = $derived<SlotDef[]>(
allCapabilities[form.provider_type]?.command_slots || [] allCapabilities[form.provider_type]?.command_slots || []
); );
let filteredCmdSlots = $derived(
slotFilter const ERROR_SLOTS = new Set(['rate_limited', 'no_results']);
? commandSlots.filter(s => s.name.toLowerCase().includes(slotFilter.toLowerCase()) || s.description.toLowerCase().includes(slotFilter.toLowerCase()))
: commandSlots /**
); * Group command slots by purpose so the form mirrors how notification
* templates are split (event vs scheduled vs settings).
*
* commandResponses — primary reply templates (/start, /help, /status, data slots)
* commandErrors — fallback messages (rate_limited, no_results)
* commandDescriptions — desc_* slots: short menu blurbs in Telegram's command picker
* commandUsage — usage_* slots: invocation examples shown by /help
*/
let commandSlotGroups = $derived([
{
group: 'commandResponses',
slots: commandSlots.filter(s =>
!s.name.startsWith('desc_') &&
!s.name.startsWith('usage_') &&
!ERROR_SLOTS.has(s.name)
),
},
{
group: 'commandErrors',
slots: commandSlots.filter(s => ERROR_SLOTS.has(s.name)),
},
{
group: 'commandDescriptions',
slots: commandSlots.filter(s => s.name.startsWith('desc_')),
},
{
group: 'commandUsage',
slots: commandSlots.filter(s => s.name.startsWith('usage_')),
},
]);
/** 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 {
@@ -424,93 +454,98 @@
</div> </div>
{/if} {/if}
<fieldset class="border border-[var(--color-border)] rounded-md p-3"> <!-- Language picker -->
<legend class="text-sm font-medium px-1">{t('cmdTemplateConfig.commandResponses')}</legend> <div class="flex items-center gap-2 mb-3">
<p class="text-xs text-[var(--color-muted-foreground)] mb-2">{t('cmdTemplateConfig.commandResponsesHint')}</p> <span class="text-xs font-medium text-[var(--color-muted-foreground)] shrink-0">
{t('templateConfig.language')}
<!-- Language picker --> </span>
<div class="flex items-center gap-2 mb-3"> <div class="flex-1 max-w-xs">
<span class="text-xs font-medium text-[var(--color-muted-foreground)] shrink-0"> <EntitySelect
{t('templateConfig.language')} items={localeItems}
</span> value={activeLocale}
<div class="flex-1 max-w-xs"> size="sm"
<EntitySelect onselect={(v) => { activeLocale = (v as string) || primaryLocale; refreshAllPreviews(); }}
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 commandSlots.length > 4}
<div class="mb-3">
<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> </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} {/if}
</div>
<div class="space-y-2"> <!-- Slot filter -->
{#each filteredCmdSlots as slot} {#if commandSlots.length > 4}
<CollapsibleSlot <div>
label={slot.name} <input type="text" bind:value={slotFilter} placeholder={t('templateConfig.filterSlots')}
description="/{slot.name} {slot.description}" class="w-full px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
expanded={expandedSlots.has(slot.name)} </div>
status={getSlotStatus(slot.name)} {/if}
ontoggle={() => toggleSlot(slot.name)}
> {#each commandSlotGroups.filter(g => g.slots.length > 0) as group}
<div class="flex items-center justify-end gap-2 mb-2"> {@const filteredSlots = slotFilter ? group.slots.filter(s => s.name.toLowerCase().includes(slotFilter.toLowerCase()) || s.description.toLowerCase().includes(slotFilter.toLowerCase())) : group.slots}
{#if slotPreview[slot.name] && !slotErrors[slot.name]} {#if filteredSlots.length > 0}
<button type="button" onclick={() => togglePreview(slot.name)} <fieldset class="border border-[var(--color-border)] rounded-md p-3">
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)]'}"> <legend class="text-sm font-medium px-1">
{t('templateConfig.preview')} {t(`cmdTemplateConfig.${group.group}`)}<Hint text={t(`hints.${group.group}`)} />
</legend>
<div class="space-y-2 mt-2">
{#each filteredSlots 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 getVarsFor(slot.name)}
<button type="button" onclick={() => showVarsFor = slot.name}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
{/if}
<button type="button" onclick={() => resetSlotToDefault(slot.name)}
title={t('templateConfig.resetToDefault')}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">
{t('templateConfig.resetToDefault')}
</button> </button>
{/if}
{#if getVarsFor(slot.name)}
<button type="button" onclick={() => showVarsFor = slot.name}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
{/if}
<button type="button" onclick={() => resetSlotToDefault(slot.name)}
title={t('templateConfig.resetToDefault')}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">
{t('templateConfig.resetToDefault')}
</button>
</div>
{#if showPreviewFor.has(slot.name) && slotPreview[slot.name] && !slotErrors[slot.name]}
<div class="p-2 bg-[var(--color-muted)] rounded text-sm mb-2">
<pre class="whitespace-pre-wrap text-xs">{@html sanitizePreview(slotPreview[slot.name])}</pre>
</div> </div>
{: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={getVarsFor(slot.name) || undefined}
/>
{/if}
{#if slotErrors[slot.name]} {#if showPreviewFor.has(slot.name) && slotPreview[slot.name] && !slotErrors[slot.name]}
{#if slotErrorTypes[slot.name] === 'undefined'} <div class="p-2 bg-[var(--color-muted)] rounded text-sm mb-2">
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">{t('common.undefinedVar')}: {slotErrors[slot.name]}</p> <pre class="whitespace-pre-wrap text-xs">{@html sanitizePreview(slotPreview[slot.name])}</pre>
</div>
{:else} {:else}
<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> <JinjaEditor
value={getSlotValue(slot.name)}
onchange={(v: string) => { setSlotValue(slot.name, v); validateSlot(slot.name, v); }}
rows={3}
errorLine={slotErrorLines[slot.name] || null}
variables={getVarsFor(slot.name) || undefined}
/>
{/if} {/if}
{/if}
</CollapsibleSlot> {#if slotErrors[slot.name]}
{/each} {#if slotErrorTypes[slot.name] === 'undefined'}
</div> <p class="mt-1 text-xs" style="color: var(--color-warning-fg);">⚠ {t('common.undefinedVar')}: {slotErrors[slot.name]}</p>
</fieldset> {:else}
<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}
</CollapsibleSlot>
{/each}
</div>
</fieldset>
{/if}
{/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">
{editing ? t('common.save') : t('common.create')} {editing ? t('common.save') : t('common.create')}