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.
 
 
 

1432 lines
57 KiB

import { useSecondaryPage, useSmartHashtagNavigation, useSmartRelayNavigation } from '@/PageManager'
import Image from '@/components/Image'
import MediaPlayer from '@/components/MediaPlayer'
import WebPreview from '@/components/WebPreview'
import YoutubeEmbeddedPlayer from '@/components/YoutubeEmbeddedPlayer'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNoteList } from '@/lib/link'
import { useMediaExtraction } from '@/hooks'
import { cleanUrl, isImage, isMedia, isVideo, isAudio, isWebsocketUrl } from '@/lib/url'
import { getImetaInfosFromEvent } from '@/lib/event'
import { Event, kinds } from 'nostr-tools'
import { useMemo, useState, useCallback, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { createRoot, Root } from 'react-dom/client'
import Lightbox from 'yet-another-react-lightbox'
import Zoom from 'yet-another-react-lightbox/plugins/zoom'
import { EmbeddedNote, EmbeddedMention } from '@/components/Embedded'
import Wikilink from '@/components/UniversalContent/Wikilink'
import { preprocessAsciidocMediaLinks } from '../MarkdownArticle/preprocessMarkup'
import logger from '@/lib/logger'
import katex from 'katex'
import 'katex/dist/katex.min.css'
import { WS_URL_REGEX, YOUTUBE_URL_REGEX } from '@/constants'
/**
* Truncate link display text to 200 characters, adding ellipsis if truncated
*/
function truncateLinkText(text: string, maxLength: number = 200): string {
if (text.length <= maxLength) {
return text
}
return text.substring(0, maxLength) + '...'
}
/**
* Check if a URL is a YouTube URL
*/
function isYouTubeUrl(url: string): boolean {
// Create a new regex instance to avoid state issues with global regex
// Keep the 'i' flag for case-insensitivity but remove 'g' to avoid state issues
const flags = YOUTUBE_URL_REGEX.flags.replace('g', '')
const regex = new RegExp(YOUTUBE_URL_REGEX.source, flags)
return regex.test(url)
}
/**
* Convert markdown syntax to AsciiDoc syntax
* This converts all markdown elements to their AsciiDoc equivalents before processing
*/
function convertMarkdownToAsciidoc(content: string): string {
let asciidoc = content
// Note: We don't remove front matter here because the user's content uses --- as horizontal rules
// If there's actual YAML front matter, it should be handled separately
// For now, we'll convert --- to horizontal rules (except table separators)
// Convert nostr addresses directly to AsciiDoc link format
// Do this early so they're protected from other markdown conversions
// naddr addresses can be 200+ characters, so we use + instead of specific length
asciidoc = asciidoc.replace(/nostr:(npub1[a-z0-9]{58,}|nprofile1[a-z0-9]+|note1[a-z0-9]{58,}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)/g, (_match, bech32Id) => {
// Convert directly to AsciiDoc link format
// This will be processed later in HTML post-processing to render as React components
return `link:nostr:${bech32Id}[${bech32Id}]`
})
// Protect code blocks - we'll process them separately
const codeBlockPlaceholders: string[] = []
asciidoc = asciidoc.replace(/```(\w+)?\n([\s\S]*?)```/g, (_match, lang, code) => {
const placeholder = `__CODE_BLOCK_${codeBlockPlaceholders.length}__`
codeBlockPlaceholders.push(`[source${lang ? ',' + lang : ''}]\n----\n${code.trim()}\n----`)
return placeholder
})
// Protect inline code - but handle LaTeX math separately
const inlineCodePlaceholders: string[] = []
// Handle LaTeX math in inline code blocks like `$...$`
// The content may have escaped backslashes: `$\\frac{\\infty}{21,000,000} = \\infty$`
// We need to detect LaTeX math and convert it to AsciiDoc stem: syntax
asciidoc = asciidoc.replace(/`([^`\n]+)`/g, (_match, content) => {
// Check if this is LaTeX math - pattern: $...$ where ... contains LaTeX syntax
// Match the full pattern: $ followed by LaTeX expression and ending with $
const latexMatch = content.match(/^\$([^$]+)\$$/)
if (latexMatch) {
// This is pure LaTeX math - convert to AsciiDoc stem syntax
const latexExpr = latexMatch[1]
// The latexExpr contains the LaTeX code (backslashes are already in the string)
// AsciiDoc stem:[...] will process this with the stem processor
return `stem:[${latexExpr}]`
}
// Check if content contains LaTeX math mixed with other text
if (content.includes('$') && content.match(/\$[^$]+\$/)) {
// Replace $...$ parts with stem:[...]
const processed = content.replace(/\$([^$]+)\$/g, 'stem:[$1]')
// If it's now just stem, return it directly, otherwise it needs to be in code
if (processed.startsWith('stem:[') && processed.endsWith(']') && !processed.includes('`')) {
return processed
}
// Mixed content - keep as code but with stem inside (won't work well, but preserve it)
const placeholder = `__INLINE_CODE_${inlineCodePlaceholders.length}__`
inlineCodePlaceholders.push(`\`${processed}\``)
return placeholder
}
// Regular inline code - preserve it
const placeholder = `__INLINE_CODE_${inlineCodePlaceholders.length}__`
inlineCodePlaceholders.push(`\`${content}\``)
return placeholder
})
// Convert headers (must be at start of line)
asciidoc = asciidoc.replace(/^#{6}\s+(.+)$/gm, '====== $1 ======')
asciidoc = asciidoc.replace(/^#{5}\s+(.+)$/gm, '===== $1 =====')
asciidoc = asciidoc.replace(/^#{4}\s+(.+)$/gm, '==== $1 ====')
asciidoc = asciidoc.replace(/^#{3}\s+(.+)$/gm, '=== $1 ===')
asciidoc = asciidoc.replace(/^#{2}\s+(.+)$/gm, '== $1 ==')
asciidoc = asciidoc.replace(/^#{1}\s+(.+)$/gm, '= $1 =')
// Convert tables BEFORE horizontal rules (to avoid converting table separators)
// Markdown tables: | col1 | col2 |\n|------|------|\n| data1 | data2 |
// Use a simpler approach: match lines with pipes, separator row, and data rows
asciidoc = asciidoc.replace(/(\|[^\n]+\|\s*\n\|[\s\-\|:]+\|\s*\n(?:\|[^\n]+\|\s*\n?)+)/gm, (match) => {
const lines = match.trim().split('\n').map(line => line.trim()).filter(line => line)
if (lines.length < 2) return match
// First line is header, second is separator, rest are data
const headerRow = lines[0]
const separatorRow = lines[1]
// Verify it's a table separator (has dashes)
if (!separatorRow.match(/[\-:]/)) return match
// Parse header cells - markdown format: | col1 | col2 | col3 |
// When split by |, we get: ['', ' col1 ', ' col2 ', ' col3 ', '']
// We need to extract all non-empty cells
const headerParts = headerRow.split('|')
const headerCells: string[] = []
for (let i = 0; i < headerParts.length; i++) {
const cell = headerParts[i].trim()
// Skip empty cells only at the very start and end
if (cell === '' && (i === 0 || i === headerParts.length - 1)) continue
headerCells.push(cell)
}
if (headerCells.length < 2) return match
const colCount = headerCells.length
const dataRows = lines.slice(2)
// Build AsciiDoc table - use equal width columns
let tableAsciidoc = `[cols="${Array(colCount).fill('*').join(',')}"]\n|===\n`
// Header row - prefix each cell with . to make it a header cell in AsciiDoc
// Ensure cells are properly formatted (no leading/trailing spaces, escape special chars)
const headerRowCells = headerCells.map(cell => {
// Clean up the cell content
let cleanCell = cell.trim()
// Escape pipe characters if any
cleanCell = cleanCell.replace(/\|/g, '\\|')
// Return with . prefix for header
return `.${cleanCell}`
})
tableAsciidoc += headerRowCells.join('|') + '\n\n'
// Data rows
dataRows.forEach(row => {
if (!row.includes('|')) return
const rowParts = row.split('|')
const rowCells: string[] = []
// Parse data row cells the same way as header
for (let i = 0; i < rowParts.length; i++) {
const cell = rowParts[i].trim()
// Skip empty cells only at the very start and end
if (cell === '' && (i === 0 || i === rowParts.length - 1)) continue
rowCells.push(cell)
}
// Ensure we have the right number of cells
while (rowCells.length < colCount) {
rowCells.push('')
}
// Take only the number of columns we need
const finalCells = rowCells.slice(0, colCount)
tableAsciidoc += finalCells.map(cell => cell.replace(/\|/g, '\\|')).join('|') + '\n'
})
tableAsciidoc += '|==='
return tableAsciidoc
})
// Convert horizontal rules (but not table separators, which are already processed)
// Convert standalone --- lines to AsciiDoc horizontal rule
// We do this after table processing to avoid interfering with table separators
asciidoc = asciidoc.replace(/^---\s*$/gm, (match, offset, string) => {
// Check if this is part of a table separator (would have been processed already)
const lines = string.split('\n')
const lineIndex = string.substring(0, offset).split('\n').length - 1
const prevLine = lines[lineIndex - 1]?.trim() || ''
const nextLine = lines[lineIndex + 1]?.trim() || ''
// If it looks like a table separator (has pipes nearby), don't convert
if (prevLine.includes('|') || nextLine.includes('|')) {
return match
}
// Convert to AsciiDoc horizontal rule (three single quotes)
return '\'\'\''
})
// Convert blockquotes - handle multi-line blockquotes
// Match consecutive lines starting with >
asciidoc = asciidoc.replace(/(^>\s+.+(?:\n>\s+.+)*)/gm, (match) => {
const lines = match.split('\n').map((line: string) => line.replace(/^>\s*/, ''))
const content = lines.join('\n').trim()
return `____\n${content}\n____`
})
// Convert lists (must be at start of line)
// Unordered lists: *, -, +
asciidoc = asciidoc.replace(/^(\s*)[\*\-\+]\s+(.+)$/gm, '$1* $2')
// Ordered lists: 1., 2., etc.
asciidoc = asciidoc.replace(/^(\s*)\d+\.\s+(.+)$/gm, '$1. $2')
// Convert images: ![alt](url) -> image:url[alt] (single colon for inline, but AsciiDoc will render as block)
// For block images in AsciiDoc, we can use image:: or just ensure it's on its own line
asciidoc = asciidoc.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, url) => {
// Escape brackets in alt text and URL if needed
const escapedAlt = alt.replace(/\[/g, '\\[').replace(/\]/g, '\\]').replace(/"/g, '&quot;')
// Use image:: for block-level images (double colon)
// Add width attribute to make it responsive
return `image::${url}[${escapedAlt},width=100%]`
})
// Convert links: [text](url) -> link:url[text]
asciidoc = asciidoc.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => {
// Skip if it was an image (shouldn't happen after image conversion, but safety check)
if (match.startsWith('![')) return match
// Escape brackets in link text
const escapedText = text.replace(/\[/g, '\\[').replace(/\]/g, '\\]')
return `link:${url}[${escapedText}]`
})
// Nostr addresses are already converted to link: format above, no need to restore
// Convert strikethrough: ~~text~~ -> [line-through]#text#
// Also handle single tilde strikethrough: ~text~ -> [line-through]#text#
asciidoc = asciidoc.replace(/~~([^~\n]+?)~~/g, '[line-through]#$1#')
// Single tilde strikethrough (common in some markdown flavors)
asciidoc = asciidoc.replace(/(?<!~)~([^~\n]+?)~(?!~)/g, '[line-through]#$1#')
// Note: Subscript ~text~ is now handled as strikethrough above
// If you need subscript, use a different syntax or handle it differently
// Convert superscript: ^text^
asciidoc = asciidoc.replace(/\^([^\^\n]+?)\^/g, '[superscript]#$1#')
// Convert bold: **text** or __text__
asciidoc = asciidoc.replace(/\*\*([^*\n]+?)\*\*/g, '*$1*')
asciidoc = asciidoc.replace(/__(?!_)([^_\n]+?)(?<!_)__/g, '*$1*')
// Convert italic: *text* or _text_ (but not if already bold)
// Process single asterisk for italic (but not if it's part of **bold**)
asciidoc = asciidoc.replace(/(?<!\*)\*(?![\*\s])([^\*\n]+?)(?<!\*)\*(?!\*)/g, (match, text) => {
// Skip if it looks like a list item
if (/^\s*\*\s/.test(match)) return match
// Skip if already processed as bold (shouldn't happen, but safety)
if (match.includes('*$1*')) return match
return `_${text}_`
})
// Process single underscore for italic
asciidoc = asciidoc.replace(/(?<!_)_(?!_)([^_\n]+?)(?<!_)_(?!_)/g, (match, text) => {
// Skip if already processed as bold
if (match.includes('*$1*')) return match
return `_${text}_`
})
// Restore inline code
inlineCodePlaceholders.forEach((code, index) => {
asciidoc = asciidoc.replace(`__INLINE_CODE_${index}__`, code)
})
// Restore code blocks
codeBlockPlaceholders.forEach((block, index) => {
asciidoc = asciidoc.replace(`__CODE_BLOCK_${index}__`, block)
})
return asciidoc
}
export default function AsciidocArticle({
event,
className,
hideImagesAndInfo = false
}: {
event: Event
className?: string
hideImagesAndInfo?: boolean
}) {
const { push } = useSecondaryPage()
const { navigateToHashtag } = useSmartHashtagNavigation()
const { navigateToRelay } = useSmartRelayNavigation()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const contentRef = useRef<HTMLDivElement>(null)
// Preprocess content: convert all markdown to AsciiDoc syntax
const processedContent = useMemo(() => {
let content = event.content
// Convert all markdown syntax to AsciiDoc syntax
content = convertMarkdownToAsciidoc(content)
// Now process raw URLs that aren't already in AsciiDoc syntax
content = preprocessAsciidocMediaLinks(content)
// Convert "Read naddr... instead." patterns to AsciiDoc links
const redirectRegex = /Read (naddr1[a-z0-9]+) instead\./gi
content = content.replace(redirectRegex, (_match, naddr) => {
return `Read link:/notes/${naddr}[${naddr}] instead.`
})
return content
}, [event.content])
// Extract all media from event
const extractedMedia = useMediaExtraction(event, event.content)
// Extract media from tags only (for display at top)
const tagMedia = useMemo(() => {
const seenUrls = new Set<string>()
const media: Array<{ url: string; type: 'image' | 'video' | 'audio'; poster?: string }> = []
// Extract from imeta tags
const imetaInfos = getImetaInfosFromEvent(event)
imetaInfos.forEach((info) => {
const cleaned = cleanUrl(info.url)
if (!cleaned || seenUrls.has(cleaned)) return
if (!isImage(cleaned) && !isMedia(cleaned)) return
seenUrls.add(cleaned)
if (info.m?.startsWith('image/') || isImage(cleaned)) {
media.push({ url: info.url, type: 'image' })
} else if (info.m?.startsWith('video/') || isVideo(cleaned)) {
media.push({ url: info.url, type: 'video', poster: info.image })
} else if (info.m?.startsWith('audio/') || isAudio(cleaned)) {
media.push({ url: info.url, type: 'audio' })
}
})
// Extract from r tags
event.tags.filter(tag => tag[0] === 'r' && tag[1]).forEach(tag => {
const url = tag[1]
const cleaned = cleanUrl(url)
if (!cleaned || seenUrls.has(cleaned)) return
if (!isImage(cleaned) && !isMedia(cleaned)) return
seenUrls.add(cleaned)
if (isImage(cleaned)) {
media.push({ url, type: 'image' })
} else if (isVideo(cleaned)) {
media.push({ url, type: 'video' })
} else if (isAudio(cleaned)) {
media.push({ url, type: 'audio' })
}
})
// Extract from image tag
const imageTag = event.tags.find(tag => tag[0] === 'image' && tag[1])
if (imageTag?.[1]) {
const cleaned = cleanUrl(imageTag[1])
if (cleaned && !seenUrls.has(cleaned) && isImage(cleaned)) {
seenUrls.add(cleaned)
media.push({ url: imageTag[1], type: 'image' })
}
}
return media
}, [event.id, JSON.stringify(event.tags)])
// Extract YouTube URLs from tags (for display at top)
const tagYouTubeUrls = useMemo(() => {
const youtubeUrls: string[] = []
const seenUrls = new Set<string>()
event.tags
.filter(tag => tag[0] === 'r' && tag[1])
.forEach(tag => {
const url = tag[1]
if (!url.startsWith('http://') && !url.startsWith('https://')) return
if (!isYouTubeUrl(url)) return
const cleaned = cleanUrl(url)
if (cleaned && !seenUrls.has(cleaned)) {
youtubeUrls.push(cleaned)
seenUrls.add(cleaned)
}
})
return youtubeUrls
}, [event.id, JSON.stringify(event.tags)])
// Extract non-media links from tags (excluding YouTube URLs)
const tagLinks = useMemo(() => {
const links: string[] = []
const seenUrls = new Set<string>()
event.tags
.filter(tag => tag[0] === 'r' && tag[1])
.forEach(tag => {
const url = tag[1]
if (!url.startsWith('http://') && !url.startsWith('https://')) return
if (isImage(url) || isMedia(url)) return
if (isYouTubeUrl(url)) return // Exclude YouTube URLs
const cleaned = cleanUrl(url)
if (cleaned && !seenUrls.has(cleaned)) {
links.push(cleaned)
seenUrls.add(cleaned)
}
})
return links
}, [event.id, JSON.stringify(event.tags)])
// Get all images for gallery (deduplicated)
const allImages = useMemo(() => {
const seenUrls = new Set<string>()
const images: Array<{ url: string; alt?: string }> = []
// Add images from extractedMedia
extractedMedia.images.forEach(img => {
const cleaned = cleanUrl(img.url)
if (cleaned && !seenUrls.has(cleaned)) {
seenUrls.add(cleaned)
images.push({ url: img.url, alt: img.alt })
}
})
// Add metadata image if it exists
if (metadata.image) {
const cleaned = cleanUrl(metadata.image)
if (cleaned && !seenUrls.has(cleaned) && isImage(cleaned)) {
seenUrls.add(cleaned)
images.push({ url: metadata.image })
}
}
return images
}, [extractedMedia.images, metadata.image])
// Create image index map for lightbox
const imageIndexMap = useMemo(() => {
const map = new Map<string, number>()
allImages.forEach((img, index) => {
const cleaned = cleanUrl(img.url)
if (cleaned) map.set(cleaned, index)
})
return map
}, [allImages])
// Parse content to find media URLs that are already rendered
const mediaUrlsInContent = useMemo(() => {
const urls = new Set<string>()
const urlRegex = /https?:\/\/[^\s<>"']+/g
let match
while ((match = urlRegex.exec(event.content)) !== null) {
const url = match[0]
const cleaned = cleanUrl(url)
if (cleaned && (isImage(cleaned) || isVideo(cleaned) || isAudio(cleaned))) {
urls.add(cleaned)
}
}
return urls
}, [event.content])
// Extract YouTube URLs from content
const youtubeUrlsInContent = useMemo(() => {
const urls = new Set<string>()
const urlRegex = /https?:\/\/[^\s<>"']+/g
let match
while ((match = urlRegex.exec(event.content)) !== null) {
const url = match[0]
const cleaned = cleanUrl(url)
if (cleaned && isYouTubeUrl(cleaned)) {
urls.add(cleaned)
}
}
return urls
}, [event.content])
// Extract non-media links from content (excluding YouTube URLs)
const contentLinks = useMemo(() => {
const links: string[] = []
const seenUrls = new Set<string>()
const urlRegex = /https?:\/\/[^\s<>"']+/g
let match
while ((match = urlRegex.exec(event.content)) !== null) {
const url = match[0]
if ((url.startsWith('http://') || url.startsWith('https://')) && !isImage(url) && !isMedia(url) && !isYouTubeUrl(url)) {
const cleaned = cleanUrl(url)
if (cleaned && !seenUrls.has(cleaned)) {
links.push(cleaned)
seenUrls.add(cleaned)
}
}
}
return links
}, [event.content])
// Image gallery state
const [lightboxIndex, setLightboxIndex] = useState(-1)
const openLightbox = useCallback((index: number) => {
setLightboxIndex(index)
}, [])
// Filter tag media to only show what's not in content
const leftoverTagMedia = useMemo(() => {
const metadataImageUrl = metadata.image ? cleanUrl(metadata.image) : null
return tagMedia.filter(media => {
const cleaned = cleanUrl(media.url)
if (!cleaned) return false
// Skip if already in content
if (mediaUrlsInContent.has(cleaned)) return false
// Skip if this is the metadata image (shown separately)
if (metadataImageUrl && cleaned === metadataImageUrl && !hideImagesAndInfo) return false
return true
})
}, [tagMedia, mediaUrlsInContent, metadata.image, hideImagesAndInfo])
// Filter tag YouTube URLs to only show what's not in content
const leftoverTagYouTubeUrls = useMemo(() => {
return tagYouTubeUrls.filter(url => {
const cleaned = cleanUrl(url)
return cleaned && !youtubeUrlsInContent.has(cleaned)
})
}, [tagYouTubeUrls, youtubeUrlsInContent])
// Filter tag links to only show what's not in content (to avoid duplicate WebPreview cards)
const leftoverTagLinks = useMemo(() => {
const contentLinksSet = new Set(contentLinks.map(link => cleanUrl(link)).filter(Boolean))
return tagLinks.filter(link => {
const cleaned = cleanUrl(link)
return cleaned && !contentLinksSet.has(cleaned)
})
}, [tagLinks, contentLinks])
// Extract hashtags from content (for deduplication with metadata tags)
const hashtagsInContent = useMemo(() => {
const tags = new Set<string>()
const hashtagRegex = /#([a-zA-Z0-9_]+)/g
let match
while ((match = hashtagRegex.exec(event.content)) !== null) {
tags.add(match[1].toLowerCase())
}
return tags
}, [event.content])
// Filter metadata tags to only show what's not already in content
const leftoverMetadataTags = useMemo(() => {
return metadata.tags.filter(tag => !hashtagsInContent.has(tag.toLowerCase()))
}, [metadata.tags, hashtagsInContent])
// Parse AsciiDoc content and post-process for nostr: links and hashtags
const [parsedHtml, setParsedHtml] = useState<string>('')
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
let cancelled = false
const parseAsciidoc = async () => {
setIsLoading(true)
try {
const Asciidoctor = await import('@asciidoctor/core')
const asciidoctor = Asciidoctor.default()
if (cancelled) return
const html = asciidoctor.convert(processedContent, {
safe: 'safe',
backend: 'html5',
doctype: 'article',
attributes: {
'showtitle': true,
'sectanchors': true,
'sectlinks': true,
'toc': 'left',
'toclevels': 6,
'toc-title': 'Table of Contents',
'source-highlighter': 'highlight.js',
'stem': 'latexmath',
'data-uri': true,
'imagesdir': '',
'linkcss': false,
'stylesheet': '',
'stylesdir': '',
'prewrap': true,
'sectnums': false,
'sectnumlevels': 6,
'experimental': true,
'compat-mode': false,
'attribute-missing': 'warn',
'attribute-undefined': 'warn',
'skip-front-matter': true
}
})
if (cancelled) return
let htmlString = typeof html === 'string' ? html : html.toString()
// Note: Markdown is now converted to AsciiDoc in preprocessing,
// so post-processing markdown should not be necessary
// Post-process HTML to handle nostr: links
// Mentions (npub/nprofile) should be inline, events (note/nevent/naddr) should be block-level
// First, handle nostr: links in <a> tags (from AsciiDoc link: syntax)
// Match the full bech32 address format - addresses can vary in length
// npub: 58 chars, nprofile: variable, note: 58 chars, nevent: variable, naddr: 200+ chars
// Use a more flexible pattern that matches any valid bech32 address
htmlString = htmlString.replace(/<a[^>]*href=["']nostr:((?:npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]{20,})["'][^>]*>([^<]*)<\/a>/gi, (_match, bech32Id, _linkText) => {
// Validate bech32 ID and create appropriate placeholder
if (!bech32Id) return _match
// Escape the bech32 ID for HTML attributes
const escapedId = bech32Id.replace(/"/g, '&quot;').replace(/'/g, '&#39;')
if (bech32Id.startsWith('npub') || bech32Id.startsWith('nprofile')) {
return `<span data-nostr-mention="${escapedId}" class="nostr-mention-placeholder"></span>`
} else if (bech32Id.startsWith('note') || bech32Id.startsWith('nevent') || bech32Id.startsWith('naddr')) {
return `<div data-nostr-note="${escapedId}" class="nostr-note-placeholder"></div>`
}
return _match
})
// Also handle nostr: addresses in plain text nodes (not already in <a> tags)
// Process text nodes by replacing content between > and <
// Use more flexible regex that matches any valid bech32 address
htmlString = htmlString.replace(/>([^<]*nostr:((?:npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]+)[^<]*)</g, (_match, textContent) => {
// Extract nostr addresses from the text content - use the same flexible pattern
const nostrRegex = /nostr:((?:npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]+)/g
let processedText = textContent
const replacements: Array<{ start: number; end: number; replacement: string }> = []
let m
while ((m = nostrRegex.exec(textContent)) !== null) {
const bech32Id = m[1]
const start = m.index
const end = m.index + m[0].length
if (bech32Id.startsWith('npub') || bech32Id.startsWith('nprofile')) {
replacements.push({
start,
end,
replacement: `<span data-nostr-mention="${bech32Id}" class="nostr-mention-placeholder"></span>`
})
} else if (bech32Id.startsWith('note') || bech32Id.startsWith('nevent') || bech32Id.startsWith('naddr')) {
replacements.push({
start,
end,
replacement: `<div data-nostr-note="${bech32Id}" class="nostr-note-placeholder"></div>`
})
}
}
// Apply replacements in reverse order to preserve indices
for (let i = replacements.length - 1; i >= 0; i--) {
const r = replacements[i]
processedText = processedText.substring(0, r.start) + r.replacement + processedText.substring(r.end)
}
return `>${processedText}<`
})
// Handle LaTeX math expressions from AsciiDoc stem processor
// AsciiDoc with stem: latexmath outputs \(...\) for inline and \[...\] for block math
// In HTML, these appear as literal \( and \) characters (backslash + parenthesis)
// We need to match the literal backslash-paren sequence
// In regex: \\ matches a literal backslash, \( matches a literal (
htmlString = htmlString.replace(/\\\(([^)]+?)\\\)/g, (_match, latex) => {
// Inline math - escape for HTML attribute
// Unescape any HTML entities that might have been created
const unescaped = latex.replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, '&')
const escaped = unescaped.replace(/"/g, '&quot;').replace(/'/g, '&#39;')
return `<span data-latex-inline="${escaped}" class="latex-inline-placeholder"></span>`
})
htmlString = htmlString.replace(/\\\[([^\]]+?)\\\]/g, (_match, latex) => {
// Block math - escape for HTML attribute
const unescaped = latex.replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, '&')
const escaped = unescaped.replace(/"/g, '&quot;').replace(/'/g, '&#39;')
return `<div data-latex-block="${escaped}" class="latex-block-placeholder my-4"></div>`
})
// Handle wikilinks - convert passthrough markers to placeholders
// AsciiDoc passthrough +++WIKILINK:link|display+++ outputs just WIKILINK:link|display in HTML
// Match WIKILINK: followed by any characters (including |) until end of text or HTML tag
htmlString = htmlString.replace(/WIKILINK:([^<>\s]+)/g, (_match, linkContent) => {
// Escape special characters for HTML attributes
const escaped = linkContent.replace(/"/g, '&quot;').replace(/'/g, '&#39;')
return `<span data-wikilink="${escaped}" class="wikilink-placeholder"></span>`
})
// Handle YouTube URLs and relay URLs in links
// Process all link matches first to determine which are standalone
const linkMatches: Array<{ match: string; href: string; linkText: string; index: number; isStandalone: boolean }> = []
const linkRegex = /<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/g
let linkMatch
while ((linkMatch = linkRegex.exec(htmlString)) !== null) {
const match = linkMatch[0]
const href = linkMatch[1]
const linkText = linkMatch[2]
const index = linkMatch.index
// Check if link is standalone (on its own line, not part of a sentence/list/quote)
let isStandalone = false
if (href.startsWith('http://') || href.startsWith('https://')) {
// Get context around the link
const beforeMatch = htmlString.substring(Math.max(0, index - 500), index)
const afterMatch = htmlString.substring(index + match.length, Math.min(htmlString.length, index + match.length + 500))
// Extract the parent paragraph/div content
const paragraphMatch = beforeMatch.match(/<p[^>]*>([^<]*)$/)
const divMatch = beforeMatch.match(/<div[^>]*>([^<]*)$/)
// If link is in a paragraph, check if paragraph contains only the link
if (paragraphMatch) {
const paragraphEnd = afterMatch.match(/^([^<]*)<\/p>/)
const paragraphContent = paragraphMatch[1] + linkText + (paragraphEnd?.[1] || '')
const trimmedContent = paragraphContent.trim()
// If paragraph contains only the link (possibly with whitespace), it's standalone
if (trimmedContent === linkText.trim() || trimmedContent === '') {
// Check if it's in a list or blockquote by looking further back
const contextBefore = htmlString.substring(Math.max(0, index - 1000), index)
if (!contextBefore.match(/<[uo]l[^>]*>/) && !contextBefore.match(/<blockquote[^>]*>/)) {
isStandalone = true
}
}
}
// If link is in a div and the div contains only the link, it's standalone
if (!isStandalone && divMatch) {
const divEnd = afterMatch.match(/^([^<]*)<\/div>/)
const divContent = divMatch[1] + linkText + (divEnd?.[1] || '')
const trimmedContent = divContent.trim()
if (trimmedContent === linkText.trim() || trimmedContent === '') {
const contextBefore = htmlString.substring(Math.max(0, index - 1000), index)
if (!contextBefore.match(/<[uo]l[^>]*>/) && !contextBefore.match(/<blockquote[^>]*>/)) {
isStandalone = true
}
}
}
// If link appears to be on its own line (surrounded by block-level tags or whitespace)
if (!isStandalone) {
const beforeTrimmed = beforeMatch.replace(/\s*$/, '')
const afterTrimmed = afterMatch.replace(/^\s*/, '')
if (
(beforeTrimmed.endsWith('</p>') || beforeTrimmed.endsWith('</div>') || beforeTrimmed.endsWith('<br') || beforeTrimmed === '') &&
(afterTrimmed.startsWith('</p>') || afterTrimmed.startsWith('</div>') || afterTrimmed.startsWith('<p') || afterTrimmed.startsWith('<div') || afterTrimmed === '')
) {
const contextBefore = htmlString.substring(Math.max(0, index - 1000), index)
if (!contextBefore.match(/<[uo]l[^>]*>/) && !contextBefore.match(/<blockquote[^>]*>/)) {
isStandalone = true
}
}
}
}
linkMatches.push({ match, href, linkText, index, isStandalone })
}
// Replace links in reverse order to preserve indices
for (let i = linkMatches.length - 1; i >= 0; i--) {
const { match, href, linkText, isStandalone } = linkMatches[i]
let replacement = match
// Check if the href is a YouTube URL
if (isYouTubeUrl(href)) {
const cleanedUrl = cleanUrl(href)
replacement = `<div data-youtube-url="${cleanedUrl.replace(/"/g, '&quot;')}" class="youtube-placeholder my-2"></div>`
}
// Check if the href is a relay URL
else if (isWebsocketUrl(href)) {
const relayPath = `/relays/${encodeURIComponent(href)}`
replacement = `<a href="${relayPath}" class="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words cursor-pointer" data-relay-url="${href}" data-original-text="${linkText.replace(/"/g, '&quot;')}">${linkText}</a>`
}
// For regular HTTP/HTTPS links, check if standalone
else if (href.startsWith('http://') || href.startsWith('https://')) {
if (isStandalone) {
// Standalone link - render as WebPreview
const cleanedUrl = cleanUrl(href)
replacement = `<div data-webpreview-url="${cleanedUrl.replace(/"/g, '&quot;')}" class="webpreview-placeholder my-2"></div>`
} else {
// Inline link - keep as regular link
const escapedLinkText = linkText.replace(/"/g, '&quot;')
replacement = `<a href="${href}" class="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words" target="_blank" rel="noopener noreferrer" data-original-text="${escapedLinkText}">${linkText}</a>`
}
}
// For other links (like relative links), keep as-is but add data attribute
else {
const escapedLinkText = linkText.replace(/"/g, '&quot;')
replacement = match.replace(/<a/, `<a data-original-text="${escapedLinkText}"`)
}
htmlString = htmlString.substring(0, linkMatches[i].index) + replacement + htmlString.substring(linkMatches[i].index + match.length)
}
// Handle YouTube URLs in plain text (not in <a> tags)
// Create a new regex instance to avoid state issues
const youtubeRegex = new RegExp(YOUTUBE_URL_REGEX.source, YOUTUBE_URL_REGEX.flags)
htmlString = htmlString.replace(youtubeRegex, (match) => {
// Only replace if not already in a tag (basic check)
if (!match.includes('<') && !match.includes('>') && isYouTubeUrl(match)) {
const cleanedUrl = cleanUrl(match)
return `<div data-youtube-url="${cleanedUrl.replace(/"/g, '&quot;')}" class="youtube-placeholder my-2"></div>`
}
return match
})
// Handle relay URLs in plain text (not in <a> tags) - convert to relay page links
htmlString = htmlString.replace(WS_URL_REGEX, (match) => {
// Only replace if not already in a tag (basic check)
if (!match.includes('<') && !match.includes('>') && isWebsocketUrl(match)) {
const relayPath = `/relays/${encodeURIComponent(match)}`
return `<a href="${relayPath}" class="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words cursor-pointer" data-relay-url="${match}" data-original-text="${match.replace(/"/g, '&quot;')}">${match}</a>`
}
return match
})
// Handle plain HTTP/HTTPS URLs in text (not in <a> tags, not YouTube, not relay) - convert to WebPreview placeholders
const httpUrlRegex = /https?:\/\/[^\s<>"']+/g
htmlString = htmlString.replace(httpUrlRegex, (match) => {
// Only replace if not already in a tag (basic check)
if (!match.includes('<') && !match.includes('>')) {
// Skip if it's a YouTube URL or relay URL (already handled)
if (isYouTubeUrl(match) || isWebsocketUrl(match)) {
return match
}
// Skip if it's an image or media URL (handled separately)
if (isImage(match) || isVideo(match) || isAudio(match)) {
return match
}
const cleanedUrl = cleanUrl(match)
return `<div data-webpreview-url="${cleanedUrl.replace(/"/g, '&quot;')}" class="webpreview-placeholder my-2"></div>`
}
return match
})
setParsedHtml(htmlString)
} catch (error) {
logger.error('Failed to parse AsciiDoc', error as Error)
setParsedHtml('<p>Error parsing AsciiDoc content</p>')
} finally {
if (!cancelled) {
setIsLoading(false)
}
}
}
parseAsciidoc()
return () => {
cancelled = true
}
}, [processedContent])
// Store React roots for cleanup
const reactRootsRef = useRef<Map<Element, Root>>(new Map())
// Post-process rendered HTML to inject React components for nostr: links and handle hashtags
useEffect(() => {
if (!contentRef.current || !parsedHtml || isLoading) return
// Clean up previous roots
reactRootsRef.current.forEach((root, element) => {
root.unmount()
reactRootsRef.current.delete(element)
})
// Process nostr: mentions - replace placeholders with React components (inline)
const nostrMentions = contentRef.current.querySelectorAll('.nostr-mention-placeholder[data-nostr-mention]')
nostrMentions.forEach((element) => {
const bech32Id = element.getAttribute('data-nostr-mention')
if (!bech32Id) {
logger.warn('Nostr mention placeholder found but no bech32Id attribute')
return
}
// Create an inline container for React component (mentions should be inline)
const container = document.createElement('span')
container.className = 'inline-block'
const parent = element.parentNode
if (!parent) {
logger.warn('Nostr mention placeholder has no parent node')
return
}
parent.replaceChild(container, element)
// Use React to render the component
const root = createRoot(container)
root.render(<EmbeddedMention userId={bech32Id} />)
reactRootsRef.current.set(container, root)
})
// Process nostr: notes - replace placeholders with React components
const nostrNotes = contentRef.current.querySelectorAll('.nostr-note-placeholder[data-nostr-note]')
nostrNotes.forEach((element) => {
const bech32Id = element.getAttribute('data-nostr-note')
if (!bech32Id) {
logger.warn('Nostr note placeholder found but no bech32Id attribute')
return
}
// Create a block-level container for React component that fills width
const container = document.createElement('div')
container.className = 'w-full my-2'
const parent = element.parentNode
if (!parent) {
logger.warn('Nostr note placeholder has no parent node')
return
}
parent.replaceChild(container, element)
// Use React to render the component
const root = createRoot(container)
root.render(<EmbeddedNote noteId={bech32Id} />)
reactRootsRef.current.set(container, root)
})
// Process LaTeX math expressions - render with KaTeX
const latexInlinePlaceholders = contentRef.current.querySelectorAll('.latex-inline-placeholder[data-latex-inline]')
latexInlinePlaceholders.forEach((element) => {
const latex = element.getAttribute('data-latex-inline')
if (!latex) return
try {
// Render LaTeX with KaTeX
const rendered = katex.renderToString(latex, {
throwOnError: false,
displayMode: false
})
// Replace the placeholder with the rendered HTML
element.outerHTML = rendered
} catch (error) {
logger.error('Error rendering LaTeX inline math:', error)
// On error, show the raw LaTeX
element.outerHTML = `<span>$${latex}$</span>`
}
})
const latexBlockPlaceholders = contentRef.current.querySelectorAll('.latex-block-placeholder[data-latex-block]')
latexBlockPlaceholders.forEach((element) => {
const latex = element.getAttribute('data-latex-block')
if (!latex) return
try {
// Render LaTeX with KaTeX in display mode
const rendered = katex.renderToString(latex, {
throwOnError: false,
displayMode: true
})
// Replace the placeholder with the rendered HTML
element.outerHTML = rendered
} catch (error) {
logger.error('Error rendering LaTeX block math:', error)
// On error, show the raw LaTeX
element.outerHTML = `<div>$$${latex}$$</div>`
}
})
// Process YouTube URLs - replace placeholders with React components
const youtubePlaceholders = contentRef.current.querySelectorAll('.youtube-placeholder[data-youtube-url]')
youtubePlaceholders.forEach((element) => {
const youtubeUrl = element.getAttribute('data-youtube-url')
if (!youtubeUrl) return
// Create a container for React component
const container = document.createElement('div')
container.className = 'my-2'
element.parentNode?.replaceChild(container, element)
// Use React to render the component
const root = createRoot(container)
root.render(<YoutubeEmbeddedPlayer url={youtubeUrl} className="max-w-[400px]" mustLoad={false} />)
reactRootsRef.current.set(container, root)
})
// Process wikilinks - replace placeholders with React components
const wikilinks = contentRef.current.querySelectorAll('.wikilink-placeholder[data-wikilink]')
wikilinks.forEach((element) => {
const linkContent = element.getAttribute('data-wikilink')
if (!linkContent) return
// Parse wikilink: extract target and display text
let target = linkContent.includes('|') ? linkContent.split('|')[0].trim() : linkContent.trim()
let displayText = linkContent.includes('|') ? linkContent.split('|')[1].trim() : linkContent.trim()
// Handle book: prefix
if (linkContent.startsWith('book:')) {
target = linkContent.replace('book:', '').trim()
}
// Convert to d-tag format (same as MarkdownArticle)
const dtag = target.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
// Create a container for React component
const container = document.createElement('span')
container.className = 'inline-block'
element.parentNode?.replaceChild(container, element)
// Use React to render the component
const root = createRoot(container)
root.render(<Wikilink dTag={dtag} displayText={displayText} />)
reactRootsRef.current.set(container, root)
})
// Process WebPreview placeholders - replace with React components
const webpreviewPlaceholders = contentRef.current.querySelectorAll('.webpreview-placeholder[data-webpreview-url]')
webpreviewPlaceholders.forEach((element) => {
const url = element.getAttribute('data-webpreview-url')
if (!url) return
// Create a container for React component
const container = document.createElement('div')
container.className = 'my-2'
element.parentNode?.replaceChild(container, element)
// Use React to render the component
const root = createRoot(container)
root.render(<WebPreview url={url} className="w-full" />)
reactRootsRef.current.set(container, root)
})
// Process hashtags in text nodes - convert #tag to links
const walker = document.createTreeWalker(
contentRef.current,
NodeFilter.SHOW_TEXT,
{
acceptNode: (node) => {
// Skip if parent is a link, code, or pre tag
const parent = node.parentElement
if (!parent) return NodeFilter.FILTER_ACCEPT
if (parent.tagName === 'A' || parent.tagName === 'CODE' || parent.tagName === 'PRE') {
return NodeFilter.FILTER_REJECT
}
return NodeFilter.FILTER_ACCEPT
}
}
)
const textNodes: Text[] = []
let node
while ((node = walker.nextNode())) {
if (node.nodeType === Node.TEXT_NODE && node.textContent) {
textNodes.push(node as Text)
}
}
textNodes.forEach((textNode) => {
const text = textNode.textContent || ''
const hashtagRegex = /#([a-zA-Z0-9_]+)/g
const matches = Array.from(text.matchAll(hashtagRegex))
if (matches.length > 0) {
const fragment = document.createDocumentFragment()
let lastIndex = 0
matches.forEach((match) => {
if (match.index === undefined) return
// Add text before hashtag
if (match.index > lastIndex) {
fragment.appendChild(document.createTextNode(text.slice(lastIndex, match.index)))
}
// Create hashtag link
const link = document.createElement('a')
link.href = `/notes?t=${match[1].toLowerCase()}`
link.className = 'inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline cursor-pointer'
link.textContent = `#${match[1]}`
link.addEventListener('click', (e) => {
e.stopPropagation()
e.preventDefault()
navigateToHashtag(`/notes?t=${match[1].toLowerCase()}`)
})
fragment.appendChild(link)
lastIndex = match.index + match[0].length
})
// Add remaining text
if (lastIndex < text.length) {
fragment.appendChild(document.createTextNode(text.slice(lastIndex)))
}
textNode.parentNode?.replaceChild(fragment, textNode)
}
})
// Handle all links - truncate display text and add click handlers for relay URLs
const allLinks = contentRef.current.querySelectorAll('a[href]')
allLinks.forEach((link) => {
const href = link.getAttribute('href')
if (!href) return
// Get current link text (this might be the full URL or custom text)
const linkText = link.textContent || ''
// Truncate link text if it's longer than 200 characters
if (linkText.length > 200) {
const truncatedText = truncateLinkText(linkText)
link.textContent = truncatedText
// Store full text as title for tooltip
if (!link.getAttribute('title')) {
link.setAttribute('title', linkText)
}
}
// Handle relay URL links - add click handlers to navigate to relay page
const relayUrl = link.getAttribute('data-relay-url')
if (relayUrl) {
const relayPath = `/relays/${encodeURIComponent(relayUrl)}`
link.setAttribute('href', relayPath)
link.addEventListener('click', (e) => {
e.stopPropagation()
e.preventDefault()
navigateToRelay(relayPath)
})
}
})
// Cleanup function
return () => {
reactRootsRef.current.forEach((root) => {
root.unmount()
})
reactRootsRef.current.clear()
}
}, [parsedHtml, isLoading, navigateToHashtag, navigateToRelay])
// Initialize syntax highlighting
useEffect(() => {
const initHighlight = async () => {
if (typeof window !== 'undefined') {
const hljs = await import('highlight.js')
if (contentRef.current) {
contentRef.current.querySelectorAll('pre code').forEach((block) => {
const element = block as HTMLElement
element.style.color = 'inherit'
element.classList.add('text-gray-900', 'dark:text-gray-100')
hljs.default.highlightElement(element)
element.style.color = 'inherit'
})
}
}
}
const timeoutId = setTimeout(initHighlight, 100)
return () => clearTimeout(timeoutId)
}, [parsedHtml])
return (
<>
<style>{`
.hljs {
background: transparent !important;
}
.hljs-keyword,
.hljs-selector-tag,
.hljs-literal,
.hljs-title,
.hljs-section,
.hljs-doctag,
.hljs-type,
.hljs-name,
.hljs-strong {
color: #dc2626 !important;
font-weight: bold !important;
}
.hljs-string,
.hljs-title.class_,
.hljs-attr,
.hljs-symbol,
.hljs-bullet,
.hljs-addition,
.hljs-code,
.hljs-regexp,
.hljs-selector-pseudo,
.hljs-selector-attr,
.hljs-selector-class,
.hljs-selector-id {
color: #0284c7 !important;
}
.hljs-comment,
.hljs-quote {
color: #6b7280 !important;
}
.hljs-number,
.hljs-deletion {
color: #0d9488 !important;
}
.dark .hljs-keyword,
.dark .hljs-selector-tag,
.dark .hljs-literal,
.dark .hljs-title,
.dark .hljs-section,
.dark .hljs-doctag,
.dark .hljs-type,
.dark .hljs-name,
.dark .hljs-strong {
color: #f87171 !important;
}
.dark .hljs-string,
.dark .hljs-title.class_,
.dark .hljs-attr,
.dark .hljs-symbol,
.dark .hljs-bullet,
.dark .hljs-addition,
.dark .hljs-code,
.dark .hljs-regexp,
.dark .hljs-selector-pseudo,
.dark .hljs-selector-attr,
.dark .hljs-selector-class,
.dark .hljs-selector-id {
color: #38bdf8 !important;
}
.dark .hljs-comment,
.dark .hljs-quote {
color: #9ca3af !important;
}
.dark .hljs-number,
.dark .hljs-deletion {
color: #5eead4 !important;
}
.asciidoc-content img {
display: block;
max-width: 400px;
height: auto;
border-radius: 0.5rem;
cursor: zoom-in;
margin: 0.5rem 0;
}
.asciidoc-content a[href^="/notes?t="] {
color: #16a34a !important;
text-decoration: none !important;
}
.asciidoc-content a[href^="/notes?t="]:hover {
color: #15803d !important;
text-decoration: underline !important;
}
.dark .asciidoc-content a[href^="/notes?t="] {
color: #4ade80 !important;
}
.dark .asciidoc-content a[href^="/notes?t="]:hover {
color: #86efac !important;
}
`}</style>
<div className={`prose prose-zinc max-w-none dark:prose-invert break-words overflow-wrap-anywhere ${className || ''}`}>
{/* Metadata */}
{!hideImagesAndInfo && metadata.title && <h1 className="break-words">{metadata.title}</h1>}
{!hideImagesAndInfo && metadata.summary && (
<blockquote>
<p className="break-words">{metadata.summary}</p>
</blockquote>
)}
{hideImagesAndInfo && metadata.title && (
<h2 className="text-2xl font-bold mb-4 leading-tight break-words">{metadata.title}</h2>
)}
{/* Metadata image */}
{!hideImagesAndInfo && metadata.image && (() => {
const cleanedMetadataImage = cleanUrl(metadata.image)
// Don't show if already in content
if (cleanedMetadataImage && mediaUrlsInContent.has(cleanedMetadataImage)) {
return null
}
const metadataImageIndex = imageIndexMap.get(cleanedMetadataImage)
return (
<Image
image={{ url: metadata.image, pubkey: event.pubkey }}
className="max-w-[400px] w-full h-auto my-0 cursor-zoom-in"
classNames={{
wrapper: 'rounded-lg',
errorPlaceholder: 'aspect-square h-[30vh]'
}}
onClick={(e) => {
e.stopPropagation()
if (metadataImageIndex !== undefined) {
openLightbox(metadataImageIndex)
}
}}
/>
)
})()}
{/* Media from tags (only if not in content) */}
{leftoverTagMedia.length > 0 && (
<div className="space-y-4 mb-6">
{leftoverTagMedia.map((media) => {
const cleaned = cleanUrl(media.url)
const mediaIndex = imageIndexMap.get(cleaned)
if (media.type === 'image') {
return (
<div key={`tag-media-${cleaned}`} className="my-2">
<Image
image={{ url: media.url, pubkey: event.pubkey }}
className="max-w-[400px] rounded-lg cursor-zoom-in"
classNames={{
wrapper: 'rounded-lg',
errorPlaceholder: 'aspect-square h-[30vh]'
}}
onClick={(e) => {
e.stopPropagation()
if (mediaIndex !== undefined) {
openLightbox(mediaIndex)
}
}}
/>
</div>
)
} else if (media.type === 'video' || media.type === 'audio') {
return (
<div key={`tag-media-${cleaned}`} className="my-2 w-full max-w-full overflow-hidden">
<MediaPlayer
src={media.url}
className="max-w-full sm:max-w-[400px] w-full"
mustLoad={true}
poster={media.poster}
/>
</div>
)
}
return null
})}
</div>
)}
{/* YouTube URLs from tags (only if not in content) */}
{leftoverTagYouTubeUrls.length > 0 && (
<div className="space-y-4 mb-6">
{leftoverTagYouTubeUrls.map((url) => {
const cleaned = cleanUrl(url)
return (
<div key={`tag-youtube-${cleaned}`} className="my-2">
<YoutubeEmbeddedPlayer
url={url}
className="max-w-[400px]"
mustLoad={false}
/>
</div>
)
})}
</div>
)}
{/* Parsed AsciiDoc content */}
{isLoading ? (
<div>Loading content...</div>
) : (
<div
ref={contentRef}
className="asciidoc-content break-words"
dangerouslySetInnerHTML={{ __html: parsedHtml }}
/>
)}
{/* Hashtags from metadata (only if not already in content) */}
{!hideImagesAndInfo && leftoverMetadataTags.length > 0 && (
<div className="flex gap-2 flex-wrap pb-2 mt-4">
{leftoverMetadataTags.map((tag) => (
<div
key={tag}
title={tag}
className="flex items-center rounded-full px-3 bg-muted text-muted-foreground max-w-44 cursor-pointer hover:bg-accent hover:text-accent-foreground"
onClick={(e) => {
e.stopPropagation()
push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] }))
}}
>
#<span className="truncate">{tag}</span>
</div>
))}
</div>
)}
{/* WebPreview cards for links from tags (only if not already in content) */}
{/* Note: Links in content are already rendered as links in the AsciiDoc HTML above, so we don't show WebPreview for them */}
{leftoverTagLinks.length > 0 && (
<div className="space-y-3 mt-6">
{leftoverTagLinks.map((url, index) => (
<WebPreview key={`tag-${index}-${url}`} url={url} className="w-full" />
))}
</div>
)}
</div>
{/* Image gallery lightbox */}
{allImages.length > 0 && lightboxIndex >= 0 && createPortal(
<div onClick={(e) => e.stopPropagation()}>
<Lightbox
index={lightboxIndex}
slides={allImages.map(({ url, alt }) => ({
src: url,
alt: alt || url
}))}
plugins={[Zoom]}
open={lightboxIndex >= 0}
close={() => setLightboxIndex(-1)}
controller={{
closeOnBackdropClick: true,
closeOnPullUp: true,
closeOnPullDown: true
}}
styles={{
toolbar: { paddingTop: '2.25rem' }
}}
carousel={{
finite: false
}}
/>
</div>,
document.body
)}
</>
)
}