From 872592dd766839bd2d5b85c27af83fd896b0d760 Mon Sep 17 00:00:00 2001 From: limina1 Date: Mon, 28 Jul 2025 15:59:10 -0400 Subject: [PATCH] feat: add My Notes page with tag filtering and AsciiDoc rendering - Add My Notes navigation link - Create My Notes page with 30041 event fetching - Implement tag filtering system with sidebar - Add AsciiDoc rendering for note content - Include Playwright tests for layout validation - Fix NDKFilter import issues (type imports) - Update layout to prevent horizontal scroll - Add publisher service for note publishing --- playwright.config.ts | 12 +- src/lib/components/Navigation.svelte | 1 + src/lib/services/publisher.ts | 91 +++++++++ src/lib/utils/event_search.ts | 3 +- src/lib/utils/search_types.ts | 3 +- src/routes/+layout.svelte | 2 +- src/routes/my-notes/+page.svelte | 276 +++++++++++++++++++++++++++ tests/e2e/my_notes_layout.pw.spec.ts | 103 ++++++++++ 8 files changed, 482 insertions(+), 9 deletions(-) create mode 100644 src/routes/my-notes/+page.svelte create mode 100644 tests/e2e/my_notes_layout.pw.spec.ts diff --git a/playwright.config.ts b/playwright.config.ts index 4ef00bd..5779001 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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({ ], /* 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', diff --git a/src/lib/components/Navigation.svelte b/src/lib/components/Navigation.svelte index fdcfe32..e155c03 100644 --- a/src/lib/components/Navigation.svelte +++ b/src/lib/components/Navigation.svelte @@ -31,6 +31,7 @@ Visualize Getting Started Events + My Notes About Contact diff --git a/src/lib/services/publisher.ts b/src/lib/services/publisher.ts index 4bfc033..3d5e9fe 100644 --- a/src/lib/services/publisher.ts +++ b/src/lib/services/publisher.ts @@ -3,6 +3,7 @@ import { ndkInstance } from "../ndk.ts"; import { getMimeTags } from "../utils/mime.ts"; import { parseAsciiDocSections } from "../utils/ZettelParser.ts"; import { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk"; +import { nip19 } from "nostr-tools"; export interface PublishResult { success: boolean; @@ -103,6 +104,96 @@ export async function publishZettel( } } +/** + * Publishes all AsciiDoc sections as separate Nostr events + * @param options - Publishing options + * @returns Promise resolving to array of publish results + */ +export async function publishMultipleZettels( + options: PublishOptions, +): Promise { + const { content, kind = 30041, onError } = options; + + if (!content.trim()) { + const error = 'Please enter some content'; + onError?.(error); + return [{ success: false, error }]; + } + + const ndk = get(ndkInstance); + if (!ndk?.activeUser) { + const error = 'Please log in first'; + onError?.(error); + return [{ success: false, error }]; + } + + try { + const sections = parseAsciiDocSections(content, 2); + if (sections.length === 0) { + throw new Error('No valid sections found in content'); + } + + const allRelayUrls = Array.from(ndk.pool?.relays.values() || []).map((r) => r.url); + if (allRelayUrls.length === 0) { + throw new Error('No relays available in NDK pool'); + } + const relaySet = NDKRelaySet.fromRelayUrls(allRelayUrls, ndk); + + const results: PublishResult[] = []; + const publishedEvents: NDKEvent[] = []; + for (const section of sections) { + const title = section.title; + const cleanContent = section.content; + const sectionTags = section.tags || []; + const dTag = generateDTag(title); + const [mTag, MTag] = getMimeTags(kind); + const tags: string[][] = [["d", dTag], mTag, MTag, ["title", title]]; + if (sectionTags) { + tags.push(...sectionTags); + } + const ndkEvent = new NDKEvent(ndk); + ndkEvent.kind = kind; + ndkEvent.created_at = Math.floor(Date.now() / 1000); + ndkEvent.tags = tags; + ndkEvent.content = cleanContent; + ndkEvent.pubkey = ndk.activeUser.pubkey; + try { + await ndkEvent.sign(); + const publishedToRelays = await ndkEvent.publish(relaySet); + if (publishedToRelays.size > 0) { + results.push({ success: true, eventId: ndkEvent.id }); + publishedEvents.push(ndkEvent); + } else { + results.push({ success: false, error: 'Failed to publish to any relays' }); + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error'; + results.push({ success: false, error: errorMessage }); + } + } + // Debug: extract and log 'e' and 'a' tags from all published events + publishedEvents.forEach(ev => { + // Extract d-tag from tags + const dTagEntry = ev.tags.find(t => t[0] === 'd'); + const dTag = dTagEntry ? dTagEntry[1] : ''; + const aTag = `${ev.kind}:${ev.pubkey}:${dTag}`; + console.log(`Event ${ev.id} tags:`); + console.log(' e:', ev.id); + console.log(' a:', aTag); + // Print nevent and naddr using nip19 + const nevent = nip19.neventEncode({ id: ev.id }); + const naddr = nip19.naddrEncode({ kind: ev.kind, pubkey: ev.pubkey, identifier: dTag }); + console.log(' nevent:', nevent); + console.log(' naddr:', naddr); + }); + return results; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + onError?.(errorMessage); + return [{ success: false, error: errorMessage }]; + } +} + function generateDTag(title: string): string { return title .toLowerCase() diff --git a/src/lib/utils/event_search.ts b/src/lib/utils/event_search.ts index 25319c0..aa1e9a7 100644 --- a/src/lib/utils/event_search.ts +++ b/src/lib/utils/event_search.ts @@ -1,7 +1,8 @@ import { ndkInstance } from "../ndk.ts"; import { fetchEventWithFallback } from "./nostrUtils.ts"; import { nip19 } from "nostr-tools"; -import { NDKEvent, NDKFilter } from "@nostr-dev-kit/ndk"; +import { NDKEvent } from "@nostr-dev-kit/ndk"; +import type { NDKFilter } from "@nostr-dev-kit/ndk"; import { get } from "svelte/store"; import { wellKnownUrl, isValidNip05Address } from "./search_utils.ts"; import { TIMEOUTS, VALIDATION } from "./search_constants.ts"; diff --git a/src/lib/utils/search_types.ts b/src/lib/utils/search_types.ts index 134ceff..167472e 100644 --- a/src/lib/utils/search_types.ts +++ b/src/lib/utils/search_types.ts @@ -1,4 +1,5 @@ -import { NDKEvent, NDKFilter, NDKSubscription } from "@nostr-dev-kit/ndk"; +import { NDKEvent, NDKSubscription } from "@nostr-dev-kit/ndk"; +import type { NDKFilter } from "@nostr-dev-kit/ndk"; /** * Extended NostrProfile interface for search results diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 47be24c..90335f6 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -45,7 +45,7 @@ -
+
diff --git a/src/routes/my-notes/+page.svelte b/src/routes/my-notes/+page.svelte new file mode 100644 index 0000000..0163a77 --- /dev/null +++ b/src/routes/my-notes/+page.svelte @@ -0,0 +1,276 @@ + + +
+ + + + +
+

My Notes

+ {#if loading} +
Loading…
+ {:else if error} +
{error}
+ {:else if filteredEvents.length === 0} +
No notes found.
+ {:else} +
    + {#each filteredEvents as event} +
  • +
    +
    {getTitle(event)}
    + +
    + {#if showTags[event.id]} +
    + {#each getTags(event) as tag} + + {tag[0]}: + {tag[1]} + + {/each} +
    + {/if} +
    + {event.created_at + ? new Date(event.created_at * 1000).toLocaleString() + : ""} +
    +
    + {@html renderedContent[event.id] || ""} +
    +
  • + {/each} +
+ {/if} +
+
diff --git a/tests/e2e/my_notes_layout.pw.spec.ts b/tests/e2e/my_notes_layout.pw.spec.ts new file mode 100644 index 0000000..0a17d75 --- /dev/null +++ b/tests/e2e/my_notes_layout.pw.spec.ts @@ -0,0 +1,103 @@ +import { test, expect } from '@playwright/test'; + +// Utility to check for horizontal scroll bar +async function hasHorizontalScroll(page, selector) { + return await page.evaluate((sel) => { + 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); + } + } + }); +}); \ No newline at end of file