21 changed files with 1632 additions and 1027 deletions
@ -1,172 +0,0 @@
@@ -1,172 +0,0 @@
|
||||
<script lang="ts"> |
||||
import { ndkInstance } from "$lib/ndk"; |
||||
import { naddrEncode } from "$lib/utils"; |
||||
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||
import { standardRelays } from "../consts"; |
||||
import { Card, Img } from "flowbite-svelte"; |
||||
import CardActions from "$components/util/CardActions.svelte"; |
||||
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; |
||||
import { goto } from '$app/navigation'; |
||||
import { getUserMetadata, toNpub, getMatchingTags } from "$lib/utils/nostrUtils"; |
||||
|
||||
const { event } = $props<{ event: NDKEvent }>(); |
||||
|
||||
const relays = $derived.by(() => { |
||||
return $ndkInstance.activeUser?.relayUrls ?? standardRelays; |
||||
}); |
||||
|
||||
const href = $derived.by(() => { |
||||
const d = event.getMatchingTags("d")[0]?.[1]; |
||||
if (d != null) { |
||||
return `publication?d=${d}`; |
||||
} else { |
||||
return `publication?id=${naddrEncode(event, relays)}`; |
||||
} |
||||
}); |
||||
|
||||
let title: string = $derived(event.getMatchingTags('title')[0]?.[1]); |
||||
let authorTag: string = $derived(event.getMatchingTags('author')[0]?.[1] ?? ''); |
||||
let pTag: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? ''); |
||||
let version: string = $derived(event.getMatchingTags('version')[0]?.[1] ?? '1'); |
||||
let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null); |
||||
let authorPubkey: string = $derived( |
||||
event.getMatchingTags("p")[0]?.[1] ?? null, |
||||
); |
||||
let hashtags: string[] = $derived(event.getMatchingTags('t').map((tag: string[]) => tag[1])); |
||||
|
||||
// New: fetch profile display name for authorPubkey |
||||
let authorDisplayName = $state<string | undefined>(undefined); |
||||
let imageLoaded = $state(false); |
||||
let imageError = $state(false); |
||||
|
||||
function isValidNostrPubkey(str: string): boolean { |
||||
return /^[a-f0-9]{64}$/i.test(str) || (str.startsWith('npub1') && str.length >= 59 && str.length <= 63); |
||||
} |
||||
|
||||
function navigateToHashtagSearch(tag: string): void { |
||||
const encoded = encodeURIComponent(tag); |
||||
goto(`/events?t=${encoded}`, { |
||||
replaceState: false, |
||||
keepFocus: true, |
||||
noScroll: true, |
||||
}); |
||||
} |
||||
|
||||
function generatePastelColor(eventId: string): string { |
||||
// Use the first 6 characters of the event ID to generate a pastel color |
||||
const hash = eventId.substring(0, 6); |
||||
const r = parseInt(hash.substring(0, 2), 16); |
||||
const g = parseInt(hash.substring(2, 4), 16); |
||||
const b = parseInt(hash.substring(4, 6), 16); |
||||
|
||||
// Convert to pastel by mixing with white (lightening the color) |
||||
const pastelR = Math.round((r + 255) / 2); |
||||
const pastelG = Math.round((g + 255) / 2); |
||||
const pastelB = Math.round((b + 255) / 2); |
||||
|
||||
return `rgb(${pastelR}, ${pastelG}, ${pastelB})`; |
||||
} |
||||
|
||||
function handleImageLoad() { |
||||
imageLoaded = true; |
||||
} |
||||
|
||||
function handleImageError() { |
||||
imageError = true; |
||||
} |
||||
|
||||
$effect(() => { |
||||
if (authorPubkey) { |
||||
getUserMetadata(toNpub(authorPubkey) as string).then((profile) => { |
||||
authorDisplayName = |
||||
profile.displayName || |
||||
(profile as any).display_name || |
||||
authorTag || |
||||
authorPubkey; |
||||
}); |
||||
} else { |
||||
authorDisplayName = undefined; |
||||
} |
||||
}); |
||||
</script> |
||||
|
||||
{#if title != null && href != null} |
||||
<Card |
||||
class="ArticleBox card-leather max-w-md h-64 flex flex-row overflow-hidden" |
||||
> |
||||
<div class="w-24 h-full overflow-hidden flex-shrink-0"> |
||||
{#if image && !imageError} |
||||
<div class="w-full h-full relative"> |
||||
<!-- Pastel placeholder --> |
||||
<div |
||||
class="w-full h-full transition-opacity duration-300" |
||||
style="background-color: {generatePastelColor(event.id)}; opacity: {imageLoaded ? '0' : '1'}" |
||||
></div> |
||||
<!-- Image --> |
||||
<img |
||||
src={image} |
||||
class="absolute inset-0 w-full h-full object-cover transition-opacity duration-300" |
||||
style="opacity: {imageLoaded ? '1' : '0'}" |
||||
onload={handleImageLoad} |
||||
onerror={handleImageError} |
||||
loading="lazy" |
||||
alt="Publication cover" |
||||
/> |
||||
</div> |
||||
{:else} |
||||
<!-- Pastel placeholder when no image or image failed to load --> |
||||
<div |
||||
class="w-full h-full" |
||||
style="background-color: {generatePastelColor(event.id)}" |
||||
></div> |
||||
{/if} |
||||
</div> |
||||
<div class="flex flex-col flex-grow p-4 relative"> |
||||
<div class="absolute top-2 right-2 z-10"> |
||||
<CardActions {event} /> |
||||
</div> |
||||
<button |
||||
class="flex flex-col space-y-2 text-left w-full bg-transparent border-none p-0 hover:underline pr-8" |
||||
onclick={() => goto(`/${href}`)} |
||||
> |
||||
<h2 class='text-lg font-bold line-clamp-2' title="{title}">{title}</h2> |
||||
<h3 class='text-base font-normal'> |
||||
by |
||||
{#if authorTag && pTag && isValidNostrPubkey(pTag)} |
||||
{authorTag} {@render userBadge(pTag, '')} |
||||
{:else if authorTag} |
||||
{authorTag} |
||||
{:else if pTag && isValidNostrPubkey(pTag)} |
||||
{@render userBadge(pTag, '')} |
||||
{:else if authorPubkey != null} |
||||
{@render userBadge(authorPubkey, authorDisplayName)} |
||||
{:else} |
||||
unknown |
||||
{/if} |
||||
</h3> |
||||
{#if version != "1"} |
||||
<h3 |
||||
class="text-base font-medium text-primary-700 dark:text-primary-300" |
||||
> |
||||
version: {version} |
||||
</h3> |
||||
{/if} |
||||
</button> |
||||
{#if hashtags.length > 0} |
||||
<div class="tags mt-auto pt-2 flex flex-wrap gap-1"> |
||||
{#each hashtags as tag (tag)} |
||||
<button |
||||
class="text-xs text-primary-600 dark:text-primary-500 hover:text-primary-800 dark:hover:text-primary-300 hover:underline cursor-pointer" |
||||
onclick={(e: MouseEvent) => { |
||||
e.stopPropagation(); |
||||
navigateToHashtagSearch(tag); |
||||
}} |
||||
> |
||||
#{tag} |
||||
</button> |
||||
{/each} |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
</Card> |
||||
{/if} |
||||
@ -1,198 +0,0 @@
@@ -1,198 +0,0 @@
|
||||
<script lang="ts"> |
||||
import type { PublicationTree } from "$lib/data_structures/publication_tree"; |
||||
import { |
||||
contentParagraph, |
||||
sectionHeading, |
||||
} from "$lib/snippets/PublicationSnippets.svelte"; |
||||
import { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||
import { TextPlaceholder } from "flowbite-svelte"; |
||||
import { getContext } from "svelte"; |
||||
import type { Asciidoctor, Document } from "asciidoctor"; |
||||
import { getMatchingTags } from "$lib/utils/nostrUtils"; |
||||
import { postProcessAdvancedAsciidoctorHtml } from "$lib/utils/markup/advancedAsciidoctorPostProcessor"; |
||||
import { goto } from '$app/navigation'; |
||||
|
||||
let { |
||||
address, |
||||
rootAddress, |
||||
leaves, |
||||
ref, |
||||
}: { |
||||
address: string; |
||||
rootAddress: string; |
||||
leaves: Array<NDKEvent | null>; |
||||
ref: (ref: HTMLElement) => void; |
||||
} = $props(); |
||||
|
||||
console.debug(`[PublicationSection] Received address: ${address}`); |
||||
console.debug(`[PublicationSection] Root address: ${rootAddress}`); |
||||
console.debug(`[PublicationSection] Leaves count: ${leaves.length}`); |
||||
|
||||
const publicationTree: PublicationTree = getContext("publicationTree"); |
||||
const asciidoctor: Asciidoctor = getContext("asciidoctor"); |
||||
|
||||
let leafEvent: Promise<NDKEvent | null> = $derived.by( |
||||
async () => { |
||||
console.debug(`[PublicationSection] Getting event for address: ${address}`); |
||||
const event = await publicationTree.getEvent(address); |
||||
console.debug(`[PublicationSection] Retrieved event: ${event?.id}`); |
||||
return event; |
||||
}, |
||||
); |
||||
|
||||
let rootEvent: Promise<NDKEvent | null> = $derived.by( |
||||
async () => await publicationTree.getEvent(rootAddress), |
||||
); |
||||
|
||||
let publicationType: Promise<string | undefined> = $derived.by( |
||||
async () => (await rootEvent)?.getMatchingTags("type")[0]?.[1], |
||||
); |
||||
|
||||
let leafHierarchy: Promise<NDKEvent[]> = $derived.by( |
||||
async () => await publicationTree.getHierarchy(address), |
||||
); |
||||
|
||||
let leafTitle: Promise<string | undefined> = $derived.by( |
||||
async () => (await leafEvent)?.getMatchingTags("title")[0]?.[1], |
||||
); |
||||
|
||||
let leafContent: Promise<string | Document> = $derived.by(async () => { |
||||
const rawContent = (await leafEvent)?.content ?? ""; |
||||
const asciidoctorHtml = asciidoctor.convert(rawContent); |
||||
return await postProcessAdvancedAsciidoctorHtml(asciidoctorHtml.toString()); |
||||
}); |
||||
|
||||
let leafHashtags: Promise<string[]> = $derived.by( |
||||
async () => (await leafEvent)?.getMatchingTags("t").map((tag: string[]) => tag[1]) ?? [], |
||||
); |
||||
|
||||
let previousLeafEvent: NDKEvent | null = $derived.by(() => { |
||||
let index: number; |
||||
let event: NDKEvent | null = null; |
||||
let decrement = 1; |
||||
|
||||
do { |
||||
index = leaves.findIndex((leaf) => leaf?.tagAddress() === address); |
||||
if (index === 0) { |
||||
return null; |
||||
} |
||||
event = leaves[index - decrement++]; |
||||
} while (event == null && index - decrement >= 0); |
||||
|
||||
return event; |
||||
}); |
||||
|
||||
let previousLeafHierarchy: Promise<NDKEvent[] | null> = $derived.by( |
||||
async () => { |
||||
if (!previousLeafEvent) { |
||||
return null; |
||||
} |
||||
return await publicationTree.getHierarchy(previousLeafEvent.tagAddress()); |
||||
}, |
||||
); |
||||
|
||||
let divergingBranches = $derived.by(async () => { |
||||
let [leafHierarchyValue, previousLeafHierarchyValue] = await Promise.all([ |
||||
leafHierarchy, |
||||
previousLeafHierarchy, |
||||
]); |
||||
|
||||
const branches: [NDKEvent, number][] = []; |
||||
|
||||
if (!previousLeafHierarchyValue) { |
||||
for (let i = 0; i < leafHierarchyValue.length - 1; i++) { |
||||
branches.push([leafHierarchyValue[i], i]); |
||||
} |
||||
return branches; |
||||
} |
||||
|
||||
const minLength = Math.min( |
||||
leafHierarchyValue.length, |
||||
previousLeafHierarchyValue.length, |
||||
); |
||||
|
||||
// Find the first diverging node. |
||||
let divergingIndex = 0; |
||||
while ( |
||||
divergingIndex < minLength && |
||||
leafHierarchyValue[divergingIndex].tagAddress() === |
||||
previousLeafHierarchyValue[divergingIndex].tagAddress() |
||||
) { |
||||
divergingIndex++; |
||||
} |
||||
|
||||
// Add all branches from the first diverging node to the current leaf. |
||||
for (let i = divergingIndex; i < leafHierarchyValue.length - 1; i++) { |
||||
branches.push([leafHierarchyValue[i], i]); |
||||
} |
||||
|
||||
return branches; |
||||
}); |
||||
|
||||
let sectionRef: HTMLElement; |
||||
|
||||
function navigateToHashtagSearch(tag: string): void { |
||||
const encoded = encodeURIComponent(tag); |
||||
goto(`/events?t=${encoded}`, { |
||||
replaceState: false, |
||||
keepFocus: true, |
||||
noScroll: true, |
||||
}); |
||||
} |
||||
|
||||
$effect(() => { |
||||
if (!sectionRef) { |
||||
return; |
||||
} |
||||
|
||||
ref(sectionRef); |
||||
}); |
||||
|
||||
|
||||
|
||||
</script> |
||||
|
||||
<section |
||||
id={address} |
||||
bind:this={sectionRef} |
||||
class="publication-leather content-visibility-auto" |
||||
> |
||||
{#await Promise.all( [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches, leafEvent, leafHashtags], )} |
||||
<TextPlaceholder size="xxl" /> |
||||
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches, resolvedLeafEvent, hashtags]} |
||||
{@const contentString = leafContent.toString()} |
||||
|
||||
|
||||
|
||||
{#each divergingBranches as [branch, depth]} |
||||
{@render sectionHeading( |
||||
getMatchingTags(branch, "title")[0]?.[1] ?? "", |
||||
depth, |
||||
)} |
||||
{/each} |
||||
{#if leafTitle} |
||||
{@const leafDepth = leafHierarchy.length - 1} |
||||
{@render sectionHeading(leafTitle, leafDepth)} |
||||
{/if} |
||||
{@render contentParagraph( |
||||
contentString, |
||||
publicationType ?? "article", |
||||
false, |
||||
)} |
||||
{#if hashtags.length > 0} |
||||
<div class="tags my-2 flex flex-wrap gap-1"> |
||||
{#each hashtags as tag (tag)} |
||||
<button |
||||
class="text-sm text-primary-600 dark:text-primary-500 hover:text-primary-800 dark:hover:text-primary-300 hover:underline cursor-pointer" |
||||
onclick={(e: MouseEvent) => { |
||||
e.stopPropagation(); |
||||
navigateToHashtagSearch(tag); |
||||
}} |
||||
> |
||||
#{tag} |
||||
</button> |
||||
{/each} |
||||
</div> |
||||
{/if} |
||||
{/await} |
||||
</section> |
||||
@ -1,28 +0,0 @@
@@ -1,28 +0,0 @@
|
||||
<script lang="ts"> |
||||
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||
import { nip19 } from "nostr-tools"; |
||||
export let notes: NDKEvent[] = []; |
||||
// check if notes is empty |
||||
if (notes.length === 0) { |
||||
console.debug("notes is empty"); |
||||
} |
||||
</script> |
||||
|
||||
<div class="toc"> |
||||
<h2>Table of contents</h2> |
||||
<ul> |
||||
{#each notes as note} |
||||
<li> |
||||
<a href="#{nip19.noteEncode(note.id)}" |
||||
>{note.getMatchingTags("title")[0][1]}</a |
||||
> |
||||
</li> |
||||
{/each} |
||||
</ul> |
||||
</div> |
||||
|
||||
<style> |
||||
.toc h2 { |
||||
text-align: center; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
|
||||
<script lang="ts"> |
||||
import { ndkInstance } from '$lib/ndk'; |
||||
import { naddrEncode } from '$lib/utils'; |
||||
import type { NDKEvent } from '@nostr-dev-kit/ndk'; |
||||
import { standardRelays } from '../../consts'; |
||||
import { Card, Img } from "flowbite-svelte"; |
||||
import CardActions from "$components/util/CardActions.svelte"; |
||||
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; |
||||
|
||||
const { event } = $props<{ event: NDKEvent }>(); |
||||
|
||||
const relays = $derived.by(() => { |
||||
return $ndkInstance.activeUser?.relayUrls ?? standardRelays; |
||||
}); |
||||
|
||||
const href = $derived.by(() => { |
||||
const d = event.getMatchingTags('d')[0]?.[1]; |
||||
if (d != null) { |
||||
return `publication?d=${d}`; |
||||
} else { |
||||
return `publication?id=${naddrEncode(event, relays)}`; |
||||
} |
||||
} |
||||
); |
||||
|
||||
let title: string = $derived(event.getMatchingTags('title')[0]?.[1]); |
||||
let author: string = $derived(event.getMatchingTags(event, 'author')[0]?.[1] ?? 'unknown'); |
||||
let version: string = $derived(event.getMatchingTags('version')[0]?.[1] ?? '1'); |
||||
let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null); |
||||
let authorPubkey: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? null); |
||||
|
||||
console.log("PublicationHeader event:", event); |
||||
</script> |
||||
|
||||
{#if title != null && href != null} |
||||
<Card class='ArticleBox card-leather max-w-md flex flex-row space-x-2'> |
||||
{#if image} |
||||
<div class="flex col justify-center align-middle max-h-36 max-w-24 overflow-hidden"> |
||||
<Img src={image} class="rounded w-full h-full object-cover"/> |
||||
</div> |
||||
{/if} |
||||
<div class='col flex flex-row flex-grow space-x-4'> |
||||
<div class="flex flex-col flex-grow"> |
||||
<a href="/{href}" class='flex flex-col space-y-2'> |
||||
<h2 class='text-lg font-bold line-clamp-2' title="{title}">{title}</h2> |
||||
<h3 class='text-base font-normal'> |
||||
by |
||||
{#if authorPubkey != null} |
||||
{@render userBadge(authorPubkey, author)} |
||||
{:else} |
||||
{author} |
||||
{/if} |
||||
</h3> |
||||
{#if version != '1'} |
||||
<h3 class='text-base font-thin'>version: {version}</h3> |
||||
{/if} |
||||
</a> |
||||
</div> |
||||
<div class="flex flex-col justify-start items-center"> |
||||
<CardActions event={event} /> |
||||
</div> |
||||
</div> |
||||
</Card> |
||||
{/if} |
||||
@ -0,0 +1,122 @@
@@ -0,0 +1,122 @@
|
||||
<script lang='ts'> |
||||
import type { PublicationTree } from "$lib/data_structures/publication_tree"; |
||||
import { contentParagraph, sectionHeading } from "$lib/snippets/PublicationSnippets.svelte"; |
||||
import { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||
import { TextPlaceholder } from "flowbite-svelte"; |
||||
import { getContext } from "svelte"; |
||||
import type { Asciidoctor, Document } from "asciidoctor"; |
||||
import { getMatchingTags } from '$lib/utils/nostrUtils'; |
||||
import type { SveltePublicationTree } from "./svelte_publication_tree.svelte"; |
||||
|
||||
let { |
||||
address, |
||||
rootAddress, |
||||
leaves, |
||||
ref, |
||||
}: { |
||||
address: string, |
||||
rootAddress: string, |
||||
leaves: Array<NDKEvent | null>, |
||||
ref: (ref: HTMLElement) => void, |
||||
} = $props(); |
||||
|
||||
const publicationTree: SveltePublicationTree = getContext('publicationTree'); |
||||
const asciidoctor: Asciidoctor = getContext('asciidoctor'); |
||||
|
||||
let leafEvent: Promise<NDKEvent | null> = $derived.by(async () => |
||||
await publicationTree.getEvent(address)); |
||||
|
||||
let rootEvent: Promise<NDKEvent | null> = $derived.by(async () => |
||||
await publicationTree.getEvent(rootAddress)); |
||||
|
||||
let publicationType: Promise<string | undefined> = $derived.by(async () => |
||||
(await rootEvent)?.getMatchingTags('type')[0]?.[1]); |
||||
|
||||
let leafHierarchy: Promise<NDKEvent[]> = $derived.by(async () => |
||||
await publicationTree.getHierarchy(address)); |
||||
|
||||
let leafTitle: Promise<string | undefined> = $derived.by(async () => |
||||
(await leafEvent)?.getMatchingTags('title')[0]?.[1]); |
||||
|
||||
let leafContent: Promise<string | Document> = $derived.by(async () => |
||||
asciidoctor.convert((await leafEvent)?.content ?? '')); |
||||
|
||||
let previousLeafEvent: NDKEvent | null = $derived.by(() => { |
||||
let index: number; |
||||
let event: NDKEvent | null = null; |
||||
let decrement = 1; |
||||
|
||||
do { |
||||
index = leaves.findIndex(leaf => leaf?.tagAddress() === address); |
||||
if (index === 0) { |
||||
return null; |
||||
} |
||||
event = leaves[index - decrement++]; |
||||
} while (event == null && index - decrement >= 0); |
||||
|
||||
return event; |
||||
}); |
||||
|
||||
let previousLeafHierarchy: Promise<NDKEvent[] | null> = $derived.by(async () => { |
||||
if (!previousLeafEvent) { |
||||
return null; |
||||
} |
||||
return await publicationTree.getHierarchy(previousLeafEvent.tagAddress()); |
||||
}); |
||||
|
||||
let divergingBranches = $derived.by(async () => { |
||||
let [leafHierarchyValue, previousLeafHierarchyValue] = await Promise.all([leafHierarchy, previousLeafHierarchy]); |
||||
|
||||
const branches: [NDKEvent, number][] = []; |
||||
|
||||
if (!previousLeafHierarchyValue) { |
||||
for (let i = 0; i < leafHierarchyValue.length - 1; i++) { |
||||
branches.push([leafHierarchyValue[i], i]); |
||||
} |
||||
return branches; |
||||
} |
||||
|
||||
const minLength = Math.min(leafHierarchyValue.length, previousLeafHierarchyValue.length); |
||||
|
||||
// Find the first diverging node. |
||||
let divergingIndex = 0; |
||||
while ( |
||||
divergingIndex < minLength && |
||||
leafHierarchyValue[divergingIndex].tagAddress() === previousLeafHierarchyValue[divergingIndex].tagAddress() |
||||
) { |
||||
divergingIndex++; |
||||
} |
||||
|
||||
// Add all branches from the first diverging node to the current leaf. |
||||
for (let i = divergingIndex; i < leafHierarchyValue.length - 1; i++) { |
||||
branches.push([leafHierarchyValue[i], i]); |
||||
} |
||||
|
||||
return branches; |
||||
}); |
||||
|
||||
let sectionRef: HTMLElement; |
||||
|
||||
$effect(() => { |
||||
if (!sectionRef) { |
||||
return; |
||||
} |
||||
|
||||
ref(sectionRef); |
||||
}); |
||||
</script> |
||||
|
||||
<section id={address} bind:this={sectionRef} class='publication-leather content-visibility-auto'> |
||||
{#await Promise.all([leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches])} |
||||
<TextPlaceholder size='xxl' /> |
||||
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]} |
||||
{#each divergingBranches as [branch, depth]} |
||||
{@render sectionHeading(getMatchingTags(branch, 'title')[0]?.[1] ?? '', depth)} |
||||
{/each} |
||||
{#if leafTitle} |
||||
{@const leafDepth = leafHierarchy.length - 1} |
||||
{@render sectionHeading(leafTitle, leafDepth)} |
||||
{/if} |
||||
{@render contentParagraph(leafContent.toString(), publicationType ?? 'article', false)} |
||||
{/await} |
||||
</section> |
||||
@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
|
||||
<script lang='ts'> |
||||
import { |
||||
TableOfContents, |
||||
type TocEntry |
||||
} from '$lib/components/publications/table_of_contents.svelte'; |
||||
import { getContext } from 'svelte'; |
||||
import { SidebarDropdownWrapper, SidebarGroup, SidebarItem } from 'flowbite-svelte'; |
||||
import Self from './TableOfContents.svelte'; |
||||
|
||||
let { |
||||
depth, |
||||
onSectionFocused, |
||||
} = $props<{ |
||||
rootAddress: string; |
||||
depth: number; |
||||
onSectionFocused?: (address: string) => void; |
||||
}>(); |
||||
|
||||
let toc = getContext('toc') as TableOfContents; |
||||
|
||||
let entries = $derived.by<TocEntry[]>(() => { |
||||
const newEntries = []; |
||||
for (const [_, entry] of toc.addressMap) { |
||||
if (entry.depth !== depth) { |
||||
continue; |
||||
} |
||||
|
||||
newEntries.push(entry); |
||||
} |
||||
|
||||
return newEntries; |
||||
}); |
||||
|
||||
function setEntryExpanded(address: string, expanded: boolean = false) { |
||||
const entry = toc.getEntry(address); |
||||
if (!entry) { |
||||
return; |
||||
} |
||||
|
||||
toc.expandedMap.set(address, expanded); |
||||
entry.resolveChildren(); |
||||
} |
||||
</script> |
||||
|
||||
<!-- TODO: Figure out how to style indentations. --> |
||||
<!-- TODO: Make group title fonts the same as entry title fonts. --> |
||||
<SidebarGroup> |
||||
{#each entries as entry} |
||||
{@const address = entry.address} |
||||
{@const expanded = toc.expandedMap.get(address) ?? false} |
||||
{@const isLeaf = toc.leaves.has(address)} |
||||
{#if isLeaf} |
||||
<SidebarItem |
||||
label={entry.title} |
||||
href={`#${address}`} |
||||
spanClass='px-2 text-ellipsis' |
||||
onclick={() => onSectionFocused?.(address)} |
||||
/> |
||||
{:else} |
||||
{@const childDepth = depth + 1} |
||||
<SidebarDropdownWrapper |
||||
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' |
||||
bind:isOpen={ |
||||
() => expanded, |
||||
(open) => setEntryExpanded(address, open) |
||||
} |
||||
> |
||||
<Self |
||||
rootAddress={address} |
||||
depth={childDepth} |
||||
onSectionFocused={onSectionFocused} |
||||
/> |
||||
</SidebarDropdownWrapper> |
||||
{/if} |
||||
{/each} |
||||
</SidebarGroup> |
||||
@ -0,0 +1,111 @@
@@ -0,0 +1,111 @@
|
||||
import { SvelteSet } from "svelte/reactivity"; |
||||
import { PublicationTree } from "../../data_structures/publication_tree.ts"; |
||||
import NDK, { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||
|
||||
export class SveltePublicationTree { |
||||
resolvedAddresses: SvelteSet<string> = new SvelteSet(); |
||||
|
||||
#publicationTree: PublicationTree; |
||||
#nodeResolvedObservers: Array<(address: string) => void> = []; |
||||
#bookmarkMovedObservers: Array<(address: string) => void> = []; |
||||
|
||||
constructor(rootEvent: NDKEvent, ndk: NDK) { |
||||
this.#publicationTree = new PublicationTree(rootEvent, ndk); |
||||
|
||||
this.#publicationTree.onNodeResolved(this.#handleNodeResolved); |
||||
this.#publicationTree.onBookmarkMoved(this.#handleBookmarkMoved); |
||||
} |
||||
|
||||
// #region Proxied Public Methods
|
||||
|
||||
getChildAddresses(address: string): Promise<Array<string | null>> { |
||||
return this.#publicationTree.getChildAddresses(address); |
||||
} |
||||
|
||||
getEvent(address: string): Promise<NDKEvent | null> { |
||||
return this.#publicationTree.getEvent(address); |
||||
} |
||||
|
||||
getHierarchy(address: string): Promise<NDKEvent[]> { |
||||
return this.#publicationTree.getHierarchy(address); |
||||
} |
||||
|
||||
async getParent(address: string): Promise<NDKEvent | null> { |
||||
const hierarchy = await this.getHierarchy(address); |
||||
|
||||
// The last element in the hierarchy is the event with the given address, so the parent is the
|
||||
// second to last element.
|
||||
return hierarchy.at(-2) ?? null; |
||||
} |
||||
|
||||
setBookmark(address: string) { |
||||
this.#publicationTree.setBookmark(address); |
||||
} |
||||
|
||||
/** |
||||
* Registers an observer function that is invoked whenever a new node is resolved. |
||||
* @param observer The observer function. |
||||
*/ |
||||
onNodeResolved(observer: (address: string) => void) { |
||||
this.#nodeResolvedObservers.push(observer); |
||||
} |
||||
|
||||
/** |
||||
* Registers an observer function that is invoked whenever the bookmark is moved. |
||||
* @param observer The observer function. |
||||
*/ |
||||
onBookmarkMoved(observer: (address: string) => void) { |
||||
this.#bookmarkMovedObservers.push(observer); |
||||
} |
||||
|
||||
// #endregion
|
||||
|
||||
// #region Proxied Async Iterator Methods
|
||||
|
||||
[Symbol.asyncIterator](): AsyncIterator<NDKEvent | null> { |
||||
return this; |
||||
} |
||||
|
||||
next(): Promise<IteratorResult<NDKEvent | null>> { |
||||
return this.#publicationTree.next(); |
||||
} |
||||
|
||||
previous(): Promise<IteratorResult<NDKEvent | null>> { |
||||
return this.#publicationTree.previous(); |
||||
} |
||||
|
||||
// #endregion
|
||||
|
||||
// #region Private Methods
|
||||
|
||||
/** |
||||
* Observer function that is invoked whenever a new node is resolved on the publication tree. |
||||
*
|
||||
* @param address The address of the resolved node. |
||||
*
|
||||
* This member is declared as an arrow function to ensure that the correct `this` context is |
||||
* used when the function is invoked in this class's constructor. |
||||
*/ |
||||
#handleNodeResolved = (address: string) => { |
||||
this.resolvedAddresses.add(address); |
||||
for (const observer of this.#nodeResolvedObservers) { |
||||
observer(address); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Observer function that is invoked whenever the bookmark is moved on the publication tree. |
||||
*
|
||||
* @param address The address of the new bookmark. |
||||
*
|
||||
* This member is declared as an arrow function to ensure that the correct `this` context is |
||||
* used when the function is invoked in this class's constructor. |
||||
*/ |
||||
#handleBookmarkMoved = (address: string) => { |
||||
for (const observer of this.#bookmarkMovedObservers) { |
||||
observer(address); |
||||
} |
||||
} |
||||
|
||||
// #endregion
|
||||
} |
||||
@ -0,0 +1,292 @@
@@ -0,0 +1,292 @@
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity'; |
||||
import { SveltePublicationTree } from './svelte_publication_tree.svelte.ts'; |
||||
import type { NDKEvent } from '../../utils/nostrUtils.ts'; |
||||
import { indexKind } from '../../consts.ts'; |
||||
|
||||
export interface TocEntry { |
||||
address: string; |
||||
title: string; |
||||
href?: string; |
||||
children: TocEntry[]; |
||||
parent?: TocEntry; |
||||
depth: number; |
||||
childrenResolved: boolean; |
||||
resolveChildren: () => Promise<void>; |
||||
} |
||||
|
||||
/** |
||||
* Maintains a table of contents (ToC) for a `SveltePublicationTree`. Since publication trees are |
||||
* conceptually infinite and lazy-loading, the ToC represents only the portion of the tree that has |
||||
* been "discovered". The ToC is updated as new nodes are resolved within the publication tree. |
||||
*
|
||||
* @see SveltePublicationTree |
||||
*/ |
||||
export class TableOfContents { |
||||
public addressMap: SvelteMap<string, TocEntry> = new SvelteMap(); |
||||
public expandedMap: SvelteMap<string, boolean> = new SvelteMap(); |
||||
public leaves: SvelteSet<string> = new SvelteSet(); |
||||
|
||||
#root: TocEntry | null = null; |
||||
#publicationTree: SveltePublicationTree; |
||||
#pagePathname: string; |
||||
|
||||
/** |
||||
* Constructs a `TableOfContents` from a `SveltePublicationTree`. |
||||
*
|
||||
* @param rootAddress The address of the root event. |
||||
* @param publicationTree The SveltePublicationTree instance. |
||||
* @param pagePathname The current page pathname for href generation. |
||||
*/ |
||||
constructor(rootAddress: string, publicationTree: SveltePublicationTree, pagePathname: string) { |
||||
this.#publicationTree = publicationTree; |
||||
this.#pagePathname = pagePathname; |
||||
this.#init(rootAddress); |
||||
} |
||||
|
||||
// #region Public Methods
|
||||
|
||||
/** |
||||
* Returns the root entry of the ToC. |
||||
*
|
||||
* @returns The root entry of the ToC, or `null` if the ToC has not been initialized. |
||||
*/ |
||||
getRootEntry(): TocEntry | null { |
||||
return this.#root; |
||||
} |
||||
|
||||
getEntry(address: string): TocEntry | undefined { |
||||
return this.addressMap.get(address); |
||||
} |
||||
|
||||
/** |
||||
* Builds a table of contents from the DOM subtree rooted at `parentElement`. |
||||
*
|
||||
* @param parentElement The root of the DOM subtree containing the content to be added to the |
||||
* ToC. |
||||
* @param parentAddress The address of the event corresponding to the DOM subtree root indicated |
||||
* by `parentElement`. |
||||
*
|
||||
* This function is intended for use on segments of HTML markup that are not directly derived |
||||
* from a structure publication of the kind supported by `PublicationTree`. It may be used to |
||||
* produce a table of contents from the contents of a kind `30041` event with AsciiDoc markup, or |
||||
* from a kind `30023` event with Markdown content. |
||||
*/ |
||||
buildTocFromDocument( |
||||
parentElement: HTMLElement, |
||||
parentEntry: TocEntry, |
||||
) { |
||||
parentElement |
||||
.querySelectorAll<HTMLHeadingElement>(`h${parentEntry.depth}`) |
||||
.forEach((header) => { |
||||
// TODO: Correctly update ToC state from DOM.
|
||||
const title = header.textContent?.trim(); |
||||
const id = header.id; |
||||
|
||||
// Only create an entry if the header has an ID and a title.
|
||||
if (id && title) { |
||||
const href = `${this.#pagePathname}#${id}`; |
||||
|
||||
// TODO: Check this logic.
|
||||
const tocEntry: TocEntry = { |
||||
address: parentEntry.address, |
||||
title, |
||||
href, |
||||
depth: parentEntry.depth + 1, |
||||
children: [], |
||||
childrenResolved: true, |
||||
resolveChildren: () => Promise.resolve(), |
||||
}; |
||||
parentEntry.children.push(tocEntry); |
||||
this.expandedMap.set(tocEntry.address, false); |
||||
|
||||
this.buildTocFromDocument(header, tocEntry); |
||||
} |
||||
}); |
||||
} |
||||
|
||||
// #endregion
|
||||
|
||||
// #region Iterator Methods
|
||||
|
||||
/** |
||||
* Iterates over all ToC entries in depth-first order. |
||||
*/ |
||||
*[Symbol.iterator](): IterableIterator<TocEntry> { |
||||
function* traverse(entry: TocEntry | null): IterableIterator<TocEntry> { |
||||
if (!entry) { |
||||
return; |
||||
} |
||||
|
||||
yield entry; |
||||
|
||||
if (entry.children) { |
||||
for (const child of entry.children) { |
||||
yield* traverse(child); |
||||
} |
||||
} |
||||
} |
||||
|
||||
yield* traverse(this.#root); |
||||
} |
||||
|
||||
// #endregion
|
||||
|
||||
// #region Private Methods
|
||||
|
||||
/** |
||||
* Initializes the ToC from the associated publication tree. |
||||
*
|
||||
* @param rootAddress The address of the publication's root event. |
||||
*
|
||||
* Michael J - 07 July 2025 - NOTE: Since the publication tree is conceptually infinite and |
||||
* lazy-loading, the ToC is not guaranteed to contain all the nodes at any layer until the |
||||
* publication has been fully resolved. |
||||
*
|
||||
* Michael J - 07 July 2025 - TODO: If the relay provides event metadata, use the metadata to |
||||
* initialize the ToC with all of its first-level children. |
||||
*/ |
||||
async #init(rootAddress: string) { |
||||
const rootEvent = await this.#publicationTree.getEvent(rootAddress); |
||||
if (!rootEvent) { |
||||
throw new Error(`[ToC] Root event ${rootAddress} not found.`); |
||||
} |
||||
|
||||
this.#root = await this.#buildTocEntry(rootAddress); |
||||
|
||||
this.addressMap.set(rootAddress, this.#root); |
||||
|
||||
// Handle any other nodes that have already been resolved in parallel.
|
||||
await Promise.all( |
||||
Array.from(this.#publicationTree.resolvedAddresses).map((address) => |
||||
this.#buildTocEntryFromResolvedNode(address) |
||||
) |
||||
); |
||||
|
||||
// Set up an observer to handle progressive resolution of the publication tree.
|
||||
this.#publicationTree.onNodeResolved((address: string) => { |
||||
this.#buildTocEntryFromResolvedNode(address); |
||||
}); |
||||
} |
||||
|
||||
#getTitle(event: NDKEvent | null): string { |
||||
if (!event) { |
||||
// TODO: What do we want to return in this case?
|
||||
return '[untitled]'; |
||||
} |
||||
const titleTag = event.getMatchingTags?.('title')?.[0]?.[1]; |
||||
return titleTag || event.tagAddress() || '[untitled]'; |
||||
} |
||||
|
||||
async #buildTocEntry(address: string): Promise<TocEntry> { |
||||
// Michael J - 07 July 2025 - NOTE: This arrow function is nested so as to use its containing
|
||||
// scope in its operation. Do not move it to the top level without ensuring it still has access
|
||||
// to the necessary variables.
|
||||
const resolver = async () => { |
||||
if (entry.childrenResolved) { |
||||
return; |
||||
} |
||||
|
||||
const event = await this.#publicationTree.getEvent(entry.address); |
||||
if (event?.kind !== indexKind) { |
||||
// TODO: Build ToC entries from HTML markup in this case.
|
||||
return; |
||||
} |
||||
|
||||
const childAddresses = await this.#publicationTree.getChildAddresses(entry.address); |
||||
for (const childAddress of childAddresses) { |
||||
if (!childAddress) { |
||||
continue; |
||||
} |
||||
|
||||
// Michael J - 16 June 2025 - This duplicates logic in the outer function, but is necessary
|
||||
// here so that we can determine whether to render an entry as a leaf before it is fully
|
||||
// resolved.
|
||||
if (childAddress.split(':')[0] !== indexKind.toString()) { |
||||
this.leaves.add(childAddress); |
||||
} |
||||
|
||||
// Michael J - 05 June 2025 - The `getChildAddresses` method forces node resolution on the
|
||||
// publication tree. This is acceptable here, because the tree is always resolved
|
||||
// top-down. Therefore, by the time we handle a node's resolution, its parent and
|
||||
// siblings have already been resolved.
|
||||
const childEntry = await this.#buildTocEntry(childAddress); |
||||
childEntry.parent = entry; |
||||
childEntry.depth = entry.depth + 1; |
||||
entry.children.push(childEntry); |
||||
this.addressMap.set(childAddress, childEntry); |
||||
} |
||||
|
||||
await this.#matchChildrenToTagOrder(entry); |
||||
|
||||
entry.childrenResolved = true; |
||||
} |
||||
|
||||
const event = await this.#publicationTree.getEvent(address); |
||||
if (!event) { |
||||
throw new Error(`[ToC] Event ${address} not found.`); |
||||
} |
||||
|
||||
const depth = (await this.#publicationTree.getHierarchy(address)).length; |
||||
|
||||
const entry: TocEntry = { |
||||
address, |
||||
title: this.#getTitle(event), |
||||
href: `${this.#pagePathname}#${address}`, |
||||
children: [], |
||||
depth, |
||||
childrenResolved: false, |
||||
resolveChildren: resolver, |
||||
}; |
||||
this.expandedMap.set(address, false); |
||||
|
||||
// Michael J - 16 June 2025 - We determine whether to add a leaf both here and in the inner
|
||||
// resolver function. The resolver function is called when entries are resolved by expanding
|
||||
// a ToC entry, and we'll reach the block below when entries are resolved by the publication
|
||||
// tree.
|
||||
if (event.kind !== indexKind) { |
||||
this.leaves.add(address); |
||||
} |
||||
|
||||
return entry; |
||||
} |
||||
|
||||
/** |
||||
* Reorders the children of a ToC entry to match the order of 'a' tags in the corresponding |
||||
* Nostr index event. |
||||
*
|
||||
* @param entry The ToC entry to reorder. |
||||
*
|
||||
* This function has a time complexity of `O(n log n)`, where `n` is the number of children the |
||||
* parent event has. Average size of `n` is small enough to be negligible. |
||||
*/ |
||||
async #matchChildrenToTagOrder(entry: TocEntry) { |
||||
const parentEvent = await this.#publicationTree.getEvent(entry.address); |
||||
if (parentEvent?.kind === indexKind) { |
||||
const tagOrder = parentEvent.getMatchingTags('a').map(tag => tag[1]); |
||||
const addressToOrdinal = new Map<string, number>(); |
||||
|
||||
// Build map of addresses to their ordinals from tag order
|
||||
tagOrder.forEach((address, index) => { |
||||
addressToOrdinal.set(address, index); |
||||
}); |
||||
|
||||
entry.children.sort((a, b) => { |
||||
const aOrdinal = addressToOrdinal.get(a.address) ?? Number.MAX_SAFE_INTEGER; |
||||
const bOrdinal = addressToOrdinal.get(b.address) ?? Number.MAX_SAFE_INTEGER; |
||||
return aOrdinal - bOrdinal; |
||||
}); |
||||
} |
||||
} |
||||
|
||||
#buildTocEntryFromResolvedNode(address: string) { |
||||
if (this.addressMap.has(address)) { |
||||
return; |
||||
} |
||||
|
||||
this.#buildTocEntry(address).then((entry) => { |
||||
this.addressMap.set(address, entry); |
||||
}); |
||||
} |
||||
|
||||
// #endregion
|
||||
} |
||||
@ -1,150 +0,0 @@
@@ -1,150 +0,0 @@
|
||||
<script lang="ts"> |
||||
import { |
||||
Heading, |
||||
Sidebar, |
||||
SidebarGroup, |
||||
SidebarItem, |
||||
SidebarWrapper, |
||||
} from "flowbite-svelte"; |
||||
import { onMount } from "svelte"; |
||||
import { pharosInstance, tocUpdate } from "$lib/parser"; |
||||
import { publicationColumnVisibility } from "$lib/stores"; |
||||
|
||||
let { rootId } = $props<{ rootId: string }>(); |
||||
|
||||
if (rootId !== $pharosInstance.getRootIndexId()) { |
||||
console.error("Root ID does not match parser root index ID"); |
||||
} |
||||
|
||||
const tocBreakpoint = 1140; |
||||
|
||||
let activeHash = $state(window.location.hash); |
||||
|
||||
interface TocItem { |
||||
label: string; |
||||
hash: string; |
||||
} |
||||
|
||||
// Get TOC items from parser |
||||
let tocItems = $state<TocItem[]>([]); |
||||
|
||||
$effect(() => { |
||||
// This will re-run whenever tocUpdate changes |
||||
tocUpdate; |
||||
const items: TocItem[] = []; |
||||
const childIds = $pharosInstance.getChildIndexIds(rootId); |
||||
console.log("TOC rootId:", rootId, "childIds:", childIds); |
||||
const processNode = (nodeId: string) => { |
||||
const title = $pharosInstance.getIndexTitle(nodeId); |
||||
if (title) { |
||||
items.push({ |
||||
label: title, |
||||
hash: `#${nodeId}`, |
||||
}); |
||||
} |
||||
const children = $pharosInstance.getChildIndexIds(nodeId); |
||||
children.forEach(processNode); |
||||
}; |
||||
childIds.forEach(processNode); |
||||
tocItems = items; |
||||
}); |
||||
|
||||
function normalizeHashPath(str: string): string { |
||||
return str |
||||
.toLowerCase() |
||||
.replace(/\s+/g, "-") |
||||
.replace(/[^\w-]/g, ""); |
||||
} |
||||
|
||||
function scrollToElementWithOffset() { |
||||
const hash = window.location.hash; |
||||
if (hash) { |
||||
const targetElement = document.querySelector(hash); |
||||
if (targetElement) { |
||||
const headerOffset = 80; |
||||
const elementPosition = targetElement.getBoundingClientRect().top; |
||||
const offsetPosition = elementPosition + window.scrollY - headerOffset; |
||||
|
||||
window.scrollTo({ |
||||
top: offsetPosition, |
||||
behavior: "auto", |
||||
}); |
||||
} |
||||
} |
||||
} |
||||
|
||||
function updateActiveHash() { |
||||
activeHash = window.location.hash; |
||||
} |
||||
|
||||
/** |
||||
* Hides the table of contents sidebar when the window shrinks below a certain size. This |
||||
* prevents the sidebar from occluding the article content. |
||||
*/ |
||||
function setTocVisibilityOnResize() { |
||||
// Always show TOC on laptop and larger screens, collapsible only on small/medium |
||||
publicationColumnVisibility.update((v) => ({ |
||||
...v, |
||||
toc: window.innerWidth >= tocBreakpoint, |
||||
})); |
||||
} |
||||
|
||||
/** |
||||
* Hides the table of contents sidebar when the user clicks outside of it. |
||||
*/ |
||||
function hideTocOnClick(ev: MouseEvent) { |
||||
const target = ev.target as HTMLElement; |
||||
|
||||
if (target.closest(".sidebar-leather") || target.closest(".btn-leather")) { |
||||
return; |
||||
} |
||||
|
||||
// Only allow hiding TOC on screens smaller than tocBreakpoint |
||||
if (window.innerWidth < tocBreakpoint && $publicationColumnVisibility.toc) { |
||||
publicationColumnVisibility.update((v) => ({ ...v, toc: false })); |
||||
} |
||||
} |
||||
|
||||
onMount(() => { |
||||
// Always check whether the TOC sidebar should be visible. |
||||
setTocVisibilityOnResize(); |
||||
|
||||
window.addEventListener("hashchange", updateActiveHash); |
||||
window.addEventListener("hashchange", scrollToElementWithOffset); |
||||
// Also handle the case where the user lands on the page with a hash in the URL |
||||
scrollToElementWithOffset(); |
||||
|
||||
window.addEventListener("resize", setTocVisibilityOnResize); |
||||
window.addEventListener("click", hideTocOnClick); |
||||
|
||||
return () => { |
||||
window.removeEventListener("hashchange", updateActiveHash); |
||||
window.removeEventListener("hashchange", scrollToElementWithOffset); |
||||
window.removeEventListener("resize", setTocVisibilityOnResize); |
||||
window.removeEventListener("click", hideTocOnClick); |
||||
}; |
||||
}); |
||||
</script> |
||||
|
||||
<!-- TODO: Get TOC from parser. --> |
||||
{#if $publicationColumnVisibility.toc} |
||||
<Sidebar class="sidebar-leather left-0"> |
||||
<SidebarWrapper> |
||||
<SidebarGroup class="sidebar-group-leather"> |
||||
<Heading tag="h1" class="h-leather !text-lg">Table of contents</Heading> |
||||
<p> |
||||
(This ToC is only for demo purposes, and is not fully-functional.) |
||||
</p> |
||||
{#each tocItems as item} |
||||
<SidebarItem |
||||
class="sidebar-item-leather {activeHash === item.hash |
||||
? 'bg-primary-200 font-bold' |
||||
: ''}" |
||||
label={item.label} |
||||
href={item.hash} |
||||
/> |
||||
{/each} |
||||
</SidebarGroup> |
||||
</SidebarWrapper> |
||||
</Sidebar> |
||||
{/if} |
||||
Loading…
Reference in new issue