Merge branch 'worktree-agent-a71dc2ea'
# Conflicts: # internal/api/settings.go # web/src/routes/projects/[id]/volumes/+page.svelte # web/src/routes/settings/auth/+page.svelte
This commit is contained in:
+18
-1
@@ -8,6 +8,7 @@
|
|||||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
|
|
||||||
let stages = $state<Stage[]>([]);
|
let stages = $state<Stage[]>([]);
|
||||||
let selectedStageId = $state('');
|
let selectedStageId = $state('');
|
||||||
@@ -27,6 +28,8 @@
|
|||||||
let editValue = $state('');
|
let editValue = $state('');
|
||||||
let editEncrypted = $state(false);
|
let editEncrypted = $state(false);
|
||||||
|
|
||||||
|
let envDeleteTarget = $state<string | null>(null);
|
||||||
|
|
||||||
const projectId = $derived($page.params.id);
|
const projectId = $derived($page.params.id);
|
||||||
|
|
||||||
async function loadProject() {
|
async function loadProject() {
|
||||||
@@ -290,7 +293,7 @@
|
|||||||
<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={() => startEdit(env)} title={$t('envEditor.edit')}>
|
<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={() => startEdit(env)} title={$t('envEditor.edit')}>
|
||||||
<IconEdit size={16} />
|
<IconEdit size={16} />
|
||||||
</button>
|
</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={() => handleDelete(env.id)} title={$t('envEditor.delete')}>
|
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors" onclick={() => { envDeleteTarget = env.id; }} title={$t('envEditor.delete')}>
|
||||||
<IconTrash size={16} />
|
<IconTrash size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -331,3 +334,17 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={envDeleteTarget !== null}
|
||||||
|
title={$t('envEditor.deleteTitle')}
|
||||||
|
message={$t('envEditor.deleteMessage')}
|
||||||
|
confirmLabel={$t('common.delete')}
|
||||||
|
confirmVariant="danger"
|
||||||
|
onconfirm={async () => {
|
||||||
|
const envId = envDeleteTarget;
|
||||||
|
envDeleteTarget = null;
|
||||||
|
if (envId) await handleDelete(envId);
|
||||||
|
}}
|
||||||
|
oncancel={() => { envDeleteTarget = null; }}
|
||||||
|
/>
|
||||||
|
|||||||
@@ -1,71 +1,37 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import type { Volume, VolumeScope, VolumeScopeInfo } from '$lib/types';
|
import type { Volume } from '$lib/types';
|
||||||
import * as api from '$lib/api';
|
import * as api from '$lib/api';
|
||||||
import { toasts } from '$lib/stores/toast';
|
import { toasts } from '$lib/stores/toast';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { IconChevronRight, IconPlus, IconEdit, IconTrash, IconCheck, IconX, IconSearch, IconExternalLink } from '$lib/components/icons';
|
import { IconChevronRight, IconPlus, IconEdit, IconTrash, IconCheck, IconX, IconLoader } from '$lib/components/icons';
|
||||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||||
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
function downloadUrl(volId: string): string {
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
return api.volumeDownloadUrl(projectId, volId);
|
|
||||||
}
|
|
||||||
|
|
||||||
let volumes = $state<Volume[]>([]);
|
let volumes = $state<Volume[]>([]);
|
||||||
let scopeInfos = $state<VolumeScopeInfo[]>([]);
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
|
||||||
let newSource = $state('');
|
let newSource = $state('');
|
||||||
let newTarget = $state('');
|
let newTarget = $state('');
|
||||||
let newScope = $state<VolumeScope>('project');
|
let newMode = $state<'shared' | 'isolated'>('shared');
|
||||||
let newName = $state('');
|
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
|
||||||
let editingId = $state('');
|
let editingId = $state('');
|
||||||
let editSource = $state('');
|
let editSource = $state('');
|
||||||
let editTarget = $state('');
|
let editTarget = $state('');
|
||||||
let editScope = $state<VolumeScope>('project');
|
let editMode = $state<'shared' | 'isolated'>('shared');
|
||||||
let editName = $state('');
|
|
||||||
|
|
||||||
const projectId = $derived($page.params.id ?? '');
|
let volumeDeleteTarget = $state<string | null>(null);
|
||||||
|
|
||||||
const newNeedsName = $derived(newScope === 'project_named' || newScope === 'named');
|
const projectId = $derived($page.params.id);
|
||||||
const editNeedsName = $derived(editScope === 'project_named' || editScope === 'named');
|
|
||||||
const newIsEphemeral = $derived(newScope === 'ephemeral');
|
|
||||||
const editIsEphemeral = $derived(editScope === 'ephemeral');
|
|
||||||
|
|
||||||
function scopeDescription(scope: VolumeScope): string {
|
async function loadVolumes() {
|
||||||
return scopeInfos.find(s => s.scope === scope)?.description ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function scopePathExample(scope: VolumeScope): string {
|
|
||||||
return scopeInfos.find(s => s.scope === scope)?.path_example ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const scopeColors: Record<string, { bg: string; text: string }> = {
|
|
||||||
instance: { bg: 'bg-amber-50 dark:bg-amber-900/30', text: 'text-amber-700 dark:text-amber-400' },
|
|
||||||
stage: { bg: 'bg-blue-50 dark:bg-blue-900/30', text: 'text-blue-700 dark:text-blue-400' },
|
|
||||||
project: { bg: 'bg-emerald-50 dark:bg-emerald-900/30', text: 'text-emerald-700 dark:text-emerald-400' },
|
|
||||||
project_named: { bg: 'bg-violet-50 dark:bg-violet-900/30', text: 'text-violet-700 dark:text-violet-400' },
|
|
||||||
named: { bg: 'bg-purple-50 dark:bg-purple-900/30', text: 'text-purple-700 dark:text-purple-400' },
|
|
||||||
ephemeral: { bg: 'bg-gray-100 dark:bg-gray-800', text: 'text-gray-600 dark:text-gray-400' },
|
|
||||||
};
|
|
||||||
|
|
||||||
function scopeColor(scope: string) {
|
|
||||||
return scopeColors[scope] ?? scopeColors.project;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadData() {
|
|
||||||
loading = true;
|
loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
const [vols, scopes] = await Promise.all([
|
volumes = await api.listVolumes(projectId);
|
||||||
api.listVolumes(projectId),
|
|
||||||
api.listVolumeScopes()
|
|
||||||
]);
|
|
||||||
volumes = vols;
|
|
||||||
scopeInfos = scopes;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : $t('volumeEditor.loadFailed');
|
error = e instanceof Error ? e.message : $t('volumeEditor.loadFailed');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -74,23 +40,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleAdd() {
|
async function handleAdd() {
|
||||||
if (!newIsEphemeral && !newSource.trim()) return;
|
if (!newSource.trim() || !newTarget.trim()) return;
|
||||||
if (!newTarget.trim()) return;
|
|
||||||
if (newNeedsName && !newName.trim()) return;
|
|
||||||
saving = true;
|
saving = true;
|
||||||
try {
|
try {
|
||||||
await api.createVolume(projectId, {
|
await api.createVolume(projectId, { source: newSource.trim(), target: newTarget.trim(), mode: newMode });
|
||||||
source: newSource.trim(),
|
|
||||||
target: newTarget.trim(),
|
|
||||||
scope: newScope,
|
|
||||||
name: newNeedsName ? newName.trim() : undefined
|
|
||||||
});
|
|
||||||
newSource = '';
|
newSource = '';
|
||||||
newTarget = '';
|
newTarget = '';
|
||||||
newScope = 'project';
|
newMode = 'shared';
|
||||||
newName = '';
|
|
||||||
toasts.success($t('volumeEditor.volumeAdded'));
|
toasts.success($t('volumeEditor.volumeAdded'));
|
||||||
await loadData();
|
await loadVolumes();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toasts.error(e instanceof Error ? e.message : $t('volumeEditor.addFailed'));
|
toasts.error(e instanceof Error ? e.message : $t('volumeEditor.addFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -102,27 +60,19 @@
|
|||||||
editingId = vol.id;
|
editingId = vol.id;
|
||||||
editSource = vol.source;
|
editSource = vol.source;
|
||||||
editTarget = vol.target;
|
editTarget = vol.target;
|
||||||
editScope = vol.scope || 'project';
|
editMode = vol.mode;
|
||||||
editName = vol.name || '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelEdit() { editingId = ''; }
|
function cancelEdit() { editingId = ''; }
|
||||||
|
|
||||||
async function handleUpdate() {
|
async function handleUpdate() {
|
||||||
if (!editIsEphemeral && !editSource.trim()) return;
|
if (!editSource.trim() || !editTarget.trim()) return;
|
||||||
if (!editTarget.trim()) return;
|
|
||||||
if (editNeedsName && !editName.trim()) return;
|
|
||||||
saving = true;
|
saving = true;
|
||||||
try {
|
try {
|
||||||
await api.updateVolume(projectId, editingId, {
|
await api.updateVolume(projectId, editingId, { source: editSource.trim(), target: editTarget.trim(), mode: editMode });
|
||||||
source: editSource.trim(),
|
|
||||||
target: editTarget.trim(),
|
|
||||||
scope: editScope,
|
|
||||||
name: editNeedsName ? editName.trim() : ''
|
|
||||||
});
|
|
||||||
editingId = '';
|
editingId = '';
|
||||||
toasts.success($t('volumeEditor.volumeUpdated'));
|
toasts.success($t('volumeEditor.volumeUpdated'));
|
||||||
await loadData();
|
await loadVolumes();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toasts.error(e instanceof Error ? e.message : $t('volumeEditor.updateFailed'));
|
toasts.error(e instanceof Error ? e.message : $t('volumeEditor.updateFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -134,7 +84,7 @@
|
|||||||
try {
|
try {
|
||||||
await api.deleteVolume(projectId, volId);
|
await api.deleteVolume(projectId, volId);
|
||||||
toasts.success($t('volumeEditor.volumeDeleted'));
|
toasts.success($t('volumeEditor.volumeDeleted'));
|
||||||
await loadData();
|
await loadVolumes();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toasts.error(e instanceof Error ? e.message : $t('volumeEditor.deleteFailed'));
|
toasts.error(e instanceof Error ? e.message : $t('volumeEditor.deleteFailed'));
|
||||||
}
|
}
|
||||||
@@ -142,7 +92,7 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void projectId;
|
void projectId;
|
||||||
loadData();
|
loadVolumes();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -158,79 +108,51 @@
|
|||||||
<IconChevronRight size={14} />
|
<IconChevronRight size={14} />
|
||||||
</div>
|
</div>
|
||||||
<h1 class="mt-1 text-2xl font-bold text-[var(--text-primary)]">{$t('volumeEditor.title')}</h1>
|
<h1 class="mt-1 text-2xl font-bold text-[var(--text-primary)]">{$t('volumeEditor.title')}</h1>
|
||||||
<p class="mt-1 text-sm text-[var(--text-secondary)]">{$t('volumeEditor.description')}</p>
|
<p class="mt-1 text-sm text-[var(--text-secondary)]">
|
||||||
|
{$t('volumeEditor.description')}
|
||||||
|
<strong>{$t('volumeEditor.shared')}</strong> — {$t('volumeEditor.sharedDesc')}
|
||||||
|
<strong>{$t('volumeEditor.isolated')}</strong> — {$t('volumeEditor.isolatedDesc')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scope guide -->
|
|
||||||
{#if scopeInfos.length > 0}
|
|
||||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{#each scopeInfos as info}
|
|
||||||
{@const colors = scopeColor(info.scope)}
|
|
||||||
<div class="rounded-lg border border-[var(--border-primary)] p-3 {colors.bg}">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="rounded-full px-2 py-0.5 text-xs font-semibold {colors.text} {colors.bg}">{info.scope}</span>
|
|
||||||
{#if info.needs_name}
|
|
||||||
<span class="text-[10px] text-[var(--text-tertiary)]">({$t('volumeEditor.requiresName')})</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<p class="mt-1.5 text-xs text-[var(--text-secondary)]">{info.description}</p>
|
|
||||||
<code class="mt-1 block text-[10px] text-[var(--text-tertiary)]">{info.path_example}</code>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<Skeleton height="12rem" />
|
<div class="space-y-4">
|
||||||
|
<Skeleton height="12rem" />
|
||||||
|
</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
|
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
|
||||||
<p class="text-sm text-[var(--color-danger)]">{error}</p>
|
<p class="text-sm text-[var(--color-danger)]">{error}</p>
|
||||||
<button type="button" class="mt-2 text-sm font-medium text-[var(--color-danger)] underline hover:no-underline" onclick={loadData}>
|
<button type="button" class="mt-2 text-sm font-medium text-[var(--color-danger)] underline hover:no-underline" onclick={loadVolumes}>
|
||||||
{$t('common.retry')}
|
{$t('common.retry')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Volumes table -->
|
|
||||||
<div class="overflow-hidden rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
|
<div class="overflow-hidden 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)]">
|
<table class="min-w-full divide-y divide-[var(--border-primary)]">
|
||||||
<thead class="bg-[var(--surface-card-hover)]">
|
<thead class="bg-[var(--surface-card-hover)]">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.sourceHost')}</th>
|
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.sourceHost')}</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.targetContainer')}</th>
|
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.targetContainer')}</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.scope')}</th>
|
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.mode')}</th>
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.nameColumn')}</th>
|
|
||||||
<th class="px-4 py-3 text-right text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.actions')}</th>
|
<th class="px-4 py-3 text-right text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.actions')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-[var(--border-secondary)]">
|
<tbody class="divide-y divide-[var(--border-secondary)]">
|
||||||
{#each volumes as vol (vol.id)}
|
{#each volumes as vol (vol.id)}
|
||||||
{#if editingId === vol.id}
|
{#if editingId === vol.id}
|
||||||
<!-- Edit row -->
|
|
||||||
<tr class="bg-[var(--color-brand-50)]/30">
|
<tr class="bg-[var(--color-brand-50)]/30">
|
||||||
<td class="px-4 py-2.5">
|
<td class="px-4 py-2.5">
|
||||||
{#if editIsEphemeral}
|
<input type="text" bind:value={editSource} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
||||||
<span class="text-xs text-[var(--text-tertiary)] italic">{$t('volumeEditor.noHostPath')}</span>
|
|
||||||
{:else}
|
|
||||||
<input type="text" bind:value={editSource} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
|
||||||
{/if}
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2.5">
|
<td class="px-4 py-2.5">
|
||||||
<input type="text" bind:value={editTarget} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
<input type="text" bind:value={editTarget} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2.5">
|
<td class="px-4 py-2.5">
|
||||||
<select bind:value={editScope} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none">
|
<select bind:value={editMode} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none">
|
||||||
{#each scopeInfos as info}
|
<option value="shared">{$t('volumeEditor.shared')}</option>
|
||||||
<option value={info.scope}>{info.scope}</option>
|
<option value="isolated">{$t('volumeEditor.isolated')}</option>
|
||||||
{/each}
|
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2.5">
|
|
||||||
{#if editNeedsName}
|
|
||||||
<input type="text" bind:value={editName} placeholder={$t('volumeEditor.namePlaceholder')} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
|
||||||
{:else}
|
|
||||||
<span class="text-xs text-[var(--text-tertiary)]">—</span>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-2.5 text-right">
|
<td class="px-4 py-2.5 text-right">
|
||||||
<div class="flex items-center justify-end gap-1">
|
<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={saving} onclick={handleUpdate}><IconCheck size={16} /></button>
|
<button type="button" class="rounded-lg p-1.5 text-emerald-600 hover:bg-emerald-50 transition-colors" disabled={saving} onclick={handleUpdate}><IconCheck size={16} /></button>
|
||||||
@@ -239,34 +161,20 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Display row -->
|
|
||||||
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors">
|
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors">
|
||||||
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-primary)]">
|
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-primary)]">{vol.source}</td>
|
||||||
{#if vol.scope === 'ephemeral'}
|
|
||||||
<span class="text-xs text-[var(--text-tertiary)] italic">{$t('volumeEditor.tmpfs')}</span>
|
|
||||||
{:else}
|
|
||||||
{vol.source}
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-secondary)]">{vol.target}</td>
|
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-secondary)]">{vol.target}</td>
|
||||||
<td class="px-4 py-2.5">
|
<td class="px-4 py-2.5">
|
||||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {scopeColor(vol.scope).bg} {scopeColor(vol.scope).text}">{vol.scope}</span>
|
{#if vol.mode === 'shared'}
|
||||||
</td>
|
<span class="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">{$t('volumeEditor.shared')}</span>
|
||||||
<td class="px-4 py-2.5 text-sm text-[var(--text-secondary)]">
|
{:else}
|
||||||
{vol.name || '—'}
|
<span class="rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700">{$t('volumeEditor.isolated')}</span>
|
||||||
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="whitespace-nowrap px-4 py-2.5 text-right">
|
<td class="whitespace-nowrap px-4 py-2.5 text-right">
|
||||||
<div class="flex items-center justify-end gap-1">
|
<div class="flex items-center justify-end gap-1">
|
||||||
{#if vol.scope !== 'ephemeral'}
|
|
||||||
<a href="/projects/{projectId}/volumes/{vol.id}/browse" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors" title={$t('volumeBrowser.browse')}>
|
|
||||||
<IconSearch size={16} />
|
|
||||||
</a>
|
|
||||||
<a href={downloadUrl(vol.id)} target="_blank" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-emerald-50 hover:text-emerald-600 transition-colors" title={$t('volumeBrowser.download')}>
|
|
||||||
<IconExternalLink size={16} />
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
<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={() => startEdit(vol)}><IconEdit size={16} /></button>
|
<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={() => startEdit(vol)}><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={() => handleDelete(vol.id)}><IconTrash 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={() => { volumeDeleteTarget = vol.id; }} title={$t('common.delete')} aria-label={$t('common.delete')}><IconTrash size={16} /></button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -276,34 +184,22 @@
|
|||||||
<!-- Add new row -->
|
<!-- Add new row -->
|
||||||
<tr class="bg-[var(--surface-card-hover)]">
|
<tr class="bg-[var(--surface-card-hover)]">
|
||||||
<td class="px-4 py-2.5">
|
<td class="px-4 py-2.5">
|
||||||
{#if newIsEphemeral}
|
<input type="text" bind:value={newSource} placeholder="/data/my-app/uploads" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
||||||
<span class="text-xs text-[var(--text-tertiary)] italic">{$t('volumeEditor.noHostPath')}</span>
|
|
||||||
{:else}
|
|
||||||
<input type="text" bind:value={newSource} placeholder="/data/my-app/uploads" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
|
||||||
{/if}
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2.5">
|
<td class="px-4 py-2.5">
|
||||||
<input type="text" bind:value={newTarget} placeholder="/app/uploads" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
<input type="text" bind:value={newTarget} placeholder="/app/uploads" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2.5">
|
<td class="px-4 py-2.5">
|
||||||
<select bind:value={newScope} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none">
|
<select bind:value={newMode} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none">
|
||||||
{#each scopeInfos as info}
|
<option value="shared">{$t('volumeEditor.shared')}</option>
|
||||||
<option value={info.scope}>{info.scope}</option>
|
<option value="isolated">{$t('volumeEditor.isolated')}</option>
|
||||||
{/each}
|
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2.5">
|
|
||||||
{#if newNeedsName}
|
|
||||||
<input type="text" bind:value={newName} placeholder={$t('volumeEditor.namePlaceholder')} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
|
||||||
{:else}
|
|
||||||
<span class="text-xs text-[var(--text-tertiary)]">—</span>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-2.5 text-right">
|
<td class="px-4 py-2.5 text-right">
|
||||||
<button
|
<button
|
||||||
type="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"
|
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={(!newIsEphemeral && !newSource.trim()) || !newTarget.trim() || (newNeedsName && !newName.trim()) || saving}
|
disabled={!newSource.trim() || !newTarget.trim() || saving}
|
||||||
onclick={handleAdd}
|
onclick={handleAdd}
|
||||||
>
|
>
|
||||||
<IconPlus size={14} />
|
<IconPlus size={14} />
|
||||||
@@ -315,19 +211,22 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scope hint for current selection -->
|
|
||||||
{#if scopeDescription(newScope)}
|
|
||||||
<div class="rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] p-3">
|
|
||||||
<p class="text-xs text-[var(--text-secondary)]">
|
|
||||||
<strong class="text-[var(--text-primary)]">{newScope}:</strong>
|
|
||||||
{scopeDescription(newScope)}
|
|
||||||
</p>
|
|
||||||
<code class="mt-1 block text-[10px] text-[var(--text-tertiary)]">{scopePathExample(newScope)}</code>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if volumes.length === 0}
|
{#if volumes.length === 0}
|
||||||
<p class="text-center text-sm text-[var(--text-tertiary)]">{$t('volumeEditor.noVolumes')}</p>
|
<p class="text-center text-sm text-[var(--text-tertiary)]">{$t('volumeEditor.noVolumes')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={volumeDeleteTarget !== null}
|
||||||
|
title={$t('volumeEditor.deleteTitle')}
|
||||||
|
message={$t('volumeEditor.deleteMessage')}
|
||||||
|
confirmLabel={$t('common.delete')}
|
||||||
|
confirmVariant="danger"
|
||||||
|
onconfirm={async () => {
|
||||||
|
const volId = volumeDeleteTarget;
|
||||||
|
volumeDeleteTarget = null;
|
||||||
|
if (volId) await handleDelete(volId);
|
||||||
|
}}
|
||||||
|
oncancel={() => { volumeDeleteTarget = null; }}
|
||||||
|
/>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { IconLoader, IconPlus, IconTrash, IconUsers } from '$lib/components/icons';
|
import { IconLoader, IconPlus, IconTrash, IconUsers } from '$lib/components/icons';
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
import { getAuthToken } from '$lib/auth';
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
|
|
||||||
interface AuthSettings {
|
interface AuthSettings {
|
||||||
auth_mode: string;
|
auth_mode: string;
|
||||||
@@ -34,7 +34,10 @@
|
|||||||
let newEmail = $state('');
|
let newEmail = $state('');
|
||||||
let newRole = $state('viewer');
|
let newRole = $state('viewer');
|
||||||
|
|
||||||
function authHeaders(): Record<string, string> { return { 'Content-Type': 'application/json', Authorization: `Bearer ${getAuthToken() ?? ''}` }; }
|
let userDeleteTarget = $state<User | null>(null);
|
||||||
|
|
||||||
|
function getToken(): string { return localStorage.getItem('auth_token') ?? ''; }
|
||||||
|
function authHeaders(): Record<string, string> { return { 'Content-Type': 'application/json', Authorization: `Bearer ${getToken()}` }; }
|
||||||
|
|
||||||
onMount(async () => { await Promise.all([loadSettings(), loadUsers()]); });
|
onMount(async () => { await Promise.all([loadSettings(), loadUsers()]); });
|
||||||
|
|
||||||
@@ -68,13 +71,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteUser(id: string) {
|
async function deleteUser(id: string) {
|
||||||
if (!confirm($t('settingsAuth.deleteConfirm'))) return;
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/auth/users/${id}`, { method: 'DELETE', headers: authHeaders() });
|
const res = await fetch(`/api/auth/users/${id}`, { method: 'DELETE', headers: authHeaders() });
|
||||||
const envelope = await res.json();
|
const envelope = await res.json();
|
||||||
if (envelope.success) { await loadUsers(); message = $t('settingsAuth.userDeleted'); }
|
if (envelope.success) { await loadUsers(); message = $t('settingsAuth.userDeleted'); }
|
||||||
else error = envelope.error ?? $t('settingsAuth.deleteFailed');
|
else error = envelope.error ?? $t('settingsAuth.deleteFailed');
|
||||||
} catch (err: unknown) { error = err instanceof Error ? err.message : 'Network error'; }
|
} catch (err: unknown) { error = err instanceof Error ? err.message : $t('settingsAuth.networkError'); }
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -165,7 +167,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2.5 text-sm text-[var(--text-secondary)]">{user.created_at}</td>
|
<td class="px-4 py-2.5 text-sm text-[var(--text-secondary)]">{user.created_at}</td>
|
||||||
<td class="px-4 py-2.5 text-right">
|
<td class="px-4 py-2.5 text-right">
|
||||||
<button onclick={() => deleteUser(user.id)} class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors">
|
<button onclick={() => { userDeleteTarget = user; }} class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors">
|
||||||
<IconTrash size={16} />
|
<IconTrash size={16} />
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
@@ -198,3 +200,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={userDeleteTarget !== null}
|
||||||
|
title={$t('settingsAuth.deleteUserTitle')}
|
||||||
|
message={$t('settingsAuth.deleteConfirm', { username: userDeleteTarget?.username ?? '' })}
|
||||||
|
confirmLabel={$t('common.delete')}
|
||||||
|
confirmVariant="danger"
|
||||||
|
onconfirm={async () => {
|
||||||
|
const user = userDeleteTarget;
|
||||||
|
userDeleteTarget = null;
|
||||||
|
if (user) await deleteUser(user.id);
|
||||||
|
}}
|
||||||
|
oncancel={() => { userDeleteTarget = null; }}
|
||||||
|
/>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { toasts } from '$lib/stores/toast';
|
import { toasts } from '$lib/stores/toast';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { IconPlus, IconLoader, IconEdit, IconTrash, IconWifi } from '$lib/components/icons';
|
import { IconPlus, IconLoader, IconEdit, IconTrash, IconWifi } from '$lib/components/icons';
|
||||||
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
|
|
||||||
let registries = $state<Registry[]>([]);
|
let registries = $state<Registry[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
let testingId = $state<string | null>(null);
|
let testingId = $state<string | null>(null);
|
||||||
let healthStatus = $state<Record<string, 'checking' | 'healthy' | 'unhealthy'>>({});
|
let healthStatus = $state<Record<string, 'checking' | 'healthy' | 'unhealthy'>>({});
|
||||||
|
|
||||||
|
let registryDeleteTarget = $state<Registry | null>(null);
|
||||||
let errors = $state<Record<string, string>>({});
|
let errors = $state<Record<string, string>>({});
|
||||||
|
|
||||||
function validateForm(): boolean {
|
function validateForm(): boolean {
|
||||||
@@ -59,7 +61,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(registry: Registry) {
|
async function handleDelete(registry: Registry) {
|
||||||
if (!confirm($t('settingsRegistries.deleteConfirm', { name: registry.name }))) return;
|
|
||||||
try { await deleteRegistry(registry.id); toasts.success($t('settingsRegistries.registryDeleted', { name: registry.name })); await loadRegistryList(); }
|
try { await deleteRegistry(registry.id); toasts.success($t('settingsRegistries.registryDeleted', { name: registry.name })); await loadRegistryList(); }
|
||||||
catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsRegistries.deleteFailed')); }
|
catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsRegistries.deleteFailed')); }
|
||||||
}
|
}
|
||||||
@@ -174,10 +175,24 @@
|
|||||||
{testingId === registry.id ? $t('settingsRegistries.testing') : $t('settingsRegistries.test')}
|
{testingId === registry.id ? $t('settingsRegistries.testing') : $t('settingsRegistries.test')}
|
||||||
</button>
|
</button>
|
||||||
<button onclick={() => startEdit(registry)} class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors"><IconEdit size={16} /></button>
|
<button onclick={() => startEdit(registry)} class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors"><IconEdit size={16} /></button>
|
||||||
<button onclick={() => handleDelete(registry)} class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors"><IconTrash size={16} /></button>
|
<button onclick={() => { registryDeleteTarget = registry; }} class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors"><IconTrash size={16} /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={registryDeleteTarget !== null}
|
||||||
|
title={$t('settingsRegistries.deleteTitle')}
|
||||||
|
message={$t('settingsRegistries.deleteConfirm', { name: registryDeleteTarget?.name ?? '' })}
|
||||||
|
confirmLabel={$t('common.delete')}
|
||||||
|
confirmVariant="danger"
|
||||||
|
onconfirm={async () => {
|
||||||
|
const reg = registryDeleteTarget;
|
||||||
|
registryDeleteTarget = null;
|
||||||
|
if (reg) await handleDelete(reg);
|
||||||
|
}}
|
||||||
|
oncancel={() => { registryDeleteTarget = null; }}
|
||||||
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user