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", "loadFailed": "Failed to load dashboard",
"staleContainers": "Stale Containers", "staleContainers": "Stale Containers",
"unusedImagesWarning": "Unused Docker images are taking up disk space", "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": { "projects": {
"title": "Projects", "title": "Projects",
@@ -80,6 +87,11 @@
"loadingTags": "Loading tags...", "loadingTags": "Loading tags...",
"chooseTag": "Choose a tag...", "chooseTag": "Choose a tag...",
"enterTag": "Enter image tag (e.g., dev-abc123)", "enterTag": "Enter image tag (e.g., dev-abc123)",
"registryTag": "Registry",
"localTag": "Local",
"alsoLocal": "Also available locally",
"searchTags": "Search tags...",
"deployTag": "Tag",
"deploy": "Deploy", "deploy": "Deploy",
"deploying": "Deploying...", "deploying": "Deploying...",
"recentDeploys": "Recent Deploys", "recentDeploys": "Recent Deploys",
@@ -107,7 +119,7 @@
"autoDeployLabel": "Auto Deploy", "autoDeployLabel": "Auto Deploy",
"enableProxy": "Enable Proxy", "enableProxy": "Enable Proxy",
"accessListId": "NPM Access List ID", "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", "localImages": "Local Docker Images",
"imageTag": "Tag", "imageTag": "Tag",
"imageId": "Image ID", "imageId": "Image ID",
@@ -124,6 +136,8 @@
"deleteStage": "Delete stage", "deleteStage": "Delete stage",
"deleteStageConfirm": "Delete stage \"{name}\"?", "deleteStageConfirm": "Delete stage \"{name}\"?",
"stageCreated": "Stage \"{name}\" created", "stageCreated": "Stage \"{name}\" created",
"stageUpdated": "Stage updated",
"stageUpdateFailed": "Failed to update stage",
"stageDeleted": "Stage \"{name}\" deleted", "stageDeleted": "Stage \"{name}\" deleted",
"projectUpdated": "Project updated", "projectUpdated": "Project updated",
"updateFailed": "Failed to update project", "updateFailed": "Failed to update project",
@@ -135,6 +149,7 @@
"description": "Manage per-stage environment variable overrides. Stage-level values override project-level defaults.", "description": "Manage per-stage environment variable overrides. Stage-level values override project-level defaults.",
"stage": "Stage", "stage": "Stage",
"projectDefaults": "Project-Level Defaults", "projectDefaults": "Project-Level Defaults",
"noProjectEnv": "No project-level environment variables defined yet.",
"stageOverrides": "Stage Overrides", "stageOverrides": "Stage Overrides",
"key": "Key", "key": "Key",
"value": "Value", "value": "Value",
@@ -160,7 +175,9 @@
"updateFailed": "Failed to update env var", "updateFailed": "Failed to update env var",
"deleteFailed": "Failed to delete env var", "deleteFailed": "Failed to delete env var",
"loadEnvFailed": "Failed to load env vars", "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": { "volumeEditor": {
"title": "Volume Mounts", "title": "Volume Mounts",
@@ -405,7 +422,7 @@
"remoteModeWarning": "Requires Server IP in General settings. Ports are auto-mapped to random host ports.", "remoteModeWarning": "Requires Server IP in General settings. Ports are auto-mapped to random host ports.",
"accessList": "Default Access List", "accessList": "Default Access List",
"accessListHelp": "NPM access list for HTTP authentication on proxy hosts. Can be overridden per project.", "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", "selectAccessList": "Select an access list",
"noAccessLists": "No access lists found in NPM", "noAccessLists": "No access lists found in NPM",
"accessListLoadFailed": "Failed to load access lists" "accessListLoadFailed": "Failed to load access lists"
@@ -560,6 +577,8 @@
"openSite": "Open Site", "openSite": "Open Site",
"confirmDelete": "Delete Site", "confirmDelete": "Delete Site",
"confirmDeleteMsg": "This will permanently delete the site and remove its container", "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", "siteInfo": "Site Information",
"folder": "Folder", "folder": "Folder",
"syncTrigger": "Sync Trigger", "syncTrigger": "Sync Trigger",
@@ -609,13 +628,26 @@
"provider": "Git Provider", "provider": "Git Provider",
"detectedProvider": "Auto-detected", "detectedProvider": "Auto-detected",
"browseRepos": "Browse repositories", "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": { "common": {
"cancel": "Cancel", "cancel": "Cancel",
"confirm": "Confirm", "confirm": "Confirm",
"delete": "Delete", "delete": "Delete",
"edit": "Edit", "edit": "Edit",
"change": "Change",
"save": "Save", "save": "Save",
"retry": "Retry", "retry": "Retry",
"loading": "Loading...", "loading": "Loading...",
+37 -5
View File
@@ -33,7 +33,14 @@
"loadFailed": "Не удалось загрузить панель", "loadFailed": "Не удалось загрузить панель",
"staleContainers": "Устаревшие контейнеры", "staleContainers": "Устаревшие контейнеры",
"unusedImagesWarning": "Неиспользуемые Docker-образы занимают дисковое пространство", "unusedImagesWarning": "Неиспользуемые Docker-образы занимают дисковое пространство",
"unusedImages": "неиспользуемых образов" "unusedImages": "неиспользуемых образов",
"staticSites": "Статические сайты",
"totalSites": "Всего сайтов",
"deployedSites": "развёрнуто",
"failedSites": "с ошибкой",
"noSites": "Статических сайтов пока нет.",
"addFirstSite": "Разверните первый сайт",
"viewAllSites": "Все сайты"
}, },
"projects": { "projects": {
"title": "Проекты", "title": "Проекты",
@@ -80,6 +87,11 @@
"loadingTags": "Загрузка тегов...", "loadingTags": "Загрузка тегов...",
"chooseTag": "Выберите тег...", "chooseTag": "Выберите тег...",
"enterTag": "Введите тег образа (напр., dev-abc123)", "enterTag": "Введите тег образа (напр., dev-abc123)",
"registryTag": "Реестр",
"localTag": "Локальный",
"alsoLocal": "Также доступен локально",
"searchTags": "Поиск тегов...",
"deployTag": "Тег",
"deploy": "Развернуть", "deploy": "Развернуть",
"deploying": "Развёртывание...", "deploying": "Развёртывание...",
"recentDeploys": "Последние деплои", "recentDeploys": "Последние деплои",
@@ -107,7 +119,7 @@
"autoDeployLabel": "Авто-деплой", "autoDeployLabel": "Авто-деплой",
"enableProxy": "Включить прокси", "enableProxy": "Включить прокси",
"accessListId": "ID списка доступа NPM", "accessListId": "ID списка доступа NPM",
"accessListIdHelp": "Переопределение для проекта. 0 = использовать глобальное из настроек NPM.", "accessListIdHelp": "Переопределить глобальный список доступа для этого проекта. Очистите, чтобы наследовать из настроек NPM.",
"localImages": "Локальные Docker-образы", "localImages": "Локальные Docker-образы",
"imageTag": "Тег", "imageTag": "Тег",
"imageId": "ID образа", "imageId": "ID образа",
@@ -124,6 +136,8 @@
"deleteStage": "Удалить стадию", "deleteStage": "Удалить стадию",
"deleteStageConfirm": "Удалить стадию \"{name}\"?", "deleteStageConfirm": "Удалить стадию \"{name}\"?",
"stageCreated": "Стадия \"{name}\" создана", "stageCreated": "Стадия \"{name}\" создана",
"stageUpdated": "Стадия обновлена",
"stageUpdateFailed": "Не удалось обновить стадию",
"stageDeleted": "Стадия \"{name}\" удалена", "stageDeleted": "Стадия \"{name}\" удалена",
"projectUpdated": "Проект обновлён", "projectUpdated": "Проект обновлён",
"updateFailed": "Не удалось обновить проект", "updateFailed": "Не удалось обновить проект",
@@ -135,6 +149,7 @@
"description": "Управление переопределениями переменных окружения на уровне стадий. Значения стадий переопределяют значения проекта.", "description": "Управление переопределениями переменных окружения на уровне стадий. Значения стадий переопределяют значения проекта.",
"stage": "Стадия", "stage": "Стадия",
"projectDefaults": "Значения проекта по умолчанию", "projectDefaults": "Значения проекта по умолчанию",
"noProjectEnv": "Переменные окружения на уровне проекта ещё не определены.",
"stageOverrides": "Переопределения стадии", "stageOverrides": "Переопределения стадии",
"key": "Ключ", "key": "Ключ",
"value": "Значение", "value": "Значение",
@@ -160,7 +175,9 @@
"updateFailed": "Не удалось обновить переменную", "updateFailed": "Не удалось обновить переменную",
"deleteFailed": "Не удалось удалить переменную", "deleteFailed": "Не удалось удалить переменную",
"loadEnvFailed": "Не удалось загрузить переменные", "loadEnvFailed": "Не удалось загрузить переменные",
"leaveEmptyToKeep": "Оставьте пустым, чтобы сохранить текущее" "leaveEmptyToKeep": "Оставьте пустым, чтобы сохранить текущее",
"deleteTitle": "Удалить переменную окружения",
"deleteMessage": "Вы уверены, что хотите удалить эту переменную окружения? Это действие нельзя отменить."
}, },
"volumeEditor": { "volumeEditor": {
"title": "Тома", "title": "Тома",
@@ -405,7 +422,7 @@
"remoteModeWarning": "Требуется IP сервера в общих настройках. Порты автоматически привязываются к случайным портам хоста.", "remoteModeWarning": "Требуется IP сервера в общих настройках. Порты автоматически привязываются к случайным портам хоста.",
"accessList": "Список доступа по умолчанию", "accessList": "Список доступа по умолчанию",
"accessListHelp": "Список доступа NPM для HTTP-аутентификации на прокси-хостах. Можно переопределить для каждого проекта.", "accessListHelp": "Список доступа NPM для HTTP-аутентификации на прокси-хостах. Можно переопределить для каждого проекта.",
"noAccessList": "Нет (публичный)", "noAccessList": "Глобальные настройки",
"selectAccessList": "Выберите список доступа", "selectAccessList": "Выберите список доступа",
"noAccessLists": "Списки доступа в NPM не найдены", "noAccessLists": "Списки доступа в NPM не найдены",
"accessListLoadFailed": "Не удалось загрузить списки доступа" "accessListLoadFailed": "Не удалось загрузить списки доступа"
@@ -560,6 +577,8 @@
"openSite": "Открыть сайт", "openSite": "Открыть сайт",
"confirmDelete": "Удалить сайт", "confirmDelete": "Удалить сайт",
"confirmDeleteMsg": "Это удалит сайт и его контейнер", "confirmDeleteMsg": "Это удалит сайт и его контейнер",
"confirmDeleteSecret": "Удалить секрет",
"confirmDeleteSecretMsg": "Вы уверены, что хотите удалить секрет",
"siteInfo": "Информация о сайте", "siteInfo": "Информация о сайте",
"folder": "Папка", "folder": "Папка",
"syncTrigger": "Триггер синхр.", "syncTrigger": "Триггер синхр.",
@@ -609,13 +628,26 @@
"provider": "Git-провайдер", "provider": "Git-провайдер",
"detectedProvider": "Автоопределён", "detectedProvider": "Автоопределён",
"browseRepos": "Обзор репозиториев", "browseRepos": "Обзор репозиториев",
"selectRepo": "Выберите репозиторий" "selectRepo": "Выберите репозиторий",
"storage": "Хранилище данных",
"enableStorage": "Включить хранилище данных",
"storageHelp": "Подключает Docker-том в /app/data, чтобы Deno-бэкенд мог читать и записывать файлы, сохраняющиеся между деплоями.",
"storageLimitMB": "Лимит хранилища (МБ)",
"storageLimitHelp": "Максимальный размер хранилища в мегабайтах. 0 = без ограничений.",
"storageVolume": "Том",
"dataPath": "Путь к данным",
"storageMountPath": "Путь монтирования",
"storageLimit": "Лимит",
"storageUsed": "Использовано",
"storageOfLimit": "от лимита использовано",
"unlimited": "Без ограничений"
}, },
"common": { "common": {
"cancel": "Отмена", "cancel": "Отмена",
"confirm": "Подтвердить", "confirm": "Подтвердить",
"delete": "Удалить", "delete": "Удалить",
"edit": "Изменить", "edit": "Изменить",
"change": "Изменить",
"save": "Сохранить", "save": "Сохранить",
"retry": "Повторить", "retry": "Повторить",
"loading": "Загрузка...", "loading": "Загрузка...",
+264 -99
View File
@@ -8,7 +8,7 @@
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import EmptyState from '$lib/components/EmptyState.svelte'; import EmptyState from '$lib/components/EmptyState.svelte';
import Skeleton from '$lib/components/Skeleton.svelte'; import Skeleton from '$lib/components/Skeleton.svelte';
import { IconTrash, IconKey, IconHardDrive, IconDeploy, IconClock, IconLoader, IconPlus, IconEdit, IconCheck, IconX } from '$lib/components/icons'; import { IconTrash, IconKey, IconHardDrive, IconDeploy, IconClock, IconPlus, IconEdit, IconCheck, IconX } from '$lib/components/icons';
import Breadcrumb from '$lib/components/Breadcrumb.svelte'; import Breadcrumb from '$lib/components/Breadcrumb.svelte';
import FormField from '$lib/components/FormField.svelte'; import FormField from '$lib/components/FormField.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte'; import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
@@ -30,7 +30,51 @@
let deployLoading = $state(false); let deployLoading = $state(false);
let deployError = $state(''); let deployError = $state('');
let availableTags = $state<string[]>([]);
// Edit stage
let editingStageId = $state('');
let editStageName = $state('');
let editStageTagPattern = $state('');
let editStageAutoDeploy = $state(true);
let editStageEnableProxy = $state(true);
let editStageMaxInstances = $state('1');
let editStageCpuLimit = $state('');
let editStageMemoryLimit = $state('');
let savingStage = $state(false);
function startEditStage(stage: Stage) {
editingStageId = stage.id;
editStageName = stage.name;
editStageTagPattern = stage.tag_pattern;
editStageAutoDeploy = stage.auto_deploy;
editStageEnableProxy = stage.enable_proxy;
editStageMaxInstances = String(stage.max_instances);
editStageCpuLimit = stage.cpu_limit ? String(stage.cpu_limit) : '';
editStageMemoryLimit = stage.memory_limit ? String(stage.memory_limit) : '';
}
async function handleUpdateStage() {
if (!editStageName.trim()) return;
savingStage = true;
try {
await api.updateStage(projectId, editingStageId, {
name: editStageName.trim(),
tag_pattern: editStageTagPattern.trim() || '*',
auto_deploy: editStageAutoDeploy,
enable_proxy: editStageEnableProxy,
max_instances: parseInt(editStageMaxInstances) || 1,
cpu_limit: parseFloat(editStageCpuLimit) || 0,
memory_limit: parseInt(editStageMemoryLimit) || 0,
});
toasts.success($t('projectDetail.stageUpdated'));
editingStageId = '';
await loadProject();
} catch (e) {
toasts.error(e instanceof Error ? e.message : $t('projectDetail.stageUpdateFailed'));
} finally {
savingStage = false;
}
}
// Add stage form // Add stage form
let showAddStage = $state(false); let showAddStage = $state(false);
@@ -149,32 +193,42 @@
async function handleDeleteStage(stageId: string, name: string) { async function handleDeleteStage(stageId: string, name: string) {
try { try {
await api.deleteStage(projectId, stageId); await api.deleteStage(projectId, stageId);
// Update local state immediately so the UI reflects the change.
stages = stages.filter((s) => s.id !== stageId);
const { [stageId]: _, ...rest } = instancesByStage;
instancesByStage = rest;
toasts.success($t('projectDetail.stageDeleted', { name })); toasts.success($t('projectDetail.stageDeleted', { name }));
await loadProject();
} catch (e) { } catch (e) {
toasts.error(e instanceof Error ? e.message : $t('projectDetail.stageDeleteFailed')); toasts.error(e instanceof Error ? e.message : $t('projectDetail.stageDeleteFailed'));
} }
} }
let tagsLoading = $state(false);
let settingsDomain = $state(''); let settingsDomain = $state('');
let localImages = $state<LocalImage[]>([]); let localImages = $state<LocalImage[]>([]);
let showDeleteConfirm = $state(false); let showDeleteConfirm = $state(false);
let stageDeleteTarget = $state<{ id: string; name: string } | null>(null);
let loadController: AbortController | null = null;
const projectId = $derived($page.params.id!); // always present on [id] route const projectId = $derived($page.params.id!); // always present on [id] route
async function loadProject() { async function loadProject() {
// Abort any previous in-flight load before starting a new one.
loadController?.abort();
const controller = new AbortController();
loadController = controller;
const signal = controller.signal;
if (!project) loading = true; if (!project) loading = true;
error = ''; error = '';
try { try {
const detail = await api.getProject(projectId); const detail = await api.getProject(projectId, signal);
project = detail.project; project = detail.project;
stages = detail.stages ?? []; stages = detail.stages ?? [];
const instanceResults = await Promise.all( const instanceResults = await Promise.all(
stages.map(async (s) => { stages.map(async (s) => {
try { try {
const instances = await api.listInstances(projectId, s.id); const instances = await api.listInstances(projectId, s.id, signal);
return { stageId: s.id, instances }; return { stageId: s.id, instances };
} catch { } catch {
return { stageId: s.id, instances: [] }; return { stageId: s.id, instances: [] };
@@ -190,9 +244,9 @@
// Fetch deploys, settings, and images in parallel (independent of each other). // Fetch deploys, settings, and images in parallel (independent of each other).
const [deploysResult, settingsResult, imagesResult] = await Promise.allSettled([ const [deploysResult, settingsResult, imagesResult] = await Promise.allSettled([
api.listDeploys(20), api.listDeploys(20, signal),
api.getSettings(), api.getSettings(signal),
api.listProjectImages(projectId) api.listProjectImages(projectId, signal)
]); ]);
deploys = deploysResult.status === 'fulfilled' deploys = deploysResult.status === 'fulfilled'
@@ -205,32 +259,92 @@
? imagesResult.value ? imagesResult.value
: []; : [];
} catch (e) { } catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return;
error = e instanceof Error ? e.message : $t('projectDetail.loadFailed'); error = e instanceof Error ? e.message : $t('projectDetail.loadFailed');
} finally { } finally {
loading = false; loading = false;
} }
} }
async function loadTags(stageId: string) { let tagPickerOpen = $state(false);
let tagPickerItems = $state<EntityPickerItem[]>([]);
async function openTagPicker(stageId: string) {
deployStageId = stageId; deployStageId = stageId;
deployTag = ''; deployTag = '';
availableTags = [];
if (!project?.registry || !project?.image) return; // Build local image suggestions.
const imgs = localImages;
const localItems: EntityPickerItem[] = imgs
.filter((img) => img.tag)
.map((img) => ({
value: img.tag,
label: img.tag,
group: $t('projectDetail.localTag'),
description: `${(img.size / (1024 * 1024)).toFixed(0)} MB`
}));
tagsLoading = true; // Try to fetch registry tags.
let registryItems: EntityPickerItem[] = [];
try { try {
// Look up registry ID from name.
const registries = await api.listRegistries(); const registries = await api.listRegistries();
const reg = registries.find(r => r.name === project?.registry); // Match by registry URL hostname (project.registry stores the hostname)
if (reg) { // or by name, or try all registries if project.registry is empty.
availableTags = await api.listRegistryTags(reg.id, project.image); const projectRegistry = project?.registry || '';
const projectImage = project?.image || '';
let reg = registries.find(r => {
if (!projectRegistry) return false;
const urlHost = new URL(r.url).hostname;
return r.name === projectRegistry || urlHost === projectRegistry;
});
// If project has no registry set but image contains a hostname, try matching by image prefix.
if (!reg && projectImage.includes('/')) {
const imageHost = projectImage.split('/')[0];
if (imageHost.includes('.')) {
reg = registries.find(r => {
try { return new URL(r.url).hostname === imageHost; } catch { return false; }
});
}
} }
} catch {
availableTags = []; if (reg) {
} finally { // Strip registry hostname from image if present (registry API expects owner/name).
tagsLoading = false; let imageForRegistry = projectImage;
try {
const urlHost = new URL(reg.url).hostname;
if (imageForRegistry.startsWith(urlHost + '/')) {
imageForRegistry = imageForRegistry.substring(urlHost.length + 1);
}
} catch { /* keep as-is */ }
const tags = await api.listRegistryTags(reg.id, imageForRegistry);
const localTagSet = new Set(imgs.map((img) => img.tag));
registryItems = tags.map((tag) => ({
value: tag,
label: tag,
group: $t('projectDetail.registryTag'),
description: localTagSet.has(tag) ? $t('projectDetail.alsoLocal') : undefined
}));
}
} catch { /* ignore registry errors */ }
// Merge: registry tags first, then local-only tags.
if (registryItems.length > 0) {
const registryTagSet = new Set(registryItems.map((item) => item.value));
const localOnly = localItems.filter((item) => !registryTagSet.has(item.value));
tagPickerItems = [...registryItems, ...localOnly];
} else {
tagPickerItems = localItems;
} }
tagPickerOpen = true;
}
function handleTagSelect(tag: string) {
deployTag = tag;
tagPickerOpen = false;
} }
async function handleDeploy() { async function handleDeploy() {
@@ -267,6 +381,10 @@
$effect(() => { $effect(() => {
void projectId; void projectId;
if (!deleted) loadProject(); if (!deleted) loadProject();
return () => {
loadController?.abort();
};
}); });
</script> </script>
@@ -470,94 +588,117 @@
<div class="mt-4 space-y-4"> <div class="mt-4 space-y-4">
{#each stages as stage (stage.id)} {#each stages as stage (stage.id)}
{@const stageInstances = instancesByStage[stage.id] ?? []} {@const stageInstances = instancesByStage[stage.id] ?? []}
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)] overflow-hidden"> <div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
<!-- Stage header --> <!-- Stage header -->
<div class="flex items-center justify-between flex-wrap gap-2 border-b border-[var(--border-secondary)] px-5 py-4"> {#if editingStageId === stage.id}
<div class="flex items-center gap-3 flex-wrap"> <div class="border-b border-[var(--border-secondary)] px-5 py-4">
<h3 class="text-base font-semibold text-[var(--text-primary)]">{stage.name}</h3> <div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<span class="rounded-md bg-[var(--surface-card-hover)] px-2 py-0.5 font-mono text-xs text-[var(--text-tertiary)]">{stage.tag_pattern}</span> <FormField label={$t('projectDetail.nameLabel')} name="editStageName" bind:value={editStageName} />
{#if stage.auto_deploy} <FormField label={$t('projectDetail.tagPattern')} name="editStagePattern" bind:value={editStageTagPattern} />
<span class="rounded-full badge-success rounded-full px-2 py-0.5 text-xs font-medium">{$t('projectDetail.autoDeploy')}</span> <FormField label={$t('projectDetail.maxInstances')} name="editStageMax" type="number" bind:value={editStageMaxInstances} />
{/if} <FormField label={$t('projectDetail.cpuLimit')} name="editStageCpu" type="number" bind:value={editStageCpuLimit} placeholder="0" />
{#if stage.confirm} <FormField label={$t('projectDetail.memoryLimit')} name="editStageMem" type="number" bind:value={editStageMemoryLimit} placeholder="0" />
<span class="rounded-full badge-warning rounded-full px-2 py-0.5 text-xs font-medium">{$t('projectDetail.requiresConfirm')}</span> <div class="flex gap-4 items-end pb-1">
{/if} <div class="flex flex-col items-center gap-1">
{#if !stage.enable_proxy} <span class="text-xs font-medium text-[var(--text-tertiary)]">{$t('projectDetail.autoDeployLabel')}</span>
<span class="rounded-full badge-gray rounded-full px-2 py-0.5 text-xs font-medium">{$t('projectDetail.noProxy')}</span> <ToggleSwitch bind:checked={editStageAutoDeploy} label={$t('projectDetail.autoDeployLabel')} />
{/if} </div>
</div> <div class="flex flex-col items-center gap-1">
<div class="flex items-center gap-3"> <span class="text-xs font-medium text-[var(--text-tertiary)]">{$t('projectDetail.enableProxy')}</span>
<span class="text-xs text-[var(--text-tertiary)]"> <ToggleSwitch bind:checked={editStageEnableProxy} label={$t('projectDetail.enableProxy')} />
{stageInstances.length} / {stage.max_instances} {$t('projectDetail.instances')} </div>
</span>
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--color-brand-600)] px-3 py-1.5 text-xs font-medium text-white hover:bg-[var(--color-brand-700)] transition-colors active:animate-press"
onclick={() => loadTags(stage.id)}
>
<IconDeploy size={14} />
{$t('projectDetail.deployNewVersion')}
</button>
<button
type="button"
title={$t('projectDetail.deleteStage')}
onclick={() => { if (confirm($t('projectDetail.deleteStageConfirm', { name: stage.name }))) handleDeleteStage(stage.id, stage.name); }}
class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--color-danger-light)] hover:text-[var(--color-danger)] transition-colors"
>
<IconTrash size={14} />
</button>
</div>
</div>
<!-- Deploy form -->
{#if deployStageId === stage.id}
<div class="border-b border-[var(--border-secondary)] bg-[var(--surface-card-hover)] px-5 py-4 animate-scale-in">
<div class="flex items-end gap-3">
<div class="flex-1">
<label for="deploy-tag-{stage.id}" class="block text-xs font-medium text-[var(--text-secondary)]">
{$t('projectDetail.selectTag')}
</label>
{#if tagsLoading}
<div class="mt-1 flex items-center gap-2 text-sm text-[var(--text-tertiary)]">
<IconLoader size={16} />
{$t('projectDetail.loadingTags')}
</div>
{:else if availableTags.length > 0}
<select
id="deploy-tag-{stage.id}"
bind:value={deployTag}
class="mt-1 block w-full rounded-lg 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"
>
<option value="">{$t('projectDetail.chooseTag')}</option>
{#each availableTags as tag}
<option value={tag}>{tag}</option>
{/each}
</select>
{:else}
<input
id="deploy-tag-{stage.id}"
type="text"
bind:value={deployTag}
placeholder={$t('projectDetail.enterTag')}
class="mt-1 block w-full rounded-lg 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"
/>
{/if}
</div> </div>
</div>
<div class="mt-3 flex items-center gap-2 justify-end">
<button type="button" onclick={() => { editingStageId = ''; }}
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-1.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors">
<IconX size={14} />
{$t('projects.cancel')}
</button>
<button type="button" onclick={handleUpdateStage} disabled={savingStage || !editStageName.trim()}
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--color-brand-600)] px-3 py-1.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors">
<IconCheck size={14} />
{savingStage ? $t('projectDetail.saving') : $t('common.save')}
</button>
</div>
</div>
{:else}
<div class="flex items-center justify-between flex-wrap gap-2 border-b border-[var(--border-secondary)] px-5 py-4">
<div class="flex items-center gap-3 flex-wrap">
<h3 class="text-base font-semibold text-[var(--text-primary)]">{stage.name}</h3>
<span class="rounded-md bg-[var(--surface-card-hover)] px-2 py-0.5 font-mono text-xs text-[var(--text-tertiary)]">{stage.tag_pattern}</span>
{#if stage.auto_deploy}
<span class="rounded-full badge-success rounded-full px-2 py-0.5 text-xs font-medium">{$t('projectDetail.autoDeploy')}</span>
{/if}
{#if stage.confirm}
<span class="rounded-full badge-warning rounded-full px-2 py-0.5 text-xs font-medium">{$t('projectDetail.requiresConfirm')}</span>
{/if}
{#if !stage.enable_proxy}
<span class="rounded-full badge-gray rounded-full px-2 py-0.5 text-xs font-medium">{$t('projectDetail.noProxy')}</span>
{/if}
</div>
<div class="flex items-center gap-3">
<span class="text-xs text-[var(--text-tertiary)]">
{stageInstances.length} / {stage.max_instances} {$t('projectDetail.instances')}
</span>
<button <button
type="button" type="button"
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors active:animate-press" class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--color-brand-600)] px-3 py-1.5 text-xs font-medium text-white hover:bg-[var(--color-brand-700)] transition-colors active:animate-press"
disabled={!deployTag.trim() || deployLoading} onclick={() => openTagPicker(stage.id)}
onclick={handleDeploy}
> >
{deployLoading ? $t('projectDetail.deploying') : $t('projectDetail.deploy')} <IconDeploy size={14} />
{$t('projectDetail.deployNewVersion')}
</button> </button>
<button <button
type="button" type="button"
class="rounded-lg px-3 py-2 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card)] transition-colors" title={$t('common.edit')}
onclick={() => { deployStageId = ''; }} onclick={() => startEditStage(stage)}
class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors"
> >
{$t('common.cancel')} <IconEdit size={14} />
</button> </button>
<button
type="button"
title={$t('projectDetail.deleteStage')}
onclick={() => { stageDeleteTarget = { id: stage.id, name: stage.name }; }}
class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--color-danger-light)] hover:text-[var(--color-danger)] transition-colors"
>
<IconTrash size={14} />
</button>
</div>
</div>
{/if}
<!-- Deploy confirmation -->
{#if deployStageId === stage.id && deployTag}
<div class="border-b border-[var(--border-secondary)] bg-[var(--surface-card-hover)] px-5 py-4">
<div class="flex items-center gap-3">
<span class="text-sm text-[var(--text-secondary)]">{$t('projectDetail.deployTag')}:</span>
<span class="rounded-md bg-[var(--surface-card)] px-2.5 py-1 font-mono text-sm font-medium text-[var(--text-primary)] border border-[var(--border-primary)]">{deployTag}</span>
<button
type="button"
class="text-xs text-[var(--text-link)] hover:text-[var(--text-link-hover)] transition-colors"
onclick={() => openTagPicker(stage.id)}
>
{$t('common.change')}
</button>
<div class="ml-auto flex items-center gap-2">
<button
type="button"
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-1.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors active:animate-press"
disabled={deployLoading}
onclick={handleDeploy}
>
{deployLoading ? $t('projectDetail.deploying') : $t('projectDetail.deploy')}
</button>
<button
type="button"
class="rounded-lg px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card)] transition-colors"
onclick={() => { deployStageId = ''; deployTag = ''; }}
>
{$t('common.cancel')}
</button>
</div>
</div> </div>
{#if deployError} {#if deployError}
<p class="mt-2 text-xs text-[var(--color-danger)]">{deployError}</p> <p class="mt-2 text-xs text-[var(--color-danger)]">{deployError}</p>
@@ -671,6 +812,20 @@
oncancel={() => { showDeleteConfirm = false; }} oncancel={() => { showDeleteConfirm = false; }}
/> />
<ConfirmDialog
open={stageDeleteTarget !== null}
title={$t('projectDetail.deleteStage')}
message={stageDeleteTarget ? $t('projectDetail.deleteStageConfirm', { name: stageDeleteTarget.name }) : ''}
confirmLabel={$t('common.delete')}
confirmVariant="danger"
onconfirm={async () => {
const target = stageDeleteTarget;
stageDeleteTarget = null;
if (target) await handleDeleteStage(target.id, target.name);
}}
oncancel={() => { stageDeleteTarget = null; }}
/>
<EntityPicker <EntityPicker
bind:open={accessListPickerOpen} bind:open={accessListPickerOpen}
items={accessListPickerItems} items={accessListPickerItems}
@@ -679,4 +834,14 @@
onselect={handleProjectAccessListSelect} onselect={handleProjectAccessListSelect}
onclose={() => { accessListPickerOpen = false; }} onclose={() => { accessListPickerOpen = false; }}
/> />
<EntityPicker
bind:open={tagPickerOpen}
items={tagPickerItems}
current={deployTag}
title={$t('projectDetail.selectTag')}
placeholder={$t('projectDetail.searchTags')}
onselect={handleTagSelect}
onclose={() => { tagPickerOpen = false; }}
/>
{/if} {/if}
+154 -37
View File
@@ -31,8 +31,68 @@
let envDeleteTarget = $state<string | null>(null); let envDeleteTarget = $state<string | null>(null);
// Project-level env editing
let newProjectKey = $state('');
let newProjectValue = $state('');
let savingProject = $state(false);
let editingProjectKey = $state('');
let editProjectValue = $state('');
let projectEnvDeleteTarget = $state<string | null>(null);
const projectId = $derived($page.params.id); const projectId = $derived($page.params.id);
async function handleAddProjectEnv() {
if (!newProjectKey.trim()) return;
savingProject = true;
try {
const updated = { ...projectEnv, [newProjectKey.trim()]: newProjectValue };
await api.updateProject(projectId!, { env: JSON.stringify(updated) });
projectEnv = updated;
newProjectKey = '';
newProjectValue = '';
toasts.success($t('envEditor.envAdded'));
} catch (e) {
toasts.error(e instanceof Error ? e.message : $t('envEditor.addFailed'));
} finally {
savingProject = false;
}
}
function startEditProjectEnv(key: string) {
editingProjectKey = key;
editProjectValue = projectEnv[key] ?? '';
}
async function handleUpdateProjectEnv() {
if (!editingProjectKey) return;
savingProject = true;
try {
const updated = { ...projectEnv, [editingProjectKey]: editProjectValue };
await api.updateProject(projectId!, { env: JSON.stringify(updated) });
projectEnv = updated;
editingProjectKey = '';
toasts.success($t('envEditor.envUpdated'));
} catch (e) {
toasts.error(e instanceof Error ? e.message : $t('envEditor.updateFailed'));
} finally {
savingProject = false;
}
}
async function handleDeleteProjectEnv(key: string) {
savingProject = true;
try {
const { [key]: _, ...rest } = projectEnv;
await api.updateProject(projectId!, { env: JSON.stringify(rest) });
projectEnv = rest;
toasts.success($t('envEditor.envDeleted'));
} catch (e) {
toasts.error(e instanceof Error ? e.message : $t('envEditor.deleteFailed'));
} finally {
savingProject = false;
}
}
async function loadProject() { async function loadProject() {
if (stages.length === 0) loading = true; if (stages.length === 0) loading = true;
error = ''; error = '';
@@ -169,41 +229,42 @@
<p class="text-sm text-[var(--color-danger)]">{error}</p> <p class="text-sm text-[var(--color-danger)]">{error}</p>
</div> </div>
{:else} {:else}
<!-- Stage selector --> <!-- Project-level env -->
<div>
<label for="stage-select" class="block text-sm font-medium text-[var(--text-primary)]">{$t('envEditor.stage')}</label>
<select
id="stage-select"
bind:value={selectedStageId}
class="mt-1 block w-64 rounded-lg 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"
>
{#each stages as stage (stage.id)}
<option value={stage.id}>{stage.name}</option>
{/each}
</select>
</div>
{#if stages.length === 0} {#if stages.length === 0}
<EmptyState title={$t('envEditor.noStages')} icon="instances" /> <EmptyState title={$t('envEditor.noStages')} icon="instances" />
{:else} {:else}
<!-- Project-level env --> <div>
{#if Object.keys(projectEnv).length > 0} <h2 class="text-sm font-semibold text-[var(--text-secondary)]">{$t('envEditor.projectDefaults')}</h2>
<div> <div class="mt-2 overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
<h2 class="text-sm font-semibold text-[var(--text-secondary)]">{$t('envEditor.projectDefaults')}</h2> <table class="min-w-full divide-y divide-[var(--border-primary)]">
<div class="mt-2 overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card-hover)]"> <thead class="bg-[var(--surface-card-hover)]">
<table class="min-w-full divide-y divide-[var(--border-primary)]"> <tr>
<thead> <th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.key')}</th>
<tr> <th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.value')}</th>
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.key')}</th> <th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.source')}</th>
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.value')}</th> <th class="px-4 py-2.5 text-right text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.actions')}</th>
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.source')}</th> </tr>
</tr> </thead>
</thead> <tbody class="divide-y divide-[var(--border-secondary)]">
<tbody class="divide-y divide-[var(--border-secondary)]"> {#each Object.entries(projectEnv) as [key, val] (key)}
{#each Object.entries(projectEnv) as [key, value] (key)} {#if editingProjectKey === key}
<tr class={isOverridden(key) ? 'opacity-50' : ''}> <tr class="bg-[var(--color-brand-50)]/30">
<td class="whitespace-nowrap px-4 py-2.5 font-mono text-sm text-[var(--text-primary)]">{key}</td> <td class="whitespace-nowrap px-4 py-2.5 font-mono text-sm text-[var(--text-primary)]">{key}</td>
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-secondary)]">{value}</td> <td class="px-4 py-2.5">
<input type="text" bind:value={editProjectValue} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
</td>
<td class="px-4 py-2.5"></td>
<td class="px-4 py-2.5 text-right">
<div class="flex items-center justify-end gap-1">
<button type="button" class="rounded-lg p-1.5 text-emerald-600 hover:bg-emerald-50 transition-colors" disabled={savingProject} onclick={handleUpdateProjectEnv}><IconCheck size={16} /></button>
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] transition-colors" onclick={() => { editingProjectKey = ''; }}><IconX size={16} /></button>
</div>
</td>
</tr>
{:else}
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors {isOverridden(key) ? 'opacity-50' : ''}">
<td class="whitespace-nowrap px-4 py-2.5 font-mono text-sm text-[var(--text-primary)]">{key}</td>
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-secondary)]">{val}</td>
<td class="px-4 py-2.5 text-sm"> <td class="px-4 py-2.5 text-sm">
{#if isOverridden(key)} {#if isOverridden(key)}
<span class="rounded-full badge-warning rounded-full px-2 py-0.5 text-xs font-medium">{$t('envEditor.overridden')}</span> <span class="rounded-full badge-warning rounded-full px-2 py-0.5 text-xs font-medium">{$t('envEditor.overridden')}</span>
@@ -211,17 +272,59 @@
<span class="rounded-full badge-success rounded-full px-2 py-0.5 text-xs font-medium">{$t('envEditor.inherited')}</span> <span class="rounded-full badge-success rounded-full px-2 py-0.5 text-xs font-medium">{$t('envEditor.inherited')}</span>
{/if} {/if}
</td> </td>
<td class="whitespace-nowrap px-4 py-2.5 text-right">
<div class="flex items-center justify-end gap-1">
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors" onclick={() => startEditProjectEnv(key)}><IconEdit size={16} /></button>
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors" onclick={() => { projectEnvDeleteTarget = key; }}><IconTrash size={16} /></button>
</div>
</td>
</tr> </tr>
{/each} {/if}
</tbody> {/each}
</table>
</div> <!-- Add new project env row -->
<tr class="bg-[var(--surface-card-hover)]">
<td class="px-4 py-2.5">
<input type="text" bind:value={newProjectKey} placeholder="KEY_NAME" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
</td>
<td class="px-4 py-2.5">
<input type="text" bind:value={newProjectValue} placeholder="value" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
</td>
<td class="px-4 py-2.5"></td>
<td class="px-4 py-2.5 text-right">
<button
type="button"
class="inline-flex items-center gap-1 rounded-lg bg-[var(--color-brand-600)] px-3 py-1.5 text-xs font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors active:animate-press"
disabled={!newProjectKey.trim() || savingProject}
onclick={handleAddProjectEnv}
>
<IconPlus size={14} />
{savingProject ? $t('envEditor.adding') : $t('envEditor.add')}
</button>
</td>
</tr>
</tbody>
</table>
</div> </div>
{/if} {#if Object.keys(projectEnv).length === 0}
<p class="mt-2 text-center text-xs text-[var(--text-tertiary)]">{$t('envEditor.noProjectEnv')}</p>
{/if}
</div>
<!-- Stage-level overrides --> <!-- Stage-level overrides -->
<div> <div>
<h2 class="text-sm font-semibold text-[var(--text-secondary)]">{$t('envEditor.stageOverrides')}</h2> <div class="flex items-center gap-4">
<h2 class="text-sm font-semibold text-[var(--text-secondary)]">{$t('envEditor.stageOverrides')}</h2>
<select
id="stage-select"
bind:value={selectedStageId}
class="block w-48 rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-1.5 text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-2 focus:ring-[var(--color-brand-500)] focus:outline-none"
>
{#each stages as stage (stage.id)}
<option value={stage.id}>{stage.name}</option>
{/each}
</select>
</div>
{#if envLoading} {#if envLoading}
<div class="mt-4 flex items-center justify-center gap-2 py-8 text-[var(--text-tertiary)]"> <div class="mt-4 flex items-center justify-center gap-2 py-8 text-[var(--text-tertiary)]">
@@ -346,3 +449,17 @@
}} }}
oncancel={() => { envDeleteTarget = null; }} oncancel={() => { envDeleteTarget = null; }}
/> />
<ConfirmDialog
open={projectEnvDeleteTarget !== null}
title={$t('envEditor.deleteTitle')}
message={$t('envEditor.deleteMessage')}
confirmLabel={$t('common.delete')}
confirmVariant="danger"
onconfirm={async () => {
const key = projectEnvDeleteTarget;
projectEnvDeleteTarget = null;
if (key) await handleDeleteProjectEnv(key);
}}
oncancel={() => { projectEnvDeleteTarget = null; }}
/>