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.
423 lines
11 KiB
Svelte
423 lines
11 KiB
Svelte
<script lang="ts">
|
|
import MdiIcon from './MdiIcon.svelte';
|
|
import { t } from '$lib/i18n';
|
|
import { portal } from '$lib/portal';
|
|
|
|
export interface EntityItem {
|
|
value: string | number;
|
|
label: string;
|
|
icon?: string;
|
|
desc?: string;
|
|
disabled?: boolean;
|
|
disabledHint?: string;
|
|
}
|
|
|
|
let {
|
|
items = [],
|
|
value = $bindable(),
|
|
placeholder = 'Select...',
|
|
allowNone = false,
|
|
noneLabel = '—',
|
|
disabled = false,
|
|
size = 'default',
|
|
open = $bindable(false),
|
|
showTrigger = true,
|
|
onselect,
|
|
onclose,
|
|
}: {
|
|
items: EntityItem[];
|
|
value: string | number | null;
|
|
placeholder?: string;
|
|
allowNone?: boolean;
|
|
noneLabel?: string;
|
|
disabled?: boolean;
|
|
size?: 'sm' | 'default';
|
|
open?: boolean;
|
|
showTrigger?: boolean;
|
|
onselect?: (value: string | number | null) => void;
|
|
onclose?: () => void;
|
|
} = $props();
|
|
|
|
let query = $state('');
|
|
let highlightIdx = $state(0);
|
|
let inputEl = $state<HTMLInputElement | undefined>();
|
|
let listEl = $state<HTMLDivElement | undefined>();
|
|
|
|
const selected = $derived(items.find(i => String(i.value) === String(value)));
|
|
|
|
const filtered = $derived.by(() => {
|
|
const q = query.toLowerCase().trim();
|
|
let result: EntityItem[] = [];
|
|
if (allowNone) {
|
|
result.push({ value: '', label: noneLabel, icon: '' });
|
|
}
|
|
const matching = q
|
|
? items.filter(i => i.label.toLowerCase().includes(q) || (i.desc || '').toLowerCase().includes(q))
|
|
: items;
|
|
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;
|
|
}
|
|
|
|
// 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);
|
|
open = false;
|
|
query = '';
|
|
}
|
|
|
|
function handleKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'Escape') {
|
|
closePalette();
|
|
return;
|
|
}
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
highlightIdx = Math.min(highlightIdx + 1, filtered.length - 1);
|
|
scrollToHighlight();
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
highlightIdx = Math.max(highlightIdx - 1, 0);
|
|
scrollToHighlight();
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
if (filtered[highlightIdx]) selectItem(filtered[highlightIdx]);
|
|
} else if (e.key === 'Tab') {
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
|
|
function scrollToHighlight() {
|
|
requestAnimationFrame(() => {
|
|
listEl?.querySelector('.ep-highlight')?.scrollIntoView({ block: 'nearest' });
|
|
});
|
|
}
|
|
|
|
// Reset highlight when query changes
|
|
$effect(() => {
|
|
query; // track
|
|
highlightIdx = 0;
|
|
});
|
|
</script>
|
|
|
|
<!-- 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-arrow"><MdiIcon name="mdiChevronDown" size={14} /></span>
|
|
</button>
|
|
{/if}
|
|
|
|
<!-- Palette overlay — portalled to <body> to escape backdrop-filter ancestors -->
|
|
{#if open}
|
|
<div use:portal class="es-portal-root">
|
|
<div class="ep-overlay" onclick={closePalette} role="presentation"></div>
|
|
|
|
<div class="ep-container">
|
|
<div class="ep-search-row">
|
|
<MdiIcon name="mdiMagnify" size={18} />
|
|
<input
|
|
bind:this={inputEl}
|
|
bind:value={query}
|
|
placeholder={selected ? selected.label : placeholder}
|
|
class="ep-input"
|
|
type="text"
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
onkeydown={handleKeydown}
|
|
/>
|
|
<kbd class="ep-kbd">ESC</kbd>
|
|
</div>
|
|
|
|
<div class="ep-list" bind:this={listEl} role="listbox">
|
|
{#if filtered.length === 0}
|
|
<div class="ep-empty">{t('common.noMatches')}</div>
|
|
{:else}
|
|
{#each filtered as item, i}
|
|
<button
|
|
class="ep-item"
|
|
class:ep-highlight={i === highlightIdx && !item.disabled}
|
|
class:ep-current={String(item.value) === String(value)}
|
|
class:ep-disabled={item.disabled}
|
|
role="option"
|
|
aria-selected={String(item.value) === String(value)}
|
|
aria-disabled={item.disabled || undefined}
|
|
onclick={() => selectItem(item)}
|
|
onmouseenter={() => highlightIdx = i}
|
|
type="button"
|
|
>
|
|
{#if item.icon}
|
|
<span class="ep-item-icon"><MdiIcon name={item.icon} size={18} /></span>
|
|
{/if}
|
|
<span class="ep-item-label">{item.label}</span>
|
|
{#if item.disabled && item.disabledHint}
|
|
<span class="ep-item-hint">{item.disabledHint}</span>
|
|
{:else if item.desc}
|
|
<span class="ep-item-desc">{item.desc}</span>
|
|
{/if}
|
|
</button>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<style>
|
|
/* Trigger button */
|
|
.es-trigger {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
width: 100%;
|
|
padding: 0.5rem 0.75rem;
|
|
border: 1px solid var(--color-rule-strong);
|
|
border-radius: 0.625rem;
|
|
font-size: 0.875rem;
|
|
background: var(--color-input-bg);
|
|
color: var(--color-foreground);
|
|
transition: border-color 0.15s, background 0.15s;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
font-family: inherit;
|
|
}
|
|
.es-trigger.es-sm {
|
|
padding: 0.3rem 0.55rem;
|
|
font-size: 0.8rem;
|
|
gap: 0.4rem;
|
|
}
|
|
.es-trigger:hover {
|
|
background: var(--color-glass-strong);
|
|
border-color: var(--color-rule-strong);
|
|
}
|
|
.es-trigger-icon {
|
|
flex-shrink: 0;
|
|
color: var(--color-primary);
|
|
}
|
|
.es-trigger-label {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.es-trigger-none {
|
|
color: var(--color-muted-foreground);
|
|
}
|
|
.es-trigger-arrow {
|
|
flex-shrink: 0;
|
|
color: var(--color-muted-foreground);
|
|
}
|
|
|
|
/* Portal root — escapes any backdrop-filter ancestor */
|
|
.es-portal-root {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 9998;
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* Overlay */
|
|
.ep-overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
pointer-events: auto;
|
|
background: rgba(0, 0, 0, 0.55);
|
|
backdrop-filter: blur(8px) saturate(120%);
|
|
-webkit-backdrop-filter: blur(8px) saturate(120%);
|
|
}
|
|
|
|
/* Palette container — high opacity for legibility */
|
|
.ep-container {
|
|
pointer-events: auto;
|
|
position: absolute;
|
|
top: min(20vh, 120px);
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
z-index: 1;
|
|
width: min(480px, 92vw);
|
|
max-height: 60vh;
|
|
background: var(--ep-solid-bg);
|
|
border: 1px solid var(--color-rule-strong);
|
|
border-radius: 16px;
|
|
box-shadow: var(--shadow-card), 0 24px 48px -16px rgba(0, 0, 0, 0.55);
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
--ep-solid-bg: #131520;
|
|
}
|
|
:global([data-theme="light"]) .ep-container { --ep-solid-bg: #fafafe; }
|
|
.ep-container::after {
|
|
content: '';
|
|
position: absolute; inset: 0;
|
|
border-radius: inherit;
|
|
pointer-events: none;
|
|
background: linear-gradient(180deg, var(--color-highlight), transparent 30%);
|
|
opacity: 0.4;
|
|
}
|
|
|
|
/* Search row */
|
|
.ep-search-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.6rem;
|
|
padding: 0.85rem 1rem;
|
|
border-bottom: 1px solid var(--color-border);
|
|
color: var(--color-muted-foreground);
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
.ep-input {
|
|
flex: 1;
|
|
border: none;
|
|
outline: none;
|
|
background: transparent;
|
|
font-size: 0.9rem;
|
|
color: var(--color-foreground);
|
|
padding: 0;
|
|
font-family: inherit;
|
|
}
|
|
.ep-input::placeholder { color: var(--color-muted-foreground); }
|
|
.ep-kbd {
|
|
font-size: 0.62rem;
|
|
font-family: var(--font-mono);
|
|
padding: 0.2rem 0.45rem;
|
|
border-radius: 6px;
|
|
background: var(--color-glass-strong);
|
|
color: var(--color-foreground);
|
|
border: 1px solid var(--color-border);
|
|
}
|
|
|
|
/* List */
|
|
.ep-list {
|
|
overflow-y: auto;
|
|
overscroll-behavior: contain;
|
|
scrollbar-width: thin;
|
|
padding: 0.35rem;
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
.ep-empty {
|
|
padding: 1.25rem;
|
|
text-align: center;
|
|
color: var(--color-muted-foreground);
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
/* Items */
|
|
.ep-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.65rem;
|
|
width: 100%;
|
|
padding: 0.55rem 0.75rem;
|
|
border: 1px solid transparent;
|
|
background: transparent;
|
|
color: var(--color-foreground);
|
|
font-size: 0.88rem;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
transition: background 0.12s, border-color 0.12s;
|
|
border-radius: 10px;
|
|
font-family: inherit;
|
|
}
|
|
.ep-item:hover, .ep-item.ep-highlight {
|
|
background: rgba(255, 255, 255, 0.06);
|
|
border-color: var(--color-rule-strong);
|
|
}
|
|
:global([data-theme="light"]) .ep-item:hover,
|
|
:global([data-theme="light"]) .ep-item.ep-highlight {
|
|
background: rgba(20, 15, 60, 0.05);
|
|
}
|
|
.ep-item.ep-disabled {
|
|
opacity: 0.4;
|
|
cursor: default;
|
|
}
|
|
.ep-item.ep-disabled:hover {
|
|
background: transparent;
|
|
border-color: transparent;
|
|
}
|
|
.ep-item.ep-current {
|
|
background: linear-gradient(135deg,
|
|
color-mix(in srgb, var(--color-primary) 14%, transparent),
|
|
color-mix(in srgb, var(--color-orchid) 14%, transparent));
|
|
border-color: color-mix(in srgb, var(--color-primary) 40%, var(--color-border));
|
|
box-shadow: inset 0 1px 0 var(--color-highlight);
|
|
}
|
|
.ep-item-icon {
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--color-muted-foreground);
|
|
width: 28px; height: 28px;
|
|
border-radius: 8px;
|
|
background: var(--color-glass-strong);
|
|
border: 1px solid var(--color-border);
|
|
}
|
|
.ep-item.ep-current .ep-item-icon {
|
|
color: var(--color-primary);
|
|
background: var(--color-glass-elev);
|
|
}
|
|
.ep-item-label {
|
|
flex: 1;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
font-weight: 500;
|
|
}
|
|
.ep-item-desc {
|
|
font-size: 0.7rem;
|
|
font-family: var(--font-mono);
|
|
color: var(--color-muted-foreground);
|
|
padding: 0.12rem 0.5rem;
|
|
border-radius: 9999px;
|
|
background: var(--color-glass-strong);
|
|
border: 1px solid var(--color-border);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
max-width: 40%;
|
|
}
|
|
.ep-item-hint {
|
|
font-size: 0.7rem;
|
|
font-style: italic;
|
|
color: var(--color-muted-foreground);
|
|
white-space: nowrap;
|
|
margin-left: auto;
|
|
}
|
|
</style>
|