e0bae394ee
Backend security: - Reject Gitea webhooks when webhook_secret is empty (was silently skipping) - Add slowapi rate limiting on login (5/min) and setup (3/min) endpoints - Add CORS middleware with configurable origins - Mask telegram_webhook_secret in settings API response - Protect system-owned command template configs from regular user modification - Increase minimum password length to 8 characters Backend performance: - Batch queries in _resolve_command_context (3 queries instead of 3N) - Concurrent album fetching with asyncio.gather in immich commands - Singleton Jinja2 SandboxedEnvironment (reuse instead of per-render creation) - TTLCache for rate limits (bounded memory, auto-eviction) - Optional aiohttp session reuse in send_reply/send_media_group Backend code quality: - Extract dispatch_helpers.py (shared link_data loading + event filtering) - Extract database/seeds.py from main.py (490 lines → dedicated module) - Split immich_handler.py (415 lines) into commands/immich/ subpackage - Replace bare except blocks with logged warnings - Add per-provider config validation (Pydantic models) - Truncate command input to 512 chars - Expose usage_* and desc_* slots in capabilities and variables API Frontend security: - CSS.escape() for user-controlled querySelector in highlight.ts - Client-side password min 8 chars validation on setup and password change Frontend code quality: - Replace any types with proper interfaces across top files - Decompose targets/+page.svelte into TargetForm + ReceiverSection - Fix $derived.by usage, $state mutation patterns - Add console.warn to empty catch blocks Frontend UX: - Auth redirect via goto() with "Redirecting..." state - Platform-aware Ctrl/Cmd K keyboard hint - Remove stat-card hover transform Frontend accessibility: - Modal: role=dialog, aria-modal, focus trap, restore focus - EntitySelect/IconGridSelect: listbox/option roles, aria-selected/disabled
106 lines
3.0 KiB
TypeScript
106 lines
3.0 KiB
TypeScript
/**
|
|
* Card highlight system for cross-entity navigation.
|
|
*
|
|
* CrossLink/SearchPalette call requestHighlight(id) before goto().
|
|
* The destination page calls highlightFromUrl() after data loads, which
|
|
* picks up the pending ID and highlights the matching card with a
|
|
* smooth CSS keyframe glow + dim overlay.
|
|
*/
|
|
|
|
const HIGHLIGHT_DURATION = 2000;
|
|
const WAIT_TIMEOUT = 5000;
|
|
|
|
/** Pending highlight ID — set before navigation. */
|
|
let _pendingHighlight: string | null = null;
|
|
|
|
/** Request a card highlight. Called before goto(). */
|
|
export function requestHighlight(id: string | number): void {
|
|
_pendingHighlight = String(id);
|
|
}
|
|
|
|
/** Check for pending highlight, find card, scroll & highlight. Call after data loads. */
|
|
export function highlightFromUrl(): void {
|
|
if (typeof window === 'undefined') return;
|
|
|
|
// Check global pending first, then URL param as fallback
|
|
let id = _pendingHighlight;
|
|
_pendingHighlight = null;
|
|
|
|
if (!id) {
|
|
const params = new URLSearchParams(window.location.search);
|
|
id = params.get('highlight');
|
|
if (id) {
|
|
params.delete('highlight');
|
|
const qs = params.toString();
|
|
const cleanUrl = window.location.pathname + (qs ? '?' + qs : '');
|
|
window.history.replaceState(null, '', cleanUrl);
|
|
}
|
|
}
|
|
|
|
if (!id) return;
|
|
|
|
// Wait for DOM to render after loaded=true
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
const card = document.querySelector(`[data-entity-id="${CSS.escape(id)}"]`);
|
|
if (card) {
|
|
_highlightCard(card as HTMLElement);
|
|
} else {
|
|
_waitForCard(id!);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function _highlightCard(card: HTMLElement): void {
|
|
const overlay = _showDimOverlay();
|
|
|
|
// Scroll to card
|
|
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
|
|
// Apply highlight via inline style (overrides stagger CSS class animation)
|
|
card.style.animation = 'cardHighlight 2s ease-in-out';
|
|
card.style.position = 'relative';
|
|
card.style.zIndex = '11';
|
|
|
|
// Cleanup: set animation to 'none' (inline beats class, prevents stagger replay)
|
|
setTimeout(() => {
|
|
card.style.animation = 'none';
|
|
card.style.removeProperty('position');
|
|
card.style.removeProperty('z-index');
|
|
overlay.classList.remove('active');
|
|
setTimeout(() => overlay.remove(), 300);
|
|
}, HIGHLIGHT_DURATION);
|
|
}
|
|
|
|
function _showDimOverlay(): HTMLElement {
|
|
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);
|
|
}
|
|
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="${CSS.escape(id)}"]`);
|
|
if (card) {
|
|
observer.disconnect();
|
|
setTimeout(() => _highlightCard(card as HTMLElement), 50);
|
|
return;
|
|
}
|
|
if (Date.now() - start > WAIT_TIMEOUT) {
|
|
observer.disconnect();
|
|
}
|
|
});
|
|
|
|
observer.observe(document.body, { childList: true, subtree: true });
|
|
setTimeout(() => observer.disconnect(), WAIT_TIMEOUT);
|
|
}
|