Compare commits

...

3 Commits

Author SHA1 Message Date
alexei.dolgolyov 349e9136a4 chore: release v0.6.5
Release / release (push) Successful in 1m36s
2026-04-28 19:10:49 +03:00
alexei.dolgolyov 04c8e3c8b2 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.
2026-04-28 19:06:39 +03:00
alexei.dolgolyov 9afd38e50e fix(redesign): contain modal scroll chaining and smooth Telegram chat refresh
- Add overscroll-behavior: contain to all in-modal/popup scroll
  containers (Modal body, EntitySelect, MultiEntitySelect, IconPicker,
  IconGridSelect, SearchPalette, TimezoneSelector) so reaching the
  inner scroll boundary no longer scrolls the page underneath.
- Telegram bot Discover Chats no longer collapses the existing chat
  list into a "Loading…" placeholder. Split chatsLoading (initial)
  from chatsRefreshing (Discover); rows are keyed by chat.id with
  flip+fade animations; the list dims with a sweeping shimmer bar
  while the Discover button shows a spinning icon and "Discovering
  chats…" label. Honors prefers-reduced-motion.
2026-04-28 18:52:20 +03:00
15 changed files with 306 additions and 156 deletions
+13 -8
View File
@@ -1,19 +1,23 @@
# v0.6.4 (2026-04-27) # v0.6.5 (2026-04-28)
Fixes Telegram chat actions: the indicator the user picks in the UI is now actually sent, and the phantom "typing…" bubble that lingered for ~5s after a notification arrived is gone. UI polish across the redesign: command-template editing now groups slots into four labelled fieldsets that mirror the notification-template page, modal/popup scrolling no longer drags the page underneath, and Telegram's Discover Chats keeps the existing list visible with a smooth shimmer instead of blanking it to "Loading…".
## User-facing changes ## User-facing changes
### Features
- **Command template slots grouped into 4 fieldsets:** the command-template configs page now mirrors the notification-template layout, splitting slots by name prefix into Command Responses, Error Messages (`rate_limited` / `no_results`), Command Descriptions (`desc_*`), and Usage Examples (`usage_*`). The language picker, reset-all button, and slot filter are hoisted above the groups so they apply across all fieldsets, and empty groups are hidden so providers without `usage_*` slots don't render an empty header ([04c8e3c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/04c8e3c))
### Bug Fixes ### Bug Fixes
- **Telegram chat action UI choice now respected:** `chat_action` was stored in two places — the model column and the config JSON — and dispatch unconditionally overrode the config value with the column. The frontend only ever wrote the JSON path, so picking "upload_photo" / "record_voice" / etc. in the UI silently had no effect on outgoing chat actions. The column is now the single source of truth: the frontend sends `chat_action` top-level, dispatch reads from the column, and a one-time migration backfills existing config values into the column and strips the legacy key ([72dd611](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/72dd611)) - **Modal scroll chaining contained:** scrolling past the inner boundary of a modal or popup no longer scrolls the page underneath. `overscroll-behavior: contain` was added to every in-modal/popup scroll container — Modal body, `EntitySelect`, `MultiEntitySelect`, `IconPicker`, `IconGridSelect`, `SearchPalette`, and `TimezoneSelector` ([9afd38e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9afd38e))
- **Phantom Telegram chat-action indicator:** a long-standing race in the keepalive loop (bare `sleep(4)` + `finally cancel`) could fire one last `sendChatAction` after the response had already arrived, leaving a "typing…" / "uploading…" bubble in the chat for ~5 seconds. Replaced with a stop event + `wait_for` so callers signal stop cleanly via the new `stop_keepalive` helper ([72dd611](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/72dd611)) - **Smoother Telegram Discover Chats refresh:** Discover Chats no longer collapses the existing chat list into a "Loading…" placeholder. The initial-load state (`chatsLoading`) is now split from the refresh state (`chatsRefreshing`); rows are keyed by `chat.id` with flip+fade animations, the list dims with a sweeping shimmer while the Discover button shows a spinning icon and a "Discovering chats…" label. Honors `prefers-reduced-motion` ([9afd38e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9afd38e))
## Development / Internal ## Development / Internal
### Database ### i18n
- **Migration:** new one-shot migration moves `chat_action` from `notification_target.config` JSON into the dedicated column on existing rows, then deletes the legacy key from config. No action required — runs automatically on backend start ([72dd611](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/72dd611)) - Drop orphan `cmdTemplateConfig.commandResponsesHint` key — `hints.commandResponses` replaces it ([04c8e3c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/04c8e3c))
--- ---
@@ -21,7 +25,8 @@ Fixes Telegram chat actions: the indicator the user picks in the UI is now actua
<summary>All Commits</summary> <summary>All Commits</summary>
| Hash | Message | Author | | Hash | Message | Author |
|------------------------------------------------------------------------------------------|----------------------------------------------------------------------|------------------| |------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------|------------------|
| [72dd611](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/72dd611) | fix(telegram): respect chat_action UI choice, drop phantom indicator | alexei.dolgolyov | | [04c8e3c](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/04c8e3c) | feat(frontend): group command template slots into 4 logical fieldsets | alexei.dolgolyov |
| [9afd38e](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/9afd38e) | fix(redesign): contain modal scroll chaining and smooth Telegram chat refresh | alexei.dolgolyov |
</details> </details>
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "notify-bridge-frontend", "name": "notify-bridge-frontend",
"private": true, "private": true,
"version": "0.6.4", "version": "0.6.5",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -304,6 +304,7 @@
/* List */ /* List */
.ep-list { .ep-list {
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin; scrollbar-width: thin;
padding: 0.35rem; padding: 0.35rem;
position: relative; position: relative;
@@ -195,6 +195,7 @@
padding: 0.5rem; padding: 0.5rem;
max-height: 320px; max-height: 320px;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin; scrollbar-width: thin;
} }
:global([data-theme="light"]) .icon-grid-popup { --igs-solid-bg: #fafafe; } :global([data-theme="light"]) .icon-grid-popup { --igs-solid-bg: #fafafe; }
@@ -188,6 +188,7 @@
max-height: 14rem; max-height: 14rem;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
overscroll-behavior: contain;
scrollbar-width: thin; scrollbar-width: thin;
position: relative; position: relative;
z-index: 1; z-index: 1;
+1
View File
@@ -192,6 +192,7 @@
z-index: 1; z-index: 1;
padding: 0 1.5rem 1.5rem; padding: 0 1.5rem 1.5rem;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
} }
.modal-close { .modal-close {
@@ -307,6 +307,7 @@
.mes-list { .mes-list {
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin; scrollbar-width: thin;
padding: 0.25rem 0; padding: 0.25rem 0;
} }
@@ -342,6 +342,7 @@
.sp-results { .sp-results {
max-height: 52vh; max-height: 52vh;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin; scrollbar-width: thin;
padding: 0.35rem; padding: 0.35rem;
position: relative; position: relative;
@@ -530,6 +530,7 @@
.tz-list { .tz-list {
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
/* No top padding — the sticky group head is at top:0 of the /* No top padding — the sticky group head is at top:0 of the
scroll container, so any padding-top would let scrolling scroll container, so any padding-top would let scrolling
items leak into the gap above the sticky header. */ items leak into the gap above the sticky header. */
+9 -2
View File
@@ -475,6 +475,7 @@
"noCommandsForProvider": "This provider type does not support bot commands.", "noCommandsForProvider": "This provider type does not support bot commands.",
"syncCommands": "Sync Commands", "syncCommands": "Sync Commands",
"discoverChats": "Discover chats from Telegram", "discoverChats": "Discover chats from Telegram",
"discoveringChats": "Discovering chats…",
"clickToCopy": "Click to copy chat ID", "clickToCopy": "Click to copy chat ID",
"chatsDiscovered": "Chats discovered", "chatsDiscovered": "Chats discovered",
"chatDeleted": "Chat removed", "chatDeleted": "Chat removed",
@@ -822,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",
@@ -875,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",
+9 -2
View File
@@ -475,6 +475,7 @@
"noCommandsForProvider": "Этот тип провайдера не поддерживает команды бота.", "noCommandsForProvider": "Этот тип провайдера не поддерживает команды бота.",
"syncCommands": "Синхр. команды", "syncCommands": "Синхр. команды",
"discoverChats": "Обнаружить чаты из Telegram", "discoverChats": "Обнаружить чаты из Telegram",
"discoveringChats": "Поиск чатов…",
"clickToCopy": "Нажмите, чтобы скопировать ID чата", "clickToCopy": "Нажмите, чтобы скопировать ID чата",
"chatsDiscovered": "Чаты обнаружены", "chatsDiscovered": "Чаты обнаружены",
"chatDeleted": "Чат удалён", "chatDeleted": "Чат удалён",
@@ -822,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",
@@ -875,7 +880,9 @@
"noConfigs": "Шаблонов команд пока нет.", "noConfigs": "Шаблонов команд пока нет.",
"confirmDelete": "Удалить этот шаблон команд?", "confirmDelete": "Удалить этот шаблон команд?",
"commandResponses": "Ответы команд", "commandResponses": "Ответы команд",
"commandResponsesHint": "Оставьте слот пустым, чтобы использовать ответ по умолчанию." "commandErrors": "Сообщения об ошибках",
"commandDescriptions": "Описания команд",
"commandUsage": "Примеры использования"
}, },
"commandConfig": { "commandConfig": {
"titleEmphasis": "конфигурации", "titleEmphasis": "конфигурации",
+98 -9
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { slide } from 'svelte/transition'; import { slide, fade } from 'svelte/transition';
import { flip } from 'svelte/animate';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api'; import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte'; import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n'; import { t, getLocale } from '$lib/i18n';
@@ -35,6 +36,10 @@
// Per-bot expandable sections // Per-bot expandable sections
let chats = $state<Record<number, TelegramChat[]>>({}); let chats = $state<Record<number, TelegramChat[]>>({});
let chatsLoading = $state<Record<number, boolean>>({}); let chatsLoading = $state<Record<number, boolean>>({});
// Distinct from chatsLoading: refresh keeps the existing list visible
// instead of swapping it for a placeholder, avoiding the disorienting
// "everything disappears" flash during Discover.
let chatsRefreshing = $state<Record<number, boolean>>({});
let expandedSection = $state<Record<number, string>>({}); let expandedSection = $state<Record<number, string>>({});
// Webhook status per bot // Webhook status per bot
@@ -98,12 +103,13 @@
} }
async function discoverChats(botId: number) { async function discoverChats(botId: number) {
chatsLoading = { ...chatsLoading, [botId]: true }; if (chatsRefreshing[botId]) return;
chatsRefreshing = { ...chatsRefreshing, [botId]: true };
try { try {
chats = { ...chats, [botId]: await api<TelegramChat[]>(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' }) }; chats = { ...chats, [botId]: await api<TelegramChat[]>(`/telegram-bots/${botId}/chats/discover`, { method: 'POST' }) };
snackSuccess(t('telegramBot.chatsDiscovered')); snackSuccess(t('telegramBot.chatsDiscovered'));
} catch (err: any) { snackError(err.message); } } catch (err: any) { snackError(err.message); }
chatsLoading = { ...chatsLoading, [botId]: false }; chatsRefreshing = { ...chatsRefreshing, [botId]: false };
} }
async function deleteChat(botId: number, chatDbId: number) { async function deleteChat(botId: number, chatDbId: number) {
@@ -371,12 +377,16 @@
<!-- Chats section --> <!-- Chats section -->
{#if expandedSection[bot.id] === 'chats'} {#if expandedSection[bot.id] === 'chats'}
<div class="mt-3 border-t border-[var(--color-border)] pt-3" in:slide> <div class="mt-3 border-t border-[var(--color-border)] pt-3" in:slide>
{#if chatsLoading[bot.id]} {#if chatsLoading[bot.id] && !chats[bot.id]}
<p class="text-xs text-[var(--color-muted-foreground)]">{t('common.loading')}</p> <p class="text-xs text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
{:else if (chats[bot.id] || []).length === 0} {:else if (chats[bot.id] || []).length === 0 && !chatsRefreshing[bot.id]}
<p class="text-xs text-[var(--color-muted-foreground)]">{t('telegramBot.noChats')}</p> <p class="text-xs text-[var(--color-muted-foreground)]">{t('telegramBot.noChats')}</p>
{:else} {:else}
{@const gridStyle = "display:grid; grid-template-columns:1fr 80px 40px 100px 50px 130px 60px; align-items:center; gap:0.5rem;"} {@const gridStyle = "display:grid; grid-template-columns:1fr 80px 40px 100px 50px 130px 60px; align-items:center; gap:0.5rem;"}
<div class="chat-list-wrap" class:is-refreshing={chatsRefreshing[bot.id]}>
{#if chatsRefreshing[bot.id]}
<div class="chat-shimmer" aria-hidden="true" transition:fade={{ duration: 180 }}></div>
{/if}
<!-- Header --> <!-- Header -->
<div style="{gridStyle} padding:0.25rem 0.5rem; border-bottom:1px solid var(--color-border);" <div style="{gridStyle} padding:0.25rem 0.5rem; border-bottom:1px solid var(--color-border);"
class="text-[0.65rem] font-semibold uppercase tracking-wide text-[var(--color-muted-foreground)]"> class="text-[0.65rem] font-semibold uppercase tracking-wide text-[var(--color-muted-foreground)]">
@@ -389,9 +399,12 @@
<span></span> <span></span>
</div> </div>
<!-- Rows --> <!-- Rows -->
{#each chats[bot.id] as chat} {#each (chats[bot.id] || []) as chat (chat.id)}
<div style={gridStyle} <div style={gridStyle}
class="text-sm px-2 py-1.5 rounded hover:bg-[var(--color-muted)] cursor-pointer" class="chat-row text-sm px-2 py-1.5 rounded hover:bg-[var(--color-muted)] cursor-pointer"
animate:flip={{ duration: 280 }}
in:fade={{ duration: 220, delay: 60 }}
out:fade={{ duration: 140 }}
onclick={(e: MouseEvent) => copyChatId(e, chat.chat_id)} onclick={(e: MouseEvent) => copyChatId(e, chat.chat_id)}
onkeydown={(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); copyChatId(e as unknown as MouseEvent, chat.chat_id); } }} onkeydown={(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); copyChatId(e as unknown as MouseEvent, chat.chat_id); } }}
title={t('telegramBot.clickToCopy')} title={t('telegramBot.clickToCopy')}
@@ -426,11 +439,18 @@
</div> </div>
</div> </div>
{/each} {/each}
{#if chatsRefreshing[bot.id] && (chats[bot.id] || []).length === 0}
<p class="text-xs text-[var(--color-muted-foreground)] py-2 px-2">{t('telegramBot.discoveringChats')}</p>
{/if}
</div>
{/if} {/if}
<button onclick={() => discoverChats(bot.id)} <button onclick={() => discoverChats(bot.id)}
class="text-xs text-[var(--color-primary)] hover:underline mt-2 flex items-center gap-1"> disabled={chatsRefreshing[bot.id]}
class="discover-btn text-xs text-[var(--color-primary)] hover:underline mt-2 flex items-center gap-1 disabled:opacity-70 disabled:cursor-default disabled:no-underline">
<span class="discover-icon" class:is-spinning={chatsRefreshing[bot.id]}>
<MdiIcon name="mdiSync" size={14} /> <MdiIcon name="mdiSync" size={14} />
{t('telegramBot.discoverChats')} </span>
{chatsRefreshing[bot.id] ? t('telegramBot.discoveringChats') : t('telegramBot.discoverChats')}
</button> </button>
</div> </div>
{/if} {/if}
@@ -553,3 +573,72 @@
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} /> <BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<style>
/* Chat list — smooth refresh state.
The list stays mounted during Discover; we only dim it slightly
and run a thin shimmer bar across the top so the user sees
"refreshing" instead of "everything vanished and came back". */
.chat-list-wrap {
position: relative;
transition: opacity 0.25s ease, filter 0.25s ease;
}
.chat-list-wrap.is-refreshing {
opacity: 0.78;
filter: saturate(0.9);
}
.chat-list-wrap.is-refreshing .chat-row {
pointer-events: none;
}
.chat-shimmer {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
overflow: hidden;
border-radius: 2px;
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
z-index: 2;
}
.chat-shimmer::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--color-primary) 70%, transparent) 50%,
transparent 100%
);
transform: translateX(-100%);
animation: chat-shimmer-sweep 1.15s ease-in-out infinite;
}
@keyframes chat-shimmer-sweep {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.discover-icon {
display: inline-flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease;
}
.discover-icon.is-spinning {
animation: discover-spin 1s linear infinite;
}
@keyframes discover-spin {
to { transform: rotate(-360deg); }
}
@media (prefers-reduced-motion: reduce) {
.chat-shimmer::after,
.discover-icon.is-spinning {
animation: none;
}
.chat-list-wrap {
transition: none;
}
}
</style>
@@ -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,10 +454,6 @@
</div> </div>
{/if} {/if}
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
<legend class="text-sm font-medium px-1">{t('cmdTemplateConfig.commandResponses')}</legend>
<p class="text-xs text-[var(--color-muted-foreground)] mb-2">{t('cmdTemplateConfig.commandResponsesHint')}</p>
<!-- Language picker --> <!-- Language picker -->
<div class="flex items-center gap-2 mb-3"> <div class="flex items-center gap-2 mb-3">
<span class="text-xs font-medium text-[var(--color-muted-foreground)] shrink-0"> <span class="text-xs font-medium text-[var(--color-muted-foreground)] shrink-0">
@@ -453,14 +479,21 @@
<!-- Slot filter --> <!-- Slot filter -->
{#if commandSlots.length > 4} {#if commandSlots.length > 4}
<div class="mb-3"> <div>
<input type="text" bind:value={slotFilter} placeholder={t('templateConfig.filterSlots')} <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)]" /> class="w-full px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div> </div>
{/if} {/if}
<div class="space-y-2"> {#each commandSlotGroups.filter(g => g.slots.length > 0) as group}
{#each filteredCmdSlots as slot} {@const filteredSlots = slotFilter ? group.slots.filter(s => s.name.toLowerCase().includes(slotFilter.toLowerCase()) || s.description.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(`cmdTemplateConfig.${group.group}`)}<Hint text={t(`hints.${group.group}`)} />
</legend>
<div class="space-y-2 mt-2">
{#each filteredSlots as slot}
<CollapsibleSlot <CollapsibleSlot
label={slot.name} label={slot.name}
description="/{slot.name} — {slot.description}" description="/{slot.name} — {slot.description}"
@@ -511,6 +544,8 @@
{/each} {/each}
</div> </div>
</fieldset> </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')}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "notify-bridge-core" name = "notify-bridge-core"
version = "0.6.4" version = "0.6.5"
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates" description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "notify-bridge-server" name = "notify-bridge-server"
version = "0.6.4" version = "0.6.5"
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database" description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [