Files
notify-bridge/frontend/src/routes/+layout.svelte
T
alexei.dolgolyov 2be608ba95 feat(cache): thumbhash-validated asset cache + settings UX overhaul
Cache engine:
- TelegramFileCache: configurable max_entries (LRU cap applies in both TTL
  and thumbhash modes), ttl_seconds<=0 disables TTL, stats() method.
- Dispatcher builds an asset.id -> thumbhash resolver from event.added_assets
  (Immich populates thumbhash in extra) and passes it to TelegramClient, so
  asset-cache entries invalidate on visual change rather than age.
- Watcher wires app settings into cache init: URL cache = TTL + LRU cap,
  asset cache = thumbhash + LRU cap. Adds soft-reset (in-memory only) used
  when cache params change.

Settings:
- New key telegram_asset_cache_max_entries (default 5000).
- telegram_cache_ttl_hours default bumped 48 -> 720 (30d); now URL-only.
- PUT /settings resets in-memory caches when cache keys change (files kept).
- New endpoints: GET/POST /settings/telegram-cache/stats and /clear.

Settings page:
- Cache stats card (count + size + oldest/newest per bucket) with a hint
  explaining that the size is cumulative uploaded-to-Telegram bytes.
- Clear-cache button behind a confirm modal.
- New TimezoneSelector + LocaleSelector components replace raw inputs.
- max-entries input, TTL range updated (0..8760, 0 = disabled).

Mobile nav:
- "More" panel now mirrors the full sidebar tree (groups + subnodes) so
  every destination is reachable on mobile; previously flat hand-picked list.
- Nav height uses env(safe-area-inset-bottom); panel bottom + z-index fixed
  so content can't visually overlay the bottom bar.

A11y / DOM warnings:
- Password-change form has a hidden username field for password-manager
  association; autocomplete hints on all three password inputs.
- Telegram webhook secret wrapped in a no-op form + autocomplete=off.

Bug fix:
- update_settings used any(await ... for ...) which raised TypeError at
  runtime (async generator not an iterator); replaced with explicit loop.
2026-04-22 15:09:59 +03:00

797 lines
32 KiB
Svelte

<script lang="ts">
import '../app.css';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { fade, slide } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import { api } from '$lib/api';
import { getAuth, loadUser, logout } from '$lib/auth.svelte';
import { t, getLocale, setLocale, type Locale } from '$lib/i18n';
import { getTheme, initTheme, setTheme, type Theme } from '$lib/theme.svelte';
import Modal from '$lib/components/Modal.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
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);
let _syncingFilter = false;
// Sync filter value → store
$effect(() => {
const v = providerFilterValue;
if (_syncingFilter) return;
globalProviderFilter.set(v === 0 ? null : v);
});
// Sync store → filter value (handles auto-clear of stale IDs)
$effect(() => {
const storeId = globalProviderFilter.id;
if (storeId === null && providerFilterValue !== 0) {
_syncingFilter = true;
providerFilterValue = 0;
_syncingFilter = false;
}
});
let showPasswordForm = $state(false);
let redirecting = $state(false);
let openSearch: (() => void) | undefined;
let pwdCurrent = $state('');
let pwdNew = $state('');
let pwdConfirm = $state('');
let pwdMsg = $state('');
let pwdSuccess = $state(false);
async function changePassword(e: SubmitEvent) {
e.preventDefault(); pwdMsg = ''; pwdSuccess = false;
if (pwdNew.length < 8) { pwdMsg = t('auth.passwordTooShort'); return; }
if (pwdNew !== pwdConfirm) { pwdMsg = t('auth.passwordMismatch'); return; }
try {
await api('/auth/password', { method: 'PUT', body: JSON.stringify({ current_password: pwdCurrent, new_password: pwdNew }) });
pwdMsg = t('common.changePassword');
pwdSuccess = true;
pwdCurrent = ''; pwdNew = ''; pwdConfirm = '';
snackSuccess(t('snack.passwordChanged'));
setTimeout(() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; pwdConfirm = ''; }, 2000);
} catch (err: any) { pwdMsg = err.message; pwdSuccess = false; snackError(err.message); }
}
let collapsed = $state(false);
let isMac = $derived(typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent));
// 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;
// Single pass to count targets by type
const targetsByType = new Map<string, number>();
for (const t of targets) {
targetsByType.set(t.type, (targetsByType.get(t.type) || 0) + 1);
}
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: targetsByType.get('telegram') || 0,
targets_webhook: targetsByType.get('webhook') || 0,
targets_email: targetsByType.get('email') || 0,
targets_discord: targetsByType.get('discord') || 0,
targets_slack: targetsByType.get('slack') || 0,
targets_ntfy: targetsByType.get('ntfy') || 0,
targets_matrix: targetsByType.get('matrix') || 0,
targets_broadcast: targetsByType.get('broadcast') || 0,
} as Record<string, number>;
});
interface NavItem {
href: string;
key: string;
icon: string;
countKey?: string;
}
interface NavGroup {
key: string;
icon: string;
children: NavItem[];
}
type NavEntry = NavItem | NavGroup;
function isGroup(entry: NavEntry): entry is NavGroup {
return 'children' in entry;
}
const baseNavEntries: NavEntry[] = [
{ href: '/', key: 'nav.dashboard', icon: 'mdiViewDashboard' },
{ href: '/providers', key: 'nav.providers', icon: 'mdiServer', countKey: 'providers' },
{
key: 'nav.notification', icon: 'mdiBellOutline',
children: [
{ href: '/notification-trackers', key: 'nav.trackers', icon: 'mdiRadar', countKey: 'notification_trackers' },
{ href: '/tracking-configs', key: 'nav.configs', icon: 'mdiCog', countKey: 'tracking_configs' },
{ href: '/template-configs', key: 'nav.templates', icon: 'mdiFileDocumentEdit', countKey: 'template_configs' },
],
},
{
key: 'nav.commands', icon: 'mdiConsoleLine',
children: [
{ href: '/command-trackers', key: 'nav.trackers', icon: 'mdiRadar', countKey: 'command_trackers' },
{ href: '/command-configs', key: 'nav.configs', icon: 'mdiCog', countKey: 'command_configs' },
{ href: '/command-template-configs', key: 'nav.templates', icon: 'mdiCodeBracesBox', countKey: 'command_template_configs' },
],
},
{
key: 'nav.automation', icon: 'mdiRobotOutline',
children: [
{ href: '/actions', key: 'nav.actions', icon: 'mdiPlayCircleOutline', countKey: 'actions' },
],
},
{
key: 'nav.bots', icon: 'mdiRobot',
children: [
{ href: '/bots?tab=telegram', key: 'nav.telegram', icon: 'mdiSendCircle', countKey: 'telegram_bots' },
{ href: '/bots?tab=email', key: 'nav.email', icon: 'mdiEmailOutline', countKey: 'email_bots' },
{ href: '/bots?tab=matrix', key: 'nav.matrix', icon: 'mdiMatrix', countKey: 'matrix_bots' },
],
},
{
key: 'nav.targets', icon: 'mdiTarget',
children: [
{ href: '/targets?type=telegram', key: 'nav.targetTelegram', icon: 'mdiSend', countKey: 'targets_telegram' },
{ href: '/targets?type=webhook', key: 'nav.targetWebhook', icon: 'mdiWebhook', countKey: 'targets_webhook' },
{ href: '/targets?type=email', key: 'nav.targetEmail', icon: 'mdiEmailOutline', countKey: 'targets_email' },
{ href: '/targets?type=discord', key: 'nav.targetDiscord', icon: 'mdiChat', countKey: 'targets_discord' },
{ href: '/targets?type=slack', key: 'nav.targetSlack', icon: 'mdiSlack', countKey: 'targets_slack' },
{ href: '/targets?type=ntfy', key: 'nav.targetNtfy', icon: 'mdiBell', countKey: 'targets_ntfy' },
{ href: '/targets?type=matrix', key: 'nav.targetMatrix', icon: 'mdiMatrix', countKey: 'targets_matrix' },
{ href: '/targets?type=broadcast', key: 'nav.targetBroadcast', icon: 'mdiBullhorn', countKey: 'targets_broadcast' },
],
},
];
const navEntries = $derived<NavEntry[]>(auth.isAdmin
? [
...baseNavEntries,
{
key: 'nav.settings', icon: 'mdiCogOutline',
children: [
{ href: '/settings', key: 'nav.common', icon: 'mdiCogOutline' },
{ href: '/settings/backup', key: 'nav.backup', icon: 'mdiBackupRestore' },
{ href: '/users', key: 'nav.users', icon: 'mdiAccountGroup' },
],
},
]
: baseNavEntries
);
// Track which groups are expanded (persisted in localStorage)
let expandedGroups = $state<Record<string, boolean>>({});
function toggleGroup(key: string) {
expandedGroups = { ...expandedGroups, [key]: !expandedGroups[key] };
if (typeof localStorage !== 'undefined') {
localStorage.setItem('nav_expanded', JSON.stringify(expandedGroups));
}
}
function isGroupActive(group: NavGroup): boolean {
return group.children.some(c => {
const path = c.href.split('?')[0];
return page.url.pathname === path;
});
}
// Mobile: flatten nav for bottom bar (first 4 + "More" button)
const mobileNavItems = $derived<NavItem[]>([
{ href: '/', key: 'nav.dashboard', icon: 'mdiViewDashboard' },
{ href: '/notification-trackers', key: 'nav.notification', icon: 'mdiBellOutline' },
{ href: '/command-trackers', key: 'nav.commands', icon: 'mdiConsoleLine' },
{ href: '/targets', key: 'nav.targets', icon: 'mdiTarget' },
]);
// "More" panel mirrors the full desktop sidebar tree so every subnode is
// reachable on mobile (previously it was a flat hand-picked list that
// hid all target types, bot channels, and several nested pages).
let mobileMoreOpen = $state(false);
function closeMobileMore() {
mobileMoreOpen = false;
}
const isAuthPage = $derived(
page.url.pathname === '/login' || page.url.pathname === '/setup'
);
onMount(async () => {
initTheme();
if (typeof localStorage !== 'undefined') {
collapsed = localStorage.getItem('sidebar_collapsed') === 'true';
try {
const saved = localStorage.getItem('nav_expanded');
if (saved) expandedGroups = JSON.parse(saved);
} catch (e) { console.warn('Failed to parse nav_expanded:', e); }
}
await loadUser();
if (!auth.user && !isAuthPage) {
redirecting = true;
goto('/login');
}
// Load all caches for nav counts + global provider filter
if (auth.user) {
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));
}
});
// Auto-expand group containing the active page
$effect(() => {
for (const entry of navEntries) {
if (isGroup(entry) && isGroupActive(entry) && !expandedGroups[entry.key]) {
expandedGroups = { ...expandedGroups, [entry.key]: true };
}
}
});
function cycleTheme() {
const order: Theme[] = ['light', 'dark', 'system'];
const idx = order.indexOf(theme.current);
setTheme(order[(idx + 1) % order.length]);
}
function toggleLocale() {
setLocale(getLocale() === 'en' ? 'ru' : 'en');
}
function toggleSidebar() {
collapsed = !collapsed;
if (typeof localStorage !== 'undefined') {
localStorage.setItem('sidebar_collapsed', String(collapsed));
}
}
function isActive(href: string): boolean {
const [hrefPath, hrefQs] = href.split('?');
if (page.url.pathname !== hrefPath) return false;
if (hrefQs) {
// Link has query params — all must match
const params = new URLSearchParams(hrefQs);
for (const [k, v] of params) {
if (page.url.searchParams.get(k) !== v) return false;
}
return true;
}
// Link has NO query params — only active if URL also has no
// query params that a sibling link would claim
// (e.g. /bots is not active when URL is /bots?tab=matrix)
if (page.url.searchParams.size > 0) {
// Check if any sibling nav item matches with those params
for (const entry of navEntries) {
if (isGroup(entry)) {
for (const child of entry.children) {
if (child.href !== href && child.href.startsWith(hrefPath + '?')) {
const sibQs = child.href.split('?')[1];
const sibParams = new URLSearchParams(sibQs);
let sibMatch = true;
for (const [k, v] of sibParams) {
if (page.url.searchParams.get(k) !== v) { sibMatch = false; break; }
}
if (sibMatch) return false; // a sibling with query params matches better
}
}
}
}
}
return true;
}
</script>
{#if isAuthPage}
{@render children()}
{:else if auth.loading}
<div class="min-h-screen flex items-center justify-center">
<div class="flex items-center gap-3">
<div class="w-5 h-5 rounded-full border-2 border-[var(--color-primary)] border-t-transparent animate-spin"></div>
<p class="text-sm text-[var(--color-muted-foreground)]">{t('common.loading')}</p>
</div>
</div>
{:else if auth.user}
<div class="flex h-screen">
<!-- Sidebar -->
<aside
class="sidebar {collapsed ? 'w-[3.75rem]' : 'w-[15rem]'} flex flex-col max-md:hidden"
style="background: var(--color-sidebar); border-right: 1px solid var(--color-border); transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1);"
>
<!-- Header -->
<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 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"
title={collapsed ? t('common.expand') : t('common.collapse')}>
<MdiIcon name={collapsed ? 'mdiChevronRight' : 'mdiChevronLeft'} size={18} />
</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}
<!-- Search button -->
<div class="{collapsed ? 'px-2 py-1.5' : 'px-3 py-1.5'}" style="border-bottom: 1px solid var(--color-border);">
<button onclick={() => openSearch?.()}
class="search-btn flex items-center gap-2 w-full {collapsed ? 'justify-center px-2' : 'px-2.5'} py-1.5 rounded-lg text-sm transition-all duration-200"
title={t('searchPalette.placeholder')}>
<MdiIcon name="mdiMagnify" size={16} />
{#if !collapsed}
<span class="flex-1 text-left text-xs">{t('searchPalette.placeholder')}</span>
<kbd class="text-[0.6rem] font-mono px-1 py-0.5 rounded" style="background: var(--color-background); border: 1px solid var(--color-border);">{isMac ? '⌘' : 'Ctrl '}K</kbd>
{/if}
</button>
</div>
<!-- Nav -->
<nav class="flex-1 p-2 space-y-0.5 overflow-y-auto">
{#each navEntries as entry}
{#if isGroup(entry)}
<!-- Group header -->
<button
onclick={() => collapsed ? null : toggleGroup(entry.key)}
class="nav-link group flex items-center gap-2.5 {collapsed ? 'justify-center px-2' : 'px-3'} py-2 rounded-lg text-sm transition-all duration-200 w-full text-left relative {isGroupActive(entry) ? 'active' : ''} {isGroupActive(entry) && !expandedGroups[entry.key] ? 'active-bg' : ''}"
title={collapsed ? t(entry.key) : ''}
>
{#if isGroupActive(entry) && !expandedGroups[entry.key]}
<div class="active-indicator" style="position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--color-primary); box-shadow: 0 0 8px var(--color-glow-strong);"></div>
{/if}
<MdiIcon name={entry.icon} size={18} />
{#if !collapsed}
<span class="truncate flex-1">{t(entry.key)}</span>
<span class="nav-chevron" style="display: inline-flex; transition: transform 0.2s ease; transform: rotate({expandedGroups[entry.key] ? '90deg' : '0deg'});">
<MdiIcon name="mdiChevronRight" size={14} />
</span>
{/if}
</button>
<!-- Group children -->
{#if expandedGroups[entry.key] && !collapsed}
<div transition:slide={{ duration: 200, easing: cubicOut }} class="ml-3 pl-3 space-y-0.5" style="border-left: 1px solid var(--color-border);">
{#each entry.children as child}
<a
href={child.href}
class="nav-link nav-link-child group flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm transition-all duration-200 relative {isActive(child.href) ? 'active' : ''}"
>
{#if isActive(child.href)}
<div class="active-indicator" style="position: absolute; left: -13px; top: 50%; transform: translateY(-50%); width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--color-primary); box-shadow: 0 0 8px var(--color-glow-strong);"></div>
{/if}
<MdiIcon name={child.icon} size={15} />
<span class="truncate flex-1">{t(child.key)}</span>
{#if child.countKey && navCounts[child.countKey]}
<span class="nav-badge-sm">{navCounts[child.countKey]}</span>
{/if}
</a>
{/each}
</div>
{/if}
{:else}
<!-- Top-level item -->
<a
href={entry.href}
class="nav-link group flex items-center gap-2.5 {collapsed ? 'justify-center px-2' : 'px-3'} py-2 rounded-lg text-sm transition-all duration-200 relative {isActive(entry.href) ? 'active' : ''}"
title={collapsed ? t(entry.key) : ''}
>
{#if isActive(entry.href)}
<div class="active-indicator" style="position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: 3px; height: 60%; border-radius: 0 3px 3px 0; background: var(--color-primary); box-shadow: 0 0 8px var(--color-glow-strong);"></div>
{/if}
<MdiIcon name={entry.icon} size={18} />
{#if !collapsed}
<span class="truncate flex-1">{t(entry.key)}</span>
{#if entry.countKey && navCounts[entry.countKey]}
<span class="nav-badge">{navCounts[entry.countKey]}</span>
{/if}
{/if}
</a>
{/if}
{/each}
</nav>
<!-- Footer -->
<div style="border-top: 1px solid var(--color-border);">
<!-- Theme & Language -->
<div class="flex {collapsed ? 'flex-col items-center gap-1 p-2' : 'gap-1.5 px-4 py-2.5'}">
<button onclick={toggleLocale}
class="footer-pill flex items-center justify-center {collapsed ? 'w-8 h-8' : 'px-2.5 py-1'} rounded-lg text-xs font-medium transition-all duration-200"
title={t('common.language')}>
{getLocale().toUpperCase()}
</button>
<button onclick={cycleTheme}
class="footer-pill flex items-center justify-center {collapsed ? 'w-8 h-8' : 'px-2.5 py-1'} rounded-lg text-xs transition-all duration-200"
title={t('common.theme')}>
<MdiIcon name={theme.resolved === 'dark' ? 'mdiWeatherNight' : theme.current === 'system' ? 'mdiDesktopTowerMonitor' : 'mdiWeatherSunny'} size={14} />
</button>
<a href="/docs" target="_blank" rel="noopener noreferrer"
class="footer-pill flex items-center justify-center {collapsed ? 'w-8 h-8' : 'px-2.5 py-1'} rounded-lg text-xs transition-all duration-200"
title={t('common.apiDocs')}>
<MdiIcon name="mdiApi" size={14} />
</a>
</div>
<!-- User info -->
<div class="p-2.5" style="border-top: 1px solid var(--color-border);">
{#if collapsed}
<button onclick={logout}
class="sidebar-icon-btn w-full flex justify-center py-2 rounded-lg transition-all duration-200"
title={t('nav.logout')}>
<MdiIcon name="mdiLogout" size={16} />
</button>
{:else}
<div class="px-1.5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2.5">
<div class="w-7 h-7 rounded-full flex items-center justify-center text-[0.7rem] font-semibold"
style="background: var(--color-primary); color: var(--color-primary-foreground);">
{auth.user.username[0].toUpperCase()}
</div>
<div>
<p class="text-sm font-medium">{auth.user.username}</p>
<p class="text-[0.65rem] tracking-wide uppercase" style="color: var(--color-muted-foreground);">{auth.user.role}</p>
</div>
</div>
<button onclick={logout}
class="sidebar-icon-btn p-1.5 rounded-lg transition-all duration-200"
title={t('nav.logout')}>
<MdiIcon name="mdiLogout" size={15} />
</button>
</div>
<button onclick={() => showPasswordForm = true}
class="change-pwd-link text-[0.7rem] mt-1.5 transition-colors duration-200 flex items-center gap-1">
<MdiIcon name="mdiKeyVariant" size={12} />
{t('common.changePassword')}
</button>
</div>
{/if}
</div>
</div>
</aside>
<!-- Mobile bottom nav -->
<nav class="mobile-nav" style="position: fixed; bottom: 0; left: 0; right: 0; z-index: 60; background: var(--color-sidebar); border-top: 1px solid var(--color-border); display: none; justify-content: space-around; padding: 0.375rem 0 calc(0.375rem + env(safe-area-inset-bottom, 0px)); backdrop-filter: blur(12px);">
{#each mobileNavItems as item}
<a href={item.href} aria-label={t(item.key)}
class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs rounded-lg transition-all duration-200"
style="color: {isActive(item.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'};">
<MdiIcon name={item.icon} size={20} />
</a>
{/each}
<button onclick={() => openSearch?.()} aria-label={t('searchPalette.placeholder')}
class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs rounded-lg transition-all duration-200"
style="color: var(--color-muted-foreground);">
<MdiIcon name="mdiMagnify" size={20} />
</button>
<button onclick={() => mobileMoreOpen = !mobileMoreOpen} aria-label={t('nav.more')}
class="flex flex-col items-center gap-0.5 px-2 py-1.5 text-xs rounded-lg transition-all duration-200"
style="color: {mobileMoreOpen ? 'var(--color-primary)' : 'var(--color-muted-foreground)'};">
<MdiIcon name="mdiDotsHorizontal" size={20} />
</button>
</nav>
<!-- Mobile "More" panel — mirrors the full desktop nav tree -->
{#if mobileMoreOpen}
<div class="mobile-more-backdrop" style="position: fixed; inset: 0; z-index: 49; background: rgba(0,0,0,0.4); backdrop-filter: blur(2px);"
onclick={closeMobileMore} role="presentation"></div>
<div class="mobile-more-panel" style="position: fixed; bottom: calc(3rem + env(safe-area-inset-bottom, 0px)); left: 0; right: 0; z-index: 50; background: var(--color-sidebar); border-top: 1px solid var(--color-border); border-radius: 1rem 1rem 0 0; padding: 1rem; max-height: calc(70vh - env(safe-area-inset-bottom, 0px)); overflow-y: auto;"
transition:slide={{ duration: 200, easing: cubicOut }}>
{#if allProviders.length >= 1}
<div class="mb-3 pb-3" style="border-bottom: 1px solid var(--color-border);">
<IconGridSelect items={providerFilterItems} bind:value={providerFilterValue} columns={Math.min(providerFilterItems.length, 4)} compact />
</div>
{/if}
<div class="space-y-3">
{#each navEntries as entry}
{#if isGroup(entry)}
<div>
<div class="flex items-center gap-1.5 px-1 pb-1.5 text-[0.65rem] font-semibold uppercase tracking-wider"
style="color: var(--color-muted-foreground);">
<MdiIcon name={entry.icon} size={13} />
<span>{t(entry.key)}</span>
</div>
<div class="grid grid-cols-3 gap-2">
{#each entry.children as child}
<a href={child.href} onclick={closeMobileMore}
class="flex flex-col items-center gap-1 p-3 rounded-lg transition-all duration-200 relative"
style="color: {isActive(child.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(child.href) ? 'var(--color-sidebar-active)' : 'transparent'};"
>
<MdiIcon name={child.icon} size={20} />
<span class="text-xs text-center leading-tight">{t(child.key)}</span>
{#if child.countKey && navCounts[child.countKey]}
<span class="nav-badge-sm" style="position: absolute; top: 0.25rem; right: 0.25rem;">{navCounts[child.countKey]}</span>
{/if}
</a>
{/each}
</div>
</div>
{:else}
<a href={entry.href} onclick={closeMobileMore}
class="flex items-center gap-2 p-3 rounded-lg transition-all duration-200 relative"
style="color: {isActive(entry.href) ? 'var(--color-primary)' : 'var(--color-muted-foreground)'}; background: {isActive(entry.href) ? 'var(--color-sidebar-active)' : 'transparent'};"
>
<MdiIcon name={entry.icon} size={18} />
<span class="text-sm flex-1">{t(entry.key)}</span>
{#if entry.countKey && navCounts[entry.countKey]}
<span class="nav-badge">{navCounts[entry.countKey]}</span>
{/if}
</a>
{/if}
{/each}
<div class="pt-2" style="border-top: 1px solid var(--color-border);">
<button onclick={() => { closeMobileMore(); logout(); }}
class="flex items-center gap-2 p-3 w-full rounded-lg transition-all duration-200"
style="color: var(--color-muted-foreground);">
<MdiIcon name="mdiLogout" size={18} />
<span class="text-sm">{t('nav.logout')}</span>
</button>
</div>
</div>
</div>
{/if}
<!-- Main content -->
<main class="flex-1 overflow-auto md:pb-0"
style="padding-bottom: calc(4rem + env(safe-area-inset-bottom, 0px));">
{#key page.url.pathname}
<div class="max-w-5xl mx-auto p-4 md:p-8" in:fade={{ duration: 200, delay: 50 }}>
{@render children()}
</div>
{/key}
</main>
</div>
{:else}
<div class="min-h-screen flex items-center justify-center">
<div class="flex items-center gap-3">
<div class="w-5 h-5 rounded-full border-2 border-[var(--color-primary)] border-t-transparent animate-spin"></div>
<p class="text-sm text-[var(--color-muted-foreground)]">{redirecting ? t('common.redirecting') : t('common.loading')}</p>
</div>
</div>
{/if}
<!-- Password change modal -->
<Modal open={showPasswordForm} title={t('common.changePassword')} onclose={() => { showPasswordForm = false; pwdMsg = ''; pwdSuccess = false; pwdConfirm = ''; }}>
<form onsubmit={changePassword} class="space-y-3">
<input type="text" name="username" autocomplete="username" value={auth.user?.username ?? ''}
readonly aria-hidden="true" tabindex="-1"
style="position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none;" />
<div>
<label for="pwd-current" class="block text-sm font-medium mb-1">{t('common.currentPassword')}</label>
<input id="pwd-current" type="password" autocomplete="current-password" bind:value={pwdCurrent} required
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="pwd-new" class="block text-sm font-medium mb-1">{t('common.newPassword')}</label>
<input id="pwd-new" type="password" autocomplete="new-password" bind:value={pwdNew} required minlength="8"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="pwd-confirm" class="block text-sm font-medium mb-1">{t('auth.confirmPassword')}</label>
<input id="pwd-confirm" type="password" autocomplete="new-password" bind:value={pwdConfirm} required minlength="8"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-lg text-sm bg-[var(--color-background)]" />
</div>
{#if pwdMsg}
<p class="text-sm" style="color: var({pwdSuccess ? '--color-success-fg' : '--color-error-fg'});">{pwdMsg}</p>
{/if}
<button type="submit"
class="primary-btn w-full py-2.5 rounded-lg text-sm font-medium transition-all duration-200">
{t('common.save')}
</button>
</form>
</Modal>
<Snackbar />
<SearchPalette onopen={(fn) => openSearch = fn} />
<style>
@media (max-width: 767px) {
.mobile-nav { display: flex !important; }
.mobile-more-panel a:hover,
.mobile-more-panel button:hover {
background: var(--color-muted);
}
}
/* 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);
background: transparent;
}
.sidebar-icon-btn:hover {
background: var(--color-muted);
color: var(--color-foreground);
}
/* Search button */
.search-btn {
background: var(--color-muted);
color: var(--color-muted-foreground);
border: 1px solid var(--color-border);
}
.search-btn:hover {
border-color: var(--color-primary);
color: var(--color-foreground);
}
/* Nav links (top-level items, group headers, group children) */
.nav-link {
color: var(--color-muted-foreground);
background: transparent;
font-weight: 400;
}
.nav-link:not(.active):hover {
background: var(--color-muted);
color: var(--color-foreground);
}
.nav-link.active {
color: var(--color-primary);
font-weight: 500;
}
.nav-link.active-bg {
background: var(--color-sidebar-active);
}
/* Footer pill buttons (locale, theme) */
.footer-pill {
background: var(--color-muted);
color: var(--color-muted-foreground);
}
.footer-pill:hover {
color: var(--color-foreground);
box-shadow: 0 0 8px var(--color-glow);
}
/* Change password link */
.change-pwd-link {
color: var(--color-muted-foreground);
}
.change-pwd-link:hover {
color: var(--color-primary);
}
/* Primary action button (password form submit) */
.primary-btn {
background: var(--color-primary);
color: var(--color-primary-foreground);
}
.primary-btn:hover {
box-shadow: 0 0 16px var(--color-glow-strong);
}
.nav-badge {
font-size: 0.6rem;
font-weight: 600;
padding: 0.1rem 0.4rem;
border-radius: 9999px;
background: var(--color-primary);
color: var(--color-primary-foreground);
font-family: var(--font-mono);
line-height: 1.2;
min-width: 1.2rem;
text-align: center;
}
.nav-badge-sm {
font-size: 0.55rem;
font-weight: 600;
padding: 0.05rem 0.35rem;
border-radius: 9999px;
background: var(--color-muted);
color: var(--color-muted-foreground);
font-family: var(--font-mono);
line-height: 1.2;
min-width: 1rem;
text-align: center;
}
</style>