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.
 
 
 

292 lines
12 KiB

import { useSecondaryPage } from '@/PageManager'
import ImageWithLightbox from '@/components/ImageWithLightbox'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNoteList } from '@/lib/link'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { Event, kinds } from 'nostr-tools'
import { useMemo, useState, useEffect, useRef } from 'react'
import { useEventFieldParser } from '@/hooks/useContentParser'
import WebPreview from '../../WebPreview'
import HighlightSourcePreview from '../../UniversalContent/HighlightSourcePreview'
import { Button } from '@/components/ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { ExtendedKind } from '@/constants'
export default function Article({
event,
className
}: {
event: Event
className?: string
}) {
const { push } = useSecondaryPage()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const [isInfoOpen, setIsInfoOpen] = useState(false)
// Determine if this is an article-type event that should show ToC and Article Info
const isArticleType = useMemo(() => {
return event.kind === kinds.LongFormArticle ||
event.kind === ExtendedKind.WIKI_ARTICLE ||
event.kind === ExtendedKind.PUBLICATION ||
event.kind === ExtendedKind.PUBLICATION_CONTENT
}, [event.kind])
// Use the comprehensive content parser
const { parsedContent, isLoading, error } = useEventFieldParser(event, 'content', {
enableMath: true,
enableSyntaxHighlighting: true
})
const contentRef = useRef<HTMLDivElement>(null)
// Handle wikilink clicks
useEffect(() => {
if (!contentRef.current) return
const handleWikilinkClick = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (target.classList.contains('wikilink')) {
event.preventDefault()
const dTag = target.getAttribute('data-dtag')
const displayText = target.getAttribute('data-display')
if (dTag && displayText) {
// Create a simple dropdown menu
const existingDropdown = document.querySelector('.wikilink-dropdown')
if (existingDropdown) {
existingDropdown.remove()
}
const dropdown = document.createElement('div')
dropdown.className = 'wikilink-dropdown fixed bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg z-50 p-2'
dropdown.style.left = `${event.pageX}px`
dropdown.style.top = `${event.pageY + 10}px`
const wikistrButton = document.createElement('button')
wikistrButton.className = 'w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2'
wikistrButton.innerHTML = '<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>View on Wikistr'
wikistrButton.onclick = () => {
window.open(`https://wikistr.imwald.eu/${dTag}`, '_blank', 'noopener,noreferrer')
dropdown.remove()
}
const alexandriaButton = document.createElement('button')
alexandriaButton.className = 'w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2'
alexandriaButton.innerHTML = '<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>View on Alexandria'
alexandriaButton.onclick = () => {
window.open(`https://next-alexandria.gitcitadel.eu/events?d=${dTag}`, '_blank', 'noopener,noreferrer')
dropdown.remove()
}
dropdown.appendChild(wikistrButton)
dropdown.appendChild(alexandriaButton)
document.body.appendChild(dropdown)
// Close dropdown when clicking outside
const closeDropdown = (e: MouseEvent) => {
if (!dropdown.contains(e.target as Node)) {
dropdown.remove()
document.removeEventListener('click', closeDropdown)
}
}
setTimeout(() => document.addEventListener('click', closeDropdown), 0)
}
}
}
contentRef.current.addEventListener('click', handleWikilinkClick)
return () => {
contentRef.current?.removeEventListener('click', handleWikilinkClick)
}
}, [parsedContent])
// Add ToC return buttons to section headers
useEffect(() => {
if (!contentRef.current || !isArticleType || !parsedContent) return
const addTocReturnButtons = () => {
const headers = contentRef.current?.querySelectorAll('h1, h2, h3, h4, h5, h6')
if (!headers) return
headers.forEach((header) => {
// Skip if button already exists
if (header.querySelector('.toc-return-btn')) return
// Create the return button
const returnBtn = document.createElement('span')
returnBtn.className = 'toc-return-btn'
returnBtn.innerHTML = '↑ ToC'
returnBtn.title = 'Return to Table of Contents'
// Add click handler
returnBtn.addEventListener('click', (e) => {
e.preventDefault()
e.stopPropagation()
// Scroll to the ToC
const tocElement = document.getElementById('toc')
if (tocElement) {
tocElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
})
// Add the button to the header
header.appendChild(returnBtn)
})
}
// Add buttons after a short delay to ensure content is rendered
const timeoutId = setTimeout(addTocReturnButtons, 100)
return () => clearTimeout(timeoutId)
}, [parsedContent?.html, isArticleType])
if (isLoading) {
return (
<div className={`prose prose-zinc max-w-none dark:prose-invert break-words ${className || ''}`}>
<div>Loading content...</div>
</div>
)
}
if (error) {
return (
<div className={`prose prose-zinc max-w-none dark:prose-invert break-words ${className || ''}`}>
<div className="text-red-500">Error loading content: {error.message}</div>
</div>
)
}
if (!parsedContent) {
return (
<div className={`prose prose-zinc max-w-none dark:prose-invert break-words ${className || ''}`}>
<div>No content available</div>
</div>
)
}
return (
<div className={`${parsedContent.cssClasses} ${className || ''}`}>
{/* Article metadata */}
<h1 className="break-words">{metadata.title}</h1>
{metadata.summary && (
<blockquote>
<p className="break-words">{metadata.summary}</p>
</blockquote>
)}
{metadata.image && (
<ImageWithLightbox
image={{ url: metadata.image, pubkey: event.pubkey }}
className="w-full max-w-[400px] h-auto object-contain my-0"
/>
)}
{/* Render AsciiDoc content (everything is now processed as AsciiDoc) */}
<div ref={contentRef} className={isArticleType ? "asciidoc-content" : "simple-content"} dangerouslySetInnerHTML={{ __html: parsedContent.html }} />
{/* Collapsible Article Info - only for article-type events */}
{isArticleType && (parsedContent.media.length > 0 || parsedContent.links.length > 0 || parsedContent.nostrLinks.length > 0 || parsedContent.highlightSources.length > 0 || parsedContent.hashtags.length > 0) && (
<Collapsible open={isInfoOpen} onOpenChange={setIsInfoOpen} className="mt-4">
<CollapsibleTrigger asChild>
<Button variant="outline" className="w-full justify-between">
<span>Article Info</span>
{isInfoOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 mt-2">
{/* Media thumbnails */}
{parsedContent.media.length > 0 && (
<div className="p-4 bg-muted rounded-lg">
<h4 className="text-sm font-semibold mb-3">Images in this article:</h4>
<div className="grid grid-cols-8 sm:grid-cols-12 md:grid-cols-16 gap-1">
{parsedContent.media.map((media, index) => (
<div key={index} className="aspect-square">
<ImageWithLightbox
image={media}
className="w-full h-full object-cover rounded cursor-pointer hover:opacity-80 transition-opacity"
classNames={{
wrapper: 'w-full h-full'
}}
/>
</div>
))}
</div>
</div>
)}
{/* Links summary with OpenGraph previews */}
{parsedContent.links.length > 0 && (
<div className="p-4 bg-muted rounded-lg">
<h4 className="text-sm font-semibold mb-3">Links in this article:</h4>
<div className="space-y-3">
{parsedContent.links.map((link, index) => (
<WebPreview
key={index}
url={link.url}
className="w-full"
/>
))}
</div>
</div>
)}
{/* Nostr links summary */}
{parsedContent.nostrLinks.length > 0 && (
<div className="p-4 bg-muted rounded-lg">
<h4 className="text-sm font-semibold mb-2">Nostr references:</h4>
<div className="space-y-1">
{parsedContent.nostrLinks.map((link, index) => (
<div key={index} className="text-sm">
<span className="font-mono text-blue-600">{link.type}:</span>{' '}
<span className="font-mono">{link.id}</span>
</div>
))}
</div>
</div>
)}
{/* Highlight sources */}
{parsedContent.highlightSources.length > 0 && (
<div className="p-4 bg-muted rounded-lg">
<h4 className="text-sm font-semibold mb-3">Highlight sources:</h4>
<div className="space-y-3">
{parsedContent.highlightSources.map((source, index) => (
<HighlightSourcePreview
key={index}
source={source}
className="w-full"
/>
))}
</div>
</div>
)}
{/* Hashtags */}
{parsedContent.hashtags.length > 0 && (
<div className="p-4 bg-muted rounded-lg">
<h4 className="text-sm font-semibold mb-3">Tags:</h4>
<div className="flex gap-2 flex-wrap">
{parsedContent.hashtags.map((tag) => (
<div
key={tag}
title={tag}
className="flex items-center rounded-full px-3 py-1 bg-background text-muted-foreground max-w-44 cursor-pointer hover:bg-accent hover:text-accent-foreground transition-colors"
onClick={(e) => {
e.stopPropagation()
push(toNoteList({ hashtag: tag, kinds: [kinds.LongFormArticle] }))
}}
>
#<span className="truncate">{tag}</span>
</div>
))}
</div>
</div>
)}
</CollapsibleContent>
</Collapsible>
)}
</div>
)
}