feat(frontend): group targets by bot, redesign backup settings
Targets page: collapse targets under a per-bot header (BotGroupHeader) with a count chip and an "Open bot" cross-link. Receivers are hidden by default and expand per group; non-bot types fall back to a "Direct delivery" group. Telegram "Add receiver" now opens the EntitySelect chat palette directly instead of an inline form — EntitySelect grew a bindable `open` flag, `showTrigger`, and an `onclose` cancel signal. Backup settings page: split the monolithic +page into focused panels (BackupHero, BackupLedger, ExportPanel, ImportPanel, PendingStrip, ScheduleCassette) and introduce a stepwise export/import flow with category groups, secrets handling, conflict policy, and validation gating. New i18n keys in both locales cover the bot grouping labels and the backup step copy.
This commit is contained in:
@@ -2,8 +2,6 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { api, fetchAuth } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
import Loading from '$lib/components/Loading.svelte';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||
@@ -11,32 +9,55 @@
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
||||
|
||||
// --- Export state ---
|
||||
let exportSecrets = $state('exclude');
|
||||
let exporting = $state(false);
|
||||
import BackupHero from './BackupHero.svelte';
|
||||
import PendingStrip from './PendingStrip.svelte';
|
||||
import ExportPanel from './ExportPanel.svelte';
|
||||
import ImportPanel from './ImportPanel.svelte';
|
||||
import ScheduleCassette from './ScheduleCassette.svelte';
|
||||
import BackupLedger from './BackupLedger.svelte';
|
||||
|
||||
const categories = [
|
||||
{ key: 'providers', label: 'backup.catProviders' },
|
||||
{ key: 'telegram_bots', label: 'backup.catTelegramBots' },
|
||||
{ key: 'matrix_bots', label: 'backup.catMatrixBots' },
|
||||
{ key: 'email_bots', label: 'backup.catEmailBots' },
|
||||
{ key: 'targets', label: 'backup.catTargets' },
|
||||
{ key: 'tracking_configs', label: 'backup.catTrackingConfigs' },
|
||||
{ key: 'template_configs', label: 'backup.catTemplateConfigs' },
|
||||
{ key: 'command_configs', label: 'backup.catCommandConfigs' },
|
||||
{ key: 'command_template_configs', label: 'backup.catCommandTemplateConfigs' },
|
||||
{ key: 'notification_trackers', label: 'backup.catNotificationTrackers' },
|
||||
{ key: 'command_trackers', label: 'backup.catCommandTrackers' },
|
||||
{ key: 'actions', label: 'backup.catActions' },
|
||||
{ key: 'app_settings', label: 'backup.catAppSettings' },
|
||||
type SecretsMode = 'exclude' | 'masked' | 'include';
|
||||
type ConflictMode = 'skip' | 'rename' | 'overwrite';
|
||||
|
||||
interface BackupFile {
|
||||
filename: string;
|
||||
size: number;
|
||||
created_at?: string | null;
|
||||
}
|
||||
|
||||
interface ScheduledSettings {
|
||||
backup_scheduled_enabled: string;
|
||||
backup_scheduled_interval_hours: string;
|
||||
backup_secrets_mode: string;
|
||||
backup_retention_count: string;
|
||||
}
|
||||
|
||||
interface PendingState {
|
||||
pending: boolean;
|
||||
uploaded_at?: string | null;
|
||||
uploaded_by?: string | null;
|
||||
conflict_mode?: string;
|
||||
supervised?: boolean;
|
||||
}
|
||||
|
||||
const allCategories = [
|
||||
'providers', 'telegram_bots', 'matrix_bots', 'email_bots', 'targets',
|
||||
'tracking_configs', 'template_configs',
|
||||
'command_configs', 'command_template_configs',
|
||||
'notification_trackers', 'command_trackers',
|
||||
'actions', 'app_settings',
|
||||
];
|
||||
|
||||
// --- Export state ---
|
||||
let exportSecrets = $state<SecretsMode>('exclude');
|
||||
let exporting = $state(false);
|
||||
let selectedCategories = $state<Record<string, boolean>>(
|
||||
Object.fromEntries(categories.map(c => [c.key, true]))
|
||||
Object.fromEntries(allCategories.map(k => [k, true]))
|
||||
);
|
||||
|
||||
// --- Import state ---
|
||||
let importFile: File | null = $state(null);
|
||||
let importConflict = $state('skip');
|
||||
let importConflict = $state<ConflictMode>('skip');
|
||||
let importing = $state(false);
|
||||
let validating = $state(false);
|
||||
let validationResult: any = $state(null);
|
||||
@@ -47,7 +68,7 @@
|
||||
// --- Scheduled backup state ---
|
||||
let loaded = $state(false);
|
||||
let error = $state('');
|
||||
let scheduledSettings = $state({
|
||||
let scheduledSettings = $state<ScheduledSettings>({
|
||||
backup_scheduled_enabled: 'false',
|
||||
backup_scheduled_interval_hours: '24',
|
||||
backup_secrets_mode: 'exclude',
|
||||
@@ -56,22 +77,22 @@
|
||||
let savingSchedule = $state(false);
|
||||
|
||||
// --- Backup files ---
|
||||
let backupFiles = $state<any[]>([]);
|
||||
let backupFiles = $state<BackupFile[]>([]);
|
||||
let loadingFiles = $state(false);
|
||||
let confirmDeleteFile = $state('');
|
||||
let creatingBackup = $state(false);
|
||||
|
||||
// --- Pending restore state ---
|
||||
let pending = $state<{ pending: boolean; uploaded_at?: string | null; uploaded_by?: string | null; conflict_mode?: string; supervised?: boolean } | null>(null);
|
||||
let pending = $state<PendingState | null>(null);
|
||||
let postRestoreModalOpen = $state(false);
|
||||
let restartingOverlay = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const [settings, files, p] = await Promise.all([
|
||||
api('/backup/scheduled'),
|
||||
api('/backup/files'),
|
||||
api('/backup/pending-restore'),
|
||||
api<ScheduledSettings>('/backup/scheduled'),
|
||||
api<BackupFile[]>('/backup/files'),
|
||||
api<PendingState>('/backup/pending-restore'),
|
||||
]);
|
||||
scheduledSettings = settings;
|
||||
backupFiles = files;
|
||||
@@ -84,7 +105,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
async function cancelPending() {
|
||||
async function cancelPending(): Promise<void> {
|
||||
try {
|
||||
await api('/backup/pending-restore', { method: 'DELETE' });
|
||||
snackSuccess(t('backup.pendingCancelled'));
|
||||
@@ -92,14 +113,13 @@
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
}
|
||||
|
||||
async function applyAndRestart() {
|
||||
async function applyAndRestart(): Promise<void> {
|
||||
try {
|
||||
await api('/backup/apply-restart', { method: 'POST' });
|
||||
restartingOverlay = true;
|
||||
// Poll /health until the new instance is up
|
||||
const startedAt = Date.now();
|
||||
let attempts = 0;
|
||||
const poll = async () => {
|
||||
const poll = async (): Promise<void> => {
|
||||
attempts += 1;
|
||||
try {
|
||||
const res = await fetch('/api/health');
|
||||
@@ -117,7 +137,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function createManualBackup() {
|
||||
async function createManualBackup(): Promise<void> {
|
||||
creatingBackup = true;
|
||||
try {
|
||||
const mode = scheduledSettings.backup_secrets_mode || 'exclude';
|
||||
@@ -132,7 +152,7 @@
|
||||
}
|
||||
|
||||
// --- Export ---
|
||||
async function doExport() {
|
||||
async function doExport(): Promise<void> {
|
||||
if (exportSecrets === 'include') {
|
||||
confirmExportOpen = true;
|
||||
return;
|
||||
@@ -140,7 +160,7 @@
|
||||
await performExport();
|
||||
}
|
||||
|
||||
async function performExport() {
|
||||
async function performExport(): Promise<void> {
|
||||
confirmExportOpen = false;
|
||||
exporting = true;
|
||||
try {
|
||||
@@ -165,8 +185,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
// --- Validate ---
|
||||
async function validateFile() {
|
||||
// --- Validate / Import ---
|
||||
function handleFileSelect(file: File | null): void {
|
||||
importFile = file;
|
||||
validationResult = null;
|
||||
importResult = null;
|
||||
}
|
||||
|
||||
async function validateFile(): Promise<void> {
|
||||
if (!importFile) return;
|
||||
validating = true;
|
||||
validationResult = null;
|
||||
@@ -183,12 +209,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
// --- Import ---
|
||||
async function doImport() {
|
||||
function doImport(): void {
|
||||
confirmImportOpen = true;
|
||||
}
|
||||
|
||||
async function performImport() {
|
||||
async function performImport(): Promise<void> {
|
||||
confirmImportOpen = false;
|
||||
if (!importFile) return;
|
||||
importing = true;
|
||||
@@ -213,10 +238,10 @@
|
||||
}
|
||||
|
||||
// --- Scheduled settings ---
|
||||
async function saveSchedule() {
|
||||
async function saveSchedule(): Promise<void> {
|
||||
savingSchedule = true;
|
||||
try {
|
||||
scheduledSettings = await api('/backup/scheduled', {
|
||||
scheduledSettings = await api<ScheduledSettings>('/backup/scheduled', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(scheduledSettings),
|
||||
});
|
||||
@@ -229,10 +254,10 @@
|
||||
}
|
||||
|
||||
// --- File management ---
|
||||
async function refreshFiles() {
|
||||
async function refreshFiles(): Promise<void> {
|
||||
loadingFiles = true;
|
||||
try {
|
||||
backupFiles = await api('/backup/files');
|
||||
backupFiles = await api<BackupFile[]>('/backup/files');
|
||||
} catch (err: any) {
|
||||
snackError(err.message);
|
||||
} finally {
|
||||
@@ -240,7 +265,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFile(filename: string) {
|
||||
async function downloadFile(filename: string): Promise<void> {
|
||||
try {
|
||||
const data = await api(`/backup/files/${filename}`);
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
@@ -255,7 +280,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFile(filename: string) {
|
||||
async function deleteFile(filename: string): Promise<void> {
|
||||
try {
|
||||
await api(`/backup/files/${filename}`, { method: 'DELETE' });
|
||||
snackSuccess(t('backup.fileDeleted'));
|
||||
@@ -265,355 +290,61 @@
|
||||
snackError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (input.files?.length) {
|
||||
importFile = input.files[0];
|
||||
validationResult = null;
|
||||
importResult = null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
let allSelected = $derived(Object.values(selectedCategories).every(v => v));
|
||||
let noneSelected = $derived(Object.values(selectedCategories).every(v => !v));
|
||||
|
||||
function toggleAll() {
|
||||
const newVal = !allSelected;
|
||||
for (const key of Object.keys(selectedCategories)) {
|
||||
selectedCategories[key] = newVal;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageHeader
|
||||
title={t('backup.title')}
|
||||
emphasis={t('backup.titleEmphasis')}
|
||||
description={t('backup.description')}
|
||||
crumb={t('crumbs.systemMaintenance')}
|
||||
/>
|
||||
<BackupHero files={backupFiles} scheduled={scheduledSettings} {pending} />
|
||||
|
||||
{#if !loaded}
|
||||
<Loading />
|
||||
{:else}
|
||||
<ErrorBanner message={error} />
|
||||
|
||||
{#if pending?.pending}
|
||||
<div class="mb-4 p-3 rounded-lg flex flex-wrap items-center gap-3 pending-banner"
|
||||
style="border: 1px solid color-mix(in srgb, var(--color-warning-fg) 40%, transparent); background: color-mix(in srgb, var(--color-warning-bg) 60%, transparent);">
|
||||
<span style="color: var(--color-warning-fg); flex-shrink: 0;">
|
||||
<MdiIcon name="mdiClockAlert" size={20} />
|
||||
</span>
|
||||
<div class="flex-1 min-w-[12rem] text-sm">
|
||||
<div class="font-medium">{t('backup.pendingTitle')}</div>
|
||||
<div class="text-xs break-words" style="color: var(--color-muted-foreground);">
|
||||
{t('backup.pendingBy').replace('{by}', pending.uploaded_by || '')} · {t('backup.pendingAt').replace('{at}', pending.uploaded_at || '')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
{#if pending.supervised}
|
||||
<Button size="sm" onclick={applyAndRestart}>
|
||||
<MdiIcon name="mdiRestart" size={14} /> {t('backup.restartNow')}
|
||||
</Button>
|
||||
{/if}
|
||||
<button onclick={cancelPending}
|
||||
class="px-3 py-1.5 text-sm rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
<PendingStrip {pending} onApply={applyAndRestart} onCancel={cancelPending} />
|
||||
|
||||
<div class="backup-page stagger-children">
|
||||
<div class="action-deck">
|
||||
<ExportPanel
|
||||
{selectedCategories}
|
||||
{exportSecrets}
|
||||
{exporting}
|
||||
onCategoriesChange={(next) => selectedCategories = next}
|
||||
onSecretsChange={(next) => exportSecrets = next}
|
||||
onExport={doExport}
|
||||
/>
|
||||
<ImportPanel
|
||||
{importFile}
|
||||
{importConflict}
|
||||
{validating}
|
||||
{validationResult}
|
||||
{importing}
|
||||
{importResult}
|
||||
onFileSelect={handleFileSelect}
|
||||
onConflictChange={(mode) => importConflict = mode}
|
||||
onValidate={validateFile}
|
||||
onImport={doImport}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-6">
|
||||
<ScheduleCassette
|
||||
enabled={scheduledSettings.backup_scheduled_enabled === 'true'}
|
||||
bind:intervalHours={scheduledSettings.backup_scheduled_interval_hours}
|
||||
bind:secretsMode={scheduledSettings.backup_secrets_mode}
|
||||
bind:retentionCount={scheduledSettings.backup_retention_count}
|
||||
saving={savingSchedule}
|
||||
onToggle={() => scheduledSettings.backup_scheduled_enabled =
|
||||
scheduledSettings.backup_scheduled_enabled === 'true' ? 'false' : 'true'}
|
||||
onSave={saveSchedule}
|
||||
/>
|
||||
|
||||
<!-- Export Section -->
|
||||
<Card>
|
||||
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
||||
<MdiIcon name="mdiDatabaseExport" size={18} />
|
||||
{t('backup.export')}
|
||||
</h3>
|
||||
<p class="text-xs mb-4" style="color: var(--color-muted-foreground);">{t('backup.exportDescription')}</p>
|
||||
|
||||
<!-- Categories -->
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-xs font-medium">{t('backup.categories')}</span>
|
||||
<button class="text-xs underline" style="color: var(--color-primary);" onclick={toggleAll}>
|
||||
{allSelected ? t('backup.deselectAll') : t('backup.selectAll')}
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-1.5">
|
||||
{#each categories as cat}
|
||||
<label class="flex items-center gap-1.5 text-xs">
|
||||
<input type="checkbox" bind:checked={selectedCategories[cat.key]} />
|
||||
{t(cat.label)}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Secrets mode -->
|
||||
<div class="mb-4">
|
||||
<div class="block text-xs font-medium mb-2">{t('backup.secretsMode')}</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="flex items-center gap-1.5 text-xs">
|
||||
<input type="radio" bind:group={exportSecrets} value="exclude" />
|
||||
{t('backup.secretsExclude')}
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 text-xs">
|
||||
<input type="radio" bind:group={exportSecrets} value="masked" />
|
||||
{t('backup.secretsMasked')}
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 text-xs">
|
||||
<input type="radio" bind:group={exportSecrets} value="include" />
|
||||
{t('backup.secretsInclude')}
|
||||
</label>
|
||||
</div>
|
||||
{#if exportSecrets === 'include'}
|
||||
<div class="mt-2 p-2 rounded-md text-xs flex items-center gap-2"
|
||||
style="background: var(--color-error-bg); color: var(--color-error-fg);">
|
||||
<MdiIcon name="mdiAlert" size={14} />
|
||||
{t('backup.secretsWarningExport')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Button onclick={doExport} disabled={exporting || noneSelected}>
|
||||
{#if exporting}
|
||||
<MdiIcon name="mdiLoading" size={14} />
|
||||
{:else}
|
||||
<MdiIcon name="mdiDownload" size={14} />
|
||||
{/if}
|
||||
{exporting ? t('common.loading') : t('backup.exportBtn')}
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<!-- Import Section -->
|
||||
<Card>
|
||||
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
||||
<MdiIcon name="mdiDatabaseImport" size={18} />
|
||||
{t('backup.import')}
|
||||
</h3>
|
||||
<p class="text-xs mb-4" style="color: var(--color-muted-foreground);">{t('backup.importDescription')}</p>
|
||||
|
||||
<!-- File picker -->
|
||||
<div class="mb-4">
|
||||
<input type="file" accept=".json" onchange={handleFileSelect}
|
||||
class="text-xs file:mr-2 file:py-1.5 file:px-3 file:rounded-md file:border-0 file:text-xs file:font-medium file:cursor-pointer"
|
||||
style="file:background: var(--color-muted); file:color: var(--color-foreground);" />
|
||||
</div>
|
||||
|
||||
{#if importFile}
|
||||
<!-- Validate -->
|
||||
<div class="mb-4 flex items-center gap-2">
|
||||
<Button variant="secondary" onclick={validateFile} disabled={validating}>
|
||||
{#if validating}
|
||||
<MdiIcon name="mdiLoading" size={14} />
|
||||
{:else}
|
||||
<MdiIcon name="mdiCheckCircleOutline" size={14} />
|
||||
{/if}
|
||||
{validating ? t('backup.validating') : t('backup.validateBtn')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if validationResult}
|
||||
<div class="mb-4 p-3 rounded-md text-xs border" style="border-color: var(--color-border);">
|
||||
<div class="flex items-center gap-2 mb-2 font-medium">
|
||||
{#if validationResult.valid}
|
||||
<span style="color: var(--color-success-fg, green);"><MdiIcon name="mdiCheckCircle" size={14} /></span>
|
||||
<span style="color: var(--color-success-fg, green);">{t('backup.validationPassed')}</span>
|
||||
{:else}
|
||||
<MdiIcon name="mdiCloseCircle" size={14} />
|
||||
<span style="color: var(--color-error-fg);">{t('backup.validationFailed')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if Object.keys(validationResult.entity_counts || {}).length}
|
||||
<div class="mb-2">
|
||||
<span class="font-medium">{t('backup.entities')}:</span>
|
||||
{#each Object.entries(validationResult.entity_counts) as [cat, count]}
|
||||
<span class="inline-block mr-2">{cat}: {count}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#each validationResult.warnings || [] as w}
|
||||
<div class="flex items-start gap-1 mt-1" style="color: var(--color-warning-fg, orange);">
|
||||
<MdiIcon name="mdiAlert" size={12} />
|
||||
<span>{w}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{#each validationResult.errors || [] as e}
|
||||
<div class="flex items-start gap-1 mt-1" style="color: var(--color-error-fg);">
|
||||
<MdiIcon name="mdiAlertCircle" size={12} />
|
||||
<span>{e}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Conflict mode -->
|
||||
<div class="mb-4">
|
||||
<div class="block text-xs font-medium mb-2">{t('backup.conflictMode')}</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="flex items-center gap-1.5 text-xs">
|
||||
<input type="radio" bind:group={importConflict} value="skip" />
|
||||
{t('backup.conflictSkip')}
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 text-xs">
|
||||
<input type="radio" bind:group={importConflict} value="rename" />
|
||||
{t('backup.conflictRename')}
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 text-xs">
|
||||
<input type="radio" bind:group={importConflict} value="overwrite" />
|
||||
{t('backup.conflictOverwrite')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onclick={doImport}
|
||||
disabled={importing || !validationResult?.valid}>
|
||||
{#if importing}
|
||||
<MdiIcon name="mdiLoading" size={14} />
|
||||
{:else}
|
||||
<MdiIcon name="mdiUpload" size={14} />
|
||||
{/if}
|
||||
{importing ? t('backup.importing') : t('backup.importBtn')}
|
||||
</Button>
|
||||
|
||||
{#if importResult}
|
||||
<div class="mt-4 p-3 rounded-md text-xs border" style="border-color: var(--color-border);">
|
||||
<div class="font-medium mb-1">{t('backup.importResults')}</div>
|
||||
<div class="space-y-0.5">
|
||||
<div>{t('backup.resultCreated')}: {importResult.created}</div>
|
||||
<div>{t('backup.resultSkipped')}: {importResult.skipped}</div>
|
||||
<div>{t('backup.resultOverwritten')}: {importResult.overwritten}</div>
|
||||
{#if importResult.errors?.length}
|
||||
<div style="color: var(--color-error-fg);">{t('backup.resultErrors')}: {importResult.errors.length}</div>
|
||||
{#each importResult.errors as e}
|
||||
<div class="ml-2" style="color: var(--color-error-fg);">{e}</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if importResult.warnings?.length}
|
||||
{#each importResult.warnings as w}
|
||||
<div class="ml-2" style="color: var(--color-warning-fg, orange);">{w}</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<!-- Scheduled Backups Section -->
|
||||
<Card>
|
||||
<h3 class="text-sm font-semibold mb-4 flex items-center gap-2">
|
||||
<MdiIcon name="mdiClockOutline" size={18} />
|
||||
{t('backup.scheduled')}
|
||||
</h3>
|
||||
|
||||
<div class="space-y-3">
|
||||
<label class="flex items-center gap-2 text-xs">
|
||||
<input type="checkbox"
|
||||
checked={scheduledSettings.backup_scheduled_enabled === 'true'}
|
||||
onchange={() => scheduledSettings.backup_scheduled_enabled =
|
||||
scheduledSettings.backup_scheduled_enabled === 'true' ? 'false' : 'true'} />
|
||||
<span class="font-medium">{t('backup.enableScheduled')}</span>
|
||||
</label>
|
||||
|
||||
{#if scheduledSettings.backup_scheduled_enabled === 'true'}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label for="backup-interval" class="block text-xs font-medium mb-1">{t('backup.interval')}</label>
|
||||
<select id="backup-interval" bind:value={scheduledSettings.backup_scheduled_interval_hours}
|
||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
||||
<option value="6">6 {t('backup.hours')}</option>
|
||||
<option value="12">12 {t('backup.hours')}</option>
|
||||
<option value="24">24 {t('backup.hours')}</option>
|
||||
<option value="48">48 {t('backup.hours')}</option>
|
||||
<option value="72">72 {t('backup.hours')}</option>
|
||||
<option value="168">168 {t('backup.hours')} (7d)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="backup-secrets-mode" class="block text-xs font-medium mb-1">{t('backup.secretsMode')}</label>
|
||||
<select id="backup-secrets-mode" bind:value={scheduledSettings.backup_secrets_mode}
|
||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
||||
<option value="exclude">{t('backup.secretsExclude')}</option>
|
||||
<option value="masked">{t('backup.secretsMasked')}</option>
|
||||
<option value="include">{t('backup.secretsInclude')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="backup-retention" class="block text-xs font-medium mb-1">{t('backup.retention')}</label>
|
||||
<select id="backup-retention" bind:value={scheduledSettings.backup_retention_count}
|
||||
class="w-full px-3 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]">
|
||||
<option value="3">3</option>
|
||||
<option value="5">5</option>
|
||||
<option value="10">10</option>
|
||||
<option value="20">20</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<Button onclick={saveSchedule} disabled={savingSchedule}>
|
||||
{savingSchedule ? t('common.loading') : t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Saved Backup Files -->
|
||||
<Card>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-semibold flex items-center gap-2">
|
||||
<MdiIcon name="mdiFolder" size={18} />
|
||||
{t('backup.savedFiles')}
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button size="sm" onclick={createManualBackup} disabled={creatingBackup}>
|
||||
<MdiIcon name="mdiPlus" size={14} /> {creatingBackup ? t('common.loading') : t('backup.createManual')}
|
||||
</Button>
|
||||
<button onclick={refreshFiles} class="text-xs" style="color: var(--color-primary);" disabled={loadingFiles}>
|
||||
<MdiIcon name="mdiRefresh" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if backupFiles.length === 0}
|
||||
<p class="text-xs" style="color: var(--color-muted-foreground);">{t('backup.noFiles')}</p>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each backupFiles as file}
|
||||
<div class="flex items-center justify-between p-2 rounded-md border text-xs"
|
||||
style="border-color: var(--color-border);">
|
||||
<div class="flex items-center gap-2">
|
||||
<MdiIcon name="mdiFileDocument" size={14} />
|
||||
<span class="font-mono">{file.filename}</span>
|
||||
<span style="color: var(--color-muted-foreground);">({formatSize(file.size)})</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button onclick={() => downloadFile(file.filename)}
|
||||
class="p-1 rounded hover:bg-[var(--color-muted)]" title={t('backup.download')}>
|
||||
<MdiIcon name="mdiDownload" size={14} />
|
||||
</button>
|
||||
<button onclick={() => confirmDeleteFile = file.filename}
|
||||
class="p-1 rounded hover:bg-[var(--color-muted)]" title={t('common.delete')}
|
||||
style="color: var(--color-error-fg);">
|
||||
<MdiIcon name="mdiDelete" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</Card>
|
||||
<BackupLedger
|
||||
files={backupFiles}
|
||||
loading={loadingFiles}
|
||||
creating={creatingBackup}
|
||||
onCreate={createManualBackup}
|
||||
onRefresh={refreshFiles}
|
||||
onDownload={downloadFile}
|
||||
onDelete={(filename) => confirmDeleteFile = filename}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -652,27 +383,25 @@
|
||||
<svelte:window onkeydown={postRestoreModalOpen ? (e) => { if (e.key === 'Escape') postRestoreModalOpen = false; } : undefined} />
|
||||
{#if postRestoreModalOpen && pending?.pending}
|
||||
<div class="post-restore-backdrop"
|
||||
style="position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,0.5); backdrop-filter: blur(3px); display: flex; align-items: center; justify-content: center; padding: 1rem;"
|
||||
onclick={() => postRestoreModalOpen = false}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') postRestoreModalOpen = false; }}
|
||||
role="presentation">
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div role="dialog" aria-modal="true" aria-labelledby="post-restore-title" tabindex="-1"
|
||||
style="background: var(--color-card); border: 1px solid var(--color-border); border-radius: 1rem; padding: 1.5rem; max-width: 420px; width: 100%; box-shadow: 0 20px 60px rgba(0,0,0,0.4);"
|
||||
class="post-restore-card"
|
||||
onclick={(e) => e.stopPropagation()}>
|
||||
<div class="flex items-start gap-3 mb-4">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-full flex-shrink-0"
|
||||
style="background: var(--color-warning-bg); color: var(--color-warning-fg);">
|
||||
<div class="post-restore-head">
|
||||
<div class="post-restore-icon">
|
||||
<MdiIcon name="mdiClockAlert" size={22} />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h3 id="post-restore-title" class="font-semibold mb-1">{t('backup.restorePrepared')}</h3>
|
||||
<p class="text-sm break-words" style="color: var(--color-muted-foreground);">{t('backup.restoreApplyPrompt')}</p>
|
||||
<div class="post-restore-text">
|
||||
<h3 id="post-restore-title">{t('backup.restorePrepared')}</h3>
|
||||
<p>{t('backup.restoreApplyPrompt')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end flex-wrap">
|
||||
<button onclick={() => postRestoreModalOpen = false}
|
||||
class="px-3 py-2 text-sm rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors">
|
||||
<div class="post-restore-actions">
|
||||
<button class="post-restore-later" type="button"
|
||||
onclick={() => postRestoreModalOpen = false}>
|
||||
{t('backup.applyLater')}
|
||||
</button>
|
||||
{#if pending.supervised}
|
||||
@@ -687,30 +416,162 @@
|
||||
|
||||
<!-- Restarting overlay -->
|
||||
{#if restartingOverlay}
|
||||
<div role="alert" aria-live="assertive"
|
||||
style="position: fixed; inset: 0; z-index: 60; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); padding: 1rem;">
|
||||
<div class="text-center p-6" style="color: var(--color-foreground);">
|
||||
<div class="restart-spinner" style="color: var(--color-primary); margin-bottom: 1rem;">
|
||||
<div class="restart-overlay" role="alert" aria-live="assertive">
|
||||
<div class="restart-card">
|
||||
<div class="restart-spinner">
|
||||
<MdiIcon name="mdiRestart" size={40} />
|
||||
</div>
|
||||
<p class="text-lg font-semibold">{t('backup.restartingTitle')}</p>
|
||||
<p class="text-sm mt-2" style="color: var(--color-muted-foreground);">{t('backup.restartingDescription')}</p>
|
||||
<p class="restart-title">{t('backup.restartingTitle')}</p>
|
||||
<p class="restart-sub">{t('backup.restartingDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.backup-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.action-deck {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.25rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
@media (min-width: 960px) {
|
||||
.action-deck { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
|
||||
/* Post-restore modal */
|
||||
.post-restore-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
.post-restore-card {
|
||||
background: var(--color-glass-elev);
|
||||
backdrop-filter: blur(28px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(28px) saturate(160%);
|
||||
border: 1px solid var(--color-rule-strong);
|
||||
border-radius: 22px;
|
||||
padding: 1.5rem;
|
||||
max-width: 440px;
|
||||
width: 100%;
|
||||
box-shadow: 0 30px 70px -16px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.post-restore-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.85rem;
|
||||
margin-bottom: 1.1rem;
|
||||
}
|
||||
.post-restore-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px; height: 42px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-warning-bg);
|
||||
color: var(--color-warning-fg);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.post-restore-text { min-width: 0; }
|
||||
.post-restore-text h3 {
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
font-size: 1.15rem;
|
||||
margin: 0 0 0.25rem;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
.post-restore-text p {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-muted-foreground);
|
||||
margin: 0;
|
||||
line-height: 1.45;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.post-restore-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.post-restore-later {
|
||||
padding: 0 0.95rem;
|
||||
height: 34px;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.82rem;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.post-restore-later:hover {
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-foreground);
|
||||
border-color: var(--color-rule-strong);
|
||||
}
|
||||
|
||||
/* Restarting overlay */
|
||||
.restart-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
.restart-card {
|
||||
text-align: center;
|
||||
padding: 1.6rem 2rem;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
.restart-spinner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-bottom: 0.85rem;
|
||||
color: var(--color-primary);
|
||||
animation: restart-spin 1.2s linear infinite;
|
||||
transform-origin: center center;
|
||||
}
|
||||
.restart-title {
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
letter-spacing: -0.015em;
|
||||
}
|
||||
.restart-sub {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-muted-foreground);
|
||||
margin: 0.4rem 0 0;
|
||||
}
|
||||
@keyframes restart-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.restart-spinner { animation: none !important; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
|
||||
type Tone = 'mint' | 'sky' | 'orchid' | 'coral' | 'citrus' | 'primary';
|
||||
|
||||
interface BackupFile {
|
||||
filename: string;
|
||||
size: number;
|
||||
created_at?: string | null;
|
||||
}
|
||||
|
||||
interface ScheduledSettings {
|
||||
backup_scheduled_enabled: string;
|
||||
backup_scheduled_interval_hours: string;
|
||||
backup_secrets_mode: string;
|
||||
backup_retention_count: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
files: BackupFile[];
|
||||
scheduled: ScheduledSettings;
|
||||
pending: { pending: boolean } | null;
|
||||
}
|
||||
|
||||
let { files, scheduled, pending }: Props = $props();
|
||||
|
||||
function relativeTime(iso: string | null | undefined): string {
|
||||
if (!iso) return '';
|
||||
const date = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
|
||||
if (isNaN(date.getTime())) return '';
|
||||
const diffSec = Math.max(0, (Date.now() - date.getTime()) / 1000);
|
||||
if (diffSec < 60) return t('dashboard.justNow');
|
||||
const min = Math.floor(diffSec / 60);
|
||||
if (min < 60) return t('dashboard.minutesAgo').replace('{n}', String(min));
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return t('dashboard.hoursAgo').replace('{n}', String(hr));
|
||||
const day = Math.floor(hr / 24);
|
||||
return t('dashboard.daysAgo').replace('{n}', String(day));
|
||||
}
|
||||
|
||||
function latestCreatedAt(list: BackupFile[]): string | null {
|
||||
const stamps = list
|
||||
.map(f => f.created_at)
|
||||
.filter((s): s is string => !!s)
|
||||
.sort();
|
||||
return stamps.length ? stamps[stamps.length - 1] : null;
|
||||
}
|
||||
|
||||
function ageHours(iso: string | null): number {
|
||||
if (!iso) return Infinity;
|
||||
const date = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
|
||||
if (isNaN(date.getTime())) return Infinity;
|
||||
return (Date.now() - date.getTime()) / 3_600_000;
|
||||
}
|
||||
|
||||
const pills = $derived.by<Array<{ label: string; tone?: Tone }>>(() => {
|
||||
const out: Array<{ label: string; tone?: Tone }> = [];
|
||||
if (pending?.pending) {
|
||||
out.push({ label: t('backup.restorePrepared'), tone: 'coral' });
|
||||
}
|
||||
if (scheduled.backup_scheduled_enabled === 'true') {
|
||||
out.push({
|
||||
label: t('backup.scheduleOn').replace('{h}', scheduled.backup_scheduled_interval_hours || '24'),
|
||||
tone: 'mint',
|
||||
});
|
||||
} else {
|
||||
out.push({ label: t('backup.scheduleOff') });
|
||||
}
|
||||
const latest = latestCreatedAt(files);
|
||||
if (latest) {
|
||||
const hours = ageHours(latest);
|
||||
const tone: Tone = hours < 48 ? 'mint' : hours < 24 * 7 ? 'citrus' : 'coral';
|
||||
out.push({ label: t('backup.lastBackup').replace('{ago}', relativeTime(latest)), tone });
|
||||
} else {
|
||||
out.push({ label: t('backup.never'), tone: 'citrus' });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
</script>
|
||||
|
||||
<PageHeader
|
||||
title={t('backup.title')}
|
||||
emphasis={t('backup.titleEmphasis')}
|
||||
description={t('backup.description')}
|
||||
crumb={t('crumbs.systemMaintenance')}
|
||||
count={files.length}
|
||||
countLabel={t('backup.countLabel')}
|
||||
{pills}
|
||||
/>
|
||||
@@ -0,0 +1,357 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
|
||||
interface BackupFile {
|
||||
filename: string;
|
||||
size: number;
|
||||
created_at?: string | null;
|
||||
}
|
||||
|
||||
type Tone = 'mint' | 'sky' | 'citrus' | 'coral';
|
||||
|
||||
interface Props {
|
||||
files: BackupFile[];
|
||||
loading: boolean;
|
||||
creating: boolean;
|
||||
onCreate: () => void;
|
||||
onRefresh: () => void;
|
||||
onDownload: (filename: string) => void;
|
||||
onDelete: (filename: string) => void;
|
||||
}
|
||||
|
||||
let { files, loading, creating, onCreate, onRefresh, onDownload, onDelete }: Props = $props();
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes) return '0 B';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function parseDate(iso: string | null | undefined): Date | null {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(iso) ? iso : iso + 'Z');
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
function relativeTime(iso: string | null | undefined): string {
|
||||
const date = parseDate(iso);
|
||||
if (!date) return '';
|
||||
const diffSec = Math.max(0, (Date.now() - date.getTime()) / 1000);
|
||||
if (diffSec < 60) return t('dashboard.justNow');
|
||||
const min = Math.floor(diffSec / 60);
|
||||
if (min < 60) return t('dashboard.minutesAgo').replace('{n}', String(min));
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return t('dashboard.hoursAgo').replace('{n}', String(hr));
|
||||
const day = Math.floor(hr / 24);
|
||||
return t('dashboard.daysAgo').replace('{n}', String(day));
|
||||
}
|
||||
|
||||
function absoluteTime(iso: string | null | undefined): string {
|
||||
const date = parseDate(iso);
|
||||
return date ? date.toLocaleString() : '—';
|
||||
}
|
||||
|
||||
function ageTone(iso: string | null | undefined): Tone {
|
||||
const date = parseDate(iso);
|
||||
if (!date) return 'coral';
|
||||
const hours = (Date.now() - date.getTime()) / 3_600_000;
|
||||
if (hours < 48) return 'mint';
|
||||
if (hours < 24 * 7) return 'sky';
|
||||
if (hours < 24 * 30) return 'citrus';
|
||||
return 'coral';
|
||||
}
|
||||
|
||||
const totalSize = $derived(files.reduce((sum, f) => sum + (f.size || 0), 0));
|
||||
</script>
|
||||
|
||||
<section class="ledger glass">
|
||||
<header class="ledger-head">
|
||||
<div>
|
||||
<div class="ledger-eyebrow">
|
||||
<MdiIcon name="mdiArchiveOutline" size={12} />
|
||||
<span>{t('backup.savedFiles')}</span>
|
||||
</div>
|
||||
{#if files.length > 0}
|
||||
<div class="ledger-summary">
|
||||
<span class="ledger-count font-mono">{files.length}</span>
|
||||
<span class="ledger-count-label">{t('backup.countLabel')}</span>
|
||||
<span class="ledger-sep">·</span>
|
||||
<span class="ledger-total">{t('backup.totalSize').replace('{size}', formatBytes(totalSize))}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="ledger-actions">
|
||||
<Button size="sm" variant="secondary" onclick={onCreate} disabled={creating}>
|
||||
{#if creating}
|
||||
<MdiIcon name="mdiLoading" size={14} />
|
||||
{:else}
|
||||
<MdiIcon name="mdiPlus" size={14} />
|
||||
{/if}
|
||||
{creating ? t('common.loading') : t('backup.createManual')}
|
||||
</Button>
|
||||
<button class="icon-btn" type="button" onclick={onRefresh} disabled={loading}
|
||||
aria-label={t('common.refresh', 'Refresh')} title={t('common.refresh', 'Refresh')}>
|
||||
<span class:spinning={loading}><MdiIcon name="mdiRefresh" size={16} /></span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if files.length === 0}
|
||||
<div class="ledger-empty">
|
||||
<MdiIcon name="mdiCloudOffOutline" size={28} />
|
||||
<p>{t('backup.noFiles')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ol class="ledger-list">
|
||||
{#each files as file (file.filename)}
|
||||
{@const tone = ageTone(file.created_at)}
|
||||
<li class="row" data-tone={tone}>
|
||||
<span class="row-edge" aria-hidden="true"></span>
|
||||
<span class="row-dot" aria-hidden="true"></span>
|
||||
<div class="row-time">
|
||||
<span class="row-rel">{relativeTime(file.created_at) || '—'}</span>
|
||||
<span class="row-abs" title={absoluteTime(file.created_at)}>
|
||||
{absoluteTime(file.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="row-name">
|
||||
<span class="row-filename" title={file.filename}>{file.filename}</span>
|
||||
</div>
|
||||
<span class="row-size font-mono">{formatBytes(file.size)}</span>
|
||||
<div class="row-actions">
|
||||
<button class="icon-btn" type="button"
|
||||
onclick={() => onDownload(file.filename)}
|
||||
aria-label={t('backup.download')}
|
||||
title={t('backup.download')}>
|
||||
<MdiIcon name="mdiDownload" size={14} />
|
||||
</button>
|
||||
<button class="icon-btn icon-btn-danger" type="button"
|
||||
onclick={() => onDelete(file.filename)}
|
||||
aria-label={t('common.delete')}
|
||||
title={t('common.delete')}>
|
||||
<MdiIcon name="mdiTrashCanOutline" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.ledger {
|
||||
padding: 1.4rem 1.5rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.95rem;
|
||||
}
|
||||
.ledger-head {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ledger-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--color-muted-foreground);
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
.ledger-summary {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.45rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.ledger-count {
|
||||
font-size: 1.7rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.025em;
|
||||
color: var(--color-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.ledger-count-label {
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.ledger-sep { color: var(--color-muted-foreground); opacity: 0.5; }
|
||||
.ledger-total {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.ledger-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.icon-btn:hover:not(:disabled) {
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-foreground);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
.icon-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.icon-btn-danger:hover:not(:disabled) {
|
||||
color: var(--color-error-fg);
|
||||
border-color: color-mix(in srgb, var(--color-error-fg) 35%, var(--color-border));
|
||||
background: color-mix(in srgb, var(--color-error-fg) 8%, var(--color-glass-strong));
|
||||
}
|
||||
.spinning {
|
||||
display: inline-flex;
|
||||
animation: ledger-spin 1.1s linear infinite;
|
||||
}
|
||||
@keyframes ledger-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.ledger-empty {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1.6rem 1rem;
|
||||
color: var(--color-muted-foreground);
|
||||
text-align: center;
|
||||
}
|
||||
.ledger-empty p { margin: 0; font-size: 0.8rem; }
|
||||
|
||||
.ledger-list {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.row {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0.55rem 0.75rem 0.55rem 1rem;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-glass-strong);
|
||||
transition: transform 0.18s, border-color 0.18s, background 0.18s;
|
||||
overflow: hidden;
|
||||
}
|
||||
.row:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--color-rule-strong);
|
||||
background: var(--color-glass-elev);
|
||||
}
|
||||
.row-edge {
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 3px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.row[data-tone="mint"] .row-edge { background: var(--color-mint); }
|
||||
.row[data-tone="sky"] .row-edge { background: var(--color-sky); }
|
||||
.row[data-tone="citrus"] .row-edge { background: var(--color-citrus); }
|
||||
.row[data-tone="coral"] .row-edge { background: var(--color-coral); }
|
||||
.row-dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.row[data-tone="mint"] .row-dot { background: var(--color-mint); box-shadow: 0 0 8px color-mix(in srgb, var(--color-mint) 50%, transparent); }
|
||||
.row[data-tone="sky"] .row-dot { background: var(--color-sky); }
|
||||
.row[data-tone="citrus"] .row-dot { background: var(--color-citrus); }
|
||||
.row[data-tone="coral"] .row-dot { background: var(--color-coral); }
|
||||
|
||||
.row-time {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.05rem;
|
||||
min-width: 6.5rem;
|
||||
}
|
||||
.row-rel {
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-foreground);
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
.row-abs {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
color: var(--color-muted-foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 14rem;
|
||||
}
|
||||
.row-name {
|
||||
min-width: 0;
|
||||
}
|
||||
.row-filename {
|
||||
display: block;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-muted-foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.row:hover .row-filename { color: var(--color-foreground); }
|
||||
|
||||
.row-size {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 0.15rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.18s;
|
||||
}
|
||||
.row:hover .row-actions,
|
||||
.row:focus-within .row-actions { opacity: 1; }
|
||||
@media (max-width: 640px) {
|
||||
.row { grid-template-columns: auto 1fr auto; row-gap: 0.25rem; }
|
||||
.row-time { grid-column: 2; min-width: 0; }
|
||||
.row-name { grid-column: 1 / -1; }
|
||||
.row-size { grid-column: 3; grid-row: 1; }
|
||||
.row-actions { grid-column: 1 / -1; opacity: 1; justify-content: flex-end; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.row { transition: none !important; }
|
||||
.row:hover { transform: none !important; }
|
||||
.spinning { animation: none !important; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,392 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
|
||||
type SecretsMode = 'exclude' | 'masked' | 'include';
|
||||
|
||||
interface Props {
|
||||
selectedCategories: Record<string, boolean>;
|
||||
exportSecrets: SecretsMode;
|
||||
exporting: boolean;
|
||||
onCategoriesChange: (next: Record<string, boolean>) => void;
|
||||
onSecretsChange: (next: SecretsMode) => void;
|
||||
onExport: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
selectedCategories,
|
||||
exportSecrets,
|
||||
exporting,
|
||||
onCategoriesChange,
|
||||
onSecretsChange,
|
||||
onExport,
|
||||
}: Props = $props();
|
||||
|
||||
const categoryGroups: Array<{ key: string; labelKey: string; icon: string; cats: Array<{ key: string; labelKey: string }> }> = [
|
||||
{
|
||||
key: 'identity',
|
||||
labelKey: 'backup.catGroupIdentity',
|
||||
icon: 'mdiAccountNetwork',
|
||||
cats: [
|
||||
{ key: 'providers', labelKey: 'backup.catProviders' },
|
||||
{ key: 'telegram_bots', labelKey: 'backup.catTelegramBots' },
|
||||
{ key: 'matrix_bots', labelKey: 'backup.catMatrixBots' },
|
||||
{ key: 'email_bots', labelKey: 'backup.catEmailBots' },
|
||||
{ key: 'targets', labelKey: 'backup.catTargets' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'notif',
|
||||
labelKey: 'backup.catGroupNotif',
|
||||
icon: 'mdiBellOutline',
|
||||
cats: [
|
||||
{ key: 'tracking_configs', labelKey: 'backup.catTrackingConfigs' },
|
||||
{ key: 'template_configs', labelKey: 'backup.catTemplateConfigs' },
|
||||
{ key: 'notification_trackers', labelKey: 'backup.catNotificationTrackers' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'cmd',
|
||||
labelKey: 'backup.catGroupCmd',
|
||||
icon: 'mdiConsoleLine',
|
||||
cats: [
|
||||
{ key: 'command_configs', labelKey: 'backup.catCommandConfigs' },
|
||||
{ key: 'command_template_configs', labelKey: 'backup.catCommandTemplateConfigs' },
|
||||
{ key: 'command_trackers', labelKey: 'backup.catCommandTrackers' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'system',
|
||||
labelKey: 'backup.catGroupSystem',
|
||||
icon: 'mdiCog',
|
||||
cats: [
|
||||
{ key: 'actions', labelKey: 'backup.catActions' },
|
||||
{ key: 'app_settings', labelKey: 'backup.catAppSettings' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function toggleCat(key: string): void {
|
||||
onCategoriesChange({ ...selectedCategories, [key]: !selectedCategories[key] });
|
||||
}
|
||||
|
||||
function groupState(groupKey: string): 'all' | 'none' | 'some' {
|
||||
const group = categoryGroups.find(g => g.key === groupKey);
|
||||
if (!group) return 'none';
|
||||
const flags = group.cats.map(c => !!selectedCategories[c.key]);
|
||||
if (flags.every(v => v)) return 'all';
|
||||
if (flags.every(v => !v)) return 'none';
|
||||
return 'some';
|
||||
}
|
||||
|
||||
function toggleGroup(groupKey: string): void {
|
||||
const group = categoryGroups.find(g => g.key === groupKey);
|
||||
if (!group) return;
|
||||
const target = groupState(groupKey) !== 'all';
|
||||
const next = { ...selectedCategories };
|
||||
for (const c of group.cats) next[c.key] = target;
|
||||
onCategoriesChange(next);
|
||||
}
|
||||
|
||||
const noneSelected = $derived(Object.values(selectedCategories).every(v => !v));
|
||||
const totalSelected = $derived(Object.values(selectedCategories).filter(v => v).length);
|
||||
|
||||
const secretsModes: Array<{ value: SecretsMode; icon: string; labelKey: string }> = [
|
||||
{ value: 'exclude', icon: 'mdiShieldCheckOutline', labelKey: 'backup.secretsExclude' },
|
||||
{ value: 'masked', icon: 'mdiEyeOffOutline', labelKey: 'backup.secretsMasked' },
|
||||
{ value: 'include', icon: 'mdiKeyVariant', labelKey: 'backup.secretsInclude' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<section class="export-panel glass">
|
||||
<header class="panel-head">
|
||||
<div class="panel-eyebrow">
|
||||
<MdiIcon name="mdiDatabaseExport" size={14} />
|
||||
<span>{t('backup.export')}</span>
|
||||
</div>
|
||||
<h3 class="panel-title">{t('backup.exportDescription')}</h3>
|
||||
</header>
|
||||
|
||||
<div class="panel-body">
|
||||
<!-- Step 1: categories -->
|
||||
<div class="step">
|
||||
<div class="step-head">
|
||||
<span class="step-num">01</span>
|
||||
<span class="step-label">{t('backup.stepCategories')}</span>
|
||||
<span class="step-count">{totalSelected}</span>
|
||||
</div>
|
||||
<div class="group-grid">
|
||||
{#each categoryGroups as group}
|
||||
{@const state = groupState(group.key)}
|
||||
<div class="group" class:group-all={state === 'all'} class:group-some={state === 'some'}>
|
||||
<button class="group-head" type="button" onclick={() => toggleGroup(group.key)}>
|
||||
<span class="group-icon"><MdiIcon name={group.icon} size={14} /></span>
|
||||
<span class="group-title">{t(group.labelKey)}</span>
|
||||
<span class="group-state">
|
||||
{#if state === 'all'}<MdiIcon name="mdiCheckboxMarked" size={14} />
|
||||
{:else if state === 'some'}<MdiIcon name="mdiMinusBoxOutline" size={14} />
|
||||
{:else}<MdiIcon name="mdiCheckboxBlankOutline" size={14} />{/if}
|
||||
</span>
|
||||
</button>
|
||||
<div class="chip-row">
|
||||
{#each group.cats as cat}
|
||||
<button class="chip" type="button"
|
||||
class:chip-on={selectedCategories[cat.key]}
|
||||
onclick={() => toggleCat(cat.key)}>
|
||||
{t(cat.labelKey)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: secrets -->
|
||||
<div class="step">
|
||||
<div class="step-head">
|
||||
<span class="step-num">02</span>
|
||||
<span class="step-label">{t('backup.stepSecrets')}</span>
|
||||
</div>
|
||||
<div class="segmented" role="radiogroup" aria-label={t('backup.secretsMode')}>
|
||||
{#each secretsModes as mode}
|
||||
<button type="button"
|
||||
role="radio"
|
||||
aria-checked={exportSecrets === mode.value}
|
||||
class="seg"
|
||||
class:seg-on={exportSecrets === mode.value}
|
||||
onclick={() => onSecretsChange(mode.value)}>
|
||||
<MdiIcon name={mode.icon} size={14} />
|
||||
<span>{t(mode.labelKey)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if exportSecrets === 'include'}
|
||||
<div class="warn-strip" role="status">
|
||||
<span class="warn-edge" aria-hidden="true"></span>
|
||||
<MdiIcon name="mdiAlertOctagonOutline" size={14} />
|
||||
<span>{t('backup.secretsWarningExport')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Step 3: CTA -->
|
||||
<div class="step step-cta">
|
||||
<Button onclick={onExport} disabled={exporting || noneSelected}>
|
||||
{#if exporting}
|
||||
<MdiIcon name="mdiLoading" size={14} />
|
||||
{:else}
|
||||
<MdiIcon name="mdiDownload" size={14} />
|
||||
{/if}
|
||||
{exporting ? t('common.loading') : t('backup.exportBtn')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.export-panel {
|
||||
padding: 1.5rem 1.5rem 1.35rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
min-height: 100%;
|
||||
}
|
||||
.panel-head {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.panel-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--color-muted-foreground);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 400;
|
||||
font-size: 1.15rem;
|
||||
line-height: 1.35;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--color-foreground);
|
||||
max-width: 36ch;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.step-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.step-num {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.step-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
.step-count {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.65rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-muted-foreground);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.group-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
@media (min-width: 560px) {
|
||||
.group-grid { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
.group {
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-glass-strong);
|
||||
padding: 0.55rem 0.65rem 0.7rem;
|
||||
transition: border-color 0.18s ease, background 0.18s ease;
|
||||
}
|
||||
.group-all { border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border)); background: color-mix(in srgb, var(--color-primary) 6%, var(--color-glass-strong)); }
|
||||
.group-some { border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border)); }
|
||||
.group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0.15rem 0.1rem 0.4rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-foreground);
|
||||
font-family: inherit;
|
||||
}
|
||||
.group-icon { color: var(--color-primary); display: inline-flex; }
|
||||
.group-title {
|
||||
font-size: 0.74rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.005em;
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
.group-state {
|
||||
display: inline-flex;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.group-all .group-state { color: var(--color-primary); }
|
||||
.group-some .group-state { color: var(--color-citrus); }
|
||||
|
||||
.chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.chip {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.chip:hover { color: var(--color-foreground); border-color: var(--color-rule-strong); }
|
||||
.chip-on {
|
||||
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
|
||||
border-color: color-mix(in srgb, var(--color-primary) 55%, var(--color-border));
|
||||
color: var(--color-foreground);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 25%, transparent);
|
||||
}
|
||||
|
||||
.segmented {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
@media (min-width: 480px) {
|
||||
.segmented { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.72rem;
|
||||
text-align: left;
|
||||
line-height: 1.25;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.18s;
|
||||
}
|
||||
.seg:hover { color: var(--color-foreground); border-color: var(--color-rule-strong); background: var(--color-glass-elev); }
|
||||
.seg-on {
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 16%, transparent), color-mix(in srgb, var(--color-orchid) 14%, transparent));
|
||||
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
|
||||
color: var(--color-foreground);
|
||||
box-shadow:
|
||||
inset 0 1px 0 var(--color-highlight),
|
||||
0 0 0 1px color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||
}
|
||||
|
||||
.warn-strip {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.75rem 0.55rem 1rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.4;
|
||||
color: var(--color-error-fg);
|
||||
background: color-mix(in srgb, var(--color-error-fg) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--color-error-fg) 30%, var(--color-border));
|
||||
overflow: hidden;
|
||||
}
|
||||
.warn-edge {
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 3px;
|
||||
background: var(--color-coral);
|
||||
}
|
||||
|
||||
.step-cta {
|
||||
margin-top: auto;
|
||||
padding-top: 0.4rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,603 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
|
||||
type ConflictMode = 'skip' | 'rename' | 'overwrite';
|
||||
|
||||
interface ValidationResult {
|
||||
valid: boolean;
|
||||
entity_counts?: Record<string, number>;
|
||||
warnings?: string[];
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
interface ImportResult {
|
||||
created?: number;
|
||||
skipped?: number;
|
||||
overwritten?: number;
|
||||
errors?: string[];
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
importFile: File | null;
|
||||
importConflict: ConflictMode;
|
||||
validating: boolean;
|
||||
validationResult: ValidationResult | null;
|
||||
importing: boolean;
|
||||
importResult: ImportResult | null;
|
||||
onFileSelect: (file: File | null) => void;
|
||||
onConflictChange: (mode: ConflictMode) => void;
|
||||
onValidate: () => void;
|
||||
onImport: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
importFile,
|
||||
importConflict,
|
||||
validating,
|
||||
validationResult,
|
||||
importing,
|
||||
importResult,
|
||||
onFileSelect,
|
||||
onConflictChange,
|
||||
onValidate,
|
||||
onImport,
|
||||
}: Props = $props();
|
||||
|
||||
let dragging = $state(false);
|
||||
let inputEl = $state<HTMLInputElement | undefined>();
|
||||
|
||||
const conflictOptions: Array<{ value: ConflictMode; icon: string; labelKey: string }> = [
|
||||
{ value: 'skip', icon: 'mdiSkipNext', labelKey: 'backup.conflictSkip' },
|
||||
{ value: 'rename', icon: 'mdiRename', labelKey: 'backup.conflictRename' },
|
||||
{ value: 'overwrite', icon: 'mdiSync', labelKey: 'backup.conflictOverwrite' },
|
||||
];
|
||||
|
||||
function pickFile(): void {
|
||||
inputEl?.click();
|
||||
}
|
||||
|
||||
function handleInput(e: Event): void {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0] ?? null;
|
||||
onFileSelect(file);
|
||||
}
|
||||
|
||||
function handleDrop(e: DragEvent): void {
|
||||
e.preventDefault();
|
||||
dragging = false;
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (file && (file.name.endsWith('.json') || file.type === 'application/json')) {
|
||||
onFileSelect(file);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent): void {
|
||||
e.preventDefault();
|
||||
dragging = true;
|
||||
}
|
||||
|
||||
function handleDragLeave(): void {
|
||||
dragging = false;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const entityCount = $derived(
|
||||
validationResult?.entity_counts
|
||||
? Object.values(validationResult.entity_counts).reduce<number>((a, b) => a + (b as number), 0)
|
||||
: 0
|
||||
);
|
||||
</script>
|
||||
|
||||
<section class="import-panel glass">
|
||||
<header class="panel-head">
|
||||
<div class="panel-eyebrow">
|
||||
<MdiIcon name="mdiDatabaseImport" size={14} />
|
||||
<span>{t('backup.import')}</span>
|
||||
</div>
|
||||
<h3 class="panel-title">{t('backup.importDescription')}</h3>
|
||||
</header>
|
||||
|
||||
<div class="panel-body">
|
||||
<!-- Step 1: file -->
|
||||
<div class="step">
|
||||
<div class="step-head">
|
||||
<span class="step-num">01</span>
|
||||
<span class="step-label">{t('backup.stepFile')}</span>
|
||||
</div>
|
||||
|
||||
{#if importFile}
|
||||
<div class="file-pill">
|
||||
<span class="file-icon"><MdiIcon name="mdiCodeJson" size={18} /></span>
|
||||
<div class="file-meta">
|
||||
<div class="file-name" title={importFile.name}>{importFile.name}</div>
|
||||
<div class="file-size">{formatBytes(importFile.size)}</div>
|
||||
</div>
|
||||
<button class="file-change" type="button" onclick={pickFile}>
|
||||
<MdiIcon name="mdiSwapHorizontal" size={14} />
|
||||
<span>{t('backup.changeFile')}</span>
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button type="button"
|
||||
class="dropzone"
|
||||
class:dropzone-active={dragging}
|
||||
onclick={pickFile}
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={handleDrop}>
|
||||
<span class="dropzone-icon"><MdiIcon name="mdiCloudUploadOutline" size={28} /></span>
|
||||
<span class="dropzone-text">
|
||||
{dragging ? t('backup.dropZoneActive') : t('backup.dropZone')}
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
<input bind:this={inputEl} type="file" accept=".json,application/json"
|
||||
class="visually-hidden" onchange={handleInput} />
|
||||
</div>
|
||||
|
||||
<!-- Step 2: validate -->
|
||||
{#if importFile}
|
||||
<div class="step">
|
||||
<div class="step-head">
|
||||
<span class="step-num">02</span>
|
||||
<span class="step-label">{t('backup.stepValidate')}</span>
|
||||
{#if validationResult}
|
||||
<span class="validate-pill"
|
||||
class:validate-ok={validationResult.valid}
|
||||
class:validate-bad={!validationResult.valid}>
|
||||
<MdiIcon name={validationResult.valid ? 'mdiCheckCircle' : 'mdiCloseCircle'} size={12} />
|
||||
{validationResult.valid ? t('backup.validationPassed') : t('backup.validationFailed')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !validationResult}
|
||||
<Button variant="secondary" size="sm" onclick={onValidate} disabled={validating}>
|
||||
{#if validating}
|
||||
<MdiIcon name="mdiLoading" size={14} />
|
||||
{:else}
|
||||
<MdiIcon name="mdiCheckDecagramOutline" size={14} />
|
||||
{/if}
|
||||
{validating ? t('backup.validating') : t('backup.validateBtn')}
|
||||
</Button>
|
||||
{:else}
|
||||
<div class="validate-card" class:validate-card-bad={!validationResult.valid}>
|
||||
{#if entityCount > 0}
|
||||
<div class="validate-summary">
|
||||
<span class="validate-count font-mono">{entityCount}</span>
|
||||
<span class="validate-count-label">{t('backup.entities')}</span>
|
||||
</div>
|
||||
<div class="validate-categories">
|
||||
{#each Object.entries(validationResult.entity_counts ?? {}) as [cat, count]}
|
||||
<span class="validate-cat">
|
||||
<span class="validate-cat-num font-mono">{count}</span>
|
||||
<span class="validate-cat-name">{cat}</span>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if validationResult.warnings?.length}
|
||||
<ul class="validate-list validate-warn">
|
||||
{#each validationResult.warnings as w}
|
||||
<li><MdiIcon name="mdiAlert" size={12} /><span>{w}</span></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if validationResult.errors?.length}
|
||||
<ul class="validate-list validate-err">
|
||||
{#each validationResult.errors as e}
|
||||
<li><MdiIcon name="mdiAlertCircle" size={12} /><span>{e}</span></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Step 3: conflict mode -->
|
||||
{#if importFile && validationResult?.valid}
|
||||
<div class="step">
|
||||
<div class="step-head">
|
||||
<span class="step-num">03</span>
|
||||
<span class="step-label">{t('backup.stepConflict')}</span>
|
||||
</div>
|
||||
<div class="segmented" role="radiogroup" aria-label={t('backup.conflictMode')}>
|
||||
{#each conflictOptions as opt}
|
||||
<button type="button"
|
||||
role="radio"
|
||||
aria-checked={importConflict === opt.value}
|
||||
class="seg"
|
||||
class:seg-on={importConflict === opt.value}
|
||||
onclick={() => onConflictChange(opt.value)}>
|
||||
<MdiIcon name={opt.icon} size={14} />
|
||||
<span>{t(opt.labelKey)}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Step 4: CTA + results -->
|
||||
<div class="step step-cta">
|
||||
{#if importFile && !validationResult?.valid && !validating}
|
||||
<div class="cta-hint">
|
||||
<MdiIcon name="mdiInformationOutline" size={12} />
|
||||
<span>{t('backup.validateFirst')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<Button onclick={onImport} disabled={importing || !importFile || !validationResult?.valid}>
|
||||
{#if importing}
|
||||
<MdiIcon name="mdiLoading" size={14} />
|
||||
{:else}
|
||||
<MdiIcon name="mdiUpload" size={14} />
|
||||
{/if}
|
||||
{importing ? t('backup.importing') : t('backup.importBtn')}
|
||||
</Button>
|
||||
|
||||
{#if importResult}
|
||||
<div class="import-results">
|
||||
<div class="result-tiles">
|
||||
<div class="result-tile tile-created">
|
||||
<span class="result-num font-mono">{importResult.created ?? 0}</span>
|
||||
<span class="result-label">{t('backup.resultCreated')}</span>
|
||||
</div>
|
||||
<div class="result-tile tile-skipped">
|
||||
<span class="result-num font-mono">{importResult.skipped ?? 0}</span>
|
||||
<span class="result-label">{t('backup.resultSkipped')}</span>
|
||||
</div>
|
||||
<div class="result-tile tile-overwritten">
|
||||
<span class="result-num font-mono">{importResult.overwritten ?? 0}</span>
|
||||
<span class="result-label">{t('backup.resultOverwritten')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if importResult.errors?.length}
|
||||
<ul class="validate-list validate-err">
|
||||
{#each importResult.errors as e}
|
||||
<li><MdiIcon name="mdiAlertCircle" size={12} /><span>{e}</span></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if importResult.warnings?.length}
|
||||
<ul class="validate-list validate-warn">
|
||||
{#each importResult.warnings as w}
|
||||
<li><MdiIcon name="mdiAlert" size={12} /><span>{w}</span></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.import-panel {
|
||||
padding: 1.5rem 1.5rem 1.35rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
min-height: 100%;
|
||||
}
|
||||
.panel-head { position: relative; z-index: 1; }
|
||||
.panel-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--color-muted-foreground);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 400;
|
||||
font-size: 1.15rem;
|
||||
line-height: 1.35;
|
||||
letter-spacing: -0.015em;
|
||||
color: var(--color-foreground);
|
||||
max-width: 36ch;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.step { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.step-head { display: flex; align-items: baseline; gap: 0.6rem; }
|
||||
.step-num {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.step-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
/* Drop zone */
|
||||
.dropzone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.55rem;
|
||||
padding: 1.65rem 1.1rem;
|
||||
border-radius: 16px;
|
||||
border: 1.5px dashed var(--color-rule-strong);
|
||||
background: color-mix(in srgb, var(--color-primary) 4%, var(--color-glass-strong));
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.78rem;
|
||||
text-align: center;
|
||||
transition: background 0.18s, border-color 0.18s, color 0.18s, transform 0.18s;
|
||||
min-height: 140px;
|
||||
}
|
||||
.dropzone:hover {
|
||||
color: var(--color-foreground);
|
||||
border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border));
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-glass-strong));
|
||||
}
|
||||
.dropzone-active {
|
||||
color: var(--color-foreground);
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 14%, var(--color-glass-strong));
|
||||
transform: scale(1.005);
|
||||
}
|
||||
.dropzone-icon { color: var(--color-primary); display: inline-flex; }
|
||||
.dropzone-text { line-height: 1.4; max-width: 28ch; }
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0,0,0,0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* File pill */
|
||||
.file-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: 14px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-primary) 35%, var(--color-border));
|
||||
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-glass-strong));
|
||||
}
|
||||
.file-icon { color: var(--color-primary); flex-shrink: 0; }
|
||||
.file-meta { flex: 1; min-width: 0; }
|
||||
.file-name {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-foreground);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.file-size {
|
||||
font-size: 0.66rem;
|
||||
color: var(--color-muted-foreground);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.file-change {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.32rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-glass-strong);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-muted-foreground);
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.file-change:hover { color: var(--color-foreground); border-color: var(--color-rule-strong); background: var(--color-glass-elev); }
|
||||
|
||||
/* Validation */
|
||||
.validate-pill {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.65rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.validate-ok {
|
||||
color: var(--color-success-fg);
|
||||
background: var(--color-success-bg);
|
||||
border: 1px solid color-mix(in srgb, var(--color-success-fg) 30%, transparent);
|
||||
}
|
||||
.validate-bad {
|
||||
color: var(--color-error-fg);
|
||||
background: var(--color-error-bg);
|
||||
border: 1px solid color-mix(in srgb, var(--color-error-fg) 30%, transparent);
|
||||
}
|
||||
|
||||
.validate-card {
|
||||
padding: 0.7rem 0.85rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-glass-strong);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.validate-card-bad {
|
||||
border-color: color-mix(in srgb, var(--color-error-fg) 28%, var(--color-border));
|
||||
background: color-mix(in srgb, var(--color-error-fg) 6%, var(--color-glass-strong));
|
||||
}
|
||||
.validate-summary {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.validate-count {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
line-height: 1;
|
||||
}
|
||||
.validate-count-label {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.validate-categories {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.validate-cat {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.3rem;
|
||||
padding: 0.18rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-glass);
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
.validate-cat-num {
|
||||
color: var(--color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.validate-cat-name {
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
.validate-list {
|
||||
list-style: none;
|
||||
padding: 0; margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.validate-list li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.validate-warn li { color: var(--color-warning-fg); }
|
||||
.validate-err li { color: var(--color-error-fg); }
|
||||
|
||||
/* Segmented (same vocabulary as ExportPanel) */
|
||||
.segmented {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
@media (min-width: 480px) {
|
||||
.segmented { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
.seg {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-muted-foreground);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.72rem;
|
||||
text-align: left;
|
||||
line-height: 1.25;
|
||||
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.18s;
|
||||
}
|
||||
.seg:hover { color: var(--color-foreground); border-color: var(--color-rule-strong); background: var(--color-glass-elev); }
|
||||
.seg-on {
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--color-primary) 16%, transparent), color-mix(in srgb, var(--color-orchid) 14%, transparent));
|
||||
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
|
||||
color: var(--color-foreground);
|
||||
box-shadow:
|
||||
inset 0 1px 0 var(--color-highlight),
|
||||
0 0 0 1px color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||||
}
|
||||
|
||||
.cta-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-muted-foreground);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.step-cta {
|
||||
margin-top: auto;
|
||||
padding-top: 0.4rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.import-results {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.result-tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.result-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
padding: 0.6rem 0.7rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-glass-strong);
|
||||
}
|
||||
.result-num {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
.result-label {
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.tile-created { border-color: color-mix(in srgb, var(--color-mint) 35%, var(--color-border)); }
|
||||
.tile-created .result-num { color: var(--color-mint); }
|
||||
.tile-skipped { border-color: color-mix(in srgb, var(--color-sky) 30%, var(--color-border)); }
|
||||
.tile-skipped .result-num { color: var(--color-sky); }
|
||||
.tile-overwritten { border-color: color-mix(in srgb, var(--color-citrus) 35%, var(--color-border)); }
|
||||
.tile-overwritten .result-num { color: var(--color-citrus); }
|
||||
</style>
|
||||
@@ -0,0 +1,136 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
|
||||
interface PendingState {
|
||||
pending: boolean;
|
||||
uploaded_at?: string | null;
|
||||
uploaded_by?: string | null;
|
||||
conflict_mode?: string;
|
||||
supervised?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
pending: PendingState | null;
|
||||
onApply: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let { pending, onApply, onCancel }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if pending?.pending}
|
||||
<div class="pending-strip animate-rise" role="alert">
|
||||
<span class="pending-edge" aria-hidden="true"></span>
|
||||
<span class="aurora-pulse error" aria-hidden="true"></span>
|
||||
<div class="pending-body">
|
||||
<div class="pending-title">
|
||||
<MdiIcon name="mdiShieldAlertOutline" size={16} />
|
||||
<span>{t('backup.pendingTitle')}</span>
|
||||
</div>
|
||||
<div class="pending-meta">
|
||||
{t('backup.pendingBy').replace('{by}', pending.uploaded_by || '—')}
|
||||
<span class="pending-dot">·</span>
|
||||
{t('backup.pendingAt').replace('{at}', pending.uploaded_at || '—')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pending-actions">
|
||||
{#if pending.supervised}
|
||||
<Button size="sm" onclick={onApply}>
|
||||
<MdiIcon name="mdiRestart" size={14} /> {t('backup.restartNow')}
|
||||
</Button>
|
||||
{/if}
|
||||
<button class="pending-cancel" onclick={onCancel} type="button">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.pending-strip {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
padding: 0.85rem 1.1rem 0.85rem 1.35rem;
|
||||
margin-bottom: 1.25rem;
|
||||
border-radius: 18px;
|
||||
background: var(--color-glass);
|
||||
backdrop-filter: blur(28px) saturate(160%);
|
||||
-webkit-backdrop-filter: blur(28px) saturate(160%);
|
||||
border: 1px solid color-mix(in srgb, var(--color-error-fg) 35%, var(--color-border));
|
||||
box-shadow:
|
||||
var(--shadow-card),
|
||||
0 0 0 1px color-mix(in srgb, var(--color-error-fg) 18%, transparent) inset;
|
||||
overflow: hidden;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.pending-strip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(180deg, var(--color-highlight), transparent 30%);
|
||||
opacity: 0.35;
|
||||
}
|
||||
.pending-edge {
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 4px;
|
||||
background: linear-gradient(180deg, var(--color-coral), color-mix(in srgb, var(--color-coral) 50%, transparent));
|
||||
}
|
||||
.pending-body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
min-width: 12rem;
|
||||
}
|
||||
.pending-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-foreground);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.pending-meta {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-muted-foreground);
|
||||
margin-top: 0.18rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
.pending-dot {
|
||||
opacity: 0.6;
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
.pending-actions {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.pending-cancel {
|
||||
padding: 0 0.95rem;
|
||||
height: 34px;
|
||||
font-size: 0.82rem;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
color: var(--color-muted-foreground);
|
||||
border: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.pending-cancel:hover {
|
||||
background: var(--color-glass-strong);
|
||||
color: var(--color-foreground);
|
||||
border-color: var(--color-rule-strong);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,210 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/i18n';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
|
||||
import type { GridItem } from '$lib/components/IconGridSelect.svelte';
|
||||
|
||||
interface Props {
|
||||
enabled: boolean;
|
||||
intervalHours: string;
|
||||
secretsMode: string;
|
||||
retentionCount: string;
|
||||
saving: boolean;
|
||||
onToggle: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
enabled,
|
||||
intervalHours = $bindable(),
|
||||
secretsMode = $bindable(),
|
||||
retentionCount = $bindable(),
|
||||
saving,
|
||||
onToggle,
|
||||
onSave,
|
||||
}: Props = $props();
|
||||
|
||||
const intervalItems: GridItem[] = $derived([
|
||||
{ value: '6', icon: 'mdiTimerSand', label: `6 ${t('backup.hours')}` },
|
||||
{ value: '12', icon: 'mdiClockOutline', label: `12 ${t('backup.hours')}` },
|
||||
{ value: '24', icon: 'mdiCalendarToday', label: `24 ${t('backup.hours')}` },
|
||||
{ value: '48', icon: 'mdiCalendarRange', label: `48 ${t('backup.hours')}` },
|
||||
{ value: '72', icon: 'mdiCalendarWeek', label: `72 ${t('backup.hours')}` },
|
||||
{ value: '168', icon: 'mdiCalendarMonth', label: `7d` },
|
||||
]);
|
||||
|
||||
const secretsItems: GridItem[] = $derived([
|
||||
{ value: 'exclude', icon: 'mdiShieldCheckOutline', label: t('backup.secretsExclude') },
|
||||
{ value: 'masked', icon: 'mdiEyeOffOutline', label: t('backup.secretsMasked') },
|
||||
{ value: 'include', icon: 'mdiKeyVariant', label: t('backup.secretsInclude') },
|
||||
]);
|
||||
|
||||
const retentionItems: GridItem[] = $derived([
|
||||
{ value: '3', icon: 'mdiNumeric3BoxOutline', label: `3` },
|
||||
{ value: '5', icon: 'mdiNumeric5BoxOutline', label: `5` },
|
||||
{ value: '10', icon: 'mdiLayersTripleOutline', label: `10` },
|
||||
{ value: '20', icon: 'mdiNumeric9PlusBoxOutline', label: `20` },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<section class="cassette glass" class:cassette-on={enabled}>
|
||||
<button class="cassette-toggle" type="button" onclick={onToggle} aria-pressed={enabled}>
|
||||
<span class="toggle-track" class:toggle-on={enabled}>
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
<span class="toggle-label">
|
||||
<span class="cassette-eyebrow">
|
||||
<MdiIcon name="mdiClockOutline" size={12} />
|
||||
<span>{t('backup.scheduled')}</span>
|
||||
</span>
|
||||
<span class="cassette-title">{t('backup.enableScheduled')}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if enabled}
|
||||
<div class="cassette-controls">
|
||||
<div class="ctl">
|
||||
<span class="ctl-label">{t('backup.interval')}</span>
|
||||
<IconGridSelect items={intervalItems} bind:value={intervalHours} columns={2} />
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<span class="ctl-label">{t('backup.secretsMode')}</span>
|
||||
<IconGridSelect items={secretsItems} bind:value={secretsMode} columns={1} />
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<span class="ctl-label">{t('backup.retention')}</span>
|
||||
<IconGridSelect items={retentionItems} bind:value={retentionCount} columns={2} />
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="cassette-off">{t('backup.scheduleOff')}</div>
|
||||
{/if}
|
||||
|
||||
<div class="cassette-save">
|
||||
<Button size="sm" variant="secondary" onclick={onSave} disabled={saving}>
|
||||
<MdiIcon name="mdiContentSave" size={14} />
|
||||
{saving ? t('common.loading') : t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.cassette {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 1.1rem;
|
||||
padding: 0.95rem 1.15rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cassette-on { border-color: color-mix(in srgb, var(--color-mint) 30%, var(--color-border)); }
|
||||
|
||||
.cassette-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
color: var(--color-foreground);
|
||||
text-align: left;
|
||||
padding: 0.2rem 0.1rem;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.toggle-track {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-glass-strong);
|
||||
border: 1px solid var(--color-rule-strong);
|
||||
flex-shrink: 0;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
.toggle-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px; height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-muted-foreground);
|
||||
transition: transform 0.2s, background 0.2s;
|
||||
}
|
||||
.toggle-on {
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--color-mint) 60%, transparent), color-mix(in srgb, var(--color-primary) 60%, transparent));
|
||||
border-color: color-mix(in srgb, var(--color-mint) 60%, var(--color-rule-strong));
|
||||
}
|
||||
.toggle-on .toggle-thumb {
|
||||
background: white;
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
||||
.toggle-label { display: flex; flex-direction: column; gap: 0.1rem; }
|
||||
.cassette-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
.cassette-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
letter-spacing: -0.005em;
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
|
||||
.cassette-controls {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.7rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
@media (min-width: 720px) {
|
||||
.cassette-controls { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
.ctl { display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; }
|
||||
.ctl-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--color-muted-foreground);
|
||||
}
|
||||
|
||||
.cassette-off {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 0.78rem;
|
||||
color: var(--color-muted-foreground);
|
||||
font-style: italic;
|
||||
font-family: var(--font-display);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.cassette-save {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.cassette-save { width: 100%; }
|
||||
.cassette-save > :global(*) { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user