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
+180 -7
View File
@@ -2,36 +2,64 @@
import type { Snippet } from 'svelte';
interface Props {
/** Primary eyebrow label (monospace caps). Defaults to "THE FORGE" for brand consistency. */
eyebrow?: string;
/** Right-hand side of the eyebrow, separated by // — typically the section name. */
eyebrowSuffix?: string;
/** Optional back link (href) shown as a small arrow before the eyebrow. */
backHref?: string;
/** Optional back link label (accessible name). */
backLabel?: string;
/** Main display title. */
title: string;
/** Optional trailing accent character after the title (defaults to "."). Pass empty string to hide. */
accent?: string;
/** Optional monospace kicker shown directly below the title (e.g. image ref, repo slug). */
kicker?: string;
/** Optional plain-text lede paragraph. */
lede?: string;
/** Optional HTML lede paragraph snippet (takes priority over `lede`). */
lede_html?: Snippet;
/** Right-hand toolbar snippet — page actions live here. */
toolbar?: Snippet;
size?: 'md' | 'lg' | 'xl';
/** Optional stat rail snippet — small dl below the hero. Great for listing counts. */
stats?: Snippet;
/** Display size. */
size?: 'sm' | 'md' | 'lg' | 'xl';
}
const {
eyebrow = 'THE FORGE',
eyebrowSuffix,
backHref,
backLabel = 'Back',
title,
accent = '.',
kicker,
lede,
lede_html,
toolbar,
stats,
size = 'lg'
}: Props = $props();
</script>
<header class="hero">
<header class="hero forge-hero">
<div class="top">
<span class="forge-eyebrow">
{#if backHref}
<a href={backHref} class="back" aria-label={backLabel}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
</a>
<span class="sep">/</span>
{/if}
<span class="forge-ember"></span>
<span>{eyebrow}</span>
<span class="eb-word">{eyebrow}</span>
{#if eyebrowSuffix}
<span class="sep">//</span>
<span>{eyebrowSuffix}</span>
<span class="eb-word eb-suffix">{eyebrowSuffix}</span>
{/if}
</span>
{#if toolbar}
@@ -39,34 +67,179 @@
{/if}
</div>
<h1 class="forge-display" class:s-md={size === 'md'} class:s-lg={size === 'lg'} class:s-xl={size === 'xl'}>
<h1 class="forge-display"
class:s-sm={size === 'sm'}
class:s-md={size === 'md'}
class:s-lg={size === 'lg'}
class:s-xl={size === 'xl'}
>
{title}{#if accent}<span class="accent">{accent}</span>{/if}
</h1>
{#if kicker}
<p class="forge-kicker">{kicker}</p>
{/if}
{#if lede_html}
<p class="forge-lede">{@render lede_html()}</p>
{:else if lede}
<p class="forge-lede">{lede}</p>
{/if}
{#if stats}
<dl class="forge-hero-stats">{@render stats()}</dl>
{/if}
<div class="rule" aria-hidden="true">
<span class="tick"></span><span class="tick"></span><span class="tick"></span>
</div>
</header>
<style>
.hero { margin-bottom: 2rem; }
.hero {
margin-bottom: 1.75rem;
animation: hero-rise 520ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.top {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 1.25rem;
margin-bottom: 1rem;
flex-wrap: wrap;
animation: hero-fade 600ms 80ms ease-out both;
}
.toolbar { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.forge-eyebrow { gap: 0.5rem; }
.eb-word { transition: color 200ms ease; }
.eb-suffix { color: var(--text-secondary); }
.back {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px; height: 18px;
border-radius: 4px;
color: var(--text-tertiary);
transition: all 150ms ease;
}
.back:hover {
color: var(--color-brand-600);
background: var(--forge-accent-soft);
transform: translateX(-1px);
}
.forge-display {
animation: hero-fade 700ms 140ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.forge-display.s-sm { font-size: clamp(1.5rem, 3vw, 1.875rem); }
.forge-display.s-md { font-size: clamp(1.75rem, 3.5vw, 2.25rem); }
.forge-display.s-lg { font-size: clamp(2rem, 4vw, 2.75rem); }
.forge-display.s-xl { font-size: clamp(2.5rem, 5vw, 3.5rem); }
.forge-kicker {
font-family: var(--forge-mono);
font-size: 0.78rem;
color: var(--text-tertiary);
margin: 0.4rem 0 0;
letter-spacing: 0.02em;
animation: hero-fade 700ms 200ms ease-out both;
}
:global(.forge-hero .forge-lede) {
animation: hero-fade 700ms 220ms ease-out both;
}
.forge-hero-stats {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(auto, 1fr);
gap: 1.25rem;
margin: 1.1rem 0 0;
padding: 0;
animation: hero-fade 700ms 280ms ease-out both;
}
:global(.forge-hero-stats > div) {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
}
:global(.forge-hero-stats dt) {
font-family: var(--forge-mono);
font-size: 0.6rem;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--text-tertiary);
margin: 0;
}
:global(.forge-hero-stats dd) {
font-family: var(--forge-display);
font-size: 1.35rem;
line-height: 1.1;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
margin: 0;
}
:global(.forge-hero-stats dd.accent) { color: var(--forge-accent); }
:global(.forge-hero-stats dd.warn) { color: var(--color-warning-dark); }
:global(.forge-hero-stats dd.fail) { color: var(--color-danger); }
/* Decorative registration rule — tiny monospace tick marks under the hero */
.rule {
display: flex;
align-items: center;
gap: 6px;
margin-top: 1.25rem;
height: 1px;
animation: hero-fade 700ms 340ms ease-out both;
}
.rule::before,
.rule::after {
content: '';
flex: 1;
height: 1px;
background: linear-gradient(90deg, transparent, var(--border-primary) 20%, var(--border-primary) 80%, transparent);
}
.rule::after { flex: 3; }
.tick {
width: 3px; height: 3px;
background: var(--border-primary);
border-radius: 50%;
}
.tick:first-of-type { background: var(--forge-accent); }
@keyframes hero-rise {
from { transform: translateY(6px); }
to { transform: translateY(0); }
}
@keyframes hero-fade {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.hero,
.top,
.forge-display,
.forge-kicker,
.forge-hero-stats,
.rule,
:global(.forge-hero .forge-lede) {
animation: none !important;
}
}
@media (max-width: 640px) {
.top { align-items: flex-start; }
.forge-hero-stats {
grid-auto-flow: row;
grid-auto-columns: auto;
grid-template-columns: repeat(2, 1fr);
gap: 0.9rem 1.25rem;
}
}
</style>
+11 -4
View File
@@ -781,15 +781,22 @@
},
"proxies": {
"title": "Proxy Routes",
"description": "Active proxy routes created by deployments.",
"description": "Active proxy routes from deployed containers and static sites.",
"domain": "Domain",
"project": "Project",
"stage": "Stage",
"project": "Project / Site",
"stage": "Stage / Mode",
"tag": "Tag",
"port": "Port",
"status": "Status",
"source": "Source",
"sourceContainer": "Container",
"sourceStatic": "Static Site",
"sourceDeno": "Deno Site",
"filterAll": "All",
"filterContainers": "Containers",
"filterSites": "Sites",
"noRoutes": "No proxy routes",
"noRoutesDesc": "Proxy routes are created automatically when you deploy a container with proxy enabled.",
"noRoutesDesc": "Proxy routes are created automatically when you deploy a container with proxy enabled or publish a static site.",
"searchPlaceholder": "Search by domain, project, or tag...",
"noMatch": "No routes match your search.",
"loadFailed": "Failed to load proxy routes",
+11 -4
View File
@@ -781,15 +781,22 @@
},
"proxies": {
"title": "Прокси-маршруты",
"description": "Активные прокси-маршруты, созданные при развёртывании.",
"description": "Активные прокси-маршруты от контейнеров и статических сайтов.",
"domain": "Домен",
"project": "Проект",
"stage": "Этап",
"project": "Проект / Сайт",
"stage": "Этап / Режим",
"tag": "Тег",
"port": "Порт",
"status": "Статус",
"source": "Источник",
"sourceContainer": "Контейнер",
"sourceStatic": "Статический сайт",
"sourceDeno": "Deno-сайт",
"filterAll": "Все",
"filterContainers": "Контейнеры",
"filterSites": "Сайты",
"noRoutes": "Нет прокси-маршрутов",
"noRoutesDesc": "Прокси-маршруты создаются автоматически при развёртывании контейнера с включённым прокси.",
"noRoutesDesc": "Прокси-маршруты создаются автоматически при развёртывании контейнера с прокси или публикации статического сайта.",
"searchPlaceholder": "Поиск по домену, проекту или тегу...",
"noMatch": "Нет маршрутов, соответствующих поиску.",
"loadFailed": "Не удалось загрузить прокси-маршруты",
+4 -1
View File
@@ -279,8 +279,11 @@ export interface NpmAccessList {
name: string;
}
/** A proxy route managed by a deployed instance. */
export type ProxyRouteSource = 'instance' | 'static_site';
/** A proxy route managed by a deployed instance or a static site. */
export interface ProxyRoute {
source: ProxyRouteSource;
instance_id: string;
project_id: string;
project_name: string;