73 changed files with 4262 additions and 2483 deletions
@ -1,6 +1,14 @@ |
|||||||
{ |
{ |
||||||
"editor.tabSize": 2, |
"css.validate": false, |
||||||
"files.associations": { |
"tailwindCSS.includeLanguages": { |
||||||
"*.css": "postcss" |
"svelte": "html", |
||||||
} |
"typescript": "javascript", |
||||||
|
"javascript": "javascript" |
||||||
|
}, |
||||||
|
"editor.quickSuggestions": { |
||||||
|
"strings": true |
||||||
|
}, |
||||||
|
"files.associations": { |
||||||
|
"*.svelte": "svelte" |
||||||
|
} |
||||||
} |
} |
||||||
@ -0,0 +1,317 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { Button, Textarea, Alert } from 'flowbite-svelte'; |
||||||
|
import { parseBasicmarkup } from '$lib/utils/markup/basicMarkupParser'; |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
import { getEventHash, signEvent, getUserMetadata, type NostrProfile } from '$lib/utils/nostrUtils'; |
||||||
|
import { standardRelays, fallbackRelays } from '$lib/consts'; |
||||||
|
import { userRelays } from '$lib/stores/relayStore'; |
||||||
|
import { get } from 'svelte/store'; |
||||||
|
import { goto } from '$app/navigation'; |
||||||
|
import type { NDKEvent } from '$lib/utils/nostrUtils'; |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
|
||||||
|
const props = $props<{ |
||||||
|
event: NDKEvent; |
||||||
|
userPubkey: string; |
||||||
|
userRelayPreference: boolean; |
||||||
|
}>(); |
||||||
|
|
||||||
|
let content = $state(''); |
||||||
|
let preview = $state(''); |
||||||
|
let isSubmitting = $state(false); |
||||||
|
let success = $state<{ relay: string; eventId: string } | null>(null); |
||||||
|
let error = $state<string | null>(null); |
||||||
|
let showOtherRelays = $state(false); |
||||||
|
let showFallbackRelays = $state(false); |
||||||
|
let userProfile = $state<NostrProfile | null>(null); |
||||||
|
|
||||||
|
// Fetch user profile on mount |
||||||
|
onMount(async () => { |
||||||
|
if (props.userPubkey) { |
||||||
|
const npub = nip19.npubEncode(props.userPubkey); |
||||||
|
userProfile = await getUserMetadata(npub); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
// Markup buttons |
||||||
|
const markupButtons = [ |
||||||
|
{ label: 'Bold', action: () => insertMarkup('**', '**') }, |
||||||
|
{ label: 'Italic', action: () => insertMarkup('_', '_') }, |
||||||
|
{ label: 'Strike', action: () => insertMarkup('~~', '~~') }, |
||||||
|
{ label: 'Link', action: () => insertMarkup('[', '](url)') }, |
||||||
|
{ label: 'Image', action: () => insertMarkup('') }, |
||||||
|
{ label: 'Quote', action: () => insertMarkup('> ', '') }, |
||||||
|
{ label: 'List', action: () => insertMarkup('- ', '') }, |
||||||
|
{ label: 'Numbered List', action: () => insertMarkup('1. ', '') }, |
||||||
|
{ label: 'Hashtag', action: () => insertMarkup('#', '') } |
||||||
|
]; |
||||||
|
|
||||||
|
function insertMarkup(prefix: string, suffix: string) { |
||||||
|
const textarea = document.querySelector('textarea'); |
||||||
|
if (!textarea) return; |
||||||
|
|
||||||
|
const start = textarea.selectionStart; |
||||||
|
const end = textarea.selectionEnd; |
||||||
|
const selectedText = content.substring(start, end); |
||||||
|
|
||||||
|
content = content.substring(0, start) + prefix + selectedText + suffix + content.substring(end); |
||||||
|
updatePreview(); |
||||||
|
|
||||||
|
// Set cursor position after the inserted markup |
||||||
|
setTimeout(() => { |
||||||
|
textarea.focus(); |
||||||
|
textarea.selectionStart = textarea.selectionEnd = start + prefix.length + selectedText.length + suffix.length; |
||||||
|
}, 0); |
||||||
|
} |
||||||
|
|
||||||
|
async function updatePreview() { |
||||||
|
preview = await parseBasicmarkup(content); |
||||||
|
} |
||||||
|
|
||||||
|
function clearForm() { |
||||||
|
content = ''; |
||||||
|
preview = ''; |
||||||
|
error = null; |
||||||
|
success = null; |
||||||
|
showOtherRelays = false; |
||||||
|
showFallbackRelays = false; |
||||||
|
} |
||||||
|
|
||||||
|
function removeFormatting() { |
||||||
|
content = content |
||||||
|
.replace(/\*\*(.*?)\*\*/g, '$1') |
||||||
|
.replace(/_(.*?)_/g, '$1') |
||||||
|
.replace(/~~(.*?)~~/g, '$1') |
||||||
|
.replace(/\[(.*?)\]\(.*?\)/g, '$1') |
||||||
|
.replace(/!\[(.*?)\]\(.*?\)/g, '$1') |
||||||
|
.replace(/^>\s*/gm, '') |
||||||
|
.replace(/^[-*]\s*/gm, '') |
||||||
|
.replace(/^\d+\.\s*/gm, '') |
||||||
|
.replace(/#(\w+)/g, '$1'); |
||||||
|
updatePreview(); |
||||||
|
} |
||||||
|
|
||||||
|
async function handleSubmit(useOtherRelays = false, useFallbackRelays = false) { |
||||||
|
isSubmitting = true; |
||||||
|
error = null; |
||||||
|
success = null; |
||||||
|
|
||||||
|
try { |
||||||
|
if (!props.event.kind) { |
||||||
|
throw new Error('Invalid event: missing kind'); |
||||||
|
} |
||||||
|
|
||||||
|
const kind = props.event.kind === 1 ? 1 : 1111; |
||||||
|
const tags: string[][] = []; |
||||||
|
|
||||||
|
if (kind === 1) { |
||||||
|
// NIP-10 reply |
||||||
|
tags.push(['e', props.event.id, '', 'reply']); |
||||||
|
tags.push(['p', props.event.pubkey]); |
||||||
|
if (props.event.tags) { |
||||||
|
const rootTag = props.event.tags.find((t: string[]) => t[0] === 'e' && t[3] === 'root'); |
||||||
|
if (rootTag) { |
||||||
|
tags.push(['e', rootTag[1], '', 'root']); |
||||||
|
} |
||||||
|
// Add all p tags from the parent event |
||||||
|
props.event.tags.filter((t: string[]) => t[0] === 'p').forEach((t: string[]) => { |
||||||
|
if (!tags.some((pt: string[]) => pt[1] === t[1])) { |
||||||
|
tags.push(['p', t[1]]); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
} else { |
||||||
|
// NIP-22 comment |
||||||
|
tags.push(['E', props.event.id, '', props.event.pubkey]); |
||||||
|
tags.push(['K', props.event.kind.toString()]); |
||||||
|
tags.push(['P', props.event.pubkey]); |
||||||
|
tags.push(['e', props.event.id, '', props.event.pubkey]); |
||||||
|
tags.push(['k', props.event.kind.toString()]); |
||||||
|
tags.push(['p', props.event.pubkey]); |
||||||
|
} |
||||||
|
|
||||||
|
const eventToSign = { |
||||||
|
kind, |
||||||
|
created_at: Math.floor(Date.now() / 1000), |
||||||
|
tags, |
||||||
|
content, |
||||||
|
pubkey: props.userPubkey |
||||||
|
}; |
||||||
|
|
||||||
|
const id = getEventHash(eventToSign); |
||||||
|
const sig = await signEvent(eventToSign); |
||||||
|
|
||||||
|
const signedEvent = { |
||||||
|
...eventToSign, |
||||||
|
id, |
||||||
|
sig |
||||||
|
}; |
||||||
|
|
||||||
|
// Determine which relays to use |
||||||
|
let relays = props.userRelayPreference ? get(userRelays) : standardRelays; |
||||||
|
if (useOtherRelays) { |
||||||
|
relays = props.userRelayPreference ? standardRelays : get(userRelays); |
||||||
|
} |
||||||
|
if (useFallbackRelays) { |
||||||
|
relays = fallbackRelays; |
||||||
|
} |
||||||
|
|
||||||
|
// Try to publish to relays |
||||||
|
let published = false; |
||||||
|
for (const relayUrl of relays) { |
||||||
|
try { |
||||||
|
const ws = new WebSocket(relayUrl); |
||||||
|
await new Promise<void>((resolve, reject) => { |
||||||
|
const timeout = setTimeout(() => { |
||||||
|
ws.close(); |
||||||
|
reject(new Error('Timeout')); |
||||||
|
}, 5000); |
||||||
|
|
||||||
|
ws.onopen = () => { |
||||||
|
ws.send(JSON.stringify(['EVENT', signedEvent])); |
||||||
|
}; |
||||||
|
|
||||||
|
ws.onmessage = (e) => { |
||||||
|
const [type, id, ok, message] = JSON.parse(e.data); |
||||||
|
if (type === 'OK' && id === signedEvent.id) { |
||||||
|
clearTimeout(timeout); |
||||||
|
if (ok) { |
||||||
|
published = true; |
||||||
|
success = { relay: relayUrl, eventId: signedEvent.id }; |
||||||
|
ws.close(); |
||||||
|
resolve(); |
||||||
|
} else { |
||||||
|
ws.close(); |
||||||
|
reject(new Error(message)); |
||||||
|
} |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
ws.onerror = () => { |
||||||
|
clearTimeout(timeout); |
||||||
|
ws.close(); |
||||||
|
reject(new Error('WebSocket error')); |
||||||
|
}; |
||||||
|
}); |
||||||
|
if (published) break; |
||||||
|
} catch (e) { |
||||||
|
console.error(`Failed to publish to ${relayUrl}:`, e); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (!published) { |
||||||
|
if (!useOtherRelays && !useFallbackRelays) { |
||||||
|
showOtherRelays = true; |
||||||
|
error = 'Failed to publish to primary relays. Would you like to try the other relays?'; |
||||||
|
} else if (useOtherRelays && !useFallbackRelays) { |
||||||
|
showFallbackRelays = true; |
||||||
|
error = 'Failed to publish to other relays. Would you like to try the fallback relays?'; |
||||||
|
} else { |
||||||
|
error = 'Failed to publish to any relays. Please try again later.'; |
||||||
|
} |
||||||
|
} else { |
||||||
|
// Navigate to the event page |
||||||
|
const nevent = nip19.neventEncode({ id: signedEvent.id }); |
||||||
|
goto(`/events?id=${nevent}`); |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
error = e instanceof Error ? e.message : 'An error occurred'; |
||||||
|
} finally { |
||||||
|
isSubmitting = false; |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="w-full space-y-4"> |
||||||
|
<div class="flex flex-wrap gap-2"> |
||||||
|
{#each markupButtons as button} |
||||||
|
<Button size="xs" on:click={button.action}>{button.label}</Button> |
||||||
|
{/each} |
||||||
|
<Button size="xs" color="alternative" on:click={removeFormatting}>Remove Formatting</Button> |
||||||
|
<Button size="xs" color="alternative" on:click={clearForm}>Clear</Button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> |
||||||
|
<div> |
||||||
|
<Textarea |
||||||
|
bind:value={content} |
||||||
|
on:input={updatePreview} |
||||||
|
placeholder="Write your comment..." |
||||||
|
rows={10} |
||||||
|
class="w-full" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
<div class="prose dark:prose-invert max-w-none p-4 border rounded-lg"> |
||||||
|
{@html preview} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if error} |
||||||
|
<Alert color="red" dismissable> |
||||||
|
{error} |
||||||
|
{#if showOtherRelays} |
||||||
|
<Button size="xs" class="mt-2" on:click={() => handleSubmit(true)}>Try Other Relays</Button> |
||||||
|
{/if} |
||||||
|
{#if showFallbackRelays} |
||||||
|
<Button size="xs" class="mt-2" on:click={() => handleSubmit(false, true)}>Try Fallback Relays</Button> |
||||||
|
{/if} |
||||||
|
</Alert> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if success} |
||||||
|
<Alert color="green" dismissable> |
||||||
|
Comment published successfully to {success.relay}! |
||||||
|
<a href="/events?id={nip19.neventEncode({ id: success.eventId })}" class="text-primary-600 dark:text-primary-500 hover:underline"> |
||||||
|
View your comment |
||||||
|
</a> |
||||||
|
</Alert> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<div class="flex justify-end items-center gap-4"> |
||||||
|
{#if userProfile} |
||||||
|
<div class="flex items-center gap-2 text-sm"> |
||||||
|
{#if userProfile.picture} |
||||||
|
<img |
||||||
|
src={userProfile.picture} |
||||||
|
alt={userProfile.name || 'Profile'} |
||||||
|
class="w-8 h-8 rounded-full" |
||||||
|
onerror={(e) => { |
||||||
|
const img = e.target as HTMLImageElement; |
||||||
|
img.src = `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(img.alt)}`; |
||||||
|
}} |
||||||
|
/> |
||||||
|
{/if} |
||||||
|
<span class="text-gray-700 dark:text-gray-300"> |
||||||
|
{userProfile.displayName || userProfile.name || nip19.npubEncode(props.userPubkey).slice(0, 8) + '...'} |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
<Button |
||||||
|
on:click={() => handleSubmit()} |
||||||
|
disabled={isSubmitting || !content.trim() || !props.userPubkey} |
||||||
|
class="w-full md:w-auto" |
||||||
|
> |
||||||
|
{#if !props.userPubkey} |
||||||
|
Not Signed In |
||||||
|
{:else if isSubmitting} |
||||||
|
Publishing... |
||||||
|
{:else} |
||||||
|
Post Comment |
||||||
|
{/if} |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if !props.userPubkey} |
||||||
|
<Alert color="yellow" class="mt-4"> |
||||||
|
Please sign in to post comments. Your comments will be signed with your current account. |
||||||
|
</Alert> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
/* Add styles for disabled state */ |
||||||
|
:global(.disabled) { |
||||||
|
opacity: 0.6; |
||||||
|
cursor: not-allowed; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,184 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser"; |
||||||
|
import { getMimeTags } from "$lib/utils/mime"; |
||||||
|
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; |
||||||
|
import { toNpub } from "$lib/utils/nostrUtils"; |
||||||
|
import { neventEncode, naddrEncode, nprofileEncode } from "$lib/utils"; |
||||||
|
import { standardRelays } from "$lib/consts"; |
||||||
|
import type { NDKEvent } from '$lib/utils/nostrUtils'; |
||||||
|
import { getMatchingTags } from '$lib/utils/nostrUtils'; |
||||||
|
import ProfileHeader from "$components/cards/ProfileHeader.svelte"; |
||||||
|
|
||||||
|
const { event, profile = null, searchValue = null } = $props<{ |
||||||
|
event: NDKEvent; |
||||||
|
profile?: { |
||||||
|
name?: string; |
||||||
|
display_name?: string; |
||||||
|
about?: string; |
||||||
|
picture?: string; |
||||||
|
banner?: string; |
||||||
|
website?: string; |
||||||
|
lud16?: string; |
||||||
|
nip05?: string; |
||||||
|
} | null; |
||||||
|
searchValue?: string | null; |
||||||
|
}>(); |
||||||
|
|
||||||
|
let showFullContent = $state(false); |
||||||
|
let parsedContent = $state(''); |
||||||
|
let contentPreview = $state(''); |
||||||
|
|
||||||
|
function getEventTitle(event: NDKEvent): string { |
||||||
|
return getMatchingTags(event, 'title')[0]?.[1] || 'Untitled'; |
||||||
|
} |
||||||
|
|
||||||
|
function getEventSummary(event: NDKEvent): string { |
||||||
|
return getMatchingTags(event, 'summary')[0]?.[1] || ''; |
||||||
|
} |
||||||
|
|
||||||
|
function getEventHashtags(event: NDKEvent): string[] { |
||||||
|
return getMatchingTags(event, 't').map((tag: string[]) => tag[1]); |
||||||
|
} |
||||||
|
|
||||||
|
function getEventTypeDisplay(event: NDKEvent): string { |
||||||
|
const [mTag, MTag] = getMimeTags(event.kind || 0); |
||||||
|
return MTag[1].split('/')[1] || `Event Kind ${event.kind}`; |
||||||
|
} |
||||||
|
|
||||||
|
function renderTag(tag: string[]): string { |
||||||
|
if (tag[0] === 'a' && tag.length > 1) { |
||||||
|
const [kind, pubkey, d] = tag[1].split(':'); |
||||||
|
return `<a href='/events?id=${naddrEncode({kind: +kind, pubkey, tags: [['d', d]], content: '', id: '', sig: ''} as any, standardRelays)}' class='underline text-primary-700'>a:${tag[1]}</a>`; |
||||||
|
} else if (tag[0] === 'e' && tag.length > 1) { |
||||||
|
return `<a href='/events?id=${neventEncode({id: tag[1], kind: 1, content: '', tags: [], pubkey: '', sig: ''} as any, standardRelays)}' class='underline text-primary-700'>e:${tag[1]}</a>`; |
||||||
|
} else { |
||||||
|
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>${tag[0]}:${tag[1]}</span>`; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
$effect(() => { |
||||||
|
if (event && event.kind !== 0 && event.content) { |
||||||
|
parseBasicmarkup(event.content).then(html => { |
||||||
|
parsedContent = html; |
||||||
|
contentPreview = html.slice(0, 250); |
||||||
|
}); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
// --- Identifier helpers --- |
||||||
|
function getIdentifiers(event: NDKEvent, profile: any): { label: string, value: string, link?: string }[] { |
||||||
|
const ids: { label: string, value: string, link?: string }[] = []; |
||||||
|
if (event.kind === 0) { |
||||||
|
// NIP-05 |
||||||
|
const nip05 = profile?.nip05 || getMatchingTags(event, 'nip05')[0]?.[1]; |
||||||
|
// npub |
||||||
|
const npub = toNpub(event.pubkey); |
||||||
|
if (npub) ids.push({ label: 'npub', value: npub, link: `/events?id=${npub}` }); |
||||||
|
// nprofile |
||||||
|
ids.push({ label: 'nprofile', value: nprofileEncode(event.pubkey, standardRelays), link: `/events?id=${nprofileEncode(event.pubkey, standardRelays)}` }); |
||||||
|
// nevent |
||||||
|
ids.push({ label: 'nevent', value: neventEncode(event, standardRelays), link: `/events?id=${neventEncode(event, standardRelays)}` }); |
||||||
|
// hex pubkey |
||||||
|
ids.push({ label: 'pubkey', value: event.pubkey }); |
||||||
|
} else { |
||||||
|
// nevent |
||||||
|
ids.push({ label: 'nevent', value: neventEncode(event, standardRelays), link: `/events?id=${neventEncode(event, standardRelays)}` }); |
||||||
|
// naddr (if addressable) |
||||||
|
try { |
||||||
|
const naddr = naddrEncode(event, standardRelays); |
||||||
|
ids.push({ label: 'naddr', value: naddr, link: `/events?id=${naddr}` }); |
||||||
|
} catch {} |
||||||
|
// hex id |
||||||
|
ids.push({ label: 'id', value: event.id }); |
||||||
|
} |
||||||
|
return ids; |
||||||
|
} |
||||||
|
|
||||||
|
function isCurrentSearch(value: string): boolean { |
||||||
|
if (!searchValue) return false; |
||||||
|
// Compare ignoring case and possible nostr: prefix |
||||||
|
const norm = (s: string) => s.replace(/^nostr:/, '').toLowerCase(); |
||||||
|
return norm(value) === norm(searchValue); |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="flex flex-col space-y-4"> |
||||||
|
{#if event.kind !== 0 && getEventTitle(event)} |
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">{getEventTitle(event)}</h2> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<div class="flex items-center space-x-2"> |
||||||
|
{#if toNpub(event.pubkey)} |
||||||
|
<span class="text-gray-600 dark:text-gray-400">Author: {@render userBadge(toNpub(event.pubkey) as string, profile?.display_name || event.pubkey)}</span> |
||||||
|
{:else} |
||||||
|
<span class="text-gray-600 dark:text-gray-400">Author: {profile?.display_name || event.pubkey}</span> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<div class="flex items-center space-x-2"> |
||||||
|
<span class="text-gray-600 dark:text-gray-400">Kind:</span> |
||||||
|
<span class="font-mono">{event.kind}</span> |
||||||
|
<span class="text-gray-600 dark:text-gray-400">({getEventTypeDisplay(event)})</span> |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if getEventSummary(event)} |
||||||
|
<div class="flex flex-col space-y-1"> |
||||||
|
<span class="text-gray-600 dark:text-gray-400">Summary:</span> |
||||||
|
<p class="text-gray-800 dark:text-gray-200">{getEventSummary(event)}</p> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if getEventHashtags(event).length} |
||||||
|
<div class="flex flex-col space-y-1"> |
||||||
|
<span class="text-gray-600 dark:text-gray-400">Tags:</span> |
||||||
|
<div class="flex flex-wrap gap-2"> |
||||||
|
{#each getEventHashtags(event) as tag} |
||||||
|
<span class="px-2 py-1 rounded bg-primary-100 text-primary-700 text-sm font-medium">#{tag}</span> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<!-- Content --> |
||||||
|
<div class="flex flex-col space-y-1"> |
||||||
|
{#if event.kind !== 0} |
||||||
|
<span class="text-gray-600 dark:text-gray-400">Content:</span> |
||||||
|
<div class="prose dark:prose-invert max-w-none"> |
||||||
|
{@html showFullContent ? parsedContent : contentPreview} |
||||||
|
{#if !showFullContent && parsedContent.length > 250} |
||||||
|
<button class="mt-2 text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300" onclick={() => showFullContent = true}>Show more</button> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- If event is profile --> |
||||||
|
{#if event.kind === 0} |
||||||
|
<ProfileHeader {event} {profile} identifiers={getIdentifiers(event, profile)} /> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<!-- Tags Array --> |
||||||
|
{#if event.tags && event.tags.length} |
||||||
|
<div class="flex flex-col space-y-1"> |
||||||
|
<span class="text-gray-600 dark:text-gray-400">Event Tags:</span> |
||||||
|
<div class="flex flex-wrap gap-2"> |
||||||
|
{#each event.tags as tag} |
||||||
|
{@html renderTag(tag)} |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<!-- Raw Event JSON --> |
||||||
|
<details class="bg-primary-50 dark:bg-primary-900 rounded p-4"> |
||||||
|
<summary class="cursor-pointer font-semibold text-primary-700 dark:text-primary-300 mb-2"> |
||||||
|
Show Raw Event JSON |
||||||
|
</summary> |
||||||
|
<pre |
||||||
|
class="overflow-x-auto text-xs bg-highlight dark:bg-primary-900 rounded p-4 mt-2 font-mono" |
||||||
|
style="line-height: 1.7; font-size: 1rem;" |
||||||
|
> |
||||||
|
{JSON.stringify(event.rawEvent(), null, 2)} |
||||||
|
</pre> |
||||||
|
</details> |
||||||
|
</div> |
||||||
@ -0,0 +1,204 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { Input, Button } from "flowbite-svelte"; |
||||||
|
import { ndkInstance } from "$lib/ndk"; |
||||||
|
import { fetchEventWithFallback } from "$lib/utils/nostrUtils"; |
||||||
|
import { nip19 } from '$lib/utils/nostrUtils'; |
||||||
|
import { goto } from '$app/navigation'; |
||||||
|
import type { NDKEvent } from '$lib/utils/nostrUtils'; |
||||||
|
import RelayDisplay from './RelayDisplay.svelte'; |
||||||
|
|
||||||
|
const { loading, error, searchValue, onEventFound, event } = $props<{ |
||||||
|
loading: boolean; |
||||||
|
error: string | null; |
||||||
|
searchValue: string | null; |
||||||
|
onEventFound: (event: NDKEvent) => void; |
||||||
|
event: NDKEvent | null; |
||||||
|
}>(); |
||||||
|
|
||||||
|
let searchQuery = $state(""); |
||||||
|
let localError = $state<string | null>(null); |
||||||
|
let relayStatuses = $state<Record<string, 'pending' | 'found' | 'notfound'>>({}); |
||||||
|
let foundEvent = $state<NDKEvent | null>(null); |
||||||
|
let searching = $state(false); |
||||||
|
|
||||||
|
$effect(() => { |
||||||
|
if (searchValue) { |
||||||
|
searchEvent(false, searchValue); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
$effect(() => { |
||||||
|
foundEvent = event; |
||||||
|
}); |
||||||
|
|
||||||
|
async function searchEvent(clearInput: boolean = true, queryOverride?: string) { |
||||||
|
localError = null; |
||||||
|
const query = (queryOverride !== undefined ? queryOverride : searchQuery).trim(); |
||||||
|
if (!query) return; |
||||||
|
|
||||||
|
// Only update the URL if this is a manual search |
||||||
|
if (clearInput) { |
||||||
|
const encoded = encodeURIComponent(query); |
||||||
|
goto(`?id=${encoded}`, { replaceState: false, keepFocus: true, noScroll: true }); |
||||||
|
} |
||||||
|
|
||||||
|
if (clearInput) { |
||||||
|
searchQuery = ''; |
||||||
|
} |
||||||
|
|
||||||
|
// Clean the query |
||||||
|
let cleanedQuery = query.replace(/^nostr:/, ''); |
||||||
|
let filterOrId: any = cleanedQuery; |
||||||
|
console.log('[Events] Cleaned query:', cleanedQuery); |
||||||
|
|
||||||
|
// NIP-05 address pattern: user@domain |
||||||
|
if (/^[a-z0-9._-]+@[a-z0-9.-]+$/i.test(cleanedQuery)) { |
||||||
|
try { |
||||||
|
const [name, domain] = cleanedQuery.split('@'); |
||||||
|
const res = await fetch(`https://${domain}/.well-known/nostr.json?name=${name}`); |
||||||
|
const data = await res.json(); |
||||||
|
const pubkey = data.names?.[name]; |
||||||
|
if (pubkey) { |
||||||
|
filterOrId = { kinds: [0], authors: [pubkey] }; |
||||||
|
const profileEvent = await fetchEventWithFallback($ndkInstance, filterOrId, 10000); |
||||||
|
if (profileEvent) { |
||||||
|
handleFoundEvent(profileEvent); |
||||||
|
return; |
||||||
|
} else { |
||||||
|
localError = 'No profile found for this NIP-05 address.'; |
||||||
|
return; |
||||||
|
} |
||||||
|
} else { |
||||||
|
localError = 'NIP-05 address not found.'; |
||||||
|
return; |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
localError = 'Error resolving NIP-05 address.'; |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// If it's a 64-char hex, try as event id first, then as pubkey (profile) |
||||||
|
if (/^[a-f0-9]{64}$/i.test(cleanedQuery)) { |
||||||
|
// Try as event id |
||||||
|
filterOrId = cleanedQuery; |
||||||
|
const eventResult = await fetchEventWithFallback($ndkInstance, filterOrId, 10000); |
||||||
|
// Always try as pubkey (profile event) as well |
||||||
|
const profileFilter = { kinds: [0], authors: [cleanedQuery] }; |
||||||
|
const profileEvent = await fetchEventWithFallback($ndkInstance, profileFilter, 10000); |
||||||
|
// Prefer profile if found and pubkey matches query |
||||||
|
if (profileEvent && profileEvent.pubkey.toLowerCase() === cleanedQuery.toLowerCase()) { |
||||||
|
handleFoundEvent(profileEvent); |
||||||
|
} else if (eventResult) { |
||||||
|
handleFoundEvent(eventResult); |
||||||
|
} |
||||||
|
return; |
||||||
|
} else if (/^(nevent|note|naddr|npub|nprofile)[a-z0-9]+$/i.test(cleanedQuery)) { |
||||||
|
try { |
||||||
|
const decoded = nip19.decode(cleanedQuery); |
||||||
|
if (!decoded) throw new Error('Invalid identifier'); |
||||||
|
console.log('[Events] Decoded NIP-19:', decoded); |
||||||
|
switch (decoded.type) { |
||||||
|
case 'nevent': |
||||||
|
filterOrId = decoded.data.id; |
||||||
|
break; |
||||||
|
case 'note': |
||||||
|
filterOrId = decoded.data; |
||||||
|
break; |
||||||
|
case 'naddr': |
||||||
|
filterOrId = { |
||||||
|
kinds: [decoded.data.kind], |
||||||
|
authors: [decoded.data.pubkey], |
||||||
|
'#d': [decoded.data.identifier], |
||||||
|
}; |
||||||
|
break; |
||||||
|
case 'nprofile': |
||||||
|
filterOrId = { |
||||||
|
kinds: [0], |
||||||
|
authors: [decoded.data.pubkey], |
||||||
|
}; |
||||||
|
break; |
||||||
|
case 'npub': |
||||||
|
filterOrId = { |
||||||
|
kinds: [0], |
||||||
|
authors: [decoded.data], |
||||||
|
}; |
||||||
|
break; |
||||||
|
default: |
||||||
|
filterOrId = cleanedQuery; |
||||||
|
} |
||||||
|
console.log('[Events] Using filterOrId:', filterOrId); |
||||||
|
} catch (e) { |
||||||
|
console.error('[Events] Invalid Nostr identifier:', cleanedQuery, e); |
||||||
|
localError = 'Invalid Nostr identifier.'; |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
console.log('Searching for event:', filterOrId); |
||||||
|
const event = await fetchEventWithFallback($ndkInstance, filterOrId, 10000); |
||||||
|
|
||||||
|
if (!event) { |
||||||
|
console.warn('[Events] Event not found for filterOrId:', filterOrId); |
||||||
|
localError = 'Event not found'; |
||||||
|
} else { |
||||||
|
console.log('[Events] Event found:', event); |
||||||
|
handleFoundEvent(event); |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
console.error('[Events] Error fetching event:', err, 'Query:', query); |
||||||
|
localError = 'Error fetching event. Please check the ID and try again.'; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function handleFoundEvent(event: NDKEvent) { |
||||||
|
foundEvent = event; |
||||||
|
onEventFound(event); |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="flex flex-col space-y-6"> |
||||||
|
<div class="flex gap-2"> |
||||||
|
<Input |
||||||
|
bind:value={searchQuery} |
||||||
|
placeholder="Enter event ID, nevent, or naddr..." |
||||||
|
class="flex-grow" |
||||||
|
on:keydown={(e: KeyboardEvent) => e.key === 'Enter' && searchEvent(true)} |
||||||
|
/> |
||||||
|
<Button on:click={() => searchEvent(true)} disabled={loading}> |
||||||
|
{loading ? 'Searching...' : 'Search'} |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if localError || error} |
||||||
|
<div class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg" role="alert"> |
||||||
|
{localError || error} |
||||||
|
{#if searchQuery.trim()} |
||||||
|
<div class="mt-2"> |
||||||
|
You can also try viewing this event on |
||||||
|
<a |
||||||
|
class="underline text-primary-700" |
||||||
|
href={"https://njump.me/" + encodeURIComponent(searchQuery.trim())} |
||||||
|
target="_blank" |
||||||
|
rel="noopener" |
||||||
|
>Njump</a>. |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<div class="mt-4"> |
||||||
|
<div class="flex flex-wrap gap-2"> |
||||||
|
{#each Object.entries(relayStatuses) as [relay, status]} |
||||||
|
<RelayDisplay {relay} showStatus={true} status={status} /> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
{#if !foundEvent && Object.values(relayStatuses).some(s => s === 'pending')} |
||||||
|
<div class="text-gray-500 mt-2">Searching relays...</div> |
||||||
|
{/if} |
||||||
|
{#if !foundEvent && !searching && Object.values(relayStatuses).every(s => s !== 'pending')} |
||||||
|
<div class="text-red-500 mt-2">Event not found on any relay.</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
</div> |
||||||
@ -1,29 +1,36 @@ |
|||||||
<script lang="ts"> |
<script lang="ts"> |
||||||
import { DarkMode, Navbar, NavLi, NavUl, NavHamburger, NavBrand } from 'flowbite-svelte'; |
import { |
||||||
import Login from './Login.svelte'; |
DarkMode, |
||||||
|
Navbar, |
||||||
|
NavLi, |
||||||
|
NavUl, |
||||||
|
NavHamburger, |
||||||
|
NavBrand, |
||||||
|
} from "flowbite-svelte"; |
||||||
|
import Login from "./Login.svelte"; |
||||||
|
|
||||||
let { class: className = '' } = $props(); |
let { class: className = "" } = $props(); |
||||||
|
|
||||||
let leftMenuOpen = $state(false); |
|
||||||
</script> |
</script> |
||||||
|
|
||||||
<Navbar class={`Navbar navbar-leather ${className}`}> |
<Navbar class={`Navbar navbar-leather navbar-main ${className}`}> |
||||||
<div class='flex flex-grow justify-between'> |
<div class="flex flex-grow justify-between"> |
||||||
<NavBrand href='/'> |
<NavBrand href="/"> |
||||||
<h1>Alexandria</h1> |
<h1>Alexandria</h1> |
||||||
</NavBrand> |
</NavBrand> |
||||||
</div> |
</div> |
||||||
<div class='flex md:order-2'> |
<div class="flex md:order-2"> |
||||||
<Login /> |
<Login /> |
||||||
<NavHamburger class='btn-leather' /> |
<NavHamburger class="btn-leather" /> |
||||||
</div> |
</div> |
||||||
<NavUl class='ul-leather'> |
<NavUl class="ul-leather"> |
||||||
<NavLi href='/new/edit'>Publish</NavLi> |
<NavLi href="/">Publications</NavLi> |
||||||
<NavLi href='/visualize'>Visualize</NavLi> |
<NavLi href="/visualize">Visualize</NavLi> |
||||||
<NavLi href='/about'>About</NavLi> |
<NavLi href="/start">Getting Started</NavLi> |
||||||
<NavLi href='/contact'>Contact</NavLi> |
<NavLi href="/events">Events</NavLi> |
||||||
<NavLi> |
<NavLi href="/about">About</NavLi> |
||||||
<DarkMode btnClass='btn-leather p-0'/> |
<NavLi href="/contact">Contact</NavLi> |
||||||
|
<NavLi> |
||||||
|
<DarkMode btnClass="btn-leather p-0" /> |
||||||
</NavLi> |
</NavLi> |
||||||
</NavUl> |
</NavUl> |
||||||
</Navbar> |
</Navbar> |
||||||
|
|||||||
@ -0,0 +1,190 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { Button } from "flowbite-svelte"; |
||||||
|
import { ndkInstance } from "$lib/ndk"; |
||||||
|
import { get } from 'svelte/store'; |
||||||
|
import type { NDKEvent } from '$lib/utils/nostrUtils'; |
||||||
|
import { createRelaySetFromUrls, createNDKEvent } from '$lib/utils/nostrUtils'; |
||||||
|
import RelayDisplay, { getConnectedRelays, getEventRelays } from './RelayDisplay.svelte'; |
||||||
|
import { standardRelays, fallbackRelays } from "$lib/consts"; |
||||||
|
|
||||||
|
const { event } = $props<{ |
||||||
|
event: NDKEvent; |
||||||
|
}>(); |
||||||
|
|
||||||
|
let searchingRelays = $state(false); |
||||||
|
let foundRelays = $state<string[]>([]); |
||||||
|
let broadcasting = $state(false); |
||||||
|
let broadcastSuccess = $state(false); |
||||||
|
let broadcastError = $state<string | null>(null); |
||||||
|
let showRelayModal = $state(false); |
||||||
|
let relaySearchResults = $state<Record<string, 'pending' | 'found' | 'notfound'>>({}); |
||||||
|
let allRelays = $state<string[]>([]); |
||||||
|
|
||||||
|
// Magnifying glass icon SVG |
||||||
|
const searchIcon = `<svg class="w-4 h-4 mr-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 20 20"> |
||||||
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"/> |
||||||
|
</svg>`; |
||||||
|
|
||||||
|
// Broadcast icon SVG |
||||||
|
const broadcastIcon = `<svg class="w-4 h-4 mr-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> |
||||||
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 0 0-3.7-3.7 48.678 48.678 0 0 0-7.324 0 4.006 4.006 0 0 0-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 0 0 3.7 3.7 48.656 48.656 0 0 0 7.324 0 4.006 4.006 0 0 0 3.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3-3 3"/> |
||||||
|
</svg>`; |
||||||
|
|
||||||
|
async function broadcastEvent() { |
||||||
|
if (!event || !$ndkInstance?.activeUser) return; |
||||||
|
broadcasting = true; |
||||||
|
broadcastSuccess = false; |
||||||
|
broadcastError = null; |
||||||
|
|
||||||
|
try { |
||||||
|
const connectedRelays = getConnectedRelays(); |
||||||
|
if (connectedRelays.length === 0) { |
||||||
|
throw new Error('No connected relays available'); |
||||||
|
} |
||||||
|
|
||||||
|
// Create a new event with the same content |
||||||
|
const newEvent = createNDKEvent($ndkInstance, { |
||||||
|
...event.rawEvent(), |
||||||
|
pubkey: $ndkInstance.activeUser.pubkey, |
||||||
|
created_at: Math.floor(Date.now() / 1000), |
||||||
|
sig: '' |
||||||
|
}); |
||||||
|
|
||||||
|
// Publish to all relays |
||||||
|
await newEvent.publish(); |
||||||
|
broadcastSuccess = true; |
||||||
|
} catch (err) { |
||||||
|
console.error('Error broadcasting event:', err); |
||||||
|
broadcastError = err instanceof Error ? err.message : 'Failed to broadcast event'; |
||||||
|
} finally { |
||||||
|
broadcasting = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function openRelayModal() { |
||||||
|
showRelayModal = true; |
||||||
|
relaySearchResults = {}; |
||||||
|
searchAllRelaysLive(); |
||||||
|
} |
||||||
|
|
||||||
|
async function searchAllRelaysLive() { |
||||||
|
if (!event) return; |
||||||
|
relaySearchResults = {}; |
||||||
|
const ndk = get(ndkInstance); |
||||||
|
const userRelays = Array.from(ndk?.pool?.relays.values() || []).map(r => r.url); |
||||||
|
allRelays = [ |
||||||
|
...standardRelays, |
||||||
|
...userRelays, |
||||||
|
...fallbackRelays |
||||||
|
].filter((url, idx, arr) => arr.indexOf(url) === idx); |
||||||
|
relaySearchResults = Object.fromEntries(allRelays.map((r: string) => [r, 'pending'])); |
||||||
|
await Promise.all( |
||||||
|
allRelays.map(async (relay: string) => { |
||||||
|
try { |
||||||
|
const relaySet = createRelaySetFromUrls([relay], ndk); |
||||||
|
const found = await ndk.fetchEvent( |
||||||
|
{ ids: [event?.id || ''] }, |
||||||
|
undefined, |
||||||
|
relaySet |
||||||
|
).withTimeout(3000); |
||||||
|
relaySearchResults = { ...relaySearchResults, [relay]: found ? 'found' : 'notfound' }; |
||||||
|
} catch { |
||||||
|
relaySearchResults = { ...relaySearchResults, [relay]: 'notfound' }; |
||||||
|
} |
||||||
|
}) |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
function closeRelayModal() { |
||||||
|
showRelayModal = false; |
||||||
|
} |
||||||
|
|
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="mt-4 flex flex-wrap gap-2"> |
||||||
|
<Button |
||||||
|
on:click={openRelayModal} |
||||||
|
class="flex items-center" |
||||||
|
> |
||||||
|
{@html searchIcon} |
||||||
|
Where can I find this event? |
||||||
|
</Button> |
||||||
|
|
||||||
|
{#if $ndkInstance?.activeUser} |
||||||
|
<Button |
||||||
|
on:click={broadcastEvent} |
||||||
|
disabled={broadcasting} |
||||||
|
class="flex items-center" |
||||||
|
> |
||||||
|
{@html broadcastIcon} |
||||||
|
{broadcasting ? 'Broadcasting...' : 'Broadcast'} |
||||||
|
</Button> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if foundRelays.length > 0} |
||||||
|
<div class="mt-2"> |
||||||
|
<span class="font-semibold">Found on {foundRelays.length} relay(s):</span> |
||||||
|
<div class="flex flex-wrap gap-2 mt-1"> |
||||||
|
{#each foundRelays as relay} |
||||||
|
<RelayDisplay {relay} /> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if broadcastSuccess} |
||||||
|
<div class="mt-2 p-2 bg-green-100 text-green-700 rounded"> |
||||||
|
Event broadcast successfully to: |
||||||
|
<div class="flex flex-wrap gap-2 mt-1"> |
||||||
|
{#each getConnectedRelays() as relay} |
||||||
|
<RelayDisplay {relay} /> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if broadcastError} |
||||||
|
<div class="mt-2 p-2 bg-red-100 text-red-700 rounded"> |
||||||
|
{broadcastError} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<div class="mt-2"> |
||||||
|
<span class="font-semibold">Found on:</span> |
||||||
|
<div class="flex flex-wrap gap-2 mt-1"> |
||||||
|
{#each getEventRelays(event) as relay} |
||||||
|
<RelayDisplay {relay} /> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if showRelayModal} |
||||||
|
<div class="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center"> |
||||||
|
<div class="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-6 w-full max-w-lg relative"> |
||||||
|
<button class="absolute top-2 right-2 text-gray-500 hover:text-gray-800" onclick={closeRelayModal}>×</button> |
||||||
|
<h2 class="text-lg font-semibold mb-4">Relay Search Results</h2> |
||||||
|
<div class="flex flex-col gap-4 max-h-96 overflow-y-auto"> |
||||||
|
{#each Object.entries({ |
||||||
|
'Standard Relays': standardRelays, |
||||||
|
'User Relays': Array.from($ndkInstance?.pool?.relays.values() || []).map(r => r.url), |
||||||
|
'Fallback Relays': fallbackRelays |
||||||
|
}) as [groupName, groupRelays]} |
||||||
|
{#if groupRelays.length > 0} |
||||||
|
<div class="flex flex-col gap-2"> |
||||||
|
<h3 class="font-medium text-gray-700 dark:text-gray-300 sticky top-0 bg-white dark:bg-gray-900 py-2"> |
||||||
|
{groupName} |
||||||
|
</h3> |
||||||
|
{#each groupRelays as relay} |
||||||
|
<RelayDisplay {relay} showStatus={true} status={relaySearchResults[relay] || null} /> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
<div class="mt-4 flex justify-end"> |
||||||
|
<Button onclick={closeRelayModal}>Close</Button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
@ -0,0 +1,59 @@ |
|||||||
|
<script lang="ts" context="module"> |
||||||
|
import type { NDKEvent } from '$lib/utils/nostrUtils'; |
||||||
|
|
||||||
|
// Get relays from event (prefer event.relay or event.relays, fallback to standardRelays) |
||||||
|
export function getEventRelays(event: NDKEvent): string[] { |
||||||
|
if (event && (event as any).relay) { |
||||||
|
const relay = (event as any).relay; |
||||||
|
return [typeof relay === 'string' ? relay : relay.url]; |
||||||
|
} |
||||||
|
if (event && (event as any).relays && (event as any).relays.length) { |
||||||
|
return (event as any).relays.map((r: any) => typeof r === 'string' ? r : r.url); |
||||||
|
} |
||||||
|
return standardRelays; |
||||||
|
} |
||||||
|
|
||||||
|
export function getConnectedRelays(): string[] { |
||||||
|
const ndk = get(ndkInstance); |
||||||
|
return Array.from(ndk?.pool?.relays.values() || []) |
||||||
|
.filter(r => r.status === 1) // Only use connected relays |
||||||
|
.map(r => r.url); |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<script lang="ts"> |
||||||
|
import { get } from 'svelte/store'; |
||||||
|
import { ndkInstance } from "$lib/ndk"; |
||||||
|
import { standardRelays } from "$lib/consts"; |
||||||
|
|
||||||
|
export let relay: string; |
||||||
|
export let showStatus = false; |
||||||
|
export let status: 'pending' | 'found' | 'notfound' | null = null; |
||||||
|
|
||||||
|
// Use a static fallback icon for all relays |
||||||
|
function relayFavicon(relay: string): string { |
||||||
|
return '/favicon.png'; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="flex items-center gap-2 p-2 rounded border border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900"> |
||||||
|
<img |
||||||
|
src={relayFavicon(relay)} |
||||||
|
alt="relay icon" |
||||||
|
class="w-5 h-5 object-contain" |
||||||
|
onerror={(e) => { (e.target as HTMLImageElement).src = '/favicon.png'; }} |
||||||
|
/> |
||||||
|
<span class="font-mono text-xs flex-1">{relay}</span> |
||||||
|
{#if showStatus && status} |
||||||
|
{#if status === 'pending'} |
||||||
|
<svg class="w-4 h-4 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24"> |
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> |
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path> |
||||||
|
</svg> |
||||||
|
{:else if status === 'found'} |
||||||
|
<span class="text-green-600">✓</span> |
||||||
|
{:else} |
||||||
|
<span class="text-red-500">✗</span> |
||||||
|
{/if} |
||||||
|
{/if} |
||||||
|
</div> |
||||||
@ -0,0 +1,70 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import type { NDKEvent } from '@nostr-dev-kit/ndk'; |
||||||
|
import { scale } from 'svelte/transition'; |
||||||
|
import { Card, Img } from "flowbite-svelte"; |
||||||
|
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; |
||||||
|
import Interactions from "$components/util/Interactions.svelte"; |
||||||
|
import { quintOut } from "svelte/easing"; |
||||||
|
import CardActions from "$components/util/CardActions.svelte"; |
||||||
|
|
||||||
|
const { rootId, event, onBlogUpdate, active = true } = $props<{ rootId: string, event: NDKEvent, onBlogUpdate?: any, active: boolean }>(); |
||||||
|
|
||||||
|
let title: string = $derived(event.getMatchingTags('title')[0]?.[1]); |
||||||
|
let author: string = $derived(getMatchingTags(event, 'author')[0]?.[1] ?? 'unknown'); |
||||||
|
let image: string = $derived(event.getMatchingTags('image')[0]?.[1] ?? null); |
||||||
|
let authorPubkey: string = $derived(event.getMatchingTags('p')[0]?.[1] ?? null); |
||||||
|
let hashtags: string = $derived(event.getMatchingTags('t') ?? null); |
||||||
|
|
||||||
|
function publishedAt() { |
||||||
|
const date = event.created_at ? new Date(event.created_at * 1000) : ''; |
||||||
|
if (date !== '') { |
||||||
|
const formattedDate = new Intl.DateTimeFormat("en-US", { |
||||||
|
year: "numeric", |
||||||
|
month: "short", |
||||||
|
day: "2-digit", |
||||||
|
}).format(date); |
||||||
|
return formattedDate ?? ""; |
||||||
|
} |
||||||
|
return ''; |
||||||
|
} |
||||||
|
|
||||||
|
function showBlog() { |
||||||
|
onBlogUpdate?.(rootId); |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
{#if title != null} |
||||||
|
<Card class="ArticleBox card-leather w-full grid max-w-xl {active ? 'active' : ''}"> |
||||||
|
<div class='space-y-4'> |
||||||
|
<div class="flex flex-row justify-between my-2"> |
||||||
|
<div class="flex flex-col"> |
||||||
|
{@render userBadge(authorPubkey, author)} |
||||||
|
<span class='text-gray-500'>{publishedAt()}</span> |
||||||
|
</div> |
||||||
|
<CardActions event={event} /> |
||||||
|
</div> |
||||||
|
{#if image && active} |
||||||
|
<div class="ArticleBoxImage flex col justify-center" |
||||||
|
in:scale={{ start: 0.8, duration: 500, delay: 100, easing: quintOut }} |
||||||
|
> |
||||||
|
<Img src={image} class="rounded w-full max-h-72 object-cover"/> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
<div class='flex flex-col flex-grow space-y-4'> |
||||||
|
<button onclick={() => showBlog()} class='text-left'> |
||||||
|
<h2 class='text-lg font-bold line-clamp-2' title="{title}">{title}</h2> |
||||||
|
</button> |
||||||
|
{#if hashtags} |
||||||
|
<div class="tags"> |
||||||
|
{#each hashtags as tag} |
||||||
|
<span>{tag}</span> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{#if active} |
||||||
|
<Interactions rootId={rootId} event={event} /> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
</Card> |
||||||
|
{/if} |
||||||
@ -0,0 +1,120 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { Card, Img, Modal, Button, P } from "flowbite-svelte"; |
||||||
|
import { onMount } from "svelte"; |
||||||
|
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; |
||||||
|
import { type NostrProfile, toNpub } from "$lib/utils/nostrUtils.ts"; |
||||||
|
import QrCode from "$components/util/QrCode.svelte"; |
||||||
|
import CopyToClipboard from "$components/util/CopyToClipboard.svelte"; |
||||||
|
// @ts-ignore |
||||||
|
import { bech32 } from 'https://esm.sh/bech32'; |
||||||
|
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||||
|
|
||||||
|
const { event, profile, identifiers = [] } = $props<{ event: NDKEvent, profile: NostrProfile, identifiers?: { label: string, value: string, link?: string }[] }>(); |
||||||
|
|
||||||
|
let lnModalOpen = $state(false); |
||||||
|
let lnurl = $state<string | null>(null); |
||||||
|
|
||||||
|
onMount(async () => { |
||||||
|
if (profile?.lud16) { |
||||||
|
try { |
||||||
|
// Convert LN address to LNURL |
||||||
|
const [name, domain] = profile?.lud16.split('@'); |
||||||
|
const url = `https://${domain}/.well-known/lnurlp/${name}`; |
||||||
|
const words = bech32.toWords(new TextEncoder().encode(url)); |
||||||
|
lnurl = bech32.encode('lnurl', words); |
||||||
|
} catch { |
||||||
|
console.log('Error converting LN address to LNURL'); |
||||||
|
} |
||||||
|
} |
||||||
|
}); |
||||||
|
</script> |
||||||
|
|
||||||
|
{#if profile} |
||||||
|
<Card class="ArticleBox card-leather w-full max-w-2xl"> |
||||||
|
<div class='space-y-4'> |
||||||
|
{#if profile.banner} |
||||||
|
<div class="ArticleBoxImage flex col justify-center"> |
||||||
|
<Img src={profile.banner} class="rounded w-full max-h-72 object-cover" alt="Profile banner" onerror={(e) => { (e.target as HTMLImageElement).style.display = 'none';}} /> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
<div class='flex flex-row space-x-4 items-center'> |
||||||
|
{#if profile.picture} |
||||||
|
<img src={profile.picture} alt="Profile avatar" class="w-16 h-16 rounded-full border" onerror={(e) => { (e.target as HTMLImageElement).src = '/favicon.png'; }} /> |
||||||
|
{/if} |
||||||
|
{@render userBadge(toNpub(event.pubkey) as string, profile.displayName || profile.name || event.pubkey)} |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<div class="mt-2 flex flex-col gap-4"> |
||||||
|
<dl class="grid grid-cols-1 gap-y-2"> |
||||||
|
{#if profile.name} |
||||||
|
<div class="flex gap-2"> |
||||||
|
<dt class="font-semibold min-w-[120px]">Name:</dt> |
||||||
|
<dd>{profile.name}</dd> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{#if profile.displayName} |
||||||
|
<div class="flex gap-2"> |
||||||
|
<dt class="font-semibold min-w-[120px]">Display Name:</dt> |
||||||
|
<dd>{profile.displayName}</dd> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{#if profile.about} |
||||||
|
<div class="flex gap-2"> |
||||||
|
<dt class="font-semibold min-w-[120px]">About:</dt> |
||||||
|
<dd class="whitespace-pre-line">{profile.about}</dd> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{#if profile.website} |
||||||
|
<div class="flex gap-2"> |
||||||
|
<dt class="font-semibold min-w-[120px]">Website:</dt> |
||||||
|
<dd> |
||||||
|
<a href={profile.website} target="_blank" class="underline text-primary-700 dark:text-primary-200">{profile.website}</a> |
||||||
|
</dd> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{#if profile.lud16} |
||||||
|
<div class="flex items-center gap-2 mt-4"> |
||||||
|
<dt class="font-semibold min-w-[120px]">Lightning Address:</dt> |
||||||
|
<dd><Button class="btn-leather" color="primary" outline onclick={() => lnModalOpen = true}>{profile.lud16}</Button> </dd> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{#if profile.nip05} |
||||||
|
<div class="flex gap-2"> |
||||||
|
<dt class="font-semibold min-w-[120px]">NIP-05:</dt> |
||||||
|
<dd>{profile.nip05}</dd> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{#each identifiers as id} |
||||||
|
<div class="flex gap-2"> |
||||||
|
<dt class="font-semibold min-w-[120px]">{id.label}:</dt> |
||||||
|
<dd class="break-all">{#if id.link}<a href={id.link} class="underline text-primary-700 dark:text-primary-200 break-all">{id.value}</a>{:else}{id.value}{/if}</dd> |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
</dl> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</Card> |
||||||
|
|
||||||
|
<Modal class='modal-leather' title='Lightning Address' bind:open={lnModalOpen} outsideclose size='sm'> |
||||||
|
{#if profile.lud16} |
||||||
|
<div> |
||||||
|
<div class='flex flex-col items-center'> |
||||||
|
{@render userBadge(toNpub(event.pubkey) as string, profile?.displayName || profile.name || event.pubkey)} |
||||||
|
<P>{profile.lud16}</P> |
||||||
|
</div> |
||||||
|
<div class="flex flex-col items-center mt-3 space-y-4"> |
||||||
|
<P>Scan the QR code or copy the address</P> |
||||||
|
{#if lnurl} |
||||||
|
<P style="overflow-wrap: anywhere"> |
||||||
|
<CopyToClipboard icon={false} displayText={lnurl}></CopyToClipboard> |
||||||
|
</P> |
||||||
|
<QrCode value={lnurl} /> |
||||||
|
{:else} |
||||||
|
<P>Couldn't generate address.</P> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</Modal> |
||||||
|
{/if} |
||||||
@ -0,0 +1,148 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { BookOutline, CaretLeftOutline, CloseOutline, GlobeOutline } from "flowbite-svelte-icons"; |
||||||
|
import { Button } from "flowbite-svelte"; |
||||||
|
import { publicationColumnVisibility } from "$lib/stores"; |
||||||
|
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; |
||||||
|
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||||
|
import { onDestroy, onMount } from "svelte"; |
||||||
|
|
||||||
|
let { |
||||||
|
publicationType, |
||||||
|
indexEvent |
||||||
|
} = $props<{ |
||||||
|
rootId: any, |
||||||
|
publicationType: string, |
||||||
|
indexEvent: NDKEvent |
||||||
|
}>(); |
||||||
|
|
||||||
|
let title: string = $derived(indexEvent.getMatchingTags('title')[0]?.[1]); |
||||||
|
let author: string = $derived(indexEvent.getMatchingTags(event, 'author')[0]?.[1] ?? 'unknown'); |
||||||
|
let pubkey: string = $derived(indexEvent.getMatchingTags('p')[0]?.[1] ?? null); |
||||||
|
let isLeaf: boolean = $derived(indexEvent.kind === 30041); |
||||||
|
|
||||||
|
let lastScrollY = $state(0); |
||||||
|
let isVisible = $state(true); |
||||||
|
|
||||||
|
// Function to toggle column visibility |
||||||
|
function toggleColumn(column: 'toc' | 'blog' | 'inner' | 'discussion') { |
||||||
|
publicationColumnVisibility.update(current => { |
||||||
|
const newValue = !current[column]; |
||||||
|
const updated = { ...current, [column]: newValue }; |
||||||
|
|
||||||
|
if (window.innerWidth < 1400 && column === 'blog' && newValue) { |
||||||
|
updated.discussion = false; |
||||||
|
} |
||||||
|
|
||||||
|
return updated; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function shouldShowBack() { |
||||||
|
const vis = $publicationColumnVisibility; |
||||||
|
return ['discussion', 'toc', 'inner'].some(key => vis[key as keyof typeof vis]); |
||||||
|
} |
||||||
|
|
||||||
|
function backToMain() { |
||||||
|
publicationColumnVisibility.update(current => { |
||||||
|
const updated = { ...current }; |
||||||
|
|
||||||
|
// if current is 'inner', just go back to blog |
||||||
|
if (current.inner && !(current.discussion || current.toc)) { |
||||||
|
updated.inner = false; |
||||||
|
updated.blog = true; |
||||||
|
return updated; |
||||||
|
} |
||||||
|
|
||||||
|
updated.discussion = false; |
||||||
|
updated.toc = false; |
||||||
|
|
||||||
|
if (publicationType === 'blog') { |
||||||
|
updated.inner = true; |
||||||
|
updated.blog = false; |
||||||
|
} else { |
||||||
|
updated.main = true; |
||||||
|
} |
||||||
|
|
||||||
|
return updated; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function backToBlog() { |
||||||
|
publicationColumnVisibility.update(current => { |
||||||
|
const updated = { ...current }; |
||||||
|
updated.inner = false; |
||||||
|
updated.discussion = false; |
||||||
|
updated.blog = true; |
||||||
|
return updated; |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
function handleScroll() { |
||||||
|
if (window.innerWidth < 768) { |
||||||
|
const currentScrollY = window.scrollY; |
||||||
|
|
||||||
|
// Hide on scroll down |
||||||
|
if (currentScrollY > lastScrollY && currentScrollY > 50) { |
||||||
|
isVisible = false; |
||||||
|
} |
||||||
|
// Show on scroll up |
||||||
|
else if (currentScrollY < lastScrollY) { |
||||||
|
isVisible = true; |
||||||
|
} |
||||||
|
|
||||||
|
lastScrollY = currentScrollY; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
let unsubscribe: () => void; |
||||||
|
onMount(() => { |
||||||
|
window.addEventListener('scroll', handleScroll); |
||||||
|
unsubscribe = publicationColumnVisibility.subscribe(() => { |
||||||
|
isVisible = true; // show navbar when store changes |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
onDestroy(() => { |
||||||
|
window.removeEventListener('scroll', handleScroll); |
||||||
|
unsubscribe(); |
||||||
|
}); |
||||||
|
</script> |
||||||
|
|
||||||
|
<nav class="Navbar navbar-leather flex fixed top-[60px] sm:top-[76px] w-full min-h-[70px] px-2 sm:px-4 py-2.5 z-10 transition-transform duration-300 {isVisible ? 'translate-y-0' : '-translate-y-full'}"> |
||||||
|
<div class="mx-auto flex space-x-2 container"> |
||||||
|
<div class="flex items-center space-x-2 md:min-w-52 min-w-8"> |
||||||
|
{#if shouldShowBack()} |
||||||
|
<Button class='btn-leather !w-auto sm:hidden' outline={true} onclick={backToMain}> |
||||||
|
<CaretLeftOutline class="!fill-none inline mr-1" /><span class="hidden sm:inline">Back</span> |
||||||
|
</Button> |
||||||
|
{/if} |
||||||
|
{#if !isLeaf} |
||||||
|
{#if publicationType === 'blog'} |
||||||
|
<Button class="btn-leather hidden sm:flex !w-auto {$publicationColumnVisibility.blog ? 'active' : ''}" |
||||||
|
outline={true} onclick={() => toggleColumn('blog')} > |
||||||
|
<BookOutline class="!fill-none inline mr-1" /><span class="hidden sm:inline">Table of Contents</span> |
||||||
|
</Button> |
||||||
|
{:else if !$publicationColumnVisibility.discussion && !$publicationColumnVisibility.toc} |
||||||
|
<Button class='btn-leather !w-auto' outline={true} onclick={() => toggleColumn('toc')}> |
||||||
|
<BookOutline class="!fill-none inline mr-1" /><span class="hidden sm:inline">Table of Contents</span> |
||||||
|
</Button> |
||||||
|
{/if} |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
<div class="flex flex-grow text justify-center items-center"> |
||||||
|
<p class="max-w-[60vw] line-ellipsis"><b class="text-nowrap">{title}</b> <span class="whitespace-nowrap">by {@render userBadge(pubkey, author)}</span></p> |
||||||
|
</div> |
||||||
|
<div class="flex justify-end items-center space-x-2 md:min-w-52 min-w-8"> |
||||||
|
{#if $publicationColumnVisibility.inner} |
||||||
|
<Button class='btn-leather !w-auto hidden sm:flex' outline={true} onclick={backToBlog}> |
||||||
|
<CloseOutline class="!fill-none inline mr-1" /><span class="hidden sm:inline">Close</span> |
||||||
|
</Button> |
||||||
|
{/if} |
||||||
|
{#if publicationType !== 'blog' && !$publicationColumnVisibility.discussion} |
||||||
|
<Button class="btn-leather !hidden sm:flex !w-auto" outline={true} onclick={() => toggleColumn('discussion')} > |
||||||
|
<GlobeOutline class="!fill-none inline mr-1" /><span class="hidden sm:inline">Discussion</span> |
||||||
|
</Button> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</nav> |
||||||
@ -1,27 +1,44 @@ |
|||||||
<script lang='ts'> |
<script lang='ts'> |
||||||
import { ClipboardCheckOutline, ClipboardCleanOutline } from "flowbite-svelte-icons"; |
import { ClipboardCheckOutline, ClipboardCleanOutline } from "flowbite-svelte-icons"; |
||||||
|
import { withTimeout } from "$lib/utils/nostrUtils"; |
||||||
|
import type { Component } from "svelte"; |
||||||
|
|
||||||
let { displayText, copyText = displayText} = $props(); |
let { displayText, copyText = displayText, icon = ClipboardCleanOutline } = $props<{ |
||||||
|
displayText: string; |
||||||
|
copyText?: string; |
||||||
|
icon?: Component | false; |
||||||
|
}>(); |
||||||
|
|
||||||
let copied: boolean = $state(false); |
let copied: boolean = $state(false); |
||||||
|
|
||||||
async function copyToClipboard() { |
async function copyToClipboard() { |
||||||
try { |
try { |
||||||
await navigator.clipboard.writeText(copyText); |
await withTimeout(navigator.clipboard.writeText(copyText), 2000); |
||||||
copied = true; |
copied = true; |
||||||
setTimeout(() => { |
await withTimeout( |
||||||
|
new Promise(resolve => setTimeout(resolve, 4000)), |
||||||
|
4000 |
||||||
|
).then(() => { |
||||||
copied = false; |
copied = false; |
||||||
}, 4000); |
}).catch(() => { |
||||||
|
// If timeout occurs, still reset the state |
||||||
|
copied = false; |
||||||
|
}); |
||||||
} catch (err) { |
} catch (err) { |
||||||
console.error("Failed to copy: ", err); |
console.error("[CopyToClipboard] Failed to copy:", err instanceof Error ? err.message : err); |
||||||
} |
} |
||||||
} |
} |
||||||
</script> |
</script> |
||||||
|
|
||||||
<button class='btn-leather text-nowrap' onclick={copyToClipboard}> |
<button class='btn-leather w-full text-left' onclick={copyToClipboard}> |
||||||
{#if copied} |
{#if copied} |
||||||
<ClipboardCheckOutline class="!fill-none dark:!fill-none inline mr-1" /> Copied! |
<ClipboardCheckOutline class="inline mr-2" /> Copied! |
||||||
{:else} |
{:else} |
||||||
<ClipboardCleanOutline class="!fill-none dark:!fill-none inline mr-1" /> {displayText} |
{#if icon === ClipboardCleanOutline} |
||||||
|
<ClipboardCleanOutline class="inline mr-2" /> |
||||||
|
{:else if icon === ClipboardCheckOutline} |
||||||
|
<ClipboardCheckOutline class="inline mr-2" /> |
||||||
|
{/if} |
||||||
|
{displayText} |
||||||
{/if} |
{/if} |
||||||
</button> |
</button> |
||||||
|
|||||||
@ -0,0 +1,110 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { userBadge } from "$lib/snippets/UserSnippets.svelte"; |
||||||
|
import CardActions from "$components/util/CardActions.svelte"; |
||||||
|
import Interactions from "$components/util/Interactions.svelte"; |
||||||
|
import { P } from "flowbite-svelte"; |
||||||
|
import { getMatchingTags } from '$lib/utils/nostrUtils'; |
||||||
|
|
||||||
|
// isModal |
||||||
|
// - don't show interactions in modal view |
||||||
|
// - don't show all the details when _not_ in modal view |
||||||
|
let { event, isModal = false } = $props(); |
||||||
|
|
||||||
|
let title: string = $derived(getMatchingTags(event, 'title')[0]?.[1]); |
||||||
|
let author: string = $derived(getMatchingTags(event, 'author')[0]?.[1] ?? 'unknown'); |
||||||
|
let version: string = $derived(getMatchingTags(event, 'version')[0]?.[1] ?? '1'); |
||||||
|
let image: string = $derived(getMatchingTags(event, 'image')[0]?.[1] ?? null); |
||||||
|
let originalAuthor: string = $derived(getMatchingTags(event, 'p')[0]?.[1] ?? null); |
||||||
|
let summary: string = $derived(getMatchingTags(event, 'summary')[0]?.[1] ?? null); |
||||||
|
let type: string = $derived(getMatchingTags(event, 'type')[0]?.[1] ?? null); |
||||||
|
let language: string = $derived(getMatchingTags(event, 'l')[0]?.[1] ?? null); |
||||||
|
let source: string = $derived(getMatchingTags(event, 'source')[0]?.[1] ?? null); |
||||||
|
let publisher: string = $derived(getMatchingTags(event, 'published_by')[0]?.[1] ?? null); |
||||||
|
let identifier: string = $derived(getMatchingTags(event, 'i')[0]?.[1] ?? null); |
||||||
|
let hashtags: string[] = $derived(getMatchingTags(event, 't').map(tag => tag[1])); |
||||||
|
let rootId: string = $derived(getMatchingTags(event, 'd')[0]?.[1] ?? null); |
||||||
|
let kind = $derived(event.kind); |
||||||
|
|
||||||
|
|
||||||
|
</script> |
||||||
|
|
||||||
|
|
||||||
|
<div class="flex flex-col relative mb-2"> |
||||||
|
{#if !isModal} |
||||||
|
<div class="flex flex-row justify-between items-center"> |
||||||
|
<P class='text-base font-normal'>{@render userBadge(event.pubkey, author)}</P> |
||||||
|
<CardActions event={event}></CardActions> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
<div class="flex-grow grid grid-cols-1 md:grid-cols-[auto_1fr] gap-4 items-center"> |
||||||
|
{#if image} |
||||||
|
<div class="my-2"> |
||||||
|
<img class="w-full md:max-w-48 object-contain rounded" alt={title} src={image} /> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
<div class="space-y-4 my-4"> |
||||||
|
<h1 class="text-3xl font-bold">{title}</h1> |
||||||
|
<h2 class="text-base font-bold"> |
||||||
|
by |
||||||
|
{#if originalAuthor !== null} |
||||||
|
{@render userBadge(originalAuthor, author)} |
||||||
|
{:else} |
||||||
|
{author} |
||||||
|
{/if} |
||||||
|
</h2> |
||||||
|
{#if version !== '1' } |
||||||
|
<h4 class="text-base font-thin">Version: {version}</h4> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if summary} |
||||||
|
<div class="flex flex-row my-2"> |
||||||
|
<p class='text-base text-primary-900 dark:text-highlight'>{summary}</p> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if hashtags.length} |
||||||
|
<div class="tags my-2"> |
||||||
|
{#each hashtags as tag} |
||||||
|
<span class="text-sm">#{tag}</span> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if isModal} |
||||||
|
<div class="flex flex-row my-4"> |
||||||
|
<h4 class='text-base font-normal mt-2'> |
||||||
|
{#if kind === 30040} |
||||||
|
<span>Index author:</span> |
||||||
|
{:else} |
||||||
|
<span>Author:</span> |
||||||
|
{/if} |
||||||
|
{@render userBadge(event.pubkey, author)} |
||||||
|
</h4> |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
|
<div class="flex flex-col pb-4 space-y-1"> |
||||||
|
{#if source !== null} |
||||||
|
<h5 class="text-sm">Source: <a class="underline break-all" href={source} target="_blank">{source}</a></h5> |
||||||
|
{/if} |
||||||
|
{#if type !== null} |
||||||
|
<h5 class="text-sm">Publication type: {type}</h5> |
||||||
|
{/if} |
||||||
|
{#if language !== null} |
||||||
|
<h5 class="text-sm">Language: {language}</h5> |
||||||
|
{/if} |
||||||
|
{#if publisher !== null} |
||||||
|
<h5 class="text-sm">Published by: {publisher}</h5> |
||||||
|
{/if} |
||||||
|
{#if identifier !== null} |
||||||
|
<h5 class="text-sm">{identifier}</h5> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if !isModal} |
||||||
|
<Interactions event={event} rootId={rootId} direction="row"/> |
||||||
|
{/if} |
||||||
@ -1,59 +0,0 @@ |
|||||||
<script lang='ts'> |
|
||||||
import { Avatar } from 'flowbite-svelte'; |
|
||||||
import { type NDKUserProfile } from "@nostr-dev-kit/ndk"; |
|
||||||
import { ndkInstance } from '$lib/ndk'; |
|
||||||
|
|
||||||
let { pubkey, title = null } = $props(); |
|
||||||
|
|
||||||
const externalProfileDestination = 'https://njump.me/' |
|
||||||
let loading = $state(true); |
|
||||||
let anon = $state(false); |
|
||||||
let npub = $state(''); |
|
||||||
|
|
||||||
let profile = $state<NDKUserProfile | null>(null); |
|
||||||
let pfp = $derived(profile?.image); |
|
||||||
let username = $derived(profile?.name); |
|
||||||
|
|
||||||
async function fetchUserData(pubkey: string) { |
|
||||||
let user; |
|
||||||
user = $ndkInstance |
|
||||||
.getUser({ pubkey: pubkey ?? undefined }); |
|
||||||
|
|
||||||
npub = user.npub; |
|
||||||
|
|
||||||
user.fetchProfile() |
|
||||||
.then(userProfile => { |
|
||||||
profile = userProfile; |
|
||||||
if (!profile?.name) anon = true; |
|
||||||
loading = false; |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
// Fetch data when component mounts |
|
||||||
$effect(() => { |
|
||||||
if (pubkey) { |
|
||||||
fetchUserData(pubkey); |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
function shortenNpub(long: string|undefined) { |
|
||||||
if (!long) return ''; |
|
||||||
return long.slice(0, 8) + '…' + long.slice(-4); |
|
||||||
} |
|
||||||
</script> |
|
||||||
|
|
||||||
{#if loading} |
|
||||||
{title ?? '…'} |
|
||||||
{:else if anon } |
|
||||||
<a class='underline' href='{externalProfileDestination}{npub}' title={title ?? npub} target='_blank'>{shortenNpub(npub)}</a> |
|
||||||
{:else if npub } |
|
||||||
<a href='{externalProfileDestination}{npub}' title={title ?? username} target='_blank'> |
|
||||||
<Avatar rounded |
|
||||||
class='h-6 w-6 mx-1 cursor-pointer inline' |
|
||||||
src={pfp} |
|
||||||
alt={username} /> |
|
||||||
<span class='underline'>{username ?? shortenNpub(npub)}</span> |
|
||||||
</a> |
|
||||||
{:else} |
|
||||||
{title ?? pubkey} |
|
||||||
{/if} |
|
||||||
@ -0,0 +1,93 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { |
||||||
|
Button, Modal, P |
||||||
|
} from "flowbite-svelte"; |
||||||
|
import { HeartOutline, FilePenOutline, AnnotationOutline } from 'flowbite-svelte-icons'; |
||||||
|
import ZapOutline from "$components/util/ZapOutline.svelte"; |
||||||
|
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||||
|
import { onMount } from "svelte"; |
||||||
|
import { ndkInstance } from '$lib/ndk'; |
||||||
|
import { publicationColumnVisibility } from "$lib/stores"; |
||||||
|
|
||||||
|
const { rootId, event, direction = 'row' } = $props<{ rootId: string, event?: NDKEvent, direction?: string }>(); |
||||||
|
|
||||||
|
// Reactive arrays to hold incoming events |
||||||
|
let likes: NDKEvent[] = []; |
||||||
|
let zaps: NDKEvent[] = []; |
||||||
|
let highlights: NDKEvent[] = []; |
||||||
|
let comments: NDKEvent[] = []; |
||||||
|
|
||||||
|
let interactionOpen: boolean = $state(false); |
||||||
|
|
||||||
|
// Reactive counts derived from array lengths |
||||||
|
// Derived counts from store values |
||||||
|
const likeCount = $derived(likes.length); |
||||||
|
const zapCount = $derived(zaps.length); |
||||||
|
const highlightCount = $derived(highlights.length); |
||||||
|
const commentCount = $derived(comments.length); |
||||||
|
|
||||||
|
/** |
||||||
|
* Subscribe to Nostr events of a given kind that reference our root event via e-tag. |
||||||
|
* Push new events into the provided array if not already present. |
||||||
|
* Returns the subscription for later cleanup. |
||||||
|
*/ |
||||||
|
function subscribeCount(kind: number, targetArray: NDKEvent[]) { |
||||||
|
const sub = $ndkInstance.subscribe({ |
||||||
|
kinds: [kind], |
||||||
|
'#a': [rootId] // Will this work? |
||||||
|
}); |
||||||
|
|
||||||
|
|
||||||
|
sub.on('event', (evt: NDKEvent) => { |
||||||
|
// Only add if we haven't seen this event ID yet |
||||||
|
if (!targetArray.find(e => e.id === evt.id)) { |
||||||
|
targetArray.push(evt); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
return sub; |
||||||
|
} |
||||||
|
|
||||||
|
let subs: any[] = []; |
||||||
|
|
||||||
|
onMount(() => { |
||||||
|
// Subscribe to each kind; store subs for cleanup |
||||||
|
subs.push(subscribeCount(7, likes)); // likes (Reaction) |
||||||
|
subs.push(subscribeCount(9735, zaps)); // zaps (Zap Receipts) |
||||||
|
subs.push(subscribeCount(30023, highlights)); // highlights (custom kind) |
||||||
|
subs.push(subscribeCount(1, comments)); // comments (Text Notes) |
||||||
|
}); |
||||||
|
|
||||||
|
function showDiscussion() { |
||||||
|
publicationColumnVisibility.update(v => { |
||||||
|
const updated = { ...v, discussion: true}; |
||||||
|
// hide blog, unless the only column |
||||||
|
if (v.inner) { |
||||||
|
updated.blog = (v.blog && window.innerWidth >= 1400 ); |
||||||
|
} |
||||||
|
return updated; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function doLike() { |
||||||
|
interactionOpen = true; |
||||||
|
} |
||||||
|
function doHighlight() { |
||||||
|
interactionOpen = true; |
||||||
|
} |
||||||
|
function doZap() { |
||||||
|
interactionOpen = true; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class='InteractiveMenu !hidden flex-{direction} justify-around align-middle text-primary-700 dark:text-gray-500'> |
||||||
|
<Button color="none" class='flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0' onclick={doLike}><HeartOutline class="mx-2" size="lg" /><span>{likeCount}</span></Button> |
||||||
|
<Button color="none" class='flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0' onclick={doZap}><ZapOutline className="mx-2" /><span>{zapCount}</span></Button> |
||||||
|
<Button color="none" class='flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0' onclick={doHighlight}><FilePenOutline class="mx-2" size="lg"/><span>{highlightCount}</span></Button> |
||||||
|
<Button color="none" class='flex flex-{direction} shrink-0 md:min-w-11 min-h-11 items-center p-0' onclick={showDiscussion}><AnnotationOutline class="mx-2" size="lg"/><span>{commentCount}</span></Button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<Modal class='modal-leather' title='Interaction' bind:open={interactionOpen} autoclose outsideclose size='sm'> |
||||||
|
<P>Can't like, zap or highlight yet.</P> |
||||||
|
<P>You should totally check out the discussion though.</P> |
||||||
|
</Modal> |
||||||
@ -0,0 +1,17 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
import QRCode from 'qrcode'; |
||||||
|
|
||||||
|
export let value: string; |
||||||
|
let canvas: HTMLCanvasElement; |
||||||
|
|
||||||
|
async function renderQR() { |
||||||
|
if (canvas && value) { |
||||||
|
await QRCode.toCanvas(canvas, value, { width: 240 }); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
onMount(renderQR); |
||||||
|
</script> |
||||||
|
|
||||||
|
<canvas class="qr-code" bind:this={canvas}></canvas> |
||||||
@ -0,0 +1,143 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { |
||||||
|
Heading, |
||||||
|
Sidebar, |
||||||
|
SidebarGroup, |
||||||
|
SidebarItem, |
||||||
|
SidebarWrapper, |
||||||
|
} from "flowbite-svelte"; |
||||||
|
import { onMount } from "svelte"; |
||||||
|
import { pharosInstance, tocUpdate } from "$lib/parser"; |
||||||
|
import { publicationColumnVisibility } from "$lib/stores"; |
||||||
|
|
||||||
|
let { rootId } = $props<{ rootId: string }>(); |
||||||
|
|
||||||
|
if (rootId !== $pharosInstance.getRootIndexId()) { |
||||||
|
console.error("Root ID does not match parser root index ID"); |
||||||
|
} |
||||||
|
|
||||||
|
const tocBreakpoint = 1140; |
||||||
|
|
||||||
|
let activeHash = $state(window.location.hash); |
||||||
|
|
||||||
|
interface TocItem { |
||||||
|
label: string; |
||||||
|
hash: string; |
||||||
|
} |
||||||
|
|
||||||
|
// Get TOC items from parser |
||||||
|
let tocItems = $state<TocItem[]>([]); |
||||||
|
|
||||||
|
$effect(() => { |
||||||
|
// This will re-run whenever tocUpdate changes |
||||||
|
tocUpdate; |
||||||
|
const items: TocItem[] = []; |
||||||
|
const childIds = $pharosInstance.getChildIndexIds(rootId); |
||||||
|
console.log('TOC rootId:', rootId, 'childIds:', childIds); |
||||||
|
const processNode = (nodeId: string) => { |
||||||
|
const title = $pharosInstance.getIndexTitle(nodeId); |
||||||
|
if (title) { |
||||||
|
items.push({ |
||||||
|
label: title, |
||||||
|
hash: `#${nodeId}` |
||||||
|
}); |
||||||
|
} |
||||||
|
const children = $pharosInstance.getChildIndexIds(nodeId); |
||||||
|
children.forEach(processNode); |
||||||
|
}; |
||||||
|
childIds.forEach(processNode); |
||||||
|
tocItems = items; |
||||||
|
}); |
||||||
|
|
||||||
|
function normalizeHashPath(str: string): string { |
||||||
|
return str |
||||||
|
.toLowerCase() |
||||||
|
.replace(/\s+/g, "-") |
||||||
|
.replace(/[^\w-]/g, ""); |
||||||
|
} |
||||||
|
|
||||||
|
function scrollToElementWithOffset() { |
||||||
|
const hash = window.location.hash; |
||||||
|
if (hash) { |
||||||
|
const targetElement = document.querySelector(hash); |
||||||
|
if (targetElement) { |
||||||
|
const headerOffset = 80; |
||||||
|
const elementPosition = targetElement.getBoundingClientRect().top; |
||||||
|
const offsetPosition = elementPosition + window.scrollY - headerOffset; |
||||||
|
|
||||||
|
window.scrollTo({ |
||||||
|
top: offsetPosition, |
||||||
|
behavior: "auto", |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function updateActiveHash() { |
||||||
|
activeHash = window.location.hash; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Hides the table of contents sidebar when the window shrinks below a certain size. This |
||||||
|
* prevents the sidebar from occluding the article content. |
||||||
|
*/ |
||||||
|
function setTocVisibilityOnResize() { |
||||||
|
// Always show TOC on laptop and larger screens, collapsible only on small/medium |
||||||
|
publicationColumnVisibility.update(v => ({ ...v, toc: window.innerWidth >= tocBreakpoint })); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Hides the table of contents sidebar when the user clicks outside of it. |
||||||
|
*/ |
||||||
|
function hideTocOnClick(ev: MouseEvent) { |
||||||
|
const target = ev.target as HTMLElement; |
||||||
|
|
||||||
|
if (target.closest(".sidebar-leather") || target.closest(".btn-leather")) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Only allow hiding TOC on screens smaller than tocBreakpoint |
||||||
|
if (window.innerWidth < tocBreakpoint && $publicationColumnVisibility.toc) { |
||||||
|
publicationColumnVisibility.update(v => ({ ...v, toc: false})); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
onMount(() => { |
||||||
|
// Always check whether the TOC sidebar should be visible. |
||||||
|
setTocVisibilityOnResize(); |
||||||
|
|
||||||
|
window.addEventListener("hashchange", updateActiveHash); |
||||||
|
window.addEventListener("hashchange", scrollToElementWithOffset); |
||||||
|
// Also handle the case where the user lands on the page with a hash in the URL |
||||||
|
scrollToElementWithOffset(); |
||||||
|
|
||||||
|
window.addEventListener("resize", setTocVisibilityOnResize); |
||||||
|
window.addEventListener("click", hideTocOnClick); |
||||||
|
|
||||||
|
return () => { |
||||||
|
window.removeEventListener("hashchange", updateActiveHash); |
||||||
|
window.removeEventListener("hashchange", scrollToElementWithOffset); |
||||||
|
window.removeEventListener("resize", setTocVisibilityOnResize); |
||||||
|
window.removeEventListener("click", hideTocOnClick); |
||||||
|
}; |
||||||
|
}); |
||||||
|
</script> |
||||||
|
|
||||||
|
<!-- TODO: Get TOC from parser. --> |
||||||
|
{#if $publicationColumnVisibility.toc} |
||||||
|
<Sidebar class='sidebar-leather left-0'> |
||||||
|
<SidebarWrapper> |
||||||
|
<SidebarGroup class='sidebar-group-leather'> |
||||||
|
<Heading tag="h1" class="h-leather !text-lg">Table of contents</Heading> |
||||||
|
<p>(This ToC is only for demo purposes, and is not fully-functional.)</p> |
||||||
|
{#each tocItems as item} |
||||||
|
<SidebarItem |
||||||
|
class="sidebar-item-leather {activeHash === item.hash ? 'bg-primary-200 font-bold' : ''}" |
||||||
|
label={item.label} |
||||||
|
href={item.hash} |
||||||
|
/> |
||||||
|
{/each} |
||||||
|
</SidebarGroup> |
||||||
|
</SidebarWrapper> |
||||||
|
</Sidebar> |
||||||
|
{/if} |
||||||
@ -0,0 +1,19 @@ |
|||||||
|
<script> |
||||||
|
export let size = 24; // default size |
||||||
|
export let className = ''; |
||||||
|
</script> |
||||||
|
|
||||||
|
<svg |
||||||
|
xmlns="http://www.w3.org/2000/svg" |
||||||
|
width={size} |
||||||
|
height={size} |
||||||
|
fill="none" |
||||||
|
stroke="currentColor" |
||||||
|
stroke-width="2" |
||||||
|
stroke-linecap="round" |
||||||
|
stroke-linejoin="round" |
||||||
|
class={className} |
||||||
|
viewBox="0 0 24 24" |
||||||
|
> |
||||||
|
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/> |
||||||
|
</svg> |
||||||
@ -0,0 +1,19 @@ |
|||||||
|
<script module lang='ts'> |
||||||
|
import { createProfileLink, createProfileLinkWithVerification, toNpub } from '$lib/utils/nostrUtils'; |
||||||
|
|
||||||
|
export { userBadge }; |
||||||
|
</script> |
||||||
|
|
||||||
|
{#snippet userBadge(identifier: string, displayText: string | undefined)} |
||||||
|
{#if toNpub(identifier)} |
||||||
|
{#await createProfileLinkWithVerification(toNpub(identifier) as string, displayText)} |
||||||
|
{@html createProfileLink(toNpub(identifier) as string, displayText)} |
||||||
|
{:then html} |
||||||
|
{@html html} |
||||||
|
{:catch} |
||||||
|
{@html createProfileLink(toNpub(identifier) as string, displayText)} |
||||||
|
{/await} |
||||||
|
{:else} |
||||||
|
{displayText ?? ''} |
||||||
|
{/if} |
||||||
|
{/snippet} |
||||||
@ -0,0 +1,4 @@ |
|||||||
|
import { writable } from 'svelte/store'; |
||||||
|
|
||||||
|
// Initialize with empty array, will be populated from user preferences
|
||||||
|
export const userRelays = writable<string[]>([]);
|
||||||
@ -0,0 +1,79 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { Heading, P } from "flowbite-svelte"; |
||||||
|
import { onMount } from "svelte"; |
||||||
|
import { page } from "$app/stores"; |
||||||
|
import type { NDKEvent } from '$lib/utils/nostrUtils'; |
||||||
|
import EventSearch from '$lib/components/EventSearch.svelte'; |
||||||
|
import EventDetails from '$lib/components/EventDetails.svelte'; |
||||||
|
import RelayActions from '$lib/components/RelayActions.svelte'; |
||||||
|
import CommentBox from '$lib/components/CommentBox.svelte'; |
||||||
|
|
||||||
|
let loading = $state(false); |
||||||
|
let error = $state<string | null>(null); |
||||||
|
let searchValue = $state<string | null>(null); |
||||||
|
let event = $state<NDKEvent | null>(null); |
||||||
|
let profile = $state<{ |
||||||
|
name?: string; |
||||||
|
display_name?: string; |
||||||
|
about?: string; |
||||||
|
picture?: string; |
||||||
|
banner?: string; |
||||||
|
website?: string; |
||||||
|
lud16?: string; |
||||||
|
nip05?: string; |
||||||
|
} | null>(null); |
||||||
|
let userPubkey = $state<string | null>(null); |
||||||
|
let userRelayPreference = $state(false); |
||||||
|
|
||||||
|
function handleEventFound(newEvent: NDKEvent) { |
||||||
|
event = newEvent; |
||||||
|
if (newEvent.kind === 0) { |
||||||
|
try { |
||||||
|
profile = JSON.parse(newEvent.content); |
||||||
|
} catch { |
||||||
|
profile = null; |
||||||
|
} |
||||||
|
} else { |
||||||
|
profile = null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
onMount(async () => { |
||||||
|
const id = $page.url.searchParams.get('id'); |
||||||
|
if (id) { |
||||||
|
searchValue = id; |
||||||
|
} |
||||||
|
|
||||||
|
// Get user's pubkey and relay preference from localStorage |
||||||
|
userPubkey = localStorage.getItem('userPubkey'); |
||||||
|
userRelayPreference = localStorage.getItem('useUserRelays') === 'true'; |
||||||
|
}); |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="w-full flex justify-center"> |
||||||
|
<main class="main-leather flex flex-col space-y-6 max-w-2xl w-full my-6 px-4"> |
||||||
|
<div class="flex justify-between items-center"> |
||||||
|
<Heading tag="h1" class="h-leather mb-2">Events</Heading> |
||||||
|
</div> |
||||||
|
|
||||||
|
<P class="mb-3"> |
||||||
|
Use this page to view any event (npub, nprofile, nevent, naddr, note, pubkey, or eventID). |
||||||
|
</P> |
||||||
|
|
||||||
|
<EventSearch {loading} {error} {searchValue} {event} onEventFound={handleEventFound} /> |
||||||
|
{#if event} |
||||||
|
<EventDetails {event} {profile} {searchValue} /> |
||||||
|
<RelayActions {event} /> |
||||||
|
{#if userPubkey} |
||||||
|
<div class="mt-8"> |
||||||
|
<Heading tag="h2" class="h-leather mb-4">Add Comment</Heading> |
||||||
|
<CommentBox event={event} userPubkey={userPubkey} userRelayPreference={userRelayPreference} /> |
||||||
|
</div> |
||||||
|
{:else} |
||||||
|
<div class="mt-8 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg"> |
||||||
|
<P>Please sign in to add comments.</P> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{/if} |
||||||
|
</main> |
||||||
|
</div> |
||||||
@ -0,0 +1,177 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { Heading, Img, P, A } from "flowbite-svelte"; |
||||||
|
|
||||||
|
// Get the git tag version from environment variables |
||||||
|
const appVersion = import.meta.env.APP_VERSION || "development"; |
||||||
|
const isVersionKnown = appVersion !== "development"; |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="w-full flex justify-center"> |
||||||
|
<main class="main-leather flex flex-col space-y-6 max-w-2xl w-full my-6 px-4"> |
||||||
|
<Heading tag="h1" class="h-leather mb-2" |
||||||
|
>Getting Started with Alexandria</Heading |
||||||
|
> |
||||||
|
|
||||||
|
<Heading tag="h2" class="h-leather mt-4 mb-2">Overview</Heading> |
||||||
|
|
||||||
|
<P class="mb-4"> |
||||||
|
Alexandria opens up to the <A href="./">landing page</A>, where the user |
||||||
|
can: login (top-right), select whether to only view the publications |
||||||
|
hosted on the <A href="https://thecitadel.nostr1.com/" target="_blank" |
||||||
|
>thecitadel document relay</A |
||||||
|
> or add in their own relays, and scroll/search the publications. |
||||||
|
</P> |
||||||
|
|
||||||
|
<div class="flex flex-col items-center space-y-4 my-4"> |
||||||
|
<Img |
||||||
|
src="/screenshots/LandingPage.png" |
||||||
|
alt="Landing page" |
||||||
|
class="image-border rounded-lg" |
||||||
|
width="400" |
||||||
|
/> |
||||||
|
<Img |
||||||
|
src="/screenshots/YourRelays.png" |
||||||
|
alt="Relay selection" |
||||||
|
class="image-border rounded-lg" |
||||||
|
width="400" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<P class="mb-3"> |
||||||
|
There is also the ability to view the publications as a diagram, if you |
||||||
|
click on "Visualize", and to publish an e-book or other document (coming |
||||||
|
soon). |
||||||
|
</P> |
||||||
|
|
||||||
|
<P class="mb-3"> |
||||||
|
If you click on a card, which represents a 30040 index event, the |
||||||
|
associated reading view opens to the publication. The app then pulls all |
||||||
|
of the content events (30041s and 30818s for wiki pages), in the order in |
||||||
|
which they are indexed, and displays them as a single document. |
||||||
|
</P> |
||||||
|
|
||||||
|
<P class="mb-3"> |
||||||
|
Each content section (30041 or 30818) is also a level in the table of |
||||||
|
contents, which can be accessed from the floating icon top-left in the |
||||||
|
reading view. This allows for navigation within the publication. |
||||||
|
Publications of type "blog" have a ToC which emphasizes that each entry |
||||||
|
is a blog post. |
||||||
|
|
||||||
|
(This functionality has been temporarily disabled, but the TOC is visible.) |
||||||
|
</P> |
||||||
|
|
||||||
|
<div class="flex flex-col items-center space-y-4 my-4"> |
||||||
|
<Img |
||||||
|
src="/screenshots/ToC_normal.png" |
||||||
|
alt="ToC basic" |
||||||
|
class="image-border rounded-lg" |
||||||
|
width="400" |
||||||
|
/> |
||||||
|
<Img |
||||||
|
src="/screenshots/ToC_blog.png" |
||||||
|
alt="ToC blog" |
||||||
|
class="image-border rounded-lg" |
||||||
|
width="400" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<Heading tag="h2" class="h-leather mt-4 mb-2">Typical use cases</Heading> |
||||||
|
|
||||||
|
<Heading tag="h3" class="h-leather mb-3">For e-books</Heading> |
||||||
|
|
||||||
|
<P class="mb-3"> |
||||||
|
The most common use for Alexandria is for e-books: both those the users |
||||||
|
have written themselves and those uploaded to Nostr from other sources. |
||||||
|
The first minor version of the app, Gutenberg, is focused on displaying |
||||||
|
and producing these publications. |
||||||
|
</P> |
||||||
|
|
||||||
|
<P class="mb-3"> |
||||||
|
An example of a book is <A |
||||||
|
href="/publication?d=jane-eyre-an-autobiography-by-charlotte-bront%C3%AB-v-3rd-edition" |
||||||
|
>Jane Eyre</A |
||||||
|
> |
||||||
|
</P> |
||||||
|
|
||||||
|
<div class="flex justify-center my-4"> |
||||||
|
<Img |
||||||
|
src="/screenshots/JaneEyre.png" |
||||||
|
alt="Jane Eyre, by Charlotte Brontë" |
||||||
|
class="image-border rounded-lg" |
||||||
|
width="400" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<Heading tag="h3" class="h-leather mb-3">For scientific papers</Heading> |
||||||
|
|
||||||
|
<P class="mb-3"> |
||||||
|
Alexandria will also display research papers with Asciimath and LaTeX |
||||||
|
embedding, and the normal advanced formatting options available for |
||||||
|
Asciidoc. In addition, we will be implementing special citation events, |
||||||
|
which will serve as an alternative or addition to the normal footnotes. |
||||||
|
</P> |
||||||
|
|
||||||
|
<P class="mb-3"> |
||||||
|
Correctly displaying such papers, integrating citations, and allowing them |
||||||
|
to be reviewed (with kind 1111 comments), and annotated (with highlights) |
||||||
|
by users, is the focus of the second minor version, Euler. |
||||||
|
</P> |
||||||
|
|
||||||
|
<P class="mb-3"> |
||||||
|
Euler will also pioneer the HTTP-based (rather than websocket-based) |
||||||
|
e-paper compatible version of the web app. |
||||||
|
</P> |
||||||
|
|
||||||
|
<P class="mb-3"> |
||||||
|
An example of a research paper is <A |
||||||
|
href="/publication?d=less-partnering-less-children-or-both-by-julia-hellstrand-v-1" |
||||||
|
>Less Partnering, Less Children, or Both?</A |
||||||
|
> |
||||||
|
</P> |
||||||
|
|
||||||
|
<div class="flex justify-center my-4"> |
||||||
|
<Img |
||||||
|
src="/screenshots/ResearchPaper.png" |
||||||
|
alt="Research paper" |
||||||
|
class="image-border rounded-lg" |
||||||
|
width="400" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<Heading tag="h3" class="h-leather mb-3">For documentation</Heading> |
||||||
|
|
||||||
|
<P class="mb-3"> |
||||||
|
Our own team uses Alexandria to document the app, to display our <A |
||||||
|
href="/publication?d=the-gitcitadel-blog-by-stella-v-1">blog entries</A |
||||||
|
>, as well as to store copies of our most interesting <A |
||||||
|
href="/publication?d=gitcitadel-project-documentation-by-stella-v-1" |
||||||
|
>technical specifications</A |
||||||
|
>. |
||||||
|
</P> |
||||||
|
|
||||||
|
<div class="flex justify-center my-4"> |
||||||
|
<Img |
||||||
|
src="/screenshots/Documentation.png" |
||||||
|
alt="Documentation" |
||||||
|
class="image-border rounded-lg" |
||||||
|
width="400" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<Heading tag="h3" class="h-leather mb-3">For wiki pages</Heading> |
||||||
|
|
||||||
|
<P class="mb-3"> |
||||||
|
Alexandria now supports wiki pages (kind 30818), allowing for |
||||||
|
collaborative knowledge bases and documentation. Wiki pages, such as this |
||||||
|
one about the <A href="/publication?d=sybil">Sybil utility</A> use the same |
||||||
|
Asciidoc format as other publications but are specifically designed for interconnected, |
||||||
|
evolving content. |
||||||
|
</P> |
||||||
|
|
||||||
|
<P class="mb-3"> |
||||||
|
Wiki pages can be linked to from other publications and can contain links |
||||||
|
to other wiki pages, creating a web of knowledge that can be navigated and |
||||||
|
explored. |
||||||
|
</P> |
||||||
|
</main> |
||||||
|
</div> |
||||||
@ -1,3 +1,9 @@ |
|||||||
@tailwind base; |
@tailwind base; |
||||||
@tailwind components; |
@tailwind components; |
||||||
@tailwind utilities; |
@tailwind utilities; |
||||||
|
|
||||||
|
@layer components { |
||||||
|
body { |
||||||
|
@apply bg-primary-0 dark:bg-primary-1000; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,5 @@ |
|||||||
|
@layer components { |
||||||
|
canvas.qr-code { |
||||||
|
@apply block mx-auto my-4; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,20 @@ |
|||||||
|
@layer components { |
||||||
|
/* Global scrollbar styles */ |
||||||
|
* { |
||||||
|
scrollbar-color: rgba(87, 66, 41, 0.8) transparent; /* Transparent track, default scrollbar thumb */ |
||||||
|
} |
||||||
|
|
||||||
|
/* Webkit Browsers (Chrome, Safari, Edge) */ |
||||||
|
*::-webkit-scrollbar { |
||||||
|
width: 12px; /* Thin scrollbar */ |
||||||
|
} |
||||||
|
|
||||||
|
*::-webkit-scrollbar-track { |
||||||
|
background: transparent; /* Fully transparent track */ |
||||||
|
} |
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb { |
||||||
|
@apply bg-primary-500 dark:bg-primary-600 hover:bg-primary-600 dark:hover:bg-primary-800;; |
||||||
|
border-radius: 6px; /* Rounded scrollbar */ |
||||||
|
} |
||||||
|
} |
||||||
|
After Width: | Height: | Size: 385 KiB |
|
After Width: | Height: | Size: 205 KiB |
Loading…
Reference in new issue