feat: nav counter badges, login backdrop, events i18n + misc fixes
Build / build (push) Successful in 10m29s

Nav & UI polish
- Sidebar nav items show monospace count badges (projects, sites, stacks,
  proxies). Events badge shows error count only, styled red as actionable
- New $lib/stores/navCounts.ts polls all counts in parallel every 60s and
  refreshes on route change so badges track mutations
- Login page gets a dynamic forge backdrop: rotating conic glow, drifting
  embers, dot-grid texture, vignette — all pure CSS, reduced-motion safe
- main element gets scrollbar-gutter: stable so Settings tab switching no
  longer shifts horizontally when content heights differ

Events i18n
- events.source.* dictionary rewritten to match actually-emitted backend
  sources (deploy, static_site, stale_scanner, stale_cleanup, admin);
  dead keys (container, proxy, system) removed
- EventLogFilter.allSources + /events default sources state updated to match
- Localize "{N} total" via events.totalCount in the page hero toolbar

Backend
- Stage API accepts enable_proxy on create/update (defaults to true) so
  proxy registration can be opted out per stage

Concurrency
- api.ts: queued request waiters no longer double-increment the inflight
  counter; releasing a slot hands it off directly

Reactive effects
- project detail / env / volumes pages wrap side-effect calls in untrack()
  to prevent $effect feedback loops when their loaders mutate tracked state
This commit is contained in:
2026-04-22 18:30:34 +03:00
parent ef0669d5dd
commit a182a93950
12 changed files with 389 additions and 28 deletions
+94 -9
View File
@@ -13,6 +13,7 @@
import { logout as apiLogout, getHealth } from '$lib/api';
import type { DockerHealth, ProxyHealth } from '$lib/types';
import { t } from '$lib/i18n';
import { navCounts, startNavCountsPolling, stopNavCountsPolling, refreshNavCounts } from '$lib/stores/navCounts';
interface Props {
children: Snippet;
@@ -20,16 +21,25 @@
const { children }: Props = $props();
const navItems = [
type NavCountKey = 'projects' | 'sites' | 'stacks' | 'proxies' | 'eventsErrors';
const navItems: ReadonlyArray<{
href: string;
labelKey: string;
icon: string;
countKey?: NavCountKey;
/** When true the badge uses a danger style (red). */
alert?: boolean;
}> = [
{ href: '/', labelKey: 'nav.dashboard', icon: 'dashboard' },
{ href: '/projects', labelKey: 'nav.projects', icon: 'projects' },
{ href: '/sites', labelKey: 'nav.sites', icon: 'globe' },
{ href: '/stacks', labelKey: 'nav.stacks', icon: 'stacks' },
{ href: '/projects', labelKey: 'nav.projects', icon: 'projects', countKey: 'projects' },
{ href: '/sites', labelKey: 'nav.sites', icon: 'globe', countKey: 'sites' },
{ href: '/stacks', labelKey: 'nav.stacks', icon: 'stacks', countKey: 'stacks' },
{ href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' },
{ href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies' },
{ href: '/events', labelKey: 'nav.events', icon: 'events' },
{ href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies', countKey: 'proxies' },
{ href: '/events', labelKey: 'nav.events', icon: 'events', countKey: 'eventsErrors', alert: true },
{ href: '/settings', labelKey: 'nav.settings', icon: 'settings' }
] as const;
];
function isActive(href: string, pathname: string): boolean {
if (href === '/') return pathname === '/';
@@ -122,6 +132,16 @@
window.addEventListener('keydown', handleKeydown);
});
// Keep nav badges fresh. Poll kicks off once the user is authenticated;
// a route change also triggers a one-shot refresh so badges reflect any
// mutations just performed on the page we're leaving.
$effect(() => {
void $page.url.pathname;
if (!isAuthenticated()) return;
startNavCountsPolling();
refreshNavCounts();
});
// Start health polling when authenticated.
// Uses $effect to react to route changes (e.g., after login navigation).
$effect(() => {
@@ -148,6 +168,7 @@
if (healthInterval) clearInterval(healthInterval);
if (clockTimer) clearInterval(clockTimer);
if (typeof window !== 'undefined') window.removeEventListener('keydown', handleKeydown);
stopNavCountsPolling();
});
</script>
@@ -211,9 +232,21 @@
{:else if item.icon === 'settings'}
<IconSettings size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
{/if}
{$t(item.labelKey)}
<span class="nav-label">{$t(item.labelKey)}</span>
{#if item.countKey}
{@const count = $navCounts[item.countKey]}
{#if count !== null && count > 0}
<span class="nav-badge" class:nav-badge-alert={item.alert} class:nav-badge-active={active}>
{count > 99 ? '99+' : count}
</span>
{:else if count !== null && !item.alert}
<span class="nav-badge nav-badge-muted" class:nav-badge-active={active}>0</span>
{/if}
{/if}
{#if active}
<div class="ml-auto h-1.5 w-1.5 rounded-full bg-[var(--color-brand-600)]"></div>
<div class="nav-active-dot"></div>
{/if}
</a>
{/each}
@@ -375,6 +408,10 @@
}
.nav-item :global(svg) { flex-shrink: 0; }
.nav-label {
flex: 1;
min-width: 0;
}
.nav-active {
background: var(--surface-card-hover);
color: var(--text-primary) !important;
@@ -388,6 +425,50 @@
background: var(--color-brand-600);
border-radius: 0 3px 3px 0;
}
.nav-active-dot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--color-brand-600);
flex-shrink: 0;
}
/* ── Nav count badges ──────────────────────────────────────── */
.nav-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.35rem;
height: 1.1rem;
padding: 0 0.4rem;
border-radius: 999px;
background: var(--surface-card-hover);
color: var(--text-secondary);
font-family: var(--forge-mono, 'JetBrains Mono', monospace);
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.02em;
font-variant-numeric: tabular-nums;
line-height: 1;
border: 1px solid var(--border-primary);
flex-shrink: 0;
}
.nav-badge-muted {
opacity: 0.55;
}
.nav-badge-alert {
background: var(--color-danger-light);
color: var(--color-danger-dark);
border-color: color-mix(in srgb, var(--color-danger) 45%, transparent);
}
:global([data-theme='dark']) .nav-badge-alert {
background: color-mix(in srgb, var(--color-danger) 18%, transparent);
color: #fca5a5;
border-color: color-mix(in srgb, var(--color-danger) 45%, transparent);
}
.nav-active .nav-badge:not(.nav-badge-alert) {
background: var(--surface-card);
color: var(--text-primary);
}
/* ── Sidebar footline (version + live UTC clock) ───────────── */
.forge-footline {
@@ -469,6 +550,10 @@
:global(main) {
position: relative;
isolation: isolate;
/* Reserve space for the scrollbar even when content fits, so switching
between short and tall pages (e.g. inside Settings) doesn't shift the
layout horizontally by the scrollbar width. */
scrollbar-gutter: stable;
}
:global(main)::before {
content: '';