feat: project detail UX improvements

- Tag picker: replace raw text input with EntityPicker modal showing
  registry tags (auto-detected by image hostname) and local images.
  Fix URL encoding bug where encodeURIComponent encoded slashes in
  image paths, causing 502 on registry tag API.
- Stage editing: inline edit form for name, tag pattern, max instances,
  CPU/memory limits, auto-deploy and proxy toggles.
- Stage delete: use ConfirmDialog modal instead of window.confirm().
  Immediately remove stage from local state after deletion.
- Project-level env: add/edit/delete project env vars (stored in
  project.env JSON field). Move stage selector inline with Stage
  Overrides heading so it's clear project env is independent.
- Access list UX: rename "None (public)" to "Global default", clarify
  help text.
- Add missing i18n keys for all new UI (en + ru).
This commit is contained in:
2026-04-13 00:12:34 +03:00
parent 96fd910603
commit 9ec25a8d5a
5 changed files with 677 additions and 146 deletions
+185
View File
@@ -0,0 +1,185 @@
<!--
Inline combobox for selecting or typing a Docker image tag.
Shows filtered suggestions from registry tags and local images.
-->
<script lang="ts">
import { t } from '$lib/i18n';
interface Props {
id?: string;
value: string;
registryTags?: string[];
localTags?: string[];
placeholder?: string;
}
let {
id = '',
value = $bindable(''),
registryTags = [],
localTags = [],
placeholder = ''
}: Props = $props();
let open = $state(false);
let highlightIndex = $state(-1);
let inputEl: HTMLInputElement | undefined = $state(undefined);
let blurTimeout: ReturnType<typeof setTimeout> | null = null;
interface TagSuggestion {
tag: string;
source: 'registry' | 'local';
}
const allTags = $derived.by(() => {
const items: TagSuggestion[] = [];
const seen = new Set<string>();
for (const tag of registryTags) {
if (!seen.has(tag)) { seen.add(tag); items.push({ tag, source: 'registry' }); }
}
for (const tag of localTags) {
if (!seen.has(tag)) { seen.add(tag); items.push({ tag, source: 'local' }); }
}
return items;
});
const filtered = $derived.by(() => {
const q = value.toLowerCase().trim();
if (!q) return allTags;
return allTags.filter((s) => s.tag.toLowerCase().includes(q));
});
const hasSuggestions = $derived(allTags.length > 0);
function select(tag: string) {
value = tag;
close();
}
function close() {
open = false;
highlightIndex = -1;
}
function toggle() {
if (open) {
close();
} else if (hasSuggestions) {
open = true;
inputEl?.focus();
}
}
function cancelPendingBlur() {
if (blurTimeout) { clearTimeout(blurTimeout); blurTimeout = null; }
}
function handleFocus() {
cancelPendingBlur();
if (hasSuggestions) open = true;
}
function handleBlur() {
cancelPendingBlur();
blurTimeout = setTimeout(close, 200);
}
function handleInput() {
cancelPendingBlur();
open = true;
highlightIndex = -1;
}
function handleKeydown(e: KeyboardEvent) {
if (!open && (e.key === 'ArrowDown' || e.key === 'ArrowUp') && hasSuggestions) {
open = true;
highlightIndex = 0;
e.preventDefault();
return;
}
if (!open) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
highlightIndex = filtered.length > 0 ? (highlightIndex + 1) % filtered.length : -1;
break;
case 'ArrowUp':
e.preventDefault();
highlightIndex = filtered.length > 0 ? (highlightIndex - 1 + filtered.length) % filtered.length : -1;
break;
case 'Enter':
e.preventDefault();
if (highlightIndex >= 0 && highlightIndex < filtered.length) {
select(filtered[highlightIndex].tag);
}
break;
case 'Escape':
e.preventDefault();
close();
break;
}
}
</script>
<div class="relative mt-1">
<div class="flex">
<input
{id}
bind:this={inputEl}
bind:value
type="text"
{placeholder}
autocomplete="off"
spellcheck="false"
class="block w-full rounded-lg {hasSuggestions ? 'rounded-r-none' : ''} border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-2 focus:ring-[var(--color-brand-500)] focus:outline-none"
oninput={handleInput}
onfocus={handleFocus}
onblur={handleBlur}
onkeydown={handleKeydown}
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
/>
{#if hasSuggestions}
<button
type="button"
tabindex={-1}
class="flex items-center border border-l-0 border-[var(--border-input)] bg-[var(--surface-input)] px-2 rounded-r-lg text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] transition-colors"
onmousedown={(e) => { e.preventDefault(); toggle(); }}
>
<svg class="h-4 w-4 transition-transform {open ? 'rotate-180' : ''}" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z" clip-rule="evenodd" />
</svg>
</button>
{/if}
</div>
{#if open && filtered.length > 0}
<div
class="absolute z-[100] mt-1 max-h-48 w-full overflow-y-auto rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-xl"
role="listbox"
onmousedown={(e) => e.preventDefault()}
>
{#each filtered as item, i (item.tag + item.source)}
{@const highlighted = i === highlightIndex}
<button
type="button"
role="option"
aria-selected={highlighted}
class="flex w-full items-center justify-between px-3 py-2 text-left text-sm transition-colors
{highlighted
? 'bg-[var(--color-brand-50)] text-[var(--text-primary)]'
: 'text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)]'}"
onmousedown={() => { cancelPendingBlur(); select(item.tag); }}
onmouseenter={() => { highlightIndex = i; }}
>
<span class="truncate font-mono">{item.tag}</span>
<span class="ml-2 shrink-0 rounded bg-[var(--surface-card-hover)] px-1.5 py-0.5 text-[10px] text-[var(--text-tertiary)]">
{item.source === 'registry' ? $t('projectDetail.registryTag') : $t('projectDetail.localTag')}
</span>
</button>
{/each}
</div>
{/if}
</div>
+37 -5
View File
@@ -33,7 +33,14 @@
"loadFailed": "Failed to load dashboard",
"staleContainers": "Stale Containers",
"unusedImagesWarning": "Unused Docker images are taking up disk space",
"unusedImages": "unused images"
"unusedImages": "unused images",
"staticSites": "Static Sites",
"totalSites": "Total Sites",
"deployedSites": "deployed",
"failedSites": "failed",
"noSites": "No static sites yet.",
"addFirstSite": "Deploy your first site",
"viewAllSites": "View all sites"
},
"projects": {
"title": "Projects",
@@ -80,6 +87,11 @@
"loadingTags": "Loading tags...",
"chooseTag": "Choose a tag...",
"enterTag": "Enter image tag (e.g., dev-abc123)",
"registryTag": "Registry",
"localTag": "Local",
"alsoLocal": "Also available locally",
"searchTags": "Search tags...",
"deployTag": "Tag",
"deploy": "Deploy",
"deploying": "Deploying...",
"recentDeploys": "Recent Deploys",
@@ -107,7 +119,7 @@
"autoDeployLabel": "Auto Deploy",
"enableProxy": "Enable Proxy",
"accessListId": "NPM Access List ID",
"accessListIdHelp": "Per-project override. 0 = use global default from NPM settings.",
"accessListIdHelp": "Override the global access list for this project. Clear to inherit from NPM settings.",
"localImages": "Local Docker Images",
"imageTag": "Tag",
"imageId": "Image ID",
@@ -124,6 +136,8 @@
"deleteStage": "Delete stage",
"deleteStageConfirm": "Delete stage \"{name}\"?",
"stageCreated": "Stage \"{name}\" created",
"stageUpdated": "Stage updated",
"stageUpdateFailed": "Failed to update stage",
"stageDeleted": "Stage \"{name}\" deleted",
"projectUpdated": "Project updated",
"updateFailed": "Failed to update project",
@@ -135,6 +149,7 @@
"description": "Manage per-stage environment variable overrides. Stage-level values override project-level defaults.",
"stage": "Stage",
"projectDefaults": "Project-Level Defaults",
"noProjectEnv": "No project-level environment variables defined yet.",
"stageOverrides": "Stage Overrides",
"key": "Key",
"value": "Value",
@@ -160,7 +175,9 @@
"updateFailed": "Failed to update env var",
"deleteFailed": "Failed to delete env var",
"loadEnvFailed": "Failed to load env vars",
"leaveEmptyToKeep": "Leave empty to keep current"
"leaveEmptyToKeep": "Leave empty to keep current",
"deleteTitle": "Delete Environment Variable",
"deleteMessage": "Are you sure you want to delete this environment variable? This action cannot be undone."
},
"volumeEditor": {
"title": "Volume Mounts",
@@ -405,7 +422,7 @@
"remoteModeWarning": "Requires Server IP in General settings. Ports are auto-mapped to random host ports.",
"accessList": "Default Access List",
"accessListHelp": "NPM access list for HTTP authentication on proxy hosts. Can be overridden per project.",
"noAccessList": "None (public)",
"noAccessList": "Global default",
"selectAccessList": "Select an access list",
"noAccessLists": "No access lists found in NPM",
"accessListLoadFailed": "Failed to load access lists"
@@ -560,6 +577,8 @@
"openSite": "Open Site",
"confirmDelete": "Delete Site",
"confirmDeleteMsg": "This will permanently delete the site and remove its container",
"confirmDeleteSecret": "Delete Secret",
"confirmDeleteSecretMsg": "Are you sure you want to delete secret",
"siteInfo": "Site Information",
"folder": "Folder",
"syncTrigger": "Sync Trigger",
@@ -609,13 +628,26 @@
"provider": "Git Provider",
"detectedProvider": "Auto-detected",
"browseRepos": "Browse repositories",
"selectRepo": "Select a repository"
"selectRepo": "Select a repository",
"storage": "Persistent Storage",
"enableStorage": "Enable persistent storage",
"storageHelp": "Mounts a Docker volume at /app/data for your Deno backend to read and write files that persist across deployments.",
"storageLimitMB": "Storage Limit (MB)",
"storageLimitHelp": "Maximum storage size in megabytes. 0 = unlimited.",
"storageVolume": "Volume",
"dataPath": "Data Path",
"storageMountPath": "Mount Path",
"storageLimit": "Limit",
"storageUsed": "Used",
"storageOfLimit": "of limit used",
"unlimited": "Unlimited"
},
"common": {
"cancel": "Cancel",
"confirm": "Confirm",
"delete": "Delete",
"edit": "Edit",
"change": "Change",
"save": "Save",
"retry": "Retry",
"loading": "Loading...",
+37 -5
View File
@@ -33,7 +33,14 @@
"loadFailed": "Не удалось загрузить панель",
"staleContainers": "Устаревшие контейнеры",
"unusedImagesWarning": "Неиспользуемые Docker-образы занимают дисковое пространство",
"unusedImages": "неиспользуемых образов"
"unusedImages": "неиспользуемых образов",
"staticSites": "Статические сайты",
"totalSites": "Всего сайтов",
"deployedSites": "развёрнуто",
"failedSites": "с ошибкой",
"noSites": "Статических сайтов пока нет.",
"addFirstSite": "Разверните первый сайт",
"viewAllSites": "Все сайты"
},
"projects": {
"title": "Проекты",
@@ -80,6 +87,11 @@
"loadingTags": "Загрузка тегов...",
"chooseTag": "Выберите тег...",
"enterTag": "Введите тег образа (напр., dev-abc123)",
"registryTag": "Реестр",
"localTag": "Локальный",
"alsoLocal": "Также доступен локально",
"searchTags": "Поиск тегов...",
"deployTag": "Тег",
"deploy": "Развернуть",
"deploying": "Развёртывание...",
"recentDeploys": "Последние деплои",
@@ -107,7 +119,7 @@
"autoDeployLabel": "Авто-деплой",
"enableProxy": "Включить прокси",
"accessListId": "ID списка доступа NPM",
"accessListIdHelp": "Переопределение для проекта. 0 = использовать глобальное из настроек NPM.",
"accessListIdHelp": "Переопределить глобальный список доступа для этого проекта. Очистите, чтобы наследовать из настроек NPM.",
"localImages": "Локальные Docker-образы",
"imageTag": "Тег",
"imageId": "ID образа",
@@ -124,6 +136,8 @@
"deleteStage": "Удалить стадию",
"deleteStageConfirm": "Удалить стадию \"{name}\"?",
"stageCreated": "Стадия \"{name}\" создана",
"stageUpdated": "Стадия обновлена",
"stageUpdateFailed": "Не удалось обновить стадию",
"stageDeleted": "Стадия \"{name}\" удалена",
"projectUpdated": "Проект обновлён",
"updateFailed": "Не удалось обновить проект",
@@ -135,6 +149,7 @@
"description": "Управление переопределениями переменных окружения на уровне стадий. Значения стадий переопределяют значения проекта.",
"stage": "Стадия",
"projectDefaults": "Значения проекта по умолчанию",
"noProjectEnv": "Переменные окружения на уровне проекта ещё не определены.",
"stageOverrides": "Переопределения стадии",
"key": "Ключ",
"value": "Значение",
@@ -160,7 +175,9 @@
"updateFailed": "Не удалось обновить переменную",
"deleteFailed": "Не удалось удалить переменную",
"loadEnvFailed": "Не удалось загрузить переменные",
"leaveEmptyToKeep": "Оставьте пустым, чтобы сохранить текущее"
"leaveEmptyToKeep": "Оставьте пустым, чтобы сохранить текущее",
"deleteTitle": "Удалить переменную окружения",
"deleteMessage": "Вы уверены, что хотите удалить эту переменную окружения? Это действие нельзя отменить."
},
"volumeEditor": {
"title": "Тома",
@@ -405,7 +422,7 @@
"remoteModeWarning": "Требуется IP сервера в общих настройках. Порты автоматически привязываются к случайным портам хоста.",
"accessList": "Список доступа по умолчанию",
"accessListHelp": "Список доступа NPM для HTTP-аутентификации на прокси-хостах. Можно переопределить для каждого проекта.",
"noAccessList": "Нет (публичный)",
"noAccessList": "Глобальные настройки",
"selectAccessList": "Выберите список доступа",
"noAccessLists": "Списки доступа в NPM не найдены",
"accessListLoadFailed": "Не удалось загрузить списки доступа"
@@ -560,6 +577,8 @@
"openSite": "Открыть сайт",
"confirmDelete": "Удалить сайт",
"confirmDeleteMsg": "Это удалит сайт и его контейнер",
"confirmDeleteSecret": "Удалить секрет",
"confirmDeleteSecretMsg": "Вы уверены, что хотите удалить секрет",
"siteInfo": "Информация о сайте",
"folder": "Папка",
"syncTrigger": "Триггер синхр.",
@@ -609,13 +628,26 @@
"provider": "Git-провайдер",
"detectedProvider": "Автоопределён",
"browseRepos": "Обзор репозиториев",
"selectRepo": "Выберите репозиторий"
"selectRepo": "Выберите репозиторий",
"storage": "Хранилище данных",
"enableStorage": "Включить хранилище данных",
"storageHelp": "Подключает Docker-том в /app/data, чтобы Deno-бэкенд мог читать и записывать файлы, сохраняющиеся между деплоями.",
"storageLimitMB": "Лимит хранилища (МБ)",
"storageLimitHelp": "Максимальный размер хранилища в мегабайтах. 0 = без ограничений.",
"storageVolume": "Том",
"dataPath": "Путь к данным",
"storageMountPath": "Путь монтирования",
"storageLimit": "Лимит",
"storageUsed": "Использовано",
"storageOfLimit": "от лимита использовано",
"unlimited": "Без ограничений"
},
"common": {
"cancel": "Отмена",
"confirm": "Подтвердить",
"delete": "Удалить",
"edit": "Изменить",
"change": "Изменить",
"save": "Сохранить",
"retry": "Повторить",
"loading": "Загрузка...",