diff --git a/src/components/ArticleExportMenu/ArticleExportMenu.tsx b/src/components/ArticleExportMenu/ArticleExportMenu.tsx index 78e38ca..f8fc3de 100644 --- a/src/components/ArticleExportMenu/ArticleExportMenu.tsx +++ b/src/components/ArticleExportMenu/ArticleExportMenu.tsx @@ -6,7 +6,6 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { MoreVertical, FileDown } from 'lucide-react' -import { contentParserService } from '@/services/content-parser.service' import logger from '@/lib/logger' import { Event } from 'nostr-tools' @@ -16,85 +15,13 @@ interface ArticleExportMenuProps { } export default function ArticleExportMenu({ event, title }: ArticleExportMenuProps) { - const exportArticle = async (format: 'pdf' | 'epub' | 'latex' | 'adoc' | 'html') => { + const exportArticle = async () => { try { const content = event.content - const filename = `${title}.${format}` + const filename = `${title}.adoc` - let blob: Blob = new Blob(['']) - - if (format === 'adoc') { - // Export raw AsciiDoc content - blob = new Blob([content], { type: 'text/plain' }) - } else if (format === 'html') { - // Parse the AsciiDoc content to HTML - const parsedContent = await contentParserService.parseContent(content, { - eventKind: event.kind, - enableMath: true, - enableSyntaxHighlighting: true - }) - - const htmlDocument = ` - - - - ${title} - - - -
-

${title}

- ${parsedContent.html} -
- -` - - blob = new Blob([htmlDocument], { type: 'text/html' }) - } else if (format === 'latex') { - // Basic LaTeX conversion - let processedContent = content.replace(/^= (.+)$/gm, '\\section{$1}') - processedContent = processedContent.replace(/^== (.+)$/gm, '\\subsection{$1}') - processedContent = processedContent.replace(/^=== (.+)$/gm, '\\subsubsection{$1}') - blob = new Blob([processedContent], { type: 'text/plain' }) - } else if (format === 'pdf' || format === 'epub') { - // Parse the AsciiDoc content to HTML using the content parser - const parsedContent = await contentParserService.parseContent(content, { - eventKind: event.kind, - enableMath: true, - enableSyntaxHighlighting: true - }) - - const htmlDocument = ` - - - - ${title} - - - -
-

${title}

- ${parsedContent.html} -
- -` - - blob = new Blob([htmlDocument], { type: 'text/html' }) - } + // Export raw AsciiDoc content + const blob = new Blob([content], { type: 'text/plain' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') @@ -105,7 +32,7 @@ export default function ArticleExportMenu({ event, title }: ArticleExportMenuPro document.body.removeChild(a) URL.revokeObjectURL(url) - logger.info(`[ArticleExportMenu] Exported article as ${format}`) + logger.info('[ArticleExportMenu] Exported article as .adoc') } catch (error) { logger.error('[ArticleExportMenu] Error exporting article:', error) alert('Failed to export article. Please try again.') @@ -119,41 +46,10 @@ export default function ArticleExportMenu({ event, title }: ArticleExportMenuPro - e.stopPropagation()} className="w-56"> - exportArticle('html')}> - -
-
Export as HTML
-
Ready to view in browser
-
-
- exportArticle('adoc')}> - -
-
Export as AsciiDoc
-
Raw .adoc file
-
-
- exportArticle('pdf')}> - -
-
Export as PDF
-
HTML - use browser Print to PDF
-
-
- exportArticle('epub')}> - -
-
Export as EPUB
-
HTML - convert with Calibre
-
-
- exportArticle('latex')}> + e.stopPropagation()}> + -
-
Export as LaTeX
-
Basic conversion
-
+ Export as AsciiDoc
diff --git a/src/components/Note/LongFormArticle/NostrNode.tsx b/src/components/Note/LongFormArticle/NostrNode.tsx index 8b9251d..b237f92 100644 --- a/src/components/Note/LongFormArticle/NostrNode.tsx +++ b/src/components/Note/LongFormArticle/NostrNode.tsx @@ -1,17 +1,21 @@ import { EmbeddedMention, EmbeddedNote } from '@/components/Embedded' import { nip19 } from 'nostr-tools' -import { ComponentProps, useMemo } from 'react' -import { Components } from './types' +import { useMemo } from 'react' -export default function NostrNode({ rawText, bech32Id }: ComponentProps) { +interface NostrNodeProps { + rawText: string + bech32Id?: string +} + +export default function NostrNode({ rawText, bech32Id }: NostrNodeProps) { const { type, id } = useMemo(() => { if (!bech32Id) return { type: 'invalid', id: '' } try { - const { type } = nip19.decode(bech32Id) - if (type === 'npub' || type === 'nprofile') { + const decoded = nip19.decode(bech32Id) + if (decoded.type === 'npub' || decoded.type === 'nprofile') { return { type: 'mention', id: bech32Id } } - if (type === 'nevent' || type === 'naddr' || type === 'note') { + if (decoded.type === 'nevent' || decoded.type === 'naddr' || decoded.type === 'note') { return { type: 'note', id: bech32Id } } } catch (error) { diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index 646b2b0..8827fa0 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -1,16 +1,18 @@ import { SecondaryPageLink, useSecondaryPage } from '@/PageManager' import ImageWithLightbox from '@/components/ImageWithLightbox' import ImageCarousel from '@/components/ImageCarousel/ImageCarousel' +import MediaPlayer from '@/components/MediaPlayer' +import Wikilink from '@/components/UniversalContent/Wikilink' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { toNote, toNoteList, toProfile } from '@/lib/link' import { extractAllImagesFromEvent } from '@/lib/image-extraction' +import { getImetaInfosFromEvent } from '@/lib/event' 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' @@ -34,6 +36,92 @@ export default function MarkdownArticle({ // Extract all images from the event const allImages = useMemo(() => extractAllImagesFromEvent(event), [event]) const contentRef = useRef(null) + + // Extract, normalize, and deduplicate all media URLs (images, audio, video) + // from content, imeta tags, and image tags + const mediaUrls = useMemo(() => { + if (showImageGallery) return [] // Don't render inline for article content + + const seenUrls = new Set() + const mediaUrls: string[] = [] + + // Helper to normalize and add URL + const addUrl = (url: string) => { + if (!url) return + + // Normalize URL by removing tracking parameters and cleaning it + let normalizedUrl = url + .replace(/[?&](utm_[^&]*)/g, '') + .replace(/[?&](fbclid|gclid|msclkid)=[^&]*/g, '') + .replace(/[?&]w=\d+/g, '') + .replace(/[?&]h=\d+/g, '') + .replace(/[?&]q=\d+/g, '') + .replace(/[?&]f=\w+/g, '') + .replace(/[?&]auto=\w+/g, '') + .replace(/[?&]format=\w+/g, '') + .replace(/[?&]fit=\w+/g, '') + .replace(/[?&]crop=\w+/g, '') + .replace(/[?&]&+/g, '&') + .replace(/[?&]$/, '') + .replace(/\?$/, '') + + try { + // Validate URL + const parsedUrl = new URL(normalizedUrl) + const extension = parsedUrl.pathname.split('.').pop()?.toLowerCase() + + // Check if it's a media file + const isMedia = + // Audio extensions + (extension && ['mp3', 'wav', 'flac', 'aac', 'm4a', 'opus', 'wma'].includes(extension)) || + // Video extensions + (extension && ['mp4', 'webm', 'ogg', 'avi', 'mov', 'mkv', 'm4v', '3gp'].includes(extension)) || + // Image extensions + (extension && ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'tiff'].includes(extension)) + + if (isMedia && !seenUrls.has(normalizedUrl)) { + mediaUrls.push(normalizedUrl) + seenUrls.add(normalizedUrl) + } + } catch { + // Invalid URL, skip + } + } + + // 1. Extract from content - all URLs (need to match exactly what markdown will find) + const content = event.content || '' + // Match URLs that could be in markdown links or plain text + const urlMatches = content.match(/https?:\/\/[^\s<>"']+/g) || [] + urlMatches.forEach(url => { + // Normalize the URL before adding + const normalized = url.replace(/[?&](utm_[^&]*)/g, '') + .replace(/[?&](fbclid|gclid|msclkid)=[^&]*/g, '') + .replace(/[?&]w=\d+/g, '') + .replace(/[?&]h=\d+/g, '') + .replace(/[?&]q=\d+/g, '') + .replace(/[?&]f=\w+/g, '') + .replace(/[?&]auto=\w+/g, '') + .replace(/[?&]format=\w+/g, '') + .replace(/[?&]fit=\w+/g, '') + .replace(/[?&]crop=\w+/g, '') + .replace(/[?&]&+/g, '&') + .replace(/[?&]$/, '') + .replace(/\?$/, '') + addUrl(normalized) + }) + + // 2. Extract from imeta tags + const imetaInfos = getImetaInfosFromEvent(event) + imetaInfos.forEach(info => addUrl(info.url)) + + // 3. Extract from image tag + const imageTag = event.tags.find(tag => tag[0] === 'image' && tag[1]) + if (imageTag?.[1]) { + addUrl(imageTag[1]) + } + + return mediaUrls + }, [event.content, event.tags, event.pubkey, showImageGallery]) // Initialize highlight.js for syntax highlighting useEffect(() => { @@ -67,6 +155,24 @@ export default function MarkdownArticle({ if (!href) { return } + + // Handle hashtag links (format: /notes?t=tag) + if (href.startsWith('/notes?t=') || href.startsWith('notes?t=')) { + // Normalize href to include leading slash if missing + const normalizedHref = href.startsWith('/') ? href : `/${href}` + return ( + + {children} + + ) + } + + // Handle wikilinks - only handle if href looks like a wikilink format + // (we'll handle wikilinks in the text component below) + if (href.startsWith('note1') || href.startsWith('nevent1') || href.startsWith('naddr1')) { return ( ) } + + // Check if this is a media URL that should be rendered inline (for non-article content) + // If so, don't render it as a link - it will be rendered as inline media below + if (!showImageGallery) { + // Normalize the href to match the normalized mediaUrls + const normalizedHref = href.replace(/[?&](utm_[^&]*)/g, '') + .replace(/[?&](fbclid|gclid|msclkid)=[^&]*/g, '') + .replace(/[?&]w=\d+/g, '') + .replace(/[?&]h=\d+/g, '') + .replace(/[?&]q=\d+/g, '') + .replace(/[?&]f=\w+/g, '') + .replace(/[?&]auto=\w+/g, '') + .replace(/[?&]format=\w+/g, '') + .replace(/[?&]fit=\w+/g, '') + .replace(/[?&]crop=\w+/g, '') + .replace(/[?&]&+/g, '&') + .replace(/[?&]$/, '') + .replace(/\?$/, '') + + if (mediaUrls.includes(normalizedHref)) { + return null + } + } + return ( { - // Handle hashtags in text - if (typeof children === 'string') { - const hashtagRegex = /#(\w+)/g - const parts = [] - let lastIndex = 0 - let match + if (typeof children !== 'string') { + return <>{children} + } + + // Handle hashtags and wikilinks + const hashtagRegex = /#(\w+)/g + const wikilinkRegex = /\[\[([^\]]+)\]\]/g + const allMatches: Array<{index: number, end: number, type: 'hashtag' | 'wikilink', data: any}> = [] + + let match + while ((match = hashtagRegex.exec(children)) !== null) { + allMatches.push({ + index: match.index, + end: match.index + match[0].length, + type: 'hashtag', + data: match[1] + }) + } + + while ((match = wikilinkRegex.exec(children)) !== null) { + const content = match[1] + let target = content.includes('|') ? content.split('|')[0].trim() : content.trim() + let displayText = content.includes('|') ? content.split('|')[1].trim() : content.trim() + + if (content.startsWith('book:')) { + target = content.replace('book:', '').trim() + } - 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] + const dtag = target.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') + + allMatches.push({ + index: match.index, + end: match.index + match[0].length, + type: 'wikilink', + data: { dtag, displayText } + }) + } + + if (allMatches.length === 0) return <>{children} + + allMatches.sort((a, b) => a.index - b.index) + + const parts: (string | JSX.Element)[] = [] + let lastIndex = 0 + + for (const match of allMatches) { + if (match.index > lastIndex) { + parts.push(children.slice(lastIndex, match.index)) + } + + if (match.type === 'hashtag') { parts.push( - - #{hashtag} + + #{match.data} ) - - lastIndex = match.index + match[0].length + } else { + parts.push() } - // Add remaining text - if (lastIndex < children.length) { - parts.push(children.slice(lastIndex)) - } - - return <>{parts} + lastIndex = match.end + } + + if (lastIndex < children.length) { + parts.push(children.slice(lastIndex)) } - return <>{children} + return <>{parts} }, img: ({ src }) => { if (!src) return null @@ -183,7 +344,7 @@ export default function MarkdownArticle({ ) } }) as Components, - [showImageGallery, event.pubkey] + [showImageGallery, event.pubkey, mediaUrls, event.kind] ) return ( @@ -273,19 +434,76 @@ export default function MarkdownArticle({ className="w-full max-w-[400px] aspect-[3/1] object-cover my-0" /> )} - { - if (url.startsWith('nostr:')) { - return url.slice(6) // Remove 'nostr:' prefix for rendering +
+ {event.content.split(/(#\w+|\[\[[^\]]+\]\])/).map((part, index, array) => { + // Check if this part is a hashtag + if (part.match(/^#\w+$/)) { + const hashtag = part.slice(1) + + // Add spaces before and after unless at start/end of line + const isStartOfLine = index === 0 || array[index - 1].match(/^[\s]*$/) !== null + const isEndOfLine = index === array.length - 1 || array[index + 1].match(/^[\s]*$/) !== null + + const beforeSpace = isStartOfLine ? '' : ' ' + const afterSpace = isEndOfLine ? '' : ' ' + + return ( + + {beforeSpace && beforeSpace} + { + e.preventDefault() + e.stopPropagation() + const url = `/notes?t=${hashtag.toLowerCase()}` + console.log('[MarkdownArticle] Clicking hashtag, navigating to:', url) + push(url) + }} + > + {part} + + {afterSpace && afterSpace} + + ) } - return url - }} - components={components} - > - {event.content} - + // Check if this part is a wikilink + if (part.match(/^\[\[([^\]]+)\]\]$/)) { + const content = part.slice(2, -2) + let target = content.includes('|') ? content.split('|')[0].trim() : content.trim() + let displayText = content.includes('|') ? content.split('|')[1].trim() : content.trim() + + if (content.startsWith('book:')) { + target = content.replace('book:', '').trim() + } + + const dtag = target.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') + + return + } + // Regular text + return {part} + })} +
+ + {/* Inline Media - Show for non-article content (kinds 1, 11, 1111) */} + {!showImageGallery && mediaUrls.length > 0 && ( +
+ {mediaUrls.map((url) => { + const extension = url.split('.').pop()?.toLowerCase() + + // Images are already handled by the img component + if (extension && ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(extension)) { + return null + } + + // Render audio and video + return ( + + ) + })} +
+ )} {/* Image Carousel - Only show for article content (30023, 30041, 30818) */} {showImageGallery && allImages.length > 0 && ( diff --git a/src/components/Note/MarkdownArticle/remarkHashtags.ts b/src/components/Note/MarkdownArticle/remarkHashtags.ts new file mode 100644 index 0000000..a249345 --- /dev/null +++ b/src/components/Note/MarkdownArticle/remarkHashtags.ts @@ -0,0 +1,61 @@ +import type { PhrasingContent, Root, Text } from 'mdast' +import type { Plugin } from 'unified' +import { visit } from 'unist-util-visit' + +const HASHTAG_REGEX = /#([a-zA-Z0-9_]+)/g + +export const remarkHashtags: Plugin<[], Root> = () => { + return (tree) => { + visit(tree, 'text', (node: Text, index, parent) => { + if (!parent || typeof index !== 'number') return + + const text = node.value + const matches = Array.from(text.matchAll(HASHTAG_REGEX)) + + if (matches.length === 0) return + + const children: PhrasingContent[] = [] + let lastIndex = 0 + + matches.forEach((match) => { + const matchStart = match.index! + const matchEnd = matchStart + match[0].length + const hashtag = match[1] + + // Add text before the hashtag + if (matchStart > lastIndex) { + children.push({ + type: 'text', + value: text.slice(lastIndex, matchStart) + }) + } + + // Create a link node for the hashtag + children.push({ + type: 'link', + url: `/notes?t=${hashtag.toLowerCase()}`, + children: [ + { + type: 'text', + value: `#${hashtag}` + } + ] + }) + + lastIndex = matchEnd + }) + + // Add remaining text after the last match + if (lastIndex < text.length) { + children.push({ + type: 'text', + value: text.slice(lastIndex) + }) + } + + // Replace the text node with the processed children + parent.children.splice(index, 1, ...children) + }) + } +} + diff --git a/src/components/Note/PublicationIndex/PublicationIndex.tsx b/src/components/Note/PublicationIndex/PublicationIndex.tsx index 34e3cc9..54a71a9 100644 --- a/src/components/Note/PublicationIndex/PublicationIndex.tsx +++ b/src/components/Note/PublicationIndex/PublicationIndex.tsx @@ -7,14 +7,7 @@ import { generateBech32IdFromATag } from '@/lib/tag' import client from '@/services/client.service' import logger from '@/lib/logger' import { Button } from '@/components/ui/button' -import { contentParserService } from '@/services/content-parser.service' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' -import { MoreVertical, FileDown } from 'lucide-react' +import { MoreVertical } from 'lucide-react' interface PublicationReference { coordinate: string @@ -147,8 +140,8 @@ export default function PublicationIndex({ } } - // Export publication in different formats - const exportPublication = async (format: 'pdf' | 'epub' | 'latex' | 'adoc' | 'html') => { + // Export publication as AsciiDoc + const exportPublication = async () => { try { // Collect all content from references const contentParts: string[] = [] @@ -159,107 +152,15 @@ export default function PublicationIndex({ // Extract title const title = ref.event.tags.find(tag => tag[0] === 'title')?.[1] || 'Untitled' - // Extract raw content - let content = ref.event.content - - if (format === 'adoc') { - // For AsciiDoc, output the raw content with title - contentParts.push(`= ${title}\n\n${content}\n\n`) - } else if (format === 'html') { - // For HTML, parse the AsciiDoc content to HTML - const parsedContent = await contentParserService.parseContent(content, { - eventKind: ref.kind, - enableMath: true, - enableSyntaxHighlighting: true - }) - - contentParts.push(`
-

${title}

- ${parsedContent.html} -
`) - } else if (format === 'latex') { - // Convert to LaTeX - content = content.replace(/^= (.+)$/gm, '\\section{$1}') - content = content.replace(/^== (.+)$/gm, '\\subsection{$1}') - content = content.replace(/^=== (.+)$/gm, '\\subsubsection{$1}') - contentParts.push(`\\section*{${title}}\n\n${content}\n\n`) - } else if (format === 'pdf' || format === 'epub') { - // For PDF/EPUB, we need to export as HTML that can be converted - // Parse the AsciiDoc content to HTML using the content parser - const parsedContent = await contentParserService.parseContent(content, { - eventKind: ref.kind, - enableMath: true, - enableSyntaxHighlighting: true - }) - - contentParts.push(`
-

${title}

- ${parsedContent.html} -
`) - } + // For AsciiDoc, output the raw content with title + contentParts.push(`= ${title}\n\n${ref.event.content}\n\n`) } const fullContent = contentParts.join('\n') - const filename = `${metadata.title || 'publication'}.${format}` + const filename = `${metadata.title || 'publication'}.adoc` - let blob: Blob = new Blob(['']) - - if (format === 'html') { - // For HTML, wrap the content in a full HTML document - const htmlDocument = ` - - - - ${metadata.title || 'Publication'} - - - - ${fullContent} - -` - - blob = new Blob([htmlDocument], { type: 'text/html' }) - } else if (format === 'pdf' || format === 'epub') { - // For PDF/EPUB, wrap the HTML content in a full HTML document - const htmlDocument = ` - - - - ${metadata.title || 'Publication'} - - - - ${fullContent} - -` - - blob = new Blob([htmlDocument], { type: 'text/html' }) - } else { - // For AsciiDoc or LaTeX formats, use the raw content - blob = new Blob([fullContent], { - type: format === 'latex' ? 'text/plain' : 'text/plain' - }) - } + // Export as AsciiDoc + const blob = new Blob([fullContent], { type: 'text/plain' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') @@ -270,7 +171,7 @@ export default function PublicationIndex({ document.body.removeChild(a) URL.revokeObjectURL(url) - logger.info(`[PublicationIndex] Exported publication as ${format}`) + logger.info('[PublicationIndex] Exported publication as .adoc') } catch (error) { logger.error('[PublicationIndex] Error exporting publication:', error) alert('Failed to export publication. Please try again.') @@ -315,10 +216,13 @@ export default function PublicationIndex({ setIsLoading(true) const fetchedRefs: PublicationReference[] = [] + // Capture current visitedIndices at the start of the fetch + const currentVisited = visitedIndices + for (const ref of referencesData) { // Skip if this is a 30040 event we've already visited (prevent circular references) if (ref.kind === ExtendedKind.PUBLICATION) { - if (visitedIndices.has(ref.coordinate)) { + if (currentVisited.has(ref.coordinate)) { logger.debug('[PublicationIndex] Skipping visited 30040 index:', ref.coordinate) fetchedRefs.push({ ...ref, event: undefined }) continue @@ -357,7 +261,7 @@ export default function PublicationIndex({ } else { setIsLoading(false) } - }, [referencesData, visitedIndices]) + }, [referencesData, visitedIndices]) // Now include visitedIndices but capture it inside return (
@@ -366,35 +270,15 @@ export default function PublicationIndex({

{metadata.title}

- - - - - - exportPublication('html')}> - - Export as HTML - - exportPublication('adoc')}> - - Export as AsciiDoc - - exportPublication('pdf')}> - - Export as PDF - - exportPublication('epub')}> - - Export as EPUB - - exportPublication('latex')}> - - Export as LaTeX - - - +
{metadata.summary && (
@@ -506,7 +390,7 @@ function ToCItemComponent({