13 changed files with 1759 additions and 75 deletions
@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
|
||||
import { randomString } from '@/lib/random' |
||||
import { cn } from '@/lib/utils' |
||||
import modalManager from '@/services/modal-manager.service' |
||||
import { TImageInfo } from '@/types' |
||||
import { useEffect, useMemo, useState } from 'react' |
||||
import { createPortal } from 'react-dom' |
||||
import Lightbox from 'yet-another-react-lightbox' |
||||
import Zoom from 'yet-another-react-lightbox/plugins/zoom' |
||||
import Image from '../Image' |
||||
|
||||
export default function ImageWithLightbox({ |
||||
image, |
||||
className |
||||
}: { |
||||
image: TImageInfo |
||||
className?: string |
||||
}) { |
||||
const id = useMemo(() => `image-with-lightbox-${randomString()}`, []) |
||||
const [index, setIndex] = useState(-1) |
||||
useEffect(() => { |
||||
if (index >= 0) { |
||||
modalManager.register(id, () => { |
||||
setIndex(-1) |
||||
}) |
||||
} else { |
||||
modalManager.unregister(id) |
||||
} |
||||
}, [index]) |
||||
|
||||
const handlePhotoClick = (event: React.MouseEvent) => { |
||||
event.stopPropagation() |
||||
event.preventDefault() |
||||
setIndex(0) |
||||
} |
||||
|
||||
return ( |
||||
<div className="w-fit max-w-full"> |
||||
<Image |
||||
key={0} |
||||
className={cn('rounded-lg max-h-[80vh] sm:max-h-[50vh] border cursor-zoom-in', className)} |
||||
classNames={{ |
||||
errorPlaceholder: 'aspect-square h-[30vh]' |
||||
}} |
||||
image={image} |
||||
onClick={(e) => handlePhotoClick(e)} |
||||
/> |
||||
{index >= 0 && |
||||
createPortal( |
||||
<div onClick={(e) => e.stopPropagation()}> |
||||
<Lightbox |
||||
index={index} |
||||
slides={[{ src: image.url }]} |
||||
plugins={[Zoom]} |
||||
open={index >= 0} |
||||
close={() => setIndex(-1)} |
||||
controller={{ |
||||
closeOnBackdropClick: true, |
||||
closeOnPullUp: true, |
||||
closeOnPullDown: true |
||||
}} |
||||
styles={{ |
||||
toolbar: { paddingTop: '2.25rem' } |
||||
}} |
||||
/> |
||||
</div>, |
||||
document.body |
||||
)} |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
|
||||
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: '' } |
||||
console.log('NostrLink bech32Id:', bech32Id) |
||||
try { |
||||
const { type } = nip19.decode(bech32Id) |
||||
if (type === 'npub') { |
||||
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" /> |
||||
} |
||||
@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
|
||||
import ImageWithLightbox from '@/components/ImageWithLightbox' |
||||
import { Badge } from '@/components/ui/badge' |
||||
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' |
||||
import { Event } from 'nostr-tools' |
||||
import { 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 metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) |
||||
|
||||
return ( |
||||
<div className={`prose prose-zinc max-w-none dark:prose-invert ${className || ''}`}> |
||||
<h1>{metadata.title}</h1> |
||||
{metadata.summary && ( |
||||
<blockquote> |
||||
<p>{metadata.summary}</p> |
||||
</blockquote> |
||||
)} |
||||
{metadata.tags.length > 0 && ( |
||||
<div className="flex gap-1 flex-wrap"> |
||||
{metadata.tags.map((tag) => ( |
||||
<Badge key={tag} variant="secondary"> |
||||
{tag} |
||||
</Badge> |
||||
))} |
||||
</div> |
||||
)} |
||||
{metadata.image && ( |
||||
<ImageWithLightbox |
||||
image={{ url: metadata.image, pubkey: event.pubkey }} |
||||
className="w-full aspect-[3/1] object-cover rounded-lg" |
||||
/> |
||||
)} |
||||
<Markdown |
||||
remarkPlugins={[remarkGfm, remarkNostr]} |
||||
components={ |
||||
{ |
||||
nostr: (props) => <NostrNode {...props} />, |
||||
img: ({ src, ...props }) => ( |
||||
<ImageWithLightbox image={{ url: src ?? '', pubkey: event.pubkey }} {...props} /> |
||||
), |
||||
a: (props) => <a {...props} target="_blank" rel="noreferrer noopener" /> |
||||
} as Components |
||||
} |
||||
> |
||||
{event.content} |
||||
</Markdown> |
||||
</div> |
||||
) |
||||
} |
||||
@ -0,0 +1,90 @@
@@ -0,0 +1,90 @@
|
||||
import type { PhrasingContent, Root, Text } from 'mdast' |
||||
import type { Plugin } from 'unified' |
||||
import { visit } from 'unist-util-visit' |
||||
import { NostrNode } from './types' |
||||
|
||||
const NOSTR_REGEX = |
||||
/nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+|note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)/g |
||||
const NOSTR_REFERENCE_REGEX = |
||||
/\[[^\]]+\]\[(nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+|note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+))\]/g |
||||
|
||||
export const remarkNostr: Plugin<[], Root> = () => { |
||||
return (tree) => { |
||||
visit(tree, 'text', (node: Text, index, parent) => { |
||||
if (!parent || typeof index !== 'number') return |
||||
|
||||
const text = node.value |
||||
|
||||
// First, handle reference-style nostr links [text][nostr:...]
|
||||
const refMatches = Array.from(text.matchAll(NOSTR_REFERENCE_REGEX)) |
||||
// Then, handle direct nostr links that are not part of reference links
|
||||
const directMatches = Array.from(text.matchAll(NOSTR_REGEX)).filter((directMatch) => { |
||||
return !refMatches.some( |
||||
(refMatch) => |
||||
directMatch.index! >= refMatch.index! && |
||||
directMatch.index! < refMatch.index! + refMatch[0].length |
||||
) |
||||
}) |
||||
|
||||
// Combine and sort matches by position
|
||||
const allMatches = [ |
||||
...refMatches.map((match) => ({ |
||||
...match, |
||||
type: 'reference' as const, |
||||
bech32Id: match[2], |
||||
rawText: match[0] |
||||
})), |
||||
...directMatches.map((match) => ({ |
||||
...match, |
||||
type: 'direct' as const, |
||||
bech32Id: match[1], |
||||
rawText: match[0] |
||||
})) |
||||
].sort((a, b) => a.index! - b.index!) |
||||
|
||||
if (allMatches.length === 0) return |
||||
|
||||
const children: (Text | NostrNode)[] = [] |
||||
let lastIndex = 0 |
||||
|
||||
allMatches.forEach((match) => { |
||||
const matchStart = match.index! |
||||
const matchEnd = matchStart + match[0].length |
||||
|
||||
// Add text before the match
|
||||
if (matchStart > lastIndex) { |
||||
children.push({ |
||||
type: 'text', |
||||
value: text.slice(lastIndex, matchStart) |
||||
}) |
||||
} |
||||
|
||||
// Create custom nostr node with type information
|
||||
const nostrNode: NostrNode = { |
||||
type: 'nostr', |
||||
data: { |
||||
hName: 'nostr', |
||||
hProperties: { |
||||
bech32Id: match.bech32Id, |
||||
rawText: match.rawText |
||||
} |
||||
} |
||||
} |
||||
children.push(nostrNode) |
||||
|
||||
lastIndex = matchEnd |
||||
}) |
||||
|
||||
// Add remaining text after the last match
|
||||
if (lastIndex < text.length) { |
||||
children.push({ |
||||
type: 'text', |
||||
value: text.slice(lastIndex) |
||||
}) |
||||
} |
||||
|
||||
// Type assertion to tell TypeScript these are valid AST nodes
|
||||
parent.children.splice(index, 1, ...(children as PhrasingContent[])) |
||||
}) |
||||
} |
||||
} |
||||
@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
|
||||
import { ComponentProps } from 'react' |
||||
import type { Components as RmComponents } from 'react-markdown' |
||||
import type { Data, Node } from 'unist' |
||||
|
||||
// Extend the Components interface to include your custom component
|
||||
export interface Components extends RmComponents { |
||||
nostr: React.ComponentType<{ |
||||
rawText: string |
||||
bech32Id?: string |
||||
}> |
||||
} |
||||
|
||||
export interface NostrNode extends Node { |
||||
type: 'nostr' |
||||
data: Data & { |
||||
hName: string |
||||
hProperties: ComponentProps<Components['nostr']> |
||||
} |
||||
} |
||||
Loading…
Reference in new issue