Promotes the Forge visual language from the Stacks feature into a global design system used across the app: - app.css: Forge utilities (dot-grid backdrop, eyebrow, ember, display/lede, status pills, stat grid, panels, registration marks, alert, terminal, buttons). CSS variables alias the forge display font to the app's standard sans stack (Inter, now properly self-hosted via @fontsource/inter). - +layout.svelte: reskinned sidebar brand, active nav rail, mobile top bar, global h1/h2 typography overrides, main dot-grid backdrop. - Shared components reskinned: EmptyState (breathing-ember empty mark), StatusBadge (mono pills with pulse), ConfirmDialog (registration marks + forge buttons). - Dashboard (+page.svelte): ForgeHero header, forge-stat-grid, Instrument-style section titles with accent. - New ForgeHero component for reusable hero headers. Stacks feature fully localized (EN + RU): - 80+ keys under stacks.* covering list, new, detail, revisions, logs, errors, status labels, delete/rollback dialogs. - Russian uses forge vocabulary (куются/наковальня/куём/etc). - $t() wired through all three Stacks pages.
This commit is contained in:
@@ -129,13 +129,9 @@
|
||||
{sidebarOpen ? 'translate-x-0' : '-translate-x-full'}"
|
||||
>
|
||||
<!-- Logo -->
|
||||
<div class="flex h-16 items-center gap-2.5 border-b border-[var(--border-primary)] px-5">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--color-brand-600)]">
|
||||
<svg class="h-4.5 w-4.5 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-base font-bold text-[var(--text-primary)]">{$t('app.name')}</span>
|
||||
<div class="brand flex h-16 items-center gap-2.5 border-b border-[var(--border-primary)] px-5">
|
||||
<span class="forge-ember brand-ember"></span>
|
||||
<span class="brand-name">{$t('app.name')}</span>
|
||||
|
||||
<!-- Close sidebar (mobile) -->
|
||||
<button
|
||||
@@ -153,10 +149,8 @@
|
||||
{@const active = isActive(item.href, $page.url.pathname)}
|
||||
<a
|
||||
href={item.href}
|
||||
class="group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
|
||||
{active
|
||||
? 'bg-[var(--color-brand-50)] text-[var(--color-brand-700)] shadow-sm'
|
||||
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)]'}"
|
||||
class="nav-item group flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-all duration-150
|
||||
{active ? 'nav-active' : 'text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)]'}"
|
||||
>
|
||||
{#if item.icon === 'dashboard'}
|
||||
<IconDashboard size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||
@@ -276,12 +270,8 @@
|
||||
>
|
||||
<IconMenu size={22} />
|
||||
</button>
|
||||
<div class="flex h-7 w-7 items-center justify-center rounded-lg bg-[var(--color-brand-600)]">
|
||||
<svg class="h-3.5 w-3.5 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="text-sm font-bold text-[var(--text-primary)]">{$t('app.name')}</span>
|
||||
<span class="forge-ember"></span>
|
||||
<span class="brand-name">{$t('app.name')}</span>
|
||||
</header>
|
||||
|
||||
<!-- Page content -->
|
||||
@@ -295,3 +285,72 @@
|
||||
{/if}
|
||||
|
||||
<Toast />
|
||||
|
||||
<style>
|
||||
/* ── Forge-themed layout shell ─────────────────────────────────── */
|
||||
/* Page titles — larger + tighter tracking, but using the app's sans stack */
|
||||
:global(main h1) {
|
||||
font-family: var(--font-family-sans) !important;
|
||||
font-weight: 700 !important;
|
||||
letter-spacing: -0.02em !important;
|
||||
font-size: clamp(1.875rem, 4vw, 2.5rem) !important;
|
||||
line-height: 1.1 !important;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
:global(main h2) {
|
||||
font-family: var(--font-family-sans);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
:global(main code) {
|
||||
font-family: var(--forge-mono, 'JetBrains Mono', monospace);
|
||||
}
|
||||
|
||||
.brand {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.brand-ember {
|
||||
width: 10px; height: 10px;
|
||||
}
|
||||
.brand-name {
|
||||
font-family: var(--font-family-sans);
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-item :global(svg) { flex-shrink: 0; }
|
||||
.nav-active {
|
||||
background: var(--surface-card-hover);
|
||||
color: var(--text-primary) !important;
|
||||
position: relative;
|
||||
}
|
||||
.nav-active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -12px; top: 20%; bottom: 20%;
|
||||
width: 3px;
|
||||
background: var(--color-brand-600);
|
||||
border-radius: 0 3px 3px 0;
|
||||
}
|
||||
|
||||
/* Apply dot-grid backdrop to main content */
|
||||
:global(main) {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
:global(main)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; height: 480px;
|
||||
background-image: radial-gradient(var(--border-primary) 1px, transparent 1px);
|
||||
background-size: 22px 22px;
|
||||
-webkit-mask-image: radial-gradient(ellipse at 20% 0%, #000 0%, transparent 70%);
|
||||
mask-image: radial-gradient(ellipse at 20% 0%, #000 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
|
||||
+107
-71
@@ -5,7 +5,8 @@
|
||||
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import SystemHealthCard from '$lib/components/SystemHealthCard.svelte';
|
||||
import { IconDeploy, IconBox, IconServer, IconAlert, IconClock, IconGlobe } from '$lib/components/icons';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
import { IconDeploy, IconAlert } from '$lib/components/icons';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
let projects = $state<Project[]>([]);
|
||||
@@ -113,73 +114,53 @@
|
||||
<title>{$t('dashboard.title')} - {$t('app.name')}</title>
|
||||
</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('dashboard.title')}</h1>
|
||||
<a
|
||||
href="/deploy"
|
||||
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 transition-all duration-150 hover:bg-[var(--color-brand-700)] active:animate-press"
|
||||
>
|
||||
<IconDeploy size={16} />
|
||||
{$t('dashboard.quickDeploy')}
|
||||
<div class="space-y-6 dashboard">
|
||||
<!-- Hero -->
|
||||
{#snippet heroToolbar()}
|
||||
<a href="/deploy" class="forge-btn">
|
||||
<IconDeploy size={14} />
|
||||
<span>{$t('dashboard.quickDeploy')}</span>
|
||||
</a>
|
||||
</div>
|
||||
{/snippet}
|
||||
<ForgeHero
|
||||
eyebrow="THE FORGE"
|
||||
eyebrowSuffix="DASHBOARD"
|
||||
title={$t('dashboard.title')}
|
||||
accent="."
|
||||
size="lg"
|
||||
toolbar={heroToolbar}
|
||||
/>
|
||||
|
||||
<!-- Stats cards -->
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<div class="flex items-center gap-4 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)]">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--color-brand-50)] text-[var(--color-brand-600)]">
|
||||
<IconBox size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-[var(--text-secondary)]">{$t('dashboard.totalProjects')}</p>
|
||||
<p class="mt-0.5 text-2xl font-bold text-[var(--text-primary)]">{totalProjects}</p>
|
||||
</div>
|
||||
<!-- Stats grid -->
|
||||
<div class="forge-stat-grid">
|
||||
<div class="forge-stat">
|
||||
<span class="forge-stat-label">{$t('dashboard.totalProjects')}</span>
|
||||
<span class="forge-stat-value">{String(totalProjects).padStart(2, '0')}</span>
|
||||
<span class="forge-stat-sub">active</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)]">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-50 dark:bg-emerald-950/30 text-emerald-600">
|
||||
<IconServer size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-[var(--text-secondary)]">{$t('dashboard.runningInstances')}</p>
|
||||
<p class="mt-0.5 text-2xl font-bold text-emerald-600">{totalRunning}</p>
|
||||
</div>
|
||||
<div class="forge-stat">
|
||||
<span class="forge-stat-label">{$t('dashboard.runningInstances')}</span>
|
||||
<span class="forge-stat-value accent">{String(totalRunning).padStart(2, '0')}</span>
|
||||
<span class="forge-stat-sub">instances</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)]">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-xl {totalFailed > 0 ? 'bg-red-50 dark:bg-red-950/30 text-red-600' : 'bg-gray-50 dark:bg-gray-800/30 text-gray-400'}">
|
||||
<IconAlert size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-[var(--text-secondary)]">{$t('dashboard.failedInstances')}</p>
|
||||
<p class="mt-0.5 text-2xl font-bold {totalFailed > 0 ? 'text-red-600' : 'text-[var(--text-primary)]'}">{totalFailed}</p>
|
||||
</div>
|
||||
<div class="forge-stat">
|
||||
<span class="forge-stat-label">{$t('dashboard.failedInstances')}</span>
|
||||
<span class="forge-stat-value" class:fail={totalFailed > 0}>{String(totalFailed).padStart(2, '0')}</span>
|
||||
<span class="forge-stat-sub">need attention</span>
|
||||
</div>
|
||||
<a href="/containers/stale" class="flex items-center gap-4 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)] transition-colors hover:bg-[var(--surface-card-hover)]">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-xl {totalStale > 0 ? 'bg-amber-50 text-amber-600' : 'bg-gray-50 text-gray-400'}">
|
||||
<IconClock size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-[var(--text-secondary)]">{$t('dashboard.staleContainers')}</p>
|
||||
<p class="mt-0.5 text-2xl font-bold {totalStale > 0 ? 'text-amber-600' : 'text-[var(--text-primary)]'}">{totalStale}</p>
|
||||
</div>
|
||||
<a href="/containers/stale" class="forge-stat stat-link">
|
||||
<span class="forge-stat-label">{$t('dashboard.staleContainers')}</span>
|
||||
<span class="forge-stat-value" class:warn={totalStale > 0}>{String(totalStale).padStart(2, '0')}</span>
|
||||
<span class="forge-stat-sub">stale →</span>
|
||||
</a>
|
||||
<a href="/sites" class="flex items-center gap-4 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)] transition-colors hover:bg-[var(--surface-card-hover)]">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-xl {totalSites > 0 ? 'bg-[var(--color-brand-50)] text-[var(--color-brand-600)]' : 'bg-gray-50 text-gray-400'}">
|
||||
<IconGlobe size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-[var(--text-secondary)]">{$t('dashboard.totalSites')}</p>
|
||||
<p class="mt-0.5 text-2xl font-bold text-[var(--text-primary)]">
|
||||
{totalSites}
|
||||
{#if deployedSites > 0}
|
||||
<span class="text-sm font-medium text-emerald-600">{deployedSites} {$t('dashboard.deployedSites')}</span>
|
||||
{/if}
|
||||
{#if failedSitesCount > 0}
|
||||
<span class="text-sm font-medium text-red-600">{failedSitesCount} {$t('dashboard.failedSites')}</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<a href="/sites" class="forge-stat stat-link">
|
||||
<span class="forge-stat-label">{$t('dashboard.totalSites')}</span>
|
||||
<span class="forge-stat-value">{String(totalSites).padStart(2, '0')}</span>
|
||||
<span class="forge-stat-sub">
|
||||
{#if deployedSites > 0}<span class="tag ok">{deployedSites} up</span>{/if}
|
||||
{#if failedSitesCount > 0}<span class="tag bad">{failedSitesCount} fail</span>{/if}
|
||||
{#if deployedSites === 0 && failedSitesCount === 0}static sites →{/if}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -202,12 +183,12 @@
|
||||
|
||||
<!-- Static sites summary -->
|
||||
{#if !loading}
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('dashboard.staticSites')}</h2>
|
||||
<section class="section">
|
||||
<div class="section-head">
|
||||
<h2 class="section-title">{$t('dashboard.staticSites')}<span class="accent">.</span></h2>
|
||||
{#if sites.length > 0}
|
||||
<a href="/sites" class="text-sm font-medium text-[var(--color-brand-600)] hover:text-[var(--color-brand-700)]">
|
||||
{$t('dashboard.viewAllSites')} →
|
||||
<a href="/sites" class="section-more">
|
||||
{$t('dashboard.viewAllSites')} <span class="arrow">→</span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -244,12 +225,12 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Project cards -->
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('dashboard.projects')}</h2>
|
||||
<section class="section">
|
||||
<h2 class="section-title">{$t('dashboard.projects')}<span class="accent">.</span></h2>
|
||||
|
||||
{#if loading}
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@@ -285,5 +266,60 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.dashboard { position: relative; }
|
||||
|
||||
.stat-link {
|
||||
text-decoration: none;
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
.stat-link:hover { background: var(--surface-card-hover); }
|
||||
.stat-link .forge-stat-sub .tag {
|
||||
display: inline-block;
|
||||
padding: 0.05rem 0.4rem;
|
||||
margin-right: 0.25rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.stat-link .tag.ok { background: var(--color-success-light); color: var(--color-success-dark); }
|
||||
.stat-link .tag.bad { background: var(--color-danger-light); color: var(--color-danger-dark); }
|
||||
:global([data-theme='dark']) .stat-link .tag.ok { background: color-mix(in srgb, var(--color-success) 16%, transparent); color: #86efac; }
|
||||
:global([data-theme='dark']) .stat-link .tag.bad { background: color-mix(in srgb, var(--color-danger) 16%, transparent); color: #fca5a5; }
|
||||
|
||||
.section { margin-top: 0.5rem; }
|
||||
.section-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.section-title {
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 1.35rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
.section-title .accent {
|
||||
color: var(--color-brand-600);
|
||||
font-weight: 700;
|
||||
}
|
||||
.section-more {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-brand-600);
|
||||
text-decoration: none;
|
||||
}
|
||||
.section-more .arrow { display: inline-block; transition: transform 150ms ease; }
|
||||
.section-more:hover .arrow { transform: translateX(3px); }
|
||||
</style>
|
||||
|
||||
@@ -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 { t } from '$lib/i18n';
|
||||
|
||||
let stacks = $state<Stack[]>([]);
|
||||
let loading = $state(true);
|
||||
@@ -38,10 +39,10 @@
|
||||
|
||||
function statusMeta(status: string) {
|
||||
switch (status) {
|
||||
case 'running': return { label: 'RUNNING', cls: 'st-running' };
|
||||
case 'deploying':return { label: 'FORGING', cls: 'st-deploying' };
|
||||
case 'failed': return { label: 'FAILED', cls: 'st-failed' };
|
||||
default: return { label: 'COLD', cls: 'st-stopped' };
|
||||
case 'running': return { label: $t('stacks.running').toUpperCase(), cls: 'st-running' };
|
||||
case 'deploying':return { label: $t('stacks.deploying').toUpperCase(), cls: 'st-deploying' };
|
||||
case 'failed': return { label: $t('stacks.failed').toUpperCase(), cls: 'st-failed' };
|
||||
default: return { label: $t('stacks.stopped').toUpperCase(), cls: 'st-stopped' };
|
||||
}
|
||||
}
|
||||
function fmtTime(ts: string): string {
|
||||
@@ -59,34 +60,31 @@
|
||||
<div class="head-top">
|
||||
<span class="eyebrow">
|
||||
<span class="ember"></span>
|
||||
<span>THE FORGE</span>
|
||||
<span>{$t('stacks.eyebrow')}</span>
|
||||
<span class="sep">//</span>
|
||||
<span>STACKS</span>
|
||||
<span>{$t('stacks.title').toUpperCase()}</span>
|
||||
</span>
|
||||
<div class="toolbar">
|
||||
<button class="btn-ghost" onclick={loadStacks} aria-label="Refresh">
|
||||
<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>New stack</span>
|
||||
<span>{$t('stacks.newStack')}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="display">
|
||||
Stacks<span class="title-accent">.</span>
|
||||
{$t('stacks.title')}<span class="title-accent">.</span>
|
||||
</h1>
|
||||
<p class="lede">
|
||||
Compose blueprints, forged as <em>atomic units</em>.
|
||||
Spin up services, iterate on revisions, roll back without breaking a sweat.
|
||||
</p>
|
||||
<p class="lede">{@html $t('stacks.lede')}</p>
|
||||
|
||||
<dl class="runners">
|
||||
<div><dt>TOTAL</dt><dd>{loading ? '—' : String(stacks.length).padStart(2, '0')}</dd></div>
|
||||
<div><dt>RUNNING</dt><dd class="accent">{loading ? '—' : stacks.filter(s=>s.status==='running').length}</dd></div>
|
||||
<div><dt>FORGING</dt><dd>{loading ? '—' : stacks.filter(s=>s.status==='deploying').length}</dd></div>
|
||||
<div><dt>FAILED</dt><dd class:warn={stacks.some(s=>s.status==='failed')}>{loading ? '—' : stacks.filter(s=>s.status==='failed').length}</dd></div>
|
||||
<div><dt>{$t('stacks.total').toUpperCase()}</dt><dd>{loading ? '—' : String(stacks.length).padStart(2, '0')}</dd></div>
|
||||
<div><dt>{$t('stacks.running').toUpperCase()}</dt><dd class="accent">{loading ? '—' : stacks.filter(s=>s.status==='running').length}</dd></div>
|
||||
<div><dt>{$t('stacks.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>
|
||||
|
||||
@@ -105,10 +103,10 @@
|
||||
<div class="empty-mark">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
<h2>The anvil is cold.</h2>
|
||||
<p>Upload a <code>docker-compose.yml</code> to forge your first stack.</p>
|
||||
<h2>{$t('stacks.empty.title')}</h2>
|
||||
<p>{$t('stacks.empty.desc')}</p>
|
||||
<a href="/stacks/new" class="btn-primary">
|
||||
<IconPlus size={16} /><span>New stack</span>
|
||||
<IconPlus size={16} /><span>{$t('stacks.newStack')}</span>
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -133,7 +131,7 @@
|
||||
{#if s.description}
|
||||
<p class="card-desc">{s.description}</p>
|
||||
{:else}
|
||||
<p class="card-desc dim">No description</p>
|
||||
<p class="card-desc dim">{$t('stacks.card.noDescription')}</p>
|
||||
{/if}
|
||||
|
||||
{#if s.error}
|
||||
@@ -141,24 +139,24 @@
|
||||
{/if}
|
||||
|
||||
<div class="card-meta">
|
||||
<span class="meta-k">Updated</span>
|
||||
<span class="meta-k">{$t('stacks.card.updated')}</span>
|
||||
<span class="meta-v">{fmtTime(s.updated_at)}</span>
|
||||
</div>
|
||||
|
||||
<footer class="card-foot">
|
||||
{#if s.status === 'running'}
|
||||
<button class="act" onclick={() => handleStop(s)} aria-label="Stop">
|
||||
<IconStop size={13} /><span>Stop</span>
|
||||
<button class="act" onclick={() => handleStop(s)} aria-label={$t('stacks.card.stop')}>
|
||||
<IconStop size={13} /><span>{$t('stacks.card.stop')}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<button class="act" onclick={() => handleStart(s)} aria-label="Start">
|
||||
<IconPlay size={13} /><span>Start</span>
|
||||
<button class="act" onclick={() => handleStart(s)} aria-label={$t('stacks.card.start')}>
|
||||
<IconPlay size={13} /><span>{$t('stacks.card.start')}</span>
|
||||
</button>
|
||||
{/if}
|
||||
<button class="act danger" onclick={() => (confirmDelete = s)} aria-label="Delete">
|
||||
<IconTrash size={13} /><span>Delete</span>
|
||||
<button class="act danger" onclick={() => (confirmDelete = s)} aria-label={$t('stacks.card.delete')}>
|
||||
<IconTrash size={13} /><span>{$t('stacks.card.delete')}</span>
|
||||
</button>
|
||||
<a class="act-link" href="/stacks/{s.id}">Open <span class="arrow">→</span></a>
|
||||
<a class="act-link" href="/stacks/{s.id}">{$t('stacks.card.open')} <span class="arrow">→</span></a>
|
||||
</footer>
|
||||
</article>
|
||||
{/each}
|
||||
@@ -168,9 +166,9 @@
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDelete !== null}
|
||||
title="Delete stack?"
|
||||
message={confirmDelete ? `This runs 'docker compose down' and removes "${confirmDelete.name}".${deleteRemoveVolumes ? ' Named volumes will also be removed.' : ''}` : ''}
|
||||
confirmLabel="Delete"
|
||||
title={$t('stacks.detail.delete.title')}
|
||||
message={confirmDelete ? $t('stacks.detail.delete.messageBase', { name: confirmDelete.name }) + (deleteRemoveVolumes ? $t('stacks.detail.delete.messageVolumes') : '') : ''}
|
||||
confirmLabel={$t('stacks.detail.delete.confirm')}
|
||||
confirmVariant="danger"
|
||||
onconfirm={handleDelete}
|
||||
oncancel={() => { confirmDelete = null; deleteRemoveVolumes = false; }}
|
||||
@@ -178,7 +176,7 @@
|
||||
|
||||
<style>
|
||||
.forge {
|
||||
--serif: 'Instrument Serif', 'Iowan Old Style', Georgia, serif;
|
||||
--serif: var(--font-family-sans);
|
||||
--mono: var(--font-family-mono);
|
||||
--accent: var(--color-brand-600);
|
||||
--accent-soft: color-mix(in srgb, var(--color-brand-500) 14%, transparent);
|
||||
@@ -267,27 +265,28 @@
|
||||
|
||||
.display {
|
||||
font-family: var(--serif);
|
||||
font-size: clamp(3.75rem, 9vw, 6rem);
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
font-size: clamp(2rem, 4vw, 2.75rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
}
|
||||
.title-accent {
|
||||
color: var(--accent);
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
}
|
||||
.lede {
|
||||
font-family: var(--serif);
|
||||
color: var(--text-secondary);
|
||||
margin: 0.75rem 0 0;
|
||||
max-width: 52ch;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.45;
|
||||
max-width: 60ch;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.lede em {
|
||||
.lede :global(em) {
|
||||
color: var(--accent);
|
||||
font-style: italic;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.runners {
|
||||
@@ -312,7 +311,8 @@
|
||||
}
|
||||
.runners dd {
|
||||
margin: 0;
|
||||
font-family: var(--serif); font-size: 1.75rem; line-height: 1;
|
||||
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);
|
||||
}
|
||||
@@ -360,18 +360,12 @@
|
||||
}
|
||||
.empty-mark span:nth-child(2) { background: var(--accent); animation: breathe 2.4s ease-in-out infinite; }
|
||||
.empty h2 {
|
||||
font-family: var(--serif); font-weight: 400;
|
||||
font-size: 2.25rem; margin: 0 0 0.5rem;
|
||||
letter-spacing: 0;
|
||||
font-family: var(--serif); font-weight: 700;
|
||||
font-size: 1.5rem; margin: 0 0 0.5rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.empty p { color: var(--text-secondary); margin: 0 0 1.5rem; font-size: 0.95rem; }
|
||||
.empty code {
|
||||
font-family: var(--mono); font-size: 0.85em;
|
||||
padding: 0.1rem 0.4rem;
|
||||
background: var(--surface-card-hover);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.empty .btn-primary { display: inline-flex; }
|
||||
.empty :global(.btn-primary) { display: inline-flex; }
|
||||
|
||||
/* ── Grid & Cards ──────────────────────────────── */
|
||||
.grid {
|
||||
@@ -476,10 +470,11 @@
|
||||
|
||||
.card-title {
|
||||
font-family: var(--serif);
|
||||
font-size: 1.85rem; line-height: 1.1;
|
||||
font-size: 1.15rem; line-height: 1.3;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
letter-spacing: 0;
|
||||
letter-spacing: -0.01em;
|
||||
word-break: break-word;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import * as api from '$lib/api';
|
||||
import { IconArrowLeft, IconRefresh, IconPlay, IconStop, IconTrash } from '$lib/components/icons';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
const id = $derived($page.params.id ?? '');
|
||||
|
||||
@@ -42,7 +43,7 @@
|
||||
stack = s; revisions = revs; services = svcs;
|
||||
if (!editing && revs.length > 0) editYaml = revs[0].yaml;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load stack';
|
||||
error = e instanceof Error ? e.message : $t('stacks.detail.errors.load');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
@@ -51,18 +52,18 @@
|
||||
async function handleStop() {
|
||||
if (!stack) return;
|
||||
try { await api.stopStack(stack.id); setTimeout(loadAll, 1500); }
|
||||
catch (e) { error = e instanceof Error ? e.message : 'Stop failed'; }
|
||||
catch (e) { error = e instanceof Error ? e.message : $t('stacks.detail.errors.stop'); }
|
||||
}
|
||||
async function handleStart() {
|
||||
if (!stack) return;
|
||||
try { await api.startStack(stack.id); setTimeout(loadAll, 1500); }
|
||||
catch (e) { error = e instanceof Error ? e.message : 'Start failed'; }
|
||||
catch (e) { error = e instanceof Error ? e.message : $t('stacks.detail.errors.start'); }
|
||||
}
|
||||
async function submitNewRevision() {
|
||||
if (!stack) return;
|
||||
submitting = true; error = '';
|
||||
try { await api.createStackRevision(stack.id, editYaml); editing = false; setTimeout(loadAll, 1500); }
|
||||
catch (e) { error = e instanceof Error ? e.message : 'Update failed'; }
|
||||
catch (e) { error = e instanceof Error ? e.message : $t('stacks.detail.errors.update'); }
|
||||
finally { submitting = false; }
|
||||
}
|
||||
async function doRollback() {
|
||||
@@ -70,7 +71,7 @@
|
||||
const revId = confirmRollback.id;
|
||||
confirmRollback = null;
|
||||
try { await api.rollbackStack(stack.id, revId); setTimeout(loadAll, 1500); }
|
||||
catch (e) { error = e instanceof Error ? e.message : 'Rollback failed'; }
|
||||
catch (e) { error = e instanceof Error ? e.message : $t('stacks.detail.errors.rollback'); }
|
||||
}
|
||||
async function doDelete() {
|
||||
if (!stack) return;
|
||||
@@ -78,22 +79,22 @@
|
||||
const rm = deleteRemoveVolumes;
|
||||
confirmDelete = false; deleteRemoveVolumes = false;
|
||||
try { await api.deleteStack(sid, rm); await goto('/stacks'); }
|
||||
catch (e) { error = e instanceof Error ? e.message : 'Delete failed'; }
|
||||
catch (e) { error = e instanceof Error ? e.message : $t('stacks.detail.errors.delete'); }
|
||||
}
|
||||
async function loadLogs() {
|
||||
if (!stack) return;
|
||||
logsLoading = true;
|
||||
try { logsText = await api.getStackLogs(stack.id, logsService || undefined, 300); }
|
||||
catch (e) { logsText = e instanceof Error ? e.message : 'Failed to load logs'; }
|
||||
catch (e) { logsText = e instanceof Error ? e.message : $t('stacks.detail.errors.fetchLogs'); }
|
||||
finally { logsLoading = false; }
|
||||
}
|
||||
|
||||
function statusMeta(status: string) {
|
||||
switch (status) {
|
||||
case 'running': return { label: 'RUNNING', cls: 'st-running' };
|
||||
case 'deploying':return { label: 'FORGING', cls: 'st-deploying' };
|
||||
case 'failed': return { label: 'FAILED', cls: 'st-failed' };
|
||||
default: return { label: 'COLD', cls: 'st-stopped' };
|
||||
case 'running': return { label: $t('stacks.running').toUpperCase(), cls: 'st-running' };
|
||||
case 'deploying':return { label: $t('stacks.deploying').toUpperCase(), cls: 'st-deploying' };
|
||||
case 'failed': return { label: $t('stacks.failed').toUpperCase(), cls: 'st-failed' };
|
||||
default: return { label: $t('stacks.stopped').toUpperCase(), cls: 'st-stopped' };
|
||||
}
|
||||
}
|
||||
function fmtTime(ts: string): string {
|
||||
@@ -117,22 +118,22 @@
|
||||
|
||||
<a href="/stacks" class="back">
|
||||
<IconArrowLeft size={13} />
|
||||
<span>STACKS</span>
|
||||
<span>{$t('stacks.title').toUpperCase()}</span>
|
||||
</a>
|
||||
|
||||
{#if loading && !stack}
|
||||
<div class="loading">
|
||||
<span class="spinner"></span>
|
||||
<span>Loading blueprint…</span>
|
||||
<span>{$t('stacks.detail.loading')}</span>
|
||||
</div>
|
||||
{:else if error && !stack}
|
||||
<div class="alert"><span class="alert-tag">ERR</span><span>{error}</span></div>
|
||||
<div class="alert"><span class="alert-tag">{$t('stacks.detail.err')}</span><span>{error}</span></div>
|
||||
{:else if stack}
|
||||
{@const sm = statusMeta(stack.status)}
|
||||
<header class="head">
|
||||
<div class="eyebrow">
|
||||
<span class="ember"></span>
|
||||
<span>THE FORGE</span>
|
||||
<span>{$t('stacks.eyebrow')}</span>
|
||||
<span class="sep">//</span>
|
||||
<span class="mono-id">{stack.id.slice(0, 16)}</span>
|
||||
<span class="sep">//</span>
|
||||
@@ -147,36 +148,36 @@
|
||||
{#if stack.description}
|
||||
<p class="lede">{stack.description}</p>
|
||||
{:else}
|
||||
<p class="lede dim">No description</p>
|
||||
<p class="lede dim">{$t('stacks.detail.noDescription')}</p>
|
||||
{/if}
|
||||
<span class="project-chip">
|
||||
<span class="chip-k">COMPOSE PROJECT</span>
|
||||
<span class="chip-k">{$t('stacks.detail.composeProject')}</span>
|
||||
<code>{stack.compose_project_name}</code>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<button class="btn-ghost" onclick={loadAll} aria-label="Refresh">
|
||||
<button class="btn-ghost" onclick={loadAll} aria-label={$t('stacks.detail.refresh')}>
|
||||
<IconRefresh size={15} />
|
||||
</button>
|
||||
{#if stack.status === 'running'}
|
||||
<button onclick={handleStop} class="chip-btn">
|
||||
<IconStop size={13} /> <span>Stop</span>
|
||||
<IconStop size={13} /> <span>{$t('stacks.detail.stop')}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<button onclick={handleStart} class="chip-btn primary">
|
||||
<IconPlay size={13} /> <span>Start</span>
|
||||
<IconPlay size={13} /> <span>{$t('stacks.detail.start')}</span>
|
||||
</button>
|
||||
{/if}
|
||||
<button onclick={() => (confirmDelete = true)} class="chip-btn danger">
|
||||
<IconTrash size={13} /> <span>Delete</span>
|
||||
<IconTrash size={13} /> <span>{$t('stacks.detail.delete')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if stack.error}
|
||||
<div class="alert">
|
||||
<span class="alert-tag">FAULT</span>
|
||||
<span class="alert-tag">{$t('stacks.detail.fault')}</span>
|
||||
<span>{stack.error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -185,39 +186,39 @@
|
||||
<!-- ── Stat tiles ─────────────────────────────── -->
|
||||
<section class="stats">
|
||||
<div class="stat">
|
||||
<span class="stat-label">Services</span>
|
||||
<span class="stat-label">{$t('stacks.detail.stats.services')}</span>
|
||||
<span class="stat-value">{String(services.length).padStart(2,'0')}</span>
|
||||
<span class="stat-sub">in blueprint</span>
|
||||
<span class="stat-sub">{$t('stacks.detail.stats.servicesSub')}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Running</span>
|
||||
<span class="stat-label">{$t('stacks.detail.stats.running')}</span>
|
||||
<span class="stat-value accent">
|
||||
{String(services.filter(s => serviceState(s.State) === 'running').length).padStart(2,'0')}
|
||||
</span>
|
||||
<span class="stat-sub">active containers</span>
|
||||
<span class="stat-sub">{$t('stacks.detail.stats.runningSub')}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Revisions</span>
|
||||
<span class="stat-label">{$t('stacks.detail.stats.revisions')}</span>
|
||||
<span class="stat-value">{String(revisions.length).padStart(2,'0')}</span>
|
||||
<span class="stat-sub">in history</span>
|
||||
<span class="stat-sub">{$t('stacks.detail.stats.revisionsSub')}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Current</span>
|
||||
<span class="stat-label">{$t('stacks.detail.stats.current')}</span>
|
||||
<span class="stat-value">
|
||||
R{(revisions.find(r => r.id === stack?.current_revision_id)?.revision ?? 0).toString().padStart(2,'0')}
|
||||
</span>
|
||||
<span class="stat-sub">deployed</span>
|
||||
<span class="stat-sub">{$t('stacks.detail.stats.currentSub')}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Services ───────────────────────────────── -->
|
||||
<section class="panel">
|
||||
<header class="panel-head">
|
||||
<h2 class="panel-title">Services<span class="title-accent">.</span></h2>
|
||||
<span class="panel-count">{services.length} on the floor</span>
|
||||
<h2 class="panel-title">{$t('stacks.detail.services.title')}<span class="title-accent">.</span></h2>
|
||||
<span class="panel-count">{$t('stacks.detail.services.count', { n: String(services.length) })}</span>
|
||||
</header>
|
||||
{#if services.length === 0}
|
||||
<p class="panel-empty">— no containers running —</p>
|
||||
<p class="panel-empty">{$t('stacks.detail.services.empty')}</p>
|
||||
{:else}
|
||||
<ul class="svc-list">
|
||||
{#each services as svc (svc.Name)}
|
||||
@@ -242,23 +243,23 @@
|
||||
<section class="panel">
|
||||
<div class="tabs" role="tablist">
|
||||
<button role="tab" class="tab" class:active={tab==='yaml'} onclick={() => tab='yaml'}>
|
||||
<span class="tab-num">I</span><span>Blueprint</span>
|
||||
<span class="tab-num">I</span><span>{$t('stacks.detail.tabs.blueprint')}</span>
|
||||
</button>
|
||||
<button role="tab" class="tab" class:active={tab==='revisions'} onclick={() => tab='revisions'}>
|
||||
<span class="tab-num">II</span><span>Revisions</span>
|
||||
<span class="tab-num">II</span><span>{$t('stacks.detail.tabs.revisions')}</span>
|
||||
<span class="tab-badge">{revisions.length}</span>
|
||||
</button>
|
||||
<button role="tab" class="tab" class:active={tab==='logs'} onclick={() => tab='logs'}>
|
||||
<span class="tab-num">III</span><span>Logs</span>
|
||||
<span class="tab-num">III</span><span>{$t('stacks.detail.tabs.logs')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if tab === 'yaml'}
|
||||
<div class="panel-body">
|
||||
<div class="panel-toolbar">
|
||||
<span class="dim">Current revision</span>
|
||||
<span class="dim">{$t('stacks.detail.yaml.currentRevision')}</span>
|
||||
{#if !editing}
|
||||
<button class="chip" onclick={() => (editing = true)}>Edit & redeploy</button>
|
||||
<button class="chip" onclick={() => (editing = true)}>{$t('stacks.detail.yaml.edit')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if editing}
|
||||
@@ -269,9 +270,9 @@
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
<div class="panel-foot">
|
||||
<button class="btn-ghost" onclick={() => (editing = false)}>Cancel</button>
|
||||
<button class="btn-ghost" onclick={() => (editing = false)}>{$t('stacks.detail.yaml.cancel')}</button>
|
||||
<button class="btn-primary" onclick={submitNewRevision} disabled={submitting}>
|
||||
<span>{submitting ? 'Forging…' : 'Deploy new revision'}</span>
|
||||
<span>{submitting ? $t('stacks.detail.yaml.forging') : $t('stacks.detail.yaml.deployNew')}</span>
|
||||
<span class="arrow">→</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -295,17 +296,17 @@
|
||||
<div class="tl-head">
|
||||
<span class="tl-rev">R{rev.revision.toString().padStart(2, '0')}</span>
|
||||
{#if rev.id === stack.current_revision_id}
|
||||
<span class="tl-badge">CURRENT</span>
|
||||
<span class="tl-badge">{$t('stacks.detail.revisions.current')}</span>
|
||||
{/if}
|
||||
<span class="tl-status">{rev.status}</span>
|
||||
<span class="tl-time">{fmtTime(rev.created_at)}</span>
|
||||
</div>
|
||||
<div class="tl-meta">
|
||||
by <strong>{rev.author || 'operator'}</strong>
|
||||
{$t('stacks.detail.revisions.by')} <strong>{rev.author || 'operator'}</strong>
|
||||
</div>
|
||||
{#if rev.id !== stack.current_revision_id}
|
||||
<button class="tl-action" onclick={() => (confirmRollback = rev)}>
|
||||
← Rollback to this revision
|
||||
{$t('stacks.detail.revisions.rollback')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -317,16 +318,16 @@
|
||||
<div class="panel-body">
|
||||
<div class="panel-toolbar">
|
||||
<label class="log-select">
|
||||
<span class="dim">Service:</span>
|
||||
<span class="dim">{$t('stacks.detail.logs.service')}</span>
|
||||
<select bind:value={logsService}>
|
||||
<option value="">All services</option>
|
||||
<option value="">{$t('stacks.detail.logs.allServices')}</option>
|
||||
{#each services as svc (svc.Service)}
|
||||
<option value={svc.Service}>{svc.Service}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<button onclick={loadLogs} class="chip" disabled={logsLoading}>
|
||||
{logsLoading ? 'Fetching…' : 'Fetch logs'}
|
||||
{logsLoading ? $t('stacks.detail.logs.fetching') : $t('stacks.detail.logs.fetch')}
|
||||
</button>
|
||||
</div>
|
||||
{#if logsText}
|
||||
@@ -340,7 +341,7 @@
|
||||
<pre class="terminal-body">{logsText}</pre>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="panel-empty">— no logs loaded. tap fetch. —</p>
|
||||
<p class="panel-empty">{$t('stacks.detail.logs.empty')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -350,9 +351,9 @@
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmRollback !== null}
|
||||
title="Rollback to revision?"
|
||||
message={confirmRollback ? `Create a new revision from rev ${confirmRollback.revision} and redeploy the stack.` : ''}
|
||||
confirmLabel="Rollback"
|
||||
title={$t('stacks.detail.revisions.rollbackTitle')}
|
||||
message={confirmRollback ? $t('stacks.detail.revisions.rollbackMessage', { n: String(confirmRollback.revision) }) : ''}
|
||||
confirmLabel={$t('stacks.detail.revisions.rollbackConfirm')}
|
||||
confirmVariant="primary"
|
||||
onconfirm={doRollback}
|
||||
oncancel={() => (confirmRollback = null)}
|
||||
@@ -360,9 +361,9 @@
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDelete}
|
||||
title="Delete stack?"
|
||||
message={stack ? `This runs 'docker compose down' and removes "${stack.name}".${deleteRemoveVolumes ? ' Named volumes will also be removed.' : ''}` : ''}
|
||||
confirmLabel="Delete"
|
||||
title={$t('stacks.detail.delete.title')}
|
||||
message={stack ? $t('stacks.detail.delete.messageBase', { name: stack.name }) + (deleteRemoveVolumes ? $t('stacks.detail.delete.messageVolumes') : '') : ''}
|
||||
confirmLabel={$t('stacks.detail.delete.confirm')}
|
||||
confirmVariant="danger"
|
||||
onconfirm={doDelete}
|
||||
oncancel={() => { confirmDelete = false; deleteRemoveVolumes = false; }}
|
||||
@@ -370,7 +371,7 @@
|
||||
|
||||
<style>
|
||||
.forge {
|
||||
--serif: 'Instrument Serif', 'Iowan Old Style', Georgia, serif;
|
||||
--serif: var(--font-family-sans);
|
||||
--mono: var(--font-family-mono);
|
||||
--accent: var(--color-brand-600);
|
||||
--accent-soft: color-mix(in srgb, var(--color-brand-500) 14%, transparent);
|
||||
@@ -477,9 +478,9 @@
|
||||
.head-left { flex: 1; min-width: 280px; }
|
||||
.display {
|
||||
font-family: var(--serif);
|
||||
font-size: clamp(2.75rem, 7vw, 4.5rem);
|
||||
font-weight: 400; line-height: 1.05;
|
||||
letter-spacing: 0;
|
||||
font-size: clamp(1.875rem, 4vw, 2.5rem);
|
||||
font-weight: 700; line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -610,7 +611,8 @@
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.stat-value {
|
||||
font-family: var(--serif); font-size: 2.5rem; line-height: 1;
|
||||
font-family: var(--serif); font-size: 2rem; line-height: 1.1;
|
||||
font-weight: 700; letter-spacing: -0.02em;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -634,11 +636,11 @@
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
}
|
||||
.panel-title {
|
||||
font-family: var(--serif); font-size: 1.75rem;
|
||||
margin: 0; font-weight: 400; line-height: 1;
|
||||
letter-spacing: 0;
|
||||
font-family: var(--serif); font-size: 1.35rem;
|
||||
margin: 0; font-weight: 600; line-height: 1.2;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.title-accent { color: var(--accent); font-style: italic; }
|
||||
.title-accent { color: var(--accent); font-weight: 700; }
|
||||
.panel-count {
|
||||
font-family: var(--mono); font-size: 0.66rem;
|
||||
letter-spacing: 0.12em; color: var(--text-tertiary);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import * as api from '$lib/api';
|
||||
import { IconArrowLeft } from '$lib/components/icons';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
let name = $state('');
|
||||
let description = $state('');
|
||||
@@ -38,7 +39,7 @@
|
||||
async function submit(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !yaml.trim()) {
|
||||
error = 'Name and compose YAML are required.';
|
||||
error = $t('stacks.new.errorRequired');
|
||||
return;
|
||||
}
|
||||
submitting = true; error = '';
|
||||
@@ -51,7 +52,7 @@
|
||||
});
|
||||
await goto(`/stacks/${stack.id}`);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to create stack';
|
||||
error = e instanceof Error ? e.message : $t('stacks.new.errorCreate');
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
@@ -75,23 +76,18 @@
|
||||
|
||||
<a href="/stacks" class="back">
|
||||
<IconArrowLeft size={13} />
|
||||
<span>STACKS</span>
|
||||
<span>{$t('stacks.new.back').toUpperCase()}</span>
|
||||
</a>
|
||||
|
||||
<header class="head">
|
||||
<span class="eyebrow">
|
||||
<span class="ember"></span>
|
||||
<span>THE FORGE</span>
|
||||
<span>{$t('stacks.eyebrow')}</span>
|
||||
<span class="sep">//</span>
|
||||
<span>NEW BLUEPRINT</span>
|
||||
<span>{$t('stacks.new.eyebrow')}</span>
|
||||
</span>
|
||||
<h1 class="display">
|
||||
Forge a<br/>new <em>stack</em>.
|
||||
</h1>
|
||||
<p class="lede">
|
||||
Upload or paste a <code>docker-compose.yml</code>. All services in the blueprint
|
||||
deploy as a single atomic unit.
|
||||
</p>
|
||||
<h1 class="display">{$t('stacks.new.title')}</h1>
|
||||
<p class="lede">{@html $t('stacks.new.lede')}</p>
|
||||
</header>
|
||||
|
||||
<form onsubmit={submit} class="form">
|
||||
@@ -101,37 +97,37 @@
|
||||
<span class="reg reg-br" aria-hidden="true"></span>
|
||||
|
||||
{#if error}
|
||||
<div class="alert"><span class="alert-tag">ERR</span><span>{error}</span></div>
|
||||
<div class="alert"><span class="alert-tag">{$t('stacks.detail.err')}</span><span>{error}</span></div>
|
||||
{/if}
|
||||
|
||||
<div class="field">
|
||||
<label for="stack-name" class="field-label">
|
||||
<span class="num">01</span>
|
||||
<span class="lbl">Name</span>
|
||||
<span class="req">required</span>
|
||||
<span class="lbl">{$t('stacks.new.name')}</span>
|
||||
<span class="req">{$t('stacks.new.required')}</span>
|
||||
</label>
|
||||
<input
|
||||
id="stack-name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
required
|
||||
placeholder="my-app-stack"
|
||||
placeholder={$t('stacks.new.namePlaceholder')}
|
||||
class="input"
|
||||
/>
|
||||
<p class="hint">Lowercase, hyphenated. Used as the compose project name.</p>
|
||||
<p class="hint">{$t('stacks.new.nameHint')}</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="stack-desc" class="field-label">
|
||||
<span class="num">02</span>
|
||||
<span class="lbl">Description</span>
|
||||
<span class="opt">optional</span>
|
||||
<span class="lbl">{$t('stacks.new.description')}</span>
|
||||
<span class="opt">{$t('stacks.new.optional')}</span>
|
||||
</label>
|
||||
<input
|
||||
id="stack-desc"
|
||||
type="text"
|
||||
bind:value={description}
|
||||
placeholder="What does this stack do?"
|
||||
placeholder={$t('stacks.new.descriptionPlaceholder')}
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
@@ -139,11 +135,11 @@
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<span class="num">03</span>
|
||||
<span class="lbl">Compose YAML</span>
|
||||
<span class="req">required</span>
|
||||
<span class="lbl">{$t('stacks.new.composeYaml')}</span>
|
||||
<span class="req">{$t('stacks.new.required')}</span>
|
||||
<span class="spacer"></span>
|
||||
<button type="button" class="chip" onclick={loadSample}>Load sample</button>
|
||||
<button type="button" class="chip" onclick={() => fileInput?.click()}>Upload file</button>
|
||||
<button type="button" class="chip" onclick={loadSample}>{$t('stacks.new.loadSample')}</button>
|
||||
<button type="button" class="chip" onclick={() => fileInput?.click()}>{$t('stacks.new.uploadFile')}</button>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
@@ -164,8 +160,8 @@
|
||||
onclick={() => fileInput?.click()}
|
||||
>
|
||||
<div class="dz-icon">⇣</div>
|
||||
<div class="dz-title">Drop a <em>docker-compose.yml</em> here</div>
|
||||
<div class="dz-sub">or click to browse · or use <strong>Load sample</strong> above</div>
|
||||
<div class="dz-title">{$t('stacks.new.dropHere')}</div>
|
||||
<div class="dz-sub">{@html $t('stacks.new.dropSub')}</div>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
@@ -186,12 +182,12 @@
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="editor-foot">
|
||||
<span>{lineCount} lines</span>
|
||||
<span>{$t('stacks.new.lines', { n: String(lineCount) })}</span>
|
||||
<span class="sep">·</span>
|
||||
<span>{byteCount} bytes</span>
|
||||
<span>{$t('stacks.new.bytes', { n: String(byteCount) })}</span>
|
||||
<span class="sep">·</span>
|
||||
<span>YAML</span>
|
||||
<button type="button" class="clear-btn" onclick={() => (yaml = '')}>Clear</button>
|
||||
<button type="button" class="clear-btn" onclick={() => (yaml = '')}>{$t('stacks.new.clear')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -200,19 +196,19 @@
|
||||
<input type="checkbox" bind:checked={deployNow} />
|
||||
<span class="toggle-box"></span>
|
||||
<span class="toggle-text">
|
||||
<strong>Deploy immediately</strong>
|
||||
<span class="dim">Strike while the iron's hot. If unchecked, the stack is saved cold.</span>
|
||||
<strong>{$t('stacks.new.deployImmediate')}</strong>
|
||||
<span class="dim">{$t('stacks.new.deployHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<a href="/stacks" class="btn-ghost">Cancel</a>
|
||||
<a href="/stacks" class="btn-ghost">{$t('stacks.new.cancel')}</a>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
class="btn-primary"
|
||||
>
|
||||
<span>{submitting ? 'Forging…' : deployNow ? 'Forge & deploy' : 'Save blueprint'}</span>
|
||||
<span>{submitting ? $t('stacks.new.forging') : deployNow ? $t('stacks.new.forgeAndDeploy') : $t('stacks.new.saveBlueprint')}</span>
|
||||
<span class="arrow">→</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -221,7 +217,7 @@
|
||||
|
||||
<style>
|
||||
.forge {
|
||||
--serif: 'Instrument Serif', 'Iowan Old Style', Georgia, serif;
|
||||
--serif: var(--font-family-sans);
|
||||
--mono: var(--font-family-mono);
|
||||
--accent: var(--color-brand-600);
|
||||
--accent-soft: color-mix(in srgb, var(--color-brand-500) 14%, transparent);
|
||||
@@ -281,14 +277,15 @@
|
||||
|
||||
.display {
|
||||
font-family: var(--serif);
|
||||
font-size: clamp(3rem, 7vw, 4.75rem);
|
||||
font-weight: 400; line-height: 1.05;
|
||||
letter-spacing: 0;
|
||||
font-size: clamp(1.875rem, 4vw, 2.5rem);
|
||||
font-weight: 700; line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
}
|
||||
.display em {
|
||||
.display :global(em) {
|
||||
color: var(--accent);
|
||||
font-style: italic;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
.lede {
|
||||
font-family: var(--serif);
|
||||
@@ -298,7 +295,7 @@
|
||||
font-size: 1.15rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.lede code {
|
||||
.lede :global(code) {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.85em;
|
||||
padding: 0.1rem 0.4rem;
|
||||
@@ -443,13 +440,13 @@
|
||||
font-family: var(--serif); font-size: 1.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.dz-title em { color: var(--accent); font-style: italic; }
|
||||
.dz-title :global(em) { color: var(--accent); font-style: italic; }
|
||||
.dz-sub {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem; letter-spacing: 0.06em;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.dz-sub strong { color: var(--text-secondary); font-weight: 600; }
|
||||
.dz-sub :global(strong) { color: var(--text-secondary); font-weight: 600; }
|
||||
|
||||
/* ── Editor ────────────────────────────────────── */
|
||||
.editor {
|
||||
|
||||
Reference in New Issue
Block a user