Compare commits

...

24 Commits

Author SHA1 Message Date
Gigi
7a3dd421fb chore: bump version to 0.1.7 2025-10-05 12:55:10 +01:00
Gigi
4d95657bca refactor: keep Bookmarks.tsx under 210 lines by extracting logic
Extracted large functions into separate modules to follow DRY principles
and keep files manageable:

- Created useArticleLoader.ts hook (92 lines)
  - Handles article loading from naddr
  - Fetches article content and highlights
  - Sets up article coordinate for refresh

- Created contentLoader.ts utility (44 lines)
  - Handles both Nostr articles and web URLs
  - Unified content loading logic
  - Reusable across components

Result: Bookmarks.tsx reduced from 282 to 208 lines 

All files now under 210 line limit while maintaining functionality.
2025-10-05 12:47:32 +01:00
Gigi
6f28c3906c fix: show highlights for nostr articles by skipping URL filter
The HighlightsPanel was filtering out ALL highlights that didn't have
a urlReference. But Nostr article highlights reference the article via
the 'a' tag (article coordinate), not a URL.

Since we already fetch highlights specifically for the current article
using fetchHighlightsForArticle(), we don't need to filter them again.

Solution:
- Skip URL filtering when selectedUrl starts with 'nostr:'
- Keep URL filtering for web articles (backwards compatible)
- Highlights are already pre-filtered by the fetch query

This fixes the issue where 101 highlights existed for the default
article but weren't being displayed in the UI.
2025-10-05 12:37:28 +01:00
Gigi
fafe378585 fix: remove ImportMeta interface redeclaration
- ImportMeta is already defined as built-in global by vite/client
- Keep only ImportMetaEnv extension for custom env variables
- Fixes eslint no-redeclare error
2025-10-05 12:23:50 +01:00
Gigi
70b85b0cf0 fix: refresh button now works without login for article highlights
- Track current article coordinate and event ID in state
- Update handleFetchHighlights to refresh article highlights if viewing article
- Fall back to fetching user's highlights only if logged in and not viewing article
- Refresh button now works for anonymous article viewing
- No longer requires activeAccount to refresh highlights

Previously the refresh button only worked when logged in because it tried
to fetch highlights BY the user. Now it intelligently fetches highlights
FOR the current article, or falls back to user highlights if logged in.
2025-10-05 12:23:08 +01:00
Gigi
2297d8ae96 fix: query highlights using both a-tag and e-tag
- Highlights on replaceable events include BOTH 'a' and 'e' tags
- Query for highlights using article coordinate (#a tag)
- Also query using event ID (#e tag) for comprehensive results
- Combine and deduplicate results from both queries
- Add detailed logging to help diagnose why highlights aren't found
- Suggest checking highlighter.com if no highlights found

Per NIP-84 and applesauce implementation, highlights on kind:30023
articles include both an addressable reference ('a' tag) and an event
reference ('e' tag).
2025-10-05 09:19:43 +01:00
Gigi
343f176f06 debug: add detailed logging for highlight fetching
- Log article details (event ID, author, kind, d-tag, coordinate)
- Log filter being used for highlight queries
- Log sample highlight tags when found
- This will help debug why highlights aren't showing
2025-10-05 09:18:55 +01:00
Gigi
ee788cffb0 feat: add caching for nostr-native articles
- Add localStorage caching for kind:30023 articles (same as web articles)
- Cache TTL: 7 days
- Cache key prefix: article_cache_
- Add bypassCache parameter to fetchArticleByNaddr()
- Log cache hits and misses for debugging
- Gracefully handle storage errors

Articles are now cached locally after first fetch, making subsequent
loads instant and reducing relay queries.
2025-10-05 09:17:07 +01:00
Gigi
ca46feb80f fix: fetch highlights by article reference instead of author
- Add fetchHighlightsForArticle() to query highlights by article coordinate
- Use #a tag filter to find highlights that reference the article
- Query well-known relays for highlights even without authentication
- Extract article's d-tag and construct coordinate (kind:pubkey:identifier)
- Keep original fetchHighlights() for fetching user's own highlights
- Add detailed logging for debugging highlight fetching

This fixes the issue where no highlights were shown because we were
querying for highlights created BY the article author rather than
highlights created ABOUT the article.
2025-10-05 09:12:01 +01:00
Gigi
82ab07e606 feat: configure default article via environment variable
- Add VITE_DEFAULT_ARTICLE_NADDR env variable support
- Create .env with default article naddr
- Create .env.example for documentation
- Add vite-env.d.ts for TypeScript type support
- Fallback to hardcoded value if env var not set
- Using Vite's built-in env variable support (no dotenv needed)
2025-10-05 09:08:10 +01:00
Gigi
1f5e3f82b0 feat: load default article on startup with collapsed sidebars
- Redirect root path to default article (naddr)
- Start with both sidebars (bookmarks and highlights) collapsed
- Auto-fetch and show highlights for the article author
- No authentication required to view articles
- Highlights panel auto-expands when article loads
- Login page moved to /login route
2025-10-05 09:06:54 +01:00
Gigi
6265af74f2 docs: clarify why we extract image tag directly in BookmarkItem
Add comment explaining that we extract the image tag directly from
bookmark.tags since we don't have the full NostrEvent here. When we
do have full events (like in articleService), we use getArticleImage()
helper from applesauce-core as intended.
2025-10-05 08:24:05 +01:00
Gigi
e8f44986da feat: display article hero images in bookmark views and reader
- Add image prop to ContentPanel to display hero images
- Extract image tag from kind:30023 bookmark tags
- Display article images in Card, Large, and Compact views
- Show hero image at top of article reader view
- Add CSS styling for article-hero-image and reader-hero-image
- Article images clickable to open article in reader
- Per NIP-23: image tag contains header/preview image URL
2025-10-05 08:22:46 +01:00
Gigi
3d304dab15 fix: use bookmark pubkey for article author instead of tag lookup
- Pass pubkey along with bookmark data to handleSelectUrl
- Use bookmark.pubkey directly when constructing naddr
- More reliable article loading with correct author attribution
- Update type signatures across all components
2025-10-05 08:20:38 +01:00
Gigi
0f7a4d7877 feat: enable clicking on kind:30023 articles to open in reader
- Update handleSelectUrl to detect kind:30023 bookmarks
- Construct naddr from article event data (pubkey, d tag)
- Fetch and render articles using article service
- Update all bookmark views (Compact, Card, Large) to handle articles
- Show 'Read Article' button for kind:30023 bookmarks
- Articles load in the existing ContentPanel with full reader features
2025-10-05 08:19:50 +01:00
Gigi
d5e847e515 feat: show article titles for kind:30023 bookmarks
- Update hydrateItems to detect long-form articles (kind:30023)
- Extract and display article title using getArticleTitle helper
- Article titles now appear as bookmark content in lists
- Provides better context for bookmarked articles
2025-10-05 08:17:34 +01:00
Gigi
edd4e20e22 refactor: integrate long-form article rendering into existing reader view
- Create articleService to fetch articles by naddr
- Update Bookmarks component to detect naddr in URL params
- Articles now render in the existing ContentPanel with highlight support
- Remove standalone Article component
- Articles work seamlessly within the existing three-pane layout
- Support for article metadata (title, image, published date, summary)
2025-10-05 08:12:55 +01:00
Gigi
9b0c59b1ae 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)
2025-10-05 08:08:34 +01:00
Gigi
8faa2e2de0 chore: bump version to 0.1.6 2025-10-05 04:17:44 +01:00
Gigi
07a5826774 refactor: extract components to keep files under 210 lines
- Extract ColorPicker component from Settings
- Extract FontSelector component from Settings
- Move hexToRgb helper to colorHelpers utils
- Export HIGHLIGHT_COLORS constant from colorHelpers
- Settings.tsx now 209 lines (was 242)
- ContentPanel.tsx now 197 lines (was 204)

Keeps code DRY and improves maintainability
2025-10-05 04:17:03 +01:00
Gigi
21d6916ae3 fix: ensure highlight color CSS variable inherits from parent
Remove local --highlight-rgb declarations that were preventing color inheritance in preview
2025-10-05 04:14:11 +01:00
Gigi
482ba9b2df style: make font size and color buttons match icon button size (33px) 2025-10-05 04:13:28 +01:00
Gigi
e4b6d1a122 feat: add configurable highlight colors
- Add highlightColor setting with 6 preset colors (yellow, orange, pink, green, blue, purple)
- Implement color picker UI with square color swatches
- Use CSS variables to dynamically apply highlight colors
- Add hex to RGB conversion for color transparency support
- Update both marker and underline styles to use selected color
2025-10-05 04:12:31 +01:00
Gigi
b59a295ad3 feat: add highlight style setting (marker & underline) 2025-10-05 04:08:58 +01:00
30 changed files with 1019 additions and 117 deletions

View 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

View 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
View 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
View File

@@ -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
View File

@@ -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
View File

@@ -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",

View File

@@ -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"
},

View File

@@ -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>
)

View File

@@ -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} />
}

View File

@@ -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

View File

@@ -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>
)

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)

View 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

View File

@@ -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>

View 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

View File

@@ -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}`)

View File

@@ -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>

View 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])
}

View File

@@ -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;

View 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
}
}

View File

@@ -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
}
})
}

View File

@@ -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 []
}
}

View File

@@ -6,6 +6,7 @@ export interface ReadableContent {
title?: string
html?: string
markdown?: string
image?: string
}
interface CachedContent {

View File

@@ -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
View 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' }
]

View 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)
}
}

View File

@@ -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
View File

@@ -0,0 +1,5 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_DEFAULT_ARTICLE_NADDR: string
}