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:
@@ -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>
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": "Загрузка...",
|
||||
|
||||
Reference in New Issue
Block a user