diff --git a/web/src/lib/components/Breadcrumb.svelte b/web/src/lib/components/Breadcrumb.svelte new file mode 100644 index 0000000..c2c1276 --- /dev/null +++ b/web/src/lib/components/Breadcrumb.svelte @@ -0,0 +1,27 @@ + + + diff --git a/web/src/lib/components/icons/IconArrowLeft.svelte b/web/src/lib/components/icons/IconArrowLeft.svelte new file mode 100644 index 0000000..5da1936 --- /dev/null +++ b/web/src/lib/components/icons/IconArrowLeft.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/IconChevronDown.svelte b/web/src/lib/components/icons/IconChevronDown.svelte new file mode 100644 index 0000000..cbb52a8 --- /dev/null +++ b/web/src/lib/components/icons/IconChevronDown.svelte @@ -0,0 +1,7 @@ + + diff --git a/web/src/lib/components/icons/index.ts b/web/src/lib/components/icons/index.ts index 607706c..0bde807 100644 --- a/web/src/lib/components/icons/index.ts +++ b/web/src/lib/components/icons/index.ts @@ -48,3 +48,5 @@ export { default as IconRefresh } from './IconRefresh.svelte'; export { default as IconProxies } from './IconProxies.svelte'; export { default as IconEvents } from './IconEvents.svelte'; export { default as IconLogout } from './IconLogout.svelte'; +export { default as IconArrowLeft } from './IconArrowLeft.svelte'; +export { default as IconChevronDown } from './IconChevronDown.svelte'; diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 9fcbafe..f52815e 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -58,7 +58,9 @@ "imageLoadFailed": "Failed to load images", "alreadyAdded": "Already added", "portHelpText": "Auto-detected from EXPOSE if empty", - "healthcheckHelpText": "Auto-detected from image if empty" + "healthcheckHelpText": "Auto-detected from image if empty", + "searchPlaceholder": "Search projects by name, image, or registry...", + "noMatchingProjects": "No projects match your search." }, "projectDetail": { "deleteProject": "Delete Project", @@ -162,6 +164,7 @@ "save": "Save", "add": "Add", "adding": "Adding...", + "scopeGuide": "Volume Scopes", "noVolumes": "No volumes configured yet. Add one above.", "volumeAdded": "Volume added", "volumeUpdated": "Volume updated", @@ -258,6 +261,11 @@ "dockerNetworkHelp": "Docker network for deployed containers", "subdomainPattern": "Subdomain Pattern", "subdomainPatternHelp": "Pattern for auto-generated subdomains", + "subdomainVarsTitle": "Available variables", + "varProject": "Project name", + "varStage": "Stage name", + "varTag": "Image tag", + "varPort": "Container port", "pollingInterval": "Polling Interval (seconds)", "pollingIntervalHelp": "How often to check registries for new tags (10-86400)", "notificationUrl": "Notification URL", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 153d2e2..0f0cadf 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -58,7 +58,9 @@ "imageLoadFailed": "Не удалось загрузить образы", "alreadyAdded": "Уже добавлен", "portHelpText": "Автоопределение из EXPOSE, если пусто", - "healthcheckHelpText": "Автоопределение из образа, если пусто" + "healthcheckHelpText": "Автоопределение из образа, если пусто", + "searchPlaceholder": "Поиск по имени, образу или реестру...", + "noMatchingProjects": "Проекты не найдены." }, "projectDetail": { "deleteProject": "Удалить проект", @@ -162,6 +164,7 @@ "save": "Сохранить", "add": "Добавить", "adding": "Добавление...", + "scopeGuide": "Области видимости томов", "noVolumes": "Тома ещё не настроены. Добавьте один выше.", "volumeAdded": "Том добавлен", "volumeUpdated": "Том обновлён", @@ -258,6 +261,11 @@ "dockerNetworkHelp": "Docker-сеть для развёрнутых контейнеров", "subdomainPattern": "Шаблон поддомена", "subdomainPatternHelp": "Шаблон для автоматически генерируемых поддоменов", + "subdomainVarsTitle": "Доступные переменные", + "varProject": "Имя проекта", + "varStage": "Имя стадии", + "varTag": "Тег образа", + "varPort": "Порт контейнера", "pollingInterval": "Интервал опроса (секунды)", "pollingIntervalHelp": "Как часто проверять реестры на новые теги (10-86400)", "notificationUrl": "URL уведомлений", diff --git a/web/src/lib/styles/tokens.css b/web/src/lib/styles/tokens.css index 229b666..3922ee1 100644 --- a/web/src/lib/styles/tokens.css +++ b/web/src/lib/styles/tokens.css @@ -210,6 +210,13 @@ animation: button-press 150ms ease-in-out; } +/* ── Disabled Buttons ────────────────────────────────────────────── */ + +button:disabled, +a[aria-disabled="true"] { + cursor: not-allowed; +} + /* ── Skeleton Loader ──────────────────────────────────────────────── */ .skeleton { @@ -226,6 +233,32 @@ /* ── Toggle Switch ────────────────────────────────────────────────── */ +/* ── Badge Tokens ────────────────────────────────────────────────── */ + +.badge-success { background: #ecfdf5; color: #047857; } +.badge-warning { background: #fffbeb; color: #b45309; } +.badge-danger { background: #fef2f2; color: #dc2626; } +.badge-info { background: #eff6ff; color: #2563eb; } +.badge-purple { background: #faf5ff; color: #7c3aed; } +.badge-cyan { background: #ecfeff; color: #0e7490; } +.badge-gray { background: #f3f4f6; color: #4b5563; } +.badge-amber { background: #fffbeb; color: #b45309; } +.badge-indigo { background: #eef2ff; color: #4f46e5; } +.badge-rose { background: #fff1f2; color: #e11d48; } + +[data-theme="dark"] .badge-success { background: rgba(6, 78, 59, 0.3); color: #34d399; } +[data-theme="dark"] .badge-warning { background: rgba(120, 53, 15, 0.3); color: #fbbf24; } +[data-theme="dark"] .badge-danger { background: rgba(127, 29, 29, 0.3); color: #f87171; } +[data-theme="dark"] .badge-info { background: rgba(30, 58, 138, 0.3); color: #60a5fa; } +[data-theme="dark"] .badge-purple { background: rgba(76, 29, 149, 0.3); color: #a78bfa; } +[data-theme="dark"] .badge-cyan { background: rgba(14, 116, 144, 0.3); color: #22d3ee; } +[data-theme="dark"] .badge-gray { background: rgba(55, 65, 81, 0.5); color: #9ca3af; } +[data-theme="dark"] .badge-amber { background: rgba(120, 53, 15, 0.3); color: #fbbf24; } +[data-theme="dark"] .badge-indigo { background: rgba(67, 56, 202, 0.3); color: #818cf8; } +[data-theme="dark"] .badge-rose { background: rgba(159, 18, 57, 0.3); color: #fb7185; } + +/* ── Toggle Switch ────────────────────────────────────────────────── */ + .toggle-switch { position: relative; width: 2.75rem; diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 40ab99b..89c681a 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -6,7 +6,7 @@ import Toast from '$lib/components/Toast.svelte'; import ThemeToggle from '$lib/components/ThemeToggle.svelte'; import LocaleSwitcher from '$lib/components/LocaleSwitcher.svelte'; - import { IconDashboard, IconProjects, IconDeploy, IconSettings, IconMenu, IconX, IconLogout } from '$lib/components/icons'; + import { IconDashboard, IconProjects, IconDeploy, IconSettings, IconMenu, IconX, IconLogout, IconChevronDown } from '$lib/components/icons'; import { goto } from '$app/navigation'; import { connectGlobalEvents, type SSEConnection } from '$lib/sse'; import { instanceStatusStore } from '$lib/stores/instance-status'; @@ -142,24 +142,9 @@ {$t('app.name')} - - - {#if !dockerConnected && hintsExpanded && dockerHealth?.error} @@ -240,6 +225,19 @@
+

{$t('app.name')} {$t('app.version')}

diff --git a/web/src/routes/dns/+page.svelte b/web/src/routes/dns/+page.svelte index 35fa96c..92760bd 100644 --- a/web/src/routes/dns/+page.svelte +++ b/web/src/routes/dns/+page.svelte @@ -77,11 +77,11 @@ function statusColor(status: string): string { switch (status) { - case 'synced': return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'; - case 'missing': return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'; - case 'orphaned': return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'; - case 'wildcard': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'; - default: return 'bg-gray-100 text-gray-700'; + case 'synced': return 'badge-success'; + case 'missing': return 'badge-danger'; + case 'orphaned': return 'badge-warning'; + case 'wildcard': return 'badge-info'; + default: return 'badge-gray'; } } diff --git a/web/src/routes/events/+page.svelte b/web/src/routes/events/+page.svelte index 0173c3c..4ed797d 100644 --- a/web/src/routes/events/+page.svelte +++ b/web/src/routes/events/+page.svelte @@ -11,6 +11,7 @@ import EventLogEntryComponent from '$lib/components/EventLogEntry.svelte'; import EventLogFilter from '$lib/components/EventLogFilter.svelte'; import EmptyState from '$lib/components/EmptyState.svelte'; + import { IconLoader } from '$lib/components/icons'; // ── State ───────────────────────────────────────────────────── @@ -244,10 +245,7 @@ {#if loading}
- - - - +
{:else if filteredEvents.length === 0} {#if loadingMore} - - - - + {/if} {$t('events.loadMore')} diff --git a/web/src/routes/projects/+page.svelte b/web/src/routes/projects/+page.svelte index 068671b..e8900d2 100644 --- a/web/src/routes/projects/+page.svelte +++ b/web/src/routes/projects/+page.svelte @@ -12,6 +12,18 @@ let loading = $state(true); let error = $state(''); let showAddForm = $state(false); + let searchQuery = $state(''); + + const filteredProjects = $derived( + searchQuery.trim() + ? projects.filter(p => { + const q = searchQuery.toLowerCase(); + return p.name.toLowerCase().includes(q) + || p.image.toLowerCase().includes(q) + || (p.registry ?? '').toLowerCase().includes(q); + }) + : projects + ); let formName = $state(''); let formImage = $state(''); @@ -220,6 +232,22 @@ icon="projects" /> {:else} + +
+ + +
+ + {#if filteredProjects.length === 0} +
+

{$t('projects.noMatchingProjects')}

+
+ {:else}
@@ -233,7 +261,7 @@ - {#each projects as project (project.id)} + {#each filteredProjects as project (project.id)}
@@ -262,5 +290,6 @@
+ {/if} {/if} diff --git a/web/src/routes/projects/[id]/+page.svelte b/web/src/routes/projects/[id]/+page.svelte index 675e8d8..0c6c015 100644 --- a/web/src/routes/projects/[id]/+page.svelte +++ b/web/src/routes/projects/[id]/+page.svelte @@ -7,7 +7,8 @@ import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import EmptyState from '$lib/components/EmptyState.svelte'; import Skeleton from '$lib/components/Skeleton.svelte'; - import { IconTrash, IconKey, IconHardDrive, IconDeploy, IconChevronRight, IconClock, IconTag, IconLoader, IconPlus, IconEdit, IconCheck, IconX } from '$lib/components/icons'; + import { IconTrash, IconKey, IconHardDrive, IconDeploy, IconClock, IconLoader, IconPlus, IconEdit, IconCheck, IconX } from '$lib/components/icons'; + import Breadcrumb from '$lib/components/Breadcrumb.svelte'; import FormField from '$lib/components/FormField.svelte'; import ToggleSwitch from '$lib/components/ToggleSwitch.svelte'; import { toasts } from '$lib/stores/toast'; @@ -111,7 +112,7 @@ const projectId = $derived($page.params.id); async function loadProject() { - loading = true; + if (!project) loading = true; error = ''; try { const detail = await api.getProject(projectId); @@ -229,10 +230,7 @@
- +

{project.name}

{project.image}

@@ -385,13 +383,13 @@

{stage.name}

{stage.tag_pattern} {#if stage.auto_deploy} - {$t('projectDetail.autoDeploy')} + {$t('projectDetail.autoDeploy')} {/if} {#if stage.confirm} - {$t('projectDetail.requiresConfirm')} + {$t('projectDetail.requiresConfirm')} {/if} {#if !stage.enable_proxy} - {$t('projectDetail.noProxy')} + {$t('projectDetail.noProxy')} {/if}
diff --git a/web/src/routes/projects/[id]/env/+page.svelte b/web/src/routes/projects/[id]/env/+page.svelte index 7ee5b8c..5fa2a11 100644 --- a/web/src/routes/projects/[id]/env/+page.svelte +++ b/web/src/routes/projects/[id]/env/+page.svelte @@ -4,7 +4,8 @@ import * as api from '$lib/api'; import { toasts } from '$lib/stores/toast'; import { t } from '$lib/i18n'; - import { IconChevronRight, IconPlus, IconEdit, IconTrash, IconCheck, IconX, IconLock, IconLoader } from '$lib/components/icons'; + import { IconPlus, IconEdit, IconTrash, IconCheck, IconX, IconLock, IconLoader } from '$lib/components/icons'; + import Breadcrumb from '$lib/components/Breadcrumb.svelte'; import ToggleSwitch from '$lib/components/ToggleSwitch.svelte'; import Skeleton from '$lib/components/Skeleton.svelte'; import EmptyState from '$lib/components/EmptyState.svelte'; @@ -33,7 +34,7 @@ const projectId = $derived($page.params.id); async function loadProject() { - loading = true; + if (stages.length === 0) loading = true; error = ''; try { const detail = await api.getProject(projectId); @@ -153,10 +154,7 @@
- +

{$t('envEditor.title')}

{$t('envEditor.description')}

@@ -208,9 +206,9 @@ {value} {#if isOverridden(key)} - {$t('envEditor.overridden')} + {$t('envEditor.overridden')} {:else} - {$t('envEditor.inherited')} + {$t('envEditor.inherited')} {/if} @@ -275,7 +273,7 @@ {#if env.encrypted} - + {$t('envEditor.secret')} @@ -283,9 +281,9 @@ {#if env.key in projectEnv} - {$t('envEditor.overridesProject')} + {$t('envEditor.overridesProject')} {:else} - {$t('envEditor.stageOnly')} + {$t('envEditor.stageOnly')} {/if} diff --git a/web/src/routes/projects/[id]/volumes/+page.svelte b/web/src/routes/projects/[id]/volumes/+page.svelte index f09ffd6..280eaf7 100644 --- a/web/src/routes/projects/[id]/volumes/+page.svelte +++ b/web/src/routes/projects/[id]/volumes/+page.svelte @@ -1,37 +1,67 @@ @@ -68,6 +138,7 @@
{:else} {#if proxyProvider === 'npm'} +
@@ -116,6 +187,43 @@
{/if}
+ + +
+
+
+

{$t('settingsGeneral.sslCertificate')}

+

{$t('settingsGeneral.sslCertificateHelp')}

+
+ + {#if sslCertificateId > 0} + + {/if} +
+
+
+
{/if}
@@ -128,3 +236,12 @@
{/if}
+ + { certPickerOpen = false; }} +/> diff --git a/web/src/routes/settings/registries/+page.svelte b/web/src/routes/settings/registries/+page.svelte index 900b516..e3f3aed 100644 --- a/web/src/routes/settings/registries/+page.svelte +++ b/web/src/routes/settings/registries/+page.svelte @@ -175,8 +175,8 @@ {#if testingId === registry.id}{:else}{/if} {testingId === registry.id ? $t('settingsRegistries.testing') : $t('settingsRegistries.test')} - - + +
{/each}