From 0fd92fdfa34775e6501f4da5e3bb06b53140bb66 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 16 Apr 2026 04:17:42 +0300 Subject: [PATCH] feat: Forge design system app-wide + Stacks i18n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- web/package-lock.json | 14 + web/package.json | 1 + web/src/app.css | 419 +++++++++++++++++++- web/src/lib/components/ConfirmDialog.svelte | 101 ++++- web/src/lib/components/EmptyState.svelte | 104 ++--- web/src/lib/components/ForgeHero.svelte | 72 ++++ web/src/lib/components/StatusBadge.svelte | 54 ++- web/src/lib/i18n/en.json | 122 ++++++ web/src/lib/i18n/ru.json | 122 ++++++ web/src/routes/+layout.svelte | 93 ++++- web/src/routes/+page.svelte | 178 +++++---- web/src/routes/stacks/+page.svelte | 107 +++-- web/src/routes/stacks/[id]/+page.svelte | 126 +++--- web/src/routes/stacks/new/+page.svelte | 81 ++-- 14 files changed, 1251 insertions(+), 343 deletions(-) create mode 100644 web/src/lib/components/ForgeHero.svelte diff --git a/web/package-lock.json b/web/package-lock.json index 9b24bdb..f039c4c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 9c0d5aa..26a9431 100644 --- a/web/package.json +++ b/web/package.json @@ -22,6 +22,7 @@ "type": "module", "dependencies": { "@fontsource/instrument-serif": "^5.2.8", + "@fontsource/inter": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8" } } diff --git a/web/src/app.css b/web/src/app.css index c42057b..6e4df99 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -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; +} diff --git a/web/src/lib/components/ConfirmDialog.svelte b/web/src/lib/components/ConfirmDialog.svelte index 9819fe7..36fd872 100644 --- a/web/src/lib/components/ConfirmDialog.svelte +++ b/web/src/lib/components/ConfirmDialog.svelte @@ -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 @@
-
+
+ + + + +
-

{title}

-

{message}

+

{title}

+

{message}

-
-
{/if} + + diff --git a/web/src/lib/components/EmptyState.svelte b/web/src/lib/components/EmptyState.svelte index 2704215..eb97666 100644 --- a/web/src/lib/components/EmptyState.svelte +++ b/web/src/lib/components/EmptyState.svelte @@ -1,6 +1,3 @@ - -
- -
- {#if icon === 'projects'} - - - - - {:else if icon === 'instances'} - - - - {:else if icon === 'deploys'} - - - - {:else if icon === 'registries'} - - - - {:else if icon === 'volumes'} - - - - {:else if icon === 'users'} - - - - {/if} +
+
+
-

{title}

+

{title}

{#if description} -

{description}

+

{description}

{/if} {#if actionLabel} {#if actionHref} - - + + - {actionLabel} + {actionLabel} {:else if onaction} - {/if} {/if}
+ + diff --git a/web/src/lib/components/ForgeHero.svelte b/web/src/lib/components/ForgeHero.svelte new file mode 100644 index 0000000..d3a592e --- /dev/null +++ b/web/src/lib/components/ForgeHero.svelte @@ -0,0 +1,72 @@ + + +
+
+ + + {eyebrow} + {#if eyebrowSuffix} + // + {eyebrowSuffix} + {/if} + + {#if toolbar} +
{@render toolbar()}
+ {/if} +
+ +

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

+ + {#if lede_html} +

{@render lede_html()}

+ {:else if lede} +

{lede}

+ {/if} +
+ + diff --git a/web/src/lib/components/StatusBadge.svelte b/web/src/lib/components/StatusBadge.svelte index 90e6140..6ab97b3 100644 --- a/web/src/lib/components/StatusBadge.svelte +++ b/web/src/lib/components/StatusBadge.svelte @@ -1,6 +1,3 @@ - - - - {#if isAnimated} - - {/if} - - + + {label} + + diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 7a2b73d..8e48f40 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -890,5 +890,127 @@ "language": { "en": "English", "ru": "Russian" + }, + "stacks": { + "eyebrow": "THE FORGE", + "title": "Stacks", + "lede": "Compose blueprints, forged as atomic units. 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 docker-compose.yml. 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 Load sample 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" + } + } } } diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 09fe1a3..cdd035b 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -890,5 +890,127 @@ "language": { "en": "Английский", "ru": "Русский" + }, + "stacks": { + "eyebrow": "КУЗНИЦА", + "title": "Стеки", + "lede": "Compose-чертежи, выкованные как атомарные единицы. Запускайте сервисы, меняйте ревизии и откатывайтесь без нервов.", + "newStack": "Новый стек", + "refresh": "Обновить", + "total": "Всего", + "running": "Работают", + "deploying": "Куются", + "failed": "Сбой", + "stopped": "Холодные", + "empty": { + "title": "Наковальня остыла.", + "desc": "Загрузите docker-compose.yml, чтобы выковать первый стек." + }, + "card": { + "noDescription": "Без описания", + "updated": "Обновлён", + "start": "Запустить", + "stop": "Остановить", + "delete": "Удалить", + "open": "Открыть" + }, + "new": { + "eyebrow": "НОВЫЙ ЧЕРТЁЖ", + "title": "Выковать новый стек.", + "lede": "Загрузите или вставьте docker-compose.yml. Все сервисы чертежа разворачиваются как одна атомарная единица.", + "back": "Стеки", + "name": "Имя", + "namePlaceholder": "мой-стек", + "nameHint": "Строчные буквы, через дефис. Используется как имя compose-проекта.", + "description": "Описание", + "descriptionPlaceholder": "Что делает этот стек?", + "composeYaml": "Compose YAML", + "required": "обязательно", + "optional": "необязательно", + "loadSample": "Загрузить пример", + "uploadFile": "Загрузить файл", + "dropHere": "Перетащите сюда docker-compose.yml", + "dropSub": "или нажмите для выбора · или используйте Загрузить пример выше", + "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": "Не удалось загрузить логи" + } + } } } diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index d57af58..25f6aba 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -129,13 +129,9 @@ {sidebarOpen ? 'translate-x-0' : '-translate-x-full'}" > -
-
- - - -
- {$t('app.name')} +
+ + {$t('app.name')} -
- - - -
- {$t('app.name')} + + {$t('app.name')} @@ -295,3 +285,72 @@ {/if} + + diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 3ef6224..87e89d3 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -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([]); @@ -113,73 +114,53 @@ {$t('dashboard.title')} - {$t('app.name')} -
- -
-

{$t('dashboard.title')}

- - - {$t('dashboard.quickDeploy')} + + {/snippet} + - -
-
-
- -
-
-

{$t('dashboard.totalProjects')}

-

{totalProjects}

-
+ +
+
+ {$t('dashboard.totalProjects')} + {String(totalProjects).padStart(2, '0')} + active
-
-
- -
-
-

{$t('dashboard.runningInstances')}

-

{totalRunning}

-
+
+ {$t('dashboard.runningInstances')} + {String(totalRunning).padStart(2, '0')} + instances
- @@ -202,12 +183,12 @@ {#if !loading} -
-
-

{$t('dashboard.staticSites')}

+
+
+

{$t('dashboard.staticSites')}.

{#if sites.length > 0} - - {$t('dashboard.viewAllSites')} → + + {$t('dashboard.viewAllSites')} {/if}
@@ -244,12 +225,12 @@ {/each}
{/if} -
+ {/if} -
-

{$t('dashboard.projects')}

+
+

{$t('dashboard.projects')}.

{#if loading}
@@ -285,5 +266,60 @@ {/each}
{/if} -
+
+ + diff --git a/web/src/routes/stacks/+page.svelte b/web/src/routes/stacks/+page.svelte index 23555c6..e481d22 100644 --- a/web/src/routes/stacks/+page.svelte +++ b/web/src/routes/stacks/+page.svelte @@ -4,6 +4,7 @@ import * as api from '$lib/api'; import { IconPlus, IconRefresh, IconTrash, IconPlay, IconStop } from '$lib/components/icons'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; + import { t } from '$lib/i18n'; let stacks = $state([]); 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 @@
- THE FORGE + {$t('stacks.eyebrow')} // - STACKS + {$t('stacks.title').toUpperCase()}

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

-

- Compose blueprints, forged as atomic units. - Spin up services, iterate on revisions, roll back without breaking a sweat. -

+

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

-
TOTAL
{loading ? '—' : String(stacks.length).padStart(2, '0')}
-
RUNNING
{loading ? '—' : stacks.filter(s=>s.status==='running').length}
-
FORGING
{loading ? '—' : stacks.filter(s=>s.status==='deploying').length}
-
FAILED
s.status==='failed')}>{loading ? '—' : stacks.filter(s=>s.status==='failed').length}
+
{$t('stacks.total').toUpperCase()}
{loading ? '—' : String(stacks.length).padStart(2, '0')}
+
{$t('stacks.running').toUpperCase()}
{loading ? '—' : stacks.filter(s=>s.status==='running').length}
+
{$t('stacks.deploying').toUpperCase()}
{loading ? '—' : stacks.filter(s=>s.status==='deploying').length}
+
{$t('stacks.failed').toUpperCase()}
s.status==='failed')}>{loading ? '—' : stacks.filter(s=>s.status==='failed').length}
@@ -105,10 +103,10 @@
-

The anvil is cold.

-

Upload a docker-compose.yml to forge your first stack.

+

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

+

{$t('stacks.empty.desc')}

- New stack + {$t('stacks.newStack')}
{:else} @@ -133,7 +131,7 @@ {#if s.description}

{s.description}

{:else} -

No description

+

{$t('stacks.card.noDescription')}

{/if} {#if s.error} @@ -141,24 +139,24 @@ {/if}
- Updated + {$t('stacks.card.updated')} {fmtTime(s.updated_at)}
{#if s.status === 'running'} - {:else} - {/if} - - Open + {$t('stacks.card.open')}
{/each} @@ -168,9 +166,9 @@ { confirmDelete = null; deleteRemoveVolumes = false; }} @@ -178,7 +176,7 @@