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.
 
 
 
 

686 lines
24 KiB

import { ExtendedKind } from '@/constants'
import { Event, kinds, nip19 } from 'nostr-tools'
import { useEffect, useMemo, useState, useCallback } from 'react'
import { usePublicationSectionLoader } from '@/hooks/usePublicationSectionLoader'
import { parsePublicationATagCoordinate, publicationRefKey } from '@/lib/publication-section-fetch'
import { cn } from '@/lib/utils'
import AsciidocArticle from '../AsciidocArticle/AsciidocArticle'
import MarkdownArticle from '../MarkdownArticle/MarkdownArticle'
import { generateBech32IdFromATag } from '@/lib/tag'
import logger from '@/lib/logger'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { RefreshCw, ArrowUp } from 'lucide-react'
import indexedDb from '@/services/indexed-db.service'
import { useSecondaryPageOptional } from '@/PageManager'
import { extractBookMetadata } from '@/lib/bookstr-parser'
import { dTagToTitleCase } from '@/lib/event-metadata'
import Image from '@/components/Image'
import NoteOptions from '@/components/NoteOptions'
import { upsertRenderedPublicationEvents } from '@/lib/publication-rendered-events'
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
nestedRefs?: PublicationReference[] // Discovered nested references
}
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[]
}
function publicationSectionNotesLink(ref: {
coordinate?: string
eventId?: string
relay?: string
}): string | null {
if (ref.coordinate) {
const aTag = ['a', ref.coordinate, ref.relay || '', ref.eventId || '']
const bech32Id = generateBech32IdFromATag(aTag)
if (bech32Id) return `/notes?events=${encodeURIComponent(bech32Id)}`
}
if (ref.eventId) {
if (
ref.eventId.startsWith('note1') ||
ref.eventId.startsWith('nevent1') ||
ref.eventId.startsWith('naddr1')
) {
return `/notes?events=${encodeURIComponent(ref.eventId)}`
}
if (/^[0-9a-f]{64}$/i.test(ref.eventId)) {
try {
const nevent = nip19.neventEncode({ id: ref.eventId })
return `/notes?events=${encodeURIComponent(nevent)}`
} catch {
return `/notes?events=${encodeURIComponent(ref.eventId)}`
}
}
}
return null
}
export default function PublicationIndex({
event,
className,
isNested = false,
parentImageUrl
}: {
event: Event
className?: string
isNested?: boolean
parentImageUrl?: string
}) {
const secondaryPage = useSecondaryPageOptional()
const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url })
// 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 (convert to title case)
if (!meta.title) {
const dTag = event.tags.find(tag => tag[0] === 'd')?.[1]
if (dTag) {
meta.title = dTagToTitleCase(dTag)
}
}
return meta
}, [event])
const bookMetadata = useMemo(() => extractBookMetadata(event), [event])
const isBookstrEvent = (event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT) && !!bookMetadata.book
const [isRetrying, setIsRetrying] = useState(false)
// 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]) {
const parsed = parsePublicationATagCoordinate(tag[1])
if (parsed) {
refs.push({
type: 'a',
coordinate: parsed.coordinate,
kind: parsed.kind,
pubkey: parsed.pubkey,
identifier: parsed.identifier,
relay: tag[2]
})
}
} else if (tag[0] === 'e' && tag[1]) {
// Event ID reference
refs.push({
type: 'e',
eventId: tag[1],
relay: tag[2]
})
}
}
return refs
}, [event])
const { retryKeys, failedKeys, referencesWithEvents } =
usePublicationSectionLoader(event, referencesData)
// Helper function to format bookstr titles (remove hyphens, title case)
const formatBookstrTitle = useCallback((title: string, event?: Event): string => {
if (!event) return title
// Check if this is a bookstr event
const bookMetadata = extractBookMetadata(event)
const isBookstr = (event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT) && !!bookMetadata.book
if (isBookstr) {
// Remove hyphens and convert to title case
return title
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
}
return title
}, [])
// Build table of contents from references (tag-derived titles before sections load)
const tableOfContents = useMemo<ToCItem[]>(() => {
const toc: ToCItem[] = []
const titleFromIdentifier = (identifier: string, kind?: number) => {
const raw = identifier || 'Untitled'
if (
kind === ExtendedKind.PUBLICATION ||
kind === ExtendedKind.PUBLICATION_CONTENT ||
kind === kinds.LongFormArticle ||
kind === ExtendedKind.WIKI_ARTICLE_MARKDOWN
) {
return raw
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
}
return raw
}
for (const ref of referencesWithEvents) {
const coord = ref.coordinate || ref.eventId || ''
if (!coord) continue
let title: string
if (ref.event) {
const titleTag = ref.event.tags.find((tag) => tag[0] === 'title')?.[1]
const dTag = ref.event.tags.find((tag) => tag[0] === 'd')?.[1]
let rawTitle: string
if (titleTag) rawTitle = titleTag
else if (dTag) rawTitle = dTag
else rawTitle = 'Untitled'
title = titleTag ? rawTitle : formatBookstrTitle(rawTitle, ref.event)
} else if (ref.type === 'a' && ref.kind === kinds.ShortTextNote) {
title = 'Note'
} else if (ref.type === 'a' && ref.identifier) {
title = titleFromIdentifier(ref.identifier, ref.kind)
} else {
title = 'Section'
}
const tocItem: ToCItem = {
title,
coordinate: coord,
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) {
const nestedRefs: ToCItem[] = []
// Parse nested references from this publication
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 === kinds.LongFormArticle ||
kind === kinds.ShortTextNote ||
kind === ExtendedKind.PUBLICATION)
) {
// For this simplified version, we'll just extract the title from the coordinate
const rawNestedTitle = identifier || 'Untitled'
// Format for bookstr events (check if kind is bookstr-related)
const nestedTitle =
kind === ExtendedKind.PUBLICATION || kind === ExtendedKind.PUBLICATION_CONTENT
? rawNestedTitle
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
: kind === kinds.ShortTextNote
? 'Note'
: rawNestedTitle
nestedRefs.push({
title: nestedTitle,
coordinate: tag[1],
kind
})
}
}
}
if (nestedRefs.length > 0) {
tocItem.children = nestedRefs
}
}
toc.push(tocItem)
}
return toc
}, [referencesWithEvents, formatBookstrTitle])
// Scroll to ToC (scroll to top of page)
const scrollToToc = useCallback(() => {
// Find the scrollable container (could be window or a drawer/scrollable div)
let scrollContainer: HTMLElement | Window = window
const tocElement = document.getElementById('publication-toc')
if (tocElement) {
// Walk up the DOM tree to find the scrollable container
let element = tocElement.parentElement
while (element && element !== document.body) {
const style = window.getComputedStyle(element)
const overflowY = style.overflowY
// Check if this element is scrollable
if (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') {
if (element.scrollHeight > element.clientHeight) {
scrollContainer = element
break
}
}
element = element.parentElement
}
}
// Scroll to top
if (scrollContainer === window) {
window.scrollTo({ top: 0, behavior: 'smooth' })
} else {
(scrollContainer as HTMLElement).scrollTo({ top: 0, behavior: 'smooth' })
}
}, [])
// Scroll to section
const scrollToSection = (coordinate: string) => {
const element = document.getElementById(`section-${coordinate.replace(/:/g, '-')}`)
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
useEffect(() => {
void indexedDb.putReplaceableEvent(event).catch((err) => {
logger.error('[PublicationIndex] Error caching publication event:', err)
})
}, [event])
useEffect(() => {
const loaded = referencesWithEvents
.filter((r) => r.event)
.map((r) => r.event!)
if (loaded.length > 0) {
upsertRenderedPublicationEvents(event.id, loaded)
}
if (loaded.length === 0) return
const t = window.setTimeout(() => {
void indexedDb.putPublicationWithNestedEvents(event, loaded).catch((err) => {
logger.error('[PublicationIndex] Error caching publication with nested events:', err)
})
}, 400)
return () => clearTimeout(t)
}, [referencesWithEvents, event])
const handleManualRetry = useCallback(() => {
setIsRetrying(true)
const keys =
failedKeys.length > 0
? failedKeys
: (referencesData.map((r) => r.coordinate || r.eventId).filter(Boolean) as string[])
retryKeys(keys)
window.setTimeout(() => setIsRetrying(false), 600)
}, [failedKeys, referencesData, retryKeys])
return (
<div className={cn('space-y-6', className)}>
{/* Publication Metadata - only show for top-level publications */}
{!isNested && (
<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">
{metadata.title && <h1 className="text-4xl font-bold leading-tight break-words flex-1">{metadata.title}</h1>}
{!metadata.title && isBookstrEvent && (
<div className="flex-1">
<h1 className="text-4xl font-bold leading-tight break-words">
{bookMetadata.book
? bookMetadata.book
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
: 'Bookstr Publication'}
</h1>
</div>
)}
</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>
)}
{/* Display image for top-level 30040 publication */}
{metadata.image && (
<div className="mb-4">
<Image
image={{ url: metadata.image, pubkey: event.pubkey }}
className="max-w-[400px] w-full h-auto rounded-lg"
classNames={{
wrapper: 'rounded-lg',
errorPlaceholder: 'aspect-square h-[30vh]'
}}
/>
</div>
)}
<div className="text-sm text-muted-foreground space-y-1">
{metadata.author && (
<div>
<span className="font-semibold">Author:</span> {metadata.author}
</div>
)}
{metadata.version && !isBookstrEvent && (
<div>
<span className="font-semibold">Version:</span> {metadata.version}
</div>
)}
{metadata.type && !isBookstrEvent && (
<div>
<span className="font-semibold">Type:</span> {metadata.type}
</div>
)}
{isBookstrEvent && (
<>
{bookMetadata.type && (
<div>
<span className="font-semibold">Type:</span> {bookMetadata.type}
</div>
)}
{bookMetadata.book && (
<div>
<span className="font-semibold">Book:</span> {bookMetadata.book
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')}
</div>
)}
{bookMetadata.chapter && (
<div>
<span className="font-semibold">Chapter:</span> {bookMetadata.chapter}
</div>
)}
{bookMetadata.verse && (
<div>
<span className="font-semibold">Verse:</span> {bookMetadata.verse}
</div>
)}
{bookMetadata.version && (
<div>
<span className="font-semibold">Version:</span> {bookMetadata.version.toUpperCase()}
</div>
)}
</>
)}
</div>
</header>
</div>
)}
{/* Table of Contents - only show for top-level publications */}
{!isNested && tableOfContents.length > 0 && (
<div id="publication-toc" className="border rounded-lg p-6 bg-muted/30 scroll-mt-24">
<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>
)}
{/* Failed sections banner */}
{!isNested && failedKeys.length > 0 && referencesWithEvents.length > 0 && (
<div className="p-4 border rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800">
<div className="flex items-center justify-between gap-4">
<div className="text-sm text-yellow-800 dark:text-yellow-200">
{failedKeys.length} section{failedKeys.length !== 1 ? 's' : ''} failed to load.
</div>
<Button
variant="outline"
size="sm"
onClick={handleManualRetry}
disabled={isRetrying}
>
{isRetrying ? (
<Skeleton className="mr-2 inline-block size-4 shrink-0 rounded-sm align-middle" aria-hidden />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
)}
Retry All
</Button>
</div>
</div>
)}
{/* Sections */}
{referencesData.length === 0 ? (
<div className="p-6 border rounded-lg bg-muted/30 text-center text-sm text-muted-foreground">
This publication index has no linked sections.
</div>
) : (
<div className="space-y-8">
{referencesWithEvents.map((ref, index) => {
const sectionKey = publicationRefKey(ref)
const coordinate = ref.coordinate || ref.eventId || ''
const sectionId = `section-${coordinate.replace(/:/g, '-')}`
const notesLink = publicationSectionNotesLink(ref)
if (!ref.event) {
if (ref.loadStatus === 'error') {
return (
<div key={sectionKey || index} id={sectionId} className="scroll-mt-24 p-4 border rounded-lg bg-muted/50">
<div className="flex items-center justify-between gap-2">
<div className="text-sm text-muted-foreground">
Section {index + 1}: unable to load{' '}
{notesLink ? (
<a
href={notesLink}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
push(notesLink)
}}
className="text-primary hover:underline cursor-pointer"
>
{coordinate || 'unknown'}
</a>
) : (
<span>{coordinate || 'unknown'}</span>
)}
</div>
<Button
variant="outline"
size="sm"
className="shrink-0"
onClick={() => retryKeys([sectionKey])}
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
)
}
return (
<div
key={sectionKey || index}
id={sectionId}
className="scroll-mt-24 rounded-lg border border-dashed p-6 bg-muted/20 space-y-3"
aria-busy
>
<Skeleton className="h-5 w-2/3 max-w-md" />
<Skeleton className="h-28 w-full" />
<Skeleton className="h-28 w-full" />
</div>
)
}
const eventKind = ref.event?.kind ?? ref.kind ?? 0
const effectiveParentImageUrl = !isNested ? metadata.image : parentImageUrl
if (eventKind === ExtendedKind.PUBLICATION) {
return (
<div
key={sectionKey || index}
id={sectionId}
className="border-l-4 border-primary pl-6 scroll-mt-24 pt-6 relative"
>
<div className="absolute top-0 right-0 flex items-center gap-2">
{!isNested && (
<Button
variant="ghost"
size="sm"
className="opacity-70 hover:opacity-100"
onClick={scrollToToc}
title="Back to Table of Contents"
>
<ArrowUp className="h-4 w-4 mr-2" />
ToC
</Button>
)}
<NoteOptions event={ref.event} />
</div>
<PublicationIndex
event={ref.event}
isNested={true}
parentImageUrl={effectiveParentImageUrl}
/>
</div>
)
}
const renderAsAsciidoc =
eventKind === ExtendedKind.PUBLICATION_CONTENT ||
eventKind === ExtendedKind.WIKI_ARTICLE
if (renderAsAsciidoc) {
return (
<div key={sectionKey || index} id={sectionId} className="scroll-mt-24 pt-6 relative">
<div className="absolute top-0 right-0 flex items-center gap-2">
{!isNested && (
<Button
variant="ghost"
size="sm"
className="opacity-70 hover:opacity-100"
onClick={scrollToToc}
title="Back to Table of Contents"
>
<ArrowUp className="h-4 w-4 mr-2" />
ToC
</Button>
)}
<NoteOptions event={ref.event} />
</div>
<AsciidocArticle
event={ref.event}
hideImagesAndInfo={true}
parentImageUrl={effectiveParentImageUrl}
/>
</div>
)
}
// All non-publication, non-AsciiDoc section kinds use markdown renderer.
return (
<div key={sectionKey || index} id={sectionId} className="scroll-mt-24 pt-6 relative">
<div className="absolute top-0 right-0 flex items-center gap-2">
{!isNested && (
<Button
variant="ghost"
size="sm"
className="opacity-70 hover:opacity-100"
onClick={scrollToToc}
title="Back to Table of Contents"
>
<ArrowUp className="h-4 w-4 mr-2" />
ToC
</Button>
)}
<NoteOptions event={ref.event} />
</div>
<MarkdownArticle
event={ref.event}
hideMetadata={true}
parentImageUrl={effectiveParentImageUrl}
/>
</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>
)
}