mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a3dd421fb | ||
|
|
4d95657bca | ||
|
|
6f28c3906c | ||
|
|
fafe378585 | ||
|
|
70b85b0cf0 | ||
|
|
2297d8ae96 | ||
|
|
343f176f06 | ||
|
|
ee788cffb0 | ||
|
|
ca46feb80f | ||
|
|
82ab07e606 | ||
|
|
1f5e3f82b0 | ||
|
|
6265af74f2 | ||
|
|
e8f44986da | ||
|
|
3d304dab15 | ||
|
|
0f7a4d7877 | ||
|
|
d5e847e515 | ||
|
|
edd4e20e22 | ||
|
|
9b0c59b1ae | ||
|
|
8faa2e2de0 | ||
|
|
07a5826774 | ||
|
|
21d6916ae3 | ||
|
|
482ba9b2df | ||
|
|
e4b6d1a122 | ||
|
|
b59a295ad3 |
9
.cursor/rules/highlights-nip-and-docs.mdc
Normal file
9
.cursor/rules/highlights-nip-and-docs.mdc
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
description: nostr highlights spec and docs
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
Here's the spec for nostr-native highlights:
|
||||
|
||||
- https://github.com/nostr-protocol/nips/blob/master/84.md
|
||||
- https://nostrbook.dev/kinds/9802
|
||||
11
.cursor/rules/nostr-native-blog-post-aka-long-form-kind.mdc
Normal file
11
.cursor/rules/nostr-native-blog-post-aka-long-form-kind.mdc
Normal file
@@ -0,0 +1,11 @@
|
||||
---
|
||||
description: anything that has to do with kind:30023 aka nostr blog posts aka nostr-native long-form content
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
Always stick to NIPs. Do everything with applesauce (getArticleTitle, getArticleSummary, getHashtags, getMentions).
|
||||
|
||||
- https://github.com/hzrd149/applesauce/blob/17c9dbb0f2c263e2ebd01729ea2fa138eca12bd1/packages/docs/tutorial/02-helpers.md
|
||||
- https://github.com/nostr-protocol/nips/blob/master/19.md
|
||||
- https://github.com/nostr-protocol/nips/blob/master/23.md
|
||||
- https://nostrbook.dev/kinds/30023
|
||||
3
.env.example
Normal file
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
# Default article to display on app load
|
||||
# This should be a valid naddr1... string (NIP-19 encoded address pointer to a kind:30023 long-form article)
|
||||
VITE_DEFAULT_ARTICLE_NADDR=naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew
|
||||
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-8PiwZoBK.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Dljx1pJR.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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.7",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
33
src/App.tsx
33
src/App.tsx
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { EventStoreProvider, AccountsProvider } from 'applesauce-react'
|
||||
import { EventStore } from 'applesauce-core'
|
||||
import { AccountManager } from 'applesauce-accounts'
|
||||
@@ -7,11 +8,14 @@ import { createAddressLoader } from 'applesauce-loaders/loaders'
|
||||
import Login from './components/Login'
|
||||
import Bookmarks from './components/Bookmarks'
|
||||
|
||||
// Load default article from environment variable with fallback
|
||||
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
||||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
||||
|
||||
function App() {
|
||||
const [eventStore, setEventStore] = useState<EventStore | null>(null)
|
||||
const [accountManager, setAccountManager] = useState<AccountManager | null>(null)
|
||||
const [relayPool, setRelayPool] = useState<RelayPool | null>(null)
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize event store, account manager, and relay pool
|
||||
@@ -62,16 +66,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={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={() => {}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
|
||||
<Route path="/login" element={<Login onLogin={() => {}} />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
</AccountsProvider>
|
||||
</EventStoreProvider>
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ import { CardView } from './BookmarkViews/CardView'
|
||||
interface BookmarkItemProps {
|
||||
bookmark: IndividualBookmark
|
||||
index: number
|
||||
onSelectUrl?: (url: string) => void
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
viewMode?: ViewMode
|
||||
}
|
||||
|
||||
@@ -30,13 +30,19 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
const firstUrl = hasUrls ? extractedUrls[0] : null
|
||||
const firstUrlClassification = firstUrl ? classifyUrl(firstUrl) : null
|
||||
|
||||
// For kind:30023 articles, extract image tag (per NIP-23)
|
||||
// Note: We extract directly from tags here since we don't have the full event.
|
||||
// When we have full events, we use getArticleImage() helper (see articleService.ts)
|
||||
const isArticle = bookmark.kind === 30023
|
||||
const articleImage = isArticle ? bookmark.tags.find(t => t[0] === 'image')?.[1] : undefined
|
||||
|
||||
// Fetch OG image for large view (hook must be at top level)
|
||||
const instantPreview = firstUrl ? getPreviewImage(firstUrl, firstUrlClassification?.type || '') : null
|
||||
React.useEffect(() => {
|
||||
if (viewMode === 'large' && firstUrl && !instantPreview && !ogImage) {
|
||||
if (viewMode === 'large' && firstUrl && !instantPreview && !ogImage && !articleImage) {
|
||||
fetchOgImage(firstUrl).then(setOgImage)
|
||||
}
|
||||
}, [viewMode, firstUrl, instantPreview, ogImage])
|
||||
}, [viewMode, firstUrl, instantPreview, ogImage, articleImage])
|
||||
|
||||
// Resolve author profile using applesauce
|
||||
const authorProfile = useEventModel(Models.ProfileModel, [bookmark.pubkey])
|
||||
@@ -68,10 +74,20 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
}
|
||||
|
||||
const handleReadNow = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault()
|
||||
|
||||
// For kind:30023 articles, pass the bookmark data instead of URL
|
||||
if (bookmark.kind === 30023) {
|
||||
if (onSelectUrl) {
|
||||
onSelectUrl('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// For regular bookmarks with URLs
|
||||
if (!hasUrls) return
|
||||
const firstUrl = extractedUrls[0]
|
||||
if (onSelectUrl) {
|
||||
event.preventDefault()
|
||||
onSelectUrl(firstUrl)
|
||||
} else {
|
||||
window.open(firstUrl, '_blank')
|
||||
@@ -89,7 +105,8 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow
|
||||
handleReadNow,
|
||||
articleImage
|
||||
}
|
||||
|
||||
if (viewMode === 'compact') {
|
||||
@@ -97,9 +114,9 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
}
|
||||
|
||||
if (viewMode === 'large') {
|
||||
const previewImage = instantPreview || ogImage
|
||||
const previewImage = articleImage || instantPreview || ogImage
|
||||
return <LargeView {...sharedProps} previewImage={previewImage} />
|
||||
}
|
||||
|
||||
return <CardView {...sharedProps} />
|
||||
return <CardView {...sharedProps} articleImage={articleImage} />
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ViewMode } from './Bookmarks'
|
||||
|
||||
interface BookmarkListProps {
|
||||
bookmarks: Bookmark[]
|
||||
onSelectUrl?: (url: string) => void
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
isCollapsed: boolean
|
||||
onToggleCollapse: () => void
|
||||
onLogout: () => void
|
||||
|
||||
@@ -13,13 +13,14 @@ interface CardViewProps {
|
||||
index: number
|
||||
hasUrls: boolean
|
||||
extractedUrls: string[]
|
||||
onSelectUrl?: (url: string) => void
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
getIconForUrlType: IconGetter
|
||||
firstUrlClassification: { buttonText: string } | null
|
||||
authorNpub: string
|
||||
eventNevent?: string
|
||||
getAuthorDisplayName: () => string
|
||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
articleImage?: string
|
||||
}
|
||||
|
||||
export const CardView: React.FC<CardViewProps> = ({
|
||||
@@ -33,15 +34,24 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow
|
||||
handleReadNow,
|
||||
articleImage
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [urlsExpanded, setUrlsExpanded] = useState(false)
|
||||
const contentLength = (bookmark.content || '').length
|
||||
const shouldTruncate = !expanded && contentLength > 210
|
||||
const isArticle = bookmark.kind === 30023
|
||||
|
||||
return (
|
||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
||||
{isArticle && articleImage && (
|
||||
<div
|
||||
className="article-hero-image"
|
||||
style={{ backgroundImage: `url(${articleImage})` }}
|
||||
onClick={() => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)}
|
||||
/>
|
||||
)}
|
||||
<div className="bookmark-header">
|
||||
<span className="bookmark-type">
|
||||
{bookmark.isPrivate ? (
|
||||
@@ -141,11 +151,11 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
{getAuthorDisplayName()}
|
||||
</a>
|
||||
</div>
|
||||
{hasUrls && firstUrlClassification && (
|
||||
{(hasUrls && firstUrlClassification) || bookmark.kind === 30023 ? (
|
||||
<button className="read-now-button-minimal" onClick={handleReadNow}>
|
||||
{firstUrlClassification.buttonText}
|
||||
{bookmark.kind === 30023 ? 'Read Article' : firstUrlClassification?.buttonText}
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -11,9 +11,10 @@ interface CompactViewProps {
|
||||
index: number
|
||||
hasUrls: boolean
|
||||
extractedUrls: string[]
|
||||
onSelectUrl?: (url: string) => void
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
getIconForUrlType: IconGetter
|
||||
firstUrlClassification: { buttonText: string } | null
|
||||
articleImage?: string
|
||||
}
|
||||
|
||||
export const CompactView: React.FC<CompactViewProps> = ({
|
||||
@@ -25,8 +26,15 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
getIconForUrlType,
|
||||
firstUrlClassification
|
||||
}) => {
|
||||
const isArticle = bookmark.kind === 30023
|
||||
const isClickable = hasUrls || isArticle
|
||||
|
||||
const handleCompactClick = () => {
|
||||
if (hasUrls && onSelectUrl) {
|
||||
if (!onSelectUrl) return
|
||||
|
||||
if (isArticle) {
|
||||
onSelectUrl('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
|
||||
} else if (hasUrls) {
|
||||
onSelectUrl(extractedUrls[0])
|
||||
}
|
||||
}
|
||||
@@ -34,10 +42,10 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
return (
|
||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark compact ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
||||
<div
|
||||
className={`compact-row ${hasUrls ? 'clickable' : ''}`}
|
||||
className={`compact-row ${isClickable ? 'clickable' : ''}`}
|
||||
onClick={handleCompactClick}
|
||||
role={hasUrls ? 'button' : undefined}
|
||||
tabIndex={hasUrls ? 0 : undefined}
|
||||
role={isClickable ? 'button' : undefined}
|
||||
tabIndex={isClickable ? 0 : undefined}
|
||||
>
|
||||
<span className="bookmark-type-compact">
|
||||
{bookmark.isPrivate ? (
|
||||
@@ -55,13 +63,20 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
</div>
|
||||
)}
|
||||
<span className="bookmark-date-compact">{formatDate(bookmark.created_at)}</span>
|
||||
{hasUrls && (
|
||||
{isClickable && (
|
||||
<button
|
||||
className="compact-read-btn"
|
||||
onClick={(e) => { e.stopPropagation(); onSelectUrl?.(extractedUrls[0]) }}
|
||||
title={firstUrlClassification?.buttonText}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (isArticle) {
|
||||
onSelectUrl?.('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
|
||||
} else {
|
||||
onSelectUrl?.(extractedUrls[0])
|
||||
}
|
||||
}}
|
||||
title={isArticle ? 'Read Article' : firstUrlClassification?.buttonText}
|
||||
>
|
||||
<FontAwesomeIcon icon={getIconForUrlType(extractedUrls[0])} />
|
||||
<FontAwesomeIcon icon={isArticle ? getIconForUrlType('') : getIconForUrlType(extractedUrls[0])} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ interface LargeViewProps {
|
||||
index: number
|
||||
hasUrls: boolean
|
||||
extractedUrls: string[]
|
||||
onSelectUrl?: (url: string) => void
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
getIconForUrlType: IconGetter
|
||||
firstUrlClassification: { buttonText: string } | null
|
||||
previewImage: string | null
|
||||
@@ -34,15 +34,23 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
getAuthorDisplayName,
|
||||
handleReadNow
|
||||
}) => {
|
||||
const isArticle = bookmark.kind === 30023
|
||||
|
||||
return (
|
||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark large ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
||||
{hasUrls && (
|
||||
{(hasUrls || (isArticle && previewImage)) && (
|
||||
<div
|
||||
className="large-preview-image"
|
||||
onClick={() => onSelectUrl?.(extractedUrls[0])}
|
||||
onClick={() => {
|
||||
if (isArticle) {
|
||||
handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
||||
} else {
|
||||
onSelectUrl?.(extractedUrls[0])
|
||||
}
|
||||
}}
|
||||
style={previewImage ? { backgroundImage: `url(${previewImage})` } : undefined}
|
||||
>
|
||||
{!previewImage && (
|
||||
{!previewImage && hasUrls && (
|
||||
<div className="preview-placeholder">
|
||||
<FontAwesomeIcon icon={getIconForUrlType(extractedUrls[0])} />
|
||||
</div>
|
||||
@@ -80,12 +88,12 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
</a>
|
||||
)}
|
||||
|
||||
{hasUrls && firstUrlClassification && (
|
||||
{(hasUrls && firstUrlClassification) || isArticle ? (
|
||||
<button className="large-read-button" onClick={handleReadNow}>
|
||||
<FontAwesomeIcon icon={getIconForUrlType(extractedUrls[0])} />
|
||||
{firstUrlClassification.buttonText}
|
||||
<FontAwesomeIcon icon={isArticle ? getIconForUrlType('') : getIconForUrlType(extractedUrls[0])} />
|
||||
{isArticle ? 'Read Article' : firstUrlClassification?.buttonText}
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { useEventStore } from 'applesauce-react/hooks'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
@@ -6,13 +7,15 @@ import { Bookmark } from '../types/bookmarks'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { BookmarkList } from './BookmarkList'
|
||||
import { fetchBookmarks } from '../services/bookmarkService'
|
||||
import { fetchHighlights } from '../services/highlightService'
|
||||
import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
|
||||
import ContentPanel from './ContentPanel'
|
||||
import { HighlightsPanel } from './HighlightsPanel'
|
||||
import { fetchReadableContent, ReadableContent } from '../services/readerService'
|
||||
import { ReadableContent } from '../services/readerService'
|
||||
import Settings from './Settings'
|
||||
import Toast from './Toast'
|
||||
import { useSettings } from '../hooks/useSettings'
|
||||
import { useArticleLoader } from '../hooks/useArticleLoader'
|
||||
import { loadContent, BookmarkReference } from '../utils/contentLoader'
|
||||
export type ViewMode = 'compact' | 'cards' | 'large'
|
||||
|
||||
interface BookmarksProps {
|
||||
@@ -21,18 +24,21 @@ interface BookmarksProps {
|
||||
}
|
||||
|
||||
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
const { naddr } = useParams<{ naddr?: string }>()
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||
const [highlightsLoading, setHighlightsLoading] = useState(true)
|
||||
const [selectedUrl, setSelectedUrl] = useState<string | undefined>(undefined)
|
||||
const [readerLoading, setReaderLoading] = useState(false)
|
||||
const [readerContent, setReaderContent] = useState<ReadableContent | undefined>(undefined)
|
||||
const [isCollapsed, setIsCollapsed] = useState(false)
|
||||
const [isHighlightsCollapsed, setIsHighlightsCollapsed] = useState(false)
|
||||
const [isCollapsed, setIsCollapsed] = useState(true) // Start collapsed
|
||||
const [isHighlightsCollapsed, setIsHighlightsCollapsed] = useState(true) // Start collapsed
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('compact')
|
||||
const [showUnderlines, setShowUnderlines] = useState(true)
|
||||
const [selectedHighlightId, setSelectedHighlightId] = useState<string | undefined>(undefined)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [currentArticleCoordinate, setCurrentArticleCoordinate] = useState<string | undefined>(undefined)
|
||||
const [currentArticleEventId, setCurrentArticleEventId] = useState<string | undefined>(undefined)
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
const eventStore = useEventStore()
|
||||
@@ -44,6 +50,21 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
accountManager
|
||||
})
|
||||
|
||||
// Load article if naddr is in URL
|
||||
useArticleLoader({
|
||||
naddr,
|
||||
relayPool,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
setIsCollapsed,
|
||||
setIsHighlightsCollapsed,
|
||||
setHighlights,
|
||||
setHighlightsLoading,
|
||||
setCurrentArticleCoordinate,
|
||||
setCurrentArticleEventId
|
||||
})
|
||||
|
||||
// Load initial data on login
|
||||
useEffect(() => {
|
||||
if (!relayPool || !activeAccount) return
|
||||
@@ -66,11 +87,25 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
}
|
||||
|
||||
const handleFetchHighlights = async () => {
|
||||
if (!relayPool || !activeAccount) return
|
||||
if (!relayPool) return
|
||||
|
||||
setHighlightsLoading(true)
|
||||
try {
|
||||
const fetchedHighlights = await fetchHighlights(relayPool, activeAccount.pubkey)
|
||||
setHighlights(fetchedHighlights)
|
||||
// If we're viewing an article, fetch highlights for that article
|
||||
if (currentArticleCoordinate) {
|
||||
const fetchedHighlights = await fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
currentArticleCoordinate,
|
||||
currentArticleEventId
|
||||
)
|
||||
console.log(`🔄 Refreshed ${fetchedHighlights.length} highlights for article`)
|
||||
setHighlights(fetchedHighlights)
|
||||
}
|
||||
// Otherwise, if logged in, fetch user's own highlights
|
||||
else if (activeAccount) {
|
||||
const fetchedHighlights = await fetchHighlights(relayPool, activeAccount.pubkey)
|
||||
setHighlights(fetchedHighlights)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch highlights:', err)
|
||||
} finally {
|
||||
@@ -78,17 +113,20 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectUrl = async (url: string) => {
|
||||
const handleSelectUrl = async (url: string, bookmark?: BookmarkReference) => {
|
||||
if (!relayPool) return
|
||||
|
||||
setSelectedUrl(url)
|
||||
setReaderLoading(true)
|
||||
setReaderContent(undefined)
|
||||
setShowSettings(false)
|
||||
if (settings.collapseOnArticleOpen !== false) setIsCollapsed(true)
|
||||
|
||||
try {
|
||||
const content = await fetchReadableContent(url)
|
||||
const content = await loadContent(url, relayPool, bookmark)
|
||||
setReaderContent(content)
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch readable content:', err)
|
||||
console.warn('Failed to fetch content:', err)
|
||||
} finally {
|
||||
setReaderLoading(false)
|
||||
}
|
||||
@@ -127,9 +165,12 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
title={readerContent?.title}
|
||||
html={readerContent?.html}
|
||||
markdown={readerContent?.markdown}
|
||||
image={readerContent?.image}
|
||||
selectedUrl={selectedUrl}
|
||||
highlights={highlights}
|
||||
showUnderlines={showUnderlines}
|
||||
highlightStyle={settings.highlightStyle || 'marker'}
|
||||
highlightColor={settings.highlightColor || '#ffff00'}
|
||||
onHighlightClick={(id) => {
|
||||
setSelectedHighlightId(id)
|
||||
if (isHighlightsCollapsed) setIsHighlightsCollapsed(false)
|
||||
|
||||
26
src/components/ColorPicker.tsx
Normal file
26
src/components/ColorPicker.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react'
|
||||
import { HIGHLIGHT_COLORS } from '../utils/colorHelpers'
|
||||
|
||||
interface ColorPickerProps {
|
||||
selectedColor: string
|
||||
onColorChange: (color: string) => void
|
||||
}
|
||||
|
||||
const ColorPicker: React.FC<ColorPickerProps> = ({ selectedColor, onColorChange }) => {
|
||||
return (
|
||||
<div className="color-picker">
|
||||
{HIGHLIGHT_COLORS.map(color => (
|
||||
<button
|
||||
key={color.value}
|
||||
onClick={() => onColorChange(color.value)}
|
||||
className={`color-swatch ${selectedColor === color.value ? 'active' : ''}`}
|
||||
style={{ backgroundColor: color.value }}
|
||||
title={color.name}
|
||||
aria-label={`${color.name} highlight color`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ColorPicker
|
||||
@@ -7,6 +7,7 @@ import { Highlight } from '../types/highlights'
|
||||
import { applyHighlightsToHTML } from '../utils/highlightMatching'
|
||||
import { readingTime } from 'reading-time-estimator'
|
||||
import { filterHighlightsByUrl } from '../utils/urlHelpers'
|
||||
import { hexToRgb } from '../utils/colorHelpers'
|
||||
|
||||
interface ContentPanelProps {
|
||||
loading: boolean
|
||||
@@ -14,8 +15,11 @@ interface ContentPanelProps {
|
||||
html?: string
|
||||
markdown?: string
|
||||
selectedUrl?: string
|
||||
image?: string
|
||||
highlights?: Highlight[]
|
||||
showUnderlines?: boolean
|
||||
highlightStyle?: 'marker' | 'underline'
|
||||
highlightColor?: string
|
||||
onHighlightClick?: (highlightId: string) => void
|
||||
selectedHighlightId?: string
|
||||
}
|
||||
@@ -26,8 +30,11 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
html,
|
||||
markdown,
|
||||
selectedUrl,
|
||||
image,
|
||||
highlights = [],
|
||||
showUnderlines = true,
|
||||
highlightStyle = 'marker',
|
||||
highlightColor = '#ffff00',
|
||||
onHighlightClick,
|
||||
selectedHighlightId
|
||||
}) => {
|
||||
@@ -38,7 +45,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
useEffect(() => {
|
||||
if (!selectedHighlightId || !contentRef.current) return
|
||||
|
||||
const markElement = contentRef.current.querySelector(`mark.content-highlight[data-highlight-id="${selectedHighlightId}"]`)
|
||||
const markElement = contentRef.current.querySelector(`mark[data-highlight-id="${selectedHighlightId}"]`)
|
||||
|
||||
if (markElement) {
|
||||
markElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
@@ -86,18 +93,18 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
if (!contentRef.current || !originalHtmlRef.current) return
|
||||
|
||||
// Always apply highlights to the ORIGINAL HTML, not already-highlighted content
|
||||
const highlightedHTML = applyHighlightsToHTML(originalHtmlRef.current, relevantHighlights)
|
||||
const highlightedHTML = applyHighlightsToHTML(originalHtmlRef.current, relevantHighlights, highlightStyle)
|
||||
contentRef.current.innerHTML = highlightedHTML
|
||||
})
|
||||
|
||||
return () => cancelAnimationFrame(rafId)
|
||||
}, [relevantHighlights, html, markdown, showUnderlines])
|
||||
}, [relevantHighlights, html, markdown, showUnderlines, highlightStyle])
|
||||
|
||||
// Attach click handlers separately (only when handler changes)
|
||||
useEffect(() => {
|
||||
if (!onHighlightClick || !contentRef.current) return
|
||||
|
||||
const marks = contentRef.current.querySelectorAll('mark.content-highlight')
|
||||
const marks = contentRef.current.querySelectorAll('mark[data-highlight-id]')
|
||||
const handlers = new Map<Element, () => void>()
|
||||
|
||||
marks.forEach(mark => {
|
||||
@@ -149,8 +156,15 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
const highlightRgb = hexToRgb(highlightColor)
|
||||
|
||||
return (
|
||||
<div className="reader">
|
||||
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
|
||||
{image && (
|
||||
<div className="reader-hero-image">
|
||||
<img src={image} alt={title || 'Article image'} />
|
||||
</div>
|
||||
)}
|
||||
{title && (
|
||||
<div className="reader-header">
|
||||
<h2 className="reader-title">{title}</h2>
|
||||
|
||||
38
src/components/FontSelector.tsx
Normal file
38
src/components/FontSelector.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from 'react'
|
||||
|
||||
interface FontSelectorProps {
|
||||
value: string
|
||||
onChange: (font: string) => void
|
||||
}
|
||||
|
||||
const FONTS = [
|
||||
{ value: 'system', label: 'System Default', family: 'system-ui, -apple-system, sans-serif' },
|
||||
{ value: 'inter', label: 'Inter', family: 'Inter, sans-serif' },
|
||||
{ value: 'lora', label: 'Lora', family: 'Lora, serif' },
|
||||
{ value: 'merriweather', label: 'Merriweather', family: 'Merriweather, serif' },
|
||||
{ value: 'open-sans', label: 'Open Sans', family: 'Open Sans, sans-serif' },
|
||||
{ value: 'roboto', label: 'Roboto', family: 'Roboto, sans-serif' },
|
||||
{ value: 'source-serif-4', label: 'Source Serif 4', family: 'Source Serif 4, serif' },
|
||||
{ value: 'crimson-text', label: 'Crimson Text', family: 'Crimson Text, serif' },
|
||||
{ value: 'libre-baskerville', label: 'Libre Baskerville', family: 'Libre Baskerville, serif' },
|
||||
{ value: 'pt-serif', label: 'PT Serif', family: 'PT Serif, serif' }
|
||||
]
|
||||
|
||||
const FontSelector: React.FC<FontSelectorProps> = ({ value, onChange }) => {
|
||||
return (
|
||||
<select
|
||||
id="readingFont"
|
||||
value={value || 'system'}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="setting-select font-select"
|
||||
>
|
||||
{FONTS.map(font => (
|
||||
<option key={font.value} value={font.value} style={{ fontFamily: font.family }}>
|
||||
{font.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
export default FontSelector
|
||||
@@ -37,10 +37,17 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
onToggleUnderlines?.(newValue)
|
||||
}
|
||||
|
||||
// Filter highlights to show only those relevant to the current URL
|
||||
// Filter highlights to show only those relevant to the current URL or article
|
||||
const filteredHighlights = useMemo(() => {
|
||||
if (!selectedUrl) return highlights
|
||||
|
||||
// For Nostr articles (URL starts with "nostr:"), we don't need to filter
|
||||
// because we already fetched highlights specifically for this article
|
||||
if (selectedUrl.startsWith('nostr:')) {
|
||||
return highlights
|
||||
}
|
||||
|
||||
// For web URLs, filter by URL matching
|
||||
const normalizeUrl = (url: string) => {
|
||||
try {
|
||||
const urlObj = new URL(url.startsWith('http') ? url : `https://${url}`)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { faTimes, faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faTimes, faList, faThLarge, faImage, faUnderline, faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import IconButton from './IconButton'
|
||||
import ColorPicker from './ColorPicker'
|
||||
import FontSelector from './FontSelector'
|
||||
import { loadFont, getFontFamily } from '../utils/fontLoader'
|
||||
import { hexToRgb } from '../utils/colorHelpers'
|
||||
|
||||
interface SettingsProps {
|
||||
settings: UserSettings
|
||||
@@ -62,23 +65,10 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label htmlFor="readingFont">Reading Font</label>
|
||||
<select
|
||||
id="readingFont"
|
||||
<FontSelector
|
||||
value={localSettings.readingFont || 'system'}
|
||||
onChange={(e) => setLocalSettings({ ...localSettings, readingFont: e.target.value })}
|
||||
className="setting-select font-select"
|
||||
>
|
||||
<option value="system" style={{ fontFamily: 'system-ui, -apple-system, sans-serif' }}>System Default</option>
|
||||
<option value="inter" style={{ fontFamily: 'Inter, sans-serif' }}>Inter</option>
|
||||
<option value="lora" style={{ fontFamily: 'Lora, serif' }}>Lora</option>
|
||||
<option value="merriweather" style={{ fontFamily: 'Merriweather, serif' }}>Merriweather</option>
|
||||
<option value="open-sans" style={{ fontFamily: 'Open Sans, sans-serif' }}>Open Sans</option>
|
||||
<option value="roboto" style={{ fontFamily: 'Roboto, sans-serif' }}>Roboto</option>
|
||||
<option value="source-serif-4" style={{ fontFamily: 'Source Serif 4, serif' }}>Source Serif 4</option>
|
||||
<option value="crimson-text" style={{ fontFamily: 'Crimson Text, serif' }}>Crimson Text</option>
|
||||
<option value="libre-baskerville" style={{ fontFamily: 'Libre Baskerville, serif' }}>Libre Baskerville</option>
|
||||
<option value="pt-serif" style={{ fontFamily: 'PT Serif, serif' }}>PT Serif</option>
|
||||
</select>
|
||||
onChange={(font) => setLocalSettings({ ...localSettings, readingFont: font })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
@@ -111,17 +101,46 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Highlight Style</label>
|
||||
<div className="setting-buttons">
|
||||
<IconButton
|
||||
icon={faHighlighter}
|
||||
onClick={() => setLocalSettings({ ...localSettings, highlightStyle: 'marker' })}
|
||||
title="Text marker style"
|
||||
ariaLabel="Text marker style"
|
||||
variant={(localSettings.highlightStyle || 'marker') === 'marker' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUnderline}
|
||||
onClick={() => setLocalSettings({ ...localSettings, highlightStyle: 'underline' })}
|
||||
title="Underline style"
|
||||
ariaLabel="Underline style"
|
||||
variant={localSettings.highlightStyle === 'underline' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Highlight Color</label>
|
||||
<ColorPicker
|
||||
selectedColor={localSettings.highlightColor || '#ffff00'}
|
||||
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColor: color })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="setting-preview">
|
||||
<div className="preview-label">Preview</div>
|
||||
<div
|
||||
className="preview-content"
|
||||
style={{
|
||||
fontFamily: previewFontFamily,
|
||||
fontSize: `${localSettings.fontSize || 16}px`
|
||||
}}
|
||||
fontSize: `${localSettings.fontSize || 16}px`,
|
||||
'--highlight-rgb': hexToRgb(localSettings.highlightColor || '#ffff00')
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<h3>The Quick Brown Fox</h3>
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <span className={localSettings.showUnderlines !== false ? "content-highlight" : ""}>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</span> Ut enim ad minim veniam.</p>
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <span className={localSettings.showUnderlines !== false ? `content-highlight-${localSettings.highlightStyle || 'marker'}` : ""}>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</span> Ut enim ad minim veniam.</p>
|
||||
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
92
src/hooks/useArticleLoader.ts
Normal file
92
src/hooks/useArticleLoader.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useEffect } from 'react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { fetchArticleByNaddr } from '../services/articleService'
|
||||
import { fetchHighlightsForArticle } from '../services/highlightService'
|
||||
import { ReadableContent } from '../services/readerService'
|
||||
import { Highlight } from '../types/highlights'
|
||||
|
||||
interface UseArticleLoaderProps {
|
||||
naddr: string | undefined
|
||||
relayPool: RelayPool | null
|
||||
setSelectedUrl: (url: string) => void
|
||||
setReaderContent: (content: ReadableContent | undefined) => void
|
||||
setReaderLoading: (loading: boolean) => void
|
||||
setIsCollapsed: (collapsed: boolean) => void
|
||||
setIsHighlightsCollapsed: (collapsed: boolean) => void
|
||||
setHighlights: (highlights: Highlight[]) => void
|
||||
setHighlightsLoading: (loading: boolean) => void
|
||||
setCurrentArticleCoordinate: (coord: string | undefined) => void
|
||||
setCurrentArticleEventId: (id: string | undefined) => void
|
||||
}
|
||||
|
||||
export function useArticleLoader({
|
||||
naddr,
|
||||
relayPool,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
setIsCollapsed,
|
||||
setIsHighlightsCollapsed,
|
||||
setHighlights,
|
||||
setHighlightsLoading,
|
||||
setCurrentArticleCoordinate,
|
||||
setCurrentArticleEventId
|
||||
}: UseArticleLoaderProps) {
|
||||
useEffect(() => {
|
||||
if (!relayPool || !naddr) return
|
||||
|
||||
const loadArticle = async () => {
|
||||
setReaderLoading(true)
|
||||
setReaderContent(undefined)
|
||||
setSelectedUrl(`nostr:${naddr}`)
|
||||
setIsCollapsed(true)
|
||||
setIsHighlightsCollapsed(false)
|
||||
|
||||
try {
|
||||
const article = await fetchArticleByNaddr(relayPool, naddr)
|
||||
setReaderContent({
|
||||
title: article.title,
|
||||
markdown: article.markdown,
|
||||
image: article.image,
|
||||
url: `nostr:${naddr}`
|
||||
})
|
||||
|
||||
const dTag = article.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const articleCoordinate = `${article.event.kind}:${article.author}:${dTag}`
|
||||
|
||||
setCurrentArticleCoordinate(articleCoordinate)
|
||||
setCurrentArticleEventId(article.event.id)
|
||||
|
||||
console.log('📰 Article loaded:', article.title)
|
||||
console.log('📍 Coordinate:', articleCoordinate)
|
||||
|
||||
try {
|
||||
setHighlightsLoading(true)
|
||||
const fetchedHighlights = await fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
articleCoordinate,
|
||||
article.event.id
|
||||
)
|
||||
console.log(`📌 Found ${fetchedHighlights.length} highlights`)
|
||||
setHighlights(fetchedHighlights)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch highlights:', err)
|
||||
} finally {
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
} 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}`
|
||||
})
|
||||
setReaderLoading(false)
|
||||
} finally {
|
||||
setReaderLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadArticle()
|
||||
}, [naddr, relayPool])
|
||||
}
|
||||
165
src/index.css
165
src/index.css
@@ -1023,6 +1023,48 @@ body {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
/* Article hero image in card view */
|
||||
.article-hero-image {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 8px 8px 0 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.article-hero-image:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.article-hero-image::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to bottom, transparent 60%, rgba(0,0,0,0.4) 100%);
|
||||
pointer-events: none;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
/* Hero image in reader view */
|
||||
.reader-hero-image {
|
||||
width: 100%;
|
||||
margin: 0 0 2rem 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.reader-hero-image img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 500px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Private Bookmark Styles */
|
||||
.private-bookmark {
|
||||
background: #2a2a2a;
|
||||
@@ -1402,60 +1444,102 @@ body {
|
||||
}
|
||||
|
||||
/* Inline content highlights - fluorescent marker style */
|
||||
.content-highlight {
|
||||
background: rgba(255, 255, 0, 0.35);
|
||||
.content-highlight,
|
||||
.content-highlight-marker {
|
||||
background: rgba(var(--highlight-rgb, 255, 255, 0), 0.35);
|
||||
padding: 0.125rem 0.25rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 8px rgba(255, 255, 0, 0.2);
|
||||
box-shadow: 0 0 8px rgba(var(--highlight-rgb, 255, 255, 0), 0.2);
|
||||
}
|
||||
|
||||
.content-highlight:hover {
|
||||
background: rgba(255, 255, 0, 0.5);
|
||||
box-shadow: 0 0 12px rgba(255, 255, 0, 0.3);
|
||||
.content-highlight:hover,
|
||||
.content-highlight-marker:hover {
|
||||
background: rgba(var(--highlight-rgb, 255, 255, 0), 0.5);
|
||||
box-shadow: 0 0 12px rgba(var(--highlight-rgb, 255, 255, 0), 0.3);
|
||||
}
|
||||
|
||||
.content-highlight.highlight-pulse {
|
||||
/* Underline style for highlights */
|
||||
.content-highlight-underline {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 0.8);
|
||||
text-decoration-thickness: 2px;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.content-highlight-underline:hover {
|
||||
text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 1);
|
||||
text-decoration-thickness: 3px;
|
||||
}
|
||||
|
||||
.content-highlight.highlight-pulse,
|
||||
.content-highlight-marker.highlight-pulse,
|
||||
.content-highlight-underline.highlight-pulse {
|
||||
animation: highlight-pulse-animation 1.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes highlight-pulse-animation {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 8px rgba(255, 255, 0, 0.2);
|
||||
box-shadow: 0 0 8px rgba(var(--highlight-rgb, 255, 255, 0), 0.2);
|
||||
transform: scale(1);
|
||||
}
|
||||
25% {
|
||||
box-shadow: 0 0 20px rgba(255, 255, 0, 0.6);
|
||||
box-shadow: 0 0 20px rgba(var(--highlight-rgb, 255, 255, 0), 0.6);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 8px rgba(255, 255, 0, 0.2);
|
||||
box-shadow: 0 0 8px rgba(var(--highlight-rgb, 255, 255, 0), 0.2);
|
||||
transform: scale(1);
|
||||
}
|
||||
75% {
|
||||
box-shadow: 0 0 20px rgba(255, 255, 0, 0.6);
|
||||
box-shadow: 0 0 20px rgba(var(--highlight-rgb, 255, 255, 0), 0.6);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
|
||||
.reader-html .content-highlight,
|
||||
.reader-markdown .content-highlight {
|
||||
.reader-markdown .content-highlight,
|
||||
.reader-html .content-highlight-marker,
|
||||
.reader-markdown .content-highlight-marker,
|
||||
.reader-html .content-highlight-underline,
|
||||
.reader-markdown .content-highlight-underline {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.reader-html .content-highlight,
|
||||
.reader-markdown .content-highlight,
|
||||
.reader-html .content-highlight-marker,
|
||||
.reader-markdown .content-highlight-marker {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Ensure highlights work in both light and dark mode */
|
||||
@media (prefers-color-scheme: light) {
|
||||
.content-highlight {
|
||||
background: rgba(255, 255, 0, 0.4);
|
||||
box-shadow: 0 0 6px rgba(255, 255, 0, 0.15);
|
||||
.content-highlight,
|
||||
.content-highlight-marker {
|
||||
background: rgba(var(--highlight-rgb, 255, 255, 0), 0.4);
|
||||
box-shadow: 0 0 6px rgba(var(--highlight-rgb, 255, 255, 0), 0.15);
|
||||
}
|
||||
|
||||
.content-highlight:hover {
|
||||
background: rgba(255, 255, 0, 0.55);
|
||||
box-shadow: 0 0 10px rgba(255, 255, 0, 0.25);
|
||||
.content-highlight:hover,
|
||||
.content-highlight-marker:hover {
|
||||
background: rgba(var(--highlight-rgb, 255, 255, 0), 0.55);
|
||||
box-shadow: 0 0 10px rgba(var(--highlight-rgb, 255, 255, 0), 0.25);
|
||||
}
|
||||
|
||||
.content-highlight-underline {
|
||||
text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 0.9);
|
||||
}
|
||||
|
||||
.content-highlight-underline:hover {
|
||||
text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 1);
|
||||
}
|
||||
|
||||
.highlight-indicator {
|
||||
@@ -1545,13 +1629,50 @@ body {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
width: 33px;
|
||||
height: 33px;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.color-swatch:hover {
|
||||
border-color: #888;
|
||||
}
|
||||
|
||||
.color-swatch.active {
|
||||
border-color: #646cff;
|
||||
box-shadow: 0 0 0 2px #646cff;
|
||||
}
|
||||
|
||||
.color-swatch.active::after {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #000;
|
||||
font-size: 0.875rem;
|
||||
font-weight: bold;
|
||||
text-shadow: 0 0 2px #fff;
|
||||
}
|
||||
|
||||
.font-size-btn {
|
||||
min-width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
padding: 0.5rem;
|
||||
min-width: 33px;
|
||||
height: 33px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
color: #ccc;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
165
src/services/articleService.ts
Normal file
165
src/services/articleService.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
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
|
||||
}
|
||||
|
||||
interface CachedArticle {
|
||||
content: ArticleContent
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const CACHE_TTL = 7 * 24 * 60 * 60 * 1000 // 7 days in milliseconds
|
||||
const CACHE_PREFIX = 'article_cache_'
|
||||
|
||||
function getCacheKey(naddr: string): string {
|
||||
return `${CACHE_PREFIX}${naddr}`
|
||||
}
|
||||
|
||||
function getFromCache(naddr: string): ArticleContent | null {
|
||||
try {
|
||||
const cacheKey = getCacheKey(naddr)
|
||||
const cached = localStorage.getItem(cacheKey)
|
||||
if (!cached) return null
|
||||
|
||||
const { content, timestamp }: CachedArticle = JSON.parse(cached)
|
||||
const age = Date.now() - timestamp
|
||||
|
||||
if (age > CACHE_TTL) {
|
||||
localStorage.removeItem(cacheKey)
|
||||
return null
|
||||
}
|
||||
|
||||
console.log('📦 Loaded article from cache:', naddr)
|
||||
return content
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function saveToCache(naddr: string, content: ArticleContent): void {
|
||||
try {
|
||||
const cacheKey = getCacheKey(naddr)
|
||||
const cached: CachedArticle = {
|
||||
content,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
localStorage.setItem(cacheKey, JSON.stringify(cached))
|
||||
console.log('💾 Saved article to cache:', naddr)
|
||||
} catch (err) {
|
||||
console.warn('Failed to cache article:', err)
|
||||
// Silently fail if storage is full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a Nostr long-form article (NIP-23) by naddr
|
||||
* @param relayPool - The relay pool to query
|
||||
* @param naddr - The article's naddr
|
||||
* @param bypassCache - If true, skip cache and fetch fresh from relays
|
||||
*/
|
||||
export async function fetchArticleByNaddr(
|
||||
relayPool: RelayPool,
|
||||
naddr: string,
|
||||
bypassCache = false
|
||||
): Promise<ArticleContent> {
|
||||
try {
|
||||
// Check cache first unless bypassed
|
||||
if (!bypassCache) {
|
||||
const cached = getFromCache(naddr)
|
||||
if (cached) return cached
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
const content: ArticleContent = {
|
||||
title,
|
||||
markdown: article.content,
|
||||
image,
|
||||
published,
|
||||
summary,
|
||||
author: article.pubkey,
|
||||
event: article
|
||||
}
|
||||
|
||||
// Save to cache before returning
|
||||
saveToCache(naddr, content)
|
||||
|
||||
return content
|
||||
} 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
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getParsedContent } from 'applesauce-content/text'
|
||||
import { getArticleTitle } from 'applesauce-core/helpers'
|
||||
import { ActiveAccount, IndividualBookmark, ParsedContent } from '../types/bookmarks'
|
||||
import type { NostrEvent } from './bookmarkEvents'
|
||||
|
||||
@@ -94,14 +95,24 @@ export function hydrateItems(
|
||||
return items.map(item => {
|
||||
const ev = idToEvent.get(item.id)
|
||||
if (!ev) return item
|
||||
|
||||
// For long-form articles (kind:30023), use the article title as content
|
||||
let content = ev.content || item.content || ''
|
||||
if (ev.kind === 30023) {
|
||||
const articleTitle = getArticleTitle(ev)
|
||||
if (articleTitle) {
|
||||
content = articleTitle
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
pubkey: ev.pubkey || item.pubkey,
|
||||
content: ev.content || item.content || '',
|
||||
content,
|
||||
created_at: ev.created_at || item.created_at,
|
||||
kind: ev.kind || item.kind,
|
||||
tags: ev.tags || item.tags,
|
||||
parsedContent: ev.content ? (getParsedContent(ev.content) as ParsedContent) : item.parsedContent
|
||||
parsedContent: ev.content ? (getParsedContent(content) as ParsedContent) : item.parsedContent
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -29,6 +29,113 @@ function dedupeHighlights(events: NostrEvent[]): NostrEvent[] {
|
||||
return Array.from(byId.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches highlights for a specific article by its address coordinate and/or event ID
|
||||
* @param relayPool - The relay pool to query
|
||||
* @param articleCoordinate - The article's address in format "kind:pubkey:identifier" (e.g., "30023:abc...def:my-article")
|
||||
* @param eventId - Optional event ID to also query by 'e' tag
|
||||
*/
|
||||
export const fetchHighlightsForArticle = async (
|
||||
relayPool: RelayPool,
|
||||
articleCoordinate: string,
|
||||
eventId?: string
|
||||
): Promise<Highlight[]> => {
|
||||
try {
|
||||
// Use well-known relays for highlights even if user isn't logged in
|
||||
const highlightRelays = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.nostr.band',
|
||||
'wss://relay.snort.social',
|
||||
'wss://purplepag.es'
|
||||
]
|
||||
|
||||
console.log('🔍 Fetching highlights (kind 9802) for article:', articleCoordinate)
|
||||
console.log('🔍 Event ID:', eventId || 'none')
|
||||
console.log('🔍 From relays:', highlightRelays)
|
||||
|
||||
// Query for highlights that reference this article via the 'a' tag
|
||||
console.log('🔍 Filter 1 (a-tag):', JSON.stringify({ kinds: [9802], '#a': [articleCoordinate] }, null, 2))
|
||||
const aTagEvents = await lastValueFrom(
|
||||
relayPool
|
||||
.req(highlightRelays, { kinds: [9802], '#a': [articleCoordinate] })
|
||||
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
|
||||
)
|
||||
|
||||
console.log('📊 Highlights via a-tag:', aTagEvents.length)
|
||||
|
||||
// If we have an event ID, also query for highlights that reference via the 'e' tag
|
||||
let eTagEvents: NostrEvent[] = []
|
||||
if (eventId) {
|
||||
console.log('🔍 Filter 2 (e-tag):', JSON.stringify({ kinds: [9802], '#e': [eventId] }, null, 2))
|
||||
eTagEvents = await lastValueFrom(
|
||||
relayPool
|
||||
.req(highlightRelays, { kinds: [9802], '#e': [eventId] })
|
||||
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
|
||||
)
|
||||
console.log('📊 Highlights via e-tag:', eTagEvents.length)
|
||||
}
|
||||
|
||||
// Combine results from both queries
|
||||
const rawEvents = [...aTagEvents, ...eTagEvents]
|
||||
console.log('📊 Total raw highlight events fetched:', rawEvents.length)
|
||||
|
||||
if (rawEvents.length > 0) {
|
||||
console.log('📄 Sample highlight tags:', JSON.stringify(rawEvents[0].tags, null, 2))
|
||||
} else {
|
||||
console.log('❌ No highlights found. Article coordinate:', articleCoordinate)
|
||||
console.log('❌ Event ID:', eventId || 'none')
|
||||
console.log('💡 Try checking if there are any highlights on this article at https://highlighter.com')
|
||||
}
|
||||
|
||||
// Deduplicate events by ID
|
||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||
console.log('📊 Unique highlight events after deduplication:', uniqueEvents.length)
|
||||
|
||||
const highlights: Highlight[] = uniqueEvents.map((event: NostrEvent) => {
|
||||
// Use applesauce helpers to extract highlight data
|
||||
const highlightText = getHighlightText(event)
|
||||
const context = getHighlightContext(event)
|
||||
const comment = getHighlightComment(event)
|
||||
const sourceEventPointer = getHighlightSourceEventPointer(event)
|
||||
const sourceAddressPointer = getHighlightSourceAddressPointer(event)
|
||||
const sourceUrl = getHighlightSourceUrl(event)
|
||||
const attributions = getHighlightAttributions(event)
|
||||
|
||||
// Get author from attributions
|
||||
const author = attributions.find(a => a.role === 'author')?.pubkey
|
||||
|
||||
// Get event reference (prefer event pointer, fallback to address pointer)
|
||||
const eventReference = sourceEventPointer?.id ||
|
||||
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
created_at: event.created_at,
|
||||
content: highlightText,
|
||||
tags: event.tags,
|
||||
eventReference,
|
||||
urlReference: sourceUrl,
|
||||
author,
|
||||
context,
|
||||
comment
|
||||
}
|
||||
})
|
||||
|
||||
// Sort by creation time (newest first)
|
||||
return highlights.sort((a, b) => b.created_at - a.created_at)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch highlights for article:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches highlights created by a specific user
|
||||
* @param relayPool - The relay pool to query
|
||||
* @param pubkey - The user's public key
|
||||
*/
|
||||
export const fetchHighlights = async (
|
||||
relayPool: RelayPool,
|
||||
pubkey: string
|
||||
@@ -36,7 +143,7 @@ export const fetchHighlights = async (
|
||||
try {
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
|
||||
console.log('🔍 Fetching highlights (kind 9802) from relays:', relayUrls)
|
||||
console.log('🔍 Fetching highlights (kind 9802) by author:', pubkey)
|
||||
|
||||
const rawEvents = await lastValueFrom(
|
||||
relayPool
|
||||
@@ -84,7 +191,7 @@ export const fetchHighlights = async (
|
||||
// Sort by creation time (newest first)
|
||||
return highlights.sort((a, b) => b.created_at - a.created_at)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch highlights:', error)
|
||||
console.error('Failed to fetch highlights by author:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface ReadableContent {
|
||||
title?: string
|
||||
html?: string
|
||||
markdown?: string
|
||||
image?: string
|
||||
}
|
||||
|
||||
interface CachedContent {
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface UserSettings {
|
||||
highlightsCollapsed?: boolean
|
||||
readingFont?: string
|
||||
fontSize?: number
|
||||
highlightStyle?: 'marker' | 'underline'
|
||||
highlightColor?: string
|
||||
}
|
||||
|
||||
export async function loadSettings(
|
||||
|
||||
16
src/utils/colorHelpers.ts
Normal file
16
src/utils/colorHelpers.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// Helper to convert hex color to RGB values
|
||||
export function hexToRgb(hex: string): string {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
|
||||
return result
|
||||
? `${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}`
|
||||
: '255, 255, 0'
|
||||
}
|
||||
|
||||
export const HIGHLIGHT_COLORS = [
|
||||
{ name: 'Yellow', value: '#ffff00' },
|
||||
{ name: 'Orange', value: '#ff9500' },
|
||||
{ name: 'Pink', value: '#ff69b4' },
|
||||
{ name: 'Green', value: '#00ff7f' },
|
||||
{ name: 'Blue', value: '#4da6ff' },
|
||||
{ name: 'Purple', value: '#b19cd9' }
|
||||
]
|
||||
44
src/utils/contentLoader.ts
Normal file
44
src/utils/contentLoader.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { fetchArticleByNaddr } from '../services/articleService'
|
||||
import { fetchReadableContent, ReadableContent } from '../services/readerService'
|
||||
|
||||
export interface BookmarkReference {
|
||||
id: string
|
||||
kind: number
|
||||
tags: string[][]
|
||||
pubkey: string
|
||||
}
|
||||
|
||||
export async function loadContent(
|
||||
url: string,
|
||||
relayPool: RelayPool,
|
||||
bookmark?: BookmarkReference
|
||||
): Promise<ReadableContent> {
|
||||
// Check if this is a kind:30023 article
|
||||
if (bookmark && bookmark.kind === 30023) {
|
||||
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
|
||||
if (dTag !== undefined && bookmark.pubkey) {
|
||||
const pointer = {
|
||||
identifier: dTag,
|
||||
kind: 30023,
|
||||
pubkey: bookmark.pubkey,
|
||||
}
|
||||
const naddr = nip19.naddrEncode(pointer)
|
||||
const article = await fetchArticleByNaddr(relayPool, naddr)
|
||||
|
||||
return {
|
||||
title: article.title,
|
||||
markdown: article.markdown,
|
||||
image: article.image,
|
||||
url: `nostr:${naddr}`
|
||||
}
|
||||
} else {
|
||||
throw new Error('Invalid article reference - missing d tag or pubkey')
|
||||
}
|
||||
} else {
|
||||
// For regular URLs, fetch readable content
|
||||
return await fetchReadableContent(url)
|
||||
}
|
||||
}
|
||||
@@ -99,9 +99,9 @@ export function applyHighlightsToText(
|
||||
const normalizeWhitespace = (str: string) => str.replace(/\s+/g, ' ').trim()
|
||||
|
||||
// Helper to create a mark element for a highlight
|
||||
function createMarkElement(highlight: Highlight, matchText: string): HTMLElement {
|
||||
function createMarkElement(highlight: Highlight, matchText: string, highlightStyle: 'marker' | 'underline' = 'marker'): HTMLElement {
|
||||
const mark = document.createElement('mark')
|
||||
mark.className = 'content-highlight'
|
||||
mark.className = `content-highlight-${highlightStyle}`
|
||||
mark.setAttribute('data-highlight-id', highlight.id)
|
||||
mark.setAttribute('title', `Highlighted ${new Date(highlight.created_at * 1000).toLocaleDateString()}`)
|
||||
mark.textContent = matchText
|
||||
@@ -127,7 +127,8 @@ function tryMarkInTextNodes(
|
||||
textNodes: Text[],
|
||||
searchText: string,
|
||||
highlight: Highlight,
|
||||
useNormalized: boolean
|
||||
useNormalized: boolean,
|
||||
highlightStyle: 'marker' | 'underline' = 'marker'
|
||||
): boolean {
|
||||
const normalizedSearch = normalizeWhitespace(searchText)
|
||||
|
||||
@@ -154,7 +155,7 @@ function tryMarkInTextNodes(
|
||||
const before = text.substring(0, actualIndex)
|
||||
const match = text.substring(actualIndex, actualIndex + searchText.length)
|
||||
const after = text.substring(actualIndex + searchText.length)
|
||||
const mark = createMarkElement(highlight, match)
|
||||
const mark = createMarkElement(highlight, match, highlightStyle)
|
||||
|
||||
replaceTextWithMark(textNode, before, after, mark)
|
||||
return true
|
||||
@@ -166,7 +167,7 @@ function tryMarkInTextNodes(
|
||||
/**
|
||||
* Apply highlights to HTML content by injecting mark tags using DOM manipulation
|
||||
*/
|
||||
export function applyHighlightsToHTML(html: string, highlights: Highlight[]): string {
|
||||
export function applyHighlightsToHTML(html: string, highlights: Highlight[], highlightStyle: 'marker' | 'underline' = 'marker'): string {
|
||||
if (!html || highlights.length === 0) return html
|
||||
|
||||
const tempDiv = document.createElement('div')
|
||||
@@ -183,8 +184,8 @@ export function applyHighlightsToHTML(html: string, highlights: Highlight[]): st
|
||||
while ((node = walker.nextNode())) textNodes.push(node as Text)
|
||||
|
||||
// Try exact match first, then normalized match
|
||||
tryMarkInTextNodes(textNodes, searchText, highlight, false) ||
|
||||
tryMarkInTextNodes(textNodes, searchText, highlight, true)
|
||||
tryMarkInTextNodes(textNodes, searchText, highlight, false, highlightStyle) ||
|
||||
tryMarkInTextNodes(textNodes, searchText, highlight, true, highlightStyle)
|
||||
}
|
||||
|
||||
return tempDiv.innerHTML
|
||||
|
||||
5
src/vite-env.d.ts
vendored
Normal file
5
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_DEFAULT_ARTICLE_NADDR: string
|
||||
}
|
||||
Reference in New Issue
Block a user