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:
@@ -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