12 changed files with 463 additions and 15 deletions
@ -0,0 +1,25 @@ |
|||||||
|
import { redirect } from "@sveltejs/kit"; |
||||||
|
import type { PageServerLoad } from "./$types"; |
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ url }) => { |
||||||
|
const id = url.searchParams.get("id"); |
||||||
|
const dTag = url.searchParams.get("d"); |
||||||
|
|
||||||
|
// Handle backward compatibility for old query-based routes
|
||||||
|
if (id) { |
||||||
|
// Check if id is an naddr or nevent
|
||||||
|
if (id.startsWith("naddr")) { |
||||||
|
throw redirect(301, `/publication/naddr/${id}`); |
||||||
|
} else if (id.startsWith("nevent")) { |
||||||
|
throw redirect(301, `/publication/nevent/${id}`); |
||||||
|
} else { |
||||||
|
// Assume it's a hex ID
|
||||||
|
throw redirect(301, `/publication/id/${id}`); |
||||||
|
} |
||||||
|
} else if (dTag) { |
||||||
|
throw redirect(301, `/publication/d/${dTag}`); |
||||||
|
} |
||||||
|
|
||||||
|
// If no query parameters, redirect to the start page or show publication feed
|
||||||
|
throw redirect(301, "/start"); |
||||||
|
};
|
||||||
@ -0,0 +1,132 @@ |
|||||||
|
import { error } from "@sveltejs/kit"; |
||||||
|
import type { LayoutServerLoad } from "./$types"; |
||||||
|
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||||
|
import { getActiveRelaySetAsNDKRelaySet } from "../../../../lib/ndk.ts"; |
||||||
|
import { getMatchingTags } from "../../../../lib/utils/nostrUtils.ts"; |
||||||
|
import { naddrDecode, neventDecode } from "../../../../lib/utils.ts"; |
||||||
|
import type NDK from "@nostr-dev-kit/ndk"; |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetches an event by hex ID |
||||||
|
*/ |
||||||
|
async function fetchEventById(ndk: NDK, id: string): Promise<NDKEvent> { |
||||||
|
try { |
||||||
|
const event = await ndk.fetchEvent(id); |
||||||
|
if (!event) { |
||||||
|
throw new Error(`Event not found for ID: ${id}`); |
||||||
|
} |
||||||
|
return event; |
||||||
|
} catch (err) { |
||||||
|
throw error(404, `Failed to fetch publication root event.\n${err}`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetches an event by d tag |
||||||
|
*/ |
||||||
|
async function fetchEventByDTag(ndk: NDK, dTag: string): Promise<NDKEvent> { |
||||||
|
try { |
||||||
|
const relaySet = await getActiveRelaySetAsNDKRelaySet(ndk, true); |
||||||
|
const events = await ndk.fetchEvents( |
||||||
|
{ "#d": [dTag] }, |
||||||
|
{ closeOnEose: false }, |
||||||
|
relaySet, |
||||||
|
); |
||||||
|
|
||||||
|
if (!events || events.size === 0) { |
||||||
|
throw new Error(`Event not found for d tag: ${dTag}`); |
||||||
|
} |
||||||
|
|
||||||
|
// Choose the event with the latest created_at timestamp when multiple events share the same d tag
|
||||||
|
const sortedEvents = Array.from(events).sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); |
||||||
|
return sortedEvents[0]; |
||||||
|
} catch (err) { |
||||||
|
throw error(404, `Failed to fetch publication root event.\n${err}`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetches an event by naddr identifier |
||||||
|
*/ |
||||||
|
async function fetchEventByNaddr(ndk: NDK, naddr: string): Promise<NDKEvent> { |
||||||
|
try { |
||||||
|
const decoded = naddrDecode(naddr); |
||||||
|
const relaySet = await getActiveRelaySetAsNDKRelaySet(ndk, true); |
||||||
|
|
||||||
|
const filter = { |
||||||
|
kinds: [decoded.kind], |
||||||
|
authors: [decoded.pubkey], |
||||||
|
"#d": [decoded.identifier], |
||||||
|
}; |
||||||
|
|
||||||
|
const event = await ndk.fetchEvent(filter, { closeOnEose: false }, relaySet); |
||||||
|
if (!event) { |
||||||
|
throw new Error(`Event not found for naddr: ${naddr}`); |
||||||
|
} |
||||||
|
return event; |
||||||
|
} catch (err) { |
||||||
|
throw error(404, `Failed to fetch publication root event.\n${err}`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetches an event by nevent identifier |
||||||
|
*/ |
||||||
|
async function fetchEventByNevent(ndk: NDK, nevent: string): Promise<NDKEvent> { |
||||||
|
try { |
||||||
|
const decoded = neventDecode(nevent); |
||||||
|
const event = await ndk.fetchEvent(decoded.id); |
||||||
|
if (!event) { |
||||||
|
throw new Error(`Event not found for nevent: ${nevent}`); |
||||||
|
} |
||||||
|
return event; |
||||||
|
} catch (err) { |
||||||
|
throw error(404, `Failed to fetch publication root event.\n${err}`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export const load: LayoutServerLoad = async ({ params, parent, url }) => { |
||||||
|
const { type, identifier } = params; |
||||||
|
const { ndk } = await parent(); |
||||||
|
|
||||||
|
if (!ndk) { |
||||||
|
throw error(500, "NDK not available"); |
||||||
|
} |
||||||
|
|
||||||
|
let indexEvent: NDKEvent; |
||||||
|
|
||||||
|
// Handle different identifier types
|
||||||
|
switch (type) { |
||||||
|
case 'id': |
||||||
|
indexEvent = await fetchEventById(ndk, identifier); |
||||||
|
break; |
||||||
|
case 'd': |
||||||
|
indexEvent = await fetchEventByDTag(ndk, identifier); |
||||||
|
break; |
||||||
|
case 'naddr': |
||||||
|
indexEvent = await fetchEventByNaddr(ndk, identifier); |
||||||
|
break; |
||||||
|
case 'nevent': |
||||||
|
indexEvent = await fetchEventByNevent(ndk, identifier); |
||||||
|
break; |
||||||
|
default: |
||||||
|
throw error(400, `Unsupported identifier type: ${type}`); |
||||||
|
} |
||||||
|
|
||||||
|
// Extract metadata for meta tags
|
||||||
|
const title = getMatchingTags(indexEvent, "title")[0]?.[1] || "Alexandria Publication"; |
||||||
|
const summary = getMatchingTags(indexEvent, "summary")[0]?.[1] ||
|
||||||
|
"Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages."; |
||||||
|
const image = getMatchingTags(indexEvent, "image")[0]?.[1] || "/screenshots/old_books.jpg"; |
||||||
|
const currentUrl = `${url.origin}${url.pathname}`; |
||||||
|
|
||||||
|
return { |
||||||
|
indexEvent, |
||||||
|
metadata: { |
||||||
|
title, |
||||||
|
summary, |
||||||
|
image, |
||||||
|
currentUrl, |
||||||
|
}, |
||||||
|
}; |
||||||
|
};
|
||||||
@ -0,0 +1,28 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import type { LayoutProps } from "./$types"; |
||||||
|
|
||||||
|
let { data, children }: LayoutProps = $props(); |
||||||
|
const { metadata } = data; |
||||||
|
</script> |
||||||
|
|
||||||
|
<svelte:head> |
||||||
|
<!-- Basic meta tags --> |
||||||
|
<title>{metadata.title}</title> |
||||||
|
<meta name="description" content={metadata.summary} /> |
||||||
|
|
||||||
|
<!-- OpenGraph meta tags --> |
||||||
|
<meta property="og:title" content={metadata.title} /> |
||||||
|
<meta property="og:description" content={metadata.summary} /> |
||||||
|
<meta property="og:url" content={metadata.currentUrl} /> |
||||||
|
<meta property="og:type" content="article" /> |
||||||
|
<meta property="og:site_name" content="Alexandria" /> |
||||||
|
<meta property="og:image" content={metadata.image} /> |
||||||
|
|
||||||
|
<!-- Twitter Card meta tags --> |
||||||
|
<meta name="twitter:card" content="summary_large_image" /> |
||||||
|
<meta name="twitter:title" content={metadata.title} /> |
||||||
|
<meta name="twitter:description" content={metadata.summary} /> |
||||||
|
<meta name="twitter:image" content={metadata.image} /> |
||||||
|
</svelte:head> |
||||||
|
|
||||||
|
{@render children()} |
||||||
@ -0,0 +1,123 @@ |
|||||||
|
import { error } from "@sveltejs/kit"; |
||||||
|
import type { PageServerLoad } from "./$types"; |
||||||
|
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||||
|
import { getActiveRelaySetAsNDKRelaySet } from "../../../../lib/ndk.ts"; |
||||||
|
import { getMatchingTags } from "../../../../lib/utils/nostrUtils.ts"; |
||||||
|
import { naddrDecode, neventDecode } from "../../../../lib/utils.ts"; |
||||||
|
import type NDK from "@nostr-dev-kit/ndk"; |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetches an event by hex ID |
||||||
|
*/ |
||||||
|
async function fetchEventById(ndk: NDK, id: string): Promise<NDKEvent> { |
||||||
|
try { |
||||||
|
const event = await ndk.fetchEvent(id); |
||||||
|
if (!event) { |
||||||
|
throw new Error(`Event not found for ID: ${id}`); |
||||||
|
} |
||||||
|
return event; |
||||||
|
} catch (err) { |
||||||
|
throw error(404, `Failed to fetch publication root event.\n${err}`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetches an event by d tag |
||||||
|
*/ |
||||||
|
async function fetchEventByDTag(ndk: NDK, dTag: string): Promise<NDKEvent> { |
||||||
|
try { |
||||||
|
const relaySet = await getActiveRelaySetAsNDKRelaySet(ndk, true); // true for inbox relays
|
||||||
|
const events = await ndk.fetchEvents( |
||||||
|
{ "#d": [dTag] }, |
||||||
|
{ closeOnEose: false }, |
||||||
|
relaySet, |
||||||
|
); |
||||||
|
|
||||||
|
if (!events || events.size === 0) { |
||||||
|
throw new Error(`Event not found for d tag: ${dTag}`); |
||||||
|
} |
||||||
|
|
||||||
|
// AI-NOTE: Choose the event with the latest created_at timestamp when multiple events share the same d tag
|
||||||
|
const sortedEvents = Array.from(events).sort((a, b) => (b.created_at || 0) - (a.created_at || 0)); |
||||||
|
return sortedEvents[0]; |
||||||
|
} catch (err) { |
||||||
|
throw error(404, `Failed to fetch publication root event.\n${err}`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetches an event by naddr identifier |
||||||
|
*/ |
||||||
|
async function fetchEventByNaddr(ndk: NDK, naddr: string): Promise<NDKEvent> { |
||||||
|
try { |
||||||
|
const decoded = naddrDecode(naddr); |
||||||
|
const relaySet = await getActiveRelaySetAsNDKRelaySet(ndk, true); |
||||||
|
|
||||||
|
const filter = { |
||||||
|
kinds: [decoded.kind], |
||||||
|
authors: [decoded.pubkey], |
||||||
|
"#d": [decoded.identifier], |
||||||
|
}; |
||||||
|
|
||||||
|
const event = await ndk.fetchEvent(filter, { closeOnEose: false }, relaySet); |
||||||
|
if (!event) { |
||||||
|
throw new Error(`Event not found for naddr: ${naddr}`); |
||||||
|
} |
||||||
|
return event; |
||||||
|
} catch (err) { |
||||||
|
throw error(404, `Failed to fetch publication root event.\n${err}`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetches an event by nevent identifier |
||||||
|
*/ |
||||||
|
async function fetchEventByNevent(ndk: NDK, nevent: string): Promise<NDKEvent> { |
||||||
|
try { |
||||||
|
const decoded = neventDecode(nevent); |
||||||
|
const event = await ndk.fetchEvent(decoded.id); |
||||||
|
if (!event) { |
||||||
|
throw new Error(`Event not found for nevent: ${nevent}`); |
||||||
|
} |
||||||
|
return event; |
||||||
|
} catch (err) { |
||||||
|
throw error(404, `Failed to fetch publication root event.\n${err}`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, parent }) => { |
||||||
|
const { type, identifier } = params; |
||||||
|
const { ndk } = await parent(); |
||||||
|
|
||||||
|
if (!ndk) { |
||||||
|
throw error(500, "NDK not available"); |
||||||
|
} |
||||||
|
|
||||||
|
let indexEvent: NDKEvent; |
||||||
|
|
||||||
|
// Handle different identifier types
|
||||||
|
switch (type) { |
||||||
|
case 'id': |
||||||
|
indexEvent = await fetchEventById(ndk, identifier); |
||||||
|
break; |
||||||
|
case 'd': |
||||||
|
indexEvent = await fetchEventByDTag(ndk, identifier); |
||||||
|
break; |
||||||
|
case 'naddr': |
||||||
|
indexEvent = await fetchEventByNaddr(ndk, identifier); |
||||||
|
break; |
||||||
|
case 'nevent': |
||||||
|
indexEvent = await fetchEventByNevent(ndk, identifier); |
||||||
|
break; |
||||||
|
default: |
||||||
|
throw error(400, `Unsupported identifier type: ${type}`); |
||||||
|
} |
||||||
|
|
||||||
|
const publicationType = getMatchingTags(indexEvent, "type")[0]?.[1]; |
||||||
|
|
||||||
|
return { |
||||||
|
publicationType, |
||||||
|
indexEvent, |
||||||
|
ndk, // Pass ndk to the page for the publication tree
|
||||||
|
}; |
||||||
|
};
|
||||||
@ -0,0 +1,95 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import Publication from "$lib/components/publications/Publication.svelte"; |
||||||
|
import { TextPlaceholder } from "flowbite-svelte"; |
||||||
|
import type { PageProps } from "./$types"; |
||||||
|
import { onDestroy, onMount, setContext } from "svelte"; |
||||||
|
import Processor from "asciidoctor"; |
||||||
|
import ArticleNav from "$components/util/ArticleNav.svelte"; |
||||||
|
import { SveltePublicationTree } from "$lib/components/publications/svelte_publication_tree.svelte"; |
||||||
|
import { TableOfContents } from "$lib/components/publications/table_of_contents.svelte"; |
||||||
|
import { page } from "$app/state"; |
||||||
|
import { goto } from "$app/navigation"; |
||||||
|
|
||||||
|
let { data }: PageProps = $props(); |
||||||
|
|
||||||
|
const publicationTree = new SveltePublicationTree(data.indexEvent, data.ndk); |
||||||
|
const toc = new TableOfContents( |
||||||
|
data.indexEvent.tagAddress(), |
||||||
|
publicationTree, |
||||||
|
page.url.pathname ?? "", |
||||||
|
); |
||||||
|
|
||||||
|
setContext("publicationTree", publicationTree); |
||||||
|
setContext("toc", toc); |
||||||
|
setContext("asciidoctor", Processor()); |
||||||
|
|
||||||
|
publicationTree.onBookmarkMoved((address) => { |
||||||
|
goto(`#${address}`, { |
||||||
|
replaceState: true, |
||||||
|
}); |
||||||
|
|
||||||
|
// TODO: Extract IndexedDB interaction to a service layer. |
||||||
|
// Store bookmark in IndexedDB |
||||||
|
const db = indexedDB.open("alexandria", 1); |
||||||
|
db.onupgradeneeded = () => { |
||||||
|
const objectStore = db.result.createObjectStore("bookmarks", { |
||||||
|
keyPath: "key", |
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
db.onsuccess = () => { |
||||||
|
const transaction = db.result.transaction(["bookmarks"], "readwrite"); |
||||||
|
const store = transaction.objectStore("bookmarks"); |
||||||
|
const bookmarkKey = `${data.indexEvent.tagAddress()}`; |
||||||
|
store.put({ key: bookmarkKey, address }); |
||||||
|
}; |
||||||
|
}); |
||||||
|
|
||||||
|
onMount(() => { |
||||||
|
// TODO: Extract IndexedDB interaction to a service layer. |
||||||
|
// Read bookmark from IndexedDB |
||||||
|
const db = indexedDB.open("alexandria", 1); |
||||||
|
db.onupgradeneeded = () => { |
||||||
|
const objectStore = db.result.createObjectStore("bookmarks", { |
||||||
|
keyPath: "key", |
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
db.onsuccess = () => { |
||||||
|
const transaction = db.result.transaction(["bookmarks"], "readonly"); |
||||||
|
const store = transaction.objectStore("bookmarks"); |
||||||
|
const bookmarkKey = `${data.indexEvent.tagAddress()}`; |
||||||
|
const request = store.get(bookmarkKey); |
||||||
|
|
||||||
|
request.onsuccess = () => { |
||||||
|
if (request.result?.address) { |
||||||
|
// Set the bookmark in the publication tree |
||||||
|
publicationTree.setBookmark(request.result.address); |
||||||
|
|
||||||
|
// Jump to the bookmarked element |
||||||
|
goto(`#${request.result.address}`, { |
||||||
|
replaceState: true, |
||||||
|
}); |
||||||
|
} |
||||||
|
}; |
||||||
|
}; |
||||||
|
}); |
||||||
|
|
||||||
|
onDestroy(() => { |
||||||
|
// TODO: Clean up resources if needed |
||||||
|
}); |
||||||
|
</script> |
||||||
|
|
||||||
|
<ArticleNav |
||||||
|
publicationType={data.publicationType} |
||||||
|
rootId={data.indexEvent.id} |
||||||
|
indexEvent={data.indexEvent} |
||||||
|
/> |
||||||
|
|
||||||
|
<main class="publication {data.publicationType}"> |
||||||
|
<Publication |
||||||
|
rootAddress={data.indexEvent.tagAddress()} |
||||||
|
publicationType={data.publicationType} |
||||||
|
indexEvent={data.indexEvent} |
||||||
|
/> |
||||||
|
</main> |
||||||
Loading…
Reference in new issue