14 changed files with 1070 additions and 195 deletions
@ -0,0 +1,408 @@ |
|||||||
|
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 AsciidocArticle({ |
||||||
|
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]) |
||||||
|
|
||||||
|
// Process nostr addresses and other interactive elements after HTML is rendered
|
||||||
|
useEffect(() => { |
||||||
|
if (!contentRef.current || !parsedContent) return |
||||||
|
|
||||||
|
const processInteractiveElements = () => { |
||||||
|
// Process embedded note containers
|
||||||
|
const embeddedNotes = contentRef.current?.querySelectorAll('[data-embedded-note]') |
||||||
|
embeddedNotes?.forEach((container) => { |
||||||
|
const bech32Id = container.getAttribute('data-embedded-note') |
||||||
|
if (bech32Id) { |
||||||
|
// Replace with actual EmbeddedNote component
|
||||||
|
const embeddedNoteElement = document.createElement('div') |
||||||
|
embeddedNoteElement.innerHTML = `<div data-embedded-note="${bech32Id}">Loading embedded event...</div>` |
||||||
|
container.parentNode?.replaceChild(embeddedNoteElement.firstChild!, container) |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
// Process user handles
|
||||||
|
const userHandles = contentRef.current?.querySelectorAll('[data-pubkey]') |
||||||
|
userHandles?.forEach((handle) => { |
||||||
|
const pubkey = handle.getAttribute('data-pubkey') |
||||||
|
if (pubkey) { |
||||||
|
// Replace with actual Username component
|
||||||
|
const usernameElement = document.createElement('span') |
||||||
|
usernameElement.innerHTML = `<span class="user-handle" data-pubkey="${pubkey}">@${handle.textContent}</span>` |
||||||
|
handle.parentNode?.replaceChild(usernameElement.firstChild!, handle) |
||||||
|
} |
||||||
|
}) |
||||||
|
|
||||||
|
// Process wikilinks
|
||||||
|
const wikilinks = contentRef.current?.querySelectorAll('.wikilink') |
||||||
|
wikilinks?.forEach((wikilink) => { |
||||||
|
const dTag = wikilink.getAttribute('data-dtag') |
||||||
|
const displayText = wikilink.getAttribute('data-display') |
||||||
|
if (dTag && displayText) { |
||||||
|
// Add click handler for wikilinks
|
||||||
|
wikilink.addEventListener('click', (e) => { |
||||||
|
e.preventDefault() |
||||||
|
e.stopPropagation() |
||||||
|
const mouseEvent = e as MouseEvent |
||||||
|
// Create dropdown menu similar to the original implementation
|
||||||
|
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 = `${mouseEvent.pageX}px` |
||||||
|
dropdown.style.top = `${mouseEvent.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) |
||||||
|
}) |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
// Process elements after a short delay to ensure content is rendered
|
||||||
|
const timeoutId = setTimeout(processInteractiveElements, 100) |
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId) |
||||||
|
}, [parsedContent?.html]) |
||||||
|
|
||||||
|
// 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 ( |
||||||
|
<article className={`prose prose-zinc max-w-none dark:prose-invert break-words leading-relaxed ${parsedContent?.cssClasses || ''} ${className || ''}`}> |
||||||
|
{/* Article metadata */} |
||||||
|
<header className="mb-8"> |
||||||
|
<h1 className="break-words text-4xl font-bold mb-6 leading-tight">{metadata.title}</h1> |
||||||
|
{metadata.summary && ( |
||||||
|
<blockquote className="border-l-4 border-primary pl-6 italic text-muted-foreground mb-8 text-lg leading-relaxed"> |
||||||
|
<p className="break-words">{metadata.summary}</p> |
||||||
|
</blockquote> |
||||||
|
)} |
||||||
|
{metadata.image && ( |
||||||
|
<div className="mb-8"> |
||||||
|
<ImageWithLightbox |
||||||
|
image={{ url: metadata.image, pubkey: event.pubkey }} |
||||||
|
className="w-full max-w-[800px] h-auto object-contain rounded-lg shadow-lg mx-auto" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</header> |
||||||
|
|
||||||
|
{/* Render AsciiDoc content (everything is now processed as AsciiDoc) */} |
||||||
|
<div
|
||||||
|
ref={contentRef}
|
||||||
|
className={`prose prose-zinc max-w-none dark:prose-invert break-words leading-relaxed text-base ${isArticleType ? "asciidoc-content" : "simple-content"}`} |
||||||
|
style={{ |
||||||
|
// Override any problematic AsciiDoc styles
|
||||||
|
'--tw-prose-body': 'inherit', |
||||||
|
'--tw-prose-headings': 'inherit', |
||||||
|
'--tw-prose-lead': 'inherit', |
||||||
|
'--tw-prose-links': 'inherit', |
||||||
|
'--tw-prose-bold': 'inherit', |
||||||
|
'--tw-prose-counters': 'inherit', |
||||||
|
'--tw-prose-bullets': 'inherit', |
||||||
|
'--tw-prose-hr': 'inherit', |
||||||
|
'--tw-prose-quotes': 'inherit', |
||||||
|
'--tw-prose-quote-borders': 'inherit', |
||||||
|
'--tw-prose-captions': 'inherit', |
||||||
|
'--tw-prose-code': 'inherit', |
||||||
|
'--tw-prose-pre-code': 'inherit', |
||||||
|
'--tw-prose-pre-bg': 'inherit', |
||||||
|
'--tw-prose-th-borders': 'inherit', |
||||||
|
'--tw-prose-td-borders': 'inherit' |
||||||
|
} as React.CSSProperties} |
||||||
|
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> |
||||||
|
)} |
||||||
|
</article> |
||||||
|
) |
||||||
|
} |
||||||
@ -1,138 +0,0 @@ |
|||||||
import { SecondaryPageLink, useSecondaryPage } from '@/PageManager' |
|
||||||
import ImageWithLightbox from '@/components/ImageWithLightbox' |
|
||||||
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' |
|
||||||
import { toNote, toNoteList, toProfile } from '@/lib/link' |
|
||||||
import { ExternalLink } from 'lucide-react' |
|
||||||
import { Event, kinds } from 'nostr-tools' |
|
||||||
import React, { useMemo } from 'react' |
|
||||||
import Markdown from 'react-markdown' |
|
||||||
import remarkGfm from 'remark-gfm' |
|
||||||
import NostrNode from './NostrNode' |
|
||||||
import { remarkNostr } from './remarkNostr' |
|
||||||
import { Components } from './types' |
|
||||||
|
|
||||||
export default function LongFormArticle({ |
|
||||||
event, |
|
||||||
className |
|
||||||
}: { |
|
||||||
event: Event |
|
||||||
className?: string |
|
||||||
}) { |
|
||||||
const { push } = useSecondaryPage() |
|
||||||
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) |
|
||||||
|
|
||||||
const components = useMemo( |
|
||||||
() => |
|
||||||
({ |
|
||||||
nostr: ({ rawText, bech32Id }) => <NostrNode rawText={rawText} bech32Id={bech32Id} />, |
|
||||||
a: ({ href, children, ...props }) => { |
|
||||||
if (!href) { |
|
||||||
return <span {...props} className="break-words" /> |
|
||||||
} |
|
||||||
if (href.startsWith('note1') || href.startsWith('nevent1') || href.startsWith('naddr1')) { |
|
||||||
return ( |
|
||||||
<SecondaryPageLink |
|
||||||
to={toNote(href)} |
|
||||||
className="break-words underline text-foreground" |
|
||||||
> |
|
||||||
{children} |
|
||||||
</SecondaryPageLink> |
|
||||||
) |
|
||||||
} |
|
||||||
if (href.startsWith('npub1') || href.startsWith('nprofile1')) { |
|
||||||
return ( |
|
||||||
<SecondaryPageLink |
|
||||||
to={toProfile(href)} |
|
||||||
className="break-words underline text-foreground" |
|
||||||
> |
|
||||||
{children} |
|
||||||
</SecondaryPageLink> |
|
||||||
) |
|
||||||
} |
|
||||||
return ( |
|
||||||
<a |
|
||||||
{...props} |
|
||||||
href={href} |
|
||||||
target="_blank" |
|
||||||
rel="noreferrer noopener" |
|
||||||
className="break-words inline-flex items-baseline gap-1" |
|
||||||
> |
|
||||||
{children} <ExternalLink className="size-3" /> |
|
||||||
</a> |
|
||||||
) |
|
||||||
}, |
|
||||||
p: (props) => { |
|
||||||
// Check if the paragraph contains only an image
|
|
||||||
const children = props.children |
|
||||||
if (React.Children.count(children) === 1 && React.isValidElement(children)) { |
|
||||||
const child = children as React.ReactElement |
|
||||||
if (child.type === ImageWithLightbox) { |
|
||||||
// Render image outside paragraph context
|
|
||||||
return <div {...props} className="break-words" /> |
|
||||||
} |
|
||||||
} |
|
||||||
return <p {...props} className="break-words" /> |
|
||||||
}, |
|
||||||
div: (props) => <div {...props} className="break-words" />, |
|
||||||
code: (props) => <code {...props} className="break-words whitespace-pre-wrap" />, |
|
||||||
img: (props) => ( |
|
||||||
<ImageWithLightbox |
|
||||||
image={{ url: props.src || '', pubkey: event.pubkey }} |
|
||||||
className="max-h-[80vh] sm:max-h-[50vh] object-contain my-0 max-w-[400px]" |
|
||||||
classNames={{ |
|
||||||
wrapper: 'w-fit max-w-[400px]' |
|
||||||
}} |
|
||||||
/> |
|
||||||
) |
|
||||||
}) as Components, |
|
||||||
[] |
|
||||||
) |
|
||||||
|
|
||||||
return ( |
|
||||||
<div |
|
||||||
className={`prose prose-zinc max-w-none dark:prose-invert break-words overflow-wrap-anywhere ${className || ''}`} |
|
||||||
> |
|
||||||
<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] aspect-[3/1] object-cover my-0" |
|
||||||
/> |
|
||||||
)} |
|
||||||
<Markdown |
|
||||||
remarkPlugins={[remarkGfm, remarkNostr]} |
|
||||||
urlTransform={(url) => { |
|
||||||
if (url.startsWith('nostr:')) { |
|
||||||
return url.slice(6) // Remove 'nostr:' prefix for rendering
|
|
||||||
} |
|
||||||
return url |
|
||||||
}} |
|
||||||
components={components} |
|
||||||
> |
|
||||||
{event.content} |
|
||||||
</Markdown> |
|
||||||
{metadata.tags.length > 0 && ( |
|
||||||
<div className="flex gap-2 flex-wrap pb-2"> |
|
||||||
{metadata.tags.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> |
|
||||||
)} |
|
||||||
</div> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -0,0 +1,292 @@ |
|||||||
|
import { SecondaryPageLink, useSecondaryPage } from '@/PageManager' |
||||||
|
import ImageWithLightbox from '@/components/ImageWithLightbox' |
||||||
|
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' |
||||||
|
import { toNote, toNoteList, toProfile } from '@/lib/link' |
||||||
|
import { ExternalLink } from 'lucide-react' |
||||||
|
import { Event, kinds } from 'nostr-tools' |
||||||
|
import React, { useMemo, useEffect, useRef } from 'react' |
||||||
|
import Markdown from 'react-markdown' |
||||||
|
import remarkGfm from 'remark-gfm' |
||||||
|
import remarkMath from 'remark-math' |
||||||
|
import rehypeKatex from 'rehype-katex' |
||||||
|
import 'katex/dist/katex.min.css' |
||||||
|
import NostrNode from './NostrNode' |
||||||
|
import { remarkNostr } from './remarkNostr' |
||||||
|
import { Components } from './types' |
||||||
|
|
||||||
|
export default function MarkdownArticle({ |
||||||
|
event, |
||||||
|
className |
||||||
|
}: { |
||||||
|
event: Event |
||||||
|
className?: string |
||||||
|
}) { |
||||||
|
const { push } = useSecondaryPage() |
||||||
|
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) |
||||||
|
const contentRef = useRef<HTMLDivElement>(null) |
||||||
|
|
||||||
|
// Initialize highlight.js for 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) => { |
||||||
|
// Ensure text color is visible before highlighting
|
||||||
|
const element = block as HTMLElement |
||||||
|
element.style.color = 'inherit' |
||||||
|
element.classList.add('text-gray-900', 'dark:text-gray-100') |
||||||
|
hljs.default.highlightElement(element) |
||||||
|
// Ensure text color remains visible after highlighting
|
||||||
|
element.style.color = 'inherit' |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Run highlight after a short delay to ensure content is rendered
|
||||||
|
const timeoutId = setTimeout(initHighlight, 100) |
||||||
|
return () => clearTimeout(timeoutId) |
||||||
|
}, [event.content]) |
||||||
|
|
||||||
|
const components = useMemo( |
||||||
|
() => |
||||||
|
({ |
||||||
|
nostr: ({ rawText, bech32Id }) => <NostrNode rawText={rawText} bech32Id={bech32Id} />, |
||||||
|
a: ({ href, children, ...props }) => { |
||||||
|
if (!href) { |
||||||
|
return <span {...props} className="break-words" /> |
||||||
|
} |
||||||
|
if (href.startsWith('note1') || href.startsWith('nevent1') || href.startsWith('naddr1')) { |
||||||
|
return ( |
||||||
|
<SecondaryPageLink |
||||||
|
to={toNote(href)} |
||||||
|
className="break-words underline text-foreground" |
||||||
|
> |
||||||
|
{children} |
||||||
|
</SecondaryPageLink> |
||||||
|
) |
||||||
|
} |
||||||
|
if (href.startsWith('npub1') || href.startsWith('nprofile1')) { |
||||||
|
return ( |
||||||
|
<SecondaryPageLink |
||||||
|
to={toProfile(href)} |
||||||
|
className="break-words underline text-foreground" |
||||||
|
> |
||||||
|
{children} |
||||||
|
</SecondaryPageLink> |
||||||
|
) |
||||||
|
} |
||||||
|
return ( |
||||||
|
<a |
||||||
|
{...props} |
||||||
|
href={href} |
||||||
|
target="_blank" |
||||||
|
rel="noreferrer noopener" |
||||||
|
className="break-words inline-flex items-baseline gap-1" |
||||||
|
> |
||||||
|
{children} <ExternalLink className="size-3" /> |
||||||
|
</a> |
||||||
|
) |
||||||
|
}, |
||||||
|
p: (props) => { |
||||||
|
// Check if the paragraph contains only an image
|
||||||
|
const children = props.children |
||||||
|
if (React.Children.count(children) === 1 && React.isValidElement(children)) { |
||||||
|
const child = children as React.ReactElement |
||||||
|
if (child.type === ImageWithLightbox) { |
||||||
|
// Render image outside paragraph context
|
||||||
|
return <div {...props} className="break-words" /> |
||||||
|
} |
||||||
|
} |
||||||
|
return <p {...props} className="break-words" /> |
||||||
|
}, |
||||||
|
div: (props) => <div {...props} className="break-words" />, |
||||||
|
code: ({ className, children, ...props }: any) => { |
||||||
|
const match = /language-(\w+)/.exec(className || '') |
||||||
|
const isInline = !match |
||||||
|
return !isInline && match ? ( |
||||||
|
<pre className="bg-gray-100 dark:bg-gray-800 rounded-lg p-4 overflow-x-auto"> |
||||||
|
<code className={`language-${match[1]} ${className || ''} text-gray-900 dark:text-gray-100`} {...props}> |
||||||
|
{children} |
||||||
|
</code> |
||||||
|
</pre> |
||||||
|
) : ( |
||||||
|
<code className={`${className || ''} break-words whitespace-pre-wrap bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded text-gray-900 dark:text-gray-100`} {...props}> |
||||||
|
{children} |
||||||
|
</code> |
||||||
|
) |
||||||
|
}, |
||||||
|
text: ({ children }) => { |
||||||
|
// Handle hashtags in text
|
||||||
|
if (typeof children === 'string') { |
||||||
|
const hashtagRegex = /#(\w+)/g |
||||||
|
const parts = [] |
||||||
|
let lastIndex = 0 |
||||||
|
let match |
||||||
|
|
||||||
|
while ((match = hashtagRegex.exec(children)) !== null) { |
||||||
|
// Add text before the hashtag
|
||||||
|
if (match.index > lastIndex) { |
||||||
|
parts.push(children.slice(lastIndex, match.index)) |
||||||
|
} |
||||||
|
|
||||||
|
// Add the hashtag as a clickable link
|
||||||
|
const hashtag = match[1] |
||||||
|
parts.push( |
||||||
|
<SecondaryPageLink |
||||||
|
key={match.index} |
||||||
|
to={toNoteList({ hashtag, kinds: [kinds.LongFormArticle] })} |
||||||
|
className="text-green-600 dark:text-green-400 hover:underline" |
||||||
|
> |
||||||
|
#{hashtag} |
||||||
|
</SecondaryPageLink> |
||||||
|
) |
||||||
|
|
||||||
|
lastIndex = match.index + match[0].length |
||||||
|
} |
||||||
|
|
||||||
|
// Add remaining text
|
||||||
|
if (lastIndex < children.length) { |
||||||
|
parts.push(children.slice(lastIndex)) |
||||||
|
} |
||||||
|
|
||||||
|
return <>{parts}</> |
||||||
|
} |
||||||
|
|
||||||
|
return <>{children}</> |
||||||
|
}, |
||||||
|
img: (props) => ( |
||||||
|
<ImageWithLightbox |
||||||
|
image={{ url: props.src || '', pubkey: event.pubkey }} |
||||||
|
className="max-w-[400px] object-contain my-0" |
||||||
|
classNames={{ |
||||||
|
wrapper: 'w-fit max-w-[400px]' |
||||||
|
}} |
||||||
|
/> |
||||||
|
) |
||||||
|
}) as Components, |
||||||
|
[] |
||||||
|
) |
||||||
|
|
||||||
|
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: #f85149 !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: #0366d6 !important; |
||||||
|
} |
||||||
|
.hljs-comment, |
||||||
|
.hljs-quote { |
||||||
|
color: #8b949e !important; |
||||||
|
} |
||||||
|
.hljs-number, |
||||||
|
.hljs-deletion { |
||||||
|
color: #005cc5 !important; |
||||||
|
} |
||||||
|
.hljs-variable, |
||||||
|
.hljs-template-variable, |
||||||
|
.hljs-link { |
||||||
|
color: #e36209 !important; |
||||||
|
} |
||||||
|
.hljs-meta { |
||||||
|
color: #6f42c1 !important; |
||||||
|
} |
||||||
|
.hljs-built_in, |
||||||
|
.hljs-class .hljs-title { |
||||||
|
color: #005cc5 !important; |
||||||
|
} |
||||||
|
.hljs-params { |
||||||
|
color: #f0f6fc !important; |
||||||
|
} |
||||||
|
.hljs-attribute { |
||||||
|
color: #005cc5 !important; |
||||||
|
} |
||||||
|
.hljs-function .hljs-title { |
||||||
|
color: #6f42c1 !important; |
||||||
|
} |
||||||
|
.hljs-subst { |
||||||
|
color: #f0f6fc !important; |
||||||
|
} |
||||||
|
.hljs-emphasis { |
||||||
|
font-style: italic; |
||||||
|
} |
||||||
|
.hljs-strong { |
||||||
|
font-weight: bold; |
||||||
|
} |
||||||
|
`}</style>
|
||||||
|
<div |
||||||
|
ref={contentRef} |
||||||
|
className={`prose prose-zinc max-w-none dark:prose-invert break-words overflow-wrap-anywhere ${className || ''}`} |
||||||
|
> |
||||||
|
{metadata.title && <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] aspect-[3/1] object-cover my-0" |
||||||
|
/> |
||||||
|
)} |
||||||
|
<Markdown |
||||||
|
remarkPlugins={[remarkGfm, remarkMath, remarkNostr]} |
||||||
|
rehypePlugins={[rehypeKatex]} |
||||||
|
urlTransform={(url) => { |
||||||
|
if (url.startsWith('nostr:')) { |
||||||
|
return url.slice(6) // Remove 'nostr:' prefix for rendering
|
||||||
|
} |
||||||
|
return url |
||||||
|
}} |
||||||
|
components={components} |
||||||
|
> |
||||||
|
{event.content} |
||||||
|
</Markdown> |
||||||
|
{metadata.tags.length > 0 && ( |
||||||
|
<div className="flex gap-2 flex-wrap pb-2"> |
||||||
|
{metadata.tags.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> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,29 @@ |
|||||||
|
import { EmbeddedMention, EmbeddedNote } from '@/components/Embedded' |
||||||
|
import { nip19 } from 'nostr-tools' |
||||||
|
import { ComponentProps, useMemo } from 'react' |
||||||
|
import { Components } from './types' |
||||||
|
|
||||||
|
export default function NostrNode({ rawText, bech32Id }: ComponentProps<Components['nostr']>) { |
||||||
|
const { type, id } = useMemo(() => { |
||||||
|
if (!bech32Id) return { type: 'invalid', id: '' } |
||||||
|
try { |
||||||
|
const { type } = nip19.decode(bech32Id) |
||||||
|
if (type === 'npub' || type === 'nprofile') { |
||||||
|
return { type: 'mention', id: bech32Id } |
||||||
|
} |
||||||
|
if (type === 'nevent' || type === 'naddr' || type === 'note') { |
||||||
|
return { type: 'note', id: bech32Id } |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.error('Invalid bech32 ID:', bech32Id, error) |
||||||
|
} |
||||||
|
return { type: 'invalid', id: '' } |
||||||
|
}, [bech32Id]) |
||||||
|
|
||||||
|
if (type === 'invalid') return rawText |
||||||
|
|
||||||
|
if (type === 'mention') { |
||||||
|
return <EmbeddedMention userId={id} className="not-prose" /> |
||||||
|
} |
||||||
|
return <EmbeddedNote noteId={id} className="not-prose" /> |
||||||
|
} |
||||||
Loading…
Reference in new issue