feat: unified THE FORGE // SECTION headers and merged proxy routes
Build / build (push) Successful in 10m37s

UI consistency
- ForgeHero now supports backHref, mono kicker, stats snippet, staggered
  entrance animation, and a registration-tick divider
- Every route now opens with the same "THE FORGE // SECTION" eyebrow: projects,
  sites, stacks, proxies, events, dns, deploy, settings, stale containers,
  site/project detail + env/volumes/browse, new site wizard
- Stacks list/detail/new moved to the shared hero and brand-anchor eyebrow
- Toolbars migrated from bespoke buttons to the shared .forge-btn utilities
- Sidebar footline adds a live UTC "forge clock" and a vim-style g-prefix
  quick-nav hint (g d/p/s/k/x/r/e/c jumps to each section)

Proxies page
- Server-side: merge static site proxy routes with instance routes and sort
  by domain (internal/api/proxies.go, internal/store/static_sites.go)
- ProxyRoute gains a Source field ("instance" | "static_site")
- Frontend adds source filter tabs and per-source labels/badges
This commit is contained in:
2026-04-22 16:27:55 +03:00
parent 0fd92fdfa3
commit ef0669d5dd
25 changed files with 702 additions and 277 deletions
+130 -1
View File
@@ -44,6 +44,43 @@
let hintsExpanded = $state(false);
let proxyHintsExpanded = $state(false);
// Live UTC forge clock (refreshes every second). A small thing, but it makes
// the sidebar feel alive and reinforces the "control room" aesthetic.
let nowUtc = $state('');
let clockTimer: ReturnType<typeof setInterval> | null = null;
function tickClock() {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
nowUtc = `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
}
// Keyboard quick-nav: "g" then a letter jumps to a section (vim-style).
// g+d → dashboard, g+p → projects, g+s → sites, g+k → stacks, g+x → deploy,
// g+r → proxies, g+e → events, g+c → settings
let gPressedAt = 0;
function handleKeydown(e: KeyboardEvent) {
// Ignore when typing in inputs/textareas/contenteditable.
const target = e.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key === 'g') {
gPressedAt = Date.now();
return;
}
if (Date.now() - gPressedAt > 1200) return;
const map: Record<string, string> = {
d: '/', p: '/projects', s: '/sites', k: '/stacks',
x: '/deploy', r: '/proxies', e: '/events', c: '/settings'
};
const dest = map[e.key.toLowerCase()];
if (dest) {
e.preventDefault();
gPressedAt = 0;
goto(dest);
}
}
const dockerConnected = $derived(dockerHealth?.connected ?? false);
const proxyConnected = $derived(proxyHealth?.connected ?? true);
const proxyProviderName = $derived(proxyHealth?.provider ?? '');
@@ -80,6 +117,9 @@
goto('/', { replaceState: true });
}
}
tickClock();
clockTimer = setInterval(tickClock, 1000);
window.addEventListener('keydown', handleKeydown);
});
// Start health polling when authenticated.
@@ -106,6 +146,8 @@
onDestroy(() => {
if (healthInterval) clearInterval(healthInterval);
if (clockTimer) clearInterval(clockTimer);
if (typeof window !== 'undefined') window.removeEventListener('keydown', handleKeydown);
});
</script>
@@ -255,7 +297,18 @@
<IconLogout size={16} />
</button>
</div>
<p class="text-xs text-[var(--text-tertiary)]">{$t('app.name')} {$t('app.version')}</p>
<div class="forge-footline">
<span class="forge-footline-version">{$t('app.name')} {$t('app.version')}</span>
<span class="forge-footline-clock" title="UTC">
<span class="clock-dot"></span>
<span class="clock-time">{nowUtc || '--:--:--'}</span>
<span class="clock-suffix">UTC</span>
</span>
</div>
<p class="forge-nav-hint" title="Press 'g' then a letter to jump between sections">
<kbd>g</kbd><span class="arr"></span><kbd>d</kbd><kbd>p</kbd><kbd>s</kbd><kbd>k</kbd>
<span class="hint-label">quick-nav</span>
</p>
</div>
</aside>
@@ -336,6 +389,82 @@
border-radius: 0 3px 3px 0;
}
/* ── Sidebar footline (version + live UTC clock) ───────────── */
.forge-footline {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding-top: 0.1rem;
}
.forge-footline-version {
font-family: var(--forge-mono, 'JetBrains Mono', monospace);
font-size: 0.66rem;
letter-spacing: 0.08em;
color: var(--text-tertiary);
}
.forge-footline-clock {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-family: var(--forge-mono, 'JetBrains Mono', monospace);
font-size: 0.66rem;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
.clock-dot {
width: 5px; height: 5px; border-radius: 50%;
background: var(--color-brand-500);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-brand-500) 20%, transparent);
animation: forge-breathe 2.4s ease-in-out infinite;
}
.clock-suffix {
font-size: 0.56rem;
letter-spacing: 0.18em;
color: var(--text-tertiary);
text-transform: uppercase;
}
/* ── Keyboard quick-nav hint ───────────────────────────────── */
.forge-nav-hint {
display: flex;
align-items: center;
gap: 0.3rem;
margin: 0.55rem 0 0;
padding: 0;
font-family: var(--forge-mono, 'JetBrains Mono', monospace);
font-size: 0.58rem;
color: var(--text-tertiary);
letter-spacing: 0.06em;
}
.forge-nav-hint kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 14px;
height: 14px;
padding: 0 3px;
border: 1px solid var(--border-primary);
border-radius: 3px;
background: var(--surface-card);
color: var(--text-secondary);
font-family: inherit;
font-size: 0.6rem;
line-height: 1;
box-shadow: 0 1px 0 var(--border-primary);
}
.forge-nav-hint .arr {
color: var(--text-tertiary);
opacity: 0.5;
font-size: 0.55rem;
padding: 0 0.15rem;
}
.forge-nav-hint .hint-label {
margin-left: auto;
text-transform: uppercase;
opacity: 0.7;
}
/* Apply dot-grid backdrop to main content */
:global(main) {
position: relative;