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:
@@ -14,11 +14,34 @@
|
||||
import Snackbar from '$lib/components/Snackbar.svelte';
|
||||
import SearchPalette from '$lib/components/SearchPalette.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
import {
|
||||
providersCache, notificationTrackersCache, trackingConfigsCache,
|
||||
templateConfigsCache, commandConfigsCache, commandTemplateConfigsCache,
|
||||
commandTrackersCache, actionsCache, telegramBotsCache, emailBotsCache,
|
||||
matrixBotsCache, targetsCache,
|
||||
} from '$lib/stores/caches.svelte';
|
||||
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
||||
import { providerDefaultIcon } from '$lib/grid-items';
|
||||
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
const auth = getAuth();
|
||||
const theme = getTheme();
|
||||
|
||||
let allProviders = $derived(providersCache.items);
|
||||
|
||||
let providerFilterItems = $derived([
|
||||
{ value: 0, icon: 'mdiFilterOff', label: t('common.allProviders'), desc: '' },
|
||||
...allProviders.map(p => ({ value: p.id, icon: providerDefaultIcon(p), label: p.name, desc: p.type })),
|
||||
]);
|
||||
let providerFilterValue = $state(globalProviderFilter.id ?? 0);
|
||||
|
||||
// Sync filter value → store
|
||||
$effect(() => {
|
||||
const v = providerFilterValue;
|
||||
globalProviderFilter.set(v === 0 ? null : v);
|
||||
});
|
||||
|
||||
let showPasswordForm = $state(false);
|
||||
let redirecting = $state(false);
|
||||
let openSearch: (() => void) | undefined;
|
||||
@@ -43,8 +66,38 @@
|
||||
let collapsed = $state(false);
|
||||
let isMac = $derived(typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent));
|
||||
|
||||
// Nav counts for badges
|
||||
let navCounts = $state<Record<string, number>>({});
|
||||
// Nav counts — computed reactively from caches + global provider filter
|
||||
let navCounts = $derived.by(() => {
|
||||
const pid = globalProviderFilter.id;
|
||||
const ptype = globalProviderFilter.providerType;
|
||||
|
||||
const filterById = <T extends { provider_id?: number }>(items: T[]) =>
|
||||
pid ? items.filter(i => i.provider_id === pid) : items;
|
||||
const filterByType = <T extends { provider_type?: string }>(items: T[]) =>
|
||||
ptype ? items.filter(i => i.provider_type === ptype) : items;
|
||||
|
||||
const targets = targetsCache.items;
|
||||
return {
|
||||
providers: pid ? 1 : providersCache.items.length,
|
||||
notification_trackers: filterById(notificationTrackersCache.items as any[]).length,
|
||||
tracking_configs: filterByType(trackingConfigsCache.items as any[]).length,
|
||||
template_configs: filterByType(templateConfigsCache.items as any[]).length,
|
||||
command_trackers: filterById(commandTrackersCache.items as any[]).length,
|
||||
command_configs: filterByType(commandConfigsCache.items as any[]).length,
|
||||
command_template_configs: filterByType(commandTemplateConfigsCache.items as any[]).length,
|
||||
actions: filterById(actionsCache.items as any[]).length,
|
||||
telegram_bots: telegramBotsCache.items.length,
|
||||
email_bots: emailBotsCache.items.length,
|
||||
matrix_bots: matrixBotsCache.items.length,
|
||||
targets_telegram: targets.filter(t => t.type === 'telegram').length,
|
||||
targets_webhook: targets.filter(t => t.type === 'webhook').length,
|
||||
targets_email: targets.filter(t => t.type === 'email').length,
|
||||
targets_discord: targets.filter(t => t.type === 'discord').length,
|
||||
targets_slack: targets.filter(t => t.type === 'slack').length,
|
||||
targets_ntfy: targets.filter(t => t.type === 'ntfy').length,
|
||||
targets_matrix: targets.filter(t => t.type === 'matrix').length,
|
||||
} as Record<string, number>;
|
||||
});
|
||||
|
||||
interface NavItem {
|
||||
href: string;
|
||||
@@ -170,9 +223,22 @@
|
||||
redirecting = true;
|
||||
goto('/login');
|
||||
}
|
||||
// Load nav counts
|
||||
// Load all caches for nav counts + global provider filter
|
||||
if (auth.user) {
|
||||
try { navCounts = await api('/status/counts'); } catch (e) { console.warn('Failed to load nav counts:', e); }
|
||||
Promise.all([
|
||||
providersCache.fetch(),
|
||||
notificationTrackersCache.fetch(),
|
||||
trackingConfigsCache.fetch(),
|
||||
templateConfigsCache.fetch(),
|
||||
commandTrackersCache.fetch(),
|
||||
commandConfigsCache.fetch(),
|
||||
commandTemplateConfigsCache.fetch(),
|
||||
actionsCache.fetch(),
|
||||
telegramBotsCache.fetch(),
|
||||
emailBotsCache.fetch(),
|
||||
matrixBotsCache.fetch(),
|
||||
targetsCache.fetch(),
|
||||
]).catch(e => console.warn('Failed to load caches for nav counts:', e));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -260,11 +326,16 @@
|
||||
<div class="flex items-center {collapsed ? 'justify-center p-2.5' : 'justify-between px-5 py-4'}" style="border-bottom: 1px solid var(--color-border);">
|
||||
{#if !collapsed}
|
||||
<div class="animate-fade-slide-in">
|
||||
<h1 class="text-base font-semibold tracking-tight" style="color: var(--color-foreground);">
|
||||
<span style="color: var(--color-primary);">Notify</span> Bridge
|
||||
<h1 class="text-base font-semibold tracking-tight flex items-center gap-1.5" style="color: var(--color-foreground);">
|
||||
{#if globalProviderFilter.provider}
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon(globalProviderFilter.provider)} size={18} /></span>
|
||||
{/if}
|
||||
<span><span style="color: var(--color-primary);">Notify</span> Bridge</span>
|
||||
</h1>
|
||||
<p class="text-[0.7rem] text-[var(--color-muted-foreground)] mt-0.5 tracking-wide uppercase">{t('app.tagline')}</p>
|
||||
</div>
|
||||
{:else if globalProviderFilter.provider}
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon(globalProviderFilter.provider)} size={18} /></span>
|
||||
{/if}
|
||||
<button onclick={toggleSidebar}
|
||||
class="sidebar-icon-btn flex items-center justify-center w-8 h-8 rounded-lg transition-all duration-200"
|
||||
@@ -286,6 +357,25 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Global provider filter -->
|
||||
{#if allProviders.length > 1}
|
||||
<div class="{collapsed ? 'px-2 py-1' : 'px-3 py-1.5'}" style="border-bottom: 1px solid var(--color-border);">
|
||||
{#if collapsed}
|
||||
<button onclick={() => {
|
||||
const ids = [0, ...allProviders.map(p => p.id)];
|
||||
const idx = ids.indexOf(providerFilterValue);
|
||||
providerFilterValue = ids[(idx + 1) % ids.length];
|
||||
}}
|
||||
class="provider-filter-btn flex items-center justify-center w-full py-1.5 rounded-lg text-sm transition-all duration-200"
|
||||
title={globalProviderFilter.provider?.name || t('common.allProviders')}>
|
||||
<MdiIcon name={globalProviderFilter.provider ? providerDefaultIcon(globalProviderFilter.provider) : 'mdiFilterOff'} size={16} />
|
||||
</button>
|
||||
{:else}
|
||||
<IconGridSelect items={providerFilterItems} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 3)} compact />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Nav -->
|
||||
<nav class="flex-1 p-2 space-y-0.5 overflow-y-auto">
|
||||
{#each navEntries as entry}
|
||||
@@ -467,6 +557,38 @@
|
||||
.mobile-nav { display: flex !important; }
|
||||
}
|
||||
|
||||
/* Provider filter chips */
|
||||
.provider-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.provider-chip:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.provider-chip.active {
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.provider-filter-btn {
|
||||
color: var(--color-muted-foreground);
|
||||
background: transparent;
|
||||
}
|
||||
.provider-filter-btn:hover {
|
||||
color: var(--color-primary);
|
||||
background: var(--color-muted);
|
||||
}
|
||||
|
||||
/* Sidebar icon button (toggle, logout) */
|
||||
.sidebar-icon-btn {
|
||||
color: var(--color-muted-foreground);
|
||||
|
||||
Reference in New Issue
Block a user