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.
310 lines
10 KiB
310 lines
10 KiB
import { SecondaryPageLink, useSecondaryPage } from '@/PageManager' |
|
import ImageWithLightbox from '@/components/ImageWithLightbox' |
|
import ImageCarousel from '@/components/ImageCarousel/ImageCarousel' |
|
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' |
|
import { toNote, toNoteList, toProfile } from '@/lib/link' |
|
import { extractAllImagesFromEvent } from '@/lib/image-extraction' |
|
import { ExternalLink, ChevronDown, ChevronRight } from 'lucide-react' |
|
import { Event, kinds } from 'nostr-tools' |
|
import React, { useMemo, useEffect, useRef, useState } 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' |
|
import { Button } from '@/components/ui/button' |
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' |
|
|
|
export default function MarkdownArticle({ |
|
event, |
|
className |
|
}: { |
|
event: Event |
|
className?: string |
|
}) { |
|
const { push } = useSecondaryPage() |
|
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) |
|
const [isImagesOpen, setIsImagesOpen] = useState(false) |
|
|
|
// Extract all images from the event |
|
const allImages = useMemo(() => extractAllImagesFromEvent(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: () => { |
|
// Don't render inline images - they'll be shown in the carousel |
|
return null |
|
} |
|
}) 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> |
|
|
|
{/* Image Carousel - Collapsible */} |
|
{allImages.length > 0 && ( |
|
<Collapsible open={isImagesOpen} onOpenChange={setIsImagesOpen} className="mt-8"> |
|
<CollapsibleTrigger asChild> |
|
<Button variant="outline" className="w-full justify-between"> |
|
<span>Images in this article ({allImages.length})</span> |
|
{isImagesOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />} |
|
</Button> |
|
</CollapsibleTrigger> |
|
<CollapsibleContent className="mt-4"> |
|
<ImageCarousel images={allImages} /> |
|
</CollapsibleContent> |
|
</Collapsible> |
|
)} |
|
{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> |
|
</> |
|
) |
|
}
|
|
|