You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
276 lines
9.1 KiB
276 lines
9.1 KiB
<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>
|
|
|