You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
529 lines
19 KiB
529 lines
19 KiB
import { ExtendedKind } from '@/constants' |
|
import { Event } from 'nostr-tools' |
|
import { useEffect, useMemo, useState } from 'react' |
|
import { cn } from '@/lib/utils' |
|
import AsciidocArticle from '../AsciidocArticle/AsciidocArticle' |
|
import MarkdownArticle from '../MarkdownArticle/MarkdownArticle' |
|
import { generateBech32IdFromATag } from '@/lib/tag' |
|
import client from '@/services/client.service' |
|
import logger from '@/lib/logger' |
|
import { Button } from '@/components/ui/button' |
|
import { MoreVertical } from 'lucide-react' |
|
import indexedDb from '@/services/indexed-db.service' |
|
import { isReplaceableEvent } from '@/lib/event' |
|
|
|
interface PublicationReference { |
|
coordinate?: string |
|
eventId?: string |
|
event?: Event |
|
kind?: number |
|
pubkey?: string |
|
identifier?: string |
|
relay?: string |
|
type: 'a' | 'e' // 'a' for addressable (coordinate), 'e' for event ID |
|
} |
|
|
|
interface ToCItem { |
|
title: string |
|
coordinate: string |
|
event?: Event |
|
kind: number |
|
children?: ToCItem[] |
|
} |
|
|
|
interface PublicationMetadata { |
|
title?: string |
|
summary?: string |
|
image?: string |
|
author?: string |
|
version?: string |
|
type?: string |
|
tags: string[] |
|
} |
|
|
|
export default function PublicationIndex({ |
|
event, |
|
className |
|
}: { |
|
event: Event |
|
className?: string |
|
}) { |
|
// Parse publication metadata from event tags |
|
const metadata = useMemo<PublicationMetadata>(() => { |
|
const meta: PublicationMetadata = { tags: [] } |
|
|
|
for (const [tagName, tagValue] of event.tags) { |
|
if (tagName === 'title') { |
|
meta.title = tagValue |
|
} else if (tagName === 'summary') { |
|
meta.summary = tagValue |
|
} else if (tagName === 'image') { |
|
meta.image = tagValue |
|
} else if (tagName === 'author') { |
|
meta.author = tagValue |
|
} else if (tagName === 'version') { |
|
meta.version = tagValue |
|
} else if (tagName === 'type') { |
|
meta.type = tagValue |
|
} else if (tagName === 't' && tagValue) { |
|
meta.tags.push(tagValue.toLowerCase()) |
|
} |
|
} |
|
|
|
// Fallback title from d-tag if no title |
|
if (!meta.title) { |
|
meta.title = event.tags.find(tag => tag[0] === 'd')?.[1] |
|
} |
|
|
|
return meta |
|
}, [event]) |
|
const [references, setReferences] = useState<PublicationReference[]>([]) |
|
const [visitedIndices, setVisitedIndices] = useState<Set<string>>(new Set()) |
|
const [isLoading, setIsLoading] = useState(true) |
|
|
|
// Build table of contents from references |
|
const tableOfContents = useMemo<ToCItem[]>(() => { |
|
const toc: ToCItem[] = [] |
|
|
|
for (const ref of references) { |
|
if (!ref.event) continue |
|
|
|
// Extract title from the event |
|
const title = ref.event.tags.find(tag => tag[0] === 'title')?.[1] || |
|
ref.event.tags.find(tag => tag[0] === 'd')?.[1] || |
|
'Untitled' |
|
|
|
const tocItem: ToCItem = { |
|
title, |
|
coordinate: ref.coordinate || ref.eventId || '', |
|
event: ref.event, |
|
kind: ref.kind || ref.event?.kind || 0 |
|
} |
|
|
|
// For nested 30040 publications, recursively get their ToC |
|
if ((ref.kind === ExtendedKind.PUBLICATION || ref.event?.kind === ExtendedKind.PUBLICATION) && ref.event) { |
|
const nestedRefs: ToCItem[] = [] |
|
|
|
// Parse nested references from this publication (both 'a' and 'e' tags) |
|
for (const tag of ref.event.tags) { |
|
if (tag[0] === 'a' && tag[1]) { |
|
const [kindStr, , identifier] = tag[1].split(':') |
|
const kind = parseInt(kindStr) |
|
|
|
if (!isNaN(kind) && kind === ExtendedKind.PUBLICATION_CONTENT || |
|
kind === ExtendedKind.WIKI_ARTICLE || |
|
kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN || |
|
kind === ExtendedKind.PUBLICATION) { |
|
// For this simplified version, we'll just extract the title from the coordinate |
|
const nestedTitle = identifier || 'Untitled' |
|
|
|
nestedRefs.push({ |
|
title: nestedTitle, |
|
coordinate: tag[1], |
|
kind |
|
}) |
|
} |
|
} else if (tag[0] === 'e' && tag[1]) { |
|
// For 'e' tags, we can't extract title from the tag alone |
|
// The title will come from the fetched event if available |
|
const nestedTitle = ref.event?.tags.find(t => t[0] === 'title')?.[1] || 'Untitled' |
|
|
|
nestedRefs.push({ |
|
title: nestedTitle, |
|
coordinate: tag[1], // Use event ID as coordinate |
|
kind: ref.event?.kind |
|
}) |
|
} |
|
} |
|
|
|
if (nestedRefs.length > 0) { |
|
tocItem.children = nestedRefs |
|
} |
|
} |
|
|
|
toc.push(tocItem) |
|
} |
|
|
|
return toc |
|
}, [references]) |
|
|
|
// Scroll to section |
|
const scrollToSection = (coordinate: string) => { |
|
const element = document.getElementById(`section-${coordinate.replace(/:/g, '-')}`) |
|
if (element) { |
|
element.scrollIntoView({ behavior: 'smooth', block: 'start' }) |
|
} |
|
} |
|
|
|
// Export publication as AsciiDoc |
|
const exportPublication = async () => { |
|
try { |
|
// Collect all content from references |
|
const contentParts: string[] = [] |
|
|
|
for (const ref of references) { |
|
if (!ref.event) continue |
|
|
|
// Extract title |
|
const title = ref.event.tags.find(tag => tag[0] === 'title')?.[1] || 'Untitled' |
|
|
|
// For AsciiDoc, output the raw content with title |
|
contentParts.push(`= ${title}\n\n${ref.event.content}\n\n`) |
|
} |
|
|
|
const fullContent = contentParts.join('\n') |
|
const filename = `${metadata.title || 'publication'}.adoc` |
|
|
|
// Export as AsciiDoc |
|
const blob = new Blob([fullContent], { type: 'text/plain' }) |
|
|
|
const url = URL.createObjectURL(blob) |
|
const a = document.createElement('a') |
|
a.href = url |
|
a.download = filename |
|
document.body.appendChild(a) |
|
a.click() |
|
document.body.removeChild(a) |
|
URL.revokeObjectURL(url) |
|
|
|
logger.info('[PublicationIndex] Exported publication as .adoc') |
|
} catch (error) { |
|
logger.error('[PublicationIndex] Error exporting publication:', error) |
|
alert('Failed to export publication. Please try again.') |
|
} |
|
} |
|
|
|
// Extract references from 'a' tags (addressable events) and 'e' tags (event IDs) |
|
const referencesData = useMemo(() => { |
|
const refs: PublicationReference[] = [] |
|
for (const tag of event.tags) { |
|
if (tag[0] === 'a' && tag[1]) { |
|
// Addressable event (kind:pubkey:identifier) |
|
const [kindStr, pubkey, identifier] = tag[1].split(':') |
|
const kind = parseInt(kindStr) |
|
if (!isNaN(kind)) { |
|
refs.push({ |
|
type: 'a', |
|
coordinate: tag[1], |
|
kind, |
|
pubkey, |
|
identifier: identifier || '', |
|
relay: tag[2], |
|
eventId: tag[3] // Optional event ID for version tracking |
|
}) |
|
} |
|
} else if (tag[0] === 'e' && tag[1]) { |
|
// Event ID reference |
|
refs.push({ |
|
type: 'e', |
|
eventId: tag[1], |
|
relay: tag[2] |
|
}) |
|
} |
|
} |
|
return refs |
|
}, [event]) |
|
|
|
// Add current event to visited set |
|
const currentCoordinate = useMemo(() => { |
|
const dTag = event.tags.find(tag => tag[0] === 'd')?.[1] || '' |
|
return `${event.kind}:${event.pubkey}:${dTag}` |
|
}, [event]) |
|
|
|
useEffect(() => { |
|
setVisitedIndices(prev => new Set([...prev, currentCoordinate])) |
|
|
|
// Cache the current publication index event as replaceable event |
|
indexedDb.putReplaceableEvent(event).catch(err => { |
|
logger.error('[PublicationIndex] Error caching publication event:', err) |
|
}) |
|
}, [currentCoordinate, event]) |
|
|
|
// Fetch referenced events |
|
useEffect(() => { |
|
let isMounted = true |
|
|
|
const fetchReferences = async () => { |
|
setIsLoading(true) |
|
const fetchedRefs: PublicationReference[] = [] |
|
|
|
// Capture current visitedIndices at the start of the fetch |
|
const currentVisited = visitedIndices |
|
|
|
// Add a timeout to prevent infinite loading on mobile |
|
const timeout = setTimeout(() => { |
|
if (isMounted) { |
|
logger.warn('[PublicationIndex] Fetch timeout reached, setting loaded state') |
|
setIsLoading(false) |
|
} |
|
}, 30000) // 30 second timeout |
|
|
|
try { |
|
for (const ref of referencesData) { |
|
if (!isMounted) break |
|
|
|
// Skip if this is a 30040 event we've already visited (prevent circular references) |
|
if (ref.type === 'a' && ref.kind === ExtendedKind.PUBLICATION && ref.coordinate) { |
|
if (currentVisited.has(ref.coordinate)) { |
|
logger.debug('[PublicationIndex] Skipping visited 30040 index:', ref.coordinate) |
|
fetchedRefs.push({ ...ref, event: undefined }) |
|
continue |
|
} |
|
} |
|
|
|
try { |
|
let fetchedEvent: Event | undefined = undefined |
|
|
|
if (ref.type === 'a' && ref.coordinate) { |
|
// Handle addressable event (a tag) |
|
const aTag = ['a', ref.coordinate, ref.relay || '', ref.eventId || ''] |
|
const bech32Id = generateBech32IdFromATag(aTag) |
|
|
|
if (bech32Id) { |
|
// Try to get by coordinate (replaceable event) |
|
fetchedEvent = await indexedDb.getPublicationEvent(ref.coordinate) |
|
|
|
// If not found, try to fetch from relay |
|
if (!fetchedEvent) { |
|
fetchedEvent = await client.fetchEvent(bech32Id) |
|
// Save to cache as replaceable event |
|
if (fetchedEvent) { |
|
await indexedDb.putReplaceableEvent(fetchedEvent) |
|
logger.debug('[PublicationIndex] Cached event with coordinate:', ref.coordinate) |
|
} |
|
} else { |
|
logger.debug('[PublicationIndex] Loaded from cache by coordinate:', ref.coordinate) |
|
} |
|
} else { |
|
logger.warn('[PublicationIndex] Could not generate bech32 ID for:', ref.coordinate) |
|
} |
|
} else if (ref.type === 'e' && ref.eventId) { |
|
// Handle event ID reference (e tag) |
|
// Try to fetch by event ID first |
|
fetchedEvent = await client.fetchEvent(ref.eventId) |
|
|
|
if (fetchedEvent) { |
|
// Check if this is a replaceable event kind |
|
if (isReplaceableEvent(fetchedEvent.kind)) { |
|
// Save to cache as replaceable event (will be linked to master via putPublicationWithNestedEvents) |
|
await indexedDb.putReplaceableEvent(fetchedEvent) |
|
logger.debug('[PublicationIndex] Cached replaceable event with ID:', ref.eventId) |
|
} else { |
|
// For non-replaceable events, we'll link them to master later via putPublicationWithNestedEvents |
|
// Just cache them for now without master link - they'll be properly linked when we call putPublicationWithNestedEvents |
|
logger.debug('[PublicationIndex] Cached non-replaceable event with ID (will link to master):', ref.eventId) |
|
} |
|
} else { |
|
logger.warn('[PublicationIndex] Could not fetch event for ID:', ref.eventId) |
|
} |
|
} |
|
|
|
if (fetchedEvent && isMounted) { |
|
fetchedRefs.push({ ...ref, event: fetchedEvent }) |
|
} else if (isMounted) { |
|
const identifier = ref.type === 'a' ? ref.coordinate : ref.eventId |
|
logger.warn('[PublicationIndex] Could not fetch event for:', identifier || 'unknown') |
|
fetchedRefs.push({ ...ref, event: undefined }) |
|
} |
|
} catch (error) { |
|
logger.error('[PublicationIndex] Error fetching reference:', error) |
|
if (isMounted) { |
|
fetchedRefs.push({ ...ref, event: undefined }) |
|
} |
|
} |
|
} |
|
|
|
if (isMounted) { |
|
setReferences(fetchedRefs) |
|
setIsLoading(false) |
|
|
|
// Store master publication with all nested events |
|
const nestedEvents = fetchedRefs.filter(ref => ref.event).map(ref => ref.event!).filter((e): e is Event => e !== undefined) |
|
if (nestedEvents.length > 0) { |
|
indexedDb.putPublicationWithNestedEvents(event, nestedEvents).catch(err => { |
|
logger.error('[PublicationIndex] Error caching publication with nested events:', err) |
|
}) |
|
} |
|
} |
|
} finally { |
|
clearTimeout(timeout) |
|
} |
|
} |
|
|
|
if (referencesData.length > 0) { |
|
fetchReferences() |
|
} else { |
|
setIsLoading(false) |
|
} |
|
|
|
return () => { |
|
isMounted = false |
|
} |
|
}, [referencesData, visitedIndices]) // Now include visitedIndices but capture it inside |
|
|
|
return ( |
|
<div className={cn('space-y-6', className)}> |
|
{/* Publication Metadata */} |
|
<div className="prose prose-zinc max-w-none dark:prose-invert"> |
|
<header className="mb-8 border-b pb-6"> |
|
<div className="flex items-start justify-between gap-4 mb-4"> |
|
<h1 className="text-4xl font-bold leading-tight break-words flex-1">{metadata.title}</h1> |
|
<Button |
|
variant="ghost" |
|
size="icon" |
|
className="shrink-0" |
|
onClick={exportPublication} |
|
title="Export as AsciiDoc" |
|
> |
|
<MoreVertical className="h-5 w-5" /> |
|
</Button> |
|
</div> |
|
{metadata.summary && ( |
|
<blockquote className="border-l-4 border-primary pl-6 italic text-muted-foreground mb-4 text-lg leading-relaxed"> |
|
<p className="break-words">{metadata.summary}</p> |
|
</blockquote> |
|
)} |
|
<div className="text-sm text-muted-foreground space-y-1"> |
|
{metadata.author && ( |
|
<div> |
|
<span className="font-semibold">Author:</span> {metadata.author} |
|
</div> |
|
)} |
|
{metadata.version && ( |
|
<div> |
|
<span className="font-semibold">Version:</span> {metadata.version} |
|
</div> |
|
)} |
|
{metadata.type && ( |
|
<div> |
|
<span className="font-semibold">Type:</span> {metadata.type} |
|
</div> |
|
)} |
|
</div> |
|
</header> |
|
</div> |
|
|
|
{/* Table of Contents */} |
|
{!isLoading && tableOfContents.length > 0 && ( |
|
<div className="border rounded-lg p-6 bg-muted/30"> |
|
<h2 className="text-xl font-semibold mb-4">Table of Contents</h2> |
|
<nav> |
|
<ul className="space-y-2"> |
|
{tableOfContents.map((item, index) => ( |
|
<ToCItemComponent |
|
key={index} |
|
item={item} |
|
onItemClick={scrollToSection} |
|
level={0} |
|
/> |
|
))} |
|
</ul> |
|
</nav> |
|
</div> |
|
)} |
|
|
|
{/* Content - render referenced events */} |
|
{isLoading ? ( |
|
<div className="text-muted-foreground"> |
|
<div>Loading publication content...</div> |
|
<div className="text-xs mt-2">If this takes too long, the content may not be available.</div> |
|
</div> |
|
) : references.length === 0 ? ( |
|
<div className="p-6 border rounded-lg bg-muted/30 text-center"> |
|
<div className="text-lg font-semibold mb-2">No content loaded</div> |
|
<div className="text-sm text-muted-foreground"> |
|
Unable to load publication content. The referenced events may not be available on the current relays. |
|
</div> |
|
</div> |
|
) : ( |
|
<div className="space-y-8"> |
|
{references.map((ref, index) => { |
|
if (!ref.event) { |
|
return ( |
|
<div key={index} className="p-4 border rounded-lg bg-muted/50"> |
|
<div className="text-sm text-muted-foreground"> |
|
Reference {index + 1}: Unable to load event {ref.coordinate || ref.eventId || 'unknown'} |
|
</div> |
|
</div> |
|
) |
|
} |
|
|
|
// Render based on event kind |
|
const coordinate = ref.coordinate || ref.eventId || '' |
|
const sectionId = `section-${coordinate.replace(/:/g, '-')}` |
|
const eventKind = ref.kind || ref.event.kind |
|
|
|
if (eventKind === ExtendedKind.PUBLICATION) { |
|
// Recursively render nested 30040 publication index |
|
return ( |
|
<div key={index} id={sectionId} className="border-l-4 border-primary pl-6 scroll-mt-4"> |
|
<PublicationIndex event={ref.event} /> |
|
</div> |
|
) |
|
} else if (eventKind === ExtendedKind.PUBLICATION_CONTENT || eventKind === ExtendedKind.WIKI_ARTICLE) { |
|
// Render 30041 or 30818 content as AsciidocArticle |
|
return ( |
|
<div key={index} id={sectionId} className="scroll-mt-4"> |
|
<AsciidocArticle event={ref.event} hideImagesAndInfo={true} /> |
|
</div> |
|
) |
|
} else if (eventKind === ExtendedKind.WIKI_ARTICLE_MARKDOWN) { |
|
// Render 30817 content as MarkdownArticle |
|
return ( |
|
<div key={index} id={sectionId} className="scroll-mt-4"> |
|
<MarkdownArticle event={ref.event} showImageGallery={false} /> |
|
</div> |
|
) |
|
} else { |
|
// Fallback for other kinds - just show a placeholder |
|
return ( |
|
<div key={index} className="p-4 border rounded-lg"> |
|
<div className="text-sm text-muted-foreground"> |
|
Reference {index + 1}: Unsupported kind {eventKind} |
|
</div> |
|
</div> |
|
) |
|
} |
|
})} |
|
</div> |
|
)} |
|
</div> |
|
) |
|
} |
|
|
|
// ToC Item Component - renders nested table of contents items |
|
function ToCItemComponent({ |
|
item, |
|
onItemClick, |
|
level |
|
}: { |
|
item: ToCItem |
|
onItemClick: (coordinate: string) => void |
|
level: number |
|
}) { |
|
const indentClass = level > 0 ? `ml-${level * 4}` : '' |
|
|
|
return ( |
|
<li className={cn('list-none', indentClass)}> |
|
<button |
|
onClick={() => onItemClick(item.coordinate)} |
|
className="text-left text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline cursor-pointer" |
|
> |
|
{item.title} |
|
</button> |
|
{item.children && item.children.length > 0 && ( |
|
<ul className="mt-2 space-y-1"> |
|
{item.children.map((child, childIndex) => ( |
|
<ToCItemComponent |
|
key={childIndex} |
|
item={child} |
|
onItemClick={onItemClick} |
|
level={level + 1} |
|
/> |
|
))} |
|
</ul> |
|
)} |
|
</li> |
|
) |
|
} |
|
|
|
|