refactor: integrate long-form article rendering into existing reader view

- Create articleService to fetch articles by naddr
- Update Bookmarks component to detect naddr in URL params
- Articles now render in the existing ContentPanel with highlight support
- Remove standalone Article component
- Articles work seamlessly within the existing three-pane layout
- Support for article metadata (title, image, published date, summary)
This commit is contained in:
Gigi
2025-10-05 08:12:55 +01:00
parent 9b0c59b1ae
commit edd4e20e22
5 changed files with 147 additions and 179 deletions

2
dist/index.html vendored
View File

@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Boris - Nostr Bookmarks</title> <title>Boris - Nostr Bookmarks</title>
<script type="module" crossorigin src="/assets/index-zNlVawf9.js"></script> <script type="module" crossorigin src="/assets/index-Cu-7RlRs.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ChEoItgK.css"> <link rel="stylesheet" crossorigin href="/assets/index-ChEoItgK.css">
</head> </head>
<body> <body>

View File

@@ -7,7 +7,6 @@ import { RelayPool } from 'applesauce-relay'
import { createAddressLoader } from 'applesauce-loaders/loaders' import { createAddressLoader } from 'applesauce-loaders/loaders'
import Login from './components/Login' import Login from './components/Login'
import Bookmarks from './components/Bookmarks' import Bookmarks from './components/Bookmarks'
import Article from './components/Article'
function App() { function App() {
const [eventStore, setEventStore] = useState<EventStore | null>(null) const [eventStore, setEventStore] = useState<EventStore | null>(null)
@@ -67,7 +66,15 @@ function App() {
<BrowserRouter> <BrowserRouter>
<div className="app"> <div className="app">
<Routes> <Routes>
<Route path="/a/:naddr" element={<Article relayPool={relayPool} />} /> <Route
path="/a/:naddr"
element={
<Bookmarks
relayPool={relayPool}
onLogout={() => setIsAuthenticated(false)}
/>
}
/>
<Route path="/" element={ <Route path="/" element={
!isAuthenticated ? ( !isAuthenticated ? (
<Login onLogin={() => setIsAuthenticated(true)} /> <Login onLogin={() => setIsAuthenticated(true)} />

View File

@@ -1,176 +0,0 @@
import { useState, useEffect } from 'react'
import { useParams, Link } from 'react-router-dom'
import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19'
import { NostrEvent } from 'nostr-tools'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { remarkNostrMentions } from 'applesauce-content/markdown'
import {
getArticleTitle,
getArticleImage,
getArticlePublished,
getArticleSummary
} from 'applesauce-core/helpers'
import { npubEncode } from 'nostr-tools/nip19'
import { RelayPool, completeOnEose } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
interface ArticleProps {
relayPool: RelayPool
}
const Article: React.FC<ArticleProps> = ({ relayPool }) => {
const { naddr } = useParams<{ naddr: string }>()
const [article, setArticle] = useState<NostrEvent | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!naddr) return
const fetchArticle = async () => {
setLoading(true)
setError(null)
try {
// Decode the naddr
const decoded = nip19.decode(naddr)
if (decoded.type !== 'naddr') {
throw new Error('Invalid naddr format')
}
const pointer = decoded.data as AddressPointer
// Define relays to query
const relays = pointer.relays && pointer.relays.length > 0
? pointer.relays
: [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.nostr.band',
'wss://relay.primal.net'
]
// Fetch the article event
const filter = {
kinds: [pointer.kind],
authors: [pointer.pubkey],
'#d': [pointer.identifier]
}
// Use applesauce relay pool pattern
const events = await lastValueFrom(
relayPool
.req(relays, filter)
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
)
if (events.length > 0) {
// Sort by created_at and take the most recent
events.sort((a, b) => b.created_at - a.created_at)
setArticle(events[0])
} else {
setError('Article not found')
}
} catch (err) {
console.error('Failed to fetch article:', err)
setError(err instanceof Error ? err.message : 'Failed to load article')
} finally {
setLoading(false)
}
}
fetchArticle()
}, [naddr, relayPool])
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-xl">Loading article...</div>
</div>
)
}
if (error) {
return (
<div className="flex flex-col items-center justify-center min-h-screen gap-4">
<div className="text-xl text-red-500">Error: {error}</div>
<Link to="/" className="btn btn-primary">
Go Home
</Link>
</div>
)
}
if (!article) {
return (
<div className="flex flex-col items-center justify-center min-h-screen gap-4">
<div className="text-xl">Article not found</div>
<Link to="/" className="btn btn-primary">
Go Home
</Link>
</div>
)
}
const title = getArticleTitle(article)
const image = getArticleImage(article)
const published = getArticlePublished(article)
const summary = getArticleSummary(article)
return (
<div className="min-h-screen bg-base-100">
<div className="container mx-auto max-w-4xl px-4 py-8">
<Link to="/" className="btn btn-ghost mb-6">
Back to Home
</Link>
{image && (
<div className="w-full mb-6 rounded-lg overflow-hidden">
<img
src={image}
alt={title}
className="w-full h-auto max-h-[400px] object-cover"
/>
</div>
)}
<article>
<h1 className="text-4xl font-bold mb-4">{title}</h1>
<div className="text-sm opacity-70 mb-2">
By {npubEncode(article.pubkey).slice(0, 12)}...
</div>
{published && (
<div className="text-sm opacity-60 mb-6">
Published: {new Date(published * 1000).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</div>
)}
{summary && (
<div className="text-lg opacity-80 italic mb-8 border-l-4 border-primary pl-4">
{summary}
</div>
)}
<div className="prose prose-lg max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkNostrMentions]}
>
{article.content}
</ReactMarkdown>
</div>
</article>
</div>
</div>
)
}
export default Article

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { Hooks } from 'applesauce-react' import { Hooks } from 'applesauce-react'
import { useEventStore } from 'applesauce-react/hooks' import { useEventStore } from 'applesauce-react/hooks'
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
@@ -10,6 +11,7 @@ import { fetchHighlights } from '../services/highlightService'
import ContentPanel from './ContentPanel' import ContentPanel from './ContentPanel'
import { HighlightsPanel } from './HighlightsPanel' import { HighlightsPanel } from './HighlightsPanel'
import { fetchReadableContent, ReadableContent } from '../services/readerService' import { fetchReadableContent, ReadableContent } from '../services/readerService'
import { fetchArticleByNaddr } from '../services/articleService'
import Settings from './Settings' import Settings from './Settings'
import Toast from './Toast' import Toast from './Toast'
import { useSettings } from '../hooks/useSettings' import { useSettings } from '../hooks/useSettings'
@@ -21,6 +23,7 @@ interface BookmarksProps {
} }
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => { const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
const { naddr } = useParams<{ naddr?: string }>()
const [bookmarks, setBookmarks] = useState<Bookmark[]>([]) const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
const [highlights, setHighlights] = useState<Highlight[]>([]) const [highlights, setHighlights] = useState<Highlight[]>([])
const [highlightsLoading, setHighlightsLoading] = useState(true) const [highlightsLoading, setHighlightsLoading] = useState(true)
@@ -44,6 +47,38 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
accountManager accountManager
}) })
// Load article if naddr is in URL
useEffect(() => {
if (!relayPool || !naddr) return
const loadArticle = async () => {
setReaderLoading(true)
setReaderContent(undefined)
setSelectedUrl(`nostr:${naddr}`) // Use naddr as the URL identifier
setIsCollapsed(true)
try {
const article = await fetchArticleByNaddr(relayPool, naddr)
setReaderContent({
title: article.title,
markdown: article.markdown,
url: `nostr:${naddr}`
})
} catch (err) {
console.error('Failed to load article:', err)
setReaderContent({
title: 'Error Loading Article',
html: `<p>Failed to load article: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
url: `nostr:${naddr}`
})
} finally {
setReaderLoading(false)
}
}
loadArticle()
}, [naddr, relayPool])
// Load initial data on login // Load initial data on login
useEffect(() => { useEffect(() => {
if (!relayPool || !activeAccount) return if (!relayPool || !activeAccount) return

View File

@@ -0,0 +1,102 @@
import { RelayPool, completeOnEose } from 'applesauce-relay'
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
import { nip19 } from 'nostr-tools'
import { AddressPointer } from 'nostr-tools/nip19'
import { NostrEvent } from 'nostr-tools'
import {
getArticleTitle,
getArticleImage,
getArticlePublished,
getArticleSummary
} from 'applesauce-core/helpers'
export interface ArticleContent {
title: string
markdown: string
image?: string
published?: number
summary?: string
author: string
event: NostrEvent
}
/**
* Fetches a Nostr long-form article (NIP-23) by naddr
*/
export async function fetchArticleByNaddr(
relayPool: RelayPool,
naddr: string
): Promise<ArticleContent> {
try {
// Decode the naddr
const decoded = nip19.decode(naddr)
if (decoded.type !== 'naddr') {
throw new Error('Invalid naddr format')
}
const pointer = decoded.data as AddressPointer
// Define relays to query
const relays = pointer.relays && pointer.relays.length > 0
? pointer.relays
: [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.nostr.band',
'wss://relay.primal.net'
]
// Fetch the article event
const filter = {
kinds: [pointer.kind],
authors: [pointer.pubkey],
'#d': [pointer.identifier]
}
// Use applesauce relay pool pattern
const events = await lastValueFrom(
relayPool
.req(relays, filter)
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
)
if (events.length === 0) {
throw new Error('Article not found')
}
// Sort by created_at and take the most recent
events.sort((a, b) => b.created_at - a.created_at)
const article = events[0]
const title = getArticleTitle(article) || 'Untitled Article'
const image = getArticleImage(article)
const published = getArticlePublished(article)
const summary = getArticleSummary(article)
return {
title,
markdown: article.content,
image,
published,
summary,
author: article.pubkey,
event: article
}
} catch (err) {
console.error('Failed to fetch article:', err)
throw err
}
}
/**
* Checks if a string is a valid naddr
*/
export function isNaddr(str: string): boolean {
try {
const decoded = nip19.decode(str)
return decoded.type === 'naddr'
} catch {
return false
}
}