From d33affda10e68a8a65518f6263b93ca19800ce1f Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 29 Oct 2025 22:30:01 +0100 Subject: [PATCH] add books --- .../PublicationIndex/PublicationIndex.tsx | 358 ++++++++++++++++++ src/components/Note/index.tsx | 7 +- src/services/content-parser.service.ts | 26 ++ 3 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 src/components/Note/PublicationIndex/PublicationIndex.tsx diff --git a/src/components/Note/PublicationIndex/PublicationIndex.tsx b/src/components/Note/PublicationIndex/PublicationIndex.tsx new file mode 100644 index 0000000..21570a9 --- /dev/null +++ b/src/components/Note/PublicationIndex/PublicationIndex.tsx @@ -0,0 +1,358 @@ +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 { generateBech32IdFromATag } from '@/lib/tag' +import client from '@/services/client.service' +import logger from '@/lib/logger' + +interface PublicationReference { + coordinate: string + event?: Event + kind: number + pubkey: string + identifier: string + relay?: string + eventId?: string +} + +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(() => { + 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([]) + const [visitedIndices, setVisitedIndices] = useState>(new Set()) + const [isLoading, setIsLoading] = useState(true) + + // Build table of contents from references + const tableOfContents = useMemo(() => { + 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, + event: ref.event, + kind: ref.kind + } + + // 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.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 + }) + } + } + } + + 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' }) + } + } + + // Extract references from 'a' tags + const referencesData = useMemo(() => { + const refs: PublicationReference[] = [] + for (const tag of event.tags) { + if (tag[0] === 'a' && tag[1]) { + const [kindStr, pubkey, identifier] = tag[1].split(':') + const kind = parseInt(kindStr) + if (!isNaN(kind)) { + refs.push({ + coordinate: tag[1], + kind, + pubkey, + identifier: identifier || '', + relay: tag[2], + eventId: tag[3] // Optional event ID for version tracking + }) + } + } + } + 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])) + }, [currentCoordinate]) + + // Fetch referenced events + useEffect(() => { + const fetchReferences = async () => { + setIsLoading(true) + const fetchedRefs: PublicationReference[] = [] + + for (const ref of referencesData) { + // Skip if this is a 30040 event we've already visited (prevent circular references) + if (ref.kind === ExtendedKind.PUBLICATION) { + if (visitedIndices.has(ref.coordinate)) { + logger.debug('[PublicationIndex] Skipping visited 30040 index:', ref.coordinate) + fetchedRefs.push({ ...ref, event: undefined }) + continue + } + } + + try { + // Generate bech32 ID from the 'a' tag + const aTag = ['a', ref.coordinate, ref.relay || '', ref.eventId || ''] + const bech32Id = generateBech32IdFromATag(aTag) + + if (bech32Id) { + const fetchedEvent = await client.fetchEvent(bech32Id) + if (fetchedEvent) { + fetchedRefs.push({ ...ref, event: fetchedEvent }) + } else { + logger.warn('[PublicationIndex] Could not fetch event for:', ref.coordinate) + fetchedRefs.push({ ...ref, event: undefined }) + } + } else { + logger.warn('[PublicationIndex] Could not generate bech32 ID for:', ref.coordinate) + fetchedRefs.push({ ...ref, event: undefined }) + } + } catch (error) { + logger.error('[PublicationIndex] Error fetching reference:', error) + fetchedRefs.push({ ...ref, event: undefined }) + } + } + + setReferences(fetchedRefs) + setIsLoading(false) + } + + if (referencesData.length > 0) { + fetchReferences() + } else { + setIsLoading(false) + } + }, [referencesData, visitedIndices]) + + return ( +
+ {/* Publication Metadata */} +
+
+

{metadata.title}

+ {metadata.summary && ( +
+

{metadata.summary}

+
+ )} +
+ {metadata.author && ( +
+ Author: {metadata.author} +
+ )} + {metadata.version && ( +
+ Version: {metadata.version} +
+ )} + {metadata.type && ( +
+ Type: {metadata.type} +
+ )} +
+
+
+ + {/* Table of Contents */} + {!isLoading && tableOfContents.length > 0 && ( +
+

Table of Contents

+ +
+ )} + + {/* Content - render referenced events */} + {isLoading ? ( +
Loading publication content...
+ ) : ( +
+ {references.map((ref, index) => { + if (!ref.event) { + return ( +
+
+ Reference {index + 1}: Unable to load event from coordinate {ref.coordinate} +
+
+ ) + } + + // Render based on event kind + const sectionId = `section-${ref.coordinate.replace(/:/g, '-')}` + + if (ref.kind === ExtendedKind.PUBLICATION) { + // Recursively render nested 30040 publication index + return ( +
+ +
+ ) + } else if (ref.kind === ExtendedKind.PUBLICATION_CONTENT || ref.kind === ExtendedKind.WIKI_ARTICLE) { + // Render 30041 or 30818 content as AsciidocArticle + return ( +
+ +
+ ) + } else { + // Fallback for other kinds - just show a placeholder + return ( +
+
+ Reference {index + 1}: Unsupported kind {ref.kind} +
+
+ ) + } + })} +
+ )} +
+ ) +} + +// 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 ( +
  • + + {item.children && item.children.length > 0 && ( +
      + {item.children.map((child, childIndex) => ( + + ))} +
    + )} +
  • + ) +} + diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index f4587d1..1d320df 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -30,6 +30,7 @@ import LongFormArticlePreview from './LongFormArticlePreview' import MarkdownArticle from './MarkdownArticle/MarkdownArticle' import AsciidocArticle from './AsciidocArticle/AsciidocArticle' import PublicationCard from './PublicationCard' +import PublicationIndex from './PublicationIndex/PublicationIndex' import WikiCard from './WikiCard' import MutedNote from './MutedNote' import NsfwNote from './NsfwNote' @@ -106,7 +107,11 @@ export default function Note({ ) } else if (event.kind === ExtendedKind.PUBLICATION) { - content = + content = showFull ? ( + + ) : ( + + ) } else if (event.kind === ExtendedKind.PUBLICATION_CONTENT) { content = showFull ? ( diff --git a/src/services/content-parser.service.ts b/src/services/content-parser.service.ts index 4bd4dad..232f07b 100644 --- a/src/services/content-parser.service.ts +++ b/src/services/content-parser.service.ts @@ -211,6 +211,9 @@ class ContentParserService { // Process nostr: addresses - convert them to proper AsciiDoc format result = this.processNostrAddresses(result) + // Process hashtags - convert them to proper AsciiDoc format + result = this.processHashtags(result) + // Debug: log the converted AsciiDoc for troubleshooting if (process.env.NODE_ENV === 'development') { console.log('Converted AsciiDoc:', result) @@ -451,6 +454,24 @@ class ContentParserService { return processed } + /** + * Process hashtags in content + */ + private processHashtags(content: string): string { + let processed = content + + // Convert hashtags to AsciiDoc link format: #hashtag -> hashtag:tag[#tag] + // This regex matches # followed by word characters, avoiding those in URLs, code blocks, etc. + // Using word boundary approach to avoid matching # in URLs + processed = processed.replace(/\B#([a-zA-Z0-9_]+)/g, (_match, hashtag) => { + // Normalize hashtag to lowercase for consistency + const normalizedHashtag = hashtag.toLowerCase() + return `hashtag:${normalizedHashtag}[#${hashtag}]` + }) + + return processed + } + /** * Process wikilinks in content (both standard and bookstr macro) */ @@ -493,6 +514,11 @@ class ContentParserService { private processWikilinksInHtml(html: string): string { let processed = html + // Convert hashtag links to HTML with green styling + processed = processed.replace(/hashtag:([^[]+)\[([^\]]+)\]/g, (_match, normalizedHashtag, displayText) => { + return `${displayText}` + }) + // Convert wikilink:dtag[display] format to HTML with data attributes processed = processed.replace(/wikilink:([^[]+)\[([^\]]+)\]/g, (_match, dTag, displayText) => { return `${displayText}`