diff --git a/internal/api/proxies.go b/internal/api/proxies.go index 6ac9515..2a2572b 100644 --- a/internal/api/proxies.go +++ b/internal/api/proxies.go @@ -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) } diff --git a/internal/store/instances.go b/internal/store/instances.go index 9025288..c2f3954 100644 --- a/internal/store/instances.go +++ b/internal/store/instances.go @@ -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 } diff --git a/internal/store/static_sites.go b/internal/store/static_sites.go index be4a5a5..346ed8c 100644 --- a/internal/store/static_sites.go +++ b/internal/store/static_sites.go @@ -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) diff --git a/web/src/lib/components/ForgeHero.svelte b/web/src/lib/components/ForgeHero.svelte index d3a592e..de21991 100644 --- a/web/src/lib/components/ForgeHero.svelte +++ b/web/src/lib/components/ForgeHero.svelte @@ -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(); -
+
+ {#if backHref} + + + + / + {/if} - {eyebrow} + {eyebrow} {#if eyebrowSuffix} // - {eyebrowSuffix} + {eyebrowSuffix} {/if} {#if toolbar} @@ -39,34 +67,179 @@ {/if}
-

+

{title}{#if accent}{accent}{/if}

+ {#if kicker} +

{kicker}

+ {/if} + {#if lede_html}

{@render lede_html()}

{:else if lede}

{lede}

{/if} + + {#if stats} +
{@render stats()}
+ {/if} + +
diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 8e48f40..cc4ffc7 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -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", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index cdd035b..3316113 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -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": "Не удалось загрузить прокси-маршруты", diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 3ff29dc..525bb5e 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -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; diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 25f6aba..e12dc72 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -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 | 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 = { + 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); }); @@ -255,7 +297,18 @@ -

{$t('app.name')} {$t('app.version')}

+
+ {$t('app.name')} {$t('app.version')} + + + {nowUtc || '--:--:--'} + UTC + +
+

+ gdpsk + quick-nav +

@@ -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; diff --git a/web/src/routes/containers/stale/+page.svelte b/web/src/routes/containers/stale/+page.svelte index 9d54c2f..bb442bb 100644 --- a/web/src/routes/containers/stale/+page.svelte +++ b/web/src/routes/containers/stale/+page.svelte @@ -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 @@
- -
-

{$t('stale.title')}

+ {#snippet heroToolbar()} {#if containers.length > 0} {/if} -
+ {/snippet} + {#if loading} diff --git a/web/src/routes/deploy/+page.svelte b/web/src/routes/deploy/+page.svelte index 76268da..8611057 100644 --- a/web/src/routes/deploy/+page.svelte +++ b/web/src/routes/deploy/+page.svelte @@ -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 @@
-
-

{$t('quickDeploy.title')}

-

{$t('quickDeploy.description')}

-
+
diff --git a/web/src/routes/dns/+page.svelte b/web/src/routes/dns/+page.svelte index 92760bd..e098aa6 100644 --- a/web/src/routes/dns/+page.svelte +++ b/web/src/routes/dns/+page.svelte @@ -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([]); @@ -99,32 +100,25 @@
{:else} - -
-
-

{$t('dns.title')}

-

{$t('dns.description')}

-
-
- + {#if !wildcardDns} + - {#if !wildcardDns} - - {/if} -
-
+ {/if} + {/snippet} +
diff --git a/web/src/routes/events/+page.svelte b/web/src/routes/events/+page.svelte index 6dea53d..7eb7945 100644 --- a/web/src/routes/events/+page.svelte +++ b/web/src/routes/events/+page.svelte @@ -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 @@
- -
-

{$t('events.title')}

-
- {#if stats.total > 0} - {stats.total} total - - {/if} -
-
+ {#snippet heroToolbar()} + {#if stats.total > 0} + {stats.total} total + + {/if} + {/snippet} + ([]); let loading = $state(true); @@ -144,17 +145,22 @@
-
-

{$t('projects.title')}

+ {#snippet heroToolbar()} -
+ {/snippet} + {#if showAddForm} diff --git a/web/src/routes/projects/[id]/+page.svelte b/web/src/routes/projects/[id]/+page.svelte index 06d3817..a3e422e 100644 --- a/web/src/routes/projects/[id]/+page.svelte +++ b/web/src/routes/projects/[id]/+page.svelte @@ -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 @@
{:else if project} + {@const p = project}
- -
-
- -

{project.name}

-

{project.image}

-
+ {#snippet projectToolbar()} -
+ {/snippet} +
diff --git a/web/src/routes/projects/[id]/env/+page.svelte b/web/src/routes/projects/[id]/env/+page.svelte index 37fae90..8dfb08f 100644 --- a/web/src/routes/projects/[id]/env/+page.svelte +++ b/web/src/routes/projects/[id]/env/+page.svelte @@ -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 @@
- -
- -

{$t('envEditor.title')}

-

{$t('envEditor.description')}

-
+ {#if loading}
diff --git a/web/src/routes/projects/[id]/volumes/+page.svelte b/web/src/routes/projects/[id]/volumes/+page.svelte index 280eaf7..b0c61ce 100644 --- a/web/src/routes/projects/[id]/volumes/+page.svelte +++ b/web/src/routes/projects/[id]/volumes/+page.svelte @@ -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 @@
- -
- -

{$t('volumeEditor.title')}

-

{$t('volumeEditor.description')}

-
+ {#if scopes.length > 0 && !loading} diff --git a/web/src/routes/projects/[id]/volumes/[volId]/browse/+page.svelte b/web/src/routes/projects/[id]/volumes/[volId]/browse/+page.svelte index ff1f66a..2e7da67 100644 --- a/web/src/routes/projects/[id]/volumes/[volId]/browse/+page.svelte +++ b/web/src/routes/projects/[id]/volumes/[volId]/browse/+page.svelte @@ -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 @@
- -
- -
-

{$t('volumeBrowser.title')}

-
+ {#snippet browserToolbar()}
-
-
+ {/snippet} + - +
+
+ @@ -79,7 +134,7 @@ - {#each filtered as route (route.instance_id)} + {#each filtered as route (route.source + ':' + route.instance_id)} + - + - + @@ -109,7 +173,7 @@
{$t('proxies.domain')}{$t('proxies.source')} {$t('proxies.project')} {$t('proxies.stage')} {$t('proxies.tag')}
{#if route.domain} @@ -91,15 +146,24 @@ {/if} - + + {sourceLabel(route)} + + + {route.project_name} {route.stage_name}{route.stage_name || '—'} - {route.image_tag} + {#if route.image_tag} + {route.image_tag} + {:else} + + {/if} {route.port}{route.port > 0 ? route.port : '—'}
- {#if filtered.length === 0 && search} + {#if filtered.length === 0}

{$t('proxies.noMatch')}

{/if} diff --git a/web/src/routes/settings/+layout.svelte b/web/src/routes/settings/+layout.svelte index a3291ae..62536f8 100644 --- a/web/src/routes/settings/+layout.svelte +++ b/web/src/routes/settings/+layout.svelte @@ -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 @@
-

{$t('settings.title')}

+
diff --git a/web/src/routes/sites/+page.svelte b/web/src/routes/sites/+page.svelte index c1b09e6..307b429 100644 --- a/web/src/routes/sites/+page.svelte +++ b/web/src/routes/sites/+page.svelte @@ -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([]); let loading = $state(true); @@ -110,16 +111,18 @@
- + {/snippet} + {#if loading} diff --git a/web/src/routes/sites/[id]/+page.svelte b/web/src/routes/sites/[id]/+page.svelte index f463388..aeaeb14 100644 --- a/web/src/routes/sites/[id]/+page.svelte +++ b/web/src/routes/sites/[id]/+page.svelte @@ -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(null); let secrets = $state([]); @@ -149,66 +150,56 @@

{error}

{:else if site} - -
-
- - - -
-

{site.name}

-

{site.repo_owner}/{site.repo_name} · {site.branch}

-
-
-
+ {@const s = site} + {#snippet siteToolbar()} - {#if site.status === 'stopped'} - - {:else if site.status === 'deployed'} - {/if} - {#if site.domain} + {#if s.domain} - - {$t('sites.openSite')} + + {$t('sites.openSite')} {/if} -
-
+ {/snippet} + {#if error}
diff --git a/web/src/routes/sites/new/+page.svelte b/web/src/routes/sites/new/+page.svelte index a4f83cf..b227d2e 100644 --- a/web/src/routes/sites/new/+page.svelte +++ b/web/src/routes/sites/new/+page.svelte @@ -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 @@
- -
- - - -

{$t('sites.newSite')}

-
+
diff --git a/web/src/routes/stacks/+page.svelte b/web/src/routes/stacks/+page.svelte index e481d22..1fc5937 100644 --- a/web/src/routes/stacks/+page.svelte +++ b/web/src/routes/stacks/+page.svelte @@ -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([]); @@ -54,39 +55,31 @@
- - -
-
- - - {$t('stacks.eyebrow')} - // - {$t('stacks.title').toUpperCase()} - - -
- -

- {$t('stacks.title')}. -

-

{@html $t('stacks.lede')}

- -
-
{$t('stacks.total').toUpperCase()}
{loading ? '—' : String(stacks.length).padStart(2, '0')}
-
{$t('stacks.running').toUpperCase()}
{loading ? '—' : stacks.filter(s=>s.status==='running').length}
-
{$t('stacks.deploying').toUpperCase()}
{loading ? '—' : stacks.filter(s=>s.status==='deploying').length}
-
{$t('stacks.failed').toUpperCase()}
s.status==='failed')}>{loading ? '—' : stacks.filter(s=>s.status==='failed').length}
-
-
+ {#snippet stacksToolbar()} + + + + {$t('stacks.newStack')} + + {/snippet} + {#snippet stacksStats()} +
{$t('stacks.total').toUpperCase()}
{loading ? '—' : String(stacks.length).padStart(2, '0')}
+
{$t('stacks.running').toUpperCase()}
{loading ? '—' : stacks.filter(s=>s.status==='running').length}
+
{$t('stacks.deploying').toUpperCase()}
{loading ? '—' : stacks.filter(s=>s.status==='deploying').length}
+
{$t('stacks.failed').toUpperCase()}
s.status==='failed')}>{loading ? '—' : stacks.filter(s=>s.status==='failed').length}
+ {/snippet} + {#snippet stacksLede()}{@html $t('stacks.lede')}{/snippet} + {#if error}
ERR{error}
@@ -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; diff --git a/web/src/routes/stacks/[id]/+page.svelte b/web/src/routes/stacks/[id]/+page.svelte index e054446..d85e399 100644 --- a/web/src/routes/stacks/[id]/+page.svelte +++ b/web/src/routes/stacks/[id]/+page.svelte @@ -133,7 +133,9 @@
- {$t('stacks.eyebrow')} + THE FORGE + // + STACK // {stack.id.slice(0, 16)} // diff --git a/web/src/routes/stacks/new/+page.svelte b/web/src/routes/stacks/new/+page.svelte index 87664f1..9e08c80 100644 --- a/web/src/routes/stacks/new/+page.svelte +++ b/web/src/routes/stacks/new/+page.svelte @@ -82,9 +82,9 @@
- {$t('stacks.eyebrow')} + THE FORGE // - {$t('stacks.new.eyebrow')} + NEW STACK

{$t('stacks.new.title')}

{@html $t('stacks.new.lede')}