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
+20 -3
View File
@@ -3,10 +3,12 @@ package api
import ( import (
"log/slog" "log/slog"
"net/http" "net/http"
"sort"
) )
// listProxyRoutes handles GET /api/proxies. // listProxyRoutes handles GET /api/proxies.
// Returns all proxy-enabled instances with project and stage names. // Returns proxy routes from both Docker instances and static sites,
// merged and sorted by domain.
func (s *Server) listProxyRoutes(w http.ResponseWriter, r *http.Request) { func (s *Server) listProxyRoutes(w http.ResponseWriter, r *http.Request) {
settings, err := s.store.GetSettings() settings, err := s.store.GetSettings()
if err != nil { if err != nil {
@@ -15,12 +17,27 @@ func (s *Server) listProxyRoutes(w http.ResponseWriter, r *http.Request) {
return return
} }
routes, err := s.store.ListProxyRoutes(settings.Domain) instanceRoutes, err := s.store.ListProxyRoutes(settings.Domain)
if err != nil { if err != nil {
slog.Error("failed to list proxy routes", "error", err) slog.Error("failed to list instance proxy routes", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error") respondError(w, http.StatusInternalServerError, "internal server error")
return return
} }
siteRoutes, err := s.store.ListStaticSiteProxyRoutes(settings.Domain)
if err != nil {
slog.Error("failed to list static site proxy routes", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
routes := append(instanceRoutes, siteRoutes...)
sort.SliceStable(routes, func(i, j int) bool {
if routes[i].Domain == routes[j].Domain {
return routes[i].ProjectName < routes[j].ProjectName
}
return routes[i].Domain < routes[j].Domain
})
respondJSON(w, http.StatusOK, routes) respondJSON(w, http.StatusOK, routes)
} }
+4 -1
View File
@@ -119,8 +119,10 @@ func (s *Store) ListAllInstances() ([]Instance, error) {
return instances, rows.Err() return instances, rows.Err()
} }
// ProxyRoute represents a proxy-enabled instance with project and stage names. // ProxyRoute represents a proxy-enabled resource (Docker instance or static site)
// joined with the human-readable names needed to render the Proxies page.
type ProxyRoute struct { type ProxyRoute struct {
Source string `json:"source"` // "instance" or "static_site"
InstanceID string `json:"instance_id"` InstanceID string `json:"instance_id"`
ProjectID string `json:"project_id"` ProjectID string `json:"project_id"`
ProjectName string `json:"project_name"` ProjectName string `json:"project_name"`
@@ -164,6 +166,7 @@ func (s *Store) ListProxyRoutes(domain string) ([]ProxyRoute, error) {
); err != nil { ); err != nil {
return nil, fmt.Errorf("scan proxy route: %w", err) return nil, fmt.Errorf("scan proxy route: %w", err)
} }
r.Source = "instance"
if domain != "" && r.Subdomain != "" { if domain != "" && r.Subdomain != "" {
r.Domain = r.Subdomain + "." + domain r.Domain = r.Subdomain + "." + domain
} }
+45
View File
@@ -4,6 +4,7 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"strings"
"github.com/google/uuid" "github.com/google/uuid"
) )
@@ -154,6 +155,50 @@ func (s *Store) UpdateStaticSiteContainer(id, containerID, proxyRouteID string)
return nil return nil
} }
// ListStaticSiteProxyRoutes returns proxy routes backed by static sites,
// shaped to match the unified ProxyRoute model used by the Proxies page.
// Sites without an active proxy route are skipped.
func (s *Store) ListStaticSiteProxyRoutes(domain string) ([]ProxyRoute, error) {
rows, err := s.db.Query(
`SELECT id, name, mode, provider, domain, container_id, proxy_route_id, status, created_at
FROM static_sites
WHERE proxy_route_id != ''
ORDER BY name`,
)
if err != nil {
return nil, fmt.Errorf("query static site proxy routes: %w", err)
}
defer rows.Close()
suffix := ""
if domain != "" {
suffix = "." + strings.ToLower(domain)
}
routes := []ProxyRoute{}
for rows.Next() {
var r ProxyRoute
var mode, provider, fullDomain string
if err := rows.Scan(
&r.InstanceID, &r.ProjectName, &mode, &provider, &fullDomain,
&r.ContainerID, &r.ProxyRouteID, &r.Status, &r.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scan static site proxy route: %w", err)
}
r.Source = "static_site"
r.StageName = mode
r.ImageTag = provider
r.Domain = fullDomain
if suffix != "" && strings.HasSuffix(strings.ToLower(fullDomain), suffix) {
r.Subdomain = fullDomain[:len(fullDomain)-len(suffix)]
} else {
r.Subdomain = fullDomain
}
routes = append(routes, r)
}
return routes, rows.Err()
}
// DeleteStaticSite removes a static site by ID. Cascading deletes handle secrets. // DeleteStaticSite removes a static site by ID. Cascading deletes handle secrets.
func (s *Store) DeleteStaticSite(id string) error { func (s *Store) DeleteStaticSite(id string) error {
result, err := s.db.Exec(`DELETE FROM static_sites WHERE id = ?`, id) result, err := s.db.Exec(`DELETE FROM static_sites WHERE id = ?`, id)
+180 -7
View File
@@ -2,36 +2,64 @@
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
interface Props { interface Props {
/** Primary eyebrow label (monospace caps). Defaults to "THE FORGE" for brand consistency. */
eyebrow?: string; eyebrow?: string;
/** Right-hand side of the eyebrow, separated by // — typically the section name. */
eyebrowSuffix?: string; 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; title: string;
/** Optional trailing accent character after the title (defaults to "."). Pass empty string to hide. */
accent?: string; 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; lede?: string;
/** Optional HTML lede paragraph snippet (takes priority over `lede`). */
lede_html?: Snippet; lede_html?: Snippet;
/** Right-hand toolbar snippet — page actions live here. */
toolbar?: Snippet; 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 { const {
eyebrow = 'THE FORGE', eyebrow = 'THE FORGE',
eyebrowSuffix, eyebrowSuffix,
backHref,
backLabel = 'Back',
title, title,
accent = '.', accent = '.',
kicker,
lede, lede,
lede_html, lede_html,
toolbar, toolbar,
stats,
size = 'lg' size = 'lg'
}: Props = $props(); }: Props = $props();
</script> </script>
<header class="hero"> <header class="hero forge-hero">
<div class="top"> <div class="top">
<span class="forge-eyebrow"> <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 class="forge-ember"></span>
<span>{eyebrow}</span> <span class="eb-word">{eyebrow}</span>
{#if eyebrowSuffix} {#if eyebrowSuffix}
<span class="sep">//</span> <span class="sep">//</span>
<span>{eyebrowSuffix}</span> <span class="eb-word eb-suffix">{eyebrowSuffix}</span>
{/if} {/if}
</span> </span>
{#if toolbar} {#if toolbar}
@@ -39,34 +67,179 @@
{/if} {/if}
</div> </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} {title}{#if accent}<span class="accent">{accent}</span>{/if}
</h1> </h1>
{#if kicker}
<p class="forge-kicker">{kicker}</p>
{/if}
{#if lede_html} {#if lede_html}
<p class="forge-lede">{@render lede_html()}</p> <p class="forge-lede">{@render lede_html()}</p>
{:else if lede} {:else if lede}
<p class="forge-lede">{lede}</p> <p class="forge-lede">{lede}</p>
{/if} {/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> </header>
<style> <style>
.hero { margin-bottom: 2rem; } .hero {
margin-bottom: 1.75rem;
animation: hero-rise 520ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.top { .top {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
margin-bottom: 1.25rem; margin-bottom: 1rem;
flex-wrap: wrap; flex-wrap: wrap;
animation: hero-fade 600ms 80ms ease-out both;
} }
.toolbar { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; } .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-md { font-size: clamp(1.75rem, 3.5vw, 2.25rem); }
.forge-display.s-lg { font-size: clamp(2rem, 4vw, 2.75rem); } .forge-display.s-lg { font-size: clamp(2rem, 4vw, 2.75rem); }
.forge-display.s-xl { font-size: clamp(2.5rem, 5vw, 3.5rem); } .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) { @media (max-width: 640px) {
.top { align-items: flex-start; } .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> </style>
+11 -4
View File
@@ -781,15 +781,22 @@
}, },
"proxies": { "proxies": {
"title": "Proxy Routes", "title": "Proxy Routes",
"description": "Active proxy routes created by deployments.", "description": "Active proxy routes from deployed containers and static sites.",
"domain": "Domain", "domain": "Domain",
"project": "Project", "project": "Project / Site",
"stage": "Stage", "stage": "Stage / Mode",
"tag": "Tag", "tag": "Tag",
"port": "Port", "port": "Port",
"status": "Status", "status": "Status",
"source": "Source",
"sourceContainer": "Container",
"sourceStatic": "Static Site",
"sourceDeno": "Deno Site",
"filterAll": "All",
"filterContainers": "Containers",
"filterSites": "Sites",
"noRoutes": "No proxy routes", "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...", "searchPlaceholder": "Search by domain, project, or tag...",
"noMatch": "No routes match your search.", "noMatch": "No routes match your search.",
"loadFailed": "Failed to load proxy routes", "loadFailed": "Failed to load proxy routes",
+11 -4
View File
@@ -781,15 +781,22 @@
}, },
"proxies": { "proxies": {
"title": "Прокси-маршруты", "title": "Прокси-маршруты",
"description": "Активные прокси-маршруты, созданные при развёртывании.", "description": "Активные прокси-маршруты от контейнеров и статических сайтов.",
"domain": "Домен", "domain": "Домен",
"project": "Проект", "project": "Проект / Сайт",
"stage": "Этап", "stage": "Этап / Режим",
"tag": "Тег", "tag": "Тег",
"port": "Порт", "port": "Порт",
"status": "Статус", "status": "Статус",
"source": "Источник",
"sourceContainer": "Контейнер",
"sourceStatic": "Статический сайт",
"sourceDeno": "Deno-сайт",
"filterAll": "Все",
"filterContainers": "Контейнеры",
"filterSites": "Сайты",
"noRoutes": "Нет прокси-маршрутов", "noRoutes": "Нет прокси-маршрутов",
"noRoutesDesc": "Прокси-маршруты создаются автоматически при развёртывании контейнера с включённым прокси.", "noRoutesDesc": "Прокси-маршруты создаются автоматически при развёртывании контейнера с прокси или публикации статического сайта.",
"searchPlaceholder": "Поиск по домену, проекту или тегу...", "searchPlaceholder": "Поиск по домену, проекту или тегу...",
"noMatch": "Нет маршрутов, соответствующих поиску.", "noMatch": "Нет маршрутов, соответствующих поиску.",
"loadFailed": "Не удалось загрузить прокси-маршруты", "loadFailed": "Не удалось загрузить прокси-маршруты",
+4 -1
View File
@@ -279,8 +279,11 @@ export interface NpmAccessList {
name: string; 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 { export interface ProxyRoute {
source: ProxyRouteSource;
instance_id: string; instance_id: string;
project_id: string; project_id: string;
project_name: string; project_name: string;
+130 -1
View File
@@ -44,6 +44,43 @@
let hintsExpanded = $state(false); let hintsExpanded = $state(false);
let proxyHintsExpanded = $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 dockerConnected = $derived(dockerHealth?.connected ?? false);
const proxyConnected = $derived(proxyHealth?.connected ?? true); const proxyConnected = $derived(proxyHealth?.connected ?? true);
const proxyProviderName = $derived(proxyHealth?.provider ?? ''); const proxyProviderName = $derived(proxyHealth?.provider ?? '');
@@ -80,6 +117,9 @@
goto('/', { replaceState: true }); goto('/', { replaceState: true });
} }
} }
tickClock();
clockTimer = setInterval(tickClock, 1000);
window.addEventListener('keydown', handleKeydown);
}); });
// Start health polling when authenticated. // Start health polling when authenticated.
@@ -106,6 +146,8 @@
onDestroy(() => { onDestroy(() => {
if (healthInterval) clearInterval(healthInterval); if (healthInterval) clearInterval(healthInterval);
if (clockTimer) clearInterval(clockTimer);
if (typeof window !== 'undefined') window.removeEventListener('keydown', handleKeydown);
}); });
</script> </script>
@@ -255,7 +297,18 @@
<IconLogout size={16} /> <IconLogout size={16} />
</button> </button>
</div> </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> </div>
</aside> </aside>
@@ -336,6 +389,82 @@
border-radius: 0 3px 3px 0; 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 */ /* Apply dot-grid backdrop to main content */
:global(main) { :global(main) {
position: relative; position: relative;
+14 -8
View File
@@ -5,6 +5,7 @@
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import EmptyState from '$lib/components/EmptyState.svelte'; import EmptyState from '$lib/components/EmptyState.svelte';
import SkeletonCard from '$lib/components/SkeletonCard.svelte'; import SkeletonCard from '$lib/components/SkeletonCard.svelte';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import { IconTrash, IconLoader } from '$lib/components/icons'; import { IconTrash, IconLoader } from '$lib/components/icons';
import { toasts } from '$lib/stores/toast'; import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
@@ -75,22 +76,27 @@
</svelte:head> </svelte:head>
<div class="space-y-6"> <div class="space-y-6">
<!-- Header --> {#snippet heroToolbar()}
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('stale.title')}</h1>
{#if containers.length > 0} {#if containers.length > 0}
<button <button
type="button" type="button"
disabled={bulkCleaning} disabled={bulkCleaning}
onclick={() => { confirmBulk = true; }} onclick={() => { confirmBulk = true; }}
class="inline-flex items-center gap-2 rounded-lg border border-[var(--color-danger)] px-4 py-2.5 text-sm font-medium text-[var(--color-danger)] transition-colors hover:bg-[var(--color-danger-light)] disabled:opacity-50 active:animate-press" class="forge-btn-ghost forge-btn-danger"
> >
{#if bulkCleaning}<IconLoader size={16} />{/if} {#if bulkCleaning}<IconLoader size={14} />{/if}
<IconTrash size={16} /> <IconTrash size={14} />
{$t('stale.cleanupAll')} <span>{$t('stale.cleanupAll')}</span>
</button> </button>
{/if} {/if}
</div> {/snippet}
<ForgeHero
backHref="/"
eyebrowSuffix="STALE"
title={$t('stale.title')}
size="lg"
toolbar={heroToolbar}
/>
<!-- Content --> <!-- Content -->
{#if loading} {#if loading}
+7 -4
View File
@@ -5,6 +5,7 @@
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte'; import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import EntityPicker from '$lib/components/EntityPicker.svelte'; import EntityPicker from '$lib/components/EntityPicker.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import { toasts } from '$lib/stores/toast'; import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@@ -200,10 +201,12 @@
</svelte:head> </svelte:head>
<div class="mx-auto max-w-2xl space-y-6"> <div class="mx-auto max-w-2xl space-y-6">
<div> <ForgeHero
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('quickDeploy.title')}</h1> eyebrowSuffix="DEPLOY"
<p class="mt-1 text-sm text-[var(--text-secondary)]">{$t('quickDeploy.description')}</p> title={$t('quickDeploy.title')}
</div> lede={$t('quickDeploy.description')}
size="lg"
/>
<!-- Step 1 --> <!-- Step 1 -->
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]"> <div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
+16 -22
View File
@@ -5,6 +5,7 @@
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { IconSearch, IconRefresh, IconTrash, IconLoader } from '$lib/components/icons'; import { IconSearch, IconRefresh, IconTrash, IconLoader } from '$lib/components/icons';
import Skeleton from '$lib/components/Skeleton.svelte'; import Skeleton from '$lib/components/Skeleton.svelte';
import ForgeHero from '$lib/components/ForgeHero.svelte';
let loading = $state(true); let loading = $state(true);
let records = $state<DnsRecordView[]>([]); let records = $state<DnsRecordView[]>([]);
@@ -99,32 +100,25 @@
<Skeleton height="20rem" /> <Skeleton height="20rem" />
</div> </div>
{:else} {:else}
<!-- Header --> {#snippet heroToolbar()}
<div class="flex items-center justify-between"> <button onclick={handleRefresh} class="forge-btn-ghost">
<div> <IconRefresh size={14} />
<h1 class="text-xl font-bold text-[var(--text-primary)]">{$t('dns.title')}</h1> <span>{$t('dns.refresh')}</span>
<p class="text-sm text-[var(--text-secondary)]">{$t('dns.description')}</p>
</div>
<div class="flex items-center gap-2">
<button
onclick={handleRefresh}
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-2 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
>
<IconRefresh size={16} />
{$t('dns.refresh')}
</button> </button>
{#if !wildcardDns} {#if !wildcardDns}
<button <button onclick={handleSync} disabled={syncing} class="forge-btn">
onclick={handleSync} {#if syncing}<IconLoader size={14} />{/if}
disabled={syncing} <span>{syncing ? $t('dns.syncing') : $t('dns.syncNow')}</span>
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--color-brand-600)] px-3 py-2 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
>
{#if syncing}<IconLoader size={16} />{/if}
{syncing ? $t('dns.syncing') : $t('dns.syncNow')}
</button> </button>
{/if} {/if}
</div> {/snippet}
</div> <ForgeHero
eyebrowSuffix="DNS"
title={$t('dns.title')}
lede={$t('dns.description')}
size="lg"
toolbar={heroToolbar}
/>
<!-- Filters --> <!-- Filters -->
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
+11 -8
View File
@@ -13,6 +13,7 @@
import EventLogEntryComponent from '$lib/components/EventLogEntry.svelte'; import EventLogEntryComponent from '$lib/components/EventLogEntry.svelte';
import EventLogFilter from '$lib/components/EventLogFilter.svelte'; import EventLogFilter from '$lib/components/EventLogFilter.svelte';
import EmptyState from '$lib/components/EmptyState.svelte'; import EmptyState from '$lib/components/EmptyState.svelte';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import { IconLoader } from '$lib/components/icons'; import { IconLoader } from '$lib/components/icons';
// ── State ───────────────────────────────────────────────────── // ── State ─────────────────────────────────────────────────────
@@ -213,22 +214,24 @@
</script> </script>
<div class="space-y-4"> <div class="space-y-4">
<!-- Header --> {#snippet heroToolbar()}
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('events.title')}</h1>
<div class="flex items-center gap-3">
{#if stats.total > 0} {#if stats.total > 0}
<span class="text-xs text-[var(--text-tertiary)] tabular-nums">{stats.total} total</span> <span class="forge-pill"><span class="pulse"></span>{stats.total} total</span>
<button <button
type="button" type="button"
onclick={() => { showClearConfirm = true; }} onclick={() => { showClearConfirm = true; }}
class="rounded-lg border border-[var(--border-primary)] px-3 py-1.5 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--color-danger)] transition-colors" class="forge-btn-ghost forge-btn-danger"
> >
{$t('events.clearAll')} {$t('events.clearAll')}
</button> </button>
{/if} {/if}
</div> {/snippet}
</div> <ForgeHero
eyebrowSuffix="EVENTS"
title={$t('events.title')}
size="lg"
toolbar={heroToolbar}
/>
<!-- Filter bar (includes severity stats as pill counts) --> <!-- Filter bar (includes severity stats as pill counts) -->
<EventLogFilter <EventLogFilter
+12 -6
View File
@@ -7,6 +7,7 @@
import SkeletonTable from '$lib/components/SkeletonTable.svelte'; import SkeletonTable from '$lib/components/SkeletonTable.svelte';
import EmptyState from '$lib/components/EmptyState.svelte'; import EmptyState from '$lib/components/EmptyState.svelte';
import EntityPicker from '$lib/components/EntityPicker.svelte'; import EntityPicker from '$lib/components/EntityPicker.svelte';
import ForgeHero from '$lib/components/ForgeHero.svelte';
let projects = $state<Project[]>([]); let projects = $state<Project[]>([]);
let loading = $state(true); let loading = $state(true);
@@ -144,17 +145,22 @@
</svelte:head> </svelte:head>
<div class="space-y-6"> <div class="space-y-6">
<div class="flex items-center justify-between"> {#snippet heroToolbar()}
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('projects.title')}</h1>
<button <button
type="button" type="button"
class="inline-flex items-center gap-2 rounded-lg {showAddForm ? 'border border-[var(--border-primary)] bg-[var(--surface-card)] text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)]' : 'bg-[var(--color-brand-600)] text-white shadow-sm hover:bg-[var(--color-brand-700)]'} px-4 py-2.5 text-sm font-medium transition-all duration-150 active:animate-press" class={showAddForm ? 'forge-btn-ghost' : 'forge-btn'}
onclick={() => { showAddForm = !showAddForm; }} onclick={() => { showAddForm = !showAddForm; }}
> >
{#if !showAddForm}<IconPlus size={16} />{/if} {#if !showAddForm}<IconPlus size={14} />{/if}
{showAddForm ? $t('projects.cancel') : $t('projects.addProject')} <span>{showAddForm ? $t('projects.cancel') : $t('projects.addProject')}</span>
</button> </button>
</div> {/snippet}
<ForgeHero
eyebrowSuffix="PROJECTS"
title={$t('projects.title')}
size="lg"
toolbar={heroToolbar}
/>
<!-- Add project form --> <!-- Add project form -->
{#if showAddForm} {#if showAddForm}
+15 -12
View File
@@ -9,7 +9,7 @@
import EmptyState from '$lib/components/EmptyState.svelte'; import EmptyState from '$lib/components/EmptyState.svelte';
import Skeleton from '$lib/components/Skeleton.svelte'; import Skeleton from '$lib/components/Skeleton.svelte';
import { IconTrash, IconKey, IconHardDrive, IconDeploy, IconClock, IconPlus, IconEdit, IconCheck, IconX } from '$lib/components/icons'; import { IconTrash, IconKey, IconHardDrive, IconDeploy, IconClock, IconPlus, IconEdit, IconCheck, IconX } from '$lib/components/icons';
import Breadcrumb from '$lib/components/Breadcrumb.svelte'; import ForgeHero from '$lib/components/ForgeHero.svelte';
import FormField from '$lib/components/FormField.svelte'; import FormField from '$lib/components/FormField.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte'; import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import EntityPicker from '$lib/components/EntityPicker.svelte'; import EntityPicker from '$lib/components/EntityPicker.svelte';
@@ -415,23 +415,26 @@
</button> </button>
</div> </div>
{:else if project} {:else if project}
{@const p = project}
<div class="space-y-6"> <div class="space-y-6">
<!-- Header --> {#snippet projectToolbar()}
<div class="flex items-start justify-between">
<div>
<Breadcrumb items={[{ label: $t('projects.title'), href: '/projects' }]} />
<h1 class="mt-1 text-2xl font-bold text-[var(--text-primary)]">{project.name}</h1>
<p class="mt-1 font-mono text-sm text-[var(--text-tertiary)]">{project.image}</p>
</div>
<button <button
type="button" type="button"
class="inline-flex items-center gap-2 rounded-lg border border-[var(--color-danger)] px-3 py-2 text-sm font-medium text-[var(--color-danger)] hover:bg-[var(--color-danger-light)] transition-colors active:animate-press" class="forge-btn-ghost forge-btn-danger"
onclick={() => { showDeleteConfirm = true; }} onclick={() => { showDeleteConfirm = true; }}
> >
<IconTrash size={16} /> <IconTrash size={14} />
{$t('projectDetail.deleteProject')} <span>{$t('projectDetail.deleteProject')}</span>
</button> </button>
</div> {/snippet}
<ForgeHero
backHref="/projects"
eyebrowSuffix="PROJECT"
title={p.name}
kicker={p.image}
size="lg"
toolbar={projectToolbar}
/>
<!-- Project settings links --> <!-- Project settings links -->
<div class="flex gap-3"> <div class="flex gap-3">
+8 -7
View File
@@ -5,7 +5,7 @@
import { toasts } from '$lib/stores/toast'; import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { IconPlus, IconEdit, IconTrash, IconCheck, IconX, IconLock, IconLoader } from '$lib/components/icons'; import { IconPlus, IconEdit, IconTrash, IconCheck, IconX, IconLock, IconLoader } from '$lib/components/icons';
import Breadcrumb from '$lib/components/Breadcrumb.svelte'; import ForgeHero from '$lib/components/ForgeHero.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte'; import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import Skeleton from '$lib/components/Skeleton.svelte'; import Skeleton from '$lib/components/Skeleton.svelte';
import EmptyState from '$lib/components/EmptyState.svelte'; import EmptyState from '$lib/components/EmptyState.svelte';
@@ -212,12 +212,13 @@
</svelte:head> </svelte:head>
<div class="space-y-6"> <div class="space-y-6">
<!-- Header --> <ForgeHero
<div> backHref={`/projects/${projectId}`}
<Breadcrumb items={[{ label: $t('common.project'), href: `/projects/${projectId}` }]} /> eyebrowSuffix="ENV"
<h1 class="mt-1 text-2xl font-bold text-[var(--text-primary)]">{$t('envEditor.title')}</h1> title={$t('envEditor.title')}
<p class="mt-1 text-sm text-[var(--text-secondary)]">{$t('envEditor.description')}</p> lede={$t('envEditor.description')}
</div> size="lg"
/>
{#if loading} {#if loading}
<div class="space-y-4"> <div class="space-y-4">
@@ -5,7 +5,7 @@
import { toasts } from '$lib/stores/toast'; import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { IconPlus, IconEdit, IconTrash, IconCheck, IconX, IconInfo } from '$lib/components/icons'; import { IconPlus, IconEdit, IconTrash, IconCheck, IconX, IconInfo } from '$lib/components/icons';
import Breadcrumb from '$lib/components/Breadcrumb.svelte'; import ForgeHero from '$lib/components/ForgeHero.svelte';
import Skeleton from '$lib/components/Skeleton.svelte'; import Skeleton from '$lib/components/Skeleton.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
@@ -147,12 +147,13 @@
</svelte:head> </svelte:head>
<div class="space-y-6"> <div class="space-y-6">
<!-- Header --> <ForgeHero
<div> backHref={`/projects/${projectId}`}
<Breadcrumb items={[{ label: $t('common.project'), href: `/projects/${projectId}` }]} /> eyebrowSuffix="VOLUMES"
<h1 class="mt-1 text-2xl font-bold text-[var(--text-primary)]">{$t('volumeEditor.title')}</h1> title={$t('volumeEditor.title')}
<p class="mt-1 text-sm text-[var(--text-secondary)]">{$t('volumeEditor.description')}</p> lede={$t('volumeEditor.description')}
</div> size="lg"
/>
<!-- Scope legend --> <!-- Scope legend -->
{#if scopes.length > 0 && !loading} {#if scopes.length > 0 && !loading}
@@ -5,7 +5,7 @@
import { toasts } from '$lib/stores/toast'; import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { IconLoader, IconChevronRight } from '$lib/components/icons'; import { IconLoader, IconChevronRight } from '$lib/components/icons';
import Breadcrumb from '$lib/components/Breadcrumb.svelte'; import ForgeHero from '$lib/components/ForgeHero.svelte';
import Skeleton from '$lib/components/Skeleton.svelte'; import Skeleton from '$lib/components/Skeleton.svelte';
const projectId = $derived($page.params.id ?? ''); const projectId = $derived($page.params.id ?? '');
@@ -123,15 +123,7 @@
</svelte:head> </svelte:head>
<div class="space-y-4"> <div class="space-y-4">
<!-- Header --> {#snippet browserToolbar()}
<div>
<Breadcrumb items={[
{ label: $t('common.project'), href: `/projects/${projectId}` },
{ label: $t('volumeEditor.title'), href: `/projects/${projectId}/volumes` }
]} />
<div class="mt-1 flex items-center justify-between">
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('volumeBrowser.title')}</h1>
<div class="flex items-center gap-2">
<button <button
type="button" type="button"
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-2 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors" class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-2 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
@@ -148,11 +140,16 @@
{$t('volumeBrowser.upload')} {$t('volumeBrowser.upload')}
<input bind:this={fileInput} type="file" multiple class="hidden" onchange={handleUpload} /> <input bind:this={fileInput} type="file" multiple class="hidden" onchange={handleUpload} />
</label> </label>
</div> {/snippet}
</div> <ForgeHero
</div> backHref={`/projects/${projectId}/volumes`}
eyebrowSuffix="VOLUME BROWSER"
title={$t('volumeBrowser.title')}
size="lg"
toolbar={browserToolbar}
/>
<!-- Breadcrumbs --> <!-- Path breadcrumbs -->
<nav class="flex items-center gap-1 text-sm"> <nav class="flex items-center gap-1 text-sm">
<button <button
type="button" type="button"
+88 -24
View File
@@ -1,27 +1,56 @@
<script lang="ts"> <script lang="ts">
import { listProxyRoutes } from '$lib/api'; import { listProxyRoutes } from '$lib/api';
import type { ProxyRoute } from '$lib/types'; import type { ProxyRoute, ProxyRouteSource } from '$lib/types';
import { toasts } from '$lib/stores/toast'; import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import Skeleton from '$lib/components/Skeleton.svelte'; import Skeleton from '$lib/components/Skeleton.svelte';
import EmptyState from '$lib/components/EmptyState.svelte'; import EmptyState from '$lib/components/EmptyState.svelte';
import StatusBadge from '$lib/components/StatusBadge.svelte'; import StatusBadge from '$lib/components/StatusBadge.svelte';
import ForgeHero from '$lib/components/ForgeHero.svelte';
type SourceFilter = 'all' | ProxyRouteSource;
let routes = $state<ProxyRoute[]>([]); let routes = $state<ProxyRoute[]>([]);
let loading = $state(true); let loading = $state(true);
let search = $state(''); let search = $state('');
let sourceFilter = $state<SourceFilter>('all');
const filtered = $derived( const counts = $derived({
search.trim() all: routes.length,
? routes.filter((r) => { instance: routes.filter((r) => r.source === 'instance').length,
const q = search.toLowerCase(); static_site: routes.filter((r) => r.source === 'static_site').length,
return r.domain?.toLowerCase().includes(q) });
|| r.project_name.toLowerCase().includes(q)
|| r.stage_name.toLowerCase().includes(q) const filtered = $derived.by(() => {
|| r.image_tag.toLowerCase().includes(q); const q = search.trim().toLowerCase();
}) return routes.filter((r) => {
: routes if (sourceFilter !== 'all' && r.source !== sourceFilter) return false;
if (!q) return true;
return (
r.domain?.toLowerCase().includes(q) ||
r.project_name.toLowerCase().includes(q) ||
r.stage_name.toLowerCase().includes(q) ||
r.image_tag.toLowerCase().includes(q)
); );
});
});
function sourceLabel(route: ProxyRoute): string {
if (route.source === 'static_site') {
return route.stage_name === 'deno' ? $t('proxies.sourceDeno') : $t('proxies.sourceStatic');
}
return $t('proxies.sourceContainer');
}
function sourceBadgeClass(source: ProxyRouteSource): string {
return source === 'static_site'
? 'bg-[var(--color-accent-50)] text-[var(--color-accent-700)] dark:bg-[var(--color-accent-900)] dark:text-[var(--color-accent-200)]'
: 'bg-[var(--color-brand-50)] text-[var(--color-brand-700)] dark:bg-[var(--color-brand-900)] dark:text-[var(--color-brand-200)]';
}
function targetHref(route: ProxyRoute): string {
return route.source === 'static_site' ? `/sites/${route.instance_id}` : `/projects/${route.project_id}`;
}
async function loadRoutes() { async function loadRoutes() {
loading = true; loading = true;
@@ -34,7 +63,9 @@
} }
} }
$effect(() => { loadRoutes(); }); $effect(() => {
loadRoutes();
});
</script> </script>
<svelte:head> <svelte:head>
@@ -42,12 +73,12 @@
</svelte:head> </svelte:head>
<div class="space-y-6"> <div class="space-y-6">
<div class="flex items-center justify-between"> <ForgeHero
<div> eyebrowSuffix="PROXIES"
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('proxies.title')}</h1> title={$t('proxies.title')}
<p class="mt-1 text-sm text-[var(--text-secondary)]">{$t('proxies.description')}</p> lede={$t('proxies.description')}
</div> size="lg"
</div> />
{#if loading} {#if loading}
<div class="space-y-3"> <div class="space-y-3">
@@ -58,7 +89,8 @@
{:else if routes.length === 0} {:else if routes.length === 0}
<EmptyState title={$t('proxies.noRoutes')} description={$t('proxies.noRoutesDesc')} icon="instances" /> <EmptyState title={$t('proxies.noRoutes')} description={$t('proxies.noRoutesDesc')} icon="instances" />
{:else} {:else}
<!-- Search --> <!-- Filters -->
<div class="flex flex-wrap items-center gap-3">
<input <input
type="text" type="text"
bind:value={search} bind:value={search}
@@ -66,11 +98,34 @@
class="w-full max-w-md rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]" class="w-full max-w-md rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]"
/> />
<div class="inline-flex rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] p-0.5 text-sm" role="tablist" aria-label={$t('proxies.source')}>
{#each [
{ value: 'all' as const, label: $t('proxies.filterAll'), count: counts.all },
{ value: 'instance' as const, label: $t('proxies.filterContainers'), count: counts.instance },
{ value: 'static_site' as const, label: $t('proxies.filterSites'), count: counts.static_site },
] as opt}
<button
type="button"
role="tab"
aria-selected={sourceFilter === opt.value}
onclick={() => (sourceFilter = opt.value)}
class="rounded-md px-3 py-1.5 transition-colors {sourceFilter === opt.value
? 'bg-[var(--color-brand-500)] text-white shadow-[var(--shadow-xs)]'
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}"
>
{opt.label}
<span class="ml-1 text-xs opacity-75">({opt.count})</span>
</button>
{/each}
</div>
</div>
<div class="overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]"> <div class="overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
<table class="min-w-full divide-y divide-[var(--border-primary)]"> <table class="min-w-full divide-y divide-[var(--border-primary)]">
<thead class="bg-[var(--surface-card-hover)]"> <thead class="bg-[var(--surface-card-hover)]">
<tr> <tr>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('proxies.domain')}</th> <th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('proxies.domain')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('proxies.source')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('proxies.project')}</th> <th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('proxies.project')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('proxies.stage')}</th> <th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('proxies.stage')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('proxies.tag')}</th> <th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('proxies.tag')}</th>
@@ -79,7 +134,7 @@
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-[var(--border-secondary)]"> <tbody class="divide-y divide-[var(--border-secondary)]">
{#each filtered as route (route.instance_id)} {#each filtered as route (route.source + ':' + route.instance_id)}
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors"> <tr class="hover:bg-[var(--surface-card-hover)] transition-colors">
<td class="px-4 py-3"> <td class="px-4 py-3">
{#if route.domain} {#if route.domain}
@@ -91,15 +146,24 @@
{/if} {/if}
</td> </td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<a href="/projects/{route.project_id}" class="text-sm font-medium text-[var(--text-primary)] hover:text-[var(--text-link)] transition-colors"> <span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {sourceBadgeClass(route.source)}">
{sourceLabel(route)}
</span>
</td>
<td class="px-4 py-3">
<a href={targetHref(route)} class="text-sm font-medium text-[var(--text-primary)] hover:text-[var(--text-link)] transition-colors">
{route.project_name} {route.project_name}
</a> </a>
</td> </td>
<td class="px-4 py-3 text-sm text-[var(--text-secondary)]">{route.stage_name}</td> <td class="px-4 py-3 text-sm text-[var(--text-secondary)]">{route.stage_name || '—'}</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
{#if route.image_tag}
<span class="rounded-full bg-[var(--surface-card-hover)] px-2 py-0.5 text-xs font-mono text-[var(--text-secondary)]">{route.image_tag}</span> <span class="rounded-full bg-[var(--surface-card-hover)] px-2 py-0.5 text-xs font-mono text-[var(--text-secondary)]">{route.image_tag}</span>
{:else}
<span class="text-sm text-[var(--text-tertiary)]"></span>
{/if}
</td> </td>
<td class="px-4 py-3 text-sm font-mono text-[var(--text-secondary)]">{route.port}</td> <td class="px-4 py-3 text-sm font-mono text-[var(--text-secondary)]">{route.port > 0 ? route.port : '—'}</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<StatusBadge status={route.status} /> <StatusBadge status={route.status} />
</td> </td>
@@ -109,7 +173,7 @@
</table> </table>
</div> </div>
{#if filtered.length === 0 && search} {#if filtered.length === 0}
<p class="text-center text-sm text-[var(--text-tertiary)]">{$t('proxies.noMatch')}</p> <p class="text-center text-sm text-[var(--text-tertiary)]">{$t('proxies.noMatch')}</p>
{/if} {/if}
+6 -1
View File
@@ -4,6 +4,7 @@
import { getSettings } from '$lib/api'; import { getSettings } from '$lib/api';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { IconSettings, IconDatabase, IconShield, IconHardDrive, IconWifi } from '$lib/components/icons'; import { IconSettings, IconDatabase, IconShield, IconHardDrive, IconWifi } from '$lib/components/icons';
import ForgeHero from '$lib/components/ForgeHero.svelte';
interface Props { interface Props {
children: Snippet; children: Snippet;
@@ -41,7 +42,11 @@
</script> </script>
<div class="mx-auto max-w-4xl"> <div class="mx-auto max-w-4xl">
<h1 class="mb-6 text-2xl font-bold text-[var(--text-primary)]">{$t('settings.title')}</h1> <ForgeHero
eyebrowSuffix="SETTINGS"
title={$t('settings.title')}
size="lg"
/>
<div class="flex flex-col gap-6 sm:flex-row"> <div class="flex flex-col gap-6 sm:flex-row">
<!-- Sub-navigation --> <!-- Sub-navigation -->
+12 -9
View File
@@ -6,6 +6,7 @@
import SkeletonTable from '$lib/components/SkeletonTable.svelte'; import SkeletonTable from '$lib/components/SkeletonTable.svelte';
import EmptyState from '$lib/components/EmptyState.svelte'; import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import ForgeHero from '$lib/components/ForgeHero.svelte';
let sites = $state<StaticSite[]>([]); let sites = $state<StaticSite[]>([]);
let loading = $state(true); let loading = $state(true);
@@ -110,16 +111,18 @@
</svelte:head> </svelte:head>
<div class="space-y-6"> <div class="space-y-6">
<div class="flex items-center justify-between"> {#snippet heroToolbar()}
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('sites.title')}</h1> <a href="/sites/new" class="forge-btn">
<a <IconPlus size={14} />
href="/sites/new" <span>{$t('sites.addSite')}</span>
class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-[var(--color-brand-700)] transition-all duration-150 active:animate-press"
>
<IconPlus size={16} />
{$t('sites.addSite')}
</a> </a>
</div> {/snippet}
<ForgeHero
eyebrowSuffix="SITES"
title={$t('sites.title')}
size="lg"
toolbar={heroToolbar}
/>
{#if loading} {#if loading}
<SkeletonTable rows={4} cols={5} /> <SkeletonTable rows={4} cols={5} />
+31 -40
View File
@@ -6,7 +6,8 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import FormField from '$lib/components/FormField.svelte'; import FormField from '$lib/components/FormField.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { IconArrowLeft, IconRefresh, IconGlobe, IconTrash, IconPlus, IconLoader, IconLock, IconUnlock, IconPlay, IconStop } from '$lib/components/icons'; import ForgeHero from '$lib/components/ForgeHero.svelte';
import { IconRefresh, IconGlobe, IconTrash, IconPlus, IconLoader, IconLock, IconUnlock, IconPlay, IconStop } from '$lib/components/icons';
let site = $state<StaticSite | null>(null); let site = $state<StaticSite | null>(null);
let secrets = $state<StaticSiteSecret[]>([]); let secrets = $state<StaticSiteSecret[]>([]);
@@ -149,66 +150,56 @@
<p class="text-sm text-[var(--color-danger)]">{error}</p> <p class="text-sm text-[var(--color-danger)]">{error}</p>
</div> </div>
{:else if site} {:else if site}
<!-- Header --> {@const s = site}
<div class="flex items-center justify-between"> {#snippet siteToolbar()}
<div class="flex items-center gap-3">
<a href="/sites" class="rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-card-hover)] transition-colors">
<IconArrowLeft size={20} />
</a>
<div>
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{site.name}</h1>
<p class="text-sm text-[var(--text-tertiary)]">{site.repo_owner}/{site.repo_name} &middot; {site.branch}</p>
</div>
</div>
<div class="flex items-center gap-2">
<button <button
type="button" type="button"
disabled={deploying} disabled={deploying}
onclick={handleDeploy} onclick={handleDeploy}
class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors" class="forge-btn"
> >
<IconRefresh size={16} class={deploying ? 'animate-spin' : ''} /> <IconRefresh size={14} class={deploying ? 'animate-spin' : ''} />
{$t('sites.deploy')} <span>{$t('sites.deploy')}</span>
</button> </button>
{#if site.status === 'stopped'} {#if s.status === 'stopped'}
<button <button type="button" onclick={handleStart} class="forge-btn-ghost">
type="button" <IconPlay size={14} />
onclick={handleStart} <span>{$t('sites.start')}</span>
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-emerald-600 hover:bg-[var(--surface-card-hover)] transition-colors"
>
<IconPlay size={16} />
{$t('sites.start')}
</button> </button>
{:else if site.status === 'deployed'} {:else if s.status === 'deployed'}
<button <button type="button" onclick={handleStop} class="forge-btn-ghost">
type="button" <IconStop size={14} />
onclick={handleStop} <span>{$t('sites.stop')}</span>
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
>
<IconStop size={16} />
{$t('sites.stop')}
</button> </button>
{/if} {/if}
{#if site.domain} {#if s.domain}
<a <a
href="https://{site.domain}" href="https://{s.domain}"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors" class="forge-btn-ghost"
> >
<IconGlobe size={16} /> <IconGlobe size={14} />
{$t('sites.openSite')} <span>{$t('sites.openSite')}</span>
</a> </a>
{/if} {/if}
<button <button
type="button" type="button"
onclick={() => { confirmDelete = true; }} onclick={() => { confirmDelete = true; }}
class="rounded-lg border border-[var(--color-danger-light)] px-4 py-2.5 text-sm font-medium text-[var(--color-danger)] hover:bg-red-50 dark:hover:bg-red-900/10 transition-colors" class="forge-btn-icon forge-btn-danger"
aria-label="Delete"
> >
<IconTrash size={16} /> <IconTrash size={16} />
</button> </button>
</div> {/snippet}
</div> <ForgeHero
backHref="/sites"
eyebrowSuffix="SITE"
title={s.name}
kicker="{s.repo_owner}/{s.repo_name} · {s.branch}"
size="lg"
toolbar={siteToolbar}
/>
{#if error} {#if error}
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-3"> <div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-3">
+8 -8
View File
@@ -4,7 +4,8 @@
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import FormField from '$lib/components/FormField.svelte'; import FormField from '$lib/components/FormField.svelte';
import { IconArrowLeft, IconCheck, IconLoader, IconChevronRight, IconSearch } from '$lib/components/icons'; import ForgeHero from '$lib/components/ForgeHero.svelte';
import { IconCheck, IconLoader, IconChevronRight, IconSearch } from '$lib/components/icons';
import EntityPicker from '$lib/components/EntityPicker.svelte'; import EntityPicker from '$lib/components/EntityPicker.svelte';
import type { EntityPickerItem } from '$lib/types'; import type { EntityPickerItem } from '$lib/types';
@@ -274,13 +275,12 @@
</svelte:head> </svelte:head>
<div class="space-y-6"> <div class="space-y-6">
<!-- Header --> <ForgeHero
<div class="flex items-center gap-3"> backHref="/sites"
<a href="/sites" class="rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-card-hover)] transition-colors"> eyebrowSuffix="NEW SITE"
<IconArrowLeft size={20} /> title={$t('sites.newSite')}
</a> size="lg"
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('sites.newSite')}</h1> />
</div>
<!-- Progress --> <!-- Progress -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
+18 -56
View File
@@ -4,6 +4,7 @@
import * as api from '$lib/api'; import * as api from '$lib/api';
import { IconPlus, IconRefresh, IconTrash, IconPlay, IconStop } from '$lib/components/icons'; import { IconPlus, IconRefresh, IconTrash, IconPlay, IconStop } from '$lib/components/icons';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
let stacks = $state<Stack[]>([]); let stacks = $state<Stack[]>([]);
@@ -54,39 +55,31 @@
</script> </script>
<div class="forge"> <div class="forge">
<div class="dot-grid" aria-hidden="true"></div> {#snippet stacksToolbar()}
<button class="forge-btn-icon" onclick={loadStacks} aria-label={$t('stacks.refresh')}>
<header class="head">
<div class="head-top">
<span class="eyebrow">
<span class="ember"></span>
<span>{$t('stacks.eyebrow')}</span>
<span class="sep">//</span>
<span>{$t('stacks.title').toUpperCase()}</span>
</span>
<div class="toolbar">
<button class="btn-ghost" onclick={loadStacks} aria-label={$t('stacks.refresh')}>
<IconRefresh size={16} /> <IconRefresh size={16} />
</button> </button>
<a href="/stacks/new" class="btn-primary"> <a href="/stacks/new" class="forge-btn">
<IconPlus size={16} /> <IconPlus size={14} />
<span>{$t('stacks.newStack')}</span> <span>{$t('stacks.newStack')}</span>
</a> </a>
</div> {/snippet}
</div> {#snippet stacksStats()}
<h1 class="display">
{$t('stacks.title')}<span class="title-accent">.</span>
</h1>
<p class="lede">{@html $t('stacks.lede')}</p>
<dl class="runners">
<div><dt>{$t('stacks.total').toUpperCase()}</dt><dd>{loading ? '—' : String(stacks.length).padStart(2, '0')}</dd></div> <div><dt>{$t('stacks.total').toUpperCase()}</dt><dd>{loading ? '—' : String(stacks.length).padStart(2, '0')}</dd></div>
<div><dt>{$t('stacks.running').toUpperCase()}</dt><dd class="accent">{loading ? '—' : stacks.filter(s=>s.status==='running').length}</dd></div> <div><dt>{$t('stacks.running').toUpperCase()}</dt><dd class="accent">{loading ? '—' : stacks.filter(s=>s.status==='running').length}</dd></div>
<div><dt>{$t('stacks.deploying').toUpperCase()}</dt><dd>{loading ? '—' : stacks.filter(s=>s.status==='deploying').length}</dd></div> <div><dt>{$t('stacks.deploying').toUpperCase()}</dt><dd>{loading ? '—' : stacks.filter(s=>s.status==='deploying').length}</dd></div>
<div><dt>{$t('stacks.failed').toUpperCase()}</dt><dd class:warn={stacks.some(s=>s.status==='failed')}>{loading ? '—' : stacks.filter(s=>s.status==='failed').length}</dd></div> <div><dt>{$t('stacks.failed').toUpperCase()}</dt><dd class:warn={stacks.some(s=>s.status==='failed')}>{loading ? '—' : stacks.filter(s=>s.status==='failed').length}</dd></div>
</dl> {/snippet}
</header> {#snippet stacksLede()}{@html $t('stacks.lede')}{/snippet}
<ForgeHero
eyebrow={$t('stacks.eyebrow')}
eyebrowSuffix={$t('stacks.title').toUpperCase()}
title={$t('stacks.title')}
size="lg"
toolbar={stacksToolbar}
lede_html={stacksLede}
stats={stacksStats}
/>
{#if error} {#if error}
<div class="alert"><span class="alert-tag">ERR</span><span>{error}</span></div> <div class="alert"><span class="alert-tag">ERR</span><span>{error}</span></div>
@@ -216,7 +209,6 @@
text-transform: uppercase; text-transform: uppercase;
color: var(--text-tertiary); color: var(--text-tertiary);
} }
.eyebrow .sep { opacity: 0.5; }
.ember { .ember {
width: 8px; height: 8px; border-radius: 50%; width: 8px; height: 8px; border-radius: 50%;
background: var(--accent); background: var(--accent);
@@ -289,36 +281,6 @@
font-weight: 500; font-weight: 500;
} }
.runners {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0;
margin: 1.75rem 0 0;
border: 1px solid var(--border-primary);
border-radius: var(--radius-xl);
overflow: hidden;
background: var(--surface-card);
}
.runners > div {
padding: 0.85rem 1.1rem;
border-right: 1px solid var(--border-secondary);
}
.runners > div:last-child { border-right: 0; }
.runners dt {
font-family: var(--mono); font-size: 0.62rem;
letter-spacing: 0.2em; color: var(--text-tertiary);
text-transform: uppercase; margin: 0 0 0.25rem;
}
.runners dd {
margin: 0;
font-family: var(--serif); font-size: 1.75rem; line-height: 1.1;
font-weight: 700; letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
color: var(--text-primary);
}
.runners dd.accent { color: var(--accent); }
.runners dd.warn { color: var(--color-danger); }
/* ── Alert ─────────────────────────────────────── */ /* ── Alert ─────────────────────────────────────── */
.alert { .alert {
display: flex; gap: 0.7rem; align-items: center; display: flex; gap: 0.7rem; align-items: center;
+3 -1
View File
@@ -133,7 +133,9 @@
<header class="head"> <header class="head">
<div class="eyebrow"> <div class="eyebrow">
<span class="ember"></span> <span class="ember"></span>
<span>{$t('stacks.eyebrow')}</span> <span>THE FORGE</span>
<span class="sep">//</span>
<span>STACK</span>
<span class="sep">//</span> <span class="sep">//</span>
<span class="mono-id">{stack.id.slice(0, 16)}</span> <span class="mono-id">{stack.id.slice(0, 16)}</span>
<span class="sep">//</span> <span class="sep">//</span>
+2 -2
View File
@@ -82,9 +82,9 @@
<header class="head"> <header class="head">
<span class="eyebrow"> <span class="eyebrow">
<span class="ember"></span> <span class="ember"></span>
<span>{$t('stacks.eyebrow')}</span> <span>THE FORGE</span>
<span class="sep">//</span> <span class="sep">//</span>
<span>{$t('stacks.new.eyebrow')}</span> <span>NEW STACK</span>
</span> </span>
<h1 class="display">{$t('stacks.new.title')}</h1> <h1 class="display">{$t('stacks.new.title')}</h1>
<p class="lede">{@html $t('stacks.new.lede')}</p> <p class="lede">{@html $t('stacks.new.lede')}</p>