Browse Source

Merge branch 'master' into issue#199#202

master
Nuša Pukšič 11 months ago
parent
commit
9f5d08acc9
  1. 16
      deno.lock
  2. 3
      import_map.json
  3. 2
      src/app.html
  4. 2
      src/lib/components/Login.svelte
  5. 0
      src/lib/components/Modal.svelte
  6. 1
      src/lib/components/Preview.svelte
  7. 32
      src/lib/components/Publication.svelte
  8. 9
      src/lib/components/PublicationHeader.svelte
  9. 94
      src/lib/components/util/CardActions.svelte
  10. 4
      src/lib/components/util/CopyToClipboard.svelte
  11. 16
      src/lib/components/util/Profile.svelte
  12. 2
      src/lib/consts.ts
  13. 16
      src/lib/data_structures/lazy.ts
  14. 430
      src/lib/data_structures/publication_tree.ts
  15. 2
      src/lib/navigator/EventNetwork/Legend.svelte
  16. 130
      src/lib/navigator/EventNetwork/NodeTooltip.svelte
  17. 18
      src/lib/navigator/EventNetwork/index.svelte
  18. 25
      src/lib/parser.ts
  19. 45
      src/lib/snippets/PublicationSnippets.svelte
  20. 2
      src/lib/stores.ts
  21. 49
      src/lib/utils.ts
  22. 29
      src/routes/+layout.svelte
  23. 140
      src/routes/about/+page.svelte
  24. 48
      src/routes/publication/+page.svelte
  25. 127
      src/routes/publication/+page.ts
  26. 71
      src/routes/visualize/+page.svelte
  27. BIN
      static/favicon.png
  28. BIN
      static/screenshots/old_books.jpg
  29. 3
      tsconfig.json
  30. 27
      vite.config.ts

16
deno.lock

@ -2862,6 +2862,22 @@ @@ -2862,6 +2862,22 @@
}
},
"workspace": {
"dependencies": [
"npm:@nostr-dev-kit/ndk-cache-dexie@2.5",
"npm:@nostr-dev-kit/ndk@2.11",
"npm:@popperjs/core@2.11",
"npm:@tailwindcss/forms@0.5",
"npm:@tailwindcss/typography@0.5",
"npm:asciidoctor@3.0",
"npm:d3@7.9",
"npm:flowbite-svelte-icons@2.0",
"npm:flowbite-svelte@0.44",
"npm:flowbite@2.2",
"npm:he@1.2",
"npm:nostr-tools@2.10",
"npm:svelte@5.0",
"npm:tailwind-merge@2.5"
],
"packageJson": {
"dependencies": [
"npm:@nostr-dev-kit/ndk-cache-dexie@2.5",

3
import_map.json

@ -13,6 +13,7 @@ @@ -13,6 +13,7 @@
"svelte": "npm:svelte@5.0.x",
"flowbite": "npm:flowbite@2.2.x",
"flowbite-svelte": "npm:flowbite-svelte@0.44.x",
"flowbite-svelte-icons": "npm:flowbite-svelte-icons@2.0.x"
"flowbite-svelte-icons": "npm:flowbite-svelte-icons@2.0.x",
"child_process": "node:child_process"
}
}

2
src/app.html

@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="icon" href="%sveltekit.assets%/favicon.png?v=2" />
<meta name="viewport" content="width=device-width" />
%sveltekit.head%
</head>

2
src/lib/components/Login.svelte

@ -50,7 +50,7 @@ @@ -50,7 +50,7 @@
<Popover
class='popover-leather w-fit'
placement='bottom'
target='avatar'
triggeredBy='#avatar'
>
<div class='w-full flex space-x-2'>
<Button

0
src/lib/Modal.svelte → src/lib/components/Modal.svelte

1
src/lib/components/Preview.svelte

@ -3,6 +3,7 @@ @@ -3,6 +3,7 @@
import { Button, ButtonGroup, CloseButton, Input, P, Textarea, Tooltip } from 'flowbite-svelte';
import { CaretDownSolid, CaretUpSolid, EditOutline } from 'flowbite-svelte-icons';
import Self from './Preview.svelte';
import { contentParagraph, sectionHeading } from '$lib/snippets/PublicationSnippets.svelte';
// TODO: Fix move between parents.

32
src/lib/components/Publication.svelte

@ -2,10 +2,16 @@ @@ -2,10 +2,16 @@
import Preview from "./Preview.svelte";
import { pharosInstance } from "$lib/parser";
import { page } from "$app/state";
import { ndkInstance } from "$lib/ndk";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import Details from "$components/util/Details.svelte";
import { publicationColumnVisibility } from "$lib/stores";
let { rootId, publicationType } = $props<{ rootId: string, publicationType: string }>();
let { rootId, publicationType, indexEvent } = $props<{
rootId: string,
publicationType: string,
indexEvent: NDKEvent
}>();
if (rootId !== $pharosInstance.getRootIndexId()) {
console.error("Root ID does not match parser root index ID");
@ -33,19 +39,19 @@ @@ -33,19 +39,19 @@
}
</script>
{#if $publicationColumnVisibility.details}
<div class="flex flex-col space-y-4 max-w-2xl overflow-auto flex-shrink flex-grow-1">
<!-- <Details {event} />-->
</div>
{/if}
{#if isDefaultVisible()}
<div class="flex flex-col space-y-4 overflow-auto flex-grow-1
{publicationType === 'blog' ? 'max-w-xl' : 'max-w-2xl' }
{currentBlog !== null ? 'discreet' : ''}
">
<Preview {rootId} {publicationType} index={0} onBlogUpdate={loadBlog} />
</div>
{#if showTocButton && !showToc}
<Button
class="btn-leather fixed top-20 left-4 h-6 w-6"
outline={true}
on:click={(ev) => {
showToc = true;
ev.stopPropagation();
}}
>
<BookOutline />
</Button>
<Tooltip>Show Table of Contents</Tooltip>
{/if}
{#if currentBlog !== null && $publicationColumnVisibility.inner }

9
src/lib/components/PublicationHeader.svelte

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
<script lang="ts">
import { ndkInstance } from '$lib/ndk';
import { neventEncode } from '$lib/utils';
import { naddrEncode } from '$lib/utils';
import type { NDKEvent } from '@nostr-dev-kit/ndk';
import { standardRelays } from '../consts';
import { Card, Img } from "flowbite-svelte";
@ -16,11 +16,12 @@ @@ -16,11 +16,12 @@
const href = $derived.by(() => {
const d = event.getMatchingTags('d')[0]?.[1];
if (d != null) {
return `publication?d=${d}`;
return `publication?d=${d}`;
} else {
return `publication?id=${neventEncode(event, relays)}`;
return `publication?id=${naddrEncode(event, relays)}`;
}
});
}
);
let title: string = $derived(event.getMatchingTags('title')[0]?.[1]);
let author: string = $derived(event.getMatchingTags('author')[0]?.[1] ?? 'unknown');

94
src/lib/components/util/CardActions.svelte

@ -9,9 +9,8 @@ @@ -9,9 +9,8 @@
} from "flowbite-svelte-icons";
import { Button, Modal, Popover } from "flowbite-svelte";
import { standardRelays } from "$lib/consts";
import { neventEncode } from "$lib/utils";
import { type AddressPointer, naddrEncode } from "nostr-tools/nip19";
import Details from "$components/util/Details.svelte";
import { neventEncode, naddrEncode } from "$lib/utils";
import InlineProfile from "$components/util/InlineProfile.svelte";
let { event } = $props();
@ -34,16 +33,9 @@ @@ -34,16 +33,9 @@
function shareNjump() {
const relays: string[] = standardRelays;
const dTag : string | undefined = event.dTag;
if (typeof dTag === 'string') {
const opts: AddressPointer = {
identifier: dTag,
pubkey: event.pubkey,
kind: 30040,
relays
};
const naddr = naddrEncode(opts);
try {
const naddr = naddrEncode(event, relays);
console.debug(naddr);
navigator.clipboard.writeText(`https://njump.me/${naddr}`);
shareLinkCopied = true;
@ -51,9 +43,8 @@ @@ -51,9 +43,8 @@
shareLinkCopied = false;
}, 4000);
}
else {
console.log('dTag is undefined');
catch (e) {
console.error('Failed to encode naddr:', e);
}
}
@ -103,32 +94,32 @@ @@ -103,32 +94,32 @@
<div class='flex flex-col text-nowrap'>
<ul class="space-y-2">
<li>
<a href="" role="button" class='btn-leather' onclick={viewDetails}>
<button class='btn-leather w-full text-left' onclick={viewDetails}>
<EyeOutline class="inline mr-2" /> View details
</a>
</button>
</li>
<li>
<a role="button" class='btn-leather' onclick={shareNjump}>
<button class='btn-leather w-full text-left' onclick={shareNjump}>
{#if shareLinkCopied}
<ClipboardCheckOutline class="inline mr-2" /> Copied!
{:else}
<ShareNodesOutline class="inline mr-2" /> Share via NJump
{/if}
</a>
</button>
</li>
<li>
<a role="button" class='btn-leather' onclick={copyEventId}>
<button class='btn-leather w-full text-left' onclick={copyEventId}>
{#if eventIdCopied}
<ClipboardCheckOutline class="inline mr-2" /> Copied!
{:else}
<ClipboardCleanOutline class="inline mr-2" /> Copy event ID
{/if}
</a>
</button>
</li>
<li>
<a href="" role="button" class='btn-leather' onclick={viewJson}>
<button class='btn-leather w-full text-left' onclick={viewJson}>
<CodeOutline class="inline mr-2" /> View JSON
</a>
</button>
</li>
</ul>
</div>
@ -136,13 +127,60 @@ @@ -136,13 +127,60 @@
</Popover>
{/if}
<!-- Event JSON -->
<Modal class='modal-leather' title='Event JSON' bind:open={jsonModalOpen} autoclose outsideclose size='sm'>
<div class="overflow-auto bg-highlight dark:bg-primary-900 text-sm rounded p-1">
<code>{JSON.stringify(event.rawEvent())}</code>
<Modal class='modal-leather' title='Event JSON' bind:open={jsonModalOpen} autoclose outsideclose size='lg'>
<div class="overflow-auto bg-highlight dark:bg-primary-900 text-sm rounded p-1" style="max-height: 70vh;">
<pre><code>{JSON.stringify(event.rawEvent(), null, 2)}</code></pre>
</div>
</Modal>
<!-- Event details -->
<Modal class='modal-leather' title='Publication details' bind:open={detailsModalOpen} autoclose outsideclose size='sm'>
<Details event={event} />
<div class="flex flex-row space-x-4">
{#if image}
<div class="flex col">
<img class="max-w-48" src={image} alt="Publication cover" />
</div>
{/if}
<div class="flex flex-col col space-y-5 justify-center align-middle">
<h1 class="text-3xl font-bold mt-5">{title}</h1>
<h2 class="text-base font-bold">by
{#if originalAuthor !== null}
<InlineProfile pubkey={originalAuthor} title={author} />
{:else}
{author}
{/if}
</h2>
<h4 class='text-base font-thin mt-2'>Version: {version}</h4>
</div>
</div>
{#if summary}
<div class="flex flex-row ">
<p class='text-base text-primary-900 dark:text-highlight'>{summary}</p>
</div>
{/if}
<div class="flex flex-row ">
<h4 class='text-base font-normal mt-2'>Index author: <InlineProfile pubkey={event.pubkey} /></h4>
</div>
<div class="flex flex-col pb-4 space-y-1">
{#if source !== null}
<h5 class="text-sm">Source: <a class="underline" href={source} target="_blank">{source}</a></h5>
{/if}
{#if type !== null}
<h5 class="text-sm">Publication type: {type}</h5>
{/if}
{#if language !== null}
<h5 class="text-sm">Language: {language}</h5>
{/if}
{#if publisher !== null}
<h5 class="text-sm">Published by: {publisher}</h5>
{/if}
{#if identifier !== null}
<h5 class="text-sm">{identifier}</h5>
{/if}
</div>
</Modal>
</div>

4
src/lib/components/util/CopyToClipboard.svelte

@ -18,10 +18,10 @@ @@ -18,10 +18,10 @@
}
</script>
<a role="button" class='btn-leather text-nowrap' onclick={copyToClipboard}>
<button class='btn-leather text-nowrap' onclick={copyToClipboard}>
{#if copied}
<ClipboardCheckOutline class="!fill-none dark:!fill-none inline mr-1" /> Copied!
{:else}
<ClipboardCleanOutline class="!fill-none dark:!fill-none inline mr-1" /> {displayText}
{/if}
</a>
</button>

16
src/lib/components/util/Profile.svelte

@ -46,10 +46,12 @@ function shortenNpub(long: string|undefined) { @@ -46,10 +46,12 @@ function shortenNpub(long: string|undefined) {
class='h-6 w-6 cursor-pointer'
src={pfp}
alt={username}
id="profile-avatar"
/>
{#key username || tag}
<Popover
target="avatar"
placement="bottom"
triggeredBy="#profile-avatar"
class='popover-leather w-[180px]'
trigger='hover'
>
@ -70,25 +72,21 @@ function shortenNpub(long: string|undefined) { @@ -70,25 +72,21 @@ function shortenNpub(long: string|undefined) {
</li>
{#if isNav}
<li>
<a
href=""
<button
id='sign-out-button'
class='btn-leather text-nowrap mt-3 flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500'
onclick={handleSignOutClick}
role="button"
>
<ArrowRightToBracketOutline class='mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none' /> Sign out
</a>
</button>
</li>
{:else}
<!-- li>
<a
href=""
<button
class='btn-leather text-nowrap mt-3 flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500'
role="button"
>
<FileSearchOutline class='mr-1 !h-6 inline !fill-none dark:!fill-none' /> More content
</a>
</button>
</li -->
{/if}
</ul>

2
src/lib/consts.ts

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
export const wikiKind = 30818;
export const indexKind = 30040;
export const zettelKinds = [ 30041 ];
export const zettelKinds = [ 30041, 30818 ];
export const standardRelays = [ 'wss://thecitadel.nostr1.com', 'wss://relay.noswhere.com' ];
export const bootstrapRelays = [ 'wss://purplepag.es', 'wss://relay.noswhere.com' ];

16
src/lib/data_structures/lazy.ts

@ -0,0 +1,16 @@ @@ -0,0 +1,16 @@
export class Lazy<T> {
#value?: T;
#resolver: () => Promise<T>;
constructor(resolver: () => Promise<T>) {
this.#resolver = resolver;
}
async value(): Promise<T> {
if (!this.#value) {
this.#value = await this.#resolver();
}
return this.#value;
}
}

430
src/lib/data_structures/publication_tree.ts

@ -0,0 +1,430 @@ @@ -0,0 +1,430 @@
import type NDK from "@nostr-dev-kit/ndk";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { Lazy } from "./lazy.ts";
import { findIndexAsync as _findIndexAsync } from '../utils.ts';
enum PublicationTreeNodeType {
Root,
Branch,
Leaf,
}
interface PublicationTreeNode {
type: PublicationTreeNodeType;
address: string;
parent?: PublicationTreeNode;
children?: Array<Lazy<PublicationTreeNode>>;
}
export class PublicationTree implements AsyncIterable<NDKEvent> {
/**
* The root node of the tree.
*/
#root: PublicationTreeNode;
/**
* A map of addresses in the tree to their corresponding nodes.
*/
#nodes: Map<string, Lazy<PublicationTreeNode>>;
/**
* A map of addresses in the tree to their corresponding events.
*/
#events: Map<string, NDKEvent>;
/**
* An ordered list of the addresses of the leaves of the tree.
*/
#leaves: string[] = [];
/**
* The address of the last-visited node. Used for iteration and progressive retrieval.
*/
#bookmark?: string;
/**
* The NDK instance used to fetch events.
*/
#ndk: NDK;
constructor(rootEvent: NDKEvent, ndk: NDK) {
const rootAddress = rootEvent.tagAddress();
this.#root = {
type: PublicationTreeNodeType.Root,
address: rootAddress,
children: [],
};
this.#nodes = new Map<string, Lazy<PublicationTreeNode>>();
this.#nodes.set(rootAddress, new Lazy<PublicationTreeNode>(() => Promise.resolve(this.#root)));
this.#events = new Map<string, NDKEvent>();
this.#events.set(rootAddress, rootEvent);
this.#ndk = ndk;
}
/**
* Adds an event to the publication tree.
* @param event The event to be added.
* @param parentEvent The parent event of the event to be added.
* @throws An error if the parent event is not in the tree.
* @description The parent event must already be in the tree. Use
* {@link PublicationTree.getEvent} to retrieve an event already in the tree.
*/
async addEvent(event: NDKEvent, parentEvent: NDKEvent) {
const address = event.tagAddress();
const parentAddress = parentEvent.tagAddress();
const parentNode = await this.#nodes.get(parentAddress)?.value();
if (!parentNode) {
throw new Error(
`PublicationTree: Parent node with address ${parentAddress} not found.`
);
}
const node: PublicationTreeNode = {
type: await this.#getNodeType(event),
address,
parent: parentNode,
children: [],
};
const lazyNode = new Lazy<PublicationTreeNode>(() => Promise.resolve(node));
parentNode.children!.push(lazyNode);
this.#nodes.set(address, lazyNode);
this.#events.set(address, event);
}
/**
* Lazily adds an event to the publication tree by address if the full event is not already
* loaded into memory.
* @param address The address of the event to add.
* @param parentEvent The parent event of the event to add.
* @description The parent event must already be in the tree. Use
* {@link PublicationTree.getEvent} to retrieve an event already in the tree.
*/
async addEventByAddress(address: string, parentEvent: NDKEvent) {
const parentAddress = parentEvent.tagAddress();
const parentNode = await this.#nodes.get(parentAddress)?.value();
if (!parentNode) {
throw new Error(
`PublicationTree: Parent node with address ${parentAddress} not found.`
);
}
await this.#addNode(address, parentNode);
}
/**
* Retrieves an event from the publication tree.
* @param address The address of the event to retrieve.
* @returns The event, or null if the event is not found.
*/
async getEvent(address: string): Promise<NDKEvent | null> {
let event = this.#events.get(address) ?? null;
if (!event) {
event = await this.#depthFirstRetrieve(address);
}
return event;
}
/**
* Retrieves the addresses of the loaded children, if any, of the node with the given address.
* @param address The address of the parent node.
* @returns An array of addresses of any loaded child nodes.
*/
async getChildAddresses(address: string): Promise<string[]> {
const node = await this.#nodes.get(address)?.value();
if (!node) {
throw new Error(`PublicationTree: Node with address ${address} not found.`);
}
return Promise.all(
node.children?.map(async child =>
(await child.value()).address
) ?? []
);
}
/**
* Retrieves the events in the hierarchy of the event with the given address.
* @param address The address of the event for which to retrieve the hierarchy.
* @returns Returns an array of events in the addressed event's hierarchy, beginning with the
* root and ending with the addressed event.
*/
async getHierarchy(address: string): Promise<NDKEvent[]> {
let node = await this.#nodes.get(address)?.value();
if (!node) {
throw new Error(`PublicationTree: Node with address ${address} not found.`);
}
const hierarchy: NDKEvent[] = [this.#events.get(address)!];
while (node.parent) {
hierarchy.push(this.#events.get(node.parent.address)!);
node = node.parent;
}
return hierarchy.reverse();
}
/**
* Sets a start point for iteration over the leaves of the tree.
* @param address The address of the event to bookmark.
*/
setBookmark(address: string) {
this.#bookmark = address;
this.#cursor.tryMoveTo(address);
}
// #region Iteration Cursor
#cursor = new class {
target: PublicationTreeNode | null | undefined;
#tree: PublicationTree;
constructor(tree: PublicationTree) {
this.#tree = tree;
}
async tryMoveTo(address?: string) {
if (!address) {
const startEvent = await this.#tree.#depthFirstRetrieve();
this.target = await this.#tree.#nodes.get(startEvent!.tagAddress())?.value();
} else {
this.target = await this.#tree.#nodes.get(address)?.value();
}
if (!this.target) {
return false;
}
return true;
}
async tryMoveToFirstChild(): Promise<boolean> {
if (!this.target) {
throw new Error("Cursor: Target node is null or undefined.");
}
if (this.target.type === PublicationTreeNodeType.Leaf) {
return false;
}
this.target = (await this.target.children?.at(0)?.value())!;
return true;
}
async tryMoveToNextSibling(): Promise<boolean> {
if (!this.target) {
throw new Error("Cursor: Target node is null or undefined.");
}
const parent = this.target.parent;
const siblings = parent?.children;
if (!siblings) {
return false;
}
const currentIndex = await siblings.findIndexAsync(
async (sibling: Lazy<PublicationTreeNode>) => (await sibling.value()).address === this.target!.address
);
if (currentIndex === -1) {
return false;
}
const nextSibling = (await siblings.at(currentIndex + 1)?.value()) ?? null;
if (!nextSibling) {
return false;
}
this.target = nextSibling;
return true;
}
tryMoveToParent(): boolean {
if (!this.target) {
throw new Error("Cursor: Target node is null or undefined.");
}
const parent = this.target.parent;
if (!parent) {
return false;
}
this.target = parent;
return true;
}
}(this);
// #endregion
// #region Async Iterator Implementation
[Symbol.asyncIterator](): AsyncIterator<NDKEvent> {
return this;
}
async next(): Promise<IteratorResult<NDKEvent>> {
if (!this.#cursor.target) {
await this.#cursor.tryMoveTo(this.#bookmark);
}
do {
if (await this.#cursor.tryMoveToFirstChild()) {
continue;
}
if (await this.#cursor.tryMoveToNextSibling()) {
continue;
}
if (this.#cursor.tryMoveToParent()) {
continue;
}
if (this.#cursor.target?.type === PublicationTreeNodeType.Root) {
return { done: true, value: null };
}
} while (this.#cursor.target?.type !== PublicationTreeNodeType.Leaf);
const event = await this.getEvent(this.#cursor.target!.address);
return { done: false, value: event! };
}
// #endregion
// #region Private Methods
/**
* Traverses the publication tree in a depth-first manner to retrieve an event, filling in
* missing nodes during the traversal.
* @param address The address of the event to retrieve. If no address is provided, the function
* will return the first leaf in the tree.
* @returns The event, or null if the event is not found.
*/
async #depthFirstRetrieve(address?: string): Promise<NDKEvent | null> {
if (address && this.#nodes.has(address)) {
return this.#events.get(address)!;
}
const stack: string[] = [this.#root.address];
let currentNode: PublicationTreeNode | null | undefined = this.#root;
let currentEvent: NDKEvent | null | undefined = this.#events.get(this.#root.address)!;
while (stack.length > 0) {
const currentAddress = stack.pop();
currentNode = await this.#nodes.get(currentAddress!)?.value();
if (!currentNode) {
throw new Error(`PublicationTree: Node with address ${currentAddress} not found.`);
}
currentEvent = this.#events.get(currentAddress!);
if (!currentEvent) {
throw new Error(`PublicationTree: Event with address ${currentAddress} not found.`);
}
// Stop immediately if the target of the search is found.
if (address != null && currentAddress === address) {
return currentEvent;
}
const currentChildAddresses = currentEvent.tags
.filter(tag => tag[0] === 'a')
.map(tag => tag[1]);
// If the current event has no children, it is a leaf.
if (currentChildAddresses.length === 0) {
// Return the first leaf if no address was provided.
if (address == null) {
return currentEvent!;
}
continue;
}
// Augment the tree with the children of the current event.
for (const childAddress of currentChildAddresses) {
if (this.#nodes.has(childAddress)) {
continue;
}
await this.#addNode(childAddress, currentNode!);
}
// Push the popped address's children onto the stack for the next iteration.
while (currentChildAddresses.length > 0) {
const nextAddress = currentChildAddresses.pop()!;
stack.push(nextAddress);
}
}
return null;
}
#addNode(address: string, parentNode: PublicationTreeNode) {
const lazyNode = new Lazy<PublicationTreeNode>(() => this.#resolveNode(address, parentNode));
parentNode.children!.push(lazyNode);
this.#nodes.set(address, lazyNode);
}
/**
* Resolves a node address into an event, and creates new nodes for its children.
*
* This method is intended for use as a {@link Lazy} resolver.
*
* @param address The address of the node to resolve.
* @param parentNode The parent node of the node to resolve.
* @returns The resolved node.
*/
async #resolveNode(
address: string,
parentNode: PublicationTreeNode
): Promise<PublicationTreeNode> {
const [kind, pubkey, dTag] = address.split(':');
const event = await this.#ndk.fetchEvent({
kinds: [parseInt(kind)],
authors: [pubkey],
'#d': [dTag],
});
if (!event) {
throw new Error(
`PublicationTree: Event with address ${address} not found.`
);
}
this.#events.set(address, event);
const childAddresses = event.tags.filter(tag => tag[0] === 'a').map(tag => tag[1]);
const node: PublicationTreeNode = {
type: await this.#getNodeType(event),
address,
parent: parentNode,
children: [],
};
for (const address of childAddresses) {
this.addEventByAddress(address, event);
}
return node;
}
async #getNodeType(event: NDKEvent): Promise<PublicationTreeNodeType> {
if (event.tagAddress() === this.#root.address) {
return PublicationTreeNodeType.Root;
}
if (event.kind === 30040 && event.tags.some(tag => tag[0] === 'a')) {
return PublicationTreeNodeType.Branch;
}
return PublicationTreeNodeType.Leaf;
}
// #endregion
}

2
src/lib/navigator/EventNetwork/Legend.svelte

@ -18,7 +18,7 @@ @@ -18,7 +18,7 @@
<span class="legend-circle content"></span>
<span class="legend-letter">C</span>
</div>
<span>Content events (kind 30041) - Publication sections</span>
<span>Content events (kinds 30041, 30818) - Publication sections</span>
</li>
<li class="legend-item">
<svg class="w-6 h-6 mr-2" viewBox="0 0 24 24">

130
src/lib/navigator/EventNetwork/NodeTooltip.svelte

@ -1,38 +1,128 @@ @@ -1,38 +1,128 @@
<script lang="ts">
import type { NetworkNode } from "./types";
import { onMount, createEventDispatcher } from "svelte";
export let node: NetworkNode;
export let selected: boolean = false;
export let x: number;
export let y: number;
let { node, selected = false, x, y } = $props<{
node: NetworkNode;
selected?: boolean;
x: number;
y: number;
}>();
const dispatch = createEventDispatcher();
let tooltipElement: HTMLDivElement;
let tooltipX = $state(x + 10);
let tooltipY = $state(y - 10);
function getAuthorTag(node: NetworkNode): string {
if (node.event) {
const authorTags = node.event.getMatchingTags("author");
if (authorTags.length > 0) {
return authorTags[0][1];
}
}
return "Unknown";
}
function getSummaryTag(node: NetworkNode): string | null {
if (node.event) {
const summaryTags = node.event.getMatchingTags("summary");
if (summaryTags.length > 0) {
return summaryTags[0][1];
}
}
return null;
}
function getDTag(node: NetworkNode): string {
if (node.event) {
const dTags = node.event.getMatchingTags("d");
if (dTags.length > 0) {
return dTags[0][1];
}
}
return "View Publication";
}
function truncateContent(content: string, maxLength: number = 200): string {
if (content.length <= maxLength) return content;
return content.substring(0, maxLength) + "...";
}
function closeTooltip() {
dispatch('close');
}
// Ensure tooltip is fully visible on screen
onMount(() => {
if (tooltipElement) {
const rect = tooltipElement.getBoundingClientRect();
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
// Check if tooltip goes off the right edge
if (rect.right > windowWidth) {
tooltipX = windowWidth - rect.width - 10;
}
// Check if tooltip goes off the bottom edge
if (rect.bottom > windowHeight) {
tooltipY = windowHeight - rect.height - 10;
}
// Check if tooltip goes off the left edge
if (rect.left < 0) {
tooltipX = 10;
}
// Check if tooltip goes off the top edge
if (rect.top < 0) {
tooltipY = 10;
}
}
});
</script>
<div
bind:this={tooltipElement}
class="tooltip-leather fixed p-4 rounded shadow-lg bg-primary-0 dark:bg-primary-800
border border-gray-200 dark:border-gray-800 transition-colors duration-200"
style="left: {x + 10}px; top: {y - 10}px; z-index: 1000;"
style="left: {tooltipX}px; top: {tooltipY}px; z-index: 1000; max-width: 400px;"
>
<div class="space-y-2">
<div class="font-bold text-base">{node.title}</div>
<button
class="absolute top-2 left-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-full p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
onclick={closeTooltip}
aria-label="Close"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
<div class="space-y-2 pl-6">
<div class="font-bold text-base">
<a href="/publication?id={node.id}" class="text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500">
{node.title}
</a>
</div>
<div class="text-gray-600 dark:text-gray-400 text-sm">
{node.type} ({node.isContainer ? "30040" : "30041"})
{node.type} ({node.kind})
</div>
<div
class="text-gray-600 dark:text-gray-400 text-sm overflow-hidden text-ellipsis"
>
ID: {node.id}
{#if node.naddr}
<div>{node.naddr}</div>
{/if}
{#if node.nevent}
<div>{node.nevent}</div>
{/if}
<div class="text-gray-600 dark:text-gray-400 text-sm">
Author: {getAuthorTag(node)}
</div>
{#if node.isContainer && getSummaryTag(node)}
<div class="mt-2 text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded overflow-auto max-h-40">
<span class="font-semibold">Summary:</span> {truncateContent(getSummaryTag(node) || "", 200)}
</div>
{/if}
{#if node.content}
<div
class="mt-2 text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded overflow-auto max-h-40"
>
{node.content}
{truncateContent(node.content)}
</div>
{/if}
{#if selected}
@ -41,4 +131,4 @@ @@ -41,4 +131,4 @@
</div>
{/if}
</div>
</div>
</div>

18
src/lib/navigator/EventNetwork/index.svelte

@ -8,6 +8,7 @@ @@ -8,6 +8,7 @@
import { createSimulation, setupDragHandlers, applyGlobalLogGravity, applyConnectedGravity } from "./utils/forceSimulation";
import Legend from "./Legend.svelte";
import NodeTooltip from "./NodeTooltip.svelte";
import type { NetworkNode, NetworkLink } from "./types";
let { events = [] } = $props<{ events?: NDKEvent[] }>();
@ -90,14 +91,14 @@ @@ -90,14 +91,14 @@
function updateGraph() {
if (!svg || !events?.length || !svgGroup) return;
const { nodes, links } = generateGraph(events, currentLevels);
const { nodes, links } = generateGraph(events, Number(currentLevels));
if (!nodes.length) return;
// Stop any existing simulation
if (simulation) simulation.stop();
// Create new simulation
simulation = createSimulation(nodes, links, nodeRadius, linkDistance);
simulation = createSimulation(nodes, links, Number(nodeRadius), Number(linkDistance));
const dragHandler = setupDragHandlers(simulation);
// Update links
@ -303,6 +304,11 @@ @@ -303,6 +304,11 @@
updateGraph();
}
});
function handleTooltipClose() {
tooltipVisible = false;
selectedNodeId = null;
}
</script>
<div
@ -321,15 +327,9 @@ @@ -321,15 +327,9 @@
selected={tooltipNode.id === selectedNodeId}
x={tooltipX}
y={tooltipY}
on:close={handleTooltipClose}
/>
{/if}
<Legend />
</div>
<style>
.tooltip {
max-width: 300px;
word-wrap: break-word;
}
</style>

25
src/lib/parser.ts

@ -12,7 +12,7 @@ import type { @@ -12,7 +12,7 @@ import type {
} from 'asciidoctor';
import he from 'he';
import { writable, type Writable } from 'svelte/store';
import { zettelKinds } from './consts';
import { zettelKinds } from './consts.ts';
interface IndexMetadata {
authors?: string[];
@ -66,6 +66,8 @@ export default class Pharos { @@ -66,6 +66,8 @@ export default class Pharos {
private asciidoctor: Asciidoctor;
private pharosExtensions: Extensions.Registry;
private ndk: NDK;
private contextCounters: Map<string, number> = new Map<string, number>();
@ -135,25 +137,26 @@ export default class Pharos { @@ -135,25 +137,26 @@ export default class Pharos {
constructor(ndk: NDK) {
this.asciidoctor = asciidoctor();
this.pharosExtensions = this.asciidoctor.Extensions.create();
this.ndk = ndk;
const pharos = this;
this.asciidoctor.Extensions.register(function () {
const registry = this;
registry.treeProcessor(function () {
const dsl = this;
dsl.process(function (document) {
const treeProcessor = this;
pharos.treeProcessor(treeProcessor, document);
});
})
this.pharosExtensions.treeProcessor(function () {
const dsl = this;
dsl.process(function (document) {
const treeProcessor = this;
pharos.treeProcessor(treeProcessor, document);
});
});
}
parse(content: string, options?: ProcessorOptions | undefined): void {
try {
this.html = this.asciidoctor.convert(content, options) as string | Document | undefined;
this.html = this.asciidoctor.convert(content, {
'extension_registry': this.pharosExtensions,
...options,
}) as string | Document | undefined;
} catch (error) {
console.error(error);
throw new Error('Failed to parse AsciiDoc document.');

45
src/lib/snippets/PublicationSnippets.svelte

@ -0,0 +1,45 @@ @@ -0,0 +1,45 @@
<script module lang='ts'>
import { P } from 'flowbite-svelte';
export { contentParagraph, sectionHeading };
</script>
{#snippet sectionHeading(title: string, depth: number)}
{#if depth === 0}
<h1 class='h-leather'>
{title}
</h1>
{:else if depth === 1}
<h2 class='h-leather'>
{title}
</h2>
{:else if depth === 2}
<h3 class='h-leather'>
{title}
</h3>
{:else if depth === 3}
<h4 class='h-leather'>
{title}
</h4>
{:else if depth === 4}
<h5 class='h-leather'>
{title}
</h5>
{:else}
<h6 class='h-leather'>
{title}
</h6>
{/if}
{/snippet}
{#snippet contentParagraph(content: string, publicationType: string, isSectionStart: boolean)}
{#if publicationType === 'novel'}
<P class='whitespace-normal' firstupper={isSectionStart}>
{@html content}
</P>
{:else}
<P class='whitespace-normal' firstupper={false}>
{@html content}
</P>
{/if}
{/snippet}

2
src/lib/stores.ts

@ -3,7 +3,7 @@ import { FeedType } from "./consts"; @@ -3,7 +3,7 @@ import { FeedType } from "./consts";
export let idList = writable<string[]>([]);
export let alexandriaKinds = readable<number[]>([30040, 30041]);
export let alexandriaKinds = readable<number[]>([30040, 30041, 30818]);
export let feedType = writable<FeedType>(FeedType.StandardRelays);

49
src/lib/utils.ts

@ -10,6 +10,20 @@ export function neventEncode(event: NDKEvent, relays: string[]) { @@ -10,6 +10,20 @@ export function neventEncode(event: NDKEvent, relays: string[]) {
});
}
export function naddrEncode(event: NDKEvent, relays: string[]) {
const dTag = event.getMatchingTags('d')[0]?.[1];
if (!dTag) {
throw new Error('Event does not have a d tag');
}
return nip19.naddrEncode({
identifier: dTag,
pubkey: event.pubkey,
kind: event.kind || 0,
relays,
});
}
export function formatDate(unixtimestamp: number) {
const months = [
"Jan",
@ -109,3 +123,38 @@ export function filterValidIndexEvents(events: Set<NDKEvent>): Set<NDKEvent> { @@ -109,3 +123,38 @@ export function filterValidIndexEvents(events: Set<NDKEvent>): Set<NDKEvent> {
console.debug(`Filtered index events: ${events.size} events remaining.`);
return events;
}
/**
* Async version of Array.findIndex() that runs sequentially.
* Returns the index of the first element that satisfies the provided testing function.
* @param array The array to search
* @param predicate The async testing function
* @returns A promise that resolves to the index of the first matching element, or -1 if none found
*/
export async function findIndexAsync<T>(
array: T[],
predicate: (element: T, index: number, array: T[]) => Promise<boolean>
): Promise<number> {
for (let i = 0; i < array.length; i++) {
if (await predicate(array[i], i, array)) {
return i;
}
}
return -1;
}
// Extend Array prototype with findIndexAsync
declare global {
interface Array<T> {
findIndexAsync(
predicate: (element: T, index: number, array: T[]) => Promise<boolean>
): Promise<number>;
}
}
Array.prototype.findIndexAsync = function<T>(
this: T[],
predicate: (element: T, index: number, array: T[]) => Promise<boolean>
): Promise<number> {
return findIndexAsync(this, predicate);
};

29
src/routes/+layout.svelte

@ -2,15 +2,44 @@ @@ -2,15 +2,44 @@
import "../app.css";
import Navigation from "$lib/components/Navigation.svelte";
import { onMount } from "svelte";
import { page } from "$app/stores";
// Compute viewport height.
$: displayHeight = window.innerHeight;
// Get standard metadata for OpenGraph tags
let title = 'Library of Alexandria';
let currentUrl = $page.url.href;
// Get default image and summary for the Alexandria website
let image = '/screenshots/old_books.jpg';
let summary = 'Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages.';
onMount(() => {
document.body.style.height = `${displayHeight}px`;
});
</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="website" />
<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>
<div class={'leather min-h-full w-full flex flex-col items-center'}>
<Navigation class='sticky top-0' />
<slot />

140
src/routes/about/+page.svelte

@ -1,56 +1,118 @@ @@ -1,56 +1,118 @@
<script lang='ts'>
import { Heading } from "flowbite-svelte";
import { Heading, Img, P, A } from "flowbite-svelte";
// Get the git tag version from environment variables
const appVersion = import.meta.env.APP_VERSION || 'development';
const isVersionKnown = appVersion !== 'development';
</script>
<div class='w-full flex justify-center'>
<main class='main-leather flex flex-col space-y-4 max-w-2xl w-full mt-4 mb-4'>
<Heading tag='h1' class='h-leather mb-2'>About</Heading>
<p>Alexandria is a reader and writer for <a href="https://github.com/nostr-protocol/nips/pull/1600" class='underline' target="_blank">curated publications</a> (in Asciidoc), and will eventually also support long-form articles (Markdown) and wiki pages (Asciidoc). It is produced by the <a href="https://wikistr.com/gitcitadel-project" class='underline' target="_blank">GitCitadel project team</a>.</p>
<p>Please submit support issues on the <a href="https://gitcitadel.com/r/naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqy88wumn8ghj7mn0wvhxcmmv9uqq5emfw33kjarpv3jkcs83wav" class='underline' target="_blank">project repo page</a> and follow us on <a href="https://github.com/ShadowySupercode/gitcitadel" class='underline' target="_blank">GitHub</a> and <a href="https://geyser.fund/project/gitcitadel" class='underline' target="_blank">Geyserfund</a>.</p>
<p>We are easiest to contact over our Nostr address <a href="https://njump.me/nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg" class='underline' title="npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz" target="_blank">npub1s3h…75wz</a>.</p>
<Heading tag='h2' class='h-leather mb-2'>Overview</Heading>
<p>Alexandria opens up to the <a href="https://next-alexandria.gitcitadel.eu/" class='underline'>landing page</a>, where the user can: login (top-right), select whether to only view the publications hosted on the <a href="https://thecitadel.nostr1.com/" class='underline' target="_blank">thecitadel document relay</a> or add in their own relays, and scroll/search the publications.</p>
<p><img src="/screenshots/LandingPage.png" alt="Landing page" class='image-border'></p>
<p><img src="/screenshots/YourRelays.png" alt="Relay selection" class='image-border'></p>
<p>There is also the ability to view the publications as a diagram, if you click on "Visualize", and to publish an e-book or other document (coming soon).</p>
<p>If you click on a card, which represents a 30040 index event, the associated reading view opens to the publication. The app then pulls all of the content events (30041s), in the order in which they are indexed, and displays them as a single document.</p>
<main class='main-leather flex flex-col space-y-6 max-w-2xl w-full my-6 px-4'>
<div class="flex justify-between items-center">
<Heading tag='h1' class='h-leather mb-2'>About the Library of Alexandria</Heading>
{#if isVersionKnown}
<span class="text-sm bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded text-nowrap">Version: {appVersion}</span>
{/if}
</div>
<Img src="/screenshots/old_books.jpg" alt="Alexandria icon" />
<P class="mb-3">
Alexandria is a reader and writer for <A href="/publication?d=gitcitadel-project-documentation-curated-publications-specification-7-by-stella-v-1">curated publications</A> (in Asciidoc), wiki pages (Asciidoc), and will eventually also support long-form articles (Markdown). It is produced by the <A href="/publication?d=gitcitadel-project-documentation-gitcitadel-project-1-by-stella-v-1">GitCitadel project team</A>.
</P>
<P class="mb-3">
Please submit support issues on the <A href="https://gitcitadel.com/r/naddr1qvzqqqrhnypzquqjyy5zww7uq7hehemjt7juf0q0c9rgv6lv8r2yxcxuf0rvcx9eqy88wumn8ghj7mn0wvhxcmmv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uqsuamnwvaz7tmwdaejumr0dshsqzjpd3jhsctwv3exjcgtpg0n0/issues" target="_blank">Alexandria repo page</A> and follow us on <A href="https://github.com/ShadowySupercode/gitcitadel" target="_blank">GitHub</A> and <A href="https://geyser.fund/project/gitcitadel" target="_blank">Geyserfund</A>.
</P>
<P>
We are easiest to contact over our Nostr address <A href="https://njump.me/nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg" title="npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz" target="_blank">npub1s3h…75wz</A>.
</P>
<p>Each 30041 section is also a level in the table of contents, which can be accessed from the floating icon top-left in the reading view. This allows for navigation within the publication. (This functionality has been temporarily disabled.)</p>
<p><img src="/screenshots/ToC_icon.png" alt="ToC icon" class='image-border'></p>
<p><img src="/screenshots/TableOfContents.png" alt="Table of contents example" class='image-border'></p>
<Heading tag='h2' class='h-leather mt-4 mb-2'>Overview</Heading>
<P class="mb-4">
Alexandria opens up to the <A href="./">landing page</A>, where the user can: login (top-right), select whether to only view the publications hosted on the <A href="https://thecitadel.nostr1.com/" target="_blank">thecitadel document relay</A> or add in their own relays, and scroll/search the publications.
</P>
<div class="flex flex-col items-center space-y-4 my-4">
<Img src="/screenshots/LandingPage.png" alt="Landing page" class='image-border rounded-lg' width="400" />
<Img src="/screenshots/YourRelays.png" alt="Relay selection" class='image-border rounded-lg' width="400" />
</div>
<P class="mb-3">
There is also the ability to view the publications as a diagram, if you click on "Visualize", and to publish an e-book or other document (coming soon).
</P>
<P class="mb-3">
If you click on a card, which represents a 30040 index event, the associated reading view opens to the publication. The app then pulls all of the content events (30041s and 30818s for wiki pages), in the order in which they are indexed, and displays them as a single document.
</P>
<P class="mb-3">
Each content section (30041 or 30818) is also a level in the table of contents, which can be accessed from the floating icon top-left in the reading view. This allows for navigation within the publication. (This functionality has been temporarily disabled.)
</P>
<div class="flex flex-col items-center space-y-4 my-4">
<Img src="/screenshots/ToC_icon.png" alt="ToC icon" class='image-border rounded-lg' width="400" />
<Img src="/screenshots/TableOfContents.png" alt="Table of contents example" class='image-border rounded-lg' width="400" />
</div>
<Heading tag='h2' class='h-leather mt-4 mb-2'>Typical use cases</Heading>
<Heading tag='h2' class='h-leather mb-2'>Typical use cases</Heading>
<Heading tag='h3' class='h-leather mb-3'>For e-books</Heading>
<P class="mb-3">
The most common use for Alexandria is for e-books: both those users have written themselves and those uploaded to Nostr from other sources. The first minor version of the app, Gutenberg, is focused on displaying and producing these publications.
</P>
<Heading tag='h3' class='h-leather mb-2'>For e-books</Heading>
<p>The most common use for Alexandria is for e-books: both those users have written themselves and those uploaded to Nostr from other sources. The first minor version of the app, Gutenberg, is focused on displaying and producing these publications.</p>
<P class="mb-3">
An example of a book is <A href="/publication?d=jane-eyre-an-autobiography-by-charlotte-bront%C3%AB-v-3rd-edition">Jane Eyre</A>
</P>
<p>An example of a book is <a href="https://next-alexandria.gitcitadel.eu/publication?d=jane-eyre-an-autobiography-by-charlotte-bront%C3%83-v-third-edition" class='underline'>Jane Eyre</a></p>
<div class="flex justify-center my-4">
<Img src="/screenshots/JaneEyre.png" alt="Jane Eyre, by Charlotte Brontë" class='image-border rounded-lg' width="400" />
</div>
<Heading tag='h3' class='h-leather mb-3'>For scientific papers</Heading>
<p><img src="/screenshots/JaneEyre.png" alt="Jane Eyre, by Charlotte Brontë" class='image-border'></p>
<P class="mb-3">
Alexandria will also display research papers with Asciimath and LaTeX embedding, and the normal advanced formatting options available for Asciidoc. In addition, we will be implementing special citation events, which will serve as an alternative or addition to the normal footnotes.
</P>
<Heading tag='h3' class='h-leather mb-2'>For scientific papers</Heading>
<p>Alexandria will also display research papers with Asciimath and LaTeX embedding, and the normal advanced formatting options available for Asciidoc. In addition, we will be implementing special citation events, which will serve as an alternative or addition to the normal footnotes.</p>
<P class="mb-3">
Correctly displaying such papers, integrating citations, and allowing them to be reviewed (with kind 1111 comments), and annotated (with highlights) by users, is the focus of the second minor version, Euler.
</P>
<p>Correctly displaying such papers, integrating citations, and allowing them to be reviewed (with kind 1111 comments), and annotated (with highlights) by users, is the focus of the second minor version, Euler.</p>
<P class="mb-3">
Euler will also pioneer the HTTP-based (rather than websocket-based) e-paper compatible version of the web app.
</P>
<p>Euler will also pioneer the HTTP-based (rather than websocket-based) e-paper compatible version of the web app.</p>
<P class="mb-3">
An example of a research paper is <A href="/publication?d=less-partnering-less-children-or-both-by-j.i.s.-hellstrand-v-1">Less Partnering, Less Children, or Both?</A>
</P>
<p>An example of a research paper is <a href="https://next-alexandria.gitcitadel.eu/publication?d=less-partnering-less-children-or-both-by-j.i.s.-hellstrand-v-1" class='underline'>Less Partnering, Less Children, or Both?</a></p>
<div class="flex justify-center my-4">
<Img src="/screenshots/ResearchPaper.png" alt="Research paper" class='image-border rounded-lg' width="400" />
</div>
<Heading tag='h3' class='h-leather mb-3'>For documentation</Heading>
<p><img src="/screenshots/ResearchPaper.png" alt="Research paper" class='image-border'></p>
<P class="mb-3">
Our own team uses Alexandria to document the app, to display our <A href="/publication?d=the-gitcitadel-blog-by-stella-v-1">blog entries</A>, as well as to store copies of our most interesting <A href="/publication?d=gitcitadel-project-documentation-by-stella-v-1">technical specifications</A>.
</P>
<Heading tag='h3' class='h-leather mb-2'>For documentation</Heading>
<p>Our own team uses Alexandria to document the app, to display our blog entries, as well as to store copies of our most interesting technical specifications.</p>
<div class="flex justify-center my-4">
<Img src="/screenshots/Documentation.png" alt="Documentation" class='image-border rounded-lg' width="400" />
</div>
<Heading tag='h3' class='h-leather mb-3'>For wiki pages</Heading>
<p><img src="/screenshots/Documentation.png" alt="Documentation" class='image-border'></p>
<P class="mb-3">
Alexandria now supports wiki pages (kind 30818), allowing for collaborative knowledge bases and documentation. Wiki pages use the same Asciidoc format as other publications but are specifically designed for interconnected, evolving content.
</P>
</main>
</div>
<P class="mb-3">
Wiki pages can be linked to from other publications and can contain links to other wiki pages, creating a web of knowledge that can be navigated and explored.
</P>
</main>
</div>

48
src/routes/publication/+page.svelte

@ -4,12 +4,52 @@ @@ -4,12 +4,52 @@
import type { PageData } from "./$types";
import { onDestroy } from "svelte";
import ArticleNav from "$components/util/ArticleNav.svelte";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { pharosInstance } from "$lib/parser";
import { page } from "$app/stores";
let { data }: { data: PageData } = $props();
// Extend the PageData type with the properties we need
interface ExtendedPageData extends PageData {
waitable: Promise<any>;
publicationType: string;
indexEvent: NDKEvent;
parser: any;
}
let { data } = $props<{ data: ExtendedPageData }>();
// 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 = $page.url.href;
// 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.');
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>
{#key data}
<ArticleNav publicationType={data.publicationType} rootId={data.parser.getRootIndexId()} />
{/key}
@ -18,6 +58,10 @@ @@ -18,6 +58,10 @@
{#await data.waitable}
<TextPlaceholder divClass='skeleton-leather w-full' size="xxl" />
{:then}
<Article rootId={data.parser.getRootIndexId()} publicationType={data.publicationType} />
<Article
rootId={data.parser.getRootIndexId()}
publicationType={data.publicationType}
indexEvent={data.indexEvent}
/>
{/await}
</main>

127
src/routes/publication/+page.ts

@ -1,48 +1,107 @@ @@ -1,48 +1,107 @@
import { error } from '@sveltejs/kit';
import { NDKRelay, NDKRelaySet, type NDKEvent } from '@nostr-dev-kit/ndk';
import type { PageLoad } from './$types';
import { get } from 'svelte/store';
import { getActiveRelays, inboxRelays, ndkInstance } from '$lib/ndk';
import { standardRelays } from '$lib/consts';
import type { Load } from '@sveltejs/kit';
import type { NDKEvent } from '@nostr-dev-kit/ndk';
import { nip19 } from 'nostr-tools';
import { getActiveRelays } from '$lib/ndk.ts';
export const load: PageLoad = async ({ url, parent }) => {
const id = url.searchParams.get('id');
const dTag = url.searchParams.get('d');
const { ndk, parser } = await parent();
/**
* 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;
}
}
let eventPromise: Promise<NDKEvent | null>;
let indexEvent: NDKEvent | null;
/**
* Fetches an event by ID or filter
*/
async function fetchEventById(ndk: any, 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}`);
}
}
if (id) {
eventPromise = ndk.fetchEvent(id)
.then((ev: NDKEvent | null) => {
return ev;
})
.catch((err: any) => {
error(404, `Failed to fetch publication root event for ID: ${id}\n${err}`);
});
} else if (dTag) {
eventPromise = new Promise<NDKEvent | null>(resolve => {
ndk
.fetchEvent({ '#d': [dTag] }, { closeOnEose: false }, getActiveRelays(ndk))
.then((event: NDKEvent | null) => {
resolve(event);
})
.catch((err: any) => {
error(404, `Failed to fetch publication root event for d tag: ${dTag}\n${err}`);
});
});
} else {
error(400, 'No publication root event ID or d tag provided.');
/**
* Fetches an event by d tag
*/
async function fetchEventByDTag(ndk: any, dTag: string): Promise<NDKEvent> {
try {
const event = await ndk.fetchEvent(
{ '#d': [dTag] },
{ closeOnEose: false },
getActiveRelays(ndk)
);
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}`);
}
}
indexEvent = await eventPromise as NDKEvent;
export const load: Load = async ({ url, parent }: { url: URL; parent: () => Promise<any> }) => {
const id = url.searchParams.get('id');
const dTag = url.searchParams.get('d');
const { ndk, parser } = 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 = indexEvent?.getMatchingTags('type')[0]?.[1];
const fetchPromise = parser.fetch(indexEvent);
return {
waitable: fetchPromise,
publicationType,
indexEvent,
};
};

71
src/routes/visualize/+page.svelte

@ -49,7 +49,7 @@ @@ -49,7 +49,7 @@
// Fetch the referenced content events
const contentEvents = await $ndkInstance.fetchEvents(
{
kinds: [30041],
kinds: [30041, 30818],
ids: Array.from(contentEventIds),
},
{
@ -79,44 +79,45 @@ @@ -79,44 +79,45 @@
</script>
<div class="leather w-full p-4 relative">
<h1 class="h-leather text-2xl font-bold mb-4">Publication Network</h1>
<!-- Settings Toggle Button -->
<!-- Settings Button - Using Flowbite Components -->
{#if !loading && !error}
<Button
class="btn-leather fixed right-4 top-24 z-40 rounded-lg min-w-[150px]"
size="sm"
on:click={() => (showSettings = !showSettings)}
>
<CogSolid class="mr-2 h-5 w-5" />
Settings
</Button>
<div class="flex items-center gap-4 mb-4">
<h1 class="h-leather text-2xl font-bold">Publication Network</h1>
<!-- Settings Button - Using Flowbite Components -->
{#if !loading && !error}
<Button
class="btn-leather z-10 rounded-lg min-w-[120px]"
on:click={() => (showSettings = !showSettings)}
>
<CogSolid class="mr-2 h-5 w-5" />
Settings
</Button>
{/if}
</div>
{#if !loading && !error && showSettings}
<!-- Settings Panel -->
{#if showSettings}
<div
class="fixed right-0 top-[140px] h-auto w-80 bg-white dark:bg-gray-800 p-4 shadow-lg z-30
overflow-y-auto max-h-[calc(100vh-96px)] rounded-l-lg border-l border-t border-b
border-gray-200 dark:border-gray-700"
transition:fly={{ duration: 300, x: 320, opacity: 1, easing: quintOut }}
>
<div class="card space-y-4">
<h2 class="text-xl font-bold mb-4 h-leather">
Visualization Settings
</h2>
<div
class="absolute left-[220px] top-14 h-auto w-80 bg-white dark:bg-gray-800 p-4 shadow-lg z-10
overflow-y-auto max-h-[calc(100vh-96px)] rounded-lg border
border-gray-200 dark:border-gray-700"
transition:fly={{ duration: 300, y: -10, opacity: 1, easing: quintOut }}
>
<div class="card space-y-4">
<h2 class="text-xl font-bold mb-4 h-leather">
Visualization Settings
</h2>
<div class="space-y-4">
<span class="text-sm text-gray-600 dark:text-gray-400">
Showing {events.length} events from {$networkFetchLimit} headers
</span>
<EventLimitControl on:update={handleLimitUpdate} />
<EventRenderLevelLimit on:update={handleLimitUpdate} />
</div>
<div class="space-y-4">
<span class="text-sm text-gray-600 dark:text-gray-400">
Showing {events.length} events from {$networkFetchLimit} headers
</span>
<EventLimitControl on:update={handleLimitUpdate} />
<EventRenderLevelLimit on:update={handleLimitUpdate} />
</div>
</div>
{/if}
</div>
{/if}
{#if loading}
<div class="flex justify-center items-center h-64">
<div role="status">
@ -155,6 +156,6 @@ @@ -155,6 +156,6 @@
</div>
{:else}
<EventNetwork {events} />
<div class="mt-8 prose dark:prose-invert max-w-none" />
<div class="mt-8 prose dark:prose-invert max-w-none"></div>
{/if}
</div>
</div>

BIN
static/favicon.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
static/screenshots/old_books.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

3
tsconfig.json

@ -8,7 +8,8 @@ @@ -8,7 +8,8 @@
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true
"strict": true,
"allowImportingTsExtensions": true
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
//

27
vite.config.ts

@ -1,9 +1,36 @@ @@ -1,9 +1,36 @@
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
import { execSync } from "child_process";
// Function to get the latest git tag
function getAppVersionString() {
// if running in ci context, we can assume the package has been properly versioned
if (process.env.ALEXANDIRA_IS_CI_BUILD && process.env.npm_package_version && process.env.npm_package_version.trim() !== '') {
return process.env.npm_package_version;
}
try {
// Get the latest git tag, assuming git is installed and tagged branch is available
const tag = execSync('git describe --tags --abbrev=0').toString().trim();
return tag;
} catch (error) {
return 'development';
}
}
export default defineConfig({
plugins: [sveltekit()],
resolve: {
alias: {
$lib: './src/lib',
$components: './src/components'
}
},
test: {
include: ['./tests/unit/**/*.unit-test.js']
},
define: {
// Expose the app version as a global variable
'import.meta.env.APP_VERSION': JSON.stringify(getAppVersionString())
}
});

Loading…
Cancel
Save