Browse Source

Merges pull request #53

SSR Rendering of Meta Tags
master
silberengel 8 months ago
parent
commit
e4c488911a
No known key found for this signature in database
GPG Key ID: 962BEC8725790894
  1. 31
      .cursor/rules/alexandria.mdc
  2. 1257
      package-lock.json
  3. 8
      src/lib/components/publications/PublicationFeed.svelte
  4. 16
      src/lib/components/publications/PublicationHeader.svelte
  5. 4
      src/lib/components/util/ContainingIndexes.svelte
  6. 2
      src/lib/components/util/ViewPublicationLink.svelte
  7. 357
      src/lib/navigator/EventNetwork/Legend.svelte
  8. 2
      src/lib/navigator/EventNetwork/NodeTooltip.svelte
  9. 81
      src/lib/navigator/EventNetwork/Settings.svelte
  10. 8
      src/lib/navigator/EventNetwork/TagTable.svelte
  11. 6
      src/lib/navigator/EventNetwork/utils/personNetworkBuilder.ts
  12. 7
      src/lib/ndk.ts
  13. 2
      src/lib/stores/index.ts
  14. 56
      src/lib/utils.ts
  15. 2
      src/lib/utils/event_search.ts
  16. 6
      src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts
  17. 10
      src/lib/utils/markup/asciidoctorPostProcessor.ts
  18. 5
      src/lib/utils/markup/basicMarkupParser.ts
  19. 5
      src/lib/utils/network_detection.ts
  20. 11
      src/lib/utils/nostrUtils.ts
  21. 2
      src/lib/utils/nostr_identifiers.ts
  22. 143
      src/lib/utils/websocket_utils.ts
  23. 24
      src/routes/+layout.ts
  24. 4
      src/routes/about/+page.svelte
  25. 6
      src/routes/events/+page.svelte
  26. 5
      src/routes/proxy+layout.ts
  27. 41
      src/routes/publication/+page.server.ts
  28. 134
      src/routes/publication/+page.svelte
  29. 115
      src/routes/publication/+page.ts
  30. 28
      src/routes/publication/[type]/[identifier]/+layout.server.ts
  31. 29
      src/routes/publication/[type]/[identifier]/+layout.svelte
  32. 65
      src/routes/publication/[type]/[identifier]/+layout.ts
  33. 18
      src/routes/publication/[type]/[identifier]/+page.server.ts
  34. 114
      src/routes/publication/[type]/[identifier]/+page.svelte
  35. 54
      src/routes/publication/[type]/[identifier]/+page.ts
  36. 10
      src/routes/start/+page.svelte

31
.cursor/rules/alexandria.mdc

@ -9,11 +9,7 @@ You are senior full-stack software engineer with 20 years of experience writing
## Project Overview ## Project Overview
Alexandria is a Nostr project written in Svelte 5 and SvelteKit 2. It is a web app for reading, commenting on, and publishing books, blogs, and other long-form content stored on Nostr relays. It revolves around breaking long AsciiDoc documents into Nostr events, with each event containing a paragraph or so of text from the document. These individual content events are organized by index events into publications. An index contains an ordered list of references to other index events or content events, forming a tree. Alexandria is a Nostr project written in Svelte 5 and SvelteKit 2. It is a web app for reading, commenting on, and publishing books, blogs, and other long-form content stored on Nostr relays.
### Reader Features
In reader mode, Alexandria loads a document tree from a root publication index event. The AsciiDoc text content of the various content events, along with headers specified by tags in the index events, is composed and rendered as a single document from the user's point of view.
### Tech Stack ### Tech Stack
@ -36,14 +32,33 @@ When responding to prompts, adhere to the following rules:
- Avoid proposing code edits unless I specifically tell you to do so. - Avoid proposing code edits unless I specifically tell you to do so.
- When giving examples from my codebase, include the file name and line numbers so I can find the relevant code easily. - When giving examples from my codebase, include the file name and line numbers so I can find the relevant code easily.
## Code Style ## Coding Guidelines
### Prime Directive
NEVER assume developer intent. If you are unsure about something, ALWAYS stop and ask the developer for clarification before proceeding.
### AI Anchor Comments
Observe the following style guidelines when writing code: - Use anchor comments prefixed with `AI-NOTE:`, `AI-TODO:`, or `AI-QUESTION:` to share context between AI agents and developers across time.
- Use all-caps prefixes.
- Also _read_ (but do not write) variants of this format that begin with `AI-<date>:` where `<date>` is some date in `MM/DD/YYYY` format. Anchor comments with this format are used by developers to record context.
- **Important:** Before scanning files, ALWAYS search first for `AI-` anchor comments in relevant subdirectories.
- ALWAYS update relevant anchor comments when modifying associated code.
- NEVER remove `AI-` comments unless the developer explicitly instructs it.
- Add new anchor comments as relevant when:
- Code is unusually complex.
- Code is critical to security, performance, or functionality.
- Code is confusing.
- Code could have a bug.
### General Guidance ### General Guidance
- Before writing any code, ALWAYS search the codebase for relevant anchor comments.
- Whenever updating code, ALWAYS update relevant anchor comments.
- Prefer to use Deno to manage dependencies, build the project, and run tests.
- Use snake_case names for plain TypeScript files. - Use snake_case names for plain TypeScript files.
- Use comments sparingly; code should be self-documenting. - Use comments sparingly; aim to make code readable and self-documenting.
### JavaScript/TypeScript ### JavaScript/TypeScript

1257
package-lock.json generated

File diff suppressed because it is too large Load Diff

8
src/lib/components/publications/PublicationFeed.svelte

@ -290,9 +290,9 @@
}; };
// Debounced search function // Debounced search function
const debouncedSearch = debounce(async (query: string) => { const debouncedSearch = debounce((query: string | undefined) => {
console.debug("[PublicationFeed] Search query changed:", query); console.debug("[PublicationFeed] Search query changed:", query);
if (query.trim()) { if (query && query.trim()) {
const filtered = filterEventsBySearch(allIndexEvents); const filtered = filterEventsBySearch(allIndexEvents);
eventsInView = filtered.slice(0, 30); eventsInView = filtered.slice(0, 30);
endOfFeed = filtered.length <= 30; endOfFeed = filtered.length <= 30;
@ -303,10 +303,6 @@
}, 300); }, 300);
$effect(() => { $effect(() => {
console.debug(
"[PublicationFeed] Search query effect triggered:",
props.searchQuery,
);
debouncedSearch(props.searchQuery); debouncedSearch(props.searchQuery);
}); });

16
src/lib/components/publications/PublicationHeader.svelte

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { naddrEncode } from "$lib/utils"; import { naddrEncode, neventEncode } from "$lib/utils";
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { activeInboxRelays } from "$lib/ndk"; import { activeInboxRelays } from "$lib/ndk";
import { Card } from "flowbite-svelte"; import { Card } from "flowbite-svelte";
@ -7,6 +7,7 @@
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import LazyImage from "$components/util/LazyImage.svelte"; import LazyImage from "$components/util/LazyImage.svelte";
import { generateDarkPastelColor } from "$lib/utils/image_utils"; import { generateDarkPastelColor } from "$lib/utils/image_utils";
import { indexKind } from "$lib/consts";
const { event } = $props<{ event: NDKEvent }>(); const { event } = $props<{ event: NDKEvent }>();
@ -19,11 +20,16 @@
}); });
const href = $derived.by(() => { const href = $derived.by(() => {
const d = event.getMatchingTags("d")[0]?.[1]; const dTag = event.getMatchingTags("d")[0]?.[1];
if (d != null) { const isIndexEvent = event.kind === indexKind;
return `publication?d=${d}`;
if (dTag != null && isIndexEvent) {
// For index events with d tag, use naddr encoding
const naddr = naddrEncode(event, relays);
return `publication/naddr/${naddr}`;
} else { } else {
return `publication?id=${naddrEncode(event, relays)}`; // Fallback to d tag if available
return dTag ? `publication/d/${dTag}` : null;
} }
}); });

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

@ -47,12 +47,12 @@
function navigateToIndex(indexEvent: NDKEvent) { function navigateToIndex(indexEvent: NDKEvent) {
const dTag = getMatchingTags(indexEvent, "d")[0]?.[1]; const dTag = getMatchingTags(indexEvent, "d")[0]?.[1];
if (dTag) { if (dTag) {
goto(`/publication?d=${encodeURIComponent(dTag)}`); goto(`/publication/d/${encodeURIComponent(dTag)}`);
} else { } else {
// Fallback to naddr // Fallback to naddr
try { try {
const naddr = naddrEncode(indexEvent, $activeInboxRelays); const naddr = naddrEncode(indexEvent, $activeInboxRelays);
goto(`/publication?id=${encodeURIComponent(naddr)}`); goto(`/publication/naddr/${encodeURIComponent(naddr)}`);
} catch (err) { } catch (err) {
console.error("[ContainingIndexes] Error creating naddr:", err); console.error("[ContainingIndexes] Error creating naddr:", err);
} }

2
src/lib/components/util/ViewPublicationLink.svelte

@ -64,7 +64,7 @@
"ViewPublicationLink: Navigating to publication:", "ViewPublicationLink: Navigating to publication:",
naddrAddress, naddrAddress,
); );
goto(`/publication?id=${encodeURIComponent(naddrAddress)}`); goto(`/publication/naddr/${encodeURIComponent(naddrAddress)}`);
} else { } else {
console.log("ViewPublicationLink: No naddr address found for event"); console.log("ViewPublicationLink: No naddr address found for event");
} }

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

@ -97,7 +97,13 @@
</script> </script>
<div class={`leather-legend ${className}`}> <div class={`leather-legend ${className}`}>
<div class="flex items-center justify-between space-x-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md px-2 py-1 -mx-2 -my-1" onclick={toggle}> <button
class="flex items-center justify-between space-x-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md px-2 py-1 -mx-2 -my-1 w-full text-left border-none bg-none"
onclick={toggle}
onkeydown={(e) => e.key === 'Enter' || e.key === ' ' ? toggle() : null}
aria-expanded={expanded}
aria-controls="legend-content"
>
<h3 class="h-leather">Legend</h3> <h3 class="h-leather">Legend</h3>
<div class="pointer-events-none"> <div class="pointer-events-none">
{#if expanded} {#if expanded}
@ -106,13 +112,19 @@
<CaretDownOutline /> <CaretDownOutline />
{/if} {/if}
</div> </div>
</div> </button>
{#if expanded} {#if expanded}
<div class="space-y-4"> <div id="legend-content" class="space-y-4">
<!-- Node Types Section --> <!-- Node Types Section -->
<div class="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 last:border-b-0 last:mb-0"> <div class="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 last:border-b-0 last:mb-0">
<div class="flex justify-between items-center cursor-pointer px-2 py-2 rounded hover:bg-gray-50 dark:hover:bg-gray-800 mb-3" onclick={toggleNodeTypes}> <button
class="flex justify-between items-center cursor-pointer px-2 py-2 rounded hover:bg-gray-50 dark:hover:bg-gray-800 mb-3 w-full text-left border-none bg-none"
onclick={toggleNodeTypes}
onkeydown={(e) => e.key === 'Enter' || e.key === ' ' ? toggleNodeTypes() : null}
aria-expanded={nodeTypesExpanded}
aria-controls="node-types-content"
>
<h4 class="font-semibold text-gray-700 dark:text-gray-300 text-sm m-0">Node Types</h4> <h4 class="font-semibold text-gray-700 dark:text-gray-300 text-sm m-0">Node Types</h4>
<div class="pointer-events-none"> <div class="pointer-events-none">
{#if nodeTypesExpanded} {#if nodeTypesExpanded}
@ -121,87 +133,95 @@
<CaretDownOutline class="w-3 h-3" /> <CaretDownOutline class="w-3 h-3" />
{/if} {/if}
</div> </div>
</div> </button>
{#if nodeTypesExpanded} {#if nodeTypesExpanded}
<ul class="space-y-2"> <div id="node-types-content">
<!-- Dynamic event kinds --> <ul class="space-y-2">
{#each Object.entries(eventCounts).sort(([a], [b]) => Number(a) - Number(b)) as [kindStr, count]} <!-- Dynamic event kinds -->
{@const kind = Number(kindStr)} {#each Object.entries(eventCounts).sort(([a], [b]) => Number(a) - Number(b)) as [kindStr, count]}
{@const countNum = count as number} {@const kind = Number(kindStr)}
{@const color = getEventKindColor(kind)} {@const countNum = count as number}
{@const name = getEventKindName(kind)} {@const color = getEventKindColor(kind)}
{#if countNum > 0} {@const name = getEventKindName(kind)}
<li class="flex items-center mb-2 last:mb-0"> {#if countNum > 0}
<div class="flex items-center mr-2"> <li class="flex items-center mb-2 last:mb-0">
<span <div class="flex items-center mr-2">
class="w-4 h-4 rounded-full" <span
style="background-color: {color}" class="w-4 h-4 rounded-full"
> style="background-color: {color}"
>
</span>
</div>
<span class="text-sm text-gray-700 dark:text-gray-300">
{kind} - {name} ({countNum})
</span> </span>
</div> </li>
<span class="text-sm text-gray-700 dark:text-gray-300">
{kind} - {name} ({countNum})
</span>
</li>
{/if}
{/each}
<!-- Connection lines -->
<li class="flex items-center mb-2 last:mb-0">
<svg class="w-6 h-6 mr-2" viewBox="0 0 24 24">
<path
d="M4 12h16M16 6l6 6-6 6"
class="network-link-leather"
stroke-width="2"
fill="none"
/>
</svg>
<span class="text-sm text-gray-700 dark:text-gray-300">
{#if starMode}
Radial connections from centers to related events
{:else}
Arrows indicate relationships and sequence
{/if} {/if}
</span> {/each}
</li>
<!-- Connection lines -->
<!-- Edge colors for person connections -->
{#if showPersonNodes && personAnchors.length > 0}
<li class="flex items-center mb-2 last:mb-0">
<svg class="w-6 h-6 mr-2" viewBox="0 0 24 24">
<path
d="M4 12h16"
class="person-link-signed"
stroke-width="2"
fill="none"
/>
</svg>
<span class="text-xs text-gray-700 dark:text-gray-300">
Authored by person
</span>
</li>
<li class="flex items-center mb-2 last:mb-0"> <li class="flex items-center mb-2 last:mb-0">
<svg class="w-6 h-6 mr-2" viewBox="0 0 24 24"> <svg class="w-6 h-6 mr-2" viewBox="0 0 24 24">
<path <path
d="M4 12h16" d="M4 12h16M16 6l6 6-6 6"
class="person-link-referenced" class="network-link-leather"
stroke-width="2" stroke-width="2"
fill="none" fill="none"
/> />
</svg> </svg>
<span class="text-xs text-gray-700 dark:text-gray-300"> <span class="text-sm text-gray-700 dark:text-gray-300">
References person {#if starMode}
Radial connections from centers to related events
{:else}
Arrows indicate relationships and sequence
{/if}
</span> </span>
</li> </li>
{/if}
</ul> <!-- Edge colors for person connections -->
{#if showPersonNodes && personAnchors.length > 0}
<li class="flex items-center mb-2 last:mb-0">
<svg class="w-6 h-6 mr-2" viewBox="0 0 24 24">
<path
d="M4 12h16"
class="person-link-signed"
stroke-width="2"
fill="none"
/>
</svg>
<span class="text-xs text-gray-700 dark:text-gray-300">
Authored by person
</span>
</li>
<li class="flex items-center mb-2 last:mb-0">
<svg class="w-6 h-6 mr-2" viewBox="0 0 24 24">
<path
d="M4 12h16"
class="person-link-referenced"
stroke-width="2"
fill="none"
/>
</svg>
<span class="text-xs text-gray-700 dark:text-gray-300">
References person
</span>
</li>
{/if}
</ul>
</div>
{/if} {/if}
</div> </div>
<!-- Tag Anchor Controls Section --> <!-- Tag Anchor Controls Section -->
<div class="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 last:border-b-0 last:mb-0"> <div class="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 last:border-b-0 last:mb-0">
<div class="flex justify-between items-center cursor-pointer px-2 py-2 rounded hover:bg-gray-50 dark:hover:bg-gray-800 mb-3" onclick={() => tagControlsExpanded = !tagControlsExpanded}> <button
class="flex justify-between items-center cursor-pointer px-2 py-2 rounded hover:bg-gray-50 dark:hover:bg-gray-800 mb-3 w-full text-left border-none bg-none"
onclick={() => tagControlsExpanded = !tagControlsExpanded}
onkeydown={(e) => e.key === 'Enter' || e.key === ' ' ? (tagControlsExpanded = !tagControlsExpanded) : null}
aria-expanded={tagControlsExpanded}
aria-controls="tag-controls-content"
>
<h4 class="font-semibold text-gray-700 dark:text-gray-300 text-sm m-0">Tag Anchor Controls</h4> <h4 class="font-semibold text-gray-700 dark:text-gray-300 text-sm m-0">Tag Anchor Controls</h4>
<div class="pointer-events-none"> <div class="pointer-events-none">
{#if tagControlsExpanded} {#if tagControlsExpanded}
@ -210,10 +230,10 @@
<CaretDownOutline class="w-3 h-3" /> <CaretDownOutline class="w-3 h-3" />
{/if} {/if}
</div> </div>
</div> </button>
{#if tagControlsExpanded} {#if tagControlsExpanded}
<div class="space-y-3"> <div id="tag-controls-content" class="space-y-3">
<!-- Show Tag Anchors Toggle --> <!-- Show Tag Anchors Toggle -->
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<button <button
@ -221,7 +241,9 @@
showTagAnchors = !showTagAnchors; showTagAnchors = !showTagAnchors;
onTagSettingsChange(); onTagSettingsChange();
}} }}
onkeydown={(e) => e.key === 'Enter' || e.key === ' ' ? (showTagAnchors = !showTagAnchors, onTagSettingsChange()) : null}
class="px-2 py-1 border border-gray-300 dark:border-gray-700 rounded text-xs font-medium cursor-pointer transition min-w-[3rem] hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-primary-500 {showTagAnchors ? 'bg-blue-600 text-white border-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:text-white dark:border-blue-600 dark:hover:bg-blue-700' : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'}" class="px-2 py-1 border border-gray-300 dark:border-gray-700 rounded text-xs font-medium cursor-pointer transition min-w-[3rem] hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-primary-500 {showTagAnchors ? 'bg-blue-600 text-white border-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:text-white dark:border-blue-600 dark:hover:bg-blue-700' : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'}"
aria-pressed={showTagAnchors}
> >
{showTagAnchors ? 'ON' : 'OFF'} {showTagAnchors ? 'ON' : 'OFF'}
</button> </button>
@ -231,8 +253,9 @@
{#if showTagAnchors} {#if showTagAnchors}
<!-- Tag Type Selection --> <!-- Tag Type Selection -->
<div> <div>
<label class="text-xs text-gray-600 dark:text-gray-400">Tag Type:</label> <label for="tag-type-select" class="text-xs text-gray-600 dark:text-gray-400">Tag Type:</label>
<select <select
id="tag-type-select"
bind:value={selectedTagType} bind:value={selectedTagType}
onchange={onTagSettingsChange} onchange={onTagSettingsChange}
class="w-full text-xs bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1 dark:text-white mt-1" class="w-full text-xs bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1 dark:text-white mt-1"
@ -253,7 +276,13 @@
<!-- Tag Anchors section --> <!-- Tag Anchors section -->
{#if showTags && tagAnchors.length > 0} {#if showTags && tagAnchors.length > 0}
<div class="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 last:border-b-0 last:mb-0"> <div class="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 last:border-b-0 last:mb-0">
<div class="flex justify-between items-center cursor-pointer px-2 py-2 rounded hover:bg-gray-50 dark:hover:bg-gray-800 mb-3" onclick={toggleTagAnchors}> <button
class="flex justify-between items-center cursor-pointer px-2 py-2 rounded hover:bg-gray-50 dark:hover:bg-gray-800 mb-3 w-full text-left border-none bg-none"
onclick={toggleTagAnchors}
onkeydown={(e) => e.key === 'Enter' || e.key === ' ' ? toggleTagAnchors() : null}
aria-expanded={tagAnchorsExpanded}
aria-controls="tag-anchors-content"
>
<h4 class="font-semibold text-gray-700 dark:text-gray-300 text-sm m-0">Active Tag Anchors: {tagAnchors[0].type}</h4> <h4 class="font-semibold text-gray-700 dark:text-gray-300 text-sm m-0">Active Tag Anchors: {tagAnchors[0].type}</h4>
<div class="pointer-events-none"> <div class="pointer-events-none">
{#if tagAnchorsExpanded} {#if tagAnchorsExpanded}
@ -262,89 +291,70 @@
<CaretDownOutline class="w-3 h-3" /> <CaretDownOutline class="w-3 h-3" />
{/if} {/if}
</div> </div>
</div> </button>
{#if tagAnchorsExpanded} {#if tagAnchorsExpanded}
{@const sortedAnchors = tagSortMode === 'count' {@const sortedAnchors = tagSortMode === 'count'
? [...tagAnchors].sort((a, b) => b.count - a.count) ? [...tagAnchors].sort((a, b) => b.count - a.count)
: [...tagAnchors].sort((a, b) => a.label.localeCompare(b.label)) : [...tagAnchors].sort((a, b) => a.label.localeCompare(b.label))
} }
{#if autoDisabledTags} <div id="tag-anchors-content">
<div class="text-xs text-amber-600 dark:text-amber-400 mb-2 p-2 bg-amber-50 dark:bg-amber-900/20 rounded"> {#if autoDisabledTags}
<strong>Note:</strong> All {tagAnchors.length} tags were auto-disabled to prevent graph overload. Click individual tags below to enable them. <div class="text-xs text-amber-600 dark:text-amber-400 mb-2 p-2 bg-amber-50 dark:bg-amber-900/20 rounded">
</div> <strong>Note:</strong> All {tagAnchors.length} tags were auto-disabled to prevent graph overload. Click individual tags below to enable them.
{/if} </div>
{/if}
<!-- Sort options and controls -->
<div class="flex items-center justify-between gap-4 mb-3"> <!-- Sort options and controls -->
<div class="flex items-center gap-4"> <div class="flex items-center justify-between gap-4 mb-3">
<span class="text-xs text-gray-600 dark:text-gray-400">Sort by:</span> <div class="flex items-center gap-4">
<label class="flex items-center gap-1 cursor-pointer"> <span class="text-xs text-gray-600 dark:text-gray-400">Sort by:</span>
<input <label class="flex items-center gap-1 cursor-pointer">
type="radio" <input
name="tagSort" type="radio"
value="count" name="tagSort"
bind:group={tagSortMode} value="count"
class="w-3 h-3" bind:group={tagSortMode}
/> class="w-3 h-3"
<span class="text-xs">Count</span> />
</label> <span class="text-xs">Count</span>
<label class="flex items-center gap-1 cursor-pointer"> </label>
<input <label class="flex items-center gap-1 cursor-pointer">
type="radio" <input
name="tagSort" type="radio"
value="alphabetical" name="tagSort"
bind:group={tagSortMode} value="alphabetical"
class="w-3 h-3" bind:group={tagSortMode}
/> class="w-3 h-3"
<span class="text-xs">Alphabetical</span> />
</label> <span class="text-xs">Alphabetical</span>
</label>
</div>
</div> </div>
<label class="flex items-center gap-1 cursor-pointer"> <div class="space-y-1 max-h-48 overflow-y-auto">
<input {#each sortedAnchors as tag}
type="checkbox" {@const isDisabled = disabledTags.has(tag.value)}
onclick={invertTagSelection} <button
class="w-3 h-3" class="flex items-center justify-between w-full p-2 rounded text-left border-none bg-none cursor-pointer transition hover:bg-black/5 dark:hover:bg-white/5 disabled:opacity-50"
/> onclick={() => onTagToggle(tag.value)}
<span class="text-xs">Invert Selection</span> onkeydown={(e) => e.key === 'Enter' || e.key === ' ' ? onTagToggle(tag.value) : null}
</label> disabled={false}
</div> title={isDisabled ? `Click to show ${tag.label}` : `Click to hide ${tag.label}`}
aria-pressed={!isDisabled}
<div
class="grid gap-1 {tagAnchors.length > 20 ? 'max-h-96 overflow-y-auto pr-2' : ''}"
style="grid-template-columns: repeat({TAG_LEGEND_COLUMNS}, 1fr);"
>
{#each sortedAnchors as anchor}
{@const tagId = `${anchor.type}-${anchor.label}`}
{@const isDisabled = disabledTags.has(tagId)}
<button
class="flex items-center gap-1 p-1 rounded w-full text-left border-none bg-none cursor-pointer transition hover:bg-black/5 dark:hover:bg-white/5 disabled:opacity-50"
onclick={() => onTagToggle(tagId)}
title={isDisabled ? `Click to show ${anchor.label}` : `Click to hide ${anchor.label}`}
> >
<span class="text-xs text-gray-700 dark:text-gray-300" style="opacity: {isDisabled ? 0.5 : 1};">
{tag.label} ({tag.count})
</span>
<div class="flex items-center"> <div class="flex items-center">
<span <span
class="w-4.5 h-4.5 rounded-full border-2 border-white flex items-center justify-center" class="inline-block w-3.5 h-3.5 rotate-45 border-2 border-white"
style="background-color: {anchor.color}; opacity: {isDisabled ? 0.3 : 1};" style="background-color: {getEventKindColor(30040)}; opacity: {isDisabled ? 0.3 : 1};"
> ></span>
<span class="text-xs text-white font-bold">
{anchor.type === "t"
? "#"
: anchor.type === "author"
? "A"
: anchor.type.charAt(0).toUpperCase()}
</span>
</span>
</div> </div>
<span class="text-xs text-gray-700 dark:text-gray-300 truncate" style="opacity: {isDisabled ? 0.5 : 1};" title={anchor.label}>
{anchor.label.length > 25 ? anchor.label.slice(0, 22) + '...' : anchor.label}
{#if !isDisabled}
<span class="text-gray-500 dark:text-gray-400">({anchor.count})</span>
{/if}
</span>
</button> </button>
{/each} {/each}
</div>
</div> </div>
{/if} {/if}
</div> </div>
@ -352,7 +362,13 @@
<!-- Person Visualizer Section --> <!-- Person Visualizer Section -->
<div class="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 last:border-b-0 last:mb-0"> <div class="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 last:border-b-0 last:mb-0">
<div class="flex justify-between items-center cursor-pointer px-2 py-2 rounded hover:bg-gray-50 dark:hover:bg-gray-800 mb-3" onclick={() => personVisualizerExpanded = !personVisualizerExpanded}> <button
class="flex justify-between items-center cursor-pointer px-2 py-2 rounded hover:bg-gray-50 dark:hover:bg-gray-800 mb-3 w-full text-left border-none bg-none"
onclick={() => personVisualizerExpanded = !personVisualizerExpanded}
onkeydown={(e) => e.key === 'Enter' || e.key === ' ' ? (personVisualizerExpanded = !personVisualizerExpanded) : null}
aria-expanded={personVisualizerExpanded}
aria-controls="person-visualizer-content"
>
<h4 class="font-semibold text-gray-700 dark:text-gray-300 text-sm m-0">Person Visualizer</h4> <h4 class="font-semibold text-gray-700 dark:text-gray-300 text-sm m-0">Person Visualizer</h4>
<div class="pointer-events-none"> <div class="pointer-events-none">
{#if personVisualizerExpanded} {#if personVisualizerExpanded}
@ -361,10 +377,10 @@
<CaretDownOutline class="w-3 h-3" /> <CaretDownOutline class="w-3 h-3" />
{/if} {/if}
</div> </div>
</div> </button>
{#if personVisualizerExpanded} {#if personVisualizerExpanded}
<div class="space-y-3"> <div id="person-visualizer-content" class="space-y-3">
<!-- Show Person Nodes Toggle --> <!-- Show Person Nodes Toggle -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
@ -373,7 +389,9 @@
showPersonNodes = !showPersonNodes; showPersonNodes = !showPersonNodes;
onPersonSettingsChange(); onPersonSettingsChange();
}} }}
onkeydown={(e) => e.key === 'Enter' || e.key === ' ' ? (showPersonNodes = !showPersonNodes, onPersonSettingsChange()) : null}
class="px-2 py-1 border border-gray-300 dark:border-gray-700 rounded text-xs font-medium cursor-pointer transition min-w-[3rem] hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-primary-500 {showPersonNodes ? 'bg-blue-600 text-white border-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:text-white dark:border-blue-600 dark:hover:bg-blue-700' : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'}" class="px-2 py-1 border border-gray-300 dark:border-gray-700 rounded text-xs font-medium cursor-pointer transition min-w-[3rem] hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-primary-500 {showPersonNodes ? 'bg-blue-600 text-white border-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:text-white dark:border-blue-600 dark:hover:bg-blue-700' : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300'}"
aria-pressed={showPersonNodes}
> >
{showPersonNodes ? 'ON' : 'OFF'} {showPersonNodes ? 'ON' : 'OFF'}
</button> </button>
@ -430,37 +448,30 @@
> >
{#each personAnchors as person} {#each personAnchors as person}
{@const isDisabled = disabledPersons.has(person.pubkey)} {@const isDisabled = disabledPersons.has(person.pubkey)}
<button <button
class="flex items-center gap-1 p-1 rounded w-full text-left border-none bg-none cursor-pointer transition hover:bg-black/5 dark:hover:bg-white/5 disabled:opacity-50" class="flex items-center gap-1 p-1 rounded w-full text-left border-none bg-none cursor-pointer transition hover:bg-black/5 dark:hover:bg-white/5 disabled:opacity-50"
onclick={() => { onclick={() => {
if (showPersonNodes) { if (showPersonNodes) {
onPersonToggle(person.pubkey); onPersonToggle(person.pubkey);
} }
}} }}
disabled={!showPersonNodes} onkeydown={(e) => e.key === 'Enter' || e.key === ' ' ? (showPersonNodes && onPersonToggle(person.pubkey)) : null}
title={!showPersonNodes ? 'Enable "Show Person Nodes" first' : isDisabled ? `Click to show ${person.displayName || person.pubkey}` : `Click to hide ${person.displayName || person.pubkey}`} disabled={!showPersonNodes}
> title={!showPersonNodes ? 'Enable "Show Person Nodes" first' : isDisabled ? `Click to show ${person.displayName || person.pubkey}` : `Click to hide ${person.displayName || person.pubkey}`}
<div class="flex items-center"> aria-pressed={!isDisabled}
<span >
class="inline-block w-3.5 h-3.5 rotate-45 border-2 border-white" <div class="flex items-center">
style="background-color: {person.isFromFollowList ? getEventKindColor(3) : '#10B981'}; opacity: {isDisabled ? 0.3 : 1};" <span
/> class="inline-block w-3.5 h-3.5 rotate-45 border-2 border-white"
</div> style="background-color: {person.isFromFollowList ? getEventKindColor(3) : '#10B981'}; opacity: {isDisabled ? 0.3 : 1};"
<span class="text-xs text-gray-700 dark:text-gray-300" style="opacity: {isDisabled ? 0.5 : 1};"> ></span>
{person.displayName || person.pubkey.slice(0, 8) + '...'} </div>
{#if !isDisabled} <span class="text-xs text-gray-700 dark:text-gray-300" style="opacity: {isDisabled ? 0.5 : 1};">
<span class="text-gray-500 dark:text-gray-400"> {person.displayName || person.pubkey.substring(0, 8)}
({person.signedByCount || 0}s/{person.referencedCount || 0}r) </span>
</span> </button>
{/if}
</span>
</button>
{/each} {/each}
</div> </div>
{:else if showPersonNodes}
<p class="text-xs text-gray-500 dark:text-gray-400">
No people found in the current events.
</p>
{/if} {/if}
</div> </div>
{/if} {/if}

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

@ -188,7 +188,7 @@
<div class="tooltip-content"> <div class="tooltip-content">
<!-- Title with link --> <!-- Title with link -->
<div class="tooltip-title"> <div class="tooltip-title">
<a href={getEventUrl(node)} class="tooltip-title-link"> <a href="/publication?id={node.id}" class="tooltip-title-link">
{getLinkText(node)} {getLinkText(node)}
</a> </a>
</div> </div>

81
src/lib/navigator/EventNetwork/Settings.svelte

@ -42,7 +42,13 @@
</script> </script>
<div class="leather-legend sm:!right-1 sm:!left-auto"> <div class="leather-legend sm:!right-1 sm:!left-auto">
<div class="flex items-center justify-between space-x-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md px-2 py-1 -mx-2 -my-1" onclick={toggle}> <button
class="flex items-center justify-between space-x-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md px-2 py-1 -mx-2 -my-1 w-full text-left border-none bg-none"
onclick={toggle}
onkeydown={(e) => e.key === 'Enter' || e.key === ' ' ? toggle() : null}
aria-expanded={expanded}
aria-controls="settings-content"
>
<h3 class="h-leather">Settings</h3> <h3 class="h-leather">Settings</h3>
<div class="pointer-events-none"> <div class="pointer-events-none">
{#if expanded} {#if expanded}
@ -51,10 +57,10 @@
<CaretDownOutline /> <CaretDownOutline />
{/if} {/if}
</div> </div>
</div> </button>
{#if expanded} {#if expanded}
<div class="space-y-4"> <div id="settings-content" class="space-y-4">
<span class="leather bg-transparent legend-text"> <span class="leather bg-transparent legend-text">
Showing {count} of {totalCount} events Showing {count} of {totalCount} events
</span> </span>
@ -63,9 +69,12 @@
<div <div
class="settings-section border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 last:border-b-0 last:mb-0" class="settings-section border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 last:border-b-0 last:mb-0"
> >
<div <button
class="settings-section-header flex justify-between items-center cursor-pointer py-2 mb-3 hover:bg-gray-50 dark:hover:bg-white/5 hover:rounded-md hover:px-2" class="settings-section-header flex justify-between items-center cursor-pointer py-2 mb-3 hover:bg-gray-50 dark:hover:bg-white/5 hover:rounded-md hover:px-2 w-full text-left border-none bg-none"
onclick={toggleEventTypes} onclick={toggleEventTypes}
onkeydown={(e) => e.key === 'Enter' || e.key === ' ' ? toggleEventTypes() : null}
aria-expanded={eventTypesExpanded}
aria-controls="event-types-content"
> >
<h4 class="settings-section-title font-semibold text-gray-700 dark:text-gray-300 m-0 text-sm"> <h4 class="settings-section-title font-semibold text-gray-700 dark:text-gray-300 m-0 text-sm">
Event Configuration Event Configuration
@ -77,21 +86,24 @@
<CaretDownOutline class="w-3 h-3" /> <CaretDownOutline class="w-3 h-3" />
{/if} {/if}
</div> </div>
</div> </button>
{#if eventTypesExpanded} {#if eventTypesExpanded}
<EventTypeConfig onReload={onupdate} {eventCounts} {profileStats} /> <div id="event-types-content">
<EventTypeConfig onReload={onupdate} {eventCounts} {profileStats} />
</div>
{/if} {/if}
</div> </div>
<!-- Visual Settings Section --> <!-- Visual Settings Section -->
<div <div
class="settings-section border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 last:border-b-0 last:mb-0" class="settings-section border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 last:border-b-0 last:mb-0"
> >
<div <button
class="settings-section-header flex justify-between items-center cursor-pointer py-2 mb-3 hover:bg-gray-50 dark:hover:bg-white/5 hover:rounded-md hover:px-2" class="settings-section-header flex justify-between items-center cursor-pointer py-2 mb-3 hover:bg-gray-50 dark:hover:bg-white/5 hover:rounded-md hover:px-2 w-full text-left border-none bg-none"
onclick={toggleVisualSettings} onclick={toggleVisualSettings}
onkeydown={(e) => e.key === 'Enter' || e.key === ' ' ? toggleVisualSettings() : null}
aria-expanded={visualSettingsExpanded}
aria-controls="visual-settings-content"
> >
<h4 class="settings-section-title font-semibold text-gray-700 dark:text-gray-300 m-0 text-sm"> <h4 class="settings-section-title font-semibold text-gray-700 dark:text-gray-300 m-0 text-sm">
Visual Settings Visual Settings
@ -103,32 +115,31 @@
<CaretDownOutline class="w-3 h-3" /> <CaretDownOutline class="w-3 h-3" />
{/if} {/if}
</div> </div>
</div> </button>
{#if visualSettingsExpanded} {#if visualSettingsExpanded}
<div id="visual-settings-content">
<div class="space-y-4"> <div class="space-y-4">
<div class="space-y-2"> <div class="space-y-2">
<label <label
class="leather bg-transparent legend-text flex items-center space-x-2" class="leather bg-transparent legend-text flex items-center space-x-2"
> >
<Toggle <Toggle
checked={starVisualization} checked={starVisualization}
onchange={(e: Event) => { onchange={(e: Event) => {
const target = e.target as HTMLInputElement; const target = e.target as HTMLInputElement;
starVisualization = target.checked; starVisualization = target.checked;
}} }}
class="text-xs" class="text-xs"
/> />
<span>Star Network View</span> <span>Star Network View</span>
</label> </label>
<p class="text-xs text-gray-500 dark:text-gray-400"> <p class="text-xs text-gray-500 dark:text-gray-400">
Toggle between star clusters (on) and linear sequence (off) Toggle between star clusters (on) and linear sequence (off)
visualization visualization
</p> </p>
</div>
</div>
</div> </div>
</div>
{/if} {/if}
</div> </div>
</div> </div>

8
src/lib/navigator/EventNetwork/TagTable.svelte

@ -12,12 +12,12 @@
}>(); }>();
// Computed property for unique tags // Computed property for unique tags
let uniqueTags = $derived(() => { let uniqueTags = $derived.by(() => {
const tagMap = new Map(); const tagMap = new Map<string, { value: string; count: number; firstEvent: string }>();
events.forEach(event => { events.forEach((event: NDKEvent) => {
const tags = event.tags || []; const tags = event.tags || [];
tags.forEach(tag => { tags.forEach((tag: string[]) => {
if (tag[0] === selectedTagType) { if (tag[0] === selectedTagType) {
const tagValue = tag[1]; const tagValue = tag[1];
const count = tagMap.get(tagValue)?.count || 0; const count = tagMap.get(tagValue)?.count || 0;

6
src/lib/navigator/EventNetwork/utils/personNetworkBuilder.ts

@ -293,13 +293,15 @@ export function createPersonLinks(
connectionType = 'referenced'; connectionType = 'referenced';
} }
return { const link: PersonLink = {
source: anchor, source: anchor,
target: node, target: node,
isSequential: false, isSequential: false,
connectionType, connectionType,
}; };
}).filter(Boolean); // Remove undefineds
return link;
}).filter((link): link is PersonLink => link !== undefined); // Remove undefineds and type guard
}); });
debug("Created person links", { linkCount: links.length }); debug("Created person links", { linkCount: links.length });

7
src/lib/ndk.ts

@ -6,7 +6,7 @@ import NDK, {
NDKUser, NDKUser,
NDKEvent, NDKEvent,
} from "@nostr-dev-kit/ndk"; } from "@nostr-dev-kit/ndk";
import { get, writable, type Writable } from "svelte/store"; import { writable, get, type Writable } from "svelte/store";
import { import {
loginStorageKey, loginStorageKey,
} from "./consts.ts"; } from "./consts.ts";
@ -33,6 +33,11 @@ export const outboxRelays = writable<string[]>([]);
export const activeInboxRelays = writable<string[]>([]); export const activeInboxRelays = writable<string[]>([]);
export const activeOutboxRelays = writable<string[]>([]); export const activeOutboxRelays = writable<string[]>([]);
// Subscribe to userStore changes and update ndkSignedIn accordingly
userStore.subscribe((userState) => {
ndkSignedIn.set(userState.signedIn);
});
/** /**
* Custom authentication policy that handles NIP-42 authentication manually * Custom authentication policy that handles NIP-42 authentication manually
* when the default NDK authentication fails * when the default NDK authentication fails

2
src/lib/stores/index.ts

@ -1,2 +0,0 @@
export * from './relayStore';
export * from './displayLimits';

56
src/lib/utils.ts

@ -1,6 +1,21 @@
import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { getMatchingTags } from "./utils/nostrUtils.ts"; import { getMatchingTags } from "./utils/nostrUtils.ts";
import type { AddressPointer, EventPointer } from "nostr-tools/nip19";
export class DecodeError extends Error {
constructor(message: string) {
super(message);
this.name = "DecodeError";
}
}
export class InvalidKindError extends DecodeError {
constructor(message: string) {
super(message);
this.name = "InvalidKindError";
}
}
export function neventEncode(event: NDKEvent, relays: string[]) { export function neventEncode(event: NDKEvent, relays: string[]) {
return nip19.neventEncode({ return nip19.neventEncode({
@ -29,6 +44,44 @@ export function nprofileEncode(pubkey: string, relays: string[]) {
return nip19.nprofileEncode({ pubkey, relays }); return nip19.nprofileEncode({ pubkey, relays });
} }
/**
* Decodes a nostr identifier (naddr, nevent) and returns the decoded data.
* @param identifier The nostr identifier to decode.
* @param expectedType The expected type of the decoded data ('naddr' or 'nevent').
* @returns The decoded data.
*/
function decodeNostrIdentifier<T extends AddressPointer | EventPointer>(
identifier: string,
expectedType: "naddr" | "nevent",
): T {
try {
if (!identifier.startsWith(expectedType)) {
throw new InvalidKindError(`Invalid ${expectedType} format`);
}
const decoded = nip19.decode(identifier);
if (decoded.type !== expectedType) {
throw new InvalidKindError(`Decoded result is not an ${expectedType}`);
}
return decoded.data as T;
} catch (error) {
throw new DecodeError(`Failed to decode ${expectedType}: ${error}`);
}
}
/**
* Decodes an naddr identifier and returns the decoded data
*/
export function naddrDecode(naddr: string): AddressPointer {
return decodeNostrIdentifier<AddressPointer>(naddr, "naddr");
}
/**
* Decodes an nevent identifier and returns the decoded data
*/
export function neventDecode(nevent: string): EventPointer {
return decodeNostrIdentifier<EventPointer>(nevent, "nevent");
}
export function formatDate(unixtimestamp: number) { export function formatDate(unixtimestamp: number) {
const months = [ const months = [
"Jan", "Jan",
@ -169,7 +222,8 @@ Array.prototype.findIndexAsync = function <T>(
* @param wait The number of milliseconds to delay * @param wait The number of milliseconds to delay
* @returns A debounced version of the function * @returns A debounced version of the function
*/ */
export function debounce<T extends (...args: unknown[]) => unknown>( // deno-lint-ignore no-explicit-any
export function debounce<T extends (...args: any[]) => any>(
func: T, func: T,
wait: number, wait: number,
): (...args: Parameters<T>) => void { ): (...args: Parameters<T>) => void {

2
src/lib/utils/event_search.ts

@ -1,8 +1,8 @@
import { ndkInstance } from "../ndk.ts"; import { ndkInstance } from "../ndk.ts";
import { fetchEventWithFallback } from "./nostrUtils.ts"; import { fetchEventWithFallback } from "./nostrUtils.ts";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import type { NDKFilter } from "@nostr-dev-kit/ndk"; import type { NDKFilter } from "@nostr-dev-kit/ndk";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { wellKnownUrl, isValidNip05Address } from "./search_utils.ts"; import { wellKnownUrl, isValidNip05Address } from "./search_utils.ts";
import { TIMEOUTS, VALIDATION } from "./search_constants.ts"; import { TIMEOUTS, VALIDATION } from "./search_constants.ts";

6
src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts

@ -32,9 +32,11 @@ export async function postProcessAdvancedAsciidoctorHtml(
} }
if ( if (
typeof globalThis !== "undefined" && typeof globalThis !== "undefined" &&
typeof globalThis.MathJax?.typesetPromise === "function" // deno-lint-ignore no-explicit-any
typeof (globalThis as any).MathJax?.typesetPromise === "function"
) { ) {
setTimeout(() => globalThis.MathJax.typesetPromise(), 0); // deno-lint-ignore no-explicit-any
setTimeout(() => (globalThis as any).MathJax.typesetPromise(), 0);
} }
return processedHtml; return processedHtml;
} catch (error) { } catch (error) {

10
src/lib/utils/markup/asciidoctorPostProcessor.ts

@ -24,9 +24,10 @@ function replaceWikilinks(html: string): string {
(_match, target, label) => { (_match, target, label) => {
const normalized = normalizeDTag(target.trim()); const normalized = normalizeDTag(target.trim());
const display = (label || target).trim(); const display = (label || target).trim();
const url = `./events?d=${normalized}`; const url = `/events?d=${normalized}`;
// Output as a clickable <a> with the [[display]] format and matching link colors // Output as a clickable <a> with the [[display]] format and matching link colors
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${display}</a>`; // Use onclick to bypass SvelteKit routing and navigate directly
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}" onclick="window.location.href='${url}'; return false;">${display}</a>`;
}, },
); );
} }
@ -37,8 +38,9 @@ function replaceWikilinks(html: string): string {
function replaceAsciiDocAnchors(html: string): string { function replaceAsciiDocAnchors(html: string): string {
return html.replace(/<a id="([^"]+)"><\/a>/g, (_match, id) => { return html.replace(/<a id="([^"]+)"><\/a>/g, (_match, id) => {
const normalized = normalizeDTag(id.trim()); const normalized = normalizeDTag(id.trim());
const url = `./events?d=${normalized}`; const url = `/events?d=${normalized}`;
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${id}</a>`; // Use onclick to bypass SvelteKit routing and navigate directly
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}" onclick="window.location.href='${url}'; return false;">${id}</a>`;
}); });
} }

5
src/lib/utils/markup/basicMarkupParser.ts

@ -160,9 +160,10 @@ function replaceWikilinks(text: string): string {
(_match, target, label) => { (_match, target, label) => {
const normalized = normalizeDTag(target.trim()); const normalized = normalizeDTag(target.trim());
const display = (label || target).trim(); const display = (label || target).trim();
const url = `./events?d=${normalized}`; const url = `/events?d=${normalized}`;
// Output as a clickable <a> with the [[display]] format and matching link colors // Output as a clickable <a> with the [[display]] format and matching link colors
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${display}</a>`; // Use onclick to bypass SvelteKit routing and navigate directly
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}" onclick="window.location.href='${url}'; return false;">${display}</a>`;
}, },
); );
} }

5
src/lib/utils/network_detection.ts

@ -153,10 +153,11 @@ export function getRelaySetForNetworkCondition(
*/ */
export function startNetworkMonitoring( export function startNetworkMonitoring(
onNetworkChange: (condition: NetworkCondition) => void, onNetworkChange: (condition: NetworkCondition) => void,
checkInterval: number = 60000 // Increased to 60 seconds to reduce spam checkInterval: number = 60000, // Increased to 60 seconds to reduce spam
): () => void { ): () => void {
let lastCondition: NetworkCondition | null = null; let lastCondition: NetworkCondition | null = null;
let intervalId: number | null = null; // deno-lint-ignore no-explicit-any
let intervalId: any = null;
const checkNetwork = async () => { const checkNetwork = async () => {
try { try {

11
src/lib/utils/nostrUtils.ts

@ -147,7 +147,7 @@ export function createProfileLink(
const escapedText = escapeHtml(displayText || defaultText); const escapedText = escapeHtml(displayText || defaultText);
// Remove target="_blank" for internal navigation // Remove target="_blank" for internal navigation
return `<a href="./events?id=${escapedId}" class="npub-badge">@${escapedText}</a>`; return `<a href="/events?id=${escapedId}" class="npub-badge">@${escapedText}</a>`;
} }
/** /**
@ -230,9 +230,9 @@ export async function createProfileLinkWithVerification(
const type = nip05.endsWith("edu") ? "edu" : "standard"; const type = nip05.endsWith("edu") ? "edu" : "standard";
switch (type) { switch (type) {
case "edu": case "edu":
return `<span class="npub-badge"><a href="./events?id=${escapedId}">@${displayIdentifier}</a>${graduationCapSvg}</span>`; return `<span class="npub-badge"><a href="/events?id=${escapedId}">@${displayIdentifier}</a>${graduationCapSvg}</span>`;
case "standard": case "standard":
return `<span class="npub-badge"><a href="./events?id=${escapedId}">@${displayIdentifier}</a>${badgeCheckSvg}</span>`; return `<span class="npub-badge"><a href="/events?id=${escapedId}">@${displayIdentifier}</a>${badgeCheckSvg}</span>`;
} }
} }
/** /**
@ -244,7 +244,7 @@ function createNoteLink(identifier: string): string {
const escapedId = escapeHtml(cleanId); const escapedId = escapeHtml(cleanId);
const escapedText = escapeHtml(shortId); const escapedText = escapeHtml(shortId);
return `<a href="./events?id=${escapedId}" class="inline-flex items-center text-primary-600 dark:text-primary-500 hover:underline break-all">${escapedText}</a>`; return `<a href="/events?id=${escapedId}" class="inline-flex items-center text-primary-600 dark:text-primary-500 hover:underline break-all">${escapedText}</a>`;
} }
/** /**
@ -428,6 +428,9 @@ Promise.prototype.withTimeout = function <T>(
return withTimeout(timeoutMs, this); return withTimeout(timeoutMs, this);
}; };
// TODO: Implement fetch for no-auth relays using the WebSocketPool and raw WebSockets.
// This fetch function will be used for server-side loading.
/** /**
* Fetches an event using a two-step relay strategy: * Fetches an event using a two-step relay strategy:
* 1. First tries standard relays with timeout * 1. First tries standard relays with timeout

2
src/lib/utils/nostr_identifiers.ts

@ -1,9 +1,9 @@
import { VALIDATION } from './search_constants'; import { VALIDATION } from './search_constants';
import type { NostrEventId } from './nostr_identifiers';
/** /**
* Nostr identifier types * Nostr identifier types
*/ */
export type NostrEventId = string; // 64-character hex string
export type NostrCoordinate = string; // kind:pubkey:d-tag format export type NostrCoordinate = string; // kind:pubkey:d-tag format
export type NostrIdentifier = NostrEventId | NostrCoordinate; export type NostrIdentifier = NostrEventId | NostrCoordinate;

143
src/lib/utils/websocket_utils.ts

@ -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}`);
}
}

24
src/routes/+layout.ts

@ -8,14 +8,16 @@ import { loginMethodStorageKey } from "../lib/stores/userStore.ts";
import Pharos, { pharosInstance } from "../lib/parser.ts"; import Pharos, { pharosInstance } from "../lib/parser.ts";
import type { LayoutLoad } from "./$types"; import type { LayoutLoad } from "./$types";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { browser } from "$app/environment";
// AI-NOTE: Leave SSR off until event fetches are implemented server-side.
export const ssr = false; export const ssr = false;
export const load: LayoutLoad = () => { /**
// Initialize NDK with new relay management system * Attempts to restore the user's authentication session from localStorage.
const ndk = initNdk(); * Handles extension, Amber (NIP-46), and npub login methods.
ndkInstance.set(ndk); */
function restoreAuthSession() {
try { try {
const pubkey = getPersistedLogin(); const pubkey = getPersistedLogin();
const loginMethod = localStorage.getItem(loginMethodStorageKey); const loginMethod = localStorage.getItem(loginMethodStorageKey);
@ -113,9 +115,19 @@ export const load: LayoutLoad = () => {
`Failed to restore login: ${e}\n\nContinuing with anonymous session.`, `Failed to restore login: ${e}\n\nContinuing with anonymous session.`,
); );
} }
}
export const load: LayoutLoad = () => {
// Initialize NDK with new relay management system
const ndk = initNdk();
ndkInstance.set(ndk);
if (browser) {
restoreAuthSession();
}
const parser = new Pharos(ndk); const parser = new Pharos(ndk);
pharosInstance.set(parser); pharosInstance.set(parser);
return { return {
ndk, ndk,

4
src/routes/about/+page.svelte

@ -26,11 +26,11 @@
<P class="mb-3"> <P class="mb-3">
Alexandria is a reader and writer for <A Alexandria is a reader and writer for <A
href="./publication?d=gitcitadel-project-documentation-curated-publications-specification-7-by-stella-v-1" href="./publication/d/gitcitadel-project-documentation-curated-publications-specification-7-by-stella-v-1"
>curated publications</A >curated publications</A
> (in Asciidoc), wiki pages (Asciidoc), and will eventually also support long-form > (in Asciidoc), wiki pages (Asciidoc), and will eventually also support long-form
articles (markup). It is produced by the <A articles (markup). It is produced by the <A
href="./publication?d=gitcitadel-project-documentation-by-stella-v-1" href="./publication/d/gitcitadel-project-documentation-by-stella-v-1"
>GitCitadel project team</A >GitCitadel project team</A
>. >.
</P> </P>

6
src/routes/events/+page.svelte

@ -354,9 +354,9 @@
</script> </script>
<div class="w-full flex justify-center"> <div class="w-full flex justify-center">
<div class="flex w-full max-w-7xl my-6 px-4 mx-auto gap-6"> <div class="flex flex-col lg:flex-row w-full max-w-7xl my-6 px-4 mx-auto gap-6">
<!-- Left Panel: Search and Results --> <!-- Left Panel: Search and Results -->
<div class={showSidePanel ? "w-80 min-w-80" : "flex-1 max-w-4xl mx-auto"}> <div class={showSidePanel ? "w-full lg:w-80 lg:min-w-80" : "flex-1 max-w-4xl mx-auto"}>
<div class="main-leather flex flex-col space-y-6"> <div class="main-leather flex flex-col space-y-6">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<Heading tag="h1" class="h-leather mb-2">Events</Heading> <Heading tag="h1" class="h-leather mb-2">Events</Heading>
@ -775,7 +775,7 @@
<!-- Right Panel: Event Details --> <!-- Right Panel: Event Details -->
{#if showSidePanel && event} {#if showSidePanel && event}
<div class="flex-1 min-w-0 main-leather flex flex-col space-y-6"> <div class="w-full lg:flex-1 lg:min-w-0 main-leather flex flex-col space-y-6">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<Heading tag="h2" class="h-leather mb-2">Event Details</Heading> <Heading tag="h2" class="h-leather mb-2">Event Details</Heading>
<button <button

5
src/routes/proxy+layout.ts

@ -0,0 +1,5 @@
import type { LayoutLoad } from "./$types";
export const load: LayoutLoad = async () => {
return {};
};

41
src/routes/publication/+page.server.ts

@ -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);
};

134
src/routes/publication/+page.svelte

@ -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>

115
src/routes/publication/+page.ts

@ -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,
};
};

28
src/routes/publication/[type]/[identifier]/+layout.server.ts

@ -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,
},
};
};

29
src/routes/publication/[type]/[identifier]/+layout.svelte

@ -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()}

65
src/routes/publication/[type]/[identifier]/+layout.ts

@ -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}`);
}
};

18
src/routes/publication/[type]/[identifier]/+page.server.ts

@ -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
};
};

114
src/routes/publication/[type]/[identifier]/+page.svelte

@ -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}

54
src/routes/publication/[type]/[identifier]/+page.ts

@ -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}`);
}
};

10
src/routes/start/+page.svelte

@ -91,7 +91,7 @@
<P class="mb-3"> <P class="mb-3">
An example of a book is <a An example of a book is <a
href="/publication?d=jane-eyre-an-autobiography-by-charlotte-bront%C3%AB-v-3rd-edition" href="/publication/d/jane-eyre-an-autobiography-by-charlotte-bront%C3%AB-v-3rd-edition"
>Jane Eyre</a >Jane Eyre</a
> >
</P> </P>
@ -127,7 +127,7 @@
<P class="mb-3"> <P class="mb-3">
An example of a research paper is <a An example of a research paper is <a
href="/publication?d=less-partnering-less-children-or-both-by-julia-hellstrand-v-1" href="/publication/d/less-partnering-less-children-or-both-by-julia-hellstrand-v-1"
>Less Partnering, Less Children, or Both?</a >Less Partnering, Less Children, or Both?</a
> >
</P> </P>
@ -145,9 +145,9 @@
<P class="mb-3"> <P class="mb-3">
Our own team uses Alexandria to document the app, to display our <a 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 href="/publication/d/the-gitcitadel-blog-by-stella-v-1">blog entries</a
>, as well as to store copies of our most interesting >, as well as to store copies of our most interesting
<a href="/publication?d=gitcitadel-project-documentation-by-stella-v-1" <a href="/publication/d/gitcitadel-project-documentation-by-stella-v-1"
>technical specifications</a >technical specifications</a
>. >.
</P> </P>
@ -168,7 +168,7 @@
collaborative knowledge bases and documentation. Wiki pages, such as this collaborative knowledge bases and documentation. Wiki pages, such as this
one about the <button one about the <button
class="underline text-primary-700 bg-transparent border-none p-0" class="underline text-primary-700 bg-transparent border-none p-0"
onclick={() => goto("/publication?d=sybil")}>Sybil utility</button onclick={() => goto("/publication/d/sybil")}>Sybil utility</button
> use the same Asciidoc format as other publications but are specifically designed > use the same Asciidoc format as other publications but are specifically designed
for interconnected, evolving content. for interconnected, evolving content.
</P> </P>

Loading…
Cancel
Save