feat: card highlight system for cross-entity navigation

When clicking a CrossLink, the target entity ID is passed as
?highlight=<id> in the URL. The destination page:
1. Shows a semi-transparent dim overlay (z-index: 10)
2. Finds the card with data-entity-id matching the ID
3. Scrolls to it smoothly (block: center)
4. Applies a pulsing primary-color box-shadow animation (z-index: 11)
5. Cleans up overlay + animation after 2 seconds

If the card isn't in DOM yet (data still loading), a MutationObserver
waits up to 5 seconds for it to appear.

Changes:
- New highlight.ts utility with highlightFromUrl(), MutationObserver,
  dim overlay management
- Card component accepts entityId prop → data-entity-id attribute
- CrossLink accepts entityId prop → appends ?highlight=<id> to href
- All 9 entity pages: Card elements have entityId, highlightFromUrl()
  called after data loads
- CSS: cardHighlight keyframe animation + nav-dim-overlay styles
This commit is contained in:
2026-03-21 23:59:25 +03:00
parent 227b9c2e92
commit f0f49db21e
13 changed files with 202 additions and 27 deletions
+11 -3
View File
@@ -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<string, string> = {
@@ -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}
<div class="space-y-3 stagger-children">
{#each targets as target}
<Card hover>
<Card hover entityId={target.id}>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
@@ -406,7 +414,7 @@
<p class="font-medium">{target.name}</p>
{#if !activeType}<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.type}</span>{/if}
{#if target.receiver_count}<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.receiver_count} receiver(s)</span>{/if}
{#if getBotName(target)}<CrossLink href={getBotHref(target)} icon="mdiRobot" label={getBotName(target)} />{/if}
{#if getBotName(target)}<CrossLink href={getBotHref(target)} icon="mdiRobot" label={getBotName(target)} entityId={getBotEntityId(target)} />{/if}
</div>
<p class="text-sm text-[var(--color-muted-foreground)]">
{#if target.type === 'telegram'}