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