diff --git a/src/components/CitationCard/index.tsx b/src/components/CitationCard/index.tsx new file mode 100644 index 0000000..2420ace --- /dev/null +++ b/src/components/CitationCard/index.tsx @@ -0,0 +1,386 @@ +import { ExtendedKind } from '@/constants' +import { Event } from 'nostr-tools' +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { getTagValue } from '@/lib/tag' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { ExternalLink, Book, FileText, Bot } from 'lucide-react' + +interface CitationCardProps { + event: Event + className?: string + displayType?: 'end' | 'foot' | 'foot-end' | 'inline' | 'quote' | 'prompt-end' | 'prompt-inline' +} + +export default function CitationCard({ event, className, displayType = 'end' }: CitationCardProps) { + const { t } = useTranslation() + + const citationData = useMemo(() => { + const title = getTagValue(event, 'title') || '' + const author = getTagValue(event, 'author') || '' + const publishedOn = getTagValue(event, 'published_on') || '' + const accessedOn = getTagValue(event, 'accessed_on') || '' + const summary = getTagValue(event, 'summary') || '' + const location = getTagValue(event, 'location') || '' + const publishedBy = getTagValue(event, 'published_by') || '' + const version = getTagValue(event, 'version') || '' + + if (event.kind === ExtendedKind.CITATION_INTERNAL) { + const cTag = event.tags.find(tag => tag[0] === 'c')?.[1] || '' + const relayHint = event.tags.find(tag => tag[0] === 'c')?.[2] || '' + const geohash = getTagValue(event, 'g') || '' + + return { + type: 'internal', + title, + author, + publishedOn, + accessedOn, + summary, + location, + geohash, + cTag, + relayHint + } + } else if (event.kind === ExtendedKind.CITATION_EXTERNAL) { + const url = getTagValue(event, 'u') || '' + const openTimestamp = getTagValue(event, 'open_timestamp') || '' + const geohash = getTagValue(event, 'g') || '' + + return { + type: 'external', + title, + author, + url, + publishedOn, + publishedBy, + version, + accessedOn, + summary, + location, + geohash, + openTimestamp + } + } else if (event.kind === ExtendedKind.CITATION_HARDCOPY) { + const pageRange = getTagValue(event, 'page_range') || '' + const chapterTitle = getTagValue(event, 'chapter_title') || '' + const editor = getTagValue(event, 'editor') || '' + const publishedIn = event.tags.find(tag => tag[0] === 'published_in')?.[1] || '' + const volume = event.tags.find(tag => tag[0] === 'published_in')?.[2] || '' + const doi = getTagValue(event, 'doi') || '' + const geohash = getTagValue(event, 'g') || '' + + return { + type: 'hardcopy', + title, + author, + pageRange, + chapterTitle, + editor, + publishedOn, + publishedBy, + publishedIn, + volume, + doi, + version, + accessedOn, + summary, + location, + geohash + } + } else if (event.kind === ExtendedKind.CITATION_PROMPT) { + const llm = getTagValue(event, 'llm') || '' + const url = getTagValue(event, 'u') || '' + + return { + type: 'prompt', + llm, + accessedOn, + version, + summary, + url + } + } + + return null + }, [event]) + + if (!citationData) { + return null + } + + const formatDate = (dateStr: string) => { + if (!dateStr) return '' + try { + const date = new Date(dateStr) + return date.toLocaleDateString() + } catch { + return dateStr + } + } + + const renderCitationContent = () => { + if (citationData.type === 'internal') { + return ( +
+ {citationData.author && ( +
{citationData.author}
+ )} + {citationData.title && ( +
"{citationData.title}"
+ )} + {citationData.publishedOn && ( +
{formatDate(citationData.publishedOn)}
+ )} + {citationData.cTag && ( +
+ nostr:{citationData.cTag} +
+ )} + {citationData.summary && ( +
{citationData.summary}
+ )} + {event.content && ( +
+ {event.content} +
+ )} +
+ ) + } else if (citationData.type === 'external') { + return ( +
+ {citationData.author && ( +
{citationData.author}
+ )} + {citationData.title && ( +
"{citationData.title}"
+ )} + {citationData.publishedBy && ( +
{citationData.publishedBy}
+ )} + {citationData.publishedOn && ( +
{formatDate(citationData.publishedOn)}
+ )} + {citationData.url && ( +
+ + + {citationData.url} + +
+ )} + {citationData.accessedOn && ( +
+ {t('Accessed on')} {formatDate(citationData.accessedOn)} +
+ )} + {citationData.version && ( +
{t('Version')}: {citationData.version}
+ )} + {citationData.summary && ( +
{citationData.summary}
+ )} + {event.content && ( +
+ {event.content} +
+ )} +
+ ) + } else if (citationData.type === 'hardcopy') { + return ( +
+ {citationData.author && ( +
{citationData.author}
+ )} + {citationData.title && ( +
"{citationData.title}"
+ )} + {citationData.chapterTitle && ( +
{t('Chapter')}: {citationData.chapterTitle}
+ )} + {citationData.editor && ( +
{t('Edited by')} {citationData.editor}
+ )} + {citationData.publishedIn && ( +
+ {t('Published in')} {citationData.publishedIn} + {citationData.volume && `, ${t('Volume')} ${citationData.volume}`} +
+ )} + {citationData.publishedBy && ( +
{citationData.publishedBy}
+ )} + {citationData.publishedOn && ( +
{formatDate(citationData.publishedOn)}
+ )} + {citationData.pageRange && ( +
{t('Pages')}: {citationData.pageRange}
+ )} + {citationData.doi && ( +
DOI: {citationData.doi}
+ )} + {citationData.accessedOn && ( +
+ {t('Accessed on')} {formatDate(citationData.accessedOn)} +
+ )} + {citationData.version && ( +
{t('Version')}: {citationData.version}
+ )} + {citationData.summary && ( +
{citationData.summary}
+ )} + {event.content && ( +
+ {event.content} +
+ )} +
+ ) + } else if (citationData.type === 'prompt') { + return ( +
+ {citationData.llm && ( +
{citationData.llm}
+ )} + {citationData.accessedOn && ( +
{t('Accessed on')} {formatDate(citationData.accessedOn)}
+ )} + {citationData.version && ( +
{t('Version')}: {citationData.version}
+ )} + {citationData.url && ( +
+ + + {citationData.url} + +
+ )} + {citationData.summary && ( +
{citationData.summary}
+ )} + {event.content && ( +
+ {event.content} +
+ )} +
+ ) + } + + return null + } + + const getIcon = () => { + switch (citationData.type) { + case 'internal': + return + case 'external': + return + case 'hardcopy': + return + case 'prompt': + return + default: + return + } + } + + const getTitle = () => { + switch (citationData.type) { + case 'internal': + return t('Internal Citation') + case 'external': + return t('External Citation') + case 'hardcopy': + return t('Hardcopy Citation') + case 'prompt': + return t('Prompt Citation') + default: + return t('Citation') + } + } + + // For inline citations, render a compact version + if (displayType === 'inline' || displayType === 'prompt-inline') { + const inlineText = citationData.type === 'internal' && citationData.author && citationData.publishedOn + ? `(${citationData.author}, ${formatDate(citationData.publishedOn)})` + : citationData.type === 'prompt' && citationData.llm + ? `(${citationData.llm})` + : `[${t('Citation')}]` + + return ( + + { + e.preventDefault() + // Scroll to full citation in references section + const refSection = document.getElementById('references-section') + if (refSection) { + refSection.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + }} + > + {inlineText} + + + ) + } + + // For footnotes (foot-end), render a brief reference + if (displayType === 'foot-end') { + return ( +
+
+ {citationData.type === 'internal' && citationData.author && citationData.publishedOn + ? `${citationData.author}, ${formatDate(citationData.publishedOn)}` + : citationData.type === 'external' && citationData.author + ? `${citationData.author}` + : citationData.type === 'hardcopy' && citationData.author + ? `${citationData.author}` + : citationData.type === 'prompt' && citationData.llm + ? `${citationData.llm}` + : t('See reference')} +
+
+ ) + } + + // For quotes, render with quote styling + if (displayType === 'quote') { + return ( + + + + {getIcon()} + {getTitle()} + + + + {renderCitationContent()} + + + ) + } + + // For endnotes, footnotes, and prompt-end, render full citation + return ( + + + + {getIcon()} + {getTitle()} + + + + {renderCitationContent()} + + + ) +} + diff --git a/src/components/EmbeddedCitation/index.tsx b/src/components/EmbeddedCitation/index.tsx new file mode 100644 index 0000000..797848b --- /dev/null +++ b/src/components/EmbeddedCitation/index.tsx @@ -0,0 +1,54 @@ +import { useFetchEvent } from '@/hooks' +import CitationCard from '@/components/CitationCard' +import { Skeleton } from '@/components/ui/skeleton' +import { nip19 } from 'nostr-tools' + +interface EmbeddedCitationProps { + citationId: string // nevent or note ID + displayType?: 'end' | 'foot' | 'foot-end' | 'inline' | 'quote' | 'prompt-end' | 'prompt-inline' + className?: string +} + +export default function EmbeddedCitation({ citationId, displayType = 'end', className }: EmbeddedCitationProps) { + // Try to decode as bech32 first + let eventId: string | null = null + + try { + const decoded = nip19.decode(citationId) + if (decoded.type === 'nevent') { + const data = decoded.data as any + eventId = data.id || citationId + } else if (decoded.type === 'note') { + eventId = decoded.data as string + } else { + // If it's not a note/nevent, use the original ID + eventId = citationId + } + } catch { + // If decoding fails, assume it's already a hex ID + eventId = citationId + } + + const { event, isLoading } = useFetchEvent(eventId || '') + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (!event) { + return ( +
+
+ Citation not found: {citationId.slice(0, 20)}... +
+
+ ) + } + + return +} + diff --git a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx index b78b80b..7559d2a 100644 --- a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx +++ b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx @@ -15,6 +15,7 @@ import { createRoot, Root } from 'react-dom/client' import Lightbox from 'yet-another-react-lightbox' import Zoom from 'yet-another-react-lightbox/plugins/zoom' import { EmbeddedNote, EmbeddedMention } from '@/components/Embedded' +import EmbeddedCitation from '@/components/EmbeddedCitation' import Wikilink from '@/components/UniversalContent/Wikilink' import { preprocessAsciidocMediaLinks } from '../MarkdownArticle/preprocessMarkup' import logger from '@/lib/logger' @@ -693,6 +694,13 @@ export default function AsciidocArticle({ return `
` }) + // Handle citation markup: [[citation::type::nevent...]] + // AsciiDoc passthrough +++[[citation::type::nevent...]]+++ outputs just [[citation::type::nevent...]] in HTML + htmlString = htmlString.replace(/\[\[citation::(end|foot|foot-end|inline|quote|prompt-end|prompt-inline)::([^\]]+)\]\]/g, (_match, citationType, citationId) => { + const escapedId = citationId.replace(/"/g, '"').replace(/'/g, ''') + return `
` + }) + // Handle wikilinks - convert passthrough markers to placeholders // AsciiDoc passthrough +++WIKILINK:link|display+++ outputs just WIKILINK:link|display in HTML // Match WIKILINK: followed by any characters (including |) until end of text or HTML tag @@ -929,6 +937,38 @@ export default function AsciidocArticle({ reactRootsRef.current.set(container, root) }) + // Process citations - replace placeholders with React components + const citationPlaceholders = contentRef.current.querySelectorAll('.citation-placeholder[data-citation]') + citationPlaceholders.forEach((element) => { + const citationId = element.getAttribute('data-citation') + const citationType = element.getAttribute('data-citation-type') || 'end' + if (!citationId) { + logger.warn('Citation placeholder found but no citation ID attribute') + return + } + + // Determine container class based on citation type + const isInline = citationType === 'inline' || citationType === 'prompt-inline' + const container = document.createElement(isInline ? 'span' : 'div') + container.className = isInline ? 'inline' : 'w-full my-2' + const parent = element.parentNode + if (!parent) { + logger.warn('Citation placeholder has no parent node') + return + } + parent.replaceChild(container, element) + + // Use React to render the component + const root = createRoot(container) + root.render( + + ) + reactRootsRef.current.set(container, root) + }) + // Process LaTeX math expressions - render with KaTeX const latexInlinePlaceholders = contentRef.current.querySelectorAll('.latex-inline-placeholder[data-latex-inline]') latexInlinePlaceholders.forEach((element) => { diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index 8860f59..725d08a 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -16,6 +16,7 @@ import { createPortal } from 'react-dom' import Lightbox from 'yet-another-react-lightbox' import Zoom from 'yet-another-react-lightbox/plugins/zoom' import { EmbeddedNote, EmbeddedMention } from '@/components/Embedded' +import EmbeddedCitation from '@/components/EmbeddedCitation' import { preprocessMarkdownMediaLinks } from './preprocessMarkup' import katex from 'katex' import 'katex/dist/katex.min.css' @@ -355,11 +356,12 @@ function parseMarkdownContent( imageThumbnailMap?: Map getImageIdentifier?: (url: string) => string | null } -): { nodes: React.ReactNode[]; hashtagsInContent: Set; footnotes: Map } { +): { nodes: React.ReactNode[]; hashtagsInContent: Set; footnotes: Map; citations: Array<{ id: string; type: string; citationId: string }> } { const { eventPubkey, imageIndexMap, openLightbox, navigateToHashtag, navigateToRelay, videoPosterMap, imageThumbnailMap, getImageIdentifier } = options const parts: React.ReactNode[] = [] const hashtagsInContent = new Set() const footnotes = new Map() + const citations: Array<{ id: string; type: string; citationId: string }> = [] let lastIndex = 0 // Helper function to check if an index range falls within any block-level pattern @@ -863,6 +865,34 @@ function parseMarkdownContent( } }) + // Citation markup: [[citation::type::nevent...]] + const citationRegex = /\[\[citation::(end|foot|foot-end|inline|quote|prompt-end|prompt-inline)::([^\]]+)\]\]/g + const citationMatches = Array.from(content.matchAll(citationRegex)) + citationMatches.forEach(match => { + if (match.index !== undefined) { + const start = match.index + const end = match.index + match[0].length + // Only add if not already covered by other patterns and not in block pattern + const isInOther = patterns.some(p => + (p.type === 'markdown-link' || p.type === 'markdown-image-link' || p.type === 'markdown-image' || p.type === 'relay-url' || p.type === 'youtube-url' || p.type === 'nostr') && + start >= p.index && + start < p.end + ) + if (!isInOther && !isWithinBlockPattern(start, end, blockPatterns)) { + const citationType = match[1] + const citationId = match[2] + const citationIndex = citations.length + citations.push({ id: `citation-${citationIndex}`, type: citationType, citationId }) + patterns.push({ + index: start, + end: end, + type: 'citation', + data: { type: citationType, citationId, index: citationIndex } + }) + } + } + }) + // Nostr addresses (nostr:npub1..., nostr:note1..., etc.) const nostrRegex = /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 nostrMatches = Array.from(content.matchAll(nostrRegex)) @@ -872,7 +902,7 @@ function parseMarkdownContent( const end = match.index + match[0].length // Only add if not already covered by other patterns and not in block pattern const isInOther = patterns.some(p => - (p.type === 'markdown-link' || p.type === 'markdown-image-link' || p.type === 'markdown-image' || p.type === 'relay-url' || p.type === 'youtube-url') && + (p.type === 'markdown-link' || p.type === 'markdown-image-link' || p.type === 'markdown-image' || p.type === 'relay-url' || p.type === 'youtube-url' || p.type === 'citation') && start >= p.index && start < p.end ) @@ -1725,6 +1755,71 @@ function parseMarkdownContent( // Footnote not found, just render the reference as-is parts.push([^{footnoteId}]) } + } else if (pattern.type === 'citation') { + const { type: citationType, citationId, index: citationIndex } = pattern.data + const citationNumber = citationIndex + 1 + + if (citationType === 'inline' || citationType === 'prompt-inline') { + // Inline citations render as clickable text + parts.push( + + ) + } else if (citationType === 'foot' || citationType === 'foot-end') { + // Footnotes render as superscript numbers + parts.push( + + { + e.preventDefault() + const citationElement = document.getElementById(`citation-${citationIndex}`) + if (citationElement) { + citationElement.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } + }} + > + [{citationNumber}] + + + ) + } else if (citationType === 'quote') { + // Quotes render as block-level citation cards + parts.push( +
+ +
+ ) + } else { + // end, prompt-end render as superscript numbers that link to references section + parts.push( + + { + e.preventDefault() + const refSection = document.getElementById('references-section') + if (refSection) { + refSection.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + }} + > + [{citationNumber}] + + + ) + } } else if (pattern.type === 'nostr') { const bech32Id = pattern.data // Check if it's a profile type (mentions/handles should be inline) @@ -1949,7 +2044,7 @@ function parseMarkdownContent(

) }).filter(Boolean) - return { nodes: formattedParagraphs, hashtagsInContent, footnotes } + return { nodes: formattedParagraphs, hashtagsInContent, footnotes, citations } } // Filter out empty spans before wrapping lists @@ -2131,7 +2226,87 @@ function parseMarkdownContent( ) } - return { nodes: wrappedParts, hashtagsInContent, footnotes } + // Add citations section (footnotes) at the end if there are any footnotes + const footCitations = citations.filter(c => c.type === 'foot' || c.type === 'foot-end') + if (footCitations.length > 0) { + wrappedParts.push( + + ) + } + + // Add references section at the end if there are any endnote citations + const endCitations = citations.filter(c => c.type === 'end' || c.type === 'prompt-end') + if (endCitations.length > 0) { + wrappedParts.push( + + ) + } + + return { nodes: wrappedParts, hashtagsInContent, footnotes, citations } } /** diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index 79ce78f..5d40f7d 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -38,6 +38,7 @@ import UnknownNote from './UnknownNote' import VideoNote from './VideoNote' import RelayReview from './RelayReview' import Zap from './Zap' +import CitationCard from '@/components/CitationCard' export default function Note({ event, @@ -146,6 +147,13 @@ export default function Note({ ) + } else if ( + event.kind === ExtendedKind.CITATION_INTERNAL || + event.kind === ExtendedKind.CITATION_EXTERNAL || + event.kind === ExtendedKind.CITATION_HARDCOPY || + event.kind === ExtendedKind.CITATION_PROMPT + ) { + content = } else if (event.kind === ExtendedKind.POLL) { content = ( <> diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index 0994054..2734ffb 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -841,7 +841,7 @@ export default function PostContent({ ) : isWikiArticleMarkdown ? ( t('New Wiki Article (Markdown)') ) : isPublicationContent ? ( - t('New Publication Content') + t('Take a note') ) : isCitationInternal ? ( t('New Internal Citation') ) : isCitationExternal ? ( @@ -972,7 +972,7 @@ export default function PostContent({ {hasPrivateRelaysAvailable && ( handleArticleToggle('publication')}> - {t('Publication Content')} + {t('Take a note')} )} @@ -1162,9 +1162,6 @@ export default function PostContent({ setIsNsfw={setIsNsfw} minPow={minPow} setMinPow={setMinPow} - useCacheOnlyForPrivateNotes={useCacheOnlyForPrivateNotes} - setUseCacheOnlyForPrivateNotes={setUseCacheOnlyForPrivateNotes} - hasCacheRelaysAvailable={hasCacheRelaysAvailable} />
+ )