You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
429 lines
14 KiB
429 lines
14 KiB
<script lang="ts"> |
|
import { |
|
Alert, |
|
Button, |
|
Card, |
|
Sidebar, |
|
SidebarGroup, |
|
SidebarWrapper, |
|
Heading, |
|
CloseButton, uiHelpers |
|
} from "flowbite-svelte"; |
|
import { getContext, onDestroy, onMount } from "svelte"; |
|
import { |
|
CloseOutline, |
|
ExclamationCircleOutline, |
|
} 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 { 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"; |
|
import ArticleNav from "$components/util/ArticleNav.svelte"; |
|
|
|
let { rootAddress, publicationType, indexEvent, publicationTree, toc } = $props<{ |
|
rootAddress: string; |
|
publicationType: string; |
|
indexEvent: NDKEvent; |
|
publicationTree: SveltePublicationTree; |
|
toc: TocType; |
|
}>(); |
|
|
|
// #region Loading |
|
let leaves = $state<Array<NDKEvent | null>>([]); |
|
let isLoading = $state(false); |
|
let isDone = $state(false); |
|
let lastElementRef = $state<HTMLElement | null>(null); |
|
let activeAddress = $state<string | null>(null); |
|
let loadedAddresses = $state<Set<string>>(new Set()); |
|
let hasInitialized = $state(false); |
|
|
|
let observer: IntersectionObserver; |
|
|
|
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}`); |
|
|
|
isLoading = true; |
|
|
|
try { |
|
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; |
|
} |
|
|
|
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}`); |
|
} else { |
|
console.warn(`[Publication] Duplicate event detected: ${address}`); |
|
} |
|
} else { |
|
console.log("[Publication] Got null event"); |
|
leaves.push(null); |
|
} |
|
} |
|
} catch (error) { |
|
console.error("[Publication] Error loading more content:", error); |
|
} finally { |
|
isLoading = false; |
|
console.log(`[Publication] Finished loading. Total leaves: ${leaves.length}, loaded addresses: ${loadedAddresses.size}`); |
|
} |
|
} |
|
|
|
function setLastElementRef(el: HTMLElement, i: number) { |
|
if (i === leaves.length - 1) { |
|
lastElementRef = el; |
|
} |
|
} |
|
|
|
$effect(() => { |
|
if (!lastElementRef) { |
|
return; |
|
} |
|
|
|
if (isDone) { |
|
observer?.unobserve(lastElementRef!); |
|
return; |
|
} |
|
|
|
observer?.observe(lastElementRef!); |
|
return () => observer?.unobserve(lastElementRef!); |
|
}); |
|
|
|
// #endregion |
|
|
|
// AI-NOTE: Combined effect to handle publicationTree changes and initial loading |
|
// This prevents conflicts between separate effects that could cause duplicate loading |
|
$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(); |
|
} |
|
|
|
// 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); |
|
} |
|
}); |
|
|
|
// #region Columns visibility |
|
|
|
let currentBlog: null | string = $state(null); |
|
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; |
|
} |
|
|
|
function closeToc() { |
|
publicationColumnVisibility.update((v) => ({ ...v, toc: false })); |
|
} |
|
|
|
function closeDiscussion() { |
|
publicationColumnVisibility.update((v) => ({ ...v, discussion: false })); |
|
} |
|
|
|
function loadBlog(rootId: string) { |
|
// depending on the size of the screen, also toggle blog list & discussion visibility |
|
publicationColumnVisibility.update((current) => { |
|
const updated = current; |
|
if (window.innerWidth < 1024) { |
|
updated.blog = false; |
|
updated.discussion = false; |
|
} |
|
updated.inner = true; |
|
return updated; |
|
}); |
|
|
|
currentBlog = rootId; |
|
// set current blog values for publication render |
|
if (leaves.length > 0) { |
|
currentBlogEvent = |
|
leaves.find((i) => i && i.tagAddress() === currentBlog) ?? null; |
|
} |
|
} |
|
|
|
function showBlogHeader() { |
|
return currentBlog && currentBlogEvent && window.innerWidth < 1140; |
|
} |
|
|
|
// #endregion |
|
|
|
/** |
|
* Performs actions on the DOM element for a publication tree leaf when it is mounted. |
|
* |
|
* @param el The DOM element that was mounted. |
|
* @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); |
|
|
|
// 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. |
|
const entry = toc.getEntry(address); |
|
if (!entry) { |
|
console.warn(`[Publication] No parent found for ${address}`); |
|
return; |
|
} |
|
toc.buildTocFromDocument(el, entry); |
|
} |
|
|
|
// #region Lifecycle hooks |
|
|
|
onDestroy(() => { |
|
// reset visibility |
|
publicationColumnVisibility.reset(); |
|
}); |
|
|
|
onMount(() => { |
|
// Set current columns depending on the publication type |
|
const isBlog = publicationType === "blog"; |
|
publicationColumnVisibility.update((v) => ({ |
|
...v, |
|
main: !isBlog, |
|
blog: isBlog, |
|
})); |
|
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(); |
|
}; |
|
}); |
|
|
|
// #endregion |
|
</script> |
|
|
|
<!-- Add gap & items-start so sticky sidebars size correctly --> |
|
<div class="relative grid gap-4 items-start grid-cols-[1fr_3fr_1fr] grid-rows-[auto_1fr]"> |
|
<!-- Full-width ArticleNav row --> |
|
<ArticleNav |
|
publicationType={publicationType} |
|
rootId={indexEvent.id} |
|
indexEvent={indexEvent} |
|
/> |
|
<!-- Three-column row --> |
|
<div class="contents"> |
|
<!-- Table of contents --> |
|
<div class="mt-[70px] relative {$publicationColumnVisibility.toc ? 'w-64' : 'w-auto'}"> |
|
{#if publicationType !== "blog" && !isLeaf} |
|
{#if $publicationColumnVisibility.toc} |
|
<Sidebar |
|
class="z-10 ml-2 fixed top-[162px] max-h-[calc(100vh-165px)] overflow-y-auto dark:bg-primary-900 bg-primary-50 rounded" |
|
activeUrl={`#${activeAddress ?? ""}`} |
|
classes={{ |
|
div: 'dark:bg-primary-900 bg-primary-50', |
|
active: 'bg-primary-100 dark:bg-primary-800 p-2 rounded-lg', |
|
nonactive: 'bg-primary-50 dark:bg-primary-900', |
|
}} |
|
> |
|
<SidebarWrapper> |
|
<CloseButton color="secondary" class="m-2 dark:text-primary-100" onclick={closeToc} ></CloseButton> |
|
<TableOfContents |
|
{rootAddress} |
|
{toc} |
|
depth={2} |
|
onSectionFocused={(address: string) => publicationTree.setBookmark(address)} |
|
onLoadMore={() => { |
|
if (!isLoading && !isDone && publicationTree) { |
|
loadMore(4); |
|
} |
|
}} |
|
/> |
|
|
|
</SidebarWrapper> |
|
</Sidebar> |
|
{/if} |
|
{/if} |
|
|
|
</div> |
|
<div class="mt-[70px]"> |
|
<!-- Default publications --> |
|
{#if $publicationColumnVisibility.main} |
|
<!-- Remove overflow-auto so page scroll drives it --> |
|
<div class="flex flex-col p-4 space-y-4 max-w-3xl flex-grow-2 mx-auto"> |
|
<div |
|
class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border" |
|
> |
|
<Details event={indexEvent} /> |
|
</div> |
|
<!-- Publication sections/cards --> |
|
{#each leaves as leaf, i} |
|
{#if leaf == null} |
|
<Alert class="flex space-x-2"> |
|
<ExclamationCircleOutline class="w-5 h-5" /> |
|
Error loading content. One or more events could not be loaded. |
|
</Alert> |
|
{:else} |
|
{@const address = leaf.tagAddress()} |
|
<PublicationSection |
|
{rootAddress} |
|
{leaves} |
|
{address} |
|
{publicationTree} |
|
{toc} |
|
ref={(el) => onPublicationSectionMounted(el, address)} |
|
/> |
|
{/if} |
|
{/each} |
|
<div class="flex justify-center my-4"> |
|
{#if isLoading} |
|
<Button disabled color="primary">Loading...</Button> |
|
{:else if !isDone} |
|
<Button color="primary" onclick={() => loadMore(1)}>Show More</Button> |
|
{:else} |
|
<p class="text-gray-500 dark:text-gray-400"> |
|
You've reached the end of the publication. |
|
</p> |
|
{/if} |
|
</div> |
|
</div> |
|
{/if} |
|
|
|
<!-- Blog list --> |
|
{#if $publicationColumnVisibility.blog} |
|
<!-- Remove overflow-auto --> |
|
<div |
|
class={`flex flex-col p-4 space-y-4 max-w-xl flex-grow-1 ${isInnerActive() ? "discreet" : ""}`} |
|
> |
|
<div |
|
class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border" |
|
> |
|
<Details event={indexEvent} /> |
|
</div> |
|
<!-- List blog excerpts --> |
|
{#each leaves as leaf, i} |
|
{#if leaf} |
|
<BlogHeader |
|
rootId={leaf.tagAddress()} |
|
event={leaf} |
|
onBlogUpdate={loadBlog} |
|
active={!isInnerActive()} |
|
/> |
|
{/if} |
|
{/each} |
|
</div> |
|
{/if} |
|
|
|
{#if isInnerActive()} |
|
{#key currentBlog} |
|
<!-- Remove overflow-auto & sticky; allow page scroll --> |
|
<div class="flex flex-col p-4 max-w-3xl flex-grow-2"> |
|
<!-- ...existing code... --> |
|
</div> |
|
{/key} |
|
{/if} |
|
</div> |
|
|
|
<div class="mt-[70px] relative {$publicationColumnVisibility.discussion ? 'w-64' : 'w-auto'}"> |
|
<!-- Discussion sidebar --> |
|
{#if $publicationColumnVisibility.discussion} |
|
<Sidebar |
|
class="z-10 ml-4 fixed top-[162px] h-[calc(100vh-165px)] overflow-y-auto" |
|
classes={{ |
|
div: 'bg-transparent' |
|
}} |
|
> |
|
<SidebarWrapper> |
|
<SidebarGroup> |
|
<div class="flex justify-between items-baseline"> |
|
<Heading tag="h1" class="h-leather !text-lg">Discussion</Heading> |
|
<Button |
|
class="btn-leather hidden sm:flex z-30 !p-1 bg-primary-50 dark:bg-gray-800" |
|
outline |
|
onclick={closeDiscussion} |
|
> |
|
<CloseOutline /> |
|
</Button> |
|
</div> |
|
<div class="flex flex-col space-y-4"> |
|
<!-- TODO |
|
alternative for other publications and |
|
when blog is not opened, but discussion is opened from the list |
|
--> |
|
{#if showBlogHeader() && currentBlog && currentBlogEvent} |
|
<BlogHeader |
|
rootId={currentBlog} |
|
event={currentBlogEvent} |
|
onBlogUpdate={loadBlog} |
|
active={true} |
|
/> |
|
{/if} |
|
<div class="flex flex-col w-full space-y-4"> |
|
<Card class="ArticleBox card-leather w-full grid max-w-xl"> |
|
<div class="flex flex-col my-2"> |
|
<span>Unknown</span> |
|
<span class="text-gray-500">1.1.1970</span> |
|
</div> |
|
<div class="flex flex-col flex-grow space-y-4"> |
|
This is a very intelligent comment placeholder that applies to |
|
all the content equally well. |
|
</div> |
|
</Card> |
|
</div> |
|
</div> |
|
</SidebarGroup> |
|
</SidebarWrapper> |
|
</Sidebar> |
|
{/if} |
|
</div> |
|
</div> |
|
</div>
|
|
|