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
+128 -6
View File
@@ -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);
+7 -5
View File
@@ -10,14 +10,15 @@
import EventChart from '$lib/components/EventChart.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import { eventTypeFilterItems, sortFilterItems } from '$lib/grid-items';
import { eventTypeFilterItems, sortFilterItems, providerDefaultIcon } from '$lib/grid-items';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import type { DashboardStatus } from '$lib/types';
let status = $state<DashboardStatus | null>(null);
let providers = $derived(providersCache.items);
const providerFilterItems = $derived([
{ value: '', label: t('dashboard.allProviders'), icon: 'mdiFilterOff' },
...providers.map(p => ({ value: p.id, label: p.name, icon: p.icon || 'mdiServer', desc: p.type })),
...providers.map(p => ({ value: p.id, label: p.name, icon: providerDefaultIcon(p), desc: p.type })),
]);
let chartDays = $state<{ date: string; [eventType: string]: string | number }[]>([]);
let loaded = $state(false);
@@ -31,6 +32,7 @@
// Event filters
let filterEventType = $state('');
let filterProviderId = $state('');
let effectiveProviderId = $derived(globalProviderFilter.id ? String(globalProviderFilter.id) : filterProviderId);
let filterSearch = $state('');
let filterSort = $state('newest');
let eventsLimit = $state(calcPageSize());
@@ -66,7 +68,7 @@
function buildFilterParams(): URLSearchParams {
const params = new URLSearchParams();
if (filterEventType) params.set('event_type', filterEventType);
if (filterProviderId) params.set('provider_id', filterProviderId);
if (effectiveProviderId) params.set('provider_id', effectiveProviderId);
if (filterSearch) params.set('search', filterSearch);
return params;
}
@@ -99,7 +101,7 @@
// Auto-apply when filter values change (via IconGridSelect bind:value)
let _prevFilterKey = '';
$effect(() => {
const key = `${filterEventType}|${filterProviderId}|${filterSort}`;
const key = `${filterEventType}|${effectiveProviderId}|${filterSort}`;
if (loaded && key !== _prevFilterKey && _prevFilterKey !== '') {
applyFilters();
}
@@ -260,7 +262,7 @@
class="w-full px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div class="w-40"><IconGridSelect items={eventTypeFilterItems()} bind:value={filterEventType} columns={3} compact /></div>
<div class="w-40"><IconGridSelect items={providerFilterItems} bind:value={filterProviderId} columns={2} compact /></div>
{#if !globalProviderFilter.id}<div class="w-40"><IconGridSelect items={providerFilterItems} bind:value={filterProviderId} columns={2} compact /></div>{/if}
<div class="w-36"><IconGridSelect items={sortFilterItems()} bind:value={filterSort} columns={2} compact /></div>
</div>
+12 -4
View File
@@ -13,8 +13,11 @@
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import CrossLink from '$lib/components/CrossLink.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 RuleEditor from './RuleEditor.svelte';
import ExecutionHistory from './ExecutionHistory.svelte';
import type { Action, ActionRule } from '$lib/types';
@@ -23,7 +26,8 @@
let providers = $derived(providersCache.items);
let filterText = $state('');
let actions = $derived(allActions.filter((a: Action) =>
!filterText || a.name.toLowerCase().includes(filterText.toLowerCase()) || a.action_type.toLowerCase().includes(filterText.toLowerCase())
(!filterText || a.name.toLowerCase().includes(filterText.toLowerCase()) || a.action_type.toLowerCase().includes(filterText.toLowerCase())) &&
(!globalProviderFilter.id || a.provider_id === globalProviderFilter.id)
));
let showForm = $state(false);
@@ -49,7 +53,7 @@
}));
let providerItems = $derived(actionProviders.map((p: any) => ({
value: p.id, label: p.name, icon: p.icon || 'mdiServer', desc: p.type,
value: p.id, label: p.name, icon: providerDefaultIcon(p), desc: p.type,
})));
// Action types for selected provider
@@ -136,8 +140,12 @@
executing = { ...executing, [id]: false };
}
function getProvider(providerId: number) {
return providers.find((p: any) => p.id === providerId);
}
function getProviderName(providerId: number): string {
return providers.find((p: any) => p.id === providerId)?.name || '?';
return getProvider(providerId)?.name || '?';
}
function formatSchedule(action: Action): string {
@@ -297,7 +305,7 @@
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{action.action_type}</span>
</div>
<div class="flex items-center gap-3 text-xs text-[var(--color-muted-foreground)]">
<span>{getProviderName(action.provider_id)}</span>
<CrossLink href="/providers" icon={providerDefaultIcon(getProvider(action.provider_id) || {})} label={getProviderName(action.provider_id)} entityId={action.provider_id} />
<span>{formatSchedule(action)}</span>
<span>{action.rules?.length || 0} {t('actions.rules')}</span>
{#if action.last_run_status}
@@ -112,6 +112,7 @@
name: '',
rule_config: {
criteria: { person_ids: [], person_names: [], query: '', asset_type: 'all', date_from: '', date_to: '', favorite_only: false },
target_album_ids: [] as string[], target_album_names: [] as string[],
target_album_id: '', target_album_name: '',
create_album_if_missing: false, create_album_name: '',
},
+67 -16
View File
@@ -10,6 +10,7 @@
import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import { snackSuccess, snackError, snackInfo } from '$lib/stores/snackbar.svelte';
import type { TelegramBot, TelegramChat } from '$lib/types';
@@ -104,6 +105,40 @@
} catch (err: any) { snackError(err.message); }
}
const LANG_ITEMS = [
{ value: '', label: '—', icon: 'mdiTranslate', desc: 'Auto' },
{ value: 'en', label: 'EN', icon: 'mdiAlphaECircle', desc: 'English' },
{ value: 'ru', label: 'RU', icon: 'mdiAlphaRCircle', desc: 'Русский' },
{ value: 'uk', label: 'UK', icon: 'mdiAlphaUCircle', desc: 'Українська' },
{ value: 'de', label: 'DE', icon: 'mdiAlphaDCircle', desc: 'Deutsch' },
{ value: 'fr', label: 'FR', icon: 'mdiAlphaFCircle', desc: 'Français' },
{ value: 'es', label: 'ES', icon: 'mdiAlphaECircle', desc: 'Español' },
{ value: 'it', label: 'IT', icon: 'mdiAlphaICircle', desc: 'Italiano' },
{ value: 'pt', label: 'PT', icon: 'mdiAlphaPCircle', desc: 'Português' },
{ value: 'zh', label: 'ZH', icon: 'mdiAlphaZCircle', desc: '中文' },
{ value: 'ja', label: 'JA', icon: 'mdiAlphaJCircle', desc: '日本語' },
{ value: 'ko', label: 'KO', icon: 'mdiAlphaKCircle', desc: '한국어' },
{ value: 'pl', label: 'PL', icon: 'mdiAlphaPCircle', desc: 'Polski' },
{ value: 'nl', label: 'NL', icon: 'mdiAlphaNCircle', desc: 'Nederlands' },
{ value: 'tr', label: 'TR', icon: 'mdiAlphaTCircle', desc: 'Türkçe' },
{ value: 'ar', label: 'AR', icon: 'mdiAlphaACircle', desc: 'العربية' },
{ value: 'hi', label: 'HI', icon: 'mdiAlphaHCircle', desc: 'हिन्दी' },
];
async function updateChatLanguage(botId: number, chat: TelegramChat, lang: string) {
try {
await api(`/telegram-bots/${botId}/chats/${chat.id}`, {
method: 'PUT',
body: JSON.stringify({ language_code: lang }),
});
// Update local state immutably
chats[botId] = (chats[botId] || []).map(c =>
c.id === chat.id ? { ...c, language_code: lang } : c
);
snackSuccess(t('telegramBot.languageUpdated'));
} catch (err: any) { snackError(err.message); }
}
async function loadListenerStatus(botId: number) {
botListenerLoading = { ...botListenerLoading, [botId]: true };
try {
@@ -302,28 +337,43 @@
{:else if (chats[bot.id] || []).length === 0}
<p class="text-xs text-[var(--color-muted-foreground)]">{t('telegramBot.noChats')}</p>
{:else}
<div class="space-y-1">
{#each chats[bot.id] as chat}
<div class="flex items-center justify-between text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)] cursor-pointer"
onclick={(e: MouseEvent) => copyChatId(e, chat.chat_id)}
title={t('telegramBot.clickToCopy')}
role="button" tabindex="0">
<div class="flex items-center gap-2">
<span class="font-medium">{chat.title || chat.username || 'Unknown'}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chatTypeLabel(chat.type)}</span>
{#if chat.language_code}<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chat.language_code.toUpperCase()}</span>{/if}
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.chat_id}</span>
</div>
<div class="flex items-center gap-1">
{@const gridStyle = "display:grid; grid-template-columns:1fr 80px 100px 130px 60px; align-items:center; gap:0.5rem;"}
<!-- Header -->
<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)]">
<span>{t('telegramBot.chatName')}</span>
<span style="text-align:center">{t('telegramBot.chatType')}</span>
<span style="text-align:center">{t('telegramBot.chatLang')}</span>
<span style="text-align:center">{t('telegramBot.chatId')}</span>
<span></span>
</div>
<!-- Rows -->
{#each chats[bot.id] as chat}
<div style={gridStyle}
class="text-sm px-2 py-1.5 rounded hover:bg-[var(--color-muted)] cursor-pointer"
onclick={(e: MouseEvent) => copyChatId(e, chat.chat_id)}
title={t('telegramBot.clickToCopy')}
role="button" tabindex="0">
<span class="font-medium truncate">{chat.title || chat.username || 'Unknown'}</span>
<span style="text-align:center" class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{chatTypeLabel(chat.type)}</span>
<div style="justify-self:center" onclick={(e: MouseEvent) => e.stopPropagation()}>
<EntitySelect
items={LANG_ITEMS}
value={chat.language_code || ''}
size="sm"
onselect={(val) => updateChatLanguage(bot.id, chat, String(val ?? ''))}
/>
</div>
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.chat_id}</span>
<div style="justify-self:end" class="flex items-center gap-1">
<IconButton icon="mdiSend" title="Test message" size={14}
onclick={(e: MouseEvent) => testChat(e, bot.id, chat.chat_id)}
disabled={chatTesting[`${bot.id}_${chat.chat_id}`]} />
<IconButton icon="mdiDelete" title={t('common.delete')} size={14}
onclick={(e: MouseEvent) => { e.stopPropagation(); deleteChat(bot.id, chat.id); }} variant="danger" />
</div>
</div>
{/each}
</div>
</div>
{/each}
{/if}
<button onclick={() => discoverChats(bot.id)}
class="text-xs text-[var(--color-primary)] hover:underline mt-2 flex items-center gap-1">
@@ -435,3 +485,4 @@
<ConfirmModal open={confirmDelete !== null} message={t('telegramBot.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
@@ -17,6 +17,7 @@
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 type { CommandConfig } from '$lib/types';
function templateName(id: number | null): string {
@@ -28,9 +29,10 @@
let allCmdConfigs = $derived(commandConfigsCache.items);
let filterText = $state('');
let filterType = $state('');
let effectiveType = $derived(globalProviderFilter.providerType || filterType);
let configs = $derived(allCmdConfigs.filter(c =>
(!filterText || c.name.toLowerCase().includes(filterText.toLowerCase())) &&
(!filterType || c.provider_type === filterType)
(!effectiveType || c.provider_type === effectiveType)
));
let cmdTemplateConfigs = $derived(commandTemplateConfigsCache.items);
const templateItems = $derived(cmdTemplateConfigs
@@ -240,9 +242,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">
<IconGridSelect items={providerTypeFilterItems()} bind:value={filterType} columns={2} compact />
</div>
{/if}
</div>
{/if}
@@ -18,6 +18,7 @@
import JinjaEditor from '$lib/components/JinjaEditor.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
interface CmdTemplateConfig {
id: number;
@@ -40,9 +41,10 @@
let allCmdTplConfigs = $state<CmdTemplateConfig[]>([]);
let filterText = $state('');
let filterType = $state('');
let effectiveType = $derived(globalProviderFilter.providerType || filterType);
let configs = $derived(allCmdTplConfigs.filter(c =>
(!filterText || c.name.toLowerCase().includes(filterText.toLowerCase())) &&
(!filterType || c.provider_type === filterType)
(!effectiveType || c.provider_type === effectiveType)
));
let loaded = $state(false);
let showForm = $state(false);
@@ -336,9 +338,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">
<IconGridSelect items={providerTypeFilterItems()} bind:value={filterType} columns={2} compact />
</div>
{/if}
</div>
{/if}
@@ -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}
@@ -14,6 +14,8 @@
import EntitySelect from '$lib/components/EntitySelect.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import { providerDefaultIcon } from '$lib/grid-items';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import type { Tracker, TrackerTarget, TrackingConfig, TemplateConfig, NotificationTarget } from '$lib/types';
import TrackerForm from './TrackerForm.svelte';
@@ -26,12 +28,13 @@
let allNotificationTrackers = $state<Tracker[]>([]);
let filterText = $state('');
let filterProviderId = $state(0);
let effectiveProviderId = $derived(globalProviderFilter.id || filterProviderId);
let notificationTrackers = $derived(allNotificationTrackers.filter(t =>
(!filterText || t.name.toLowerCase().includes(filterText.toLowerCase())) &&
(!filterProviderId || t.provider_id === filterProviderId)
(!effectiveProviderId || t.provider_id === effectiveProviderId)
));
let providers = $derived(providersCache.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 })));
let targets = $derived(targetsCache.items);
let trackingConfigs = $derived(trackingConfigsCache.items);
let templateConfigs = $derived(templateConfigsCache.items);
@@ -379,9 +382,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}
+5 -3
View File
@@ -13,7 +13,8 @@
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import { providerTypeItems } from '$lib/grid-items';
import { providerTypeItems, providerDefaultIcon } from '$lib/grid-items';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import type { ServiceProvider } from '$lib/types';
@@ -21,7 +22,8 @@
let allProviders = $derived(providersCache.items);
let filterText = $state('');
let providers = $derived(allProviders.filter(p =>
!filterText || p.name.toLowerCase().includes(filterText.toLowerCase()) || p.type.toLowerCase().includes(filterText.toLowerCase())
(!filterText || p.name.toLowerCase().includes(filterText.toLowerCase()) || p.type.toLowerCase().includes(filterText.toLowerCase())) &&
(!globalProviderFilter.id || p.id === globalProviderFilter.id)
));
let showForm = $state(false);
let editing = $state<number | null>(null);
@@ -249,7 +251,7 @@
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="health-dot {health[provider.id] === true ? 'online' : health[provider.id] === false ? 'offline' : 'checking'}"></div>
<span style="color: var(--color-primary);"><MdiIcon name={provider.icon || 'mdiServer'} size={20} /></span>
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon(provider)} size={20} /></span>
<div>
<div class="flex items-center gap-2">
<p class="font-medium">{provider.name}</p>
@@ -19,14 +19,16 @@
import JinjaEditor from '$lib/components/JinjaEditor.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import type { TemplateConfig } from '$lib/types';
let allTemplateConfigs = $derived(templateConfigsCache.items);
let filterText = $state('');
let filterType = $state('');
let effectiveType = $derived(globalProviderFilter.providerType || filterType);
let configs = $derived(allTemplateConfigs.filter(c =>
(!filterText || c.name.toLowerCase().includes(filterText.toLowerCase())) &&
(!filterType || c.provider_type === filterType)
(!effectiveType || c.provider_type === effectiveType)
));
let loaded = $state(false);
let varsRef = $state<Record<string, any>>({});
@@ -42,6 +44,21 @@
let validateTimers: Record<string, ReturnType<typeof setTimeout>> = {};
let dateFormatPreview = $state<Record<string, string | null>>({});
const LOCALES = ['en', 'ru'] as const;
let activeLocale = $state<string>('en');
/** Get slot template for current locale, with fallback. */
function getSlotValue(slotName: string): string {
return form.slots[slotName]?.[activeLocale] || '';
}
/** Set slot template for current locale (immutable update). */
function setSlotValue(slotName: string, value: string) {
form.slots = {
...form.slots,
[slotName]: { ...(form.slots[slotName] || {}), [activeLocale]: value }
};
}
function refreshDateFormatPreview() {
clearTimeout(validateTimers['_dateFmt']);
validateTimers['_dateFmt'] = setTimeout(async () => {
@@ -97,7 +114,7 @@
for (const group of templateSlots) {
for (const slot of group.slots) {
if (slot.isDateFormat) continue;
const template = form.slots[slot.key] || '';
const template = getSlotValue(slot.key);
if (template) {
validateSlot(slot.key, template, true);
}
@@ -108,7 +125,7 @@
const defaultForm = () => ({
provider_type: 'immich', name: '', description: '', icon: '',
slots: {} as Record<string, string>,
slots: {} as Record<string, Record<string, string>>,
date_format: '%d.%m.%Y, %H:%M UTC',
date_only_format: '%d.%m.%Y',
});
@@ -152,18 +169,18 @@
finally { loaded = true; highlightFromUrl(); }
}
function openNew() { form = defaultForm(); editing = null; showForm = true; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; refreshDateFormatPreview(); }
function openNew() { form = defaultForm(); editing = null; showForm = true; activeLocale = 'en'; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; refreshDateFormatPreview(); }
function edit(c: TemplateConfig) {
form = {
provider_type: c.provider_type,
name: c.name,
description: c.description || '',
icon: c.icon || '',
slots: { ...c.slots },
slots: Object.fromEntries(Object.entries(c.slots).map(([k, v]) => [k, { ...v }])),
date_format: c.date_format || '%d.%m.%Y, %H:%M UTC',
date_only_format: c.date_only_format || '%d.%m.%Y',
};
editing = c.id; showForm = true;
editing = c.id; showForm = true; activeLocale = 'en';
slotPreview = {}; slotErrors = {}; dateFormatPreview = {};
setTimeout(() => refreshAllPreviews(), 100);
}
@@ -184,12 +201,13 @@
name: `${c.name} (Copy)`,
description: c.description || '',
icon: c.icon || '',
slots: { ...c.slots },
slots: Object.fromEntries(Object.entries(c.slots).map(([k, v]) => [k, { ...v }])),
date_format: c.date_format || '%d.%m.%Y, %H:%M UTC',
date_only_format: c.date_only_format || '%d.%m.%Y',
};
editing = null;
showForm = true;
activeLocale = 'en';
slotPreview = {};
slotErrors = {};
setTimeout(() => refreshAllPreviews(), 100);
@@ -290,6 +308,17 @@
<IconGridSelect items={previewTargetTypeItems()} bind:value={previewTargetType} columns={2} />
</div>
<!-- Locale tabs -->
<div class="flex gap-1 mb-3 border-b border-[var(--color-border)]">
{#each LOCALES as loc}
<button type="button"
class="px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors {activeLocale === loc ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}"
onclick={() => { activeLocale = loc; refreshAllPreviews(); }}>
{loc.toUpperCase()}
</button>
{/each}
</div>
{#each templateSlots.filter(g => g.slots.length > 0) as group}
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
<legend class="text-sm font-medium px-1">{t(`templateConfig.${group.group}`)}{#if group.group === 'eventMessages'}<Hint text={t('hints.eventMessages')} />{:else if group.group === 'scheduledMessages'}<Hint text={t('hints.scheduledMessages')} />{/if}</legend>
@@ -315,7 +344,7 @@
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">{t('templateConfig.invalidFormat')}</p>
{/if}
{:else}
<JinjaEditor value={form.slots[slot.key] || ''} onchange={(v: string) => { form.slots[slot.key] = v; validateSlot(slot.key, v); }} rows={slot.rows || 3} errorLine={slotErrorLines[slot.key] || null} variables={varsRef[slot.key] || undefined} />
<JinjaEditor value={getSlotValue(slot.key)} onchange={(v: string) => { setSlotValue(slot.key, v); validateSlot(slot.key, v); }} rows={slot.rows || 3} errorLine={slotErrorLines[slot.key] || null} variables={varsRef[slot.key] || undefined} />
{#if slotErrors[slot.key]}
{#if slotErrorTypes[slot.key] === 'undefined'}
<p class="mt-1 text-xs" style="color: #d97706;">{t('common.undefinedVar')}: {slotErrors[slot.key]}</p>
@@ -347,9 +376,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">
<IconGridSelect items={providerTypeFilterItems()} bind:value={filterType} columns={2} compact />
</div>
{/if}
</div>
{/if}
@@ -14,17 +14,19 @@
import Hint from '$lib/components/Hint.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import { providerTypeItems, providerTypeFilterItems, sortByItems, sortOrderItems, albumModeItems, assetTypeItems, memorySourceItems } from '$lib/grid-items';
import { providerTypeItems, providerTypeFilterItems, sortByItems, sortOrderItems, albumModeItems, assetTypeItems, memorySourceItems, providerDefaultIcon } from '$lib/grid-items';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import type { TrackingConfig } from '$lib/types';
let allConfigs = $derived(trackingConfigsCache.items);
let filterText = $state('');
let filterType = $state('');
let effectiveType = $derived(globalProviderFilter.providerType || filterType);
let configs = $derived(allConfigs.filter(c =>
(!filterText || c.name.toLowerCase().includes(filterText.toLowerCase())) &&
(!filterType || c.provider_type === filterType)
(!effectiveType || c.provider_type === effectiveType)
));
let loaded = $state(false);
let showForm = $state(false);
@@ -262,9 +264,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">
<IconGridSelect items={providerTypeFilterItems()} bind:value={filterType} columns={2} compact />
</div>
{/if}
</div>
{/if}
@@ -283,7 +287,7 @@
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
{#if config.icon}<MdiIcon name={config.icon} />{/if}
<span style="color: var(--color-primary);"><MdiIcon name={providerDefaultIcon({ icon: config.icon, type: config.provider_type })} size={20} /></span>
<p class="font-medium">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)] font-mono">{config.provider_type}</span>
</div>