diff --git a/frontend/src/app.css b/frontend/src/app.css index f1c1ca1..4bbf0b5 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -183,3 +183,29 @@ a:focus-visible { .font-mono { font-family: var(--font-mono); } + +/* Card highlight for cross-entity navigation */ +@keyframes cardHighlight { + 0%, 100% { box-shadow: none; } + 25%, 75% { box-shadow: 0 0 0 3px var(--color-primary), 0 0 20px color-mix(in srgb, var(--color-primary) 30%, transparent); } +} + +.card-highlight { + animation: cardHighlight 2s ease-in-out; + position: relative; + z-index: 11; +} + +.nav-dim-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + z-index: 10; + pointer-events: none; + opacity: 0; + transition: opacity 0.3s ease-in-out; +} + +.nav-dim-overlay.active { + opacity: 1; +} diff --git a/frontend/src/lib/components/Card.svelte b/frontend/src/lib/components/Card.svelte new file mode 100644 index 0000000..c90c08d --- /dev/null +++ b/frontend/src/lib/components/Card.svelte @@ -0,0 +1,31 @@ + + +
+ {@render children()} +
+ + diff --git a/frontend/src/lib/components/CrossLink.svelte b/frontend/src/lib/components/CrossLink.svelte index a5d64c0..6e2b02b 100644 --- a/frontend/src/lib/components/CrossLink.svelte +++ b/frontend/src/lib/components/CrossLink.svelte @@ -6,22 +6,30 @@ href, icon = 'mdiLink', label, + entityId = null, title = '', }: { href: string; icon?: string; label: string; + entityId?: number | string | null; title?: string; } = $props(); + const targetHref = $derived( + entityId != null + ? `${href.split('?')[0]}?highlight=${entityId}${href.includes('?') ? '&' + href.split('?')[1] : ''}` + : href + ); + function navigate(e: MouseEvent) { e.preventDefault(); e.stopPropagation(); - goto(href); + goto(targetHref); } - + {label} diff --git a/frontend/src/lib/highlight.ts b/frontend/src/lib/highlight.ts new file mode 100644 index 0000000..5326ad6 --- /dev/null +++ b/frontend/src/lib/highlight.ts @@ -0,0 +1,93 @@ +/** + * Card highlight system for cross-entity navigation. + * + * When navigating via CrossLink, the target entity ID is passed as ?highlight=. + * The destination page calls highlightFromUrl() on mount, which: + * 1. Shows a dim overlay behind everything + * 2. Finds the card with [data-entity-id=""] + * 3. Scrolls to it smoothly + * 4. Applies a pulsing box-shadow animation + * 5. Cleans up after 2 seconds + * + * If the card isn't in the DOM yet (data still loading), a MutationObserver + * waits up to 5 seconds for it to appear. + */ + +const HIGHLIGHT_DURATION = 2000; +const WAIT_TIMEOUT = 5000; + +/** Show dim overlay, find card, scroll & highlight. Call from onMount or $effect. */ +export function highlightFromUrl(): void { + if (typeof window === 'undefined') return; + const params = new URLSearchParams(window.location.search); + const id = params.get('highlight'); + if (!id) return; + + // Clean the URL immediately (remove highlight param, keep others) + params.delete('highlight'); + const qs = params.toString(); + const cleanUrl = window.location.pathname + (qs ? '?' + qs : ''); + window.history.replaceState(null, '', cleanUrl); + + // Try to find the card now, or wait for it + const card = document.querySelector(`[data-entity-id="${id}"]`); + if (card) { + _highlightCard(card as HTMLElement); + } else { + _waitForCard(id); + } +} + +function _highlightCard(card: HTMLElement): void { + // Show dim overlay + const overlay = _showDimOverlay(); + + // Scroll to card + card.scrollIntoView({ behavior: 'smooth', block: 'center' }); + + // Apply highlight + card.classList.add('card-highlight'); + + // Cleanup after duration + setTimeout(() => { + card.classList.remove('card-highlight'); + overlay.classList.remove('active'); + setTimeout(() => overlay.remove(), 300); // wait for fade-out + }, HIGHLIGHT_DURATION); +} + +function _showDimOverlay(): HTMLElement { + // Reuse existing overlay if present + let overlay = document.querySelector('.nav-dim-overlay') as HTMLElement | null; + if (!overlay) { + overlay = document.createElement('div'); + overlay.className = 'nav-dim-overlay'; + document.body.appendChild(overlay); + } + // Force reflow then activate + void overlay.offsetHeight; + overlay.classList.add('active'); + return overlay; +} + +function _waitForCard(id: string): void { + const start = Date.now(); + + const observer = new MutationObserver(() => { + const card = document.querySelector(`[data-entity-id="${id}"]`); + if (card) { + observer.disconnect(); + // Small delay for layout to settle + requestAnimationFrame(() => _highlightCard(card as HTMLElement)); + return; + } + if (Date.now() - start > WAIT_TIMEOUT) { + observer.disconnect(); + } + }); + + observer.observe(document.body, { childList: true, subtree: true }); + + // Safety timeout + setTimeout(() => observer.disconnect(), WAIT_TIMEOUT); +} diff --git a/frontend/src/routes/command-configs/+page.svelte b/frontend/src/routes/command-configs/+page.svelte index 9c21243..52a9b10 100644 --- a/frontend/src/routes/command-configs/+page.svelte +++ b/frontend/src/routes/command-configs/+page.svelte @@ -13,6 +13,7 @@ import IconButton from '$lib/components/IconButton.svelte'; import CrossLink from '$lib/components/CrossLink.svelte'; import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; + import { highlightFromUrl } from '$lib/highlight'; function templateName(id: number | null): string { if (!id) return ''; @@ -67,7 +68,7 @@ commandTemplateConfigsCache.fetch(), ]); } catch (err: any) { error = err.message || t('common.loadError'); snackError(error); } - finally { loaded = true; } + finally { loaded = true; highlightFromUrl(); } } function openNew() { @@ -238,7 +239,7 @@ {:else}
{#each configs as cfg} - +
@@ -256,7 +257,7 @@ · {t('commandConfig.defaultCount')}: {cfg.default_count} {#if cfg.command_template_config_id} - + {/if}
diff --git a/frontend/src/routes/command-template-configs/+page.svelte b/frontend/src/routes/command-template-configs/+page.svelte index d8d8741..6781a53 100644 --- a/frontend/src/routes/command-template-configs/+page.svelte +++ b/frontend/src/routes/command-template-configs/+page.svelte @@ -15,6 +15,7 @@ import Modal from '$lib/components/Modal.svelte'; import JinjaEditor from '$lib/components/JinjaEditor.svelte'; import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; + import { highlightFromUrl } from '$lib/highlight'; interface CmdTemplateConfig { id: number; @@ -79,6 +80,7 @@ snackError(error); } finally { loaded = true; + highlightFromUrl(); } } @@ -295,7 +297,7 @@ {:else}
{#each configs as config} - +
diff --git a/frontend/src/routes/command-trackers/+page.svelte b/frontend/src/routes/command-trackers/+page.svelte index 8bf6932..d7e34df 100644 --- a/frontend/src/routes/command-trackers/+page.svelte +++ b/frontend/src/routes/command-trackers/+page.svelte @@ -14,6 +14,7 @@ import IconButton from '$lib/components/IconButton.svelte'; import CrossLink from '$lib/components/CrossLink.svelte'; import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; + import { highlightFromUrl } from '$lib/highlight'; import type { ServiceProvider, TelegramBot } from '$lib/types'; let trackers = $state([]); @@ -61,7 +62,7 @@ telegramBotsCache.fetch(), ]); } catch (err: any) { error = err.message || t('common.loadError'); snackError(error); } - finally { loaded = true; } + finally { loaded = true; highlightFromUrl(); } } function openNew() { form = defaultForm(); editing = null; showForm = true; } @@ -226,14 +227,14 @@ {:else}
{#each trackers as trk} - +

{trk.name}

- - + + @@ -270,7 +271,7 @@
- + {listener.listener_type}
{#each notificationTrackers as tracker} - +
@@ -379,7 +380,7 @@ {tracker.enabled ? t('notificationTracker.active') : t('notificationTracker.paused')} -t +t

{(tracker.collection_ids || []).length} {t('notificationTracker.albums_count')} · {t('notificationTracker.every')} {tracker.scan_interval}s · {(tracker.tracker_targets || []).length} {t('notificationTracker.linkedTargets')} diff --git a/frontend/src/routes/providers/+page.svelte b/frontend/src/routes/providers/+page.svelte index f214d09..761e37c 100644 --- a/frontend/src/routes/providers/+page.svelte +++ b/frontend/src/routes/providers/+page.svelte @@ -13,6 +13,7 @@ import ConfirmModal from '$lib/components/ConfirmModal.svelte'; import IconButton from '$lib/components/IconButton.svelte'; import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; + import { highlightFromUrl } from '$lib/highlight'; import type { ServiceProvider } from '$lib/types'; let providers = $derived(providersCache.items); @@ -34,7 +35,7 @@ loadError = ''; } catch (err: any) { loadError = err.message || t('providers.loadError'); - } finally { loaded = true; } + } finally { loaded = true; highlightFromUrl(); } // Ping all providers in background for (const p of providers) { health = { ...health, [p.id]: null }; @@ -151,7 +152,7 @@ {:else}

{#each providers as provider} - +
diff --git a/frontend/src/routes/targets/+page.svelte b/frontend/src/routes/targets/+page.svelte index ff56193..4fce2a9 100644 --- a/frontend/src/routes/targets/+page.svelte +++ b/frontend/src/routes/targets/+page.svelte @@ -17,6 +17,7 @@ import CrossLink from '$lib/components/CrossLink.svelte'; import IconGridSelect from '$lib/components/IconGridSelect.svelte'; import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; + import { highlightFromUrl } from '$lib/highlight'; import type { NotificationTarget, TelegramBot, TelegramChat, EmailBot, MatrixBot } from '$lib/types'; function getBotName(target: any): string | null { @@ -42,6 +43,13 @@ return '/telegram-bots'; } + function getBotEntityId(target: any): number | null { + if (target.type === 'telegram') return target.config?.bot_id || null; + if (target.type === 'email') return target.config?.email_bot_id || null; + if (target.type === 'matrix') return target.config?.matrix_bot_id || null; + return null; + } + const ALL_TYPES = ['telegram', 'webhook', 'email', 'discord', 'slack', 'ntfy', 'matrix'] as const; type TargetType = typeof ALL_TYPES[number]; const TYPE_ICONS: Record = { @@ -106,7 +114,7 @@ emailBotsCache.fetch(), matrixBotsCache.fetch(), ]); loadError = ''; - } catch (err: any) { loadError = err.message || t('common.loadError'); snackError(loadError); } finally { loaded = true; } + } catch (err: any) { loadError = err.message || t('common.loadError'); snackError(loadError); } finally { loaded = true; highlightFromUrl(); } } async function loadBotChats() { @@ -398,7 +406,7 @@ {:else}
{#each targets as target} - +
@@ -406,7 +414,7 @@

{target.name}

{#if !activeType}{target.type}{/if} {#if target.receiver_count}{target.receiver_count} receiver(s){/if} - {#if getBotName(target)}{/if} + {#if getBotName(target)}{/if}

{#if target.type === 'telegram'} diff --git a/frontend/src/routes/telegram-bots/+page.svelte b/frontend/src/routes/telegram-bots/+page.svelte index 4e79528..06c2c37 100644 --- a/frontend/src/routes/telegram-bots/+page.svelte +++ b/frontend/src/routes/telegram-bots/+page.svelte @@ -14,6 +14,7 @@ import ConfirmModal from '$lib/components/ConfirmModal.svelte'; import IconButton from '$lib/components/IconButton.svelte'; import { snackSuccess, snackError, snackInfo } from '$lib/stores/snackbar.svelte'; + import { highlightFromUrl } from '$lib/highlight'; import type { TelegramBot, TelegramChat, EmailBot, MatrixBot } from '$lib/types'; type BotTab = 'telegram' | 'email' | 'matrix'; @@ -51,7 +52,7 @@ matrixBotsCache.fetch(true), ]); } catch (err: any) { error = err.message || t('common.loadError'); snackError(error); } - finally { loaded = true; } + finally { loaded = true; highlightFromUrl(); } } function openNew() { form = { name: '', icon: '', token: '' }; editing = null; showForm = true; } @@ -390,7 +391,7 @@ {:else}

{#each bots as bot} - +
@@ -638,7 +639,7 @@ {:else}
{#each emailBots as bot} - +
@@ -721,7 +722,7 @@ {:else}
{#each matrixBots as bot} - +
diff --git a/frontend/src/routes/template-configs/+page.svelte b/frontend/src/routes/template-configs/+page.svelte index 70c01ea..6a5755d 100644 --- a/frontend/src/routes/template-configs/+page.svelte +++ b/frontend/src/routes/template-configs/+page.svelte @@ -16,6 +16,7 @@ import Modal from '$lib/components/Modal.svelte'; import JinjaEditor from '$lib/components/JinjaEditor.svelte'; import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; + import { highlightFromUrl } from '$lib/highlight'; import type { TemplateConfig } from '$lib/types'; let configs = $derived(templateConfigsCache.items); @@ -140,7 +141,7 @@ api('/providers/capabilities'), ]); } catch (err: any) { error = err.message || t('common.loadError'); snackError(error); } - finally { loaded = true; } + finally { loaded = true; highlightFromUrl(); } } function openNew() { form = defaultForm(); editing = null; showForm = true; slotPreview = {}; slotErrors = {}; dateFormatPreview = {}; refreshDateFormatPreview(); } @@ -327,7 +328,7 @@ {:else}
{#each configs as config} - +
diff --git a/frontend/src/routes/tracking-configs/+page.svelte b/frontend/src/routes/tracking-configs/+page.svelte index a2d342a..500e2d0 100644 --- a/frontend/src/routes/tracking-configs/+page.svelte +++ b/frontend/src/routes/tracking-configs/+page.svelte @@ -14,6 +14,7 @@ import Hint from '$lib/components/Hint.svelte'; import IconButton from '$lib/components/IconButton.svelte'; import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte'; + import { highlightFromUrl } from '$lib/highlight'; import type { TrackingConfig } from '$lib/types'; let configs = $derived(trackingConfigsCache.items); @@ -43,7 +44,7 @@ async function load() { try { await trackingConfigsCache.fetch(true); } catch (err: any) { error = err.message || t('common.loadError'); snackError(error); } - finally { loaded = true; } + finally { loaded = true; highlightFromUrl(); } } function openNew() { form = defaultForm(); editing = null; showForm = true; } @@ -208,7 +209,7 @@ {:else}
{#each configs as config} - +