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:
@@ -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>
|
||||
@@ -48,3 +48,5 @@ export { default as IconRefresh } from './IconRefresh.svelte';
|
||||
export { default as IconProxies } from './IconProxies.svelte';
|
||||
export { default as IconEvents } from './IconEvents.svelte';
|
||||
export { default as IconLogout } from './IconLogout.svelte';
|
||||
export { default as IconArrowLeft } from './IconArrowLeft.svelte';
|
||||
export { default as IconChevronDown } from './IconChevronDown.svelte';
|
||||
|
||||
@@ -58,7 +58,9 @@
|
||||
"imageLoadFailed": "Failed to load images",
|
||||
"alreadyAdded": "Already added",
|
||||
"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": {
|
||||
"deleteProject": "Delete Project",
|
||||
@@ -162,6 +164,7 @@
|
||||
"save": "Save",
|
||||
"add": "Add",
|
||||
"adding": "Adding...",
|
||||
"scopeGuide": "Volume Scopes",
|
||||
"noVolumes": "No volumes configured yet. Add one above.",
|
||||
"volumeAdded": "Volume added",
|
||||
"volumeUpdated": "Volume updated",
|
||||
@@ -258,6 +261,11 @@
|
||||
"dockerNetworkHelp": "Docker network for deployed containers",
|
||||
"subdomainPattern": "Subdomain Pattern",
|
||||
"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)",
|
||||
"pollingIntervalHelp": "How often to check registries for new tags (10-86400)",
|
||||
"notificationUrl": "Notification URL",
|
||||
|
||||
@@ -58,7 +58,9 @@
|
||||
"imageLoadFailed": "Не удалось загрузить образы",
|
||||
"alreadyAdded": "Уже добавлен",
|
||||
"portHelpText": "Автоопределение из EXPOSE, если пусто",
|
||||
"healthcheckHelpText": "Автоопределение из образа, если пусто"
|
||||
"healthcheckHelpText": "Автоопределение из образа, если пусто",
|
||||
"searchPlaceholder": "Поиск по имени, образу или реестру...",
|
||||
"noMatchingProjects": "Проекты не найдены."
|
||||
},
|
||||
"projectDetail": {
|
||||
"deleteProject": "Удалить проект",
|
||||
@@ -162,6 +164,7 @@
|
||||
"save": "Сохранить",
|
||||
"add": "Добавить",
|
||||
"adding": "Добавление...",
|
||||
"scopeGuide": "Области видимости томов",
|
||||
"noVolumes": "Тома ещё не настроены. Добавьте один выше.",
|
||||
"volumeAdded": "Том добавлен",
|
||||
"volumeUpdated": "Том обновлён",
|
||||
@@ -258,6 +261,11 @@
|
||||
"dockerNetworkHelp": "Docker-сеть для развёрнутых контейнеров",
|
||||
"subdomainPattern": "Шаблон поддомена",
|
||||
"subdomainPatternHelp": "Шаблон для автоматически генерируемых поддоменов",
|
||||
"subdomainVarsTitle": "Доступные переменные",
|
||||
"varProject": "Имя проекта",
|
||||
"varStage": "Имя стадии",
|
||||
"varTag": "Тег образа",
|
||||
"varPort": "Порт контейнера",
|
||||
"pollingInterval": "Интервал опроса (секунды)",
|
||||
"pollingIntervalHelp": "Как часто проверять реестры на новые теги (10-86400)",
|
||||
"notificationUrl": "URL уведомлений",
|
||||
|
||||
@@ -210,6 +210,13 @@
|
||||
animation: button-press 150ms ease-in-out;
|
||||
}
|
||||
|
||||
/* ── Disabled Buttons ────────────────────────────────────────────── */
|
||||
|
||||
button:disabled,
|
||||
a[aria-disabled="true"] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Skeleton Loader ──────────────────────────────────────────────── */
|
||||
|
||||
.skeleton {
|
||||
@@ -226,6 +233,32 @@
|
||||
|
||||
/* ── 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 {
|
||||
position: relative;
|
||||
width: 2.75rem;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import Toast from '$lib/components/Toast.svelte';
|
||||
import ThemeToggle from '$lib/components/ThemeToggle.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 { connectGlobalEvents, type SSEConnection } from '$lib/sse';
|
||||
import { instanceStatusStore } from '$lib/stores/instance-status';
|
||||
@@ -142,24 +142,9 @@
|
||||
</div>
|
||||
<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) -->
|
||||
<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; }}
|
||||
aria-label="Close sidebar"
|
||||
>
|
||||
@@ -213,7 +198,7 @@
|
||||
</span>
|
||||
<span class="flex-1 text-left">Docker {dockerConnected ? $t('health.connected') : $t('health.disconnected')}</span>
|
||||
{#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}
|
||||
</button>
|
||||
{#if !dockerConnected && hintsExpanded && dockerHealth?.error}
|
||||
@@ -240,6 +225,19 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<ThemeToggle />
|
||||
<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>
|
||||
<p class="text-xs text-[var(--text-tertiary)]">{$t('app.name')} {$t('app.version')}</p>
|
||||
</div>
|
||||
|
||||
@@ -77,11 +77,11 @@
|
||||
|
||||
function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'synced': return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400';
|
||||
case 'missing': return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400';
|
||||
case 'orphaned': return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400';
|
||||
case 'wildcard': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400';
|
||||
default: return 'bg-gray-100 text-gray-700';
|
||||
case 'synced': return 'badge-success';
|
||||
case 'missing': return 'badge-danger';
|
||||
case 'orphaned': return 'badge-warning';
|
||||
case 'wildcard': return 'badge-info';
|
||||
default: return 'badge-gray';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import EventLogEntryComponent from '$lib/components/EventLogEntry.svelte';
|
||||
import EventLogFilter from '$lib/components/EventLogFilter.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import { IconLoader } from '$lib/components/icons';
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -244,10 +245,7 @@
|
||||
<!-- Event list -->
|
||||
{#if loading}
|
||||
<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">
|
||||
<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>
|
||||
<IconLoader size={20} class="animate-spin text-[var(--color-brand-500)]" />
|
||||
</div>
|
||||
{:else if filteredEvents.length === 0}
|
||||
<EmptyState
|
||||
@@ -279,10 +277,7 @@
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{#if loadingMore}
|
||||
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<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>
|
||||
<IconLoader size={16} class="animate-spin" />
|
||||
{/if}
|
||||
{$t('events.loadMore')}
|
||||
</button>
|
||||
|
||||
@@ -12,6 +12,18 @@
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
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 formImage = $state('');
|
||||
@@ -220,6 +232,22 @@
|
||||
icon="projects"
|
||||
/>
|
||||
{: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)]">
|
||||
<table class="min-w-full divide-y divide-[var(--border-primary)]">
|
||||
<thead class="bg-[var(--surface-card-hover)]">
|
||||
@@ -233,7 +261,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<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">
|
||||
<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">
|
||||
@@ -262,5 +290,6 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||
import { IconTrash, IconKey, IconHardDrive, IconDeploy, 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 ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
@@ -111,7 +112,7 @@
|
||||
const projectId = $derived($page.params.id);
|
||||
|
||||
async function loadProject() {
|
||||
loading = true;
|
||||
if (!project) loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const detail = await api.getProject(projectId);
|
||||
@@ -229,10 +230,7 @@
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-1.5 text-sm text-[var(--text-tertiary)]">
|
||||
<a href="/projects" class="hover:text-[var(--text-link)] transition-colors">{$t('projects.title')}</a>
|
||||
<IconChevronRight size={14} />
|
||||
</div>
|
||||
<Breadcrumb items={[{ label: $t('projects.title'), href: '/projects' }]} />
|
||||
<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>
|
||||
</div>
|
||||
@@ -385,13 +383,13 @@
|
||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">{stage.name}</h3>
|
||||
<span class="rounded-md bg-[var(--surface-card-hover)] px-2 py-0.5 font-mono text-xs text-[var(--text-tertiary)]">{stage.tag_pattern}</span>
|
||||
{#if stage.auto_deploy}
|
||||
<span class="rounded-full 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 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 !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}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
+9
-11
@@ -4,7 +4,8 @@
|
||||
import * as api from '$lib/api';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
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 Skeleton from '$lib/components/Skeleton.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
@@ -33,7 +34,7 @@
|
||||
const projectId = $derived($page.params.id);
|
||||
|
||||
async function loadProject() {
|
||||
loading = true;
|
||||
if (stages.length === 0) loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const detail = await api.getProject(projectId);
|
||||
@@ -153,10 +154,7 @@
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<div class="flex items-center gap-1.5 text-sm text-[var(--text-tertiary)]">
|
||||
<a href="/projects/{projectId}" class="hover:text-[var(--text-link)] transition-colors">{$t('common.project')}</a>
|
||||
<IconChevronRight size={14} />
|
||||
</div>
|
||||
<Breadcrumb items={[{ label: $t('common.project'), href: `/projects/${projectId}` }]} />
|
||||
<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>
|
||||
</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 text-sm">
|
||||
{#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}
|
||||
<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}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -275,7 +273,7 @@
|
||||
</td>
|
||||
<td class="px-4 py-2.5">
|
||||
{#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} />
|
||||
{$t('envEditor.secret')}
|
||||
</span>
|
||||
@@ -283,9 +281,9 @@
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-sm">
|
||||
{#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}
|
||||
<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}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-2.5 text-right">
|
||||
|
||||
@@ -1,37 +1,67 @@
|
||||
<script lang="ts">
|
||||
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 { toasts } from '$lib/stores/toast';
|
||||
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 EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
|
||||
let volumes = $state<Volume[]>([]);
|
||||
let scopes = $state<VolumeScopeInfo[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
let newSource = $state('');
|
||||
let newTarget = $state('');
|
||||
let newMode = $state<'shared' | 'isolated'>('shared');
|
||||
let newScope = $state<VolumeScope>('project');
|
||||
let newName = $state('');
|
||||
let saving = $state(false);
|
||||
|
||||
let editingId = $state('');
|
||||
let editSource = $state('');
|
||||
let editTarget = $state('');
|
||||
let editMode = $state<'shared' | 'isolated'>('shared');
|
||||
let editScope = $state<VolumeScope>('project');
|
||||
let editName = $state('');
|
||||
|
||||
let volumeDeleteTarget = $state<string | null>(null);
|
||||
|
||||
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() {
|
||||
loading = true;
|
||||
if (volumes.length === 0) loading = true;
|
||||
error = '';
|
||||
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) {
|
||||
error = e instanceof Error ? e.message : $t('volumeEditor.loadFailed');
|
||||
} finally {
|
||||
@@ -40,13 +70,21 @@
|
||||
}
|
||||
|
||||
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;
|
||||
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 = '';
|
||||
newTarget = '';
|
||||
newMode = 'shared';
|
||||
newScope = 'project';
|
||||
newName = '';
|
||||
toasts.success($t('volumeEditor.volumeAdded'));
|
||||
await loadVolumes();
|
||||
} catch (e) {
|
||||
@@ -60,16 +98,24 @@
|
||||
editingId = vol.id;
|
||||
editSource = vol.source;
|
||||
editTarget = vol.target;
|
||||
editMode = vol.mode;
|
||||
editScope = (vol.scope || 'project') as VolumeScope;
|
||||
editName = vol.name || '';
|
||||
}
|
||||
|
||||
function cancelEdit() { editingId = ''; }
|
||||
|
||||
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;
|
||||
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 = '';
|
||||
toasts.success($t('volumeEditor.volumeUpdated'));
|
||||
await loadVolumes();
|
||||
@@ -103,18 +149,32 @@
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<div class="flex items-center gap-1.5 text-sm text-[var(--text-tertiary)]">
|
||||
<a href="/projects/{projectId}" class="hover:text-[var(--text-link)] transition-colors">{$t('common.project')}</a>
|
||||
<IconChevronRight size={14} />
|
||||
</div>
|
||||
<Breadcrumb items={[{ label: $t('common.project'), href: `/projects/${projectId}` }]} />
|
||||
<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')}
|
||||
<strong>{$t('volumeEditor.shared')}</strong> — {$t('volumeEditor.sharedDesc')}
|
||||
<strong>{$t('volumeEditor.isolated')}</strong> — {$t('volumeEditor.isolatedDesc')}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-[var(--text-secondary)]">{$t('volumeEditor.description')}</p>
|
||||
</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}
|
||||
<div class="space-y-4">
|
||||
<Skeleton height="12rem" />
|
||||
@@ -133,7 +193,8 @@
|
||||
<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.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>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -142,17 +203,29 @@
|
||||
{#if editingId === vol.id}
|
||||
<tr class="bg-[var(--color-brand-50)]/30">
|
||||
<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 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" />
|
||||
</td>
|
||||
<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">
|
||||
<option value="shared">{$t('volumeEditor.shared')}</option>
|
||||
<option value="isolated">{$t('volumeEditor.isolated')}</option>
|
||||
<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">
|
||||
{#each scopes as s}
|
||||
<option value={s.scope}>{scopeLabel(s.scope)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</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">
|
||||
<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>
|
||||
@@ -162,14 +235,19 @@
|
||||
</tr>
|
||||
{:else}
|
||||
<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">
|
||||
{#if vol.mode === 'shared'}
|
||||
<span class="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">{$t('volumeEditor.shared')}</span>
|
||||
{:else}
|
||||
<span class="rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700">{$t('volumeEditor.isolated')}</span>
|
||||
{/if}
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {scopeColor(vol.scope)}">{scopeLabel(vol.scope)}</span>
|
||||
</td>
|
||||
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-secondary)]">
|
||||
{vol.name || '—'}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-2.5 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
@@ -184,22 +262,34 @@
|
||||
<!-- Add new row -->
|
||||
<tr class="bg-[var(--surface-card-hover)]">
|
||||
<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 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" />
|
||||
</td>
|
||||
<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">
|
||||
<option value="shared">{$t('volumeEditor.shared')}</option>
|
||||
<option value="isolated">{$t('volumeEditor.isolated')}</option>
|
||||
<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">
|
||||
{#each scopes as s}
|
||||
<option value={s.scope}>{scopeLabel(s.scope)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-lg bg-[var(--color-brand-600)] px-3 py-1.5 text-xs font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors active:animate-press"
|
||||
disabled={!newSource.trim() || !newTarget.trim() || saving}
|
||||
disabled={(!newScopeIsEphemeral && !newSource.trim()) || !newTarget.trim() || (newScopeNeedsName && !newName.trim()) || saving}
|
||||
onclick={handleAdd}
|
||||
>
|
||||
<IconPlus size={14} />
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
import * as api from '$lib/api';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
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';
|
||||
|
||||
const projectId = $derived($page.params.id ?? '');
|
||||
@@ -124,12 +125,10 @@
|
||||
<div class="space-y-4">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<div class="flex items-center gap-1.5 text-sm text-[var(--text-tertiary)]">
|
||||
<a href="/projects/{projectId}" class="hover:text-[var(--text-link)] transition-colors">{$t('common.project')}</a>
|
||||
<IconChevronRight size={14} />
|
||||
<a href="/projects/{projectId}/volumes" class="hover:text-[var(--text-link)] transition-colors">{$t('volumeEditor.title')}</a>
|
||||
<IconChevronRight size={14} />
|
||||
</div>
|
||||
<Breadcrumb items={[
|
||||
{ label: $t('common.project'), href: `/projects/${projectId}` },
|
||||
{ label: $t('volumeEditor.title'), href: `/projects/${projectId}/volumes` }
|
||||
]} />
|
||||
<div class="mt-1 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('volumeBrowser.title')}</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import ProxyGroup from '$lib/components/ProxyGroup.svelte';
|
||||
import ProxyFilter from '$lib/components/ProxyFilter.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 loading = $state(true);
|
||||
@@ -144,9 +144,7 @@
|
||||
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"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14" /><path d="M12 5v14" />
|
||||
</svg>
|
||||
<IconPlus size={16} />
|
||||
{$t('proxies.create')}
|
||||
</a>
|
||||
</div>
|
||||
@@ -158,14 +156,9 @@
|
||||
<span class="ml-2 text-sm text-[var(--text-secondary)]">{$t('common.loading')}</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<!-- Error state -->
|
||||
<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-red-700 dark:text-red-300">{error}</p>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
<button type="button" class="mt-2 text-sm font-medium text-[var(--color-danger)] underline hover:no-underline" onclick={loadProxies}>
|
||||
{$t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { getProxy } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
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 loading = $state(true);
|
||||
@@ -50,9 +50,7 @@
|
||||
href="/proxies"
|
||||
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">
|
||||
<path d="M19 12H5" /><path d="m12 19-7-7 7-7" />
|
||||
</svg>
|
||||
<IconArrowLeft size={16} />
|
||||
{$t('common.back')}
|
||||
</a>
|
||||
</div>
|
||||
@@ -71,12 +69,9 @@
|
||||
<span class="ml-2 text-sm text-[var(--text-secondary)]">{$t('common.loading')}</span>
|
||||
</div>
|
||||
{: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">
|
||||
<p class="text-sm text-red-700 dark:text-red-300">{error}</p>
|
||||
<a
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
<a href="/proxies" class="mt-2 inline-block text-sm font-medium text-[var(--color-danger)] underline hover:no-underline">
|
||||
{$t('common.back')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import type { StandaloneProxy } from '$lib/types';
|
||||
import { t } from '$lib/i18n';
|
||||
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 {
|
||||
goto('/proxies');
|
||||
@@ -27,9 +27,7 @@
|
||||
href="/proxies"
|
||||
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">
|
||||
<path d="M19 12H5" /><path d="m12 19-7-7 7-7" />
|
||||
</svg>
|
||||
<IconArrowLeft size={16} />
|
||||
{$t('common.back')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<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 FormField from '$lib/components/FormField.svelte';
|
||||
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
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';
|
||||
|
||||
let loading = $state(true);
|
||||
@@ -22,11 +22,6 @@
|
||||
let notificationUrl = $state('');
|
||||
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.
|
||||
let proxyProvider = $state('npm');
|
||||
@@ -93,7 +88,6 @@
|
||||
subdomainPattern = settings.subdomain_pattern ?? '';
|
||||
pollingInterval = settings.polling_interval ?? '';
|
||||
baseVolumePath = settings.base_volume_path ?? '';
|
||||
sslCertificateId = settings.ssl_certificate_id ?? 0;
|
||||
notificationUrl = settings.notification_url ?? '';
|
||||
staleThresholdDays = String(settings.stale_threshold_days ?? 7);
|
||||
proxyProvider = settings.proxy_provider ?? 'npm';
|
||||
@@ -124,7 +118,6 @@
|
||||
subdomain_pattern: subdomainPattern.trim(), polling_interval: pollingInterval.trim(),
|
||||
base_volume_path: baseVolumePath.trim(), notification_url: notificationUrl.trim(),
|
||||
proxy_provider: proxyProvider,
|
||||
ssl_certificate_id: proxyProvider === 'npm' ? sslCertificateId : 0,
|
||||
stale_threshold_days: Math.max(1, parseInt(staleThresholdDays, 10) || 7),
|
||||
wildcard_dns: wildcardDns,
|
||||
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() {
|
||||
loadingZones = true;
|
||||
@@ -262,7 +209,6 @@
|
||||
|
||||
async function init() {
|
||||
await loadSettings();
|
||||
await resolveCertName();
|
||||
if (!wildcardDns && cloudflareZoneId) {
|
||||
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.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.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.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')} />
|
||||
@@ -325,43 +298,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- SSL Certificate (NPM only) -->
|
||||
{#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}
|
||||
<!-- SSL Certificate moved to Credentials page -->
|
||||
|
||||
<!-- Stale Detection -->
|
||||
<div class="mt-6 border-t border-[var(--border-primary)] pt-4">
|
||||
@@ -511,15 +448,6 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<EntityPicker
|
||||
bind:open={certPickerOpen}
|
||||
items={certPickerItems}
|
||||
current={String(sslCertificateId)}
|
||||
title={$t('settingsGeneral.selectCertificate')}
|
||||
onselect={handleCertSelect}
|
||||
onclose={() => { certPickerOpen = false; }}
|
||||
/>
|
||||
|
||||
<EntityPicker
|
||||
bind:open={zonePickerOpen}
|
||||
items={zonePickerItems}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { t } from '$lib/i18n';
|
||||
import { IconLoader, IconPlus, IconTrash, IconUsers } from '$lib/components/icons';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import FormField from '$lib/components/FormField.svelte';
|
||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||
import {
|
||||
getAuthSettings,
|
||||
@@ -197,7 +198,7 @@
|
||||
</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">
|
||||
<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} />
|
||||
</button>
|
||||
</td>
|
||||
@@ -215,13 +216,16 @@
|
||||
<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>
|
||||
<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)]" />
|
||||
<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)]" />
|
||||
<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)]" />
|
||||
<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)]">
|
||||
<option value="viewer">{$t('settingsAuth.viewer')}</option>
|
||||
<option value="admin">{$t('settingsAuth.admin')}</option>
|
||||
</select>
|
||||
<FormField label={$t('settingsAuth.username')} name="newUsername" bind:value={newUsername} placeholder={$t('settingsAuth.username')} required />
|
||||
<FormField label={$t('settingsAuth.password')} name="newPassword" type="password" bind:value={newPassword} placeholder={$t('settingsAuth.password')} required />
|
||||
<FormField label={$t('settingsAuth.email')} name="newEmail" type="email" bind:value={newEmail} placeholder={$t('settingsAuth.email')} />
|
||||
<div>
|
||||
<label for="newRole" class="block text-sm font-medium text-[var(--text-primary)]">{$t('settingsAuth.role')}</label>
|
||||
<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)]">
|
||||
<option value="viewer">{$t('settingsAuth.viewer')}</option>
|
||||
<option value="admin">{$t('settingsAuth.admin')}</option>
|
||||
</select>
|
||||
</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">
|
||||
<IconPlus size={16} />
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let creatingBackup = $state(false);
|
||||
let refreshing = $state(false);
|
||||
|
||||
let backupEnabled = $state(false);
|
||||
let backupIntervalHours = $state('24');
|
||||
@@ -22,8 +23,12 @@
|
||||
let confirmDeleteId = $state('');
|
||||
let confirmRestoreId = $state('');
|
||||
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
async function loadData(refresh = false) {
|
||||
if (refresh) {
|
||||
refreshing = true;
|
||||
} else {
|
||||
loading = true;
|
||||
}
|
||||
try {
|
||||
const [settings, backupList] = await Promise.all([
|
||||
getSettings(),
|
||||
@@ -37,6 +42,7 @@
|
||||
toasts.error(err instanceof Error ? err.message : 'Failed to load backup settings');
|
||||
} finally {
|
||||
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="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>
|
||||
<button onclick={() => loadData()}
|
||||
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">
|
||||
<IconRefresh size={14} />
|
||||
<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 disabled:opacity-50">
|
||||
{#if refreshing}
|
||||
<IconLoader size={14} class="animate-spin" />
|
||||
{:else}
|
||||
<IconRefresh size={14} />
|
||||
{/if}
|
||||
</button>
|
||||
</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">
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium
|
||||
{backup.backup_type === 'auto'
|
||||
? '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' ? 'badge-info' : 'badge-success'}">
|
||||
{backup.backup_type === 'auto' ? $t('settingsBackup.typeAuto') : $t('settingsBackup.typeManual')}
|
||||
</span>
|
||||
</td>
|
||||
@@ -240,7 +248,8 @@
|
||||
{$t('settingsBackup.restore')}
|
||||
</button>
|
||||
<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} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<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 EntityPicker from '$lib/components/EntityPicker.svelte';
|
||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
||||
import { toasts } from '$lib/stores/toast';
|
||||
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 saving = $state(false);
|
||||
@@ -16,6 +18,13 @@
|
||||
let editingNpm = $state(false);
|
||||
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 {
|
||||
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'); } }
|
||||
@@ -34,6 +43,7 @@
|
||||
npmEmail = settings.npm_email ?? '';
|
||||
npmHasCredentials = !!(settings.npm_url && settings.npm_email);
|
||||
npmPassword = '';
|
||||
sslCertificateId = settings.ssl_certificate_id ?? 0;
|
||||
} catch (err) { toasts.error(err instanceof Error ? err.message : $t('settingsCredentials.loadFailed')); } finally { loading = false; }
|
||||
}
|
||||
|
||||
@@ -41,7 +51,7 @@
|
||||
if (!validateNpmForm()) return;
|
||||
saving = true;
|
||||
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();
|
||||
await updateSettings(payload);
|
||||
npmHasCredentials = true;
|
||||
@@ -51,7 +61,67 @@
|
||||
} 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>
|
||||
|
||||
<svelte:head>
|
||||
@@ -68,6 +138,7 @@
|
||||
<div class="space-y-4"><Skeleton height="12rem" /></div>
|
||||
{:else}
|
||||
{#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="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
@@ -116,6 +187,43 @@
|
||||
</div>
|
||||
{/if}
|
||||
</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}
|
||||
|
||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
||||
@@ -128,3 +236,12 @@
|
||||
</div>
|
||||
{/if}
|
||||
</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}
|
||||
{testingId === registry.id ? $t('settingsRegistries.testing') : $t('settingsRegistries.test')}
|
||||
</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={() => { 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={() => 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" title={$t('common.delete')} aria-label={$t('common.delete')}><IconTrash size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
Reference in New Issue
Block a user