feat: unified THE FORGE // SECTION headers and merged proxy routes
Build / build (push) Successful in 10m37s
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:
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Не удалось загрузить прокси-маршруты",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user