feat: locale-aware notification templates + UX improvements

- Add locale support to notification templates (matching command template
  pattern): TemplateSlot now has locale field with (config_id, slot_name,
  locale) uniqueness, nested API format {slot: {locale: template}}
- Migration merges separate EN/RU system configs into unified per-provider
  configs; seeds create one config per provider with multi-locale slots
- Locale-aware dispatch with EN fallback in NotificationDispatcher
- Frontend locale tabs (EN/RU) on template config editor
- Fix tracking config cards not showing default provider icons
- Global provider filter, search palette, and various UX polish
This commit is contained in:
2026-03-23 19:08:48 +03:00
parent 6a559bfcd2
commit 37388c430c
30 changed files with 628 additions and 318 deletions
@@ -16,19 +16,22 @@
import EntitySelect from '$lib/components/EntitySelect.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { providerDefaultIcon } from '$lib/grid-items';
import type { ServiceProvider, TelegramBot } from '$lib/types';
let allCmdTrackers = $state<any[]>([]);
let filterText = $state('');
let filterProviderId = $state(0);
let effectiveProviderId = $derived(globalProviderFilter.id || filterProviderId);
let trackers = $derived(allCmdTrackers.filter((t: any) =>
(!filterText || t.name.toLowerCase().includes(filterText.toLowerCase())) &&
(!filterProviderId || t.provider_id === filterProviderId)
(!effectiveProviderId || t.provider_id === effectiveProviderId)
));
let providers = $derived(providersCache.items);
let commandConfigs = $derived(commandConfigsCache.items);
let telegramBots = $derived(telegramBotsCache.items);
const providerItems = $derived(providers.map(p => ({ value: p.id, label: p.name, icon: p.icon || 'mdiServer', desc: p.type })));
const providerItems = $derived(providers.map(p => ({ value: p.id, label: p.name, icon: providerDefaultIcon(p), desc: p.type })));
const configItems = $derived(filteredConfigs().map((c: any) => ({ value: c.id, label: c.name, icon: c.icon || 'mdiCog', desc: c.provider_type })));
const botItems = $derived(telegramBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiRobot', desc: b.bot_username ? `@${b.bot_username}` : '' })));
let loaded = $state(false);
@@ -222,9 +225,11 @@
<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">
<EntitySelect items={[{value: 0, label: t('common.allProviders'), icon: 'mdiFilterOff'}, ...providerItems]} bind:value={filterProviderId} placeholder={t('common.allProviders')} />
</div>
{/if}
</div>
{/if}