diff --git a/src/app.css b/src/app.css index 89a56e5..538e550 100644 --- a/src/app.css +++ b/src/app.css @@ -202,7 +202,7 @@ @apply text-base font-semibold; } - /* Heading links - primary-600 (light) / primary-400 (dark) for hover */ + /* Heading links - primary-600 (light, more golden) / primary-300 (dark) for hover */ h1 a, h2 a, h3 a, @@ -216,7 +216,7 @@ h5.h-leather a, h6.h-leather a { @apply text-gray-900 dark:text-gray-100 hover:text-primary-600 - dark:hover:text-primary-400; + dark:hover:text-primary-300; } /* === LEATHER COMPONENTS === */ @@ -225,6 +225,39 @@ @apply text-gray-900 dark:text-gray-100; } + /* Override Flowbite button hover in light mode - make it subtle like nav bar */ + /* Target all buttons in light mode */ + button:hover { + background-color: var(--color-primary-100) !important; + } + + /* Override outline buttons specifically */ + button[class*="outline"]:hover { + background-color: var(--color-primary-100) !important; + border-color: var(--color-primary-200) !important; + color: var(--color-primary-800) !important; + } + + /* Override btn-leather buttons */ + button.btn-leather:hover { + background-color: var(--color-primary-100) !important; + } + + /* Dark mode overrides */ + .dark button:hover { + background-color: var(--color-primary-800) !important; + } + + .dark button[class*="outline"]:hover { + background-color: var(--color-primary-800) !important; + border-color: var(--color-primary-600) !important; + color: var(--color-primary-100) !important; + } + + .dark button.btn-leather:hover { + background-color: var(--color-primary-800) !important; + } + .btn-leather.text-xs { @apply px-2 py-1; } @@ -242,8 +275,8 @@ } div[role="tooltip"] button.btn-leather { - @apply hover:text-primary-600 dark:hover:text-primary-400 - hover:border-primary-600 dark:hover:border-primary-400 hover:bg-gray-200 + @apply hover:text-primary-700 dark:hover:text-primary-300 + hover:border-primary-700 dark:hover:border-primary-300 hover:bg-gray-200 dark:hover:bg-gray-700; } @@ -301,7 +334,7 @@ div.modal-leather > div > h4 a, div.modal-leather > div > h5 a, div.modal-leather > div > h6 a { - @apply hover:text-primary-600 dark:hover:text-primary-400; + @apply hover:text-primary-700 dark:hover:text-primary-300; } /* Navbar */ @@ -314,17 +347,70 @@ } nav.navbar-leather svg { - @apply fill-gray-900 hover:fill-primary-600 dark:fill-gray-100 - dark:hover:fill-primary-400; + @apply fill-gray-900 hover:fill-primary-700 dark:fill-gray-100 + dark:hover:fill-primary-300; } /* NavBrand hover - all text highlights together */ #navi a:hover h1, #navi a:hover p { - @apply !text-primary-600 dark:!text-primary-400; + @apply !text-primary-600 dark:!text-primary-300; transition: color 0.2s ease-in-out; } + /* Navbar menu items hover effect - ALL items get same background hover */ + #navi ul li.navbar-menu-item, + #navi ul li:has(.navbar-menu-item), + #navi li.navbar-menu-item { + @apply rounded px-2 py-1 transition-colors; + } + + #navi ul li.navbar-menu-item:hover, + #navi ul li:has(.navbar-menu-item):hover, + #navi li.navbar-menu-item:hover, + #navi ul li.navbar-menu-item:has(button:hover), + #navi ul li.navbar-menu-item:has(div:hover), + #navi ul li.navbar-menu-item:has(span:hover), + #navi ul li.navbar-menu-item:has(a:hover), + #navi ul li.navbar-menu-item:has(img:hover), + #navi ul li.navbar-menu-item:has(svg:hover), + #navi ul li.navbar-menu-item:has([class*="Avatar"]:hover) { + @apply !bg-primary-100; + } + + .dark #navi ul li.navbar-menu-item:hover, + .dark #navi ul li:has(.navbar-menu-item):hover, + .dark #navi li.navbar-menu-item:hover, + .dark #navi ul li.navbar-menu-item:has(button:hover), + .dark #navi ul li.navbar-menu-item:has(div:hover), + .dark #navi ul li.navbar-menu-item:has(span:hover), + .dark #navi ul li.navbar-menu-item:has(a:hover), + .dark #navi ul li.navbar-menu-item:has(img:hover), + .dark #navi ul li.navbar-menu-item:has(svg:hover), + .dark #navi ul li.navbar-menu-item:has([class*="Avatar"]:hover) { + @apply !bg-primary-800; + } + + /* Explore text color - matches chevron in dark mode */ + #navi ul li.navbar-menu-item:first-of-type { + @apply text-primary-800 dark:text-white cursor-pointer; + } + + /* Remove ALL backgrounds from ALL child elements - use universal selector with max specificity */ + #navi ul li.navbar-menu-item *, + #navi ul li.navbar-menu-item *:hover, + #navi ul li.navbar-menu-item *:focus, + #navi ul li.navbar-menu-item *:active, + #navi ul li.navbar-menu-item:hover *, + #navi ul li.navbar-menu-item:hover *:hover, + #navi ul li.navbar-menu-item:hover *:focus, + #navi ul li.navbar-menu-item:hover *:active { + background-color: transparent !important; + background: transparent !important; + background-image: none !important; + box-shadow: none !important; + } + nav.navbar-leather h1, nav.navbar-leather h2, nav.navbar-leather h3, @@ -340,7 +426,7 @@ nav.navbar-leather h4 a, nav.navbar-leather h5 a, nav.navbar-leather h6 a { - @apply hover:text-primary-600 dark:hover:text-primary-400; + @apply hover:text-primary-700 dark:hover:text-primary-300; } div.textarea-leather { @@ -430,23 +516,23 @@ /* Lists */ .ol-leather li a, .ul-leather li a { - @apply text-gray-900 dark:text-gray-100 hover:text-primary-600 - dark:hover:text-primary-400; + @apply text-gray-900 dark:text-gray-100 hover:text-primary-700 + dark:hover:text-primary-300; } - /* Links - consistent hover colors */ + /* Links - consistent hover colors - improved contrast */ .link { - @apply underline cursor-pointer hover:text-primary-600 - dark:hover:text-primary-400; + @apply underline cursor-pointer hover:text-primary-700 + dark:hover:text-primary-300; } .npub-badge { - @apply inline-flex space-x-1 items-center text-primary-600 - dark:text-primary-500 hover:underline me-2 px-2 py-0.5 rounded-sm border - border-primary-600 dark:border-primary-500; + @apply inline-flex space-x-1 items-center text-primary-700 + dark:text-primary-300 hover:underline me-2 px-2 py-0.5 rounded-sm border + border-primary-700 dark:border-primary-300; svg { - @apply fill-primary-600 dark:fill-primary-500; + @apply fill-primary-700 dark:fill-primary-300; } } @@ -455,6 +541,19 @@ } } +/* Force remove backgrounds from DarkMode button - outside layer for max priority */ +#navi ul li.navbar-menu-item:nth-child(2) *, +#navi ul li.navbar-menu-item:nth-child(2) *:hover, +#navi ul li.navbar-menu-item:nth-child(2) *:focus, +#navi ul li.navbar-menu-item:nth-child(2) *:active, +#navi ul li.navbar-menu-item:nth-child(2):hover *, +#navi ul li.navbar-menu-item:nth-child(2):hover *:hover { + background-color: transparent !important; + background: transparent !important; + background-image: none !important; + box-shadow: none !important; +} + @layer components { nav a { text-decoration-line: none !important; @@ -464,6 +563,23 @@ @apply block mx-auto my-4; } + /* Fix white wrapper behind buttons on publication content in light mode */ + main.publication div.flex.gap-2, + main.publication div.flex.justify-between { + @apply bg-transparent; + } + + /* Override Flowbite light button white background in light mode to be more subtle */ + main.publication :global(button.bg-gray-100), + main.publication :global(button.bg-gray-50) { + @apply !bg-primary-100 !border-primary-200 !text-primary-800; + } + + main.publication :global(button.bg-gray-100:hover), + main.publication :global(button.bg-gray-50:hover) { + @apply !bg-primary-200 !border-primary-300; + } + /* Legend */ .leather-legend { @apply relative m-4 sm:m-0 sm:absolute sm:top-1 sm:left-1 flex-shrink-0 p-2 @@ -488,7 +604,7 @@ } .leather-legend button { - @apply dark:text-white; + @apply text-gray-900 dark:text-gray-100; } .publication-leather { @@ -527,10 +643,10 @@ } } - /* All links - consistent hover behavior */ + /* All links - consistent hover behavior - improved contrast */ a { - @apply underline cursor-pointer hover:text-primary-600 - dark:hover:text-primary-400; + @apply underline cursor-pointer hover:text-primary-700 + dark:hover:text-primary-300; } .imageblock { @@ -567,10 +683,14 @@ } } - /* Footnotes */ + /* Footnotes - improved contrast */ .footnote-ref { text-decoration: none; - color: var(--color-primary-500); + color: var(--color-primary-700); + } + + .dark .footnote-ref { + color: var(--color-primary-300); } .footnotes { @@ -600,12 +720,21 @@ .footnote-backref { text-decoration: none; margin-left: 0.5rem; - color: var(--color-primary-500); + color: var(--color-primary-700); + } + + .dark .footnote-backref { + color: var(--color-primary-300); } .note-leather .footnote-ref, .note-leather .footnote-backref { - color: var(--color-primary-500); + color: var(--color-primary-700); + } + + .dark .note-leather .footnote-ref, + .dark .note-leather .footnote-backref { + color: var(--color-primary-300); } /* Scrollable content */ @@ -678,10 +807,11 @@ @apply focus:border-primary-600 dark:focus:border-primary-400; } - /* Table of Contents highlighting */ + /* Table of Contents highlighting - improved contrast */ .toc-highlight { - @apply bg-primary-300 dark:bg-primary-700 border-s-4 border-primary-600 - rounded dark:border-primary-400 font-medium; + @apply bg-primary-300 dark:bg-primary-700 border-s-4 border-primary-700 + rounded dark:border-primary-300 font-medium text-gray-900 + dark:text-gray-100; transition: all 0.2s ease-in-out; } diff --git a/src/app.html b/src/app.html index 0ccb5a2..7a7ff22 100644 --- a/src/app.html +++ b/src/app.html @@ -8,8 +8,12 @@ - - -
- -
-

Alexandria

-

- READ THE ORIGINAL. MAKE CONNECTIONS. CULTIVATE KNOWLEDGE. -

-
-
-
-
- - -
- - Publications - Compose - Visualize - Getting Started - Events - {#if userState.signedIn} - My Notes - {/if} - About - Contact - - - - -
diff --git a/src/lib/components/cards/BlogHeader.svelte b/src/lib/components/cards/BlogHeader.svelte index 80de2e7..f3b35e9 100644 --- a/src/lib/components/cards/BlogHeader.svelte +++ b/src/lib/components/cards/BlogHeader.svelte @@ -87,60 +87,103 @@ {#if title != null} - -
-
-
- {@render userBadge(authorPubkey, author, ndk)} - {publishedAt()} + {#if active} + +
{ + // Don't trigger if clicking on CardActions or its children + const target = e.target as HTMLElement; + if (target.closest('.card-actions') || target.closest('button[type="button"]')) { + return; + } + showBlog(); + }} + onkeydown={(e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + const target = e.target as HTMLElement; + if (!target.closest('.card-actions') && !target.closest('button[type="button"]')) { + showBlog(); + } + } + }} + > + +
+
+
+ {@render userBadge(authorPubkey, author, ndk)} + {publishedAt()} +
-
-
- {#if image} - - {:else} -
-
- {/if} -
- -
- - {#if hashtags} -
- {#each hashtags as tag} - #{tag} - {/each} -
- {/if} -
- - {#if active} + {#if hashtags} +
+ {#each hashtags as tag} + #{tag} + {/each} +
+ {/if} +
+ - {/if} - - -
- + + +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + > + +
+
+ +
+ {:else} + +
showBlog()} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + showBlog(); + } + }} + > +

{title}

+
+

{publishedAt()}

+ +

{author}

- + {/if} {/if} diff --git a/src/lib/components/publications/CommentButton.svelte b/src/lib/components/publications/CommentButton.svelte index fb6ee25..95ae02e 100644 --- a/src/lib/components/publications/CommentButton.svelte +++ b/src/lib/components/publications/CommentButton.svelte @@ -129,12 +129,6 @@ commentEvent.tags.push(["e", eventId, relayHint]); } - console.log("[CommentButton] Created NIP-22 comment event:", { - kind: commentEvent.kind, - tags: commentEvent.tags, - content: commentEvent.content, - }); - return commentEvent; } @@ -179,8 +173,6 @@ await commentEvent.sign($userStore.signer); } - console.log("[CommentButton] Signed comment event:", commentEvent.rawEvent()); - // Build relay list following the same pattern as eventServices const relays = [ ...communityRelays, @@ -191,8 +183,6 @@ // Remove duplicates const uniqueRelays = Array.from(new Set(relays)); - console.log("[CommentButton] Publishing to relays:", uniqueRelays); - const signedEvent = { ...plainEvent, id: commentEvent.id, @@ -217,11 +207,9 @@ clearTimeout(timeout); if (ok) { publishedCount++; - console.log(`[CommentButton] Published to ${relayUrl}`); WebSocketPool.instance.release(ws); resolve(); } else { - console.warn(`[CommentButton] ${relayUrl} rejected: ${message}`); WebSocketPool.instance.release(ws); reject(new Error(message)); } @@ -240,8 +228,6 @@ throw new Error("Failed to publish to any relays"); } - console.log(`[CommentButton] Published to ${publishedCount} relay(s)`); - // Success! success = true; commentContent = ""; diff --git a/src/lib/components/publications/CommentLayer.svelte b/src/lib/components/publications/CommentLayer.svelte index 0ddda39..26f574a 100644 --- a/src/lib/components/publications/CommentLayer.svelte +++ b/src/lib/components/publications/CommentLayer.svelte @@ -36,7 +36,6 @@ async function fetchComments() { // Prevent concurrent fetches if (loading) { - console.log("[CommentLayer] Already loading, skipping fetch"); return; } @@ -55,8 +54,6 @@ // AI-NOTE: Mock mode allows testing comment UI without publishing to relays // This is useful for development and demonstrating the comment system if (useMockComments) { - console.log(`[CommentLayer] MOCK MODE - Generating mock comments for ${allAddresses.length} sections`); - try { // Generate mock comment data const mockComments = generateMockCommentsForSections(allAddresses); @@ -64,7 +61,6 @@ // Convert to NDKEvent instances (same as real events) comments = mockComments.map(rawEvent => new NDKEventClass(ndk, rawEvent)); - console.log(`[CommentLayer] Generated ${comments.length} mock comments`); loading = false; return; } catch (err) { @@ -74,11 +70,6 @@ } } - console.log(`[CommentLayer] Fetching comments for:`, { - eventIds: allEventIds, - addresses: allAddresses - }); - try { // Build filter for kind 1111 comment events // IMPORTANT: Use only #a tags because filters are AND, not OR @@ -96,8 +87,6 @@ filter["#e"] = allEventIds; } - console.log(`[CommentLayer] Fetching with filter:`, JSON.stringify(filter, null, 2)); - // Build explicit relay set (same pattern as HighlightLayer) const relays = [ ...communityRelays, @@ -105,7 +94,6 @@ ...$activeInboxRelays, ]; const uniqueRelays = Array.from(new Set(relays)); - console.log(`[CommentLayer] Fetching from ${uniqueRelays.length} relays:`, uniqueRelays); /** * Use WebSocketPool with nostr-tools protocol instead of NDK @@ -124,31 +112,55 @@ */ const subscriptionId = `comments-${Date.now()}`; const receivedEventIds = new Set(); - let eoseCount = 0; + let responseCount = 0; + const totalRelays = uniqueRelays.length; + + // AI-NOTE: Helper to check if all relays have responded and clear loading state early + const checkAllResponses = () => { + responseCount++; + if (responseCount >= totalRelays && loading) { + loading = false; + } + }; const fetchPromises = uniqueRelays.map(async (relayUrl) => { try { - console.log(`[CommentLayer] Connecting to ${relayUrl}`); const ws = await WebSocketPool.instance.acquire(relayUrl); return new Promise((resolve) => { + let released = false; + let resolved = false; + + const releaseConnection = () => { + if (released) { + return; + } + released = true; + try { + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.send(JSON.stringify(["CLOSE", subscriptionId])); + } + ws.removeEventListener("message", messageHandler); + WebSocketPool.instance.release(ws); + } catch (err) { + console.error(`[CommentLayer] Error releasing connection to ${relayUrl}:`, err); + } + }; + + const safeResolve = () => { + if (!resolved) { + resolved = true; + checkAllResponses(); + resolve(); + } + }; + const messageHandler = (event: MessageEvent) => { try { const message = JSON.parse(event.data); - // Log ALL messages from relay.nostr.band for debugging - if (relayUrl.includes('relay.nostr.band')) { - console.log(`[CommentLayer] RAW message from ${relayUrl}:`, message); - } - if (message[0] === "EVENT" && message[1] === subscriptionId) { const rawEvent = message[2]; - console.log(`[CommentLayer] EVENT from ${relayUrl}:`, { - id: rawEvent.id, - kind: rawEvent.kind, - content: rawEvent.content.substring(0, 50), - tags: rawEvent.tags - }); // Avoid duplicates if (!receivedEventIds.has(rawEvent.id)) { @@ -157,19 +169,11 @@ // Convert to NDKEvent const ndkEvent = new NDKEventClass(ndk, rawEvent); comments = [...comments, ndkEvent]; - console.log(`[CommentLayer] Added comment, total now: ${comments.length}`); } } else if (message[0] === "EOSE" && message[1] === subscriptionId) { - eoseCount++; - console.log(`[CommentLayer] EOSE from ${relayUrl} (${eoseCount}/${uniqueRelays.length})`); - - // Close subscription - ws.send(JSON.stringify(["CLOSE", subscriptionId])); - ws.removeEventListener("message", messageHandler); - WebSocketPool.instance.release(ws); - resolve(); - } else if (message[0] === "NOTICE") { - console.warn(`[CommentLayer] NOTICE from ${relayUrl}:`, message[1]); + // Close subscription and release connection + releaseConnection(); + safeResolve(); } } catch (err) { console.error(`[CommentLayer] Error processing message from ${relayUrl}:`, err); @@ -180,41 +184,25 @@ // Send REQ const req = ["REQ", subscriptionId, filter]; - if (relayUrl.includes('relay.nostr.band')) { - console.log(`[CommentLayer] Sending REQ to ${relayUrl}:`, JSON.stringify(req)); - } else { - console.log(`[CommentLayer] Sending REQ to ${relayUrl}`); - } ws.send(JSON.stringify(req)); // Timeout per relay (5 seconds) setTimeout(() => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify(["CLOSE", subscriptionId])); - ws.removeEventListener("message", messageHandler); - WebSocketPool.instance.release(ws); - } - resolve(); + releaseConnection(); + safeResolve(); }, 5000); }); } catch (err) { console.error(`[CommentLayer] Error connecting to ${relayUrl}:`, err); + // Mark this relay as responded if connection fails + checkAllResponses(); } }); // Wait for all relays to respond or timeout - await Promise.all(fetchPromises); - - console.log(`[CommentLayer] Fetched ${comments.length} comments`); - - if (comments.length > 0) { - console.log(`[CommentLayer] Comments summary:`, comments.map(c => ({ - content: c.content.substring(0, 30) + "...", - address: c.tags.find(t => t[0] === "a")?.[1], - author: c.pubkey.substring(0, 8) - }))); - } - + await Promise.allSettled(fetchPromises); + + // Ensure loading is cleared even if checkAllResponses didn't fire loading = false; } catch (err) { @@ -232,8 +220,6 @@ const currentCount = eventIds.length + eventAddresses.length; const hasEventData = currentCount > 0; - console.log(`[CommentLayer] Event data effect - count: ${currentCount}, lastFetched: ${lastFetchedCount}, loading: ${loading}`); - // Only fetch if: // 1. We have event data // 2. The count has changed since last fetch @@ -246,7 +232,6 @@ // Debounce: wait 500ms for more events to arrive before fetching fetchTimeout = setTimeout(() => { - console.log(`[CommentLayer] Event data stabilized at ${currentCount} events, fetching comments...`); lastFetchedCount = currentCount; fetchComments(); }, 500); @@ -264,8 +249,6 @@ * Public method to refresh comments (e.g., after creating a new one) */ export function refresh() { - console.log("[CommentLayer] Manual refresh triggered"); - // Clear existing comments comments = []; diff --git a/src/lib/components/publications/HighlightLayer.svelte b/src/lib/components/publications/HighlightLayer.svelte index 48b00f6..bcc1406 100644 --- a/src/lib/components/publications/HighlightLayer.svelte +++ b/src/lib/components/publications/HighlightLayer.svelte @@ -75,7 +75,6 @@ async function fetchHighlights() { // Prevent concurrent fetches if (loading) { - console.log("[HighlightLayer] Already loading, skipping fetch"); return; } @@ -99,10 +98,6 @@ // AI-NOTE: Mock mode allows testing highlight UI without publishing to relays // This is useful for development and demonstrating the highlight system if (useMockHighlights) { - console.log( - `[HighlightLayer] MOCK MODE - Generating mock highlights for ${allAddresses.length} sections`, - ); - try { // Generate mock highlight data const mockHighlights = generateMockHighlightsForSections(allAddresses); @@ -112,9 +107,6 @@ (rawEvent) => new NDKEventClass(ndk, rawEvent), ); - console.log( - `[HighlightLayer] Generated ${highlights.length} mock highlights`, - ); loading = false; return; } catch (err) { @@ -127,11 +119,6 @@ } } - console.log(`[HighlightLayer] Fetching highlights for:`, { - eventIds: allEventIds, - addresses: allAddresses, - }); - try { // Build filter for kind 9802 highlight events // IMPORTANT: Use only #a tags because filters are AND, not OR @@ -149,11 +136,6 @@ filter["#e"] = allEventIds; } - console.log( - `[HighlightLayer] Fetching with filter:`, - JSON.stringify(filter, null, 2), - ); - // Build explicit relay set (same pattern as HighlightSelectionHandler and CommentButton) const relays = [ ...communityRelays, @@ -161,10 +143,6 @@ ...$activeInboxRelays, ]; const uniqueRelays = Array.from(new Set(relays)); - console.log( - `[HighlightLayer] Fetching from ${uniqueRelays.length} relays:`, - uniqueRelays, - ); /** * Use WebSocketPool with nostr-tools protocol instead of NDK @@ -186,30 +164,41 @@ const fetchPromises = uniqueRelays.map(async (relayUrl) => { try { - console.log(`[HighlightLayer] Connecting to ${relayUrl}`); const ws = await WebSocketPool.instance.acquire(relayUrl); return new Promise((resolve) => { + let released = false; + let resolved = false; + + const releaseConnection = () => { + if (released) { + return; + } + released = true; + try { + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.send(JSON.stringify(["CLOSE", subscriptionId])); + } + ws.removeEventListener("message", messageHandler); + WebSocketPool.instance.release(ws); + } catch (err) { + console.error(`[HighlightLayer] Error releasing connection to ${relayUrl}:`, err); + } + }; + + const safeResolve = () => { + if (!resolved) { + resolved = true; + resolve(); + } + }; + const messageHandler = (event: MessageEvent) => { try { const message = JSON.parse(event.data); - // Log ALL messages from relay.nostr.band for debugging - if (relayUrl.includes("relay.nostr.band")) { - console.log( - `[HighlightLayer] RAW message from ${relayUrl}:`, - message, - ); - } - if (message[0] === "EVENT" && message[1] === subscriptionId) { const rawEvent = message[2]; - console.log(`[HighlightLayer] EVENT from ${relayUrl}:`, { - id: rawEvent.id, - kind: rawEvent.kind, - content: rawEvent.content.substring(0, 50), - tags: rawEvent.tags, - }); // Avoid duplicates if (!receivedEventIds.has(rawEvent.id)) { @@ -218,29 +207,16 @@ // Convert to NDKEvent const ndkEvent = new NDKEventClass(ndk, rawEvent); highlights = [...highlights, ndkEvent]; - console.log( - `[HighlightLayer] Added highlight, total now: ${highlights.length}`, - ); } } else if ( message[0] === "EOSE" && message[1] === subscriptionId ) { eoseCount++; - console.log( - `[HighlightLayer] EOSE from ${relayUrl} (${eoseCount}/${uniqueRelays.length})`, - ); - // Close subscription - ws.send(JSON.stringify(["CLOSE", subscriptionId])); - ws.removeEventListener("message", messageHandler); - WebSocketPool.instance.release(ws); - resolve(); - } else if (message[0] === "NOTICE") { - console.warn( - `[HighlightLayer] NOTICE from ${relayUrl}:`, - message[1], - ); + // Close subscription and release connection + releaseConnection(); + safeResolve(); } } catch (err) { console.error( @@ -254,24 +230,12 @@ // Send REQ const req = ["REQ", subscriptionId, filter]; - if (relayUrl.includes("relay.nostr.band")) { - console.log( - `[HighlightLayer] Sending REQ to ${relayUrl}:`, - JSON.stringify(req), - ); - } else { - console.log(`[HighlightLayer] Sending REQ to ${relayUrl}`); - } ws.send(JSON.stringify(req)); // Timeout per relay (5 seconds) setTimeout(() => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify(["CLOSE", subscriptionId])); - ws.removeEventListener("message", messageHandler); - WebSocketPool.instance.release(ws); - } - resolve(); + releaseConnection(); + safeResolve(); }, 5000); }); } catch (err) { @@ -285,19 +249,6 @@ // Wait for all relays to respond or timeout await Promise.all(fetchPromises); - console.log(`[HighlightLayer] Fetched ${highlights.length} highlights`); - - if (highlights.length > 0) { - console.log( - `[HighlightLayer] Highlights summary:`, - highlights.map((h) => ({ - content: h.content.substring(0, 30) + "...", - address: h.tags.find((t) => t[0] === "a")?.[1], - author: h.pubkey.substring(0, 8), - })), - ); - } - loading = false; // Rendering is handled by the visibility/highlights effect @@ -321,9 +272,6 @@ targetAddress?: string, ): boolean { if (!containerRef) { - console.log( - `[HighlightLayer] Cannot highlight by position - no containerRef`, - ); return false; } @@ -333,30 +281,10 @@ const sectionElement = document.getElementById(targetAddress); if (sectionElement) { searchRoot = sectionElement; - console.log( - `[HighlightLayer] Highlighting in specific section: ${targetAddress}`, - ); - } else { - console.log( - `[HighlightLayer] Section ${targetAddress} not found in DOM, searching globally`, - ); } } - console.log( - `[HighlightLayer] Applying position-based highlight ${offsetStart}-${offsetEnd}`, - ); - const result = highlightByOffset(searchRoot, offsetStart, offsetEnd, color); - - if (result) { - console.log( - `[HighlightLayer] Successfully applied position-based highlight`, - ); - } else { - console.log(`[HighlightLayer] Failed to apply position-based highlight`); - } - - return result; + return highlightByOffset(searchRoot, offsetStart, offsetEnd, color); } /** @@ -371,9 +299,6 @@ targetAddress?: string, ): void { if (!containerRef || !text || text.trim().length === 0) { - console.log( - `[HighlightLayer] Cannot highlight - containerRef: ${!!containerRef}, text: "${text}"`, - ); return; } @@ -383,21 +308,9 @@ const sectionElement = document.getElementById(targetAddress); if (sectionElement) { searchRoot = sectionElement; - console.log( - `[HighlightLayer] Searching in specific section: ${targetAddress}`, - ); - } else { - console.log( - `[HighlightLayer] Section ${targetAddress} not found in DOM, searching globally`, - ); } } - console.log( - `[HighlightLayer] Searching for text: "${text}" in`, - searchRoot, - ); - // Use TreeWalker to find all text nodes const walker = document.createTreeWalker( searchRoot, @@ -412,22 +325,11 @@ } // Search for the highlight text in text nodes - console.log( - `[HighlightLayer] Searching through ${textNodes.length} text nodes`, - ); - for (const textNode of textNodes) { const nodeText = textNode.textContent || ""; const index = nodeText.toLowerCase().indexOf(text.toLowerCase()); if (index !== -1) { - console.log( - `[HighlightLayer] Found match in text node:`, - nodeText.substring( - Math.max(0, index - 20), - Math.min(nodeText.length, index + text.length + 20), - ), - ); const parent = textNode.parentNode; if (!parent) continue; @@ -459,44 +361,26 @@ parent.replaceChild(fragment, textNode); - console.log(`[HighlightLayer] Highlighted text:`, match); return; // Only highlight first occurrence to avoid multiple highlights } } - - console.log(`[HighlightLayer] No match found for text: "${text}"`); } /** * Render all highlights on the page */ function renderHighlights() { - console.log( - `[HighlightLayer] renderHighlights called - visible: ${visible}, containerRef: ${!!containerRef}, highlights: ${highlights.length}`, - ); - if (!visible || !containerRef) { - console.log( - `[HighlightLayer] Skipping render - visible: ${visible}, containerRef: ${!!containerRef}`, - ); return; } if (highlights.length === 0) { - console.log(`[HighlightLayer] No highlights to render`); return; } // Clear existing highlights clearHighlights(); - console.log(`[HighlightLayer] Rendering ${highlights.length} highlights`); - console.log(`[HighlightLayer] Container element:`, containerRef); - console.log( - `[HighlightLayer] Container has children:`, - containerRef.children.length, - ); - // Apply each highlight for (const highlight of highlights) { const content = highlight.content; @@ -511,42 +395,19 @@ const hasOffset = offsetTag && offsetTag[1] !== undefined && offsetTag[2] !== undefined; - console.log(`[HighlightLayer] Rendering highlight:`, { - hasOffset, - offsetTag, - content: content.substring(0, 50), - contentLength: content.length, - targetAddress, - color, - allTags: highlight.tags, - }); - if (hasOffset) { // Use position-based highlighting const offsetStart = parseInt(offsetTag[1], 10); const offsetEnd = parseInt(offsetTag[2], 10); if (!isNaN(offsetStart) && !isNaN(offsetEnd)) { - console.log( - `[HighlightLayer] Using position-based highlighting: ${offsetStart}-${offsetEnd}`, - ); highlightByPosition(offsetStart, offsetEnd, color, targetAddress); - } else { - console.log( - `[HighlightLayer] Invalid offset values, falling back to text search`, - ); - if (content && content.trim().length > 0) { - findAndHighlightText(content, color, targetAddress); - } - } - } else { - // Fall back to text-based highlighting - console.log(`[HighlightLayer] Using text-based highlighting`); - if (content && content.trim().length > 0) { + } else if (content && content.trim().length > 0) { findAndHighlightText(content, color, targetAddress); - } else { - console.log(`[HighlightLayer] Skipping highlight - empty content`); } + } else if (content && content.trim().length > 0) { + // Fall back to text-based highlighting + findAndHighlightText(content, color, targetAddress); } } @@ -575,10 +436,6 @@ parent.normalize(); } }); - - console.log( - `[HighlightLayer] Cleared ${highlightElements.length} highlights`, - ); } // Track the last fetched event count to know when to refetch @@ -590,10 +447,6 @@ const currentCount = eventIds.length + eventAddresses.length; const hasEventData = currentCount > 0; - console.log( - `[HighlightLayer] Event data effect - count: ${currentCount}, lastFetched: ${lastFetchedCount}, loading: ${loading}`, - ); - // Only fetch if: // 1. We have event data // 2. The count has changed since last fetch @@ -606,9 +459,6 @@ // Debounce: wait 500ms for more events to arrive before fetching fetchTimeout = setTimeout(() => { - console.log( - `[HighlightLayer] Event data stabilized at ${currentCount} events, fetching highlights...`, - ); lastFetchedCount = currentCount; fetchHighlights(); }, 500); @@ -626,14 +476,8 @@ $effect(() => { // This effect runs when either visible or highlights.length changes const highlightCount = highlights.length; - console.log( - `[HighlightLayer] Visibility/highlights effect - visible: ${visible}, highlights: ${highlightCount}`, - ); if (visible && highlightCount > 0) { - console.log( - `[HighlightLayer] Both visible and highlights ready, rendering...`, - ); renderHighlights(); } else if (!visible) { clearHighlights(); @@ -653,9 +497,6 @@ */ async function fetchAuthorProfiles() { const uniquePubkeys = Array.from(groupedHighlights.keys()); - console.log( - `[HighlightLayer] Fetching profiles for ${uniquePubkeys.length} authors`, - ); for (const pubkey of uniquePubkeys) { try { @@ -693,27 +534,17 @@ * Scroll to a specific highlight in the document */ function scrollToHighlight(highlight: NDKEvent) { - console.log( - `[HighlightLayer] scrollToHighlight called for:`, - highlight.content.substring(0, 50), - ); - if (!containerRef) { - console.warn(`[HighlightLayer] No containerRef available`); return; } const content = highlight.content; if (!content || content.trim().length === 0) { - console.warn(`[HighlightLayer] No content in highlight`); return; } // Find the highlight mark element const highlightMarks = containerRef.querySelectorAll("mark.highlight"); - console.log( - `[HighlightLayer] Found ${highlightMarks.length} highlight marks in DOM`, - ); // Try exact match first for (const mark of highlightMarks) { @@ -721,9 +552,6 @@ const searchText = content.toLowerCase(); if (markText === searchText) { - console.log( - `[HighlightLayer] Found exact match, scrolling and flashing`, - ); // Scroll to this element mark.scrollIntoView({ behavior: "smooth", block: "center" }); @@ -742,9 +570,6 @@ const searchText = content.toLowerCase(); if (markText.includes(searchText) || searchText.includes(markText)) { - console.log( - `[HighlightLayer] Found partial match, scrolling and flashing`, - ); mark.scrollIntoView({ behavior: "smooth", block: "center" }); mark.classList.add("highlight-flash"); setTimeout(() => { @@ -753,11 +578,6 @@ return; } } - - console.warn( - `[HighlightLayer] Could not find highlight mark for:`, - content.substring(0, 50), - ); } /** @@ -770,7 +590,6 @@ try { await navigator.clipboard.writeText(naddr); copyFeedback = highlight.id; - console.log(`[HighlightLayer] Copied naddr to clipboard:`, naddr); // Clear feedback after 2 seconds setTimeout(() => { @@ -792,8 +611,6 @@ * Public method to refresh highlights (e.g., after creating a new one) */ export function refresh() { - console.log("[HighlightLayer] Manual refresh triggered"); - // Clear existing highlights highlights = []; clearHighlights(); diff --git a/src/lib/components/publications/HighlightSelectionHandler.svelte b/src/lib/components/publications/HighlightSelectionHandler.svelte index 3e2efd6..d1930aa 100644 --- a/src/lib/components/publications/HighlightSelectionHandler.svelte +++ b/src/lib/components/publications/HighlightSelectionHandler.svelte @@ -97,7 +97,6 @@ // Don't use closest('.publication-leather') as Details also has that class const publicationSection = target.closest("section[id]") as HTMLElement; if (!publicationSection) { - console.log("[HighlightSelectionHandler] No section[id] found, aborting"); return; } @@ -105,14 +104,6 @@ const sectionAddress = publicationSection.dataset.eventAddress; const sectionEventId = publicationSection.dataset.eventId; - console.log("[HighlightSelectionHandler] Selection in section:", { - element: publicationSection, - address: sectionAddress, - eventId: sectionEventId, - allDataAttrs: publicationSection.dataset, - sectionId: publicationSection.id, - }); - currentSelection = selection; selectedText = text; selectedSectionAddress = sectionAddress; @@ -155,12 +146,6 @@ selectedSectionAddress || publicationEvent.tagAddress(); const useEventId = selectedSectionEventId || publicationEvent.id; - console.log("[HighlightSelectionHandler] Creating highlight with:", { - address: useAddress, - eventId: useEventId, - fallbackUsed: !selectedSectionAddress, - }); - const tags: string[][] = []; // Always prefer addressable events for publications @@ -227,11 +212,6 @@ // Remove duplicates const uniqueRelays = Array.from(new Set(relays)); - console.log( - "[HighlightSelectionHandler] Publishing to relays:", - uniqueRelays, - ); - const signedEvent = { ...plainEvent, id: event.id, @@ -256,15 +236,9 @@ clearTimeout(timeout); if (ok) { publishedCount++; - console.log( - `[HighlightSelectionHandler] Published to ${relayUrl}`, - ); WebSocketPool.instance.release(ws); resolve(); } else { - console.warn( - `[HighlightSelectionHandler] ${relayUrl} rejected: ${message}`, - ); WebSocketPool.instance.release(ws); reject(new Error(message)); } diff --git a/src/lib/components/publications/Publication.svelte b/src/lib/components/publications/Publication.svelte index fdfc7b1..092af49 100644 --- a/src/lib/components/publications/Publication.svelte +++ b/src/lib/components/publications/Publication.svelte @@ -8,19 +8,30 @@ SidebarWrapper, Heading, CloseButton, - uiHelpers, + Textarea, + Popover, + P, + Modal, } from "flowbite-svelte"; import { getContext, onDestroy, onMount } from "svelte"; import { CloseOutline, ExclamationCircleOutline, + MessageDotsOutline, + FilePenOutline, + DotsVerticalOutline, + EyeOutline, + EyeSlashOutline, + ClipboardCleanOutline, + TrashBinOutline, } from "flowbite-svelte-icons"; import type { NDKEvent } from "@nostr-dev-kit/ndk"; import PublicationSection from "./PublicationSection.svelte"; import Details from "$components/util/Details.svelte"; + import CopyToClipboard from "$components/util/CopyToClipboard.svelte"; + import { neventEncode, naddrEncode } from "$lib/utils"; import { publicationColumnVisibility } from "$lib/stores"; import BlogHeader from "$components/cards/BlogHeader.svelte"; - import Interactions from "$components/util/Interactions.svelte"; import type { SveltePublicationTree } from "./svelte_publication_tree.svelte"; import TableOfContents from "./TableOfContents.svelte"; import type { TableOfContents as TocType } from "./table_of_contents.svelte"; @@ -28,15 +39,13 @@ import { deleteEvent } from "$lib/services/deletion"; import { getNdkContext, activeOutboxRelays } from "$lib/ndk"; import { goto } from "$app/navigation"; + import { getMatchingTags } from "$lib/utils/nostrUtils"; import HighlightLayer from "./HighlightLayer.svelte"; - import { EyeOutline, EyeSlashOutline } from "flowbite-svelte-icons"; - import HighlightButton from "./HighlightButton.svelte"; import HighlightSelectionHandler from "./HighlightSelectionHandler.svelte"; import CommentLayer from "./CommentLayer.svelte"; - import CommentButton from "./CommentButton.svelte"; import SectionComments from "./SectionComments.svelte"; - import { Textarea, P } from "flowbite-svelte"; import { userStore } from "$lib/stores/userStore"; + import CardActions from "$components/util/CardActions.svelte"; let { rootAddress, publicationType, indexEvent, publicationTree, toc } = $props<{ @@ -55,7 +64,7 @@ let publicationContentRef: HTMLElement | null = $state(null); // Comment layer state - let commentsVisible = $state(true); + let commentsVisible = $state(false); let comments = $state([]); let commentLayerRef: any = null; let showArticleCommentUI = $state(false); @@ -63,6 +72,10 @@ let isSubmittingArticleComment = $state(false); let articleCommentError = $state(null); let articleCommentSuccess = $state(false); + + // Publication header actions menu state + let publicationActionsOpen = $state(false); + let detailsModalOpen = $state(false); // Toggle between mock and real data for testing (DEBUG MODE) // Can be controlled via VITE_USE_MOCK_COMMENTS and VITE_USE_MOCK_HIGHLIGHTS environment variables @@ -73,13 +86,6 @@ import.meta.env.VITE_USE_MOCK_HIGHLIGHTS === "true", ); - // Log initial state for debugging - console.log("[Publication] Mock data initialized:", { - envVars: { - VITE_USE_MOCK_COMMENTS: import.meta.env.VITE_USE_MOCK_COMMENTS, - VITE_USE_MOCK_HIGHLIGHTS: import.meta.env.VITE_USE_MOCK_HIGHLIGHTS, - }, - }); // Derive all event IDs and addresses for highlight fetching let allEventIds = $derived.by(() => { @@ -114,111 +120,528 @@ let leaves = $state>([]); let isLoading = $state(false); let isDone = $state(false); - let lastElementRef = $state(null); + let sentinelRef = $state(null); + let topSentinelRef = $state(null); let activeAddress = $state(null); let loadedAddresses = $state>(new Set()); let hasInitialized = $state(false); let highlightModeActive = $state(false); let publicationDeleted = $state(false); + let sidebarTop = $state(162); // Default to 162px (100px navbar + 62px ArticleNav) + + // AI-NOTE: Batch loading configuration + const INITIAL_LOAD_COUNT = 30; + const AUTO_LOAD_BATCH_SIZE = 25; + const JUMP_WINDOW_SIZE = 5; - let observer: IntersectionObserver; - + /** + * Loads more events from the publication tree. + * + * @param count Number of events to load in this batch + */ async function loadMore(count: number) { if (!publicationTree) { console.warn("[Publication] publicationTree is not available"); return; } - console.log( - `[Publication] Loading ${count} more events. Current leaves: ${leaves.length}, loaded addresses: ${loadedAddresses.size}`, - ); + if (isLoading || isDone) { + return; + } isLoading = true; try { + const newEvents: Array = []; + let consecutiveNulls = 0; + const MAX_CONSECUTIVE_NULLS = 10; // Break if we get too many nulls in a row + const LOAD_TIMEOUT = 30000; // 30 second timeout per load operation + + // Create a timeout promise to prevent hanging + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(`Load timeout after ${LOAD_TIMEOUT}ms`)); + }, LOAD_TIMEOUT); + }); + + // AI-NOTE: Load events incrementally so users see content immediately instead of staring at empty page + // Load events sequentially to maintain order, displaying them as they're loaded for (let i = 0; i < count; i++) { - const iterResult = await publicationTree.next(); - const { done, value } = iterResult; - - if (done) { - console.log("[Publication] Iterator done, no more events"); - isDone = true; - break; - } + try { + const iterResult = await Promise.race([ + publicationTree.next(), + timeoutPromise, + ]); + + const { done, value } = iterResult; + + if (done) { + isDone = true; + break; + } - if (value) { - const address = value.tagAddress(); - console.log(`[Publication] Got event: ${address} (${value.id})`); - if (!loadedAddresses.has(address)) { - loadedAddresses.add(address); - leaves.push(value); - console.log(`[Publication] Added event: ${address}`); + if (value) { + consecutiveNulls = 0; // Reset null counter + const address = value.tagAddress(); + // Check both loadedAddresses and leaves to prevent duplicates + const alreadyInLeaves = leaves.some(leaf => leaf?.tagAddress() === address); + if (!loadedAddresses.has(address) && !alreadyInLeaves) { + loadedAddresses.add(address); + // AI-NOTE: Add event immediately to leaves so user sees it right away + leaves = [...leaves, value]; + newEvents.push(value); + } else { + newEvents.push(null); + } } else { - console.warn(`[Publication] Duplicate event detected: ${address}`); + consecutiveNulls++; + + // Break early if we're getting too many nulls - likely no more content + if (consecutiveNulls >= MAX_CONSECUTIVE_NULLS) { + isDone = true; + break; + } + + newEvents.push(null); + } + } catch (error) { + console.error(`[Publication] Error getting next event (iteration ${i + 1}/${count}):`, error); + // Continue to next iteration instead of breaking entirely + newEvents.push(null); + consecutiveNulls++; + + if (consecutiveNulls >= MAX_CONSECUTIVE_NULLS) { + break; } - } else { - console.log("[Publication] Got null event"); - leaves.push(null); + } + } + + // Check if we got valid events + const validEvents = newEvents.filter(e => e !== null); + if (validEvents.length === 0 && newEvents.length > 0) { + // We got through the loop but no valid events - might be done + if (consecutiveNulls >= MAX_CONSECUTIVE_NULLS) { + isDone = true; } } } catch (error) { console.error("[Publication] Error loading more content:", error); + // Don't mark as done on error - might be transient network issue } finally { isLoading = false; - console.log( - `[Publication] Finished loading. Total leaves: ${leaves.length}, loaded addresses: ${loadedAddresses.size}`, - ); + + // AI-NOTE: The ResizeObserver effect will handle checking sentinel position + // after content actually renders, so we don't need aggressive post-load checks here } } - function setLastElementRef(el: HTMLElement, i: number) { - if (i === leaves.length - 1) { - lastElementRef = el; + /** + * Gets all section addresses (leaf entries only) from TOC in depth-first order. + */ + function getAllSectionAddresses(): string[] { + const addresses: string[] = []; + for (const entry of toc) { + // Only include leaf entries (sections), not chapters + if (toc.leaves.has(entry.address)) { + addresses.push(entry.address); + } } + return addresses; } - $effect(() => { - if (!lastElementRef) { + /** + * Inserts events into leaves array in TOC order, ensuring no duplicates. + * Returns the updated leaves array. + */ + function insertEventsInOrder( + eventsToInsert: Array, + allAddresses: string[] + ): Array { + const existingAddresses = new Set(leaves.map(leaf => leaf?.tagAddress()).filter(Boolean)); + const newLeaves = [...leaves]; + + // Filter out nulls and duplicates + const validEvents = eventsToInsert.filter(event => { + if (!event) { + return false; + } + const address = event.tagAddress(); + return address && !existingAddresses.has(address); + }); + + // Sort events by their TOC index + const sortedEvents = validEvents.sort((a, b) => { + const indexA = allAddresses.indexOf(a!.tagAddress()); + const indexB = allAddresses.indexOf(b!.tagAddress()); + return indexA - indexB; + }); + + // Insert each event at the correct position + for (const event of sortedEvents) { + const address = event!.tagAddress(); + const index = allAddresses.indexOf(address); + + // Find insertion point + let insertIndex = newLeaves.length; + for (let i = 0; i < newLeaves.length; i++) { + const leafAddress = newLeaves[i]?.tagAddress(); + if (leafAddress) { + const leafIndex = allAddresses.indexOf(leafAddress); + if (leafIndex > index) { + insertIndex = i; + break; + } + } + } + + // Only insert if not already present + if (!newLeaves.some(leaf => leaf?.tagAddress() === address)) { + newLeaves.splice(insertIndex, 0, event); + existingAddresses.add(address); + } + } + + return newLeaves; + } + + /** + * Loads sections before a given address in the TOC order. + */ + async function loadSectionsBefore(referenceAddress: string, count: number = AUTO_LOAD_BATCH_SIZE) { + if (!publicationTree || !toc) { return; } - if (isDone) { - observer?.unobserve(lastElementRef!); + const allAddresses = getAllSectionAddresses(); + const referenceIndex = allAddresses.indexOf(referenceAddress); + + if (referenceIndex === -1 || referenceIndex === 0) { + return; // Not found or already at beginning + } + + const startIndex = Math.max(0, referenceIndex - count); + const addressesToLoad = allAddresses.slice(startIndex, referenceIndex).reverse(); + + // Filter out already loaded + const existingAddresses = new Set(leaves.map(leaf => leaf?.tagAddress()).filter(Boolean)); + const addressesToLoadFiltered = addressesToLoad.filter(addr => + !loadedAddresses.has(addr) && !existingAddresses.has(addr) + ); + + if (addressesToLoadFiltered.length === 0) { return; } - observer?.observe(lastElementRef!); - return () => observer?.unobserve(lastElementRef!); - }); + isLoading = true; + const newEvents: Array = []; + + for (const address of addressesToLoadFiltered) { + try { + const event = await publicationTree.getEvent(address); + if (event) { + newEvents.push(event); + loadedAddresses.add(address); + } else { + newEvents.push(null); + } + } catch (error) { + console.error(`[Publication] Error loading section ${address}:`, error); + newEvents.push(null); + } + } + + if (newEvents.length > 0) { + leaves = insertEventsInOrder(newEvents, allAddresses); + } + + isLoading = false; + } + + /** + * Loads sections after a given address in the TOC order. + */ + async function loadSectionsAfter(referenceAddress: string, count: number = AUTO_LOAD_BATCH_SIZE) { + if (!publicationTree || !toc || isLoading) { + return; + } + + const allAddresses = getAllSectionAddresses(); + const referenceIndex = allAddresses.indexOf(referenceAddress); + + if (referenceIndex === -1) { + return; + } + + const endIndex = Math.min(allAddresses.length - 1, referenceIndex + count); + const addressesToLoad = allAddresses.slice(referenceIndex + 1, endIndex + 1); + + // Filter out already loaded + const existingAddresses = new Set(leaves.map(leaf => leaf?.tagAddress()).filter(Boolean)); + const addressesToLoadFiltered = addressesToLoad.filter(addr => + !loadedAddresses.has(addr) && !existingAddresses.has(addr) + ); + + if (addressesToLoadFiltered.length === 0) { + return; + } + + isLoading = true; + const newEvents: Array = []; + + for (const address of addressesToLoadFiltered) { + try { + const event = await publicationTree.getEvent(address); + if (event) { + newEvents.push(event); + loadedAddresses.add(address); + } else { + newEvents.push(null); + } + } catch (error) { + console.error(`[Publication] Error loading section ${address}:`, error); + newEvents.push(null); + } + } + + if (newEvents.length > 0) { + leaves = insertEventsInOrder(newEvents, allAddresses); + } + + isLoading = false; + } + + /** + * Jumps to a specific section and loads a window of sections around it. + * This allows users to jump forward to sections that haven't been rendered yet. + * Also fills in any gaps between initially loaded sections and the jump window. + * + * @param targetAddress The address of the section to jump to + * @param windowSize Number of sections to load before and after the target (default: JUMP_WINDOW_SIZE) + */ + async function jumpToSection(targetAddress: string, windowSize: number = JUMP_WINDOW_SIZE) { + if (!publicationTree || !toc) { + return; + } + + // Check if target is already loaded + const alreadyLoaded = leaves.some(leaf => leaf?.tagAddress() === targetAddress); + if (alreadyLoaded) { + // Scroll to the section + const element = document.getElementById(targetAddress); + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + return; + } + + const allAddresses = getAllSectionAddresses(); + const targetIndex = allAddresses.indexOf(targetAddress); + + if (targetIndex === -1) { + return; + } + + // Find the last loaded section index + const existingAddresses = new Set(leaves.map(leaf => leaf?.tagAddress()).filter(Boolean)); + let lastLoadedIndex = -1; + for (let i = 0; i < allAddresses.length; i++) { + if (existingAddresses.has(allAddresses[i])) { + lastLoadedIndex = i; + } + } + + // Calculate jump window + const jumpStartIndex = Math.max(0, targetIndex - windowSize); + const jumpEndIndex = Math.min(allAddresses.length - 1, targetIndex + windowSize); + + // Determine if we need to fill a gap between last loaded and jump window + let gapStartIndex = -1; + let gapEndIndex = -1; + + if (lastLoadedIndex >= 0 && jumpStartIndex > lastLoadedIndex + 1) { + // There's a gap - fill it + gapStartIndex = lastLoadedIndex + 1; + gapEndIndex = jumpStartIndex - 1; + } + + // Collect all addresses to load (gap + jump window) + const addressesToLoad: string[] = []; + + // Add gap addresses if needed + if (gapStartIndex >= 0 && gapEndIndex >= gapStartIndex) { + for (let i = gapStartIndex; i <= gapEndIndex; i++) { + const addr = allAddresses[i]; + if (!loadedAddresses.has(addr) && !existingAddresses.has(addr)) { + addressesToLoad.push(addr); + } + } + } + + // Add jump window addresses + for (let i = jumpStartIndex; i <= jumpEndIndex; i++) { + const addr = allAddresses[i]; + if (!loadedAddresses.has(addr) && !existingAddresses.has(addr)) { + addressesToLoad.push(addr); + } + } + + // Load events + const windowEvents: Array<{ address: string; event: NDKEvent | null; index: number }> = []; + for (const address of addressesToLoad) { + try { + const event = await publicationTree.getEvent(address); + if (event) { + windowEvents.push({ address, event, index: allAddresses.indexOf(address) }); + loadedAddresses.add(address); + } + } catch (error) { + console.error(`[Publication] Error loading section ${address}:`, error); + } + } + + // Insert events in TOC order, ensuring no duplicates + const eventsToInsert: Array = windowEvents.map(({ event }) => event); + leaves = insertEventsInOrder(eventsToInsert, allAddresses); + + // Set bookmark to target address for future sequential loading + publicationTree.setBookmark(targetAddress); + + // Scroll to target section after a short delay to allow rendering + setTimeout(() => { + const element = document.getElementById(targetAddress); + if (element) { + element.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + // Update observer after DOM updates + updateFirstSectionObserver(); + }, 100); + } + + /** + * Background-loads all events in the publication tree in breadth-first order (level by level). + * This ensures the TOC is fully populated with all sections. + * + * Loads: root -> level 1 children -> level 2 children -> etc. + * Also resolves children for each entry to establish parent relationships in TOC. + * + * AI-NOTE: Throttled to avoid blocking main publication loading. Processes in small batches + * with delays to prevent overwhelming relays. + */ + async function backgroundLoadAllEvents() { + if (!publicationTree || !toc) { + return; + } + + // Throttling configuration + const BATCH_SIZE = 10; // Process 3 addresses at a time + const BATCH_DELAY_MS = 200; // 200ms delay between batches + const LEVEL_DELAY_MS = 500; // 500ms delay between levels + + // Track which addresses we've processed to avoid duplicates + const processedAddresses = new Set(); + + // Start with root address + const queue: string[] = [rootAddress]; + processedAddresses.add(rootAddress); + + // Process level by level (breadth-first) + while (queue.length > 0) { + const currentLevelAddresses = [...queue]; + queue.length = 0; // Clear queue for next level + + // Process addresses in small batches to avoid overwhelming relays + for (let i = 0; i < currentLevelAddresses.length; i += BATCH_SIZE) { + const batch = currentLevelAddresses.slice(i, i + BATCH_SIZE); + + // Process batch in parallel + const batchPromises = batch.map(async (address) => { + try { + // Get child addresses for this node - this triggers node resolution + const childAddresses = await publicationTree.getChildAddresses(address); + + // Resolve children for this entry to establish parent relationships in TOC + const entry = toc.getEntry(address); + if (entry && !entry.childrenResolved) { + await entry.resolveChildren(); + } + + // Add valid children to queue for next level + for (const childAddress of childAddresses) { + if (childAddress && !processedAddresses.has(childAddress)) { + processedAddresses.add(childAddress); + queue.push(childAddress); + + // Resolve the child event to populate TOC (non-blocking) + publicationTree.getEvent(childAddress).catch(() => { + // Silently handle errors in background loading + }); + } + } + } catch (error) { + console.error(`[Publication] Error loading children for ${address}:`, error); + } + }); + + // Wait for batch to complete + await Promise.all(batchPromises); + + // Small delay between batches to avoid blocking main loading + if (i + BATCH_SIZE < currentLevelAddresses.length) { + await new Promise(resolve => setTimeout(resolve, BATCH_DELAY_MS)); + } + } + + // Delay between levels to give main loading priority + if (queue.length > 0) { + await new Promise(resolve => setTimeout(resolve, LEVEL_DELAY_MS)); + } + } + } // #endregion - // AI-NOTE: Combined effect to handle publicationTree changes and initial loading + // AI-NOTE: Combined effect to handle publicationTree changes and initial loading // This prevents conflicts between separate effects that could cause duplicate loading + let publicationTreeInstance = $state(null); + $effect(() => { - if (publicationTree) { - // Reset state when publicationTree changes - leaves = []; - isLoading = false; - isDone = false; - lastElementRef = null; - loadedAddresses = new Set(); - hasInitialized = false; - - // Reset the publication tree iterator to prevent duplicate events - if (typeof publicationTree.resetIterator === "function") { - publicationTree.resetIterator(); - } + if (!publicationTree) { + return; + } - // AI-NOTE: Use setTimeout to ensure iterator reset completes before loading - // This prevents race conditions where loadMore is called before the iterator is fully reset - setTimeout(() => { - // Load initial content after reset - console.log("[Publication] Loading initial content after reset"); - hasInitialized = true; - loadMore(12); - }, 0); + // Only reset if publicationTree actually changed (different instance) + if (publicationTree === publicationTreeInstance && hasInitialized) { + return; // Already initialized with this tree, don't reset } + + // Reset state when publicationTree changes + leaves = []; + isLoading = false; + isDone = false; + sentinelRef = null; + loadedAddresses = new Set(); + hasInitialized = false; + publicationTreeInstance = publicationTree; + + // Reset the publication tree iterator to prevent duplicate events + if (typeof publicationTree.resetIterator === "function") { + publicationTree.resetIterator(); + } + + // Load initial content after reset + hasInitialized = true; + loadMore(INITIAL_LOAD_COUNT); + + // Start background loading all events in level-layers for TOC + // This runs in the background and doesn't block the UI + // Wait a bit for toc to be initialized + setTimeout(() => { + if (toc && publicationTree) { + backgroundLoadAllEvents().catch(() => { + // Silently handle errors in background loading + }); + } + }, 100); }); // #region Columns visibility @@ -227,9 +650,6 @@ let currentBlogEvent: null | NDKEvent = $state(null); const isLeaf = $derived(indexEvent.kind === 30041); - const tocSidebarUi = uiHelpers(); - const closeTocSidebar = tocSidebarUi.close; - const isTocOpen = $state($publicationColumnVisibility.toc); function isInnerActive() { return currentBlog !== null && $publicationColumnVisibility.inner; @@ -243,12 +663,18 @@ publicationColumnVisibility.update((v) => ({ ...v, discussion: false })); } + function viewDetails() { + detailsModalOpen = true; + publicationActionsOpen = false; + } + function loadBlog(rootId: string) { - // depending on the size of the screen, also toggle blog list & discussion visibility + // depending on the size of the screen, also toggle discussion visibility publicationColumnVisibility.update((current) => { const updated = current; if (window.innerWidth < 1024) { - updated.blog = false; + // Don't set blog = false on mobile - we need it to show the article + // The blog list is already hidden via CSS (hidden md:flex) updated.discussion = false; } updated.inner = true; @@ -276,7 +702,6 @@ } function handleCommentPosted() { - console.log("[Publication] Comment posted, refreshing comment layer"); // Refresh the comment layer after a short delay to allow relay indexing setTimeout(() => { if (commentLayerRef) { @@ -329,8 +754,6 @@ await commentEvent.sign(); await commentEvent.publish(); - console.log("[Publication] Article comment published:", commentEvent.id); - articleCommentSuccess = true; articleCommentContent = ""; @@ -365,11 +788,7 @@ eventAddress: indexEvent.tagAddress(), eventKind: indexEvent.kind, reason: "User deleted publication", - onSuccess: (deletionEventId) => { - console.log( - "[Publication] Deletion event published:", - deletionEventId, - ); + onSuccess: () => { publicationDeleted = true; // Redirect after 2 seconds @@ -399,17 +818,17 @@ * @param address The address of the event that was mounted. */ function onPublicationSectionMounted(el: HTMLElement, address: string) { - // Update last element ref for the intersection observer. - setLastElementRef(el, leaves.length); + // AI-NOTE: Using sentinel element for intersection observer instead of tracking last element + // The sentinel is a dedicated element placed after all sections for better performance // Michael J - 08 July 2025 - NOTE: Updating the ToC from here somewhat breaks separation of // concerns, since the TableOfContents component is primarily responsible for working with the // ToC data structure. However, the Publication component has direct access to the needed DOM // element already, and I want to avoid complicated callbacks between the two components. // Update the ToC from the contents of the leaf section. + // AI-NOTE: TOC updates happen in parallel as sections mount, improving performance const entry = toc.getEntry(address); if (!entry) { - console.warn(`[Publication] No parent found for ${address}`); return; } toc.buildTocFromDocument(el, entry); @@ -423,6 +842,20 @@ }); onMount(() => { + // Measure the actual navbar and ArticleNav heights to position sidebars correctly + const navbar = document.getElementById("navi"); + const articleNav = document.querySelector("nav.navbar-leather"); + + if (navbar && articleNav) { + const navbarRect = navbar.getBoundingClientRect(); + const articleNavRect = articleNav.getBoundingClientRect(); + sidebarTop = articleNavRect.bottom; + } else if (navbar) { + // Fallback: if ArticleNav not found, use navbar height + estimated ArticleNav height + const navbarRect = navbar.getBoundingClientRect(); + sidebarTop = navbarRect.bottom + 62; // Estimated ArticleNav height + } + // Set current columns depending on the publication type const isBlog = publicationType === "blog"; publicationColumnVisibility.update((v) => ({ @@ -433,31 +866,6 @@ if (isLeaf || isBlog) { publicationColumnVisibility.update((v) => ({ ...v, toc: false })); } - - // Set up the intersection observer. - observer = new IntersectionObserver( - (entries) => { - entries.forEach((entry) => { - if ( - entry.isIntersecting && - !isLoading && - !isDone && - publicationTree - ) { - loadMore(1); - } - }); - }, - { threshold: 0.5 }, - ); - - // AI-NOTE: Removed duplicate loadMore call - // Initial content loading is handled by the $effect that watches publicationTree - // This prevents duplicate loading when both onMount and $effect trigger - - return () => { - observer.disconnect(); - }; }); // Setup highlight layer container reference @@ -467,12 +875,244 @@ } }); + // #region Infinite Scroll Observer State + // AI-NOTE: IntersectionObserver-based infinite scroll with debouncing + // Observes sentinels and first section element for upward scrolling + + const UPWARD_LOAD_DEBOUNCE_MS = 3000; + const OBSERVER_UPDATE_DELAY_MS = 800; + const DOM_STABILIZATION_DELAY_MS = 500; + + let lastUpwardLoadTime = 0; + let isUpdatingObserver = false; + let isLoadingUpward = $state(false); + let scrollObserver: IntersectionObserver | null = null; + let observedFirstSectionAddress: string | null = null; + let observerUpdateTimeout: number | null = null; + let ignoreNextFirstSectionIntersection = false; + + /** + * Handles upward loading with proper debouncing and observer management. + */ + async function handleUpwardLoad(referenceAddress: string, source: "top-sentinel" | "first-section") { + if (isLoadingUpward) { + return; + } + + const now = Date.now(); + if ((now - lastUpwardLoadTime) < UPWARD_LOAD_DEBOUNCE_MS) { + return; + } + + const firstSection = leaves.filter(l => l !== null)[0]; + if (!firstSection || firstSection.tagAddress() === rootAddress) { + return; + } + + const firstAddress = firstSection.tagAddress(); + if (referenceAddress !== firstAddress && source === "first-section") { + return; + } + + isLoadingUpward = true; + lastUpwardLoadTime = now; + + // Unobserve elements to prevent loop + if (observedFirstSectionAddress && scrollObserver) { + const firstElement = document.getElementById(observedFirstSectionAddress); + if (firstElement) { + scrollObserver.unobserve(firstElement); + } + } + if (scrollObserver && source === "top-sentinel" && topSentinelRef) { + scrollObserver.unobserve(topSentinelRef); + } + + try { + await loadSectionsBefore(firstAddress, AUTO_LOAD_BATCH_SIZE); + + // Wait for DOM stabilization before updating observer + setTimeout(() => { + if (source === "top-sentinel" && scrollObserver && topSentinelRef) { + scrollObserver.observe(topSentinelRef); + } + if (!isLoadingUpward) { + updateFirstSectionObserver(); + } + }, DOM_STABILIZATION_DELAY_MS); + } catch (error) { + console.error(`[Publication] Error in upward load from ${source}:`, error); + setTimeout(() => { + if (source === "top-sentinel" && scrollObserver && topSentinelRef) { + scrollObserver.observe(topSentinelRef); + } + if (!isLoadingUpward) { + updateFirstSectionObserver(); + } + }, DOM_STABILIZATION_DELAY_MS); + } finally { + isLoadingUpward = false; + } + } + + /** + * Updates the observer to watch the current first section element. + * Called explicitly after loading sections before to avoid reactive loops. + */ + function updateFirstSectionObserver() { + if (!scrollObserver || isLoading || isUpdatingObserver || isLoadingUpward) { + return; + } + + if (observerUpdateTimeout !== null) { + clearTimeout(observerUpdateTimeout); + observerUpdateTimeout = null; + } + + observerUpdateTimeout = window.setTimeout(() => { + if (!scrollObserver || isLoading || isUpdatingObserver || isLoadingUpward) { + return; + } + + const firstSection = leaves.filter(l => l !== null)[0]; + if (!firstSection) { + return; + } + + const firstAddress = firstSection.tagAddress(); + + if (firstAddress === rootAddress || firstAddress === observedFirstSectionAddress) { + return; + } + + isUpdatingObserver = true; + + // Unobserve previous first section + if (observedFirstSectionAddress && scrollObserver) { + const prevElement = document.getElementById(observedFirstSectionAddress); + if (prevElement) { + scrollObserver.unobserve(prevElement); + } + } + + // Observe new first section + const firstElement = document.getElementById(firstAddress); + if (firstElement && scrollObserver) { + scrollObserver.observe(firstElement); + observedFirstSectionAddress = firstAddress; + ignoreNextFirstSectionIntersection = true; + } + + isUpdatingObserver = false; + observerUpdateTimeout = null; + }, OBSERVER_UPDATE_DELAY_MS); + } + // #endregion + + $effect(() => { + if (!hasInitialized || !publicationTree || !toc) { + return; + } + + let setupTimeout: number | null = null; + + const setupObserver = () => { + if (scrollObserver) { + return; + } + + const bottomSentinel = document.getElementById("publication-sentinel"); + const topSentinel = document.getElementById("publication-top-sentinel"); + + if (!bottomSentinel && !topSentinel) { + return; + } + + scrollObserver = new IntersectionObserver( + (entries) => { + if (isLoading || isDone || isUpdatingObserver || isLoadingUpward) { + return; + } + + for (const entry of entries) { + if (!entry.isIntersecting) { + continue; + } + + const targetId = entry.target.id; + + if (targetId === "publication-sentinel") { + // Downward loading + const lastSection = leaves.filter(l => l !== null).slice(-1)[0]; + if (lastSection) { + loadSectionsAfter(lastSection.tagAddress(), AUTO_LOAD_BATCH_SIZE); + } else { + loadMore(AUTO_LOAD_BATCH_SIZE); + } + break; + } else if (targetId === "publication-top-sentinel") { + // Upward loading from top sentinel + handleUpwardLoad("", "top-sentinel"); + break; + } else { + // First section element intersection + if (ignoreNextFirstSectionIntersection) { + ignoreNextFirstSectionIntersection = false; + break; + } + + const firstSection = leaves.filter(l => l !== null)[0]; + if (firstSection && targetId === firstSection.tagAddress() && targetId !== rootAddress) { + handleUpwardLoad(targetId, "first-section"); + } + break; + } + } + }, + { + rootMargin: "1000px 0px 1000px 0px", + threshold: 0, + }, + ); + + if (bottomSentinel) { + scrollObserver.observe(bottomSentinel); + } + if (topSentinel) { + scrollObserver.observe(topSentinel); + } + + // Initial observer update after setup + setTimeout(() => { + updateFirstSectionObserver(); + }, 200); + }; + + setupTimeout = window.setTimeout(() => { + setupObserver(); + }, 100); + + return () => { + if (setupTimeout !== null) { + clearTimeout(setupTimeout); + } + if (observerUpdateTimeout !== null) { + clearTimeout(observerUpdateTimeout); + } + if (scrollObserver) { + scrollObserver.disconnect(); + scrollObserver = null; + observedFirstSectionAddress = null; + } + }; + }); + // #endregion
@@ -486,60 +1126,20 @@ // Refresh highlights after a short delay to allow relay indexing setTimeout(() => { if (highlightLayerRef) { - console.log("[Publication] Refreshing highlights after creation"); highlightLayerRef.refresh(); } }, 500); }} /> - +
- -
- {#if publicationType !== "blog" && !isLeaf} - {#if $publicationColumnVisibility.toc} - - - - - publicationTree.setBookmark(address)} - onLoadMore={() => { - if (!isLoading && !isDone && publicationTree) { - loadMore(4); - } - }} - /> - - - {/if} - {/if} -
-
+ +
{#if $publicationColumnVisibility.main}
@@ -547,12 +1147,150 @@
+ + +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + > +
(publicationActionsOpen = true)} + > + + + {#if publicationActionsOpen} + (publicationActionsOpen = false)} + > +
+
+
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • + {#if $userStore.signedIn && $userStore.pubkey === indexEvent.pubkey} +
  • + +
  • + {/if} +
+
+
+
+ {/if} +
+
{#if publicationDeleted} @@ -584,39 +1322,6 @@
- -
-
- - -
-
- - -
-
{#if showArticleCommentUI} @@ -665,6 +1370,22 @@
{/if} + + +
+ {#if isLoadingUpward && leaves.length > 0} +
+
+ Loading previous sections... +
+ {/if} +
+ {#each leaves as leaf, i} {#if leaf == null} @@ -674,6 +1395,8 @@ {:else} {@const address = leaf.tagAddress()} + {@const publicationTitle = getMatchingTags(indexEvent, "title")[0]?.[1]} + {@const isFirstSection = i === 0} onPublicationSectionMounted(el, address)} /> {/if} {/each} -
- {#if isLoading} - - {:else if !isDone} - - {:else} + + + + +
+ {#if isDone}

You've reached the end of the publication.

+ {:else if isLoading} +
+
+ Loading more... +
{/if}
{/if} - + {#if $publicationColumnVisibility.blog} - -
+
+
-
+
+
+
+ + {#each leaves as leaf, i} + {#if leaf} + + {/if} + {/each}
- - {#each leaves as leaf, i} - {#if leaf} - - {/if} - {/each} -
- {/if} - {#if isInnerActive()} - {#key currentBlog} - -
- -
- {/key} + + {#if isInnerActive()} + {#key currentBlog} +
+ {#if currentBlogEvent && currentBlog} + {@const address = currentBlog} + onPublicationSectionMounted(el, address)} + /> + {:else} +
+

Loading article...

+
+ {/if} +
+ {/key} + {/if} +
{/if}
+
+
-
+
+ {#if $publicationColumnVisibility.discussion} + - - {#if $publicationColumnVisibility.discussion} -
@@ -795,9 +1551,61 @@ {/if} +
+ + +{#if publicationType !== "blog" && !isLeaf} + +
{ + if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') { + closeToc(); + } + }} + >
+ + +
+
+
+ +
+
+ { + // Jump to section instead of just setting bookmark + jumpToSection(address); + }} + onLoadMore={() => { + if (!isLoading && !isDone && publicationTree) { + // AI-NOTE: TOC load more triggers auto-loading with standard batch size + loadMore(AUTO_LOAD_BATCH_SIZE); + } + }} + onClose={closeToc} + /> +
-
+{/if} + + +
+ +
diff --git a/src/lib/components/publications/PublicationHeader.svelte b/src/lib/components/publications/PublicationHeader.svelte index 1de1016..368a239 100644 --- a/src/lib/components/publications/PublicationHeader.svelte +++ b/src/lib/components/publications/PublicationHeader.svelte @@ -80,9 +80,9 @@ {#if title != null && href != null} - - -
+ + +
{#if image} void; allComments?: NDKEvent[]; commentsVisible?: boolean; + publicationTitle?: string; + isFirstSection?: boolean; } = $props(); const asciidoctor: Asciidoctor = getContext("asciidoctor"); @@ -59,10 +63,6 @@ leafEvent.then((e) => { if (e?.id) { leafEventId = e.id; - console.log( - `[PublicationSection] Set leafEventId for ${address}:`, - e.id, - ); } }); }); @@ -89,17 +89,32 @@ // AI-NOTE: Kind 30023 events contain Markdown content, not AsciiDoc // Use parseAdvancedmarkup for 30023 events, Asciidoctor for 30041/30818 events + let processed: string; if (event?.kind === 30023) { - return await parseAdvancedmarkup(content); + processed = await parseAdvancedmarkup(content); } else { // For 30041 and 30818 events, use Asciidoctor (AsciiDoc) const converted = asciidoctor.convert(content); - const processed = await postProcessAdvancedAsciidoctorHtml( + processed = await postProcessAdvancedAsciidoctorHtml( converted.toString(), ndk, ); - return processed; } + + // Remove redundant h1 title from first section if it matches publication title + if (isFirstSection && publicationTitle && typeof processed === 'string') { + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = processed; + const h1Elements = tempDiv.querySelectorAll('h1'); + h1Elements.forEach((h1) => { + if (h1.textContent?.trim() === publicationTitle.trim()) { + h1.remove(); + } + }); + processed = tempDiv.innerHTML; + } + + return processed; }); let previousLeafEvent: NDKEvent | null = $derived.by(() => { @@ -186,11 +201,7 @@ eventAddress: address, eventKind: event.kind, reason: "User deleted section", - onSuccess: (deletionEventId) => { - console.log( - "[PublicationSection] Deletion event published:", - deletionEventId, - ); + onSuccess: () => { // Refresh the page to reflect the deletion window.location.reload(); }, @@ -212,19 +223,11 @@ } ref(sectionRef); - - // Log data attributes for debugging - console.log(`[PublicationSection] Section mounted:`, { - address, - leafEventId, - dataAddress: sectionRef.dataset.eventAddress, - dataEventId: sectionRef.dataset.eventId, - }); }); -
+
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]} - -
- -
- {#await leafEvent then event} - {#if event} - - {/if} - {/await} -
+ +
{#each divergingBranches as [branch, depth]} {@render sectionHeading( getMatchingTags(branch, "title")[0]?.[1] ?? "", @@ -257,7 +248,21 @@ {/each} {#if leafTitle} {@const leafDepth = leafHierarchy.length - 1} - {@render sectionHeading(leafTitle, leafDepth)} +
+ +
+ {#await leafEvent then event} + {#if event} + + {/if} + {/await} +
+ {@render sectionHeading(leafTitle, leafDepth)} +
{/if} {@render contentParagraph( leafContent.toString(), @@ -267,7 +272,7 @@
-
+
- - {#await leafEvent then event} - {#if event} - - - {/if} - {/await}
void; onLoadMore?: () => void; + onClose?: () => void; }>(); let entries = $derived.by(() => { const newEntries = []; + const rootEntry = rootAddress === toc.getRootEntry()?.address + ? toc.getRootEntry() + : toc.getEntry(rootAddress); + + if (!rootEntry) { + return []; + } + + // Filter entries that are direct children of rootAddress at the correct depth for (const [_, entry] of toc.addressMap) { + // Must match the depth if (entry.depth !== depth) { continue; } - + + // Check if entry is a direct child of rootAddress + // Primary check: parent relationship (set when resolveChildren is called) + // Fallback: entry is in rootEntry's children array + // Final fallback: depth-based check for root's direct children only + const isDirectChild = + entry.parent?.address === rootAddress || + rootEntry.children.some((child: TocEntry) => child.address === entry.address) || + (entry.depth === rootEntry.depth + 1 && + rootAddress === toc.getRootEntry()?.address && + !entry.parent); // Only use depth check if parent not set (temporary state) + + if (!isDirectChild) { + continue; + } + newEntries.push(entry); } @@ -45,6 +71,36 @@ toc.expandedMap.set(address, expanded); entry.resolveChildren(); + + // AI-NOTE: When expanding a chapter, scroll it to the top of the TOC so its children are visible + if (expanded) { + // Use setTimeout to allow the expansion animation to start and DOM to update + setTimeout(() => { + // Find the scrollable container (the div with overflow-y-auto in the TOC drawer) + const scrollableContainer = document.querySelector('.overflow-y-auto'); + if (!scrollableContainer) { + return; + } + + // Find all buttons in the TOC that match the entry title + const buttons = scrollableContainer.querySelectorAll('button'); + for (const button of buttons) { + const buttonText = button.textContent?.trim(); + if (buttonText === entry.title) { + // Find the parent container of the dropdown (the SidebarDropdownWrapper) + const dropdownContainer = button.closest('[class*="w-full"]'); + if (dropdownContainer) { + // Scroll the chapter to the top of the TOC container + dropdownContainer.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + } + break; + } + } + }, 150); + } } function handleSectionClick(address: string) { @@ -59,11 +115,13 @@ onSectionFocused?.(address); + // Close the drawer after navigation + onClose?.(); + // Check if this is the last entry and trigger loading more events const currentEntries = entries; const lastEntry = currentEntries[currentEntries.length - 1]; if (lastEntry && lastEntry.address === address) { - console.debug('[TableOfContents] Last entry clicked, triggering load more'); onLoadMore?.(); } } @@ -73,6 +131,27 @@ return currentVisibleSection === address; } + // Calculate indentation based on depth + // Depth 2 = no indent (Beginning, root entry) + // Depth 3 = indent level 1 (30041 sections under 30040) + // Depth 4+ = more indentation + function getIndentClass(depth: number): string { + if (depth <= 2) { + return ""; + } + // Each level beyond 2 adds 1rem (16px) of padding + const indentLevel = depth - 2; + // Use standard Tailwind classes: pl-4 (1rem), pl-8 (2rem), pl-12 (3rem), etc. + const paddingMap: Record = { + 1: "pl-4", // 1rem + 2: "pl-8", // 2rem + 3: "pl-12", // 3rem + 4: "pl-16", // 4rem + 5: "pl-20", // 5rem + }; + return paddingMap[indentLevel] || `pl-[${indentLevel}rem]`; + } + // Set up intersection observer to track visible sections onMount(() => { observer = new IntersectionObserver( @@ -153,17 +232,62 @@ + + {#if depth === 2} + { + e.preventDefault(); + window.scrollTo({ + top: 0, + behavior: 'smooth', + }); + onClose?.(); + }} + > + + + {/if} + + {#if depth === 2} + {@const rootEntry = toc.getRootEntry()} + {#if rootEntry} + {@const isVisible = isEntryVisible(rootEntry.address)} + { + const element = document.getElementById(rootEntry.address); + if (element) { + element.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + } + onClose?.(); + }} + > + + + {/if} + {/if} {#each entries as entry, index} {@const address = entry.address} {@const expanded = toc.expandedMap.get(address) ?? false} {@const isLeaf = toc.leaves.has(address)} {@const isVisible = isEntryVisible(address)} + {@const indentClass = getIndentClass(entry.depth)} {#if isLeaf} handleSectionClick(address)} > @@ -172,10 +296,11 @@ {@const childDepth = depth + 1} expanded, (open) => setEntryExpanded(address, open)} > - + {/if} {/each} diff --git a/src/lib/components/util/ArticleNav.svelte b/src/lib/components/util/ArticleNav.svelte index 3b2f9de..d6eff39 100644 --- a/src/lib/components/util/ArticleNav.svelte +++ b/src/lib/components/util/ArticleNav.svelte @@ -36,11 +36,14 @@ let lastScrollY = $state(0); let isVisible = $state(true); + let navbarTop = $state(100); // Default to 100px // Function to toggle column visibility function toggleColumn(column: "toc" | "blog" | "inner" | "discussion") { + console.log("[ArticleNav] toggleColumn called with:", column); publicationColumnVisibility.update((current) => { const newValue = !current[column]; + console.log("[ArticleNav] Toggling", column, "from", current[column], "to", newValue); const updated = { ...current, [column]: newValue }; if (window.innerWidth < 1400 && column === "blog" && newValue) { @@ -93,6 +96,16 @@ }); } + function handleBlogTocClick() { + if ($publicationColumnVisibility.inner) { + // Viewing article: go back to TOC + backToBlog(); + } else if ($publicationColumnVisibility.blog) { + // Showing TOC: toggle it (though it should stay visible) + toggleColumn("blog"); + } + } + function handleScroll() { if (window.innerWidth < 768) { const currentScrollY = window.scrollY; @@ -139,6 +152,13 @@ let unsubscribe: () => void; onMount(() => { + // Measure the actual navbar height to position ArticleNav correctly + const navbar = document.getElementById("navi"); + if (navbar) { + const rect = navbar.getBoundingClientRect(); + navbarTop = rect.bottom; + } + window.addEventListener("scroll", handleScroll); unsubscribe = publicationColumnVisibility.subscribe(() => { isVisible = true; // show navbar when store changes @@ -152,40 +172,38 @@