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:
+26
-1
@@ -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> = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)]" />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user