Browse Source

Merges pull request #52

Feature/cleaned my notes
master
silberengel 7 months ago
parent
commit
4999e51055
No known key found for this signature in database
GPG Key ID: 962BEC8725790894
  1. 6
      package-lock.json
  2. 12
      playwright.config.ts
  3. 22
      src/app.css
  4. 1
      src/lib/components/Navigation.svelte
  5. 2
      src/lib/components/RelayStatus.svelte
  6. 70
      src/lib/components/publications/PublicationFeed.svelte
  7. 18
      src/lib/components/publications/PublicationHeader.svelte
  8. 18
      src/lib/components/publications/PublicationSection.svelte
  9. 3
      src/lib/components/util/ArticleNav.svelte
  10. 69
      src/lib/components/util/ViewPublicationLink.svelte
  11. 102
      src/lib/data_structures/publication_tree.ts
  12. 21
      src/lib/utils.ts
  13. 134
      src/lib/utils/websocket_utils.ts
  14. 2
      src/routes/+layout.svelte
  15. 276
      src/routes/my-notes/+page.svelte
  16. 123
      src/routes/publication/+error.svelte
  17. 39
      src/routes/publication/[type]/[identifier]/+layout.server.ts
  18. 2
      src/routes/publication/[type]/[identifier]/+layout.svelte
  19. 14
      src/routes/publication/[type]/[identifier]/+page.svelte
  20. 85
      src/routes/publication/[type]/[identifier]/+page.ts
  21. 103
      tests/e2e/my_notes_layout.pw.spec.ts

6
package-lock.json generated

@ -6422,9 +6422,9 @@ @@ -6422,9 +6422,9 @@
}
},
"node_modules/svelte": {
"version": "5.37.2",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.37.2.tgz",
"integrity": "sha512-SAakJiy04/OvXRAUnGxRACGzw6GB9kmxYIjuMO/zTcTL6psqc54Y0O/yR6I3OLqFqn79EPd23qsCGkKozvYYbQ==",
"version": "5.37.3",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.37.3.tgz",
"integrity": "sha512-7t/ejshehHd+95z3Z7ebS7wsqHDQxi/8nBTuTRwpMgNegfRBfuitCSKTUDKIBOExqfT2+DhQ2VLG8Xn+cBXoaQ==",
"dev": true,
"license": "MIT",
"dependencies": {

12
playwright.config.ts

@ -27,7 +27,7 @@ export default defineConfig({ @@ -27,7 +27,7 @@ export default defineConfig({
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
// baseURL: 'http://127.0.0.1:3000',
baseURL: 'http://localhost:5173',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
@ -72,11 +72,11 @@ export default defineConfig({ @@ -72,11 +72,11 @@ export default defineConfig({
],
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// url: 'http://127.0.0.1:3000',
// reuseExistingServer: !process.env.CI,
// },
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
// Glob patterns or regular expressions to ignore test files.
// testIgnore: '*test-assets',

22
src/app.css

@ -247,6 +247,28 @@ @@ -247,6 +247,28 @@
@apply text-base font-semibold;
}
/* Line clamp utilities for text truncation */
.line-clamp-1 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
}
.line-clamp-2 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.line-clamp-3 {
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
/* Lists */
.ol-leather li a,
.ul-leather li a {

1
src/lib/components/Navigation.svelte

@ -31,6 +31,7 @@ @@ -31,6 +31,7 @@
<NavLi href="/visualize">Visualize</NavLi>
<NavLi href="/start">Getting Started</NavLi>
<NavLi href="/events">Events</NavLi>
<NavLi href="/my-notes">My Notes</NavLi>
<NavLi href="/about">About</NavLi>
<NavLi href="/contact">Contact</NavLi>
<NavLi>

2
src/lib/components/RelayStatus.svelte

@ -136,7 +136,7 @@ import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk"; @@ -136,7 +136,7 @@ import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
<div class="space-y-2">
{#each relayStatuses as status}
<div class="flex items-center justify-between p-3 border rounded-lg">
<div class="flex items-center justify-between p-3">
<div class="flex-1">
<div class="font-medium">{status.url}</div>
<div class="text-sm {getStatusColor(status)}">

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

@ -7,10 +7,9 @@ @@ -7,10 +7,9 @@
import { onMount, onDestroy } from "svelte";
import {
getMatchingTags,
NDKRelaySetFromNDK,
type NDKEvent,
type NDKRelaySet,
} from "$lib/utils/nostrUtils";
import { WebSocketPool } from "$lib/data_structures/websocket_pool";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { searchCache } from "$lib/utils/searchCache";
import { indexEventCache } from "$lib/utils/indexEventCache";
import { isValidNip05Address } from "$lib/utils/search_utility";
@ -139,21 +138,54 @@ @@ -139,21 +138,54 @@
async function fetchFromRelay(relay: string): Promise<void> {
try {
console.debug(`[PublicationFeed] Fetching from relay: ${relay}`);
const relaySet = NDKRelaySetFromNDK.fromRelayUrls([relay], ndk);
let eventSet = await ndk
.fetchEvents(
{
kinds: [indexKind],
limit: 1000, // Increased limit to get more events
},
{
groupable: false,
skipVerification: false,
skipValidation: false,
},
relaySet,
)
.withTimeout(5000); // Reduced timeout to 5 seconds for faster response
// Use WebSocketPool to get a pooled connection
const ws = await WebSocketPool.instance.acquire(relay);
const subId = crypto.randomUUID();
// Create a promise that resolves with the events
const eventPromise = new Promise<Set<NDKEvent>>((resolve, reject) => {
const events = new Set<NDKEvent>();
const messageHandler = (ev: MessageEvent) => {
try {
const data = JSON.parse(ev.data);
if (data[0] === "EVENT" && data[1] === subId) {
const event = new NDKEvent(ndk, data[2]);
events.add(event);
} else if (data[0] === "EOSE" && data[1] === subId) {
resolve(events);
}
} catch (error) {
console.error(`[PublicationFeed] Error parsing message from ${relay}:`, error);
}
};
const errorHandler = (ev: Event) => {
reject(new Error(`WebSocket error for ${relay}: ${ev}`));
};
ws.addEventListener("message", messageHandler);
ws.addEventListener("error", errorHandler);
// Send the subscription request
ws.send(JSON.stringify([
"REQ",
subId,
{ kinds: [indexKind], limit: 1000 }
]));
// Set up cleanup
setTimeout(() => {
ws.removeEventListener("message", messageHandler);
ws.removeEventListener("error", errorHandler);
WebSocketPool.instance.release(ws);
resolve(events);
}, 5000);
});
let eventSet = await eventPromise;
console.debug(`[PublicationFeed] Raw events from ${relay}:`, eventSet.size);
eventSet = filterValidIndexEvents(eventSet);
@ -364,7 +396,7 @@ @@ -364,7 +396,7 @@
<div class="flex flex-col space-y-4">
<div
class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 w-full"
class="grid grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 w-full"
>
{#if loading && eventsInView.length === 0}
{#each getSkeletonIds() as id}

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

@ -47,9 +47,9 @@ @@ -47,9 +47,9 @@
</script>
{#if title != null && href != null}
<Card class="ArticleBox card-leather max-w-md h-48 flex flex-row space-x-2 relative">
<Card class="ArticleBox card-leather w-full h-48 flex flex-row space-x-2 relative">
<div
class="flex-shrink-0 w-32 h-40 overflow-hidden rounded flex items-center justify-center p-2 -mt-2"
class="flex-shrink-0 w-40 h-40 overflow-hidden rounded flex items-center justify-center p-2 -mt-2"
>
{#if image}
<LazyImage
@ -67,12 +67,12 @@ @@ -67,12 +67,12 @@
{/if}
</div>
<div class="flex flex-col flex-grow space-x-2">
<div class="flex flex-col flex-grow">
<a href="/{href}" class="flex flex-col space-y-2 h-full">
<div class="flex-grow pt-2">
<h2 class="text-lg font-bold line-clamp-2" {title}>{title}</h2>
<h3 class="text-base font-normal mt-2">
<div class="flex flex-col flex-grow space-x-2 min-w-0">
<div class="flex flex-col flex-grow min-w-0">
<a href="/{href}" class="flex flex-col space-y-2 h-full min-w-0">
<div class="flex-grow pt-2 min-w-0">
<h2 class="text-lg font-bold line-clamp-2 break-words" {title}>{title}</h2>
<h3 class="text-base font-normal mt-2 break-words">
by
{#if authorPubkey != null}
{@render userBadge(authorPubkey, author)}
@ -82,7 +82,7 @@ @@ -82,7 +82,7 @@
</h3>
</div>
{#if version != "1"}
<h3 class="text-sm font-semibold text-primary-600 dark:text-primary-400 mt-auto">version: {version}</h3>
<h3 class="text-sm font-semibold text-primary-600 dark:text-primary-400 mt-auto break-words">version: {version}</h3>
{/if}
</a>
</div>

18
src/lib/components/publications/PublicationSection.svelte

@ -11,6 +11,7 @@ @@ -11,6 +11,7 @@
import { getMatchingTags } from "$lib/utils/nostrUtils";
import type { SveltePublicationTree } from "./svelte_publication_tree.svelte";
import { postProcessAdvancedAsciidoctorHtml } from "$lib/utils/markup/advancedAsciidoctorPostProcessor";
import { parseAdvancedmarkup } from "$lib/utils/markup/advancedMarkupParser";
let {
address,
@ -48,10 +49,19 @@ @@ -48,10 +49,19 @@
);
let leafContent: Promise<string | Document> = $derived.by(async () => {
const content = (await leafEvent)?.content ?? "";
const converted = asciidoctor.convert(content);
const processed = await postProcessAdvancedAsciidoctorHtml(converted.toString());
return processed;
const event = await leafEvent;
const content = event?.content ?? "";
// AI-NOTE: Kind 30023 events contain Markdown content, not AsciiDoc
// Use parseAdvancedmarkup for 30023 events, Asciidoctor for 30041/30818 events
if (event?.kind === 30023) {
return await parseAdvancedmarkup(content);
} else {
// For 30041 and 30818 events, use Asciidoctor (AsciiDoc)
const converted = asciidoctor.convert(content);
const processed = await postProcessAdvancedAsciidoctorHtml(converted.toString());
return processed;
}
});
let previousLeafEvent: NDKEvent | null = $derived.by(() => {

3
src/lib/components/util/ArticleNav.svelte

@ -27,6 +27,7 @@ @@ -27,6 +27,7 @@
indexEvent.getMatchingTags("p")[0]?.[1] ?? null,
);
let isLeaf: boolean = $derived(indexEvent.kind === 30041);
let isIndexEvent: boolean = $derived(indexEvent.kind === 30040);
let lastScrollY = $state(0);
let isVisible = $state(true);
@ -140,7 +141,7 @@ @@ -140,7 +141,7 @@
<span class="hidden sm:inline">Back</span>
</Button>
{/if}
{#if !isLeaf}
{#if isIndexEvent}
{#if publicationType === "blog"}
<Button
class={`btn-leather hidden sm:flex !w-auto ${$publicationColumnVisibility.blog ? "active" : ""}`}

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

@ -21,52 +21,69 @@ @@ -21,52 +21,69 @@
return getEventType(event.kind || 0) === "addressable";
}
function getNaddrAddress(event: NDKEvent): string | null {
if (!isAddressableEvent(event)) {
return null;
}
try {
return naddrEncode(event, $activeInboxRelays);
} catch {
return null;
}
}
// AI-NOTE: Always ensure the returned address is a valid naddr1... string.
// If the tag value is a raw coordinate (kind:pubkey:d-tag), encode it.
// If it's already naddr1..., use as-is. Otherwise, fallback to event's own naddr.
function getViewPublicationNaddr(event: NDKEvent): string | null {
// First, check for a-tags with 'defer' - these indicate the event is deferring to someone else's version
const aTags = getMatchingTags(event, "a");
for (const tag of aTags) {
if (tag.length >= 2 && tag.includes("defer")) {
// This is a deferral to someone else's addressable event
return tag[1]; // Return the addressable event address
const value = tag[1];
if (value.startsWith("naddr1")) {
return value;
}
// Check for coordinate format: kind:pubkey:d-tag
const coordMatch = value.match(/^(\d+):([0-9a-fA-F]{64}):(.+)$/);
if (coordMatch) {
const [_, kind, pubkey, dTag] = coordMatch;
try {
return naddrEncode({ kind: Number(kind), pubkey, tags: [["d", dTag]] } as NDKEvent, $activeInboxRelays);
} catch {
return null;
}
}
// Fallback: if not naddr1 or coordinate, ignore
}
}
// For deferred events with deferral tag, use the deferral naddr instead of the event's own naddr
const deferralNaddr = getDeferralNaddr(event);
if (deferralNaddr) {
return deferralNaddr;
if (deferralNaddr.startsWith("naddr1")) {
return deferralNaddr;
}
const coordMatch = deferralNaddr.match(/^(\d+):([0-9a-fA-F]{64}):(.+)$/);
if (coordMatch) {
const [_, kind, pubkey, dTag] = coordMatch;
try {
return naddrEncode({ kind: Number(kind), pubkey, tags: [["d", dTag]] } as NDKEvent, $activeInboxRelays);
} catch {
return null;
}
}
}
// Otherwise, use the event's own naddr if it's addressable
return getNaddrAddress(event);
}
function getNaddrAddress(event: NDKEvent): string | null {
if (!isAddressableEvent(event)) {
return null;
}
try {
return naddrEncode(event, $activeInboxRelays);
} catch {
return null;
}
}
function navigateToPublication() {
const naddrAddress = getViewPublicationNaddr(event);
console.log("ViewPublicationLink: navigateToPublication called", {
eventKind: event.kind,
naddrAddress,
isAddressable: isAddressableEvent(event),
});
if (naddrAddress) {
console.log(
"ViewPublicationLink: Navigating to publication:",
naddrAddress,
);
goto(`/publication/naddr/${encodeURIComponent(naddrAddress)}`);
} else {
console.log("ViewPublicationLink: No naddr address found for event");
const url = `/publication/naddr/${naddrAddress}`;
goto(url);
}
}

102
src/lib/data_structures/publication_tree.ts

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
import type NDK from "@nostr-dev-kit/ndk";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { Lazy } from "./lazy.ts";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import type NDK from "@nostr-dev-kit/ndk";
import { fetchEventById } from "../utils/websocket_utils.ts";
enum PublicationTreeNodeType {
Branch,
@ -583,6 +584,52 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -583,6 +584,52 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
.filter((tag) => tag[0] === "a")
.map((tag) => tag[1]);
console.debug(`[PublicationTree] Current event ${currentEvent.id} has ${currentEvent.tags.length} tags:`, currentEvent.tags);
console.debug(`[PublicationTree] Found ${currentChildAddresses.length} a-tags in current event:`, currentChildAddresses);
// If no a-tags found, try e-tags as fallback
if (currentChildAddresses.length === 0) {
const eTags = currentEvent.tags
.filter((tag) => tag[0] === "e" && tag[1] && /^[0-9a-fA-F]{64}$/.test(tag[1]));
console.debug(`[PublicationTree] Found ${eTags.length} e-tags for current event ${currentEvent.id}:`, eTags.map(tag => tag[1]));
// For e-tags with hex IDs, fetch the referenced events to get their addresses
const eTagPromises = eTags.map(async (tag) => {
try {
console.debug(`[PublicationTree] Fetching event for e-tag ${tag[1]} in depthFirstRetrieve`);
const referencedEvent = await fetchEventById(tag[1]);
if (referencedEvent) {
// Construct the proper address format from the referenced event
const dTag = referencedEvent.tags.find(tag => tag[0] === "d")?.[1];
if (dTag) {
const address = `${referencedEvent.kind}:${referencedEvent.pubkey}:${dTag}`;
console.debug(`[PublicationTree] Constructed address from e-tag in depthFirstRetrieve: ${address}`);
return address;
} else {
console.debug(`[PublicationTree] Referenced event ${tag[1]} has no d-tag in depthFirstRetrieve`);
}
} else {
console.debug(`[PublicationTree] Failed to fetch event for e-tag ${tag[1]} in depthFirstRetrieve - event not found`);
}
return null;
} catch (error) {
console.warn(`[PublicationTree] Failed to fetch event for e-tag ${tag[1]} in depthFirstRetrieve:`, error);
return null;
}
});
const resolvedAddresses = await Promise.all(eTagPromises);
const validAddresses = resolvedAddresses.filter(addr => addr !== null) as string[];
console.debug(`[PublicationTree] Resolved ${validAddresses.length} valid addresses from e-tags in depthFirstRetrieve:`, validAddresses);
if (validAddresses.length > 0) {
currentChildAddresses.push(...validAddresses);
}
}
// If the current event has no children, it is a leaf.
if (currentChildAddresses.length === 0) {
// Return the first leaf if no address was provided.
@ -671,6 +718,52 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -671,6 +718,52 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
.filter((tag) => tag[0] === "a")
.map((tag) => tag[1]);
console.debug(`[PublicationTree] Event ${event.id} has ${event.tags.length} tags:`, event.tags);
console.debug(`[PublicationTree] Found ${childAddresses.length} a-tags:`, childAddresses);
// If no a-tags found, try e-tags as fallback
if (childAddresses.length === 0) {
const eTags = event.tags
.filter((tag) => tag[0] === "e" && tag[1] && /^[0-9a-fA-F]{64}$/.test(tag[1]));
console.debug(`[PublicationTree] Found ${eTags.length} e-tags for event ${event.id}:`, eTags.map(tag => tag[1]));
// For e-tags with hex IDs, fetch the referenced events to get their addresses
const eTagPromises = eTags.map(async (tag) => {
try {
console.debug(`[PublicationTree] Fetching event for e-tag ${tag[1]}`);
const referencedEvent = await fetchEventById(tag[1]);
if (referencedEvent) {
// Construct the proper address format from the referenced event
const dTag = referencedEvent.tags.find(tag => tag[0] === "d")?.[1];
if (dTag) {
const address = `${referencedEvent.kind}:${referencedEvent.pubkey}:${dTag}`;
console.debug(`[PublicationTree] Constructed address from e-tag: ${address}`);
return address;
} else {
console.debug(`[PublicationTree] Referenced event ${tag[1]} has no d-tag`);
}
} else {
console.debug(`[PublicationTree] Failed to fetch event for e-tag ${tag[1]}`);
}
return null;
} catch (error) {
console.warn(`[PublicationTree] Failed to fetch event for e-tag ${tag[1]}:`, error);
return null;
}
});
const resolvedAddresses = await Promise.all(eTagPromises);
const validAddresses = resolvedAddresses.filter(addr => addr !== null) as string[];
console.debug(`[PublicationTree] Resolved ${validAddresses.length} valid addresses from e-tags:`, validAddresses);
if (validAddresses.length > 0) {
childAddresses.push(...validAddresses);
}
}
const node: PublicationTreeNode = {
type: this.#getNodeType(event),
status: PublicationTreeNodeStatus.Resolved,
@ -690,7 +783,10 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> { @@ -690,7 +783,10 @@ export class PublicationTree implements AsyncIterable<NDKEvent | null> {
}
#getNodeType(event: NDKEvent): PublicationTreeNodeType {
if (event.kind === 30040 && event.tags.some((tag) => tag[0] === "a")) {
if (event.kind === 30040 && (
event.tags.some((tag) => tag[0] === "a") ||
event.tags.some((tag) => tag[0] === "e" && tag[1] && /^[0-9a-fA-F]{64}$/.test(tag[1]))
)) {
return PublicationTreeNodeType.Branch;
}

21
src/lib/utils.ts

@ -2,6 +2,7 @@ import type { NDKEvent } from "@nostr-dev-kit/ndk"; @@ -2,6 +2,7 @@ import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools";
import { getMatchingTags } from "./utils/nostrUtils.ts";
import type { AddressPointer, EventPointer } from "nostr-tools/nip19";
import type { NostrEvent } from "./utils/websocket_utils.ts";
export class DecodeError extends Error {
constructor(message: string) {
@ -40,6 +41,26 @@ export function naddrEncode(event: NDKEvent, relays: string[]) { @@ -40,6 +41,26 @@ export function naddrEncode(event: NDKEvent, relays: string[]) {
});
}
/**
* Creates a tag address from a raw Nostr event (for compatibility with NDK events)
* @param event The raw Nostr event
* @param relays Optional relay list for the address
* @returns A tag address string
*/
export function createTagAddress(event: NostrEvent, relays: string[] = []): string {
const dTag = event.tags.find((tag: string[]) => tag[0] === "d")?.[1];
if (!dTag) {
throw new Error("Event does not have a d tag");
}
return nip19.naddrEncode({
identifier: dTag,
pubkey: event.pubkey,
kind: event.kind,
relays,
});
}
export function nprofileEncode(pubkey: string, relays: string[]) {
return nip19.nprofileEncode({ pubkey, relays });
}

134
src/lib/utils/websocket_utils.ts

@ -1,6 +1,8 @@ @@ -1,6 +1,8 @@
import { WebSocketPool } from "../data_structures/websocket_pool.ts";
import { error } from "@sveltejs/kit";
import { naddrDecode, neventDecode } from "../utils.ts";
import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts";
import { get } from "svelte/store";
export interface NostrEvent {
id: string;
@ -25,8 +27,9 @@ export interface NostrFilter { @@ -25,8 +27,9 @@ export interface NostrFilter {
type ResolveCallback<T> = (value: T | PromiseLike<T>) => void;
type RejectCallback = (reason?: any) => void;
type EventHandler = (ev: Event) => void;
type MessageEventHandler = (ev: MessageEvent) => void;
type EventHandlerReject = (reject: RejectCallback) => EventHandler;
type EventHandlerResolve<T> = (resolve: ResolveCallback<T>) => EventHandlerReject;
type EventHandlerResolve<T> = (resolve: ResolveCallback<T>) => (reject: RejectCallback) => MessageEventHandler;
function handleMessage(
ev: MessageEvent,
@ -66,46 +69,93 @@ function handleError( @@ -66,46 +69,93 @@ function handleError(
reject(ev);
}
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();
// AI-NOTE: Currying is used here to abstract the internal handler logic away from the WebSocket
// handling logic. The message and error handlers themselves can be refactored without affecting
// the WebSocket handling logic.
const curriedMessageHandler: (subId: string) => EventHandlerResolve<NostrEvent> =
(subId) =>
(resolve) =>
export async function fetchNostrEvent(filter: NostrFilter): Promise<NostrEvent | null> {
// AI-NOTE: Updated to use active relay stores instead of hardcoded relay URL
// This ensures the function uses the user's configured relays and can find events
// across multiple relays rather than being limited to a single hardcoded relay.
// Get available relays from the active relay stores
const inboxRelays = get(activeInboxRelays);
const outboxRelays = get(activeOutboxRelays);
// Combine all available relays, prioritizing inbox relays
let availableRelays = [...inboxRelays, ...outboxRelays];
// AI-NOTE: Use fallback relays when stores are empty (e.g., during SSR)
// This ensures publications can still load even when relay stores haven't been populated
if (availableRelays.length === 0) {
// Import fallback relays from constants
const { searchRelays, secondaryRelays } = await import("../consts.ts");
availableRelays = [...searchRelays, ...secondaryRelays];
if (availableRelays.length === 0) {
availableRelays = ["wss://thecitadel.nostr1.com"];
}
}
// Try all available relays in parallel and return the first result
const relayPromises = availableRelays.map(async (relay) => {
try {
const ws = await WebSocketPool.instance.acquire(relay);
const subId = crypto.randomUUID();
// AI-NOTE: Currying is used here to abstract the internal handler logic away from the WebSocket
// handling logic. The message and error handlers themselves can be refactored without affecting
// the WebSocket handling logic.
const curriedMessageHandler: (subId: string) => (resolve: ResolveCallback<NostrEvent>) => (reject: RejectCallback) => MessageEventHandler =
(subId) =>
(resolve) =>
(reject) =>
(ev: MessageEvent) =>
handleMessage(ev, subId, resolve, reject);
const curriedErrorHandler: EventHandlerReject =
(reject) =>
(ev: MessageEvent) =>
handleMessage(ev, subId, resolve, reject);
const curriedErrorHandler: EventHandlerReject =
(reject) =>
(ev: Event) =>
handleError(ev, reject);
// AI-NOTE: These variables store references to partially-applied handlers so that the `finally`
// block receives the correct references to clean up the listeners.
let messageHandler: EventHandler;
let errorHandler: EventHandler;
const res = new Promise<NostrEvent>((resolve, reject) => {
messageHandler = curriedMessageHandler(subId)(resolve)(reject);
errorHandler = curriedErrorHandler(reject);
ws.addEventListener("message", messageHandler);
ws.addEventListener("error", errorHandler);
})
.withTimeout(2000)
.finally(() => {
ws.removeEventListener("message", messageHandler);
ws.removeEventListener("error", errorHandler);
WebSocketPool.instance.release(ws);
(ev: Event) =>
handleError(ev, reject);
// AI-NOTE: These variables store references to partially-applied handlers so that the `finally`
// block receives the correct references to clean up the listeners.
let messageHandler: MessageEventHandler;
let errorHandler: EventHandler;
const res = new Promise<NostrEvent>((resolve, reject) => {
messageHandler = curriedMessageHandler(subId)(resolve)(reject);
errorHandler = curriedErrorHandler(reject);
ws.addEventListener("message", messageHandler);
ws.addEventListener("error", errorHandler);
})
.withTimeout(2000)
.finally(() => {
ws.removeEventListener("message", messageHandler);
ws.removeEventListener("error", errorHandler);
WebSocketPool.instance.release(ws);
});
ws.send(JSON.stringify(["REQ", subId, filter]));
const result = await res;
if (result) {
return result;
}
return null;
} catch (err) {
return null;
}
});
ws.send(JSON.stringify(["REQ", subId, filter]));
return res;
// Wait for all relay results and find the first successful one
const results = await Promise.allSettled(relayPromises);
// Find the first successful result
for (const result of results) {
if (result.status === 'fulfilled' && result.value) {
return result.value;
}
}
return null;
}
/**
@ -115,7 +165,7 @@ export async function fetchEventById(id: string): Promise<NostrEvent> { @@ -115,7 +165,7 @@ export async function fetchEventById(id: string): Promise<NostrEvent> {
try {
const event = await fetchNostrEvent({ ids: [id], limit: 1 });
if (!event) {
error(404, `Event not found for ID: ${id}`);
error(404, `Event not found for ID: ${id}. href="/events?id=${id}"`);
}
return event;
} catch (err) {
@ -133,7 +183,7 @@ export async function fetchEventByDTag(dTag: string): Promise<NostrEvent> { @@ -133,7 +183,7 @@ export async function fetchEventByDTag(dTag: string): Promise<NostrEvent> {
try {
const event = await fetchNostrEvent({ "#d": [dTag], limit: 1 });
if (!event) {
error(404, `Event not found for d-tag: ${dTag}`);
error(404, `Event not found for d-tag: ${dTag}. href="/events?d=${dTag}"`);
}
return event;
} catch (err) {
@ -157,7 +207,7 @@ export async function fetchEventByNaddr(naddr: string): Promise<NostrEvent> { @@ -157,7 +207,7 @@ export async function fetchEventByNaddr(naddr: string): Promise<NostrEvent> {
};
const event = await fetchNostrEvent(filter);
if (!event) {
error(404, `Event not found for naddr: ${naddr}`);
error(404, `Event not found for naddr: ${naddr}. href="/events?id=${naddr}"`);
}
return event;
} catch (err) {
@ -176,7 +226,7 @@ export async function fetchEventByNevent(nevent: string): Promise<NostrEvent> { @@ -176,7 +226,7 @@ export async function fetchEventByNevent(nevent: string): Promise<NostrEvent> {
const decoded = neventDecode(nevent);
const event = await fetchNostrEvent({ ids: [decoded.id], limit: 1 });
if (!event) {
error(404, `Event not found for nevent: ${nevent}`);
error(404, `Event not found for nevent: ${nevent}. href="/events?id=${nevent}"`);
}
return event;
} catch (err) {

2
src/routes/+layout.svelte

@ -45,7 +45,7 @@ @@ -45,7 +45,7 @@
<meta name="twitter:image" content={image} />
</svelte:head>
<div class={"leather mt-[76px] h-full w-full flex flex-col items-center"}>
<div class={"leather mt-[76px] w-full mx-auto flex flex-col items-center"}>
<Navigation class="fixed top-0" />
<slot />
</div>

276
src/routes/my-notes/+page.svelte

@ -0,0 +1,276 @@ @@ -0,0 +1,276 @@
<script lang="ts">
import { onMount } from "svelte";
import { userStore } from "$lib/stores/userStore";
import { ndkInstance } from "$lib/ndk";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { get } from "svelte/store";
import { getMatchingTags } from "$lib/utils/nostrUtils";
import { getTitleTagForEvent } from "$lib/utils/event_input_utils";
import asciidoctor from "asciidoctor";
import { postProcessAsciidoctorHtml } from "$lib/utils/markup/asciidoctorPostProcessor";
let events: NDKEvent[] = [];
let loading = true;
let error: string | null = null;
let showTags: Record<string, boolean> = {};
let renderedContent: Record<string, string> = {};
// Tag type and tag filter state
const tagTypes = ["t", "title", "m", "w"]; // 'm' is MIME type
let selectedTagTypes: Set<string> = new Set();
let tagTypeLabels: Record<string, string> = {
t: "hashtag",
title: "",
m: "mime",
w: "wiki",
};
let tagFilter: Set<string> = new Set();
// Unique tags by type
let uniqueTagsByType: Record<string, Set<string>> = {};
let allUniqueTags: Set<string> = new Set();
async function fetchMyNotes() {
loading = true;
error = null;
try {
const user = get(userStore);
if (!user.pubkey) {
error = "You must be logged in to view your notes.";
loading = false;
return;
}
const ndk = get(ndkInstance);
if (!ndk) {
error = "NDK not initialized.";
loading = false;
return;
}
const eventSet = await ndk.fetchEvents({
kinds: [30041],
authors: [user.pubkey],
limit: 1000,
});
events = Array.from(eventSet)
.filter((e): e is NDKEvent => !!e && typeof e.created_at === "number")
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
// Render AsciiDoc for each event
for (const event of events) {
const html = asciidoctor().convert(event.content, {
standalone: false,
doctype: "article",
attributes: { showtitle: true, sectids: true },
});
renderedContent[event.id] = await postProcessAsciidoctorHtml(
html as string,
);
}
// Collect unique tags by type
uniqueTagsByType = {};
allUniqueTags = new Set();
for (const event of events) {
for (const tag of event.tags || []) {
if (tag.length >= 2 && tag[1]) {
if (!uniqueTagsByType[tag[0]]) uniqueTagsByType[tag[0]] = new Set();
uniqueTagsByType[tag[0]].add(tag[1]);
allUniqueTags.add(tag[1]);
}
}
}
} catch (e) {
error = "Failed to fetch notes.";
} finally {
loading = false;
}
}
function getTitle(event: NDKEvent): string {
// Try to get the title tag, else extract from content
const titleTag = getMatchingTags(event, "title");
if (titleTag.length > 0 && titleTag[0][1]) {
return titleTag[0][1];
}
return getTitleTagForEvent(event.kind, event.content) || "Untitled";
}
function getTags(event: NDKEvent): [string, string][] {
// Only return tags that have at least two elements
return (event.tags || []).filter(
(tag): tag is [string, string] => tag.length >= 2,
);
}
function toggleTags(eventId: string) {
showTags[eventId] = !showTags[eventId];
// Force Svelte to update
showTags = { ...showTags };
}
function toggleTagType(type: string) {
if (selectedTagTypes.has(type)) {
selectedTagTypes.delete(type);
} else {
selectedTagTypes.add(type);
}
// Force Svelte to update
selectedTagTypes = new Set(selectedTagTypes);
// Clear tag filter if tag type changes
tagFilter = new Set();
}
function toggleTag(tag: string) {
if (tagFilter.has(tag)) {
tagFilter.delete(tag);
} else {
tagFilter.add(tag);
}
tagFilter = new Set(tagFilter);
}
function clearTagFilter() {
tagFilter = new Set();
}
// Compute which tags to show in the filter
$: tagsToShow = (() => {
if (selectedTagTypes.size === 0) {
return [];
}
let tags = new Set<string>();
for (const type of selectedTagTypes) {
for (const tag of uniqueTagsByType[type] || []) {
tags.add(tag);
}
}
return Array.from(tags).sort();
})();
// Compute filtered events
$: filteredEvents = (() => {
if (selectedTagTypes.size === 0 && tagFilter.size === 0) {
return events;
}
return events.filter((event) => {
const tags = getTags(event);
// If tag type(s) selected, only consider those tags
const relevantTags =
selectedTagTypes.size === 0
? tags
: tags.filter((tag) => selectedTagTypes.has(tag[0]));
// If tag filter is empty, show all events with relevant tags
if (tagFilter.size === 0) {
return relevantTags.length > 0;
}
// Otherwise, event must have at least one of the selected tags
return relevantTags.some((tag) => tagFilter.has(tag[1]));
});
})();
onMount(fetchMyNotes);
</script>
<div
class="flex flex-col lg:flex-row w-full max-w-7xl mx-auto py-8 px-8 gap-8 lg:gap-24 min-w-0 overflow-hidden"
>
<!-- Tag Filter Sidebar -->
<aside class="w-full lg:w-80 flex-shrink-0 self-start">
<h2 class="text-lg font-bold mb-4">Tag Type</h2>
<div class="flex flex-wrap gap-2 mb-6">
{#each tagTypes as type}
<button
class="px-3 py-1 rounded-full text-xs font-medium flex items-center gap-2 transition-colors
bg-amber-100 text-amber-900 hover:bg-amber-200
{selectedTagTypes.has(type)
? 'border-2 border-amber-800'
: 'border border-amber-200'}"
on:click={() => toggleTagType(type)}
>
{#if type.length === 1}
<span class="text-amber-400 font-mono">{type}</span>
<span class="text-amber-900 font-normal">{tagTypeLabels[type]}</span
>
{:else}
<span class="text-amber-900 font-mono">{type}</span>
{/if}
</button>
{/each}
</div>
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-bold">Tag Filter</h2>
{#if tagsToShow.length > 0}
<button
class="ml-2 px-3 py-1 rounded-full text-xs font-medium bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600 border border-gray-300 dark:border-gray-600"
on:click={clearTagFilter}
disabled={tagFilter.size === 0}
>
Clear Tag Filter
</button>
{/if}
</div>
<div class="flex flex-wrap gap-2 mb-4">
{#each tagsToShow as tag}
<button
class="px-3 py-1 rounded-full text-xs font-medium flex items-center gap-2 transition-colors
bg-amber-100 text-amber-900 hover:bg-amber-200
{tagFilter.has(tag)
? 'border-2 border-amber-800'
: 'border border-amber-200'}"
on:click={() => toggleTag(tag)}
>
<span>{tag}</span>
</button>
{/each}
</div>
</aside>
<!-- Notes Feed -->
<div class="flex-1 w-full lg:max-w-5xl lg:ml-auto px-0 lg:px-4 min-w-0 overflow-hidden">
<h1 class="text-2xl font-bold mb-6">My Notes</h1>
{#if loading}
<div class="text-gray-500">Loading…</div>
{:else if error}
<div class="text-red-500">{error}</div>
{:else if filteredEvents.length === 0}
<div class="text-gray-500">No notes found.</div>
{:else}
<ul class="space-y-4 w-full">
{#each filteredEvents as event}
<li class="p-4 bg-white dark:bg-gray-800 rounded shadow w-full overflow-hidden">
<div class="flex items-center justify-between mb-2 min-w-0">
<div class="font-semibold text-lg truncate flex-1 mr-2">{getTitle(event)}</div>
<button
class="flex-shrink-0 px-2 py-1 text-xs rounded bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600"
on:click={() => toggleTags(event.id)}
aria-label="Show tags"
>
{showTags[event.id] ? "Hide Tags" : "Show Tags"}
</button>
</div>
{#if showTags[event.id]}
<div class="mb-2 text-xs flex flex-wrap gap-2">
{#each getTags(event) as tag}
<span
class="bg-amber-900 text-amber-100 px-2 py-1 rounded-full text-xs font-medium flex items-baseline"
>
<span class="font-mono">{tag[0]}:</span>
<span>{tag[1]}</span>
</span>
{/each}
</div>
{/if}
<div class="text-sm text-gray-400 mb-2">
{event.created_at
? new Date(event.created_at * 1000).toLocaleString()
: ""}
</div>
<div
class="prose prose-sm dark:prose-invert max-w-none asciidoc-content overflow-x-auto break-words"
>
{@html renderedContent[event.id] || ""}
</div>
</li>
{/each}
</ul>
{/if}
</div>
</div>

123
src/routes/publication/+error.svelte

@ -3,28 +3,125 @@ @@ -3,28 +3,125 @@
import { Alert, P, Button } from "flowbite-svelte";
import { ExclamationCircleOutline } from "flowbite-svelte-icons";
import { page } from "$app/state";
// Parse error message to extract search parameters and format it nicely
function parseErrorMessage(message: string): {
errorType: string;
identifier: string;
searchUrl?: string;
shortIdentifier?: string;
} {
const searchLinkMatch = message.match(/href="([^"]+)"/);
let searchUrl: string | undefined;
let baseMessage = message;
if (searchLinkMatch) {
searchUrl = searchLinkMatch[1];
baseMessage = message.replace(/href="[^"]+"/, '').trim();
}
// Extract error type and identifier from the message
const match = baseMessage.match(/Event not found for (\w+): (.+)/);
if (match) {
const errorType = match[1];
const fullIdentifier = match[2];
const shortIdentifier = fullIdentifier.length > 50
? fullIdentifier.substring(0, 47) + '...'
: fullIdentifier;
return {
errorType,
identifier: fullIdentifier,
searchUrl,
shortIdentifier
};
}
return {
errorType: 'unknown',
identifier: baseMessage,
searchUrl,
shortIdentifier: baseMessage.length > 50
? baseMessage.substring(0, 47) + '...'
: baseMessage
};
}
$: errorInfo = page.error?.message ? parseErrorMessage(page.error.message) : {
errorType: 'unknown',
identifier: '',
shortIdentifier: ''
};
</script>
<main>
<Alert>
<div class="flex items-center space-x-2">
<ExclamationCircleOutline class="w-6 h-6" />
<span class="text-lg font-medium"> Failed to load publication. </span>
<main class="max-w-2xl mx-auto p-6">
<Alert class="border-l-4 border-red-500 bg-red-50 dark:bg-red-900/20">
<div class="flex items-center space-x-2 mb-4">
<ExclamationCircleOutline class="w-6 h-6 text-red-600 dark:text-red-400" />
<span class="text-lg font-medium text-red-800 dark:text-red-200">
Failed to load publication
</span>
</div>
<P size="sm">
Alexandria failed to find one or more of the events comprising this
publication.
</P>
<P size="xs">
{page.error?.message}
<P size="sm" class="text-gray-700 dark:text-gray-300 mb-4">
Alexandria failed to find one or more of the events comprising this publication.
</P>
<div class="bg-gray-100 dark:bg-gray-800 rounded-lg p-4 mb-4">
<div class="flex items-start space-x-2">
<span class="text-sm font-medium text-gray-600 dark:text-gray-400 min-w-0">
Error Type:
</span>
<span class="text-sm text-gray-800 dark:text-gray-200 font-mono">
{errorInfo.errorType}
</span>
</div>
<div class="flex items-start space-x-2 mt-2">
<span class="text-sm font-medium text-gray-600 dark:text-gray-400 min-w-0">
Identifier:
</span>
<div class="flex-1 min-w-0">
<div class="text-sm text-gray-800 dark:text-gray-200 font-mono break-all">
{errorInfo.shortIdentifier}
</div>
{#if errorInfo.identifier.length > 50}
<details class="mt-2">
<summary class="text-xs text-blue-600 dark:text-blue-400 cursor-pointer hover:underline">
Show full identifier
</summary>
<div class="mt-2 text-xs text-gray-600 dark:text-gray-400 font-mono break-all">
{errorInfo.identifier}
</div>
</details>
{/if}
</div>
</div>
</div>
{#if errorInfo.searchUrl}
<div class="mb-4">
<Button
class="btn-leather !w-fit bg-blue-600 hover:bg-blue-700 text-white"
size="sm"
onclick={() => {
if (errorInfo.searchUrl) {
goto(errorInfo.searchUrl);
}
}}
>
🔍 Search for this event
</Button>
</div>
{/if}
<div class="flex space-x-2">
<Button
class="btn-leather !w-fit"
size="sm"
onclick={() => invalidateAll()}
>
Try Again
🔄 Try Again
</Button>
<Button
class="btn-leather !w-fit"
@ -32,7 +129,7 @@ @@ -32,7 +129,7 @@
outline
onclick={() => goto("/")}
>
Return home
🏠 Return home
</Button>
</div>
</Alert>

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

@ -1,40 +1,29 @@ @@ -1,40 +1,29 @@
import { error } from "@sveltejs/kit";
import type { LayoutServerLoad } from "./$types";
import { fetchEventByDTag, fetchEventById, fetchEventByNaddr, fetchEventByNevent } from "../../../../lib/utils/websocket_utils.ts";
import type { NostrEvent } from "../../../../lib/utils/websocket_utils.ts";
// AI-NOTE: Server-side event fetching for SEO metadata
async function fetchEventServerSide(type: string, identifier: string): Promise<NostrEvent | null> {
// For now, return null to indicate server-side fetch not implemented
// This will fall back to client-side fetching
return null;
}
export const load: LayoutServerLoad = async ({ params, url }) => {
const { type, identifier } = params;
let indexEvent: NostrEvent;
// 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:
error(400, `Unsupported identifier type: ${type}`);
}
// Try to fetch event server-side for metadata
const indexEvent = await fetchEventServerSide(type, identifier);
// 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] ||
// Extract metadata for meta tags (use fallbacks if no event found)
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 image = indexEvent?.tags.find((tag) => tag[0] === "image")?.[1] || "/screenshots/old_books.jpg";
const currentUrl = `${url.origin}${url.pathname}`;
return {
indexEvent,
indexEvent, // Will be null, triggering client-side fetch
metadata: {
title,
summary,

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

@ -3,6 +3,8 @@ @@ -3,6 +3,8 @@
import type { LayoutProps } from "./$types";
let { data, children }: LayoutProps = $props();
// AI-NOTE: Use metadata from server-side load for SEO and social sharing
const { metadata } = data;
</script>

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

@ -14,8 +14,16 @@ @@ -14,8 +14,16 @@
// 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;
// AI-NOTE: Always create NDK event since we now ensure NDK is available
console.debug('[Publication] data.indexEvent:', data.indexEvent);
console.debug('[Publication] data.ndk:', data.ndk);
const indexEvent = data.indexEvent && data.ndk
? createNDKEvent(data.ndk, data.indexEvent)
: null; // No event if no NDK or no event data
console.debug('[Publication] indexEvent created:', indexEvent);
// 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(
@ -92,6 +100,8 @@ @@ -92,6 +100,8 @@
</script>
{#if indexEvent && data.indexEvent}
{@const debugInfo = `indexEvent: ${!!indexEvent}, data.indexEvent: ${!!data.indexEvent}`}
{@const debugElement = console.debug('[Publication] Rendering publication with:', debugInfo)}
<ArticleNav
publicationType={data.publicationType}
rootId={data.indexEvent.id}
@ -106,6 +116,8 @@ @@ -106,6 +116,8 @@
/>
</main>
{:else}
{@const debugInfo = `indexEvent: ${!!indexEvent}, data.indexEvent: ${!!data.indexEvent}`}
{@const debugElement = console.debug('[Publication] NOT rendering publication with:', debugInfo)}
<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>

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

@ -3,37 +3,78 @@ import type { PageLoad } from "./$types"; @@ -3,37 +3,78 @@ 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";
export const load: PageLoad = async ({ params }: { params: { type: string; identifier: string } }) => {
export const load: PageLoad = async ({ params, parent }: { params: { type: string; identifier: string }; parent: any }) => {
const { type, identifier } = params;
// Get layout data (no server-side data since SSR is disabled)
const layoutData = await parent();
let indexEvent: NostrEvent | null;
// 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:
error(400, `Unsupported identifier type: ${type}`);
// AI-NOTE: Always fetch client-side since server-side fetch returns null for now
let indexEvent: NostrEvent | null = null;
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:
error(400, `Unsupported identifier type: ${type}`);
}
} catch (err) {
throw err;
}
if (!indexEvent) {
error(404, `Event not found for ${type}: ${identifier}`);
// AI-NOTE: Handle case where no relays are available during preloading
// This prevents 404 errors when relay stores haven't been populated yet
// Create appropriate search link based on type
let searchParam = '';
switch (type) {
case 'id':
searchParam = `id=${identifier}`;
break;
case 'd':
searchParam = `d=${identifier}`;
break;
case 'naddr':
case 'nevent':
searchParam = `id=${identifier}`;
break;
default:
searchParam = `q=${identifier}`;
}
error(404, `Event not found for ${type}: ${identifier}. href="/events?${searchParam}"`);
}
const publicationType = indexEvent.tags.find((tag) => tag[0] === "type")?.[1] ?? "";
return {
// AI-NOTE: Use proper NDK instance from layout or create one with relays
let ndk = layoutData?.ndk;
if (!ndk) {
// Import NDK dynamically to avoid SSR issues
const NDK = (await import("@nostr-dev-kit/ndk")).default;
// Import initNdk to get properly configured NDK with relays
const { initNdk } = await import("$lib/ndk");
ndk = initNdk();
}
const result = {
publicationType,
indexEvent,
ndk, // Use minimal NDK instance
};
return result;
};

103
tests/e2e/my_notes_layout.pw.spec.ts

@ -0,0 +1,103 @@ @@ -0,0 +1,103 @@
import { test, expect, type Page } from '@playwright/test';
// Utility to check for horizontal scroll bar
async function hasHorizontalScroll(page: Page, selector: string) {
return await page.evaluate((sel: string) => {
const el = document.querySelector(sel);
if (!el) return false;
return el.scrollWidth > el.clientWidth;
}, selector);
}
test.describe('My Notes Layout', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/my-notes');
await page.waitForSelector('h1:text("My Notes")');
});
test('no horizontal scroll bar for all tag type and tag filter combinations', async ({ page }) => {
// Helper to check scroll for current state
async function assertNoScroll() {
const hasScroll = await hasHorizontalScroll(page, 'main, body, html');
expect(hasScroll).toBeFalsy();
}
// Check default (no tag type selected)
await assertNoScroll();
// Get all tag type buttons
const tagTypeButtons = await page.locator('aside button').all();
// Only consider tag type buttons (first N)
const tagTypeCount = await page.locator('aside > div.flex.flex-wrap.gap-2.mb-6 > button').count();
// For each single tag type
for (let i = 0; i < tagTypeCount; i++) {
// Click tag type button
await tagTypeButtons[i].click();
await page.waitForTimeout(100); // Wait for UI update
await assertNoScroll();
// Get tag filter buttons (after tag type buttons)
const tagFilterButtons = await page.locator('aside > div.flex.flex-wrap.gap-2.mb-4 > button').all();
// Try all single tag filter selections
for (let j = 0; j < tagFilterButtons.length; j++) {
await tagFilterButtons[j].click();
await page.waitForTimeout(100);
await assertNoScroll();
// Deselect
await tagFilterButtons[j].click();
await page.waitForTimeout(50);
}
// Try all pairs of tag filter selections
for (let j = 0; j < tagFilterButtons.length; j++) {
for (let k = j + 1; k < tagFilterButtons.length; k++) {
await tagFilterButtons[j].click();
await tagFilterButtons[k].click();
await page.waitForTimeout(100);
await assertNoScroll();
// Deselect
await tagFilterButtons[j].click();
await tagFilterButtons[k].click();
await page.waitForTimeout(50);
}
}
// Deselect tag type
await tagTypeButtons[i].click();
await page.waitForTimeout(100);
}
// Try all pairs of tag type selections (multi-select)
for (let i = 0; i < tagTypeCount; i++) {
for (let j = i + 1; j < tagTypeCount; j++) {
await tagTypeButtons[i].click();
await tagTypeButtons[j].click();
await page.waitForTimeout(100);
await assertNoScroll();
// Get tag filter buttons for this combination
const tagFilterButtons = await page.locator('aside > div.flex.flex-wrap.gap-2.mb-4 > button').all();
// Try all single tag filter selections
for (let k = 0; k < tagFilterButtons.length; k++) {
await tagFilterButtons[k].click();
await page.waitForTimeout(100);
await assertNoScroll();
await tagFilterButtons[k].click();
await page.waitForTimeout(50);
}
// Try all pairs of tag filter selections
for (let k = 0; k < tagFilterButtons.length; k++) {
for (let l = k + 1; l < tagFilterButtons.length; l++) {
await tagFilterButtons[k].click();
await tagFilterButtons[l].click();
await page.waitForTimeout(100);
await assertNoScroll();
await tagFilterButtons[k].click();
await tagFilterButtons[l].click();
await page.waitForTimeout(50);
}
}
// Deselect tag types
await tagTypeButtons[i].click();
await tagTypeButtons[j].click();
await page.waitForTimeout(100);
}
}
});
});
Loading…
Cancel
Save