Browse Source

Merges pull request #81

Issue#312 major qc revamp -- Part 1

I'm merging this because it at least looks neater and the infinite scroll makes it more usable.
master
silberengel 3 months ago
parent
commit
5ad97c11f6
No known key found for this signature in database
GPG Key ID: 962BEC8725790894
  1. 188
      src/app.css
  2. 8
      src/app.html
  3. 12
      src/lib/a/nav/ANavbar.svelte
  4. 3
      src/lib/a/primitives/ADetails.svelte
  5. 11
      src/lib/a/primitives/ANostrUser.svelte
  6. 48
      src/lib/components/Navigation.svelte
  7. 137
      src/lib/components/cards/BlogHeader.svelte
  8. 14
      src/lib/components/publications/CommentButton.svelte
  9. 109
      src/lib/components/publications/CommentLayer.svelte
  10. 255
      src/lib/components/publications/HighlightLayer.svelte
  11. 26
      src/lib/components/publications/HighlightSelectionHandler.svelte
  12. 1298
      src/lib/components/publications/Publication.svelte
  13. 6
      src/lib/components/publications/PublicationHeader.svelte
  14. 90
      src/lib/components/publications/PublicationSection.svelte
  15. 135
      src/lib/components/publications/TableOfContents.svelte
  16. 62
      src/lib/components/util/ArticleNav.svelte
  17. 18
      src/lib/components/util/CardActions.svelte
  18. 14
      src/lib/components/util/Details.svelte
  19. 36
      src/lib/data_structures/websocket_pool.ts
  20. 9
      src/lib/services/publisher.ts
  21. 2
      src/lib/snippets/PublicationSnippets.svelte
  22. 13
      src/lib/stores/themeStore.ts
  23. 11
      src/routes/+layout.svelte
  24. 2
      src/routes/publication/[type]/[identifier]/+page.svelte
  25. 28
      src/styles/a/cards.css
  26. 2
      src/styles/base.css
  27. 12
      src/styles/notifications.css
  28. 2
      src/styles/publications.css
  29. 20
      src/styles/visualize.css
  30. 11
      src/theme-tokens.css

188
src/app.css

@ -202,7 +202,7 @@
@apply text-base font-semibold; @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, h1 a,
h2 a, h2 a,
h3 a, h3 a,
@ -216,7 +216,7 @@
h5.h-leather a, h5.h-leather a,
h6.h-leather a { h6.h-leather a {
@apply text-gray-900 dark:text-gray-100 hover:text-primary-600 @apply text-gray-900 dark:text-gray-100 hover:text-primary-600
dark:hover:text-primary-400; dark:hover:text-primary-300;
} }
/* === LEATHER COMPONENTS === */ /* === LEATHER COMPONENTS === */
@ -225,6 +225,39 @@
@apply text-gray-900 dark:text-gray-100; @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 { .btn-leather.text-xs {
@apply px-2 py-1; @apply px-2 py-1;
} }
@ -242,8 +275,8 @@
} }
div[role="tooltip"] button.btn-leather { div[role="tooltip"] button.btn-leather {
@apply hover:text-primary-600 dark:hover:text-primary-400 @apply hover:text-primary-700 dark:hover:text-primary-300
hover:border-primary-600 dark:hover:border-primary-400 hover:bg-gray-200 hover:border-primary-700 dark:hover:border-primary-300 hover:bg-gray-200
dark:hover:bg-gray-700; dark:hover:bg-gray-700;
} }
@ -301,7 +334,7 @@
div.modal-leather > div > h4 a, div.modal-leather > div > h4 a,
div.modal-leather > div > h5 a, div.modal-leather > div > h5 a,
div.modal-leather > div > h6 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 */ /* Navbar */
@ -314,17 +347,70 @@
} }
nav.navbar-leather svg { nav.navbar-leather svg {
@apply fill-gray-900 hover:fill-primary-600 dark:fill-gray-100 @apply fill-gray-900 hover:fill-primary-700 dark:fill-gray-100
dark:hover:fill-primary-400; dark:hover:fill-primary-300;
} }
/* NavBrand hover - all text highlights together */ /* NavBrand hover - all text highlights together */
#navi a:hover h1, #navi a:hover h1,
#navi a:hover p { #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; 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 h1,
nav.navbar-leather h2, nav.navbar-leather h2,
nav.navbar-leather h3, nav.navbar-leather h3,
@ -340,7 +426,7 @@
nav.navbar-leather h4 a, nav.navbar-leather h4 a,
nav.navbar-leather h5 a, nav.navbar-leather h5 a,
nav.navbar-leather h6 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 { div.textarea-leather {
@ -430,23 +516,23 @@
/* Lists */ /* Lists */
.ol-leather li a, .ol-leather li a,
.ul-leather li a { .ul-leather li a {
@apply text-gray-900 dark:text-gray-100 hover:text-primary-600 @apply text-gray-900 dark:text-gray-100 hover:text-primary-700
dark:hover:text-primary-400; dark:hover:text-primary-300;
} }
/* Links - consistent hover colors */ /* Links - consistent hover colors - improved contrast */
.link { .link {
@apply underline cursor-pointer hover:text-primary-600 @apply underline cursor-pointer hover:text-primary-700
dark:hover:text-primary-400; dark:hover:text-primary-300;
} }
.npub-badge { .npub-badge {
@apply inline-flex space-x-1 items-center text-primary-600 @apply inline-flex space-x-1 items-center text-primary-700
dark:text-primary-500 hover:underline me-2 px-2 py-0.5 rounded-sm border dark:text-primary-300 hover:underline me-2 px-2 py-0.5 rounded-sm border
border-primary-600 dark:border-primary-500; border-primary-700 dark:border-primary-300;
svg { 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 { @layer components {
nav a { nav a {
text-decoration-line: none !important; text-decoration-line: none !important;
@ -464,6 +563,23 @@
@apply block mx-auto my-4; @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 */ /* Legend */
.leather-legend { .leather-legend {
@apply relative m-4 sm:m-0 sm:absolute sm:top-1 sm:left-1 flex-shrink-0 p-2 @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 { .leather-legend button {
@apply dark:text-white; @apply text-gray-900 dark:text-gray-100;
} }
.publication-leather { .publication-leather {
@ -527,10 +643,10 @@
} }
} }
/* All links - consistent hover behavior */ /* All links - consistent hover behavior - improved contrast */
a { a {
@apply underline cursor-pointer hover:text-primary-600 @apply underline cursor-pointer hover:text-primary-700
dark:hover:text-primary-400; dark:hover:text-primary-300;
} }
.imageblock { .imageblock {
@ -567,10 +683,14 @@
} }
} }
/* Footnotes */ /* Footnotes - improved contrast */
.footnote-ref { .footnote-ref {
text-decoration: none; text-decoration: none;
color: var(--color-primary-500); color: var(--color-primary-700);
}
.dark .footnote-ref {
color: var(--color-primary-300);
} }
.footnotes { .footnotes {
@ -600,12 +720,21 @@
.footnote-backref { .footnote-backref {
text-decoration: none; text-decoration: none;
margin-left: 0.5rem; 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-ref,
.note-leather .footnote-backref { .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 */ /* Scrollable content */
@ -678,10 +807,11 @@
@apply focus:border-primary-600 dark:focus:border-primary-400; @apply focus:border-primary-600 dark:focus:border-primary-400;
} }
/* Table of Contents highlighting */ /* Table of Contents highlighting - improved contrast */
.toc-highlight { .toc-highlight {
@apply bg-primary-300 dark:bg-primary-700 border-s-4 border-primary-600 @apply bg-primary-300 dark:bg-primary-700 border-s-4 border-primary-700
rounded dark:border-primary-400 font-medium; rounded dark:border-primary-300 font-medium text-gray-900
dark:text-gray-100;
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
} }

8
src/app.html

@ -8,8 +8,12 @@
<!-- Apply saved theme ASAP to avoid flash --> <!-- Apply saved theme ASAP to avoid flash -->
<script> <script>
try { try {
const t = localStorage.getItem("theme"); const t = localStorage.getItem("alexandria/theme") || "light";
if (t) document.documentElement.dataset.theme = t; document.documentElement.dataset.theme = t;
// Add .dark class for non-light themes
if (t !== "light") {
document.documentElement.classList.add("dark");
}
} catch (_) { } catch (_) {
/* no-op */ /* no-op */
} }

12
src/lib/a/nav/ANavbar.svelte

@ -121,11 +121,10 @@
</div> </div>
</NavBrand> </NavBrand>
<div class="flex md:order-2"> <div class="flex md:order-2">
<Profile />
<NavHamburger /> <NavHamburger />
</div> </div>
<NavUl class="order-1 ml-auto items-center" classes={{ ul: "items-center" }}> <NavUl class="order-1 ml-auto items-center" classes={{ ul: "items-center" }}>
<NavLi class="cursor-pointer"> <NavLi class="navbar-menu-item">
Explore<ChevronDownOutline Explore<ChevronDownOutline
class="text-primary-800 ms-2 inline h-6 w-6 dark:text-white" class="text-primary-800 ms-2 inline h-6 w-6 dark:text-white"
/> />
@ -134,7 +133,7 @@
{#snippet children({ item })} {#snippet children({ item })}
<a <a
href={item.href} href={item.href}
class="block h-full rounded-lg p-3 hover:bg-gray-50 dark:hover:bg-gray-700" class="block h-full rounded-lg p-3 hover:bg-gray-50 dark:hover:bg-primary-800 transition-colors"
> >
<div class="font-semibold dark:text-white">{item.name}</div> <div class="font-semibold dark:text-white">{item.name}</div>
<span class="text-sm font-light text-gray-500 dark:text-gray-400" <span class="text-sm font-light text-gray-500 dark:text-gray-400"
@ -143,6 +142,11 @@
</a> </a>
{/snippet} {/snippet}
</MegaMenu> </MegaMenu>
<DarkMode /> <NavLi class="navbar-menu-item">
<DarkMode />
</NavLi>
<NavLi class="navbar-menu-item">
<Profile />
</NavLi>
</NavUl> </NavUl>
</Navbar> </Navbar>

3
src/lib/a/primitives/ADetails.svelte

@ -57,6 +57,7 @@
defaultOpen = false, defaultOpen = false,
forceHide = false, forceHide = false,
class: className = "", class: className = "",
children,
} = $props(); } = $props();
let open = $derived(defaultOpen); let open = $derived(defaultOpen);
$effect(() => { $effect(() => {
@ -98,6 +99,6 @@
{#if !(tech && !$showTech && forceHide)}<div {#if !(tech && !$showTech && forceHide)}<div
class="px-3 pb-3 pt-1 text-[0.95rem] leading-6" class="px-3 pb-3 pt-1 text-[0.95rem] leading-6"
> >
<slot /> {@render children()}
</div>{/if} </div>{/if}
</details> </details>

11
src/lib/a/primitives/ANostrUser.svelte

@ -96,6 +96,7 @@
badgeLimit = 6, badgeLimit = 6,
href = undefined as string | undefined, href = undefined as string | undefined,
class: className = "", class: className = "",
badges,
} = $props(); } = $props();
// Derived view-model // Derived view-model
@ -207,11 +208,11 @@
{#if showBadges} {#if showBadges}
<span class="mt-1 block"> <span class="mt-1 block">
<slot name="badges"> {#if badges}
{#if nativeBadges} {@render badges()}
<ANostrBadgeRow badges={nativeBadges} limit={badgeLimit} size="s" /> {:else if nativeBadges}
{/if} <ANostrBadgeRow badges={nativeBadges} limit={badgeLimit} size="s" />
</slot> {/if}
</span> </span>
{/if} {/if}
</span> </span>

48
src/lib/components/Navigation.svelte

@ -1,48 +0,0 @@
<script lang="ts">
import {
DarkMode,
Navbar,
NavLi,
NavUl,
NavHamburger,
NavBrand,
} from "flowbite-svelte";
import Profile from "./util/Profile.svelte";
import { userStore } from "$lib/stores/userStore";
let { class: className = "" } = $props();
let userState = $derived($userStore);
</script>
<Navbar class={`Navbar navbar-leather navbar-main ${className}`}>
<div class="flex flex-grow justify-between">
<NavBrand href="/">
<div class="flex flex-col">
<h1 class="text-2xl font-bold">Alexandria</h1>
<p class="text-xs font-semibold tracking-wide max-sm:max-w-[11rem]">
READ THE ORIGINAL. MAKE CONNECTIONS. CULTIVATE KNOWLEDGE.
</p>
</div>
</NavBrand>
</div>
<div class="flex md:order-2">
<Profile />
<NavHamburger class="btn-leather" />
</div>
<NavUl class="ul-leather">
<NavLi href="/">Publications</NavLi>
<NavLi href="/new/compose">Compose</NavLi>
<NavLi href="/visualize">Visualize</NavLi>
<NavLi href="/start">Getting Started</NavLi>
<NavLi href="/events">Events</NavLi>
{#if userState.signedIn}
<NavLi href="/my-notes">My Notes</NavLi>
{/if}
<NavLi href="/about">About</NavLi>
<NavLi href="/contact">Contact</NavLi>
<NavLi>
<DarkMode class="btn-leather p-0" />
</NavLi>
</NavUl>
</Navbar>

137
src/lib/components/cards/BlogHeader.svelte

@ -87,60 +87,103 @@
</script> </script>
{#if title != null} {#if title != null}
<Card {#if active}
class="ArticleBox card-leather w-full grid max-w-xl {active <!-- Full card view when active -->
? 'active' <div
: ''}" class="ArticleBox card-leather w-full grid active cursor-pointer min-w-0"
> role="button"
<div class="space-y-4 relative"> tabindex={0}
<div class="flex flex-row justify-between my-2"> onclick={(e: MouseEvent) => {
<div class="flex flex-col"> // Don't trigger if clicking on CardActions or its children
{@render userBadge(authorPubkey, author, ndk)} const target = e.target as HTMLElement;
<span class="text-gray-700 dark:text-gray-300">{publishedAt()}</span> 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();
}
}
}}
>
<Card class="w-full h-full min-w-0 !max-w-none">
<div class="space-y-4 relative pl-4 min-w-0 w-full">
<div class="flex flex-row justify-between my-2">
<div class="flex flex-col">
{@render userBadge(authorPubkey, author, ndk)}
<span class="text-gray-700 dark:text-gray-300">{publishedAt()}</span>
</div>
</div> </div>
</div>
<div <div
class="ArticleBoxImage flex justify-center items-center p-2 h-40 -mt-2" class="ArticleBoxImage flex justify-center items-center p-2 h-40 -mt-2"
in:scale={{ start: 0.8, duration: 500, delay: 100, easing: quintOut }} in:scale={{ start: 0.8, duration: 500, delay: 100, easing: quintOut }}
> >
{#if image} {#if image}
<LazyImage <LazyImage
src={image} src={image}
alt={title || "Publication image"} alt={title || "Publication image"}
eventId={event.id} eventId={event.id}
className="rounded w-full h-full object-cover" className="rounded w-full h-full object-cover"
/> />
{:else} {:else}
<div <div
class="rounded w-full h-full" class="rounded w-full h-full"
style="background-color: {generateDarkPastelColor(event.id)};" style="background-color: {generateDarkPastelColor(event.id)};"
> >
</div> </div>
{/if} {/if}
</div> </div>
<div class="flex flex-col space-y-4"> <div class="flex flex-col space-y-4">
<button onclick={() => showBlog()} class="text-left">
<h2 class="text-lg font-bold line-clamp-2" {title}>{title}</h2> <h2 class="text-lg font-bold line-clamp-2" {title}>{title}</h2>
</button> {#if hashtags}
{#if hashtags} <div class="tags">
<div class="tags"> {#each hashtags as tag}
{#each hashtags as tag} <span>#{tag}</span>
<span class="mr-2">#{tag}</span> {/each}
{/each} </div>
</div> {/if}
{/if} </div>
</div>
{#if active}
<Interactions {rootId} {event} /> <Interactions {rootId} {event} />
{/if}
<!-- Position CardActions at bottom-right --> <!-- Position CardActions at bottom-right -->
<div class="absolute bottom-2 right-2"> <div
<CardActions {event} onDelete={handleDelete} /> class="absolute bottom-2 right-2 card-actions"
role="button"
tabindex={0}
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<CardActions {event} onDelete={handleDelete} />
</div>
</div>
</Card>
</div>
{:else}
<!-- Simple list view when collapsed -->
<div
class="py-2 pl-4 border-b border-gray-200 dark:border-gray-700 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
role="button"
tabindex="0"
onclick={() => showBlog()}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
showBlog();
}
}}
>
<h3 class="text-base font-medium text-gray-900 dark:text-white">{title}</h3>
<div class="flex items-center gap-2 mt-1">
<p class="text-sm text-gray-500 dark:text-gray-400">{publishedAt()}</p>
<span class="text-sm text-gray-400 dark:text-gray-500"></span>
<p class="text-sm text-gray-500 dark:text-gray-400">{author}</p>
</div> </div>
</div> </div>
</Card> {/if}
{/if} {/if}

14
src/lib/components/publications/CommentButton.svelte

@ -129,12 +129,6 @@
commentEvent.tags.push(["e", eventId, relayHint]); 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; return commentEvent;
} }
@ -179,8 +173,6 @@
await commentEvent.sign($userStore.signer); await commentEvent.sign($userStore.signer);
} }
console.log("[CommentButton] Signed comment event:", commentEvent.rawEvent());
// Build relay list following the same pattern as eventServices // Build relay list following the same pattern as eventServices
const relays = [ const relays = [
...communityRelays, ...communityRelays,
@ -191,8 +183,6 @@
// Remove duplicates // Remove duplicates
const uniqueRelays = Array.from(new Set(relays)); const uniqueRelays = Array.from(new Set(relays));
console.log("[CommentButton] Publishing to relays:", uniqueRelays);
const signedEvent = { const signedEvent = {
...plainEvent, ...plainEvent,
id: commentEvent.id, id: commentEvent.id,
@ -217,11 +207,9 @@
clearTimeout(timeout); clearTimeout(timeout);
if (ok) { if (ok) {
publishedCount++; publishedCount++;
console.log(`[CommentButton] Published to ${relayUrl}`);
WebSocketPool.instance.release(ws); WebSocketPool.instance.release(ws);
resolve(); resolve();
} else { } else {
console.warn(`[CommentButton] ${relayUrl} rejected: ${message}`);
WebSocketPool.instance.release(ws); WebSocketPool.instance.release(ws);
reject(new Error(message)); reject(new Error(message));
} }
@ -240,8 +228,6 @@
throw new Error("Failed to publish to any relays"); throw new Error("Failed to publish to any relays");
} }
console.log(`[CommentButton] Published to ${publishedCount} relay(s)`);
// Success! // Success!
success = true; success = true;
commentContent = ""; commentContent = "";

109
src/lib/components/publications/CommentLayer.svelte

@ -36,7 +36,6 @@
async function fetchComments() { async function fetchComments() {
// Prevent concurrent fetches // Prevent concurrent fetches
if (loading) { if (loading) {
console.log("[CommentLayer] Already loading, skipping fetch");
return; return;
} }
@ -55,8 +54,6 @@
// AI-NOTE: Mock mode allows testing comment UI without publishing to relays // AI-NOTE: Mock mode allows testing comment UI without publishing to relays
// This is useful for development and demonstrating the comment system // This is useful for development and demonstrating the comment system
if (useMockComments) { if (useMockComments) {
console.log(`[CommentLayer] MOCK MODE - Generating mock comments for ${allAddresses.length} sections`);
try { try {
// Generate mock comment data // Generate mock comment data
const mockComments = generateMockCommentsForSections(allAddresses); const mockComments = generateMockCommentsForSections(allAddresses);
@ -64,7 +61,6 @@
// Convert to NDKEvent instances (same as real events) // Convert to NDKEvent instances (same as real events)
comments = mockComments.map(rawEvent => new NDKEventClass(ndk, rawEvent)); comments = mockComments.map(rawEvent => new NDKEventClass(ndk, rawEvent));
console.log(`[CommentLayer] Generated ${comments.length} mock comments`);
loading = false; loading = false;
return; return;
} catch (err) { } catch (err) {
@ -74,11 +70,6 @@
} }
} }
console.log(`[CommentLayer] Fetching comments for:`, {
eventIds: allEventIds,
addresses: allAddresses
});
try { try {
// Build filter for kind 1111 comment events // Build filter for kind 1111 comment events
// IMPORTANT: Use only #a tags because filters are AND, not OR // IMPORTANT: Use only #a tags because filters are AND, not OR
@ -96,8 +87,6 @@
filter["#e"] = allEventIds; filter["#e"] = allEventIds;
} }
console.log(`[CommentLayer] Fetching with filter:`, JSON.stringify(filter, null, 2));
// Build explicit relay set (same pattern as HighlightLayer) // Build explicit relay set (same pattern as HighlightLayer)
const relays = [ const relays = [
...communityRelays, ...communityRelays,
@ -105,7 +94,6 @@
...$activeInboxRelays, ...$activeInboxRelays,
]; ];
const uniqueRelays = Array.from(new Set(relays)); 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 * Use WebSocketPool with nostr-tools protocol instead of NDK
@ -124,31 +112,55 @@
*/ */
const subscriptionId = `comments-${Date.now()}`; const subscriptionId = `comments-${Date.now()}`;
const receivedEventIds = new Set<string>(); const receivedEventIds = new Set<string>();
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) => { const fetchPromises = uniqueRelays.map(async (relayUrl) => {
try { try {
console.log(`[CommentLayer] Connecting to ${relayUrl}`);
const ws = await WebSocketPool.instance.acquire(relayUrl); const ws = await WebSocketPool.instance.acquire(relayUrl);
return new Promise<void>((resolve) => { return new Promise<void>((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) => { const messageHandler = (event: MessageEvent) => {
try { try {
const message = JSON.parse(event.data); 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) { if (message[0] === "EVENT" && message[1] === subscriptionId) {
const rawEvent = message[2]; 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 // Avoid duplicates
if (!receivedEventIds.has(rawEvent.id)) { if (!receivedEventIds.has(rawEvent.id)) {
@ -157,19 +169,11 @@
// Convert to NDKEvent // Convert to NDKEvent
const ndkEvent = new NDKEventClass(ndk, rawEvent); const ndkEvent = new NDKEventClass(ndk, rawEvent);
comments = [...comments, ndkEvent]; comments = [...comments, ndkEvent];
console.log(`[CommentLayer] Added comment, total now: ${comments.length}`);
} }
} else if (message[0] === "EOSE" && message[1] === subscriptionId) { } else if (message[0] === "EOSE" && message[1] === subscriptionId) {
eoseCount++; // Close subscription and release connection
console.log(`[CommentLayer] EOSE from ${relayUrl} (${eoseCount}/${uniqueRelays.length})`); releaseConnection();
safeResolve();
// 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]);
} }
} catch (err) { } catch (err) {
console.error(`[CommentLayer] Error processing message from ${relayUrl}:`, err); console.error(`[CommentLayer] Error processing message from ${relayUrl}:`, err);
@ -180,41 +184,25 @@
// Send REQ // Send REQ
const req = ["REQ", subscriptionId, filter]; 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)); ws.send(JSON.stringify(req));
// Timeout per relay (5 seconds) // Timeout per relay (5 seconds)
setTimeout(() => { setTimeout(() => {
if (ws.readyState === WebSocket.OPEN) { releaseConnection();
ws.send(JSON.stringify(["CLOSE", subscriptionId])); safeResolve();
ws.removeEventListener("message", messageHandler);
WebSocketPool.instance.release(ws);
}
resolve();
}, 5000); }, 5000);
}); });
} catch (err) { } catch (err) {
console.error(`[CommentLayer] Error connecting to ${relayUrl}:`, 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 // Wait for all relays to respond or timeout
await Promise.all(fetchPromises); await Promise.allSettled(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)
})));
}
// Ensure loading is cleared even if checkAllResponses didn't fire
loading = false; loading = false;
} catch (err) { } catch (err) {
@ -232,8 +220,6 @@
const currentCount = eventIds.length + eventAddresses.length; const currentCount = eventIds.length + eventAddresses.length;
const hasEventData = currentCount > 0; const hasEventData = currentCount > 0;
console.log(`[CommentLayer] Event data effect - count: ${currentCount}, lastFetched: ${lastFetchedCount}, loading: ${loading}`);
// Only fetch if: // Only fetch if:
// 1. We have event data // 1. We have event data
// 2. The count has changed since last fetch // 2. The count has changed since last fetch
@ -246,7 +232,6 @@
// Debounce: wait 500ms for more events to arrive before fetching // Debounce: wait 500ms for more events to arrive before fetching
fetchTimeout = setTimeout(() => { fetchTimeout = setTimeout(() => {
console.log(`[CommentLayer] Event data stabilized at ${currentCount} events, fetching comments...`);
lastFetchedCount = currentCount; lastFetchedCount = currentCount;
fetchComments(); fetchComments();
}, 500); }, 500);
@ -264,8 +249,6 @@
* Public method to refresh comments (e.g., after creating a new one) * Public method to refresh comments (e.g., after creating a new one)
*/ */
export function refresh() { export function refresh() {
console.log("[CommentLayer] Manual refresh triggered");
// Clear existing comments // Clear existing comments
comments = []; comments = [];

255
src/lib/components/publications/HighlightLayer.svelte

@ -75,7 +75,6 @@
async function fetchHighlights() { async function fetchHighlights() {
// Prevent concurrent fetches // Prevent concurrent fetches
if (loading) { if (loading) {
console.log("[HighlightLayer] Already loading, skipping fetch");
return; return;
} }
@ -99,10 +98,6 @@
// AI-NOTE: Mock mode allows testing highlight UI without publishing to relays // AI-NOTE: Mock mode allows testing highlight UI without publishing to relays
// This is useful for development and demonstrating the highlight system // This is useful for development and demonstrating the highlight system
if (useMockHighlights) { if (useMockHighlights) {
console.log(
`[HighlightLayer] MOCK MODE - Generating mock highlights for ${allAddresses.length} sections`,
);
try { try {
// Generate mock highlight data // Generate mock highlight data
const mockHighlights = generateMockHighlightsForSections(allAddresses); const mockHighlights = generateMockHighlightsForSections(allAddresses);
@ -112,9 +107,6 @@
(rawEvent) => new NDKEventClass(ndk, rawEvent), (rawEvent) => new NDKEventClass(ndk, rawEvent),
); );
console.log(
`[HighlightLayer] Generated ${highlights.length} mock highlights`,
);
loading = false; loading = false;
return; return;
} catch (err) { } catch (err) {
@ -127,11 +119,6 @@
} }
} }
console.log(`[HighlightLayer] Fetching highlights for:`, {
eventIds: allEventIds,
addresses: allAddresses,
});
try { try {
// Build filter for kind 9802 highlight events // Build filter for kind 9802 highlight events
// IMPORTANT: Use only #a tags because filters are AND, not OR // IMPORTANT: Use only #a tags because filters are AND, not OR
@ -149,11 +136,6 @@
filter["#e"] = allEventIds; filter["#e"] = allEventIds;
} }
console.log(
`[HighlightLayer] Fetching with filter:`,
JSON.stringify(filter, null, 2),
);
// Build explicit relay set (same pattern as HighlightSelectionHandler and CommentButton) // Build explicit relay set (same pattern as HighlightSelectionHandler and CommentButton)
const relays = [ const relays = [
...communityRelays, ...communityRelays,
@ -161,10 +143,6 @@
...$activeInboxRelays, ...$activeInboxRelays,
]; ];
const uniqueRelays = Array.from(new Set(relays)); 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 * Use WebSocketPool with nostr-tools protocol instead of NDK
@ -186,30 +164,41 @@
const fetchPromises = uniqueRelays.map(async (relayUrl) => { const fetchPromises = uniqueRelays.map(async (relayUrl) => {
try { try {
console.log(`[HighlightLayer] Connecting to ${relayUrl}`);
const ws = await WebSocketPool.instance.acquire(relayUrl); const ws = await WebSocketPool.instance.acquire(relayUrl);
return new Promise<void>((resolve) => { return new Promise<void>((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) => { const messageHandler = (event: MessageEvent) => {
try { try {
const message = JSON.parse(event.data); 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) { if (message[0] === "EVENT" && message[1] === subscriptionId) {
const rawEvent = message[2]; 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 // Avoid duplicates
if (!receivedEventIds.has(rawEvent.id)) { if (!receivedEventIds.has(rawEvent.id)) {
@ -218,29 +207,16 @@
// Convert to NDKEvent // Convert to NDKEvent
const ndkEvent = new NDKEventClass(ndk, rawEvent); const ndkEvent = new NDKEventClass(ndk, rawEvent);
highlights = [...highlights, ndkEvent]; highlights = [...highlights, ndkEvent];
console.log(
`[HighlightLayer] Added highlight, total now: ${highlights.length}`,
);
} }
} else if ( } else if (
message[0] === "EOSE" && message[0] === "EOSE" &&
message[1] === subscriptionId message[1] === subscriptionId
) { ) {
eoseCount++; eoseCount++;
console.log(
`[HighlightLayer] EOSE from ${relayUrl} (${eoseCount}/${uniqueRelays.length})`,
);
// Close subscription // Close subscription and release connection
ws.send(JSON.stringify(["CLOSE", subscriptionId])); releaseConnection();
ws.removeEventListener("message", messageHandler); safeResolve();
WebSocketPool.instance.release(ws);
resolve();
} else if (message[0] === "NOTICE") {
console.warn(
`[HighlightLayer] NOTICE from ${relayUrl}:`,
message[1],
);
} }
} catch (err) { } catch (err) {
console.error( console.error(
@ -254,24 +230,12 @@
// Send REQ // Send REQ
const req = ["REQ", subscriptionId, filter]; 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)); ws.send(JSON.stringify(req));
// Timeout per relay (5 seconds) // Timeout per relay (5 seconds)
setTimeout(() => { setTimeout(() => {
if (ws.readyState === WebSocket.OPEN) { releaseConnection();
ws.send(JSON.stringify(["CLOSE", subscriptionId])); safeResolve();
ws.removeEventListener("message", messageHandler);
WebSocketPool.instance.release(ws);
}
resolve();
}, 5000); }, 5000);
}); });
} catch (err) { } catch (err) {
@ -285,19 +249,6 @@
// Wait for all relays to respond or timeout // Wait for all relays to respond or timeout
await Promise.all(fetchPromises); 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; loading = false;
// Rendering is handled by the visibility/highlights effect // Rendering is handled by the visibility/highlights effect
@ -321,9 +272,6 @@
targetAddress?: string, targetAddress?: string,
): boolean { ): boolean {
if (!containerRef) { if (!containerRef) {
console.log(
`[HighlightLayer] Cannot highlight by position - no containerRef`,
);
return false; return false;
} }
@ -333,30 +281,10 @@
const sectionElement = document.getElementById(targetAddress); const sectionElement = document.getElementById(targetAddress);
if (sectionElement) { if (sectionElement) {
searchRoot = 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( return highlightByOffset(searchRoot, offsetStart, offsetEnd, color);
`[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;
} }
/** /**
@ -371,9 +299,6 @@
targetAddress?: string, targetAddress?: string,
): void { ): void {
if (!containerRef || !text || text.trim().length === 0) { if (!containerRef || !text || text.trim().length === 0) {
console.log(
`[HighlightLayer] Cannot highlight - containerRef: ${!!containerRef}, text: "${text}"`,
);
return; return;
} }
@ -383,21 +308,9 @@
const sectionElement = document.getElementById(targetAddress); const sectionElement = document.getElementById(targetAddress);
if (sectionElement) { if (sectionElement) {
searchRoot = 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 // Use TreeWalker to find all text nodes
const walker = document.createTreeWalker( const walker = document.createTreeWalker(
searchRoot, searchRoot,
@ -412,22 +325,11 @@
} }
// Search for the highlight text in text nodes // Search for the highlight text in text nodes
console.log(
`[HighlightLayer] Searching through ${textNodes.length} text nodes`,
);
for (const textNode of textNodes) { for (const textNode of textNodes) {
const nodeText = textNode.textContent || ""; const nodeText = textNode.textContent || "";
const index = nodeText.toLowerCase().indexOf(text.toLowerCase()); const index = nodeText.toLowerCase().indexOf(text.toLowerCase());
if (index !== -1) { 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; const parent = textNode.parentNode;
if (!parent) continue; if (!parent) continue;
@ -459,44 +361,26 @@
parent.replaceChild(fragment, textNode); parent.replaceChild(fragment, textNode);
console.log(`[HighlightLayer] Highlighted text:`, match);
return; // Only highlight first occurrence to avoid multiple highlights return; // Only highlight first occurrence to avoid multiple highlights
} }
} }
console.log(`[HighlightLayer] No match found for text: "${text}"`);
} }
/** /**
* Render all highlights on the page * Render all highlights on the page
*/ */
function renderHighlights() { function renderHighlights() {
console.log(
`[HighlightLayer] renderHighlights called - visible: ${visible}, containerRef: ${!!containerRef}, highlights: ${highlights.length}`,
);
if (!visible || !containerRef) { if (!visible || !containerRef) {
console.log(
`[HighlightLayer] Skipping render - visible: ${visible}, containerRef: ${!!containerRef}`,
);
return; return;
} }
if (highlights.length === 0) { if (highlights.length === 0) {
console.log(`[HighlightLayer] No highlights to render`);
return; return;
} }
// Clear existing highlights // Clear existing highlights
clearHighlights(); 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 // Apply each highlight
for (const highlight of highlights) { for (const highlight of highlights) {
const content = highlight.content; const content = highlight.content;
@ -511,42 +395,19 @@
const hasOffset = const hasOffset =
offsetTag && offsetTag[1] !== undefined && offsetTag[2] !== undefined; 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) { if (hasOffset) {
// Use position-based highlighting // Use position-based highlighting
const offsetStart = parseInt(offsetTag[1], 10); const offsetStart = parseInt(offsetTag[1], 10);
const offsetEnd = parseInt(offsetTag[2], 10); const offsetEnd = parseInt(offsetTag[2], 10);
if (!isNaN(offsetStart) && !isNaN(offsetEnd)) { if (!isNaN(offsetStart) && !isNaN(offsetEnd)) {
console.log(
`[HighlightLayer] Using position-based highlighting: ${offsetStart}-${offsetEnd}`,
);
highlightByPosition(offsetStart, offsetEnd, color, targetAddress); highlightByPosition(offsetStart, offsetEnd, color, targetAddress);
} else { } else if (content && content.trim().length > 0) {
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) {
findAndHighlightText(content, color, targetAddress); 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(); parent.normalize();
} }
}); });
console.log(
`[HighlightLayer] Cleared ${highlightElements.length} highlights`,
);
} }
// Track the last fetched event count to know when to refetch // Track the last fetched event count to know when to refetch
@ -590,10 +447,6 @@
const currentCount = eventIds.length + eventAddresses.length; const currentCount = eventIds.length + eventAddresses.length;
const hasEventData = currentCount > 0; const hasEventData = currentCount > 0;
console.log(
`[HighlightLayer] Event data effect - count: ${currentCount}, lastFetched: ${lastFetchedCount}, loading: ${loading}`,
);
// Only fetch if: // Only fetch if:
// 1. We have event data // 1. We have event data
// 2. The count has changed since last fetch // 2. The count has changed since last fetch
@ -606,9 +459,6 @@
// Debounce: wait 500ms for more events to arrive before fetching // Debounce: wait 500ms for more events to arrive before fetching
fetchTimeout = setTimeout(() => { fetchTimeout = setTimeout(() => {
console.log(
`[HighlightLayer] Event data stabilized at ${currentCount} events, fetching highlights...`,
);
lastFetchedCount = currentCount; lastFetchedCount = currentCount;
fetchHighlights(); fetchHighlights();
}, 500); }, 500);
@ -626,14 +476,8 @@
$effect(() => { $effect(() => {
// This effect runs when either visible or highlights.length changes // This effect runs when either visible or highlights.length changes
const highlightCount = highlights.length; const highlightCount = highlights.length;
console.log(
`[HighlightLayer] Visibility/highlights effect - visible: ${visible}, highlights: ${highlightCount}`,
);
if (visible && highlightCount > 0) { if (visible && highlightCount > 0) {
console.log(
`[HighlightLayer] Both visible and highlights ready, rendering...`,
);
renderHighlights(); renderHighlights();
} else if (!visible) { } else if (!visible) {
clearHighlights(); clearHighlights();
@ -653,9 +497,6 @@
*/ */
async function fetchAuthorProfiles() { async function fetchAuthorProfiles() {
const uniquePubkeys = Array.from(groupedHighlights.keys()); const uniquePubkeys = Array.from(groupedHighlights.keys());
console.log(
`[HighlightLayer] Fetching profiles for ${uniquePubkeys.length} authors`,
);
for (const pubkey of uniquePubkeys) { for (const pubkey of uniquePubkeys) {
try { try {
@ -693,27 +534,17 @@
* Scroll to a specific highlight in the document * Scroll to a specific highlight in the document
*/ */
function scrollToHighlight(highlight: NDKEvent) { function scrollToHighlight(highlight: NDKEvent) {
console.log(
`[HighlightLayer] scrollToHighlight called for:`,
highlight.content.substring(0, 50),
);
if (!containerRef) { if (!containerRef) {
console.warn(`[HighlightLayer] No containerRef available`);
return; return;
} }
const content = highlight.content; const content = highlight.content;
if (!content || content.trim().length === 0) { if (!content || content.trim().length === 0) {
console.warn(`[HighlightLayer] No content in highlight`);
return; return;
} }
// Find the highlight mark element // Find the highlight mark element
const highlightMarks = containerRef.querySelectorAll("mark.highlight"); const highlightMarks = containerRef.querySelectorAll("mark.highlight");
console.log(
`[HighlightLayer] Found ${highlightMarks.length} highlight marks in DOM`,
);
// Try exact match first // Try exact match first
for (const mark of highlightMarks) { for (const mark of highlightMarks) {
@ -721,9 +552,6 @@
const searchText = content.toLowerCase(); const searchText = content.toLowerCase();
if (markText === searchText) { if (markText === searchText) {
console.log(
`[HighlightLayer] Found exact match, scrolling and flashing`,
);
// Scroll to this element // Scroll to this element
mark.scrollIntoView({ behavior: "smooth", block: "center" }); mark.scrollIntoView({ behavior: "smooth", block: "center" });
@ -742,9 +570,6 @@
const searchText = content.toLowerCase(); const searchText = content.toLowerCase();
if (markText.includes(searchText) || searchText.includes(markText)) { if (markText.includes(searchText) || searchText.includes(markText)) {
console.log(
`[HighlightLayer] Found partial match, scrolling and flashing`,
);
mark.scrollIntoView({ behavior: "smooth", block: "center" }); mark.scrollIntoView({ behavior: "smooth", block: "center" });
mark.classList.add("highlight-flash"); mark.classList.add("highlight-flash");
setTimeout(() => { setTimeout(() => {
@ -753,11 +578,6 @@
return; return;
} }
} }
console.warn(
`[HighlightLayer] Could not find highlight mark for:`,
content.substring(0, 50),
);
} }
/** /**
@ -770,7 +590,6 @@
try { try {
await navigator.clipboard.writeText(naddr); await navigator.clipboard.writeText(naddr);
copyFeedback = highlight.id; copyFeedback = highlight.id;
console.log(`[HighlightLayer] Copied naddr to clipboard:`, naddr);
// Clear feedback after 2 seconds // Clear feedback after 2 seconds
setTimeout(() => { setTimeout(() => {
@ -792,8 +611,6 @@
* Public method to refresh highlights (e.g., after creating a new one) * Public method to refresh highlights (e.g., after creating a new one)
*/ */
export function refresh() { export function refresh() {
console.log("[HighlightLayer] Manual refresh triggered");
// Clear existing highlights // Clear existing highlights
highlights = []; highlights = [];
clearHighlights(); clearHighlights();

26
src/lib/components/publications/HighlightSelectionHandler.svelte

@ -97,7 +97,6 @@
// Don't use closest('.publication-leather') as Details also has that class // Don't use closest('.publication-leather') as Details also has that class
const publicationSection = target.closest("section[id]") as HTMLElement; const publicationSection = target.closest("section[id]") as HTMLElement;
if (!publicationSection) { if (!publicationSection) {
console.log("[HighlightSelectionHandler] No section[id] found, aborting");
return; return;
} }
@ -105,14 +104,6 @@
const sectionAddress = publicationSection.dataset.eventAddress; const sectionAddress = publicationSection.dataset.eventAddress;
const sectionEventId = publicationSection.dataset.eventId; 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; currentSelection = selection;
selectedText = text; selectedText = text;
selectedSectionAddress = sectionAddress; selectedSectionAddress = sectionAddress;
@ -155,12 +146,6 @@
selectedSectionAddress || publicationEvent.tagAddress(); selectedSectionAddress || publicationEvent.tagAddress();
const useEventId = selectedSectionEventId || publicationEvent.id; const useEventId = selectedSectionEventId || publicationEvent.id;
console.log("[HighlightSelectionHandler] Creating highlight with:", {
address: useAddress,
eventId: useEventId,
fallbackUsed: !selectedSectionAddress,
});
const tags: string[][] = []; const tags: string[][] = [];
// Always prefer addressable events for publications // Always prefer addressable events for publications
@ -227,11 +212,6 @@
// Remove duplicates // Remove duplicates
const uniqueRelays = Array.from(new Set(relays)); const uniqueRelays = Array.from(new Set(relays));
console.log(
"[HighlightSelectionHandler] Publishing to relays:",
uniqueRelays,
);
const signedEvent = { const signedEvent = {
...plainEvent, ...plainEvent,
id: event.id, id: event.id,
@ -256,15 +236,9 @@
clearTimeout(timeout); clearTimeout(timeout);
if (ok) { if (ok) {
publishedCount++; publishedCount++;
console.log(
`[HighlightSelectionHandler] Published to ${relayUrl}`,
);
WebSocketPool.instance.release(ws); WebSocketPool.instance.release(ws);
resolve(); resolve();
} else { } else {
console.warn(
`[HighlightSelectionHandler] ${relayUrl} rejected: ${message}`,
);
WebSocketPool.instance.release(ws); WebSocketPool.instance.release(ws);
reject(new Error(message)); reject(new Error(message));
} }

1298
src/lib/components/publications/Publication.svelte

File diff suppressed because it is too large Load Diff

6
src/lib/components/publications/PublicationHeader.svelte

@ -80,9 +80,9 @@
</script> </script>
{#if title != null && href != null} {#if title != null && href != null}
<Card class="ArticleBox max-h-52 card-leather w-full relative flex flex-col sm:flex-row sm:space-x-2 overflow-hidden"> <Card class="ArticleBox max-h-52 card-leather w-full relative flex flex-row space-x-2 overflow-hidden">
<!-- Image block: full width on mobile, fixed side on md+ --> <!-- Image block: thumbnail on mobile, fixed side on sm+ -->
<div class="w-full sm:min-w-40 sm:w-40 overflow-hidden flex items-center justify-center sm:rounded-l rounded-t sm:rounded-t-none"> <div class="w-24 min-w-24 sm:min-w-40 sm:w-40 overflow-hidden flex items-center justify-center rounded-l">
{#if image} {#if image}
<LazyImage <LazyImage
src={image} src={image}

90
src/lib/components/publications/PublicationSection.svelte

@ -26,6 +26,8 @@
ref, ref,
allComments = [], allComments = [],
commentsVisible = true, commentsVisible = true,
publicationTitle,
isFirstSection = false,
}: { }: {
address: string; address: string;
rootAddress: string; rootAddress: string;
@ -35,6 +37,8 @@
ref: (ref: HTMLElement) => void; ref: (ref: HTMLElement) => void;
allComments?: NDKEvent[]; allComments?: NDKEvent[];
commentsVisible?: boolean; commentsVisible?: boolean;
publicationTitle?: string;
isFirstSection?: boolean;
} = $props(); } = $props();
const asciidoctor: Asciidoctor = getContext("asciidoctor"); const asciidoctor: Asciidoctor = getContext("asciidoctor");
@ -59,10 +63,6 @@
leafEvent.then((e) => { leafEvent.then((e) => {
if (e?.id) { if (e?.id) {
leafEventId = 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 // AI-NOTE: Kind 30023 events contain Markdown content, not AsciiDoc
// Use parseAdvancedmarkup for 30023 events, Asciidoctor for 30041/30818 events // Use parseAdvancedmarkup for 30023 events, Asciidoctor for 30041/30818 events
let processed: string;
if (event?.kind === 30023) { if (event?.kind === 30023) {
return await parseAdvancedmarkup(content); processed = await parseAdvancedmarkup(content);
} else { } else {
// For 30041 and 30818 events, use Asciidoctor (AsciiDoc) // For 30041 and 30818 events, use Asciidoctor (AsciiDoc)
const converted = asciidoctor.convert(content); const converted = asciidoctor.convert(content);
const processed = await postProcessAdvancedAsciidoctorHtml( processed = await postProcessAdvancedAsciidoctorHtml(
converted.toString(), converted.toString(),
ndk, 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(() => { let previousLeafEvent: NDKEvent | null = $derived.by(() => {
@ -186,11 +201,7 @@
eventAddress: address, eventAddress: address,
eventKind: event.kind, eventKind: event.kind,
reason: "User deleted section", reason: "User deleted section",
onSuccess: (deletionEventId) => { onSuccess: () => {
console.log(
"[PublicationSection] Deletion event published:",
deletionEventId,
);
// Refresh the page to reflect the deletion // Refresh the page to reflect the deletion
window.location.reload(); window.location.reload();
}, },
@ -212,19 +223,11 @@
} }
ref(sectionRef); ref(sectionRef);
// Log data attributes for debugging
console.log(`[PublicationSection] Section mounted:`, {
address,
leafEventId,
dataAddress: sectionRef.dataset.eventAddress,
dataEventId: sectionRef.dataset.eventId,
});
}); });
</script> </script>
<!-- Wrapper for positioning context --> <!-- Wrapper for positioning context -->
<div class="relative w-full"> <div class="relative w-full overflow-x-hidden">
<section <section
id={address} id={address}
bind:this={sectionRef} bind:this={sectionRef}
@ -235,20 +238,8 @@
{#await Promise.all( [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches], )} {#await Promise.all( [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches], )}
<TextPlaceholder size="2xl" /> <TextPlaceholder size="2xl" />
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]} {:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]}
<!-- Main content area - centered --> <!-- Main content area - left-aligned -->
<div class="section-content relative max-w-4xl mx-auto px-4"> <div class="section-content relative w-full text-left">
<!-- Mobile menu - shown only on smaller screens -->
<div class="xl:hidden absolute top-2 right-2 z-10">
{#await leafEvent then event}
{#if event}
<CardActions
{event}
sectionAddress={address}
onDelete={handleDelete}
/>
{/if}
{/await}
</div>
{#each divergingBranches as [branch, depth]} {#each divergingBranches as [branch, depth]}
{@render sectionHeading( {@render sectionHeading(
getMatchingTags(branch, "title")[0]?.[1] ?? "", getMatchingTags(branch, "title")[0]?.[1] ?? "",
@ -257,7 +248,21 @@
{/each} {/each}
{#if leafTitle} {#if leafTitle}
{@const leafDepth = leafHierarchy.length - 1} {@const leafDepth = leafHierarchy.length - 1}
{@render sectionHeading(leafTitle, leafDepth)} <div class="relative">
<!-- Section actions button - positioned next to heading -->
<div class="absolute top-0 right-0 z-20">
{#await leafEvent then event}
{#if event}
<CardActions
{event}
sectionAddress={address}
onDelete={handleDelete}
/>
{/if}
{/await}
</div>
{@render sectionHeading(leafTitle, leafDepth)}
</div>
{/if} {/if}
{@render contentParagraph( {@render contentParagraph(
leafContent.toString(), leafContent.toString(),
@ -267,7 +272,7 @@
</div> </div>
<!-- Mobile comments - shown below content on smaller screens --> <!-- Mobile comments - shown below content on smaller screens -->
<div class="xl:hidden mt-8 max-w-4xl mx-auto px-4"> <div class="xl:hidden mt-8 w-full text-left">
<SectionComments <SectionComments
sectionAddress={address} sectionAddress={address}
comments={sectionComments} comments={sectionComments}
@ -277,17 +282,6 @@
{/await} {/await}
</section> </section>
<!-- Right sidebar elements - positioned very close to content, responsive width -->
{#await leafEvent then event}
{#if event}
<!-- Three-dot menu - positioned at top-center on XL+ screens -->
<div
class="hidden xl:block absolute left-[calc(50%+26rem)] top-[20%] z-10"
>
<CardActions {event} sectionAddress={address} onDelete={handleDelete} />
</div>
{/if}
{/await}
<!-- Comments area: positioned below menu, top-center of section --> <!-- Comments area: positioned below menu, top-center of section -->
<div <div

135
src/lib/components/publications/TableOfContents.svelte

@ -12,21 +12,47 @@
import Self from "./TableOfContents.svelte"; import Self from "./TableOfContents.svelte";
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
let { depth, onSectionFocused, onLoadMore, toc } = $props<{ let { rootAddress, depth, onSectionFocused, onLoadMore, onClose, toc } = $props<{
rootAddress: string; rootAddress: string;
depth: number; depth: number;
toc: TableOfContents; toc: TableOfContents;
onSectionFocused?: (address: string) => void; onSectionFocused?: (address: string) => void;
onLoadMore?: () => void; onLoadMore?: () => void;
onClose?: () => void;
}>(); }>();
let entries = $derived.by<TocEntry[]>(() => { let entries = $derived.by<TocEntry[]>(() => {
const newEntries = []; 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) { for (const [_, entry] of toc.addressMap) {
// Must match the depth
if (entry.depth !== depth) { if (entry.depth !== depth) {
continue; 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); newEntries.push(entry);
} }
@ -45,6 +71,36 @@
toc.expandedMap.set(address, expanded); toc.expandedMap.set(address, expanded);
entry.resolveChildren(); 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) { function handleSectionClick(address: string) {
@ -59,11 +115,13 @@
onSectionFocused?.(address); onSectionFocused?.(address);
// Close the drawer after navigation
onClose?.();
// Check if this is the last entry and trigger loading more events // Check if this is the last entry and trigger loading more events
const currentEntries = entries; const currentEntries = entries;
const lastEntry = currentEntries[currentEntries.length - 1]; const lastEntry = currentEntries[currentEntries.length - 1];
if (lastEntry && lastEntry.address === address) { if (lastEntry && lastEntry.address === address) {
console.debug('[TableOfContents] Last entry clicked, triggering load more');
onLoadMore?.(); onLoadMore?.();
} }
} }
@ -73,6 +131,27 @@
return currentVisibleSection === address; 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<number, string> = {
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 // Set up intersection observer to track visible sections
onMount(() => { onMount(() => {
observer = new IntersectionObserver( observer = new IntersectionObserver(
@ -153,17 +232,62 @@
<!-- TODO: Figure out how to style indentations. --> <!-- TODO: Figure out how to style indentations. -->
<!-- TODO: Make group title fonts the same as entry title fonts. --> <!-- TODO: Make group title fonts the same as entry title fonts. -->
<SidebarGroup> <SidebarGroup>
<!-- Beginning entry - scrolls to top of page -->
{#if depth === 2}
<SidebarItem
label="Beginning"
href="#"
spanClass="px-2 text-ellipsis"
class={getIndentClass(2)}
onclick={(e) => {
e.preventDefault();
window.scrollTo({
top: 0,
behavior: 'smooth',
});
onClose?.();
}}
>
<!-- Beginning entry -->
</SidebarItem>
{/if}
<!-- Root entry (publication header) -->
{#if depth === 2}
{@const rootEntry = toc.getRootEntry()}
{#if rootEntry}
{@const isVisible = isEntryVisible(rootEntry.address)}
<SidebarItem
label={rootEntry.title}
href={`#${rootEntry.address}`}
spanClass="px-2 text-ellipsis"
class={`${getIndentClass(rootEntry.depth)} ${isVisible ? "toc-highlight" : ""} `}
onclick={() => {
const element = document.getElementById(rootEntry.address);
if (element) {
element.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
}
onClose?.();
}}
>
<!-- Publication header entry -->
</SidebarItem>
{/if}
{/if}
{#each entries as entry, index} {#each entries as entry, index}
{@const address = entry.address} {@const address = entry.address}
{@const expanded = toc.expandedMap.get(address) ?? false} {@const expanded = toc.expandedMap.get(address) ?? false}
{@const isLeaf = toc.leaves.has(address)} {@const isLeaf = toc.leaves.has(address)}
{@const isVisible = isEntryVisible(address)} {@const isVisible = isEntryVisible(address)}
{@const indentClass = getIndentClass(entry.depth)}
{#if isLeaf} {#if isLeaf}
<SidebarItem <SidebarItem
label={entry.title} label={entry.title}
href={`#${address}`} href={`#${address}`}
spanClass="px-2 text-ellipsis" spanClass="px-2 text-ellipsis"
class={`${isVisible ? "toc-highlight" : ""} `} class={`${indentClass} ${isVisible ? "toc-highlight" : ""} `}
onclick={() => handleSectionClick(address)} onclick={() => handleSectionClick(address)}
> >
<!-- Empty for now - could add icons or labels here in the future --> <!-- Empty for now - could add icons or labels here in the future -->
@ -172,10 +296,11 @@
{@const childDepth = depth + 1} {@const childDepth = depth + 1}
<SidebarDropdownWrapper <SidebarDropdownWrapper
label={entry.title} label={entry.title}
btnClass="flex items-center p-2 w-full font-normal text-gray-900 rounded-lg transition duration-75 group hover:bg-primary-50 dark:text-white dark:hover:bg-primary-800 {isVisible ? 'toc-highlight' : ''} " btnClass="flex items-center p-2 w-full font-normal text-gray-900 rounded-lg transition duration-75 group hover:bg-primary-50 dark:text-white dark:hover:bg-primary-800 {isVisible ? 'toc-highlight' : ''} whitespace-nowrap min-w-fit {indentClass}"
class="w-full"
bind:isOpen={() => expanded, (open) => setEntryExpanded(address, open)} bind:isOpen={() => expanded, (open) => setEntryExpanded(address, open)}
> >
<Self rootAddress={address} depth={childDepth} {toc} {onSectionFocused} {onLoadMore} /> <Self rootAddress={address} depth={childDepth} {toc} {onSectionFocused} {onLoadMore} {onClose} />
</SidebarDropdownWrapper> </SidebarDropdownWrapper>
{/if} {/if}
{/each} {/each}

62
src/lib/components/util/ArticleNav.svelte

@ -36,11 +36,14 @@
let lastScrollY = $state(0); let lastScrollY = $state(0);
let isVisible = $state(true); let isVisible = $state(true);
let navbarTop = $state(100); // Default to 100px
// Function to toggle column visibility // Function to toggle column visibility
function toggleColumn(column: "toc" | "blog" | "inner" | "discussion") { function toggleColumn(column: "toc" | "blog" | "inner" | "discussion") {
console.log("[ArticleNav] toggleColumn called with:", column);
publicationColumnVisibility.update((current) => { publicationColumnVisibility.update((current) => {
const newValue = !current[column]; const newValue = !current[column];
console.log("[ArticleNav] Toggling", column, "from", current[column], "to", newValue);
const updated = { ...current, [column]: newValue }; const updated = { ...current, [column]: newValue };
if (window.innerWidth < 1400 && column === "blog" && 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() { function handleScroll() {
if (window.innerWidth < 768) { if (window.innerWidth < 768) {
const currentScrollY = window.scrollY; const currentScrollY = window.scrollY;
@ -139,6 +152,13 @@
let unsubscribe: () => void; let unsubscribe: () => void;
onMount(() => { 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); window.addEventListener("scroll", handleScroll);
unsubscribe = publicationColumnVisibility.subscribe(() => { unsubscribe = publicationColumnVisibility.subscribe(() => {
isVisible = true; // show navbar when store changes isVisible = true; // show navbar when store changes
@ -152,40 +172,38 @@
</script> </script>
<nav <nav
class="Navbar navbar-leather col-span-3 flex fixed top-[100px] sm:top-[92px] w-full min-h-[70px] px-2 sm:px-4 py-2.5 z-10 transition-transform duration-300 {isVisible class="Navbar navbar-leather col-span-2 flex fixed w-full min-h-[70px] px-2 sm:px-4 py-2.5 z-10 transition-transform duration-300 {isVisible
? 'translate-y-0' ? 'translate-y-0'
: '-translate-y-full'}" : '-translate-y-full'}"
style="top: {navbarTop}px;"
> >
<div class="mx-auto flex space-x-2 container"> <div class="mx-auto flex space-x-2 container">
<div class="flex items-center space-x-2 md:min-w-52 min-w-8"> <div class="flex items-center space-x-2 md:min-w-52 min-w-8">
{#if shouldShowBack()}
<Button
class="btn-leather !w-auto sm:hidden"
outline={true}
onclick={backToMain}
>
<CaretLeftOutline class="!fill-none inline mr-1" />
<span class="hidden sm:inline">Back</span>
</Button>
{/if}
{#if isIndexEvent} {#if isIndexEvent}
{#if publicationType === "blog"} {#if publicationType === "blog"}
<Button <!-- Blog view: hidden when showing blog list, shows back arrow when viewing article -->
class={`btn-leather hidden sm:flex !w-auto ${$publicationColumnVisibility.blog ? "active" : ""}`} {#if !($publicationColumnVisibility.blog && !$publicationColumnVisibility.inner)}
outline={true} <Button
onclick={() => toggleColumn("blog")} class={`btn-leather !w-auto ${$publicationColumnVisibility.inner ? "active" : ""}`}
> outline={true}
<BookOutline class="!fill-none inline mr-1" /> onclick={handleBlogTocClick}
<span class="hidden sm:inline">Table of Contents</span> title={$publicationColumnVisibility.inner ? "Back to Table of Contents" : "Table of Contents"}
</Button> >
{:else if !$publicationColumnVisibility.discussion && !$publicationColumnVisibility.toc} {#if $publicationColumnVisibility.inner}
<CaretLeftOutline class="!fill-none" />
{:else}
<BookOutline class="!fill-none" />
{/if}
</Button>
{/if}
{:else if !$publicationColumnVisibility.discussion}
<Button <Button
class={`btn-leather !w-auto ${$publicationColumnVisibility.toc ? "active" : ""}`} class={`btn-leather !w-auto ${$publicationColumnVisibility.toc ? "active" : ""}`}
outline={true} outline={true}
onclick={() => toggleColumn("toc")} onclick={() => toggleColumn("toc")}
title="Table of Contents"
> >
<BookOutline class="!fill-none inline mr-1" /> <BookOutline class="!fill-none" />
<span class="hidden sm:inline">Table of Contents</span>
</Button> </Button>
{/if} {/if}
{/if} {/if}

18
src/lib/components/util/CardActions.svelte

@ -26,10 +26,16 @@
import { WebSocketPool } from "$lib/data_structures/websocket_pool"; import { WebSocketPool } from "$lib/data_structures/websocket_pool";
// Component props // Component props
let { event, onDelete, sectionAddress } = $props<{ let {
event,
onDelete,
sectionAddress,
detailsModalOpen = $bindable(false)
} = $props<{
event: NDKEvent; event: NDKEvent;
onDelete?: () => void; onDelete?: () => void;
sectionAddress?: string; // If provided, shows "Comment on section" option sectionAddress?: string; // If provided, shows "Comment on section" option
detailsModalOpen?: boolean; // Bindable prop to control modal from outside
}>(); }>();
const ndk = getNdkContext(); const ndk = getNdkContext();
@ -72,8 +78,7 @@
event.tags.find((t: string[]) => t[0] === "identifier")?.[1] ?? null, event.tags.find((t: string[]) => t[0] === "identifier")?.[1] ?? null,
); );
// UI state // UI state - detailsModalOpen is now a bindable prop
let detailsModalOpen: boolean = $state(false);
let isOpen: boolean = $state(false); let isOpen: boolean = $state(false);
// Comment modal state // Comment modal state
@ -408,7 +413,7 @@
</script> </script>
<div <div
class="group bg-highlight dark:bg-primary-1000 rounded" class="group bg-transparent rounded"
role="group" role="group"
onmouseenter={openPopover} onmouseenter={openPopover}
> >
@ -416,8 +421,7 @@
<Button <Button
type="button" type="button"
id="dots-{event.id}" id="dots-{event.id}"
class=" hover:bg-primary-50 dark:text-highlight dark:hover:bg-primary-800 p-1 dots" class="!bg-transparent hover:!bg-primary-100 dark:hover:!bg-primary-800 text-primary-600 dark:text-gray-300 hover:text-primary-700 dark:hover:text-gray-200 p-1 dots !border-0 !shadow-none"
color="primary"
data-popover-target="popover-actions" data-popover-target="popover-actions"
> >
<DotsVerticalOutline class="h-6 w-6" /> <DotsVerticalOutline class="h-6 w-6" />
@ -536,7 +540,7 @@
<div class="flex flex-row"> <div class="flex flex-row">
<h4 class="text-base font-normal mt-2"> <h4 class="text-base font-normal mt-2">
Index author: {@render userBadge(event.pubkey, author, ndk)} {event.kind === 30040 ? "Index author" : "Article author"}: {@render userBadge(event.pubkey, author, ndk)}
</h4> </h4>
</div> </div>

14
src/lib/components/util/Details.svelte

@ -14,10 +14,11 @@
// isModal // isModal
// - don't show interactions in modal view // - don't show interactions in modal view
// - don't show all the details when _not_ in modal view // - don't show all the details when _not_ in modal view
let { event, isModal = false, onDelete } = $props<{ let { event, isModal = false, onDelete, hideActions = false } = $props<{
event: any; event: any;
isModal?: boolean; isModal?: boolean;
onDelete?: () => void; onDelete?: () => void;
hideActions?: boolean;
}>(); }>();
let title: string = $derived(getMatchingTags(event, "title")[0]?.[1]); let title: string = $derived(getMatchingTags(event, "title")[0]?.[1]);
@ -66,7 +67,7 @@
</script> </script>
<div class="flex flex-col relative mb-2"> <div class="flex flex-col relative mb-2">
{#if !isModal} {#if !isModal && !hideActions}
<div class="flex flex-row justify-between items-center"> <div class="flex flex-row justify-between items-center">
<!-- Index author badge --> <!-- Index author badge -->
<P class="text-base font-normal" <P class="text-base font-normal"
@ -76,6 +77,13 @@
<CardActions {event} {onDelete}></CardActions> <CardActions {event} {onDelete}></CardActions>
</div> </div>
</div> </div>
{:else if !isModal && hideActions}
<div class="flex flex-row justify-between items-center">
<!-- Index author badge -->
<P class="text-base font-normal"
>{@render userBadge(event.pubkey, undefined, ndk)}</P
>
</div>
{/if} {/if}
<div <div
class="flex-grow grid grid-cols-1 md:grid-cols-[auto_1fr] gap-4 items-center" class="flex-grow grid grid-cols-1 md:grid-cols-[auto_1fr] gap-4 items-center"
@ -134,7 +142,7 @@
{#each hashtags as tag} {#each hashtags as tag}
<button <button
onclick={() => goto(`/events?t=${encodeURIComponent(tag)}`)} onclick={() => goto(`/events?t=${encodeURIComponent(tag)}`)}
class="text-sm hover:text-primary-700 dark:hover:text-primary-300 cursor-pointer mr-2" class="text-sm text-primary-700 dark:text-primary-300 hover:text-primary-800 dark:hover:text-primary-200 cursor-pointer mr-2"
>#{tag}</button >#{tag}</button
> >
{/each} {/each}

36
src/lib/data_structures/websocket_pool.ts

@ -178,19 +178,39 @@ export class WebSocketPool {
* URL, the connection is passed to the requestor in the queue. Otherwise, the connection is * URL, the connection is passed to the requestor in the queue. Otherwise, the connection is
* marked as available. * marked as available.
* *
* @param handle - The WebSocketHandle to release. * This method is defensive: if the connection is no longer in the pool (e.g., it was already
* closed and removed), it returns silently rather than throwing an error.
*
* @param ws - The WebSocket connection to release.
*/ */
public release(ws: WebSocket): void { public release(ws: WebSocket): void {
const normalizedUrl = this.#normalizeUrl(ws.url); // AI-NOTE: Defensive check - if WebSocket is closed or doesn't have a URL, skip release
const handle = this.#pool.get(normalizedUrl); if (!ws || !ws.url) {
if (!handle) { console.warn(
throw new Error( "[WebSocketPool] Attempted to release an invalid WebSocket connection (no URL).",
"[WebSocketPool] Attempted to release an unmanaged WebSocket connection.",
); );
return;
} }
if (--handle.refCount === 0) { try {
this.#startIdleTimer(handle); const normalizedUrl = this.#normalizeUrl(ws.url);
const handle = this.#pool.get(normalizedUrl);
if (!handle) {
// AI-NOTE: Connection may have been removed due to closure or error - this is acceptable
console.debug(
`[WebSocketPool] Connection to ${normalizedUrl} is no longer in pool (likely already closed).`,
);
return;
}
if (--handle.refCount === 0) {
this.#startIdleTimer(handle);
}
} catch (error) {
// AI-NOTE: If URL normalization fails or other errors occur, log but don't throw
console.warn(
`[WebSocketPool] Error releasing connection: ${error}. This may occur if the connection was already closed.`,
);
} }
} }

9
src/lib/services/publisher.ts

@ -134,10 +134,13 @@ export async function publishSingleEvent(
dTag: tags.find((t) => t[0] === "d")?.[1], dTag: tags.find((t) => t[0] === "d")?.[1],
}); });
// Simulate network delay // Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 300 + Math.random() * 200)); await new Promise((resolve) =>
setTimeout(resolve, 300 + Math.random() * 200)
);
// Generate a fake event ID // Generate a fake event ID
const fakeEventId = Array.from({ length: 64 }, () => const fakeEventId = Array.from(
Math.floor(Math.random() * 16).toString(16), { length: 64 },
() => Math.floor(Math.random() * 16).toString(16),
).join(""); ).join("");
return { success: true, eventId: fakeEventId }; return { success: true, eventId: fakeEventId };
} }

2
src/lib/snippets/PublicationSnippets.svelte

@ -8,7 +8,7 @@
{@const headingLevel = Math.min(depth + 1, 6)} {@const headingLevel = Math.min(depth + 1, 6)}
<!-- TODO: Handle floating titles. --> <!-- TODO: Handle floating titles. -->
<svelte:element this={`h${headingLevel}`} class="h-leather"> <svelte:element this={`h${headingLevel}`} class="h-leather text-left">
{title} {title}
</svelte:element> </svelte:element>
{/snippet} {/snippet}

13
src/lib/stores/themeStore.ts

@ -10,8 +10,17 @@ export const theme = writable(initial);
theme.subscribe((v) => { theme.subscribe((v) => {
if (typeof document !== "undefined") { if (typeof document !== "undefined") {
document.documentElement.dataset.theme = String(v); const themeValue = String(v);
localStorage.setItem(KEY, String(v)); document.documentElement.dataset.theme = themeValue;
localStorage.setItem(KEY, themeValue);
// Add .dark class for non-light themes (ocean, forrest are dark themes)
// Remove .dark class for light theme
if (themeValue === "light") {
document.documentElement.classList.remove("dark");
} else {
document.documentElement.classList.add("dark");
}
} }
}); });

11
src/routes/+layout.svelte

@ -13,6 +13,8 @@
setContext("ndk", data.ndk); setContext("ndk", data.ndk);
let contentTop = $state(100); // Default to 100px
// Get standard metadata for OpenGraph tags // Get standard metadata for OpenGraph tags
let title = "Library of Alexandria"; let title = "Library of Alexandria";
let currentUrl = page.url.href; let currentUrl = page.url.href;
@ -27,6 +29,13 @@
const rect = document.body.getBoundingClientRect(); const rect = document.body.getBoundingClientRect();
// document.body.style.height = `${rect.height}px`; // document.body.style.height = `${rect.height}px`;
// Measure the actual navbar height to position content correctly
const navbar = document.getElementById("navi");
if (navbar) {
const navbarRect = navbar.getBoundingClientRect();
contentTop = navbarRect.bottom;
}
// AI-NOTE: Restore authentication state from localStorage on page load // AI-NOTE: Restore authentication state from localStorage on page load
// This function automatically restores the user's login state when the page is refreshed, // This function automatically restores the user's login state when the page is refreshed,
// preventing the user from being logged out unexpectedly. It handles extension, npub, and Amber logins. // preventing the user from being logged out unexpectedly. It handles extension, npub, and Amber logins.
@ -183,7 +192,7 @@
<div class="min-h-screen flex flex-col"> <div class="min-h-screen flex flex-col">
<ANavbar /> <ANavbar />
<div class="flex flex-1 flex-col w-full mt-[100px] self-center"> <div class="flex flex-1 flex-col w-full self-center" style="margin-top: {contentTop}px;">
{@render children()} {@render children()}
</div> </div>

2
src/routes/publication/[type]/[identifier]/+page.svelte

@ -90,8 +90,6 @@
function initializePublicationComponents(event: NDKEvent) { function initializePublicationComponents(event: NDKEvent) {
if (!data.ndk) return; if (!data.ndk) return;
console.log("[Publication] Initializing publication components for event:", event.tagAddress());
publicationTree = new SveltePublicationTree(event, data.ndk); publicationTree = new SveltePublicationTree(event, data.ndk);
toc = new TableOfContents( toc = new TableOfContents(
event.tagAddress(), event.tagAddress(),

28
src/styles/a/cards.css

@ -18,12 +18,12 @@
.card-leather h5, .card-leather h5,
.card-leather h6 { .card-leather h6 {
@apply text-gray-900 hover:text-primary-600 dark:text-gray-100 @apply text-gray-900 hover:text-primary-600 dark:text-gray-100
dark:hover:text-primary-400; dark:hover:text-primary-300;
} }
.card-leather .font-thin { .card-leather .font-thin {
@apply text-gray-900 hover:text-primary-600 dark:text-gray-100 @apply text-gray-900 hover:text-primary-700 dark:text-gray-100
dark:hover:text-primary-400; dark:hover:text-primary-300;
} }
/* Main card leather (used in profile previews) */ /* Main card leather (used in profile previews) */
@ -61,12 +61,12 @@
.ArticleBox h5, .ArticleBox h5,
.ArticleBox h6 { .ArticleBox h6 {
@apply text-gray-900 hover:text-primary-600 dark:text-gray-100 @apply text-gray-900 hover:text-primary-600 dark:text-gray-100
dark:hover:text-primary-400; dark:hover:text-primary-300;
} }
.ArticleBox .font-thin { .ArticleBox .font-thin {
@apply text-gray-900 hover:text-primary-600 dark:text-gray-100 @apply text-gray-900 hover:text-primary-700 dark:text-gray-100
dark:hover:text-primary-400; dark:hover:text-primary-300;
} }
/* Article box image transitions */ /* Article box image transitions */
@ -155,24 +155,24 @@
flex items-center gap-2 flex-wrap; flex items-center gap-2 flex-wrap;
} }
/* Card content text styles */ /* Card content text styles - improved contrast */
.card-summary { .card-summary {
@apply text-sm text-primary-900 dark:text-primary-200 line-clamp-2; @apply text-sm text-primary-900 dark:text-primary-100 line-clamp-2;
} }
.card-content { .card-content {
@apply text-sm text-gray-800 dark:text-gray-200 line-clamp-3 break-words @apply text-sm text-gray-900 dark:text-gray-100 line-clamp-3 break-words
mb-4; mb-4;
} }
.card-about { .card-about {
@apply text-sm text-gray-700 dark:text-gray-300 line-clamp-3; @apply text-sm text-gray-800 dark:text-gray-200 line-clamp-3;
} }
/* Deferral link styling */ /* Deferral link styling - improved contrast */
.deferral-link { .deferral-link {
@apply underline text-primary-700 dark:text-primary-400 @apply underline text-primary-700 dark:text-primary-300
hover:text-primary-600 dark:hover:text-primary-400 break-all hover:text-primary-800 dark:hover:text-primary-200 break-all
cursor-pointer; cursor-pointer;
} }
@ -182,7 +182,7 @@
.tags span { .tags span {
@apply bg-primary-50 text-primary-800 text-sm font-medium me-2 px-2.5 py-0.5 @apply bg-primary-50 text-primary-800 text-sm font-medium me-2 px-2.5 py-0.5
rounded-sm dark:bg-primary-900 dark:text-primary-200; rounded-sm dark:bg-primary-900 dark:text-primary-100;
} }
/* ======================================== /* ========================================

2
src/styles/base.css

@ -2,6 +2,6 @@
@layer components { @layer components {
body { body {
@apply bg-primary-0 dark:bg-primary-1000; @apply bg-primary-0 dark:bg-primary-1000 text-gray-900 dark:text-gray-100;
} }
} }

12
src/styles/notifications.css

@ -50,15 +50,15 @@
background: linear-gradient(135deg, #4b5563 0%, #374151 100%); background: linear-gradient(135deg, #4b5563 0%, #374151 100%);
} }
/* Filter button states */ /* Filter button states - improved contrast */
.filter-button-active { .filter-button-active {
background-color: rgb(107 114 128); background-color: rgb(75 85 99);
color: rgb(243 244 246); color: rgb(255 255 255);
} }
.dark .filter-button-active { .dark .filter-button-active {
background-color: rgb(107 114 128); background-color: rgb(107 114 128);
color: rgb(243 244 246); color: rgb(255 255 255);
} }
/* Reply button hover states */ /* Reply button hover states */
@ -181,11 +181,11 @@
} }
.mode-toggle-button.inactive { .mode-toggle-button.inactive {
color: rgb(55 65 81); color: rgb(31 41 55);
} }
.dark .mode-toggle-button.inactive { .dark .mode-toggle-button.inactive {
color: rgb(156 163 175); color: rgb(209 213 219);
} }
.mode-toggle-button.inactive:hover { .mode-toggle-button.inactive:hover {

2
src/styles/publications.css

@ -5,7 +5,7 @@
} }
.publication-leather p a { .publication-leather p a {
@apply underline hover:text-primary-600 dark:hover:text-primary-400; @apply underline hover:text-primary-700 dark:hover:text-primary-300;
} }
.publication-leather section p { .publication-leather section p {

20
src/styles/visualize.css

@ -1,7 +1,7 @@
@layer components { @layer components {
/* Legend styles - specific to visualization */ /* Legend styles - specific to visualization - improved contrast */
.legend-list { .legend-list {
@apply list-disc mt-2 space-y-2 text-gray-800 dark:text-gray-300; @apply list-disc mt-2 space-y-2 text-gray-900 dark:text-gray-200;
} }
.legend-item { .legend-item {
@ -70,7 +70,7 @@
} }
.network-debug { .network-debug {
@apply mt-4 text-sm text-gray-500; @apply mt-4 text-sm text-gray-700 dark:text-gray-300;
} }
/* Zoom controls */ /* Zoom controls */
@ -82,11 +82,11 @@
@apply bg-white; @apply bg-white;
} }
/* Tooltip styles - specific to visualization tooltips */ /* Tooltip styles - specific to visualization tooltips - improved contrast */
.tooltip-close-btn { .tooltip-close-btn {
@apply absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 @apply absolute top-2 right-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700
dark:hover:bg-gray-600 rounded-full p-1 text-gray-500 hover:text-gray-700 dark:hover:bg-gray-600 rounded-full p-1 text-gray-700 hover:text-gray-900
dark:text-gray-400 dark:hover:text-gray-200; dark:text-gray-300 dark:hover:text-gray-100;
} }
.tooltip-content { .tooltip-content {
@ -98,12 +98,12 @@
} }
.tooltip-title-link { .tooltip-title-link {
@apply text-gray-800 hover:text-blue-600 dark:text-gray-200 @apply text-gray-900 hover:text-blue-700 dark:text-gray-100
dark:hover:text-blue-400; dark:hover:text-blue-300;
} }
.tooltip-metadata { .tooltip-metadata {
@apply text-gray-600 dark:text-gray-400 text-sm; @apply text-gray-700 dark:text-gray-300 text-sm;
} }
.tooltip-summary { .tooltip-summary {
@ -117,7 +117,7 @@
} }
.tooltip-help-text { .tooltip-help-text {
@apply mt-2 text-xs text-gray-500 dark:text-gray-400 italic; @apply mt-2 text-xs text-gray-600 dark:text-gray-300 italic;
} }
/* Star network visualization styles */ /* Star network visualization styles */

11
src/theme-tokens.css

@ -15,6 +15,13 @@
--brand-primary-1000: #15110d; --brand-primary-1000: #15110d;
} }
/* Dark mode tweaks for default theme - improve contrast for text on dark backgrounds */
:root.dark {
/* Brighten mid-tones for better contrast when used as text */
--brand-primary-300: #d4a574;
--brand-primary-200: #e0b890;
}
/* Example alternative theme: ocean */ /* Example alternative theme: ocean */
:root[data-theme="ocean"] { :root[data-theme="ocean"] {
--brand-primary-0: #ecf8ff; --brand-primary-0: #ecf8ff;
@ -37,6 +44,8 @@
/* nudge the mid tones brighter for contrast */ /* nudge the mid tones brighter for contrast */
--brand-primary-400: #7ccdfc; --brand-primary-400: #7ccdfc;
--brand-primary-500: #38bdf8; --brand-primary-500: #38bdf8;
--brand-primary-300: #a5d8f5;
--brand-primary-200: #bae6fd;
} }
/* Example alternative theme: forrest */ /* Example alternative theme: forrest */
@ -61,4 +70,6 @@
/* nudge the mid tones brighter for contrast */ /* nudge the mid tones brighter for contrast */
--brand-primary-400: #7fc97f; --brand-primary-400: #7fc97f;
--brand-primary-500: #4caf50; --brand-primary-500: #4caf50;
--brand-primary-300: #9fda9f;
--brand-primary-200: #b8e6b8;
} }

Loading…
Cancel
Save