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:
2026-05-10 23:51:48 +03:00
parent bede928a3f
commit a666bad0c4
13 changed files with 2683 additions and 500 deletions
+39 -19
View File
@@ -20,7 +20,10 @@
noneLabel = '—',
disabled = false,
size = 'default',
open = $bindable(false),
showTrigger = true,
onselect,
onclose,
}: {
items: EntityItem[];
value: string | number | null;
@@ -29,10 +32,12 @@
noneLabel?: string;
disabled?: boolean;
size?: 'sm' | 'default';
open?: boolean;
showTrigger?: boolean;
onselect?: (value: string | number | null) => void;
onclose?: () => void;
} = $props();
let open = $state(false);
let query = $state('');
let highlightIdx = $state(0);
let inputEl = $state<HTMLInputElement | undefined>();
@@ -52,24 +57,37 @@
return [...result, ...matching];
});
// Focus input whenever the palette transitions to open (covers both internal
// trigger clicks and external programmatic opening via bind:open).
let wasOpen = false;
$effect(() => {
if (open && !wasOpen) {
query = '';
highlightIdx = Math.max(0, filtered.findIndex(i => String(i.value) === String(value)));
requestAnimationFrame(() => inputEl?.focus());
}
wasOpen = open;
});
function openPalette() {
if (disabled) return;
open = true;
query = '';
highlightIdx = Math.max(0, filtered.findIndex(i => String(i.value) === String(value)));
requestAnimationFrame(() => inputEl?.focus());
}
// Called when the user dismisses the palette (overlay click or ESC).
// Selection uses its own quiet-close path so onclose stays a true "cancel" signal.
function closePalette() {
open = false;
query = '';
onclose?.();
}
function selectItem(item: EntityItem) {
if (item.disabled) return;
value = item.value || null;
onselect?.(value);
closePalette();
open = false;
query = '';
}
function handleKeydown(e: KeyboardEvent) {
@@ -106,21 +124,23 @@
});
</script>
<!-- Trigger button -->
<button type="button" class="es-trigger" class:es-sm={size === 'sm'} onclick={openPalette}
aria-expanded={open}
aria-haspopup="listbox"
style="opacity: {disabled ? 0.5 : 1}; cursor: {disabled ? 'default' : 'pointer'};">
{#if selected}
{#if selected.icon}
<span class="es-trigger-icon"><MdiIcon name={selected.icon} size={16} /></span>
<!-- Trigger button (hidden when the parent drives `open` via bind:open) -->
{#if showTrigger}
<button type="button" class="es-trigger" class:es-sm={size === 'sm'} onclick={openPalette}
aria-expanded={open}
aria-haspopup="listbox"
style="opacity: {disabled ? 0.5 : 1}; cursor: {disabled ? 'default' : 'pointer'};">
{#if selected}
{#if selected.icon}
<span class="es-trigger-icon"><MdiIcon name={selected.icon} size={16} /></span>
{/if}
<span class="es-trigger-label">{selected.label}</span>
{:else}
<span class="es-trigger-label es-trigger-none">{placeholder}</span>
{/if}
<span class="es-trigger-label">{selected.label}</span>
{:else}
<span class="es-trigger-label es-trigger-none">{placeholder}</span>
{/if}
<span class="es-trigger-arrow"><MdiIcon name="mdiChevronDown" size={14} /></span>
</button>
<span class="es-trigger-arrow"><MdiIcon name="mdiChevronDown" size={14} /></span>
</button>
{/if}
<!-- Palette overlay — portalled to <body> to escape backdrop-filter ancestors -->
{#if open}