Browse Source

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
master
limina1 8 months ago
parent
commit
872592dd76
  1. 12
      playwright.config.ts
  2. 1
      src/lib/components/Navigation.svelte
  3. 91
      src/lib/services/publisher.ts
  4. 3
      src/lib/utils/event_search.ts
  5. 3
      src/lib/utils/search_types.ts
  6. 2
      src/routes/+layout.svelte
  7. 276
      src/routes/my-notes/+page.svelte
  8. 103
      tests/e2e/my_notes_layout.pw.spec.ts

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

1
src/lib/components/Navigation.svelte

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

91
src/lib/services/publisher.ts

@ -3,6 +3,7 @@ import { ndkInstance } from "../ndk.ts";
import { getMimeTags } from "../utils/mime.ts"; import { getMimeTags } from "../utils/mime.ts";
import { parseAsciiDocSections } from "../utils/ZettelParser.ts"; import { parseAsciiDocSections } from "../utils/ZettelParser.ts";
import { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk"; import { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools";
export interface PublishResult { export interface PublishResult {
success: boolean; 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<PublishResult[]> {
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 { function generateDTag(title: string): string {
return title return title
.toLowerCase() .toLowerCase()

3
src/lib/utils/event_search.ts

@ -1,7 +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, 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 { 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";

3
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 * Extended NostrProfile interface for search results

2
src/routes/+layout.svelte

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

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

@ -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-row w-full max-w-none py-8 px-8 gap-24 min-w-0 overflow-hidden"
>
<!-- Tag Filter Sidebar -->
<aside class="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 max-w-5xl ml-auto 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">
<div class="flex items-center justify-between mb-2">
<div class="font-semibold text-lg">{getTitle(event)}</div>
<button
class="ml-2 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>

103
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);
}
}
});
});
Loading…
Cancel
Save