mirror of
https://github.com/dergigi/boris.git
synced 2026-01-31 12:44:37 +01:00
feat: add native support for rendering Nostr long-form articles (NIP-23)
- Install react-router-dom for routing support - Create Article component to decode naddr and fetch/render articles - Add /a/:naddr route to App.tsx for article viewing - Use applesauce relay pool patterns for event fetching - Render articles with markdown using ReactMarkdown - Support article metadata (title, image, published date, summary)
This commit is contained in:
6
dist/index.html
vendored
6
dist/index.html
vendored
@@ -4,9 +4,9 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Markr - Nostr Bookmarks</title>
|
||||
<script type="module" crossorigin src="/assets/index-sYF0VIKc.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BNyWhz1u.css">
|
||||
<title>Boris - Nostr Bookmarks</title>
|
||||
<script type="module" crossorigin src="/assets/index-zNlVawf9.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-ChEoItgK.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
55
node_modules/.package-lock.json
generated
vendored
55
node_modules/.package-lock.json
generated
vendored
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
@@ -2299,6 +2299,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
|
||||
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -4830,6 +4839,44 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.9.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.3.tgz",
|
||||
"integrity": "sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.9.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.3.tgz",
|
||||
"integrity": "sha512-1QSbA0TGGFKTAc/aWjpfW/zoEukYfU4dc1dLkT/vvf54JoGMkW+fNA+3oyo2gWVW1GM7BxjJVHz5GnPJv40rvg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/reading-time-estimator": {
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/reading-time-estimator/-/reading-time-estimator-1.14.0.tgz",
|
||||
@@ -5070,6 +5117,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
|
||||
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
|
||||
58
package-lock.json
generated
58
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "boris",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.6",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||
@@ -23,6 +23,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.9.3",
|
||||
"reading-time-estimator": "^1.14.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
@@ -2291,6 +2292,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
|
||||
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -4822,6 +4832,44 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.9.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.3.tgz",
|
||||
"integrity": "sha512-4o2iWCFIwhI/eYAIL43+cjORXYn/aRQPgtFRRZb3VzoyQ5Uej0Bmqj7437L97N9NJW4wnicSwLOLS+yCXfAPgg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.9.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.3.tgz",
|
||||
"integrity": "sha512-1QSbA0TGGFKTAc/aWjpfW/zoEukYfU4dc1dLkT/vvf54JoGMkW+fNA+3oyo2gWVW1GM7BxjJVHz5GnPJv40rvg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/reading-time-estimator": {
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/reading-time-estimator/-/reading-time-estimator-1.14.0.tgz",
|
||||
@@ -5062,6 +5110,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
|
||||
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.9.3",
|
||||
"reading-time-estimator": "^1.14.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
|
||||
29
src/App.tsx
29
src/App.tsx
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||
import { EventStoreProvider, AccountsProvider } from 'applesauce-react'
|
||||
import { EventStore } from 'applesauce-core'
|
||||
import { AccountManager } from 'applesauce-accounts'
|
||||
@@ -6,6 +7,7 @@ import { RelayPool } from 'applesauce-relay'
|
||||
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
||||
import Login from './components/Login'
|
||||
import Bookmarks from './components/Bookmarks'
|
||||
import Article from './components/Article'
|
||||
|
||||
function App() {
|
||||
const [eventStore, setEventStore] = useState<EventStore | null>(null)
|
||||
@@ -62,16 +64,23 @@ function App() {
|
||||
return (
|
||||
<EventStoreProvider eventStore={eventStore}>
|
||||
<AccountsProvider manager={accountManager}>
|
||||
<div className="app">
|
||||
{!isAuthenticated ? (
|
||||
<Login onLogin={() => setIsAuthenticated(true)} />
|
||||
) : (
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={() => setIsAuthenticated(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<BrowserRouter>
|
||||
<div className="app">
|
||||
<Routes>
|
||||
<Route path="/a/:naddr" element={<Article relayPool={relayPool} />} />
|
||||
<Route path="/" element={
|
||||
!isAuthenticated ? (
|
||||
<Login onLogin={() => setIsAuthenticated(true)} />
|
||||
) : (
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={() => setIsAuthenticated(false)}
|
||||
/>
|
||||
)
|
||||
} />
|
||||
</Routes>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
</AccountsProvider>
|
||||
</EventStoreProvider>
|
||||
)
|
||||
|
||||
176
src/components/Article.tsx
Normal file
176
src/components/Article.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
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
|
||||
Reference in New Issue
Block a user