14 changed files with 1070 additions and 195 deletions
@ -0,0 +1,408 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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