mirror of
https://github.com/dergigi/boris.git
synced 2025-12-18 23:24:22 +01:00
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:
2
dist/index.html
vendored
2
dist/index.html
vendored
@@ -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>
|
||||||
|
|||||||
11
src/App.tsx
11
src/App.tsx
@@ -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)} />
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|||||||
102
src/services/articleService.ts
Normal file
102
src/services/articleService.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user