a666bad0c4
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.
358 lines
9.9 KiB
Svelte
358 lines
9.9 KiB
Svelte
<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>
|