fix: UI/UX consistency overhaul — fix 8 bugs, standardize design system

Bug fixes:
- Backup refresh no longer re-renders entire page (separate refreshing state)
- SSL cert button no longer flickers when no certs available
- Volume mode selector rewritten to use proper scope system (7 scopes)
- Navigation flicker eliminated when returning from env/volumes pages
- Logout button moved to sidebar footer near theme/locale controls
- Subdomain pattern now shows variable hint tooltip ({project}, {stage}, etc.)
- SSL certificate selector moved to Credentials page with auto-save
- Projects page now has search/filter by name, image, or registry

Consistency improvements:
- New Breadcrumb component replaces 5 inline implementations
- New IconArrowLeft, IconChevronDown components replace inline SVGs
- All inline spinners replaced with IconLoader component
- 10 semantic badge classes with dark mode variants in tokens.css
- Global disabled button cursor-not-allowed rule
- Raw inputs in auth page replaced with FormField components
- Missing aria-labels added to icon-only buttons
- Error panels standardized to use design tokens
This commit is contained in:
2026-04-04 21:34:36 +03:00
parent 27ec23921d
commit 216bd7e2db
23 changed files with 494 additions and 251 deletions
+27
View File
@@ -0,0 +1,27 @@
<script lang="ts">
import { IconChevronRight } from '$lib/components/icons';
interface BreadcrumbItem {
label: string;
href?: string;
}
interface Props {
items: BreadcrumbItem[];
}
const { items }: Props = $props();
</script>
<nav aria-label="Breadcrumb" class="flex items-center gap-1.5 text-sm text-[var(--text-tertiary)]">
{#each items as item, i}
{#if i > 0}
<IconChevronRight size={14} />
{/if}
{#if item.href}
<a href={item.href} class="hover:text-[var(--text-link)] transition-colors">{item.label}</a>
{:else}
<span class="text-[var(--text-secondary)]">{item.label}</span>
{/if}
{/each}
</nav>
@@ -0,0 +1,7 @@
<script lang="ts">
interface Props { size?: number; class?: string; }
const { size = 20, class: c = '' }: Props = $props();
</script>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class={c} aria-hidden="true">
<path d="M19 12H5" /><path d="m12 19-7-7 7-7" />
</svg>
@@ -0,0 +1,7 @@
<script lang="ts">
interface Props { size?: number; class?: string; }
const { size = 20, class: c = '' }: Props = $props();
</script>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class={c} aria-hidden="true">
<polyline points="6 9 12 15 18 9" />
</svg>
+2
View File
@@ -48,3 +48,5 @@ export { default as IconRefresh } from './IconRefresh.svelte';
export { default as IconProxies } from './IconProxies.svelte'; export { default as IconProxies } from './IconProxies.svelte';
export { default as IconEvents } from './IconEvents.svelte'; export { default as IconEvents } from './IconEvents.svelte';
export { default as IconLogout } from './IconLogout.svelte'; export { default as IconLogout } from './IconLogout.svelte';
export { default as IconArrowLeft } from './IconArrowLeft.svelte';
export { default as IconChevronDown } from './IconChevronDown.svelte';
+9 -1
View File
@@ -58,7 +58,9 @@
"imageLoadFailed": "Failed to load images", "imageLoadFailed": "Failed to load images",
"alreadyAdded": "Already added", "alreadyAdded": "Already added",
"portHelpText": "Auto-detected from EXPOSE if empty", "portHelpText": "Auto-detected from EXPOSE if empty",
"healthcheckHelpText": "Auto-detected from image if empty" "healthcheckHelpText": "Auto-detected from image if empty",
"searchPlaceholder": "Search projects by name, image, or registry...",
"noMatchingProjects": "No projects match your search."
}, },
"projectDetail": { "projectDetail": {
"deleteProject": "Delete Project", "deleteProject": "Delete Project",
@@ -162,6 +164,7 @@
"save": "Save", "save": "Save",
"add": "Add", "add": "Add",
"adding": "Adding...", "adding": "Adding...",
"scopeGuide": "Volume Scopes",
"noVolumes": "No volumes configured yet. Add one above.", "noVolumes": "No volumes configured yet. Add one above.",
"volumeAdded": "Volume added", "volumeAdded": "Volume added",
"volumeUpdated": "Volume updated", "volumeUpdated": "Volume updated",
@@ -258,6 +261,11 @@
"dockerNetworkHelp": "Docker network for deployed containers", "dockerNetworkHelp": "Docker network for deployed containers",
"subdomainPattern": "Subdomain Pattern", "subdomainPattern": "Subdomain Pattern",
"subdomainPatternHelp": "Pattern for auto-generated subdomains", "subdomainPatternHelp": "Pattern for auto-generated subdomains",
"subdomainVarsTitle": "Available variables",
"varProject": "Project name",
"varStage": "Stage name",
"varTag": "Image tag",
"varPort": "Container port",
"pollingInterval": "Polling Interval (seconds)", "pollingInterval": "Polling Interval (seconds)",
"pollingIntervalHelp": "How often to check registries for new tags (10-86400)", "pollingIntervalHelp": "How often to check registries for new tags (10-86400)",
"notificationUrl": "Notification URL", "notificationUrl": "Notification URL",
+9 -1
View File
@@ -58,7 +58,9 @@
"imageLoadFailed": "Не удалось загрузить образы", "imageLoadFailed": "Не удалось загрузить образы",
"alreadyAdded": "Уже добавлен", "alreadyAdded": "Уже добавлен",
"portHelpText": "Автоопределение из EXPOSE, если пусто", "portHelpText": "Автоопределение из EXPOSE, если пусто",
"healthcheckHelpText": "Автоопределение из образа, если пусто" "healthcheckHelpText": "Автоопределение из образа, если пусто",
"searchPlaceholder": "Поиск по имени, образу или реестру...",
"noMatchingProjects": "Проекты не найдены."
}, },
"projectDetail": { "projectDetail": {
"deleteProject": "Удалить проект", "deleteProject": "Удалить проект",
@@ -162,6 +164,7 @@
"save": "Сохранить", "save": "Сохранить",
"add": "Добавить", "add": "Добавить",
"adding": "Добавление...", "adding": "Добавление...",
"scopeGuide": "Области видимости томов",
"noVolumes": "Тома ещё не настроены. Добавьте один выше.", "noVolumes": "Тома ещё не настроены. Добавьте один выше.",
"volumeAdded": "Том добавлен", "volumeAdded": "Том добавлен",
"volumeUpdated": "Том обновлён", "volumeUpdated": "Том обновлён",
@@ -258,6 +261,11 @@
"dockerNetworkHelp": "Docker-сеть для развёрнутых контейнеров", "dockerNetworkHelp": "Docker-сеть для развёрнутых контейнеров",
"subdomainPattern": "Шаблон поддомена", "subdomainPattern": "Шаблон поддомена",
"subdomainPatternHelp": "Шаблон для автоматически генерируемых поддоменов", "subdomainPatternHelp": "Шаблон для автоматически генерируемых поддоменов",
"subdomainVarsTitle": "Доступные переменные",
"varProject": "Имя проекта",
"varStage": "Имя стадии",
"varTag": "Тег образа",
"varPort": "Порт контейнера",
"pollingInterval": "Интервал опроса (секунды)", "pollingInterval": "Интервал опроса (секунды)",
"pollingIntervalHelp": "Как часто проверять реестры на новые теги (10-86400)", "pollingIntervalHelp": "Как часто проверять реестры на новые теги (10-86400)",
"notificationUrl": "URL уведомлений", "notificationUrl": "URL уведомлений",
+33
View File
@@ -210,6 +210,13 @@
animation: button-press 150ms ease-in-out; animation: button-press 150ms ease-in-out;
} }
/* ── Disabled Buttons ────────────────────────────────────────────── */
button:disabled,
a[aria-disabled="true"] {
cursor: not-allowed;
}
/* ── Skeleton Loader ──────────────────────────────────────────────── */ /* ── Skeleton Loader ──────────────────────────────────────────────── */
.skeleton { .skeleton {
@@ -226,6 +233,32 @@
/* ── Toggle Switch ────────────────────────────────────────────────── */ /* ── Toggle Switch ────────────────────────────────────────────────── */
/* ── Badge Tokens ────────────────────────────────────────────────── */
.badge-success { background: #ecfdf5; color: #047857; }
.badge-warning { background: #fffbeb; color: #b45309; }
.badge-danger { background: #fef2f2; color: #dc2626; }
.badge-info { background: #eff6ff; color: #2563eb; }
.badge-purple { background: #faf5ff; color: #7c3aed; }
.badge-cyan { background: #ecfeff; color: #0e7490; }
.badge-gray { background: #f3f4f6; color: #4b5563; }
.badge-amber { background: #fffbeb; color: #b45309; }
.badge-indigo { background: #eef2ff; color: #4f46e5; }
.badge-rose { background: #fff1f2; color: #e11d48; }
[data-theme="dark"] .badge-success { background: rgba(6, 78, 59, 0.3); color: #34d399; }
[data-theme="dark"] .badge-warning { background: rgba(120, 53, 15, 0.3); color: #fbbf24; }
[data-theme="dark"] .badge-danger { background: rgba(127, 29, 29, 0.3); color: #f87171; }
[data-theme="dark"] .badge-info { background: rgba(30, 58, 138, 0.3); color: #60a5fa; }
[data-theme="dark"] .badge-purple { background: rgba(76, 29, 149, 0.3); color: #a78bfa; }
[data-theme="dark"] .badge-cyan { background: rgba(14, 116, 144, 0.3); color: #22d3ee; }
[data-theme="dark"] .badge-gray { background: rgba(55, 65, 81, 0.5); color: #9ca3af; }
[data-theme="dark"] .badge-amber { background: rgba(120, 53, 15, 0.3); color: #fbbf24; }
[data-theme="dark"] .badge-indigo { background: rgba(67, 56, 202, 0.3); color: #818cf8; }
[data-theme="dark"] .badge-rose { background: rgba(159, 18, 57, 0.3); color: #fb7185; }
/* ── Toggle Switch ────────────────────────────────────────────────── */
.toggle-switch { .toggle-switch {
position: relative; position: relative;
width: 2.75rem; width: 2.75rem;
+16 -18
View File
@@ -6,7 +6,7 @@
import Toast from '$lib/components/Toast.svelte'; import Toast from '$lib/components/Toast.svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte'; import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import LocaleSwitcher from '$lib/components/LocaleSwitcher.svelte'; import LocaleSwitcher from '$lib/components/LocaleSwitcher.svelte';
import { IconDashboard, IconProjects, IconDeploy, IconSettings, IconMenu, IconX, IconLogout } from '$lib/components/icons'; import { IconDashboard, IconProjects, IconDeploy, IconSettings, IconMenu, IconX, IconLogout, IconChevronDown } from '$lib/components/icons';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { connectGlobalEvents, type SSEConnection } from '$lib/sse'; import { connectGlobalEvents, type SSEConnection } from '$lib/sse';
import { instanceStatusStore } from '$lib/stores/instance-status'; import { instanceStatusStore } from '$lib/stores/instance-status';
@@ -142,24 +142,9 @@
</div> </div>
<span class="text-base font-bold text-[var(--text-primary)]">{$t('app.name')}</span> <span class="text-base font-bold text-[var(--text-primary)]">{$t('app.name')}</span>
<!-- Logout button -->
<button
type="button"
title={$t('nav.logout')}
aria-label={$t('nav.logout')}
onclick={async () => {
try { await apiLogout(); } catch { /* best effort */ }
clearAuth();
goto('/login');
}}
class="ml-auto rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-[var(--color-danger)] transition-colors"
>
<IconLogout size={18} />
</button>
<!-- Close sidebar (mobile) --> <!-- Close sidebar (mobile) -->
<button <button
class="rounded-md p-1 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] lg:hidden" class="ml-auto rounded-md p-1 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] lg:hidden"
onclick={() => { sidebarOpen = false; }} onclick={() => { sidebarOpen = false; }}
aria-label="Close sidebar" aria-label="Close sidebar"
> >
@@ -213,7 +198,7 @@
</span> </span>
<span class="flex-1 text-left">Docker {dockerConnected ? $t('health.connected') : $t('health.disconnected')}</span> <span class="flex-1 text-left">Docker {dockerConnected ? $t('health.connected') : $t('health.disconnected')}</span>
{#if !dockerConnected && dockerHealth?.error} {#if !dockerConnected && dockerHealth?.error}
<svg class="h-3 w-3 transition-transform {hintsExpanded ? 'rotate-180' : ''}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg> <IconChevronDown size={12} class="transition-transform {hintsExpanded ? 'rotate-180' : ''}" />
{/if} {/if}
</button> </button>
{#if !dockerConnected && hintsExpanded && dockerHealth?.error} {#if !dockerConnected && hintsExpanded && dockerHealth?.error}
@@ -240,6 +225,19 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<ThemeToggle /> <ThemeToggle />
<LocaleSwitcher /> <LocaleSwitcher />
<button
type="button"
title={$t('nav.logout')}
aria-label={$t('nav.logout')}
onclick={async () => {
try { await apiLogout(); } catch { /* best effort */ }
clearAuth();
goto('/login');
}}
class="rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-[var(--color-danger)] transition-colors"
>
<IconLogout size={16} />
</button>
</div> </div>
<p class="text-xs text-[var(--text-tertiary)]">{$t('app.name')} {$t('app.version')}</p> <p class="text-xs text-[var(--text-tertiary)]">{$t('app.name')} {$t('app.version')}</p>
</div> </div>
+5 -5
View File
@@ -77,11 +77,11 @@
function statusColor(status: string): string { function statusColor(status: string): string {
switch (status) { switch (status) {
case 'synced': return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'; case 'synced': return 'badge-success';
case 'missing': return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'; case 'missing': return 'badge-danger';
case 'orphaned': return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'; case 'orphaned': return 'badge-warning';
case 'wildcard': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'; case 'wildcard': return 'badge-info';
default: return 'bg-gray-100 text-gray-700'; default: return 'badge-gray';
} }
} }
+3 -8
View File
@@ -11,6 +11,7 @@
import EventLogEntryComponent from '$lib/components/EventLogEntry.svelte'; import EventLogEntryComponent from '$lib/components/EventLogEntry.svelte';
import EventLogFilter from '$lib/components/EventLogFilter.svelte'; import EventLogFilter from '$lib/components/EventLogFilter.svelte';
import EmptyState from '$lib/components/EmptyState.svelte'; import EmptyState from '$lib/components/EmptyState.svelte';
import { IconLoader } from '$lib/components/icons';
// ── State ───────────────────────────────────────────────────── // ── State ─────────────────────────────────────────────────────
@@ -244,10 +245,7 @@
<!-- Event list --> <!-- Event list -->
{#if loading} {#if loading}
<div class="flex items-center justify-center py-16"> <div class="flex items-center justify-center py-16">
<svg class="h-5 w-5 animate-spin text-[var(--color-brand-500)]" viewBox="0 0 24 24" fill="none"> <IconLoader size={20} class="animate-spin text-[var(--color-brand-500)]" />
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div> </div>
{:else if filteredEvents.length === 0} {:else if filteredEvents.length === 0}
<EmptyState <EmptyState
@@ -279,10 +277,7 @@
disabled={loadingMore} disabled={loadingMore}
> >
{#if loadingMore} {#if loadingMore}
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none"> <IconLoader size={16} class="animate-spin" />
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{/if} {/if}
{$t('events.loadMore')} {$t('events.loadMore')}
</button> </button>
+30 -1
View File
@@ -12,6 +12,18 @@
let loading = $state(true); let loading = $state(true);
let error = $state(''); let error = $state('');
let showAddForm = $state(false); let showAddForm = $state(false);
let searchQuery = $state('');
const filteredProjects = $derived(
searchQuery.trim()
? projects.filter(p => {
const q = searchQuery.toLowerCase();
return p.name.toLowerCase().includes(q)
|| p.image.toLowerCase().includes(q)
|| (p.registry ?? '').toLowerCase().includes(q);
})
: projects
);
let formName = $state(''); let formName = $state('');
let formImage = $state(''); let formImage = $state('');
@@ -220,6 +232,22 @@
icon="projects" icon="projects"
/> />
{:else} {:else}
<!-- Search filter -->
<div class="relative">
<IconSearch size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)]" />
<input
type="text"
bind:value={searchQuery}
placeholder={$t('projects.searchPlaceholder')}
class="w-full rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] py-2.5 pl-10 pr-4 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]"
/>
</div>
{#if filteredProjects.length === 0}
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-8 text-center">
<p class="text-sm text-[var(--text-tertiary)]">{$t('projects.noMatchingProjects')}</p>
</div>
{:else}
<div class="overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]"> <div class="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)]"> <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)]">
@@ -233,7 +261,7 @@
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-[var(--border-secondary)]"> <tbody class="divide-y divide-[var(--border-secondary)]">
{#each projects as project (project.id)} {#each filteredProjects as project (project.id)}
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors duration-150"> <tr class="hover:bg-[var(--surface-card-hover)] transition-colors duration-150">
<td class="whitespace-nowrap px-6 py-4"> <td class="whitespace-nowrap px-6 py-4">
<a href="/projects/{project.id}" class="font-medium text-[var(--text-link)] hover:text-[var(--text-link-hover)] transition-colors"> <a href="/projects/{project.id}" class="font-medium text-[var(--text-link)] hover:text-[var(--text-link-hover)] transition-colors">
@@ -262,5 +290,6 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{/if}
{/if} {/if}
</div> </div>
+7 -9
View File
@@ -7,7 +7,8 @@
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, IconChevronRight, IconClock, IconTag, IconLoader, IconPlus, IconEdit, IconCheck, IconX } from '$lib/components/icons'; import { IconTrash, IconKey, IconHardDrive, IconDeploy, IconClock, IconLoader, IconPlus, IconEdit, IconCheck, IconX } from '$lib/components/icons';
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';
import { toasts } from '$lib/stores/toast'; import { toasts } from '$lib/stores/toast';
@@ -111,7 +112,7 @@
const projectId = $derived($page.params.id); const projectId = $derived($page.params.id);
async function loadProject() { async function loadProject() {
loading = true; if (!project) loading = true;
error = ''; error = '';
try { try {
const detail = await api.getProject(projectId); const detail = await api.getProject(projectId);
@@ -229,10 +230,7 @@
<!-- Header --> <!-- Header -->
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div> <div>
<div class="flex items-center gap-1.5 text-sm text-[var(--text-tertiary)]"> <Breadcrumb items={[{ label: $t('projects.title'), href: '/projects' }]} />
<a href="/projects" class="hover:text-[var(--text-link)] transition-colors">{$t('projects.title')}</a>
<IconChevronRight size={14} />
</div>
<h1 class="mt-1 text-2xl font-bold text-[var(--text-primary)]">{project.name}</h1> <h1 class="mt-1 text-2xl font-bold text-[var(--text-primary)]">{project.name}</h1>
<p class="mt-1 font-mono text-sm text-[var(--text-tertiary)]">{project.image}</p> <p class="mt-1 font-mono text-sm text-[var(--text-tertiary)]">{project.image}</p>
</div> </div>
@@ -385,13 +383,13 @@
<h3 class="text-base font-semibold text-[var(--text-primary)]">{stage.name}</h3> <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> <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} {#if stage.auto_deploy}
<span class="rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700">{$t('projectDetail.autoDeploy')}</span> <span class="rounded-full badge-success rounded-full px-2 py-0.5 text-xs font-medium">{$t('projectDetail.autoDeploy')}</span>
{/if} {/if}
{#if stage.confirm} {#if stage.confirm}
<span class="rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700">{$t('projectDetail.requiresConfirm')}</span> <span class="rounded-full badge-warning rounded-full px-2 py-0.5 text-xs font-medium">{$t('projectDetail.requiresConfirm')}</span>
{/if} {/if}
{#if !stage.enable_proxy} {#if !stage.enable_proxy}
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600">{$t('projectDetail.noProxy')}</span> <span class="rounded-full badge-gray rounded-full px-2 py-0.5 text-xs font-medium">{$t('projectDetail.noProxy')}</span>
{/if} {/if}
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
+9 -11
View File
@@ -4,7 +4,8 @@
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, IconLock, IconLoader } from '$lib/components/icons'; import { IconPlus, IconEdit, IconTrash, IconCheck, IconX, IconLock, IconLoader } from '$lib/components/icons';
import Breadcrumb from '$lib/components/Breadcrumb.svelte';
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';
@@ -33,7 +34,7 @@
const projectId = $derived($page.params.id); const projectId = $derived($page.params.id);
async function loadProject() { async function loadProject() {
loading = true; if (stages.length === 0) loading = true;
error = ''; error = '';
try { try {
const detail = await api.getProject(projectId); const detail = await api.getProject(projectId);
@@ -153,10 +154,7 @@
<div class="space-y-6"> <div class="space-y-6">
<!-- Header --> <!-- Header -->
<div> <div>
<div class="flex items-center gap-1.5 text-sm text-[var(--text-tertiary)]"> <Breadcrumb items={[{ label: $t('common.project'), href: `/projects/${projectId}` }]} />
<a href="/projects/{projectId}" class="hover:text-[var(--text-link)] transition-colors">{$t('common.project')}</a>
<IconChevronRight size={14} />
</div>
<h1 class="mt-1 text-2xl font-bold text-[var(--text-primary)]">{$t('envEditor.title')}</h1> <h1 class="mt-1 text-2xl font-bold text-[var(--text-primary)]">{$t('envEditor.title')}</h1>
<p class="mt-1 text-sm text-[var(--text-secondary)]">{$t('envEditor.description')}</p> <p class="mt-1 text-sm text-[var(--text-secondary)]">{$t('envEditor.description')}</p>
</div> </div>
@@ -208,9 +206,9 @@
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-secondary)]">{value}</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 text-sm"> <td class="px-4 py-2.5 text-sm">
{#if isOverridden(key)} {#if isOverridden(key)}
<span class="rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700">{$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>
{:else} {:else}
<span class="rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700">{$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>
</tr> </tr>
@@ -275,7 +273,7 @@
</td> </td>
<td class="px-4 py-2.5"> <td class="px-4 py-2.5">
{#if env.encrypted} {#if env.encrypted}
<span class="inline-flex items-center gap-1 rounded-full bg-purple-50 px-2 py-0.5 text-xs font-medium text-purple-700"> <span class="inline-flex items-center gap-1 rounded-full badge-purple rounded-full px-2 py-0.5 text-xs font-medium">
<IconLock size={12} /> <IconLock size={12} />
{$t('envEditor.secret')} {$t('envEditor.secret')}
</span> </span>
@@ -283,9 +281,9 @@
</td> </td>
<td class="px-4 py-2.5 text-sm"> <td class="px-4 py-2.5 text-sm">
{#if env.key in projectEnv} {#if env.key in projectEnv}
<span class="rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700">{$t('envEditor.overridesProject')}</span> <span class="rounded-full badge-warning rounded-full px-2 py-0.5 text-xs font-medium">{$t('envEditor.overridesProject')}</span>
{:else} {:else}
<span class="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">{$t('envEditor.stageOnly')}</span> <span class="rounded-full badge-info rounded-full px-2 py-0.5 text-xs font-medium">{$t('envEditor.stageOnly')}</span>
{/if} {/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">
+128 -38
View File
@@ -1,37 +1,67 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import type { Volume } from '$lib/types'; import type { Volume, VolumeScopeInfo, VolumeScope } 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, IconLoader } from '$lib/components/icons'; import { IconPlus, IconEdit, IconTrash, IconCheck, IconX, IconInfo } from '$lib/components/icons';
import Breadcrumb from '$lib/components/Breadcrumb.svelte';
import Skeleton from '$lib/components/Skeleton.svelte'; import Skeleton from '$lib/components/Skeleton.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
let volumes = $state<Volume[]>([]); let volumes = $state<Volume[]>([]);
let scopes = $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 newMode = $state<'shared' | 'isolated'>('shared'); let newScope = $state<VolumeScope>('project');
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 editMode = $state<'shared' | 'isolated'>('shared'); let editScope = $state<VolumeScope>('project');
let editName = $state('');
let volumeDeleteTarget = $state<string | null>(null); let volumeDeleteTarget = $state<string | null>(null);
const projectId = $derived($page.params.id); const projectId = $derived($page.params.id);
const newScopeNeedsName = $derived(scopes.find(s => s.scope === newScope)?.needs_name ?? false);
const editScopeNeedsName = $derived(scopes.find(s => s.scope === editScope)?.needs_name ?? false);
const newScopeIsEphemeral = $derived(newScope === 'ephemeral');
const editScopeIsEphemeral = $derived(editScope === 'ephemeral');
function scopeColor(scope: string): string {
switch (scope) {
case 'instance': return 'bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400';
case 'stage': return 'bg-cyan-50 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400';
case 'project': return 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400';
case 'project_named': return 'bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400';
case 'named': return 'bg-purple-50 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400';
case 'ephemeral': return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400';
case 'absolute': return 'bg-rose-50 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400';
default: return 'bg-gray-100 text-gray-600';
}
}
function scopeLabel(scope: string): string {
return scope.replaceAll('_', ' ');
}
async function loadVolumes() { async function loadVolumes() {
loading = true; if (volumes.length === 0) loading = true;
error = ''; error = '';
try { try {
volumes = await api.listVolumes(projectId); const [vols, scopeList] = await Promise.all([
api.listVolumes(projectId),
scopes.length === 0 ? api.listVolumeScopes() : Promise.resolve(scopes)
]);
volumes = vols;
scopes = scopeList;
} catch (e) { } catch (e) {
error = e instanceof Error ? e.message : $t('volumeEditor.loadFailed'); error = e instanceof Error ? e.message : $t('volumeEditor.loadFailed');
} finally { } finally {
@@ -40,13 +70,21 @@
} }
async function handleAdd() { async function handleAdd() {
if (!newSource.trim() || !newTarget.trim()) return; if (newScope !== 'ephemeral' && !newSource.trim()) return;
if (!newTarget.trim()) return;
if (newScopeNeedsName && !newName.trim()) return;
saving = true; saving = true;
try { try {
await api.createVolume(projectId, { source: newSource.trim(), target: newTarget.trim(), mode: newMode }); await api.createVolume(projectId, {
source: newSource.trim(),
target: newTarget.trim(),
scope: newScope,
name: newScopeNeedsName ? newName.trim() : undefined
});
newSource = ''; newSource = '';
newTarget = ''; newTarget = '';
newMode = 'shared'; newScope = 'project';
newName = '';
toasts.success($t('volumeEditor.volumeAdded')); toasts.success($t('volumeEditor.volumeAdded'));
await loadVolumes(); await loadVolumes();
} catch (e) { } catch (e) {
@@ -60,16 +98,24 @@
editingId = vol.id; editingId = vol.id;
editSource = vol.source; editSource = vol.source;
editTarget = vol.target; editTarget = vol.target;
editMode = vol.mode; editScope = (vol.scope || 'project') as VolumeScope;
editName = vol.name || '';
} }
function cancelEdit() { editingId = ''; } function cancelEdit() { editingId = ''; }
async function handleUpdate() { async function handleUpdate() {
if (!editSource.trim() || !editTarget.trim()) return; if (editScope !== 'ephemeral' && !editSource.trim()) return;
if (!editTarget.trim()) return;
if (editScopeNeedsName && !editName.trim()) return;
saving = true; saving = true;
try { try {
await api.updateVolume(projectId, editingId, { source: editSource.trim(), target: editTarget.trim(), mode: editMode }); await api.updateVolume(projectId, editingId, {
source: editSource.trim(),
target: editTarget.trim(),
scope: editScope,
name: editScopeNeedsName ? editName.trim() : undefined
});
editingId = ''; editingId = '';
toasts.success($t('volumeEditor.volumeUpdated')); toasts.success($t('volumeEditor.volumeUpdated'));
await loadVolumes(); await loadVolumes();
@@ -103,18 +149,32 @@
<div class="space-y-6"> <div class="space-y-6">
<!-- Header --> <!-- Header -->
<div> <div>
<div class="flex items-center gap-1.5 text-sm text-[var(--text-tertiary)]"> <Breadcrumb items={[{ label: $t('common.project'), href: `/projects/${projectId}` }]} />
<a href="/projects/{projectId}" class="hover:text-[var(--text-link)] transition-colors">{$t('common.project')}</a>
<IconChevronRight size={14} />
</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)]"> <p class="mt-1 text-sm text-[var(--text-secondary)]">{$t('volumeEditor.description')}</p>
{$t('volumeEditor.description')}
<strong>{$t('volumeEditor.shared')}</strong>{$t('volumeEditor.sharedDesc')}
<strong>{$t('volumeEditor.isolated')}</strong>{$t('volumeEditor.isolatedDesc')}
</p>
</div> </div>
<!-- Scope legend -->
{#if scopes.length > 0 && !loading}
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 shadow-[var(--shadow-sm)]">
<div class="flex items-center gap-2 mb-3">
<IconInfo size={16} class="text-[var(--text-tertiary)]" />
<h3 class="text-sm font-semibold text-[var(--text-primary)]">{$t('volumeEditor.scopeGuide')}</h3>
</div>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{#each scopes as scope}
<div class="flex items-start gap-2 rounded-lg bg-[var(--surface-card-hover)] px-3 py-2">
<span class="mt-0.5 inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium {scopeColor(scope.scope)}">{scopeLabel(scope.scope)}</span>
<div class="min-w-0">
<p class="text-xs text-[var(--text-secondary)]">{scope.description}</p>
<p class="mt-0.5 font-mono text-[10px] text-[var(--text-tertiary)]">{scope.path_example}</p>
</div>
</div>
{/each}
</div>
</div>
{/if}
{#if loading} {#if loading}
<div class="space-y-4"> <div class="space-y-4">
<Skeleton height="12rem" /> <Skeleton height="12rem" />
@@ -133,7 +193,8 @@
<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.mode')}</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.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>
@@ -142,17 +203,29 @@
{#if editingId === vol.id} {#if editingId === vol.id}
<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">
<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 editScopeIsEphemeral}
<span class="text-xs italic text-[var(--text-tertiary)]">{$t('volumeEditor.tmpfs')}</span>
{:else}
<input type="text" bind:value={editSource} placeholder={editScope === 'absolute' ? '/mnt/data' : '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={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={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"> <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">
<option value="shared">{$t('volumeEditor.shared')}</option> {#each scopes as s}
<option value="isolated">{$t('volumeEditor.isolated')}</option> <option value={s.scope}>{scopeLabel(s.scope)}</option>
{/each}
</select> </select>
</td> </td>
<td class="px-4 py-2.5">
{#if editScopeNeedsName}
<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 font-mono 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>
@@ -162,14 +235,19 @@
</tr> </tr>
{:else} {:else}
<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)]">{vol.source}</td> <td class="px-4 py-2.5 font-mono text-sm text-[var(--text-primary)]">
{#if vol.scope === 'ephemeral'}
<span class="italic text-[var(--text-tertiary)]">{$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">
{#if vol.mode === 'shared'} <span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {scopeColor(vol.scope)}">{scopeLabel(vol.scope)}</span>
<span class="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">{$t('volumeEditor.shared')}</span> </td>
{:else} <td class="px-4 py-2.5 font-mono text-sm text-[var(--text-secondary)]">
<span class="rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700">{$t('volumeEditor.isolated')}</span> {vol.name || '—'}
{/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">
@@ -184,22 +262,34 @@
<!-- 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">
<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 newScopeIsEphemeral}
<span class="text-xs italic text-[var(--text-tertiary)]">{$t('volumeEditor.tmpfs')}</span>
{:else}
<input type="text" bind:value={newSource} placeholder={newScope === 'absolute' ? '/mnt/nfs/data' : '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={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"> <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">
<option value="shared">{$t('volumeEditor.shared')}</option> {#each scopes as s}
<option value="isolated">{$t('volumeEditor.isolated')}</option> <option value={s.scope}>{scopeLabel(s.scope)}</option>
{/each}
</select> </select>
</td> </td>
<td class="px-4 py-2.5">
{#if newScopeNeedsName}
<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 font-mono 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={!newSource.trim() || !newTarget.trim() || saving} disabled={(!newScopeIsEphemeral && !newSource.trim()) || !newTarget.trim() || (newScopeNeedsName && !newName.trim()) || saving}
onclick={handleAdd} onclick={handleAdd}
> >
<IconPlus size={14} /> <IconPlus size={14} />
@@ -4,7 +4,8 @@
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, IconLoader } from '$lib/components/icons'; import { IconLoader, IconChevronRight } from '$lib/components/icons';
import Breadcrumb from '$lib/components/Breadcrumb.svelte';
import Skeleton from '$lib/components/Skeleton.svelte'; import Skeleton from '$lib/components/Skeleton.svelte';
const projectId = $derived($page.params.id ?? ''); const projectId = $derived($page.params.id ?? '');
@@ -124,12 +125,10 @@
<div class="space-y-4"> <div class="space-y-4">
<!-- Header --> <!-- Header -->
<div> <div>
<div class="flex items-center gap-1.5 text-sm text-[var(--text-tertiary)]"> <Breadcrumb items={[
<a href="/projects/{projectId}" class="hover:text-[var(--text-link)] transition-colors">{$t('common.project')}</a> { label: $t('common.project'), href: `/projects/${projectId}` },
<IconChevronRight size={14} /> { label: $t('volumeEditor.title'), href: `/projects/${projectId}/volumes` }
<a href="/projects/{projectId}/volumes" class="hover:text-[var(--text-link)] transition-colors">{$t('volumeEditor.title')}</a> ]} />
<IconChevronRight size={14} />
</div>
<div class="mt-1 flex items-center justify-between"> <div class="mt-1 flex items-center justify-between">
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('volumeBrowser.title')}</h1> <h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('volumeBrowser.title')}</h1>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
+5 -12
View File
@@ -11,7 +11,7 @@
import ProxyGroup from '$lib/components/ProxyGroup.svelte'; import ProxyGroup from '$lib/components/ProxyGroup.svelte';
import ProxyFilter from '$lib/components/ProxyFilter.svelte'; import ProxyFilter from '$lib/components/ProxyFilter.svelte';
import EmptyState from '$lib/components/EmptyState.svelte'; import EmptyState from '$lib/components/EmptyState.svelte';
import { IconGlobe, IconLoader } from '$lib/components/icons'; import { IconGlobe, IconLoader, IconPlus } from '$lib/components/icons';
let proxies = $state<ProxyView[]>([]); let proxies = $state<ProxyView[]>([]);
let loading = $state(true); let loading = $state(true);
@@ -144,9 +144,7 @@
href="/proxies/create" href="/proxies/create"
class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-brand-700)] active:animate-press" class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-brand-700)] active:animate-press"
> >
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <IconPlus size={16} />
<path d="M5 12h14" /><path d="M12 5v14" />
</svg>
{$t('proxies.create')} {$t('proxies.create')}
</a> </a>
</div> </div>
@@ -158,14 +156,9 @@
<span class="ml-2 text-sm text-[var(--text-secondary)]">{$t('common.loading')}</span> <span class="ml-2 text-sm text-[var(--text-secondary)]">{$t('common.loading')}</span>
</div> </div>
{:else if error} {:else if error}
<!-- Error state --> <div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
<div class="rounded-xl border border-red-200 bg-red-50 p-6 text-center dark:border-red-900 dark:bg-red-950"> <p class="text-sm text-[var(--color-danger)]">{error}</p>
<p class="text-sm text-red-700 dark:text-red-300">{error}</p> <button type="button" class="mt-2 text-sm font-medium text-[var(--color-danger)] underline hover:no-underline" onclick={loadProxies}>
<button
type="button"
onclick={loadProxies}
class="mt-3 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 transition-colors"
>
{$t('common.retry')} {$t('common.retry')}
</button> </button>
</div> </div>
+5 -10
View File
@@ -9,7 +9,7 @@
import { getProxy } from '$lib/api'; import { getProxy } from '$lib/api';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import ProxyForm from '$lib/components/ProxyForm.svelte'; import ProxyForm from '$lib/components/ProxyForm.svelte';
import { IconGlobe, IconLoader } from '$lib/components/icons'; import { IconGlobe, IconLoader, IconArrowLeft } from '$lib/components/icons';
let proxy: StandaloneProxy | null = $state(null); let proxy: StandaloneProxy | null = $state(null);
let loading = $state(true); let loading = $state(true);
@@ -50,9 +50,7 @@
href="/proxies" href="/proxies"
class="inline-flex items-center gap-1 text-sm text-[var(--text-secondary)] hover:text-[var(--color-brand-600)] transition-colors" class="inline-flex items-center gap-1 text-sm text-[var(--text-secondary)] hover:text-[var(--color-brand-600)] transition-colors"
> >
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <IconArrowLeft size={16} />
<path d="M19 12H5" /><path d="m12 19-7-7 7-7" />
</svg>
{$t('common.back')} {$t('common.back')}
</a> </a>
</div> </div>
@@ -71,12 +69,9 @@
<span class="ml-2 text-sm text-[var(--text-secondary)]">{$t('common.loading')}</span> <span class="ml-2 text-sm text-[var(--text-secondary)]">{$t('common.loading')}</span>
</div> </div>
{:else if error} {:else if error}
<div class="rounded-xl border border-red-200 bg-red-50 p-6 text-center dark:border-red-900 dark:bg-red-950"> <div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
<p class="text-sm text-red-700 dark:text-red-300">{error}</p> <p class="text-sm text-[var(--color-danger)]">{error}</p>
<a <a href="/proxies" class="mt-2 inline-block text-sm font-medium text-[var(--color-danger)] underline hover:no-underline">
href="/proxies"
class="mt-3 inline-block rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 transition-colors"
>
{$t('common.back')} {$t('common.back')}
</a> </a>
</div> </div>
+2 -4
View File
@@ -6,7 +6,7 @@
import type { StandaloneProxy } from '$lib/types'; import type { StandaloneProxy } from '$lib/types';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import ProxyForm from '$lib/components/ProxyForm.svelte'; import ProxyForm from '$lib/components/ProxyForm.svelte';
import { IconGlobe } from '$lib/components/icons'; import { IconGlobe, IconArrowLeft } from '$lib/components/icons';
function handleSave(_proxy: StandaloneProxy): void { function handleSave(_proxy: StandaloneProxy): void {
goto('/proxies'); goto('/proxies');
@@ -27,9 +27,7 @@
href="/proxies" href="/proxies"
class="inline-flex items-center gap-1 text-sm text-[var(--text-secondary)] hover:text-[var(--color-brand-600)] transition-colors" class="inline-flex items-center gap-1 text-sm text-[var(--text-secondary)] hover:text-[var(--color-brand-600)] transition-colors"
> >
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <IconArrowLeft size={16} />
<path d="M19 12H5" /><path d="m12 19-7-7 7-7" />
</svg>
{$t('common.back')} {$t('common.back')}
</a> </a>
</div> </div>
+31 -103
View File
@@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
import { getSettings, updateSettings, getWebhookUrl, regenerateWebhookUrl, listNpmCertificates, testDnsConnection, listDnsZones } from '$lib/api'; import { getSettings, updateSettings, getWebhookUrl, regenerateWebhookUrl, testDnsConnection, listDnsZones } from '$lib/api';
import type { EntityPickerItem } from '$lib/types'; import type { EntityPickerItem } from '$lib/types';
import FormField from '$lib/components/FormField.svelte'; import FormField from '$lib/components/FormField.svelte';
import EntityPicker from '$lib/components/EntityPicker.svelte'; import EntityPicker from '$lib/components/EntityPicker.svelte';
import { toasts } from '$lib/stores/toast'; import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { IconLoader, IconCopy, IconRefresh, IconShield, IconX } from '$lib/components/icons'; import { IconLoader, IconCopy, IconRefresh, IconX, IconInfo } from '$lib/components/icons';
import Skeleton from '$lib/components/Skeleton.svelte'; import Skeleton from '$lib/components/Skeleton.svelte';
let loading = $state(true); let loading = $state(true);
@@ -22,11 +22,6 @@
let notificationUrl = $state(''); let notificationUrl = $state('');
let staleThresholdDays = $state('7'); let staleThresholdDays = $state('7');
let sslCertificateId = $state(0);
let sslCertName = $state('');
let certPickerOpen = $state(false);
let certPickerItems = $state<EntityPickerItem[]>([]);
let loadingCerts = $state(false);
// Proxy provider state. // Proxy provider state.
let proxyProvider = $state('npm'); let proxyProvider = $state('npm');
@@ -93,7 +88,6 @@
subdomainPattern = settings.subdomain_pattern ?? ''; subdomainPattern = settings.subdomain_pattern ?? '';
pollingInterval = settings.polling_interval ?? ''; pollingInterval = settings.polling_interval ?? '';
baseVolumePath = settings.base_volume_path ?? ''; baseVolumePath = settings.base_volume_path ?? '';
sslCertificateId = settings.ssl_certificate_id ?? 0;
notificationUrl = settings.notification_url ?? ''; notificationUrl = settings.notification_url ?? '';
staleThresholdDays = String(settings.stale_threshold_days ?? 7); staleThresholdDays = String(settings.stale_threshold_days ?? 7);
proxyProvider = settings.proxy_provider ?? 'npm'; proxyProvider = settings.proxy_provider ?? 'npm';
@@ -124,7 +118,6 @@
subdomain_pattern: subdomainPattern.trim(), polling_interval: pollingInterval.trim(), subdomain_pattern: subdomainPattern.trim(), polling_interval: pollingInterval.trim(),
base_volume_path: baseVolumePath.trim(), notification_url: notificationUrl.trim(), base_volume_path: baseVolumePath.trim(), notification_url: notificationUrl.trim(),
proxy_provider: proxyProvider, proxy_provider: proxyProvider,
ssl_certificate_id: proxyProvider === 'npm' ? sslCertificateId : 0,
stale_threshold_days: Math.max(1, parseInt(staleThresholdDays, 10) || 7), stale_threshold_days: Math.max(1, parseInt(staleThresholdDays, 10) || 7),
wildcard_dns: wildcardDns, wildcard_dns: wildcardDns,
dns_provider: wildcardDns ? '' : dnsProvider, dns_provider: wildcardDns ? '' : dnsProvider,
@@ -155,52 +148,6 @@
} }
} }
async function openCertPicker() {
loadingCerts = true;
certPickerOpen = true;
try {
const certs = await listNpmCertificates();
certPickerItems = certs.map((cert): EntityPickerItem => ({
value: String(cert.id),
label: cert.nice_name || `Certificate #${cert.id}`,
description: cert.domain_names.join(', ')
}));
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.noCertificatesFound'));
certPickerOpen = false;
} finally {
loadingCerts = false;
}
}
function handleCertSelect(value: string) {
const id = parseInt(value, 10);
sslCertificateId = id;
const item = certPickerItems.find((i) => i.value === value);
sslCertName = item?.label ?? '';
certPickerOpen = false;
}
function clearCertificate() {
sslCertificateId = 0;
sslCertName = '';
}
// When loading settings, try to resolve cert name if an ID is set.
async function resolveCertName() {
if (sslCertificateId <= 0) return;
try {
const certs = await listNpmCertificates();
const match = certs.find((c) => c.id === sslCertificateId);
if (match) {
sslCertName = match.nice_name || `Certificate #${match.id}`;
} else {
sslCertName = `Certificate #${sslCertificateId}`;
}
} catch {
sslCertName = `Certificate #${sslCertificateId}`;
}
}
async function openZonePicker() { async function openZonePicker() {
loadingZones = true; loadingZones = true;
@@ -262,7 +209,6 @@
async function init() { async function init() {
await loadSettings(); await loadSettings();
await resolveCertName();
if (!wildcardDns && cloudflareZoneId) { if (!wildcardDns && cloudflareZoneId) {
resolveZoneName(); resolveZoneName();
} }
@@ -293,7 +239,34 @@
<FormField label={$t('settingsGeneral.domain')} name="domain" bind:value={domain} placeholder="example.com" required error={errors.domain ?? ''} helpText={$t('settingsGeneral.domainHelp')} /> <FormField label={$t('settingsGeneral.domain')} name="domain" bind:value={domain} placeholder="example.com" required error={errors.domain ?? ''} helpText={$t('settingsGeneral.domainHelp')} />
<FormField label={$t('settingsGeneral.serverIp')} name="serverIp" bind:value={serverIp} placeholder="93.84.96.191" error={errors.serverIp ?? ''} helpText={$t('settingsGeneral.serverIpHelp')} /> <FormField label={$t('settingsGeneral.serverIp')} name="serverIp" bind:value={serverIp} placeholder="93.84.96.191" error={errors.serverIp ?? ''} helpText={$t('settingsGeneral.serverIpHelp')} />
<FormField label={$t('settingsGeneral.dockerNetwork')} name="network" bind:value={network} placeholder="staging-net" helpText={$t('settingsGeneral.dockerNetworkHelp')} /> <FormField label={$t('settingsGeneral.dockerNetwork')} name="network" bind:value={network} placeholder="staging-net" helpText={$t('settingsGeneral.dockerNetworkHelp')} />
<FormField label={$t('settingsGeneral.subdomainPattern')} name="subdomainPattern" bind:value={subdomainPattern} placeholder="stage-{'{stage}'}-{'{project}'}" helpText={$t('settingsGeneral.subdomainPatternHelp')} /> <div>
<div class="flex items-center gap-1.5">
<label for="subdomainPattern" class="block text-sm font-medium text-[var(--text-primary)]">{$t('settingsGeneral.subdomainPattern')}</label>
<div class="group relative">
<button type="button" class="inline-flex items-center justify-center rounded-full text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] transition-colors" aria-label={$t('settingsGeneral.subdomainVarsTitle')}>
<IconInfo size={14} />
</button>
<div class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 w-64 -translate-x-1/2 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] p-3 shadow-lg opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100">
<p class="mb-2 text-xs font-semibold text-[var(--text-primary)]">{$t('settingsGeneral.subdomainVarsTitle')}</p>
<ul class="space-y-1 text-xs text-[var(--text-secondary)]">
<li><code class="rounded bg-[var(--surface-card-hover)] px-1 py-0.5 font-mono text-[var(--text-link)]">{'{project}'}</code>{$t('settingsGeneral.varProject')}</li>
<li><code class="rounded bg-[var(--surface-card-hover)] px-1 py-0.5 font-mono text-[var(--text-link)]">{'{stage}'}</code>{$t('settingsGeneral.varStage')}</li>
<li><code class="rounded bg-[var(--surface-card-hover)] px-1 py-0.5 font-mono text-[var(--text-link)]">{'{tag}'}</code>{$t('settingsGeneral.varTag')}</li>
<li><code class="rounded bg-[var(--surface-card-hover)] px-1 py-0.5 font-mono text-[var(--text-link)]">{'{port}'}</code>{$t('settingsGeneral.varPort')}</li>
</ul>
<div class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-[var(--border-primary)]"></div>
</div>
</div>
</div>
<input
id="subdomainPattern"
type="text"
bind:value={subdomainPattern}
placeholder="stage-{'{stage}'}-{'{project}'}"
class="mt-1.5 block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]"
/>
<p class="mt-1 text-xs text-[var(--text-tertiary)]">{$t('settingsGeneral.subdomainPatternHelp')}</p>
</div>
<FormField label={$t('settingsGeneral.pollingInterval')} name="pollingInterval" type="number" bind:value={pollingInterval} placeholder="60" error={errors.pollingInterval ?? ''} helpText={$t('settingsGeneral.pollingIntervalHelp')} /> <FormField label={$t('settingsGeneral.pollingInterval')} name="pollingInterval" type="number" bind:value={pollingInterval} placeholder="60" error={errors.pollingInterval ?? ''} helpText={$t('settingsGeneral.pollingIntervalHelp')} />
<FormField label={$t('settingsGeneral.baseVolumePath')} name="baseVolumePath" bind:value={baseVolumePath} placeholder="/data" helpText={$t('settingsGeneral.baseVolumePathHelp')} /> <FormField label={$t('settingsGeneral.baseVolumePath')} name="baseVolumePath" bind:value={baseVolumePath} placeholder="/data" helpText={$t('settingsGeneral.baseVolumePathHelp')} />
<FormField label={$t('settingsGeneral.notificationUrl')} name="notificationUrl" bind:value={notificationUrl} placeholder="https://notify.example.com/webhook" error={errors.notificationUrl ?? ''} helpText={$t('settingsGeneral.notificationUrlHelp')} /> <FormField label={$t('settingsGeneral.notificationUrl')} name="notificationUrl" bind:value={notificationUrl} placeholder="https://notify.example.com/webhook" error={errors.notificationUrl ?? ''} helpText={$t('settingsGeneral.notificationUrlHelp')} />
@@ -325,43 +298,7 @@
{/if} {/if}
</div> </div>
<!-- SSL Certificate (NPM only) --> <!-- SSL Certificate moved to Credentials page -->
{#if proxyProvider === 'npm'}
<div class="mt-6 border-t border-[var(--border-primary)] pt-4">
<div class="flex items-start gap-3">
<div class="flex-1">
<label class="block text-sm font-medium text-[var(--text-primary)]">{$t('settingsGeneral.sslCertificate')}</label>
<p class="mt-0.5 text-xs text-[var(--text-tertiary)]">{$t('settingsGeneral.sslCertificateHelp')}</p>
<div class="mt-2 flex items-center gap-2">
<button
type="button"
onclick={openCertPicker}
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] px-3 py-2 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
>
<IconShield size={16} />
{#if loadingCerts}
{$t('settingsGeneral.loadingCertificates')}
{:else if sslCertificateId > 0 && sslCertName}
{sslCertName}
{:else}
{$t('settingsGeneral.noCertificate')}
{/if}
</button>
{#if sslCertificateId > 0}
<button
type="button"
onclick={clearCertificate}
class="inline-flex items-center gap-1 rounded-lg border border-[var(--border-primary)] px-2 py-2 text-sm text-[var(--text-tertiary)] hover:text-[var(--color-danger)] hover:bg-[var(--surface-card-hover)] transition-colors"
title={$t('settingsGeneral.clearCertificate')}
>
<IconX size={14} />
</button>
{/if}
</div>
</div>
</div>
</div>
{/if}
<!-- Stale Detection --> <!-- Stale Detection -->
<div class="mt-6 border-t border-[var(--border-primary)] pt-4"> <div class="mt-6 border-t border-[var(--border-primary)] pt-4">
@@ -511,15 +448,6 @@
{/if} {/if}
</div> </div>
<EntityPicker
bind:open={certPickerOpen}
items={certPickerItems}
current={String(sslCertificateId)}
title={$t('settingsGeneral.selectCertificate')}
onselect={handleCertSelect}
onclose={() => { certPickerOpen = false; }}
/>
<EntityPicker <EntityPicker
bind:open={zonePickerOpen} bind:open={zonePickerOpen}
items={zonePickerItems} items={zonePickerItems}
+12 -8
View File
@@ -3,6 +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 FormField from '$lib/components/FormField.svelte';
import Skeleton from '$lib/components/Skeleton.svelte'; import Skeleton from '$lib/components/Skeleton.svelte';
import { import {
getAuthSettings, getAuthSettings,
@@ -197,7 +198,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={() => handleDeleteUser(user.id)} class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors"> <button onclick={() => handleDeleteUser(user.id)} class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors" title={$t('common.delete')} aria-label={$t('common.delete')}>
<IconTrash size={16} /> <IconTrash size={16} />
</button> </button>
</td> </td>
@@ -215,13 +216,16 @@
<div class="mt-6 border-t border-[var(--border-primary)] pt-4"> <div class="mt-6 border-t border-[var(--border-primary)] pt-4">
<h4 class="text-sm font-semibold text-[var(--text-primary)]">{$t('settingsAuth.addUser')}</h4> <h4 class="text-sm font-semibold text-[var(--text-primary)]">{$t('settingsAuth.addUser')}</h4>
<div class="mt-3 grid grid-cols-2 gap-3"> <div class="mt-3 grid grid-cols-2 gap-3">
<input type="text" bind:value={newUsername} placeholder={$t('settingsAuth.username')} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-500)]" /> <FormField label={$t('settingsAuth.username')} name="newUsername" bind:value={newUsername} placeholder={$t('settingsAuth.username')} required />
<input type="password" bind:value={newPassword} placeholder={$t('settingsAuth.password')} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-500)]" /> <FormField label={$t('settingsAuth.password')} name="newPassword" type="password" bind:value={newPassword} placeholder={$t('settingsAuth.password')} required />
<input type="email" bind:value={newEmail} placeholder="{$t('settingsAuth.email')} (optional)" class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-2 focus:ring-[var(--color-brand-500)]" /> <FormField label={$t('settingsAuth.email')} name="newEmail" type="email" bind:value={newEmail} placeholder={$t('settingsAuth.email')} />
<select bind:value={newRole} class="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:outline-none focus:ring-2 focus:ring-[var(--color-brand-500)]"> <div>
<option value="viewer">{$t('settingsAuth.viewer')}</option> <label for="newRole" class="block text-sm font-medium text-[var(--text-primary)]">{$t('settingsAuth.role')}</label>
<option value="admin">{$t('settingsAuth.admin')}</option> <select id="newRole" bind:value={newRole} class="mt-1.5 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:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]">
</select> <option value="viewer">{$t('settingsAuth.viewer')}</option>
<option value="admin">{$t('settingsAuth.admin')}</option>
</select>
</div>
</div> </div>
<button onclick={addUser} class="mt-3 inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-[var(--color-brand-700)] transition-colors active:animate-press"> <button onclick={addUser} class="mt-3 inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-[var(--color-brand-700)] transition-colors active:animate-press">
<IconPlus size={16} /> <IconPlus size={16} />
+18 -9
View File
@@ -13,6 +13,7 @@
let loading = $state(true); let loading = $state(true);
let saving = $state(false); let saving = $state(false);
let creatingBackup = $state(false); let creatingBackup = $state(false);
let refreshing = $state(false);
let backupEnabled = $state(false); let backupEnabled = $state(false);
let backupIntervalHours = $state('24'); let backupIntervalHours = $state('24');
@@ -22,8 +23,12 @@
let confirmDeleteId = $state(''); let confirmDeleteId = $state('');
let confirmRestoreId = $state(''); let confirmRestoreId = $state('');
async function loadData() { async function loadData(refresh = false) {
loading = true; if (refresh) {
refreshing = true;
} else {
loading = true;
}
try { try {
const [settings, backupList] = await Promise.all([ const [settings, backupList] = await Promise.all([
getSettings(), getSettings(),
@@ -37,6 +42,7 @@
toasts.error(err instanceof Error ? err.message : 'Failed to load backup settings'); toasts.error(err instanceof Error ? err.message : 'Failed to load backup settings');
} finally { } finally {
loading = false; loading = false;
refreshing = false;
} }
} }
@@ -193,9 +199,13 @@
<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)] overflow-hidden">
<div class="flex items-center justify-between px-6 py-4 border-b border-[var(--border-primary)]"> <div class="flex items-center justify-between px-6 py-4 border-b border-[var(--border-primary)]">
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('settingsBackup.backupList')}</h2> <h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('settingsBackup.backupList')}</h2>
<button onclick={() => loadData()} <button onclick={() => loadData(true)} disabled={refreshing}
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"> class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors disabled:opacity-50">
<IconRefresh size={14} /> {#if refreshing}
<IconLoader size={14} class="animate-spin" />
{:else}
<IconRefresh size={14} />
{/if}
</button> </button>
</div> </div>
@@ -222,9 +232,7 @@
<td class="px-4 py-3 text-[var(--text-secondary)]">{formatSize(backup.size_bytes)}</td> <td class="px-4 py-3 text-[var(--text-secondary)]">{formatSize(backup.size_bytes)}</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium <span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
{backup.backup_type === 'auto' {backup.backup_type === 'auto' ? 'badge-info' : 'badge-success'}">
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'}">
{backup.backup_type === 'auto' ? $t('settingsBackup.typeAuto') : $t('settingsBackup.typeManual')} {backup.backup_type === 'auto' ? $t('settingsBackup.typeAuto') : $t('settingsBackup.typeManual')}
</span> </span>
</td> </td>
@@ -240,7 +248,8 @@
{$t('settingsBackup.restore')} {$t('settingsBackup.restore')}
</button> </button>
<button onclick={() => { confirmDeleteId = backup.id; }} <button onclick={() => { confirmDeleteId = backup.id; }}
class="rounded-lg px-2 py-1 text-xs text-[var(--color-danger)] hover:bg-[var(--color-danger-light)] transition-colors"> class="rounded-lg px-2 py-1 text-xs text-[var(--color-danger)] hover:bg-[var(--color-danger-light)] transition-colors"
title={$t('settingsBackup.delete')} aria-label={$t('settingsBackup.delete')}>
<IconTrash size={14} /> <IconTrash size={14} />
</button> </button>
</div> </div>
@@ -1,10 +1,12 @@
<script lang="ts"> <script lang="ts">
import { getSettings, updateSettings } from '$lib/api'; import { getSettings, updateSettings, listNpmCertificates } from '$lib/api';
import type { EntityPickerItem } from '$lib/types';
import FormField from '$lib/components/FormField.svelte'; import FormField from '$lib/components/FormField.svelte';
import EntityPicker from '$lib/components/EntityPicker.svelte';
import Skeleton from '$lib/components/Skeleton.svelte'; import Skeleton from '$lib/components/Skeleton.svelte';
import { toasts } from '$lib/stores/toast'; import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { IconLoader, IconCheck, IconEdit } from '$lib/components/icons'; import { IconLoader, IconCheck, IconEdit, IconShield, IconX } from '$lib/components/icons';
let loading = $state(true); let loading = $state(true);
let saving = $state(false); let saving = $state(false);
@@ -16,6 +18,13 @@
let editingNpm = $state(false); let editingNpm = $state(false);
let errors = $state<Record<string, string>>({}); let errors = $state<Record<string, string>>({});
// SSL certificate state
let sslCertificateId = $state(0);
let sslCertName = $state('');
let certPickerOpen = $state(false);
let certPickerItems = $state<EntityPickerItem[]>([]);
let loadingCerts = $state(false);
function validateNpmForm(): boolean { function validateNpmForm(): boolean {
const newErrors: Record<string, string> = {}; const newErrors: Record<string, string> = {};
if (!npmUrl.trim()) { newErrors.npmUrl = $t('validation.required', { field: 'NPM URL' }); } else { try { new URL(npmUrl.trim()); } catch { newErrors.npmUrl = $t('validation.invalidUrl'); } } if (!npmUrl.trim()) { newErrors.npmUrl = $t('validation.required', { field: 'NPM URL' }); } else { try { new URL(npmUrl.trim()); } catch { newErrors.npmUrl = $t('validation.invalidUrl'); } }
@@ -34,6 +43,7 @@
npmEmail = settings.npm_email ?? ''; npmEmail = settings.npm_email ?? '';
npmHasCredentials = !!(settings.npm_url && settings.npm_email); npmHasCredentials = !!(settings.npm_url && settings.npm_email);
npmPassword = ''; npmPassword = '';
sslCertificateId = settings.ssl_certificate_id ?? 0;
} catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsCredentials.loadFailed')); } finally { loading = false; } } catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsCredentials.loadFailed')); } finally { loading = false; }
} }
@@ -41,7 +51,7 @@
if (!validateNpmForm()) return; if (!validateNpmForm()) return;
saving = true; saving = true;
try { try {
const payload: Record<string, string> = { npm_url: npmUrl.trim(), npm_email: npmEmail.trim() }; const payload: Record<string, unknown> = { npm_url: npmUrl.trim(), npm_email: npmEmail.trim() };
if (npmPassword.trim()) payload.npm_password = npmPassword.trim(); if (npmPassword.trim()) payload.npm_password = npmPassword.trim();
await updateSettings(payload); await updateSettings(payload);
npmHasCredentials = true; npmHasCredentials = true;
@@ -51,7 +61,67 @@
} catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsCredentials.saveFailed')); } finally { saving = false; } } catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsCredentials.saveFailed')); } finally { saving = false; }
} }
$effect(() => { loadCredentials(); }); async function openCertPicker() {
loadingCerts = true;
try {
const certs = await listNpmCertificates();
if (certs.length === 0) {
toasts.info($t('settingsGeneral.noCertificatesFound'));
return;
}
certPickerItems = certs.map((cert): EntityPickerItem => ({
value: String(cert.id),
label: cert.nice_name || `Certificate #${cert.id}`,
description: cert.domain_names.join(', ')
}));
certPickerOpen = true;
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsGeneral.noCertificatesFound'));
} finally {
loadingCerts = false;
}
}
async function saveCertificate(id: number) {
try {
await updateSettings({ ssl_certificate_id: id });
toasts.success($t('settingsCredentials.saved'));
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('settingsCredentials.saveFailed'));
}
}
function handleCertSelect(value: string) {
sslCertificateId = parseInt(value, 10);
const item = certPickerItems.find((i) => i.value === value);
sslCertName = item?.label ?? '';
certPickerOpen = false;
saveCertificate(sslCertificateId);
}
function clearCertificate() {
sslCertificateId = 0;
sslCertName = '';
saveCertificate(0);
}
async function resolveCertName() {
if (sslCertificateId <= 0) return;
try {
const certs = await listNpmCertificates();
const match = certs.find((c) => c.id === sslCertificateId);
sslCertName = match ? (match.nice_name || `Certificate #${match.id}`) : `Certificate #${sslCertificateId}`;
} catch {
sslCertName = `Certificate #${sslCertificateId}`;
}
}
async function init() {
await loadCredentials();
await resolveCertName();
}
$effect(() => { init(); });
</script> </script>
<svelte:head> <svelte:head>
@@ -68,6 +138,7 @@
<div class="space-y-4"><Skeleton height="12rem" /></div> <div class="space-y-4"><Skeleton height="12rem" /></div>
{:else} {:else}
{#if proxyProvider === 'npm'} {#if proxyProvider === 'npm'}
<!-- NPM Credentials -->
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]"> <div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<div> <div>
@@ -116,6 +187,43 @@
</div> </div>
{/if} {/if}
</div> </div>
<!-- SSL Certificate -->
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
<div class="flex items-start gap-3">
<div class="flex-1">
<h3 class="text-sm font-semibold text-[var(--text-primary)]">{$t('settingsGeneral.sslCertificate')}</h3>
<p class="mt-0.5 text-xs text-[var(--text-tertiary)]">{$t('settingsGeneral.sslCertificateHelp')}</p>
<div class="mt-3 flex items-center gap-2">
<button
type="button"
onclick={openCertPicker}
disabled={loadingCerts}
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] px-3 py-2 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors disabled:opacity-50"
>
<IconShield size={16} />
{#if loadingCerts}
{$t('settingsGeneral.loadingCertificates')}
{:else if sslCertificateId > 0 && sslCertName}
{sslCertName}
{:else}
{$t('settingsGeneral.noCertificate')}
{/if}
</button>
{#if sslCertificateId > 0}
<button
type="button"
onclick={clearCertificate}
class="inline-flex items-center gap-1 rounded-lg border border-[var(--border-primary)] px-2 py-2 text-sm text-[var(--text-tertiary)] hover:text-[var(--color-danger)] hover:bg-[var(--surface-card-hover)] transition-colors"
title={$t('settingsGeneral.clearCertificate')}
>
<IconX size={14} />
</button>
{/if}
</div>
</div>
</div>
</div>
{/if} {/if}
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]"> <div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
@@ -128,3 +236,12 @@
</div> </div>
{/if} {/if}
</div> </div>
<EntityPicker
bind:open={certPickerOpen}
items={certPickerItems}
current={String(sslCertificateId)}
title={$t('settingsGeneral.selectCertificate')}
onselect={handleCertSelect}
onclose={() => { certPickerOpen = false; }}
/>
@@ -175,8 +175,8 @@
{#if testingId === registry.id}<IconLoader size={14} />{:else}<IconWifi size={14} />{/if} {#if testingId === registry.id}<IconLoader size={14} />{:else}<IconWifi size={14} />{/if}
{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" title={$t('common.edit')} aria-label={$t('common.edit')}><IconEdit 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> <button onclick={() => { registryDeleteTarget = registry; }} class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors" title={$t('common.delete')} aria-label={$t('common.delete')}><IconTrash size={16} /></button>
</div> </div>
</div> </div>
{/each} {/each}