clone of repo on github
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.
 
 
 
 

418 lines
13 KiB

<script lang="ts">
import {
Alert,
Button,
Card,
Sidebar,
SidebarGroup,
SidebarWrapper,
Heading,
CloseButton,
} 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";
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: 2025-01-24 - 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: 2025-01-24 - 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);
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: 2025-01-24 - 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>
<!-- Table of contents -->
{#if publicationType !== "blog" || !isLeaf}
{#if $publicationColumnVisibility.toc}
<Sidebar
activeUrl={`#${activeAddress ?? ""}`}
asideClass="fixed md:sticky top-[130px] sm:top-[146px] h-[calc(100vh-130px)] sm:h-[calc(100vh-146px)] z-10 bg-primary-0 dark:bg-primary-1000 px-5 w-80 left-0 pt-4 md:!pr-16 overflow-y-auto border border-l-4 rounded-lg border-primary-200 dark:border-primary-800 my-4"
activeClass="flex items-center p-2 bg-primary-50 dark:bg-primary-800 p-2 rounded-lg"
nonActiveClass="flex items-center p-2 hover:bg-primary-50 dark:hover:bg-primary-800 p-2 rounded-lg"
>
<CloseButton
onclick={closeToc}
class="btn-leather absolute top-4 right-4 hover:bg-primary-50 dark:hover:bg-primary-800"
/>
<TableOfContents
{rootAddress}
{toc}
depth={2}
onSectionFocused={(address: string) =>
publicationTree.setBookmark(address)}
onLoadMore={() => {
if (!isLoading && !isDone && publicationTree) {
loadMore(4);
}
}}
/>
</Sidebar>
{/if}
{/if}
<!-- Default publications -->
{#if $publicationColumnVisibility.main}
<div class="flex flex-col p-4 space-y-4 overflow-auto max-w-2xl flex-grow-2">
<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}
<div
class={`flex flex-col p-4 space-y-4 overflow-auto 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}
<div
class="flex flex-col p-4 max-w-3xl overflow-auto flex-grow-2 max-h-[calc(100vh-146px)] sticky top-[146px]"
>
{#each leaves as leaf, i}
{#if leaf && leaf.tagAddress() === currentBlog}
<div
class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border"
>
<Details event={leaf} />
</div>
<PublicationSection
{rootAddress}
{leaves}
address={leaf.tagAddress()}
{publicationTree}
{toc}
ref={(el) => setLastElementRef(el, i)}
/>
<Card class="ArticleBox !hidden card-leather min-w-full mt-4">
<Interactions rootId={currentBlog} />
</Card>
{/if}
{/each}
</div>
{/key}
{/if}
{#if $publicationColumnVisibility.discussion}
<Sidebar class="sidebar-leather right-0 md:!pl-8">
<SidebarWrapper>
<SidebarGroup class="sidebar-group-leather">
<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}