10d30fc956
Comprehensive multi-area pass driven by a parallel 8-agent production
review. Frontend, backend, database, security, performance, operational,
plus a new self-monitoring feature.
## Critical fixes
- Planka webhook: reads bounded raw body (was NameError on every call)
- HA quiet hours: ha_state_changed/automation_triggered/service_called/
event_fired added to deferrable set (were silently dropped)
- DNS-rebinding SSRF: PinnedResolver wired into shared aiohttp session
- Telegram inbound webhook: secret now mandatory (401 without)
- Generic webhook: auth_mode="none" requires explicit
acknowledge_unauthenticated=true; per-IP rate limit 60/min
- svelte-check: 5 null-narrowing errors in EventDetailModal fixed
- Provider hardcoding: Immich-only block extracted to descriptor
featureDiscoveryHint
- command_sync: snapshot+expunge bot before exiting AsyncSession
## Bug fixes
- notifier asyncio.gather(return_exceptions=True) — one bad chat no longer
cancels peer sends
- NotificationDispatcher hoisted out of per-tracker loop
- Provider credential resolution unified across all 5 dispatch sites
- HA asyncio.shield now drains inner task on cancellation
- Provider construction switched from if/elif ladder to factory registry
- NUT first poll seeds silently (no spurious ups_on_battery)
- Quiet-hours gate: event-type-disabled now wins over deferral
- APScheduler drain job ID resolution upgraded to seconds
- HA on_status_change wired through to EventLog
- Webhook payload rollback failures now logged (not swallowed)
- Batched receivers/chats/bots in load_link_data (was per-target N+1)
- flag_modified on JSON column reassignments in deferred_dispatch
## Database
- UNIQUE indexes on service_provider.webhook_token,
telegram_bot.webhook_path_id, partial UNIQUE on telegram_bot.bot_id,
telegram_chat(bot_id, chat_id), notification_tracker_target unique link,
partial UNIQUE on bridge_self provider per user
- Composite ix_event_log_user_event_type_created index
- save_chat_from_webhook switched to ON CONFLICT DO UPDATE
- ondelete=CASCADE on user-id FKs (model annotation; app-side cascade
delete added for existing data)
- delete_notification_tracker converted from N+1 to bulk DELETE/UPDATE
- Module-level asyncio.Lock replaced with lazy _get_lock() pattern
- VACUUM INTO snapshot now PRAGMA integrity_check verified
## Performance
- Jinja2 template compilation LRU cached (lru_cache maxsize=512)
- Per-locale render cache in NotificationDispatcher (skips re-rendering
identical content for receivers sharing a locale)
- Tracker list cached per provider_id with 5s TTL + explicit invalidation
on tracker CRUD (relieves HA chat-bus rate query pressure)
- Nav-counts collapsed from 16 round-trips to single UNION ALL
- HA event_log: skip persisting empty assets_added/removed events
## Security hardening
- Mass-assignment guard on Action create/update; cron sub-minute reject
- Backup JSON depth/node-count cap (depth ≤ 10, nodes ≤ 100k)
- _sanitize_config extended to all JSON-typed fields on backup import
- Telegram _safe_get walks redirects manually with SSRF revalidation
- Bcrypt 72-byte password length cap with clear 422
- Webhook payload body redaction; sensitive substring set extended with
oauth/client_secret/webhook_secret/csrf in both header filter and
template extras filter
## Frontend
- 76 catch (err: any) sites converted to errMsg(err) helper
- globalProviderFilter: pure getter; reconciliation moved to one-time
$effect in +layout
- Provider-filter binding: removed paired $effects + _syncingFilter flag,
now one-way derived
- entity-cache: separate _refreshing flag for background re-fetches
- api.ts 401 handling: AuthRedirectError class + dedup _redirecting flag,
goto() instead of window.location.href
- a11y: aria-expanded on mobile More, role=switch + aria-checked on
Telegram bot toggles
## Tests & operations
- CI pytest gate added to .gitea/workflows/build.yml + release.yml
(wheel-built install to dodge editable-install slowness)
- /api/ready upgraded to deep healthcheck (db SELECT 1, scheduler.running,
HA supervisor presence) returning {ready, checks, errors, version}
- /api/metrics endpoint with prometheus_client (deferred_pending,
event_log_total, dispatch_duration, poll_failures, send_failures)
- New OPERATIONS.md covering deploy, healthchecks, metrics, backup/restore
procedures, log handling, common scenarios, upgrade flow
- New tests: test_bridge_self (11), test_gitea_parser (9),
test_planka_parser (6), test_immich_change_detector (6),
test_backup_roundtrip (1)
## New feature: bridge self-monitoring
- New bridge_self provider type — internal sink for bridge health events
- Three event types: bridge_self_poll_failures (consecutive tracker poll
failures), bridge_self_deferred_backlog (pending count crosses
threshold), bridge_self_target_failures (consecutive 5xx/network
failures per target)
- Per-user thresholds (defaults: 3 / 100 / 5) configurable via the
provider config form
- Auto-seeded on user create + /setup + boot backfill for existing users
- Anti-spam: counters reset after emission; backlog uses transition latch
- Self-loop guard: bridge_self failures don't count toward target-failure
thresholds (logged only) — wire to your own Telegram/Email/Matrix to
get notified when polls/dispatches/sends fail
- 6 default templates (3 events × 2 locales), tracking config columns
with backfill migration, frontend descriptor (excluded from "create
provider" wizard since auto-managed)
Operator-visible behavior changes (call out in release notes):
- NOTIFY_BRIDGE_TELEGRAM_WEBHOOK_SECRET now REQUIRED for webhook mode
- Existing webhook providers with auth_mode="none" need explicit opt-in
- Generic webhook endpoint rate-limited 60/min per source IP
- HA disconnect/reconnect writes ha_status_* EventLog rows
- Every user gets a bridge_self provider — wire it to a target to
receive failure alerts
Pre-existing test failures (test_ssrf, test_release_provider) on
Python 3.13 are unrelated; CI runs on 3.12.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1170 lines
43 KiB
Svelte
1170 lines
43 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, errMsg } from '$lib/api';
|
|
import { getAuth, loadUser, logout } from '$lib/auth.svelte';
|
|
import { t, getLocale, setLocale } from '$lib/i18n';
|
|
import { getTheme, initTheme, setTheme, type Theme } from '$lib/theme.svelte';
|
|
import Modal from '$lib/components/Modal.svelte';
|
|
import NavIcon from '$lib/components/NavIcon.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, releaseStatusCache,
|
|
} from '$lib/stores/caches.svelte';
|
|
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
|
import { topbarAction } from '$lib/stores/topbar-action.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);
|
|
|
|
// Sidebar release indicator — reads from the cache populated in onMount.
|
|
const releaseUpdateAvailable = $derived(!!releaseStatusCache.value?.update_available);
|
|
// A screen reader hits the brand-version link on every page — keep the
|
|
// label informative only when an update is available, otherwise announce
|
|
// the version + product so we don't repeat "Up to date" everywhere.
|
|
const releaseTooltip = $derived(
|
|
releaseUpdateAvailable
|
|
? t('settings.release.updateAvailableTooltip').replace('{v}', releaseStatusCache.value?.latest ?? '')
|
|
: `Notify Bridge v${__APP_VERSION__}`
|
|
);
|
|
|
|
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 })),
|
|
]);
|
|
// One-way: the store is the source of truth, the filter widget displays it.
|
|
// IconGridSelect mutations route through `onChange` (see template) so we
|
|
// never need a paired `$effect` to mirror the local <-> store value, which
|
|
// previously required a `_syncingFilter` reentrancy flag.
|
|
let providerFilterValue = $derived(globalProviderFilter.id ?? 0);
|
|
|
|
// Reserve the provider-filter row from first paint until the cache resolves.
|
|
// Without this, the row appears mid-paint and pushes nav items down on every
|
|
// hard reload — the most visible "jump" the user reported.
|
|
let showProviderFilter = $derived(allProviders.length >= 1 || providersCache.fetchedAt === 0);
|
|
|
|
// Reconcile a stale persisted provider ID against the freshly-loaded
|
|
// providers cache. Lives here (not in the store getter) because writing
|
|
// `_providerId` from a `$state`-derived getter triggers Svelte's
|
|
// `state_unsafe_mutation`. Runs once per cache refresh.
|
|
$effect(() => {
|
|
// Track `fetchedAt` so we re-run after the cache loads.
|
|
void providersCache.fetchedAt;
|
|
void providersCache.items.length;
|
|
globalProviderFilter.reconcileWithCache();
|
|
});
|
|
|
|
function setProviderFilter(v: string | number) {
|
|
const num = typeof v === 'number' ? v : Number(v);
|
|
globalProviderFilter.set(num === 0 ? null : num);
|
|
}
|
|
|
|
// Collapsed-rail filter cycles through providers via the same setter so the
|
|
// store stays the single write path.
|
|
function cycleProviderFilter() {
|
|
const ids = [0, ...allProviders.map(p => p.id)];
|
|
const idx = ids.indexOf(providerFilterValue);
|
|
setProviderFilter(ids[(idx + 1) % ids.length]);
|
|
}
|
|
|
|
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: unknown) { const m = errMsg(err); pwdMsg = m; pwdSuccess = false; snackError(m); }
|
|
}
|
|
|
|
// Read persisted UI state synchronously so first paint already matches the
|
|
// user's last session — otherwise the sidebar visibly snaps from expanded
|
|
// to collapsed (and groups slide open) right after mount.
|
|
function readPersistedCollapsed(): boolean {
|
|
if (typeof localStorage === 'undefined') return false;
|
|
return localStorage.getItem('sidebar_collapsed') === 'true';
|
|
}
|
|
function readPersistedExpandedGroups(): Record<string, boolean> {
|
|
if (typeof localStorage === 'undefined') return {};
|
|
try {
|
|
const saved = localStorage.getItem('nav_expanded');
|
|
return saved ? JSON.parse(saved) : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
let collapsed = $state(readPersistedCollapsed());
|
|
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
|
|
);
|
|
|
|
/**
|
|
* Section labels above groups of nav entries — emitted in the template
|
|
* before the entry whose key matches a map below. Mirrors the Aurora
|
|
* mockup's "Overview / Routing / Operators / System" section rhythm
|
|
* without breaking the existing collapsible-group structure.
|
|
*/
|
|
const SECTION_BREAKS: Record<string, string> = {
|
|
'nav.dashboard': 'nav.sectionOverview',
|
|
'nav.notification': 'nav.sectionRouting',
|
|
'nav.bots': 'nav.sectionOperators',
|
|
'nav.settings': 'nav.sectionSystem',
|
|
};
|
|
|
|
// Track which groups are expanded (persisted in localStorage)
|
|
let expandedGroups = $state<Record<string, boolean>>(readPersistedExpandedGroups());
|
|
|
|
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 bottom-nav derives its 4 primary slots from baseNavEntries by key
|
|
// lookup. Adding a new top-level nav entry doesn't break this list, and
|
|
// renaming a key fails loudly via the assertion below — keeping desktop
|
|
// and mobile nav structure in sync without manual duplication.
|
|
const MOBILE_PRIMARY_KEYS = ['nav.dashboard', 'nav.notification', 'nav.commands', 'nav.targets'] as const;
|
|
const mobileNavItems = $derived<NavItem[]>(
|
|
MOBILE_PRIMARY_KEYS.map(key => {
|
|
const entry = baseNavEntries.find(e => e.key === key);
|
|
if (!entry) return null;
|
|
return isGroup(entry)
|
|
? { href: entry.children[0]?.href ?? '/', key: entry.key, icon: entry.icon }
|
|
: entry;
|
|
}).filter((x): x is NavItem => x !== null)
|
|
);
|
|
|
|
// "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();
|
|
// `collapsed` and `expandedGroups` are now hydrated synchronously in
|
|
// their $state initializers above to avoid a post-mount layout snap.
|
|
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(),
|
|
releaseStatusCache.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="app-shell">
|
|
<!-- Sidebar -->
|
|
<aside
|
|
class="sidebar {collapsed ? 'w-[3.75rem]' : 'w-[15rem]'} flex flex-col max-md:hidden"
|
|
>
|
|
<!-- Header -->
|
|
<div class="sidebar-header flex items-center {collapsed ? 'justify-center p-3' : 'justify-between px-5 py-5'}">
|
|
{#if !collapsed}
|
|
<div class="animate-fade-slide-in flex items-center gap-3">
|
|
<div class="brand-orb"></div>
|
|
<div class="brand-text">
|
|
<h1 class="brand-name">
|
|
{#if globalProviderFilter.provider}
|
|
<span class="brand-mark__icon" style="color: var(--color-primary);"><NavIcon name={providerDefaultIcon(globalProviderFilter.provider)} size={14} /></span>
|
|
{/if}
|
|
Notify Bridge
|
|
</h1>
|
|
<p class="brand-version font-mono">
|
|
<a
|
|
class="brand-version-link"
|
|
class:has-update={releaseUpdateAvailable}
|
|
href="/settings#release"
|
|
aria-label={releaseTooltip}
|
|
title={releaseUpdateAvailable ? releaseTooltip : undefined}
|
|
>
|
|
<span>v{__APP_VERSION__}</span>
|
|
{#if releaseUpdateAvailable}
|
|
<span class="brand-version-dot" aria-hidden="true"></span>
|
|
{/if}
|
|
</a>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<div class="brand-orb brand-orb--small"></div>
|
|
{/if}
|
|
<button onclick={toggleSidebar}
|
|
class="sidebar-icon-btn flex items-center justify-center w-10 h-10 rounded-lg transition-all duration-200"
|
|
title={collapsed ? t('common.expand') : t('common.collapse')}
|
|
aria-label={collapsed ? t('common.expand') : t('common.collapse')}>
|
|
<NavIcon name={collapsed ? 'mdiChevronRight' : 'mdiChevronLeft'} size={18} />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Global provider filter — kept rendered during the initial cache
|
|
fetch (fetchedAt === 0) so the row doesn't pop in mid-paint and
|
|
push the nav down. Hides only once we confirm zero providers. -->
|
|
{#if showProviderFilter}
|
|
<div class="{collapsed ? 'px-2 py-1' : 'px-3 py-1.5'}" style="border-bottom: 1px solid var(--color-border);">
|
|
{#if collapsed}
|
|
<button onclick={cycleProviderFilter}
|
|
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')}
|
|
aria-label={globalProviderFilter.provider?.name || t('common.allProviders')}>
|
|
<NavIcon name={globalProviderFilter.provider ? providerDefaultIcon(globalProviderFilter.provider) : 'mdiFilterOff'} size={16} />
|
|
</button>
|
|
{:else}
|
|
<IconGridSelect items={providerFilterItems} value={providerFilterValue} onChange={setProviderFilter} 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}
|
|
{#if SECTION_BREAKS[entry.key] && !collapsed}
|
|
<div class="nav-section-label">{t(SECTION_BREAKS[entry.key])}</div>
|
|
{/if}
|
|
{#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}
|
|
<NavIcon 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'});">
|
|
<NavIcon 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}
|
|
<NavIcon 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}
|
|
<NavIcon 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 class="sidebar-foot">
|
|
{#if collapsed}
|
|
<div class="flex flex-col items-center gap-1.5 py-3">
|
|
<a href="/docs" target="_blank" rel="noopener noreferrer"
|
|
class="sidebar-icon-btn flex items-center justify-center w-10 h-10 rounded-lg transition-all duration-200"
|
|
title={t('common.apiDocs')}
|
|
aria-label={t('common.apiDocs')}>
|
|
<NavIcon name="mdiApi" size={14} />
|
|
</a>
|
|
<button onclick={logout}
|
|
class="sidebar-icon-btn flex items-center justify-center w-10 h-10 rounded-lg transition-all duration-200"
|
|
title={t('nav.logout')}
|
|
aria-label={t('nav.logout')}>
|
|
<NavIcon name="mdiLogout" size={16} />
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
<div class="user-card">
|
|
<div class="user-card__main">
|
|
<div class="user-avatar">
|
|
{auth.user.username[0].toUpperCase()}
|
|
</div>
|
|
<div class="user-card__text min-w-0">
|
|
<p class="user-card__name truncate">{auth.user.username}</p>
|
|
<p class="user-card__role">{auth.user.role}</p>
|
|
</div>
|
|
<span class="user-card__chip" title={t('dashboard.live')}></span>
|
|
</div>
|
|
<div class="user-card__actions">
|
|
<button onclick={() => showPasswordForm = true} class="user-card__btn"
|
|
title={t('common.changePassword')}
|
|
aria-label={t('common.changePassword')}>
|
|
<NavIcon name="mdiKeyVariant" size={13} />
|
|
<span>{t('common.changePassword')}</span>
|
|
</button>
|
|
<a href="/docs" target="_blank" rel="noopener noreferrer"
|
|
class="user-card__btn" title={t('common.apiDocs')}
|
|
aria-label={t('common.apiDocs')}>
|
|
<NavIcon name="mdiApi" size={13} />
|
|
</a>
|
|
<button onclick={logout} class="user-card__btn user-card__btn--danger"
|
|
title={t('nav.logout')}
|
|
aria-label={t('nav.logout')}>
|
|
<NavIcon name="mdiLogout" size={13} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</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)'};">
|
|
<NavIcon 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);">
|
|
<NavIcon name="mdiMagnify" size={20} />
|
|
</button>
|
|
<button onclick={() => mobileMoreOpen = !mobileMoreOpen} aria-label={t('nav.more')}
|
|
aria-expanded={mobileMoreOpen}
|
|
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)'};">
|
|
<NavIcon 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"
|
|
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} value={providerFilterValue} onChange={setProviderFilter} 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);">
|
|
<NavIcon 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'};"
|
|
>
|
|
<NavIcon 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'};"
|
|
>
|
|
<NavIcon 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);">
|
|
<NavIcon name="mdiLogout" size={18} />
|
|
<span class="text-sm">{t('nav.logout')}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Main content -->
|
|
<main class="main-col flex-1 overflow-auto md:pb-0"
|
|
style="padding-bottom: calc(4rem + env(safe-area-inset-bottom, 0px));">
|
|
|
|
<!-- Always-visible topbar — search + utilities + primary CTA -->
|
|
<div class="topbar">
|
|
<div class="topbar-glass">
|
|
<button type="button" class="topbar-search" onclick={() => openSearch?.()}>
|
|
<NavIcon name="mdiMagnify" size={16} />
|
|
<span class="topbar-search__text">{t('searchPalette.placeholder')}</span>
|
|
<span class="topbar-search__kbd font-mono">{isMac ? '⌘' : 'Ctrl '}K</span>
|
|
</button>
|
|
<button type="button" class="topbar-icon-btn" onclick={cycleTheme}
|
|
title={t('common.theme')} aria-label={t('common.theme')}>
|
|
<NavIcon name={theme.resolved === 'dark' ? 'mdiWeatherNight' : theme.current === 'system' ? 'mdiDesktopTowerMonitor' : 'mdiWeatherSunny'} size={16} />
|
|
</button>
|
|
<button type="button" class="topbar-icon-btn" onclick={toggleLocale}
|
|
title={t('common.language')} aria-label={t('common.language')}>
|
|
<span class="topbar-locale font-mono">{getLocale().toUpperCase()}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{#key page.url.pathname}
|
|
<div class="pb-4 md:pb-8" style="padding-top: 12px;" 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>
|
|
/* === AURORA SHELL === */
|
|
.app-shell {
|
|
display: flex;
|
|
min-height: 100vh;
|
|
padding: 18px;
|
|
gap: 18px;
|
|
}
|
|
|
|
/* === SIDEBAR — frosted glass rail === */
|
|
.sidebar {
|
|
background: var(--color-glass);
|
|
backdrop-filter: blur(28px) saturate(160%);
|
|
-webkit-backdrop-filter: blur(28px) saturate(160%);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 22px;
|
|
box-shadow: var(--shadow-card);
|
|
position: sticky;
|
|
top: 18px;
|
|
height: calc(100vh - 36px);
|
|
overflow: hidden;
|
|
transition: width 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
|
}
|
|
.sidebar::after {
|
|
content: '';
|
|
position: absolute;
|
|
inset: 0;
|
|
border-radius: inherit;
|
|
pointer-events: none;
|
|
background: linear-gradient(180deg, var(--color-highlight), transparent 30%);
|
|
opacity: 0.4;
|
|
z-index: 0;
|
|
}
|
|
.sidebar > * { position: relative; z-index: 1; }
|
|
.sidebar-header {
|
|
border-bottom: 1px solid var(--color-border);
|
|
}
|
|
|
|
/* Brand — snapped to Aurora mockup: bold sans wordmark + mono version */
|
|
.brand-text { line-height: 1.1; min-width: 0; }
|
|
.brand-name {
|
|
font-family: var(--font-sans);
|
|
font-weight: 600;
|
|
font-size: 0.95rem;
|
|
letter-spacing: -0.01em;
|
|
color: var(--color-foreground);
|
|
margin: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
white-space: nowrap;
|
|
}
|
|
.brand-mark__icon {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
}
|
|
.brand-version {
|
|
font-size: 0.65rem;
|
|
color: var(--color-muted-foreground);
|
|
margin: 3px 0 0;
|
|
letter-spacing: 0.02em;
|
|
font-weight: 500;
|
|
}
|
|
.brand-version-link {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
color: inherit;
|
|
text-decoration: none;
|
|
border-radius: 0.3rem;
|
|
padding: 1px 4px;
|
|
margin: -1px -4px;
|
|
transition: color 0.15s, background 0.15s;
|
|
}
|
|
.brand-version-link:hover {
|
|
color: var(--color-foreground);
|
|
background: var(--color-glass-strong);
|
|
}
|
|
.brand-version-link.has-update {
|
|
color: var(--color-citrus, #d4a73a);
|
|
}
|
|
.brand-version-dot {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 999px;
|
|
background: var(--color-citrus, #d4a73a);
|
|
box-shadow: 0 0 6px color-mix(in srgb, var(--color-citrus, #d4a73a) 70%, transparent);
|
|
animation: brand-version-pulse 2.4s ease-in-out infinite;
|
|
}
|
|
@keyframes brand-version-pulse {
|
|
0%, 100% { transform: scale(1); opacity: 1; }
|
|
50% { transform: scale(1.35); opacity: 0.65; }
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.brand-version-dot { animation: none; }
|
|
.brand-version-link { transition: none; }
|
|
}
|
|
.brand-orb {
|
|
width: 32px; height: 32px;
|
|
border-radius: 11px;
|
|
background: conic-gradient(from 220deg, var(--color-primary), var(--color-orchid), var(--color-mint), var(--color-primary));
|
|
box-shadow: 0 4px 14px var(--color-glow);
|
|
position: relative;
|
|
flex-shrink: 0;
|
|
}
|
|
.brand-orb::after {
|
|
content: '';
|
|
position: absolute; inset: 4px;
|
|
border-radius: 7px;
|
|
background: radial-gradient(circle at 30% 25%, rgba(255,255,255,0.6), transparent 50%);
|
|
}
|
|
.brand-orb--small { width: 26px; height: 26px; border-radius: 9px; }
|
|
|
|
/* User avatar */
|
|
.user-avatar {
|
|
width: 30px; height: 30px;
|
|
border-radius: 50%;
|
|
display: grid; place-items: center;
|
|
background: linear-gradient(135deg, var(--color-orchid), var(--color-primary));
|
|
color: white;
|
|
font-weight: 600;
|
|
font-size: 0.78rem;
|
|
box-shadow: 0 0 0 2px var(--color-glass) inset;
|
|
}
|
|
|
|
.provider-filter-btn {
|
|
color: var(--color-muted-foreground);
|
|
background: transparent;
|
|
}
|
|
.provider-filter-btn:hover {
|
|
color: var(--color-foreground);
|
|
background: var(--color-glass-strong);
|
|
}
|
|
|
|
/* Sidebar icon button (toggle, logout) */
|
|
.sidebar-icon-btn {
|
|
color: var(--color-muted-foreground);
|
|
background: transparent;
|
|
}
|
|
.sidebar-icon-btn:hover {
|
|
background: var(--color-glass-strong);
|
|
color: var(--color-foreground);
|
|
}
|
|
|
|
/* Nav links — soft glass hovers, gradient bar on active.
|
|
Snapped from the Aurora dashboard mockup. */
|
|
.nav-link {
|
|
color: var(--color-muted-foreground);
|
|
background: transparent;
|
|
font-weight: 450;
|
|
border-radius: 12px !important;
|
|
font-size: 13.5px;
|
|
letter-spacing: -0.005em;
|
|
}
|
|
.nav-link:not(.active):hover {
|
|
background: var(--color-glass-strong);
|
|
color: var(--color-foreground);
|
|
}
|
|
.nav-link.active {
|
|
color: var(--color-foreground);
|
|
font-weight: 500;
|
|
background: var(--color-glass-elev);
|
|
box-shadow: inset 0 1px 0 var(--color-highlight), 0 4px 18px -8px var(--color-glow);
|
|
}
|
|
.nav-link.active-bg {
|
|
background: var(--color-glass-elev);
|
|
}
|
|
|
|
/* Sidebar footer card */
|
|
.sidebar-foot {
|
|
padding: 0.85rem 0.85rem 1rem;
|
|
}
|
|
.user-card {
|
|
background: var(--color-glass-strong);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 14px;
|
|
padding: 0.75rem 0.85rem 0.6rem;
|
|
box-shadow: inset 0 1px 0 var(--color-highlight);
|
|
}
|
|
.user-card__main {
|
|
display: flex; align-items: center; gap: 0.7rem;
|
|
}
|
|
.user-card__text { line-height: 1.15; }
|
|
.user-card__name {
|
|
font-size: 0.82rem;
|
|
font-weight: 500;
|
|
color: var(--color-foreground);
|
|
margin: 0;
|
|
}
|
|
.user-card__role {
|
|
font-size: 0.6rem;
|
|
color: var(--color-muted-foreground);
|
|
margin: 2px 0 0;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.13em;
|
|
}
|
|
.user-card__chip {
|
|
margin-left: auto;
|
|
width: 8px; height: 8px;
|
|
border-radius: 50%;
|
|
background: var(--color-mint);
|
|
box-shadow: 0 0 8px var(--color-mint);
|
|
flex-shrink: 0;
|
|
}
|
|
.user-card__actions {
|
|
display: flex; gap: 0.3rem;
|
|
margin-top: 0.65rem;
|
|
padding-top: 0.55rem;
|
|
border-top: 1px solid var(--color-border);
|
|
}
|
|
.user-card__btn {
|
|
display: inline-flex; align-items: center; justify-content: center; gap: 0.35rem;
|
|
padding: 0.35rem 0.55rem;
|
|
flex: 1;
|
|
font-size: 0.65rem;
|
|
color: var(--color-muted-foreground);
|
|
background: transparent;
|
|
border: 1px solid transparent;
|
|
border-radius: 7px;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
font-family: inherit;
|
|
text-decoration: none;
|
|
}
|
|
.user-card__btn span {
|
|
max-width: 90px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.user-card__btn:not(.user-card__btn--danger):not(:has(span)) { flex: 0 0 auto; }
|
|
.user-card__btn:hover {
|
|
background: var(--color-glass-elev);
|
|
color: var(--color-foreground);
|
|
}
|
|
.user-card__btn--danger:hover {
|
|
background: var(--color-error-bg);
|
|
color: var(--color-error-fg);
|
|
}
|
|
|
|
/* Section labels above each nav group */
|
|
.nav-section-label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.55rem;
|
|
font-size: 0.6rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.18em;
|
|
text-transform: uppercase;
|
|
color: var(--color-muted-foreground);
|
|
padding: 0.85rem 0.85rem 0.4rem;
|
|
font-family: var(--font-mono);
|
|
}
|
|
.nav-section-label::after {
|
|
content: '';
|
|
flex: 1;
|
|
height: 1px;
|
|
background: var(--color-border);
|
|
}
|
|
|
|
/* Primary action button (password form submit) */
|
|
.primary-btn {
|
|
background: linear-gradient(135deg, var(--color-primary), var(--color-orchid));
|
|
color: white;
|
|
border: 0;
|
|
box-shadow: 0 6px 20px -8px var(--color-glow-strong), inset 0 1px 0 rgba(255,255,255,0.3);
|
|
}
|
|
.primary-btn:hover {
|
|
transform: translateY(-1px);
|
|
box-shadow: 0 8px 24px -6px var(--color-glow-strong), inset 0 1px 0 rgba(255,255,255,0.3);
|
|
}
|
|
|
|
.nav-badge {
|
|
font-size: 0.6rem;
|
|
font-weight: 500;
|
|
padding: 0.12rem 0.45rem;
|
|
border-radius: 9999px;
|
|
background: var(--color-glass-elev);
|
|
color: var(--color-foreground);
|
|
font-family: var(--font-mono);
|
|
line-height: 1.2;
|
|
min-width: 1.2rem;
|
|
text-align: center;
|
|
border: 1px solid var(--color-border);
|
|
}
|
|
.nav-link.active .nav-badge {
|
|
background: var(--color-primary);
|
|
color: var(--color-primary-foreground);
|
|
border-color: transparent;
|
|
}
|
|
.nav-badge-sm {
|
|
font-size: 0.55rem;
|
|
font-weight: 500;
|
|
padding: 0.06rem 0.4rem;
|
|
border-radius: 9999px;
|
|
background: var(--color-glass-strong);
|
|
color: var(--color-muted-foreground);
|
|
font-family: var(--font-mono);
|
|
line-height: 1.2;
|
|
min-width: 1rem;
|
|
text-align: center;
|
|
}
|
|
|
|
/* === TOPBAR — always-visible search + utility row === */
|
|
.main-col {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.topbar {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 30;
|
|
flex-shrink: 0;
|
|
}
|
|
.topbar-glass {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem 0.6rem 0.5rem 0.85rem;
|
|
background: var(--color-glass);
|
|
backdrop-filter: blur(14px) saturate(150%);
|
|
-webkit-backdrop-filter: blur(14px) saturate(150%);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 18px;
|
|
box-shadow: var(--shadow-card);
|
|
position: relative;
|
|
}
|
|
.topbar-glass::after {
|
|
content: '';
|
|
position: absolute; inset: 0;
|
|
border-radius: inherit;
|
|
pointer-events: none;
|
|
background: linear-gradient(180deg, var(--color-highlight), transparent 30%);
|
|
opacity: 0.4;
|
|
}
|
|
.topbar-search {
|
|
flex: 1;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.65rem;
|
|
padding: 0.55rem 0.85rem;
|
|
background: var(--color-glass-strong);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 12px;
|
|
color: var(--color-muted-foreground);
|
|
font-size: 0.85rem;
|
|
font-family: inherit;
|
|
cursor: text;
|
|
transition: all 0.15s;
|
|
text-align: left;
|
|
}
|
|
.topbar-search:hover {
|
|
background: var(--color-glass-elev);
|
|
border-color: var(--color-rule-strong);
|
|
}
|
|
.topbar-search__text {
|
|
flex: 1;
|
|
text-align: left;
|
|
}
|
|
.topbar-search__kbd {
|
|
font-size: 0.65rem;
|
|
padding: 2px 7px;
|
|
border-radius: 6px;
|
|
background: var(--color-glass);
|
|
border: 1px solid var(--color-border);
|
|
color: var(--color-foreground);
|
|
}
|
|
.topbar-icon-btn {
|
|
width: 36px; height: 36px;
|
|
display: grid; place-items: center;
|
|
border-radius: 12px;
|
|
border: 1px solid var(--color-border);
|
|
background: var(--color-glass-strong);
|
|
color: var(--color-muted-foreground);
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
}
|
|
.topbar-icon-btn:hover {
|
|
background: var(--color-glass-elev);
|
|
color: var(--color-foreground);
|
|
}
|
|
.topbar-locale {
|
|
font-size: 0.65rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.06em;
|
|
}
|
|
|
|
@media (max-width: 720px) {
|
|
.topbar-search__kbd { display: none; }
|
|
}
|
|
|
|
/* Mobile bottom-nav */
|
|
@media (max-width: 767px) {
|
|
.app-shell {
|
|
padding: 0;
|
|
gap: 0;
|
|
}
|
|
.sidebar {
|
|
border-radius: 0;
|
|
border: 0;
|
|
border-right: 1px solid var(--color-border);
|
|
}
|
|
.mobile-nav { display: flex !important; }
|
|
.mobile-more-panel {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: calc(3rem + env(safe-area-inset-bottom, 0px));
|
|
z-index: 50;
|
|
background: var(--mobile-more-bg, rgba(19, 21, 32, 0.92));
|
|
backdrop-filter: blur(12px) saturate(150%);
|
|
-webkit-backdrop-filter: blur(12px) saturate(150%);
|
|
border-top: 1px solid var(--color-rule-strong);
|
|
padding: calc(1rem + env(safe-area-inset-top, 0px)) calc(1rem + env(safe-area-inset-right, 0px)) 1rem calc(1rem + env(safe-area-inset-left, 0px));
|
|
overflow-y: auto;
|
|
overscroll-behavior: contain;
|
|
}
|
|
:global([data-theme="light"]) .mobile-more-panel { --mobile-more-bg: rgba(250, 250, 254, 0.92); }
|
|
.mobile-more-panel a:hover,
|
|
.mobile-more-panel button:hover {
|
|
background: var(--color-glass-strong);
|
|
}
|
|
.topbar { display: none; }
|
|
}
|
|
</style>
|