From 73f240af670e02fc6a7e3857ae95a6a2e9d2af2e Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 6 Feb 2026 23:19:08 +0100 Subject: [PATCH] /LIsts page --- .../content/MarkdownRenderer.svelte | 42 ++- .../content/MediaAttachments.svelte | 31 ++- .../components/content/MetadataCard.svelte | 13 + src/lib/modules/comments/Comment.svelte | 11 +- .../modules/discussions/DiscussionCard.svelte | 13 +- src/lib/modules/feed/FeedPost.svelte | 105 ++++++- src/lib/modules/rss/RSSCommentForm.svelte | 20 +- src/lib/utils/url-cleaner.ts | 258 ++++++++++++++++++ src/routes/lists/+page.svelte | 25 +- 9 files changed, 491 insertions(+), 27 deletions(-) create mode 100644 src/lib/utils/url-cleaner.ts diff --git a/src/lib/components/content/MarkdownRenderer.svelte b/src/lib/components/content/MarkdownRenderer.svelte index d6afe0e..a33a07f 100644 --- a/src/lib/components/content/MarkdownRenderer.svelte +++ b/src/lib/components/content/MarkdownRenderer.svelte @@ -20,9 +20,10 @@ interface Props { content: string; event?: NostrEvent; // Optional event for emoji resolution + excludeMediaUrls?: string[]; // URLs already displayed by MediaAttachments - skip rendering these } - let { content, event }: Props = $props(); + let { content, event, excludeMediaUrls = [] }: Props = $props(); let containerRef = $state(null); let emojiUrls = $state>(new Map()); let highlights = $state([]); @@ -102,6 +103,21 @@ .replace(/'/g, '''); } + // Normalize URL for comparison (same logic as MediaAttachments) + function normalizeUrl(url: string): string { + if (!url || typeof url !== 'string') return url; + try { + const parsed = new URL(url); + // Remove query params and fragments for comparison + // Normalize trailing slashes and convert to lowercase for comparison + const normalized = `${parsed.protocol}//${parsed.host.toLowerCase()}${parsed.pathname}`.replace(/\/$/, ''); + return normalized; + } catch { + // If URL parsing fails, try basic normalization + return url.trim().replace(/\/$/, '').toLowerCase(); + } + } + // Convert plain media URLs (images, videos, audio) to HTML tags function convertMediaUrls(text: string): string { // Match media URLs (http/https URLs ending in media extensions) @@ -110,6 +126,9 @@ const audioExtensions = /\.(mp3|wav|ogg|flac|aac|m4a)(\?[^\s<>"']*)?$/i; const urlPattern = /https?:\/\/[^\s<>"']+/g; + // Normalize exclude URLs for comparison + const normalizedExcludeUrls = new Set(excludeMediaUrls.map(url => normalizeUrl(url))); + let result = text; const matches: Array<{ url: string; index: number; endIndex: number; type: 'image' | 'video' | 'audio' }> = []; @@ -120,6 +139,11 @@ const index = match.index; const endIndex = index + url.length; + // Skip if this URL is already displayed by MediaAttachments + if (normalizedExcludeUrls.has(normalizeUrl(url))) { + continue; + } + // Check if this URL is already in markdown or HTML const before = text.substring(Math.max(0, index - 10), index); const after = text.substring(endIndex, Math.min(text.length, endIndex + 10)); @@ -618,13 +642,23 @@ return match.replace(/]*class="emoji-inline"[^>]*alt="([^"]*)"[^>]*>/gi, '$1'); }); + // Normalize exclude URLs for comparison + const normalizedExcludeUrls = new Set(excludeMediaUrls.map(url => normalizeUrl(url))); + // Fix malformed image tags - ensure all img src attributes are absolute URLs // This prevents the browser from trying to fetch markdown syntax or malformed tags as relative URLs + // Also filter out images that are already displayed by MediaAttachments html = html.replace(/]*?)>/gi, (match, attributes) => { // Extract src attribute const srcMatch = attributes.match(/src=["']([^"']+)["']/i); if (srcMatch) { const src = srcMatch[1]; + + // Skip if this image is already displayed by MediaAttachments + if (normalizedExcludeUrls.has(normalizeUrl(src))) { + return ''; // Remove the img tag + } + // If src doesn't start with http:// or https://, it might be malformed // Check if it looks like it should be a URL but isn't properly formatted if (!src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:') && !src.startsWith('/')) { @@ -647,7 +681,13 @@ // Fix markdown image syntax that wasn't properly converted (e.g., ![image](url) as text) // This should be rare, but if marked didn't convert it, we need to handle it + // Also filter out images that are already displayed by MediaAttachments html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, url) => { + // Skip if this image is already displayed by MediaAttachments + if (normalizedExcludeUrls.has(normalizeUrl(url))) { + return ''; // Remove the markdown image syntax + } + // Only convert if it's a valid URL if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) { const escapedUrl = escapeHtml(url); diff --git a/src/lib/components/content/MediaAttachments.svelte b/src/lib/components/content/MediaAttachments.svelte index b95ce12..01c4ed5 100644 --- a/src/lib/components/content/MediaAttachments.svelte +++ b/src/lib/components/content/MediaAttachments.svelte @@ -3,9 +3,10 @@ interface Props { event: NostrEvent; + forceRender?: boolean; // If true, always render media even if URL is in content (for media kinds) } - let { event }: Props = $props(); + let { event, forceRender = false }: Props = $props(); interface MediaItem { url: string; @@ -18,12 +19,17 @@ } function normalizeUrl(url: string): string { + if (!url || typeof url !== 'string') return url; try { const parsed = new URL(url); // Remove query params and fragments for comparison - return `${parsed.protocol}//${parsed.host}${parsed.pathname}`.replace(/\/$/, ''); + // Normalize trailing slashes and convert to lowercase for comparison + // Keep protocol and host case-sensitive for proper domain matching + const normalized = `${parsed.protocol}//${parsed.host.toLowerCase()}${parsed.pathname}`.replace(/\/$/, ''); + return normalized; } catch { - return url; + // If URL parsing fails, try basic normalization + return url.trim().replace(/\/$/, '').toLowerCase(); } } @@ -34,7 +40,7 @@ // Check if URL appears as plain text (with or without protocol) const urlWithoutProtocol = normalized.replace(/^https?:\/\//i, ''); - if (content.includes(normalized.toLowerCase()) || content.includes(urlWithoutProtocol.toLowerCase())) { + if (content.includes(normalized) || content.includes(urlWithoutProtocol)) { return true; } @@ -102,7 +108,8 @@ if (url) { const normalized = normalizeUrl(url); // Skip if already displayed in content (imeta is just metadata reference) - if (isUrlInContent(url)) { + // UNLESS forceRender is true (for media kinds where media is the primary content) + if (!forceRender && isUrlInContent(url)) { continue; } @@ -176,7 +183,19 @@ // This ensures images appear where the URL is in the content, not at the top // Only extract images from tags (image, imeta, file) which are handled above - return media; + // Final deduplication pass: ensure no duplicates by normalized URL + const deduplicated: MediaItem[] = []; + const finalSeen = new Set(); + + for (const item of media) { + const normalized = normalizeUrl(item.url); + if (!finalSeen.has(normalized)) { + deduplicated.push(item); + finalSeen.add(normalized); + } + } + + return deduplicated; } const mediaItems = $derived(extractMedia()); diff --git a/src/lib/components/content/MetadataCard.svelte b/src/lib/components/content/MetadataCard.svelte index 949bc54..b6f51de 100644 --- a/src/lib/components/content/MetadataCard.svelte +++ b/src/lib/components/content/MetadataCard.svelte @@ -5,6 +5,9 @@ import { goto } from '$app/navigation'; import IconButton from '../ui/IconButton.svelte'; import { sessionManager } from '../../services/auth/session-manager.js'; + import MediaAttachments from './MediaAttachments.svelte'; + import { KIND } from '../../types/kind-lookup.js'; + import { page } from '$app/stores'; interface Props { event: NostrEvent; @@ -71,6 +74,12 @@ const hasMetadata = $derived(image || description || summary || author || title); const hasContent = $derived(event.content && event.content.trim().length > 0); const shouldShowMetadata = $derived(hasMetadata || !hasContent); // Show metadata if it exists OR if there's no content + + // Media kinds that should auto-render media (except on /feed) + const MEDIA_KINDS: number[] = [KIND.PICTURE_NOTE, KIND.VIDEO_NOTE, KIND.SHORT_VIDEO_NOTE, KIND.VOICE_NOTE, KIND.VOICE_REPLY]; + const isMediaKind = $derived(MEDIA_KINDS.includes(event.kind)); + const isOnFeedPage = $derived($page.url.pathname === '/feed'); + const shouldAutoRenderMedia = $derived(isMediaKind && !isOnFeedPage); {#if shouldShowMetadata} @@ -109,6 +118,10 @@ {/if} + {#if shouldAutoRenderMedia} + + {/if} +
- + {#if shouldAutoRenderMedia} + + {/if}
diff --git a/src/lib/modules/discussions/DiscussionCard.svelte b/src/lib/modules/discussions/DiscussionCard.svelte index 1114e29..37403f8 100644 --- a/src/lib/modules/discussions/DiscussionCard.svelte +++ b/src/lib/modules/discussions/DiscussionCard.svelte @@ -18,6 +18,7 @@ import { getEventLink } from '../../services/event-links.js'; import { goto } from '$app/navigation'; import IconButton from '../../components/ui/IconButton.svelte'; + import { page } from '$app/stores'; interface Props { thread: NostrEvent; @@ -29,6 +30,12 @@ } let { thread, commentCount: providedCommentCount = 0, upvotes: providedUpvotes = 0, downvotes: providedDownvotes = 0, votesCalculated: providedVotesCalculated = false, fullView = false }: Props = $props(); + + // Media kinds that should auto-render media (except on /feed) + const MEDIA_KINDS: number[] = [KIND.PICTURE_NOTE, KIND.VIDEO_NOTE, KIND.SHORT_VIDEO_NOTE, KIND.VOICE_NOTE, KIND.VOICE_REPLY]; + const isMediaKind = $derived(MEDIA_KINDS.includes(thread.kind)); + const isOnFeedPage = $derived($page.url.pathname === '/feed'); + const shouldAutoRenderMedia = $derived(isMediaKind && !isOnFeedPage); let commentCount = $state(0); @@ -263,6 +270,10 @@ + {#if shouldAutoRenderMedia} + + {/if} +

{getPreview()}

{#if getTopics().length > 0} @@ -311,7 +322,7 @@
- +
diff --git a/src/lib/modules/feed/FeedPost.svelte b/src/lib/modules/feed/FeedPost.svelte index 54ff465..2bd2bdd 100644 --- a/src/lib/modules/feed/FeedPost.svelte +++ b/src/lib/modules/feed/FeedPost.svelte @@ -10,6 +10,7 @@ import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte'; import MediaViewer from '../../components/content/MediaViewer.svelte'; import CommentForm from '../comments/CommentForm.svelte'; + import PollCard from '../../components/content/PollCard.svelte'; import type { NostrEvent } from '../../types/nostr.js'; import { getKindInfo, KIND } from '../../types/kind-lookup.js'; import { stripMarkdown } from '../../services/text-utils.js'; @@ -21,6 +22,7 @@ import { getEventLink } from '../../services/event-links.js'; import { goto } from '$app/navigation'; import IconButton from '../../components/ui/IconButton.svelte'; + import { page } from '$app/stores'; interface Props { post: NostrEvent; @@ -48,11 +50,20 @@ // Reply state let showReplyForm = $state(false); + // Media kinds that should auto-render media (except on /feed) + const MEDIA_KINDS: number[] = [KIND.PICTURE_NOTE, KIND.VIDEO_NOTE, KIND.SHORT_VIDEO_NOTE, KIND.VOICE_NOTE, KIND.VOICE_REPLY]; + const isMediaKind = $derived(MEDIA_KINDS.includes(post.kind)); + const isOnFeedPage = $derived($page.url.pathname === '/feed'); + const shouldAutoRenderMedia = $derived(isMediaKind && !isOnFeedPage); + // Check if card should be collapsed (only in feed view) let isMounted = $state(true); + // Don't collapse media kinds when they should auto-render (they need full space for images/videos) + const shouldNeverCollapse = $derived(fullView || shouldAutoRenderMedia); + $effect(() => { - if (fullView) { + if (shouldNeverCollapse) { shouldCollapse = false; return; } @@ -505,13 +516,87 @@ } function normalizeUrl(url: string): string { + if (!url || typeof url !== 'string') return url; try { const parsed = new URL(url); - return `${parsed.protocol}//${parsed.host}${parsed.pathname}`.replace(/\/$/, ''); + // Normalize for comparison (same as MediaAttachments) + const normalized = `${parsed.protocol}//${parsed.host.toLowerCase()}${parsed.pathname}`.replace(/\/$/, ''); + return normalized; } catch { - return url; + return url.trim().replace(/\/$/, '').toLowerCase(); } + } + + // Get media URLs that MediaAttachments will display (for fullView) + // This matches the logic in MediaAttachments.extractMedia() + function getMediaAttachmentUrls(): string[] { + const urls: string[] = []; + const seen = new Set(); + const forceRender = isMediaKind; // Same as what we pass to MediaAttachments + + // 1. Image tag (NIP-23) + const imageTag = post.tags.find((t) => t[0] === 'image'); + if (imageTag && imageTag[1]) { + const normalized = normalizeUrl(imageTag[1]); + if (!seen.has(normalized)) { + urls.push(imageTag[1]); + seen.add(normalized); + } } + + // 2. imeta tags (NIP-92) + for (const tag of post.tags) { + if (tag[0] === 'imeta') { + let url: string | undefined; + for (let i = 1; i < tag.length; i++) { + const item = tag[i]; + if (item.startsWith('url ')) { + url = item.substring(4).trim(); + break; + } + } + + if (url) { + const normalized = normalizeUrl(url); + // Skip if already displayed in content (imeta is just metadata reference) + // UNLESS forceRender is true (for media kinds where media is the primary content) + if (!forceRender && isUrlInContent(url)) { + continue; + } + + if (!seen.has(normalized)) { + urls.push(url); + seen.add(normalized); + } + } + } + } + + // 3. file tags (NIP-94) + for (const tag of post.tags) { + if (tag[0] === 'file' && tag[1]) { + const normalized = normalizeUrl(tag[1]); + if (!seen.has(normalized)) { + urls.push(tag[1]); + seen.add(normalized); + } + } + } + + // 4. Extract from markdown content (images in markdown syntax) + const imageRegex = /!\[.*?\]\((.*?)\)/g; + let match; + while ((match = imageRegex.exec(post.content)) !== null) { + const url = match[1]; + const normalized = normalizeUrl(url); + if (!seen.has(normalized)) { + urls.push(url); + seen.add(normalized); + } + } + + return urls; + } // Extract media URLs from event tags (image, imeta, file) - for feed view only // Excludes URLs that are already in the content @@ -659,8 +744,15 @@
- - + {#if (shouldAutoRenderMedia || fullView) && (post.content && post.content.trim())} + + {/if} + {#if post.kind === KIND.POLL && fullView} + + {:else} + {@const mediaAttachmentUrls = getMediaAttachmentUrls()} + + {/if}
@@ -707,6 +799,9 @@ {/if}
+ {#if shouldAutoRenderMedia} + + {/if}

{#each parseContentWithNIP21Links() as segment} {@const highlightContent = getHighlightContent()} diff --git a/src/lib/modules/rss/RSSCommentForm.svelte b/src/lib/modules/rss/RSSCommentForm.svelte index 5481990..d9d5dbe 100644 --- a/src/lib/modules/rss/RSSCommentForm.svelte +++ b/src/lib/modules/rss/RSSCommentForm.svelte @@ -14,6 +14,7 @@ import { cacheEvent } from '../../services/cache/event-cache.js'; import { autoExtractTags } from '../../services/auto-tagging.js'; import type { NostrEvent } from '../../types/nostr.js'; + import { cleanTrackingParams } from '../../utils/url-cleaner.js'; interface Props { url: string; // The RSS item URL @@ -22,6 +23,9 @@ } let { url, onPublished, onCancel }: Props = $props(); + + // Clean the URL to remove tracking parameters + const cleanedUrl = $derived(cleanTrackingParams(url)); // Create unique draft ID based on URL const DRAFT_ID = $derived(`rss_comment_${url}`); @@ -89,11 +93,11 @@ const tags: string[][] = []; // For RSS items, we use kind 1111 comments - // Add "i" tag with the URL - tags.push(['i', url]); + // Add "i" tag with the cleaned URL (tracking parameters removed) + tags.push(['i', cleanedUrl]); - // Add "r" tag with the URL for relay hints - tags.push(['r', url]); + // Add "r" tag with the cleaned URL for relay hints + tags.push(['r', cleanedUrl]); if (shouldIncludeClientTag()) { tags.push(['client', 'aitherboard']); @@ -193,8 +197,8 @@ } const tags: string[][] = []; - tags.push(['i', url]); - tags.push(['r', url]); + tags.push(['i', cleanedUrl]); + tags.push(['r', cleanedUrl]); if (shouldIncludeClientTag()) { tags.push(['client', 'aitherboard']); @@ -249,8 +253,8 @@ // Build preview event with all tags const previewTags: string[][] = []; - previewTags.push(['i', url]); - previewTags.push(['r', url]); + previewTags.push(['i', cleanedUrl]); + previewTags.push(['r', cleanedUrl]); for (const file of uploadedFiles) { previewTags.push(file.imetaTag); diff --git a/src/lib/utils/url-cleaner.ts b/src/lib/utils/url-cleaner.ts new file mode 100644 index 0000000..02074d4 --- /dev/null +++ b/src/lib/utils/url-cleaner.ts @@ -0,0 +1,258 @@ +/** + * Utility to clean tracking parameters from URLs + * Removes common tracking query parameters like utm_*, ref, fbclid, gclid, etc. + */ + +/** + * List of common tracking parameters to remove from URLs + */ +const TRACKING_PARAMS = new Set([ + // UTM parameters + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_term', + 'utm_content', + 'utm_id', + 'utm_cid', + + // Generic tracking + 'ref', + 'source', + 'campaign', + 'referrer', + 'referer', + + // Social media tracking + 'fbclid', // Facebook + 'gclid', // Google Ads + 'msclkid', // Microsoft + 'twclid', // Twitter + 'li_fat_id', // LinkedIn + 'igshid', // Instagram + + // Analytics + '_ga', // Google Analytics + '_gid', // Google Analytics + '_gl', // Google Analytics Linker + 'mc_cid', // MailChimp + 'mc_eid', // MailChimp + 'icid', // Various + 'ncid', // Various + + // Other common trackers + 'affiliate_id', + 'affid', + 'affiliate', + 'partner_id', + 'partner', + 'click_id', + 'clickid', + 'clickId', + 'click', + 'tracking_id', + 'trackingId', + 'tracking', + 'track', + 'tid', + 'trk', + 'trkid', + + // E-commerce + 'promo', + 'promocode', + 'promo_code', + 'discount', + 'coupon', + 'voucher', + + // Email marketing + 'email_source', + 'email_campaign', + 'email_medium', + + // Content + 'content_id', + 'contentId', + 'content', + + // A/B testing + 'ab_test', + 'abtest', + 'variant', + + // Time-based + 'timestamp', + 'ts', + 'time', + + // Miscellaneous + 'hash', + 'anchor', + 'position', + 'pos', + 'placement', + 'placement_id', + 'placementId', + 'widget_id', + 'widgetId', + 'widget', + 'context', + 'ctx', + 'origin', + 'orig', + 'return', + 'return_to', + 'returnTo', + 'redirect', + 'redirect_to', + 'redirectTo', + 'next', + 'continue', + 'callback', + 'cb', + 'state', + 'session_id', + 'sessionId', + 'sid', + 'token', + 'key', + 'api_key', + 'apikey', + 'apiKey', + 'auth', + 'auth_token', + 'authToken', + 'access_token', + 'accessToken', + 'refresh_token', + 'refreshToken', +]); + +/** + * Check if a URL fragment (hash) contains tracking parameters + * + * @param fragment - The URL fragment (without the #) + * @returns true if the fragment contains tracking parameters + */ +function isTrackingFragment(fragment: string): boolean { + if (!fragment) return false; + + // Check if fragment is in key=value format (e.g., "ref=rss") + const equalIndex = fragment.indexOf('='); + if (equalIndex > 0) { + const key = fragment.substring(0, equalIndex).toLowerCase(); + // Check if it's a known tracking parameter + if (TRACKING_PARAMS.has(key)) { + return true; + } + // Check for tracking patterns + if ( + key.startsWith('utm_') || + key.startsWith('tracking_') || + key.startsWith('track_') || + key.startsWith('click_') || + key.startsWith('affiliate_') || + key.startsWith('partner_') || + key.startsWith('ref_') || + key.startsWith('source_') + ) { + return true; + } + } else { + // Fragment is just a key (e.g., "ref") + const keyLower = fragment.toLowerCase(); + if (TRACKING_PARAMS.has(keyLower)) { + return true; + } + // Check for tracking patterns + if ( + keyLower.startsWith('utm_') || + keyLower.startsWith('tracking_') || + keyLower.startsWith('track_') || + keyLower.startsWith('click_') || + keyLower.startsWith('affiliate_') || + keyLower.startsWith('partner_') || + keyLower.startsWith('ref_') || + keyLower.startsWith('source_') + ) { + return true; + } + } + + return false; +} + +/** + * Clean tracking parameters from a URL + * Removes tracking parameters from both query string and URL fragment (hash) + * + * @param url - The URL to clean + * @returns The cleaned URL with tracking parameters removed + */ +export function cleanTrackingParams(url: string): string { + if (!url || typeof url !== 'string') { + return url; + } + + try { + const urlObj = new URL(url); + + // Get all search parameters + const params = urlObj.searchParams; + const keysToDelete: string[] = []; + + // Find all tracking parameters (case-insensitive) + for (const [key, value] of params.entries()) { + const keyLower = key.toLowerCase(); + + // Check if this is a known tracking parameter + if (TRACKING_PARAMS.has(keyLower)) { + keysToDelete.push(key); + continue; + } + + // Also check for common patterns (e.g., keys starting with tracking prefixes) + if ( + keyLower.startsWith('utm_') || + keyLower.startsWith('tracking_') || + keyLower.startsWith('track_') || + keyLower.startsWith('click_') || + keyLower.startsWith('affiliate_') || + keyLower.startsWith('partner_') || + keyLower.startsWith('ref_') || + keyLower.startsWith('source_') || + keyLower.endsWith('_id') && ( + keyLower.includes('track') || + keyLower.includes('click') || + keyLower.includes('affiliate') || + keyLower.includes('partner') || + keyLower.includes('campaign') || + keyLower.includes('source') + ) + ) { + keysToDelete.push(key); + } + } + + // Remove all identified tracking parameters + for (const key of keysToDelete) { + params.delete(key); + } + + // Reconstruct the URL + urlObj.search = params.toString(); + + // Check and remove tracking parameters from URL fragment (hash) + const fragment = urlObj.hash.substring(1); // Remove the # character + if (fragment && isTrackingFragment(fragment)) { + urlObj.hash = ''; // Remove the fragment + } + + return urlObj.toString(); + } catch (error) { + // If URL parsing fails, return the original URL + console.warn('Failed to parse URL for cleaning:', url, error); + return url; + } +} diff --git a/src/routes/lists/+page.svelte b/src/routes/lists/+page.svelte index 68b8dd5..108ede2 100644 --- a/src/routes/lists/+page.svelte +++ b/src/routes/lists/+page.svelte @@ -24,8 +24,19 @@ let loading = $state(true); let loadingEvents = $state(false); let hasLists = $derived(lists.length > 0); - const isLoggedIn = $derived(sessionManager.isLoggedIn()); - const currentPubkey = $derived(sessionManager.getCurrentPubkey()); + + // Subscribe to session changes to reactively update login status + let currentSession = $state(sessionManager.session.value); + const isLoggedIn = $derived(currentSession !== null); + const currentPubkey = $derived(currentSession?.pubkey || null); + + // Subscribe to session changes + $effect(() => { + const unsubscribe = sessionManager.session.subscribe((session) => { + currentSession = session; + }); + return unsubscribe; + }); // Get all relays (default + profile + user inbox) function getAllRelays(): string[] { @@ -166,9 +177,13 @@ onMount(async () => { await nostrClient.initialize(); - if (!isLoggedIn) { - goto('/login'); - return; + // Restore session if not already logged in (in case of page refresh) + if (!sessionManager.isLoggedIn()) { + const restored = await sessionManager.restoreSession(); + if (!restored || !sessionManager.isLoggedIn()) { + goto('/login'); + return; + } } await loadLists();