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:
Generated
+14
@@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@fontsource/instrument-serif": "^5.2.8",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -447,6 +448,14 @@
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/inter": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
|
||||
"integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/jetbrains-mono": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
|
||||
@@ -2300,6 +2309,11 @@
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/instrument-serif/-/instrument-serif-5.2.8.tgz",
|
||||
"integrity": "sha512-s+bkz+syj2rO00Rmq9g0P+PwuLig33DR1xDR8pTWmovH1pUjwnncrFk++q9mmOex8fUQ7oW80gPpPDaw7V1MMw=="
|
||||
},
|
||||
"@fontsource/inter": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
|
||||
"integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="
|
||||
},
|
||||
"@fontsource/jetbrains-mono": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@fontsource/instrument-serif": "^5.2.8",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8"
|
||||
}
|
||||
}
|
||||
|
||||
+417
-2
@@ -1,7 +1,9 @@
|
||||
@import 'tailwindcss';
|
||||
@import '$lib/styles/tokens.css';
|
||||
@import '@fontsource/instrument-serif/400.css';
|
||||
@import '@fontsource/instrument-serif/400-italic.css';
|
||||
@import '@fontsource/inter/400.css';
|
||||
@import '@fontsource/inter/500.css';
|
||||
@import '@fontsource/inter/600.css';
|
||||
@import '@fontsource/inter/700.css';
|
||||
@import '@fontsource/jetbrains-mono/400.css';
|
||||
@import '@fontsource/jetbrains-mono/500.css';
|
||||
@import '@fontsource/jetbrains-mono/700.css';
|
||||
@@ -69,3 +71,416 @@ input[type="number"] {
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
FORGE DESIGN SYSTEM — global utilities
|
||||
═══════════════════════════════════════════════════════════════════ */
|
||||
|
||||
:root {
|
||||
/* Display font now uses the app's standard sans stack.
|
||||
--forge-serif is kept as an alias so all existing usages switch with one edit. */
|
||||
--forge-serif: var(--font-family-sans);
|
||||
--forge-display: var(--font-family-sans);
|
||||
--forge-mono: 'JetBrains Mono', ui-monospace, 'Cascadia Code', monospace;
|
||||
--forge-accent: var(--color-brand-600);
|
||||
--forge-accent-soft: color-mix(in srgb, var(--color-brand-500) 14%, transparent);
|
||||
--forge-glow: color-mix(in srgb, var(--color-brand-500) 32%, transparent);
|
||||
}
|
||||
|
||||
/* ── Dot-grid backdrop ─────────────────────────────────────────── */
|
||||
.forge-backdrop {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
.forge-backdrop::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.8;
|
||||
}
|
||||
|
||||
/* ── Eyebrow label (mono caps) ─────────────────────────────────── */
|
||||
.forge-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.forge-eyebrow .sep { opacity: 0.5; }
|
||||
|
||||
/* ── Ember (breathing accent dot) ──────────────────────────────── */
|
||||
.forge-ember {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--forge-accent);
|
||||
box-shadow: 0 0 0 3px var(--forge-accent-soft);
|
||||
animation: forge-breathe 2.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes forge-breathe {
|
||||
0%, 100% { box-shadow: 0 0 0 3px var(--forge-accent-soft); }
|
||||
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--color-brand-500) 20%, transparent); }
|
||||
}
|
||||
@keyframes forge-blink {
|
||||
0%, 60%, 100% { opacity: 1; }
|
||||
70%, 90% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
/* ── Display heading ──────────────────────────────────────────── */
|
||||
.forge-display {
|
||||
font-family: var(--forge-display);
|
||||
font-weight: 700;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.forge-display .accent { color: var(--forge-accent); font-weight: 700; }
|
||||
.forge-display em { color: var(--forge-accent); font-style: normal; font-weight: 700; }
|
||||
|
||||
/* ── Lede paragraph ───────────────────────────────────────────── */
|
||||
.forge-lede {
|
||||
font-family: var(--font-family-sans);
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
line-height: 1.55;
|
||||
max-width: 62ch;
|
||||
margin: 0.75rem 0 0;
|
||||
}
|
||||
.forge-lede em { color: var(--forge-accent); font-style: normal; font-weight: 500; }
|
||||
.forge-lede code {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.8em;
|
||||
padding: 0.1rem 0.4rem;
|
||||
background: var(--surface-card-hover);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Status pill with pulse ────────────────────────────────────── */
|
||||
.forge-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-card-hover);
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.forge-pill .pulse {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
.forge-pill.is-running { background: var(--color-success-light); color: var(--color-success-dark); }
|
||||
.forge-pill.is-running .pulse { background: var(--color-success); animation: forge-blink 1.8s infinite; }
|
||||
.forge-pill.is-info { background: var(--color-info-light); color: var(--color-info-dark); }
|
||||
.forge-pill.is-info .pulse { background: var(--color-info); animation: forge-blink 0.8s infinite; }
|
||||
.forge-pill.is-warn { background: var(--color-warning-light); color: var(--color-warning-dark); }
|
||||
.forge-pill.is-warn .pulse { background: var(--color-warning); animation: forge-blink 0.9s infinite; }
|
||||
.forge-pill.is-fail { background: var(--color-danger-light); color: var(--color-danger-dark); }
|
||||
.forge-pill.is-fail .pulse { background: var(--color-danger); animation: forge-blink 0.5s infinite; }
|
||||
|
||||
[data-theme='dark'] .forge-pill.is-running { background: color-mix(in srgb, var(--color-success) 16%, transparent); color: #86efac; }
|
||||
[data-theme='dark'] .forge-pill.is-info { background: color-mix(in srgb, var(--color-info) 16%, transparent); color: #93c5fd; }
|
||||
[data-theme='dark'] .forge-pill.is-warn { background: color-mix(in srgb, var(--color-warning) 16%, transparent); color: #fcd34d; }
|
||||
[data-theme='dark'] .forge-pill.is-fail { background: color-mix(in srgb, var(--color-danger) 16%, transparent); color: #fca5a5; }
|
||||
|
||||
/* ── Buttons ───────────────────────────────────────────────────── */
|
||||
.forge-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 1rem;
|
||||
background: var(--text-primary);
|
||||
color: var(--surface-card);
|
||||
border: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: transform 150ms ease, box-shadow 150ms ease;
|
||||
box-shadow: 0 0 0 0 var(--forge-glow);
|
||||
}
|
||||
.forge-btn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 0 4px var(--forge-glow);
|
||||
}
|
||||
.forge-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.forge-btn-ghost {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.5rem 0.85rem;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
.forge-btn-ghost:hover:not(:disabled) {
|
||||
background: var(--surface-card-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--color-brand-300);
|
||||
}
|
||||
.forge-btn-ghost:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.forge-btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
.forge-btn-icon:hover:not(:disabled) {
|
||||
background: var(--surface-card-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--color-brand-300);
|
||||
}
|
||||
.forge-btn-icon:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.forge-btn-danger { color: var(--color-danger); border-color: var(--border-primary); }
|
||||
.forge-btn-danger:hover:not(:disabled) {
|
||||
background: var(--color-danger-light);
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger-dark);
|
||||
}
|
||||
[data-theme='dark'] .forge-btn-danger:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* ── Panel / Card with registration marks ──────────────────────── */
|
||||
.forge-panel {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
.forge-panel-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.35rem 0.85rem;
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
}
|
||||
.forge-panel-title {
|
||||
font-family: var(--forge-display);
|
||||
font-size: 1.35rem;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.forge-panel-title .accent { color: var(--forge-accent); font-weight: 600; }
|
||||
.forge-panel-body { padding: 1.15rem 1.35rem 1.35rem; }
|
||||
|
||||
/* ── Stat tile ─────────────────────────────────────────────────── */
|
||||
.forge-stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
background: var(--surface-card);
|
||||
}
|
||||
.forge-stat {
|
||||
padding: 1rem 1.15rem;
|
||||
border-right: 1px solid var(--border-secondary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.forge-stat:last-child { border-right: 0; }
|
||||
.forge-stat-label {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.forge-stat-value {
|
||||
font-family: var(--forge-display);
|
||||
font-size: 2.25rem;
|
||||
line-height: 1.1;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.forge-stat-value.accent { color: var(--forge-accent); }
|
||||
.forge-stat-value.warn { color: var(--color-warning-dark); }
|
||||
.forge-stat-value.fail { color: var(--color-danger); }
|
||||
.forge-stat-sub {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.66rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ── Registration corner marks (reveal on hover) ───────────────── */
|
||||
.forge-regs {
|
||||
position: relative;
|
||||
}
|
||||
.forge-regs::before,
|
||||
.forge-regs::after,
|
||||
.forge-regs > .reg-bl,
|
||||
.forge-regs > .reg-br {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 8px; height: 8px;
|
||||
border-color: var(--color-brand-500);
|
||||
border-style: solid;
|
||||
border-width: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 180ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
.forge-regs::before { top: -1px; left: -1px; border-top-width: 2px; border-left-width: 2px; }
|
||||
.forge-regs::after { top: -1px; right: -1px; border-top-width: 2px; border-right-width: 2px; }
|
||||
.forge-regs:hover::before,
|
||||
.forge-regs:hover::after { opacity: 1; }
|
||||
|
||||
/* ── Alert ─────────────────────────────────────────────────────── */
|
||||
.forge-alert {
|
||||
display: flex;
|
||||
gap: 0.7rem;
|
||||
align-items: center;
|
||||
padding: 0.7rem 0.9rem;
|
||||
background: var(--color-danger-light);
|
||||
color: var(--color-danger-dark);
|
||||
border: 1px solid var(--color-danger);
|
||||
border-left-width: 4px;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.forge-alert-tag {
|
||||
font-family: var(--forge-mono);
|
||||
font-weight: 700;
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.16em;
|
||||
padding: 0.15rem 0.4rem;
|
||||
background: var(--color-danger);
|
||||
color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
[data-theme='dark'] .forge-alert {
|
||||
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* ── Terminal frame ────────────────────────────────────────────── */
|
||||
.forge-terminal {
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: #0b1020;
|
||||
overflow: hidden;
|
||||
}
|
||||
[data-theme='dark'] .forge-terminal { background: #05070f; }
|
||||
.forge-terminal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.9rem;
|
||||
background: #141a2e;
|
||||
border-bottom: 1px solid #0a0e1c;
|
||||
}
|
||||
[data-theme='dark'] .forge-terminal-head { background: #0a0e1c; }
|
||||
.forge-terminal-head .dot {
|
||||
width: 9px; height: 9px; border-radius: 50%;
|
||||
background: rgba(255,255,255,0.12);
|
||||
}
|
||||
.forge-terminal-head .dot:nth-child(1) { background: color-mix(in srgb, var(--color-danger) 70%, black); }
|
||||
.forge-terminal-head .dot:nth-child(2) { background: color-mix(in srgb, var(--color-warning) 70%, black); }
|
||||
.forge-terminal-head .dot:nth-child(3) { background: color-mix(in srgb, var(--color-success) 70%, black); }
|
||||
.forge-terminal-head .title {
|
||||
margin-left: 0.6rem;
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.7rem;
|
||||
color: rgba(255,255,255,0.45);
|
||||
}
|
||||
.forge-terminal-body {
|
||||
margin: 0;
|
||||
padding: 1rem 1.1rem;
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.55;
|
||||
color: #c7d0e0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 480px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* ── Native heading tune-up ────────────────────────────────────── */
|
||||
h1.forge, h2.forge, h3.forge {
|
||||
font-family: var(--forge-display);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* ── Table refresh ─────────────────────────────────────────────── */
|
||||
.forge-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.forge-table thead th {
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
padding: 0.7rem 1rem;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
background: var(--surface-card-hover);
|
||||
}
|
||||
.forge-table tbody td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.forge-table tbody tr:last-child td { border-bottom: 0; }
|
||||
.forge-table tbody tr:hover { background: var(--surface-card-hover); }
|
||||
|
||||
/* ── Spin & fade ───────────────────────────────────────────────── */
|
||||
@keyframes forge-spin { to { transform: rotate(360deg); } }
|
||||
.forge-spinner {
|
||||
width: 12px; height: 12px;
|
||||
border: 2px solid var(--text-tertiary);
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: forge-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@@ -25,12 +25,6 @@
|
||||
oncancel
|
||||
}: Props = $props();
|
||||
|
||||
const confirmClass = $derived(
|
||||
confirmVariant === 'danger'
|
||||
? 'bg-[var(--color-danger)] hover:bg-[var(--color-danger-dark)] focus-visible:outline-[var(--color-danger)]'
|
||||
: 'bg-[var(--color-brand-600)] hover:bg-[var(--color-brand-700)] focus-visible:outline-[var(--color-brand-600)]'
|
||||
);
|
||||
|
||||
const iconBgClass = $derived(
|
||||
confirmVariant === 'danger'
|
||||
? 'bg-[var(--color-danger-light)]'
|
||||
@@ -50,28 +44,29 @@
|
||||
|
||||
<!-- Dialog -->
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-md rounded-2xl bg-[var(--surface-card)] p-6 shadow-xl animate-scale-in">
|
||||
<div class="dlg w-full max-w-md animate-scale-in">
|
||||
<span class="dlg-reg dlg-reg-tl"></span>
|
||||
<span class="dlg-reg dlg-reg-tr"></span>
|
||||
<span class="dlg-reg dlg-reg-bl"></span>
|
||||
<span class="dlg-reg dlg-reg-br"></span>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full {iconBgClass}">
|
||||
<IconAlert size={20} class={iconColorClass} />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)]">{title}</h3>
|
||||
<p class="mt-2 text-sm text-[var(--text-secondary)] leading-relaxed">{message}</p>
|
||||
<h3 class="dlg-title">{title}</h3>
|
||||
<p class="dlg-msg">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg px-4 py-2 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors active:animate-press"
|
||||
onclick={oncancel}
|
||||
>
|
||||
<div class="mt-6 flex justify-end gap-2">
|
||||
<button type="button" class="forge-btn-ghost" onclick={oncancel}>
|
||||
{$t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg px-4 py-2 text-sm font-medium text-white {confirmClass} shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 active:animate-press"
|
||||
class="dlg-confirm {confirmVariant}"
|
||||
onclick={onconfirm}
|
||||
>
|
||||
{confirmLabel}
|
||||
@@ -80,3 +75,77 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.dlg {
|
||||
position: relative;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-2xl);
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.dlg-reg {
|
||||
position: absolute;
|
||||
width: 10px; height: 10px;
|
||||
border-color: var(--color-brand-500);
|
||||
border-style: solid;
|
||||
border-width: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.dlg-reg-tl { top: -1px; left: -1px; border-top-width: 2px; border-left-width: 2px; border-top-left-radius: var(--radius-2xl); }
|
||||
.dlg-reg-tr { top: -1px; right: -1px; border-top-width: 2px; border-right-width: 2px; border-top-right-radius: var(--radius-2xl); }
|
||||
.dlg-reg-bl { bottom: -1px; left: -1px; border-bottom-width: 2px; border-left-width: 2px; border-bottom-left-radius: var(--radius-2xl); }
|
||||
.dlg-reg-br { bottom: -1px; right: -1px; border-bottom-width: 2px; border-right-width: 2px; border-bottom-right-radius: var(--radius-2xl); }
|
||||
|
||||
.dlg-title {
|
||||
font-family: var(--font-family-sans);
|
||||
font-weight: 700;
|
||||
font-size: 1.15rem;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.25;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
.dlg-msg {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
.dlg-confirm {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 1rem;
|
||||
border: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: transform 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
.dlg-confirm.primary {
|
||||
background: var(--color-brand-600);
|
||||
box-shadow: 0 0 0 0 var(--forge-glow);
|
||||
}
|
||||
.dlg-confirm.primary:hover {
|
||||
background: var(--color-brand-700);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 0 4px var(--forge-glow);
|
||||
}
|
||||
.dlg-confirm.danger {
|
||||
background: var(--color-danger);
|
||||
box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-danger) 30%, transparent);
|
||||
}
|
||||
.dlg-confirm.danger:hover {
|
||||
background: var(--color-danger-dark);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-danger) 30%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
<!--
|
||||
Task 9: Empty state component with SVG illustration and call-to-action.
|
||||
-->
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
title: string;
|
||||
@@ -17,69 +14,80 @@
|
||||
actionLabel = '',
|
||||
actionHref = '',
|
||||
onaction,
|
||||
icon = 'projects'
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-[var(--border-primary)] px-6 py-16 text-center animate-fade-in">
|
||||
<!-- SVG Illustration -->
|
||||
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-[var(--color-brand-50)]">
|
||||
{#if icon === 'projects'}
|
||||
<svg class="h-8 w-8 text-[var(--color-brand-500)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||
<path d="M12 10v6" /><path d="M9 13h6" />
|
||||
</svg>
|
||||
{:else if icon === 'instances'}
|
||||
<svg class="h-8 w-8 text-[var(--color-brand-500)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="20" height="8" x="2" y="2" rx="2" ry="2" /><rect width="20" height="8" x="2" y="14" rx="2" ry="2" /><line x1="6" x2="6.01" y1="6" y2="6" /><line x1="6" x2="6.01" y1="18" y2="18" />
|
||||
</svg>
|
||||
{:else if icon === 'deploys'}
|
||||
<svg class="h-8 w-8 text-[var(--color-brand-500)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15.59 14.37a6 6 0 0 1-5.84 7.38v-4.8m5.84-2.58a14.98 14.98 0 0 0 6.16-12.12A14.98 14.98 0 0 0 9.631 8.41m5.96 5.96a14.926 14.926 0 0 1-5.841 2.58M16.5 9a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Z" />
|
||||
</svg>
|
||||
{:else if icon === 'registries'}
|
||||
<svg class="h-8 w-8 text-[var(--color-brand-500)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<ellipse cx="12" cy="5" rx="9" ry="3" /><path d="M3 5v14a9 3 0 0 0 18 0V5" /><path d="M3 12a9 3 0 0 0 18 0" />
|
||||
</svg>
|
||||
{:else if icon === 'volumes'}
|
||||
<svg class="h-8 w-8 text-[var(--color-brand-500)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="22" x2="2" y1="12" y2="12" /><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
||||
</svg>
|
||||
{:else if icon === 'users'}
|
||||
<svg class="h-8 w-8 text-[var(--color-brand-500)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M22 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
{/if}
|
||||
<div class="empty">
|
||||
<div class="empty-mark">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">{title}</h3>
|
||||
<h3 class="empty-title">{title}</h3>
|
||||
|
||||
{#if description}
|
||||
<p class="mt-1 max-w-sm text-sm text-[var(--text-secondary)]">{description}</p>
|
||||
<p class="empty-desc">{description}</p>
|
||||
{/if}
|
||||
|
||||
{#if actionLabel}
|
||||
{#if actionHref}
|
||||
<a
|
||||
href={actionHref}
|
||||
class="mt-4 inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-brand-700)] active:animate-press"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<a href={actionHref} class="forge-btn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14" /><path d="M12 5v14" />
|
||||
</svg>
|
||||
{actionLabel}
|
||||
<span>{actionLabel}</span>
|
||||
</a>
|
||||
{:else if onaction}
|
||||
<button
|
||||
type="button"
|
||||
onclick={onaction}
|
||||
class="mt-4 inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-brand-700)] active:animate-press"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<button type="button" onclick={onaction} class="forge-btn">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14" /><path d="M12 5v14" />
|
||||
</svg>
|
||||
{actionLabel}
|
||||
<span>{actionLabel}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
border: 1px dashed var(--border-primary);
|
||||
border-radius: var(--radius-2xl);
|
||||
background: var(--surface-card);
|
||||
}
|
||||
.empty-mark {
|
||||
display: inline-flex; gap: 4px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.empty-mark span {
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
background: var(--border-input);
|
||||
}
|
||||
.empty-mark span:nth-child(2) {
|
||||
background: var(--color-brand-600);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand-500) 18%, transparent);
|
||||
animation: forge-breathe 2.4s ease-in-out infinite;
|
||||
}
|
||||
.empty-title {
|
||||
font-family: var(--font-family-sans);
|
||||
font-weight: 700;
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 0.4rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.empty-desc {
|
||||
color: var(--text-secondary);
|
||||
max-width: 42ch;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
.forge-btn { margin-top: 0.5rem; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
eyebrow?: string;
|
||||
eyebrowSuffix?: string;
|
||||
title: string;
|
||||
accent?: string;
|
||||
lede?: string;
|
||||
lede_html?: Snippet;
|
||||
toolbar?: Snippet;
|
||||
size?: 'md' | 'lg' | 'xl';
|
||||
}
|
||||
|
||||
const {
|
||||
eyebrow = 'THE FORGE',
|
||||
eyebrowSuffix,
|
||||
title,
|
||||
accent = '.',
|
||||
lede,
|
||||
lede_html,
|
||||
toolbar,
|
||||
size = 'lg'
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<header class="hero">
|
||||
<div class="top">
|
||||
<span class="forge-eyebrow">
|
||||
<span class="forge-ember"></span>
|
||||
<span>{eyebrow}</span>
|
||||
{#if eyebrowSuffix}
|
||||
<span class="sep">//</span>
|
||||
<span>{eyebrowSuffix}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if toolbar}
|
||||
<div class="toolbar">{@render toolbar()}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<h1 class="forge-display" class:s-md={size === 'md'} class:s-lg={size === 'lg'} class:s-xl={size === 'xl'}>
|
||||
{title}{#if accent}<span class="accent">{accent}</span>{/if}
|
||||
</h1>
|
||||
|
||||
{#if lede_html}
|
||||
<p class="forge-lede">{@render lede_html()}</p>
|
||||
{:else if lede}
|
||||
<p class="forge-lede">{lede}</p>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.hero { margin-bottom: 2rem; }
|
||||
.top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.toolbar { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
|
||||
|
||||
.forge-display.s-md { font-size: clamp(1.75rem, 3.5vw, 2.25rem); }
|
||||
.forge-display.s-lg { font-size: clamp(2rem, 4vw, 2.75rem); }
|
||||
.forge-display.s-xl { font-size: clamp(2.5rem, 5vw, 3.5rem); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.top { align-items: flex-start; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,3 @@
|
||||
<!--
|
||||
Task 5, 11: Status badge with pulse animation for "running" status.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { InstanceStatus, DeployStatus } from '$lib/types';
|
||||
|
||||
@@ -13,35 +10,34 @@
|
||||
|
||||
const { status, size = 'md' }: Props = $props();
|
||||
|
||||
const colorMap: Record<string, { bg: string; text: string; dot: string }> = {
|
||||
running: { bg: 'bg-emerald-50 dark:bg-emerald-950', text: 'text-emerald-700 dark:text-emerald-300', dot: 'bg-emerald-500' },
|
||||
success: { bg: 'bg-emerald-50 dark:bg-emerald-950', text: 'text-emerald-700 dark:text-emerald-300', dot: 'bg-emerald-500' },
|
||||
stopped: { bg: 'bg-gray-100 dark:bg-gray-800', text: 'text-gray-700 dark:text-gray-300', dot: 'bg-gray-400' },
|
||||
failed: { bg: 'bg-red-50 dark:bg-red-950', text: 'text-red-700 dark:text-red-300', dot: 'bg-red-500' },
|
||||
rolled_back: { bg: 'bg-red-50 dark:bg-red-950', text: 'text-red-700 dark:text-red-300', dot: 'bg-red-500' },
|
||||
removing: { bg: 'bg-amber-50 dark:bg-amber-950', text: 'text-amber-700 dark:text-amber-300', dot: 'bg-amber-500' },
|
||||
pending: { bg: 'bg-blue-50 dark:bg-blue-950', text: 'text-blue-700 dark:text-blue-300', dot: 'bg-blue-500' },
|
||||
pulling: { bg: 'bg-blue-50 dark:bg-blue-950', text: 'text-blue-700 dark:text-blue-300', dot: 'bg-blue-500' },
|
||||
starting: { bg: 'bg-amber-50 dark:bg-amber-950', text: 'text-amber-700 dark:text-amber-300', dot: 'bg-amber-500' },
|
||||
configuring_proxy: { bg: 'bg-amber-50 dark:bg-amber-950', text: 'text-amber-700 dark:text-amber-300', dot: 'bg-amber-500' },
|
||||
health_checking: { bg: 'bg-violet-50 dark:bg-violet-950', text: 'text-violet-700 dark:text-violet-300', dot: 'bg-violet-500' }
|
||||
const variantMap: Record<string, string> = {
|
||||
running: 'is-running',
|
||||
success: 'is-running',
|
||||
stopped: '',
|
||||
failed: 'is-fail',
|
||||
rolled_back: 'is-fail',
|
||||
removing: 'is-warn',
|
||||
pending: 'is-info',
|
||||
pulling: 'is-info',
|
||||
starting: 'is-warn',
|
||||
configuring_proxy: 'is-warn',
|
||||
health_checking: 'is-info',
|
||||
deploying: 'is-info'
|
||||
};
|
||||
|
||||
const fallback = { bg: 'bg-gray-100 dark:bg-gray-800', text: 'text-gray-700 dark:text-gray-300', dot: 'bg-gray-400' };
|
||||
|
||||
const colors = $derived(colorMap[status] ?? fallback);
|
||||
const sizeClass = $derived(size === 'sm' ? 'text-xs px-2 py-0.5' : 'text-sm px-2.5 py-0.5');
|
||||
const dotSize = $derived(size === 'sm' ? 'h-1.5 w-1.5' : 'h-2 w-2');
|
||||
const label = $derived(status.replace(/_/g, ' '));
|
||||
const isAnimated = $derived(status === 'running' || status === 'pulling' || status === 'starting' || status === 'health_checking');
|
||||
const variant = $derived(variantMap[status] ?? '');
|
||||
const label = $derived(status.replace(/_/g, ' ').toUpperCase());
|
||||
</script>
|
||||
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full font-medium {colors.bg} {colors.text} {sizeClass}">
|
||||
<span class="relative flex {dotSize}">
|
||||
{#if isAnimated}
|
||||
<span class="absolute inline-flex h-full w-full animate-ping rounded-full {colors.dot} opacity-50"></span>
|
||||
{/if}
|
||||
<span class="relative inline-flex rounded-full {dotSize} {colors.dot}"></span>
|
||||
</span>
|
||||
<span class="forge-pill {variant}" class:sm={size === 'sm'}>
|
||||
<span class="pulse"></span>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.forge-pill.sm {
|
||||
padding: 0.15rem 0.45rem;
|
||||
font-size: 0.58rem;
|
||||
}
|
||||
.forge-pill.sm .pulse { width: 5px; height: 5px; }
|
||||
</style>
|
||||
|
||||
@@ -890,5 +890,127 @@
|
||||
"language": {
|
||||
"en": "English",
|
||||
"ru": "Russian"
|
||||
},
|
||||
"stacks": {
|
||||
"eyebrow": "THE FORGE",
|
||||
"title": "Stacks",
|
||||
"lede": "Compose blueprints, forged as <em>atomic units</em>. Spin up services, iterate on revisions, roll back without breaking a sweat.",
|
||||
"newStack": "New stack",
|
||||
"refresh": "Refresh",
|
||||
"total": "Total",
|
||||
"running": "Running",
|
||||
"deploying": "Forging",
|
||||
"failed": "Failed",
|
||||
"stopped": "Cold",
|
||||
"empty": {
|
||||
"title": "The anvil is cold.",
|
||||
"desc": "Upload a docker-compose.yml to forge your first stack."
|
||||
},
|
||||
"card": {
|
||||
"noDescription": "No description",
|
||||
"updated": "Updated",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"delete": "Delete",
|
||||
"open": "Open"
|
||||
},
|
||||
"new": {
|
||||
"eyebrow": "NEW BLUEPRINT",
|
||||
"title": "Forge a new stack.",
|
||||
"lede": "Upload or paste a <code>docker-compose.yml</code>. All services in the blueprint deploy as a single atomic unit.",
|
||||
"back": "Stacks",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "my-app-stack",
|
||||
"nameHint": "Lowercase, hyphenated. Used as the compose project name.",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "What does this stack do?",
|
||||
"composeYaml": "Compose YAML",
|
||||
"required": "required",
|
||||
"optional": "optional",
|
||||
"loadSample": "Load sample",
|
||||
"uploadFile": "Upload file",
|
||||
"dropHere": "Drop a docker-compose.yml here",
|
||||
"dropSub": "or click to browse · or use <strong>Load sample</strong> above",
|
||||
"lines": "{n} lines",
|
||||
"bytes": "{n} bytes",
|
||||
"clear": "Clear",
|
||||
"deployImmediate": "Deploy immediately",
|
||||
"deployHint": "Strike while the iron's hot. If unchecked, the stack is saved cold.",
|
||||
"cancel": "Cancel",
|
||||
"forging": "Forging…",
|
||||
"forgeAndDeploy": "Forge & deploy",
|
||||
"saveBlueprint": "Save blueprint",
|
||||
"errorRequired": "Name and compose YAML are required.",
|
||||
"errorCreate": "Failed to create stack"
|
||||
},
|
||||
"detail": {
|
||||
"manifest": "MANIFEST",
|
||||
"loading": "Loading blueprint…",
|
||||
"composeProject": "COMPOSE PROJECT",
|
||||
"noDescription": "No description",
|
||||
"refresh": "Refresh",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"delete": "Delete",
|
||||
"fault": "FAULT",
|
||||
"err": "ERR",
|
||||
"stats": {
|
||||
"services": "Services",
|
||||
"servicesSub": "in blueprint",
|
||||
"running": "Running",
|
||||
"runningSub": "active containers",
|
||||
"revisions": "Revisions",
|
||||
"revisionsSub": "in history",
|
||||
"current": "Current",
|
||||
"currentSub": "deployed"
|
||||
},
|
||||
"services": {
|
||||
"title": "Services",
|
||||
"count": "{n} on the floor",
|
||||
"empty": "— no containers running —"
|
||||
},
|
||||
"tabs": {
|
||||
"blueprint": "Blueprint",
|
||||
"revisions": "Revisions",
|
||||
"logs": "Logs"
|
||||
},
|
||||
"yaml": {
|
||||
"currentRevision": "Current revision",
|
||||
"edit": "Edit & redeploy",
|
||||
"cancel": "Cancel",
|
||||
"forging": "Forging…",
|
||||
"deployNew": "Deploy new revision"
|
||||
},
|
||||
"revisions": {
|
||||
"current": "CURRENT",
|
||||
"by": "by",
|
||||
"rollback": "← Rollback to this revision",
|
||||
"rollbackTitle": "Rollback to revision?",
|
||||
"rollbackMessage": "Create a new revision from rev {n} and redeploy the stack.",
|
||||
"rollbackConfirm": "Rollback"
|
||||
},
|
||||
"logs": {
|
||||
"service": "Service:",
|
||||
"allServices": "All services",
|
||||
"fetching": "Fetching…",
|
||||
"fetch": "Fetch logs",
|
||||
"empty": "— no logs loaded. tap fetch. —"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Delete stack?",
|
||||
"messageBase": "This runs 'docker compose down' and removes \"{name}\".",
|
||||
"messageVolumes": " Named volumes will also be removed.",
|
||||
"confirm": "Delete"
|
||||
},
|
||||
"errors": {
|
||||
"load": "Failed to load stack",
|
||||
"stop": "Stop failed",
|
||||
"start": "Start failed",
|
||||
"update": "Update failed",
|
||||
"rollback": "Rollback failed",
|
||||
"delete": "Delete failed",
|
||||
"fetchLogs": "Failed to load logs"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -890,5 +890,127 @@
|
||||
"language": {
|
||||
"en": "Английский",
|
||||
"ru": "Русский"
|
||||
},
|
||||
"stacks": {
|
||||
"eyebrow": "КУЗНИЦА",
|
||||
"title": "Стеки",
|
||||
"lede": "Compose-чертежи, выкованные как <em>атомарные единицы</em>. Запускайте сервисы, меняйте ревизии и откатывайтесь без нервов.",
|
||||
"newStack": "Новый стек",
|
||||
"refresh": "Обновить",
|
||||
"total": "Всего",
|
||||
"running": "Работают",
|
||||
"deploying": "Куются",
|
||||
"failed": "Сбой",
|
||||
"stopped": "Холодные",
|
||||
"empty": {
|
||||
"title": "Наковальня остыла.",
|
||||
"desc": "Загрузите docker-compose.yml, чтобы выковать первый стек."
|
||||
},
|
||||
"card": {
|
||||
"noDescription": "Без описания",
|
||||
"updated": "Обновлён",
|
||||
"start": "Запустить",
|
||||
"stop": "Остановить",
|
||||
"delete": "Удалить",
|
||||
"open": "Открыть"
|
||||
},
|
||||
"new": {
|
||||
"eyebrow": "НОВЫЙ ЧЕРТЁЖ",
|
||||
"title": "Выковать новый стек.",
|
||||
"lede": "Загрузите или вставьте <code>docker-compose.yml</code>. Все сервисы чертежа разворачиваются как одна атомарная единица.",
|
||||
"back": "Стеки",
|
||||
"name": "Имя",
|
||||
"namePlaceholder": "мой-стек",
|
||||
"nameHint": "Строчные буквы, через дефис. Используется как имя compose-проекта.",
|
||||
"description": "Описание",
|
||||
"descriptionPlaceholder": "Что делает этот стек?",
|
||||
"composeYaml": "Compose YAML",
|
||||
"required": "обязательно",
|
||||
"optional": "необязательно",
|
||||
"loadSample": "Загрузить пример",
|
||||
"uploadFile": "Загрузить файл",
|
||||
"dropHere": "Перетащите сюда docker-compose.yml",
|
||||
"dropSub": "или нажмите для выбора · или используйте <strong>Загрузить пример</strong> выше",
|
||||
"lines": "{n} строк",
|
||||
"bytes": "{n} байт",
|
||||
"clear": "Очистить",
|
||||
"deployImmediate": "Развернуть сразу",
|
||||
"deployHint": "Куй железо, пока горячо. Без галочки стек сохраняется холодным.",
|
||||
"cancel": "Отмена",
|
||||
"forging": "Куём…",
|
||||
"forgeAndDeploy": "Выковать и развернуть",
|
||||
"saveBlueprint": "Сохранить чертёж",
|
||||
"errorRequired": "Имя и compose YAML обязательны.",
|
||||
"errorCreate": "Не удалось создать стек"
|
||||
},
|
||||
"detail": {
|
||||
"manifest": "МАНИФЕСТ",
|
||||
"loading": "Загрузка чертежа…",
|
||||
"composeProject": "COMPOSE-ПРОЕКТ",
|
||||
"noDescription": "Без описания",
|
||||
"refresh": "Обновить",
|
||||
"start": "Запустить",
|
||||
"stop": "Остановить",
|
||||
"delete": "Удалить",
|
||||
"fault": "СБОЙ",
|
||||
"err": "ОШБ",
|
||||
"stats": {
|
||||
"services": "Сервисы",
|
||||
"servicesSub": "в чертеже",
|
||||
"running": "Работают",
|
||||
"runningSub": "активных контейнеров",
|
||||
"revisions": "Ревизии",
|
||||
"revisionsSub": "в истории",
|
||||
"current": "Текущая",
|
||||
"currentSub": "развёрнута"
|
||||
},
|
||||
"services": {
|
||||
"title": "Сервисы",
|
||||
"count": "{n} в работе",
|
||||
"empty": "— нет запущенных контейнеров —"
|
||||
},
|
||||
"tabs": {
|
||||
"blueprint": "Чертёж",
|
||||
"revisions": "Ревизии",
|
||||
"logs": "Логи"
|
||||
},
|
||||
"yaml": {
|
||||
"currentRevision": "Текущая ревизия",
|
||||
"edit": "Править и развернуть",
|
||||
"cancel": "Отмена",
|
||||
"forging": "Куём…",
|
||||
"deployNew": "Развернуть новую ревизию"
|
||||
},
|
||||
"revisions": {
|
||||
"current": "ТЕКУЩАЯ",
|
||||
"by": "автор",
|
||||
"rollback": "← Откатиться к этой ревизии",
|
||||
"rollbackTitle": "Откатить ревизию?",
|
||||
"rollbackMessage": "Создать новую ревизию из rev {n} и развернуть стек заново.",
|
||||
"rollbackConfirm": "Откатить"
|
||||
},
|
||||
"logs": {
|
||||
"service": "Сервис:",
|
||||
"allServices": "Все сервисы",
|
||||
"fetching": "Загрузка…",
|
||||
"fetch": "Получить логи",
|
||||
"empty": "— логи не загружены. нажмите получить. —"
|
||||
},
|
||||
"delete": {
|
||||
"title": "Удалить стек?",
|
||||
"messageBase": "Будет выполнен 'docker compose down' и удалён \"{name}\".",
|
||||
"messageVolumes": " Именованные тома также будут удалены.",
|
||||
"confirm": "Удалить"
|
||||
},
|
||||
"errors": {
|
||||
"load": "Не удалось загрузить стек",
|
||||
"stop": "Остановка не удалась",
|
||||
"start": "Запуск не удался",
|
||||
"update": "Обновление не удалось",
|
||||
"rollback": "Откат не удался",
|
||||
"delete": "Удаление не удалось",
|
||||
"fetchLogs": "Не удалось загрузить логи"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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