fix(redesign): a11y, mobile, perf polish for production push

Comprehensive pre-production sweep across the Aurora redesign — drives
svelte-check to 0 errors / 0 warnings (was 61) without changing visual
intent. Highlights:

- Mobile: hero title shrinks at 480px, signal-list stacks timestamp
  under sentence below 640px, sidebar icon buttons bumped to 40x40
- Light theme: muted-foreground darkened to #3a3560 to clear WCAG AA
  on glass surfaces and the modal close button
- Perf: topbar backdrop-filter 28→14px, mobile-more sheet 28→12px to
  cut concurrent blur layers on mid-tier mobile
- a11y: prefers-reduced-motion mute for aurora drift / pulses /
  shimmer / stagger; aria-label on every icon-only button;
  aria-describedby on Hint; combobox/listbox/aria-activedescendant on
  SearchPalette; modal dialog tabindex; 47 label-without-control
  warnings across 14 form pages cleaned up via for=/id= or label→div
- Dashboard derived state split into topology- vs status-bound layers
  so polling no longer re-runs the full provider/wires computation
- Mobile bottom nav derived from baseNavEntries by key lookup so
  adding a top-level nav entry keeps the two trees in sync
- Bug: template-configs page now respects the global provider filter
  for both the count meter and the type pill (was reading the
  unfiltered cache)
- Misc: portal EventChart tooltip and switch its swatches to Aurora
  tokens; CollapsibleSlot warning state uses warning-fg/-bg tokens
  instead of #d97706; Hint z-index 99999→9999; element refs across
  Modal/EntitySelect/MultiEntitySelect/SearchPalette/IconGridSelect/
  Hint/targets converted to \$state for reactivity; 4 dead
  .topbar-cta selectors removed
This commit is contained in:
2026-04-25 14:41:12 +03:00
parent 9eb76c1407
commit 711f218622
25 changed files with 233 additions and 153 deletions
+26 -1
View File
@@ -90,7 +90,7 @@
--color-background-deep: #ede9fe;
--color-foreground: #1a1530;
--color-muted: rgba(20, 15, 60, 0.04);
--color-muted-foreground: #4a4670;
--color-muted-foreground: #3a3560;
--color-border: rgba(20, 15, 60, 0.08);
--color-glass: rgba(255, 255, 255, 0.55);
@@ -434,3 +434,28 @@ button:focus-visible, a:focus-visible {
opacity: 0.5;
animation: none;
}
/* === Reduced-motion: kill drift, pulses, shimmers, stagger entrances === */
@media (prefers-reduced-motion: reduce) {
body::before { animation: none !important; }
.animate-fade-slide-in,
.animate-shimmer,
.animate-pulse-glow,
.animate-count-up,
.animate-rise,
.stagger-children > *,
.aurora-pulse,
.aurora-pulse.warn,
.aurora-pulse.error {
animation: none !important;
}
.stat-card,
.paginator-btn,
.signal-row,
.provider-row {
transition: none !important;
}
* {
scroll-behavior: auto !important;
}
}
@@ -21,7 +21,7 @@
const STATUS_MAP: Record<string, { icon: string; color: string; bg: string }> = {
empty: { icon: 'mdiCircleOutline', color: 'var(--color-muted-foreground)', bg: 'transparent' },
valid: { icon: 'mdiCheckCircle', color: 'var(--color-success-fg)', bg: 'var(--color-success-bg)' },
warning: { icon: 'mdiAlert', color: '#d97706', bg: 'rgba(217, 119, 6, 0.1)' },
warning: { icon: 'mdiAlert', color: 'var(--color-warning-fg)', bg: 'var(--color-warning-bg)' },
error: { icon: 'mdiAlertCircle', color: 'var(--color-error-fg)', bg: 'var(--color-error-bg)' },
};
const statusConfig = $derived(STATUS_MAP[status]);
@@ -35,8 +35,8 @@
let open = $state(false);
let query = $state('');
let highlightIdx = $state(0);
let inputEl: HTMLInputElement;
let listEl: HTMLDivElement;
let inputEl = $state<HTMLInputElement | undefined>();
let listEl = $state<HTMLDivElement | undefined>();
const selected = $derived(items.find(i => String(i.value) === String(value)));
@@ -14,11 +14,11 @@
const EVENT_TYPES = ['assets_added', 'assets_removed', 'collection_renamed', 'collection_deleted', 'sharing_changed'] as const;
const COLORS: Record<string, string> = {
assets_added: '#059669',
assets_removed: '#ef4444',
collection_renamed: '#6366f1',
collection_deleted: '#dc2626',
sharing_changed: '#f59e0b',
assets_added: 'var(--color-mint)',
assets_removed: 'var(--color-coral)',
collection_renamed: 'var(--color-primary)',
collection_deleted: 'var(--color-error-fg)',
sharing_changed: 'var(--color-citrus)',
};
const LABELS: Record<string, string> = {
+5 -3
View File
@@ -4,7 +4,8 @@
let { text = '' } = $props<{ text: string }>();
let visible = $state(false);
let tooltipStyle = $state('');
let btnEl: HTMLButtonElement;
let btnEl = $state<HTMLButtonElement | undefined>();
const tooltipId = `hint-${Math.random().toString(36).slice(2, 9)}`;
function show() {
if (!btnEl) return;
@@ -14,7 +15,7 @@
let left = rect.left + rect.width / 2 - tooltipWidth / 2;
if (left < 8) left = 8;
if (left + tooltipWidth > window.innerWidth - 8) left = window.innerWidth - tooltipWidth - 8;
tooltipStyle = `position:fixed; z-index:99999; bottom:${window.innerHeight - rect.top + 8}px; left:${left}px; width:${tooltipWidth}px;`;
tooltipStyle = `position:fixed; z-index:9999; bottom:${window.innerHeight - rect.top + 8}px; left:${left}px; width:${tooltipWidth}px;`;
}
function hide() {
@@ -31,13 +32,14 @@
onfocus={show}
onblur={hide}
aria-label={text}
aria-describedby={visible ? tooltipId : undefined}
title={text}
tabindex="0"
>?</button>
{#if visible}
<div use:portal>
<div role="tooltip" style={tooltipStyle} class="hint-tooltip">
<div id={tooltipId} role="tooltip" style={tooltipStyle} class="hint-tooltip">
{text}
</div>
</div>
@@ -28,8 +28,8 @@
let open = $state(false);
let search = $state('');
let triggerEl: HTMLButtonElement;
let searchEl: HTMLInputElement;
let triggerEl = $state<HTMLButtonElement | undefined>();
let searchEl = $state<HTMLInputElement | undefined>();
let popupStyle = $state('');
const showSearch = $derived(items.length > 4);
+8 -5
View File
@@ -1,5 +1,4 @@
<script lang="ts">
import { onMount } from 'svelte';
import MdiIcon from './MdiIcon.svelte';
import { t } from '$lib/i18n';
import { portal } from '$lib/portal';
@@ -12,7 +11,7 @@
}>();
let visible = $state(false);
let panelEl: HTMLDivElement;
let panelEl = $state<HTMLDivElement | undefined>();
let previouslyFocused: HTMLElement | null = null;
const uniqueId = `modal-${Math.random().toString(36).slice(2, 9)}`;
@@ -81,13 +80,17 @@
class:visible
onclick={onclose}
onkeydown={handleBackdropKeydown}
role="presentation"
role="button"
tabindex="-1"
aria-label={t('common.close')}
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
bind:this={panelEl}
class="modal-panel"
class:visible
role="dialog"
tabindex="-1"
aria-modal="true"
aria-labelledby="modal-title-{uniqueId}"
onclick={(e) => e.stopPropagation()}
@@ -195,8 +198,8 @@
display: flex;
align-items: center;
justify-content: center;
width: 2.1rem;
height: 2.1rem;
width: 2.5rem;
height: 2.5rem;
border-radius: 10px;
border: 1px solid transparent;
background: transparent;
@@ -27,8 +27,8 @@
let open = $state(false);
let query = $state('');
let highlightIdx = $state(0);
let inputEl: HTMLInputElement;
let listEl: HTMLDivElement;
let inputEl = $state<HTMLInputElement | undefined>();
let listEl = $state<HTMLDivElement | undefined>();
const selectedItems = $derived(items.filter(i => (values || []).includes(i.value)));
@@ -26,7 +26,9 @@
let query = $state('');
let activeIndex = $state(0);
let loading = $state(false);
let inputEl: HTMLInputElement;
let inputEl = $state<HTMLInputElement | undefined>();
const listboxId = 'sp-listbox';
const optionId = (idx: number) => `sp-option-${idx}`;
// Expose openPalette to parent via callback
$effect(() => { onopen?.(openPalette); });
@@ -206,7 +208,7 @@
{#if open}
<!-- Backdrop -->
<div class="sp-backdrop" onclick={closePalette} role="presentation"></div>
<div class="sp-backdrop" onclick={closePalette} onkeydown={(e) => { if (e.key === 'Escape') closePalette(); }} role="button" tabindex="-1" aria-label={t('searchPalette.close')}></div>
<!-- Palette -->
<div class="sp-container">
@@ -218,11 +220,16 @@
placeholder={t('searchPalette.placeholder')}
class="sp-input"
type="text"
role="combobox"
aria-expanded={flatResults.length > 0}
aria-controls={listboxId}
aria-activedescendant={flatResults.length > 0 ? optionId(activeIndex) : undefined}
aria-autocomplete="list"
/>
<kbd class="sp-kbd">ESC</kbd>
</div>
<div class="sp-results">
<div class="sp-results" id={listboxId} role="listbox">
{#if loading}
<div class="sp-empty">
<div class="w-4 h-4 rounded-full border-2 border-[var(--color-primary)] border-t-transparent animate-spin"></div>
@@ -239,9 +246,12 @@
<MdiIcon name={group.icon} size={14} />
{group.label}
</div>
{#each group.items as item, i}
{#each group.items as item}
{@const flatIdx = flatIndexMap.get(item) ?? -1}
<button
id={optionId(flatIdx)}
role="option"
aria-selected={flatIdx === activeIndex}
class="sp-item"
class:sp-active={flatIdx === activeIndex}
onclick={() => navigateTo(item)}
+37 -44
View File
@@ -232,13 +232,20 @@
});
}
// Mobile: flatten nav for bottom bar (first 4 + "More" button)
const mobileNavItems = $derived<NavItem[]>([
{ href: '/', key: 'nav.dashboard', icon: 'mdiViewDashboard' },
{ href: '/notification-trackers', key: 'nav.notification', icon: 'mdiBellOutline' },
{ href: '/command-trackers', key: 'nav.commands', icon: 'mdiConsoleLine' },
{ href: '/targets', key: 'nav.targets', icon: 'mdiTarget' },
]);
// Mobile bottom-nav derives its 4 primary slots from baseNavEntries by key
// lookup. Adding a new top-level nav entry doesn't break this list, and
// renaming a key fails loudly via the assertion below — keeping desktop
// and mobile nav structure in sync without manual duplication.
const MOBILE_PRIMARY_KEYS = ['nav.dashboard', 'nav.notification', 'nav.commands', 'nav.targets'] as const;
const mobileNavItems = $derived<NavItem[]>(
MOBILE_PRIMARY_KEYS.map(key => {
const entry = baseNavEntries.find(e => e.key === key);
if (!entry) return null;
return isGroup(entry)
? { href: entry.children[0]?.href ?? '/', key: entry.key, icon: entry.icon }
: entry;
}).filter((x): x is NavItem => x !== null)
);
// "More" panel mirrors the full desktop sidebar tree so every subnode is
// reachable on mobile (previously it was a flat hand-picked list that
@@ -384,8 +391,9 @@
<div class="brand-orb brand-orb--small"></div>
{/if}
<button onclick={toggleSidebar}
class="sidebar-icon-btn flex items-center justify-center w-8 h-8 rounded-lg transition-all duration-200"
title={collapsed ? t('common.expand') : t('common.collapse')}>
class="sidebar-icon-btn flex items-center justify-center w-10 h-10 rounded-lg transition-all duration-200"
title={collapsed ? t('common.expand') : t('common.collapse')}
aria-label={collapsed ? t('common.expand') : t('common.collapse')}>
<NavIcon name={collapsed ? 'mdiChevronRight' : 'mdiChevronLeft'} size={18} />
</button>
</div>
@@ -400,7 +408,8 @@
providerFilterValue = ids[(idx + 1) % ids.length];
}}
class="provider-filter-btn flex items-center justify-center w-full py-1.5 rounded-lg text-sm transition-all duration-200"
title={globalProviderFilter.provider?.name || t('common.allProviders')}>
title={globalProviderFilter.provider?.name || t('common.allProviders')}
aria-label={globalProviderFilter.provider?.name || t('common.allProviders')}>
<NavIcon name={globalProviderFilter.provider ? providerDefaultIcon(globalProviderFilter.provider) : 'mdiFilterOff'} size={16} />
</button>
{:else}
@@ -480,13 +489,15 @@
{#if collapsed}
<div class="flex flex-col items-center gap-1.5 py-3">
<a href="/docs" target="_blank" rel="noopener noreferrer"
class="sidebar-icon-btn flex items-center justify-center w-8 h-8 rounded-lg transition-all duration-200"
title={t('common.apiDocs')}>
class="sidebar-icon-btn flex items-center justify-center w-10 h-10 rounded-lg transition-all duration-200"
title={t('common.apiDocs')}
aria-label={t('common.apiDocs')}>
<NavIcon name="mdiApi" size={14} />
</a>
<button onclick={logout}
class="sidebar-icon-btn flex items-center justify-center w-8 h-8 rounded-lg transition-all duration-200"
title={t('nav.logout')}>
class="sidebar-icon-btn flex items-center justify-center w-10 h-10 rounded-lg transition-all duration-200"
title={t('nav.logout')}
aria-label={t('nav.logout')}>
<NavIcon name="mdiLogout" size={16} />
</button>
</div>
@@ -504,16 +515,19 @@
</div>
<div class="user-card__actions">
<button onclick={() => showPasswordForm = true} class="user-card__btn"
title={t('common.changePassword')}>
title={t('common.changePassword')}
aria-label={t('common.changePassword')}>
<NavIcon name="mdiKeyVariant" size={13} />
<span>{t('common.changePassword')}</span>
</button>
<a href="/docs" target="_blank" rel="noopener noreferrer"
class="user-card__btn" title={t('common.apiDocs')}>
class="user-card__btn" title={t('common.apiDocs')}
aria-label={t('common.apiDocs')}>
<NavIcon name="mdiApi" size={13} />
</a>
<button onclick={logout} class="user-card__btn user-card__btn--danger"
title={t('nav.logout')}>
title={t('nav.logout')}
aria-label={t('nav.logout')}>
<NavIcon name="mdiLogout" size={13} />
</button>
</div>
@@ -962,8 +976,8 @@
gap: 0.5rem;
padding: 0.5rem 0.6rem 0.5rem 0.85rem;
background: var(--color-glass);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
backdrop-filter: blur(14px) saturate(150%);
-webkit-backdrop-filter: blur(14px) saturate(150%);
border: 1px solid var(--color-border);
border-radius: 18px;
box-shadow: var(--shadow-card);
@@ -1028,29 +1042,8 @@
font-weight: 600;
letter-spacing: 0.06em;
}
.topbar-cta {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0 1rem;
height: 36px;
border-radius: 12px;
border: 0;
background: linear-gradient(135deg, var(--color-primary), var(--color-orchid));
color: white;
font-size: 0.82rem;
font-weight: 500;
text-decoration: none;
cursor: pointer;
box-shadow: 0 6px 20px -8px var(--color-glow-strong), inset 0 1px 0 rgba(255,255,255,0.4);
transition: transform 0.15s;
flex-shrink: 0;
}
.topbar-cta:hover { transform: translateY(-1px); }
@media (max-width: 720px) {
.topbar-cta span { display: none; }
.topbar-cta { padding: 0; width: 36px; justify-content: center; }
.topbar-search__kbd { display: none; }
}
@@ -1073,15 +1066,15 @@
right: 0;
bottom: calc(3rem + env(safe-area-inset-bottom, 0px));
z-index: 50;
background: var(--mobile-more-bg, rgba(19, 21, 32, 0.72));
backdrop-filter: blur(28px) saturate(170%);
-webkit-backdrop-filter: blur(28px) saturate(170%);
background: var(--mobile-more-bg, rgba(19, 21, 32, 0.92));
backdrop-filter: blur(12px) saturate(150%);
-webkit-backdrop-filter: blur(12px) saturate(150%);
border-top: 1px solid var(--color-rule-strong);
padding: calc(1rem + env(safe-area-inset-top, 0px)) calc(1rem + env(safe-area-inset-right, 0px)) 1rem calc(1rem + env(safe-area-inset-left, 0px));
overflow-y: auto;
overscroll-behavior: contain;
}
:global([data-theme="light"]) .mobile-more-panel { --mobile-more-bg: rgba(250, 250, 254, 0.72); }
:global([data-theme="light"]) .mobile-more-panel { --mobile-more-bg: rgba(250, 250, 254, 0.92); }
.mobile-more-panel a:hover,
.mobile-more-panel button:hover {
background: var(--color-glass-strong);
+70 -24
View File
@@ -217,28 +217,46 @@
}
return counts;
});
// Tracker-derived stats (only recompute when the tracker list itself
// changes — not on every poll tick when only event counts move).
const providerTrackerStats = $derived.by(() => {
const stats = new Map<number, { trackerCount: number; armedCount: number }>();
for (const t of notificationTrackersCache.items) {
const cur = stats.get(t.provider_id) || { trackerCount: 0, armedCount: 0 };
cur.trackerCount += 1;
if ((t as { enabled?: boolean }).enabled !== false) cur.armedCount += 1;
stats.set(t.provider_id, cur);
}
return stats;
});
// Provider static fields — only recompute when providers array changes.
const providerStaticDeck = $derived(providers.map(p => ({
id: p.id,
name: p.name,
type: p.type,
icon: providerDefaultIcon(p),
descriptor: getDescriptor(p.type),
})));
const providerDeck = $derived.by(() => {
const max = Math.max(1, ...Array.from(providerEventCounts.values()));
return providers.map(p => {
const trackers = notificationTrackersCache.items.filter(t => t.provider_id === p.id);
return providerStaticDeck.map(p => {
const stats = providerTrackerStats.get(p.id) || { trackerCount: 0, armedCount: 0 };
const events = providerEventCounts.get(p.name) || 0;
return {
id: p.id,
name: p.name,
type: p.type,
icon: providerDefaultIcon(p),
trackerCount: trackers.length,
armedCount: trackers.filter(t => (t as { enabled?: boolean }).enabled !== false).length,
...p,
trackerCount: stats.trackerCount,
armedCount: stats.armedCount,
events,
share: events / max,
descriptor: getDescriptor(p.type),
};
}).sort((a, b) => b.events - a.events);
});
// === Active wires — derive top tracker → target routes ===
const activeWires = $derived.by(() => {
const wires: Array<{
// Static topology: only recomputes when trackers / targets / providers change,
// not on every status poll. Event counts are merged in below.
const wireTopology = $derived.by(() => {
const list: Array<{
trackerName: string;
providerName: string;
providerType: string;
@@ -246,25 +264,17 @@
targetName: string;
targetType: string;
targetIcon: string;
events: number;
}> = [];
const targetsById = new Map(targetsCache.items.map(tg => [tg.id, tg]));
const trackerEventCount = new Map<string, number>();
if (status) {
for (const ev of status.recent_events) {
const k = ev.tracker_name || '';
if (!k) continue;
trackerEventCount.set(k, (trackerEventCount.get(k) || 0) + (ev.assets_count || 1));
}
}
const providersById = new Map(providers.map(p => [p.id, p]));
for (const tracker of notificationTrackersCache.items) {
const provider = providers.find(p => p.id === tracker.provider_id);
const provider = providersById.get(tracker.provider_id);
if (!provider) continue;
const links = (tracker as { tracker_targets?: { target_id: number }[] }).tracker_targets || [];
for (const link of links) {
const target = targetsById.get(link.target_id);
if (!target) continue;
wires.push({
list.push({
trackerName: tracker.name,
providerName: provider.name,
providerType: provider.type,
@@ -272,12 +282,28 @@
targetName: target.name,
targetType: target.type,
targetIcon: target.icon || `mdi${target.type[0].toUpperCase() + target.type.slice(1)}`,
events: trackerEventCount.get(tracker.name) || 0,
});
}
}
return wires.sort((a, b) => b.events - a.events).slice(0, 6);
return list;
});
const trackerEventCount = $derived.by(() => {
const map = new Map<string, number>();
if (status) {
for (const ev of status.recent_events) {
const k = ev.tracker_name || '';
if (!k) continue;
map.set(k, (map.get(k) || 0) + (ev.assets_count || 1));
}
}
return map;
});
const activeWires = $derived(
wireTopology
.map(w => ({ ...w, events: trackerEventCount.get(w.trackerName) || 0 }))
.sort((a, b) => b.events - a.events)
.slice(0, 6)
);
// === Hero summary sentence === */
const heroSummary = $derived.by(() => {
@@ -872,6 +898,11 @@
.hero-meter-row { justify-content: flex-start; }
.hero-meter-value { font-size: 2.4rem; }
}
@media (max-width: 480px) {
.hero-card { padding: 1.1rem 1.1rem 1.3rem; }
.hero-title { font-size: 1.7rem; }
.hero-meter-value { font-size: 1.9rem; }
}
/* ============================================================
STAT CARDS — same pattern as foundation pass
@@ -1139,6 +1170,21 @@
}
.signal-when small { font-size: 0.65rem; color: var(--color-muted-foreground); }
@media (max-width: 640px) {
.signal-row {
grid-template-columns: 36px 1fr;
gap: 0.65rem;
padding: 0.75rem 1rem;
}
.signal-avatar { width: 36px; height: 36px; }
.signal-when {
grid-column: 2;
text-align: left;
margin-top: 0.2rem;
}
.signal-when b { display: inline; margin-right: 0.4rem; }
}
.clear-events-btn {
color: var(--color-muted-foreground);
background: var(--color-glass-strong);
+3 -3
View File
@@ -214,14 +214,14 @@
{#if error}<ErrorBanner message={error} />{/if}
<form onsubmit={save} class="space-y-3">
<div>
<label class="block text-sm font-medium mb-1">{t('actions.provider')}</label>
<div class="block text-sm font-medium mb-1">{t('actions.provider')}</div>
<EntitySelect items={providerItems} bind:value={form.provider_id}
placeholder={t('actions.selectProvider')} disabled={!!editing} />
</div>
{#if actionTypes.length > 0}
<div>
<label class="block text-sm font-medium mb-1">{t('actions.actionType')}</label>
<div class="block text-sm font-medium mb-1">{t('actions.actionType')}</div>
{#if !editing}
<div class="space-y-1">
{#each actionTypes as at}
@@ -251,7 +251,7 @@
</div>
<div>
<label class="block text-sm font-medium mb-1">{t('actions.schedule')}</label>
<div class="block text-sm font-medium mb-1">{t('actions.schedule')}</div>
<div class="flex gap-2 items-center mb-2">
<label class="flex items-center gap-1 text-sm">
<input type="radio" name="schedule_type" value="interval" bind:group={form.schedule_type} class="accent-[var(--color-primary)]" />
+13 -13
View File
@@ -153,8 +153,8 @@
{#if showAddForm}
<div class="border border-[var(--color-border)] rounded-md p-3 space-y-2 bg-[var(--color-muted)]/30">
<div>
<label class="block text-xs font-medium mb-1">{t('actions.ruleName')}</label>
<input bind:value={newRule.name} placeholder={t('actions.ruleNamePlaceholder')}
<label for="rule-name-new" class="block text-xs font-medium mb-1">{t('actions.ruleName')}</label>
<input id="rule-name-new" bind:value={newRule.name} placeholder={t('actions.ruleNamePlaceholder')}
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" />
</div>
@@ -189,8 +189,8 @@
{#if expandedRule === rule.id}
<div class="mt-2 pt-2 border-t border-[var(--color-border)] space-y-2">
<div>
<label class="block text-xs font-medium mb-1">{t('actions.ruleName')}</label>
<input bind:value={rule.name}
<label for="rule-name-{rule.id}" class="block text-xs font-medium mb-1">{t('actions.ruleName')}</label>
<input id="rule-name-{rule.id}" bind:value={rule.name}
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" />
</div>
@@ -219,7 +219,7 @@
<!-- Person selector -->
{#if personItems.length > 0}
<div>
<label class="block text-xs font-medium mb-1">{t('actions.persons')}</label>
<div class="block text-xs font-medium mb-1">{t('actions.persons')}</div>
<MultiEntitySelect items={personItems}
bind:values={ruleConfig.criteria.person_ids}
placeholder={t('actions.addPerson')}
@@ -231,7 +231,7 @@
<!-- Person excludes -->
<div>
<label class="block text-xs font-medium mb-1">{t('actions.excludePersons')}</label>
<div class="block text-xs font-medium mb-1">{t('actions.excludePersons')}</div>
<MultiEntitySelect items={personItems}
bind:values={ruleConfig.criteria.exclude_person_ids}
placeholder={t('actions.addExcludePerson')}
@@ -244,14 +244,14 @@
<!-- Smart search query -->
<div>
<label class="block text-xs font-medium mb-1">{t('actions.searchQuery')}</label>
<div class="block text-xs font-medium mb-1">{t('actions.searchQuery')}</div>
<input bind:value={ruleConfig.criteria.query} placeholder={t('actions.searchQueryPlaceholder')}
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" />
</div>
<!-- Asset type -->
<div class="flex items-center gap-3">
<label class="text-xs font-medium">{t('actions.assetType')}:</label>
<span class="text-xs font-medium">{t('actions.assetType')}:</span>
{#each ['all', 'image', 'video'] as at}
<label class="flex items-center gap-1 text-xs">
<input type="radio"
@@ -266,12 +266,12 @@
<!-- Date range -->
<div class="flex gap-2">
<div class="flex-1">
<label class="block text-xs font-medium mb-1">{t('actions.dateFrom')}</label>
<div class="block text-xs font-medium mb-1">{t('actions.dateFrom')}</div>
<input type="date" bind:value={ruleConfig.criteria.date_from}
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" />
</div>
<div class="flex-1">
<label class="block text-xs font-medium mb-1">{t('actions.dateTo')}</label>
<div class="block text-xs font-medium mb-1">{t('actions.dateTo')}</div>
<input type="date" bind:value={ruleConfig.criteria.date_to}
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" />
</div>
@@ -290,7 +290,7 @@
{#if albumItems.length > 0}
<div>
<label class="block text-xs font-medium mb-1">{t('actions.selectAlbum')}</label>
<div class="block text-xs font-medium mb-1">{t('actions.selectAlbum')}</div>
<MultiEntitySelect items={albumItems}
bind:values={ruleConfig.target_album_ids}
placeholder={t('actions.selectAlbumPlaceholder')}
@@ -301,7 +301,7 @@
</div>
{:else}
<div>
<label class="block text-xs font-medium mb-1">{t('actions.albumId')}</label>
<div class="block text-xs font-medium mb-1">{t('actions.albumId')}</div>
<input bind:value={ruleConfig.target_album_id}
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)] font-mono" />
</div>
@@ -314,7 +314,7 @@
{#if ruleConfig.create_album_if_missing}
<div>
<label class="block text-xs font-medium mb-1">{t('actions.newAlbumName')}</label>
<div class="block text-xs font-medium mb-1">{t('actions.newAlbumName')}</div>
<input bind:value={ruleConfig.create_album_name}
class="w-full px-2 py-1.5 border border-[var(--color-border)] rounded text-sm bg-[var(--color-background)]" />
</div>
@@ -199,7 +199,7 @@
</div>
<div>
<label class="block text-sm font-medium mb-1">{t('commandConfig.providerType')}</label>
<div class="block text-sm font-medium mb-1">{t('commandConfig.providerType')}</div>
{#if !editing}
<IconGridSelect items={providerTypeItems()} bind:value={form.provider_type} columns={2} />
{:else}
@@ -224,30 +224,30 @@
</div>
<div>
<label class="block text-sm font-medium mb-1">{t('commandConfig.responseTemplate')}</label>
<div class="block text-sm font-medium mb-1">{t('commandConfig.responseTemplate')}</div>
<EntitySelect items={templateItems} bind:value={form.command_template_config_id} placeholder={t('commandConfig.responseTemplate')} />
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div>
<label class="block text-xs mb-1">{t('commandConfig.responseMode')}</label>
<div class="block text-xs mb-1">{t('commandConfig.responseMode')}</div>
<IconGridSelect items={responseModeItems(t)} bind:value={form.response_mode} columns={2} compact />
</div>
<div>
<label class="block text-xs mb-1">{t('commandConfig.defaultCount')}</label>
<input type="number" bind:value={form.default_count} min="1" max="20"
<label for="cfg-default-count" class="block text-xs mb-1">{t('commandConfig.defaultCount')}</label>
<input id="cfg-default-count" type="number" bind:value={form.default_count} min="1" max="20"
class="w-full px-2 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
</div>
<div>
<label class="block text-xs mb-1">{t('commandConfig.searchCooldown')}</label>
<input type="number" bind:value={form.rate_limits.search} min="0" max="300"
<label for="cfg-search-cooldown" class="block text-xs mb-1">{t('commandConfig.searchCooldown')}</label>
<input id="cfg-search-cooldown" type="number" bind:value={form.rate_limits.search} min="0" max="300"
class="w-full px-2 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
</div>
</div>
<div class="w-1/2 sm:w-1/4">
<label class="block text-xs mb-1">{t('commandConfig.defaultCooldown')}</label>
<input type="number" bind:value={form.rate_limits.default} min="0" max="300"
<label for="cfg-default-cooldown" class="block text-xs mb-1">{t('commandConfig.defaultCooldown')}</label>
<input id="cfg-default-cooldown" type="number" bind:value={form.rate_limits.default} min="0" max="300"
class="w-full px-2 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
</div>
{:else}
@@ -400,7 +400,7 @@
{#if !editing}
<div>
<label class="block text-sm font-medium mb-1">{t('templateConfig.providerType')}</label>
<div class="block text-sm font-medium mb-1">{t('templateConfig.providerType')}</div>
<IconGridSelect items={providerTypeItemsFn()} bind:value={form.provider_type} columns={2} />
</div>
{:else}
@@ -484,7 +484,7 @@
{#if slotErrors[slot.name]}
{#if slotErrorTypes[slot.name] === 'undefined'}
<p class="mt-1 text-xs" style="color: #d97706;">{t('common.undefinedVar')}: {slotErrors[slot.name]}</p>
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">{t('common.undefinedVar')}: {slotErrors[slot.name]}</p>
{:else}
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">{t('common.syntaxError')}: {slotErrors[slot.name]}{slotErrorLines[slot.name] ? ` (${t('common.line')} ${slotErrorLines[slot.name]})` : ''}</p>
{/if}
@@ -274,12 +274,12 @@
</div>
<div>
<label class="block text-sm font-medium mb-1">{t('commandTracker.provider')}</label>
<div class="block text-sm font-medium mb-1">{t('commandTracker.provider')}</div>
<EntitySelect items={providerItems} bind:value={form.provider_id} placeholder={t('commandTracker.selectProvider')} />
</div>
<div>
<label class="block text-sm font-medium mb-1">{t('commandTracker.commandConfig')}</label>
<div class="block text-sm font-medium mb-1">{t('commandTracker.commandConfig')}</div>
<EntitySelect items={configItems} bind:value={form.command_config_id} placeholder={t('commandTracker.selectCommandConfig')} />
</div>
@@ -129,13 +129,13 @@
<div class="px-2.5 pb-2.5" in:slide={{ duration: 150 }}>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="block text-xs text-[var(--color-muted-foreground)] mb-1">{t('trackingConfig.title')}</label>
<div class="block text-xs text-[var(--color-muted-foreground)] mb-1">{t('trackingConfig.title')}</div>
<EntitySelect items={trackingConfigItems} value={tt.tracking_config_id}
placeholder={t('common.noneDefault')} size="sm" allowNone noneLabel={t('common.noneDefault')}
onselect={(v) => onupdateLink(tt, 'tracking_config_id', Number(v) || null)} />
</div>
<div>
<label class="block text-xs text-[var(--color-muted-foreground)] mb-1">{t('templateConfig.title')}</label>
<div class="block text-xs text-[var(--color-muted-foreground)] mb-1">{t('templateConfig.title')}</div>
<EntitySelect items={templateConfigItems} value={tt.template_config_id}
placeholder={t('common.noneDefault')} size="sm" allowNone noneLabel={t('common.noneDefault')}
onselect={(v) => onupdateLink(tt, 'template_config_id', Number(v) || null)} />
@@ -97,12 +97,12 @@
</div>
</div>
<div>
<label class="block text-sm font-medium mb-1">{t('notificationTracker.server')}</label>
<div class="block text-sm font-medium mb-1">{t('notificationTracker.server')}</div>
<EntitySelect items={providerItems} bind:value={form.provider_id} placeholder={t('notificationTracker.selectServer')} />
</div>
{#if !isScheduler && colMeta && collections.length > 0}
<div>
<label class="block text-sm font-medium mb-1">{t(colMeta.label)}</label>
<div class="block text-sm font-medium mb-1">{t(colMeta.label)}</div>
<MultiEntitySelect
items={collections.map(col => ({
value: col.id,
+2 -2
View File
@@ -202,7 +202,7 @@
<ErrorBanner message={error} />
<form onsubmit={save} class="space-y-3">
<div>
<label class="block text-sm font-medium mb-1">{t('providers.type')}</label>
<div class="block text-sm font-medium mb-1">{t('providers.type')}</div>
{#if !editing}
<IconGridSelect items={providerTypeItems()} bind:value={form.type} columns={2} />
{:else}
@@ -247,7 +247,7 @@
{/each}
{#if descriptor?.webhookUrlPattern && editing}
<div class="bg-[var(--color-muted)] rounded-md p-3">
<label class="block text-sm font-medium mb-1">{t('providers.webhookUrl')}</label>
<div class="block text-sm font-medium mb-1">{t('providers.webhookUrl')}</div>
<code class="text-xs select-all break-all">{descriptor.webhookUrlPattern.replace('{token}', providers.find(p => p.id === editing)?.webhook_token ?? '')}</code>
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('providers.webhookUrlHint')}</p>
</div>
@@ -78,7 +78,7 @@
<Card>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">{t('providers.type')}</label>
<div class="block text-sm font-medium mb-1">{t('providers.type')}</div>
<IconGridSelect items={providerTypeItems()} bind:value={form.type} columns={2} />
</div>
<div>
@@ -343,7 +343,7 @@
<!-- Categories -->
<div class="mb-4">
<div class="flex items-center gap-2 mb-2">
<label class="text-xs font-medium">{t('backup.categories')}</label>
<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>
@@ -360,7 +360,7 @@
<!-- Secrets mode -->
<div class="mb-4">
<label class="block text-xs font-medium mb-2">{t('backup.secretsMode')}</label>
<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" />
@@ -458,7 +458,7 @@
<!-- Conflict mode -->
<div class="mb-4">
<label class="block text-xs font-medium mb-2">{t('backup.conflictMode')}</label>
<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" />
@@ -528,8 +528,8 @@
{#if scheduledSettings.backup_scheduled_enabled === 'true'}
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label class="block text-xs font-medium mb-1">{t('backup.interval')}</label>
<select bind:value={scheduledSettings.backup_scheduled_interval_hours}
<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>
@@ -540,8 +540,8 @@
</select>
</div>
<div>
<label class="block text-xs font-medium mb-1">{t('backup.secretsMode')}</label>
<select bind:value={scheduledSettings.backup_secrets_mode}
<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>
@@ -549,8 +549,8 @@
</select>
</div>
<div>
<label class="block text-xs font-medium mb-1">{t('backup.retention')}</label>
<select bind:value={scheduledSettings.backup_retention_count}
<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>
@@ -656,6 +656,7 @@
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);"
onclick={(e) => e.stopPropagation()}>
+1 -1
View File
@@ -135,7 +135,7 @@
let loadError = $state('');
let showTelegramSettings = $state(false);
let confirmDelete = $state<NotificationTarget | null>(null);
let formEl: HTMLElement;
let formEl = $state<HTMLElement | undefined>();
async function scrollToForm() {
await tick();
@@ -79,7 +79,7 @@
<form onsubmit={onsave} class="space-y-4">
{#if !activeType}
<div>
<label class="block text-sm font-medium mb-1">{t('targets.type')}</label>
<div class="block text-sm font-medium mb-1">{t('targets.type')}</div>
<IconGridSelect items={typeGridItems} bind:value={formType} columns={4} />
</div>
{/if}
@@ -92,7 +92,7 @@
</div>
{#if formType === 'telegram'}
<div>
<label class="block text-sm font-medium mb-1">{t('telegramBot.selectBot')}</label>
<div class="block text-sm font-medium mb-1">{t('telegramBot.selectBot')}</div>
<EntitySelect items={telegramBotItems} bind:value={form.bot_id} placeholder={t('telegramBot.selectBot')} />
{#if telegramBotCount === 0}
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('telegramBot.noBots')} <a href="/bots?tab=telegram" class="underline"></a></p>
@@ -124,7 +124,7 @@
<input id="tgt-maxsize" type="number" bind:value={form.max_asset_size} min="1" max="50" class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div class="col-span-2">
<label class="block text-xs mb-1">{t('targets.chatAction')}</label>
<div class="block text-xs mb-1">{t('targets.chatAction')}</div>
<IconGridSelect items={chatActionItems} bind:value={form.chat_action} columns={4} compact />
</div>
<label class="flex items-center gap-2 text-sm col-span-2"><input type="checkbox" bind:checked={form.disable_url_preview} /> {t('targets.disableUrlPreview')}</label>
@@ -151,7 +151,7 @@
</div>
{:else if formType === 'email'}
<div>
<label class="block text-sm font-medium mb-1">{t('targets.selectEmailBot')}</label>
<div class="block text-sm font-medium mb-1">{t('targets.selectEmailBot')}</div>
<EntitySelect items={emailBotItems} bind:value={form.email_bot_id} placeholder={t('targets.selectEmailBot')} />
{#if emailBotCount === 0}
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('emailBot.noBots')} <a href="/bots?tab=email" class="underline"></a></p>
@@ -159,7 +159,7 @@
</div>
{:else if formType === 'matrix'}
<div>
<label class="block text-sm font-medium mb-1">{t('targets.selectMatrixBot')}</label>
<div class="block text-sm font-medium mb-1">{t('targets.selectMatrixBot')}</div>
<EntitySelect items={matrixBotItems} bind:value={form.matrix_bot_id} placeholder={t('targets.selectMatrixBot')} />
{#if matrixBotCount === 0}
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('matrixBot.noBots')} <a href="/bots?tab=matrix" class="underline"></a></p>
@@ -168,7 +168,7 @@
{:else if formType === 'broadcast'}
{@const childIds = (form.child_target_ids || []).map(String)}
<div>
<label class="block text-sm font-medium mb-1">{t('targets.selectChildTargets')}</label>
<div class="block text-sm font-medium mb-1">{t('targets.selectChildTargets')}</div>
<MultiEntitySelect
items={broadcastChildItems?.map(i => ({ value: String(i.value), label: i.label, icon: i.icon, desc: i.desc })) ?? []}
values={childIds}
@@ -219,7 +219,7 @@
const headerPills = $derived.by(() => {
const pills: Array<{ label: string; tone: 'sky' }> = [];
const types = new Set(allTemplateConfigs.map(c => c.provider_type)).size;
const types = new Set(configs.map(c => c.provider_type)).size;
if (types > 0) pills.push({ label: `${types} ${types === 1 ? t('providers.typeSingular') : t('providers.typePlural')}`, tone: 'sky' });
return pills;
});
@@ -400,7 +400,7 @@
emphasis={t('templateConfig.titleEmphasis')}
description={t('templateConfig.description')}
crumb="Routing · Notification"
count={allTemplateConfigs.length}
count={configs.length}
countLabel={t('templateConfig.countLabel')}
pills={headerPills}
>
@@ -432,7 +432,7 @@
{#if !editing}
<div>
<label class="block text-sm font-medium mb-1">{t('templateConfig.providerType')}</label>
<div class="block text-sm font-medium mb-1">{t('templateConfig.providerType')}</div>
<IconGridSelect items={providerTypeItems()} bind:value={form.provider_type} columns={2} />
</div>
{:else}
@@ -443,7 +443,7 @@
{/if}
<div class="flex items-center gap-2">
<label class="text-sm font-medium">{t('templateConfig.previewAs')}:</label>
<span class="text-sm font-medium">{t('templateConfig.previewAs')}:</span>
<IconGridSelect items={previewTargetTypeItems()} bind:value={previewTargetType} columns={2} />
</div>
@@ -484,9 +484,9 @@
{#if slot.isDateFormat}
<div>
<div class="flex items-center justify-between mb-1">
<label class="text-xs text-[var(--color-muted-foreground)]">{slot.description || t(`templateConfig.${slot.label}`, slot.label)}</label>
<label for="datefmt-{slot.key}" class="text-xs text-[var(--color-muted-foreground)]">{slot.description || t(`templateConfig.${slot.label}`, slot.label)}</label>
</div>
<input value={(form as any)[slot.key]}
<input id="datefmt-{slot.key}" value={(form as any)[slot.key]}
oninput={(e: Event) => { (form as any)[slot.key] = (e.target as HTMLInputElement).value; clearTimeout(validateTimers['_fmt']); validateTimers['_fmt'] = setTimeout(refreshAllPreviews, 600); refreshDateFormatPreview(); }}
class="w-full px-2 py-1 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
{#if dateFormatPreview[slot.key]}
@@ -532,7 +532,7 @@
{#if slotErrors[slot.key]}
{#if slotErrorTypes[slot.key] === 'undefined'}
<p class="mt-1 text-xs" style="color: #d97706;">{t('common.undefinedVar')}: {slotErrors[slot.key]}</p>
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">{t('common.undefinedVar')}: {slotErrors[slot.key]}</p>
{:else}
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">{t('common.syntaxError')}: {slotErrors[slot.key]}{slotErrorLines[slot.key] ? ` (${t('common.line')} ${slotErrorLines[slot.key]})` : ''}</p>
{/if}
@@ -294,7 +294,7 @@
</div>
<div>
<label class="block text-sm font-medium mb-1">{t('trackingConfig.providerType')}</label>
<div class="block text-sm font-medium mb-1">{t('trackingConfig.providerType')}</div>
{#if !editing}
<IconGridSelect items={providerTypeItems()} bind:value={form.provider_type} columns={2} />
{:else}