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 (
"log/slog"
"net/http"
"sort"
)
// 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) {
settings, err := s.store.GetSettings()
if err != nil {
@@ -15,12 +17,27 @@ func (s *Server) listProxyRoutes(w http.ResponseWriter, r *http.Request) {
return
}
routes, err := s.store.ListProxyRoutes(settings.Domain)
instanceRoutes, err := s.store.ListProxyRoutes(settings.Domain)
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")
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)
}
+4 -1
View File
@@ -119,8 +119,10 @@ func (s *Store) ListAllInstances() ([]Instance, error) {
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 {
Source string `json:"source"` // "instance" or "static_site"
InstanceID string `json:"instance_id"`
ProjectID string `json:"project_id"`
ProjectName string `json:"project_name"`
@@ -164,6 +166,7 @@ func (s *Store) ListProxyRoutes(domain string) ([]ProxyRoute, error) {
); err != nil {
return nil, fmt.Errorf("scan proxy route: %w", err)
}
r.Source = "instance"
if domain != "" && r.Subdomain != "" {
r.Domain = r.Subdomain + "." + domain
}
+45
View File
@@ -4,6 +4,7 @@ import (
"database/sql"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
)
@@ -154,6 +155,50 @@ func (s *Store) UpdateStaticSiteContainer(id, containerID, proxyRouteID string)
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.
func (s *Store) DeleteStaticSite(id string) error {
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';
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;
+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;
+14 -8
View File
@@ -5,6 +5,7 @@
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import { IconTrash, IconLoader } from '$lib/components/icons';
import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n';
@@ -75,22 +76,27 @@
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('stale.title')}</h1>
{#snippet heroToolbar()}
{#if containers.length > 0}
<button
type="button"
disabled={bulkCleaning}
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}
<IconTrash size={16} />
{$t('stale.cleanupAll')}
{#if bulkCleaning}<IconLoader size={14} />{/if}
<IconTrash size={14} />
<span>{$t('stale.cleanupAll')}</span>
</button>
{/if}
</div>
{/snippet}
<ForgeHero
backHref="/"
eyebrowSuffix="STALE"
title={$t('stale.title')}
size="lg"
toolbar={heroToolbar}
/>
<!-- Content -->
{#if loading}
+7 -4
View File
@@ -5,6 +5,7 @@
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import EntityPicker from '$lib/components/EntityPicker.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n';
import { goto } from '$app/navigation';
@@ -200,10 +201,12 @@
</svelte:head>
<div class="mx-auto max-w-2xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('quickDeploy.title')}</h1>
<p class="mt-1 text-sm text-[var(--text-secondary)]">{$t('quickDeploy.description')}</p>
</div>
<ForgeHero
eyebrowSuffix="DEPLOY"
title={$t('quickDeploy.title')}
lede={$t('quickDeploy.description')}
size="lg"
/>
<!-- Step 1 -->
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
+19 -25
View File
@@ -5,6 +5,7 @@
import { t } from '$lib/i18n';
import { IconSearch, IconRefresh, IconTrash, IconLoader } from '$lib/components/icons';
import Skeleton from '$lib/components/Skeleton.svelte';
import ForgeHero from '$lib/components/ForgeHero.svelte';
let loading = $state(true);
let records = $state<DnsRecordView[]>([]);
@@ -99,32 +100,25 @@
<Skeleton height="20rem" />
</div>
{:else}
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-xl font-bold text-[var(--text-primary)]">{$t('dns.title')}</h1>
<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')}
{#snippet heroToolbar()}
<button onclick={handleRefresh} class="forge-btn-ghost">
<IconRefresh size={14} />
<span>{$t('dns.refresh')}</span>
</button>
{#if !wildcardDns}
<button onclick={handleSync} disabled={syncing} class="forge-btn">
{#if syncing}<IconLoader size={14} />{/if}
<span>{syncing ? $t('dns.syncing') : $t('dns.syncNow')}</span>
</button>
{#if !wildcardDns}
<button
onclick={handleSync}
disabled={syncing}
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>
{/if}
</div>
</div>
{/if}
{/snippet}
<ForgeHero
eyebrowSuffix="DNS"
title={$t('dns.title')}
lede={$t('dns.description')}
size="lg"
toolbar={heroToolbar}
/>
<!-- Filters -->
<div class="flex flex-wrap items-center gap-3">
+19 -16
View File
@@ -13,6 +13,7 @@
import EventLogEntryComponent from '$lib/components/EventLogEntry.svelte';
import EventLogFilter from '$lib/components/EventLogFilter.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import { IconLoader } from '$lib/components/icons';
// ── State ─────────────────────────────────────────────────────
@@ -213,22 +214,24 @@
</script>
<div class="space-y-4">
<!-- Header -->
<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}
<span class="text-xs text-[var(--text-tertiary)] tabular-nums">{stats.total} total</span>
<button
type="button"
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"
>
{$t('events.clearAll')}
</button>
{/if}
</div>
</div>
{#snippet heroToolbar()}
{#if stats.total > 0}
<span class="forge-pill"><span class="pulse"></span>{stats.total} total</span>
<button
type="button"
onclick={() => { showClearConfirm = true; }}
class="forge-btn-ghost forge-btn-danger"
>
{$t('events.clearAll')}
</button>
{/if}
{/snippet}
<ForgeHero
eyebrowSuffix="EVENTS"
title={$t('events.title')}
size="lg"
toolbar={heroToolbar}
/>
<!-- Filter bar (includes severity stats as pill counts) -->
<EventLogFilter
+12 -6
View File
@@ -7,6 +7,7 @@
import SkeletonTable from '$lib/components/SkeletonTable.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import EntityPicker from '$lib/components/EntityPicker.svelte';
import ForgeHero from '$lib/components/ForgeHero.svelte';
let projects = $state<Project[]>([]);
let loading = $state(true);
@@ -144,17 +145,22 @@
</svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('projects.title')}</h1>
{#snippet heroToolbar()}
<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; }}
>
{#if !showAddForm}<IconPlus size={16} />{/if}
{showAddForm ? $t('projects.cancel') : $t('projects.addProject')}
{#if !showAddForm}<IconPlus size={14} />{/if}
<span>{showAddForm ? $t('projects.cancel') : $t('projects.addProject')}</span>
</button>
</div>
{/snippet}
<ForgeHero
eyebrowSuffix="PROJECTS"
title={$t('projects.title')}
size="lg"
toolbar={heroToolbar}
/>
<!-- Add project form -->
{#if showAddForm}
+15 -12
View File
@@ -9,7 +9,7 @@
import EmptyState from '$lib/components/EmptyState.svelte';
import Skeleton from '$lib/components/Skeleton.svelte';
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 ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import EntityPicker from '$lib/components/EntityPicker.svelte';
@@ -415,23 +415,26 @@
</button>
</div>
{:else if project}
{@const p = project}
<div class="space-y-6">
<!-- Header -->
<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>
{#snippet projectToolbar()}
<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; }}
>
<IconTrash size={16} />
{$t('projectDetail.deleteProject')}
<IconTrash size={14} />
<span>{$t('projectDetail.deleteProject')}</span>
</button>
</div>
{/snippet}
<ForgeHero
backHref="/projects"
eyebrowSuffix="PROJECT"
title={p.name}
kicker={p.image}
size="lg"
toolbar={projectToolbar}
/>
<!-- Project settings links -->
<div class="flex gap-3">
+8 -7
View File
@@ -5,7 +5,7 @@
import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n';
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 Skeleton from '$lib/components/Skeleton.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
@@ -212,12 +212,13 @@
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div>
<Breadcrumb items={[{ label: $t('common.project'), href: `/projects/${projectId}` }]} />
<h1 class="mt-1 text-2xl font-bold text-[var(--text-primary)]">{$t('envEditor.title')}</h1>
<p class="mt-1 text-sm text-[var(--text-secondary)]">{$t('envEditor.description')}</p>
</div>
<ForgeHero
backHref={`/projects/${projectId}`}
eyebrowSuffix="ENV"
title={$t('envEditor.title')}
lede={$t('envEditor.description')}
size="lg"
/>
{#if loading}
<div class="space-y-4">
@@ -5,7 +5,7 @@
import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n';
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 ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
@@ -147,12 +147,13 @@
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<div>
<Breadcrumb items={[{ label: $t('common.project'), href: `/projects/${projectId}` }]} />
<h1 class="mt-1 text-2xl font-bold text-[var(--text-primary)]">{$t('volumeEditor.title')}</h1>
<p class="mt-1 text-sm text-[var(--text-secondary)]">{$t('volumeEditor.description')}</p>
</div>
<ForgeHero
backHref={`/projects/${projectId}`}
eyebrowSuffix="VOLUMES"
title={$t('volumeEditor.title')}
lede={$t('volumeEditor.description')}
size="lg"
/>
<!-- Scope legend -->
{#if scopes.length > 0 && !loading}
@@ -5,7 +5,7 @@
import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n';
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';
const projectId = $derived($page.params.id ?? '');
@@ -123,15 +123,7 @@
</svelte:head>
<div class="space-y-4">
<!-- Header -->
<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">
{#snippet browserToolbar()}
<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"
@@ -148,11 +140,16 @@
{$t('volumeBrowser.upload')}
<input bind:this={fileInput} type="file" multiple class="hidden" onchange={handleUpload} />
</label>
</div>
</div>
</div>
{/snippet}
<ForgeHero
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">
<button
type="button"
+96 -32
View File
@@ -1,27 +1,56 @@
<script lang="ts">
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 { t } from '$lib/i18n';
import Skeleton from '$lib/components/Skeleton.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import StatusBadge from '$lib/components/StatusBadge.svelte';
import ForgeHero from '$lib/components/ForgeHero.svelte';
type SourceFilter = 'all' | ProxyRouteSource;
let routes = $state<ProxyRoute[]>([]);
let loading = $state(true);
let search = $state('');
let sourceFilter = $state<SourceFilter>('all');
const filtered = $derived(
search.trim()
? routes.filter((r) => {
const q = search.toLowerCase();
return r.domain?.toLowerCase().includes(q)
|| r.project_name.toLowerCase().includes(q)
|| r.stage_name.toLowerCase().includes(q)
|| r.image_tag.toLowerCase().includes(q);
})
: routes
);
const counts = $derived({
all: routes.length,
instance: routes.filter((r) => r.source === 'instance').length,
static_site: routes.filter((r) => r.source === 'static_site').length,
});
const filtered = $derived.by(() => {
const q = search.trim().toLowerCase();
return routes.filter((r) => {
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() {
loading = true;
@@ -34,7 +63,9 @@
}
}
$effect(() => { loadRoutes(); });
$effect(() => {
loadRoutes();
});
</script>
<svelte:head>
@@ -42,12 +73,12 @@
</svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('proxies.title')}</h1>
<p class="mt-1 text-sm text-[var(--text-secondary)]">{$t('proxies.description')}</p>
</div>
</div>
<ForgeHero
eyebrowSuffix="PROXIES"
title={$t('proxies.title')}
lede={$t('proxies.description')}
size="lg"
/>
{#if loading}
<div class="space-y-3">
@@ -58,19 +89,43 @@
{:else if routes.length === 0}
<EmptyState title={$t('proxies.noRoutes')} description={$t('proxies.noRoutesDesc')} icon="instances" />
{:else}
<!-- Search -->
<input
type="text"
bind:value={search}
placeholder={$t('proxies.searchPlaceholder')}
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)]"
/>
<!-- Filters -->
<div class="flex flex-wrap items-center gap-3">
<input
type="text"
bind:value={search}
placeholder={$t('proxies.searchPlaceholder')}
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)]">
<table class="min-w-full divide-y divide-[var(--border-primary)]">
<thead class="bg-[var(--surface-card-hover)]">
<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.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.stage')}</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>
</thead>
<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">
<td class="px-4 py-3">
{#if route.domain}
@@ -91,15 +146,24 @@
{/if}
</td>
<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}
</a>
</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">
<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>
{#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>
{:else}
<span class="text-sm text-[var(--text-tertiary)]"></span>
{/if}
</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">
<StatusBadge status={route.status} />
</td>
@@ -109,7 +173,7 @@
</table>
</div>
{#if filtered.length === 0 && search}
{#if filtered.length === 0}
<p class="text-center text-sm text-[var(--text-tertiary)]">{$t('proxies.noMatch')}</p>
{/if}
+6 -1
View File
@@ -4,6 +4,7 @@
import { getSettings } from '$lib/api';
import { t } from '$lib/i18n';
import { IconSettings, IconDatabase, IconShield, IconHardDrive, IconWifi } from '$lib/components/icons';
import ForgeHero from '$lib/components/ForgeHero.svelte';
interface Props {
children: Snippet;
@@ -41,7 +42,11 @@
</script>
<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">
<!-- Sub-navigation -->
+12 -9
View File
@@ -6,6 +6,7 @@
import SkeletonTable from '$lib/components/SkeletonTable.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import ForgeHero from '$lib/components/ForgeHero.svelte';
let sites = $state<StaticSite[]>([]);
let loading = $state(true);
@@ -110,16 +111,18 @@
</svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('sites.title')}</h1>
<a
href="/sites/new"
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')}
{#snippet heroToolbar()}
<a href="/sites/new" class="forge-btn">
<IconPlus size={14} />
<span>{$t('sites.addSite')}</span>
</a>
</div>
{/snippet}
<ForgeHero
eyebrowSuffix="SITES"
title={$t('sites.title')}
size="lg"
toolbar={heroToolbar}
/>
{#if loading}
<SkeletonTable rows={4} cols={5} />
+31 -40
View File
@@ -6,7 +6,8 @@
import { goto } from '$app/navigation';
import FormField from '$lib/components/FormField.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 secrets = $state<StaticSiteSecret[]>([]);
@@ -149,66 +150,56 @@
<p class="text-sm text-[var(--color-danger)]">{error}</p>
</div>
{:else if site}
<!-- Header -->
<div class="flex items-center justify-between">
<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">
{@const s = site}
{#snippet siteToolbar()}
<button
type="button"
disabled={deploying}
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' : ''} />
{$t('sites.deploy')}
<IconRefresh size={14} class={deploying ? 'animate-spin' : ''} />
<span>{$t('sites.deploy')}</span>
</button>
{#if site.status === 'stopped'}
<button
type="button"
onclick={handleStart}
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')}
{#if s.status === 'stopped'}
<button type="button" onclick={handleStart} class="forge-btn-ghost">
<IconPlay size={14} />
<span>{$t('sites.start')}</span>
</button>
{:else if site.status === 'deployed'}
<button
type="button"
onclick={handleStop}
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')}
{:else if s.status === 'deployed'}
<button type="button" onclick={handleStop} class="forge-btn-ghost">
<IconStop size={14} />
<span>{$t('sites.stop')}</span>
</button>
{/if}
{#if site.domain}
{#if s.domain}
<a
href="https://{site.domain}"
href="https://{s.domain}"
target="_blank"
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} />
{$t('sites.openSite')}
<IconGlobe size={14} />
<span>{$t('sites.openSite')}</span>
</a>
{/if}
<button
type="button"
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} />
</button>
</div>
</div>
{/snippet}
<ForgeHero
backHref="/sites"
eyebrowSuffix="SITE"
title={s.name}
kicker="{s.repo_owner}/{s.repo_name} · {s.branch}"
size="lg"
toolbar={siteToolbar}
/>
{#if error}
<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 { goto } from '$app/navigation';
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 type { EntityPickerItem } from '$lib/types';
@@ -274,13 +275,12 @@
</svelte:head>
<div class="space-y-6">
<!-- Header -->
<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>
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('sites.newSite')}</h1>
</div>
<ForgeHero
backHref="/sites"
eyebrowSuffix="NEW SITE"
title={$t('sites.newSite')}
size="lg"
/>
<!-- Progress -->
<div class="flex items-center gap-2">
+26 -64
View File
@@ -4,6 +4,7 @@
import * as api from '$lib/api';
import { IconPlus, IconRefresh, IconTrash, IconPlay, IconStop } from '$lib/components/icons';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import { t } from '$lib/i18n';
let stacks = $state<Stack[]>([]);
@@ -54,39 +55,31 @@
</script>
<div class="forge">
<div class="dot-grid" aria-hidden="true"></div>
<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} />
</button>
<a href="/stacks/new" class="btn-primary">
<IconPlus size={16} />
<span>{$t('stacks.newStack')}</span>
</a>
</div>
</div>
<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.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.failed').toUpperCase()}</dt><dd class:warn={stacks.some(s=>s.status==='failed')}>{loading ? '—' : stacks.filter(s=>s.status==='failed').length}</dd></div>
</dl>
</header>
{#snippet stacksToolbar()}
<button class="forge-btn-icon" onclick={loadStacks} aria-label={$t('stacks.refresh')}>
<IconRefresh size={16} />
</button>
<a href="/stacks/new" class="forge-btn">
<IconPlus size={14} />
<span>{$t('stacks.newStack')}</span>
</a>
{/snippet}
{#snippet stacksStats()}
<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.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>
{/snippet}
{#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}
<div class="alert"><span class="alert-tag">ERR</span><span>{error}</span></div>
@@ -216,7 +209,6 @@
text-transform: uppercase;
color: var(--text-tertiary);
}
.eyebrow .sep { opacity: 0.5; }
.ember {
width: 8px; height: 8px; border-radius: 50%;
background: var(--accent);
@@ -289,36 +281,6 @@
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 {
display: flex; gap: 0.7rem; align-items: center;
+3 -1
View File
@@ -133,7 +133,9 @@
<header class="head">
<div class="eyebrow">
<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="mono-id">{stack.id.slice(0, 16)}</span>
<span class="sep">//</span>
+2 -2
View File
@@ -82,9 +82,9 @@
<header class="head">
<span class="eyebrow">
<span class="ember"></span>
<span>{$t('stacks.eyebrow')}</span>
<span>THE FORGE</span>
<span class="sep">//</span>
<span>{$t('stacks.new.eyebrow')}</span>
<span>NEW STACK</span>
</span>
<h1 class="display">{$t('stacks.new.title')}</h1>
<p class="lede">{@html $t('stacks.new.lede')}</p>