diff --git a/package-lock.json b/package-lock.json
index 4478a70..73559d5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -19,6 +19,7 @@
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-checkbox": "^1.3.3",
+ "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-hover-card": "^1.1.4",
@@ -2601,6 +2602,171 @@
}
}
},
+ "node_modules/@radix-ui/react-collapsible": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
+ "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/primitive": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
+ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
+ "license": "MIT"
+ },
+ "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-compose-refs": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
+ "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-context": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
+ "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-id": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz",
+ "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-presence": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
+ "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-primitive": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
+ "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.2.3"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-use-controllable-state": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
+ "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-use-effect-event": "0.0.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-use-layout-effect": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
+ "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-collection": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.1.tgz",
diff --git a/package.json b/package.json
index 4d0609f..959ca48 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "jumble-imwald",
- "version": "10.15",
+ "version": "11.0",
"description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble",
"private": true,
"type": "module",
@@ -29,6 +29,7 @@
"@radix-ui/react-alert-dialog": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-checkbox": "^1.3.3",
+ "@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-hover-card": "^1.1.4",
diff --git a/src/components/Note/Article/index.tsx b/src/components/Note/Article/index.tsx
index a381085..ddbf3bf 100644
--- a/src/components/Note/Article/index.tsx
+++ b/src/components/Note/Article/index.tsx
@@ -1,21 +1,16 @@
import { useSecondaryPage } from '@/PageManager'
import ImageWithLightbox from '@/components/ImageWithLightbox'
-import ImageGallery from '@/components/ImageGallery'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNoteList } from '@/lib/link'
-import { ExternalLink } from 'lucide-react'
+import { ChevronDown, ChevronRight } from 'lucide-react'
import { Event, kinds } from 'nostr-tools'
-import { useMemo } from 'react'
-import Markdown from 'react-markdown'
-import remarkGfm from 'remark-gfm'
-import remarkMath from 'remark-math'
-import rehypeKatex from 'rehype-katex'
-import NostrNode from '../LongFormArticle/NostrNode'
-import { remarkNostr } from '../LongFormArticle/remarkNostr'
-import { Components } from '../LongFormArticle/types'
+import { useMemo, useState, useEffect, useRef } from 'react'
import { useEventFieldParser } from '@/hooks/useContentParser'
import WebPreview from '../../WebPreview'
-import 'katex/dist/katex.min.css'
+import HighlightSourcePreview from '../../UniversalContent/HighlightSourcePreview'
+import TableOfContents from '../../UniversalContent/TableOfContents'
+import { Button } from '@/components/ui/button'
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
export default function Article({
event,
@@ -26,6 +21,7 @@ export default function Article({
}) {
const { push } = useSecondaryPage()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
+ const [isInfoOpen, setIsInfoOpen] = useState(false)
// Use the comprehensive content parser
const { parsedContent, isLoading, error } = useEventFieldParser(event, 'content', {
@@ -33,48 +29,70 @@ export default function Article({
enableSyntaxHighlighting: true
})
- const components = useMemo(
- () =>
- ({
- nostr: ({ rawText, bech32Id }) => ,
- a: ({ href, children, ...props }) => {
- if (href?.startsWith('nostr:')) {
- return
+ const contentRef = useRef(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()
}
- return (
-
- {children}
-
-
- )
- },
- p: (props) => {
- // Check if paragraph contains only an image
- if (props.children && typeof props.children === 'string' && props.children.match(/^!\[.*\]\(.*\)$/)) {
- return
+
+ 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 = 'View on Wikistr'
+ wikistrButton.onclick = () => {
+ window.open(`https://wikistr.imwald.eu/${dTag}`, '_blank', 'noopener,noreferrer')
+ dropdown.remove()
}
- return
- },
- div: (props) => ,
- code: (props) => ,
- img: (props) => (
-
- )
- }) as Components,
- [event.pubkey]
- )
+
+ 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 = '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])
+
if (isLoading) {
return (
@@ -117,26 +135,14 @@ export default function Article({
)}
- {/* Render content based on markup type */}
- {parsedContent.markupType === 'asciidoc' ? (
- // AsciiDoc content (already processed to HTML)
-
- ) : (
- // Markdown content (let react-markdown handle it)
- {
- if (url.startsWith('nostr:')) {
- return url.slice(6) // Remove 'nostr:' prefix for rendering
- }
- return url
- }}
- components={components}
- >
- {event.content}
-
- )}
+ {/* Table of Contents */}
+
+
+ {/* Render AsciiDoc content (everything is now processed as AsciiDoc) */}
+
{/* Hashtags */}
{parsedContent.hashtags.length > 0 && (
@@ -157,55 +163,84 @@ export default function Article({
)}
- {/* Media thumbnails */}
- {parsedContent.media.length > 0 && (
-
-
Images in this article:
-
- {parsedContent.media.map((media, index) => (
-
-
+ {/* Collapsible Article Info */}
+ {(parsedContent.media.length > 0 || parsedContent.links.length > 0 || parsedContent.nostrLinks.length > 0 || parsedContent.highlightSources.length > 0) && (
+
+
+
+
+
+ {/* Media thumbnails */}
+ {parsedContent.media.length > 0 && (
+
+
Images in this article:
+
+ {parsedContent.media.map((media, index) => (
+
+
+
+ ))}
+
- ))}
-
-
- )}
+ )}
- {/* Links summary with OpenGraph previews */}
- {parsedContent.links.length > 0 && (
-
-
Links in this article:
-
- {parsedContent.links.map((link, index) => (
-
- ))}
-
-
- )}
+ {/* Links summary with OpenGraph previews */}
+ {parsedContent.links.length > 0 && (
+
+
Links in this article:
+
+ {parsedContent.links.map((link, index) => (
+
+ ))}
+
+
+ )}
- {/* Nostr links summary */}
- {parsedContent.nostrLinks.length > 0 && (
-
-
Nostr references:
-
- {parsedContent.nostrLinks.map((link, index) => (
-
-
{link.type}:{' '}
-
{link.id}
+ {/* Nostr links summary */}
+ {parsedContent.nostrLinks.length > 0 && (
+
+
Nostr references:
+
+ {parsedContent.nostrLinks.map((link, index) => (
+
+ {link.type}:{' '}
+ {link.id}
+
+ ))}
+
- ))}
-
-
+ )}
+
+ {/* Highlight sources */}
+ {parsedContent.highlightSources.length > 0 && (
+
+
Highlight sources:
+
+ {parsedContent.highlightSources.map((source, index) => (
+
+ ))}
+
+
+ )}
+
+
)}
)
diff --git a/src/components/UniversalContent/HighlightSourcePreview.tsx b/src/components/UniversalContent/HighlightSourcePreview.tsx
new file mode 100644
index 0000000..8195dcf
--- /dev/null
+++ b/src/components/UniversalContent/HighlightSourcePreview.tsx
@@ -0,0 +1,85 @@
+/**
+ * Component to display highlight sources (e/a tags or URLs) as embedded events or OpenGraph previews
+ */
+
+import { useMemo } from 'react'
+import { nip19 } from 'nostr-tools'
+import WebPreview from '../WebPreview'
+import { EmbeddedNote } from '../Embedded/EmbeddedNote'
+import { ExternalLink } from 'lucide-react'
+
+interface HighlightSourcePreviewProps {
+ source: {
+ type: 'event' | 'addressable' | 'url'
+ value: string
+ bech32: string
+ }
+ className?: string
+}
+
+export default function HighlightSourcePreview({ source, className }: HighlightSourcePreviewProps) {
+ const alexandriaUrl = useMemo(() => {
+ if (source.type === 'url') {
+ return source.value
+ }
+ return `https://next-alexandria.gitcitadel.eu/events?id=${source.bech32}`
+ }, [source])
+
+ if (source.type === 'event') {
+ // For events, try to decode and show as embedded note
+ try {
+ const decoded = nip19.decode(source.bech32)
+ if (decoded.type === 'nevent' || decoded.type === 'note') {
+ return (
+
+
+
+ )
+ }
+ } catch (error) {
+ console.warn('Failed to decode nostr event:', error)
+ }
+ }
+
+ if (source.type === 'addressable') {
+ // For addressable events, try to decode and show as embedded note
+ try {
+ const decoded = nip19.decode(source.bech32)
+ if (decoded.type === 'naddr') {
+ return (
+
+
+
+ )
+ }
+ } catch (error) {
+ console.warn('Failed to decode nostr addressable event:', error)
+ }
+ }
+
+ // Fallback: show as Alexandria link or WebPreview for URLs
+ if (source.type === 'url') {
+ return (
+
+
+
+ )
+ }
+
+ // For nostr events that couldn't be embedded, show as Alexandria link
+ return (
+
+ )
+}
diff --git a/src/components/UniversalContent/ParsedContent.tsx b/src/components/UniversalContent/ParsedContent.tsx
index 27d56ce..b9e6951 100644
--- a/src/components/UniversalContent/ParsedContent.tsx
+++ b/src/components/UniversalContent/ParsedContent.tsx
@@ -5,18 +5,9 @@
import { useEventFieldParser } from '@/hooks/useContentParser'
import { Event } from 'nostr-tools'
-import { useMemo } from 'react'
-import Markdown from 'react-markdown'
-import remarkGfm from 'remark-gfm'
-import remarkMath from 'remark-math'
-import rehypeKatex from 'rehype-katex'
-import { Components } from '../Note/LongFormArticle/types'
-import NostrNode from '../Note/LongFormArticle/NostrNode'
import ImageWithLightbox from '../ImageWithLightbox'
-import ImageGallery from '../ImageGallery'
-import { ExternalLink } from 'lucide-react'
import WebPreview from '../WebPreview'
-import 'katex/dist/katex.min.css'
+import HighlightSourcePreview from './HighlightSourcePreview'
interface ParsedContentProps {
event: Event
@@ -28,7 +19,7 @@ interface ParsedContentProps {
showLinks?: boolean
showHashtags?: boolean
showNostrLinks?: boolean
- maxImageWidth?: string
+ showHighlightSources?: boolean
}
export default function ParsedContent({
@@ -41,55 +32,13 @@ export default function ParsedContent({
showLinks = false,
showHashtags = false,
showNostrLinks = false,
- maxImageWidth = '400px'
+ showHighlightSources = false,
}: ParsedContentProps) {
const { parsedContent, isLoading, error } = useEventFieldParser(event, field, {
enableMath,
enableSyntaxHighlighting
})
- const components = useMemo(
- () =>
- ({
- nostr: ({ rawText, bech32Id }) =>
,
- a: ({ href, children, ...props }) => {
- if (href?.startsWith('nostr:')) {
- return
- }
- return (
-
- {children}
-
-
- )
- },
- p: (props) => {
- // Check if paragraph contains only an image
- if (props.children && typeof props.children === 'string' && props.children.match(/^!\[.*\]\(.*\)$/)) {
- return
- }
- return
- },
- div: (props) =>
,
- code: (props) =>
,
- img: (props) => (
-
- )
- }) as Components,
- [event.pubkey, maxImageWidth]
- )
if (isLoading) {
return (
@@ -118,26 +67,8 @@ export default function ParsedContent({
return (
- {/* Render content based on markup type */}
- {parsedContent.markupType === 'asciidoc' ? (
- // AsciiDoc content (already processed to HTML)
-
- ) : (
- // Markdown content (let react-markdown handle it)
-
{
- if (url.startsWith('nostr:')) {
- return url.slice(6) // Remove 'nostr:' prefix for rendering
- }
- return url
- }}
- components={components}
- >
- {field === 'content' ? event.content : event.tags?.find(tag => tag[0] === field)?.[1] || ''}
-
- )}
+ {/* Render AsciiDoc content (everything is now processed as AsciiDoc) */}
+
{/* Media thumbnails */}
{showMedia && parsedContent.media.length > 0 && (
@@ -204,6 +135,22 @@ export default function ParsedContent({
)}
+
+ {/* Highlight sources */}
+ {showHighlightSources && parsedContent.highlightSources.length > 0 && (
+
+
Highlight sources:
+
+ {parsedContent.highlightSources.map((source, index) => (
+
+ ))}
+
+
+ )}
)
}
diff --git a/src/components/UniversalContent/TableOfContents.tsx b/src/components/UniversalContent/TableOfContents.tsx
new file mode 100644
index 0000000..a3d4ef9
--- /dev/null
+++ b/src/components/UniversalContent/TableOfContents.tsx
@@ -0,0 +1,132 @@
+/**
+ * Compact Table of Contents component for articles
+ */
+
+import { useEffect, useState } from 'react'
+import { ChevronDown, ChevronRight, Hash } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
+
+interface TocItem {
+ id: string
+ text: string
+ level: number
+}
+
+interface TableOfContentsProps {
+ content: string
+ className?: string
+}
+
+export default function TableOfContents({ content, className }: TableOfContentsProps) {
+ const [isOpen, setIsOpen] = useState(false)
+ const [tocItems, setTocItems] = useState([])
+
+ useEffect(() => {
+ // Parse content for headings
+ const parser = new DOMParser()
+ const doc = parser.parseFromString(content, 'text/html')
+
+ const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6')
+ const items: TocItem[] = []
+
+ headings.forEach((heading, index) => {
+ const level = parseInt(heading.tagName.charAt(1))
+ const text = heading.textContent?.trim() || ''
+
+ if (text) {
+ // Use existing ID if available, otherwise generate one
+ const existingId = heading.id
+ const id = existingId || `heading-${index}-${text.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`
+
+ items.push({
+ id,
+ text,
+ level
+ })
+ }
+ })
+
+ setTocItems(items)
+ }, [content])
+
+ if (tocItems.length === 0) {
+ return null
+ }
+
+ const scrollToHeading = (item: TocItem) => {
+ // Try to find the element in the actual DOM
+ const element = document.getElementById(item.id)
+ if (element) {
+ element.scrollIntoView({
+ behavior: 'smooth',
+ block: 'start',
+ inline: 'nearest'
+ })
+ setIsOpen(false)
+ } else {
+ // Fallback: try to find by text content
+ const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6')
+ for (const heading of headings) {
+ if (heading.textContent?.trim() === item.text) {
+ heading.scrollIntoView({
+ behavior: 'smooth',
+ block: 'start',
+ inline: 'nearest'
+ })
+ setIsOpen(false)
+ break
+ }
+ }
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+ {tocItems.map((item) => (
+
+ ))}
+
+
+
+
+ )
+}
diff --git a/src/components/UniversalContent/Wikilink.tsx b/src/components/UniversalContent/Wikilink.tsx
new file mode 100644
index 0000000..0fb27d0
--- /dev/null
+++ b/src/components/UniversalContent/Wikilink.tsx
@@ -0,0 +1,60 @@
+import { useState } from 'react'
+import { ExternalLink, ChevronDown, ChevronRight } from 'lucide-react'
+import { Button } from '@/components/ui/button'
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
+
+interface WikilinkProps {
+ dTag: string
+ displayText: string
+ className?: string
+}
+
+export default function Wikilink({ dTag, displayText, className }: WikilinkProps) {
+ const [isOpen, setIsOpen] = useState(false)
+
+ const handleWikistrClick = () => {
+ const url = `https://wikistr.imwald.eu/${dTag}`
+ window.open(url, '_blank', 'noopener,noreferrer')
+ }
+
+ const handleAlexandriaClick = () => {
+ const url = `https://next-alexandria.gitcitadel.eu/events?d=${dTag}`
+ window.open(url, '_blank', 'noopener,noreferrer')
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/UniversalContent/WikilinkProcessor.tsx b/src/components/UniversalContent/WikilinkProcessor.tsx
new file mode 100644
index 0000000..a1cae55
--- /dev/null
+++ b/src/components/UniversalContent/WikilinkProcessor.tsx
@@ -0,0 +1,47 @@
+import { useEffect, useRef } from 'react'
+import Wikilink from './Wikilink'
+
+interface WikilinkProcessorProps {
+ htmlContent: string
+ className?: string
+}
+
+export default function WikilinkProcessor({ htmlContent, className }: WikilinkProcessorProps) {
+ const containerRef = useRef(null)
+
+ useEffect(() => {
+ if (!containerRef.current) return
+
+ // Find all wikilink spans and replace them with Wikilink components
+ const wikilinkSpans = containerRef.current.querySelectorAll('span.wikilink')
+
+ wikilinkSpans.forEach((span) => {
+ const dTag = span.getAttribute('data-dtag')
+ const displayText = span.getAttribute('data-display')
+
+ if (dTag && displayText) {
+ // Create a container for the Wikilink component
+ const container = document.createElement('div')
+ container.className = 'inline-block'
+
+ // Replace the span with the container
+ span.parentNode?.replaceChild(container, span)
+
+ // Render the Wikilink component into the container
+ // We'll use React's createRoot for this
+ import('react-dom/client').then(({ createRoot }) => {
+ const root = createRoot(container)
+ root.render()
+ })
+ }
+ })
+ }, [htmlContent])
+
+ return (
+
+ )
+}
diff --git a/src/components/ui/collapsible.tsx b/src/components/ui/collapsible.tsx
new file mode 100644
index 0000000..7cee61e
--- /dev/null
+++ b/src/components/ui/collapsible.tsx
@@ -0,0 +1,9 @@
+import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'
+
+const Collapsible = CollapsiblePrimitive.Root
+
+const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
+
+const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent }
diff --git a/src/services/content-parser.service.ts b/src/services/content-parser.service.ts
index 802b2da..c220a8d 100644
--- a/src/services/content-parser.service.ts
+++ b/src/services/content-parser.service.ts
@@ -4,7 +4,7 @@
*/
import { detectMarkupType, getMarkupClasses, MarkupType } from '@/lib/markup-detection'
-import { Event } from 'nostr-tools'
+import { Event, nip19 } from 'nostr-tools'
import { getImetaInfosFromEvent } from '@/lib/event'
import { URL_REGEX } from '@/constants'
import { TImetaInfo } from '@/types'
@@ -18,6 +18,7 @@ export interface ParsedContent {
links: Array<{ url: string; text: string; isExternal: boolean }>
hashtags: string[]
nostrLinks: Array<{ type: 'npub' | 'nprofile' | 'nevent' | 'naddr' | 'note'; id: string; text: string }>
+ highlightSources: Array<{ type: 'event' | 'addressable' | 'url'; value: string; bech32: string }>
}
export interface ParseOptions {
@@ -68,54 +69,38 @@ class ContentParserService {
const cssClasses = getMarkupClasses(markupType)
// Extract all content elements
- const media = this.extractAllMedia(content, event)
- const links = this.extractLinks(content)
- const hashtags = this.extractHashtags(content)
- const nostrLinks = this.extractNostrLinks(content)
+ const media = this.extractAllMedia(content, event)
+ const links = this.extractLinks(content)
+ const hashtags = this.extractHashtags(content)
+ const nostrLinks = this.extractNostrLinks(content)
+ const highlightSources = event ? this.extractHighlightSources(event) : []
// Check for LaTeX math
const hasMath = enableMath && this.hasMathContent(content)
let html = ''
- let processedContent = content
try {
- switch (markupType) {
- case 'asciidoc':
- html = await this.parseAsciidoc(content, { enableMath, enableSyntaxHighlighting })
- break
-
- case 'advanced-markdown':
- processedContent = this.preprocessAdvancedMarkdown(content)
- html = await this.parseAdvancedMarkdown(processedContent, { enableMath, enableSyntaxHighlighting })
- break
-
- case 'basic-markdown':
- processedContent = this.preprocessBasicMarkdown(content)
- html = await this.parseBasicMarkdown(processedContent)
- break
-
- case 'plain-text':
- default:
- html = this.parsePlainText(content)
- break
- }
+ // Convert everything to AsciiDoc format and process as AsciiDoc
+ const asciidocContent = this.convertToAsciidoc(content, markupType)
+ html = await this.parseAsciidoc(asciidocContent, { enableMath, enableSyntaxHighlighting })
} catch (error) {
console.error('Content parsing error:', error)
// Fallback to plain text
html = this.parsePlainText(content)
}
- return {
- html,
- markupType,
- cssClasses,
- hasMath,
- media,
- links,
- hashtags,
- nostrLinks
- }
+ return {
+ html,
+ markupType: 'asciidoc',
+ cssClasses,
+ hasMath,
+ media,
+ links,
+ hashtags,
+ nostrLinks,
+ highlightSources
+ }
}
/**
@@ -143,8 +128,11 @@ class ContentParserService {
const htmlString = typeof result === 'string' ? result : result.toString()
+ // Process wikilinks in the HTML output
+ const processedHtml = this.processWikilinksInHtml(htmlString)
+
// Clean up any leftover markdown syntax
- return this.cleanupMarkdown(htmlString)
+ return this.cleanupMarkdown(processedHtml)
} catch (error) {
console.error('AsciiDoc parsing error:', error)
return this.parsePlainText(content)
@@ -152,141 +140,149 @@ class ContentParserService {
}
/**
- * Parse advanced Markdown content
+ * Convert content to AsciiDoc format based on markup type
*/
- private async parseAdvancedMarkdown(content: string, _options: { enableMath: boolean; enableSyntaxHighlighting: boolean }): Promise {
- // This will be handled by react-markdown with plugins
- // Return the processed content for react-markdown to handle
- return content
+ private convertToAsciidoc(content: string, markupType: string): string {
+ let asciidoc = ''
+
+ switch (markupType) {
+ case 'asciidoc':
+ asciidoc = content
+ break
+
+ case 'advanced-markdown':
+ case 'basic-markdown':
+ asciidoc = this.convertMarkdownToAsciidoc(content)
+ break
+
+ case 'plain-text':
+ default:
+ asciidoc = this.convertPlainTextToAsciidoc(content)
+ break
+ }
+
+ // Process wikilinks for all content types
+ return this.processWikilinks(asciidoc)
}
/**
- * Parse basic Markdown content
+ * Convert Markdown to AsciiDoc format
*/
- private parseBasicMarkdown(content: string): string {
- // Basic markdown processing
- let processed = content
+ private convertMarkdownToAsciidoc(content: string): string {
+ let asciidoc = content
+
+ // Convert headers
+ asciidoc = asciidoc.replace(/^#{6}\s+(.+)$/gm, '====== $1 ======')
+ asciidoc = asciidoc.replace(/^#{5}\s+(.+)$/gm, '===== $1 =====')
+ asciidoc = asciidoc.replace(/^#{4}\s+(.+)$/gm, '==== $1 ====')
+ asciidoc = asciidoc.replace(/^#{3}\s+(.+)$/gm, '=== $1 ===')
+ asciidoc = asciidoc.replace(/^#{2}\s+(.+)$/gm, '== $1 ==')
+ asciidoc = asciidoc.replace(/^#{1}\s+(.+)$/gm, '= $1 =')
+
+ // Convert emphasis
+ asciidoc = asciidoc.replace(/\*\*(.+?)\*\*/g, '*$1*') // Bold
+ asciidoc = asciidoc.replace(/\*(.+?)\*/g, '_$1_') // Italic
+ asciidoc = asciidoc.replace(/~~(.+?)~~/g, '[line-through]#$1#') // Strikethrough
+
+ // Convert code
+ asciidoc = asciidoc.replace(/```(\w+)?\n([\s\S]*?)```/g, (_match, lang, code) => {
+ return `[source${lang ? ',' + lang : ''}]\n----\n${code.trim()}\n----`
+ })
+ asciidoc = asciidoc.replace(/`([^`]+)`/g, '`$1`') // Inline code
- // Headers
- processed = processed.replace(/^### (.*$)/gim, '$1
')
- processed = processed.replace(/^## (.*$)/gim, '$1
')
- processed = processed.replace(/^# (.*$)/gim, '$1
')
+ // Convert blockquotes
+ asciidoc = asciidoc.replace(/^>\s+(.+)$/gm, '____\n$1\n____')
- // Bold and italic
- processed = processed.replace(/\*\*(.*?)\*\*/g, '$1')
- processed = processed.replace(/\*(.*?)\*/g, '$1')
- processed = processed.replace(/_(.*?)_/g, '$1')
- processed = processed.replace(/~(.*?)~/g, '$1')
+ // Convert lists
+ asciidoc = asciidoc.replace(/^(\s*)\*\s+(.+)$/gm, '$1* $2') // Unordered lists
+ asciidoc = asciidoc.replace(/^(\s*)\d+\.\s+(.+)$/gm, '$1. $2') // Ordered lists
- // Links and images
- processed = this.processLinks(processed)
- processed = this.processImages(processed)
+ // Convert links
+ asciidoc = asciidoc.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1[$2]')
- // Lists
- processed = this.processLists(processed)
+ // Convert images
+ asciidoc = asciidoc.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, 'image::$2[$1]')
- // Blockquotes
- processed = processed.replace(/^> (.*$)/gim, '$1
')
+ // Convert tables (basic support)
+ asciidoc = asciidoc.replace(/^\|(.+)\|$/gm, '|$1|')
- // Line breaks
- processed = processed.replace(/\n\n/g, '
')
- processed = `
${processed}
`
+ // Convert horizontal rules
+ asciidoc = asciidoc.replace(/^---$/gm, '\'\'\'')
- return processed
+ return asciidoc
}
/**
- * Parse plain text content
+ * Process wikilinks in content (both standard and bookstr macro)
*/
- private parsePlainText(content: string): string {
- // Convert line breaks to HTML
- return content
- .replace(/\n\n/g, '')
- .replace(/\n/g, '
')
- .replace(/^/, '
')
- .replace(/$/, '
')
- }
+ private processWikilinks(content: string): string {
+ let processed = content
- /**
- * Preprocess advanced Markdown content
- */
- private preprocessAdvancedMarkdown(content: string): string {
- // Handle wikilinks: [[NIP-54]] -> [NIP-54](https://next-alexandria.gitcitadel.eu/publication?d=nip-54)
- content = content.replace(/\[\[([^\]]+)\]\]/g, (_match, text) => {
- const slug = text.toLowerCase().replace(/\s+/g, '-')
- return `[${text}](https://next-alexandria.gitcitadel.eu/publication?d=${slug})`
+ // Process bookstr macro wikilinks: [[book:...]] where ... can be any book type and reference
+ processed = processed.replace(/\[\[book:([^\]]+)\]\]/g, (_match, bookContent) => {
+ const cleanContent = bookContent.trim()
+ const dTag = this.normalizeDtag(cleanContent)
+
+ return `wikilink:${dTag}[${cleanContent}]`
})
- // Handle hashtags: #hashtag -> [#hashtag](/hashtag/hashtag)
- content = content.replace(/#([a-zA-Z0-9_]+)/g, (_match, tag) => {
- return `[#${tag}](/hashtag/${tag})`
+ // Process standard wikilinks: [[Target Page]] or [[target page|see this]]
+ processed = processed.replace(/\[\[([^|\]]+)(?:\|([^\]]+))?\]\]/g, (_match, target, displayText) => {
+ const cleanTarget = target.trim()
+ const cleanDisplay = displayText ? displayText.trim() : cleanTarget
+ const dTag = this.normalizeDtag(cleanTarget)
+
+ return `wikilink:${dTag}[${cleanDisplay}]`
})
- return content
+ return processed
}
/**
- * Preprocess basic Markdown content
+ * Normalize text to d-tag format (lowercase, non-letters to dashes)
*/
- private preprocessBasicMarkdown(content: string): string {
- // Handle hashtags
- content = content.replace(/#([a-zA-Z0-9_]+)/g, (_match, tag) => {
- return `[#${tag}](/hashtag/${tag})`
- })
-
- // Handle emoji shortcodes
- content = content.replace(/:([a-zA-Z0-9_]+):/g, (_match, _emoji) => {
- // This would need an emoji mapping - for now just return as-is
- return _match
- })
-
- return content
+ private normalizeDtag(text: string): string {
+ return text
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
}
/**
- * Process markdown links
+ * Process wikilinks in HTML output
*/
- private processLinks(content: string): string {
- return content.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, text, url) => {
- // Check if it's already an HTML link
- if (content.includes(`href="${url}"`)) {
- return match
- }
-
- // Handle nostr: prefixes
- if (url.startsWith('nostr:')) {
- return `${text}`
- }
-
- return `${text} `
+ private processWikilinksInHtml(html: string): string {
+ // Convert wikilink:dtag[display] format to HTML with data attributes
+ return html.replace(/wikilink:([^[]+)\[([^\]]+)\]/g, (_match, dTag, displayText) => {
+ return `${displayText}`
})
}
/**
- * Process markdown images
+ * Convert plain text to AsciiDoc format
*/
- private processImages(content: string): string {
- return content.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, url) => {
- const altText = alt || ''
- return `
`
- })
+ private convertPlainTextToAsciidoc(content: string): string {
+ // Convert line breaks to AsciiDoc format
+ return content
+ .replace(/\n\n/g, '\n\n')
+ .replace(/\n/g, ' +\n')
}
+
/**
- * Process markdown lists
+ * Parse plain text content
*/
- private processLists(content: string): string {
- // Unordered lists
- content = content.replace(/^[\s]*\* (.+)$/gm, '$1')
- content = content.replace(/(.*<\/li>)/s, '')
-
- // Ordered lists
- content = content.replace(/^[\s]*\d+\. (.+)$/gm, '$1')
- content = content.replace(/(.*<\/li>)/s, '$1
')
-
+ private parsePlainText(content: string): string {
+ // Convert line breaks to HTML
return content
+ .replace(/\n\n/g, '')
+ .replace(/\n/g, '
')
+ .replace(/^/, '
')
+ .replace(/$/, '
')
}
+
+
/**
* Clean up leftover markdown syntax after AsciiDoc processing
*/
@@ -562,6 +558,77 @@ class ContentParserService {
return url.startsWith('nostr:') || this.getNostrType(url) !== null
}
+ /**
+ * Extract highlight sources from event tags
+ */
+ private extractHighlightSources(event: Event): Array<{ type: 'event' | 'addressable' | 'url'; value: string; bech32: string }> {
+ const sources: Array<{ type: 'event' | 'addressable' | 'url'; value: string; bech32: string }> = []
+
+ // Check for 'source' marker first (highest priority)
+ let sourceTag: string[] | undefined
+ for (const tag of event.tags) {
+ if (tag[2] === 'source' || tag[3] === 'source') {
+ sourceTag = tag
+ break
+ }
+ }
+
+ // If no 'source' marker found, process tags in priority order: e > a > r
+ if (!sourceTag) {
+ for (const tag of event.tags) {
+ // Give 'e' tags highest priority
+ if (tag[0] === 'e') {
+ sourceTag = tag
+ continue
+ }
+
+ // Give 'a' tags second priority (but don't override 'e' tags)
+ if (tag[0] === 'a' && (!sourceTag || sourceTag[0] !== 'e')) {
+ sourceTag = tag
+ continue
+ }
+
+ // Give 'r' tags lowest priority
+ if (tag[0] === 'r' && (!sourceTag || sourceTag[0] === 'r')) {
+ sourceTag = tag
+ continue
+ }
+ }
+ }
+
+ // Process the selected source tag
+ if (sourceTag) {
+ if (sourceTag[0] === 'e') {
+ sources.push({
+ type: 'event',
+ value: sourceTag[1],
+ bech32: nip19.noteEncode(sourceTag[1])
+ })
+ } else if (sourceTag[0] === 'a') {
+ const [kind, pubkey, identifier] = sourceTag[1].split(':')
+ const relay = sourceTag[2]
+ sources.push({
+ type: 'addressable',
+ value: sourceTag[1],
+ bech32: nip19.naddrEncode({
+ kind: parseInt(kind),
+ pubkey,
+ identifier: identifier || '',
+ relays: relay ? [relay] : []
+ })
+ })
+ } else if (sourceTag[0] === 'r') {
+ sources.push({
+ type: 'url',
+ value: sourceTag[1],
+ bech32: sourceTag[1]
+ })
+ }
+ }
+
+ return sources
+ }
+
/**
* Get Nostr identifier type
*/
@@ -605,7 +672,8 @@ class ContentParserService {
media: [],
links: [],
hashtags: [],
- nostrLinks: []
+ nostrLinks: [],
+ highlightSources: []
}
}