36 changed files with 1766 additions and 902 deletions
@ -1,2 +0,0 @@ |
|||||||
export * from './relayStore'; |
|
||||||
export * from './displayLimits'; |
|
||||||
@ -0,0 +1,143 @@ |
|||||||
|
import { WebSocketPool } from "../data_structures/websocket_pool.ts"; |
||||||
|
import { error } from "@sveltejs/kit"; |
||||||
|
import { naddrDecode, neventDecode } from "../utils.ts"; |
||||||
|
|
||||||
|
export interface NostrEvent { |
||||||
|
id: string; |
||||||
|
pubkey: string; |
||||||
|
created_at: number; |
||||||
|
kind: number; |
||||||
|
tags: string[][]; |
||||||
|
content: string; |
||||||
|
sig: string; |
||||||
|
} |
||||||
|
|
||||||
|
export interface NostrFilter { |
||||||
|
ids?: string[]; |
||||||
|
authors?: string[]; |
||||||
|
kinds?: number[]; |
||||||
|
[tag: `#${string}`]: string[] | undefined;
|
||||||
|
since?: number; |
||||||
|
until?: number; |
||||||
|
limit?: number; |
||||||
|
} |
||||||
|
|
||||||
|
export async function fetchNostrEvent(filter: NostrFilter): Promise<NostrEvent> { |
||||||
|
// TODO: Improve relay selection when relay management is implemented.
|
||||||
|
const ws = await WebSocketPool.instance.acquire("wss://thecitadel.nostr1.com"); |
||||||
|
const subId = crypto.randomUUID(); |
||||||
|
|
||||||
|
const res = new Promise<NostrEvent>((resolve, reject) => { |
||||||
|
ws.addEventListener("message", (ev) => { |
||||||
|
const data = JSON.parse(ev.data); |
||||||
|
|
||||||
|
if (data[1] !== subId) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
switch (data[0]) { |
||||||
|
case "EVENT": |
||||||
|
break; |
||||||
|
case "CLOSED": |
||||||
|
reject(new Error(`[WebSocket Utils]: Subscription ${subId} closed`)); |
||||||
|
break; |
||||||
|
case "EOSE": |
||||||
|
reject(new Error(`[WebSocket Utils]: Event not found`)); |
||||||
|
break; |
||||||
|
} |
||||||
|
|
||||||
|
const event = data[2] as NostrEvent; |
||||||
|
if (!event) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
resolve(event); |
||||||
|
}); |
||||||
|
|
||||||
|
ws.addEventListener("error", (ev) => { |
||||||
|
reject(ev); |
||||||
|
}); |
||||||
|
}).withTimeout(2000); |
||||||
|
|
||||||
|
ws.send(JSON.stringify(["REQ", subId, filter])); |
||||||
|
return res; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetches an event by hex ID, throwing a SvelteKit 404 error if not found. |
||||||
|
*/ |
||||||
|
export async function fetchEventById(id: string): Promise<NostrEvent> { |
||||||
|
try { |
||||||
|
const event = await fetchNostrEvent({ ids: [id], limit: 1 }); |
||||||
|
if (!event) { |
||||||
|
throw error(404, `Event not found for ID: ${id}`); |
||||||
|
} |
||||||
|
return event; |
||||||
|
} catch (err) { |
||||||
|
if (err && typeof err === "object" && "status" in err) { |
||||||
|
throw err; |
||||||
|
} |
||||||
|
throw error(404, `Failed to fetch event by ID: ${err}`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetches an event by d tag, throwing a 404 if not found. |
||||||
|
*/ |
||||||
|
export async function fetchEventByDTag(dTag: string): Promise<NostrEvent> { |
||||||
|
try { |
||||||
|
const event = await fetchNostrEvent({ "#d": [dTag], limit: 1 }); |
||||||
|
if (!event) { |
||||||
|
throw error(404, `Event not found for d-tag: ${dTag}`); |
||||||
|
} |
||||||
|
return event; |
||||||
|
} catch (err) { |
||||||
|
if (err && typeof err === "object" && "status" in err) { |
||||||
|
throw err; |
||||||
|
} |
||||||
|
throw error(404, `Failed to fetch event by d-tag: ${err}`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetches an event by naddr identifier. |
||||||
|
*/ |
||||||
|
export async function fetchEventByNaddr(naddr: string): Promise<NostrEvent> { |
||||||
|
try { |
||||||
|
const decoded = naddrDecode(naddr); |
||||||
|
const filter = { |
||||||
|
kinds: [decoded.kind], |
||||||
|
authors: [decoded.pubkey], |
||||||
|
"#d": [decoded.identifier], |
||||||
|
}; |
||||||
|
const event = await fetchNostrEvent(filter); |
||||||
|
if (!event) { |
||||||
|
throw error(404, `Event not found for naddr: ${naddr}`); |
||||||
|
} |
||||||
|
return event; |
||||||
|
} catch (err) { |
||||||
|
if (err && typeof err === "object" && "status" in err) { |
||||||
|
throw err; |
||||||
|
} |
||||||
|
throw error(404, `Failed to fetch event by naddr: ${err}`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetches an event by nevent identifier. |
||||||
|
*/ |
||||||
|
export async function fetchEventByNevent(nevent: string): Promise<NostrEvent> { |
||||||
|
try { |
||||||
|
const decoded = neventDecode(nevent); |
||||||
|
const event = await fetchNostrEvent({ ids: [decoded.id], limit: 1 }); |
||||||
|
if (!event) { |
||||||
|
throw error(404, `Event not found for nevent: ${nevent}`); |
||||||
|
} |
||||||
|
return event; |
||||||
|
} catch (err) { |
||||||
|
if (err && typeof err === "object" && "status" in err) { |
||||||
|
throw err; |
||||||
|
} |
||||||
|
throw error(404, `Failed to fetch event by nevent: ${err}`); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,5 @@ |
|||||||
|
import type { LayoutLoad } from "./$types"; |
||||||
|
|
||||||
|
export const load: LayoutLoad = async () => { |
||||||
|
return {}; |
||||||
|
};
|
||||||
@ -0,0 +1,41 @@ |
|||||||
|
import { redirect } from "@sveltejs/kit"; |
||||||
|
import type { PageServerLoad } from "./$types"; |
||||||
|
|
||||||
|
// Route pattern constants
|
||||||
|
const ROUTES = { |
||||||
|
PUBLICATION_BASE: "/publication", |
||||||
|
NADDR: "/publication/naddr", |
||||||
|
NEVENT: "/publication/nevent",
|
||||||
|
ID: "/publication/id", |
||||||
|
D_TAG: "/publication/d", |
||||||
|
START: "/start", |
||||||
|
} as const; |
||||||
|
|
||||||
|
// Identifier prefixes
|
||||||
|
const IDENTIFIER_PREFIXES = { |
||||||
|
NADDR: "naddr", |
||||||
|
NEVENT: "nevent", |
||||||
|
} as const; |
||||||
|
|
||||||
|
export const load: PageServerLoad = ({ 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(IDENTIFIER_PREFIXES.NADDR)) { |
||||||
|
throw redirect(301, `${ROUTES.NADDR}/${id}`); |
||||||
|
} else if (id.startsWith(IDENTIFIER_PREFIXES.NEVENT)) { |
||||||
|
throw redirect(301, `${ROUTES.NEVENT}/${id}`); |
||||||
|
} else { |
||||||
|
// Assume it's a hex ID
|
||||||
|
throw redirect(301, `${ROUTES.ID}/${id}`); |
||||||
|
} |
||||||
|
} else if (dTag) { |
||||||
|
throw redirect(301, `${ROUTES.D_TAG}/${dTag}`); |
||||||
|
} |
||||||
|
|
||||||
|
// If no query parameters, redirect to the start page
|
||||||
|
throw redirect(301, ROUTES.START); |
||||||
|
};
|
||||||
@ -1,134 +0,0 @@ |
|||||||
<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()); |
|
||||||
|
|
||||||
// Get publication metadata for OpenGraph tags |
|
||||||
let title = $derived( |
|
||||||
data.indexEvent?.getMatchingTags("title")[0]?.[1] || |
|
||||||
data.parser?.getIndexTitle(data.parser?.getRootIndexId()) || |
|
||||||
"Alexandria Publication", |
|
||||||
); |
|
||||||
let currentUrl = $derived( |
|
||||||
`${page.url.origin}${page.url.pathname}${page.url.search}`, |
|
||||||
); |
|
||||||
|
|
||||||
// Get image and summary from the event tags if available |
|
||||||
// If image unavailable, use the Alexandria default pic. |
|
||||||
let image = $derived( |
|
||||||
data.indexEvent?.getMatchingTags("image")[0]?.[1] || |
|
||||||
"/screenshots/old_books.jpg", |
|
||||||
); |
|
||||||
let summary = $derived( |
|
||||||
data.indexEvent?.getMatchingTags("summary")[0]?.[1] || |
|
||||||
"Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages.", |
|
||||||
); |
|
||||||
|
|
||||||
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(() => data.parser.reset()); |
|
||||||
</script> |
|
||||||
|
|
||||||
<svelte:head> |
|
||||||
<!-- Basic meta tags --> |
|
||||||
<title>{title}</title> |
|
||||||
<meta name="description" content={summary} /> |
|
||||||
|
|
||||||
<!-- OpenGraph meta tags --> |
|
||||||
<meta property="og:title" content={title} /> |
|
||||||
<meta property="og:description" content={summary} /> |
|
||||||
<meta property="og:url" content={currentUrl} /> |
|
||||||
<meta property="og:type" content="article" /> |
|
||||||
<meta property="og:site_name" content="Alexandria" /> |
|
||||||
<meta property="og:image" content={image} /> |
|
||||||
|
|
||||||
<!-- Twitter Card meta tags --> |
|
||||||
<meta name="twitter:card" content="summary_large_image" /> |
|
||||||
<meta name="twitter:title" content={title} /> |
|
||||||
<meta name="twitter:description" content={summary} /> |
|
||||||
<meta name="twitter:image" content={image} /> |
|
||||||
</svelte:head> |
|
||||||
|
|
||||||
<ArticleNav |
|
||||||
publicationType={data.publicationType} |
|
||||||
rootId={data.parser.getRootIndexId()} |
|
||||||
indexEvent={data.indexEvent} |
|
||||||
/> |
|
||||||
|
|
||||||
<main class="publication {data.publicationType}"> |
|
||||||
<Publication |
|
||||||
rootAddress={data.indexEvent.tagAddress()} |
|
||||||
publicationType={data.publicationType} |
|
||||||
indexEvent={data.indexEvent} |
|
||||||
/> |
|
||||||
</main> |
|
||||||
@ -1,115 +0,0 @@ |
|||||||
import { error } from "@sveltejs/kit"; |
|
||||||
import type { Load } from "@sveltejs/kit"; |
|
||||||
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
|
||||||
import { nip19 } from "nostr-tools"; |
|
||||||
import { getActiveRelaySetAsNDKRelaySet } from "../../lib/ndk.ts"; |
|
||||||
import { getMatchingTags } from "../../lib/utils/nostrUtils.ts"; |
|
||||||
import type NDK from "@nostr-dev-kit/ndk"; |
|
||||||
|
|
||||||
/** |
|
||||||
* Decodes an naddr identifier and returns a filter object |
|
||||||
*/ |
|
||||||
function decodeNaddr(id: string) { |
|
||||||
try { |
|
||||||
if (!id.startsWith("naddr")) return {}; |
|
||||||
|
|
||||||
const decoded = nip19.decode(id); |
|
||||||
if (decoded.type !== "naddr") return {}; |
|
||||||
|
|
||||||
const data = decoded.data; |
|
||||||
return { |
|
||||||
kinds: [data.kind], |
|
||||||
authors: [data.pubkey], |
|
||||||
"#d": [data.identifier], |
|
||||||
}; |
|
||||||
} catch (e) { |
|
||||||
console.error("Failed to decode naddr:", e); |
|
||||||
return null; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Fetches an event by ID or filter |
|
||||||
*/ |
|
||||||
async function fetchEventById(ndk: NDK, id: string): Promise<NDKEvent> { |
|
||||||
const filter = decodeNaddr(id); |
|
||||||
|
|
||||||
// Handle the case where filter is null (decoding error)
|
|
||||||
if (filter === null) { |
|
||||||
// If we can't decode the naddr, try using the raw ID
|
|
||||||
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}`); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
const hasFilter = Object.keys(filter).length > 0; |
|
||||||
|
|
||||||
try { |
|
||||||
const event = await (hasFilter |
|
||||||
? ndk.fetchEvent(filter) |
|
||||||
: 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 event = await ndk.fetchEvent( |
|
||||||
{ "#d": [dTag] }, |
|
||||||
{ closeOnEose: false }, |
|
||||||
relaySet, |
|
||||||
); |
|
||||||
|
|
||||||
if (!event) { |
|
||||||
throw new Error(`Event not found for d tag: ${dTag}`); |
|
||||||
} |
|
||||||
return event; |
|
||||||
} catch (err) { |
|
||||||
throw error(404, `Failed to fetch publication root event.\n${err}`); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// TODO: Use path params instead of query params.
|
|
||||||
export const load: Load = async ({ |
|
||||||
url, |
|
||||||
parent, |
|
||||||
}: { |
|
||||||
url: URL; |
|
||||||
parent: () => Promise<Partial<Record<string, NDK>>>; |
|
||||||
}) => { |
|
||||||
const id = url.searchParams.get("id"); |
|
||||||
const dTag = url.searchParams.get("d"); |
|
||||||
const { ndk } = await parent(); |
|
||||||
|
|
||||||
if (!id && !dTag) { |
|
||||||
throw error(400, "No publication root event ID or d tag provided."); |
|
||||||
} |
|
||||||
|
|
||||||
// Fetch the event based on available parameters
|
|
||||||
const indexEvent = id |
|
||||||
? await fetchEventById(ndk!, id) |
|
||||||
: await fetchEventByDTag(ndk!, dTag!); |
|
||||||
|
|
||||||
const publicationType = getMatchingTags(indexEvent, "type")[0]?.[1]; |
|
||||||
|
|
||||||
return { |
|
||||||
publicationType, |
|
||||||
indexEvent, |
|
||||||
}; |
|
||||||
}; |
|
||||||
@ -0,0 +1,28 @@ |
|||||||
|
import { error } from "@sveltejs/kit"; |
||||||
|
import type { LayoutServerLoad } from "./$types"; |
||||||
|
|
||||||
|
export const load: LayoutServerLoad = async ({ params, url }) => { |
||||||
|
const { type, identifier } = params; |
||||||
|
|
||||||
|
// Validate the identifier type for SSR
|
||||||
|
const validTypes = ['id', 'd', 'naddr', 'nevent']; |
||||||
|
if (!validTypes.includes(type)) { |
||||||
|
throw error(400, `Unsupported identifier type: ${type}`); |
||||||
|
} |
||||||
|
|
||||||
|
// Provide basic metadata for SSR - actual fetching will happen on client
|
||||||
|
const title = "Alexandria Publication"; |
||||||
|
const summary = "Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages."; |
||||||
|
const image = "/screenshots/old_books.jpg"; |
||||||
|
const currentUrl = `${url.origin}${url.pathname}`; |
||||||
|
|
||||||
|
return { |
||||||
|
indexEvent: null, // Will be fetched on client side
|
||||||
|
metadata: { |
||||||
|
title, |
||||||
|
summary, |
||||||
|
image, |
||||||
|
currentUrl, |
||||||
|
}, |
||||||
|
}; |
||||||
|
};
|
||||||
@ -0,0 +1,29 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import type { LayoutProps } from "./$types"; |
||||||
|
|
||||||
|
let { data, children }: LayoutProps = $props(); |
||||||
|
const { metadata } = data; |
||||||
|
</script> |
||||||
|
|
||||||
|
<!-- TODO: Provide fallback metadata values to use if the publication is on an auth-to-read relay. --> |
||||||
|
<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,65 @@ |
|||||||
|
import { error } from "@sveltejs/kit"; |
||||||
|
import type { LayoutLoad } from "./$types"; |
||||||
|
import { fetchEventByDTag, fetchEventById, fetchEventByNaddr, fetchEventByNevent } from "../../../../lib/utils/websocket_utils.ts"; |
||||||
|
import type { NostrEvent } from "../../../../lib/utils/websocket_utils.ts"; |
||||||
|
import { browser } from "$app/environment"; |
||||||
|
|
||||||
|
export const load: LayoutLoad = async ({ params, url }) => { |
||||||
|
const { type, identifier } = params; |
||||||
|
|
||||||
|
// Only fetch on the client side where WebSocket is available
|
||||||
|
if (!browser) { |
||||||
|
// Return basic metadata for SSR
|
||||||
|
return { |
||||||
|
indexEvent: null, |
||||||
|
metadata: { |
||||||
|
title: "Alexandria Publication", |
||||||
|
summary: "Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages.", |
||||||
|
image: "/screenshots/old_books.jpg", |
||||||
|
currentUrl: `${url.origin}${url.pathname}`, |
||||||
|
}, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
let indexEvent: NostrEvent; |
||||||
|
|
||||||
|
try { |
||||||
|
// Handle different identifier types
|
||||||
|
switch (type) { |
||||||
|
case 'id': |
||||||
|
indexEvent = await fetchEventById(identifier); |
||||||
|
break; |
||||||
|
case 'd': |
||||||
|
indexEvent = await fetchEventByDTag(identifier); |
||||||
|
break; |
||||||
|
case 'naddr': |
||||||
|
indexEvent = await fetchEventByNaddr(identifier); |
||||||
|
break; |
||||||
|
case 'nevent': |
||||||
|
indexEvent = await fetchEventByNevent(identifier); |
||||||
|
break; |
||||||
|
default: |
||||||
|
throw error(400, `Unsupported identifier type: ${type}`); |
||||||
|
} |
||||||
|
|
||||||
|
// Extract metadata for meta tags
|
||||||
|
const title = indexEvent.tags.find((tag) => tag[0] === "title")?.[1] || "Alexandria Publication"; |
||||||
|
const summary = indexEvent.tags.find((tag) => tag[0] === "summary")?.[1] ||
|
||||||
|
"Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages."; |
||||||
|
const image = indexEvent.tags.find((tag) => tag[0] === "image")?.[1] || "/screenshots/old_books.jpg"; |
||||||
|
const currentUrl = `${url.origin}${url.pathname}`; |
||||||
|
|
||||||
|
return { |
||||||
|
indexEvent, |
||||||
|
metadata: { |
||||||
|
title, |
||||||
|
summary, |
||||||
|
image, |
||||||
|
currentUrl, |
||||||
|
}, |
||||||
|
}; |
||||||
|
} catch (err) { |
||||||
|
console.error('Failed to fetch publication:', err); |
||||||
|
throw error(404, `Failed to load publication: ${err}`); |
||||||
|
} |
||||||
|
}; |
||||||
@ -0,0 +1,18 @@ |
|||||||
|
import { error } from "@sveltejs/kit"; |
||||||
|
import type { PageServerLoad } from "./$types"; |
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params }) => { |
||||||
|
const { type, identifier } = params; |
||||||
|
|
||||||
|
// Validate the identifier type for SSR
|
||||||
|
const validTypes = ['id', 'd', 'naddr', 'nevent']; |
||||||
|
if (!validTypes.includes(type)) { |
||||||
|
throw error(400, `Unsupported identifier type: ${type}`); |
||||||
|
} |
||||||
|
|
||||||
|
// Provide basic data for SSR - actual fetching will happen on client
|
||||||
|
return { |
||||||
|
publicationType: "", // Will be determined on client side
|
||||||
|
indexEvent: null, // Will be fetched on client side
|
||||||
|
}; |
||||||
|
};
|
||||||
@ -0,0 +1,114 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import Publication from "$lib/components/publications/Publication.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"; |
||||||
|
import { createNDKEvent } from "$lib/utils/nostrUtils"; |
||||||
|
|
||||||
|
let { data }: PageProps = $props(); |
||||||
|
|
||||||
|
// data.indexEvent can be null from server-side rendering |
||||||
|
// We need to handle this case properly |
||||||
|
const indexEvent = data.indexEvent ? createNDKEvent(data.ndk, data.indexEvent) : null; |
||||||
|
|
||||||
|
// Only create publication tree if we have a valid index event |
||||||
|
const publicationTree = indexEvent ? new SveltePublicationTree(indexEvent, data.ndk) : null; |
||||||
|
const toc = indexEvent ? new TableOfContents( |
||||||
|
indexEvent.tagAddress(), |
||||||
|
publicationTree!, |
||||||
|
page.url.pathname ?? "", |
||||||
|
) : null; |
||||||
|
|
||||||
|
setContext("publicationTree", publicationTree); |
||||||
|
setContext("toc", toc); |
||||||
|
setContext("asciidoctor", Processor()); |
||||||
|
|
||||||
|
// Only set up bookmark handling if we have a valid publication tree |
||||||
|
if (publicationTree && indexEvent) { |
||||||
|
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 = `${indexEvent.tagAddress()}`; |
||||||
|
store.put({ key: bookmarkKey, address }); |
||||||
|
}; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
onMount(() => { |
||||||
|
// Only handle bookmarks if we have valid components |
||||||
|
if (!publicationTree || !indexEvent) return; |
||||||
|
|
||||||
|
// 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 = `${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> |
||||||
|
|
||||||
|
{#if indexEvent && data.indexEvent} |
||||||
|
<ArticleNav |
||||||
|
publicationType={data.publicationType} |
||||||
|
rootId={data.indexEvent.id} |
||||||
|
indexEvent={indexEvent} |
||||||
|
/> |
||||||
|
|
||||||
|
<main class="publication {data.publicationType}"> |
||||||
|
<Publication |
||||||
|
rootAddress={indexEvent.tagAddress()} |
||||||
|
publicationType={data.publicationType} |
||||||
|
indexEvent={indexEvent} |
||||||
|
/> |
||||||
|
</main> |
||||||
|
{:else} |
||||||
|
<main class="publication"> |
||||||
|
<div class="flex items-center justify-center min-h-screen"> |
||||||
|
<p class="text-gray-600 dark:text-gray-400">Loading publication...</p> |
||||||
|
</div> |
||||||
|
</main> |
||||||
|
{/if} |
||||||
@ -0,0 +1,54 @@ |
|||||||
|
import { error } from "@sveltejs/kit"; |
||||||
|
import type { PageLoad } from "./$types"; |
||||||
|
import { fetchEventByDTag, fetchEventById, fetchEventByNaddr, fetchEventByNevent } from "../../../../lib/utils/websocket_utils.ts"; |
||||||
|
import type { NostrEvent } from "../../../../lib/utils/websocket_utils.ts"; |
||||||
|
import { browser } from "$app/environment"; |
||||||
|
|
||||||
|
export const load: PageLoad = async ({ params }) => { |
||||||
|
const { type, identifier } = params; |
||||||
|
|
||||||
|
// Only fetch on the client side where WebSocket is available
|
||||||
|
if (!browser) { |
||||||
|
// Return basic data for SSR
|
||||||
|
return { |
||||||
|
publicationType: "", |
||||||
|
indexEvent: null, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
let indexEvent: NostrEvent; |
||||||
|
|
||||||
|
try { |
||||||
|
// Handle different identifier types
|
||||||
|
switch (type) { |
||||||
|
case 'id': |
||||||
|
indexEvent = await fetchEventById(identifier); |
||||||
|
break; |
||||||
|
case 'd': |
||||||
|
indexEvent = await fetchEventByDTag(identifier); |
||||||
|
break; |
||||||
|
case 'naddr': |
||||||
|
indexEvent = await fetchEventByNaddr(identifier); |
||||||
|
break; |
||||||
|
case 'nevent': |
||||||
|
indexEvent = await fetchEventByNevent(identifier); |
||||||
|
break; |
||||||
|
default: |
||||||
|
throw error(400, `Unsupported identifier type: ${type}`); |
||||||
|
} |
||||||
|
|
||||||
|
if (!indexEvent) { |
||||||
|
throw error(404, `Event not found for ${type}: ${identifier}`); |
||||||
|
} |
||||||
|
|
||||||
|
const publicationType = indexEvent.tags.find((tag) => tag[0] === "type")?.[1] ?? ""; |
||||||
|
|
||||||
|
return { |
||||||
|
publicationType, |
||||||
|
indexEvent, |
||||||
|
}; |
||||||
|
} catch (err) { |
||||||
|
console.error('Failed to fetch publication:', err); |
||||||
|
throw error(404, `Failed to load publication: ${err}`); |
||||||
|
} |
||||||
|
};
|
||||||
Loading…
Reference in new issue