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
+264 -99
View File
@@ -8,7 +8,7 @@
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, 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 FormField from '$lib/components/FormField.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
@@ -30,7 +30,51 @@
let deployLoading = $state(false);
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
let showAddStage = $state(false);
@@ -149,32 +193,42 @@
async function handleDeleteStage(stageId: string, name: string) {
try {
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 }));
await loadProject();
} catch (e) {
toasts.error(e instanceof Error ? e.message : $t('projectDetail.stageDeleteFailed'));
}
}
let tagsLoading = $state(false);
let settingsDomain = $state('');
let localImages = $state<LocalImage[]>([]);
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
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;
error = '';
try {
const detail = await api.getProject(projectId);
const detail = await api.getProject(projectId, signal);
project = detail.project;
stages = detail.stages ?? [];
const instanceResults = await Promise.all(
stages.map(async (s) => {
try {
const instances = await api.listInstances(projectId, s.id);
const instances = await api.listInstances(projectId, s.id, signal);
return { stageId: s.id, instances };
} catch {
return { stageId: s.id, instances: [] };
@@ -190,9 +244,9 @@
// Fetch deploys, settings, and images in parallel (independent of each other).
const [deploysResult, settingsResult, imagesResult] = await Promise.allSettled([
api.listDeploys(20),
api.getSettings(),
api.listProjectImages(projectId)
api.listDeploys(20, signal),
api.getSettings(signal),
api.listProjectImages(projectId, signal)
]);
deploys = deploysResult.status === 'fulfilled'
@@ -205,32 +259,92 @@
? imagesResult.value
: [];
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return;
error = e instanceof Error ? e.message : $t('projectDetail.loadFailed');
} finally {
loading = false;
}
}
async function loadTags(stageId: string) {
let tagPickerOpen = $state(false);
let tagPickerItems = $state<EntityPickerItem[]>([]);
async function openTagPicker(stageId: string) {
deployStageId = stageId;
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 {
// Look up registry ID from name.
const registries = await api.listRegistries();
const reg = registries.find(r => r.name === project?.registry);
if (reg) {
availableTags = await api.listRegistryTags(reg.id, project.image);
// Match by registry URL hostname (project.registry stores the hostname)
// or by name, or try all registries if project.registry is empty.
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 = [];
} finally {
tagsLoading = false;
if (reg) {
// Strip registry hostname from image if present (registry API expects owner/name).
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() {
@@ -267,6 +381,10 @@
$effect(() => {
void projectId;
if (!deleted) loadProject();
return () => {
loadController?.abort();
};
});
</script>
@@ -470,94 +588,117 @@
<div class="mt-4 space-y-4">
{#each stages as stage (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 -->
<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
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}
{#if editingStageId === stage.id}
<div class="border-b border-[var(--border-secondary)] px-5 py-4">
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<FormField label={$t('projectDetail.nameLabel')} name="editStageName" bind:value={editStageName} />
<FormField label={$t('projectDetail.tagPattern')} name="editStagePattern" bind:value={editStageTagPattern} />
<FormField label={$t('projectDetail.maxInstances')} name="editStageMax" type="number" bind:value={editStageMaxInstances} />
<FormField label={$t('projectDetail.cpuLimit')} name="editStageCpu" type="number" bind:value={editStageCpuLimit} placeholder="0" />
<FormField label={$t('projectDetail.memoryLimit')} name="editStageMem" type="number" bind:value={editStageMemoryLimit} placeholder="0" />
<div class="flex gap-4 items-end pb-1">
<div class="flex flex-col items-center gap-1">
<span class="text-xs font-medium text-[var(--text-tertiary)]">{$t('projectDetail.autoDeployLabel')}</span>
<ToggleSwitch bind:checked={editStageAutoDeploy} label={$t('projectDetail.autoDeployLabel')} />
</div>
<div class="flex flex-col items-center gap-1">
<span class="text-xs font-medium text-[var(--text-tertiary)]">{$t('projectDetail.enableProxy')}</span>
<ToggleSwitch bind:checked={editStageEnableProxy} label={$t('projectDetail.enableProxy')} />
</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
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"
disabled={!deployTag.trim() || deployLoading}
onclick={handleDeploy}
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={() => openTagPicker(stage.id)}
>
{deployLoading ? $t('projectDetail.deploying') : $t('projectDetail.deploy')}
<IconDeploy size={14} />
{$t('projectDetail.deployNewVersion')}
</button>
<button
type="button"
class="rounded-lg px-3 py-2 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card)] transition-colors"
onclick={() => { deployStageId = ''; }}
title={$t('common.edit')}
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
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>
{#if deployError}
<p class="mt-2 text-xs text-[var(--color-danger)]">{deployError}</p>
@@ -671,6 +812,20 @@
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
bind:open={accessListPickerOpen}
items={accessListPickerItems}
@@ -679,4 +834,14 @@
onselect={handleProjectAccessListSelect}
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}
+154 -37
View File
@@ -31,8 +31,68 @@
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);
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() {
if (stages.length === 0) loading = true;
error = '';
@@ -169,41 +229,42 @@
<p class="text-sm text-[var(--color-danger)]">{error}</p>
</div>
{:else}
<!-- Stage selector -->
<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>
<!-- Project-level env -->
{#if stages.length === 0}
<EmptyState title={$t('envEditor.noStages')} icon="instances" />
{:else}
<!-- Project-level env -->
{#if Object.keys(projectEnv).length > 0}
<div>
<h2 class="text-sm font-semibold text-[var(--text-secondary)]">{$t('envEditor.projectDefaults')}</h2>
<div class="mt-2 overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card-hover)]">
<table class="min-w-full divide-y divide-[var(--border-primary)]">
<thead>
<tr>
<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.value')}</th>
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.source')}</th>
</tr>
</thead>
<tbody class="divide-y divide-[var(--border-secondary)]">
{#each Object.entries(projectEnv) as [key, value] (key)}
<tr class={isOverridden(key) ? 'opacity-50' : ''}>
<div>
<h2 class="text-sm font-semibold text-[var(--text-secondary)]">{$t('envEditor.projectDefaults')}</h2>
<div class="mt-2 overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
<table class="min-w-full divide-y divide-[var(--border-primary)]">
<thead class="bg-[var(--surface-card-hover)]">
<tr>
<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.value')}</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-right text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.actions')}</th>
</tr>
</thead>
<tbody class="divide-y divide-[var(--border-secondary)]">
{#each Object.entries(projectEnv) as [key, val] (key)}
{#if editingProjectKey === key}
<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="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">
{#if isOverridden(key)}
<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>
{/if}
</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>
{/each}
</tbody>
</table>
</div>
{/if}
{/each}
<!-- 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>
{/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 -->
<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}
<div class="mt-4 flex items-center justify-center gap-2 py-8 text-[var(--text-tertiary)]">
@@ -346,3 +449,17 @@
}}
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; }}
/>