mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
139 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59b7816312 | ||
|
|
3f1066ca71 | ||
|
|
744642e2b7 | ||
|
|
fd28a6e171 | ||
|
|
0124de8318 | ||
|
|
b37aac0a33 | ||
|
|
81ef047a31 | ||
|
|
704033e6cb | ||
|
|
d59d27419e | ||
|
|
f8d3fac149 | ||
|
|
61e948f6a4 | ||
|
|
22323591c9 | ||
|
|
1b548cee3c | ||
|
|
fbb8fbdc20 | ||
|
|
1e7be50e35 | ||
|
|
1a7a8367a0 | ||
|
|
1f9dbf576c | ||
|
|
630c7ef0a4 | ||
|
|
b01293aa20 | ||
|
|
d9db10fd70 | ||
|
|
872d38c7f3 | ||
|
|
06c3c1ff20 | ||
|
|
107d6757bd | ||
|
|
89bd9f631a | ||
|
|
beeb296d3b | ||
|
|
0e992ae814 | ||
|
|
8b023af6a0 | ||
|
|
6e2f1102f7 | ||
|
|
7de8c49b01 | ||
|
|
c3aece1722 | ||
|
|
7a4cb77aa3 | ||
|
|
9065501043 | ||
|
|
c9ace72d4d | ||
|
|
be6ad79f60 | ||
|
|
0473ba71fb | ||
|
|
7e575ea617 | ||
|
|
c3a2dd5603 | ||
|
|
ad54f2aaa5 | ||
|
|
a6ea97b731 | ||
|
|
2f2e19fdf9 | ||
|
|
ce99600aa9 | ||
|
|
77bcc481b5 | ||
|
|
8bb97b3e4e | ||
|
|
2bbfa82eec | ||
|
|
cc68e67726 | ||
|
|
f3a8cf1c23 | ||
|
|
290d9303b5 | ||
|
|
0ca62c4797 | ||
|
|
1441d8d998 | ||
|
|
9252078fb7 | ||
|
|
d5ab88082f | ||
|
|
a8e48ba280 | ||
|
|
dbccb28113 | ||
|
|
b1f6ac88a6 | ||
|
|
c07797ff7c | ||
|
|
41fb51c357 | ||
|
|
5e2abfa8c7 | ||
|
|
7cf2b7d35d | ||
|
|
66f0b2bc3f | ||
|
|
647cf1caf7 | ||
|
|
d4e8e465b4 | ||
|
|
fa52d61c20 | ||
|
|
c407663c2b | ||
|
|
e931f36dee | ||
|
|
ba34e51803 | ||
|
|
c67d831efd | ||
|
|
c1dedb248d | ||
|
|
b177907eb9 | ||
|
|
518c6d9714 | ||
|
|
89b14ce5b7 | ||
|
|
5f7aab90a7 | ||
|
|
6d41d95627 | ||
|
|
9aea1f9a70 | ||
|
|
8594b733ef | ||
|
|
be42203944 | ||
|
|
c51c1810c4 | ||
|
|
6bbc5eb1fc | ||
|
|
ff5c974557 | ||
|
|
61bc64ea26 | ||
|
|
73da428cd7 | ||
|
|
ce2ccd54b3 | ||
|
|
4f8bc0c641 | ||
|
|
d6edddc572 | ||
|
|
d275cb37ab | ||
|
|
959e83699a | ||
|
|
6e0a88fbd9 | ||
|
|
ba682dde1d | ||
|
|
5e788b0026 | ||
|
|
256540bf60 | ||
|
|
e710391962 | ||
|
|
29906397db | ||
|
|
aac4adeda6 | ||
|
|
008c14c14a | ||
|
|
0798267084 | ||
|
|
6088dcc395 | ||
|
|
7425121746 | ||
|
|
7735508c77 | ||
|
|
f2422e9601 | ||
|
|
336f2b62ab | ||
|
|
d3ad08dd61 | ||
|
|
d148433fcc | ||
|
|
9638ab0b84 | ||
|
|
8d7b853e75 | ||
|
|
cdbb920a5f | ||
|
|
cc311c7dc4 | ||
|
|
d4d54b1a7c | ||
|
|
235d6e33a9 | ||
|
|
0fe1085457 | ||
|
|
65e7709c63 | ||
|
|
17b5ffd96e | ||
|
|
7f95eae405 | ||
|
|
8f1e5e1082 | ||
|
|
c536de0144 | ||
|
|
8e0970b717 | ||
|
|
560a4a6785 | ||
|
|
320e7f000a | ||
|
|
832740fb59 | ||
|
|
4aea7b899b | ||
|
|
43492a4488 | ||
|
|
1552dd85d9 | ||
|
|
0bc89889e0 | ||
|
|
7a3dd421fb | ||
|
|
4d95657bca | ||
|
|
6f28c3906c | ||
|
|
fafe378585 | ||
|
|
70b85b0cf0 | ||
|
|
2297d8ae96 | ||
|
|
343f176f06 | ||
|
|
ee788cffb0 | ||
|
|
ca46feb80f | ||
|
|
82ab07e606 | ||
|
|
1f5e3f82b0 | ||
|
|
6265af74f2 | ||
|
|
e8f44986da | ||
|
|
3d304dab15 | ||
|
|
0f7a4d7877 | ||
|
|
d5e847e515 | ||
|
|
edd4e20e22 | ||
|
|
9b0c59b1ae |
9
.cursor/rules/app-settings-and-nostr.mdc
Normal file
9
.cursor/rules/app-settings-and-nostr.mdc
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
description: when dealing with user and app settings
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
We use nostr to load/save/sync our settings.
|
||||
|
||||
- https://nostrbook.dev/kinds/30078
|
||||
- https://github.com/nostr-protocol/nips/blob/master/78.md
|
||||
@@ -3,4 +3,6 @@ description: when creating or modifying UI elements, especially related to icons
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
We use FontAwesome. If you can use a fa-icon (instead of text) use a fa-icon. Always strive to keep the UI modern, beautiful, and minimalistic. Shy away from using too many colors, borders, glow, and animations.
|
||||
We use FontAwesome. If you can use a fa-icon (instead of text) use a fa-icon. Always strive to keep the UI modern, beautiful, and minimalistic. Shy away from using too many colors, borders, glow, and animations.
|
||||
|
||||
Never write "Loading" - always show a spinner, and just a spinner.
|
||||
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
|
||||
10
.cursor/rules/web-bookmarks.mdc
Normal file
10
.cursor/rules/web-bookmarks.mdc
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
description: anything to do with "web bookmarks" aka NIP-B0
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
The app also supports web bookmarks (`kind:39701`) which are distinct from public/private bookmarks as defined in NIP-51.
|
||||
|
||||
See NIP-B0 for details:
|
||||
|
||||
- https://github.com/nostr-protocol/nips/blob/master/B0.md
|
||||
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
|
||||
212
README.md
212
README.md
@@ -1,187 +1,77 @@
|
||||
# Boris
|
||||
|
||||
A minimal nostr client for bookmark management, built with [applesauce](https://github.com/hzrd149/applesauce).
|
||||
Your reading list for the Nostr world.
|
||||
|
||||
## Features
|
||||
Boris turns your Nostr bookmarks into a calm, fast, and focused reading experience. Connect your Nostr account and you'll get a clean three‑pane reader: bookmarks on the left, the article in the middle, and highlights on the right.
|
||||
|
||||
- **Nostr Authentication**: Connect using your nostr account via browser extension
|
||||
- **Bookmark Display**: View your nostr bookmarks as per [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md)
|
||||
- **Content Classification**: Automatically detect and classify URLs (articles, videos, YouTube, images)
|
||||
- **Reader Mode**: View article content inline with readable formatting
|
||||
- **Collapsible Sidebar**: Expand/collapse bookmark list for focused reading
|
||||
- **Profile Integration**: Display user profile images using applesauce ProfileModel
|
||||
- **Relative Timestamps**: Human-friendly time display (e.g., "2 hours ago")
|
||||
- **Event Links**: Quick access to view bookmarks on search.dergigi.com
|
||||
- **Private Bookmarks**: Support for Amethyst-style hidden/encrypted bookmarks
|
||||
- **Highlights Panel**: View and manage your NIP-84 highlights in a dedicated collapsible panel
|
||||
- **Three-Pane Layout**: Bookmarks sidebar, content viewer, and highlights panel working together
|
||||
- **Minimal UI**: Clean, modern interface focused on bookmark management
|
||||
## The Vision
|
||||
|
||||
## Getting Started
|
||||
When I wrote "Purple Text, Orange Highlights" 2.5 years ago, I had a certain interface in mind that would allow the reader to curate, discover, highlight, and provide value to writers and other readers alike. Boris is my attempt to build this interface.
|
||||
|
||||
### Prerequisites
|
||||
Boris has three "levels" of highlights for each article:
|
||||
- user = yellow
|
||||
- friends = orange
|
||||
- nostrverse = purple
|
||||
|
||||
- Node.js 18+
|
||||
- npm, pnpm, or yarn
|
||||
In case it's not self-explanatory:
|
||||
- **your highlights** = highlights that the logged-in npub made
|
||||
- **friends** = highlights that your friends made, i.e. highlights of the npubs that the logged-in user follows
|
||||
- **nostrverse** = all the highlights we can find on all the relays we're connected to
|
||||
|
||||
### Installation
|
||||
The user can toggle hide/show any of these "levels".
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone <your-repo-url>
|
||||
cd boris
|
||||
```
|
||||
In addition to rendering articles from nostr and the legacy web, Boris can act as a "read it later" app, thanks to the power of nostr bookmarks.
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
# or
|
||||
pnpm install
|
||||
# or
|
||||
yarn install
|
||||
```
|
||||
If you bookmark something on nostr, Boris will show it in the bookmarks bar. If said something contains a URL, Boris will extract and render it in a distraction-free and reader-friendly way.
|
||||
|
||||
3. Start the development server:
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
yarn dev
|
||||
```
|
||||
## What Boris does
|
||||
|
||||
4. Open your browser and navigate to `http://localhost:3000`
|
||||
- Collects your saved links from Nostr and shows them as a tidy reading list
|
||||
- Opens articles in a distraction‑free reader with clear typography
|
||||
- Shows community highlights layered on the article (yours, friends, everyone)
|
||||
- Lets you collapse sidebars anytime for full‑focus reading
|
||||
- Remembers simple preferences like view mode, fonts, and highlight style
|
||||
|
||||
## Usage
|
||||
## How it works
|
||||
|
||||
1. **Connect**: Click "Connect with Nostr" to authenticate using your nostr account
|
||||
2. **View Bookmarks**: Once connected, you'll see all your nostr bookmarks in the left sidebar
|
||||
3. **View Highlights**: Your NIP-84 highlights appear in the right panel
|
||||
4. **Navigate**: Click on bookmark URLs to view content in the center panel
|
||||
5. **Collapse Panels**: Use the collapse buttons to hide/show sidebars for focused viewing
|
||||
1. Connect your Nostr account.
|
||||
- Click “Connect” and approve with your usual Nostr signer.
|
||||
2. Browse your bookmarks.
|
||||
- Your lists and items appear on the left. Pick anything to read.
|
||||
3. Read in comfort.
|
||||
- The center panel renders a readable article view with images and headings.
|
||||
4. See what people highlighted.
|
||||
- The right panel shows highlights by level:
|
||||
- Mine (your highlights)
|
||||
- Friends (people you follow)
|
||||
- Nostrverse (everyone else)
|
||||
- Each level has its own color. Click any highlight to jump to that spot.
|
||||
5. Focus when you want.
|
||||
- Collapse one or both side panels. The layout adapts without wasting space.
|
||||
|
||||
## Technical Details
|
||||
## Why people like Boris
|
||||
|
||||
- Built with React and TypeScript
|
||||
- Uses [applesauce-core](https://github.com/hzrd149/applesauce) for nostr functionality
|
||||
- Implements [NIP-51](https://github.com/nostr-protocol/nips/blob/master/51.md) for bookmark management
|
||||
- Supports both individual bookmarks and bookmark lists
|
||||
- No noise: Just your saved links and the best excerpts others found
|
||||
- Fast by default: Opens instantly in your browser
|
||||
- Portable: Works with any Nostr account; your data travels with you
|
||||
- Designed for reading: Smooth navigation and instant scroll‑to‑highlight
|
||||
|
||||
## Development
|
||||
## Tips
|
||||
|
||||
### Project Structure
|
||||
- Hover icons and counters to see what they do — most controls are discoverable.
|
||||
- Lots of highlights? Scan the right panel and click to jump between them.
|
||||
- Open Settings to switch fonts, tweak highlight styles, and change the list view.
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/
|
||||
│ ├── Login.tsx # Authentication component
|
||||
│ ├── Bookmarks.tsx # Main bookmarks view with layout
|
||||
│ ├── BookmarkList.tsx # Bookmark list sidebar
|
||||
│ ├── BookmarkItem.tsx # Individual bookmark card
|
||||
│ ├── SidebarHeader.tsx # Header bar with collapse, profile, logout
|
||||
│ ├── ContentPanel.tsx # Content viewer panel
|
||||
│ ├── HighlightsPanel.tsx # Highlights sidebar panel (NIP-84)
|
||||
│ ├── HighlightItem.tsx # Individual highlight display
|
||||
│ ├── IconButton.tsx # Reusable icon button component
|
||||
│ ├── ContentWithResolvedProfiles.tsx # Profile mention resolver
|
||||
│ ├── ResolvedMention.tsx # Nostr mention component
|
||||
│ └── kindIcon.ts # Kind-specific icon mapping
|
||||
├── services/
|
||||
│ ├── bookmarkService.ts # Main bookmark fetching orchestration
|
||||
│ ├── bookmarkProcessing.ts # Decryption and processing pipeline
|
||||
│ ├── bookmarkHelpers.ts # Shared types, guards, and utilities
|
||||
│ ├── bookmarkEvents.ts # Event type handling and deduplication
|
||||
│ ├── highlightService.ts # Highlight fetching (NIP-84)
|
||||
│ └── readerService.ts # Content extraction via reader API
|
||||
├── types/
|
||||
│ ├── bookmarks.ts # Bookmark type definitions
|
||||
│ ├── highlights.ts # Highlight type definitions (NIP-84)
|
||||
│ ├── nostr.d.ts # Nostr type augmentations
|
||||
│ └── relative-time.d.ts # relative-time package types
|
||||
├── utils/
|
||||
│ ├── bookmarkUtils.tsx # Bookmark rendering utilities
|
||||
│ └── helpers.ts # General helper functions
|
||||
├── App.tsx # Main application component
|
||||
├── main.tsx # Application entry point
|
||||
└── index.css # Global styles
|
||||
```
|
||||
## Privacy and data
|
||||
|
||||
### Private (hidden) bookmarks (Amethyst-style)
|
||||
- Boris doesn’t ask for an email or create a new account — it connects to your existing Nostr identity.
|
||||
- Your bookmarks and highlights live on Nostr. Boris reads from the network and renders everything locally in your browser.
|
||||
|
||||
We support Amethyst-style private (hidden) bookmark lists alongside public ones (NIP‑51):
|
||||
## Troubleshooting
|
||||
|
||||
- **Detection and unlock**
|
||||
- Use `Helpers.hasHiddenTags(evt)` and `Helpers.isHiddenTagsLocked(evt)` to detect hidden tags.
|
||||
- First try `Helpers.unlockHiddenTags(evt, signer)`; if that fails, try with `'nip44'`.
|
||||
- For events with encrypted `content` that aren’t recognized as supporting hidden tags (e.g. kind 30001), manually decrypt:
|
||||
- Prefer `signer.nip44.decrypt(evt.pubkey, evt.content)`, fallback to `signer.nip04.decrypt(evt.pubkey, evt.content)`.
|
||||
|
||||
- **Parsing and rendering**
|
||||
- Decrypted `content` is JSON `string[][]` (tags). Convert with `Helpers.parseBookmarkTags(hiddenTags)`.
|
||||
- Map to `IndividualBookmark[]` via our `processApplesauceBookmarks(..., isPrivate=true)` and append to the private list so they render immediately alongside public items.
|
||||
|
||||
- **Caching for downstream helpers**
|
||||
- Cache manual results on the event with `BookmarkHiddenSymbol` and also store the decrypted blob under `EncryptedContentSymbol` to aid debugging and hydration.
|
||||
|
||||
- **Structure**
|
||||
- `src/services/bookmarkService.ts`: orchestrates fetching, hydration, and assembling the final bookmark payload.
|
||||
- `src/services/bookmarkProcessing.ts`: decryption/collection pipeline (unlock, manual decrypt, parse, merge).
|
||||
- `src/services/bookmarkHelpers.ts`: shared types, guards, mapping, hydration, and symbols.
|
||||
- `src/services/bookmarkEvents.ts`: event type and de‑duplication for NIP‑51 lists/sets.
|
||||
|
||||
- **Notes**
|
||||
- We avoid `any` via narrow type guards for `nip44`/`nip04` decrypt functions.
|
||||
- Files are kept small and DRY per project rules.
|
||||
- Built on applesauce helpers (`Helpers.getPublicBookmarks`, `Helpers.getHiddenBookmarks`, etc.). See applesauce docs: https://hzrd149.github.io/applesauce/typedoc/modules.html
|
||||
|
||||
### Building for Production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
# or
|
||||
pnpm build
|
||||
# or
|
||||
yarn build
|
||||
```
|
||||
|
||||
## TODO
|
||||
|
||||
### High Priority
|
||||
- [ ] **Mobile Responsive Design**: Optimize sidebar and content panel for mobile devices
|
||||
- [ ] **Keyboard Shortcuts**: Add keyboard navigation (collapse sidebar, navigate bookmarks)
|
||||
- [ ] **Search & Filter**: Add ability to search bookmarks by title, URL, or content
|
||||
- [ ] **Error Handling**: Improve error states and retry logic for failed fetches
|
||||
- [ ] **Loading States**: Better skeleton screens and loading indicators
|
||||
|
||||
### Medium Priority
|
||||
- [ ] **Bookmark Creation**: Add ability to create new bookmarks
|
||||
- [ ] **Bookmark Editing**: Edit existing bookmark metadata and tags
|
||||
- [ ] **Bookmark Deletion**: Remove bookmarks from lists
|
||||
- [ ] **Sorting Options**: Sort by date, title, kind, or custom order
|
||||
- [ ] **Bulk Actions**: Select and perform actions on multiple bookmarks
|
||||
- [ ] **Video Embeds**: Inline YouTube and video playback for video bookmarks
|
||||
|
||||
### Nice to Have
|
||||
- [ ] **Dark/Light Mode Toggle**: User preference for color scheme
|
||||
- [ ] **Export Functionality**: Export bookmarks as JSON, CSV, or HTML
|
||||
- [ ] **Import Bookmarks**: Import from browser bookmarks or other formats
|
||||
- [ ] **Tags & Categories**: Better organization with custom tags
|
||||
- [ ] **Bookmark Collections**: Create and manage custom bookmark collections
|
||||
- [ ] **Offline Support**: Cache bookmarks for offline viewing
|
||||
- [ ] **Share Bookmarks**: Generate shareable links to bookmark lists
|
||||
- [ ] **Performance Optimization**: Virtual scrolling for large bookmark lists
|
||||
- [ ] **Browser Extension**: Quick bookmark saving from any page
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request. Make sure to:
|
||||
|
||||
- Follow the existing code style
|
||||
- Keep files under 210 lines
|
||||
- Use conventional commits
|
||||
- Run linter and type checks before submitting
|
||||
- If something looks empty, try opening another article and coming back — network data can arrive in bursts.
|
||||
- Not every article has highlights yet; they grow as the community reads.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
|
||||
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-D55Gme04.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bqz-n1DY.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.6",
|
||||
"version": "0.2.3",
|
||||
"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"
|
||||
},
|
||||
|
||||
193
src/App.tsx
193
src/App.tsx
@@ -1,77 +1,166 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { EventStoreProvider, AccountsProvider } from 'applesauce-react'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||
import { EventStoreProvider, AccountsProvider, Hooks } from 'applesauce-react'
|
||||
import { EventStore } from 'applesauce-core'
|
||||
import { AccountManager } from 'applesauce-accounts'
|
||||
import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { createAddressLoader } from 'applesauce-loaders/loaders'
|
||||
import Login from './components/Login'
|
||||
import Bookmarks from './components/Bookmarks'
|
||||
import Toast from './components/Toast'
|
||||
import { useToast } from './hooks/useToast'
|
||||
import { RELAYS } from './config/relays'
|
||||
|
||||
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
||||
'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew'
|
||||
|
||||
// AppRoutes component that has access to hooks
|
||||
function AppRoutes({
|
||||
relayPool,
|
||||
showToast
|
||||
}: {
|
||||
relayPool: RelayPool
|
||||
showToast: (message: string) => void
|
||||
}) {
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
|
||||
const handleLogout = () => {
|
||||
accountManager.setActive(undefined as never)
|
||||
localStorage.removeItem('active')
|
||||
showToast('Logged out successfully')
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route
|
||||
path="/a/:naddr"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/r/*"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
const { toastMessage, toastType, showToast, clearToast } = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize event store, account manager, and relay pool
|
||||
const store = new EventStore()
|
||||
const accounts = new AccountManager()
|
||||
const pool = new RelayPool()
|
||||
|
||||
// Define relay URLs for bookmark fetching
|
||||
const relayUrls = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.nostr.band',
|
||||
'wss://relay.dergigi.com',
|
||||
'wss://wot.dergigi.com',
|
||||
'wss://relay.snort.social',
|
||||
'wss://relay.current.fyi',
|
||||
'wss://nostr-pub.wellorder.net'
|
||||
]
|
||||
|
||||
// Create a relay group for better event deduplication and management
|
||||
// This follows the applesauce-relay documentation pattern
|
||||
// Note: We could use pool.group(relayUrls) for direct requests in the future
|
||||
pool.group(relayUrls)
|
||||
console.log('Created relay group with', relayUrls.length, 'relays')
|
||||
console.log('Relay URLs:', relayUrls)
|
||||
|
||||
// Attach address/replaceable loaders so ProfileModel can fetch profiles
|
||||
const addressLoader = createAddressLoader(pool, {
|
||||
eventStore: store,
|
||||
lookupRelays: [
|
||||
'wss://purplepag.es',
|
||||
'wss://relay.primal.net',
|
||||
'wss://relay.nostr.band'
|
||||
]
|
||||
})
|
||||
store.addressableLoader = addressLoader
|
||||
store.replaceableLoader = addressLoader
|
||||
const initializeApp = async () => {
|
||||
// Initialize event store, account manager, and relay pool
|
||||
const store = new EventStore()
|
||||
const accounts = new AccountManager()
|
||||
|
||||
// Register common account types (needed for deserialization)
|
||||
registerCommonAccountTypes(accounts)
|
||||
|
||||
// Load persisted accounts from localStorage
|
||||
try {
|
||||
const json = JSON.parse(localStorage.getItem('accounts') || '[]')
|
||||
await accounts.fromJSON(json)
|
||||
console.log('Loaded', accounts.accounts.length, 'accounts from storage')
|
||||
|
||||
// Load active account from storage
|
||||
const activeId = localStorage.getItem('active')
|
||||
if (activeId && accounts.getAccount(activeId)) {
|
||||
accounts.setActive(activeId)
|
||||
console.log('Restored active account:', activeId)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load accounts from storage:', err)
|
||||
}
|
||||
|
||||
// Subscribe to accounts changes and persist to localStorage
|
||||
const accountsSub = accounts.accounts$.subscribe(() => {
|
||||
localStorage.setItem('accounts', JSON.stringify(accounts.toJSON()))
|
||||
})
|
||||
|
||||
// Subscribe to active account changes and persist to localStorage
|
||||
const activeSub = accounts.active$.subscribe((account) => {
|
||||
if (account) {
|
||||
localStorage.setItem('active', account.id)
|
||||
} else {
|
||||
localStorage.removeItem('active')
|
||||
}
|
||||
})
|
||||
|
||||
const pool = new RelayPool()
|
||||
|
||||
// Create a relay group for better event deduplication and management
|
||||
pool.group(RELAYS)
|
||||
console.log('Created relay group with', RELAYS.length, 'relays (including local)')
|
||||
console.log('Relay URLs:', RELAYS)
|
||||
|
||||
// Attach address/replaceable loaders so ProfileModel can fetch profiles
|
||||
const addressLoader = createAddressLoader(pool, {
|
||||
eventStore: store,
|
||||
lookupRelays: RELAYS
|
||||
})
|
||||
store.addressableLoader = addressLoader
|
||||
store.replaceableLoader = addressLoader
|
||||
|
||||
setEventStore(store)
|
||||
setAccountManager(accounts)
|
||||
setRelayPool(pool)
|
||||
setEventStore(store)
|
||||
setAccountManager(accounts)
|
||||
setRelayPool(pool)
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
accountsSub.unsubscribe()
|
||||
activeSub.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
let cleanup: (() => void) | undefined
|
||||
initializeApp().then((fn) => {
|
||||
cleanup = fn
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (cleanup) cleanup()
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!eventStore || !accountManager || !relayPool) {
|
||||
return <div>Loading...</div>
|
||||
return (
|
||||
<div className="loading">
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
<AppRoutes relayPool={relayPool} showToast={showToast} />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
{toastMessage && (
|
||||
<Toast
|
||||
message={toastMessage}
|
||||
type={toastType}
|
||||
onClose={clearToast}
|
||||
/>
|
||||
)}
|
||||
</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
|
||||
}
|
||||
|
||||
@@ -24,19 +24,33 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
|
||||
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
|
||||
|
||||
// Extract URLs from bookmark content
|
||||
const extractedUrls = extractUrlsFromContent(bookmark.content)
|
||||
// For web bookmarks (kind:39701), URL is stored in the 'd' tag
|
||||
const isWebBookmark = bookmark.kind === 39701
|
||||
const webBookmarkUrl = isWebBookmark ? bookmark.tags.find(t => t[0] === 'd')?.[1] : null
|
||||
|
||||
// Extract URLs from bookmark content (for regular bookmarks)
|
||||
// For web bookmarks, ensure URL has protocol
|
||||
const extractedUrls = webBookmarkUrl
|
||||
? [webBookmarkUrl.startsWith('http') ? webBookmarkUrl : `https://${webBookmarkUrl}`]
|
||||
: extractUrlsFromContent(bookmark.content)
|
||||
const hasUrls = extractedUrls.length > 0
|
||||
const firstUrl = hasUrls ? extractedUrls[0] : null
|
||||
const firstUrlClassification = firstUrl ? classifyUrl(firstUrl) : null
|
||||
|
||||
// For kind:30023 articles, extract image and summary tags (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
|
||||
const articleSummary = isArticle ? bookmark.tags.find(t => t[0] === 'summary')?.[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 +82,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 +113,9 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow
|
||||
handleReadNow,
|
||||
articleImage,
|
||||
articleSummary
|
||||
}
|
||||
|
||||
if (viewMode === 'compact') {
|
||||
@@ -97,9 +123,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} />
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronLeft, faBookmark } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import { BookmarkItem } from './BookmarkItem'
|
||||
import { formatDate, renderParsedContent } from '../utils/bookmarkUtils'
|
||||
import SidebarHeader from './SidebarHeader'
|
||||
import IconButton from './IconButton'
|
||||
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
|
||||
@@ -17,10 +17,13 @@ interface BookmarkListProps {
|
||||
onViewModeChange: (mode: ViewMode) => void
|
||||
selectedUrl?: string
|
||||
onOpenSettings: () => void
|
||||
onRefresh?: () => void
|
||||
isRefreshing?: boolean
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
bookmarks,
|
||||
export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
bookmarks,
|
||||
onSelectUrl,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
@@ -28,8 +31,17 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
selectedUrl,
|
||||
onOpenSettings
|
||||
onOpenSettings,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
loading = false
|
||||
}) => {
|
||||
// Merge and flatten all individual bookmarks from all lists
|
||||
// Re-sort after flattening to ensure newest first across all lists
|
||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||
.filter(ib => ib.content || ib.kind === 30023 || ib.kind === 39701)
|
||||
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
|
||||
|
||||
if (isCollapsed) {
|
||||
// Check if the selected URL is in bookmarks
|
||||
const isBookmarked = selectedUrl && bookmarks.some(bookmark => {
|
||||
@@ -57,88 +69,58 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
<SidebarHeader
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onLogout={onLogout}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={onViewModeChange}
|
||||
onOpenSettings={onOpenSettings}
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
/>
|
||||
|
||||
{bookmarks.length === 0 ? (
|
||||
{loading ? (
|
||||
<div className="loading">
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
</div>
|
||||
) : allIndividualBookmarks.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No bookmarks found.</p>
|
||||
<p>Add bookmarks using your nostr client to see them here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bookmarks-list">
|
||||
{bookmarks.map((bookmark, index) => (
|
||||
<div key={`${bookmark.id}-${index}`} className="bookmark-item">
|
||||
{bookmark.bookmarkCount && (
|
||||
<p className="bookmark-count">
|
||||
{bookmark.bookmarkCount} bookmarks in{' '}
|
||||
<a
|
||||
href={`https://search.dergigi.com/e/${bookmark.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="event-link"
|
||||
>
|
||||
this list
|
||||
</a>
|
||||
:
|
||||
</p>
|
||||
)}
|
||||
{bookmark.urlReferences && bookmark.urlReferences.length > 0 && (
|
||||
<div className="bookmark-urls">
|
||||
<h4>URLs:</h4>
|
||||
{bookmark.urlReferences.map((url, index) => (
|
||||
<a key={index} href={url} target="_blank" rel="noopener noreferrer" className="bookmark-url">
|
||||
{url}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{bookmark.individualBookmarks && bookmark.individualBookmarks.length > 0 && (
|
||||
<div className="individual-bookmarks">
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
{bookmark.individualBookmarks.map((individualBookmark, index) =>
|
||||
<BookmarkItem
|
||||
key={index}
|
||||
bookmark={individualBookmark}
|
||||
index={index}
|
||||
onSelectUrl={onSelectUrl}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{bookmark.eventReferences && bookmark.eventReferences.length > 0 && bookmark.individualBookmarks?.length === 0 && (
|
||||
<div className="bookmark-events">
|
||||
<h4>Event References ({bookmark.eventReferences.length}):</h4>
|
||||
<div className="event-ids">
|
||||
{bookmark.eventReferences.slice(0, 3).map((eventId, index) => (
|
||||
<span key={index} className="event-id">
|
||||
{eventId.slice(0, 8)}...{eventId.slice(-8)}
|
||||
</span>
|
||||
))}
|
||||
{bookmark.eventReferences.length > 3 && (
|
||||
<span className="more-events">... and {bookmark.eventReferences.length - 3} more</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{bookmark.parsedContent ? (
|
||||
<div className="bookmark-content">
|
||||
{renderParsedContent(bookmark.parsedContent)}
|
||||
</div>
|
||||
) : bookmark.content && (
|
||||
<p className="bookmark-content">{bookmark.content}</p>
|
||||
)}
|
||||
<div className="bookmark-meta">
|
||||
<span>Created: {formatDate(bookmark.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
{allIndividualBookmarks.map((individualBookmark, index) =>
|
||||
<BookmarkItem
|
||||
key={`${individualBookmark.id}-${index}`}
|
||||
bookmark={individualBookmark}
|
||||
index={index}
|
||||
onSelectUrl={onSelectUrl}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="view-mode-controls">
|
||||
<IconButton
|
||||
icon={faList}
|
||||
onClick={() => onViewModeChange('compact')}
|
||||
title="Compact list view"
|
||||
ariaLabel="Compact list view"
|
||||
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faThLarge}
|
||||
onClick={() => onViewModeChange('cards')}
|
||||
title="Cards view"
|
||||
ariaLabel="Cards view"
|
||||
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faImage}
|
||||
onClick={() => onViewModeChange('large')}
|
||||
title="Large preview view"
|
||||
ariaLabel="Large preview view"
|
||||
variant={viewMode === 'large' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faBookmark, faUserLock, faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faBookmark, faUserLock, faChevronDown, faChevronUp, faGlobe } from '@fortawesome/free-solid-svg-icons'
|
||||
import { IndividualBookmark } from '../../types/bookmarks'
|
||||
import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils'
|
||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||
@@ -13,13 +13,15 @@ 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
|
||||
articleSummary?: string
|
||||
}
|
||||
|
||||
export const CardView: React.FC<CardViewProps> = ({
|
||||
@@ -33,18 +35,34 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow
|
||||
handleReadNow,
|
||||
articleImage,
|
||||
articleSummary
|
||||
}) => {
|
||||
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
|
||||
const isWebBookmark = bookmark.kind === 39701
|
||||
|
||||
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 ? (
|
||||
{isWebBookmark ? (
|
||||
<span className="fa-layers fa-fw">
|
||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
||||
<FontAwesomeIcon icon={faGlobe} className="bookmark-visibility public" transform="shrink-8 down-2" />
|
||||
</span>
|
||||
) : bookmark.isPrivate ? (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
||||
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
|
||||
@@ -106,7 +124,11 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bookmark.parsedContent ? (
|
||||
{isArticle && articleSummary ? (
|
||||
<div className="bookmark-content article-summary">
|
||||
<ContentWithResolvedProfiles content={articleSummary} />
|
||||
</div>
|
||||
) : bookmark.parsedContent ? (
|
||||
<div className="bookmark-content">
|
||||
{shouldTruncate && bookmark.content
|
||||
? <ContentWithResolvedProfiles content={`${bookmark.content.slice(0, 210).trimEnd()}…`} />
|
||||
@@ -141,11 +163,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>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faBookmark, faUserLock } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faBookmark, faUserLock, faGlobe } from '@fortawesome/free-solid-svg-icons'
|
||||
import { IndividualBookmark } from '../../types/bookmarks'
|
||||
import { formatDate } from '../../utils/bookmarkUtils'
|
||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||
@@ -11,9 +11,11 @@ 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
|
||||
articleSummary?: string
|
||||
}
|
||||
|
||||
export const CompactView: React.FC<CompactViewProps> = ({
|
||||
@@ -23,24 +25,43 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
getIconForUrlType,
|
||||
firstUrlClassification
|
||||
firstUrlClassification,
|
||||
articleSummary
|
||||
}) => {
|
||||
const isArticle = bookmark.kind === 30023
|
||||
const isWebBookmark = bookmark.kind === 39701
|
||||
const isClickable = hasUrls || isArticle || isWebBookmark
|
||||
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
// For articles, prefer summary; for others, use content
|
||||
const displayText = isArticle && articleSummary
|
||||
? articleSummary
|
||||
: bookmark.content
|
||||
|
||||
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 ? (
|
||||
{isWebBookmark ? (
|
||||
<span className="fa-layers fa-fw">
|
||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
||||
<FontAwesomeIcon icon={faGlobe} className="bookmark-visibility public" transform="shrink-8 down-2" />
|
||||
</span>
|
||||
) : bookmark.isPrivate ? (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
||||
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
|
||||
@@ -49,19 +70,26 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
||||
)}
|
||||
</span>
|
||||
{bookmark.content && (
|
||||
{displayText && (
|
||||
<div className="compact-text">
|
||||
<ContentWithResolvedProfiles content={bookmark.content.slice(0, 60) + (bookmark.content.length > 60 ? '…' : '')} />
|
||||
<ContentWithResolvedProfiles content={displayText.slice(0, 60) + (displayText.length > 60 ? '…' : '')} />
|
||||
</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
|
||||
@@ -18,6 +18,7 @@ interface LargeViewProps {
|
||||
eventNevent?: string
|
||||
getAuthorDisplayName: () => string
|
||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
articleSummary?: string
|
||||
}
|
||||
|
||||
export const LargeView: React.FC<LargeViewProps> = ({
|
||||
@@ -32,17 +33,26 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow
|
||||
handleReadNow,
|
||||
articleSummary
|
||||
}) => {
|
||||
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>
|
||||
@@ -51,7 +61,11 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
)}
|
||||
|
||||
<div className="large-content">
|
||||
{bookmark.content && (
|
||||
{isArticle && articleSummary ? (
|
||||
<div className="large-text article-summary">
|
||||
<ContentWithResolvedProfiles content={articleSummary} />
|
||||
</div>
|
||||
) : bookmark.content && (
|
||||
<div className="large-text">
|
||||
<ContentWithResolvedProfiles content={bookmark.content} />
|
||||
</div>
|
||||
@@ -80,12 +94,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 React, { useState, useEffect, useMemo } from 'react'
|
||||
import { useParams, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { useEventStore } from 'applesauce-react/hooks'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
@@ -6,13 +7,22 @@ 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 { fetchContacts } from '../services/contactService'
|
||||
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 { useExternalUrlLoader } from '../hooks/useExternalUrlLoader'
|
||||
import { loadContent, BookmarkReference } from '../utils/contentLoader'
|
||||
import { HighlightVisibility } from './HighlightsPanel'
|
||||
import { HighlightButton, HighlightButtonRef } from './HighlightButton'
|
||||
import { createHighlight, eventToHighlight } from '../services/highlightCreationService'
|
||||
import { useRef, useCallback } from 'react'
|
||||
import { NostrEvent, nip19 } from 'nostr-tools'
|
||||
export type ViewMode = 'compact' | 'cards' | 'large'
|
||||
|
||||
interface BookmarksProps {
|
||||
@@ -21,21 +31,42 @@ interface BookmarksProps {
|
||||
}
|
||||
|
||||
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
const { naddr } = useParams<{ naddr?: string }>()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Extract external URL from /r/* route
|
||||
const externalUrl = location.pathname.startsWith('/r/')
|
||||
? location.pathname.slice(3) // Remove '/r/' prefix
|
||||
: undefined
|
||||
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||
const [bookmarksLoading, setBookmarksLoading] = useState(true)
|
||||
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 [showHighlights, setShowHighlights] = 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 [currentArticle, setCurrentArticle] = useState<NostrEvent | undefined>(undefined) // Store the current article event
|
||||
const [highlightVisibility, setHighlightVisibility] = useState<HighlightVisibility>({
|
||||
nostrverse: true,
|
||||
friends: true,
|
||||
mine: true
|
||||
})
|
||||
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
const eventStore = useEventStore()
|
||||
const highlightButtonRef = useRef<HighlightButtonRef>(null)
|
||||
|
||||
const { settings, saveSettings, toastMessage, toastType, clearToast } = useSettings({
|
||||
relayPool,
|
||||
@@ -44,33 +75,103 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
accountManager
|
||||
})
|
||||
|
||||
// Load nostr-native article if naddr is in URL
|
||||
useArticleLoader({
|
||||
naddr,
|
||||
relayPool,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
setIsCollapsed,
|
||||
setHighlights,
|
||||
setHighlightsLoading,
|
||||
setCurrentArticleCoordinate,
|
||||
setCurrentArticleEventId,
|
||||
setCurrentArticle
|
||||
})
|
||||
|
||||
// Load external URL if /r/* route is used
|
||||
useExternalUrlLoader({
|
||||
url: externalUrl,
|
||||
relayPool,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
setIsCollapsed,
|
||||
setHighlights,
|
||||
setHighlightsLoading,
|
||||
setCurrentArticleCoordinate,
|
||||
setCurrentArticleEventId
|
||||
})
|
||||
|
||||
// Load initial data on login
|
||||
useEffect(() => {
|
||||
if (!relayPool || !activeAccount) return
|
||||
handleFetchBookmarks()
|
||||
handleFetchHighlights()
|
||||
// Avoid overwriting article-specific highlights during initial article load
|
||||
// If an article is being viewed (naddr present), let useArticleLoader own the first highlights set
|
||||
if (!naddr) {
|
||||
handleFetchHighlights()
|
||||
}
|
||||
handleFetchContacts()
|
||||
}, [relayPool, activeAccount?.pubkey])
|
||||
|
||||
const handleFetchContacts = async () => {
|
||||
if (!relayPool || !activeAccount) return
|
||||
const contacts = await fetchContacts(relayPool, activeAccount.pubkey)
|
||||
setFollowedPubkeys(contacts)
|
||||
}
|
||||
|
||||
// Apply UI settings
|
||||
useEffect(() => {
|
||||
if (settings.defaultViewMode) setViewMode(settings.defaultViewMode)
|
||||
if (settings.showUnderlines !== undefined) setShowUnderlines(settings.showUnderlines)
|
||||
if (settings.sidebarCollapsed !== undefined) setIsCollapsed(settings.sidebarCollapsed)
|
||||
if (settings.highlightsCollapsed !== undefined) setIsHighlightsCollapsed(settings.highlightsCollapsed)
|
||||
if (settings.showHighlights !== undefined) setShowHighlights(settings.showHighlights)
|
||||
// Apply default highlight visibility settings
|
||||
setHighlightVisibility({
|
||||
nostrverse: settings.defaultHighlightVisibilityNostrverse !== false,
|
||||
friends: settings.defaultHighlightVisibilityFriends !== false,
|
||||
mine: settings.defaultHighlightVisibilityMine !== false
|
||||
})
|
||||
// Always start with both panels collapsed on initial load
|
||||
// Don't apply saved collapse settings on initial load - let user control them
|
||||
}, [settings])
|
||||
|
||||
const handleFetchBookmarks = async () => {
|
||||
if (!relayPool || !activeAccount) return
|
||||
const fullAccount = accountManager.getActive()
|
||||
await fetchBookmarks(relayPool, fullAccount || activeAccount, setBookmarks)
|
||||
setBookmarksLoading(true)
|
||||
try {
|
||||
const fullAccount = accountManager.getActive()
|
||||
await fetchBookmarks(relayPool, fullAccount || activeAccount, setBookmarks)
|
||||
} finally {
|
||||
setBookmarksLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
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 highlightsList: Highlight[] = []
|
||||
await fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
currentArticleCoordinate,
|
||||
currentArticleEventId,
|
||||
(highlight) => {
|
||||
// Render each highlight immediately as it arrives
|
||||
highlightsList.push(highlight)
|
||||
setHighlights([...highlightsList].sort((a, b) => b.created_at - a.created_at))
|
||||
}
|
||||
)
|
||||
console.log(`🔄 Refreshed ${highlightsList.length} highlights for article`)
|
||||
}
|
||||
// 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,22 +179,124 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectUrl = async (url: string) => {
|
||||
const handleRefreshBookmarks = async () => {
|
||||
if (!relayPool || !activeAccount || isRefreshing) return
|
||||
|
||||
setIsRefreshing(true)
|
||||
try {
|
||||
await handleFetchBookmarks()
|
||||
await handleFetchHighlights()
|
||||
await handleFetchContacts()
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh bookmarks:', err)
|
||||
} finally {
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Classify highlights with levels based on user context
|
||||
const classifiedHighlights = useMemo(() => {
|
||||
return highlights.map(h => {
|
||||
let level: 'mine' | 'friends' | 'nostrverse' = 'nostrverse'
|
||||
if (h.pubkey === activeAccount?.pubkey) {
|
||||
level = 'mine'
|
||||
} else if (followedPubkeys.has(h.pubkey)) {
|
||||
level = 'friends'
|
||||
}
|
||||
return { ...h, level }
|
||||
})
|
||||
}, [highlights, activeAccount?.pubkey, followedPubkeys])
|
||||
|
||||
const handleSelectUrl = async (url: string, bookmark?: BookmarkReference) => {
|
||||
if (!relayPool) return
|
||||
|
||||
// Update the URL path based on content type
|
||||
if (bookmark && bookmark.kind === 30023) {
|
||||
// For nostr articles, navigate to /a/:naddr
|
||||
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
if (dTag && bookmark.pubkey) {
|
||||
const pointer = {
|
||||
identifier: dTag,
|
||||
kind: 30023,
|
||||
pubkey: bookmark.pubkey,
|
||||
}
|
||||
const naddr = nip19.naddrEncode(pointer)
|
||||
navigate(`/a/${naddr}`)
|
||||
}
|
||||
} else if (url) {
|
||||
// For external URLs, navigate to /r/:url
|
||||
navigate(`/r/${url}`)
|
||||
}
|
||||
|
||||
setSelectedUrl(url)
|
||||
setReaderLoading(true)
|
||||
setReaderContent(undefined)
|
||||
setCurrentArticle(undefined) // Clear previous article
|
||||
setShowSettings(false)
|
||||
if (settings.collapseOnArticleOpen !== false) setIsCollapsed(true)
|
||||
|
||||
try {
|
||||
const content = await fetchReadableContent(url)
|
||||
const content = await loadContent(url, relayPool, bookmark)
|
||||
setReaderContent(content)
|
||||
|
||||
// Note: currentArticle is set by useArticleLoader when loading Nostr articles
|
||||
// For web bookmarks, there's no article event to set
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch readable content:', err)
|
||||
console.warn('Failed to fetch content:', err)
|
||||
} finally {
|
||||
setReaderLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTextSelection = useCallback((text: string) => {
|
||||
highlightButtonRef.current?.updateSelection(text)
|
||||
}, [])
|
||||
|
||||
const handleClearSelection = useCallback(() => {
|
||||
highlightButtonRef.current?.clearSelection()
|
||||
}, [])
|
||||
|
||||
const handleCreateHighlight = useCallback(async (text: string) => {
|
||||
if (!activeAccount || !relayPool) {
|
||||
console.error('Missing requirements for highlight creation')
|
||||
return
|
||||
}
|
||||
|
||||
// Need either a nostr article or an external URL
|
||||
if (!currentArticle && !selectedUrl) {
|
||||
console.error('No source available for highlight creation')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Determine the source: prefer currentArticle (for nostr content), fallback to selectedUrl (for external URLs)
|
||||
const source = currentArticle || selectedUrl!
|
||||
|
||||
// For context extraction, use article content or reader content
|
||||
const contentForContext = currentArticle
|
||||
? currentArticle.content
|
||||
: readerContent?.markdown || readerContent?.html
|
||||
|
||||
// Create and publish the highlight
|
||||
const signedEvent = await createHighlight(
|
||||
text,
|
||||
source,
|
||||
activeAccount,
|
||||
relayPool,
|
||||
contentForContext
|
||||
)
|
||||
|
||||
console.log('✅ Highlight created successfully!')
|
||||
highlightButtonRef.current?.clearSelection()
|
||||
|
||||
// Immediately add the highlight to the UI (optimistic update)
|
||||
const newHighlight = eventToHighlight(signedEvent)
|
||||
setHighlights(prev => [newHighlight, ...prev])
|
||||
} catch (error) {
|
||||
console.error('Failed to create highlight:', error)
|
||||
}
|
||||
}, [activeAccount, relayPool, currentArticle, selectedUrl, readerContent])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`three-pane ${isCollapsed ? 'sidebar-collapsed' : ''} ${isHighlightsCollapsed ? 'highlights-collapsed' : ''}`}>
|
||||
@@ -112,6 +315,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
setIsCollapsed(true)
|
||||
setIsHighlightsCollapsed(true)
|
||||
}}
|
||||
onRefresh={handleRefreshBookmarks}
|
||||
isRefreshing={isRefreshing}
|
||||
loading={bookmarksLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="pane main">
|
||||
@@ -127,9 +333,10 @@ 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}
|
||||
highlights={classifiedHighlights}
|
||||
showHighlights={showHighlights}
|
||||
highlightStyle={settings.highlightStyle || 'marker'}
|
||||
highlightColor={settings.highlightColor || '#ffff00'}
|
||||
onHighlightClick={(id) => {
|
||||
@@ -137,6 +344,11 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
if (isHighlightsCollapsed) setIsHighlightsCollapsed(false)
|
||||
}}
|
||||
selectedHighlightId={selectedHighlightId}
|
||||
highlightVisibility={highlightVisibility}
|
||||
onTextSelection={handleTextSelection}
|
||||
onClearSelection={handleClearSelection}
|
||||
currentUserPubkey={activeAccount?.pubkey}
|
||||
followedPubkeys={followedPubkeys}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -148,13 +360,24 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
onToggleCollapse={() => setIsHighlightsCollapsed(!isHighlightsCollapsed)}
|
||||
onSelectUrl={handleSelectUrl}
|
||||
selectedUrl={selectedUrl}
|
||||
onToggleUnderlines={setShowUnderlines}
|
||||
onToggleHighlights={setShowHighlights}
|
||||
selectedHighlightId={selectedHighlightId}
|
||||
onRefresh={handleFetchHighlights}
|
||||
onHighlightClick={setSelectedHighlightId}
|
||||
currentUserPubkey={activeAccount?.pubkey}
|
||||
highlightVisibility={highlightVisibility}
|
||||
onHighlightVisibilityChange={setHighlightVisibility}
|
||||
followedPubkeys={followedPubkeys}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{activeAccount && relayPool && (
|
||||
<HighlightButton
|
||||
ref={highlightButtonRef}
|
||||
onHighlight={handleCreateHighlight}
|
||||
highlightColor={settings.highlightColor || '#ffff00'}
|
||||
/>
|
||||
)}
|
||||
{toastMessage && (
|
||||
<Toast
|
||||
message={toastMessage}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import React, { useMemo, useEffect, useRef } from 'react'
|
||||
import React, { useMemo, useEffect, useRef, useState, useCallback } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner, faHighlighter, faClock } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||
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'
|
||||
import ReaderHeader from './ReaderHeader'
|
||||
import { HighlightVisibility } from './HighlightsPanel'
|
||||
|
||||
interface ContentPanelProps {
|
||||
loading: boolean
|
||||
@@ -15,12 +17,19 @@ interface ContentPanelProps {
|
||||
html?: string
|
||||
markdown?: string
|
||||
selectedUrl?: string
|
||||
image?: string
|
||||
highlights?: Highlight[]
|
||||
showUnderlines?: boolean
|
||||
showHighlights?: boolean
|
||||
highlightStyle?: 'marker' | 'underline'
|
||||
highlightColor?: string
|
||||
onHighlightClick?: (highlightId: string) => void
|
||||
selectedHighlightId?: string
|
||||
highlightVisibility?: HighlightVisibility
|
||||
currentUserPubkey?: string
|
||||
followedPubkeys?: Set<string>
|
||||
// For highlight creation
|
||||
onTextSelection?: (text: string) => void
|
||||
onClearSelection?: () => void
|
||||
}
|
||||
|
||||
const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
@@ -29,76 +38,113 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
html,
|
||||
markdown,
|
||||
selectedUrl,
|
||||
image,
|
||||
highlights = [],
|
||||
showUnderlines = true,
|
||||
showHighlights = true,
|
||||
highlightStyle = 'marker',
|
||||
highlightColor = '#ffff00',
|
||||
onHighlightClick,
|
||||
selectedHighlightId
|
||||
selectedHighlightId,
|
||||
highlightVisibility = { nostrverse: true, friends: true, mine: true },
|
||||
currentUserPubkey,
|
||||
followedPubkeys = new Set(),
|
||||
// For highlight creation
|
||||
onTextSelection,
|
||||
onClearSelection
|
||||
}) => {
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const originalHtmlRef = useRef<string>('')
|
||||
const markdownPreviewRef = useRef<HTMLDivElement>(null)
|
||||
const [renderedHtml, setRenderedHtml] = useState<string>('')
|
||||
|
||||
// Scroll to selected highlight in article when clicked from sidebar
|
||||
useEffect(() => {
|
||||
if (!selectedHighlightId || !contentRef.current) return
|
||||
|
||||
const markElement = contentRef.current.querySelector(`mark[data-highlight-id="${selectedHighlightId}"]`)
|
||||
|
||||
if (markElement) {
|
||||
markElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
|
||||
// Add pulsing animation after scroll completes
|
||||
const htmlElement = markElement as HTMLElement
|
||||
setTimeout(() => {
|
||||
htmlElement.classList.add('highlight-pulse')
|
||||
setTimeout(() => htmlElement.classList.remove('highlight-pulse'), 1500)
|
||||
}, 500)
|
||||
}
|
||||
}, [selectedHighlightId])
|
||||
|
||||
const relevantHighlights = useMemo(() => filterHighlightsByUrl(highlights, selectedUrl), [selectedUrl, highlights])
|
||||
|
||||
// Store original HTML when content changes
|
||||
useEffect(() => {
|
||||
if (!contentRef.current) return
|
||||
// Store the fresh HTML content
|
||||
originalHtmlRef.current = contentRef.current.innerHTML
|
||||
}, [html, markdown, selectedUrl])
|
||||
|
||||
// Apply highlights after DOM is rendered
|
||||
useEffect(() => {
|
||||
// Skip if no content or underlines are hidden
|
||||
if ((!html && !markdown) || !showUnderlines) {
|
||||
// If underlines are hidden, restore original HTML
|
||||
if (!showUnderlines && contentRef.current && originalHtmlRef.current) {
|
||||
contentRef.current.innerHTML = originalHtmlRef.current
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Skip if no relevant highlights
|
||||
if (relevantHighlights.length === 0) {
|
||||
// Restore original HTML if no highlights
|
||||
if (contentRef.current && originalHtmlRef.current) {
|
||||
contentRef.current.innerHTML = originalHtmlRef.current
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Use requestAnimationFrame to ensure DOM is fully rendered
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
if (!contentRef.current || !originalHtmlRef.current) return
|
||||
|
||||
// Always apply highlights to the ORIGINAL HTML, not already-highlighted content
|
||||
const highlightedHTML = applyHighlightsToHTML(originalHtmlRef.current, relevantHighlights, highlightStyle)
|
||||
contentRef.current.innerHTML = highlightedHTML
|
||||
// Filter highlights by URL and visibility settings
|
||||
const relevantHighlights = useMemo(() => {
|
||||
console.log('🔍 ContentPanel: Processing highlights', {
|
||||
totalHighlights: highlights.length,
|
||||
selectedUrl,
|
||||
showHighlights
|
||||
})
|
||||
|
||||
return () => cancelAnimationFrame(rafId)
|
||||
}, [relevantHighlights, html, markdown, showUnderlines, highlightStyle])
|
||||
const urlFiltered = filterHighlightsByUrl(highlights, selectedUrl)
|
||||
console.log('📌 URL filtered highlights:', urlFiltered.length)
|
||||
|
||||
// Apply visibility filtering
|
||||
const filtered = urlFiltered
|
||||
.map(h => {
|
||||
// Classify highlight level
|
||||
let level: 'mine' | 'friends' | 'nostrverse' = 'nostrverse'
|
||||
if (h.pubkey === currentUserPubkey) {
|
||||
level = 'mine'
|
||||
} else if (followedPubkeys.has(h.pubkey)) {
|
||||
level = 'friends'
|
||||
}
|
||||
return { ...h, level }
|
||||
})
|
||||
.filter(h => {
|
||||
// Filter by visibility settings
|
||||
if (h.level === 'mine') return highlightVisibility.mine
|
||||
if (h.level === 'friends') return highlightVisibility.friends
|
||||
return highlightVisibility.nostrverse
|
||||
})
|
||||
|
||||
console.log('✅ Relevant highlights after filtering:', filtered.length, filtered.map(h => h.content.substring(0, 30)))
|
||||
return filtered
|
||||
}, [selectedUrl, highlights, highlightVisibility, currentUserPubkey, followedPubkeys, showHighlights])
|
||||
|
||||
// Attach click handlers separately (only when handler changes)
|
||||
// Convert markdown to HTML when markdown content changes
|
||||
useEffect(() => {
|
||||
if (!markdown) {
|
||||
setRenderedHtml('')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('📝 Converting markdown to HTML...')
|
||||
|
||||
// Use requestAnimationFrame to ensure ReactMarkdown has rendered
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
if (markdownPreviewRef.current) {
|
||||
const html = markdownPreviewRef.current.innerHTML
|
||||
console.log('✅ Markdown converted to HTML:', html.length, 'chars')
|
||||
setRenderedHtml(html)
|
||||
} else {
|
||||
console.warn('⚠️ markdownPreviewRef.current is null')
|
||||
}
|
||||
})
|
||||
|
||||
return () => cancelAnimationFrame(rafId)
|
||||
}, [markdown])
|
||||
|
||||
// Prepare the final HTML with highlights applied
|
||||
const finalHtml = useMemo(() => {
|
||||
const sourceHtml = markdown ? renderedHtml : html
|
||||
|
||||
console.log('🎨 Preparing final HTML:', {
|
||||
hasMarkdown: !!markdown,
|
||||
hasHtml: !!html,
|
||||
renderedHtmlLength: renderedHtml.length,
|
||||
sourceHtmlLength: sourceHtml?.length || 0,
|
||||
showHighlights,
|
||||
relevantHighlightsCount: relevantHighlights.length
|
||||
})
|
||||
|
||||
if (!sourceHtml) {
|
||||
console.warn('⚠️ No source HTML available')
|
||||
return ''
|
||||
}
|
||||
|
||||
// Apply highlights if we have them and highlights are enabled
|
||||
if (showHighlights && relevantHighlights.length > 0) {
|
||||
console.log('✨ Applying', relevantHighlights.length, 'highlights to HTML')
|
||||
const highlightedHtml = applyHighlightsToHTML(sourceHtml, relevantHighlights, highlightStyle)
|
||||
console.log('✅ Highlights applied, result length:', highlightedHtml.length)
|
||||
return highlightedHtml
|
||||
}
|
||||
|
||||
console.log('📄 Returning source HTML without highlights')
|
||||
return sourceHtml
|
||||
}, [html, renderedHtml, markdown, relevantHighlights, showHighlights, highlightStyle])
|
||||
|
||||
|
||||
// Attach click handlers to highlight marks
|
||||
useEffect(() => {
|
||||
if (!onHighlightClick || !contentRef.current) return
|
||||
|
||||
@@ -120,9 +166,25 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
mark.removeEventListener('click', handler)
|
||||
})
|
||||
}
|
||||
}, [onHighlightClick, relevantHighlights])
|
||||
}, [onHighlightClick, finalHtml])
|
||||
|
||||
const highlightedMarkdown = useMemo(() => markdown, [markdown])
|
||||
// Scroll to selected highlight in article when clicked from sidebar
|
||||
useEffect(() => {
|
||||
if (!selectedHighlightId || !contentRef.current) return
|
||||
|
||||
const markElement = contentRef.current.querySelector(`mark[data-highlight-id="${selectedHighlightId}"]`)
|
||||
|
||||
if (markElement) {
|
||||
markElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
|
||||
// Add pulsing animation after scroll completes
|
||||
const htmlElement = markElement as HTMLElement
|
||||
setTimeout(() => {
|
||||
htmlElement.classList.add('highlight-pulse')
|
||||
setTimeout(() => htmlElement.classList.remove('highlight-pulse'), 1500)
|
||||
}, 500)
|
||||
}
|
||||
}, [selectedHighlightId, finalHtml])
|
||||
|
||||
// Calculate reading time from content (must be before early returns)
|
||||
const readingStats = useMemo(() => {
|
||||
@@ -135,6 +197,26 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
|
||||
const hasHighlights = relevantHighlights.length > 0
|
||||
|
||||
// Handle text selection for highlight creation
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setTimeout(() => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
onClearSelection?.()
|
||||
return
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
const text = selection.toString().trim()
|
||||
|
||||
if (text.length > 0 && contentRef.current?.contains(range.commonAncestorContainer)) {
|
||||
onTextSelection?.(text)
|
||||
} else {
|
||||
onClearSelection?.()
|
||||
}
|
||||
}, 10)
|
||||
}, [onTextSelection, onClearSelection])
|
||||
|
||||
if (!selectedUrl) {
|
||||
return (
|
||||
<div className="reader empty">
|
||||
@@ -148,7 +230,6 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
<div className="reader loading">
|
||||
<div className="loading-spinner">
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
<span>Loading content…</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -158,33 +239,49 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
|
||||
return (
|
||||
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
|
||||
{title && (
|
||||
<div className="reader-header">
|
||||
<h2 className="reader-title">{title}</h2>
|
||||
<div className="reader-meta">
|
||||
{readingStats && (
|
||||
<div className="reading-time">
|
||||
<FontAwesomeIcon icon={faClock} />
|
||||
<span>{readingStats.text}</span>
|
||||
</div>
|
||||
)}
|
||||
{hasHighlights && (
|
||||
<div className="highlight-indicator">
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span>{relevantHighlights.length} highlight{relevantHighlights.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{markdown ? (
|
||||
<div ref={contentRef} className="reader-markdown">
|
||||
{/* Hidden markdown preview to convert markdown to HTML */}
|
||||
{markdown && (
|
||||
<div ref={markdownPreviewRef} style={{ display: 'none' }}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{highlightedMarkdown}
|
||||
{markdown}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
) : html ? (
|
||||
<div ref={contentRef} className="reader-html" dangerouslySetInnerHTML={{ __html: html }} />
|
||||
)}
|
||||
|
||||
<ReaderHeader
|
||||
title={title}
|
||||
image={image}
|
||||
readingTimeText={readingStats ? readingStats.text : null}
|
||||
hasHighlights={hasHighlights}
|
||||
highlightCount={relevantHighlights.length}
|
||||
/>
|
||||
{markdown || html ? (
|
||||
markdown ? (
|
||||
// For markdown, always use finalHtml once it's ready to ensure highlights are applied
|
||||
renderedHtml && finalHtml ? (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="reader-markdown"
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml }}
|
||||
onMouseUp={handleMouseUp}
|
||||
/>
|
||||
) : (
|
||||
// Show loading state while markdown is being converted to HTML
|
||||
<div className="reader-markdown">
|
||||
<div className="loading-spinner">
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
// For HTML, use finalHtml directly
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="reader-html"
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
|
||||
onMouseUp={handleMouseUp}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="reader empty">
|
||||
<p>No readable content found for this URL.</p>
|
||||
|
||||
79
src/components/HighlightButton.tsx
Normal file
79
src/components/HighlightButton.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React, { useCallback, useImperativeHandle, useRef, useState } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
interface HighlightButtonProps {
|
||||
onHighlight: (text: string) => void
|
||||
highlightColor?: string
|
||||
}
|
||||
|
||||
export interface HighlightButtonRef {
|
||||
updateSelection: (text: string) => void
|
||||
clearSelection: () => void
|
||||
}
|
||||
|
||||
export const HighlightButton = React.forwardRef<HighlightButtonRef, HighlightButtonProps>(
|
||||
({ onHighlight, highlightColor = '#ffff00' }, ref) => {
|
||||
const currentSelectionRef = useRef<string>('')
|
||||
const [hasSelection, setHasSelection] = useState(false)
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (currentSelectionRef.current) {
|
||||
onHighlight(currentSelectionRef.current)
|
||||
}
|
||||
},
|
||||
[onHighlight]
|
||||
)
|
||||
|
||||
// Expose methods to update selection
|
||||
useImperativeHandle(ref, () => ({
|
||||
updateSelection: (text: string) => {
|
||||
currentSelectionRef.current = text
|
||||
setHasSelection(!!text)
|
||||
},
|
||||
clearSelection: () => {
|
||||
currentSelectionRef.current = ''
|
||||
setHasSelection(false)
|
||||
}
|
||||
}))
|
||||
|
||||
return (
|
||||
<button
|
||||
className="highlight-fab"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '32px',
|
||||
right: '32px',
|
||||
zIndex: 1000,
|
||||
width: '56px',
|
||||
height: '56px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: highlightColor,
|
||||
color: '#000',
|
||||
border: 'none',
|
||||
boxShadow: hasSelection ? '0 4px 12px rgba(0, 0, 0, 0.3)' : 'none',
|
||||
cursor: hasSelection ? 'pointer' : 'default',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'all 0.3s ease',
|
||||
opacity: hasSelection ? 1 : 0.4,
|
||||
transform: hasSelection ? 'scale(1)' : 'scale(0.8)',
|
||||
pointerEvents: hasSelection ? 'auto' : 'none',
|
||||
userSelect: 'none'
|
||||
}}
|
||||
onClick={handleClick}
|
||||
aria-label="Create highlight from selection"
|
||||
title={hasSelection ? 'Create highlight' : ''}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} size="lg" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
HighlightButton.displayName = 'HighlightButton'
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faQuoteLeft, faLink, faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faQuoteLeft, faExternalLinkAlt } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
|
||||
interface HighlightWithLevel extends Highlight {
|
||||
level?: 'mine' | 'friends' | 'nostrverse'
|
||||
}
|
||||
|
||||
interface HighlightItemProps {
|
||||
highlight: Highlight
|
||||
highlight: HighlightWithLevel
|
||||
onSelectUrl?: (url: string) => void
|
||||
isSelected?: boolean
|
||||
onHighlightClick?: (highlightId: string) => void
|
||||
@@ -14,6 +20,16 @@ interface HighlightItemProps {
|
||||
export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelectUrl, isSelected, onHighlightClick }) => {
|
||||
const itemRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Resolve the profile of the user who made the highlight
|
||||
const profile = useEventModel(Models.ProfileModel, [highlight.pubkey])
|
||||
|
||||
// Get display name for the user
|
||||
const getUserDisplayName = () => {
|
||||
if (profile?.name) return profile.name
|
||||
if (profile?.display_name) return profile.display_name
|
||||
return `${highlight.pubkey.slice(0, 8)}...` // fallback to short pubkey
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelected && itemRef.current) {
|
||||
itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
@@ -45,7 +61,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
|
||||
return (
|
||||
<div
|
||||
ref={itemRef}
|
||||
className={`highlight-item ${isSelected ? 'selected' : ''}`}
|
||||
className={`highlight-item ${isSelected ? 'selected' : ''} ${highlight.level ? `level-${highlight.level}` : ''}`}
|
||||
data-highlight-id={highlight.id}
|
||||
onClick={handleItemClick}
|
||||
style={{ cursor: onHighlightClick ? 'pointer' : 'default' }}
|
||||
@@ -65,14 +81,12 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
|
||||
</div>
|
||||
)}
|
||||
|
||||
{highlight.context && (
|
||||
<details className="highlight-context">
|
||||
<summary>Show context</summary>
|
||||
<p className="context-text">{highlight.context}</p>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div className="highlight-meta">
|
||||
<span className="highlight-author">
|
||||
{getUserDisplayName()}
|
||||
</span>
|
||||
<span className="highlight-meta-separator">•</span>
|
||||
<span className="highlight-time">
|
||||
{formatDistanceToNow(new Date(highlight.created_at * 1000), { addSuffix: true })}
|
||||
</span>
|
||||
@@ -84,10 +98,9 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({ highlight, onSelec
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => highlight.urlReference && onSelectUrl ? handleLinkClick(highlight.urlReference, e) : undefined}
|
||||
className="highlight-source"
|
||||
title={highlight.eventReference ? 'View on Nostr' : 'View source'}
|
||||
title={highlight.eventReference ? 'Open on Nostr' : 'Open source'}
|
||||
>
|
||||
<FontAwesomeIcon icon={highlight.eventReference ? faLink : faExternalLinkAlt} />
|
||||
<span>{highlight.eventReference ? 'Nostr event' : 'Source'}</span>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronRight, faHighlighter, faEye, faEyeSlash, faRotate } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronRight, faHighlighter, faEye, faEyeSlash, faRotate, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { HighlightItem } from './HighlightItem'
|
||||
|
||||
export interface HighlightVisibility {
|
||||
nostrverse: boolean
|
||||
friends: boolean
|
||||
mine: boolean
|
||||
}
|
||||
|
||||
interface HighlightsPanelProps {
|
||||
highlights: Highlight[]
|
||||
loading: boolean
|
||||
@@ -11,10 +17,14 @@ interface HighlightsPanelProps {
|
||||
onToggleCollapse: () => void
|
||||
onSelectUrl?: (url: string) => void
|
||||
selectedUrl?: string
|
||||
onToggleUnderlines?: (show: boolean) => void
|
||||
onToggleHighlights?: (show: boolean) => void
|
||||
selectedHighlightId?: string
|
||||
onRefresh?: () => void
|
||||
onHighlightClick?: (highlightId: string) => void
|
||||
currentUserPubkey?: string
|
||||
highlightVisibility?: HighlightVisibility
|
||||
onHighlightVisibilityChange?: (visibility: HighlightVisibility) => void
|
||||
followedPubkeys?: Set<string>
|
||||
}
|
||||
|
||||
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
@@ -24,42 +34,72 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
onToggleCollapse,
|
||||
onSelectUrl,
|
||||
selectedUrl,
|
||||
onToggleUnderlines,
|
||||
onToggleHighlights,
|
||||
selectedHighlightId,
|
||||
onRefresh,
|
||||
onHighlightClick
|
||||
onHighlightClick,
|
||||
currentUserPubkey,
|
||||
highlightVisibility = { nostrverse: true, friends: true, mine: true },
|
||||
onHighlightVisibilityChange,
|
||||
followedPubkeys = new Set()
|
||||
}) => {
|
||||
const [showUnderlines, setShowUnderlines] = useState(true)
|
||||
const [showHighlights, setShowHighlights] = useState(true)
|
||||
|
||||
const handleToggleUnderlines = () => {
|
||||
const newValue = !showUnderlines
|
||||
setShowUnderlines(newValue)
|
||||
onToggleUnderlines?.(newValue)
|
||||
const handleToggleHighlights = () => {
|
||||
const newValue = !showHighlights
|
||||
setShowHighlights(newValue)
|
||||
onToggleHighlights?.(newValue)
|
||||
}
|
||||
|
||||
// Filter highlights to show only those relevant to the current URL
|
||||
// Filter highlights based on visibility levels and URL
|
||||
const filteredHighlights = useMemo(() => {
|
||||
if (!selectedUrl) return highlights
|
||||
|
||||
const normalizeUrl = (url: string) => {
|
||||
try {
|
||||
const urlObj = new URL(url.startsWith('http') ? url : `https://${url}`)
|
||||
return `${urlObj.hostname.replace(/^www\./, '')}${urlObj.pathname}`.replace(/\/$/, '').toLowerCase()
|
||||
} catch {
|
||||
return url.replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/$/, '').toLowerCase()
|
||||
let urlFiltered = highlights
|
||||
|
||||
// For Nostr articles (URL starts with "nostr:"), we don't need to filter by URL
|
||||
// because we already fetched highlights specifically for this article
|
||||
if (!selectedUrl.startsWith('nostr:')) {
|
||||
// For web URLs, filter by URL matching
|
||||
const normalizeUrl = (url: string) => {
|
||||
try {
|
||||
const urlObj = new URL(url.startsWith('http') ? url : `https://${url}`)
|
||||
return `${urlObj.hostname.replace(/^www\./, '')}${urlObj.pathname}`.replace(/\/$/, '').toLowerCase()
|
||||
} catch {
|
||||
return url.replace(/^https?:\/\//, '').replace(/^www\./, '').replace(/\/$/, '').toLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedSelected = normalizeUrl(selectedUrl)
|
||||
|
||||
urlFiltered = highlights.filter(h => {
|
||||
if (!h.urlReference) return false
|
||||
const normalizedRef = normalizeUrl(h.urlReference)
|
||||
return normalizedSelected === normalizedRef ||
|
||||
normalizedSelected.includes(normalizedRef) ||
|
||||
normalizedRef.includes(normalizedSelected)
|
||||
})
|
||||
}
|
||||
|
||||
const normalizedSelected = normalizeUrl(selectedUrl)
|
||||
|
||||
return highlights.filter(h => {
|
||||
if (!h.urlReference) return false
|
||||
const normalizedRef = normalizeUrl(h.urlReference)
|
||||
return normalizedSelected === normalizedRef ||
|
||||
normalizedSelected.includes(normalizedRef) ||
|
||||
normalizedRef.includes(normalizedSelected)
|
||||
})
|
||||
}, [highlights, selectedUrl])
|
||||
// Classify and filter by visibility levels
|
||||
return urlFiltered
|
||||
.map(h => {
|
||||
// Classify highlight level
|
||||
let level: 'mine' | 'friends' | 'nostrverse' = 'nostrverse'
|
||||
if (h.pubkey === currentUserPubkey) {
|
||||
level = 'mine'
|
||||
} else if (followedPubkeys.has(h.pubkey)) {
|
||||
level = 'friends'
|
||||
}
|
||||
return { ...h, level }
|
||||
})
|
||||
.filter(h => {
|
||||
// Filter by visibility settings
|
||||
if (h.level === 'mine') return highlightVisibility.mine
|
||||
if (h.level === 'friends') return highlightVisibility.friends
|
||||
return highlightVisibility.nostrverse
|
||||
})
|
||||
}, [highlights, selectedUrl, highlightVisibility, currentUserPubkey, followedPubkeys])
|
||||
|
||||
if (isCollapsed) {
|
||||
const hasHighlights = filteredHighlights.length > 0
|
||||
@@ -82,33 +122,72 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
return (
|
||||
<div className="highlights-container">
|
||||
<div className="highlights-header">
|
||||
<div className="highlights-title">
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<h3>Highlights</h3>
|
||||
{!loading && <span className="count">({filteredHighlights.length})</span>}
|
||||
</div>
|
||||
<div className="highlights-actions">
|
||||
{onRefresh && (
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="refresh-highlights-btn"
|
||||
title="Refresh highlights"
|
||||
aria-label="Refresh highlights"
|
||||
disabled={loading}
|
||||
>
|
||||
<FontAwesomeIcon icon={faRotate} spin={loading} />
|
||||
</button>
|
||||
)}
|
||||
{filteredHighlights.length > 0 && (
|
||||
<button
|
||||
onClick={handleToggleUnderlines}
|
||||
className="toggle-underlines-btn"
|
||||
title={showUnderlines ? 'Hide underlines' : 'Show underlines'}
|
||||
aria-label={showUnderlines ? 'Hide underlines' : 'Show underlines'}
|
||||
>
|
||||
<FontAwesomeIcon icon={showUnderlines ? faEye : faEyeSlash} />
|
||||
</button>
|
||||
)}
|
||||
<div className="highlights-actions-left">
|
||||
{onHighlightVisibilityChange && (
|
||||
<div className="highlight-level-toggles">
|
||||
<button
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
nostrverse: !highlightVisibility.nostrverse
|
||||
})}
|
||||
className={`level-toggle-btn ${highlightVisibility.nostrverse ? 'active' : ''}`}
|
||||
title="Toggle nostrverse highlights"
|
||||
aria-label="Toggle nostrverse highlights"
|
||||
style={{ color: highlightVisibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faNetworkWired} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
friends: !highlightVisibility.friends
|
||||
})}
|
||||
className={`level-toggle-btn ${highlightVisibility.friends ? 'active' : ''}`}
|
||||
title={currentUserPubkey ? "Toggle friends highlights" : "Login to see friends highlights"}
|
||||
aria-label="Toggle friends highlights"
|
||||
style={{ color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined }}
|
||||
disabled={!currentUserPubkey}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUserGroup} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
mine: !highlightVisibility.mine
|
||||
})}
|
||||
className={`level-toggle-btn ${highlightVisibility.mine ? 'active' : ''}`}
|
||||
title={currentUserPubkey ? "Toggle my highlights" : "Login to see your highlights"}
|
||||
aria-label="Toggle my highlights"
|
||||
style={{ color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined }}
|
||||
disabled={!currentUserPubkey}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
className="refresh-highlights-btn"
|
||||
title="Refresh highlights"
|
||||
aria-label="Refresh highlights"
|
||||
disabled={loading}
|
||||
>
|
||||
<FontAwesomeIcon icon={faRotate} spin={loading} />
|
||||
</button>
|
||||
)}
|
||||
{filteredHighlights.length > 0 && (
|
||||
<button
|
||||
onClick={handleToggleHighlights}
|
||||
className="toggle-highlight-display-btn"
|
||||
title={showHighlights ? 'Hide highlights' : 'Show highlights'}
|
||||
aria-label={showHighlights ? 'Hide highlights' : 'Show highlights'}
|
||||
>
|
||||
<FontAwesomeIcon icon={showHighlights ? faEye : faEyeSlash} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="toggle-highlights-btn"
|
||||
@@ -120,9 +199,9 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
{loading && filteredHighlights.length === 0 ? (
|
||||
<div className="highlights-loading">
|
||||
<p>Loading highlights...</p>
|
||||
<FontAwesomeIcon icon={faHighlighter} spin />
|
||||
</div>
|
||||
) : filteredHighlights.length === 0 ? (
|
||||
<div className="highlights-empty">
|
||||
|
||||
@@ -9,6 +9,8 @@ interface IconButtonProps {
|
||||
ariaLabel?: string
|
||||
variant?: 'primary' | 'success' | 'ghost'
|
||||
size?: number
|
||||
disabled?: boolean
|
||||
spin?: boolean
|
||||
}
|
||||
|
||||
const IconButton: React.FC<IconButtonProps> = ({
|
||||
@@ -17,7 +19,9 @@ const IconButton: React.FC<IconButtonProps> = ({
|
||||
title,
|
||||
ariaLabel,
|
||||
variant = 'ghost',
|
||||
size = 33
|
||||
size = 33,
|
||||
disabled = false,
|
||||
spin = false
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
@@ -26,8 +30,9 @@ const IconButton: React.FC<IconButtonProps> = ({
|
||||
title={title}
|
||||
aria-label={ariaLabel || title}
|
||||
style={{ width: size, height: size }}
|
||||
disabled={disabled}
|
||||
>
|
||||
<FontAwesomeIcon icon={icon} />
|
||||
<FontAwesomeIcon icon={icon} spin={spin} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { Accounts } from 'applesauce-accounts'
|
||||
|
||||
interface LoginProps {
|
||||
onLogin: () => void
|
||||
}
|
||||
|
||||
const Login: React.FC<LoginProps> = ({ onLogin }) => {
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
setIsConnecting(true)
|
||||
|
||||
// Create account from nostr extension
|
||||
const account = await Accounts.ExtensionAccount.fromExtension()
|
||||
accountManager.addAccount(account)
|
||||
accountManager.setActive(account)
|
||||
onLogin()
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error)
|
||||
alert('Login failed. Please install a nostr browser extension and try again.')
|
||||
} finally {
|
||||
setIsConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-card">
|
||||
<h2>Welcome to Boris</h2>
|
||||
<p>Connect your nostr account to view your bookmarks</p>
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
disabled={isConnecting}
|
||||
className="login-button"
|
||||
>
|
||||
{isConnecting ? 'Connecting...' : 'Connect with Nostr'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Login
|
||||
52
src/components/ReaderHeader.tsx
Normal file
52
src/components/ReaderHeader.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHighlighter, faClock } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
interface ReaderHeaderProps {
|
||||
title?: string
|
||||
image?: string
|
||||
readingTimeText?: string | null
|
||||
hasHighlights: boolean
|
||||
highlightCount: number
|
||||
}
|
||||
|
||||
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
title,
|
||||
image,
|
||||
readingTimeText,
|
||||
hasHighlights,
|
||||
highlightCount
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{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>
|
||||
<div className="reader-meta">
|
||||
{readingTimeText && (
|
||||
<div className="reading-time">
|
||||
<FontAwesomeIcon icon={faClock} />
|
||||
<span>{readingTimeText}</span>
|
||||
</div>
|
||||
)}
|
||||
{hasHighlights && (
|
||||
<div className="highlight-indicator">
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReaderHeader
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { faTimes, faList, faThLarge, faImage, faUnderline, faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faTimes, faList, faThLarge, faImage, faUnderline, faHighlighter, faUndo, faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import IconButton from './IconButton'
|
||||
import ColorPicker from './ColorPicker'
|
||||
@@ -7,6 +7,24 @@ import FontSelector from './FontSelector'
|
||||
import { loadFont, getFontFamily } from '../utils/fontLoader'
|
||||
import { hexToRgb } from '../utils/colorHelpers'
|
||||
|
||||
const DEFAULT_SETTINGS: UserSettings = {
|
||||
collapseOnArticleOpen: true,
|
||||
defaultViewMode: 'compact',
|
||||
showHighlights: true,
|
||||
sidebarCollapsed: true,
|
||||
highlightsCollapsed: true,
|
||||
readingFont: 'source-serif-4',
|
||||
fontSize: 18,
|
||||
highlightStyle: 'marker',
|
||||
highlightColor: '#ffff00',
|
||||
highlightColorNostrverse: '#9333ea',
|
||||
highlightColorFriends: '#f97316',
|
||||
highlightColorMine: '#ffff00',
|
||||
defaultHighlightVisibilityNostrverse: true,
|
||||
defaultHighlightVisibilityFriends: true,
|
||||
defaultHighlightVisibilityMine: true,
|
||||
}
|
||||
|
||||
interface SettingsProps {
|
||||
settings: UserSettings
|
||||
onSave: (settings: UserSettings) => Promise<void>
|
||||
@@ -24,14 +42,15 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
useEffect(() => {
|
||||
// Preload all fonts for the dropdown
|
||||
const fonts = ['inter', 'lora', 'merriweather', 'open-sans', 'roboto', 'source-serif-4', 'crimson-text', 'libre-baskerville', 'pt-serif']
|
||||
fonts.forEach(font => loadFont(font))
|
||||
fonts.forEach(font => {
|
||||
loadFont(font).catch(err => console.warn('Failed to preload font:', font, err))
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Load font for preview when it changes
|
||||
if (localSettings.readingFont) {
|
||||
loadFont(localSettings.readingFont)
|
||||
}
|
||||
const fontToLoad = localSettings.readingFont || 'source-serif-4'
|
||||
loadFont(fontToLoad).catch(err => console.warn('Failed to load preview font:', fontToLoad, err))
|
||||
}, [localSettings.readingFont])
|
||||
|
||||
// Auto-save settings whenever they change (except on initial mount)
|
||||
@@ -44,19 +63,34 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
onSave(localSettings)
|
||||
}, [localSettings, onSave])
|
||||
|
||||
const previewFontFamily = getFontFamily(localSettings.readingFont)
|
||||
const previewFontFamily = getFontFamily(localSettings.readingFont || 'source-serif-4')
|
||||
|
||||
const handleResetToDefaults = () => {
|
||||
if (confirm('Reset all settings to defaults?')) {
|
||||
setLocalSettings(DEFAULT_SETTINGS)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-view">
|
||||
<div className="settings-header">
|
||||
<h2>Settings</h2>
|
||||
<IconButton
|
||||
icon={faTimes}
|
||||
onClick={onClose}
|
||||
title="Close settings"
|
||||
ariaLabel="Close settings"
|
||||
variant="ghost"
|
||||
/>
|
||||
<div className="settings-header-actions">
|
||||
<IconButton
|
||||
icon={faUndo}
|
||||
onClick={handleResetToDefaults}
|
||||
title="Reset to defaults"
|
||||
ariaLabel="Reset to defaults"
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
icon={faTimes}
|
||||
onClick={onClose}
|
||||
title="Close settings"
|
||||
ariaLabel="Close settings"
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-content">
|
||||
@@ -66,7 +100,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
<div className="setting-group setting-inline">
|
||||
<label htmlFor="readingFont">Reading Font</label>
|
||||
<FontSelector
|
||||
value={localSettings.readingFont || 'system'}
|
||||
value={localSettings.readingFont || 'source-serif-4'}
|
||||
onChange={(font) => setLocalSettings({ ...localSettings, readingFont: font })}
|
||||
/>
|
||||
</div>
|
||||
@@ -78,7 +112,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
<button
|
||||
key={size}
|
||||
onClick={() => setLocalSettings({ ...localSettings, fontSize: size })}
|
||||
className={`font-size-btn ${(localSettings.fontSize || 16) === size ? 'active' : ''}`}
|
||||
className={`font-size-btn ${(localSettings.fontSize || 18) === size ? 'active' : ''}`}
|
||||
title={`${size}px`}
|
||||
style={{ fontSize: `${size - 2}px` }}
|
||||
>
|
||||
@@ -89,12 +123,12 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="showUnderlines" className="checkbox-label">
|
||||
<label htmlFor="showHighlights" className="checkbox-label">
|
||||
<input
|
||||
id="showUnderlines"
|
||||
id="showHighlights"
|
||||
type="checkbox"
|
||||
checked={localSettings.showUnderlines !== false}
|
||||
onChange={(e) => setLocalSettings({ ...localSettings, showUnderlines: e.target.checked })}
|
||||
checked={localSettings.showHighlights !== false}
|
||||
onChange={(e) => setLocalSettings({ ...localSettings, showHighlights: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Show highlights</span>
|
||||
@@ -121,12 +155,35 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Highlight Color</label>
|
||||
<ColorPicker
|
||||
selectedColor={localSettings.highlightColor || '#ffff00'}
|
||||
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColor: color })}
|
||||
/>
|
||||
<label className="setting-label">My Highlights</label>
|
||||
<div className="setting-control">
|
||||
<ColorPicker
|
||||
selectedColor={localSettings.highlightColorMine || '#ffff00'}
|
||||
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColorMine: color })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label className="setting-label">Friends Highlights</label>
|
||||
<div className="setting-control">
|
||||
<ColorPicker
|
||||
selectedColor={localSettings.highlightColorFriends || '#f97316'}
|
||||
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColorFriends: color })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label className="setting-label">Nostrverse Highlights</label>
|
||||
<div className="setting-control">
|
||||
<ColorPicker
|
||||
selectedColor={localSettings.highlightColorNostrverse || '#9333ea'}
|
||||
onColorChange={(color) => setLocalSettings({ ...localSettings, highlightColorNostrverse: color })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-preview">
|
||||
@@ -135,13 +192,15 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
className="preview-content"
|
||||
style={{
|
||||
fontFamily: previewFontFamily,
|
||||
fontSize: `${localSettings.fontSize || 16}px`,
|
||||
fontSize: `${localSettings.fontSize || 18}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-${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>
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. <span className={localSettings.showHighlights !== false ? `content-highlight-${localSettings.highlightStyle || 'marker'} level-mine` : ""}>Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</span> Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</p>
|
||||
<p>Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. <span className={localSettings.showHighlights !== false ? `content-highlight-${localSettings.highlightStyle || 'marker'} level-friends` : ""}>Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</span> Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.</p>
|
||||
<p>Totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. <span className={localSettings.showHighlights !== false ? `content-highlight-${localSettings.highlightStyle || 'marker'} level-nostrverse` : ""}>Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</span> Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit.</p>
|
||||
<p>Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -180,7 +239,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
<input
|
||||
id="sidebarCollapsed"
|
||||
type="checkbox"
|
||||
checked={localSettings.sidebarCollapsed === true}
|
||||
checked={localSettings.sidebarCollapsed !== false}
|
||||
onChange={(e) => setLocalSettings({ ...localSettings, sidebarCollapsed: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
@@ -193,13 +252,40 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose }) => {
|
||||
<input
|
||||
id="highlightsCollapsed"
|
||||
type="checkbox"
|
||||
checked={localSettings.highlightsCollapsed === true}
|
||||
checked={localSettings.highlightsCollapsed !== false}
|
||||
onChange={(e) => setLocalSettings({ ...localSettings, highlightsCollapsed: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Start with highlights panel collapsed</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Default Highlight Visibility</label>
|
||||
<div className="setting-buttons">
|
||||
<IconButton
|
||||
icon={faNetworkWired}
|
||||
onClick={() => setLocalSettings({ ...localSettings, defaultHighlightVisibilityNostrverse: !(localSettings.defaultHighlightVisibilityNostrverse !== false) })}
|
||||
title="Nostrverse highlights"
|
||||
ariaLabel="Toggle nostrverse highlights by default"
|
||||
variant={(localSettings.defaultHighlightVisibilityNostrverse !== false) ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUserGroup}
|
||||
onClick={() => setLocalSettings({ ...localSettings, defaultHighlightVisibilityFriends: !(localSettings.defaultHighlightVisibilityFriends !== false) })}
|
||||
title="Friends highlights"
|
||||
ariaLabel="Toggle friends highlights by default"
|
||||
variant={(localSettings.defaultHighlightVisibilityFriends !== false) ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUser}
|
||||
onClick={() => setLocalSettings({ ...localSettings, defaultHighlightVisibilityMine: !(localSettings.defaultHighlightVisibilityMine !== false) })}
|
||||
title="My highlights"
|
||||
ariaLabel="Toggle my highlights by default"
|
||||
variant={(localSettings.defaultHighlightVisibilityMine !== false) ? 'primary' : 'ghost'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,40 @@
|
||||
import React from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronRight, faRightFromBracket, faUser, faList, faThLarge, faImage, faGear } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faRotate } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
import { Accounts } from 'applesauce-accounts'
|
||||
import IconButton from './IconButton'
|
||||
import { ViewMode } from './Bookmarks'
|
||||
|
||||
interface SidebarHeaderProps {
|
||||
onToggleCollapse: () => void
|
||||
onLogout: () => void
|
||||
viewMode: ViewMode
|
||||
onViewModeChange: (mode: ViewMode) => void
|
||||
onOpenSettings: () => void
|
||||
onRefresh?: () => void
|
||||
isRefreshing?: boolean
|
||||
}
|
||||
|
||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, viewMode, onViewModeChange, onOpenSettings }) => {
|
||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, onRefresh, isRefreshing }) => {
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
const profile = useEventModel(Models.ProfileModel, activeAccount ? [activeAccount.pubkey] : null)
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
setIsConnecting(true)
|
||||
const account = await Accounts.ExtensionAccount.fromExtension()
|
||||
accountManager.addAccount(account)
|
||||
accountManager.setActive(account)
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error)
|
||||
alert('Login failed. Please install a nostr browser extension and try again.')
|
||||
} finally {
|
||||
setIsConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getProfileImage = () => {
|
||||
return profile?.picture || null
|
||||
}
|
||||
@@ -44,13 +60,18 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</button>
|
||||
<div className="profile-avatar" title={getUserDisplayName()}>
|
||||
{profileImage ? (
|
||||
<img src={profileImage} alt={getUserDisplayName()} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
)}
|
||||
</div>
|
||||
<div className="sidebar-header-right">
|
||||
{onRefresh && (
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
onClick={onRefresh}
|
||||
title="Refresh bookmarks"
|
||||
ariaLabel="Refresh bookmarks"
|
||||
variant="ghost"
|
||||
disabled={isRefreshing}
|
||||
spin={isRefreshing}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon={faGear}
|
||||
onClick={onOpenSettings}
|
||||
@@ -58,36 +79,36 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
ariaLabel="Settings"
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
icon={faRightFromBracket}
|
||||
onClick={onLogout}
|
||||
title="Logout"
|
||||
ariaLabel="Logout"
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
<div className="view-mode-controls">
|
||||
<IconButton
|
||||
icon={faList}
|
||||
onClick={() => onViewModeChange('compact')}
|
||||
title="Compact list view"
|
||||
ariaLabel="Compact list view"
|
||||
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faThLarge}
|
||||
onClick={() => onViewModeChange('cards')}
|
||||
title="Cards view"
|
||||
ariaLabel="Cards view"
|
||||
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faImage}
|
||||
onClick={() => onViewModeChange('large')}
|
||||
title="Large preview view"
|
||||
ariaLabel="Large preview view"
|
||||
variant={viewMode === 'large' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<div
|
||||
className="profile-avatar"
|
||||
title={activeAccount ? getUserDisplayName() : "Login"}
|
||||
onClick={!activeAccount ? (isConnecting ? () => {} : handleLogin) : undefined}
|
||||
style={{ cursor: !activeAccount ? 'pointer' : 'default' }}
|
||||
>
|
||||
{profileImage ? (
|
||||
<img src={profileImage} alt={getUserDisplayName()} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faUserCircle} />
|
||||
)}
|
||||
</div>
|
||||
{activeAccount ? (
|
||||
<IconButton
|
||||
icon={faRightFromBracket}
|
||||
onClick={onLogout}
|
||||
title="Logout"
|
||||
ariaLabel="Logout"
|
||||
variant="ghost"
|
||||
/>
|
||||
) : (
|
||||
<IconButton
|
||||
icon={faRightToBracket}
|
||||
onClick={isConnecting ? () => {} : handleLogin}
|
||||
title={isConnecting ? "Connecting..." : "Login"}
|
||||
ariaLabel="Login"
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
21
src/config/relays.ts
Normal file
21
src/config/relays.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Centralized relay configuration
|
||||
* Single set of relays used throughout the application
|
||||
*/
|
||||
|
||||
// All relays including local relay
|
||||
export const RELAYS = [
|
||||
'ws://localhost:10547',
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.nostr.band',
|
||||
'wss://relay.dergigi.com',
|
||||
'wss://wot.dergigi.com',
|
||||
'wss://relay.snort.social',
|
||||
'wss://relay.current.fyi',
|
||||
'wss://nostr-pub.wellorder.net',
|
||||
'wss://purplepag.es',
|
||||
'wss://relay.primal.net',
|
||||
'wss://proxy.nostr-relay.app/5d0d38afc49c4b84ca0da951a336affa18438efed302aeedfa92eb8b0d3fcb87'
|
||||
]
|
||||
|
||||
105
src/hooks/useArticleLoader.ts
Normal file
105
src/hooks/useArticleLoader.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
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'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
|
||||
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
|
||||
setHighlights: (highlights: Highlight[]) => void
|
||||
setHighlightsLoading: (loading: boolean) => void
|
||||
setCurrentArticleCoordinate: (coord: string | undefined) => void
|
||||
setCurrentArticleEventId: (id: string | undefined) => void
|
||||
setCurrentArticle?: (article: NostrEvent) => void
|
||||
}
|
||||
|
||||
export function useArticleLoader({
|
||||
naddr,
|
||||
relayPool,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
setIsCollapsed,
|
||||
setHighlights,
|
||||
setHighlightsLoading,
|
||||
setCurrentArticleCoordinate,
|
||||
setCurrentArticleEventId,
|
||||
setCurrentArticle
|
||||
}: UseArticleLoaderProps) {
|
||||
useEffect(() => {
|
||||
if (!relayPool || !naddr) return
|
||||
|
||||
const loadArticle = async () => {
|
||||
setReaderLoading(true)
|
||||
setReaderContent(undefined)
|
||||
setSelectedUrl(`nostr:${naddr}`)
|
||||
setIsCollapsed(true)
|
||||
// Keep highlights panel collapsed by default - only open on user interaction
|
||||
|
||||
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)
|
||||
setCurrentArticle?.(article.event)
|
||||
|
||||
console.log('📰 Article loaded:', article.title)
|
||||
console.log('📍 Coordinate:', articleCoordinate)
|
||||
|
||||
// Set reader loading to false immediately after article content is ready
|
||||
// Don't wait for highlights to finish loading
|
||||
setReaderLoading(false)
|
||||
|
||||
// Fetch highlights asynchronously without blocking article display
|
||||
// Stream them as they arrive for instant rendering
|
||||
try {
|
||||
setHighlightsLoading(true)
|
||||
setHighlights([]) // Clear old highlights
|
||||
const highlightsList: Highlight[] = []
|
||||
|
||||
await fetchHighlightsForArticle(
|
||||
relayPool,
|
||||
articleCoordinate,
|
||||
article.event.id,
|
||||
(highlight) => {
|
||||
// Render each highlight immediately as it arrives
|
||||
highlightsList.push(highlight)
|
||||
setHighlights([...highlightsList].sort((a, b) => b.created_at - a.created_at))
|
||||
}
|
||||
)
|
||||
console.log(`📌 Found ${highlightsList.length} highlights`)
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
|
||||
loadArticle()
|
||||
}, [naddr, relayPool])
|
||||
}
|
||||
85
src/hooks/useExternalUrlLoader.ts
Normal file
85
src/hooks/useExternalUrlLoader.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useEffect } from 'react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { fetchReadableContent, ReadableContent } from '../services/readerService'
|
||||
import { fetchHighlightsForUrl } from '../services/highlightService'
|
||||
import { Highlight } from '../types/highlights'
|
||||
|
||||
interface UseExternalUrlLoaderProps {
|
||||
url: string | undefined
|
||||
relayPool: RelayPool | null
|
||||
setSelectedUrl: (url: string) => void
|
||||
setReaderContent: (content: ReadableContent | undefined) => void
|
||||
setReaderLoading: (loading: boolean) => void
|
||||
setIsCollapsed: (collapsed: boolean) => void
|
||||
setHighlights: (highlights: Highlight[]) => void
|
||||
setHighlightsLoading: (loading: boolean) => void
|
||||
setCurrentArticleCoordinate: (coord: string | undefined) => void
|
||||
setCurrentArticleEventId: (id: string | undefined) => void
|
||||
}
|
||||
|
||||
export function useExternalUrlLoader({
|
||||
url,
|
||||
relayPool,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
setIsCollapsed,
|
||||
setHighlights,
|
||||
setHighlightsLoading,
|
||||
setCurrentArticleCoordinate,
|
||||
setCurrentArticleEventId
|
||||
}: UseExternalUrlLoaderProps) {
|
||||
useEffect(() => {
|
||||
if (!relayPool || !url) return
|
||||
|
||||
const loadExternalUrl = async () => {
|
||||
setReaderLoading(true)
|
||||
setReaderContent(undefined)
|
||||
setSelectedUrl(url)
|
||||
setIsCollapsed(true)
|
||||
// Clear article-specific state
|
||||
setCurrentArticleCoordinate(undefined)
|
||||
setCurrentArticleEventId(undefined)
|
||||
|
||||
try {
|
||||
const content = await fetchReadableContent(url)
|
||||
setReaderContent(content)
|
||||
|
||||
console.log('🌐 External URL loaded:', content.title)
|
||||
|
||||
// Set reader loading to false immediately after content is ready
|
||||
setReaderLoading(false)
|
||||
|
||||
// Fetch highlights for this URL asynchronously
|
||||
try {
|
||||
setHighlightsLoading(true)
|
||||
setHighlights([])
|
||||
|
||||
// Check if fetchHighlightsForUrl exists, otherwise skip
|
||||
if (typeof fetchHighlightsForUrl === 'function') {
|
||||
const highlightsList = await fetchHighlightsForUrl(relayPool, url)
|
||||
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
|
||||
console.log(`📌 Found ${highlightsList.length} highlights for URL`)
|
||||
} else {
|
||||
console.log('📌 Highlight fetching for URLs not yet implemented')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch highlights:', err)
|
||||
} finally {
|
||||
setHighlightsLoading(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load external URL:', err)
|
||||
setReaderContent({
|
||||
title: 'Error Loading Content',
|
||||
html: `<p>Failed to load content: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
|
||||
url
|
||||
})
|
||||
setReaderLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadExternalUrl()
|
||||
}, [url, relayPool])
|
||||
}
|
||||
|
||||
@@ -5,11 +5,7 @@ import { EventFactory } from 'applesauce-factory'
|
||||
import { AccountManager } from 'applesauce-accounts'
|
||||
import { UserSettings, loadSettings, saveSettings, watchSettings } from '../services/settingsService'
|
||||
import { loadFont, getFontFamily } from '../utils/fontLoader'
|
||||
|
||||
const RELAY_URLS = [
|
||||
'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.nostr.band',
|
||||
'wss://relay.dergigi.com', 'wss://wot.dergigi.com'
|
||||
]
|
||||
import { RELAYS } from '../config/relays'
|
||||
|
||||
interface UseSettingsParams {
|
||||
relayPool: RelayPool | null
|
||||
@@ -29,7 +25,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
|
||||
const loadAndWatch = async () => {
|
||||
try {
|
||||
const loadedSettings = await loadSettings(relayPool, eventStore, pubkey, RELAY_URLS)
|
||||
const loadedSettings = await loadSettings(relayPool, eventStore, pubkey, RELAYS)
|
||||
if (loadedSettings) setSettings(loadedSettings)
|
||||
} catch (err) {
|
||||
console.error('Failed to load settings:', err)
|
||||
@@ -47,11 +43,32 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
|
||||
// Apply settings to document
|
||||
useEffect(() => {
|
||||
const root = document.documentElement.style
|
||||
const fontKey = settings.readingFont || 'system'
|
||||
if (fontKey !== 'system') loadFont(fontKey)
|
||||
root.setProperty('--reading-font', getFontFamily(fontKey))
|
||||
root.setProperty('--reading-font-size', `${settings.fontSize || 16}px`)
|
||||
const applyStyles = async () => {
|
||||
const root = document.documentElement.style
|
||||
const fontKey = settings.readingFont || 'system'
|
||||
|
||||
console.log('🎨 Applying settings styles:', { fontKey, fontSize: settings.fontSize })
|
||||
|
||||
// Load font first and wait for it to be ready
|
||||
if (fontKey !== 'system') {
|
||||
console.log('⏳ Waiting for font to load...')
|
||||
await loadFont(fontKey)
|
||||
console.log('✅ Font loaded, applying styles')
|
||||
}
|
||||
|
||||
// Apply font settings after font is loaded
|
||||
root.setProperty('--reading-font', getFontFamily(fontKey))
|
||||
root.setProperty('--reading-font-size', `${settings.fontSize || 18}px`)
|
||||
|
||||
// Set highlight colors for three levels
|
||||
root.setProperty('--highlight-color-mine', settings.highlightColorMine || '#ffff00')
|
||||
root.setProperty('--highlight-color-friends', settings.highlightColorFriends || '#f97316')
|
||||
root.setProperty('--highlight-color-nostrverse', settings.highlightColorNostrverse || '#9333ea')
|
||||
|
||||
console.log('✅ All styles applied')
|
||||
}
|
||||
|
||||
applyStyles()
|
||||
}, [settings])
|
||||
|
||||
const saveSettingsWithToast = useCallback(async (newSettings: UserSettings) => {
|
||||
@@ -60,7 +77,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
const fullAccount = accountManager.getActive()
|
||||
if (!fullAccount) throw new Error('No active account')
|
||||
const factory = new EventFactory({ signer: fullAccount })
|
||||
await saveSettings(relayPool, eventStore, factory, newSettings, RELAY_URLS)
|
||||
await saveSettings(relayPool, eventStore, factory, newSettings, RELAYS)
|
||||
setSettings(newSettings)
|
||||
setToastType('success')
|
||||
setToastMessage('Settings saved')
|
||||
|
||||
25
src/hooks/useToast.ts
Normal file
25
src/hooks/useToast.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
interface ToastState {
|
||||
message: string | null
|
||||
type: 'success' | 'error'
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const [toast, setToast] = useState<ToastState>({ message: null, type: 'success' })
|
||||
|
||||
const showToast = useCallback((message: string, type: 'success' | 'error' = 'success') => {
|
||||
setToast({ message, type })
|
||||
}, [])
|
||||
|
||||
const clearToast = useCallback(() => {
|
||||
setToast({ message: null, type: 'success' })
|
||||
}, [])
|
||||
|
||||
return {
|
||||
toastMessage: toast.message,
|
||||
toastType: toast.type,
|
||||
showToast,
|
||||
clearToast
|
||||
}
|
||||
}
|
||||
506
src/index.css
506
src/index.css
@@ -13,8 +13,15 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
|
||||
--reading-font: system-ui, -apple-system, sans-serif;
|
||||
--reading-font-size: 16px;
|
||||
--reading-font: 'Source Serif 4', serif;
|
||||
--reading-font-size: 18px;
|
||||
/* Layout variables */
|
||||
--sidebar-width: 320px;
|
||||
--sidebar-collapsed-width: 64px;
|
||||
--highlights-width: 360px;
|
||||
--highlights-collapsed-width: 56px;
|
||||
--main-max-width: 900px;
|
||||
--main-horizontal-padding: 1rem;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -24,9 +31,9 @@ body {
|
||||
}
|
||||
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.app {
|
||||
@@ -49,56 +56,34 @@ body {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
/* Login Styles */
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: #1a1a1a;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #333;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-card h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.login-card p {
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
background: #646cff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.login-button:hover:not(:disabled) {
|
||||
background: #535bf2;
|
||||
}
|
||||
|
||||
.login-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Bookmarks Styles */
|
||||
.bookmarks-container {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.bookmarks-container .view-mode-controls {
|
||||
margin-top: auto;
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid #333;
|
||||
background: #1a1a1a;
|
||||
border-radius: 0 0 12px 12px;
|
||||
}
|
||||
|
||||
.bookmarks-container .bookmarks-list {
|
||||
padding: 0.25rem;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sidebar-header-bar {
|
||||
@@ -109,8 +94,15 @@ body {
|
||||
padding: 0.75rem 1rem;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: 12px 12px 0 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.sidebar-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.view-mode-controls {
|
||||
@@ -179,8 +171,8 @@ body {
|
||||
.bookmarks-container.collapsed {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
padding: 0.75rem 0 0 0;
|
||||
justify-content: flex-start;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
@@ -188,16 +180,17 @@ body {
|
||||
.bookmarks-container.collapsed .toggle-sidebar-btn {
|
||||
background: #2a2a2a;
|
||||
color: #ddd;
|
||||
border: 1px solid #444;
|
||||
border: none;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
width: 48px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bookmarks-container.collapsed .toggle-sidebar-btn:hover {
|
||||
@@ -423,7 +416,7 @@ body {
|
||||
.two-pane {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
gap: 1rem;
|
||||
column-gap: 0;
|
||||
height: calc(100vh - 4rem);
|
||||
transition: grid-template-columns 0.3s ease;
|
||||
}
|
||||
@@ -435,22 +428,22 @@ body {
|
||||
/* Three-pane layout */
|
||||
.three-pane {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr 360px;
|
||||
gap: 1rem;
|
||||
height: calc(100vh - 4rem);
|
||||
grid-template-columns: var(--sidebar-width) 1fr var(--highlights-width);
|
||||
column-gap: 0;
|
||||
height: calc(100vh - 2rem);
|
||||
transition: grid-template-columns 0.3s ease;
|
||||
}
|
||||
|
||||
.three-pane.sidebar-collapsed {
|
||||
grid-template-columns: 60px 1fr 360px;
|
||||
grid-template-columns: var(--sidebar-collapsed-width) 1fr var(--highlights-width);
|
||||
}
|
||||
|
||||
.three-pane.highlights-collapsed {
|
||||
grid-template-columns: 360px 1fr 60px;
|
||||
grid-template-columns: var(--sidebar-width) 1fr var(--highlights-collapsed-width);
|
||||
}
|
||||
|
||||
.three-pane.sidebar-collapsed.highlights-collapsed {
|
||||
grid-template-columns: 60px 1fr 60px;
|
||||
grid-template-columns: var(--sidebar-collapsed-width) 1fr var(--highlights-collapsed-width);
|
||||
}
|
||||
|
||||
.pane.sidebar {
|
||||
@@ -461,9 +454,20 @@ body {
|
||||
.pane.main {
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
max-width: 900px;
|
||||
max-width: var(--main-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
padding: 0 var(--main-horizontal-padding);
|
||||
overflow-x: hidden;
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
/* Remove padding when sidebar is collapsed for zero gap */
|
||||
.three-pane.sidebar-collapsed .pane.main {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.three-pane.sidebar-collapsed.highlights-collapsed .pane.main {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.pane.highlights {
|
||||
@@ -474,9 +478,11 @@ body {
|
||||
.reader {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.reader.empty {
|
||||
@@ -699,6 +705,8 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.bookmarks-grid.bookmarks-compact {
|
||||
@@ -711,7 +719,7 @@ body {
|
||||
|
||||
.individual-bookmark {
|
||||
background: #2a2a2a;
|
||||
padding: 1.25rem;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid #333;
|
||||
@@ -728,11 +736,13 @@ body {
|
||||
|
||||
/* Compact view styles */
|
||||
.individual-bookmark.compact {
|
||||
padding: 0.4rem 0.75rem;
|
||||
padding: 0.3rem 0.25rem;
|
||||
background: transparent;
|
||||
border-bottom: 1px solid #333;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.individual-bookmark.compact:hover {
|
||||
@@ -746,6 +756,9 @@ body {
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
height: 28px;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.compact-row.clickable {
|
||||
@@ -766,7 +779,7 @@ body {
|
||||
}
|
||||
|
||||
.compact-text {
|
||||
flex: 1;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
color: #ccc;
|
||||
font-size: 0.85rem;
|
||||
@@ -774,6 +787,7 @@ body {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.bookmark-date-compact {
|
||||
@@ -784,8 +798,8 @@ body {
|
||||
}
|
||||
|
||||
.compact-read-btn {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
background: transparent;
|
||||
color: #888;
|
||||
border: none;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
@@ -797,11 +811,12 @@ body {
|
||||
width: 26px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.2s ease;
|
||||
margin-left: auto;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.compact-read-btn:hover {
|
||||
background: #218838;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.compact-read-btn:active {
|
||||
@@ -1023,6 +1038,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;
|
||||
@@ -1043,7 +1100,6 @@ body {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.login-card,
|
||||
.bookmark-item {
|
||||
background: #f9f9f9;
|
||||
border-color: #ddd;
|
||||
@@ -1129,13 +1185,14 @@ body {
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.highlights-container.collapsed {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
padding: 0.75rem 0 0 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
@@ -1143,9 +1200,9 @@ body {
|
||||
.highlights-container.collapsed .toggle-highlights-btn {
|
||||
background: #2a2a2a;
|
||||
color: #ddd;
|
||||
border: 1px solid #444;
|
||||
border: none;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
@@ -1191,10 +1248,23 @@ body {
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid #333;
|
||||
background: #1e1e1e;
|
||||
background: #1a1a1a;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
.highlights-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.highlights-actions-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.highlights-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1218,8 +1288,79 @@ body {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.highlight-mode-toggle {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.highlight-mode-toggle .mode-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.highlight-mode-toggle .mode-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.highlight-mode-toggle .mode-btn.active {
|
||||
background: #646cff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Three-level highlight toggles */
|
||||
.highlight-level-toggles {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.highlight-level-toggles .level-toggle-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.highlight-level-toggles .level-toggle-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.highlight-level-toggles .level-toggle-btn.active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.highlight-level-toggles .level-toggle-btn:not(.active) {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.highlight-level-toggles .level-toggle-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.highlight-level-toggles .level-toggle-btn:disabled:hover {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.refresh-highlights-btn,
|
||||
.toggle-underlines-btn,
|
||||
.toggle-highlight-display-btn,
|
||||
.toggle-highlights-btn {
|
||||
background: transparent;
|
||||
color: #ddd;
|
||||
@@ -1236,14 +1377,14 @@ body {
|
||||
}
|
||||
|
||||
.refresh-highlights-btn:hover,
|
||||
.toggle-underlines-btn:hover,
|
||||
.toggle-highlight-display-btn:hover,
|
||||
.toggle-highlights-btn:hover {
|
||||
background: #2a2a2a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.refresh-highlights-btn:active,
|
||||
.toggle-underlines-btn:active,
|
||||
.toggle-highlight-display-btn:active,
|
||||
.toggle-highlights-btn:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
@@ -1309,6 +1450,22 @@ body {
|
||||
box-shadow: 0 0 0 2px rgba(100, 108, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Level colors in sidebar items */
|
||||
.highlight-item.level-mine {
|
||||
border-color: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 60%, #333);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 25%, transparent);
|
||||
}
|
||||
|
||||
.highlight-item.level-friends {
|
||||
border-color: color-mix(in srgb, var(--highlight-color-friends, #f97316) 60%, #333);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-friends, #f97316) 25%, transparent);
|
||||
}
|
||||
|
||||
.highlight-item.level-nostrverse {
|
||||
border-color: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 60%, #333);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 25%, transparent);
|
||||
}
|
||||
|
||||
.highlight-quote-icon {
|
||||
color: #646cff;
|
||||
font-size: 1.2rem;
|
||||
@@ -1316,6 +1473,19 @@ body {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Level-colored quote icon */
|
||||
.highlight-item.level-mine .highlight-quote-icon {
|
||||
color: var(--highlight-color-mine, #ffff00);
|
||||
}
|
||||
|
||||
.highlight-item.level-friends .highlight-quote-icon {
|
||||
color: var(--highlight-color-friends, #f97316);
|
||||
}
|
||||
|
||||
.highlight-item.level-nostrverse .highlight-quote-icon {
|
||||
color: var(--highlight-color-nostrverse, #9333ea);
|
||||
}
|
||||
|
||||
.highlight-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -1344,41 +1514,25 @@ body {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.highlight-context {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.highlight-context summary {
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: #888;
|
||||
user-select: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.highlight-context summary:hover {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.context-text {
|
||||
margin: 0.5rem 0 0 0;
|
||||
padding: 0.75rem;
|
||||
background: #252525;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
color: #aaa;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.highlight-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #888;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.highlight-author {
|
||||
color: #aaa;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.highlight-meta-separator {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.highlight-time {
|
||||
color: #888;
|
||||
}
|
||||
@@ -1411,6 +1565,7 @@ body {
|
||||
position: relative;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 8px rgba(var(--highlight-rgb, 255, 255, 0), 0.2);
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.content-highlight:hover,
|
||||
@@ -1430,6 +1585,7 @@ body {
|
||||
text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 0.8);
|
||||
text-decoration-thickness: 2px;
|
||||
text-underline-offset: 2px;
|
||||
contain: layout style;
|
||||
}
|
||||
|
||||
.content-highlight-underline:hover {
|
||||
@@ -1478,6 +1634,68 @@ body {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Three-level highlight colors */
|
||||
.content-highlight-marker.level-mine,
|
||||
.content-highlight.level-mine {
|
||||
background: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 35%, transparent);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 20%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-marker.level-mine:hover,
|
||||
.content-highlight.level-mine:hover {
|
||||
background: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 50%, transparent);
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 30%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-marker.level-friends,
|
||||
.content-highlight.level-friends {
|
||||
background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 35%, transparent);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--highlight-color-friends, #f97316) 20%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-marker.level-friends:hover,
|
||||
.content-highlight.level-friends:hover {
|
||||
background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 50%, transparent);
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--highlight-color-friends, #f97316) 30%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-marker.level-nostrverse,
|
||||
.content-highlight.level-nostrverse {
|
||||
background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 35%, transparent);
|
||||
box-shadow: 0 0 8px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 20%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-marker.level-nostrverse:hover,
|
||||
.content-highlight.level-nostrverse:hover {
|
||||
background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 50%, transparent);
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Underline styles for three levels */
|
||||
.content-highlight-underline.level-mine {
|
||||
text-decoration-color: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 80%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-underline.level-mine:hover {
|
||||
text-decoration-color: var(--highlight-color-mine, #ffff00);
|
||||
}
|
||||
|
||||
.content-highlight-underline.level-friends {
|
||||
text-decoration-color: color-mix(in srgb, var(--highlight-color-friends, #f97316) 80%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-underline.level-friends:hover {
|
||||
text-decoration-color: var(--highlight-color-friends, #f97316);
|
||||
}
|
||||
|
||||
.content-highlight-underline.level-nostrverse {
|
||||
text-decoration-color: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 80%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-underline.level-nostrverse:hover {
|
||||
text-decoration-color: var(--highlight-color-nostrverse, #9333ea);
|
||||
}
|
||||
|
||||
/* Ensure highlights work in both light and dark mode */
|
||||
@media (prefers-color-scheme: light) {
|
||||
.content-highlight,
|
||||
@@ -1500,6 +1718,55 @@ body {
|
||||
text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 1);
|
||||
}
|
||||
|
||||
/* Three-level overrides for light mode */
|
||||
.content-highlight-marker.level-mine,
|
||||
.content-highlight.level-mine {
|
||||
background: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 40%, transparent);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 15%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-marker.level-mine:hover,
|
||||
.content-highlight.level-mine:hover {
|
||||
background: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 55%, transparent);
|
||||
box-shadow: 0 0 10px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 25%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-marker.level-friends,
|
||||
.content-highlight.level-friends {
|
||||
background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 40%, transparent);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--highlight-color-friends, #f97316) 15%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-marker.level-friends:hover,
|
||||
.content-highlight.level-friends:hover {
|
||||
background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 55%, transparent);
|
||||
box-shadow: 0 0 10px color-mix(in srgb, var(--highlight-color-friends, #f97316) 25%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-marker.level-nostrverse,
|
||||
.content-highlight.level-nostrverse {
|
||||
background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 40%, transparent);
|
||||
box-shadow: 0 0 6px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 15%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-marker.level-nostrverse:hover,
|
||||
.content-highlight.level-nostrverse:hover {
|
||||
background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 55%, transparent);
|
||||
box-shadow: 0 0 10px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 25%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-underline.level-mine {
|
||||
text-decoration-color: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 90%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-underline.level-friends {
|
||||
text-decoration-color: color-mix(in srgb, var(--highlight-color-friends, #f97316) 90%, transparent);
|
||||
}
|
||||
|
||||
.content-highlight-underline.level-nostrverse {
|
||||
text-decoration-color: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 90%, transparent);
|
||||
}
|
||||
|
||||
.highlight-indicator {
|
||||
background: rgba(100, 108, 255, 0.15);
|
||||
border-color: rgba(100, 108, 255, 0.4);
|
||||
@@ -1531,6 +1798,12 @@ body {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.settings-header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
@@ -1569,6 +1842,17 @@ body {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
text-align: left;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.setting-control {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.setting-group.setting-inline label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
161
src/services/articleService.ts
Normal file
161
src/services/articleService.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
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'
|
||||
import { RELAYS } from '../config/relays'
|
||||
|
||||
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 - prefer relays from naddr, fallback to configured relays (including local)
|
||||
const relays = pointer.relays && pointer.relays.length > 0
|
||||
? pointer.relays
|
||||
: RELAYS
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@ export function dedupeNip51Events(events: NostrEvent[]): NostrEvent[] {
|
||||
}
|
||||
const unique = Array.from(byId.values())
|
||||
|
||||
// Separate web bookmarks (kind:39701) from list-based bookmarks
|
||||
const webBookmarks = unique.filter(e => e.kind === 39701)
|
||||
|
||||
const bookmarkLists = unique
|
||||
.filter(e => e.kind === 10003 || e.kind === 30001)
|
||||
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
|
||||
@@ -33,6 +36,8 @@ export function dedupeNip51Events(events: NostrEvent[]): NostrEvent[] {
|
||||
const out: NostrEvent[] = []
|
||||
if (latestBookmarkList) out.push(latestBookmarkList)
|
||||
out.push(...setsAndNamedLists)
|
||||
// Add web bookmarks as individual events
|
||||
out.push(...webBookmarks)
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -33,6 +33,23 @@ export async function collectBookmarksFromEvents(
|
||||
if (!latestContent && evt.content && !Helpers.hasHiddenContent(evt)) latestContent = evt.content
|
||||
if (Array.isArray(evt.tags)) allTags = allTags.concat(evt.tags)
|
||||
|
||||
// Handle web bookmarks (kind:39701) as individual bookmarks
|
||||
if (evt.kind === 39701) {
|
||||
publicItemsAll.push({
|
||||
id: evt.id,
|
||||
content: evt.content || '',
|
||||
created_at: evt.created_at || Math.floor(Date.now() / 1000),
|
||||
pubkey: evt.pubkey,
|
||||
kind: evt.kind,
|
||||
tags: evt.tags || [],
|
||||
parsedContent: undefined,
|
||||
type: 'web' as const,
|
||||
isPrivate: false,
|
||||
added_at: evt.created_at || Math.floor(Date.now() / 1000)
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const pub = Helpers.getPublicBookmarks(evt)
|
||||
publicItemsAll.push(...processApplesauceBookmarks(pub, activeAccount, false))
|
||||
|
||||
@@ -80,9 +97,7 @@ export async function collectBookmarksFromEvents(
|
||||
privateItemsAll.push(...processApplesauceBookmarks(manualPrivate, activeAccount, true))
|
||||
Reflect.set(evt, BookmarkHiddenSymbol, manualPrivate)
|
||||
Reflect.set(evt, 'EncryptedContentSymbol', decryptedContent)
|
||||
if (!latestContent) {
|
||||
latestContent = decryptedContent
|
||||
}
|
||||
// Don't set latestContent to decrypted JSON - it's not user-facing content
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
@@ -29,11 +29,11 @@ export const fetchBookmarks = async (
|
||||
}
|
||||
// Get relay URLs from the pool
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
// Fetch bookmark events - NIP-51 standards and legacy formats
|
||||
// Fetch bookmark events - NIP-51 standards, legacy formats, and web bookmarks (NIP-B0)
|
||||
console.log('🔍 Fetching bookmark events from relays:', relayUrls)
|
||||
const rawEvents = await lastValueFrom(
|
||||
relayPool
|
||||
.req(relayUrls, { kinds: [10003, 30003, 30001], authors: [activeAccount.pubkey] })
|
||||
.req(relayUrls, { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] })
|
||||
.pipe(completeOnEose(), takeUntil(timer(20000)), toArray())
|
||||
)
|
||||
console.log('📊 Raw events fetched:', rawEvents.length, 'events')
|
||||
|
||||
50
src/services/contactService.ts
Normal file
50
src/services/contactService.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { RelayPool, completeOnEose } from 'applesauce-relay'
|
||||
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
||||
|
||||
/**
|
||||
* Fetches the contact list (follows) for a specific user
|
||||
* @param relayPool - The relay pool to query
|
||||
* @param pubkey - The user's public key
|
||||
* @returns Set of pubkeys that the user follows
|
||||
*/
|
||||
export const fetchContacts = async (
|
||||
relayPool: RelayPool,
|
||||
pubkey: string
|
||||
): Promise<Set<string>> => {
|
||||
try {
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
|
||||
console.log('🔍 Fetching contacts (kind 3) for user:', pubkey)
|
||||
|
||||
const events = await lastValueFrom(
|
||||
relayPool
|
||||
.req(relayUrls, { kinds: [3], authors: [pubkey] })
|
||||
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
|
||||
)
|
||||
|
||||
console.log('📊 Contact events fetched:', events.length)
|
||||
|
||||
if (events.length === 0) {
|
||||
return new Set()
|
||||
}
|
||||
|
||||
// Get the most recent contact list
|
||||
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
|
||||
const contactList = sortedEvents[0]
|
||||
|
||||
// Extract pubkeys from 'p' tags
|
||||
const followedPubkeys = new Set<string>()
|
||||
for (const tag of contactList.tags) {
|
||||
if (tag[0] === 'p' && tag[1]) {
|
||||
followedPubkeys.add(tag[1])
|
||||
}
|
||||
}
|
||||
|
||||
console.log('👥 Followed contacts:', followedPubkeys.size)
|
||||
|
||||
return followedPubkeys
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch contacts:', error)
|
||||
return new Set()
|
||||
}
|
||||
}
|
||||
205
src/services/highlightCreationService.ts
Normal file
205
src/services/highlightCreationService.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { EventFactory } from 'applesauce-factory'
|
||||
import { HighlightBlueprint } from 'applesauce-factory/blueprints'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { AddressPointer } from 'nostr-tools/nip19'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import {
|
||||
getHighlightText,
|
||||
getHighlightContext,
|
||||
getHighlightComment,
|
||||
getHighlightSourceEventPointer,
|
||||
getHighlightSourceAddressPointer,
|
||||
getHighlightSourceUrl,
|
||||
getHighlightAttributions
|
||||
} from 'applesauce-core/helpers'
|
||||
|
||||
/**
|
||||
* Creates and publishes a highlight event (NIP-84)
|
||||
* Supports both nostr-native articles and external URLs
|
||||
* Returns the signed event for immediate UI updates
|
||||
*/
|
||||
export async function createHighlight(
|
||||
selectedText: string,
|
||||
source: NostrEvent | string,
|
||||
account: IAccount,
|
||||
relayPool: RelayPool,
|
||||
contentForContext?: string,
|
||||
comment?: string
|
||||
): Promise<NostrEvent> {
|
||||
if (!selectedText || !source) {
|
||||
throw new Error('Missing required data to create highlight')
|
||||
}
|
||||
|
||||
// Create EventFactory with the account as signer
|
||||
const factory = new EventFactory({ signer: account })
|
||||
|
||||
let blueprintSource: NostrEvent | AddressPointer | string
|
||||
let context: string | undefined
|
||||
|
||||
// Handle NostrEvent (article) source
|
||||
if (typeof source === 'object' && 'kind' in source) {
|
||||
blueprintSource = parseArticleCoordinate(source)
|
||||
context = extractContext(selectedText, source.content)
|
||||
}
|
||||
// Handle URL string source
|
||||
else {
|
||||
blueprintSource = source
|
||||
// Try to extract context from provided content if available
|
||||
if (contentForContext) {
|
||||
context = extractContext(selectedText, contentForContext)
|
||||
}
|
||||
}
|
||||
|
||||
// Create highlight event using the blueprint
|
||||
const highlightEvent = await factory.create(
|
||||
HighlightBlueprint,
|
||||
selectedText,
|
||||
blueprintSource,
|
||||
context ? { comment, context } : comment ? { comment } : undefined
|
||||
)
|
||||
|
||||
// Update the alt tag to identify Boris as the creator
|
||||
const altTagIndex = highlightEvent.tags.findIndex(tag => tag[0] === 'alt')
|
||||
if (altTagIndex !== -1) {
|
||||
highlightEvent.tags[altTagIndex] = ['alt', 'Highlight created by Boris. readwithboris.com']
|
||||
} else {
|
||||
highlightEvent.tags.push(['alt', 'Highlight created by Boris. readwithboris.com'])
|
||||
}
|
||||
|
||||
// Sign the event
|
||||
const signedEvent = await factory.sign(highlightEvent)
|
||||
|
||||
// Publish to relays (including local relay)
|
||||
await relayPool.publish(RELAYS, signedEvent)
|
||||
|
||||
console.log('✅ Highlight published to', RELAYS.length, 'relays (including local):', signedEvent)
|
||||
|
||||
// Return the signed event for immediate UI updates
|
||||
return signedEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse article coordinate to create address pointer
|
||||
*/
|
||||
function parseArticleCoordinate(article: NostrEvent): AddressPointer {
|
||||
// Try to get identifier from article tags
|
||||
const identifier = article.tags.find(tag => tag[0] === 'd')?.[1] || ''
|
||||
|
||||
return {
|
||||
kind: article.kind,
|
||||
pubkey: article.pubkey,
|
||||
identifier,
|
||||
relays: [] // Optional relays hint
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts context for a highlight by finding the previous and next sentences
|
||||
* in the same paragraph as the selected text
|
||||
*/
|
||||
function extractContext(selectedText: string, articleContent: string): string | undefined {
|
||||
if (!selectedText || !articleContent) return undefined
|
||||
|
||||
// Find the position of the selected text in the article
|
||||
const selectedIndex = articleContent.indexOf(selectedText)
|
||||
if (selectedIndex === -1) return undefined
|
||||
|
||||
// Split content into paragraphs (by double newlines or single newlines)
|
||||
const paragraphs = articleContent.split(/\n\n+/)
|
||||
|
||||
// Find which paragraph contains the selected text
|
||||
let currentPos = 0
|
||||
let containingParagraph: string | undefined
|
||||
|
||||
for (const paragraph of paragraphs) {
|
||||
const paragraphEnd = currentPos + paragraph.length
|
||||
if (selectedIndex >= currentPos && selectedIndex < paragraphEnd) {
|
||||
containingParagraph = paragraph
|
||||
break
|
||||
}
|
||||
currentPos = paragraphEnd + 2 // Account for the double newline
|
||||
}
|
||||
|
||||
if (!containingParagraph) return undefined
|
||||
|
||||
// Split paragraph into sentences (basic sentence splitting)
|
||||
// This regex splits on periods, exclamation marks, or question marks followed by space or end of string
|
||||
const sentences = containingParagraph.split(/([.!?]+\s+)/).filter(s => s.trim().length > 0)
|
||||
|
||||
// Reconstruct sentences properly by joining sentence text with punctuation
|
||||
const reconstructedSentences: string[] = []
|
||||
for (let i = 0; i < sentences.length; i++) {
|
||||
if (sentences[i].match(/^[.!?]+\s*$/)) {
|
||||
// This is punctuation, attach it to previous sentence
|
||||
if (reconstructedSentences.length > 0) {
|
||||
reconstructedSentences[reconstructedSentences.length - 1] += sentences[i]
|
||||
}
|
||||
} else {
|
||||
reconstructedSentences.push(sentences[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Find which sentence contains the selected text
|
||||
let selectedSentenceIndex = -1
|
||||
for (let i = 0; i < reconstructedSentences.length; i++) {
|
||||
if (reconstructedSentences[i].includes(selectedText)) {
|
||||
selectedSentenceIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedSentenceIndex === -1) return undefined
|
||||
|
||||
// Build context from previous and next sentences
|
||||
const contextParts: string[] = []
|
||||
|
||||
// Add previous sentence if it exists
|
||||
if (selectedSentenceIndex > 0) {
|
||||
contextParts.push(reconstructedSentences[selectedSentenceIndex - 1].trim())
|
||||
}
|
||||
|
||||
// Add the selected sentence itself
|
||||
contextParts.push(reconstructedSentences[selectedSentenceIndex].trim())
|
||||
|
||||
// Add next sentence if it exists
|
||||
if (selectedSentenceIndex < reconstructedSentences.length - 1) {
|
||||
contextParts.push(reconstructedSentences[selectedSentenceIndex + 1].trim())
|
||||
}
|
||||
|
||||
// Only return context if we have more than just the selected sentence
|
||||
return contextParts.length > 1 ? contextParts.join(' ') : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a NostrEvent to a Highlight object for immediate UI display
|
||||
*/
|
||||
export function eventToHighlight(event: NostrEvent): Highlight {
|
||||
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)
|
||||
|
||||
const author = attributions.find(a => a.role === 'author')?.pubkey
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { RelayPool, completeOnEose } from 'applesauce-relay'
|
||||
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||
import { lastValueFrom, takeUntil, timer, tap, toArray } from 'rxjs'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import {
|
||||
getHighlightText,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getHighlightAttributions
|
||||
} from 'applesauce-core/helpers'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { RELAYS } from '../config/relays'
|
||||
|
||||
/**
|
||||
* Deduplicate highlight events by ID
|
||||
@@ -29,22 +30,107 @@ function dedupeHighlights(events: NostrEvent[]): NostrEvent[] {
|
||||
return Array.from(byId.values())
|
||||
}
|
||||
|
||||
export const fetchHighlights = async (
|
||||
/**
|
||||
* 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,
|
||||
pubkey: string
|
||||
articleCoordinate: string,
|
||||
eventId?: string,
|
||||
onHighlight?: (highlight: Highlight) => void
|
||||
): Promise<Highlight[]> => {
|
||||
try {
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
console.log('🔍 Fetching highlights (kind 9802) for article:', articleCoordinate)
|
||||
console.log('🔍 Event ID:', eventId || 'none')
|
||||
console.log('🔍 From relays (including local):', RELAYS)
|
||||
|
||||
console.log('🔍 Fetching highlights (kind 9802) from relays:', relayUrls)
|
||||
const seenIds = new Set<string>()
|
||||
const processEvent = (event: NostrEvent): Highlight | null => {
|
||||
if (seenIds.has(event.id)) return null
|
||||
seenIds.add(event.id)
|
||||
|
||||
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)
|
||||
|
||||
const author = attributions.find(a => a.role === 'author')?.pubkey
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
const rawEvents = await lastValueFrom(
|
||||
// Query for highlights that reference this article via the 'a' tag
|
||||
const aTagEvents = await lastValueFrom(
|
||||
relayPool
|
||||
.req(relayUrls, { kinds: [9802], authors: [pubkey] })
|
||||
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
|
||||
.req(RELAYS, { kinds: [9802], '#a': [articleCoordinate] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
const highlight = processEvent(event)
|
||||
if (highlight && onHighlight) {
|
||||
onHighlight(highlight)
|
||||
}
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(10000)),
|
||||
toArray()
|
||||
)
|
||||
)
|
||||
|
||||
console.log('📊 Raw highlight events fetched:', rawEvents.length)
|
||||
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) {
|
||||
eTagEvents = await lastValueFrom(
|
||||
relayPool
|
||||
.req(RELAYS, { kinds: [9802], '#e': [eventId] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
const highlight = processEvent(event)
|
||||
if (highlight && onHighlight) {
|
||||
onHighlight(highlight)
|
||||
}
|
||||
}),
|
||||
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)
|
||||
@@ -84,7 +170,174 @@ 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 for article:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches highlights for a specific URL
|
||||
* @param relayPool - The relay pool to query
|
||||
* @param url - The external URL to find highlights for
|
||||
*/
|
||||
export const fetchHighlightsForUrl = async (
|
||||
relayPool: RelayPool,
|
||||
url: string
|
||||
): Promise<Highlight[]> => {
|
||||
try {
|
||||
console.log('🔍 Fetching highlights (kind 9802) for URL:', url)
|
||||
|
||||
const seenIds = new Set<string>()
|
||||
const rawEvents = await lastValueFrom(
|
||||
relayPool
|
||||
.req(RELAYS, { kinds: [9802], '#r': [url] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
seenIds.add(event.id)
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(10000)),
|
||||
toArray()
|
||||
)
|
||||
)
|
||||
|
||||
console.log('📊 Highlights for URL:', rawEvents.length)
|
||||
|
||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||
const highlights: Highlight[] = uniqueEvents.map((event: NostrEvent) => {
|
||||
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)
|
||||
|
||||
const author = attributions.find(a => a.role === 'author')?.pubkey
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
return highlights.sort((a, b) => b.created_at - a.created_at)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch highlights for URL:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches highlights created by a specific user
|
||||
* @param relayPool - The relay pool to query
|
||||
* @param pubkey - The user's public key
|
||||
* @param onHighlight - Optional callback to receive highlights as they arrive
|
||||
*/
|
||||
export const fetchHighlights = async (
|
||||
relayPool: RelayPool,
|
||||
pubkey: string,
|
||||
onHighlight?: (highlight: Highlight) => void
|
||||
): Promise<Highlight[]> => {
|
||||
try {
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
|
||||
console.log('🔍 Fetching highlights (kind 9802) by author:', pubkey)
|
||||
|
||||
const seenIds = new Set<string>()
|
||||
const rawEvents = await lastValueFrom(
|
||||
relayPool
|
||||
.req(relayUrls, { kinds: [9802], authors: [pubkey] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
if (!seenIds.has(event.id)) {
|
||||
seenIds.add(event.id)
|
||||
|
||||
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)
|
||||
|
||||
const author = attributions.find(a => a.role === 'author')?.pubkey
|
||||
const eventReference = sourceEventPointer?.id ||
|
||||
(sourceAddressPointer ? `${sourceAddressPointer.kind}:${sourceAddressPointer.pubkey}:${sourceAddressPointer.identifier}` : undefined)
|
||||
|
||||
const highlight: Highlight = {
|
||||
id: event.id,
|
||||
pubkey: event.pubkey,
|
||||
created_at: event.created_at,
|
||||
content: highlightText,
|
||||
tags: event.tags,
|
||||
eventReference,
|
||||
urlReference: sourceUrl,
|
||||
author,
|
||||
context,
|
||||
comment
|
||||
}
|
||||
|
||||
if (onHighlight) {
|
||||
onHighlight(highlight)
|
||||
}
|
||||
}
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(10000)),
|
||||
toArray()
|
||||
)
|
||||
)
|
||||
|
||||
console.log('📊 Raw highlight events fetched:', rawEvents.length)
|
||||
|
||||
// Deduplicate and process events
|
||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||
console.log('📊 Unique highlight events after deduplication:', uniqueEvents.length)
|
||||
|
||||
const highlights: Highlight[] = uniqueEvents.map((event: NostrEvent) => {
|
||||
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)
|
||||
|
||||
const author = attributions.find(a => a.role === 'author')?.pubkey
|
||||
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 by author:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface ReadableContent {
|
||||
title?: string
|
||||
html?: string
|
||||
markdown?: string
|
||||
image?: string
|
||||
}
|
||||
|
||||
interface CachedContent {
|
||||
|
||||
@@ -11,13 +11,21 @@ const SETTINGS_IDENTIFIER = 'com.dergigi.boris.user-settings'
|
||||
export interface UserSettings {
|
||||
collapseOnArticleOpen?: boolean
|
||||
defaultViewMode?: 'compact' | 'cards' | 'large'
|
||||
showUnderlines?: boolean
|
||||
showHighlights?: boolean
|
||||
sidebarCollapsed?: boolean
|
||||
highlightsCollapsed?: boolean
|
||||
readingFont?: string
|
||||
fontSize?: number
|
||||
highlightStyle?: 'marker' | 'underline'
|
||||
highlightColor?: string
|
||||
// Three-level highlight colors
|
||||
highlightColorNostrverse?: string
|
||||
highlightColorFriends?: string
|
||||
highlightColorMine?: string
|
||||
// Default highlight visibility toggles
|
||||
defaultHighlightVisibilityNostrverse?: boolean
|
||||
defaultHighlightVisibilityFriends?: boolean
|
||||
defaultHighlightVisibilityMine?: boolean
|
||||
}
|
||||
|
||||
export async function loadSettings(
|
||||
@@ -26,10 +34,39 @@ export async function loadSettings(
|
||||
pubkey: string,
|
||||
relays: string[]
|
||||
): Promise<UserSettings | null> {
|
||||
console.log('⚙️ Loading settings from nostr...', { pubkey: pubkey.slice(0, 8) + '...', relays })
|
||||
|
||||
// First, check if we already have settings in the local event store
|
||||
try {
|
||||
const localEvent = await firstValueFrom(
|
||||
eventStore.replaceable(APP_DATA_KIND, pubkey, SETTINGS_IDENTIFIER)
|
||||
)
|
||||
if (localEvent) {
|
||||
const content = getAppDataContent<UserSettings>(localEvent)
|
||||
console.log('✅ Settings loaded from local store (cached):', content)
|
||||
|
||||
// Still fetch from relays in the background to get any updates
|
||||
relayPool
|
||||
.subscription(relays, {
|
||||
kinds: [APP_DATA_KIND],
|
||||
authors: [pubkey],
|
||||
'#d': [SETTINGS_IDENTIFIER]
|
||||
})
|
||||
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||
.subscribe()
|
||||
|
||||
return content || null
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('📭 No cached settings found, fetching from relays...')
|
||||
}
|
||||
|
||||
// If not in local store, fetch from relays
|
||||
return new Promise((resolve) => {
|
||||
let hasResolved = false
|
||||
const timeout = setTimeout(() => {
|
||||
if (!hasResolved) {
|
||||
console.warn('⚠️ Settings load timeout - no settings event found')
|
||||
hasResolved = true
|
||||
resolve(null)
|
||||
}
|
||||
@@ -53,16 +90,20 @@ export async function loadSettings(
|
||||
)
|
||||
if (event) {
|
||||
const content = getAppDataContent<UserSettings>(event)
|
||||
console.log('✅ Settings loaded from relays:', content)
|
||||
resolve(content || null)
|
||||
} else {
|
||||
console.log('📭 No settings event found - using defaults')
|
||||
resolve(null)
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error('❌ Error loading settings:', err)
|
||||
resolve(null)
|
||||
}
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
error: (err) => {
|
||||
console.error('❌ Settings subscription error:', err)
|
||||
clearTimeout(timeout)
|
||||
if (!hasResolved) {
|
||||
hasResolved = true
|
||||
@@ -84,11 +125,17 @@ export async function saveSettings(
|
||||
settings: UserSettings,
|
||||
relays: string[]
|
||||
): Promise<void> {
|
||||
console.log('💾 Saving settings to nostr:', settings)
|
||||
|
||||
const draft = await factory.create(AppDataBlueprint, SETTINGS_IDENTIFIER, settings, false)
|
||||
const signed = await factory.sign(draft)
|
||||
|
||||
console.log('📤 Publishing settings event:', signed.id, 'to', relays.length, 'relays')
|
||||
|
||||
eventStore.add(signed)
|
||||
await relayPool.publish(relays, signed)
|
||||
|
||||
console.log('✅ Settings published successfully')
|
||||
}
|
||||
|
||||
export function watchSettings(
|
||||
|
||||
@@ -37,7 +37,7 @@ export interface IndividualBookmark {
|
||||
tags: string[][]
|
||||
parsedContent?: ParsedContent
|
||||
author?: string
|
||||
type: 'event' | 'article'
|
||||
type: 'event' | 'article' | 'web'
|
||||
isPrivate?: boolean
|
||||
encryptedContent?: string
|
||||
// When the item was added to the bookmark list (synthetic, for sorting)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// NIP-84 Highlight types
|
||||
export type HighlightLevel = 'nostrverse' | 'friends' | 'mine'
|
||||
|
||||
export interface Highlight {
|
||||
id: string
|
||||
pubkey: string
|
||||
@@ -11,5 +13,7 @@ export interface Highlight {
|
||||
author?: string // 'p' tag with 'author' role
|
||||
context?: string // surrounding text context
|
||||
comment?: string // optional comment about the highlight
|
||||
// Level classification (computed based on user's context)
|
||||
level?: HighlightLevel
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ export function hexToRgb(hex: string): string {
|
||||
|
||||
export const HIGHLIGHT_COLORS = [
|
||||
{ name: 'Yellow', value: '#ffff00' },
|
||||
{ name: 'Orange', value: '#ff9500' },
|
||||
{ name: 'Orange', value: '#f97316' },
|
||||
{ name: 'Pink', value: '#ff69b4' },
|
||||
{ name: 'Green', value: '#00ff7f' },
|
||||
{ name: 'Blue', value: '#4da6ff' },
|
||||
{ name: 'Purple', value: '#b19cd9' }
|
||||
{ name: 'Purple', value: '#9333ea' }
|
||||
]
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -12,25 +12,83 @@ const FONT_FAMILIES: Record<string, string> = {
|
||||
}
|
||||
|
||||
const loadedFonts = new Set<string>()
|
||||
const loadingFonts = new Map<string, Promise<void>>()
|
||||
|
||||
export function loadFont(fontKey: string) {
|
||||
if (fontKey === 'system' || loadedFonts.has(fontKey)) {
|
||||
return
|
||||
export async function loadFont(fontKey: string): Promise<void> {
|
||||
if (fontKey === 'system') {
|
||||
console.log('📝 Using system font')
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
if (loadedFonts.has(fontKey)) {
|
||||
console.log('✅ Font already loaded:', fontKey)
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
// If font is currently loading, return the existing promise
|
||||
if (loadingFonts.has(fontKey)) {
|
||||
console.log('⏳ Font already loading:', fontKey)
|
||||
return loadingFonts.get(fontKey)!
|
||||
}
|
||||
|
||||
const fontFamily = FONT_FAMILIES[fontKey]
|
||||
if (!fontFamily) {
|
||||
console.warn(`Unknown font: ${fontKey}`)
|
||||
return
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
// Create a link element to load the font from Bunny Fonts
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = `https://fonts.bunny.net/css?family=${encodeURIComponent(fontFamily.toLowerCase().replace(/ /g, '-'))}:400,400i,700,700i`
|
||||
document.head.appendChild(link)
|
||||
console.log('🔤 Loading font:', fontFamily)
|
||||
|
||||
loadedFonts.add(fontKey)
|
||||
// Create a promise for this font loading
|
||||
const loadPromise = new Promise<void>((resolve) => {
|
||||
// Create a link element to load the font from Bunny Fonts
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = `https://fonts.bunny.net/css?family=${encodeURIComponent(fontFamily.toLowerCase().replace(/ /g, '-'))}:400,400i,700,700i`
|
||||
|
||||
// Wait for the stylesheet to load
|
||||
link.onload = () => {
|
||||
console.log('📄 Stylesheet loaded for:', fontFamily)
|
||||
|
||||
// Use Font Loading API to wait for the actual font to be ready
|
||||
if ('fonts' in document) {
|
||||
Promise.all([
|
||||
document.fonts.load(`400 16px "${fontFamily}"`),
|
||||
document.fonts.load(`700 16px "${fontFamily}"`)
|
||||
]).then(() => {
|
||||
console.log('✅ Font ready:', fontFamily)
|
||||
loadedFonts.add(fontKey)
|
||||
loadingFonts.delete(fontKey)
|
||||
resolve()
|
||||
}).catch((err) => {
|
||||
console.warn('⚠️ Font loading failed:', fontFamily, err)
|
||||
loadedFonts.add(fontKey) // Mark as loaded anyway to prevent retries
|
||||
loadingFonts.delete(fontKey)
|
||||
resolve()
|
||||
})
|
||||
} else {
|
||||
// Fallback: just wait a bit for older browsers
|
||||
setTimeout(() => {
|
||||
console.log('✅ Font assumed ready (no Font Loading API):', fontFamily)
|
||||
loadedFonts.add(fontKey)
|
||||
loadingFonts.delete(fontKey)
|
||||
resolve()
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
link.onerror = () => {
|
||||
console.error('❌ Failed to load font stylesheet:', fontFamily)
|
||||
loadedFonts.add(fontKey) // Mark as loaded to prevent retries
|
||||
loadingFonts.delete(fontKey)
|
||||
resolve() // Resolve anyway so we don't block
|
||||
}
|
||||
|
||||
document.head.appendChild(link)
|
||||
})
|
||||
|
||||
loadingFonts.set(fontKey, loadPromise)
|
||||
return loadPromise
|
||||
}
|
||||
|
||||
export function getFontFamily(fontKey: string | undefined): string {
|
||||
|
||||
@@ -13,7 +13,10 @@ export interface UrlClassification {
|
||||
buttonText: string
|
||||
}
|
||||
|
||||
export const classifyUrl = (url: string): UrlClassification => {
|
||||
export const classifyUrl = (url: string | undefined): UrlClassification => {
|
||||
if (!url) {
|
||||
return { type: 'article', buttonText: 'READ NOW' }
|
||||
}
|
||||
const urlLower = url.toLowerCase()
|
||||
|
||||
// Check for YouTube
|
||||
|
||||
@@ -73,11 +73,13 @@ export function applyHighlightsToText(
|
||||
|
||||
// Add the highlighted text
|
||||
const highlightedText = text.substring(match.startIndex, match.endIndex)
|
||||
const levelClass = match.highlight.level ? ` level-${match.highlight.level}` : ''
|
||||
result.push(
|
||||
<mark
|
||||
key={`highlight-${match.highlight.id}-${match.startIndex}`}
|
||||
className="content-highlight"
|
||||
className={`content-highlight${levelClass}`}
|
||||
data-highlight-id={match.highlight.id}
|
||||
data-highlight-level={match.highlight.level || 'nostrverse'}
|
||||
title={`Highlighted ${new Date(match.highlight.created_at * 1000).toLocaleDateString()}`}
|
||||
>
|
||||
{highlightedText}
|
||||
@@ -101,8 +103,10 @@ const normalizeWhitespace = (str: string) => str.replace(/\s+/g, ' ').trim()
|
||||
// Helper to create a mark element for a highlight
|
||||
function createMarkElement(highlight: Highlight, matchText: string, highlightStyle: 'marker' | 'underline' = 'marker'): HTMLElement {
|
||||
const mark = document.createElement('mark')
|
||||
mark.className = `content-highlight-${highlightStyle}`
|
||||
const levelClass = highlight.level ? ` level-${highlight.level}` : ''
|
||||
mark.className = `content-highlight-${highlightStyle}${levelClass}`
|
||||
mark.setAttribute('data-highlight-id', highlight.id)
|
||||
mark.setAttribute('data-highlight-level', highlight.level || 'nostrverse')
|
||||
mark.setAttribute('title', `Highlighted ${new Date(highlight.created_at * 1000).toLocaleDateString()}`)
|
||||
mark.textContent = matchText
|
||||
return mark
|
||||
@@ -140,8 +144,6 @@ function tryMarkInTextNodes(
|
||||
|
||||
if (index === -1) continue
|
||||
|
||||
console.log(`✅ Found ${useNormalized ? 'normalized' : 'exact'} match:`, text.slice(0, 50))
|
||||
|
||||
let actualIndex = index
|
||||
if (useNormalized) {
|
||||
// Map normalized index back to original text
|
||||
@@ -168,14 +170,26 @@ function tryMarkInTextNodes(
|
||||
* Apply highlights to HTML content by injecting mark tags using DOM manipulation
|
||||
*/
|
||||
export function applyHighlightsToHTML(html: string, highlights: Highlight[], highlightStyle: 'marker' | 'underline' = 'marker'): string {
|
||||
if (!html || highlights.length === 0) return html
|
||||
if (!html || highlights.length === 0) {
|
||||
console.log('⚠️ applyHighlightsToHTML: No HTML or highlights', { htmlLength: html?.length, highlightsCount: highlights.length })
|
||||
return html
|
||||
}
|
||||
|
||||
console.log('🔨 applyHighlightsToHTML: Processing', highlights.length, 'highlights')
|
||||
|
||||
const tempDiv = document.createElement('div')
|
||||
tempDiv.innerHTML = html
|
||||
|
||||
let appliedCount = 0
|
||||
|
||||
for (const highlight of highlights) {
|
||||
const searchText = highlight.content.trim()
|
||||
if (!searchText) continue
|
||||
if (!searchText) {
|
||||
console.warn('⚠️ Empty highlight content:', highlight.id)
|
||||
continue
|
||||
}
|
||||
|
||||
console.log('🔍 Searching for highlight:', searchText.substring(0, 50) + '...')
|
||||
|
||||
// Collect all text nodes
|
||||
const walker = document.createTreeWalker(tempDiv, NodeFilter.SHOW_TEXT, null)
|
||||
@@ -183,10 +197,21 @@ export function applyHighlightsToHTML(html: string, highlights: Highlight[], hig
|
||||
let node: Node | null
|
||||
while ((node = walker.nextNode())) textNodes.push(node as Text)
|
||||
|
||||
console.log('📄 Found', textNodes.length, 'text nodes to search')
|
||||
|
||||
// Try exact match first, then normalized match
|
||||
tryMarkInTextNodes(textNodes, searchText, highlight, false, highlightStyle) ||
|
||||
tryMarkInTextNodes(textNodes, searchText, highlight, true, highlightStyle)
|
||||
const found = tryMarkInTextNodes(textNodes, searchText, highlight, false, highlightStyle) ||
|
||||
tryMarkInTextNodes(textNodes, searchText, highlight, true, highlightStyle)
|
||||
|
||||
if (found) {
|
||||
appliedCount++
|
||||
console.log('✅ Highlight applied successfully')
|
||||
} else {
|
||||
console.warn('❌ Could not find match for highlight:', searchText.substring(0, 50))
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🎉 Applied', appliedCount, '/', highlights.length, 'highlights')
|
||||
|
||||
return tempDiv.innerHTML
|
||||
}
|
||||
|
||||
@@ -10,15 +10,43 @@ export function normalizeUrl(url: string): string {
|
||||
}
|
||||
|
||||
export function filterHighlightsByUrl(highlights: Highlight[], selectedUrl: string | undefined): Highlight[] {
|
||||
if (!selectedUrl || highlights.length === 0) return []
|
||||
if (!selectedUrl || highlights.length === 0) {
|
||||
console.log('🔍 filterHighlightsByUrl: No URL or highlights', { selectedUrl, count: highlights.length })
|
||||
return []
|
||||
}
|
||||
|
||||
console.log('🔍 filterHighlightsByUrl:', { selectedUrl, totalHighlights: highlights.length })
|
||||
|
||||
// For Nostr articles, we already fetched highlights specifically for this article
|
||||
// So we don't need to filter them - they're all relevant
|
||||
if (selectedUrl.startsWith('nostr:')) {
|
||||
console.log('📌 Nostr article - returning all', highlights.length, 'highlights')
|
||||
return highlights
|
||||
}
|
||||
|
||||
// For web URLs, filter by URL matching
|
||||
const normalizedSelected = normalizeUrl(selectedUrl)
|
||||
console.log('🔗 Normalized selected URL:', normalizedSelected)
|
||||
|
||||
return highlights.filter(h => {
|
||||
if (!h.urlReference) return false
|
||||
const filtered = highlights.filter(h => {
|
||||
if (!h.urlReference) {
|
||||
console.log('⚠️ Highlight has no urlReference:', h.id, 'eventReference:', h.eventReference)
|
||||
return false
|
||||
}
|
||||
const normalizedRef = normalizeUrl(h.urlReference)
|
||||
return normalizedSelected === normalizedRef ||
|
||||
const matches = normalizedSelected === normalizedRef ||
|
||||
normalizedSelected.includes(normalizedRef) ||
|
||||
normalizedRef.includes(normalizedSelected)
|
||||
|
||||
if (matches) {
|
||||
console.log('✅ URL match:', normalizedRef)
|
||||
} else {
|
||||
console.log('❌ URL mismatch:', normalizedRef, 'vs', normalizedSelected)
|
||||
}
|
||||
|
||||
return matches
|
||||
})
|
||||
|
||||
console.log('📊 Filtered to', filtered.length, 'highlights')
|
||||
return filtered
|
||||
}
|
||||
|
||||
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