@ -1,521 +1,136 @@
@@ -1,521 +1,136 @@
< script lang = "ts" >
import Header from '../../../lib/components/layout/Header.svelte';
import FeedPost from '../../../lib/modules/feed/FeedPost.svelte';
import MetadataCard from '../../../lib/components/content/MetadataCard.svelte';
import MarkdownRenderer from '../../../lib/components/content/MarkdownRenderer.svelte';
import EventMenu from '../../../lib/components/EventMenu.svelte';
import ThreadView from '../../../lib/modules/threads/ThreadView.svelte';
import { nostrClient } from '../../../lib/services/nostr/nostr-client.js';
import { relayManager } from '../../../lib/services/nostr/relay-manager.js';
import { loadEventIndex , type EventIndexItem } from '../../../lib/services/nostr/event-index-loader.js';
import { signAndPublish } from '../../../lib/services/nostr/auth-handler.js';
import { sessionManager } from '../../../lib/services/auth/session-manager.js';
import PublicationStatusModal from '../../../lib/components/modals/PublicationStatusModal.svelte';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { nip19 } from 'nostr-tools';
import { goto } from '$app/navigation';
import type { NostrEvent } from '../../../lib/types/nostr.js';
import { getKindInfo , isReplaceableKind , isParameterizedReplaceableKind , KIND } from '../../../lib/types/kind-lookup.js';
import { shouldIncludeClientTag } from '../../../lib/services/client-tag-preference.js';
let event = $state< NostrEvent | null > (null);
let loading = $state(tru e);
let decodedEventId = $state< string | null > (null);
let loading = $state(false);
let error = $state< string | null > (null);
let indexItems = $state< EventIndexItem [ ] > ([]);
let loadingIndex = $state(false);
let labeling = $state(false);
let publicationModalOpen = $state(false);
let publicationResults = $state< { success : string []; failed : Array < { relay : string ; error : string } > } | null>(null);
onMount(async () => {
await nostrClient.initialize();
await loadEvent();
});
$effect(() => {
if ($page.params.id) {
loadEvent();
}
});
async function loadEvent() {
if (!$page.params.id) return;
loading = true;
error = null;
try {
const eventId = decodeEventId($page.params.id);
if (!eventId) {
error = 'Invalid event ID format';
loading = false;
return;
}
const relays = relayManager.getProfileReadRelays();
const events = await nostrClient.fetchEvents(
[{ ids : [ eventId ], limit : 1 } ],
relays,
{ useCache : true , cacheResults : true }
);
if (events.length === 0) {
error = 'Event not found';
} else {
event = events[0];
// If kind 30040, load event index
if (event.kind === 30040) {
await loadIndex();
}
}
} catch (err) {
console.error('Error loading event:', err);
error = 'Failed to load event';
} finally {
loading = false;
}
}
function decodeEventId(param: string): string | null {
/**
* Decode route parameter to event hex ID
* Supports: hex event id, note, nevent, naddr
*/
async function decodeEventId(param: string): Promise< string | null > {
if (!param) return null;
// Check if it's already a hex event ID
// Check if it's already a hex event ID (64 hex characters)
if (/^[0-9a-f]{ 64 } $/i.test(param)) {
return param.toLowerCase();
}
// Check if it's a bech32 encoded format
// Check if it's a bech32 encoded format (note, nevent, naddr)
if (/^(note|nevent|naddr)1[a-z0-9]+$/i.test(param)) {
try {
const decoded = nip19.decode(param);
if (decoded.type === 'note') {
return String(decoded.data);
} else if (decoded.type === 'nevent') {
// nevent contains event id and optional relays
if (decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) {
return String(decoded.data.id);
}
} else if (decoded.type === 'naddr') {
// For naddr, we need to fetch by kind+pubkey+d
// This is handled separately
return null;
}
} catch (err) {
console.error('Error decoding bech32:', err);
return null;
}
}
return null;
}
async function loadIndex() {
if (!event || event.kind !== 30040) return;
loadingIndex = true;
try {
indexItems = await loadEventIndex(event);
} catch (err) {
console.error('Error loading event index:', err);
} finally {
loadingIndex = false;
}
}
function getSectionTitle(item: EventIndexItem): string {
const titleTag = item.event.tags.find(t => t[0] === 'title' & & t[1]);
if (titleTag) {
return titleTag[1];
}
// naddr is for parameterized replaceable events (kind + pubkey + d tag)
// Fetch the event using kind, pubkey, and d tag
if (decoded.data && typeof decoded.data === 'object' && 'kind' in decoded.data && 'pubkey' in decoded.data) {
const naddrData = decoded.data as { kind : number ; pubkey : string ; identifier? : string ; relays? : string [] } ;
const kind = naddrData.kind;
const pubkey = naddrData.pubkey;
const dTag = naddrData.identifier || '';
// Use relays from naddr if provided, otherwise use default relays
const relays = naddrData.relays & & naddrData.relays.length > 0
? naddrData.relays
: relayManager.getProfileReadRelays();
// Fetch the event by kind, pubkey, and d tag
const filter: any = {
kinds: [kind],
authors: [pubkey],
limit: 1
};
// Fallback to d-tag in Title Case
const dTag = item.event.tags.find(t => t[0] === 'd' & & t[1])?.[1];
// Only add #d filter if d tag is present
if (dTag) {
return dTag.split('-').map(word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
).join(' ');
}
return `Section ${ item . order + 1 } `;
filter['#d'] = [dTag];
}
function openSectionInNewWindow(item: EventIndexItem) {
window.open(`/event/${ item . event . id } `, '_blank');
}
async function labelAsBook() {
if (!event) return;
const filters = [filter];
const session = sessionManager.getSession();
if (!session) {
alert('You must be logged in to label events');
return;
}
labeling = true;
publicationResults = null;
try {
const tags: string[][] = [
['L', 'ugc'], // Namespace tag
['l', 'booklist', 'ugc'], // Label tag
];
const events = await nostrClient.fetchEvents(
filters,
relays,
{ useCache : true , cacheResults : true }
);
// Add reference to the event
// For kind 30040 (replaceable), use 'a' tag with kind:pubkey:d-tag
// For other events, use 'e' tag with event ID
if (event.kind === 30040) {
const dTag = event.tags.find(t => t[0] === 'd' & & t[1])?.[1];
if (dTag) {
const aTag = `${ event . kind } :${ event . pubkey } :${ dTag } `;
// Add relay hint if available
const relayUrl = relayManager.getFeedReadRelays()[0];
if (relayUrl) {
tags.push(['a', aTag, relayUrl]);
if (events.length > 0) {
return events[0].id;
} else {
tags.push(['a', aTag]);
console.warn('Event not found for naddr:', param);
return null;
}
} else {
// Fallback to 'e' tag if no d-tag found
const relayUrl = relayManager.getFeedReadRelays()[0];
if (relayUrl) {
tags.push(['e', event.id, relayUrl]);
} else {
tags.push(['e', event.id]);
}
}
} else {
// Regular event - use 'e' tag
const relayUrl = relayManager.getFeedReadRelays()[0];
if (relayUrl) {
tags.push(['e', event.id, relayUrl]);
} else {
tags.push(['e', event.id]);
} catch (error) {
console.error('Error decoding bech32:', error);
return null;
}
}
// Add client tag (NIP-89)
if (shouldIncludeClientTag()) {
tags.push(['client', 'Aitherboard']);
return null;
}
const eventTemplate = {
kind: KIND.LABEL, // 1985
content: '',
tags,
created_at: Math.floor(Date.now() / 1000),
pubkey: session.pubkey,
};
async function loadEvent() {
if (!$page.params.id) return;
const config = nostrClient.getConfig();
const relays = relayManager.getPublishRelays(config.defaultRelays);
const results = await signAndPublish(eventTemplate, relays);
publicationResults = results;
publicationModalOpen = true;
loading = true;
error = null;
decodedEventId = null;
if (results.success.length > 0) {
// Success - could show a success message
console.log('Event labeled as book successfully');
try {
const eventId = await decodeEventId($page.params.id);
if (eventId) {
decodedEventId = eventId;
} else {
console.error('Failed to publish label event') ;
error = 'Event not found or invalid format' ;
}
} catch (err) {
console.error('Error labeling event as book :', err);
alert('Failed to label event as book. Please try again.') ;
console.error('Error loading event :', err);
error = 'Failed to load event' ;
} finally {
label ing = false;
load ing = false;
}
}
const isReplaceable = $derived(event ? isReplaceableKind(event.kind) || isParameterizedReplaceableKind(event.kind) : false);
const hasContent = $derived(event ? !!event.content : false);
const isKind30040 = $derived(event?.kind === 30040);
const isLoggedIn = $derived(sessionManager.isLoggedIn());
onMount(async () => {
await nostrClient.initialize();
await loadEvent();
});
$effect(() => {
if ($page.params.id) {
loadEvent();
}
});
< / script >
< Header / >
< main class = "container mx-auto px-4 py-8" >
{ #if loading }
< div class = "loading-state" >
< p class = "text-fog-text dark:text-fog-dark-text" > Loading event...< / p >
< / div >
{ :else if error }
< div class = "error-state" >
< p class = "text-fog-text dark:text-fog-dark-text" > { error } </ p >
< / div >
{ :else if event }
< div class = "event-page" >
{ #if isReplaceable }
< div class = "metadata-wrapper" >
< MetadataCard event = { event } / >
{ #if isKind30040 && isLoggedIn }
< div class = "metadata-actions" >
< EventMenu event = { event } showContentActions= { false } />
< button class = "btn-label-book" onclick = { labelAsBook } disabled= { labeling } >
{ labeling ? 'Labeling...' : 'Label as book' }
< / button >
< / div >
{ /if }
< / div >
{ /if }
{ #if isKind30040 }
{ #if loadingIndex }
< div class = "loading-index" >
< p class = "text-fog-text dark:text-fog-dark-text" > Loading publication...< / p >
< / div >
{ :else if indexItems . length > 0 }
{ #each indexItems as item ( item . event . id )}
< div class = "section-item" >
< div class = "section-header" >
< h3 class = "section-title" > { getSectionTitle ( item )} </ h3 >
< div class = "section-actions" >
< EventMenu event = { item . event } showContentActions= { false } />
< button class = "btn-open-window" onclick = {() => openSectionInNewWindow ( item )} >
Open in new window
< / button >
< / div >
< / div >
< div class = "section-content" >
< MarkdownRenderer content = { item . event . content } event= { item . event } />
< / div >
< / div >
{ /each }
{ /if }
{ :else if hasContent }
< div class = "event-content" >
< MarkdownRenderer content = { event . content } event= { event } />
< / div >
{ :else if decodedEventId }
< ThreadView threadId = { decodedEventId } / >
{ :else if $page . params . id }
< p class = "text-fog-text dark:text-fog-dark-text" > Invalid event ID format. Supported: hex event ID, note, nevent, or naddr< / p >
{ : else }
< div class = "event-tags" >
< h3 class = "tags-title" > Tags< / h3 >
< div class = "tags-list" >
{ #each event . tags as tag }
< div class = "tag-item" >
< span class = "tag-name" > { tag [ 0 ]} </ span >
{ #each tag . slice ( 1 ) as value }
< span class = "tag-value" > { value } </ span >
{ /each }
< / div >
{ /each }
< / div >
< / div >
{ /if }
< div class = "event-details" >
< FeedPost post = { event } / >
< / div >
< / div >
< p class = "text-fog-text dark:text-fog-dark-text" > Event ID required< / p >
{ /if }
< / main >
< style >
main {
max-width: var(--content-width);
margin: 0 auto;
}
.loading-state,
.error-state {
padding: 2rem;
text-align: center;
}
.event-page {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.event-content {
padding: 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
background: var(--fog-post, #ffffff);
}
:global(.dark) .event-content {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.event-tags {
padding: 1rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
background: var(--fog-post, #ffffff);
}
:global(.dark) .event-tags {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.tags-title {
margin: 0 0 1rem 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .tags-title {
color: var(--fog-dark-text, #f9fafb);
}
.tags-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.tag-item {
display: flex;
gap: 0.5rem;
padding: 0.5rem;
background: var(--fog-highlight, #f3f4f6);
border-radius: 0.25rem;
}
:global(.dark) .tag-item {
background: var(--fog-dark-highlight, #374151);
}
.tag-name {
font-weight: 600;
color: var(--fog-accent, #64748b);
}
:global(.dark) .tag-name {
color: var(--fog-dark-accent, #94a3b8);
}
.tag-value {
color: var(--fog-text, #1f2937);
word-break: break-all;
}
:global(.dark) .tag-value {
color: var(--fog-dark-text, #f9fafb);
}
.event-details {
padding: 1rem;
}
.metadata-wrapper {
position: relative;
}
.metadata-actions {
display: flex;
gap: 0.5rem;
align-items: center;
margin-top: 0.5rem;
}
.btn-label-book {
padding: 0.5rem 1rem;
background: var(--fog-accent, #64748b);
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
}
:global(.dark) .btn-label-book {
background: var(--fog-dark-accent, #94a3b8);
}
.btn-label-book:hover {
opacity: 0.9;
}
.loading-index {
padding: 2rem;
text-align: center;
}
.section-item {
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
background: var(--fog-post, #ffffff);
}
:global(.dark) .section-item {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .section-header {
border-bottom-color: var(--fog-dark-border, #374151);
}
.section-title {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .section-title {
color: var(--fog-dark-text, #f9fafb);
}
.section-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.btn-open-window {
padding: 0.25rem 0.75rem;
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
}
:global(.dark) .btn-open-window {
background: var(--fog-dark-highlight, #374151);
color: var(--fog-dark-text, #f9fafb);
border-color: var(--fog-dark-border, #475569);
}
.btn-open-window:hover {
background: var(--fog-border, #e5e7eb);
}
:global(.dark) .btn-open-window:hover {
background: var(--fog-dark-border, #475569);
}
.section-content {
padding: 0.5rem 0;
}
.btn-label-book:hover:not(:disabled) {
opacity: 0.9;
}
.btn-label-book:disabled {
opacity: 0.5;
cursor: not-allowed;
}
< / style >
< PublicationStatusModal bind:open = { publicationModalOpen } bind:results= { publicationResults } />