diff --git a/dist/index.html b/dist/index.html index 82475110..745918ac 100644 --- a/dist/index.html +++ b/dist/index.html @@ -4,9 +4,9 @@ - Markr - Nostr Bookmarks - - + Boris - Nostr Bookmarks + +
diff --git a/node_modules/.package-lock.json b/node_modules/.package-lock.json index 131f6e8e..48309839 100644 --- a/node_modules/.package-lock.json +++ b/node_modules/.package-lock.json @@ -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", diff --git a/package-lock.json b/package-lock.json index 3936d7dd..c0b71e08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 7a492bde..ac48b04d 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/App.tsx b/src/App.tsx index fcea7594..6bdbadc6 100644 --- a/src/App.tsx +++ b/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(null) @@ -62,16 +64,23 @@ function App() { return ( -
- {!isAuthenticated ? ( - setIsAuthenticated(true)} /> - ) : ( - setIsAuthenticated(false)} - /> - )} -
+ +
+ + } /> + setIsAuthenticated(true)} /> + ) : ( + setIsAuthenticated(false)} + /> + ) + } /> + +
+
) diff --git a/src/components/Article.tsx b/src/components/Article.tsx new file mode 100644 index 00000000..97ba6f42 --- /dev/null +++ b/src/components/Article.tsx @@ -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 = ({ relayPool }) => { + const { naddr } = useParams<{ naddr: string }>() + const [article, setArticle] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( +
+
Loading article...
+
+ ) + } + + if (error) { + return ( +
+
Error: {error}
+ + Go Home + +
+ ) + } + + if (!article) { + return ( +
+
Article not found
+ + Go Home + +
+ ) + } + + const title = getArticleTitle(article) + const image = getArticleImage(article) + const published = getArticlePublished(article) + const summary = getArticleSummary(article) + + return ( +
+
+ + ← Back to Home + + + {image && ( +
+ {title} +
+ )} + +
+

{title}

+ +
+ By {npubEncode(article.pubkey).slice(0, 12)}... +
+ + {published && ( +
+ Published: {new Date(published * 1000).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + })} +
+ )} + + {summary && ( +
+ {summary} +
+ )} + +
+ + {article.content} + +
+
+
+
+ ) +} + +export default Article