44 changed files with 2260 additions and 1880 deletions
|
Before Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 99 KiB |
@ -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 @@ |
|||||||
<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 @@ |
|||||||
<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 @@ |
|||||||
<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 @@ |
|||||||
<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 @@ |
|||||||
<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 @@ |
|||||||
|
<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 @@ |
|||||||
<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 @@ |
|||||||
|
<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 @@ |
|||||||
<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 @@ |
|||||||
|
<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 @@ |
|||||||
<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 @@ |
|||||||
export const wikiKind = 30818; |
export const wikiKind = 30818; |
||||||
export const indexKind = 30040; |
export const indexKind = 30040; |
||||||
export const zettelKinds = [ 30041 ]; |
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 { |
export enum FeedType { |
||||||
Relays, |
StandardRelays = 'standard', |
||||||
Follows, |
UserRelays = 'user', |
||||||
} |
} |
||||||
|
|
||||||
|
export const loginStorageKey = 'alexandria/login/pubkey'; |
||||||
|
export const feedTypeStorageKey = 'alexandria/feed/type'; |
||||||
|
|||||||
@ -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 @@ |
|||||||
|
<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 @@ |
|||||||
|
<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 @@ |
|||||||
|
<!-- 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 @@ |
|||||||
|
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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
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 @@ |
|||||||
import { browser } from '$app/environment'; |
import NDK, { NDKNip07Signer, NDKRelay, NDKRelayAuthPolicies, NDKRelaySet, NDKUser } from '@nostr-dev-kit/ndk'; |
||||||
import NDK from '@nostr-dev-kit/ndk'; |
import { get, writable, type Writable } from 'svelte/store'; |
||||||
import NDKCacheAdapterDexie from '@nostr-dev-kit/ndk-cache-dexie'; |
import { bootstrapRelays, FeedType, loginStorageKey, standardRelays } from './consts'; |
||||||
import { writable, type Writable } from 'svelte/store'; |
import { feedType } from './stores'; |
||||||
import { standardRelays } from './consts'; |
|
||||||
|
export const ndkInstance: Writable<NDK> = writable(); |
||||||
export function getStoredNdkConfig() { |
|
||||||
const relays = JSON.parse( |
export const ndkSignedIn: Writable<boolean> = writable(false); |
||||||
(browser && localStorage.getItem('alexandria_relays')) || JSON.stringify(standardRelays) |
|
||||||
|
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)) |
||||||
); |
); |
||||||
|
} |
||||||
|
|
||||||
// const dexieAdapter = new NDKCacheAdapterDexie({ dbName: 'alexandria-ndk-cache-db' });
|
/** |
||||||
|
* 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 { |
return [inboxes, outboxes]; |
||||||
relays, |
|
||||||
// dexieAdapter,
|
|
||||||
}; |
|
||||||
} |
} |
||||||
|
|
||||||
export function getNdkInstance() { |
export function clearPersistedRelays(user: NDKUser): void { |
||||||
const { relays } = getStoredNdkConfig(); |
localStorage.removeItem(getRelayStorageKey(user, 'inbox')); |
||||||
|
localStorage.removeItem(getRelayStorageKey(user, 'outbox'));
|
||||||
|
} |
||||||
|
|
||||||
|
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 |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 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({ |
const ndk = new NDK({ |
||||||
autoConnectUserRelays: true, |
autoConnectUserRelays: true, |
||||||
enableOutboxModel: 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; |
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); |
||||||
|
|
||||||
|
return user; |
||||||
|
} catch (e) { |
||||||
|
throw new Error(`Failed to sign in with NIP-07 extension: ${e}`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
export const signedIn: Writable<boolean> = writable(false); |
/** |
||||||
|
* 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 @@ |
|||||||
import { browser } from '$app/environment'; |
import { browser } from "$app/environment"; |
||||||
import { writable, type Writable } from 'svelte/store'; |
import { writable, type Writable } from "svelte/store"; |
||||||
import type { Tab } from './types'; |
import type { Tab } from "./types"; |
||||||
|
|
||||||
export const pathLoaded: Writable<boolean> = writable(false); |
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( |
export const tabBehaviour: Writable<string> = writable( |
||||||
(browser && localStorage.getItem('wikinostr_tabBehaviour')) || 'normal' |
(browser && localStorage.getItem("wikinostr_tabBehaviour")) || "normal", |
||||||
); |
); |
||||||
export const userPublickey: Writable<string> = writable( |
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 @@ |
|||||||
<script lang='ts'> |
<script lang='ts'> |
||||||
import ArticleHeader from '$lib/components/ArticleHeader.svelte'; |
import { FeedType, feedTypeStorageKey, standardRelays } from '$lib/consts'; |
||||||
import { FeedType, indexKind, standardRelays } from '$lib/consts'; |
import { Button, Dropdown, Radio } from 'flowbite-svelte'; |
||||||
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 { ChevronDownOutline } from 'flowbite-svelte-icons'; |
import { ChevronDownOutline } from 'flowbite-svelte-icons'; |
||||||
import type { PageData } from './$types'; |
import { inboxRelays, ndkSignedIn } from '$lib/ndk'; |
||||||
import { setContext } from 'svelte'; |
import PublicationFeed from '$lib/components/PublicationFeed.svelte'; |
||||||
|
import { feedType } from '$lib/stores'; |
||||||
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); |
|
||||||
|
|
||||||
$effect(() => { |
$effect(() => { |
||||||
if (user) { |
localStorage.setItem(feedTypeStorageKey, $feedType); |
||||||
user.follows().then(follows => userFollows = follows); |
|
||||||
} |
|
||||||
}); |
}); |
||||||
|
|
||||||
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 => { |
const getFeedTypeFriendlyName = (feedType: FeedType): string => { |
||||||
switch (feedType) { |
switch (feedType) { |
||||||
case FeedType.Relays: |
case FeedType.StandardRelays: |
||||||
return 'Relays'; |
return `Alexandria's Relays`; |
||||||
case FeedType.Follows: |
case FeedType.UserRelays: |
||||||
return 'Follows'; |
return `Your Relays`; |
||||||
default: |
default: |
||||||
return ''; |
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> |
</script> |
||||||
|
|
||||||
<div class='leather flex flex-col flex-grow-0 space-y-4 overflow-y-auto w-max p-2'> |
<div class='leather flex flex-col flex-grow-0 space-y-4 overflow-y-auto w-max p-2'> |
||||||
{#key user} |
{#if !$ndkSignedIn} |
||||||
{#if user == null || readRelays == null} |
<PublicationFeed relays={standardRelays} /> |
||||||
{#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} |
{:else} |
||||||
<div class='leather w-full flex justify-end'> |
<div class='leather w-full flex justify-end'> |
||||||
<Button> |
<Button> |
||||||
{`Showing articles from: ${getFeedTypeFriendlyName(feedType)}`}<ChevronDownOutline class='w-6 h-6' /> |
{`Showing articles from: ${getFeedTypeFriendlyName($feedType)}`}<ChevronDownOutline class='w-6 h-6' /> |
||||||
</Button> |
</Button> |
||||||
<Dropdown class='w-fit p-2 space-y-2 text-sm'> |
<Dropdown class='w-fit p-2 space-y-2 text-sm'> |
||||||
<li> |
<li> |
||||||
<Radio name='relays' bind:group={feedType} value={FeedType.Relays}>Relays</Radio> |
<Radio name='relays' bind:group={$feedType} value={FeedType.StandardRelays}>Alexandria's Relays</Radio> |
||||||
</li> |
</li> |
||||||
<li> |
<li> |
||||||
<Radio name='follows' bind:group={feedType} value={FeedType.Follows}>Follows</Radio> |
<Radio name='follows' bind:group={$feedType} value={FeedType.UserRelays}>Your Relays</Radio> |
||||||
</li> |
</li> |
||||||
</Dropdown> |
</Dropdown> |
||||||
</div> |
</div> |
||||||
{#if feedType === FeedType.Relays && readRelays != null} |
{#if $feedType === FeedType.StandardRelays} |
||||||
{#await getEventsFromUserRelays(readRelays)} |
<PublicationFeed relays={standardRelays} /> |
||||||
{#each getSkeletonIds() as id} |
{:else if $feedType === FeedType.UserRelays} |
||||||
<Skeleton size='lg' /> |
<PublicationFeed relays={$inboxRelays} /> |
||||||
{/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} |
||||||
{/if} |
{/if} |
||||||
{/key} |
|
||||||
</div> |
</div> |
||||||
|
|||||||
Loading…
Reference in new issue