46 changed files with 2283 additions and 1890 deletions
@ -1,14 +1,13 @@
@@ -1,14 +1,13 @@
|
||||
FROM node:18.7.0 |
||||
FROM node:22.13.1-alpine AS build |
||||
|
||||
WORKDIR /app |
||||
|
||||
COPY package.json package.json |
||||
COPY yarn.lock yarn.lock |
||||
COPY package.json ./ |
||||
COPY package-lock.json ./ |
||||
RUN npm install |
||||
COPY . ./ |
||||
RUN npm run build |
||||
|
||||
RUN yarn |
||||
|
||||
COPY . . |
||||
|
||||
RUN yarn build |
||||
|
||||
CMD [ "yarn", "preview", "--host" ] |
||||
EXPOSE 80 |
||||
FROM nginx:1.19-alpine |
||||
COPY --from=build /app/build /usr/share/nginx/html |
||||
|
Before Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 99 KiB |
@ -1,118 +0,0 @@
@@ -1,118 +0,0 @@
|
||||
<script lang="ts"> |
||||
import { ndk } from '$lib/ndk'; |
||||
import { afterUpdate, onMount } from 'svelte'; |
||||
import type { NDKEvent } from '@nostr-dev-kit/ndk'; |
||||
import { formatDate, next } from '$lib/utils'; |
||||
import { parse } from '$lib/articleParser.js'; |
||||
import type { Tab } from '$lib/types'; |
||||
import { page } from '$app/stores'; |
||||
import { tabBehaviour, userPublickey } from '$lib/state'; |
||||
|
||||
export let eventid: string; |
||||
export let createChild: (tab: Tab) => void; |
||||
export let replaceSelf: (tab: Tab) => void; |
||||
let event: NDKEvent | null = null; |
||||
let copied = false; |
||||
|
||||
function addClickListenerToWikilinks() { |
||||
const elements = document.querySelectorAll('[id^="wikilink-v0-"]'); |
||||
|
||||
elements.forEach((element) => { |
||||
element.addEventListener('click', () => { |
||||
let a = element.id.slice(12); |
||||
if ($tabBehaviour == 'replace') { |
||||
replaceSelf({ id: next(), type: 'find', data: a }); |
||||
} else { |
||||
createChild({ id: next(), type: 'find', data: a }); |
||||
} |
||||
}); |
||||
}); |
||||
} |
||||
|
||||
function shareCopy() { |
||||
navigator.clipboard.writeText(`https://${$page.url.hostname}/article/${eventid}`); |
||||
copied = true; |
||||
setTimeout(() => { |
||||
copied = false; |
||||
}, 2500); |
||||
} |
||||
|
||||
onMount(async () => { |
||||
event = await $ndk.fetchEvent(eventid); |
||||
}); |
||||
|
||||
afterUpdate(() => { |
||||
addClickListenerToWikilinks(); |
||||
}); |
||||
</script> |
||||
|
||||
<div> |
||||
<!-- svelte-ignore a11y-no-static-element-interactions --> |
||||
<!-- svelte-ignore a11y-missing-attribute --> |
||||
<!-- svelte-ignore a11y-click-events-have-key-events --> |
||||
<article class="prose font-sans mx-auto p-2 lg:max-w-4xl"> |
||||
{#if event !== null} |
||||
<h1 class="mb-0"> |
||||
{#if event?.tags.find((e) => e[0] == 'title')?.[0] && event?.tags.find((e) => e[0] == 'title')?.[1]} |
||||
{event.tags.find((e) => e[0] == 'title')?.[1]} |
||||
{:else} |
||||
{event.tags.find((e) => e[0] == 'd')?.[1]} |
||||
{/if} |
||||
</h1> |
||||
<span> |
||||
{#await event.author?.fetchProfile()} |
||||
by <a |
||||
class="cursor-pointer" |
||||
on:click={() => { |
||||
$tabBehaviour == 'replace' |
||||
? replaceSelf({ type: 'user', id: next(), data: event?.author.hexpubkey() }) |
||||
: createChild({ type: 'user', id: next(), data: event?.author.hexpubkey() }); |
||||
}}>...</a |
||||
>, |
||||
{:then profile} |
||||
by <a |
||||
class="cursor-pointer" |
||||
on:click={() => { |
||||
$tabBehaviour == 'replace' |
||||
? replaceSelf({ type: 'user', id: next(), data: event?.author.hexpubkey() }) |
||||
: createChild({ type: 'user', id: next(), data: event?.author.hexpubkey() }); |
||||
}}>{profile !== null && JSON.parse(Array.from(profile)[0]?.content)?.name}</a |
||||
>, |
||||
{/await} |
||||
{#if event.created_at} |
||||
updated on {formatDate(event.created_at)} |
||||
{/if} |
||||
• <a |
||||
class="cursor-pointer" |
||||
on:click={() => { |
||||
$tabBehaviour == 'child' |
||||
? createChild({ id: next(), type: 'editor', data: { forkId: event?.id } }) |
||||
: replaceSelf({ id: next(), type: 'editor', data: { forkId: event?.id } }); |
||||
}} |
||||
>{#if $userPublickey == event.author.hexpubkey()}Edit{:else}Fork{/if}</a |
||||
> |
||||
• <a class="cursor-pointer" on:click={shareCopy} |
||||
>{#if copied}Copied!{:else}Share{/if}</a |
||||
> • <a |
||||
class="cursor-pointer" |
||||
on:click={() => { |
||||
$tabBehaviour == 'child' |
||||
? createChild({ |
||||
id: next(), |
||||
type: 'find', |
||||
data: event?.tags.find((e) => e[0] == 'd')?.[1] |
||||
}) |
||||
: replaceSelf({ |
||||
id: next(), |
||||
type: 'find', |
||||
data: event?.tags.find((e) => e[0] == 'd')?.[1] |
||||
}); |
||||
}}>Versions</a |
||||
> |
||||
</span> |
||||
|
||||
<!-- Content --> |
||||
{@html parse(event?.content)} |
||||
{/if} |
||||
</article> |
||||
</div> |
||||
@ -1,130 +0,0 @@
@@ -1,130 +0,0 @@
|
||||
<script lang="ts"> |
||||
import { ndk } from '$lib/ndk'; |
||||
import { wikiKind } from '$lib/consts'; |
||||
import { NDKEvent } from '@nostr-dev-kit/ndk'; |
||||
import { onMount } from 'svelte'; |
||||
import type { Tab } from '$lib/types'; |
||||
import { userPublickey } from '$lib/state'; |
||||
|
||||
export let replaceSelf: (tab: Tab) => void; |
||||
export let data: any; |
||||
if (!data.title) data.title = ''; |
||||
if (!data.summary) data.summary = ''; |
||||
if (!data.content) data.content = ''; |
||||
let forkev: NDKEvent | null; |
||||
|
||||
let success = 0; |
||||
let error: string = ''; |
||||
|
||||
onMount(async () => { |
||||
if (data.forkId) { |
||||
forkev = await $ndk.fetchEvent(data.forkId); |
||||
data.title = |
||||
forkev?.tags.find((e) => e[0] == 'title')?.[0] && |
||||
forkev?.tags.find((e) => e[0] == 'title')?.[1] |
||||
? forkev.tags.find((e) => e[0] == 'title')?.[1] |
||||
: forkev?.tags.find((e) => e[0] == 'd')?.[1]; |
||||
data.summary = |
||||
forkev?.tags.find((e) => e[0] == 'summary')?.[0] && |
||||
forkev?.tags.find((e) => e[0] == 'summary')?.[1] |
||||
? forkev?.tags.find((e) => e[0] == 'summary')?.[1] |
||||
: undefined; |
||||
data.content = forkev?.content; |
||||
} |
||||
}); |
||||
|
||||
async function publish() { |
||||
if (data.title && data.content) { |
||||
try { |
||||
let event = new NDKEvent($ndk); |
||||
event.kind = wikiKind; |
||||
event.content = data.content; |
||||
event.tags.push(['d', data.title.toLowerCase().replaceAll(' ', '-')]); |
||||
event.tags.push(['title', data.title]); |
||||
if (data.summary) { |
||||
event.tags.push(['summary', data.summary]); |
||||
} |
||||
let relays = await event.publish(); |
||||
relays.forEach((relay) => { |
||||
relay.once('published', () => { |
||||
console.debug('published to', relay); |
||||
}); |
||||
relay.once('publish:failed', (relay, err) => { |
||||
console.debug('publish failed to', relay, err); |
||||
}); |
||||
}); |
||||
success = 1; |
||||
} catch (err) { |
||||
console.debug('failed to publish event', err); |
||||
error = String(err); |
||||
success = -1; |
||||
} |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<div class="prose font-sans mx-auto p-2 lg:max-w-4xl"> |
||||
<div class="prose"> |
||||
<h1> |
||||
{#if data.forkId && $userPublickey == forkev?.author?.hexpubkey()}Editing{:else if data.forkId}Forking{:else}Creating{/if} |
||||
an article |
||||
</h1> |
||||
</div> |
||||
<div class="mt-2"> |
||||
<label class="flex items-center" |
||||
>Title |
||||
<input |
||||
placeholder="example: Greek alphabet" |
||||
bind:value={data.title} |
||||
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md ml-2" |
||||
/></label |
||||
> |
||||
</div> |
||||
<div class="mt-2"> |
||||
<label |
||||
>Article |
||||
<textarea |
||||
placeholder="The **Greek alphabet** has been used to write the [[Greek language]] sincie the late 9th or early 8th century BC. The Greek alphabet is the ancestor of the [[Latin]] and [[Cyrillic]] scripts." |
||||
bind:value={data.content} |
||||
rows="9" |
||||
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md" |
||||
/></label |
||||
> |
||||
</div> |
||||
<div class="mt-2"> |
||||
<details> |
||||
<summary> Add an explicit summary? </summary> |
||||
<label |
||||
>Summary |
||||
<textarea |
||||
bind:value={data.summary} |
||||
rows="3" |
||||
placeholder="The Greek alphabet is the earliest known alphabetic script to have distict letters for vowels. The Greek alphabet existed in many local variants." |
||||
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md" |
||||
/></label |
||||
> |
||||
</details> |
||||
</div> |
||||
|
||||
<!-- Submit --> |
||||
{#if success !== 1} |
||||
<div class="mt-2"> |
||||
<button |
||||
on:click={publish} |
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" |
||||
>Submit</button |
||||
> |
||||
</div> |
||||
{/if} |
||||
|
||||
<div> |
||||
{#if success == -1} |
||||
<p>Something went wrong :( note that only NIP07 is supported for signing</p> |
||||
<p> |
||||
Error Message: {error} |
||||
</p> |
||||
{:else if success == 1} |
||||
<p>Success!</p> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
@ -1,113 +0,0 @@
@@ -1,113 +0,0 @@
|
||||
<script lang="ts"> |
||||
import { ndk } from '$lib/ndk'; |
||||
import { wikiKind } from '$lib/consts'; |
||||
import type { NDKEvent } from '@nostr-dev-kit/ndk'; |
||||
import { onMount } from 'svelte'; |
||||
import type { Tab } from '$lib/types'; |
||||
import { tabBehaviour } from '$lib/state'; |
||||
import { parsePlainText } from '$lib/articleParser'; |
||||
import { next } from '$lib/utils'; |
||||
|
||||
export let query: string; |
||||
export let replaceSelf: (tab: Tab) => void; |
||||
export let createChild: (tab: Tab) => void; |
||||
let results: NDKEvent[] = []; |
||||
let tried = 0; |
||||
|
||||
async function search(query: string) { |
||||
results = []; |
||||
const filter = { kinds: [wikiKind], '#d': [query] }; |
||||
const events = await $ndk.fetchEvents(filter); |
||||
if (!events) { |
||||
tried = 1; |
||||
results = []; |
||||
return; |
||||
} |
||||
tried = 1; |
||||
results = Array.from(events); |
||||
} |
||||
|
||||
onMount(async () => { |
||||
await search(query); |
||||
}); |
||||
</script> |
||||
|
||||
<article class="font-sans mx-auto p-2 lg:max-w-4xl"> |
||||
<div class="prose"> |
||||
<h1 class="mb-0">{query}</h1> |
||||
<p class="mt-0 mb-0"> |
||||
There are {#if tried == 1}{results.length}{:else}...{/if} articles with the name "{query}" |
||||
</p> |
||||
</div> |
||||
{#each results as result} |
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions --> |
||||
<div |
||||
on:click={() => { |
||||
$tabBehaviour == 'child' |
||||
? createChild({ id: next(), type: 'article', data: result.id }) |
||||
: replaceSelf({ id: next(), type: 'article', data: result.id }); |
||||
}} |
||||
class="cursor-pointer px-4 py-5 bg-white border border-gray-300 hover:bg-slate-50 rounded-lg mt-2 min-h-[48px]" |
||||
> |
||||
<h1> |
||||
{result.tags.find((e) => e[0] == 'title')?.[0] && |
||||
result.tags.find((e) => e[0] == 'title')?.[1] |
||||
? result.tags.find((e) => e[0] == 'title')?.[1] |
||||
: result.tags.find((e) => e[0] == 'd')?.[1]} |
||||
</h1> |
||||
<p class="text-xs"> |
||||
<!-- implement published at? --> |
||||
<!-- {#if result.tags.find((e) => e[0] == "published_at")} |
||||
on {formatDate(result.tags.find((e) => e[0] == "published_at")[1])} |
||||
{/if} --> |
||||
{#await result.author?.fetchProfile()} |
||||
by <span class="text-gray-600 font-[600]">...</span> |
||||
{:then result} |
||||
by {result !== null && JSON.parse(Array.from(result)[0]?.content)?.name} |
||||
{/await} |
||||
</p> |
||||
<p class="text-xs"> |
||||
{#if result.tags.find((e) => e[0] == 'summary')?.[0] && result.tags.find((e) => e[0] == 'summary')?.[1]} |
||||
{result.tags |
||||
.find((e) => e[0] == 'summary')?.[1] |
||||
.slice( |
||||
0, |
||||
192 |
||||
)}{#if String(result.tags.find((e) => e[0] == 'summary')?.[1])?.length > 192}...{/if} |
||||
{:else} |
||||
{result.content.length <= 192 |
||||
? parsePlainText(result.content.slice(0, 189)) |
||||
: parsePlainText(result.content.slice(0, 189)) + '...'} |
||||
{/if} |
||||
</p> |
||||
</div> |
||||
{/each} |
||||
{#if tried == 1} |
||||
<div class="px-4 py-5 bg-white border border-gray-300 rounded-lg mt-2 min-h-[48px]"> |
||||
<p class="mb-2"> |
||||
{results.length < 1 ? "Can't find this article" : "Didn't find what you are looking for?"} |
||||
</p> |
||||
<button |
||||
on:click={() => { |
||||
$tabBehaviour == 'child' |
||||
? createChild({ id: next(), type: 'editor', data: { title: query } }) |
||||
: replaceSelf({ id: next(), type: 'editor', data: { title: query } }); |
||||
}} |
||||
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" |
||||
> |
||||
Create this article! |
||||
</button> |
||||
<button |
||||
on:click={() => |
||||
$tabBehaviour == 'replace' |
||||
? replaceSelf({ id: next(), type: 'settings' }) |
||||
: createChild({ id: next(), type: 'settings' })} |
||||
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" |
||||
> |
||||
Add more relays |
||||
</button> |
||||
</div> |
||||
{:else} |
||||
<div class="px-4 py-5 rounded-lg mt-2 min-h-[48px]">Loading...</div> |
||||
{/if} |
||||
</article> |
||||
@ -1,149 +0,0 @@
@@ -1,149 +0,0 @@
|
||||
<script lang="ts"> |
||||
import { browser } from '$app/environment'; |
||||
import { standardRelays } from '$lib/consts'; |
||||
import { ndk } from '$lib/ndk'; |
||||
import { tabBehaviour, userPublickey } from '$lib/state'; |
||||
import { NDKNip07Signer } from '@nostr-dev-kit/ndk'; |
||||
import { onMount } from 'svelte'; |
||||
|
||||
let username = '...'; |
||||
let relays: string[] = []; |
||||
let newTabBehaviour = $tabBehaviour; |
||||
let newRelay = ''; |
||||
|
||||
function removeRelay(index: number) { |
||||
relays.splice(index, 1); |
||||
relays = [...relays]; |
||||
} |
||||
|
||||
async function login() { |
||||
if (browser) { |
||||
if (!$ndk.signer) { |
||||
const signer = new NDKNip07Signer(); |
||||
$ndk.signer = signer; |
||||
ndk.set($ndk); |
||||
} |
||||
if ($ndk.signer && $userPublickey == '') { |
||||
const newUserPublicKey = (await $ndk.signer.user()).hexpubkey(); |
||||
localStorage.setItem('wikinostr_loggedInPublicKey', newUserPublicKey); |
||||
$userPublickey = newUserPublicKey; |
||||
userPublickey.set($userPublickey); |
||||
} |
||||
} |
||||
} |
||||
|
||||
function logout() { |
||||
localStorage.removeItem('wikinostr_loggedInPublicKey'); |
||||
userPublickey.set(''); |
||||
} |
||||
|
||||
function addRelay() { |
||||
if (newRelay) { |
||||
relays.push(newRelay); |
||||
newRelay = ''; |
||||
relays = [...relays]; |
||||
} |
||||
} |
||||
|
||||
function saveData() { |
||||
addRelay(); |
||||
localStorage.setItem('wikinostr_tabBehaviour', newTabBehaviour); |
||||
localStorage.setItem('wikinostr_relays', JSON.stringify(relays)); |
||||
setTimeout(() => { |
||||
window.location.href = ''; |
||||
}, 1); |
||||
} |
||||
|
||||
if (browser) { |
||||
relays = JSON.parse(localStorage.getItem('wikinostr_relays') || JSON.stringify(standardRelays)); |
||||
} |
||||
|
||||
onMount(async () => { |
||||
// get user |
||||
const user = await $ndk.getUser({ hexpubkey: $userPublickey }); |
||||
const profile = await user.fetchProfile(); |
||||
if (profile) { |
||||
username = JSON.parse(Array.from(profile)[0].content).name; |
||||
} |
||||
}); |
||||
</script> |
||||
|
||||
<article class="font-sans mx-auto p-2 lg:max-w-4xl"> |
||||
<div class="prose"> |
||||
<h1 class="mt-0">Settings</h1> |
||||
</div> |
||||
|
||||
<!-- Login Options --> |
||||
<div class="my-6"> |
||||
<p class="text-sm">Account</p> |
||||
{#if $userPublickey == ''} |
||||
<p>You are not logged in!</p> |
||||
<button |
||||
on:click={login} |
||||
type="button" |
||||
class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" |
||||
>Login with NIP07 |
||||
</button> |
||||
{:else} |
||||
<p>You are logged in as <a href={`nostr://${$userPublickey}`}>{username}</a></p> |
||||
<button |
||||
on:click={logout} |
||||
type="button" |
||||
class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" |
||||
>Logout |
||||
</button> |
||||
{/if} |
||||
</div> |
||||
|
||||
<!-- Relay Selection --> |
||||
<div class="mb-6"> |
||||
<p class="text-sm">Relays</p> |
||||
{#each relays as relay, index} |
||||
<div class="border rounded-full pl-2 my-1"> |
||||
<button |
||||
class="text-red-500 py-0.5 px-1.5 rounded-full text-xl font-bold" |
||||
on:click={() => removeRelay(index)} |
||||
> |
||||
- |
||||
</button> |
||||
{relay} |
||||
</div> |
||||
{/each} |
||||
<div class="flex"> |
||||
<input |
||||
bind:value={newRelay} |
||||
type="text" |
||||
class="inline mr-0 rounded-md rounded-r-none shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm border-gray-300" |
||||
placeholder="wss://relay.example.com" |
||||
/> |
||||
<button |
||||
on:click={addRelay} |
||||
type="button" |
||||
class="inline-flex ml-0 rounded-md rounded-l-none items-center px-2.5 py-1.5 border border-transparent text-sm font-medium shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" |
||||
>Add</button |
||||
> |
||||
</div> |
||||
</div> |
||||
|
||||
<!-- More options --> |
||||
<div class="mb-6"> |
||||
<p class="text-sm">Tab Behaviour</p> |
||||
<select |
||||
class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md" |
||||
bind:value={newTabBehaviour} |
||||
> |
||||
<option value="replace">Replace Self Everywhere</option> |
||||
<option value="normal">Normal</option> |
||||
<option value="child">Create Child Everywhere</option> |
||||
</select> |
||||
</div> |
||||
|
||||
<!-- Save button --> |
||||
<button |
||||
on:click={saveData} |
||||
type="button" |
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" |
||||
> |
||||
Save & Reload |
||||
</button> |
||||
</article> |
||||
@ -1,88 +0,0 @@
@@ -1,88 +0,0 @@
|
||||
<script lang="ts"> |
||||
import { parsePlainText } from '$lib/articleParser'; |
||||
import { wikiKind } from '$lib/consts'; |
||||
import { ndk } from '$lib/ndk'; |
||||
import { tabBehaviour } from '$lib/state'; |
||||
import type { Tab } from '$lib/types'; |
||||
import { next } from '$lib/utils'; |
||||
import type { NDKEvent } from '@nostr-dev-kit/ndk'; |
||||
import { onMount } from 'svelte'; |
||||
|
||||
let results: NDKEvent[] = []; |
||||
let username = '...'; |
||||
export let createChild: (tab: Tab) => void; |
||||
export let replaceSelf: (tab: Tab) => void; |
||||
export let data: string; |
||||
|
||||
async function search() { |
||||
results = []; |
||||
const filter = { kinds: [wikiKind], limit: 1024, authors: [data] }; |
||||
const events = await $ndk.fetchEvents(filter); |
||||
if (!events) { |
||||
results = []; |
||||
return; |
||||
} |
||||
results = Array.from(events); |
||||
} |
||||
|
||||
onMount(async () => { |
||||
// get user |
||||
const user = await $ndk.getUser({ hexpubkey: data }); |
||||
const profile = await user.fetchProfile(); |
||||
if (profile) { |
||||
username = JSON.parse(Array.from(profile)[0].content).name; |
||||
} |
||||
|
||||
await search(); |
||||
}); |
||||
</script> |
||||
|
||||
<article class="font-sans mx-auto p-2 lg:max-w-4xl"> |
||||
<div> |
||||
<div class="prose"> |
||||
<h1><a href={`nostr://${data}`}>{username}</a>'s articles</h1> |
||||
</div> |
||||
{#each results as result} |
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions --> |
||||
<div |
||||
on:click={() => |
||||
$tabBehaviour == 'replace' |
||||
? replaceSelf({ id: next(), type: 'article', data: result.id }) |
||||
: createChild({ id: next(), type: 'article', data: result.id })} |
||||
class="cursor-pointer px-4 py-5 bg-white border border-gray-300 hover:bg-slate-50 rounded-lg mt-2 min-h-[48px]" |
||||
> |
||||
<h1> |
||||
{result.tags.find((e) => e[0] == 'title')?.[0] && |
||||
result.tags.find((e) => e[0] == 'title')?.[1] |
||||
? result.tags.find((e) => e[0] == 'title')?.[1] |
||||
: result.tags.find((e) => e[0] == 'd')?.[1]} |
||||
</h1> |
||||
<p class="text-xs"> |
||||
<!-- implement published at? --> |
||||
<!-- {#if result.tags.find((e) => e[0] == "published_at")} |
||||
on {formatDate(result.tags.find((e) => e[0] == "published_at")[1])} |
||||
{/if} --> |
||||
{#await result.author?.fetchProfile()} |
||||
by <span class="text-gray-600 font-[600]">...</span> |
||||
{:then result} |
||||
by {result !== null && JSON.parse(Array.from(result)[0]?.content)?.name} |
||||
{/await} |
||||
</p> |
||||
<p class="text-xs"> |
||||
{#if result.tags.find((e) => e[0] == 'summary')?.[0] && result.tags.find((e) => e[0] == 'summary')?.[1]} |
||||
{result.tags |
||||
.find((e) => e[0] == 'summary')?.[1] |
||||
.slice( |
||||
0, |
||||
192 |
||||
)}{#if String(result.tags.find((e) => e[0] == 'summary')?.[1])?.length > 192}...{/if} |
||||
{:else} |
||||
{result.content.length <= 192 |
||||
? parsePlainText(result.content.slice(0, 189)) |
||||
: parsePlainText(result.content.slice(0, 189)) + '...'} |
||||
{/if} |
||||
</p> |
||||
</div> |
||||
{/each} |
||||
</div> |
||||
</article> |
||||
@ -1,85 +0,0 @@
@@ -1,85 +0,0 @@
|
||||
<script lang="ts"> |
||||
import { parsePlainText } from '$lib/articleParser'; |
||||
import { wikiKind } from '$lib/consts'; |
||||
import { ndk } from '$lib/ndk'; |
||||
import { tabBehaviour } from '$lib/state'; |
||||
import type { Tab } from '$lib/types'; |
||||
import { next } from '$lib/utils'; |
||||
import type { NDKEvent } from '@nostr-dev-kit/ndk'; |
||||
import { onMount } from 'svelte'; |
||||
|
||||
let results: NDKEvent[] = []; |
||||
export let createChild: (tab: Tab) => void; |
||||
export let replaceSelf: (tab: Tab) => void; |
||||
|
||||
async function search() { |
||||
results = []; |
||||
const filter = { kinds: [wikiKind], limit: 48 }; |
||||
const events = await $ndk.fetchEvents(filter); |
||||
if (!events) { |
||||
results = []; |
||||
return; |
||||
} |
||||
results = Array.from(events); |
||||
} |
||||
|
||||
onMount(async () => { |
||||
await search(); |
||||
}); |
||||
</script> |
||||
|
||||
<article class="font-sans mx-auto p-2 lg:max-w-4xl"> |
||||
<div> |
||||
<div class="prose"> |
||||
<h1>Welcome</h1> |
||||
</div> |
||||
</div> |
||||
|
||||
<div> |
||||
<div class="prose"> |
||||
<h2>Recent Articles</h2> |
||||
</div> |
||||
{#each results as result} |
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions --> |
||||
<div |
||||
on:click={() => |
||||
$tabBehaviour == 'replace' |
||||
? replaceSelf({ id: next(), type: 'article', data: result.id }) |
||||
: createChild({ id: next(), type: 'article', data: result.id })} |
||||
class="cursor-pointer px-4 py-5 bg-white border border-gray-300 hover:bg-slate-50 rounded-lg mt-2 min-h-[48px]" |
||||
> |
||||
<h1> |
||||
{result.tags.find((e) => e[0] == 'title')?.[0] && |
||||
result.tags.find((e) => e[0] == 'title')?.[1] |
||||
? result.tags.find((e) => e[0] == 'title')?.[1] |
||||
: result.tags.find((e) => e[0] == 'd')?.[1]} |
||||
</h1> |
||||
<p class="text-xs"> |
||||
<!-- implement published at? --> |
||||
<!-- {#if result.tags.find((e) => e[0] == "published_at")} |
||||
on {formatDate(result.tags.find((e) => e[0] == "published_at")[1])} |
||||
{/if} --> |
||||
{#await result.author?.fetchProfile()} |
||||
by <span class="text-gray-600 font-[600]">...</span> |
||||
{:then result} |
||||
by {result !== null && JSON.parse(Array.from(result)[0]?.content)?.name} |
||||
{/await} |
||||
</p> |
||||
<p class="text-xs"> |
||||
{#if result.tags.find((e) => e[0] == 'summary')?.[0] && result.tags.find((e) => e[0] == 'summary')?.[1]} |
||||
{result.tags |
||||
.find((e) => e[0] == 'summary')?.[1] |
||||
.slice( |
||||
0, |
||||
192 |
||||
)}{#if String(result.tags.find((e) => e[0] == 'summary')?.[1])?.length > 192}...{/if} |
||||
{:else} |
||||
{result.content.length <= 192 |
||||
? parsePlainText(result.content.slice(0, 189)) |
||||
: parsePlainText(result.content.slice(0, 189)) + '...'} |
||||
{/if} |
||||
</p> |
||||
</div> |
||||
{/each} |
||||
</div> |
||||
</article> |
||||
@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
|
||||
<script lang="ts"> |
||||
import { networkFetchLimit } from "$lib/state"; |
||||
import { createEventDispatcher } from "svelte"; |
||||
|
||||
const dispatch = createEventDispatcher<{ |
||||
update: { limit: number }; |
||||
}>(); |
||||
|
||||
let inputValue = $networkFetchLimit; |
||||
|
||||
function handleInput(event: Event) { |
||||
const input = event.target as HTMLInputElement; |
||||
const value = parseInt(input.value); |
||||
// Ensure value is between 1 and 50 |
||||
if (value >= 1 && value <= 50) { |
||||
inputValue = value; |
||||
} |
||||
} |
||||
|
||||
function handleUpdate() { |
||||
$networkFetchLimit = inputValue; |
||||
dispatch("update", { limit: inputValue }); |
||||
} |
||||
|
||||
function handleKeyDown(event: KeyboardEvent) { |
||||
if (event.key === "Enter") { |
||||
handleUpdate(); |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<div class="flex items-center gap-2 mb-4"> |
||||
<label for="event-limit" class="text-sm font-medium" |
||||
>Number of root events: |
||||
</label> |
||||
<input |
||||
type="number" |
||||
id="event-limit" |
||||
min="1" |
||||
max="50" |
||||
class="w-20 bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1" |
||||
bind:value={inputValue} |
||||
on:input={handleInput} |
||||
on:keydown={handleKeyDown} |
||||
/> |
||||
<button |
||||
on:click={handleUpdate} |
||||
class="px-3 py-1 bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800" |
||||
> |
||||
Update |
||||
</button> |
||||
</div> |
||||
@ -1,588 +0,0 @@
@@ -1,588 +0,0 @@
|
||||
<script lang="ts"> |
||||
import { onMount } from "svelte"; |
||||
import * as d3 from "d3"; |
||||
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||
|
||||
export let events: NDKEvent[] = []; |
||||
|
||||
let svg: SVGSVGElement; |
||||
let isDarkMode = false; |
||||
const nodeRadius = 20; |
||||
const linkDistance = 10; |
||||
const arrowDistance = 10; |
||||
const warmupClickEnergy = 0.9; // Energy to restart simulation on drag |
||||
let container: HTMLDivElement; |
||||
|
||||
let width: number = 1000; |
||||
let height: number = 600; |
||||
let windowHeight: number; |
||||
|
||||
$: graphHeight = windowHeight ? Math.max(windowHeight * 0.2, 400) : 400; |
||||
|
||||
$: if (container) { |
||||
width = container.clientWidth || width; |
||||
height = container.clientHeight || height; |
||||
} |
||||
|
||||
interface NetworkNode extends d3.SimulationNodeDatum { |
||||
id: string; |
||||
event?: NDKEvent; |
||||
index?: number; |
||||
isContainer: boolean; |
||||
title: string; |
||||
content: string; |
||||
author: string; |
||||
type: "Index" | "Content"; |
||||
x?: number; |
||||
y?: number; |
||||
fx?: number | null; |
||||
fy?: number | null; |
||||
vx?: number; |
||||
vy?: number; |
||||
} |
||||
|
||||
interface NetworkLink extends d3.SimulationLinkDatum<NetworkNode> { |
||||
source: NetworkNode; |
||||
target: NetworkNode; |
||||
isSequential: boolean; |
||||
} |
||||
|
||||
function createEventMap(events: NDKEvent[]): Map<string, NDKEvent> { |
||||
return new Map(events.map((event) => [event.id, event])); |
||||
} |
||||
function updateNodeVelocity( |
||||
node: NetworkNode, |
||||
deltaVx: number, |
||||
deltaVy: number, |
||||
) { |
||||
if (typeof node.vx === "number" && typeof node.vy === "number") { |
||||
node.vx = node.vx - deltaVx; |
||||
node.vy = node.vy - deltaVy; |
||||
} |
||||
} |
||||
|
||||
function applyGlobalLogGravity( |
||||
node: NetworkNode, |
||||
centerX: number, |
||||
centerY: number, |
||||
alpha: number, |
||||
) { |
||||
const dx = (node.x ?? 0) - centerX; |
||||
const dy = (node.y ?? 0) - centerY; |
||||
const distance = Math.sqrt(dx * dx + dy * dy); |
||||
|
||||
if (distance === 0) return; |
||||
|
||||
const force = Math.log(distance + 1) * 0.05 * alpha; |
||||
updateNodeVelocity(node, (dx / distance) * force, (dy / distance) * force); |
||||
} |
||||
|
||||
function applyConnectedGravity( |
||||
node: NetworkNode, |
||||
links: NetworkLink[], |
||||
alpha: number, |
||||
) { |
||||
const connectedNodes = links |
||||
.filter( |
||||
(link) => link.source.id === node.id || link.target.id === node.id, |
||||
) |
||||
.map((link) => (link.source.id === node.id ? link.target : link.source)); |
||||
|
||||
if (connectedNodes.length === 0) return; |
||||
|
||||
const cogX = d3.mean(connectedNodes, (n) => n.x); |
||||
const cogY = d3.mean(connectedNodes, (n) => n.y); |
||||
|
||||
if (cogX === undefined || cogY === undefined) return; |
||||
|
||||
const dx = (node.x ?? 0) - cogX; |
||||
const dy = (node.y ?? 0) - cogY; |
||||
const distance = Math.sqrt(dx * dx + dy * dy); |
||||
|
||||
if (distance === 0) return; |
||||
|
||||
const force = distance * 0.3 * alpha; |
||||
updateNodeVelocity(node, (dx / distance) * force, (dy / distance) * force); |
||||
} |
||||
function getNode( |
||||
id: string, |
||||
nodeMap: Map<string, NetworkNode>, |
||||
event?: NDKEvent, |
||||
index?: number, |
||||
): NetworkNode | null { |
||||
if (!id) return null; |
||||
|
||||
if (!nodeMap.has(id)) { |
||||
const node: NetworkNode = { |
||||
id, |
||||
event, |
||||
index, |
||||
isContainer: event?.kind === 30040, |
||||
title: event?.getMatchingTags("title")?.[0]?.[1] || "Untitled", |
||||
content: event?.content || "", |
||||
author: event?.pubkey || "", |
||||
type: event?.kind === 30040 ? "Index" : "Content", |
||||
x: width / 2 + (Math.random() - 0.5) * 100, |
||||
y: height / 2 + (Math.random() - 0.5) * 100, |
||||
}; |
||||
nodeMap.set(id, node); |
||||
} |
||||
return nodeMap.get(id) || null; |
||||
} |
||||
|
||||
function getEventColor(eventId: string): string { |
||||
const num = parseInt(eventId.slice(0, 4), 16); |
||||
const hue = num % 360; |
||||
const saturation = 70; |
||||
const lightness = 75; |
||||
return `hsl(${hue}, ${saturation}%, ${lightness}%)`; |
||||
} |
||||
|
||||
function generateGraph(events: NDKEvent[]): { |
||||
nodes: NetworkNode[]; |
||||
links: NetworkLink[]; |
||||
} { |
||||
const nodes: NetworkNode[] = []; |
||||
const links: NetworkLink[] = []; |
||||
const nodeMap = new Map<string, NetworkNode>(); |
||||
|
||||
// Create event lookup map - O(n) operation done once |
||||
const eventMap = createEventMap(events); |
||||
|
||||
const indexEvents = events.filter((e) => e.kind === 30040); |
||||
|
||||
indexEvents.forEach((index) => { |
||||
if (!index.id) return; |
||||
|
||||
const contentRefs = index.getMatchingTags("e"); |
||||
const sourceNode = getNode(index.id, nodeMap, index); |
||||
if (!sourceNode) return; |
||||
nodes.push(sourceNode); |
||||
|
||||
contentRefs.forEach((tag, idx) => { |
||||
if (!tag[1]) return; |
||||
|
||||
// O(1) lookup instead of O(n) search |
||||
const targetEvent = eventMap.get(tag[1]); |
||||
if (!targetEvent) return; |
||||
|
||||
const targetNode = getNode(tag[1], nodeMap, targetEvent, idx); |
||||
if (!targetNode) return; |
||||
nodes.push(targetNode); |
||||
|
||||
const prevNodeId = |
||||
idx === 0 ? sourceNode.id : contentRefs[idx - 1]?.[1]; |
||||
const prevNode = nodeMap.get(prevNodeId); |
||||
|
||||
if (prevNode && targetNode) { |
||||
links.push({ |
||||
source: prevNode, |
||||
target: targetNode, |
||||
isSequential: true, |
||||
}); |
||||
} |
||||
}); |
||||
}); |
||||
|
||||
return { nodes, links }; |
||||
} |
||||
function setupDragHandlers( |
||||
simulation: d3.Simulation<NetworkNode, NetworkLink>, |
||||
) { |
||||
// Create drag behavior with proper typing |
||||
const dragBehavior = d3 |
||||
.drag<SVGGElement, NetworkNode>() |
||||
.on( |
||||
"start", |
||||
( |
||||
event: d3.D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, |
||||
d: NetworkNode, |
||||
) => { |
||||
// Warm up simulation when drag starts |
||||
if (!event.active) |
||||
simulation.alphaTarget(warmupClickEnergy).restart(); |
||||
// Fix node position during drag |
||||
d.fx = d.x; |
||||
d.fy = d.y; |
||||
}, |
||||
) |
||||
.on( |
||||
"drag", |
||||
( |
||||
event: d3.D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, |
||||
d: NetworkNode, |
||||
) => { |
||||
// Update fixed position to drag position |
||||
d.fx = event.x; |
||||
d.fy = event.y; |
||||
}, |
||||
) |
||||
.on( |
||||
"end", |
||||
( |
||||
event: d3.D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, |
||||
d: NetworkNode, |
||||
) => { |
||||
// Cool down simulation when drag ends |
||||
if (!event.active) simulation.alphaTarget(0); |
||||
// Release fixed position, allowing forces to take over |
||||
d.fx = null; |
||||
d.fy = null; |
||||
}, |
||||
); |
||||
return dragBehavior; |
||||
} |
||||
function drawNetwork() { |
||||
if (!svg || !events?.length) return; |
||||
|
||||
const { nodes, links } = generateGraph(events); |
||||
if (!nodes.length) return; |
||||
|
||||
const svgElement = d3.select(svg).attr("viewBox", `0 0 ${width} ${height}`); |
||||
// Set up zoom behavior |
||||
let g = svgElement.append("g"); |
||||
|
||||
const zoom = d3 |
||||
.zoom<SVGSVGElement, unknown>() |
||||
.scaleExtent([0.1, 9]) |
||||
.on("zoom", (event) => { |
||||
g.attr("transform", event.transform); |
||||
}); |
||||
|
||||
svgElement.call(zoom); |
||||
if (g.empty()) { |
||||
g = svgElement.append("g"); |
||||
|
||||
// Define arrow marker with black fill |
||||
} |
||||
svgElement.select("defs").remove(); |
||||
const defs = svgElement.append("defs"); |
||||
defs |
||||
.append("marker") |
||||
.attr("id", "arrowhead") |
||||
.attr("markerUnits", "strokeWidth") // Added this |
||||
.attr("viewBox", "-10 -5 10 10") |
||||
.attr("refX", 0) |
||||
.attr("refY", 0) |
||||
.attr("markerWidth", 5) |
||||
.attr("markerHeight", 5) |
||||
.attr("orient", "auto") |
||||
.append("path") |
||||
.attr("d", "M -10 -5 L 0 0 L -10 5 z") |
||||
.attr("class", "network-link-leather") |
||||
.attr("fill", "none") |
||||
.attr("stroke-width", 1); // Added stroke |
||||
|
||||
// Force simulation setup |
||||
const simulation = d3 |
||||
.forceSimulation<NetworkNode>(nodes) |
||||
.force( |
||||
"link", |
||||
d3 |
||||
.forceLink<NetworkNode, NetworkLink>(links) |
||||
.id((d) => d.id) |
||||
.distance(linkDistance * 0.1), |
||||
) |
||||
.force("collide", d3.forceCollide<NetworkNode>().radius(nodeRadius * 4)); |
||||
simulation.on("end", () => { |
||||
// Get the bounds of the graph |
||||
const bounds = g.node()?.getBBox(); |
||||
if (bounds) { |
||||
const dx = bounds.width; |
||||
const dy = bounds.height; |
||||
const x = bounds.x; |
||||
const y = bounds.y; |
||||
|
||||
// Calculate scale to fit |
||||
const scale = 1.25 / Math.max(dx / width, dy / height); |
||||
const translate = [ |
||||
width / 2 - scale * (x + dx / 2), |
||||
height / 2 - scale * (y + dy / 2), |
||||
]; |
||||
|
||||
// Apply the initial transform |
||||
svgElement |
||||
.transition() |
||||
.duration(750) |
||||
.call( |
||||
zoom.transform, |
||||
d3.zoomIdentity.translate(translate[0], translate[1]).scale(scale), |
||||
); |
||||
} |
||||
}); |
||||
const dragHandler = setupDragHandlers(simulation); |
||||
|
||||
// Create links |
||||
// First, make sure we're selecting and creating links correctly |
||||
const link = g |
||||
.selectAll("path") // Changed from "path.link" to just "path" |
||||
.data(links) |
||||
.join( |
||||
(enter) => |
||||
enter |
||||
.append("path") |
||||
.attr("stroke-width", 2) |
||||
.attr("marker-end", "url(#arrowhead)") // This should now be applied |
||||
.attr("class", "network-link-leather"), // Add class if needed |
||||
(update) => update, |
||||
(exit) => exit.remove(), |
||||
); |
||||
|
||||
// Create nodes |
||||
const node = g |
||||
.selectAll<SVGGElement, NetworkNode>("g.node") |
||||
.data(nodes, (d: NetworkNode) => d.id) |
||||
.join( |
||||
(enter) => { |
||||
const nodeEnter = enter |
||||
.append("g") |
||||
.attr("class", "node network-node-leather") |
||||
.call(dragHandler); |
||||
|
||||
// add drag circle |
||||
nodeEnter |
||||
.append("circle") |
||||
.attr("r", nodeRadius * 2.5) |
||||
.attr("fill", "transparent") |
||||
.attr("stroke", "transparent") |
||||
.style("cursor", "move"); |
||||
|
||||
// add visual circle, stroke based on current theme |
||||
nodeEnter |
||||
.append("circle") |
||||
.attr("r", nodeRadius) |
||||
.attr("class", (d: NetworkNode) => |
||||
!d.isContainer |
||||
? "network-node-leather network-node-content" |
||||
: "network-node-leather", |
||||
) |
||||
.attr("stroke-width", 2); |
||||
|
||||
// add text labels |
||||
nodeEnter |
||||
.append("text") |
||||
.attr("dy", "0.35em") |
||||
.attr("text-anchor", "middle") |
||||
.attr("fill", "black") |
||||
.attr("font-size", "12px"); |
||||
// .attr("font-weight", "bold"); |
||||
|
||||
return nodeEnter; |
||||
}, |
||||
(update) => update, |
||||
(exit) => exit.remove(), |
||||
); |
||||
|
||||
// Add text labels |
||||
node |
||||
.select("circle:nth-child(2)") |
||||
.attr("fill", (d: NetworkNode) => |
||||
!d.isContainer |
||||
? isDarkMode |
||||
? "#FFFFFF" |
||||
: "network-link-leather" |
||||
: getEventColor(d.id), |
||||
); |
||||
|
||||
node.select("text").text((d: NetworkNode) => (d.isContainer ? "I" : "C")); |
||||
// Add tooltips |
||||
const tooltip = d3 |
||||
.select("body") |
||||
.append("div") |
||||
.attr( |
||||
"class", |
||||
"tooltip-leather fixed hidden p-4 rounded shadow-lg " + |
||||
"bg-primary-0 dark:bg-primary-800 " + |
||||
"border border-gray-200 dark:border-gray-800 " + |
||||
"p-4 rounded shadow-lg border border-gray-200 dark:border-gray-800 " + |
||||
"transition-colors duration-200", |
||||
) |
||||
.style("z-index", 1000); |
||||
|
||||
node |
||||
.on("mouseover", function (event, d) { |
||||
tooltip |
||||
.style("display", "block") |
||||
.html( |
||||
` |
||||
<div class="space-y-2"> |
||||
<div class="font-bold text-base">${d.title}</div> |
||||
<div class="text-gray-600 dark:text-gray-400 text-sm"> |
||||
${d.type} (${d.isContainer ? "30040" : "30041"}) |
||||
</div> |
||||
<div class="text-gray-600 dark:text-gray-400 text-sm overflow-hidden text-ellipsis"> |
||||
ID: ${d.id} |
||||
</div> |
||||
${ |
||||
d.content |
||||
? ` |
||||
<div class="mt-2 text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded overflow-auto max-h-40"> |
||||
${d.content} |
||||
</div> |
||||
` |
||||
: "" |
||||
} |
||||
</div> |
||||
`, |
||||
) |
||||
.style("left", event.pageX - 10 + "px") |
||||
.style("top", event.pageY + 10 + "px"); |
||||
}) |
||||
.on("mousemove", function (event) { |
||||
tooltip |
||||
.style("left", event.pageX + 10 + "px") |
||||
.style("top", event.pageY - 10 + "px"); |
||||
}) |
||||
.on("mouseout", () => { |
||||
tooltip.style("display", "none"); |
||||
}); |
||||
|
||||
// Handle simulation ticks |
||||
simulation.on("tick", () => { |
||||
nodes.forEach((node) => { |
||||
applyGlobalLogGravity(node, width / 2, height / 2, simulation.alpha()); |
||||
applyConnectedGravity(node, links, simulation.alpha()); |
||||
}); |
||||
link.attr("d", (d) => { |
||||
const dx = d.target.x! - d.source.x!; |
||||
const dy = d.target.y! - d.source.y!; |
||||
const angle = Math.atan2(dy, dx); |
||||
|
||||
const sourceGap = nodeRadius; |
||||
const targetGap = nodeRadius + arrowDistance; // Increased gap for arrowhead |
||||
|
||||
const startX = d.source.x! + sourceGap * Math.cos(angle); |
||||
const startY = d.source.y! + sourceGap * Math.sin(angle); |
||||
const endX = d.target.x! - targetGap * Math.cos(angle); |
||||
const endY = d.target.y! - targetGap * Math.sin(angle); |
||||
|
||||
return `M${startX},${startY}L${endX},${endY}`; |
||||
}); |
||||
node.attr("transform", (d) => `translate(${d.x},${d.y})`); |
||||
}); |
||||
} |
||||
|
||||
onMount(() => { |
||||
isDarkMode = document.body.classList.contains("dark"); |
||||
// Add window resize listener |
||||
const handleResize = () => { |
||||
windowHeight = window.innerHeight; |
||||
}; |
||||
|
||||
// Initial resize |
||||
windowHeight = window.innerHeight; |
||||
window.addEventListener("resize", handleResize); |
||||
|
||||
// Watch for theme changes |
||||
const themeObserver = new MutationObserver((mutations) => { |
||||
mutations.forEach((mutation) => { |
||||
if (mutation.attributeName === "class") { |
||||
const newIsDarkMode = document.body.classList.contains("dark"); |
||||
if (newIsDarkMode !== isDarkMode) { |
||||
isDarkMode = newIsDarkMode; |
||||
// drawNetwork(); |
||||
} |
||||
} |
||||
}); |
||||
}); |
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => { |
||||
for (const entry of entries) { |
||||
width = entry.contentRect.width; |
||||
height = graphHeight; |
||||
} |
||||
|
||||
// first remove all nodes and links |
||||
d3.select(svg).selectAll("*").remove(); |
||||
drawNetwork(); |
||||
}); |
||||
|
||||
// Start observers |
||||
themeObserver.observe(document.documentElement, { |
||||
attributes: true, |
||||
attributeFilter: ["class"], |
||||
}); |
||||
resizeObserver.observe(container); |
||||
// Clean up |
||||
return () => { |
||||
themeObserver.disconnect(); |
||||
resizeObserver.disconnect(); |
||||
}; |
||||
}); |
||||
// Reactive redaw |
||||
$: { |
||||
if (svg && events?.length) { |
||||
drawNetwork(); |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<div |
||||
class="flex flex-col w-full h-[calc(100vh-120px)] min-h-[400px] max-h-[900px] p-4 gap-4" |
||||
> |
||||
<div class="h-[calc(100%-130px)] min-h-[300px]" bind:this={container}> |
||||
<svg |
||||
bind:this={svg} |
||||
class="w-full h-full border border-gray-300 dark:border-gray-700 rounded" |
||||
/> |
||||
</div> |
||||
<!-- Legend --> |
||||
<div class="leather-legend"> |
||||
<h3 class="text-lg font-bold mb-2 h-leather">Legend</h3> |
||||
<ul class="legend-list"> |
||||
<li class="legend-item"> |
||||
<div class="legend-icon"> |
||||
<span |
||||
class="legend-circle" |
||||
style="background-color: hsl(200, 70%, 75%)" |
||||
> |
||||
</span> |
||||
<span class="legend-letter">I</span> |
||||
</div> |
||||
<span>Index events (kind 30040) - Each with a unique pastel color</span> |
||||
</li> |
||||
|
||||
<li class="legend-item"> |
||||
<div class="legend-icon"> |
||||
<span class="legend-circle content"></span> |
||||
<span class="legend-letter">C</span> |
||||
</div> |
||||
<span>Content events (kind 30041) - Publication sections</span> |
||||
</li> |
||||
|
||||
<li class="legend-item"> |
||||
<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" /> |
||||
</svg> |
||||
<span>Arrows indicate reading/sequence order</span> |
||||
</li> |
||||
</ul> |
||||
</div> |
||||
</div> |
||||
|
||||
<style> |
||||
.legend-list { |
||||
@apply list-disc pl-5 space-y-2 text-gray-800 dark:text-gray-300; |
||||
} |
||||
|
||||
.legend-item { |
||||
@apply flex items-center; |
||||
} |
||||
|
||||
.legend-icon { |
||||
@apply relative w-6 h-6 mr-2; |
||||
} |
||||
|
||||
.legend-circle { |
||||
@apply absolute inset-0 rounded-full border-2 border-black; |
||||
} |
||||
|
||||
.legend-circle.content { |
||||
@apply bg-gray-700 dark:bg-gray-300; |
||||
background-color: #d6c1a8; |
||||
} |
||||
|
||||
.legend-letter { |
||||
@apply absolute inset-0 flex items-center justify-center text-black text-xs; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
|
||||
<script lang="ts"> |
||||
import { levelsToRender } from "$lib/state"; |
||||
import { createEventDispatcher } from "svelte"; |
||||
|
||||
let inputValue = $levelsToRender; |
||||
|
||||
const dispatch = createEventDispatcher<{ |
||||
update: { limit: number }; |
||||
}>(); |
||||
function handleInput(event: Event) { |
||||
const input = event.target as HTMLInputElement; |
||||
const value = parseInt(input.value); |
||||
// Ensure value is between 1 and 50 |
||||
if (value >= 1 && value <= 50) { |
||||
inputValue = value; |
||||
} |
||||
} |
||||
|
||||
function handleUpdate() { |
||||
$levelsToRender = inputValue; |
||||
dispatch("update", { limit: inputValue }); |
||||
} |
||||
|
||||
function handleKeyDown(event: KeyboardEvent) { |
||||
if (event.key === "Enter") { |
||||
handleUpdate(); |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<div class="flex items-center gap-2 mb-4"> |
||||
<label for="levels-to-render" class="text-sm font-medium" |
||||
>Levels to render: |
||||
</label> |
||||
<label for="event-limit" class="text-sm font-medium">Limit: </label> |
||||
<input |
||||
type="number" |
||||
id="levels-to-render" |
||||
min="1" |
||||
max="50" |
||||
class="w-20 bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md px-2 py-1" |
||||
bind:value={inputValue} |
||||
oninput={handleInput} |
||||
onkeydown={handleKeyDown} |
||||
/> |
||||
<button |
||||
onclick={handleUpdate} |
||||
class="px-3 py-1 bg-primary-0 dark:bg-primary-1000 border border-gray-300 dark:border-gray-700 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800" |
||||
> |
||||
Update |
||||
</button> |
||||
</div> |
||||
@ -1,70 +0,0 @@
@@ -1,70 +0,0 @@
|
||||
<script lang="ts"> |
||||
import type { NDKEvent } from '@nostr-dev-kit/ndk'; |
||||
import {Converter} from 'showdown'; |
||||
const converter = new Converter(); |
||||
export let notes: NDKEvent[] = []; |
||||
notes.forEach((note) => { |
||||
note.votes = 0; |
||||
}); |
||||
import {nip19} from 'nostr-tools'; |
||||
$: notes.forEach((note) => { |
||||
note.voteUp = () => { |
||||
note.votes++; |
||||
note.update(); |
||||
}; |
||||
note.voteDown = () => { |
||||
note.votes--; |
||||
note.update(); |
||||
}; |
||||
note.getVotes = () => { |
||||
return note.votes; |
||||
}; |
||||
}); |
||||
</script> |
||||
|
||||
<div class="notes"> |
||||
{#each notes as note} |
||||
<div class="title" id={nip19.noteEncode(note.id)}> |
||||
<h4>{note.getMatchingTags('title')[0][1]}</h4> |
||||
</div> |
||||
<div class="vote"> |
||||
<button on:click={note.voteUp}>▲</button> |
||||
<p>{note.getVotes()}</p> |
||||
<button on:click={note.voteDown}>▼</button> |
||||
</div> |
||||
<div class="content"> |
||||
{@html converter.makeHtml(note.content)} |
||||
</div> |
||||
{/each} |
||||
</div> |
||||
|
||||
<style> |
||||
.notes { |
||||
display: grid; |
||||
border: 1px solid white; |
||||
} |
||||
|
||||
.title { |
||||
display: grid; |
||||
grid-column: 1/2; |
||||
margin: auto; |
||||
float: right; |
||||
border: 1px solid white; |
||||
text-align: center; |
||||
} |
||||
|
||||
.content { |
||||
display: grid; |
||||
grid-column: 1/2; |
||||
width: 100%; |
||||
padding: 10px; |
||||
border: 1px solid white; |
||||
} |
||||
.vote { |
||||
display: grid; |
||||
grid-template-rows: 1fr 1fr 1fr; |
||||
grid-column: 3/3; |
||||
width: 5%; |
||||
margin: 1%; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,115 @@
@@ -0,0 +1,115 @@
|
||||
<script lang='ts'> |
||||
import { indexKind } from '$lib/consts'; |
||||
import { ndkInstance } from '$lib/ndk'; |
||||
import { filterValidIndexEvents } from '$lib/utils'; |
||||
import { NDKRelaySet, type NDKEvent } from '@nostr-dev-kit/ndk'; |
||||
import { Button, P, Skeleton, Spinner } from 'flowbite-svelte'; |
||||
import ArticleHeader from './ArticleHeader.svelte'; |
||||
import { onMount } from 'svelte'; |
||||
|
||||
let { relays } = $props<{ relays: string[] }>(); |
||||
|
||||
let eventsInView: NDKEvent[] = $state([]); |
||||
let loadingMore: boolean = $state(false); |
||||
let endOfFeed: boolean = $state(false); |
||||
|
||||
let cutoffTimestamp: number = $derived( |
||||
eventsInView?.at(eventsInView.length - 1)?.created_at ?? new Date().getTime() |
||||
); |
||||
|
||||
async function getEvents( |
||||
before: number | undefined = undefined, |
||||
): Promise<void> { |
||||
let eventSet = await $ndkInstance.fetchEvents( |
||||
{ |
||||
kinds: [indexKind], |
||||
limit: 16, |
||||
until: before, |
||||
}, |
||||
{ |
||||
groupable: false, |
||||
skipVerification: false, |
||||
skipValidation: false, |
||||
}, |
||||
NDKRelaySet.fromRelayUrls(relays, $ndkInstance) |
||||
); |
||||
eventSet = filterValidIndexEvents(eventSet); |
||||
|
||||
let eventArray = Array.from(eventSet); |
||||
eventArray?.sort((a, b) => b.created_at! - a.created_at!); |
||||
|
||||
if (!eventArray) { |
||||
return; |
||||
} |
||||
|
||||
endOfFeed = eventArray?.at(eventArray.length - 1)?.id === eventsInView?.at(eventsInView.length - 1)?.id; |
||||
|
||||
if (endOfFeed) { |
||||
return; |
||||
} |
||||
|
||||
const eventMap = new Map([...eventsInView, ...eventArray].map(event => [event.id, event])); |
||||
const allEvents = Array.from(eventMap.values()); |
||||
const uniqueIds = new Set(allEvents.map(event => event.id)); |
||||
eventsInView = Array.from(uniqueIds) |
||||
.map(id => eventMap.get(id)) |
||||
.filter(event => event != null) as NDKEvent[]; |
||||
} |
||||
|
||||
const getSkeletonIds = (): string[] => { |
||||
const skeletonHeight = 124; // The height of the skeleton component in pixels. |
||||
|
||||
// Determine the number of skeletons to display based on the height of the screen. |
||||
const skeletonCount = Math.floor(window.innerHeight / skeletonHeight) - 2; |
||||
|
||||
const skeletonIds = []; |
||||
for (let i = 0; i < skeletonCount; i++) { |
||||
skeletonIds.push(`skeleton-${i}`); |
||||
} |
||||
return skeletonIds; |
||||
} |
||||
|
||||
async function loadMorePublications() { |
||||
loadingMore = true; |
||||
await getEvents(cutoffTimestamp); |
||||
loadingMore = false; |
||||
} |
||||
|
||||
onMount(async () => { |
||||
await getEvents(); |
||||
}); |
||||
</script> |
||||
|
||||
<div class='leather flex flex-col flex-grow-0 space-y-4 overflow-y-auto w-max p-2'> |
||||
{#if eventsInView.length === 0} |
||||
{#each getSkeletonIds() as id} |
||||
<Skeleton divClass='skeleton-leather w-full' size='lg' /> |
||||
{/each} |
||||
{:else if eventsInView.length > 0} |
||||
{#each eventsInView as event} |
||||
<ArticleHeader {event} /> |
||||
{/each} |
||||
{:else} |
||||
<p class='text-center'>No articles found.</p> |
||||
{/if} |
||||
{#if !loadingMore && !endOfFeed} |
||||
<div class='flex justify-center mt-4 mb-8'> |
||||
<Button outline class="w-full" onclick={async () => { |
||||
await loadMorePublications(); |
||||
}}> |
||||
Show more publications |
||||
</Button> |
||||
</div> |
||||
{:else if loadingMore} |
||||
<div class='flex justify-center mt-4 mb-8'> |
||||
<Button outline disabled class="w-full"> |
||||
<Spinner class='mr-3 text-gray-300' size='4' /> |
||||
Loading... |
||||
</Button> |
||||
</div> |
||||
{:else} |
||||
<div class='flex justify-center mt-4 mb-8'> |
||||
<P class='text-sm text-gray-600'>You've reached the end of the feed.</P> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
@ -1,38 +0,0 @@
@@ -1,38 +0,0 @@
|
||||
<script lang="ts"> |
||||
import { tabs } from '$lib/state'; |
||||
import { next, scrollTabIntoView } from '$lib/utils'; |
||||
import type { Tab } from '$lib/types'; |
||||
|
||||
let query = ''; |
||||
|
||||
function search() { |
||||
let a = query; |
||||
query = ''; |
||||
if (a) { |
||||
let newTabs = $tabs; |
||||
const newTab: Tab = { |
||||
id: next(), |
||||
type: 'find', |
||||
data: a.toLowerCase().replaceAll(' ', '-') |
||||
}; |
||||
newTabs.push(newTab); |
||||
tabs.set(newTabs); |
||||
scrollTabIntoView(String(newTab.id), true); |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
<form on:submit|preventDefault={search} class="mt- flex rounded-md shadow-sm"> |
||||
<div class="relative flex items-stretch flex-grow focus-within:z-10"> |
||||
<input |
||||
bind:value={query} |
||||
class="focus:ring-indigo-500 focus:border-indigo-500 block w-full rounded-none rounded-l-md sm:text-sm border-gray-300" |
||||
placeholder="article name" |
||||
/> |
||||
</div> |
||||
<button |
||||
type="submit" |
||||
class="-ml-px relative inline-flex items-center space-x-2 px-3 py-2 border border-gray-300 text-sm font-medium rounded-r-md text-gray-700 bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 text-white" |
||||
>Go</button |
||||
> |
||||
</form> |
||||
@ -1,9 +1,13 @@
@@ -1,9 +1,13 @@
|
||||
export const wikiKind = 30818; |
||||
export const indexKind = 30040; |
||||
export const zettelKinds = [ 30041 ]; |
||||
export const standardRelays = [ "wss://thecitadel.nostr1.com", "wss://relay.noswhere.com" ]; |
||||
export const standardRelays = [ 'wss://thecitadel.nostr1.com', 'wss://relay.noswhere.com' ]; |
||||
export const bootstrapRelays = [ 'wss://purplepag.es', 'wss://relay.noswhere.com' ]; |
||||
|
||||
export enum FeedType { |
||||
Relays, |
||||
Follows, |
||||
StandardRelays = 'standard', |
||||
UserRelays = 'user', |
||||
} |
||||
|
||||
export const loginStorageKey = 'alexandria/login/pubkey'; |
||||
export const feedTypeStorageKey = 'alexandria/feed/type'; |
||||
|
||||
@ -1,111 +0,0 @@
@@ -1,111 +0,0 @@
|
||||
<script lang="ts"> |
||||
import { ndk } from "$lib/ndk"; |
||||
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||
import { neventEncode } from "$lib/utils.ts"; |
||||
import { nip19 } from "nostr-tools"; |
||||
import { standardRelays } from "./consts"; |
||||
import Modal from "$lib/Modal.svelte"; |
||||
|
||||
export let event: NDKEvent; |
||||
// const eventString: string = JSON.stringify(event); |
||||
// event.toString(); |
||||
let modal = false; |
||||
|
||||
function copyEventID() { |
||||
console.debug("copyEventID"); |
||||
const relays: string[] = standardRelays; |
||||
const naddr = neventEncode(event, relays); |
||||
navigator.clipboard.writeText(naddr); |
||||
} |
||||
function viewJSON() { |
||||
console.debug("viewJSON"); |
||||
modal = !modal; |
||||
console.debug(modal); |
||||
} |
||||
|
||||
function shareNjump() { |
||||
const relays: string[] = standardRelays; |
||||
const naddr = neventEncode(event, relays); |
||||
console.debug(naddr); |
||||
navigator.clipboard.writeText(`njump.me/${naddr}`); |
||||
} |
||||
</script> |
||||
|
||||
<div class="dropdown"> |
||||
<button class="dropbtn"> |
||||
<div class="dot" /> |
||||
<div class="dot" /> |
||||
<div class="dot" /> |
||||
</button> |
||||
|
||||
<div class="dropdown-content"> |
||||
<a on:click={copyEventID}>Copy Event ID</a> |
||||
<!-- <a on:click={viewJSON}>View JSON</a> --> |
||||
<a on:click={shareNjump}>Share (njump)</a> |
||||
</div> |
||||
</div> |
||||
<Modal showModal={modal} {event} /> |
||||
|
||||
<style> |
||||
.dropdown { |
||||
position: relative; |
||||
display: inline-block; |
||||
display: grid; |
||||
grid-template-columns: 1fr 1fr 1fr; |
||||
border-color: green; |
||||
/* boarder-width: 100px; */ |
||||
} |
||||
.dropbtn { |
||||
color: white; |
||||
grid-column: 2; |
||||
margin: 50px; |
||||
padding: 16px; |
||||
font-size: 16px; |
||||
border: none; |
||||
cursor: pointer; |
||||
border: 1px solid red; |
||||
} |
||||
.dot { |
||||
height: 9px; |
||||
width: 9px; |
||||
background-color: white; |
||||
border-radius: 50%; |
||||
display: inline-block; |
||||
margin: 0 5px; |
||||
} |
||||
|
||||
/* The container <div> - needed to position the dropdown content */ |
||||
|
||||
/* Dropdown Content (Hidden by Default) */ |
||||
.dropdown-content { |
||||
display: none; |
||||
position: absolute; |
||||
background-color: #f9f9f9; |
||||
min-width: 160px; |
||||
box-shadow: 0px 8px 16px 0px rgba(0, 0, 0, 0.2); |
||||
z-index: 1; |
||||
} |
||||
|
||||
/* Links inside the dropdown */ |
||||
.dropdown-content a { |
||||
color: black; |
||||
padding: 12px 16px; |
||||
text-decoration: none; |
||||
display: block; |
||||
} |
||||
|
||||
/* Change color of dropdown links on hover */ |
||||
.dropdown-content a:hover { |
||||
background-color: #cacaca; |
||||
} |
||||
|
||||
/* Show the dropdown menu on hover */ |
||||
.dropdown:hover .dropdown-content { |
||||
display: block; |
||||
} |
||||
|
||||
/* Change the background color of the dropdown button when the dropdown content is shown */ |
||||
.dropdown:hover .dropbtn { |
||||
/* background-color: #3e8e41; */ |
||||
} |
||||
</style> |
||||
@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
|
||||
<script lang="ts"> |
||||
export let className: string = ""; |
||||
</script> |
||||
|
||||
<div class="leather-legend {className}"> |
||||
<h3 class="text-lg font-bold mb-2 h-leather">Legend</h3> |
||||
<ul class="legend-list"> |
||||
<li class="legend-item"> |
||||
<div class="legend-icon"> |
||||
<span class="legend-circle" style="background-color: hsl(200, 70%, 75%)"> |
||||
</span> |
||||
<span class="legend-letter">I</span> |
||||
</div> |
||||
<span>Index events (kind 30040) - Each with a unique pastel color</span> |
||||
</li> |
||||
<li class="legend-item"> |
||||
<div class="legend-icon"> |
||||
<span class="legend-circle content"></span> |
||||
<span class="legend-letter">C</span> |
||||
</div> |
||||
<span>Content events (kind 30041) - Publication sections</span> |
||||
</li> |
||||
<li class="legend-item"> |
||||
<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" /> |
||||
</svg> |
||||
<span>Arrows indicate reading/sequence order</span> |
||||
</li> |
||||
</ul> |
||||
</div> |
||||
|
||||
<style> |
||||
.legend-list { |
||||
@apply list-disc pl-5 space-y-2 text-gray-800 dark:text-gray-300; |
||||
} |
||||
|
||||
.legend-item { |
||||
@apply flex items-center; |
||||
} |
||||
|
||||
.legend-icon { |
||||
@apply relative w-6 h-6 mr-2; |
||||
} |
||||
|
||||
.legend-circle { |
||||
@apply absolute inset-0 rounded-full border-2 border-black; |
||||
} |
||||
|
||||
.legend-circle.content { |
||||
@apply bg-gray-700 dark:bg-gray-300; |
||||
background-color: #d6c1a8; |
||||
} |
||||
|
||||
.legend-letter { |
||||
@apply absolute inset-0 flex items-center justify-center text-black text-xs; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
|
||||
<script lang="ts"> |
||||
import type { NetworkNode } from "./types"; |
||||
|
||||
export let node: NetworkNode; |
||||
export let selected: boolean = false; |
||||
export let x: number; |
||||
export let y: number; |
||||
</script> |
||||
|
||||
<div |
||||
class="tooltip-leather fixed p-4 rounded shadow-lg bg-primary-0 dark:bg-primary-800 |
||||
border border-gray-200 dark:border-gray-800 transition-colors duration-200" |
||||
style="left: {x + 10}px; top: {y - 10}px; z-index: 1000;" |
||||
> |
||||
<div class="space-y-2"> |
||||
<div class="font-bold text-base">{node.title}</div> |
||||
<div class="text-gray-600 dark:text-gray-400 text-sm"> |
||||
{node.type} ({node.isContainer ? "30040" : "30041"}) |
||||
</div> |
||||
<div |
||||
class="text-gray-600 dark:text-gray-400 text-sm overflow-hidden text-ellipsis" |
||||
> |
||||
ID: {node.id} |
||||
{#if node.naddr} |
||||
<div>{node.naddr}</div> |
||||
{/if} |
||||
{#if node.nevent} |
||||
<div>{node.nevent}</div> |
||||
{/if} |
||||
</div> |
||||
{#if node.content} |
||||
<div |
||||
class="mt-2 text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded overflow-auto max-h-40" |
||||
> |
||||
{node.content} |
||||
</div> |
||||
{/if} |
||||
{#if selected} |
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400"> |
||||
Click node again to dismiss |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
@ -0,0 +1,335 @@
@@ -0,0 +1,335 @@
|
||||
<!-- EventNetwork.svelte --> |
||||
<script lang="ts"> |
||||
import { onMount } from "svelte"; |
||||
import * as d3 from "d3"; |
||||
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||
import { levelsToRender } from "$lib/state"; |
||||
import { generateGraph, getEventColor } from "./utils/networkBuilder"; |
||||
import { createSimulation, setupDragHandlers, applyGlobalLogGravity, applyConnectedGravity } from "./utils/forceSimulation"; |
||||
import Legend from "./Legend.svelte"; |
||||
import NodeTooltip from "./NodeTooltip.svelte"; |
||||
|
||||
let { events = [] } = $props<{ events?: NDKEvent[] }>(); |
||||
|
||||
let svg: SVGSVGElement; |
||||
let isDarkMode = $state(false); |
||||
let container: HTMLDivElement; |
||||
|
||||
// Use a string ID for comparisons instead of the node object |
||||
let selectedNodeId = $state<string | null>(null); |
||||
let tooltipVisible = $state(false); |
||||
let tooltipX = $state(0); |
||||
let tooltipY = $state(0); |
||||
let tooltipNode = $state<NetworkNode | null>(null); |
||||
|
||||
const nodeRadius = 20; |
||||
const linkDistance = 10; |
||||
const arrowDistance = 10; |
||||
|
||||
let width = $state(1000); |
||||
let height = $state(600); |
||||
let windowHeight = $state<number | undefined>(undefined); |
||||
|
||||
let simulation: d3.Simulation<NetworkNode, NetworkLink> | null = null; |
||||
let svgGroup: d3.Selection<SVGGElement, unknown, null, undefined>; |
||||
|
||||
let graphHeight = $derived(windowHeight ? Math.max(windowHeight * 0.2, 400) : 400); |
||||
|
||||
// Update dimensions when container changes |
||||
$effect(() => { |
||||
if (container) { |
||||
width = container.clientWidth || width; |
||||
height = container.clientHeight || height; |
||||
} |
||||
}); |
||||
|
||||
// Track levelsToRender changes |
||||
let currentLevels = $derived(levelsToRender); |
||||
|
||||
function initializeGraph() { |
||||
if (!svg) return; |
||||
|
||||
const svgElement = d3.select(svg) |
||||
.attr("viewBox", `0 0 ${width} ${height}`); |
||||
|
||||
// Clear existing content |
||||
svgElement.selectAll("*").remove(); |
||||
|
||||
// Create main group for zoom |
||||
svgGroup = svgElement.append("g"); |
||||
|
||||
// Set up zoom behavior |
||||
const zoom = d3 |
||||
.zoom<SVGSVGElement, unknown>() |
||||
.scaleExtent([0.1, 9]) |
||||
.on("zoom", (event) => { |
||||
svgGroup.attr("transform", event.transform); |
||||
}); |
||||
|
||||
svgElement.call(zoom); |
||||
|
||||
// Set up arrow marker |
||||
const defs = svgElement.append("defs"); |
||||
defs |
||||
.append("marker") |
||||
.attr("id", "arrowhead") |
||||
.attr("markerUnits", "strokeWidth") |
||||
.attr("viewBox", "-10 -5 10 10") |
||||
.attr("refX", 0) |
||||
.attr("refY", 0) |
||||
.attr("markerWidth", 5) |
||||
.attr("markerHeight", 5) |
||||
.attr("orient", "auto") |
||||
.append("path") |
||||
.attr("d", "M -10 -5 L 0 0 L -10 5 z") |
||||
.attr("class", "network-link-leather") |
||||
.attr("fill", "none") |
||||
.attr("stroke-width", 1); |
||||
} |
||||
|
||||
function updateGraph() { |
||||
if (!svg || !events?.length || !svgGroup) return; |
||||
|
||||
const { nodes, links } = generateGraph(events, currentLevels); |
||||
if (!nodes.length) return; |
||||
|
||||
// Stop any existing simulation |
||||
if (simulation) simulation.stop(); |
||||
|
||||
// Create new simulation |
||||
simulation = createSimulation(nodes, links, nodeRadius, linkDistance); |
||||
const dragHandler = setupDragHandlers(simulation); |
||||
|
||||
// Update links |
||||
const link = svgGroup |
||||
.selectAll<SVGPathElement, NetworkLink>("path.link") |
||||
.data(links, d => `${d.source.id}-${d.target.id}`) |
||||
.join( |
||||
enter => enter |
||||
.append("path") |
||||
.attr("class", "link network-link-leather") |
||||
.attr("stroke-width", 2) |
||||
.attr("marker-end", "url(#arrowhead)"), |
||||
update => update, |
||||
exit => exit.remove() |
||||
); |
||||
|
||||
// Update nodes |
||||
const node = svgGroup |
||||
.selectAll<SVGGElement, NetworkNode>("g.node") |
||||
.data(nodes, d => d.id) |
||||
.join( |
||||
enter => { |
||||
const nodeEnter = enter |
||||
.append("g") |
||||
.attr("class", "node network-node-leather") |
||||
.call(dragHandler); |
||||
|
||||
nodeEnter |
||||
.append("circle") |
||||
.attr("class", "drag-circle") |
||||
.attr("r", nodeRadius * 2.5) |
||||
.attr("fill", "transparent") |
||||
.attr("stroke", "transparent") |
||||
.style("cursor", "move"); |
||||
|
||||
nodeEnter |
||||
.append("circle") |
||||
.attr("class", "visual-circle") |
||||
.attr("r", nodeRadius) |
||||
.attr("stroke-width", 2); |
||||
|
||||
nodeEnter |
||||
.append("text") |
||||
.attr("dy", "0.35em") |
||||
.attr("text-anchor", "middle") |
||||
.attr("fill", "black") |
||||
.attr("font-size", "12px"); |
||||
|
||||
return nodeEnter; |
||||
}, |
||||
update => update, |
||||
exit => exit.remove() |
||||
); |
||||
|
||||
// Update node appearances |
||||
node.select("circle.visual-circle") |
||||
.attr("class", d => !d.isContainer |
||||
? "visual-circle network-node-leather network-node-content" |
||||
: "visual-circle network-node-leather" |
||||
) |
||||
.attr("fill", d => !d.isContainer |
||||
? isDarkMode ? "#FFFFFF" : "network-link-leather" |
||||
: getEventColor(d.id) |
||||
); |
||||
|
||||
node.select("text") |
||||
.text(d => d.isContainer ? "I" : "C"); |
||||
|
||||
// Update node interactions |
||||
node |
||||
.on("mouseover", (event, d) => { |
||||
if (!selectedNodeId) { |
||||
tooltipVisible = true; |
||||
tooltipNode = d; |
||||
tooltipX = event.pageX; |
||||
tooltipY = event.pageY; |
||||
} |
||||
}) |
||||
.on("mousemove", (event, d) => { |
||||
if (!selectedNodeId) { |
||||
tooltipX = event.pageX; |
||||
tooltipY = event.pageY; |
||||
} |
||||
}) |
||||
.on("mouseout", () => { |
||||
if (!selectedNodeId) { |
||||
tooltipVisible = false; |
||||
tooltipNode = null; |
||||
} |
||||
}) |
||||
.on("click", (event, d) => { |
||||
event.stopPropagation(); |
||||
if (selectedNodeId === d.id) { |
||||
selectedNodeId = null; |
||||
tooltipVisible = false; |
||||
tooltipNode = d; |
||||
tooltipX = event.pageX; |
||||
tooltipY = event.pageY; |
||||
} else { |
||||
selectedNodeId = d.id; |
||||
tooltipVisible = true; |
||||
tooltipNode = d; |
||||
tooltipX = event.pageX; |
||||
tooltipY = event.pageY; |
||||
} |
||||
}); |
||||
|
||||
// Handle simulation ticks |
||||
simulation.on("tick", () => { |
||||
nodes.forEach(node => { |
||||
applyGlobalLogGravity(node, width / 2, height / 2, simulation!.alpha()); |
||||
applyConnectedGravity(node, links, simulation!.alpha()); |
||||
}); |
||||
|
||||
// Update positions |
||||
link.attr("d", d => { |
||||
const dx = d.target.x! - d.source.x!; |
||||
const dy = d.target.y! - d.source.y!; |
||||
const angle = Math.atan2(dy, dx); |
||||
|
||||
const sourceGap = nodeRadius; |
||||
const targetGap = nodeRadius + arrowDistance; |
||||
|
||||
const startX = d.source.x! + sourceGap * Math.cos(angle); |
||||
const startY = d.source.y! + sourceGap * Math.sin(angle); |
||||
const endX = d.target.x! - targetGap * Math.cos(angle); |
||||
const endY = d.target.y! - targetGap * Math.sin(angle); |
||||
|
||||
return `M${startX},${startY}L${endX},${endY}`; |
||||
}); |
||||
|
||||
node.attr("transform", d => `translate(${d.x},${d.y})`); |
||||
}); |
||||
} |
||||
|
||||
onMount(() => { |
||||
isDarkMode = document.body.classList.contains("dark"); |
||||
|
||||
// Initialize the graph structure |
||||
initializeGraph(); |
||||
|
||||
// Handle window resizing |
||||
const handleResize = () => { |
||||
windowHeight = window.innerHeight; |
||||
}; |
||||
windowHeight = window.innerHeight; |
||||
window.addEventListener("resize", handleResize); |
||||
|
||||
// Watch for theme changes |
||||
const themeObserver = new MutationObserver((mutations) => { |
||||
mutations.forEach((mutation) => { |
||||
if (mutation.attributeName === "class") { |
||||
const newIsDarkMode = document.body.classList.contains("dark"); |
||||
if (newIsDarkMode !== isDarkMode) { |
||||
isDarkMode = newIsDarkMode; |
||||
// Update node colors when theme changes |
||||
if (svgGroup) { |
||||
svgGroup.selectAll<SVGGElement, NetworkNode>("g.node") |
||||
.select("circle.visual-circle") |
||||
.attr("fill", d => !d.isContainer |
||||
? newIsDarkMode ? "#FFFFFF" : "network-link-leather" |
||||
: getEventColor(d.id) |
||||
); |
||||
} |
||||
} |
||||
} |
||||
}); |
||||
}); |
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => { |
||||
for (const entry of entries) { |
||||
width = entry.contentRect.width; |
||||
height = graphHeight; |
||||
} |
||||
if (svg) { |
||||
d3.select(svg).attr("viewBox", `0 0 ${width} ${height}`); |
||||
// Trigger simulation to adjust to new dimensions |
||||
if (simulation) { |
||||
simulation.alpha(0.3).restart(); |
||||
} |
||||
} |
||||
}); |
||||
|
||||
themeObserver.observe(document.documentElement, { |
||||
attributes: true, |
||||
attributeFilter: ["class"], |
||||
}); |
||||
resizeObserver.observe(container); |
||||
|
||||
return () => { |
||||
themeObserver.disconnect(); |
||||
resizeObserver.disconnect(); |
||||
window.removeEventListener("resize", handleResize); |
||||
if (simulation) simulation.stop(); |
||||
}; |
||||
}); |
||||
|
||||
// Watch for changes that should trigger a graph update |
||||
$effect(() => { |
||||
if (svg && events?.length) { |
||||
// Include currentLevels in the effect dependencies |
||||
const _ = currentLevels; |
||||
updateGraph(); |
||||
} |
||||
}); |
||||
</script> |
||||
|
||||
<div |
||||
class="flex flex-col w-full h-[calc(100vh-120px)] min-h-[400px] max-h-[900px] p-4 gap-4" |
||||
> |
||||
<div class="h-[calc(100%-130px)] min-h-[300px]" bind:this={container}> |
||||
<svg |
||||
bind:this={svg} |
||||
class="w-full h-full border border-gray-300 dark:border-gray-700 rounded" |
||||
/> |
||||
</div> |
||||
|
||||
{#if tooltipVisible && tooltipNode} |
||||
<NodeTooltip |
||||
node={tooltipNode} |
||||
selected={tooltipNode.id === selectedNodeId} |
||||
x={tooltipX} |
||||
y={tooltipY} |
||||
/> |
||||
{/if} |
||||
|
||||
<Legend /> |
||||
</div> |
||||
|
||||
<style> |
||||
.tooltip { |
||||
max-width: 300px; |
||||
word-wrap: break-word; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
|
||||
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||
|
||||
export interface NetworkNode extends d3.SimulationNodeDatum { |
||||
id: string; |
||||
event?: NDKEvent; |
||||
level: number; |
||||
kind: number; |
||||
title: string; |
||||
content: string; |
||||
author: string; |
||||
type: "Index" | "Content"; |
||||
naddr?: string; |
||||
nevent?: string; |
||||
x?: number; |
||||
y?: number; |
||||
isContainer?: boolean; |
||||
} |
||||
|
||||
export interface NetworkLink extends d3.SimulationLinkDatum<NetworkNode> { |
||||
source: NetworkNode; |
||||
target: NetworkNode; |
||||
isSequential: boolean; |
||||
} |
||||
|
||||
export interface GraphData { |
||||
nodes: NetworkNode[]; |
||||
links: NetworkLink[]; |
||||
} |
||||
|
||||
export interface GraphState { |
||||
nodeMap: Map<string, NetworkNode>; |
||||
links: NetworkLink[]; |
||||
eventMap: Map<string, NDKEvent>; |
||||
referencedIds: Set<string>; |
||||
} |
||||
@ -0,0 +1,136 @@
@@ -0,0 +1,136 @@
|
||||
/** |
||||
* D3 force simulation utilities for the event network |
||||
*/ |
||||
|
||||
import type { NetworkNode, NetworkLink } from "../types"; |
||||
import type { Simulation } from "d3"; |
||||
import * as d3 from "d3"; |
||||
|
||||
/** |
||||
* Updates a node's velocity |
||||
*/ |
||||
export function updateNodeVelocity( |
||||
node: NetworkNode, |
||||
deltaVx: number, |
||||
deltaVy: number |
||||
) { |
||||
if (typeof node.vx === "number" && typeof node.vy === "number") { |
||||
node.vx = node.vx - deltaVx; |
||||
node.vy = node.vy - deltaVy; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Applies a logarithmic gravity force to a node |
||||
*/ |
||||
export function applyGlobalLogGravity( |
||||
node: NetworkNode, |
||||
centerX: number, |
||||
centerY: number, |
||||
alpha: number, |
||||
) { |
||||
const dx = (node.x ?? 0) - centerX; |
||||
const dy = (node.y ?? 0) - centerY; |
||||
const distance = Math.sqrt(dx * dx + dy * dy); |
||||
|
||||
if (distance === 0) return; |
||||
|
||||
const force = Math.log(distance + 1) * 0.05 * alpha; |
||||
updateNodeVelocity(node, (dx / distance) * force, (dy / distance) * force); |
||||
} |
||||
|
||||
/** |
||||
* Applies gravity between connected nodes |
||||
*/ |
||||
export function applyConnectedGravity( |
||||
node: NetworkNode, |
||||
links: NetworkLink[], |
||||
alpha: number, |
||||
) { |
||||
const connectedNodes = links |
||||
.filter( |
||||
(link) => link.source.id === node.id || link.target.id === node.id, |
||||
) |
||||
.map((link) => (link.source.id === node.id ? link.target : link.source)); |
||||
|
||||
if (connectedNodes.length === 0) return; |
||||
|
||||
const cogX = d3.mean(connectedNodes, (n) => n.x); |
||||
const cogY = d3.mean(connectedNodes, (n) => n.y); |
||||
|
||||
if (cogX === undefined || cogY === undefined) return; |
||||
|
||||
const dx = (node.x ?? 0) - cogX; |
||||
const dy = (node.y ?? 0) - cogY; |
||||
const distance = Math.sqrt(dx * dx + dy * dy); |
||||
|
||||
if (distance === 0) return; |
||||
|
||||
const force = distance * 0.3 * alpha; |
||||
updateNodeVelocity(node, (dx / distance) * force, (dy / distance) * force); |
||||
} |
||||
|
||||
/** |
||||
* Sets up drag behavior for nodes |
||||
*/ |
||||
export function setupDragHandlers( |
||||
simulation: Simulation<NetworkNode, NetworkLink>, |
||||
warmupClickEnergy: number = 0.9 |
||||
) { |
||||
return d3 |
||||
.drag<SVGGElement, NetworkNode>() |
||||
.on( |
||||
"start", |
||||
( |
||||
event: d3.D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, |
||||
d: NetworkNode, |
||||
) => { |
||||
if (!event.active) |
||||
simulation.alphaTarget(warmupClickEnergy).restart(); |
||||
d.fx = d.x; |
||||
d.fy = d.y; |
||||
}, |
||||
) |
||||
.on( |
||||
"drag", |
||||
( |
||||
event: d3.D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, |
||||
d: NetworkNode, |
||||
) => { |
||||
d.fx = event.x; |
||||
d.fy = event.y; |
||||
}, |
||||
) |
||||
.on( |
||||
"end", |
||||
( |
||||
event: d3.D3DragEvent<SVGGElement, NetworkNode, NetworkNode>, |
||||
d: NetworkNode, |
||||
) => { |
||||
if (!event.active) simulation.alphaTarget(0); |
||||
d.fx = null; |
||||
d.fy = null; |
||||
}, |
||||
); |
||||
} |
||||
|
||||
/** |
||||
* Creates a D3 force simulation for the network |
||||
*/ |
||||
export function createSimulation( |
||||
nodes: NetworkNode[], |
||||
links: NetworkLink[], |
||||
nodeRadius: number, |
||||
linkDistance: number |
||||
) { |
||||
return d3 |
||||
.forceSimulation<NetworkNode>(nodes) |
||||
.force( |
||||
"link", |
||||
d3 |
||||
.forceLink<NetworkNode, NetworkLink>(links) |
||||
.id((d) => d.id) |
||||
.distance(linkDistance * 0.1), |
||||
) |
||||
.force("collide", d3.forceCollide<NetworkNode>().radius(nodeRadius * 4)); |
||||
} |
||||
@ -0,0 +1,195 @@
@@ -0,0 +1,195 @@
|
||||
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||
import type { NetworkNode, NetworkLink, GraphData, GraphState } from "../types"; |
||||
import { nip19 } from "nostr-tools"; |
||||
import { standardRelays } from "$lib/consts"; |
||||
|
||||
/** |
||||
* Creates a NetworkNode from an NDKEvent |
||||
*/ |
||||
export function createNetworkNode( |
||||
event: NDKEvent, |
||||
level: number = 0 |
||||
): NetworkNode { |
||||
const isContainer = event.kind === 30040; |
||||
|
||||
const node: NetworkNode = { |
||||
id: event.id, |
||||
event, |
||||
isContainer, |
||||
level, |
||||
title: event.getMatchingTags("title")?.[0]?.[1] || "Untitled", |
||||
content: event.content || "", |
||||
author: event.pubkey || "", |
||||
kind: event.kind, |
||||
type: event?.kind === 30040 ? "Index" : "Content", |
||||
}; |
||||
|
||||
if (event.kind && event.pubkey) { |
||||
try { |
||||
const dTag = event.getMatchingTags("d")?.[0]?.[1] || ""; |
||||
node.naddr = nip19.naddrEncode({ |
||||
pubkey: event.pubkey, |
||||
identifier: dTag, |
||||
kind: event.kind, |
||||
relays: standardRelays, |
||||
}); |
||||
|
||||
node.nevent = nip19.neventEncode({ |
||||
id: event.id, |
||||
relays: standardRelays, |
||||
kind: event.kind, |
||||
}); |
||||
} catch (error) { |
||||
console.warn("Failed to generate identifiers for node:", error); |
||||
} |
||||
} |
||||
|
||||
return node; |
||||
} |
||||
|
||||
export function createEventMap(events: NDKEvent[]): Map<string, NDKEvent> { |
||||
const eventMap = new Map<string, NDKEvent>(); |
||||
events.forEach((event) => { |
||||
if (event.id) { |
||||
eventMap.set(event.id, event); |
||||
} |
||||
}); |
||||
return eventMap; |
||||
} |
||||
|
||||
export function extractEventIdFromATag(tag: string[]): string | null { |
||||
return tag[3] || null; |
||||
} |
||||
|
||||
/** |
||||
* Generates a color for an event based on its ID |
||||
*/ |
||||
export function getEventColor(eventId: string): string { |
||||
const num = parseInt(eventId.slice(0, 4), 16); |
||||
const hue = num % 360; |
||||
const saturation = 70; |
||||
const lightness = 75; |
||||
return `hsl(${hue}, ${saturation}%, ${lightness}%)`; |
||||
} |
||||
|
||||
export function initializeGraphState(events: NDKEvent[]): GraphState { |
||||
const nodeMap = new Map<string, NetworkNode>(); |
||||
const eventMap = createEventMap(events); |
||||
|
||||
// Create initial nodes
|
||||
events.forEach((event) => { |
||||
if (!event.id) return; |
||||
const node = createNetworkNode(event); |
||||
nodeMap.set(event.id, node); |
||||
}); |
||||
|
||||
// Build referenced IDs set
|
||||
const referencedIds = new Set<string>(); |
||||
events.forEach((event) => { |
||||
event.getMatchingTags("a").forEach((tag) => { |
||||
const id = extractEventIdFromATag(tag); |
||||
if (id) referencedIds.add(id); |
||||
}); |
||||
}); |
||||
|
||||
return { |
||||
nodeMap, |
||||
links: [], |
||||
eventMap, |
||||
referencedIds, |
||||
}; |
||||
} |
||||
|
||||
export function processSequence( |
||||
sequence: NetworkNode[], |
||||
indexEvent: NDKEvent, |
||||
level: number, |
||||
state: GraphState, |
||||
maxLevel: number, |
||||
): void { |
||||
if (level >= maxLevel || sequence.length === 0) return; |
||||
|
||||
// Set levels for sequence nodes
|
||||
sequence.forEach((node) => { |
||||
node.level = level + 1; |
||||
}); |
||||
|
||||
// Create initial link from index to first content
|
||||
const indexNode = state.nodeMap.get(indexEvent.id); |
||||
if (indexNode && sequence[0]) { |
||||
state.links.push({ |
||||
source: indexNode, |
||||
target: sequence[0], |
||||
isSequential: true, |
||||
}); |
||||
} |
||||
|
||||
// Create sequential links
|
||||
for (let i = 0; i < sequence.length - 1; i++) { |
||||
const currentNode = sequence[i]; |
||||
const nextNode = sequence[i + 1]; |
||||
|
||||
state.links.push({ |
||||
source: currentNode, |
||||
target: nextNode, |
||||
isSequential: true, |
||||
}); |
||||
|
||||
processNestedIndex(currentNode, level + 1, state, maxLevel); |
||||
} |
||||
|
||||
// Process final node if it's an index
|
||||
const lastNode = sequence[sequence.length - 1]; |
||||
if (lastNode?.isContainer) { |
||||
processNestedIndex(lastNode, level + 1, state, maxLevel); |
||||
} |
||||
} |
||||
|
||||
export function processNestedIndex( |
||||
node: NetworkNode, |
||||
level: number, |
||||
state: GraphState, |
||||
maxLevel: number, |
||||
): void { |
||||
if (!node.isContainer || level >= maxLevel) return; |
||||
|
||||
const nestedEvent = state.eventMap.get(node.id); |
||||
if (nestedEvent) { |
||||
processIndexEvent(nestedEvent, level, state, maxLevel); |
||||
} |
||||
} |
||||
|
||||
export function processIndexEvent( |
||||
indexEvent: NDKEvent, |
||||
level: number, |
||||
state: GraphState, |
||||
maxLevel: number, |
||||
): void { |
||||
if (level >= maxLevel) return; |
||||
|
||||
const sequence = indexEvent |
||||
.getMatchingTags("a") |
||||
.map((tag) => extractEventIdFromATag(tag)) |
||||
.filter((id): id is string => id !== null) |
||||
.map((id) => state.nodeMap.get(id)) |
||||
.filter((node): node is NetworkNode => node !== undefined); |
||||
|
||||
processSequence(sequence, indexEvent, level, state, maxLevel); |
||||
} |
||||
|
||||
export function generateGraph( |
||||
events: NDKEvent[], |
||||
maxLevel: number |
||||
): GraphData { |
||||
const state = initializeGraphState(events); |
||||
|
||||
// Process root indices
|
||||
events |
||||
.filter((e) => e.kind === 30040 && e.id && !state.referencedIds.has(e.id)) |
||||
.forEach((rootIndex) => processIndexEvent(rootIndex, 0, state, maxLevel)); |
||||
|
||||
return { |
||||
nodes: Array.from(state.nodeMap.values()), |
||||
links: state.links, |
||||
}; |
||||
} |
||||
@ -1,35 +1,245 @@
@@ -1,35 +1,245 @@
|
||||
import { browser } from '$app/environment'; |
||||
import NDK from '@nostr-dev-kit/ndk'; |
||||
import NDKCacheAdapterDexie from '@nostr-dev-kit/ndk-cache-dexie'; |
||||
import { writable, type Writable } from 'svelte/store'; |
||||
import { standardRelays } from './consts'; |
||||
|
||||
export function getStoredNdkConfig() { |
||||
const relays = JSON.parse( |
||||
(browser && localStorage.getItem('alexandria_relays')) || JSON.stringify(standardRelays) |
||||
import NDK, { NDKNip07Signer, NDKRelay, NDKRelayAuthPolicies, NDKRelaySet, NDKUser } from '@nostr-dev-kit/ndk'; |
||||
import { get, writable, type Writable } from 'svelte/store'; |
||||
import { bootstrapRelays, FeedType, loginStorageKey, standardRelays } from './consts'; |
||||
import { feedType } from './stores'; |
||||
|
||||
export const ndkInstance: Writable<NDK> = writable(); |
||||
|
||||
export const ndkSignedIn: Writable<boolean> = writable(false); |
||||
|
||||
export const activePubkey: Writable<string | null> = writable(null); |
||||
|
||||
export const inboxRelays: Writable<string[]> = writable([]); |
||||
export const outboxRelays: Writable<string[]> = writable([]); |
||||
|
||||
/** |
||||
* Gets the user's pubkey from local storage, if it exists. |
||||
* @returns The user's pubkey, or null if there is no logged-in user. |
||||
* @remarks Local storage is used in place of cookies to persist the user's login across browser |
||||
* sessions. |
||||
*/ |
||||
export function getPersistedLogin(): string | null { |
||||
const pubkey = localStorage.getItem(loginStorageKey); |
||||
return pubkey; |
||||
} |
||||
|
||||
/** |
||||
* Writes the user's pubkey to local storage. |
||||
* @param user The user to persist. |
||||
* @remarks Use this function when the user logs in. Currently, only one pubkey is stored at a |
||||
* time. |
||||
*/ |
||||
export function persistLogin(user: NDKUser): void { |
||||
localStorage.setItem(loginStorageKey, user.pubkey); |
||||
} |
||||
|
||||
/** |
||||
* Clears the user's pubkey from local storage. |
||||
* @remarks Use this function when the user logs out. |
||||
*/ |
||||
export function clearLogin(): void { |
||||
localStorage.removeItem(loginStorageKey); |
||||
} |
||||
|
||||
/** |
||||
* Constructs a key use to designate a user's relay lists in local storage. |
||||
* @param user The user for whom to construct the key. |
||||
* @param type The type of relay list to designate. |
||||
* @returns The constructed key. |
||||
*/ |
||||
function getRelayStorageKey(user: NDKUser, type: 'inbox' | 'outbox'): string { |
||||
return `${loginStorageKey}/${user.pubkey}/${type}`; |
||||
} |
||||
|
||||
/** |
||||
* Stores the user's relay lists in local storage. |
||||
* @param user The user for whom to store the relay lists. |
||||
* @param inboxes The user's inbox relays. |
||||
* @param outboxes The user's outbox relays. |
||||
*/ |
||||
function persistRelays(user: NDKUser, inboxes: Set<NDKRelay>, outboxes: Set<NDKRelay>): void { |
||||
localStorage.setItem( |
||||
getRelayStorageKey(user, 'inbox'), |
||||
JSON.stringify(Array.from(inboxes).map(relay => relay.url)) |
||||
); |
||||
localStorage.setItem( |
||||
getRelayStorageKey(user, 'outbox'),
|
||||
JSON.stringify(Array.from(outboxes).map(relay => relay.url)) |
||||
); |
||||
} |
||||
|
||||
/** |
||||
* Retrieves the user's relay lists from local storage. |
||||
* @param user The user for whom to retrieve the relay lists. |
||||
* @returns A tuple of relay sets of the form `[inboxRelays, outboxRelays]`. Either set may be |
||||
* empty if no relay lists were stored for the user. |
||||
*/ |
||||
function getPersistedRelays(user: NDKUser): [Set<string>, Set<string>] { |
||||
const inboxes = new Set<string>( |
||||
JSON.parse(localStorage.getItem(getRelayStorageKey(user, 'inbox')) ?? '[]') |
||||
); |
||||
const outboxes = new Set<string>( |
||||
JSON.parse(localStorage.getItem(getRelayStorageKey(user, 'outbox')) ?? '[]') |
||||
); |
||||
|
||||
return [inboxes, outboxes]; |
||||
} |
||||
|
||||
// const dexieAdapter = new NDKCacheAdapterDexie({ dbName: 'alexandria-ndk-cache-db' });
|
||||
export function clearPersistedRelays(user: NDKUser): void { |
||||
localStorage.removeItem(getRelayStorageKey(user, 'inbox')); |
||||
localStorage.removeItem(getRelayStorageKey(user, 'outbox'));
|
||||
} |
||||
|
||||
return { |
||||
relays, |
||||
// dexieAdapter,
|
||||
}; |
||||
export function getActiveRelays(ndk: NDK): NDKRelaySet { |
||||
return get(feedType) === FeedType.UserRelays |
||||
? new NDKRelaySet( |
||||
new Set(get(inboxRelays).map(relay => new NDKRelay( |
||||
relay, |
||||
NDKRelayAuthPolicies.signIn({ ndk }), |
||||
ndk, |
||||
))), |
||||
ndk |
||||
) |
||||
: new NDKRelaySet( |
||||
new Set(standardRelays.map(relay => new NDKRelay( |
||||
relay, |
||||
NDKRelayAuthPolicies.signIn({ ndk }), |
||||
ndk, |
||||
))), |
||||
ndk |
||||
); |
||||
} |
||||
|
||||
export function getNdkInstance() { |
||||
const { relays } = getStoredNdkConfig(); |
||||
/** |
||||
* Initializes an instance of NDK, and connects it to the logged-in user's preferred relay set |
||||
* (if available), or to Alexandria's standard relay set. |
||||
* @returns The initialized NDK instance. |
||||
*/ |
||||
export function initNdk(): NDK { |
||||
const startingPubkey = getPersistedLogin(); |
||||
const [startingInboxes, _] = startingPubkey != null |
||||
? getPersistedRelays(new NDKUser({ pubkey: startingPubkey })) |
||||
: [null, null]; |
||||
|
||||
const ndk = new NDK({ |
||||
autoConnectUserRelays: true, |
||||
enableOutboxModel: true, |
||||
explicitRelayUrls: relays, |
||||
explicitRelayUrls: startingInboxes != null |
||||
? Array.from(startingInboxes.values()) |
||||
: standardRelays, |
||||
}); |
||||
ndk.connect().then(() => console.debug('ndk connected')); |
||||
|
||||
// TODO: Should we prompt the user to confirm authentication?
|
||||
ndk.relayAuthDefaultPolicy = NDKRelayAuthPolicies.signIn({ ndk }); |
||||
ndk.connect().then(() => console.debug("ndk connected")); |
||||
return ndk; |
||||
} |
||||
|
||||
export const ndk: Writable<NDK> = writable(getNdkInstance()); |
||||
/** |
||||
* Signs in with a NIP-07 browser extension, and determines the user's preferred inbox and outbox |
||||
* relays. |
||||
* @returns The user's profile, if it is available. |
||||
* @throws If sign-in fails. This may because there is no accessible NIP-07 extension, or because |
||||
* NDK is unable to fetch the user's profile or relay lists. |
||||
*/ |
||||
export async function loginWithExtension(pubkey?: string): Promise<NDKUser | null> { |
||||
try { |
||||
const ndk = get(ndkInstance); |
||||
const signer = new NDKNip07Signer(); |
||||
const signerUser = await signer.user(); |
||||
|
||||
// TODO: Handle changing pubkeys.
|
||||
if (pubkey && signerUser.pubkey !== pubkey) { |
||||
console.debug('Switching pubkeys from last login.'); |
||||
} |
||||
|
||||
activePubkey.set(signerUser.pubkey); |
||||
|
||||
const [persistedInboxes, persistedOutboxes] = getPersistedRelays(signerUser); |
||||
for (const relay of persistedInboxes) { |
||||
ndk.addExplicitRelay(relay); |
||||
} |
||||
|
||||
const user = ndk.getUser({ pubkey: signerUser.pubkey }); |
||||
const [inboxes, outboxes] = await getUserPreferredRelays(ndk, user); |
||||
|
||||
inboxRelays.set(Array.from(inboxes ?? persistedInboxes).map(relay => relay.url)); |
||||
outboxRelays.set(Array.from(outboxes ?? persistedOutboxes).map(relay => relay.url)); |
||||
|
||||
persistRelays(signerUser, inboxes, outboxes); |
||||
|
||||
ndk.signer = signer; |
||||
ndk.activeUser = user; |
||||
|
||||
ndkInstance.set(ndk); |
||||
ndkSignedIn.set(true); |
||||
|
||||
export const signedIn: Writable<boolean> = writable(false); |
||||
return user; |
||||
} catch (e) { |
||||
throw new Error(`Failed to sign in with NIP-07 extension: ${e}`); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Handles logging out a user. |
||||
* @param user The user to log out. |
||||
*/ |
||||
export function logout(user: NDKUser): void { |
||||
clearLogin(); |
||||
clearPersistedRelays(user); |
||||
activePubkey.set(null); |
||||
ndkSignedIn.set(false); |
||||
} |
||||
|
||||
/** |
||||
* Fetches the user's NIP-65 relay list, if one can be found, and returns the inbox and outbox |
||||
* relay sets. |
||||
* @returns A tuple of relay sets of the form `[inboxRelays, outboxRelays]`. |
||||
*/ |
||||
async function getUserPreferredRelays( |
||||
ndk: NDK, |
||||
user: NDKUser, |
||||
bootstraps: readonly string[] = bootstrapRelays |
||||
): Promise<[Set<NDKRelay>, Set<NDKRelay>]> { |
||||
const relayList = await ndk.fetchEvent( |
||||
{ |
||||
kinds: [10002], |
||||
authors: [user.pubkey], |
||||
}, |
||||
{
|
||||
groupable: false, |
||||
skipVerification: false, |
||||
skipValidation: false, |
||||
}, |
||||
NDKRelaySet.fromRelayUrls(bootstraps, ndk), |
||||
); |
||||
|
||||
const inboxRelays = new Set<NDKRelay>(); |
||||
const outboxRelays = new Set<NDKRelay>(); |
||||
|
||||
if (relayList == null) { |
||||
const relayMap = await window.nostr?.getRelays?.(); |
||||
Object.entries(relayMap ?? {}).forEach(([url, relayType]) => { |
||||
const relay = new NDKRelay(url, NDKRelayAuthPolicies.signIn({ ndk }), ndk); |
||||
if (relayType.read) inboxRelays.add(relay); |
||||
if (relayType.write) outboxRelays.add(relay); |
||||
}); |
||||
} else { |
||||
relayList.tags.forEach(tag => { |
||||
switch (tag[0]) { |
||||
case 'r': |
||||
inboxRelays.add(new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk)); |
||||
break; |
||||
case 'w': |
||||
outboxRelays.add(new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk)); |
||||
break; |
||||
default: |
||||
inboxRelays.add(new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk)); |
||||
outboxRelays.add(new NDKRelay(tag[1], NDKRelayAuthPolicies.signIn({ ndk }), ndk)); |
||||
break; |
||||
} |
||||
}); |
||||
} |
||||
|
||||
return [inboxRelays, outboxRelays]; |
||||
} |
||||
|
||||
@ -1,13 +1,15 @@
@@ -1,13 +1,15 @@
|
||||
import { browser } from '$app/environment'; |
||||
import { writable, type Writable } from 'svelte/store'; |
||||
import type { Tab } from './types'; |
||||
import { browser } from "$app/environment"; |
||||
import { writable, type Writable } from "svelte/store"; |
||||
import type { Tab } from "./types"; |
||||
|
||||
export const pathLoaded: Writable<boolean> = writable(false); |
||||
|
||||
export const tabs: Writable<Tab[]> = writable([{ id: 0, type: 'welcome' }]); |
||||
export const tabs: Writable<Tab[]> = writable([{ id: 0, type: "welcome" }]); |
||||
export const tabBehaviour: Writable<string> = writable( |
||||
(browser && localStorage.getItem('wikinostr_tabBehaviour')) || 'normal' |
||||
(browser && localStorage.getItem("wikinostr_tabBehaviour")) || "normal", |
||||
); |
||||
export const userPublickey: Writable<string> = writable( |
||||
(browser && localStorage.getItem('wikinostr_loggedInPublicKey')) || '' |
||||
(browser && localStorage.getItem("wikinostr_loggedInPublicKey")) || "", |
||||
); |
||||
export const networkFetchLimit: Writable<number> = writable(5); |
||||
export const levelsToRender: Writable<number> = writable(3); |
||||
|
||||
@ -1,155 +1,48 @@
@@ -1,155 +1,48 @@
|
||||
<script lang='ts'> |
||||
import ArticleHeader from '$lib/components/ArticleHeader.svelte'; |
||||
import { FeedType, indexKind, standardRelays } from '$lib/consts'; |
||||
import { filterValidIndexEvents } from '$lib/utils'; |
||||
import NDK, { NDKEvent, NDKRelaySet, type NDKUser } from '@nostr-dev-kit/ndk'; |
||||
import { Button, Dropdown, Radio, Skeleton } from 'flowbite-svelte'; |
||||
import { FeedType, feedTypeStorageKey, standardRelays } from '$lib/consts'; |
||||
import { Button, Dropdown, Radio } from 'flowbite-svelte'; |
||||
import { ChevronDownOutline } from 'flowbite-svelte-icons'; |
||||
import type { PageData } from './$types'; |
||||
import { setContext } from 'svelte'; |
||||
|
||||
let { data }: { data: PageData } = $props(); |
||||
let ndk: NDK = data.ndk; |
||||
|
||||
let user: NDKUser | null | undefined = $state(ndk.activeUser); |
||||
let readRelays: string[] | null | undefined = $state(user?.relayUrls); |
||||
let userFollows: Set<NDKUser> | null | undefined = $state(null); |
||||
let feedType: FeedType = $state(FeedType.Relays); |
||||
import { inboxRelays, ndkSignedIn } from '$lib/ndk'; |
||||
import PublicationFeed from '$lib/components/PublicationFeed.svelte'; |
||||
import { feedType } from '$lib/stores'; |
||||
|
||||
$effect(() => { |
||||
if (user) { |
||||
user.follows().then(follows => userFollows = follows); |
||||
} |
||||
localStorage.setItem(feedTypeStorageKey, $feedType); |
||||
}); |
||||
|
||||
const getEvents = (): Promise<Set<NDKEvent>> => |
||||
// @ts-ignore |
||||
ndk.fetchEvents( |
||||
{ kinds: [indexKind] }, |
||||
{ |
||||
groupable: true, |
||||
skipVerification: false, |
||||
skipValidation: false |
||||
}, |
||||
NDKRelaySet.fromRelayUrls(standardRelays, ndk) |
||||
).then(filterValidIndexEvents); |
||||
|
||||
const getEventsFromUserRelays = (userRelays: string[]): Promise<Set<NDKEvent>> => { |
||||
return ndk |
||||
.fetchEvents( |
||||
// @ts-ignore |
||||
{ kinds: [indexKind] }, |
||||
{ |
||||
closeOnEose: true, |
||||
groupable: true, |
||||
skipVerification: false, |
||||
skipValidation: false, |
||||
}, |
||||
) |
||||
.then(filterValidIndexEvents); |
||||
} |
||||
|
||||
const getEventsFromUserFollows = (follows: Set<NDKUser>, userRelays?: string[]): Promise<Set<NDKEvent>> => { |
||||
return ndk |
||||
.fetchEvents( |
||||
{ |
||||
authors: Array.from(follows ?? []).map(user => user.pubkey), |
||||
// @ts-ignore |
||||
kinds: [indexKind] |
||||
}, |
||||
{ |
||||
groupable: true, |
||||
skipVerification: false, |
||||
skipValidation: false |
||||
}, |
||||
) |
||||
.then(filterValidIndexEvents); |
||||
} |
||||
|
||||
const getFeedTypeFriendlyName = (feedType: FeedType): string => { |
||||
switch (feedType) { |
||||
case FeedType.Relays: |
||||
return 'Relays'; |
||||
case FeedType.Follows: |
||||
return 'Follows'; |
||||
case FeedType.StandardRelays: |
||||
return `Alexandria's Relays`; |
||||
case FeedType.UserRelays: |
||||
return `Your Relays`; |
||||
default: |
||||
return ''; |
||||
} |
||||
}; |
||||
|
||||
const getSkeletonIds = (): string[] => { |
||||
const skeletonHeight = 124; // The height of the skeleton component in pixels. |
||||
|
||||
// Determine the number of skeletons to display based on the height of the screen. |
||||
const skeletonCount = Math.floor(window.innerHeight / skeletonHeight) - 2; |
||||
|
||||
const skeletonIds = []; |
||||
for (let i = 0; i < skeletonCount; i++) { |
||||
skeletonIds.push(`skeleton-${i}`); |
||||
} |
||||
return skeletonIds; |
||||
} |
||||
</script> |
||||
|
||||
<div class='leather flex flex-col flex-grow-0 space-y-4 overflow-y-auto w-max p-2'> |
||||
{#key user} |
||||
{#if user == null || readRelays == null} |
||||
{#await getEvents()} |
||||
{#each getSkeletonIds() as id} |
||||
<Skeleton size='lg' /> |
||||
{/each} |
||||
{:then events} |
||||
{#if events.size > 0} |
||||
{#each Array.from(events) as event} |
||||
<ArticleHeader {event} /> |
||||
{/each} |
||||
{:else} |
||||
<p class='text-center'>No articles found.</p> |
||||
{/if} |
||||
{/await} |
||||
{:else} |
||||
<div class='leather w-full flex justify-end'> |
||||
<Button> |
||||
{`Showing articles from: ${getFeedTypeFriendlyName(feedType)}`}<ChevronDownOutline class='w-6 h-6' /> |
||||
</Button> |
||||
<Dropdown class='w-fit p-2 space-y-2 text-sm'> |
||||
<li> |
||||
<Radio name='relays' bind:group={feedType} value={FeedType.Relays}>Relays</Radio> |
||||
</li> |
||||
<li> |
||||
<Radio name='follows' bind:group={feedType} value={FeedType.Follows}>Follows</Radio> |
||||
</li> |
||||
</Dropdown> |
||||
</div> |
||||
{#if feedType === FeedType.Relays && readRelays != null} |
||||
{#await getEventsFromUserRelays(readRelays)} |
||||
{#each getSkeletonIds() as id} |
||||
<Skeleton size='lg' /> |
||||
{/each} |
||||
{:then events} |
||||
{#if events.size > 0} |
||||
{#each Array.from(events) as event} |
||||
<ArticleHeader {event} /> |
||||
{/each} |
||||
{:else} |
||||
<p class='text-center'>No articles found.</p> |
||||
{/if} |
||||
{/await} |
||||
{:else if feedType === FeedType.Follows && userFollows != null} |
||||
{#await getEventsFromUserFollows(userFollows, readRelays)} |
||||
{#each getSkeletonIds() as id} |
||||
<Skeleton size='lg' /> |
||||
{/each} |
||||
{:then events} |
||||
{#if events.size > 0} |
||||
{#each Array.from(events) as event} |
||||
<ArticleHeader {event} /> |
||||
{/each} |
||||
{:else} |
||||
<p class='text-center'>No articles found.</p> |
||||
{/if} |
||||
{/await} |
||||
{/if} |
||||
{#if !$ndkSignedIn} |
||||
<PublicationFeed relays={standardRelays} /> |
||||
{:else} |
||||
<div class='leather w-full flex justify-end'> |
||||
<Button> |
||||
{`Showing articles from: ${getFeedTypeFriendlyName($feedType)}`}<ChevronDownOutline class='w-6 h-6' /> |
||||
</Button> |
||||
<Dropdown class='w-fit p-2 space-y-2 text-sm'> |
||||
<li> |
||||
<Radio name='relays' bind:group={$feedType} value={FeedType.StandardRelays}>Alexandria's Relays</Radio> |
||||
</li> |
||||
<li> |
||||
<Radio name='follows' bind:group={$feedType} value={FeedType.UserRelays}>Your Relays</Radio> |
||||
</li> |
||||
</Dropdown> |
||||
</div> |
||||
{#if $feedType === FeedType.StandardRelays} |
||||
<PublicationFeed relays={standardRelays} /> |
||||
{:else if $feedType === FeedType.UserRelays} |
||||
<PublicationFeed relays={$inboxRelays} /> |
||||
{/if} |
||||
{/key} |
||||
{/if} |
||||
</div> |
||||
|
||||
Loading…
Reference in new issue