feat: nav counter badges, login backdrop, events i18n + misc fixes
Build / build (push) Successful in 10m29s
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:
@@ -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: '';
|
||||
|
||||
Reference in New Issue
Block a user