mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 20:45:01 +01:00
Compare commits
169 Commits
v0.6.11
...
sync-readi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63f58e010f | ||
|
|
d0b814e39d | ||
|
|
f4a227e40a | ||
|
|
6ef0a6dd71 | ||
|
|
5502d71ac4 | ||
|
|
5e1146b015 | ||
|
|
8f89165711 | ||
|
|
674634326f | ||
|
|
30eaec5770 | ||
|
|
0ff3c864a9 | ||
|
|
ab2ca1f5e7 | ||
|
|
cf2d227f61 | ||
|
|
2c9e6cc54e | ||
|
|
8da0a06711 | ||
|
|
be8d857223 | ||
|
|
d50bcd700e | ||
|
|
820ab1d902 | ||
|
|
f5e9e5bf61 | ||
|
|
40b43532e8 | ||
|
|
51a3008730 | ||
|
|
e30cbc72c3 | ||
|
|
6f913262f4 | ||
|
|
0f0462e6ac | ||
|
|
e353f0e2d6 | ||
|
|
ee1365d3ca | ||
|
|
a215d0b026 | ||
|
|
b8d76c0bd8 | ||
|
|
233169b082 | ||
|
|
72b9a04cd2 | ||
|
|
432715efb6 | ||
|
|
8b2b954dde | ||
|
|
c2d2bd8106 | ||
|
|
a5c3085c59 | ||
|
|
c0332f08d6 | ||
|
|
38a1d6caec | ||
|
|
39dd607e7b | ||
|
|
9dc0db3e06 | ||
|
|
b1eb58a385 | ||
|
|
f3c6404f76 | ||
|
|
1a42a6422d | ||
|
|
2e2de4ccda | ||
|
|
4325d3a519 | ||
|
|
51115c5f68 | ||
|
|
2aa6fe860b | ||
|
|
86f39eacf8 | ||
|
|
d15daef3ea | ||
|
|
281c70cdea | ||
|
|
d6d6087543 | ||
|
|
d06e38bc19 | ||
|
|
cfc8eb0bbc | ||
|
|
b85f9b79c3 | ||
|
|
1b0045c737 | ||
|
|
3dc8d7d440 | ||
|
|
bf9ca48d64 | ||
|
|
70441f3d59 | ||
|
|
431f28e861 | ||
|
|
3b1fc095c4 | ||
|
|
9a6c7a29d0 | ||
|
|
c1d173f40e | ||
|
|
f03ec5df8c | ||
|
|
6c74a12636 | ||
|
|
39797803d3 | ||
|
|
c66c1e928d | ||
|
|
f934b641bb | ||
|
|
1128a11603 | ||
|
|
9f90718918 | ||
|
|
067a07fc00 | ||
|
|
1811cf045e | ||
|
|
270b4f429f | ||
|
|
380acbb55f | ||
|
|
c384f0b4fb | ||
|
|
27cf393a03 | ||
|
|
8831726913 | ||
|
|
2f4327874c | ||
|
|
483845962e | ||
|
|
c44b1d6349 | ||
|
|
79f28a142d | ||
|
|
02dd537cd9 | ||
|
|
5af1f14a0b | ||
|
|
664f59a9cc | ||
|
|
7d3641aab7 | ||
|
|
7924df4c67 | ||
|
|
68a8eed4af | ||
|
|
887db84ce7 | ||
|
|
05348fbfeb | ||
|
|
38eb6716f8 | ||
|
|
d7f9cd30eb | ||
|
|
922d041e0e | ||
|
|
76f4588c85 | ||
|
|
e163b92a7e | ||
|
|
11925a42b0 | ||
|
|
acf45530ca | ||
|
|
3792ad6abf | ||
|
|
bf98b307e8 | ||
|
|
d15392f41e | ||
|
|
f26a024255 | ||
|
|
bf9f894c0d | ||
|
|
53a7b7d1c5 | ||
|
|
a12a883cc6 | ||
|
|
0cf076b010 | ||
|
|
e2c712033f | ||
|
|
e38237ca8e | ||
|
|
1fff44fc6c | ||
|
|
4e50073e07 | ||
|
|
0ce64fe83f | ||
|
|
ef848aa93e | ||
|
|
67b287d75d | ||
|
|
b795dfd2c6 | ||
|
|
c68d855983 | ||
|
|
fb1c19e64b | ||
|
|
384c16e29d | ||
|
|
789982bd76 | ||
|
|
8bccc9de48 | ||
|
|
ec8584b4d2 | ||
|
|
54bd59fa2d | ||
|
|
b19f5f55f7 | ||
|
|
0964f25f97 | ||
|
|
5f3e6335c1 | ||
|
|
f30c894c87 | ||
|
|
bec769ac1b | ||
|
|
cb3748e06f | ||
|
|
d5a24f0a46 | ||
|
|
401a8241bd | ||
|
|
2193a7a863 | ||
|
|
e6bc4d7fda | ||
|
|
aee9f73316 | ||
|
|
aef7b4cea4 | ||
|
|
c9a8a3b91e | ||
|
|
0c7b11bdf8 | ||
|
|
8c151a5855 | ||
|
|
9b54fa9c14 | ||
|
|
99d7705404 | ||
|
|
eaa590b8e2 | ||
|
|
715fd8cf10 | ||
|
|
99a9709605 | ||
|
|
65d330d5ed | ||
|
|
1d1d389a03 | ||
|
|
0392389355 | ||
|
|
cf2a500a07 | ||
|
|
7d3748202e | ||
|
|
d7f90faea9 | ||
|
|
cb0066aac9 | ||
|
|
b48397b7a6 | ||
|
|
82ab8419e3 | ||
|
|
142a2414d3 | ||
|
|
081bd95f60 | ||
|
|
300aed0589 | ||
|
|
b2b23c66cf | ||
|
|
838bb6aa3d | ||
|
|
f14ecc5acb | ||
|
|
d533e23dc0 | ||
|
|
eefcf99364 | ||
|
|
1c0790bfb6 | ||
|
|
29e351ba78 | ||
|
|
7592c5c327 | ||
|
|
f5018204ab | ||
|
|
7ae74268fd | ||
|
|
52e959a7f5 | ||
|
|
4f03a2c276 | ||
|
|
bc4c96ee35 | ||
|
|
a866040fc1 | ||
|
|
c90fad268a | ||
|
|
8ef1f775f9 | ||
|
|
90af87339c | ||
|
|
9007b1ca71 | ||
|
|
0b7e6145de | ||
|
|
bf1b608d96 | ||
|
|
7db0f2a05c | ||
|
|
165b4d4b9f |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -8,6 +8,7 @@ dist
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
# Applesauce Reference
|
||||
# Reference Projects
|
||||
applesauce
|
||||
primal-web-app
|
||||
|
||||
|
||||
300
CHANGELOG.md
300
CHANGELOG.md
@@ -7,6 +7,294 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.6.20] - 2025-10-15
|
||||
|
||||
### Added
|
||||
|
||||
- Bookmark filter buttons by content type (articles, videos, images, web links)
|
||||
- Filter bookmarks by their content type on bookmarks sidebar
|
||||
- Filters also available on `/me` page bookmarks tab
|
||||
- Separate filter for external articles with link icon
|
||||
- Multiple filters can be active simultaneously
|
||||
- Private Bookmarks section for encrypted legacy bookmarks
|
||||
- Encrypted legacy bookmarks now grouped in separate section
|
||||
- Better organization and clarity for different bookmark types
|
||||
|
||||
### Changed
|
||||
|
||||
- Bookmark section labels improved for clarity
|
||||
- More descriptive section headings throughout
|
||||
- Better categorization of bookmark types
|
||||
- Bookmark filter button styling refined
|
||||
- Reduced whitespace around bookmark filters for cleaner layout
|
||||
- Dramatically reduced whitespace on both sidebar and `/me` page
|
||||
- Lock icon removed from individual bookmarks
|
||||
- Encryption status now indicated by section grouping
|
||||
- Cleaner bookmark item appearance
|
||||
- External article icon changed to link icon (`faLink`)
|
||||
- More intuitive icon for external content
|
||||
|
||||
### Fixed
|
||||
|
||||
- Highlight button positioning and visibility
|
||||
- Fixed to viewport for consistent placement
|
||||
- Sticky and always visible when needed
|
||||
- Properly positioned inside reader pane
|
||||
|
||||
## [0.6.19] - 2025-10-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- Highlights disappearing on external URLs after a few seconds
|
||||
- Fixed `useBookmarksData` from fetching general highlights when viewing external URLs
|
||||
- External URL highlights now managed exclusively by `useExternalUrlLoader`
|
||||
- Removed redundant `setHighlights` call that was overwriting streamed highlights
|
||||
- Improved error handling in `fetchHighlightsForUrl` to prevent silent failures
|
||||
- Isolated rebroadcast errors so they don't break highlight display
|
||||
- Added logging to help diagnose highlight fetching issues
|
||||
|
||||
## [0.6.18] - 2025-10-15
|
||||
|
||||
### Changed
|
||||
|
||||
- Zap split labels simplified and terminology updated
|
||||
- Removed redundant "Weight: xy" label to save space
|
||||
- Changed "Author(s) Share" to "Author's Share" (possessive singular)
|
||||
- Changed "Support Boris" to "Boris' Share" for consistency
|
||||
- Weight value now shown directly in label (e.g., "Your Share: 50")
|
||||
- Share and percentage now displayed on same line for cleaner layout
|
||||
- Zap preset buttons on desktop now expand to match slider width
|
||||
- Added `flex: 1` to buttons for equal width distribution
|
||||
- Buttons still wrap properly on smaller screens
|
||||
- PWA install section now always visible in settings
|
||||
- Section shows regardless of installation or device capability status
|
||||
- Button adapts with proper disabled states and visual feedback
|
||||
- "Installed" state shows checkmark icon and disabled button
|
||||
- Non-installable state shows disabled button
|
||||
|
||||
### Fixed
|
||||
|
||||
- PWA install button now properly disabled when installation is not possible on device
|
||||
- Button only enabled when browser fires `beforeinstallprompt` event
|
||||
- Removed hardcoded testing state that always showed button as installable
|
||||
- App & Airplane Mode section now always visible regardless of PWA status
|
||||
- Image cache and local relay settings always accessible
|
||||
- Previously entire section was hidden if PWA not installable/installed
|
||||
- Only PWA-specific install button is conditionally affected
|
||||
|
||||
## [0.6.17] - 2025-10-15
|
||||
|
||||
### Added
|
||||
|
||||
- PWA settings illustration (`pwa.svg`) displayed on right side of section
|
||||
- Responsive design: hidden on mobile, 30% width on desktop
|
||||
- Visual enhancement for App & Airplane Mode section
|
||||
- Zaps illustration (`zaps.svg`) displayed on right side of Zap Splits section
|
||||
- Matching responsive layout and styling as PWA illustration
|
||||
- Visual 50% indicators on zap split sliders
|
||||
- Linear gradient background using highlight colors (yellow/orange) at 50% opacity
|
||||
- Datalist tick marks at 50% for "Your Share" and "Author(s) Share" sliders
|
||||
- Tick mark at 5 for "Support Boris" slider
|
||||
- Lightning bolt icons as slider thumbs for zap splits
|
||||
- Replaces default circular slider handles
|
||||
- White lightning bolt SVG embedded in slider thumb background
|
||||
- 24px square thumb with 4px border radius
|
||||
- Offline-first description paragraph at beginning of App & Airplane Mode section
|
||||
- Explains Boris's offline capabilities upfront
|
||||
- Settings page width constraint (900px max-width)
|
||||
- Matches article view max-width for consistent reading experience
|
||||
- Centered layout with proper margins
|
||||
|
||||
### Changed
|
||||
|
||||
- Settings section reorganization
|
||||
- "PWA & Flight Mode" merged into single "App & Airplane Mode" section
|
||||
- "Layout & Navigation" and "Startup & Behavior" merged into "Layout & Behavior"
|
||||
- Section order: Theme → Reading & Display → Zap Splits → Layout & Behavior → App & Airplane Mode → Relays
|
||||
- "Startup & Behavior" moved after "Zap Splits"
|
||||
- "Layout & Navigation" moved below "Zap Splits"
|
||||
- PWA settings section restructure
|
||||
- Checkboxes moved to top (image cache, local relays)
|
||||
- Descriptive paragraphs in middle
|
||||
- Install button at bottom
|
||||
- Note about local relays moved before install paragraph
|
||||
- Zap split sliders styling
|
||||
- Left side (0-50%): highlight color (yellow) at 50% opacity
|
||||
- Right side (50-100%): friend-highlight color (orange) at 50% opacity
|
||||
- Creates visual distinction tied to app's highlight color scheme
|
||||
- Zap split description text styling
|
||||
- Now matches offline-first paragraph style with secondary color and smaller font size
|
||||
- Clear cache button styling
|
||||
- Replaced `IconButton` with plain `FontAwesomeIcon` for subtler appearance
|
||||
- No border or background, just icon with opacity
|
||||
- Font Size buttons alignment
|
||||
- Now properly align to the right using `setting-control` wrapper
|
||||
- Matches alignment of highlight color picker buttons
|
||||
- Default Highlight Visibility position
|
||||
- Moved back to original position after "Paragraph Alignment"
|
||||
- Grouped with other reading display controls
|
||||
- Spacing adjustments in App & Airplane Mode section
|
||||
- Reduced gap between elements from 1rem → 0.5rem → 0.25rem for tighter layout
|
||||
|
||||
### Fixed
|
||||
|
||||
- PWA settings paragraph wrapping
|
||||
- Moved offline-first paragraph inside flex container to prevent extending above image
|
||||
- Font Size buttons alignment issues
|
||||
- Properly implemented `setting-control` wrapper for right alignment
|
||||
- Previously attempted alignment didn't work correctly
|
||||
- Slider thumb icon centering
|
||||
- Lightning bolt icons properly centered vertically on slider
|
||||
- Added `position: relative`, `top: 0`, `margin-top: 0` for accurate positioning
|
||||
|
||||
## [0.6.16] - 2025-10-15
|
||||
|
||||
### Changed
|
||||
|
||||
- Replaced delete dialog popup with inline confirmation UI
|
||||
- Shows red "Confirm?" text with trash icon when delete is clicked
|
||||
- Clicking the red trash icon confirms deletion
|
||||
- No more modal overlay or backdrop
|
||||
- Click outside or reopen menu to cancel
|
||||
- Reordered Reading & Display settings for better organization
|
||||
- Highlight Style, Paragraph Alignment, and Default Highlight Visibility moved to top
|
||||
- Followed by Reading Font, Font Size, and color pickers
|
||||
- Setting buttons now align vertically with fixed label width (220px)
|
||||
- Creates consistent "tab stops" for cleaner visual alignment
|
||||
|
||||
### Fixed
|
||||
|
||||
- Removed unused `handleCancelDelete` function after dialog removal
|
||||
|
||||
## [0.6.15] - 2025-10-15
|
||||
|
||||
### Added
|
||||
|
||||
- Paragraph alignment setting with left-aligned and justified text options
|
||||
- Icon buttons in Reading & Display settings for switching alignment
|
||||
- CSS variable system for applying alignment to reader content
|
||||
- Real-time preview of alignment changes in settings
|
||||
- Headings remain left-aligned for optimal readability
|
||||
|
||||
### Changed
|
||||
|
||||
- Default paragraph alignment changed to justified for improved reading experience
|
||||
- Applies to paragraphs, list items, divs, and blockquotes
|
||||
- Settings stored and synced via Nostr (NIP-78)
|
||||
|
||||
## [0.6.14] - 2025-10-15
|
||||
|
||||
### Added
|
||||
|
||||
- Support for bookmark sets (NIP-51 kind:30003)
|
||||
- Bookmark sets now display alongside regular bookmark lists
|
||||
- Properly handles AddressPointer bookmarks for long-form articles
|
||||
- Content type icons for bookmarks
|
||||
- Article, video, web, and image icons to indicate bookmark content type
|
||||
- Camera icon for image bookmarks
|
||||
- Sticky note icon for text-only bookmarks without URLs
|
||||
- Bookmark grouping and sections
|
||||
- Grouped sections in sidebar and `/me` reading-list
|
||||
- Web bookmarks, default bookmarks, and legacy bookmarks in separate sections
|
||||
- Grouping and sorting helpers for organizing bookmark sections
|
||||
- Adaptive text color for publication date over hero images
|
||||
- Automatically detects image brightness and adjusts text color
|
||||
- Improved contrast for better readability
|
||||
|
||||
### Changed
|
||||
|
||||
- Renamed "Amethyst-style bookmarks" to "Old Bookmarks (Legacy)"
|
||||
- Hide cover images in compact view for cleaner layout
|
||||
- Support button improvements
|
||||
- Moved to bottom-left of bookmarks bar
|
||||
- Changed icon from lightning bolt to heart (orange color)
|
||||
- Left-aligned support button, right-aligned view mode buttons
|
||||
- Section headings improved with better typography (removed counts)
|
||||
- Icon changed from book to file-lines for default bookmarks
|
||||
- Use regular (outlined) icon variants for lighter, more refined appearance
|
||||
- Add bookmark button moved to web bookmarks section
|
||||
- Empty state messages replaced with loading spinners
|
||||
- Section dividers made more subtle
|
||||
- Simplified bookmark filtering to only exclude empty content
|
||||
|
||||
### Fixed
|
||||
|
||||
- Removed borders from compact bookmark cards for cleaner look
|
||||
- Removed duplicate type indicator icons from bookmark cards
|
||||
- Reduced section heading bottom padding for better spacing
|
||||
- Aligned add bookmark button with section heading
|
||||
- Removed redundant loading spinner above tabs
|
||||
- Resolved linter and type errors
|
||||
- Include kind:30003 in default bookmark list detection
|
||||
- Removed text shadows from publication date for cleaner look
|
||||
- Improved shadow contrast without background overlay
|
||||
- Corrected async handling in adaptive color detection
|
||||
- Corrected FastAverageColor import to use named export
|
||||
- Section heading styles now properly override with `!important`
|
||||
- Removed unused articleImage prop from CompactView
|
||||
|
||||
## [0.6.13] - 2025-10-15
|
||||
|
||||
### Added
|
||||
|
||||
- Support for `nprofile` identifiers on `/p/` profile pages (NIP-19)
|
||||
- Profile pages now accept both `npub` and `nprofile` identifiers
|
||||
- Extracts pubkey from nprofile data structure
|
||||
- Users can share profiles with relay metadata included
|
||||
- Gradient placeholder images for articles without cover images
|
||||
- Blog post cards show subtle diagonal gradient using theme colors
|
||||
- Reader view displays gradient background with newspaper icon
|
||||
- Placeholders adapt automatically to light/dark themes
|
||||
- Large view bookmarks use matching gradient backgrounds
|
||||
|
||||
### Changed
|
||||
|
||||
- PWA install section styling in settings
|
||||
- Heading now matches other section headings with proper styling
|
||||
- Install button uses standard app button styling instead of custom gradient
|
||||
- Consistent with app's design system and theme colors
|
||||
|
||||
### Fixed
|
||||
|
||||
- Mobile bookmark button visibility across all pages
|
||||
- Now visible on `/p/` (profile), `/explore`, `/me`, and `/support` pages
|
||||
- Only hidden on settings page or when scrolling down while reading
|
||||
- Prevents users from getting stuck without navigation options
|
||||
- Mobile highlights button behavior at page top
|
||||
- Hidden when scrolled to the very top of the page
|
||||
- Appears when scrolling up from below
|
||||
- Bookmark button remains visible at top (only hides on scroll down)
|
||||
- Separate visibility logic for each button improves UX
|
||||
|
||||
## [0.6.12] - 2025-10-15
|
||||
|
||||
### Changed
|
||||
|
||||
- Horizontal dividers (`<hr>`) in blog posts now display with more subtle styling
|
||||
- Reduced visual weight with 69% opacity for better readability
|
||||
- Added increased vertical padding (2.5rem) above and below dividers
|
||||
- Improved visual separation without disrupting reading flow
|
||||
|
||||
## [0.6.11] - 2025-10-15
|
||||
|
||||
### Added
|
||||
|
||||
- Colored borders to blog post and highlight cards based on relationship
|
||||
- Mine: yellow border
|
||||
- Friends: orange border
|
||||
- Nostrverse: purple border
|
||||
- Visual distinction helps identify content source at a glance
|
||||
- Mobile sidebar toggle buttons on explore page
|
||||
- Bookmark and highlights buttons now visible on explore page
|
||||
- Improves mobile navigation UX
|
||||
|
||||
### Fixed
|
||||
|
||||
- Mobile bookmarks sidebar opening and closing immediately
|
||||
- Memoized `toggleSidebar` function to prevent unnecessary re-renders
|
||||
- Updated route-change effect to only close sidebar on actual pathname changes
|
||||
- Sidebar now stays open when opened on mobile PWA
|
||||
|
||||
## [0.6.10] - 2025-10-15
|
||||
|
||||
### Added
|
||||
@@ -1353,7 +1641,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Optimize relay usage following applesauce-relay best practices
|
||||
- Use applesauce-react event models for better profile handling
|
||||
|
||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.10...HEAD
|
||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.20...HEAD
|
||||
[0.6.20]: https://github.com/dergigi/boris/compare/v0.6.19...v0.6.20
|
||||
[0.6.19]: https://github.com/dergigi/boris/compare/v0.6.18...v0.6.19
|
||||
[0.6.18]: https://github.com/dergigi/boris/compare/v0.6.17...v0.6.18
|
||||
[0.6.17]: https://github.com/dergigi/boris/compare/v0.6.16...v0.6.17
|
||||
[0.6.16]: https://github.com/dergigi/boris/compare/v0.6.15...v0.6.16
|
||||
[0.6.15]: https://github.com/dergigi/boris/compare/v0.6.14...v0.6.15
|
||||
[0.6.14]: https://github.com/dergigi/boris/compare/v0.6.13...v0.6.14
|
||||
[0.6.13]: https://github.com/dergigi/boris/compare/v0.6.12...v0.6.13
|
||||
[0.6.12]: https://github.com/dergigi/boris/compare/v0.6.11...v0.6.12
|
||||
[0.6.11]: https://github.com/dergigi/boris/compare/v0.6.10...v0.6.11
|
||||
[0.6.10]: https://github.com/dergigi/boris/compare/v0.6.9...v0.6.10
|
||||
[0.6.9]: https://github.com/dergigi/boris/compare/v0.6.8...v0.6.9
|
||||
[0.6.8]: https://github.com/dergigi/boris/compare/v0.6.7...v0.6.8
|
||||
|
||||
14
package-lock.json
generated
14
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.6.9",
|
||||
"version": "0.6.13",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "boris",
|
||||
"version": "0.6.9",
|
||||
"version": "0.6.13",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^7.1.0",
|
||||
@@ -22,6 +22,7 @@
|
||||
"applesauce-react": "^4.0.0",
|
||||
"applesauce-relay": "^4.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"fast-average-color": "^9.5.0",
|
||||
"nostr-tools": "^2.4.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^18.2.0",
|
||||
@@ -6086,6 +6087,15 @@
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-average-color": {
|
||||
"version": "9.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-average-color/-/fast-average-color-9.5.0.tgz",
|
||||
"integrity": "sha512-nC6x2YIlJ9xxgkMFMd1BNoM1ctMjNoRKfRliPmiEWW3S6rLTHiQcy9g3pt/xiKv/D0NAAkhb9VyV+WJFvTqMGg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.6.11",
|
||||
"version": "0.6.20",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
@@ -25,6 +25,7 @@
|
||||
"applesauce-react": "^4.0.0",
|
||||
"applesauce-relay": "^4.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"fast-average-color": "^9.5.0",
|
||||
"nostr-tools": "^2.4.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^18.2.0",
|
||||
|
||||
215
public/pwa.svg
Normal file
215
public/pwa.svg
Normal file
@@ -0,0 +1,215 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="649.67538"
|
||||
height="568.22024"
|
||||
viewBox="0 0 649.67538 568.22024"
|
||||
role="img"
|
||||
artist="Katerina Limpitsouni"
|
||||
source="https://undraw.co/"
|
||||
version="1.1"
|
||||
id="svg31"
|
||||
sodipodi:docname="pwa.svg"
|
||||
inkscape:version="1.4.2 (ebf0e940, 2025-05-08)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs31" />
|
||||
<sodipodi:namedview
|
||||
id="namedview31"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="1.6866359"
|
||||
inkscape:cx="303.56285"
|
||||
inkscape:cy="531.82789"
|
||||
inkscape:window-width="3840"
|
||||
inkscape:window-height="1027"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="25"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg31" />
|
||||
<path
|
||||
d="M397.23858,566.04035,390.539,618.81819l-9.85909-59.95407c-47.3817-18.18194-102.78179-21.713-102.78179-21.713s-12.22552,114.50728,28.139,162.38683,82.92182,40.60129,118.03379,11.00042c35.1114-29.60039,49.48123-70.31412,9.11675-118.19368C424.20327,581.68766,411.521,573.04476,397.23858,566.04035Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#f2f2f2"
|
||||
id="path1" />
|
||||
<path
|
||||
d="M384.1004,626.79762l1.98958,2.36c22.98681,27.551,36.40476,52.8555,40.0327,75.5803.05864.33032.09573.65881.15431.98919l-1.53846.23773-1.48187.20991c-3.64942-24.76543-19.47993-50.77428-39.52347-74.8103-.63842-.781-1.28663-1.57364-1.95824-2.34655-8.57477-10.1-17.832-19.82437-27.217-28.9415-.72021-.712-1.46191-1.42587-2.20361-2.13968-12.44963-11.96994-25.01434-22.84351-36.237-32.03036-.7903-.653-1.59224-1.296-2.38439-1.92739-19.05943-15.4717-33.9044-25.802-37.21424-28.06849-.39875-.28343-.62465-.43273-.67573-.46958l.844-1.25121.00183-.02155.85568-1.26106c.05113.03692.81117.53546,2.18233,1.49814,5.15056,3.57268,18.987,13.39417,36.1433,27.27236.77059.62957,1.57259,1.27267,2.36284,1.92555,9.11521,7.44575,19.072,15.96086,29.1037,25.25221q3.78542,3.49455,7.37706,6.9724c.75332.704,1.495,1.41783,2.21523,2.12988Q372.14864,612.73905,384.1004,626.79762Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#fff"
|
||||
id="path2" />
|
||||
<path
|
||||
d="M315.8701,561.67759c-.6941.76509-1.39989,1.54-2.13716,2.30139a84.299,84.299,0,0,1-6.3038,5.89408,82.00518,82.00518,0,0,1-32.26683,16.72907c.03131,1.03285.06269,2.06578.09217,3.12018a85.04164,85.04164,0,0,0,34.14459-17.51256,87.22471,87.22471,0,0,0,6.71826-6.30338c.72551-.75156,1.43131-1.52651,2.11561-2.30323a84.3256,84.3256,0,0,0,13.87772-21.35332q-1.56615-.32858-3.06776-.65165A81.72351,81.72351,0,0,1,315.8701,561.67759Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#fff"
|
||||
id="path3" />
|
||||
<path
|
||||
d="M354.7137,595.82775q-1.15019,1.08949-2.35939,2.109c-.23552.21856-.49252.43522-.7379.64208a82.4401,82.4401,0,0,1-74.51659,16.59042c.1138,1.08323.22759,2.1666.36294,3.25167a85.5013,85.5013,0,0,0,76.12358-17.5054c.32717-.27581.65427-.55157.97158-.83909.80793-.70112,1.59437-1.40414,2.371-2.11878a85.04917,85.04917,0,0,0,24.39782-41.355c-.955-.37409-1.91-.74825-2.87668-1.11248A81.874,81.874,0,0,1,354.7137,595.82775Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#fff"
|
||||
id="path4" />
|
||||
<path
|
||||
d="M384.1004,626.79762c-.75869.75952-1.53717,1.49572-2.32545,2.22029-.84674.77374-1.70328,1.53585-2.57954,2.27457a82.66307,82.66307,0,0,1-98.92522,5.60818c.27211,1.38968.5343,2.76759.82973,4.13747a85.69022,85.69022,0,0,0,100.06542-7.409c.87626-.73872,1.74266-1.48914,2.56785-2.26471.80983-.72274,1.58831-1.45893,2.35679-2.20683a85.43958,85.43958,0,0,0,25.37276-57.38712c-.97424-.6577-1.97364-1.27419-2.98289-1.90237A82.39644,82.39644,0,0,1,384.1004,626.79762Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#fff"
|
||||
id="path5" />
|
||||
<path
|
||||
d="M648.03621,300.20693V215.13007a49.24034,49.24034,0,0,0-49.24-49.24019H418.54942a49.24029,49.24029,0,0,0-49.2406,49.24V271.632h-3.16709v19.90855h3.16709V312.7763h-3.16709v30.52644h3.16709V356.5751h-3.16709v30.52643h3.16709v294.7669a49.23993,49.23993,0,0,0,49.23995,49.24019H598.79561a49.24028,49.24028,0,0,0,49.2406-49.24V360.76613h3.10552v-60.5592Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#3f3d56"
|
||||
id="path6" />
|
||||
<path
|
||||
d="M600.78268,178.70047H577.2545a17.47031,17.47031,0,0,1-16.17511,24.06836H457.81825a17.4703,17.4703,0,0,1-16.17512-24.06839H419.66775a36.772,36.772,0,0,0-36.772,36.772V681.526a36.772,36.772,0,0,0,36.772,36.77205h181.115a36.772,36.772,0,0,0,36.772-36.772h0V215.47244A36.772,36.772,0,0,0,600.78268,178.70047Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#fff"
|
||||
id="path7" />
|
||||
<path
|
||||
d="M605.33827,340.8917H415.11207a5.0058,5.0058,0,0,1-5-5V258.70616a5.0058,5.0058,0,0,1,5-5h190.2262a5.00573,5.00573,0,0,1,5,5V335.8917A5.00573,5.00573,0,0,1,605.33827,340.8917Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#6c63ff"
|
||||
id="path8" />
|
||||
<path
|
||||
d="M587.22522,377.41807h-154a5.5,5.5,0,0,1,0-11h154a5.5,5.5,0,0,1,0,11Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#6c63ff"
|
||||
id="path9" />
|
||||
<path
|
||||
d="M587.22523,405.41807h-154a6,6,0,0,1,0-12h154a6,6,0,0,1,0,12Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#e4e4e4"
|
||||
id="path10" />
|
||||
<path
|
||||
d="M587.22523,432.91807h-154a6,6,0,0,1,0-12h154a6,6,0,0,1,0,12Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#e4e4e4"
|
||||
id="path11" />
|
||||
<path
|
||||
d="M605.33827,571.8917H415.11207a5.0058,5.0058,0,0,1-5-5V489.70616a5.0058,5.0058,0,0,1,5-5h190.2262a5.00573,5.00573,0,0,1,5,5V566.8917A5.00573,5.00573,0,0,1,605.33827,571.8917Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#e4e4e4"
|
||||
id="path12" />
|
||||
<path
|
||||
d="M587.22523,608.91807h-154a6,6,0,0,1,0-12h154a6,6,0,0,1,0,12Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#e4e4e4"
|
||||
id="path13" />
|
||||
<path
|
||||
d="M587.22523,636.41807h-154a6,6,0,0,1,0-12h154a6,6,0,0,1,0,12Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#e4e4e4"
|
||||
id="path14" />
|
||||
<path
|
||||
d="M587.22523,663.91807h-154a6,6,0,0,1,0-12h154a6,6,0,0,1,0,12Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#e4e4e4"
|
||||
id="path15" />
|
||||
<path
|
||||
d="M760.06605,312.22721c-1.93457-14.18963-4.36084-29.42431-14.3689-39.66754a33.65518,33.65518,0,0,0-48.62622.5033c-7.28515,7.77185-10.50244,18.68475-10.79687,29.33325s2.07714,21.17865,4.708,31.50122a97.0913,97.0913,0,0,0,40.52124-7.97583,65.28916,65.28916,0,0,1,9.71558-3.81427c3.376-.85925,5.78247,1.303,8.92285,2.81073l1.72388-3.30078c1.41113,2.62616,5.78076,1.84772,7.36572-.67737C760.81605,318.41483,760.46888,315.18107,760.06605,312.22721Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#2f2e41"
|
||||
id="path16" />
|
||||
<polygon
|
||||
points="612.434 535.007 602.208 541.77 571.257 505.545 586.349 495.564 612.434 535.007"
|
||||
fill="#9e616a"
|
||||
id="polygon16" />
|
||||
<path
|
||||
d="M896.7595,709.08432,863.787,730.89015l-.27582-.417a15.38729,15.38729,0,0,1,4.34573-21.32122l.00081-.00054,20.13853-13.31819Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#2f2e41"
|
||||
id="path17" />
|
||||
<polygon
|
||||
points="480.429 553.116 468.169 553.116 462.337 505.828 480.431 505.829 480.429 553.116"
|
||||
fill="#9e616a"
|
||||
id="polygon17" />
|
||||
<path
|
||||
d="M758.71777,730.89015l-39.53076-.00146v-.5a15.3873,15.3873,0,0,1,15.38647-15.38623h.001l24.144.001Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#2f2e41"
|
||||
id="path18" />
|
||||
<path
|
||||
d="M668.3639,394.03709l-46.28906-33.06561a8.99743,8.99743,0,1,0-10.80762,7.74816c5.78613,4.85816,48.04785,46.88825,54.09888,44.67127,6.1416-2.25012,32.99341-6.32324,32.99341-6.32324l.74755-25.4953Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#9e616a"
|
||||
id="path19" />
|
||||
<path
|
||||
d="M704.73272,454.19782l.437,58.1781s10.01741,86.201,13.712,100.76318,18.69148,81.94564,18.69148,81.94564l24.3788-3.93292-15.69975-88.09791,4.74535-73.017,27.36445,73.178L847.847,675.848l17.61024-14.2095-60.48051-88.88116-18.47283-72.811s2.29785-37.66031-18.40081-52.16322Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#2f2e41"
|
||||
id="path20" />
|
||||
<circle
|
||||
cx="443.5739"
|
||||
cy="133.65539"
|
||||
r="26.72083"
|
||||
fill="#9e616a"
|
||||
id="circle20" />
|
||||
<rect
|
||||
x="722.98731"
|
||||
y="465.33587"
|
||||
width="24.29166"
|
||||
height="31.57916"
|
||||
transform="translate(-279.66359 789.41207) rotate(-65.86746)"
|
||||
fill="#2f2e41"
|
||||
id="rect20" />
|
||||
<path
|
||||
d="M593.23271,362.65743"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#6c63ff"
|
||||
id="path21" />
|
||||
<path
|
||||
d="M761.53382,350.95884c-3.14892-6.267-4.67895-14.009-11.39209-16.04077-4.5332-1.372-22.86841.68408-27,3-6.87231,3.85236-.64453,11.07111-4.699,17.82642q-6.61121,11.01552-13.22241,22.031c-3.03,5.04852-6.0918,10.16889-7.73023,15.82434-1.63818,5.65546-1.717,12.00305,1.074,17.18756,2.4978,4.64045,7.02294,7.93158,9.53515,12.56433,2.61231,4.81806-2.07715,26.33136-4.50854,31.24341l1.167.539a263.08934,263.08934,0,0,0,48.448-1.63024c3.9873-.50489,8.12744-1.16449,11.41308-3.47895,4.83985-3.40918,6.75318-9.5954,7.949-15.39337A129.67713,129.67713,0,0,0,761.53382,350.95884Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#e4e4e4"
|
||||
id="path22" />
|
||||
<path
|
||||
d="M706.84845,411.65133c7.23924-7.1146,14.51542-14.27181,20.47486-22.48827s10.5936-17.62115,11.88744-27.68835a20.50914,20.50914,0,0,0-.64136-9.62007c-1.11054-3.049-3.56912-5.755-6.73861-6.45068-5.07194-1.11355-9.6829,2.93435-13.30226,6.6577q-16.00732,16.46812-32.01478,32.936,10.19649,13.42191,20.393,26.84353Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#e4e4e4"
|
||||
id="path23" />
|
||||
<path
|
||||
d="M785.75257,417.13127c-2.25-6.14148-6.32324-32.99323-6.32324-32.99323l-25.49512-.74756,12.4646,30.7431-34.01367,47.61615s.063.10462.17749.2912a8.99538,8.99538,0,1,0,7.54468,9.55927.62106.62106,0,0,0,.77978-.13385C744.67176,466.7169,788.00257,423.27281,785.75257,417.13127Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#9e616a"
|
||||
id="path24" />
|
||||
<path
|
||||
d="M788.34461,400.17338c-2.34008-9.87665-4.69751-19.807-8.64282-29.15894s-9.59326-18.18512-17.53711-24.50317a20.50909,20.50909,0,0,0-8.563-4.43085c-3.18359-.62805-6.77148.07483-9.00732,2.42658-3.57813,3.76318-2.50147,9.80365-1.18921,14.8277q5.80444,22.2203,11.60864,44.44061,16.76184-1.77667,33.52344-3.55347Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#e4e4e4"
|
||||
id="path25" />
|
||||
<path
|
||||
d="M752.14124,301.6237c-.83545-6.464-1.708-12.98224-3.67065-19.06879-1.96265-6.08661-5.12622-11.78747-9.66431-15.23547-7.1853-5.459-16.488-4.40613-24.54394-1.266-6.23,2.42846-12.31153,6.1195-16.70484,12.05346-4.39355,5.934-6.86108,14.40119-5.2268,22.1601q12.88989-3.58722,25.77954-7.1745l-.94068.783c5.57642,3.14221,9.81153,9.64361,11.07691,17.00482a28.7171,28.7171,0,0,1-4.53662,21.03778q8.79089-3.67337,17.58178-7.34662c3.61744-1.51153,7.489-3.25317,9.634-7.13025C753.41273,312.94608,752.83485,306.98814,752.14124,301.6237Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#2f2e41"
|
||||
id="path26" />
|
||||
<path
|
||||
d="M625.98113,343.51431,608.792,369.31226a4.46863,4.46863,0,0,1-3.75549,2.00125,4.47943,4.47943,0,0,1-4.13509-2.75491,4.12763,4.12763,0,0,1-.2689-.85745,4.51165,4.51165,0,0,1,.66976-3.37929l17.18913-25.79794a4.5,4.5,0,1,1,7.48973,4.99039Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#6c63ff"
|
||||
id="path27" />
|
||||
<path
|
||||
d="M610.17821,367.23178l-3.47923,5.19091-6.15652,5.42689a2.45095,2.45095,0,0,1-3.94221-2.627l2.69471-7.8881,3.39353-5.09311Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#3f3d56"
|
||||
id="path28" />
|
||||
<path
|
||||
d="M626.74053,329.98545l-8.6142,7.59289a2.45233,2.45233,0,0,0,.26168,3.88081l1.62984,1.086-4.71315,7.07363a1,1,0,0,0,1.66439,1.109l4.71314-7.07362,1.62985,1.086a2.45552,2.45552,0,0,0,3.39872-.675,2.46816,2.46816,0,0,0,.28357-.57793l3.69013-10.8738a2.45251,2.45251,0,0,0-3.944-2.62786Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#3f3d56"
|
||||
id="path29" />
|
||||
<path
|
||||
d="M516.97522,187.41807h-27a2,2,0,0,1,0-4h27a2,2,0,0,1,0,4Z"
|
||||
transform="translate(-275.16231 -165.88988)"
|
||||
fill="#fff"
|
||||
id="path31" />
|
||||
<circle
|
||||
cx="255.31291"
|
||||
cy="19.52819"
|
||||
r="2"
|
||||
fill="#fff"
|
||||
id="circle31" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
235
public/zaps.svg
Normal file
235
public/zaps.svg
Normal file
@@ -0,0 +1,235 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="720.44"
|
||||
height="718.635"
|
||||
viewBox="0 0 720.44 718.635"
|
||||
role="img"
|
||||
artist="Katerina Limpitsouni"
|
||||
source="https://undraw.co/"
|
||||
version="1.1"
|
||||
id="svg30"
|
||||
sodipodi:docname="zaps.svg"
|
||||
xml:space="preserve"
|
||||
inkscape:version="1.4.2 (ebf0e940, 2025-05-08)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs30" /><sodipodi:namedview
|
||||
id="namedview30"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="2.6746164"
|
||||
inkscape:cx="38.510195"
|
||||
inkscape:cy="485.67712"
|
||||
inkscape:window-width="3840"
|
||||
inkscape:window-height="1027"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="25"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="g27" /><g
|
||||
transform="translate(-600 -181)"
|
||||
id="g30"><g
|
||||
transform="translate(783.85 181)"
|
||||
id="g2"><path
|
||||
d="M624.7,249.968h-3.952V141.8a62.6,62.6,0,0,0-62.6-62.6H328.97a62.6,62.6,0,0,0-62.6,62.6V735.225a62.6,62.6,0,0,0,62.6,62.6H558.143a62.6,62.6,0,0,0,62.6-62.6V326.965H624.7Z"
|
||||
transform="translate(-266.365 -79.193)"
|
||||
fill="#090814"
|
||||
id="path1" /><path
|
||||
d="M560.888,95.686H530.974a22.212,22.212,0,0,1-20.565,30.6h-131.3a22.212,22.212,0,0,1-20.566-30.6H330.607a46.752,46.752,0,0,0-46.752,46.752V735a46.752,46.752,0,0,0,46.752,46.752H560.879A46.752,46.752,0,0,0,607.63,735V142.439a46.752,46.752,0,0,0-46.744-46.752Z"
|
||||
transform="translate(-266.577 -79.397)"
|
||||
fill="#fff"
|
||||
id="path2" /></g><path
|
||||
d="M8,0H256a8,8,0,0,1,8,8V72a8,8,0,0,1-8,8H8a8,8,0,0,1-8-8V8A8,8,0,0,1,8,0Z"
|
||||
transform="translate(828 265)"
|
||||
fill="#f2f2f2"
|
||||
id="path3" /><path
|
||||
d="M8,0H256a8.065,8.065,0,0,1,8,8.128V475.474a8.065,8.065,0,0,1-8,8.128H8a8.065,8.065,0,0,1-8-8.128V8.128A8.065,8.065,0,0,1,8,0Z"
|
||||
transform="translate(828 358.398)"
|
||||
fill="#f2f2f2"
|
||||
id="path4" /><g
|
||||
transform="translate(623.104 296.398)"
|
||||
id="g9"><rect
|
||||
width="278.304"
|
||||
height="69.313"
|
||||
rx="16"
|
||||
transform="translate(0 0)"
|
||||
fill="#090814"
|
||||
id="rect4" /><rect
|
||||
width="272.003"
|
||||
height="63.012"
|
||||
rx="15"
|
||||
transform="translate(3.151 3.151)"
|
||||
fill="#fff"
|
||||
id="rect5" /><path
|
||||
d="M301.207,370.636a2.238,2.238,0,0,1-1.791-.9l-5.489-7.318a2.238,2.238,0,0,1,3.581-2.686l3.591,4.788,9.223-13.834a2.238,2.238,0,0,1,3.725,2.483L303.07,369.639a2.239,2.239,0,0,1-1.8,1Z"
|
||||
transform="translate(-53.047 -325.676)"
|
||||
fill="#6c63ff"
|
||||
id="path5" /><g
|
||||
transform="translate(17.038 13.546)"
|
||||
id="g7"><path
|
||||
d="M8.377,0H33.509a8.377,8.377,0,0,1,8.377,8.377V33.509a8.377,8.377,0,0,1-8.377,8.377H8.377A8.377,8.377,0,0,1,0,33.509V8.377A8.377,8.377,0,0,1,8.377,0Z"
|
||||
transform="translate(0 0)"
|
||||
fill="#6c63ff"
|
||||
id="path6" /><path
|
||||
fill="#ffffff"
|
||||
d="m 29.707386,18.657657 c 0.366641,-2.450824 -1.499389,-3.768325 -4.05094,-4.647244 l 0.827682,-3.319949 -2.020864,-0.503633 -0.805812,3.232462 C 23.126191,13.286911 22.580541,13.16201 22.038338,13.038257 L 22.849909,9.7844991 20.830193,9.2808662 20.001937,12.599667 c -0.439747,-0.100152 -0.87143,-0.199148 -1.290455,-0.303328 l 0.0023,-0.0104 -2.786965,-0.695882 -0.537594,2.158431 c 0,0 1.49939,0.343622 1.467733,0.364918 0.818479,0.204332 0.966398,0.745954 0.941649,1.17534 l -0.942797,3.782139 c 0.05642,0.01436 0.129503,0.03508 0.210083,0.06736 -0.06736,-0.01669 -0.13929,-0.03516 -0.213537,-0.05293 l -1.321536,5.29823 c -0.100152,0.248646 -0.353983,0.621627 -0.926112,0.480032 0.02018,0.02934 -1.468882,-0.366648 -1.468882,-0.366648 l -1.003261,2.313258 2.629833,0.65558 c 0.489237,0.122604 0.968703,0.250959 1.44068,0.371832 l -0.83632,3.357938 2.018559,0.503633 0.828264,-3.322254 c 0.551408,0.14965 1.086697,0.287791 1.610477,0.417869 l -0.825385,3.306717 2.020864,0.503632 0.83632,-3.351612 c 3.446006,0.652134 6.037269,0.3891 7.127993,-2.727673 0.878911,-2.509534 -0.04377,-3.957121 -1.856825,-4.901074 1.320388,-0.304485 2.314988,-1.173035 2.580335,-2.967123 z m -4.61731,6.474711 c -0.624507,2.509534 -4.849846,1.152888 -6.219732,0.812719 l 1.109723,-4.448662 c 1.369878,0.341891 5.762717,1.018775 5.110009,3.635943 z m 0.62508,-6.510969 c -0.569824,2.28275 -4.086632,1.122955 -5.227429,0.838617 l 1.006118,-4.034821 c 1.140796,0.284338 4.814736,0.815024 4.221311,3.196204 z"
|
||||
id="path2-8-2"
|
||||
style="stroke-width:0.575581" /></g><path
|
||||
d="M6.981,0h125.66a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||
transform="translate(72.886 17.036)"
|
||||
fill="#e6e6e6"
|
||||
id="path8" /><path
|
||||
d="M6.981,0H76.792a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||
transform="translate(72.886 37.98)"
|
||||
fill="#e6e6e6"
|
||||
id="path9" /></g><g
|
||||
transform="translate(1003.278 402.469)"
|
||||
id="g14"><rect
|
||||
width="279.354"
|
||||
height="69.313"
|
||||
rx="16"
|
||||
transform="translate(0 0)"
|
||||
fill="#090814"
|
||||
id="rect9" /><rect
|
||||
width="272.003"
|
||||
height="63.012"
|
||||
rx="15"
|
||||
transform="translate(3.151 3.151)"
|
||||
fill="#fff"
|
||||
id="rect10" /><path
|
||||
d="M301.207,370.636a2.238,2.238,0,0,1-1.791-.9l-5.489-7.318a2.238,2.238,0,0,1,3.581-2.686l3.591,4.788,9.223-13.834a2.238,2.238,0,0,1,3.725,2.483L303.07,369.639a2.239,2.239,0,0,1-1.8,1Z"
|
||||
transform="translate(-52.751 -325.287)"
|
||||
fill="#6c63ff"
|
||||
id="path10" /><g
|
||||
transform="translate(17.334 13.936)"
|
||||
id="g12"><path
|
||||
d="M8.377,0H33.509a8.377,8.377,0,0,1,8.377,8.377V33.509a8.377,8.377,0,0,1-8.377,8.377H8.377A8.377,8.377,0,0,1,0,33.509V8.377A8.377,8.377,0,0,1,8.377,0Z"
|
||||
transform="translate(0 0)"
|
||||
fill="#6c63ff"
|
||||
id="path11" /><path
|
||||
fill="#ffffff"
|
||||
d="m 29.707386,18.657657 c 0.366641,-2.450824 -1.499389,-3.768325 -4.05094,-4.647244 l 0.827682,-3.319949 -2.020864,-0.503633 -0.805812,3.232462 C 23.126191,13.286911 22.580541,13.16201 22.038338,13.038257 l 0.811571,-3.253758 -2.019716,-0.503633 -0.828256,3.318801 c -0.439747,-0.100152 -0.87143,-0.199148 -1.290455,-0.303328 l 0.0023,-0.0104 -2.786965,-0.695882 -0.537594,2.158431 c 0,0 1.49939,0.343622 1.467733,0.364918 0.818479,0.204332 0.966398,0.745954 0.941649,1.17534 l -0.942797,3.782139 c 0.05642,0.01436 0.129503,0.03508 0.210083,0.06736 -0.06736,-0.01669 -0.13929,-0.03516 -0.213537,-0.05293 l -1.321536,5.29823 c -0.100152,0.248646 -0.353983,0.621627 -0.926112,0.480032 0.02018,0.02934 -1.468882,-0.366648 -1.468882,-0.366648 l -1.003261,2.313258 2.629833,0.65558 c 0.489237,0.122604 0.968703,0.250959 1.44068,0.371832 l -0.83632,3.357938 2.018559,0.503633 0.828264,-3.322254 c 0.551408,0.14965 1.086697,0.287791 1.610477,0.417869 l -0.825385,3.306717 2.020864,0.503632 0.83632,-3.351612 c 3.446006,0.652134 6.037269,0.3891 7.127993,-2.727673 0.878911,-2.509534 -0.04377,-3.957121 -1.856825,-4.901074 1.320388,-0.304485 2.314988,-1.173035 2.580335,-2.967123 z m -4.61731,6.474711 c -0.624507,2.509534 -4.849846,1.152888 -6.219732,0.812719 l 1.109723,-4.448662 c 1.369878,0.341891 5.762717,1.018775 5.110009,3.635943 z m 0.62508,-6.510969 c -0.569824,2.28275 -4.086632,1.122955 -5.227429,0.838617 l 1.006118,-4.034821 c 1.140796,0.284338 4.814736,0.815024 4.221311,3.196204 z"
|
||||
id="path2-8-2-7"
|
||||
style="stroke-width:0.575581" /></g><path
|
||||
d="M6.981,0h125.66a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||
transform="translate(73.181 17.426)"
|
||||
fill="#e6e6e6"
|
||||
id="path13" /><path
|
||||
d="M6.981,0H76.792a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||
transform="translate(73.181 38.369)"
|
||||
fill="#e6e6e6"
|
||||
id="path14" /></g><g
|
||||
transform="translate(663.012 510.639)"
|
||||
id="g19"><rect
|
||||
width="279.354"
|
||||
height="69.313"
|
||||
rx="16"
|
||||
transform="translate(0 0)"
|
||||
fill="#090814"
|
||||
id="rect14" /><rect
|
||||
width="272.003"
|
||||
height="63.012"
|
||||
rx="15"
|
||||
transform="translate(3.151 3.151)"
|
||||
fill="#fff"
|
||||
id="rect15" /><path
|
||||
d="M301.207,370.636a2.238,2.238,0,0,1-1.791-.9l-5.489-7.318a2.238,2.238,0,0,1,3.581-2.686l3.591,4.788,9.223-13.834a2.238,2.238,0,0,1,3.725,2.483L303.07,369.639a2.239,2.239,0,0,1-1.8,1Z"
|
||||
transform="translate(-52.814 -325.25)"
|
||||
fill="#6c63ff"
|
||||
id="path15" /><g
|
||||
transform="translate(17.272 13.972)"
|
||||
id="g17"><path
|
||||
d="M8.377,0H33.509a8.377,8.377,0,0,1,8.377,8.377V33.509a8.377,8.377,0,0,1-8.377,8.377H8.377A8.377,8.377,0,0,1,0,33.509V8.377A8.377,8.377,0,0,1,8.377,0Z"
|
||||
transform="translate(0 0)"
|
||||
fill="#6c63ff"
|
||||
id="path16" /><path
|
||||
fill="#ffffff"
|
||||
d="m 29.707386,18.657657 c 0.366641,-2.450824 -1.499389,-3.768325 -4.05094,-4.647244 l 0.827682,-3.319949 -2.020864,-0.503633 -0.805812,3.232462 C 23.126191,13.286911 22.580541,13.16201 22.038338,13.038257 L 22.849909,9.784499 20.830193,9.2808661 20.001937,12.599667 c -0.439747,-0.100152 -0.87143,-0.199148 -1.290455,-0.303328 l 0.0023,-0.0104 -2.786965,-0.695882 -0.537594,2.158431 c 0,0 1.49939,0.343622 1.467733,0.364918 0.818479,0.204332 0.966398,0.745954 0.941649,1.17534 l -0.942797,3.782139 c 0.05642,0.01436 0.129503,0.03508 0.210083,0.06736 -0.06736,-0.01669 -0.13929,-0.03516 -0.213537,-0.05293 l -1.321536,5.29823 c -0.100152,0.248646 -0.353983,0.621627 -0.926112,0.480032 0.02018,0.02934 -1.468882,-0.366648 -1.468882,-0.366648 l -1.003261,2.313258 2.629833,0.65558 c 0.489237,0.122604 0.968703,0.250959 1.44068,0.371832 l -0.83632,3.357938 2.018559,0.503633 0.828264,-3.322254 c 0.551408,0.14965 1.086697,0.287791 1.610477,0.417869 l -0.825385,3.306717 2.020864,0.503632 0.83632,-3.351612 c 3.446006,0.652134 6.037269,0.3891 7.127993,-2.727673 0.878911,-2.509534 -0.04377,-3.957121 -1.856825,-4.901074 1.320388,-0.304485 2.314988,-1.173035 2.580335,-2.967123 z m -4.61731,6.474711 c -0.624507,2.509534 -4.849846,1.152888 -6.219732,0.812719 l 1.109723,-4.448662 c 1.369878,0.341891 5.762717,1.018775 5.110009,3.635943 z m 0.62508,-6.510969 c -0.569824,2.28275 -4.086632,1.122955 -5.227429,0.838617 l 1.006118,-4.034821 c 1.140796,0.284338 4.814736,0.815024 4.221311,3.196204 z"
|
||||
id="path2-8-2-0"
|
||||
style="stroke-width:0.575581" /></g><path
|
||||
d="M6.981,0h125.66a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||
transform="translate(73.119 17.463)"
|
||||
fill="#e6e6e6"
|
||||
id="path18" /><path
|
||||
d="M6.981,0H76.792a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||
transform="translate(73.119 38.406)"
|
||||
fill="#e6e6e6"
|
||||
id="path19" /></g><g
|
||||
transform="translate(1041.086 616.711)"
|
||||
id="g24"><rect
|
||||
width="279.354"
|
||||
height="70.364"
|
||||
rx="16"
|
||||
transform="translate(0 0)"
|
||||
fill="#090814"
|
||||
id="rect19" /><rect
|
||||
width="272.003"
|
||||
height="63.012"
|
||||
rx="15"
|
||||
transform="translate(4.201 4.201)"
|
||||
fill="#fff"
|
||||
id="rect20" /><path
|
||||
d="M301.207,370.636a2.238,2.238,0,0,1-1.791-.9l-5.489-7.318a2.238,2.238,0,0,1,3.581-2.686l3.591,4.788,9.223-13.834a2.238,2.238,0,0,1,3.725,2.483L303.07,369.639a2.239,2.239,0,0,1-1.8,1Z"
|
||||
transform="translate(-52.163 -324.86)"
|
||||
fill="#6c63ff"
|
||||
id="path20" /><g
|
||||
transform="translate(17.922 14.362)"
|
||||
id="g22"><path
|
||||
d="M8.377,0H33.509a8.377,8.377,0,0,1,8.377,8.377V33.509a8.377,8.377,0,0,1-8.377,8.377H8.377A8.377,8.377,0,0,1,0,33.509V8.377A8.377,8.377,0,0,1,8.377,0Z"
|
||||
transform="translate(0 0)"
|
||||
fill="#6c63ff"
|
||||
id="path21" /><path
|
||||
fill="#ffffff"
|
||||
d="m 29.707386,18.657657 c 0.366641,-2.450824 -1.499389,-3.768325 -4.05094,-4.647244 l 0.827682,-3.319949 -2.020864,-0.503633 -0.805812,3.232462 C 23.126191,13.286911 22.580541,13.16201 22.038338,13.038257 L 22.849909,9.784499 20.830193,9.2808661 20.001937,12.599667 c -0.439747,-0.100152 -0.87143,-0.199148 -1.290455,-0.303328 l 0.0023,-0.0104 -2.786965,-0.695882 -0.537594,2.158431 c 0,0 1.49939,0.343622 1.467733,0.364918 0.818479,0.204332 0.966398,0.745954 0.941649,1.17534 l -0.942797,3.782139 c 0.05642,0.01436 0.129503,0.03508 0.210083,0.06736 -0.06736,-0.01669 -0.13929,-0.03516 -0.213537,-0.05293 l -1.321536,5.29823 c -0.100152,0.248646 -0.353983,0.621627 -0.926112,0.480032 0.02018,0.02934 -1.468882,-0.366648 -1.468882,-0.366648 l -1.003261,2.313258 2.629833,0.65558 c 0.489237,0.122604 0.968703,0.250959 1.44068,0.371832 l -0.83632,3.357938 2.018559,0.503633 0.828264,-3.322254 c 0.551408,0.14965 1.086697,0.287791 1.610477,0.417869 l -0.825385,3.306717 2.020864,0.503632 0.83632,-3.351612 c 3.446006,0.652134 6.037269,0.3891 7.127993,-2.727673 0.878911,-2.509534 -0.04377,-3.957121 -1.856825,-4.901074 1.320388,-0.304485 2.314988,-1.173035 2.580335,-2.967123 z m -4.61731,6.474711 c -0.624507,2.509534 -4.849846,1.152888 -6.219732,0.812719 l 1.109723,-4.448662 c 1.369878,0.341891 5.762717,1.018775 5.110009,3.635943 z m 0.62508,-6.510969 c -0.569824,2.28275 -4.086632,1.122955 -5.227429,0.838617 l 1.006118,-4.034821 c 1.140796,0.284338 4.814736,0.815024 4.221311,3.196204 z"
|
||||
id="path2-8-2-9"
|
||||
style="stroke-width:0.575581" /></g><path
|
||||
d="M6.981,0h125.66a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||
transform="translate(73.77 17.853)"
|
||||
fill="#e6e6e6"
|
||||
id="path23" /><path
|
||||
d="M6.981,0H76.792a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||
transform="translate(73.77 38.796)"
|
||||
fill="#e6e6e6"
|
||||
id="path24" /></g><g
|
||||
transform="translate(600 723.832)"
|
||||
id="g29"><rect
|
||||
width="279.354"
|
||||
height="69.313"
|
||||
rx="16"
|
||||
transform="translate(0 0)"
|
||||
fill="#090814"
|
||||
id="rect24" /><rect
|
||||
width="273.053"
|
||||
height="63.012"
|
||||
rx="15"
|
||||
transform="translate(3.151 3.151)"
|
||||
fill="#fff"
|
||||
id="rect25" /><path
|
||||
d="M301.207,370.636a2.238,2.238,0,0,1-1.791-.9l-5.489-7.318a2.238,2.238,0,0,1,3.581-2.686l3.591,4.788,9.223-13.834a2.238,2.238,0,0,1,3.725,2.483L303.07,369.639a2.239,2.239,0,0,1-1.8,1Z"
|
||||
transform="translate(-52.631 -325.518)"
|
||||
fill="#6c63ff"
|
||||
id="path25" /><g
|
||||
transform="translate(17.454 13.704)"
|
||||
id="g27"><path
|
||||
d="M8.377,0H33.509a8.377,8.377,0,0,1,8.377,8.377V33.509a8.377,8.377,0,0,1-8.377,8.377H8.377A8.377,8.377,0,0,1,0,33.509V8.377A8.377,8.377,0,0,1,8.377,0Z"
|
||||
transform="translate(0 0)"
|
||||
fill="#6c63ff"
|
||||
id="path26" /><path
|
||||
fill="#ffffff"
|
||||
d="m 29.707386,18.657657 c 0.366641,-2.450824 -1.499389,-3.768325 -4.05094,-4.647244 l 0.827682,-3.319949 -2.020864,-0.503633 -0.805812,3.232462 C 23.126191,13.286911 22.580541,13.16201 22.038338,13.038257 l 0.811571,-3.253758 -2.019716,-0.503633 -0.828256,3.318801 c -0.439747,-0.100152 -0.87143,-0.199148 -1.290455,-0.303328 l 0.0023,-0.0104 -2.786965,-0.695882 -0.537594,2.158431 c 0,0 1.49939,0.343622 1.467733,0.364918 0.818479,0.204332 0.966398,0.745954 0.941649,1.17534 l -0.942797,3.782139 c 0.05642,0.01436 0.129503,0.03508 0.210083,0.06736 -0.06736,-0.01669 -0.13929,-0.03516 -0.213537,-0.05293 l -1.321536,5.29823 c -0.100152,0.248646 -0.353983,0.621627 -0.926112,0.480032 0.02018,0.02934 -1.468882,-0.366648 -1.468882,-0.366648 l -1.003261,2.313258 2.629833,0.65558 c 0.489237,0.122604 0.968703,0.250959 1.44068,0.371832 l -0.83632,3.357938 2.018559,0.503633 0.828264,-3.322254 c 0.551408,0.14965 1.086697,0.287791 1.610477,0.417869 l -0.825385,3.306717 2.020864,0.503632 0.83632,-3.351612 c 3.446006,0.652134 6.037269,0.3891 7.127993,-2.727673 0.878911,-2.509534 -0.04377,-3.957121 -1.856825,-4.901074 1.320388,-0.304485 2.314988,-1.173035 2.580335,-2.967123 z m -4.61731,6.474711 c -0.624507,2.509534 -4.849846,1.152888 -6.219732,0.812719 l 1.109723,-4.448662 c 1.369878,0.341891 5.762717,1.018775 5.110009,3.635943 z m 0.62508,-6.510969 c -0.569824,2.28275 -4.086632,1.122955 -5.227429,0.838617 l 1.006118,-4.034821 c 1.140796,0.284338 4.814736,0.815024 4.221311,3.196204 z"
|
||||
id="path2-8-2-98"
|
||||
style="stroke-width:0.575581" /></g><path
|
||||
d="M6.981,0h125.66a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||
transform="translate(73.301 17.194)"
|
||||
fill="#e6e6e6"
|
||||
id="path28" /><path
|
||||
d="M6.981,0H76.792a6.981,6.981,0,1,1,0,13.962H6.981A6.981,6.981,0,0,1,6.981,0Z"
|
||||
transform="translate(73.301 38.138)"
|
||||
fill="#e6e6e6"
|
||||
id="path29" /></g></g></svg>
|
||||
|
After Width: | Height: | Size: 17 KiB |
41
src/components/ArchiveFilters.tsx
Normal file
41
src/components/ArchiveFilters.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faBookOpen, faCheckCircle, faAsterisk } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faBookmark } from '@fortawesome/free-regular-svg-icons'
|
||||
import { faBooks } from '../icons/customIcons'
|
||||
|
||||
export type ArchiveFilterType = 'all' | 'to-read' | 'reading' | 'completed' | 'marked'
|
||||
|
||||
interface ArchiveFiltersProps {
|
||||
selectedFilter: ArchiveFilterType
|
||||
onFilterChange: (filter: ArchiveFilterType) => void
|
||||
}
|
||||
|
||||
const ArchiveFilters: React.FC<ArchiveFiltersProps> = ({ selectedFilter, onFilterChange }) => {
|
||||
const filters = [
|
||||
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
|
||||
{ type: 'to-read' as const, icon: faBookmark, label: 'To Read' },
|
||||
{ type: 'reading' as const, icon: faBookOpen, label: 'Reading' },
|
||||
{ type: 'completed' as const, icon: faCheckCircle, label: 'Completed' },
|
||||
{ type: 'marked' as const, icon: faBooks, label: 'Marked as Read' }
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bookmark-filters">
|
||||
{filters.map(filter => (
|
||||
<button
|
||||
key={filter.type}
|
||||
onClick={() => onFilterChange(filter.type)}
|
||||
className={`filter-btn ${selectedFilter === filter.type ? 'active' : ''}`}
|
||||
title={filter.label}
|
||||
aria-label={`Filter by ${filter.label}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={filter.icon} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ArchiveFilters
|
||||
|
||||
@@ -11,9 +11,10 @@ interface BlogPostCardProps {
|
||||
post: BlogPostPreview
|
||||
href: string
|
||||
level?: 'mine' | 'friends' | 'nostrverse'
|
||||
readingProgress?: number // 0-1 reading progress (optional)
|
||||
}
|
||||
|
||||
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level }) => {
|
||||
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level, readingProgress }) => {
|
||||
const profile = useEventModel(Models.ProfileModel, [post.author])
|
||||
const displayName = profile?.name || profile?.display_name ||
|
||||
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
|
||||
@@ -23,6 +24,10 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level }) => {
|
||||
addSuffix: true
|
||||
})
|
||||
|
||||
// Calculate progress percentage and determine color
|
||||
const progressPercent = readingProgress ? Math.round(readingProgress * 100) : 0
|
||||
const progressColor = progressPercent >= 95 ? '#10b981' : '#6366f1' // green if >=95%, blue otherwise
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
@@ -47,7 +52,37 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level }) => {
|
||||
{post.summary && (
|
||||
<p className="blog-post-card-summary">{post.summary}</p>
|
||||
)}
|
||||
<div className="blog-post-card-meta">
|
||||
|
||||
{/* Reading progress indicator - replaces the dividing line */}
|
||||
{readingProgress !== undefined && readingProgress > 0 ? (
|
||||
<div
|
||||
className="blog-post-reading-progress"
|
||||
style={{
|
||||
height: '3px',
|
||||
width: '100%',
|
||||
background: 'var(--color-border)',
|
||||
overflow: 'hidden',
|
||||
marginTop: '1rem'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: `${progressPercent}%`,
|
||||
background: progressColor,
|
||||
transition: 'width 0.3s ease, background 0.3s ease'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
height: '1px',
|
||||
background: 'var(--color-border)',
|
||||
marginTop: '1rem'
|
||||
}} />
|
||||
)}
|
||||
|
||||
<div className="blog-post-card-meta" style={{ borderTop: 'none', paddingTop: '0.75rem' }}>
|
||||
<span className="blog-post-card-author">
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
{displayName}
|
||||
|
||||
44
src/components/BookmarkFilters.tsx
Normal file
44
src/components/BookmarkFilters.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faNewspaper, faStickyNote, faCirclePlay } from '@fortawesome/free-regular-svg-icons'
|
||||
import { faGlobe, faAsterisk, faLink } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
export type BookmarkFilterType = 'all' | 'article' | 'external' | 'video' | 'note' | 'web'
|
||||
|
||||
interface BookmarkFiltersProps {
|
||||
selectedFilter: BookmarkFilterType
|
||||
onFilterChange: (filter: BookmarkFilterType) => void
|
||||
}
|
||||
|
||||
const BookmarkFilters: React.FC<BookmarkFiltersProps> = ({
|
||||
selectedFilter,
|
||||
onFilterChange
|
||||
}) => {
|
||||
const filters = [
|
||||
{ type: 'all' as const, icon: faAsterisk, label: 'All' },
|
||||
{ type: 'article' as const, icon: faNewspaper, label: 'Articles' },
|
||||
{ type: 'external' as const, icon: faLink, label: 'External Articles' },
|
||||
{ type: 'video' as const, icon: faCirclePlay, label: 'Videos' },
|
||||
{ type: 'note' as const, icon: faStickyNote, label: 'Notes' },
|
||||
{ type: 'web' as const, icon: faGlobe, label: 'Web' }
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bookmark-filters">
|
||||
{filters.map(filter => (
|
||||
<button
|
||||
key={filter.type}
|
||||
onClick={() => onFilterChange(filter.type)}
|
||||
className={`filter-btn ${selectedFilter === filter.type ? 'active' : ''}`}
|
||||
title={filter.label}
|
||||
aria-label={`Filter by ${filter.label}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={filter.icon} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BookmarkFilters
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useState } from 'react'
|
||||
import { faBookOpen, faPlay, faEye } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faNewspaper, faStickyNote, faCirclePlay, faCamera, faFileLines } from '@fortawesome/free-regular-svg-icons'
|
||||
import { faGlobe, faLink } from '@fortawesome/free-solid-svg-icons'
|
||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
import { npubEncode, neventEncode } from 'nostr-tools/nip19'
|
||||
@@ -66,18 +68,41 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
return short(bookmark.pubkey) // fallback to short pubkey
|
||||
}
|
||||
|
||||
// use helper from kindIcon.ts
|
||||
// Get content type icon based on bookmark kind and URL classification
|
||||
const getContentTypeIcon = (): IconDefinition => {
|
||||
if (isArticle) return faNewspaper // Nostr-native article
|
||||
|
||||
// For web bookmarks, classify the URL to determine icon
|
||||
if (isWebBookmark && firstUrlClassification) {
|
||||
switch (firstUrlClassification.type) {
|
||||
case 'youtube':
|
||||
case 'video':
|
||||
return faCirclePlay
|
||||
case 'image':
|
||||
return faCamera
|
||||
case 'article':
|
||||
return faLink // External article
|
||||
default:
|
||||
return faGlobe
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasUrls) return faStickyNote // Just a text note
|
||||
if (firstUrlClassification?.type === 'youtube' || firstUrlClassification?.type === 'video') return faCirclePlay
|
||||
if (firstUrlClassification?.type === 'article') return faLink // External article
|
||||
return faFileLines
|
||||
}
|
||||
|
||||
const getIconForUrlType = (url: string) => {
|
||||
const classification = classifyUrl(url)
|
||||
switch (classification.type) {
|
||||
case 'youtube':
|
||||
case 'video':
|
||||
return faPlay
|
||||
return faCirclePlay
|
||||
case 'image':
|
||||
return faEye
|
||||
return faCamera
|
||||
default:
|
||||
return faBookOpen
|
||||
return faFileLines
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,11 +138,14 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleImage,
|
||||
articleSummary
|
||||
articleSummary,
|
||||
contentTypeIcon: getContentTypeIcon()
|
||||
}
|
||||
|
||||
if (viewMode === 'compact') {
|
||||
return <CompactView {...sharedProps} />
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
|
||||
const { articleImage, ...compactProps } = sharedProps
|
||||
return <CompactView {...compactProps} />
|
||||
}
|
||||
|
||||
if (viewMode === 'large') {
|
||||
@@ -125,5 +153,5 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
return <LargeView {...sharedProps} getIconForUrlType={getIconForUrlType} previewImage={previewImage} />
|
||||
}
|
||||
|
||||
return <CardView {...sharedProps} getIconForUrlType={getIconForUrlType} articleImage={articleImage} />
|
||||
return <CardView {...sharedProps} articleImage={articleImage} />
|
||||
}
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
import React, { useRef } from 'react'
|
||||
import React, { useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronLeft, faBookmark, faList, faThLarge, faImage, faRotate, faHeart, faPlus } from '@fortawesome/free-solid-svg-icons'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||
import { BookmarkItem } from './BookmarkItem'
|
||||
import SidebarHeader from './SidebarHeader'
|
||||
import IconButton from './IconButton'
|
||||
import CompactButton from './CompactButton'
|
||||
import { ViewMode } from './Bookmarks'
|
||||
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||
import RefreshIndicator from './RefreshIndicator'
|
||||
import { BookmarkSkeleton } from './Skeletons'
|
||||
import { groupIndividualBookmarks, hasContent, getBookmarkSets, getBookmarksWithoutSet } from '../utils/bookmarkUtils'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import AddBookmarkModal from './AddBookmarkModal'
|
||||
import { createWebBookmark } from '../services/webBookmarkService'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||
|
||||
interface BookmarkListProps {
|
||||
bookmarks: Bookmark[]
|
||||
@@ -29,6 +38,7 @@ interface BookmarkListProps {
|
||||
loading?: boolean
|
||||
relayPool: RelayPool | null
|
||||
isMobile?: boolean
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
@@ -46,9 +56,23 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
lastFetchTime,
|
||||
loading = false,
|
||||
relayPool,
|
||||
isMobile = false
|
||||
isMobile = false,
|
||||
settings
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const bookmarksListRef = useRef<HTMLDivElement>(null)
|
||||
const friendsColor = settings?.highlightColorFriends || '#f97316'
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all')
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
|
||||
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
|
||||
if (!activeAccount || !relayPool) {
|
||||
throw new Error('Please login to create bookmarks')
|
||||
}
|
||||
|
||||
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
|
||||
}
|
||||
|
||||
// Pull-to-refresh for bookmarks
|
||||
const { isRefreshing: isPulling, pullPosition } = usePullToRefresh({
|
||||
@@ -62,36 +86,34 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
isDisabled: !onRefresh
|
||||
})
|
||||
|
||||
// Helper to check if a bookmark has either content or a URL
|
||||
const hasContentOrUrl = (ib: IndividualBookmark) => {
|
||||
// Check if has content (text)
|
||||
const hasContent = ib.content && ib.content.trim().length > 0
|
||||
|
||||
// Check if has URL
|
||||
let hasUrl = false
|
||||
|
||||
// For web bookmarks (kind:39701), URL is in the 'd' tag
|
||||
if (ib.kind === 39701) {
|
||||
const dTag = ib.tags?.find((t: string[]) => t[0] === 'd')?.[1]
|
||||
hasUrl = !!dTag && dTag.trim().length > 0
|
||||
} else {
|
||||
// For other bookmarks, extract URLs from content
|
||||
const urls = extractUrlsFromContent(ib.content || '')
|
||||
hasUrl = urls.length > 0
|
||||
}
|
||||
|
||||
// Always show articles (kind:30023) as they have special handling
|
||||
if (ib.kind === 30023) return true
|
||||
|
||||
// Otherwise, must have either content or URL
|
||||
return hasContent || hasUrl
|
||||
}
|
||||
|
||||
// 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(hasContentOrUrl)
|
||||
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
|
||||
.filter(hasContent)
|
||||
|
||||
// Apply filter
|
||||
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
|
||||
|
||||
// Separate bookmarks with setName (kind 30003) from regular bookmarks
|
||||
const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
|
||||
const bookmarkSets = getBookmarkSets(filteredBookmarks)
|
||||
|
||||
// Group non-set bookmarks as before
|
||||
const groups = groupIndividualBookmarks(bookmarksWithoutSet)
|
||||
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
|
||||
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
|
||||
{ key: 'public', title: 'Public Bookmarks', items: groups.publicItems },
|
||||
{ key: 'web', title: 'Web Bookmarks', items: groups.web },
|
||||
{ key: 'amethyst', title: 'Legacy Bookmarks', items: groups.amethyst }
|
||||
]
|
||||
|
||||
// Add bookmark sets as additional sections
|
||||
bookmarkSets.forEach(set => {
|
||||
sections.push({
|
||||
key: `set-${set.name}`,
|
||||
title: set.title || set.name,
|
||||
items: set.bookmarks
|
||||
})
|
||||
})
|
||||
|
||||
if (isCollapsed) {
|
||||
// Check if the selected URL is in bookmarks
|
||||
@@ -121,11 +143,21 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onLogout={onLogout}
|
||||
onOpenSettings={onOpenSettings}
|
||||
relayPool={relayPool}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
|
||||
{allIndividualBookmarks.length === 0 ? (
|
||||
{allIndividualBookmarks.length > 0 && (
|
||||
<BookmarkFilters
|
||||
selectedFilter={selectedFilter}
|
||||
onFilterChange={setSelectedFilter}
|
||||
/>
|
||||
)}
|
||||
|
||||
{filteredBookmarks.length === 0 && allIndividualBookmarks.length > 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No bookmarks match this filter.</p>
|
||||
</div>
|
||||
) : allIndividualBookmarks.length === 0 ? (
|
||||
loading ? (
|
||||
<div className={`bookmarks-list ${viewMode}`} aria-busy="true">
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
@@ -150,53 +182,87 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
isRefreshing={isPulling || isRefreshing || false}
|
||||
pullPosition={pullPosition}
|
||||
/>
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
{allIndividualBookmarks.map((individualBookmark, index) =>
|
||||
<BookmarkItem
|
||||
key={`${individualBookmark.id}-${index}`}
|
||||
bookmark={individualBookmark}
|
||||
index={index}
|
||||
onSelectUrl={onSelectUrl}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{sections.filter(s => s.items.length > 0).map(section => (
|
||||
<div key={section.key} className="bookmarks-section">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<h3 className="bookmarks-section-title" style={{ margin: 0, padding: '1.5rem 0.5rem 0.375rem', flex: 1 }}>{section.title}</h3>
|
||||
{section.key === 'web' && activeAccount && (
|
||||
<CompactButton
|
||||
icon={faPlus}
|
||||
onClick={() => setShowAddModal(true)}
|
||||
title="Add web bookmark"
|
||||
ariaLabel="Add web bookmark"
|
||||
className="bookmark-section-action"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
{section.items.map((individualBookmark, index) => (
|
||||
<BookmarkItem
|
||||
key={`${section.key}-${individualBookmark.id}-${index}`}
|
||||
bookmark={individualBookmark}
|
||||
index={index}
|
||||
onSelectUrl={onSelectUrl}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="view-mode-controls">
|
||||
{onRefresh && (
|
||||
<div className="view-mode-left">
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
onClick={onRefresh}
|
||||
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
|
||||
ariaLabel="Refresh bookmarks"
|
||||
icon={faHeart}
|
||||
onClick={() => navigate('/support')}
|
||||
title="Support Boris"
|
||||
ariaLabel="Support"
|
||||
variant="ghost"
|
||||
disabled={isRefreshing}
|
||||
spin={isRefreshing}
|
||||
style={{ color: friendsColor }}
|
||||
/>
|
||||
)}
|
||||
<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 className="view-mode-right">
|
||||
{onRefresh && (
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
onClick={onRefresh}
|
||||
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
|
||||
ariaLabel="Refresh bookmarks"
|
||||
variant="ghost"
|
||||
disabled={isRefreshing}
|
||||
spin={isRefreshing}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
{showAddModal && (
|
||||
<AddBookmarkModal
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onSave={handleSaveBookmark}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faBookmark, faUserLock, faChevronDown, faChevronUp, faGlobe } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
|
||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||
import { IndividualBookmark } from '../../types/bookmarks'
|
||||
import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils'
|
||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||
import IconButton from '../IconButton'
|
||||
import { classifyUrl } from '../../utils/helpers'
|
||||
import { IconGetter } from './shared'
|
||||
import { useImageCache } from '../../hooks/useImageCache'
|
||||
import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview'
|
||||
import { getEventUrl } from '../../config/nostrGateways'
|
||||
@@ -18,13 +17,13 @@ interface CardViewProps {
|
||||
hasUrls: boolean
|
||||
extractedUrls: string[]
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
getIconForUrlType: IconGetter
|
||||
authorNpub: string
|
||||
eventNevent?: string
|
||||
getAuthorDisplayName: () => string
|
||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
articleImage?: string
|
||||
articleSummary?: string
|
||||
contentTypeIcon: IconDefinition
|
||||
}
|
||||
|
||||
export const CardView: React.FC<CardViewProps> = ({
|
||||
@@ -33,13 +32,13 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
hasUrls,
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
getIconForUrlType,
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleImage,
|
||||
articleSummary
|
||||
articleSummary,
|
||||
contentTypeIcon
|
||||
}) => {
|
||||
const firstUrl = hasUrls ? extractedUrls[0] : null
|
||||
const firstUrlClassificationType = firstUrl ? classifyUrl(firstUrl)?.type : null
|
||||
@@ -52,7 +51,6 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
const contentLength = (bookmark.content || '').length
|
||||
const shouldTruncate = !expanded && contentLength > 210
|
||||
const isArticle = bookmark.kind === 30023
|
||||
const isWebBookmark = bookmark.kind === 39701
|
||||
|
||||
// Determine which image to use (article image, instant preview, or OG image)
|
||||
const previewImage = articleImage || instantPreview || ogImage
|
||||
@@ -92,19 +90,7 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
)}
|
||||
<div className="bookmark-header">
|
||||
<span className="bookmark-type">
|
||||
{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" />
|
||||
</>
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
||||
)}
|
||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||
</span>
|
||||
|
||||
{eventNevent ? (
|
||||
@@ -127,23 +113,14 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
<div className="bookmark-urls">
|
||||
{(urlsExpanded ? extractedUrls : extractedUrls.slice(0, 1)).map((url, urlIndex) => {
|
||||
return (
|
||||
<div key={urlIndex} className="url-row">
|
||||
<button
|
||||
className="bookmark-url"
|
||||
onClick={(e) => { e.stopPropagation(); onSelectUrl?.(url) }}
|
||||
title="Open in reader"
|
||||
>
|
||||
{url}
|
||||
</button>
|
||||
<IconButton
|
||||
icon={getIconForUrlType(url)}
|
||||
ariaLabel="Open"
|
||||
title="Open"
|
||||
variant="success"
|
||||
size={32}
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onSelectUrl?.(url) }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
key={urlIndex}
|
||||
className="bookmark-url"
|
||||
onClick={(e) => { e.stopPropagation(); onSelectUrl?.(url) }}
|
||||
title="Open in reader"
|
||||
>
|
||||
{url}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{extractedUrls.length > 1 && (
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faBookmark, faUserLock, faGlobe } from '@fortawesome/free-solid-svg-icons'
|
||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||
import { IndividualBookmark } from '../../types/bookmarks'
|
||||
import { formatDateCompact } from '../../utils/bookmarkUtils'
|
||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||
import { useImageCache } from '../../hooks/useImageCache'
|
||||
|
||||
interface CompactViewProps {
|
||||
bookmark: IndividualBookmark
|
||||
@@ -12,8 +11,8 @@ interface CompactViewProps {
|
||||
hasUrls: boolean
|
||||
extractedUrls: string[]
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
articleImage?: string
|
||||
articleSummary?: string
|
||||
contentTypeIcon: IconDefinition
|
||||
}
|
||||
|
||||
export const CompactView: React.FC<CompactViewProps> = ({
|
||||
@@ -22,16 +21,13 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
hasUrls,
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
articleImage,
|
||||
articleSummary
|
||||
articleSummary,
|
||||
contentTypeIcon
|
||||
}) => {
|
||||
const isArticle = bookmark.kind === 30023
|
||||
const isWebBookmark = bookmark.kind === 39701
|
||||
const isClickable = hasUrls || isArticle || isWebBookmark
|
||||
|
||||
// Get cached image for thumbnail
|
||||
const cachedImage = useImageCache(articleImage || undefined)
|
||||
|
||||
const handleCompactClick = () => {
|
||||
if (!onSelectUrl) return
|
||||
|
||||
@@ -55,27 +51,8 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
role={isClickable ? 'button' : undefined}
|
||||
tabIndex={isClickable ? 0 : undefined}
|
||||
>
|
||||
{/* Thumbnail image */}
|
||||
{cachedImage && (
|
||||
<div className="compact-thumbnail">
|
||||
<img src={cachedImage} alt="" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="bookmark-type-compact">
|
||||
{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" />
|
||||
</>
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faBookmark} className="bookmark-visibility public" />
|
||||
)}
|
||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||
</span>
|
||||
{displayText && (
|
||||
<div className="compact-text">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||
import { IndividualBookmark } from '../../types/bookmarks'
|
||||
import { formatDate } from '../../utils/bookmarkUtils'
|
||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||
@@ -21,6 +22,7 @@ interface LargeViewProps {
|
||||
getAuthorDisplayName: () => string
|
||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
articleSummary?: string
|
||||
contentTypeIcon: IconDefinition
|
||||
}
|
||||
|
||||
export const LargeView: React.FC<LargeViewProps> = ({
|
||||
@@ -35,7 +37,8 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleSummary
|
||||
articleSummary,
|
||||
contentTypeIcon
|
||||
}) => {
|
||||
const cachedImage = useImageCache(previewImage || undefined)
|
||||
const isArticle = bookmark.kind === 30023
|
||||
@@ -90,6 +93,9 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
)}
|
||||
|
||||
<div className="large-footer">
|
||||
<span className="bookmark-type-large">
|
||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||
</span>
|
||||
<span className="large-author">
|
||||
<Link
|
||||
to={`/p/${authorNpub}`}
|
||||
|
||||
@@ -58,16 +58,18 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
// Extract tab from profile routes
|
||||
const profileTab = location.pathname.endsWith('/writings') ? 'writings' : 'highlights'
|
||||
|
||||
// Decode npub to pubkey for profile view
|
||||
// Decode npub or nprofile to pubkey for profile view
|
||||
let profilePubkey: string | undefined
|
||||
if (npub && showProfile) {
|
||||
try {
|
||||
const decoded = nip19.decode(npub)
|
||||
if (decoded.type === 'npub') {
|
||||
profilePubkey = decoded.data
|
||||
} else if (decoded.type === 'nprofile') {
|
||||
profilePubkey = decoded.data.pubkey
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to decode npub:', err)
|
||||
console.error('Failed to decode npub/nprofile:', err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +167,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
activeAccount,
|
||||
accountManager,
|
||||
naddr,
|
||||
externalUrl,
|
||||
currentArticleCoordinate,
|
||||
currentArticleEventId,
|
||||
settings
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useState, useEffect, useRef } from 'react'
|
||||
import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react'
|
||||
import ReactPlayer from 'react-player'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
@@ -6,10 +6,10 @@ import rehypeRaw from 'rehype-raw'
|
||||
import rehypePrism from 'rehype-prism-plus'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import 'prismjs/themes/prism-tomorrow.css'
|
||||
import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare, faSearch } from '@fortawesome/free-solid-svg-icons'
|
||||
import { ContentSkeleton } from './Skeletons'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { getNostrUrl } from '../config/nostrGateways'
|
||||
import { getNostrUrl, getSearchUrl } from '../config/nostrGateways'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
@@ -36,6 +36,13 @@ import { classifyUrl } from '../utils/helpers'
|
||||
import { buildNativeVideoUrl } from '../utils/videoHelpers'
|
||||
import { useReadingPosition } from '../hooks/useReadingPosition'
|
||||
import { ReadingProgressIndicator } from './ReadingProgressIndicator'
|
||||
import { EventFactory } from 'applesauce-factory'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import {
|
||||
generateArticleIdentifier,
|
||||
loadReadingPosition,
|
||||
saveReadingPosition
|
||||
} from '../services/readingPositionService'
|
||||
|
||||
interface ContentPanelProps {
|
||||
loading: boolean
|
||||
@@ -100,6 +107,9 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
const [showArticleMenu, setShowArticleMenu] = useState(false)
|
||||
const [showVideoMenu, setShowVideoMenu] = useState(false)
|
||||
const [showExternalMenu, setShowExternalMenu] = useState(false)
|
||||
const [articleMenuOpenUpward, setArticleMenuOpenUpward] = useState(false)
|
||||
const [videoMenuOpenUpward, setVideoMenuOpenUpward] = useState(false)
|
||||
const [externalMenuOpenUpward, setExternalMenuOpenUpward] = useState(false)
|
||||
const articleMenuRef = useRef<HTMLDivElement>(null)
|
||||
const videoMenuRef = useRef<HTMLDivElement>(null)
|
||||
const externalMenuRef = useRef<HTMLDivElement>(null)
|
||||
@@ -126,10 +136,58 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
onClearSelection
|
||||
})
|
||||
|
||||
// Get event store for reading position service
|
||||
const eventStore = Hooks.useEventStore()
|
||||
|
||||
// Reading position tracking - only for text content, not videos
|
||||
const isTextContent = !loading && !!(markdown || html) && !selectedUrl?.includes('youtube') && !selectedUrl?.includes('vimeo')
|
||||
const { isReadingComplete, progressPercentage } = useReadingPosition({
|
||||
|
||||
// Generate article identifier for saving/loading position
|
||||
const articleIdentifier = useMemo(() => {
|
||||
if (!selectedUrl) return null
|
||||
return generateArticleIdentifier(selectedUrl)
|
||||
}, [selectedUrl])
|
||||
|
||||
// Callback to save reading position
|
||||
const handleSavePosition = useCallback(async (position: number) => {
|
||||
if (!activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
||||
console.log('⏭️ [ContentPanel] Skipping save - missing requirements:', {
|
||||
hasAccount: !!activeAccount,
|
||||
hasRelayPool: !!relayPool,
|
||||
hasEventStore: !!eventStore,
|
||||
hasIdentifier: !!articleIdentifier
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!settings?.syncReadingPosition) {
|
||||
console.log('⏭️ [ContentPanel] Sync disabled in settings')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('💾 [ContentPanel] Saving position:', Math.round(position * 100) + '%', 'for article:', selectedUrl?.slice(0, 50))
|
||||
|
||||
try {
|
||||
const factory = new EventFactory({ signer: activeAccount })
|
||||
await saveReadingPosition(
|
||||
relayPool,
|
||||
eventStore,
|
||||
factory,
|
||||
articleIdentifier,
|
||||
{
|
||||
position,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
scrollTop: window.pageYOffset || document.documentElement.scrollTop
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('❌ [ContentPanel] Failed to save reading position:', error)
|
||||
}
|
||||
}, [activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
|
||||
|
||||
const { isReadingComplete, progressPercentage, saveNow } = useReadingPosition({
|
||||
enabled: isTextContent,
|
||||
syncEnabled: settings?.syncReadingPosition,
|
||||
onSave: handleSavePosition,
|
||||
onReadingComplete: () => {
|
||||
// Optional: Auto-mark as read when reading is complete
|
||||
if (activeAccount && !isMarkedAsRead) {
|
||||
@@ -138,6 +196,73 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
}
|
||||
})
|
||||
|
||||
// Load saved reading position when article loads
|
||||
useEffect(() => {
|
||||
if (!isTextContent || !activeAccount || !relayPool || !eventStore || !articleIdentifier) {
|
||||
console.log('⏭️ [ContentPanel] Skipping position restore - missing requirements:', {
|
||||
isTextContent,
|
||||
hasAccount: !!activeAccount,
|
||||
hasRelayPool: !!relayPool,
|
||||
hasEventStore: !!eventStore,
|
||||
hasIdentifier: !!articleIdentifier
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!settings?.syncReadingPosition) {
|
||||
console.log('⏭️ [ContentPanel] Sync disabled - not restoring position')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('📖 [ContentPanel] Loading position for article:', selectedUrl?.slice(0, 50))
|
||||
|
||||
const loadPosition = async () => {
|
||||
try {
|
||||
const savedPosition = await loadReadingPosition(
|
||||
relayPool,
|
||||
eventStore,
|
||||
activeAccount.pubkey,
|
||||
articleIdentifier
|
||||
)
|
||||
|
||||
if (savedPosition && savedPosition.position > 0.05 && savedPosition.position < 1) {
|
||||
console.log('🎯 [ContentPanel] Restoring position:', Math.round(savedPosition.position * 100) + '%')
|
||||
// Wait for content to be fully rendered before scrolling
|
||||
setTimeout(() => {
|
||||
const documentHeight = document.documentElement.scrollHeight
|
||||
const windowHeight = window.innerHeight
|
||||
const scrollTop = savedPosition.position * (documentHeight - windowHeight)
|
||||
|
||||
window.scrollTo({
|
||||
top: scrollTop,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
|
||||
console.log('✅ [ContentPanel] Restored to position:', Math.round(savedPosition.position * 100) + '%', 'scrollTop:', scrollTop)
|
||||
}, 500) // Give content time to render
|
||||
} else if (savedPosition) {
|
||||
if (savedPosition.position === 1) {
|
||||
console.log('✅ [ContentPanel] Article completed (100%), starting from top')
|
||||
} else {
|
||||
console.log('⏭️ [ContentPanel] Position too early (<5%):', Math.round(savedPosition.position * 100) + '%')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [ContentPanel] Failed to load reading position:', error)
|
||||
}
|
||||
}
|
||||
|
||||
loadPosition()
|
||||
}, [isTextContent, activeAccount, relayPool, eventStore, articleIdentifier, settings?.syncReadingPosition, selectedUrl])
|
||||
|
||||
// Save position before unmounting or changing article
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveNow) {
|
||||
saveNow()
|
||||
}
|
||||
}
|
||||
}, [saveNow, selectedUrl])
|
||||
|
||||
// Close menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
@@ -161,6 +286,35 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
}
|
||||
}, [showArticleMenu, showVideoMenu, showExternalMenu])
|
||||
|
||||
// Check available space and position menu upward if needed
|
||||
useEffect(() => {
|
||||
const checkMenuPosition = (menuRef: React.RefObject<HTMLDivElement>, setOpenUpward: (value: boolean) => void) => {
|
||||
if (!menuRef.current) return
|
||||
|
||||
const menuWrapper = menuRef.current
|
||||
const menuElement = menuWrapper.querySelector('.article-menu') as HTMLElement
|
||||
if (!menuElement) return
|
||||
|
||||
const rect = menuWrapper.getBoundingClientRect()
|
||||
const viewportHeight = window.innerHeight
|
||||
const spaceBelow = viewportHeight - rect.bottom
|
||||
const menuHeight = menuElement.offsetHeight || 300 // estimate if not rendered yet
|
||||
|
||||
// Open upward if there's not enough space below (with 20px buffer)
|
||||
setOpenUpward(spaceBelow < menuHeight + 20 && rect.top > menuHeight)
|
||||
}
|
||||
|
||||
if (showArticleMenu) {
|
||||
checkMenuPosition(articleMenuRef, setArticleMenuOpenUpward)
|
||||
}
|
||||
if (showVideoMenu) {
|
||||
checkMenuPosition(videoMenuRef, setVideoMenuOpenUpward)
|
||||
}
|
||||
if (showExternalMenu) {
|
||||
checkMenuPosition(externalMenuRef, setExternalMenuOpenUpward)
|
||||
}
|
||||
}, [showArticleMenu, showVideoMenu, showExternalMenu])
|
||||
|
||||
const readingStats = useMemo(() => {
|
||||
const content = markdown || html || ''
|
||||
if (!content) return null
|
||||
@@ -218,9 +372,15 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
relays: relayHints
|
||||
})
|
||||
|
||||
// Check for source URL in 'r' tags
|
||||
const sourceUrl = currentArticle.tags.find(t => t[0] === 'r')?.[1]
|
||||
|
||||
return {
|
||||
portal: getNostrUrl(naddr),
|
||||
native: `nostr:${naddr}`
|
||||
native: `nostr:${naddr}`,
|
||||
naddr,
|
||||
sourceUrl,
|
||||
borisUrl: `${window.location.origin}/a/${naddr}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,6 +405,73 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
}
|
||||
setShowArticleMenu(false)
|
||||
}
|
||||
|
||||
const handleShareBoris = async () => {
|
||||
try {
|
||||
if (!articleLinks) return
|
||||
|
||||
if ((navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
|
||||
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({
|
||||
title: title || 'Article',
|
||||
url: articleLinks.borisUrl
|
||||
})
|
||||
} else {
|
||||
await navigator.clipboard.writeText(articleLinks.borisUrl)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Share failed', e)
|
||||
} finally {
|
||||
setShowArticleMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleShareOriginal = async () => {
|
||||
try {
|
||||
if (!articleLinks?.sourceUrl) return
|
||||
|
||||
if ((navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
|
||||
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({
|
||||
title: title || 'Article',
|
||||
url: articleLinks.sourceUrl
|
||||
})
|
||||
} else {
|
||||
await navigator.clipboard.writeText(articleLinks.sourceUrl)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Share failed', e)
|
||||
} finally {
|
||||
setShowArticleMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyBoris = async () => {
|
||||
try {
|
||||
if (!articleLinks) return
|
||||
await navigator.clipboard.writeText(articleLinks.borisUrl)
|
||||
} catch (e) {
|
||||
console.warn('Copy failed', e)
|
||||
} finally {
|
||||
setShowArticleMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyOriginal = async () => {
|
||||
try {
|
||||
if (!articleLinks?.sourceUrl) return
|
||||
await navigator.clipboard.writeText(articleLinks.sourceUrl)
|
||||
} catch (e) {
|
||||
console.warn('Copy failed', e)
|
||||
} finally {
|
||||
setShowArticleMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenSearch = () => {
|
||||
if (articleLinks) {
|
||||
window.open(getSearchUrl(articleLinks.naddr), '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
setShowArticleMenu(false)
|
||||
}
|
||||
|
||||
// Video actions
|
||||
const handleOpenVideoExternal = () => {
|
||||
@@ -307,10 +534,16 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
|
||||
const handleShareExternalUrl = async () => {
|
||||
try {
|
||||
if (selectedUrl && (navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
|
||||
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({ title: title || 'Article', url: selectedUrl })
|
||||
} else if (selectedUrl) {
|
||||
await navigator.clipboard.writeText(selectedUrl)
|
||||
if (!selectedUrl) return
|
||||
const borisUrl = `${window.location.origin}/r/${encodeURIComponent(selectedUrl)}`
|
||||
|
||||
if ((navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
|
||||
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({
|
||||
title: title || 'Article',
|
||||
url: borisUrl
|
||||
})
|
||||
} else {
|
||||
await navigator.clipboard.writeText(borisUrl)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Share failed', e)
|
||||
@@ -318,6 +551,13 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
setShowExternalMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearchExternalUrl = () => {
|
||||
if (selectedUrl) {
|
||||
window.open(getSearchUrl(selectedUrl), '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
setShowExternalMenu(false)
|
||||
}
|
||||
|
||||
// Check if article is already marked as read when URL/article changes
|
||||
useEffect(() => {
|
||||
@@ -501,7 +741,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
<FontAwesomeIcon icon={faEllipsisH} />
|
||||
</button>
|
||||
{showVideoMenu && (
|
||||
<div className="article-menu">
|
||||
<div className={`article-menu ${videoMenuOpenUpward ? 'open-upward' : ''}`}>
|
||||
<button className="article-menu-item" onClick={handleOpenVideoExternal}>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open Link</span>
|
||||
@@ -582,13 +822,13 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
</button>
|
||||
|
||||
{showExternalMenu && (
|
||||
<div className="article-menu">
|
||||
<div className={`article-menu ${externalMenuOpenUpward ? 'open-upward' : ''}`}>
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleOpenExternalUrl}
|
||||
onClick={handleShareExternalUrl}
|
||||
>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open Original URL</span>
|
||||
<FontAwesomeIcon icon={faShare} />
|
||||
<span>Share</span>
|
||||
</button>
|
||||
<button
|
||||
className="article-menu-item"
|
||||
@@ -599,10 +839,17 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
</button>
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleShareExternalUrl}
|
||||
onClick={handleOpenExternalUrl}
|
||||
>
|
||||
<FontAwesomeIcon icon={faShare} />
|
||||
<span>Share</span>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open Original</span>
|
||||
</button>
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleSearchExternalUrl}
|
||||
>
|
||||
<FontAwesomeIcon icon={faSearch} />
|
||||
<span>Search</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -623,13 +870,52 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
</button>
|
||||
|
||||
{showArticleMenu && (
|
||||
<div className="article-menu">
|
||||
<div className={`article-menu ${articleMenuOpenUpward ? 'open-upward' : ''}`}>
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleShareBoris}
|
||||
>
|
||||
<FontAwesomeIcon icon={faShare} />
|
||||
<span>Share</span>
|
||||
</button>
|
||||
{articleLinks.sourceUrl && (
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleShareOriginal}
|
||||
>
|
||||
<FontAwesomeIcon icon={faShare} />
|
||||
<span>Share Original</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleCopyBoris}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
<span>Copy Link</span>
|
||||
</button>
|
||||
{articleLinks.sourceUrl && (
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleCopyOriginal}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
<span>Copy Original</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleOpenSearch}
|
||||
>
|
||||
<FontAwesomeIcon icon={faSearch} />
|
||||
<span>Search</span>
|
||||
</button>
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleOpenPortal}
|
||||
>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open on Nostr</span>
|
||||
<span>Open with njump</span>
|
||||
</button>
|
||||
<button
|
||||
className="article-menu-item"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||
import IconButton from './IconButton'
|
||||
import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
@@ -237,35 +237,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
return `/a/${naddr}`
|
||||
}
|
||||
|
||||
const handleHighlightClick = (highlightId: string) => {
|
||||
const highlight = highlights.find(h => h.id === highlightId)
|
||||
if (!highlight) return
|
||||
|
||||
// For nostr-native articles
|
||||
if (highlight.eventReference) {
|
||||
// Convert eventReference to naddr
|
||||
if (highlight.eventReference.includes(':')) {
|
||||
const parts = highlight.eventReference.split(':')
|
||||
const kind = parseInt(parts[0])
|
||||
const pubkey = parts[1]
|
||||
const identifier = parts[2] || ''
|
||||
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind,
|
||||
pubkey,
|
||||
identifier
|
||||
})
|
||||
navigate(`/a/${naddr}`, { state: { highlightId, openHighlights: true } })
|
||||
} else {
|
||||
// Already an naddr
|
||||
navigate(`/a/${highlight.eventReference}`, { state: { highlightId, openHighlights: true } })
|
||||
}
|
||||
}
|
||||
// For web URLs
|
||||
else if (highlight.urlReference) {
|
||||
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`, { state: { highlightId, openHighlights: true } })
|
||||
}
|
||||
}
|
||||
|
||||
// Classify highlights with levels based on user context and apply visibility filters
|
||||
const classifiedHighlights = useMemo(() => {
|
||||
@@ -320,8 +291,8 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
)
|
||||
}
|
||||
return filteredBlogPosts.length === 0 ? (
|
||||
<div className="explore-empty" style={{ gridColumn: '1/-1', textAlign: 'center', color: 'var(--text-secondary)', padding: '2rem' }}>
|
||||
<p>No blog posts yet. Pull to refresh!</p>
|
||||
<div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
@@ -347,8 +318,8 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
)
|
||||
}
|
||||
return classifiedHighlights.length === 0 ? (
|
||||
<div className="explore-empty" style={{ gridColumn: '1/-1', textAlign: 'center', color: 'var(--text-secondary)', padding: '2rem' }}>
|
||||
<p>No highlights yet. Pull to refresh!</p>
|
||||
<div className="explore-loading" style={{ gridColumn: '1/-1', display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
@@ -357,7 +328,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
key={highlight.id}
|
||||
highlight={highlight}
|
||||
relayPool={relayPool}
|
||||
onHighlightClick={handleHighlightClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -13,10 +13,10 @@ import { areAllRelaysLocal } from '../utils/helpers'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { formatDateCompact } from '../utils/bookmarkUtils'
|
||||
import { createDeletionRequest } from '../services/deletionService'
|
||||
import ConfirmDialog from './ConfirmDialog'
|
||||
import { getNostrUrl } from '../config/nostrGateways'
|
||||
import CompactButton from './CompactButton'
|
||||
import { HighlightCitation } from './HighlightCitation'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
// Helper to detect if a URL is an image
|
||||
const isImageUrl = (url: string): boolean => {
|
||||
@@ -207,6 +207,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Resolve the profile of the user who made the highlight
|
||||
const profile = useEventModel(Models.ProfileModel, [highlight.pubkey])
|
||||
@@ -257,25 +258,52 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
}
|
||||
}, [isSelected])
|
||||
|
||||
// Close menu when clicking outside
|
||||
// Close menu and reset delete confirm when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setShowMenu(false)
|
||||
setShowDeleteConfirm(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showMenu) {
|
||||
if (showMenu || showDeleteConfirm) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}
|
||||
}, [showMenu])
|
||||
}, [showMenu, showDeleteConfirm])
|
||||
|
||||
const handleItemClick = () => {
|
||||
// If onHighlightClick is provided, use it (legacy behavior)
|
||||
if (onHighlightClick) {
|
||||
onHighlightClick(highlight.id)
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, navigate to the article that this highlight references
|
||||
if (highlight.eventReference) {
|
||||
// Parse the event reference - it can be an event ID or article coordinate (kind:pubkey:identifier)
|
||||
const parts = highlight.eventReference.split(':')
|
||||
|
||||
// If it's an article coordinate (3 parts) and kind is 30023, navigate to it
|
||||
if (parts.length === 3) {
|
||||
const [kind, pubkey, identifier] = parts
|
||||
|
||||
if (kind === '30023') {
|
||||
// Encode as naddr and navigate
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey,
|
||||
identifier
|
||||
})
|
||||
navigate(`/a/${naddr}`)
|
||||
}
|
||||
}
|
||||
} else if (highlight.urlReference) {
|
||||
// Navigate to external URL
|
||||
navigate(`/r/${encodeURIComponent(highlight.urlReference)}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,12 +462,12 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
setShowDeleteConfirm(false)
|
||||
}
|
||||
|
||||
const handleMenuToggle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
// Reset delete confirm state when opening/closing menu
|
||||
if (!showMenu) {
|
||||
setShowDeleteConfirm(false)
|
||||
}
|
||||
setShowMenu(!showMenu)
|
||||
}
|
||||
|
||||
@@ -461,6 +489,11 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
setShowDeleteConfirm(true)
|
||||
}
|
||||
|
||||
const handleConfirmDeleteClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
handleConfirmDelete()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -468,7 +501,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
className={`highlight-item ${isSelected ? 'selected' : ''} ${highlight.level ? `level-${highlight.level}` : ''}`}
|
||||
data-highlight-id={highlight.id}
|
||||
onClick={handleItemClick}
|
||||
style={{ cursor: onHighlightClick ? 'pointer' : 'default' }}
|
||||
style={{ cursor: (onHighlightClick || highlight.eventReference || highlight.urlReference) ? 'pointer' : 'default' }}
|
||||
>
|
||||
<div className="highlight-header">
|
||||
<CompactButton
|
||||
@@ -533,6 +566,33 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="highlight-menu-wrapper" ref={menuRef}>
|
||||
{showDeleteConfirm && canDelete && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginRight: '0.5rem' }}>
|
||||
<span style={{ fontSize: '0.875rem', color: 'rgb(220 38 38)', fontWeight: 500 }}>Confirm?</span>
|
||||
<button
|
||||
onClick={handleConfirmDeleteClick}
|
||||
disabled={isDeleting}
|
||||
title="Confirm deletion"
|
||||
style={{
|
||||
color: 'rgb(220 38 38)',
|
||||
background: 'rgba(220, 38, 38, 0.1)',
|
||||
border: '1px solid rgb(220 38 38)',
|
||||
borderRadius: '4px',
|
||||
padding: '0.375rem',
|
||||
cursor: isDeleting ? 'not-allowed' : 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minWidth: '33px',
|
||||
minHeight: '33px',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={isDeleting ? faSpinner : faTrash} spin={isDeleting} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CompactButton
|
||||
icon={faEllipsisH}
|
||||
onClick={handleMenuToggle}
|
||||
@@ -546,7 +606,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
onClick={handleOpenPortal}
|
||||
>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open on Nostr</span>
|
||||
<span>Open with njump</span>
|
||||
</button>
|
||||
<button
|
||||
className="highlight-menu-item"
|
||||
@@ -571,17 +631,6 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={showDeleteConfirm}
|
||||
title="Delete Highlight?"
|
||||
message="This will request deletion of your highlight. It may still be visible on some relays that don't honor deletion requests."
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
variant="danger"
|
||||
onConfirm={handleConfirmDelete}
|
||||
onCancel={handleCancelDelete}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,11 +19,15 @@ import BlogPostCard from './BlogPostCard'
|
||||
import { BookmarkItem } from './BookmarkItem'
|
||||
import IconButton from './IconButton'
|
||||
import { ViewMode } from './Bookmarks'
|
||||
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||
import { getCachedMeData, setCachedMeData, updateCachedHighlights } from '../services/meCache'
|
||||
import { faBooks } from '../icons/customIcons'
|
||||
import { usePullToRefresh } from 'use-pull-to-refresh'
|
||||
import RefreshIndicator from './RefreshIndicator'
|
||||
import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils'
|
||||
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
|
||||
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
|
||||
import { generateArticleIdentifier, loadReadingPosition } from '../services/readingPositionService'
|
||||
import ArchiveFilters, { ArchiveFilterType } from './ArchiveFilters'
|
||||
|
||||
interface MeProps {
|
||||
relayPool: RelayPool
|
||||
@@ -35,6 +39,7 @@ type TabType = 'highlights' | 'reading-list' | 'archive' | 'writings'
|
||||
|
||||
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const eventStore = Hooks.useEventStore()
|
||||
const navigate = useNavigate()
|
||||
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
||||
|
||||
@@ -48,6 +53,9 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
|
||||
const [archiveFilter, setArchiveFilter] = useState<ArchiveFilterType>('all')
|
||||
const [readingPositions, setReadingPositions] = useState<Map<string, number>>(new Map())
|
||||
|
||||
// Update local state when prop changes
|
||||
useEffect(() => {
|
||||
@@ -119,6 +127,65 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
loadData()
|
||||
}, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger])
|
||||
|
||||
// Load reading positions for read articles (only for own profile)
|
||||
useEffect(() => {
|
||||
const loadPositions = async () => {
|
||||
if (!isOwnProfile || !activeAccount || !relayPool || !eventStore || readArticles.length === 0) {
|
||||
console.log('🔍 [Archive] Skipping position load:', {
|
||||
isOwnProfile,
|
||||
hasAccount: !!activeAccount,
|
||||
hasRelayPool: !!relayPool,
|
||||
hasEventStore: !!eventStore,
|
||||
articlesCount: readArticles.length
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
console.log('📊 [Archive] Loading reading positions for', readArticles.length, 'articles')
|
||||
|
||||
const positions = new Map<string, number>()
|
||||
|
||||
// Load positions for all read articles
|
||||
await Promise.all(
|
||||
readArticles.map(async (post) => {
|
||||
try {
|
||||
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey: post.author,
|
||||
identifier: dTag
|
||||
})
|
||||
const articleUrl = `nostr:${naddr}`
|
||||
const identifier = generateArticleIdentifier(articleUrl)
|
||||
|
||||
console.log('🔍 [Archive] Loading position for:', post.title?.slice(0, 50), 'identifier:', identifier.slice(0, 32))
|
||||
|
||||
const savedPosition = await loadReadingPosition(
|
||||
relayPool,
|
||||
eventStore,
|
||||
activeAccount.pubkey,
|
||||
identifier
|
||||
)
|
||||
|
||||
if (savedPosition && savedPosition.position > 0) {
|
||||
console.log('✅ [Archive] Found position:', Math.round(savedPosition.position * 100) + '%', 'for', post.title?.slice(0, 50))
|
||||
positions.set(post.event.id, savedPosition.position)
|
||||
} else {
|
||||
console.log('❌ [Archive] No position found for:', post.title?.slice(0, 50))
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('⚠️ [Archive] Failed to load reading position for article:', error)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
console.log('📊 [Archive] Loaded positions for', positions.size, '/', readArticles.length, 'articles')
|
||||
setReadingPositions(positions)
|
||||
}
|
||||
|
||||
loadPositions()
|
||||
}, [readArticles, isOwnProfile, activeAccount, relayPool, eventStore])
|
||||
|
||||
// Pull-to-refresh
|
||||
const { isRefreshing, pullPosition } = usePullToRefresh({
|
||||
onRefresh: () => {
|
||||
@@ -150,23 +217,6 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
return `/a/${naddr}`
|
||||
}
|
||||
|
||||
// Helper to check if a bookmark has either content or a URL (same logic as BookmarkList)
|
||||
const hasContentOrUrl = (ib: IndividualBookmark) => {
|
||||
const hasContent = ib.content && ib.content.trim().length > 0
|
||||
|
||||
let hasUrl = false
|
||||
if (ib.kind === 39701) {
|
||||
const dTag = ib.tags?.find((t: string[]) => t[0] === 'd')?.[1]
|
||||
hasUrl = !!dTag && dTag.trim().length > 0
|
||||
} else {
|
||||
const urls = extractUrlsFromContent(ib.content || '')
|
||||
hasUrl = urls.length > 0
|
||||
}
|
||||
|
||||
if (ib.kind === 30023) return true
|
||||
return hasContent || hasUrl
|
||||
}
|
||||
|
||||
const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => {
|
||||
if (bookmark && bookmark.kind === 30023) {
|
||||
// For kind:30023 articles, navigate to the article route
|
||||
@@ -186,10 +236,44 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
}
|
||||
}
|
||||
|
||||
// Merge and flatten all individual bookmarks (same logic as BookmarkList)
|
||||
// Merge and flatten all individual bookmarks
|
||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||
.filter(hasContentOrUrl)
|
||||
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
|
||||
.filter(hasContent)
|
||||
|
||||
// Apply bookmark filter
|
||||
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, bookmarkFilter)
|
||||
|
||||
const groups = groupIndividualBookmarks(filteredBookmarks)
|
||||
|
||||
// Apply archive filter
|
||||
const filteredReadArticles = readArticles.filter(post => {
|
||||
const position = readingPositions.get(post.event.id)
|
||||
|
||||
switch (archiveFilter) {
|
||||
case 'to-read':
|
||||
// No position or 0% progress
|
||||
return !position || position === 0
|
||||
case 'reading':
|
||||
// Has some progress but not completed (0 < position < 1)
|
||||
return position !== undefined && position > 0 && position < 0.95
|
||||
case 'completed':
|
||||
// 95% or more read (we consider 95%+ as completed)
|
||||
return position !== undefined && position >= 0.95
|
||||
case 'marked':
|
||||
// Manually marked as read (in archive but no reading position data)
|
||||
// These are articles that were marked via the emoji reaction
|
||||
return !position || position === 0
|
||||
case 'all':
|
||||
default:
|
||||
return true
|
||||
}
|
||||
})
|
||||
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
|
||||
{ key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
|
||||
{ key: 'public', title: 'Public Bookmarks', items: groups.publicItems },
|
||||
{ key: 'web', title: 'Web Bookmarks', items: groups.web },
|
||||
{ key: 'amethyst', title: 'Legacy Bookmarks', items: groups.amethyst }
|
||||
]
|
||||
|
||||
// Show content progressively - no blocking error screens
|
||||
const hasData = highlights.length > 0 || bookmarks.length > 0 || readArticles.length > 0 || writings.length > 0
|
||||
@@ -208,12 +292,8 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
)
|
||||
}
|
||||
return highlights.length === 0 ? (
|
||||
<div className="explore-empty" style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||
<p>
|
||||
{isOwnProfile
|
||||
? 'No highlights yet. Pull to refresh!'
|
||||
: 'No highlights yet. Pull to refresh!'}
|
||||
</p>
|
||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="highlights-list me-highlights-list">
|
||||
@@ -241,22 +321,38 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
)
|
||||
}
|
||||
return allIndividualBookmarks.length === 0 ? (
|
||||
<div className="explore-empty" style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||
<p>No bookmarks yet. Pull to refresh!</p>
|
||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="bookmarks-list">
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
{allIndividualBookmarks.map((individualBookmark, index) => (
|
||||
<BookmarkItem
|
||||
key={`${individualBookmark.id}-${index}`}
|
||||
bookmark={individualBookmark}
|
||||
index={index}
|
||||
viewMode={viewMode}
|
||||
onSelectUrl={handleSelectUrl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{allIndividualBookmarks.length > 0 && (
|
||||
<BookmarkFilters
|
||||
selectedFilter={bookmarkFilter}
|
||||
onFilterChange={setBookmarkFilter}
|
||||
/>
|
||||
)}
|
||||
{filteredBookmarks.length === 0 ? (
|
||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
No bookmarks match this filter.
|
||||
</div>
|
||||
) : (
|
||||
sections.filter(s => s.items.length > 0).map(section => (
|
||||
<div key={section.key} className="bookmarks-section">
|
||||
<h3 className="bookmarks-section-title">{section.title}</h3>
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
{section.items.map((individualBookmark, index) => (
|
||||
<BookmarkItem
|
||||
key={`${section.key}-${individualBookmark.id}-${index}`}
|
||||
bookmark={individualBookmark}
|
||||
index={index}
|
||||
viewMode={viewMode}
|
||||
onSelectUrl={handleSelectUrl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)))}
|
||||
<div className="view-mode-controls" style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
@@ -301,19 +397,34 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
)
|
||||
}
|
||||
return readArticles.length === 0 ? (
|
||||
<div className="explore-empty" style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||
<p>No read articles yet. Pull to refresh!</p>
|
||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
{readArticles.map((post) => (
|
||||
<BlogPostCard
|
||||
key={post.event.id}
|
||||
post={post}
|
||||
href={getPostUrl(post)}
|
||||
<>
|
||||
{readArticles.length > 0 && (
|
||||
<ArchiveFilters
|
||||
selectedFilter={archiveFilter}
|
||||
onFilterChange={setArchiveFilter}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{filteredReadArticles.length === 0 ? (
|
||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
No articles match this filter.
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
{filteredReadArticles.map((post) => (
|
||||
<BlogPostCard
|
||||
key={post.event.id}
|
||||
post={post}
|
||||
href={getPostUrl(post)}
|
||||
readingProgress={readingPositions.get(post.event.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
case 'writings':
|
||||
@@ -327,12 +438,8 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
)
|
||||
}
|
||||
return writings.length === 0 ? (
|
||||
<div className="explore-empty" style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||
<p>
|
||||
{isOwnProfile
|
||||
? 'No articles written yet. Pull to refresh!'
|
||||
: 'No articles written yet. Pull to refresh!'}
|
||||
</p>
|
||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
@@ -360,12 +467,6 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
|
||||
<div className="explore-header">
|
||||
{viewingPubkey && <AuthorCard authorPubkey={viewingPubkey} clickable={false} />}
|
||||
|
||||
{loading && hasData && (
|
||||
<div className="explore-loading" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="me-tabs">
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHighlighter, faClock } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faHighlighter, faClock, faNewspaper } from '@fortawesome/free-solid-svg-icons'
|
||||
import { format } from 'date-fns'
|
||||
import { useImageCache } from '../hooks/useImageCache'
|
||||
import { useAdaptiveTextColor } from '../hooks/useAdaptiveTextColor'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import { Highlight, HighlightLevel } from '../types/highlights'
|
||||
import { HighlightVisibility } from './HighlightsPanel'
|
||||
@@ -34,6 +35,7 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
highlightVisibility = { nostrverse: true, friends: true, mine: true }
|
||||
}) => {
|
||||
const cachedImage = useImageCache(image)
|
||||
const { textColor } = useAdaptiveTextColor(cachedImage)
|
||||
const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null
|
||||
const isLongSummary = summary && summary.length > 150
|
||||
|
||||
@@ -70,13 +72,25 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
}
|
||||
}, [highlights, highlightVisibility, settings])
|
||||
|
||||
if (cachedImage) {
|
||||
// Show hero section if we have an image OR a title
|
||||
if (cachedImage || title) {
|
||||
return (
|
||||
<>
|
||||
<div className="reader-hero-image">
|
||||
<img src={cachedImage} alt={title || 'Article image'} />
|
||||
{cachedImage ? (
|
||||
<img src={cachedImage} alt={title || 'Article image'} />
|
||||
) : (
|
||||
<div className="reader-hero-placeholder">
|
||||
<FontAwesomeIcon icon={faNewspaper} />
|
||||
</div>
|
||||
)}
|
||||
{formattedDate && (
|
||||
<div className="publish-date-topright">
|
||||
<div
|
||||
className="publish-date-topright"
|
||||
style={{
|
||||
color: textColor
|
||||
}}
|
||||
>
|
||||
{formattedDate}
|
||||
</div>
|
||||
)}
|
||||
@@ -118,7 +132,12 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
{title && (
|
||||
<div className="reader-header">
|
||||
{formattedDate && (
|
||||
<div className="publish-date-topright">
|
||||
<div
|
||||
className="publish-date-topright"
|
||||
style={{
|
||||
color: textColor
|
||||
}}
|
||||
>
|
||||
{formattedDate}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,10 +6,8 @@ import IconButton from './IconButton'
|
||||
import { loadFont } from '../utils/fontLoader'
|
||||
import ThemeSettings from './Settings/ThemeSettings'
|
||||
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
|
||||
import LayoutNavigationSettings from './Settings/LayoutNavigationSettings'
|
||||
import StartupPreferencesSettings from './Settings/StartupPreferencesSettings'
|
||||
import LayoutBehaviorSettings from './Settings/LayoutBehaviorSettings'
|
||||
import ZapSettings from './Settings/ZapSettings'
|
||||
import OfflineModeSettings from './Settings/OfflineModeSettings'
|
||||
import RelaySettings from './Settings/RelaySettings'
|
||||
import PWASettings from './Settings/PWASettings'
|
||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||
@@ -35,6 +33,8 @@ const DEFAULT_SETTINGS: UserSettings = {
|
||||
zapSplitAuthorWeight: 50,
|
||||
useLocalRelayAsCache: true,
|
||||
rebroadcastToAllRelays: false,
|
||||
paragraphAlignment: 'justify',
|
||||
syncReadingPosition: false,
|
||||
}
|
||||
|
||||
interface SettingsProps {
|
||||
@@ -162,12 +162,10 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
|
||||
<div className="settings-content">
|
||||
<ThemeSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<LayoutNavigationSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<StartupPreferencesSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<OfflineModeSettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
|
||||
<LayoutBehaviorSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
|
||||
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
|
||||
<PWASettings />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
125
src/components/Settings/LayoutBehaviorSettings.tsx
Normal file
125
src/components/Settings/LayoutBehaviorSettings.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import React from 'react'
|
||||
import { faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
import IconButton from '../IconButton'
|
||||
|
||||
interface LayoutBehaviorSettingsProps {
|
||||
settings: UserSettings
|
||||
onUpdate: (updates: Partial<UserSettings>) => void
|
||||
}
|
||||
|
||||
const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ settings, onUpdate }) => {
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Layout & Behavior</h3>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Default Bookmark View</label>
|
||||
<div className="setting-buttons">
|
||||
<IconButton
|
||||
icon={faList}
|
||||
onClick={() => onUpdate({ defaultViewMode: 'compact' })}
|
||||
title="Compact list view"
|
||||
ariaLabel="Compact list view"
|
||||
variant={(settings.defaultViewMode || 'compact') === 'compact' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faThLarge}
|
||||
onClick={() => onUpdate({ defaultViewMode: 'cards' })}
|
||||
title="Cards view"
|
||||
ariaLabel="Cards view"
|
||||
variant={settings.defaultViewMode === 'cards' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faImage}
|
||||
onClick={() => onUpdate({ defaultViewMode: 'large' })}
|
||||
title="Large preview view"
|
||||
ariaLabel="Large preview view"
|
||||
variant={settings.defaultViewMode === 'large' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="collapseOnArticleOpen" className="checkbox-label">
|
||||
<input
|
||||
id="collapseOnArticleOpen"
|
||||
type="checkbox"
|
||||
checked={settings.collapseOnArticleOpen !== false}
|
||||
onChange={(e) => onUpdate({ collapseOnArticleOpen: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Collapse bookmark bar when opening an article</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="sidebarCollapsed" className="checkbox-label">
|
||||
<input
|
||||
id="sidebarCollapsed"
|
||||
type="checkbox"
|
||||
checked={settings.sidebarCollapsed !== false}
|
||||
onChange={(e) => onUpdate({ sidebarCollapsed: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Start with bookmarks sidebar collapsed</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="highlightsCollapsed" className="checkbox-label">
|
||||
<input
|
||||
id="highlightsCollapsed"
|
||||
type="checkbox"
|
||||
checked={settings.highlightsCollapsed !== false}
|
||||
onChange={(e) => onUpdate({ highlightsCollapsed: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Start with highlights panel collapsed</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="rebroadcastToAllRelays" className="checkbox-label">
|
||||
<input
|
||||
id="rebroadcastToAllRelays"
|
||||
type="checkbox"
|
||||
checked={settings.rebroadcastToAllRelays ?? false}
|
||||
onChange={(e) => onUpdate({ rebroadcastToAllRelays: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Rebroadcast events while browsing</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="autoCollapseSidebarOnMobile" className="checkbox-label">
|
||||
<input
|
||||
id="autoCollapseSidebarOnMobile"
|
||||
type="checkbox"
|
||||
checked={settings.autoCollapseSidebarOnMobile !== false}
|
||||
onChange={(e) => onUpdate({ autoCollapseSidebarOnMobile: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Auto-collapse sidebar on small screens</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="syncReadingPosition" className="checkbox-label">
|
||||
<input
|
||||
id="syncReadingPosition"
|
||||
type="checkbox"
|
||||
checked={settings.syncReadingPosition ?? false}
|
||||
onChange={(e) => onUpdate({ syncReadingPosition: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Sync reading position across devices</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LayoutBehaviorSettings
|
||||
|
||||
@@ -3,15 +3,15 @@ import { faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
import IconButton from '../IconButton'
|
||||
|
||||
interface LayoutNavigationSettingsProps {
|
||||
interface LayoutBehaviorSettingsProps {
|
||||
settings: UserSettings
|
||||
onUpdate: (updates: Partial<UserSettings>) => void
|
||||
}
|
||||
|
||||
const LayoutNavigationSettings: React.FC<LayoutNavigationSettingsProps> = ({ settings, onUpdate }) => {
|
||||
const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ settings, onUpdate }) => {
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Layout & Navigation</h3>
|
||||
<h3 className="section-title">Layout & Behavior</h3>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Default Bookmark View</label>
|
||||
@@ -52,9 +52,61 @@ const LayoutNavigationSettings: React.FC<LayoutNavigationSettingsProps> = ({ set
|
||||
<span>Collapse bookmark bar when opening an article</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="sidebarCollapsed" className="checkbox-label">
|
||||
<input
|
||||
id="sidebarCollapsed"
|
||||
type="checkbox"
|
||||
checked={settings.sidebarCollapsed !== false}
|
||||
onChange={(e) => onUpdate({ sidebarCollapsed: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Start with bookmarks sidebar collapsed</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="highlightsCollapsed" className="checkbox-label">
|
||||
<input
|
||||
id="highlightsCollapsed"
|
||||
type="checkbox"
|
||||
checked={settings.highlightsCollapsed !== false}
|
||||
onChange={(e) => onUpdate({ highlightsCollapsed: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Start with highlights panel collapsed</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="rebroadcastToAllRelays" className="checkbox-label">
|
||||
<input
|
||||
id="rebroadcastToAllRelays"
|
||||
type="checkbox"
|
||||
checked={settings.rebroadcastToAllRelays ?? false}
|
||||
onChange={(e) => onUpdate({ rebroadcastToAllRelays: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Rebroadcast events while browsing</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="autoCollapseSidebarOnMobile" className="checkbox-label">
|
||||
<input
|
||||
id="autoCollapseSidebarOnMobile"
|
||||
type="checkbox"
|
||||
checked={settings.autoCollapseSidebarOnMobile !== false}
|
||||
onChange={(e) => onUpdate({ autoCollapseSidebarOnMobile: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Auto-collapse sidebar on small screens</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LayoutNavigationSettings
|
||||
export default LayoutBehaviorSettings
|
||||
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { faTrash } from '@fortawesome/free-solid-svg-icons'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
import { getImageCacheStatsAsync, clearImageCache } from '../../services/imageCacheService'
|
||||
import IconButton from '../IconButton'
|
||||
|
||||
interface OfflineModeSettingsProps {
|
||||
settings: UserSettings
|
||||
onUpdate: (updates: Partial<UserSettings>) => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const OfflineModeSettings: React.FC<OfflineModeSettingsProps> = ({ settings, onUpdate, onClose }) => {
|
||||
const navigate = useNavigate()
|
||||
const [cacheStats, setCacheStats] = useState<{
|
||||
totalSizeMB: number
|
||||
itemCount: number
|
||||
items: Array<{ url: string, sizeMB: number }>
|
||||
}>({ totalSizeMB: 0, itemCount: 0, items: [] })
|
||||
|
||||
const handleLinkClick = (url: string) => {
|
||||
if (onClose) onClose()
|
||||
navigate(`/r/${encodeURIComponent(url)}`)
|
||||
}
|
||||
|
||||
const handleClearCache = async () => {
|
||||
if (confirm('Are you sure you want to clear all cached images?')) {
|
||||
await clearImageCache()
|
||||
const stats = await getImageCacheStatsAsync()
|
||||
setCacheStats(stats)
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache stats periodically
|
||||
useEffect(() => {
|
||||
const updateStats = async () => {
|
||||
const stats = await getImageCacheStatsAsync()
|
||||
setCacheStats(stats)
|
||||
}
|
||||
|
||||
updateStats() // Initial load
|
||||
const interval = setInterval(updateStats, 3000) // Update every 3 seconds
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Flight Mode</h3>
|
||||
|
||||
<div className="setting-group" style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<label htmlFor="enableImageCache" className="checkbox-label" style={{ marginBottom: 0 }}>
|
||||
<input
|
||||
id="enableImageCache"
|
||||
type="checkbox"
|
||||
checked={settings.enableImageCache ?? true}
|
||||
onChange={(e) => onUpdate({ enableImageCache: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Use local image cache</span>
|
||||
</label>
|
||||
|
||||
{(settings.enableImageCache ?? true) && (
|
||||
<div style={{
|
||||
fontSize: '0.85rem',
|
||||
color: 'var(--text-secondary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem'
|
||||
}}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
( {cacheStats.totalSizeMB.toFixed(1)} MB /
|
||||
<input
|
||||
id="imageCacheSizeMB"
|
||||
type="number"
|
||||
min="10"
|
||||
max="500"
|
||||
value={settings.imageCacheSizeMB ?? 210}
|
||||
onChange={(e) => onUpdate({ imageCacheSizeMB: parseInt(e.target.value) || 210 })}
|
||||
style={{
|
||||
width: '50px',
|
||||
padding: '0.15rem 0.35rem',
|
||||
background: 'var(--surface-secondary)',
|
||||
border: '1px solid var(--border-color, #333)',
|
||||
borderRadius: '4px',
|
||||
color: 'inherit',
|
||||
fontSize: 'inherit',
|
||||
fontFamily: 'inherit',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
/>
|
||||
MB used )
|
||||
</span>
|
||||
<IconButton
|
||||
icon={faTrash}
|
||||
onClick={handleClearCache}
|
||||
title="Clear cache"
|
||||
variant="ghost"
|
||||
size={28}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="useLocalRelayAsCache" className="checkbox-label">
|
||||
<input
|
||||
id="useLocalRelayAsCache"
|
||||
type="checkbox"
|
||||
checked={settings.useLocalRelayAsCache ?? true}
|
||||
onChange={(e) => onUpdate({ useLocalRelayAsCache: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Use local relays as cache</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginTop: '1.5rem',
|
||||
padding: '1rem',
|
||||
background: 'var(--surface-secondary)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.9rem',
|
||||
lineHeight: '1.6'
|
||||
}}>
|
||||
<p style={{ margin: 0, color: 'var(--text-secondary)' }}>
|
||||
Boris works best with a local relay. Consider running{' '}
|
||||
<a
|
||||
href="https://github.com/greenart7c3/Citrine?tab=readme-ov-file#download"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--accent, #8b5cf6)' }}
|
||||
>
|
||||
Citrine
|
||||
</a>
|
||||
{' or '}
|
||||
<a
|
||||
href="https://github.com/CodyTseng/nostr-relay-tray/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--accent, #8b5cf6)' }}
|
||||
>
|
||||
nostr-relay-tray
|
||||
</a>
|
||||
. Don't know what relays are? Learn more{' '}
|
||||
<a
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleLinkClick('https://nostr.how/en/relays')
|
||||
}}
|
||||
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
|
||||
>
|
||||
here
|
||||
</a>
|
||||
{' and '}
|
||||
<a
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleLinkClick('https://davidebtc186.substack.com/p/the-importance-of-hosting-your-own')
|
||||
}}
|
||||
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
|
||||
>
|
||||
here
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default OfflineModeSettings
|
||||
|
||||
@@ -1,80 +1,206 @@
|
||||
import React from 'react'
|
||||
import { faDownload, faCheckCircle, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { faDownload, faCheckCircle, faTrash } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { usePWAInstall } from '../../hooks/usePWAInstall'
|
||||
import { useIsMobile } from '../../hooks/useMediaQuery'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
import { getImageCacheStatsAsync, clearImageCache } from '../../services/imageCacheService'
|
||||
|
||||
const PWASettings: React.FC = () => {
|
||||
interface PWASettingsProps {
|
||||
settings: UserSettings
|
||||
onUpdate: (updates: Partial<UserSettings>) => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const PWASettings: React.FC<PWASettingsProps> = ({ settings, onUpdate, onClose }) => {
|
||||
const navigate = useNavigate()
|
||||
const isMobile = useIsMobile()
|
||||
const { isInstallable, isInstalled, installApp } = usePWAInstall()
|
||||
const [cacheStats, setCacheStats] = useState<{
|
||||
totalSizeMB: number
|
||||
itemCount: number
|
||||
items: Array<{ url: string, sizeMB: number }>
|
||||
}>({ totalSizeMB: 0, itemCount: 0, items: [] })
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (isInstalled) return
|
||||
const success = await installApp()
|
||||
if (success) {
|
||||
console.log('App installed successfully')
|
||||
}
|
||||
}
|
||||
|
||||
if (isInstalled) {
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3>Progressive Web App</h3>
|
||||
<div className="setting-item">
|
||||
<div className="setting-info">
|
||||
<FontAwesomeIcon icon={faCheckCircle} style={{ color: '#22c55e', marginRight: '8px' }} />
|
||||
<span>Boris is installed as an app</span>
|
||||
</div>
|
||||
<p className="setting-description">
|
||||
You can launch Boris from your home screen or app drawer.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
const handleLinkClick = (url: string) => {
|
||||
if (onClose) onClose()
|
||||
navigate(`/r/${encodeURIComponent(url)}`)
|
||||
}
|
||||
|
||||
if (!isInstallable) {
|
||||
return null
|
||||
const handleClearCache = async () => {
|
||||
if (confirm('Are you sure you want to clear all cached images?')) {
|
||||
await clearImageCache()
|
||||
const stats = await getImageCacheStatsAsync()
|
||||
setCacheStats(stats)
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache stats periodically
|
||||
useEffect(() => {
|
||||
const updateStats = async () => {
|
||||
const stats = await getImageCacheStatsAsync()
|
||||
setCacheStats(stats)
|
||||
}
|
||||
|
||||
updateStats() // Initial load
|
||||
const interval = setInterval(updateStats, 3000) // Update every 3 seconds
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3>Progressive Web App</h3>
|
||||
<div className="setting-item">
|
||||
<div className="setting-info">
|
||||
<FontAwesomeIcon icon={faMobileAlt} style={{ marginRight: '8px' }} />
|
||||
<span>Install Boris as an app</span>
|
||||
<h3 className="section-title">App & Airplane Mode</h3>
|
||||
|
||||
<div style={{ display: 'flex', gap: '2rem', alignItems: 'stretch' }}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
||||
<p className="setting-description" style={{ marginBottom: '1rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
|
||||
Boris is offline‑first by design. You can read, create highlights, and browse your library without being connected to the internet. Boris will store changes locally and sync later.
|
||||
</p>
|
||||
|
||||
{/* Flight Mode Section - Checkboxes First */}
|
||||
<div className="setting-group" style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<label htmlFor="enableImageCache" className="checkbox-label" style={{ marginBottom: 0 }}>
|
||||
<input
|
||||
id="enableImageCache"
|
||||
type="checkbox"
|
||||
checked={settings.enableImageCache ?? true}
|
||||
onChange={(e) => onUpdate({ enableImageCache: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Use local image cache</span>
|
||||
</label>
|
||||
|
||||
{(settings.enableImageCache ?? true) && (
|
||||
<div style={{
|
||||
fontSize: '0.85rem',
|
||||
color: 'var(--text-secondary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem'
|
||||
}}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
( {cacheStats.totalSizeMB.toFixed(1)} MB /
|
||||
<input
|
||||
id="imageCacheSizeMB"
|
||||
type="number"
|
||||
min="10"
|
||||
max="500"
|
||||
value={settings.imageCacheSizeMB ?? 210}
|
||||
onChange={(e) => onUpdate({ imageCacheSizeMB: parseInt(e.target.value) || 210 })}
|
||||
style={{
|
||||
width: '50px',
|
||||
padding: '0.15rem 0.35rem',
|
||||
background: 'var(--surface-secondary)',
|
||||
border: '1px solid var(--border-color, #333)',
|
||||
borderRadius: '4px',
|
||||
color: 'inherit',
|
||||
fontSize: 'inherit',
|
||||
fontFamily: 'inherit',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
/>
|
||||
MB used )
|
||||
</span>
|
||||
<FontAwesomeIcon
|
||||
icon={faTrash}
|
||||
onClick={handleClearCache}
|
||||
title="Clear cache"
|
||||
style={{ cursor: 'pointer', fontSize: '0.85rem', opacity: 0.7 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* PWA Install Section - Paragraphs */}
|
||||
<div className="setting-group">
|
||||
<p className="setting-description" style={{ marginTop: '0.5rem', marginBottom: '0.75rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
|
||||
<strong>Note:</strong> Boris works best with a local relay. Consider running{' '}
|
||||
<a
|
||||
href="https://github.com/greenart7c3/Citrine?tab=readme-ov-file#download"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--accent, #8b5cf6)' }}
|
||||
>
|
||||
Citrine
|
||||
</a>
|
||||
{' or '}
|
||||
<a
|
||||
href="https://github.com/CodyTseng/nostr-relay-tray/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: 'var(--accent, #8b5cf6)' }}
|
||||
>
|
||||
nostr-relay-tray
|
||||
</a>
|
||||
{' '}to bring full offline functionality to Boris. Don't know what relays are? Learn more{' '}
|
||||
<a
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleLinkClick('https://nostr.how/en/relays')
|
||||
}}
|
||||
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
|
||||
>
|
||||
here
|
||||
</a>
|
||||
{' and '}
|
||||
<a
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleLinkClick('https://davidebtc186.substack.com/p/the-importance-of-hosting-your-own')
|
||||
}}
|
||||
style={{ color: 'var(--accent, #8b5cf6)', cursor: 'pointer' }}
|
||||
>
|
||||
here
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="useLocalRelayAsCache" className="checkbox-label">
|
||||
<input
|
||||
id="useLocalRelayAsCache"
|
||||
type="checkbox"
|
||||
checked={settings.useLocalRelayAsCache ?? true}
|
||||
onChange={(e) => onUpdate({ useLocalRelayAsCache: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Use local relays as cache</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<p className="setting-description" style={{ marginBottom: '1rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
|
||||
Install Boris on your device for a native app experience.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="zap-preset-btn"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}
|
||||
disabled={isInstalled || !isInstallable}
|
||||
>
|
||||
<FontAwesomeIcon icon={isInstalled ? faCheckCircle : faDownload} />
|
||||
{isInstalled ? 'Installed' : 'Install App'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="setting-description">
|
||||
Install Boris on your device for a native app experience with offline support.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="install-button"
|
||||
style={{
|
||||
marginTop: '12px',
|
||||
padding: '8px 16px',
|
||||
background: 'linear-gradient(135deg, #3b82f6 0%, #1e40af 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(59, 130, 246, 0.3)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faDownload} />
|
||||
Install App
|
||||
</button>
|
||||
|
||||
{!isMobile && (
|
||||
<img
|
||||
src="/pwa.svg"
|
||||
alt="Progressive Web App"
|
||||
style={{ width: '30%', height: 'auto', flexShrink: 0, opacity: 0.8 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { faHighlighter, faUnderline, faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faHighlighter, faUnderline, faNetworkWired, faUserGroup, faUser, faAlignLeft, faAlignJustify } from '@fortawesome/free-solid-svg-icons'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
import IconButton from '../IconButton'
|
||||
import ColorPicker from '../ColorPicker'
|
||||
@@ -19,35 +19,6 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Reading & Display</h3>
|
||||
|
||||
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<div className="setting-group setting-inline" style={{ flex: '1 1 auto', minWidth: '200px' }}>
|
||||
<label htmlFor="readingFont">Reading Font</label>
|
||||
<div className="setting-control">
|
||||
<FontSelector
|
||||
value={settings.readingFont || 'source-serif-4'}
|
||||
onChange={(font) => onUpdate({ readingFont: font })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline" style={{ flex: '0 1 auto' }}>
|
||||
<label>Font Size</label>
|
||||
<div className="setting-buttons">
|
||||
{[16, 18, 21, 24, 28, 32].map(size => (
|
||||
<button
|
||||
key={size}
|
||||
onClick={() => onUpdate({ fontSize: size })}
|
||||
className={`font-size-btn ${(settings.fontSize || 21) === size ? 'active' : ''}`}
|
||||
title={`${size}px`}
|
||||
style={{ fontSize: `${size - 2}px` }}
|
||||
>
|
||||
A
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Highlight Style</label>
|
||||
<div className="setting-buttons">
|
||||
@@ -69,31 +40,21 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label className="setting-label">My Highlights</label>
|
||||
<div className="setting-control">
|
||||
<ColorPicker
|
||||
selectedColor={settings.highlightColorMine || '#fde047'}
|
||||
onColorChange={(color) => onUpdate({ highlightColorMine: color })}
|
||||
<label>Paragraph Alignment</label>
|
||||
<div className="setting-buttons">
|
||||
<IconButton
|
||||
icon={faAlignLeft}
|
||||
onClick={() => onUpdate({ paragraphAlignment: 'left' })}
|
||||
title="Left aligned"
|
||||
ariaLabel="Left aligned"
|
||||
variant={settings.paragraphAlignment === 'left' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label className="setting-label">Friends Highlights</label>
|
||||
<div className="setting-control">
|
||||
<ColorPicker
|
||||
selectedColor={settings.highlightColorFriends || '#f97316'}
|
||||
onColorChange={(color) => onUpdate({ highlightColorFriends: color })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label className="setting-label">Nostrverse Highlights</label>
|
||||
<div className="setting-control">
|
||||
<ColorPicker
|
||||
selectedColor={settings.highlightColorNostrverse || '#9333ea'}
|
||||
onColorChange={(color) => onUpdate({ highlightColorNostrverse: color })}
|
||||
<IconButton
|
||||
icon={faAlignJustify}
|
||||
onClick={() => onUpdate({ paragraphAlignment: 'justify' })}
|
||||
title="Justified"
|
||||
ariaLabel="Justified"
|
||||
variant={(settings.paragraphAlignment || 'justify') === 'justify' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,6 +98,65 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label htmlFor="readingFont">Reading Font</label>
|
||||
<div className="setting-control">
|
||||
<FontSelector
|
||||
value={settings.readingFont || 'source-serif-4'}
|
||||
onChange={(font) => onUpdate({ readingFont: font })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label className="setting-label">Font Size</label>
|
||||
<div className="setting-control">
|
||||
<div className="setting-buttons">
|
||||
{[16, 18, 21, 24, 28, 32].map(size => (
|
||||
<button
|
||||
key={size}
|
||||
onClick={() => onUpdate({ fontSize: size })}
|
||||
className={`font-size-btn ${(settings.fontSize || 21) === size ? 'active' : ''}`}
|
||||
title={`${size}px`}
|
||||
style={{ fontSize: `${size - 2}px` }}
|
||||
>
|
||||
A
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label className="setting-label">My Highlights</label>
|
||||
<div className="setting-control">
|
||||
<ColorPicker
|
||||
selectedColor={settings.highlightColorMine || '#fde047'}
|
||||
onColorChange={(color) => onUpdate({ highlightColorMine: color })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label className="setting-label">Friends Highlights</label>
|
||||
<div className="setting-control">
|
||||
<ColorPicker
|
||||
selectedColor={settings.highlightColorFriends || '#f97316'}
|
||||
onColorChange={(color) => onUpdate({ highlightColorFriends: color })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label className="setting-label">Nostrverse Highlights</label>
|
||||
<div className="setting-control">
|
||||
<ColorPicker
|
||||
selectedColor={settings.highlightColorNostrverse || '#9333ea'}
|
||||
onColorChange={(color) => onUpdate({ highlightColorNostrverse: color })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="showHighlights" className="checkbox-label">
|
||||
<input
|
||||
@@ -157,7 +177,8 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
||||
style={{
|
||||
fontFamily: previewFontFamily,
|
||||
fontSize: `${settings.fontSize || 21}px`,
|
||||
'--highlight-rgb': hexToRgb(settings.highlightColor || '#ffff00')
|
||||
'--highlight-rgb': hexToRgb(settings.highlightColor || '#ffff00'),
|
||||
'--paragraph-alignment': settings.paragraphAlignment || 'justify'
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<h3>The Quick Brown Fox</h3>
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import React from 'react'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
|
||||
interface StartupPreferencesSettingsProps {
|
||||
settings: UserSettings
|
||||
onUpdate: (updates: Partial<UserSettings>) => void
|
||||
}
|
||||
|
||||
const StartupPreferencesSettings: React.FC<StartupPreferencesSettingsProps> = ({ settings, onUpdate }) => {
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Startup & Behavior</h3>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="sidebarCollapsed" className="checkbox-label">
|
||||
<input
|
||||
id="sidebarCollapsed"
|
||||
type="checkbox"
|
||||
checked={settings.sidebarCollapsed !== false}
|
||||
onChange={(e) => onUpdate({ sidebarCollapsed: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Start with bookmarks sidebar collapsed</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="highlightsCollapsed" className="checkbox-label">
|
||||
<input
|
||||
id="highlightsCollapsed"
|
||||
type="checkbox"
|
||||
checked={settings.highlightsCollapsed !== false}
|
||||
onChange={(e) => onUpdate({ highlightsCollapsed: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Start with highlights panel collapsed</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="rebroadcastToAllRelays" className="checkbox-label">
|
||||
<input
|
||||
id="rebroadcastToAllRelays"
|
||||
type="checkbox"
|
||||
checked={settings.rebroadcastToAllRelays ?? false}
|
||||
onChange={(e) => onUpdate({ rebroadcastToAllRelays: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Rebroadcast events while browsing</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="autoCollapseSidebarOnMobile" className="checkbox-label">
|
||||
<input
|
||||
id="autoCollapseSidebarOnMobile"
|
||||
type="checkbox"
|
||||
checked={settings.autoCollapseSidebarOnMobile !== false}
|
||||
onChange={(e) => onUpdate({ autoCollapseSidebarOnMobile: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Auto-collapse sidebar on small screens</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default StartupPreferencesSettings
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
import { useIsMobile } from '../../hooks/useMediaQuery'
|
||||
|
||||
interface ZapSettingsProps {
|
||||
settings: UserSettings
|
||||
@@ -7,6 +8,7 @@ interface ZapSettingsProps {
|
||||
}
|
||||
|
||||
const ZapSettings: React.FC<ZapSettingsProps> = ({ settings, onUpdate }) => {
|
||||
const isMobile = useIsMobile()
|
||||
const highlighterWeight = settings.zapSplitHighlighterWeight ?? 50
|
||||
const borisWeight = settings.zapSplitBorisWeight ?? 2.1
|
||||
const authorWeight = settings.zapSplitAuthorWeight ?? 50
|
||||
@@ -42,98 +44,119 @@ const ZapSettings: React.FC<ZapSettingsProps> = ({ settings, onUpdate }) => {
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Zap Splits</h3>
|
||||
|
||||
<div className="setting-group">
|
||||
<label className="setting-label">Presets</label>
|
||||
<div className="zap-preset-buttons">
|
||||
<button
|
||||
onClick={() => applyPreset(presets.default)}
|
||||
className={`zap-preset-btn ${isPresetActive(presets.default) ? 'active' : ''}`}
|
||||
title="You: 49%, Author: 49%, Boris: 2%"
|
||||
>
|
||||
Default
|
||||
</button>
|
||||
<button
|
||||
onClick={() => applyPreset(presets.generous)}
|
||||
className={`zap-preset-btn ${isPresetActive(presets.generous) ? 'active' : ''}`}
|
||||
title="You: 6%, Author: 83%, Boris: 11%"
|
||||
>
|
||||
Generous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => applyPreset(presets.selfless)}
|
||||
className={`zap-preset-btn ${isPresetActive(presets.selfless) ? 'active' : ''}`}
|
||||
title="You: 1%, Author: 80%, Boris: 19%"
|
||||
>
|
||||
Selfless
|
||||
</button>
|
||||
<button
|
||||
onClick={() => applyPreset(presets.boris)}
|
||||
className={`zap-preset-btn ${isPresetActive(presets.boris) ? 'active' : ''}`}
|
||||
title="You: 10%, Author: 10%, Boris: 80%"
|
||||
>
|
||||
Boris 🧡
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label className="setting-label">Your Share</label>
|
||||
<div className="zap-split-container">
|
||||
<div className="zap-split-labels">
|
||||
<span className="zap-split-label">Weight: {highlighterWeight}</span>
|
||||
<span className="zap-split-label">({highlighterPercentage.toFixed(1)}%)</span>
|
||||
<div style={{ display: 'flex', gap: '2rem', alignItems: 'stretch' }}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
||||
<div className="setting-group">
|
||||
<label className="setting-label">Presets</label>
|
||||
<div className="zap-preset-buttons">
|
||||
<button
|
||||
onClick={() => applyPreset(presets.default)}
|
||||
className={`zap-preset-btn ${isPresetActive(presets.default) ? 'active' : ''}`}
|
||||
title="You: 49%, Author: 49%, Boris: 2%"
|
||||
>
|
||||
Default
|
||||
</button>
|
||||
<button
|
||||
onClick={() => applyPreset(presets.generous)}
|
||||
className={`zap-preset-btn ${isPresetActive(presets.generous) ? 'active' : ''}`}
|
||||
title="You: 6%, Author: 83%, Boris: 11%"
|
||||
>
|
||||
Generous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => applyPreset(presets.selfless)}
|
||||
className={`zap-preset-btn ${isPresetActive(presets.selfless) ? 'active' : ''}`}
|
||||
title="You: 1%, Author: 80%, Boris: 19%"
|
||||
>
|
||||
Selfless
|
||||
</button>
|
||||
<button
|
||||
onClick={() => applyPreset(presets.boris)}
|
||||
className={`zap-preset-btn ${isPresetActive(presets.boris) ? 'active' : ''}`}
|
||||
title="You: 10%, Author: 10%, Boris: 80%"
|
||||
>
|
||||
Boris 🧡
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={highlighterWeight}
|
||||
onChange={(e) => onUpdate({ zapSplitHighlighterWeight: parseInt(e.target.value) })}
|
||||
className="zap-split-slider"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label className="setting-label">Author(s) Share</label>
|
||||
<div className="zap-split-container">
|
||||
<div className="zap-split-labels">
|
||||
<span className="zap-split-label">Weight: {authorWeight}</span>
|
||||
<span className="zap-split-label">({authorPercentage.toFixed(1)}%)</span>
|
||||
|
||||
<div className="setting-group">
|
||||
<div className="zap-split-container">
|
||||
<div className="zap-split-labels">
|
||||
<span className="zap-split-label">Your Share: {highlighterWeight}</span>
|
||||
<span className="zap-split-label">({highlighterPercentage.toFixed(1)}%)</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={highlighterWeight}
|
||||
onChange={(e) => onUpdate({ zapSplitHighlighterWeight: parseInt(e.target.value) })}
|
||||
className="zap-split-slider"
|
||||
list="highlighter-ticks"
|
||||
/>
|
||||
<datalist id="highlighter-ticks">
|
||||
<option value="50" label="50%"></option>
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={authorWeight}
|
||||
onChange={(e) => onUpdate({ zapSplitAuthorWeight: parseInt(e.target.value) })}
|
||||
className="zap-split-slider"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label className="setting-label">Support Boris</label>
|
||||
<div className="zap-split-container">
|
||||
<div className="zap-split-labels">
|
||||
<span className="zap-split-label">Weight: {borisWeight.toFixed(1)}</span>
|
||||
<span className="zap-split-label">({borisPercentage.toFixed(1)}%)</span>
|
||||
<div className="setting-group">
|
||||
<div className="zap-split-container">
|
||||
<div className="zap-split-labels">
|
||||
<span className="zap-split-label">Author's Share: {authorWeight}</span>
|
||||
<span className="zap-split-label">({authorPercentage.toFixed(1)}%)</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={authorWeight}
|
||||
onChange={(e) => onUpdate({ zapSplitAuthorWeight: parseInt(e.target.value) })}
|
||||
className="zap-split-slider"
|
||||
list="author-ticks"
|
||||
/>
|
||||
<datalist id="author-ticks">
|
||||
<option value="50" label="50%"></option>
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="10"
|
||||
step="0.1"
|
||||
value={borisWeight}
|
||||
onChange={(e) => onUpdate({ zapSplitBorisWeight: parseFloat(e.target.value) })}
|
||||
className="zap-split-slider"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="zap-split-description">
|
||||
Weights determine zap splits when highlighting nostr-native content.
|
||||
If the content has multiple authors, their share is divided proportionally.
|
||||
<div className="setting-group">
|
||||
<div className="zap-split-container">
|
||||
<div className="zap-split-labels">
|
||||
<span className="zap-split-label">Boris' Share: {borisWeight.toFixed(1)}</span>
|
||||
<span className="zap-split-label">({borisPercentage.toFixed(1)}%)</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="10"
|
||||
step="0.1"
|
||||
value={borisWeight}
|
||||
onChange={(e) => onUpdate({ zapSplitBorisWeight: parseFloat(e.target.value) })}
|
||||
className="zap-split-slider"
|
||||
list="boris-ticks"
|
||||
/>
|
||||
<datalist id="boris-ticks">
|
||||
<option value="5" label="5"></option>
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="setting-description" style={{ marginBottom: '1rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
|
||||
Weights determine zap splits when highlighting nostr-native content.
|
||||
If the content has multiple authors, their share is divided proportionally.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isMobile && (
|
||||
<img
|
||||
src="/zaps.svg"
|
||||
alt="Zap Splits"
|
||||
style={{ width: '30%', height: 'auto', flexShrink: 0, opacity: 0.8 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faHome, faPlus, faNewspaper, faTimes, faBolt } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faHome, faNewspaper, faTimes } 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 { RelayPool } from 'applesauce-relay'
|
||||
import IconButton from './IconButton'
|
||||
import AddBookmarkModal from './AddBookmarkModal'
|
||||
import { createWebBookmark } from '../services/webBookmarkService'
|
||||
import { RELAYS } from '../config/relays'
|
||||
|
||||
interface SidebarHeaderProps {
|
||||
onToggleCollapse: () => void
|
||||
onLogout: () => void
|
||||
onOpenSettings: () => void
|
||||
relayPool: RelayPool | null
|
||||
isMobile?: boolean
|
||||
}
|
||||
|
||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, relayPool, isMobile = false }) => {
|
||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, isMobile = false }) => {
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
@@ -54,14 +48,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
return `${activeAccount.pubkey.slice(0, 8)}...${activeAccount.pubkey.slice(-8)}`
|
||||
}
|
||||
|
||||
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
|
||||
if (!activeAccount || !relayPool) {
|
||||
throw new Error('Please login to create bookmarks')
|
||||
}
|
||||
|
||||
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
|
||||
}
|
||||
|
||||
const profileImage = getProfileImage()
|
||||
|
||||
return (
|
||||
@@ -117,13 +103,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
ariaLabel="Explore"
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
icon={faBolt}
|
||||
onClick={() => navigate('/support')}
|
||||
title="Support"
|
||||
ariaLabel="Support"
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
icon={faGear}
|
||||
onClick={onOpenSettings}
|
||||
@@ -131,15 +110,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
ariaLabel="Settings"
|
||||
variant="ghost"
|
||||
/>
|
||||
{activeAccount && (
|
||||
<IconButton
|
||||
icon={faPlus}
|
||||
onClick={() => setShowAddModal(true)}
|
||||
title="Add bookmark"
|
||||
ariaLabel="Add bookmark"
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
{activeAccount ? (
|
||||
<IconButton
|
||||
icon={faRightFromBracket}
|
||||
@@ -159,12 +129,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showAddModal && (
|
||||
<AddBookmarkModal
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onSave={handleSaveBookmark}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faBolt, faSpinner, faUserCircle } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faHeart, faSpinner, faUserCircle } from '@fortawesome/free-solid-svg-icons'
|
||||
import { fetchBorisZappers, ZapSender } from '../services/zapReceiptService'
|
||||
import { fetchProfiles } from '../services/profileService'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
@@ -207,7 +207,7 @@ const SupporterCard: React.FC<SupporterCardProps> = ({ supporter, isWhale }) =>
|
||||
className="absolute -bottom-1 -right-1 w-8 h-8 bg-yellow-400 rounded-full flex items-center justify-center border-2"
|
||||
style={{ borderColor: 'var(--color-bg)' }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBolt} className="text-zinc-900 text-sm" />
|
||||
<FontAwesomeIcon icon={faHeart} className="text-zinc-900 text-sm" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faBookmark, faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
@@ -105,13 +105,33 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
const highlightsRef = useRef<HTMLDivElement>(null)
|
||||
const mainPaneRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Detect scroll direction to hide/show mobile buttons
|
||||
// Now using window scroll (document scroll) instead of pane scroll
|
||||
// Detect scroll direction and position to hide/show mobile buttons
|
||||
// Only hide on scroll down when viewing article content
|
||||
const isViewingArticle = !!(props.selectedUrl)
|
||||
const scrollDirection = useScrollDirection({
|
||||
threshold: 10,
|
||||
enabled: isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed
|
||||
enabled: isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && isViewingArticle
|
||||
})
|
||||
const showMobileButtons = scrollDirection !== 'down'
|
||||
|
||||
// Track if we're at the top of the page
|
||||
const [isAtTop, setIsAtTop] = useState(true)
|
||||
useEffect(() => {
|
||||
if (!isMobile || !isViewingArticle) return
|
||||
|
||||
const handleScroll = () => {
|
||||
setIsAtTop(window.scrollY <= 10)
|
||||
}
|
||||
|
||||
handleScroll() // Check initial position
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
|
||||
return () => window.removeEventListener('scroll', handleScroll)
|
||||
}, [isMobile, isViewingArticle])
|
||||
|
||||
// Bookmark button: hide only when scrolling down
|
||||
const showBookmarkButton = scrollDirection !== 'down'
|
||||
// Highlights button: hide when scrolling down OR at the top
|
||||
const showHighlightsButton = scrollDirection !== 'down' && !isAtTop
|
||||
|
||||
// Lock body scroll when mobile sidebar or highlights is open
|
||||
useEffect(() => {
|
||||
@@ -229,11 +249,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile bookmark button - only show when viewing article or explore (not on settings/me/profile/support) */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showMe && !props.showProfile && !props.showSupport && (
|
||||
{/* Mobile bookmark button - always show except on settings page */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && (
|
||||
<button
|
||||
className={`fixed z-[900] bg-zinc-800/70 border border-zinc-600/40 rounded-lg text-zinc-200 flex items-center justify-center transition-all duration-300 active:scale-95 backdrop-blur-sm md:hidden ${
|
||||
showMobileButtons ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
|
||||
showBookmarkButton ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
|
||||
}`}
|
||||
style={{
|
||||
top: 'calc(1rem + env(safe-area-inset-top))',
|
||||
@@ -249,11 +269,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mobile highlights button - only show when viewing article or explore (not on settings/me/profile/support) */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showMe && !props.showProfile && !props.showSupport && (
|
||||
{/* Mobile highlights button - only show when viewing article content */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && isViewingArticle && (
|
||||
<button
|
||||
className={`fixed z-[900] border border-zinc-600/40 rounded-lg flex items-center justify-center transition-all duration-300 active:scale-95 backdrop-blur-sm md:hidden ${
|
||||
showMobileButtons ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
|
||||
showHighlightsButton ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
|
||||
}`}
|
||||
style={{
|
||||
top: 'calc(1rem + env(safe-area-inset-top))',
|
||||
@@ -304,6 +324,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
loading={props.bookmarksLoading}
|
||||
relayPool={props.relayPool}
|
||||
isMobile={isMobile}
|
||||
settings={props.settings}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@@ -402,7 +423,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
)}
|
||||
<RelayStatusIndicator
|
||||
relayPool={props.relayPool}
|
||||
showOnMobile={showMobileButtons}
|
||||
showOnMobile={showBookmarkButton}
|
||||
/>
|
||||
{props.toastMessage && (
|
||||
<Toast
|
||||
|
||||
@@ -2,20 +2,21 @@
|
||||
* Nostr gateway URLs for viewing events and profiles on the web
|
||||
*/
|
||||
|
||||
export const NOSTR_GATEWAY = 'https://ants.sh' as const
|
||||
export const NOSTR_GATEWAY = 'https://nostr.at' as const
|
||||
export const SEARCH_PORTAL = 'https://ants.sh' as const
|
||||
|
||||
/**
|
||||
* Get a profile URL on the gateway
|
||||
*/
|
||||
export function getProfileUrl(npub: string): string {
|
||||
return `${NOSTR_GATEWAY}/p/${npub}`
|
||||
return `${NOSTR_GATEWAY}/${npub}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an event URL on the gateway
|
||||
*/
|
||||
export function getEventUrl(nevent: string): string {
|
||||
return `${NOSTR_GATEWAY}/e/${nevent}`
|
||||
return `${NOSTR_GATEWAY}/${nevent}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,12 +24,14 @@ export function getEventUrl(nevent: string): string {
|
||||
* Automatically detects if it's a profile (npub/nprofile) or event (note/nevent/naddr)
|
||||
*/
|
||||
export function getNostrUrl(identifier: string): string {
|
||||
// Check the prefix to determine if it's a profile or event
|
||||
if (identifier.startsWith('npub') || identifier.startsWith('nprofile')) {
|
||||
return `${NOSTR_GATEWAY}/p/${identifier}`
|
||||
}
|
||||
|
||||
// Everything else (note, nevent, naddr) goes to /e/
|
||||
return `${NOSTR_GATEWAY}/e/${identifier}`
|
||||
// nostr.at uses simple /{identifier} format for all types
|
||||
return `${NOSTR_GATEWAY}/${identifier}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a search portal URL with a query
|
||||
*/
|
||||
export function getSearchUrl(query: string): string {
|
||||
return `${SEARCH_PORTAL}/?q=${encodeURIComponent(query)}`
|
||||
}
|
||||
|
||||
|
||||
90
src/hooks/useAdaptiveTextColor.ts
Normal file
90
src/hooks/useAdaptiveTextColor.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { FastAverageColor } from 'fast-average-color'
|
||||
|
||||
interface AdaptiveTextColor {
|
||||
textColor: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to determine optimal text color based on image background
|
||||
* Samples the top-right corner of the image to ensure publication date is readable
|
||||
*
|
||||
* @param imageUrl - The URL of the image to analyze
|
||||
* @returns Object containing textColor for optimal contrast
|
||||
*/
|
||||
export function useAdaptiveTextColor(imageUrl: string | undefined): AdaptiveTextColor {
|
||||
const [colors, setColors] = useState<AdaptiveTextColor>({
|
||||
textColor: '#ffffff'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageUrl) {
|
||||
// No image, use default white text
|
||||
setColors({
|
||||
textColor: '#ffffff'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const fac = new FastAverageColor()
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
|
||||
img.onload = () => {
|
||||
try {
|
||||
const width = img.naturalWidth
|
||||
const height = img.naturalHeight
|
||||
|
||||
// Sample top-right corner (last 25% width, first 25% height)
|
||||
const color = fac.getColor(img, {
|
||||
left: Math.floor(width * 0.75),
|
||||
top: 0,
|
||||
width: Math.floor(width * 0.25),
|
||||
height: Math.floor(height * 0.25)
|
||||
})
|
||||
|
||||
console.log('Adaptive color detected:', {
|
||||
hex: color.hex,
|
||||
rgb: color.rgb,
|
||||
isLight: color.isLight,
|
||||
isDark: color.isDark
|
||||
})
|
||||
|
||||
// Use library's built-in isLight check for optimal contrast
|
||||
if (color.isLight) {
|
||||
console.log('Light background detected, using black text')
|
||||
setColors({
|
||||
textColor: '#000000'
|
||||
})
|
||||
} else {
|
||||
console.log('Dark background detected, using white text')
|
||||
setColors({
|
||||
textColor: '#ffffff'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
// Fallback to default on error
|
||||
console.error('Error analyzing image color:', error)
|
||||
setColors({
|
||||
textColor: '#ffffff'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
img.onerror = () => {
|
||||
// Fallback to default if image fails to load
|
||||
setColors({
|
||||
textColor: '#ffffff'
|
||||
})
|
||||
}
|
||||
|
||||
img.src = imageUrl
|
||||
|
||||
return () => {
|
||||
fac.destroy()
|
||||
}
|
||||
}, [imageUrl])
|
||||
|
||||
return colors
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ interface UseBookmarksDataParams {
|
||||
activeAccount: IAccount | undefined
|
||||
accountManager: AccountManager
|
||||
naddr?: string
|
||||
externalUrl?: string
|
||||
currentArticleCoordinate?: string
|
||||
currentArticleEventId?: string
|
||||
settings?: UserSettings
|
||||
@@ -23,6 +24,7 @@ export const useBookmarksData = ({
|
||||
activeAccount,
|
||||
accountManager,
|
||||
naddr,
|
||||
externalUrl,
|
||||
currentArticleCoordinate,
|
||||
currentArticleEventId,
|
||||
settings
|
||||
@@ -115,11 +117,13 @@ export const useBookmarksData = ({
|
||||
// Fetch highlights/contacts independently to avoid disturbing bookmarks
|
||||
useEffect(() => {
|
||||
if (!relayPool || !activeAccount) return
|
||||
if (!naddr) {
|
||||
// Only fetch general highlights when not viewing an article (naddr) or external URL
|
||||
// External URLs have their highlights fetched by useExternalUrlLoader
|
||||
if (!naddr && !externalUrl) {
|
||||
handleFetchHighlights()
|
||||
}
|
||||
handleFetchContacts()
|
||||
}, [relayPool, activeAccount, naddr, handleFetchHighlights, handleFetchContacts])
|
||||
}, [relayPool, activeAccount, naddr, externalUrl, handleFetchHighlights, handleFetchContacts])
|
||||
|
||||
return {
|
||||
bookmarks,
|
||||
|
||||
@@ -71,7 +71,7 @@ export function useExternalUrlLoader({
|
||||
// Check if fetchHighlightsForUrl exists, otherwise skip
|
||||
if (typeof fetchHighlightsForUrl === 'function') {
|
||||
const seen = new Set<string>()
|
||||
const highlightsList = await fetchHighlightsForUrl(
|
||||
await fetchHighlightsForUrl(
|
||||
relayPool,
|
||||
url,
|
||||
(highlight) => {
|
||||
@@ -84,9 +84,9 @@ export function useExternalUrlLoader({
|
||||
})
|
||||
}
|
||||
)
|
||||
// Ensure final list is sorted and contains all items
|
||||
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
|
||||
console.log(`📌 Found ${highlightsList.length} highlights for URL`)
|
||||
// Highlights are already set via the streaming callback
|
||||
// No need to set them again as that could cause a flash/disappearance
|
||||
console.log(`📌 Finished fetching highlights for URL`)
|
||||
} else {
|
||||
console.log('📌 Highlight fetching for URLs not yet implemented')
|
||||
}
|
||||
|
||||
@@ -1,21 +1,72 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
|
||||
interface UseReadingPositionOptions {
|
||||
enabled?: boolean
|
||||
onPositionChange?: (position: number) => void
|
||||
onReadingComplete?: () => void
|
||||
readingCompleteThreshold?: number // Default 0.9 (90%)
|
||||
syncEnabled?: boolean // Whether to sync positions to Nostr
|
||||
onSave?: (position: number) => void // Callback for saving position
|
||||
autoSaveInterval?: number // Auto-save interval in ms (default 5000)
|
||||
}
|
||||
|
||||
export const useReadingPosition = ({
|
||||
enabled = true,
|
||||
onPositionChange,
|
||||
onReadingComplete,
|
||||
readingCompleteThreshold = 0.9
|
||||
readingCompleteThreshold = 0.9,
|
||||
syncEnabled = false,
|
||||
onSave,
|
||||
autoSaveInterval = 5000
|
||||
}: UseReadingPositionOptions = {}) => {
|
||||
const [position, setPosition] = useState(0)
|
||||
const [isReadingComplete, setIsReadingComplete] = useState(false)
|
||||
const hasTriggeredComplete = useRef(false)
|
||||
const lastSavedPosition = useRef(0)
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
// Debounced save function
|
||||
const scheduleSave = useCallback((currentPosition: number) => {
|
||||
if (!syncEnabled || !onSave) return
|
||||
|
||||
// Don't save if position is too low (< 5%)
|
||||
if (currentPosition < 0.05) return
|
||||
|
||||
// Don't save if position hasn't changed significantly (less than 1%)
|
||||
// But always save if we've reached 100% (completion)
|
||||
const hasSignificantChange = Math.abs(currentPosition - lastSavedPosition.current) >= 0.01
|
||||
const hasReachedCompletion = currentPosition === 1 && lastSavedPosition.current < 1
|
||||
|
||||
if (!hasSignificantChange && !hasReachedCompletion) return
|
||||
|
||||
// Clear existing timer
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current)
|
||||
}
|
||||
|
||||
// Schedule new save
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
lastSavedPosition.current = currentPosition
|
||||
onSave(currentPosition)
|
||||
}, autoSaveInterval)
|
||||
}, [syncEnabled, onSave, autoSaveInterval])
|
||||
|
||||
// Immediate save function
|
||||
const saveNow = useCallback(() => {
|
||||
if (!syncEnabled || !onSave) return
|
||||
|
||||
// Cancel any pending saves
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current)
|
||||
saveTimerRef.current = null
|
||||
}
|
||||
|
||||
// Save if position is meaningful (>= 5%)
|
||||
if (position >= 0.05) {
|
||||
lastSavedPosition.current = position
|
||||
onSave(position)
|
||||
}
|
||||
}, [syncEnabled, onSave, position])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
@@ -30,12 +81,20 @@ export const useReadingPosition = ({
|
||||
const documentHeight = document.documentElement.scrollHeight
|
||||
|
||||
// Calculate position based on how much of the content has been scrolled through
|
||||
const scrollProgress = Math.min(scrollTop / (documentHeight - windowHeight), 1)
|
||||
const clampedProgress = Math.max(0, Math.min(1, scrollProgress))
|
||||
// Add a small threshold (5px) to account for rounding and make it easier to reach 100%
|
||||
const maxScroll = documentHeight - windowHeight
|
||||
const scrollProgress = maxScroll > 0 ? scrollTop / maxScroll : 0
|
||||
|
||||
// If we're within 5px of the bottom, consider it 100%
|
||||
const isAtBottom = scrollTop + windowHeight >= documentHeight - 5
|
||||
const clampedProgress = isAtBottom ? 1 : Math.max(0, Math.min(1, scrollProgress))
|
||||
|
||||
setPosition(clampedProgress)
|
||||
onPositionChange?.(clampedProgress)
|
||||
|
||||
// Schedule auto-save if sync is enabled
|
||||
scheduleSave(clampedProgress)
|
||||
|
||||
// Check if reading is complete
|
||||
if (clampedProgress >= readingCompleteThreshold && !hasTriggeredComplete.current) {
|
||||
setIsReadingComplete(true)
|
||||
@@ -54,8 +113,13 @@ export const useReadingPosition = ({
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
window.removeEventListener('resize', handleScroll)
|
||||
|
||||
// Clear save timer on unmount
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold])
|
||||
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold, scheduleSave])
|
||||
|
||||
// Reset reading complete state when enabled changes
|
||||
useEffect(() => {
|
||||
@@ -68,6 +132,7 @@ export const useReadingPosition = ({
|
||||
return {
|
||||
position,
|
||||
isReadingComplete,
|
||||
progressPercentage: Math.round(position * 100)
|
||||
progressPercentage: Math.round(position * 100),
|
||||
saveNow
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,9 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
root.setProperty('--highlight-color-friends', settings.highlightColorFriends || '#f97316')
|
||||
root.setProperty('--highlight-color-nostrverse', settings.highlightColorNostrverse || '#9333ea')
|
||||
|
||||
// Set paragraph alignment
|
||||
root.setProperty('--paragraph-alignment', settings.paragraphAlignment || 'justify')
|
||||
|
||||
console.log('✅ All styles applied')
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export function dedupeNip51Events(events: NostrEvent[]): NostrEvent[] {
|
||||
const webBookmarks = unique.filter(e => e.kind === 39701)
|
||||
|
||||
const bookmarkLists = unique
|
||||
.filter(e => e.kind === 10003 || e.kind === 30001)
|
||||
.filter(e => e.kind === 10003 || e.kind === 30003 || e.kind === 30001)
|
||||
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
|
||||
const latestBookmarkList = bookmarkLists.find(list => !list.tags?.some((t: string[]) => t[0] === 'd'))
|
||||
|
||||
|
||||
@@ -16,11 +16,24 @@ export interface BookmarkData {
|
||||
tags?: string[][]
|
||||
}
|
||||
|
||||
export interface AddressPointer {
|
||||
kind: number
|
||||
pubkey: string
|
||||
identifier: string
|
||||
relays?: string[]
|
||||
}
|
||||
|
||||
export interface EventPointer {
|
||||
id: string
|
||||
relays?: string[]
|
||||
author?: string
|
||||
}
|
||||
|
||||
export interface ApplesauceBookmarks {
|
||||
notes?: BookmarkData[]
|
||||
articles?: BookmarkData[]
|
||||
hashtags?: BookmarkData[]
|
||||
urls?: BookmarkData[]
|
||||
notes?: EventPointer[]
|
||||
articles?: AddressPointer[]
|
||||
hashtags?: string[]
|
||||
urls?: string[]
|
||||
}
|
||||
|
||||
export interface AccountWithExtension {
|
||||
@@ -55,25 +68,83 @@ export const processApplesauceBookmarks = (
|
||||
|
||||
if (typeof bookmarks === 'object' && bookmarks !== null && !Array.isArray(bookmarks)) {
|
||||
const applesauceBookmarks = bookmarks as ApplesauceBookmarks
|
||||
const allItems: BookmarkData[] = []
|
||||
if (applesauceBookmarks.notes) allItems.push(...applesauceBookmarks.notes)
|
||||
if (applesauceBookmarks.articles) allItems.push(...applesauceBookmarks.articles)
|
||||
if (applesauceBookmarks.hashtags) allItems.push(...applesauceBookmarks.hashtags)
|
||||
if (applesauceBookmarks.urls) allItems.push(...applesauceBookmarks.urls)
|
||||
const allItems: IndividualBookmark[] = []
|
||||
|
||||
// Process notes (EventPointer[])
|
||||
if (applesauceBookmarks.notes) {
|
||||
applesauceBookmarks.notes.forEach((note: EventPointer) => {
|
||||
allItems.push({
|
||||
id: note.id,
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
pubkey: note.author || activeAccount.pubkey,
|
||||
kind: 1, // Short note kind
|
||||
tags: [],
|
||||
parsedContent: undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
added_at: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Process articles (AddressPointer[])
|
||||
if (applesauceBookmarks.articles) {
|
||||
applesauceBookmarks.articles.forEach((article: AddressPointer) => {
|
||||
// Convert AddressPointer to coordinate format: kind:pubkey:identifier
|
||||
const coordinate = `${article.kind}:${article.pubkey}:${article.identifier || ''}`
|
||||
allItems.push({
|
||||
id: coordinate,
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
pubkey: article.pubkey,
|
||||
kind: article.kind, // Usually 30023 for long-form articles
|
||||
tags: [],
|
||||
parsedContent: undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
added_at: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Process hashtags (string[])
|
||||
if (applesauceBookmarks.hashtags) {
|
||||
applesauceBookmarks.hashtags.forEach((hashtag: string) => {
|
||||
allItems.push({
|
||||
id: `hashtag-${hashtag}`,
|
||||
content: `#${hashtag}`,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
pubkey: activeAccount.pubkey,
|
||||
kind: 1,
|
||||
tags: [['t', hashtag]],
|
||||
parsedContent: undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
added_at: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Process URLs (string[])
|
||||
if (applesauceBookmarks.urls) {
|
||||
applesauceBookmarks.urls.forEach((url: string) => {
|
||||
allItems.push({
|
||||
id: `url-${url}`,
|
||||
content: url,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
pubkey: activeAccount.pubkey,
|
||||
kind: 1,
|
||||
tags: [['r', url]],
|
||||
parsedContent: undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
added_at: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return allItems
|
||||
.filter((bookmark: BookmarkData) => bookmark.id) // Skip bookmarks without valid IDs
|
||||
.map((bookmark: BookmarkData) => ({
|
||||
id: bookmark.id!,
|
||||
content: bookmark.content || '',
|
||||
created_at: bookmark.created_at || Math.floor(Date.now() / 1000),
|
||||
pubkey: activeAccount.pubkey,
|
||||
kind: bookmark.kind || 30001,
|
||||
tags: bookmark.tags || [],
|
||||
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
added_at: bookmark.created_at || Math.floor(Date.now() / 1000)
|
||||
}))
|
||||
}
|
||||
|
||||
const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks]
|
||||
|
||||
@@ -33,6 +33,12 @@ export async function collectBookmarksFromEvents(
|
||||
if (!latestContent && evt.content && !Helpers.hasHiddenContent(evt)) latestContent = evt.content
|
||||
if (Array.isArray(evt.tags)) allTags = allTags.concat(evt.tags)
|
||||
|
||||
// Extract the 'd' tag and metadata for bookmark sets (kind 30003)
|
||||
const dTag = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] : undefined
|
||||
const setTitle = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'title')?.[1] : undefined
|
||||
const setDescription = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'description')?.[1] : undefined
|
||||
const setImage = evt.kind === 30003 ? evt.tags?.find((t: string[]) => t[0] === 'image')?.[1] : undefined
|
||||
|
||||
// Handle web bookmarks (kind:39701) as individual bookmarks
|
||||
if (evt.kind === 39701) {
|
||||
publicItemsAll.push({
|
||||
@@ -45,13 +51,27 @@ export async function collectBookmarksFromEvents(
|
||||
parsedContent: undefined,
|
||||
type: 'web' as const,
|
||||
isPrivate: false,
|
||||
added_at: evt.created_at || Math.floor(Date.now() / 1000)
|
||||
added_at: evt.created_at || Math.floor(Date.now() / 1000),
|
||||
sourceKind: 39701,
|
||||
setName: dTag,
|
||||
setTitle,
|
||||
setDescription,
|
||||
setImage
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const pub = Helpers.getPublicBookmarks(evt)
|
||||
publicItemsAll.push(...processApplesauceBookmarks(pub, activeAccount, false))
|
||||
publicItemsAll.push(
|
||||
...processApplesauceBookmarks(pub, activeAccount, false).map(i => ({
|
||||
...i,
|
||||
sourceKind: evt.kind,
|
||||
setName: dTag,
|
||||
setTitle,
|
||||
setDescription,
|
||||
setImage
|
||||
}))
|
||||
)
|
||||
|
||||
try {
|
||||
if (Helpers.hasHiddenTags(evt) && !Helpers.isHiddenTagsUnlocked(evt) && signerCandidate) {
|
||||
@@ -94,7 +114,16 @@ export async function collectBookmarksFromEvents(
|
||||
try {
|
||||
const hiddenTags = JSON.parse(decryptedContent) as string[][]
|
||||
const manualPrivate = Helpers.parseBookmarkTags(hiddenTags)
|
||||
privateItemsAll.push(...processApplesauceBookmarks(manualPrivate, activeAccount, true))
|
||||
privateItemsAll.push(
|
||||
...processApplesauceBookmarks(manualPrivate, activeAccount, true).map(i => ({
|
||||
...i,
|
||||
sourceKind: evt.kind,
|
||||
setName: dTag,
|
||||
setTitle,
|
||||
setDescription,
|
||||
setImage
|
||||
}))
|
||||
)
|
||||
Reflect.set(evt, BookmarkHiddenSymbol, manualPrivate)
|
||||
Reflect.set(evt, 'EncryptedContentSymbol', decryptedContent)
|
||||
// Don't set latestContent to decrypted JSON - it's not user-facing content
|
||||
@@ -106,7 +135,16 @@ export async function collectBookmarksFromEvents(
|
||||
|
||||
const priv = Helpers.getHiddenBookmarks(evt)
|
||||
if (priv) {
|
||||
privateItemsAll.push(...processApplesauceBookmarks(priv, activeAccount, true))
|
||||
privateItemsAll.push(
|
||||
...processApplesauceBookmarks(priv, activeAccount, true).map(i => ({
|
||||
...i,
|
||||
sourceKind: evt.kind,
|
||||
setName: dTag,
|
||||
setTitle,
|
||||
setDescription,
|
||||
setImage
|
||||
}))
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// ignore individual event failures
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
dedupeNip51Events,
|
||||
hydrateItems,
|
||||
isAccountWithExtension,
|
||||
isHexId,
|
||||
hasNip04Decrypt,
|
||||
hasNip44Decrypt,
|
||||
dedupeBookmarksById,
|
||||
@@ -57,11 +56,28 @@ export const fetchBookmarks = async (
|
||||
rawEvents.forEach((evt, i) => {
|
||||
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none'
|
||||
const contentPreview = evt.content ? evt.content.slice(0, 50) + (evt.content.length > 50 ? '...' : '') : 'empty'
|
||||
console.log(` Event ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag=${dTag}, contentLength=${evt.content?.length || 0}, contentPreview=${contentPreview}`)
|
||||
const eTags = evt.tags?.filter((t: string[]) => t[0] === 'e').length || 0
|
||||
const aTags = evt.tags?.filter((t: string[]) => t[0] === 'a').length || 0
|
||||
console.log(` Event ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag=${dTag}, contentLength=${evt.content?.length || 0}, eTags=${eTags}, aTags=${aTags}, contentPreview=${contentPreview}`)
|
||||
})
|
||||
|
||||
const bookmarkListEvents = dedupeNip51Events(rawEvents)
|
||||
console.log('📋 After deduplication:', bookmarkListEvents.length, 'bookmark events')
|
||||
|
||||
// Log which events made it through deduplication
|
||||
bookmarkListEvents.forEach((evt, i) => {
|
||||
const dTag = evt.tags?.find((t: string[]) => t[0] === 'd')?.[1] || 'none'
|
||||
console.log(` Dedupe ${i}: kind=${evt.kind}, id=${evt.id?.slice(0, 8)}, dTag="${dTag}"`)
|
||||
})
|
||||
|
||||
// Check specifically for Primal's "reads" list
|
||||
const primalReads = rawEvents.find(e => e.kind === 10003 && e.tags?.find((t: string[]) => t[0] === 'd' && t[1] === 'reads'))
|
||||
if (primalReads) {
|
||||
console.log('✅ Found Primal reads list:', primalReads.id.slice(0, 8))
|
||||
} else {
|
||||
console.log('❌ No Primal reads list found (kind:10003 with d="reads")')
|
||||
}
|
||||
|
||||
if (bookmarkListEvents.length === 0) {
|
||||
// Keep existing bookmarks visible; do not clear list if nothing new found
|
||||
return
|
||||
@@ -97,20 +113,88 @@ export const fetchBookmarks = async (
|
||||
)
|
||||
|
||||
const allItems = [...publicItemsAll, ...privateItemsAll]
|
||||
const noteIds = Array.from(new Set(allItems.map(i => i.id).filter(isHexId)))
|
||||
let idToEvent: Map<string, NostrEvent> = new Map()
|
||||
|
||||
// Separate hex IDs (regular events) from coordinates (addressable events)
|
||||
const noteIds: string[] = []
|
||||
const coordinates: string[] = []
|
||||
|
||||
allItems.forEach(i => {
|
||||
// Check if it's a hex ID (64 character hex string)
|
||||
if (/^[0-9a-f]{64}$/i.test(i.id)) {
|
||||
noteIds.push(i.id)
|
||||
} else if (i.id.includes(':')) {
|
||||
// Coordinate format: kind:pubkey:identifier
|
||||
coordinates.push(i.id)
|
||||
}
|
||||
})
|
||||
|
||||
const idToEvent: Map<string, NostrEvent> = new Map()
|
||||
|
||||
// Fetch regular events by ID
|
||||
if (noteIds.length > 0) {
|
||||
try {
|
||||
const events = await queryEvents(
|
||||
relayPool,
|
||||
{ ids: noteIds },
|
||||
{ ids: Array.from(new Set(noteIds)) },
|
||||
{ localTimeoutMs: 800, remoteTimeoutMs: 2500 }
|
||||
)
|
||||
idToEvent = new Map(events.map((e: NostrEvent) => [e.id, e]))
|
||||
events.forEach((e: NostrEvent) => {
|
||||
idToEvent.set(e.id, e)
|
||||
// Also store by coordinate if it's an addressable event
|
||||
if (e.kind && e.kind >= 30000 && e.kind < 40000) {
|
||||
const dTag = e.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||
const coordinate = `${e.kind}:${e.pubkey}:${dTag}`
|
||||
idToEvent.set(coordinate, e)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch events for hydration:', error)
|
||||
console.warn('Failed to fetch events by ID:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch addressable events by coordinates
|
||||
if (coordinates.length > 0) {
|
||||
try {
|
||||
// Group by kind for more efficient querying
|
||||
const byKind = new Map<number, Array<{ pubkey: string; identifier: string }>>()
|
||||
|
||||
coordinates.forEach(coord => {
|
||||
const parts = coord.split(':')
|
||||
const kind = parseInt(parts[0])
|
||||
const pubkey = parts[1]
|
||||
const identifier = parts[2] || ''
|
||||
|
||||
if (!byKind.has(kind)) {
|
||||
byKind.set(kind, [])
|
||||
}
|
||||
byKind.get(kind)!.push({ pubkey, identifier })
|
||||
})
|
||||
|
||||
// Query each kind group
|
||||
for (const [kind, items] of byKind.entries()) {
|
||||
const authors = Array.from(new Set(items.map(i => i.pubkey)))
|
||||
const identifiers = Array.from(new Set(items.map(i => i.identifier)))
|
||||
|
||||
const events = await queryEvents(
|
||||
relayPool,
|
||||
{ kinds: [kind], authors, '#d': identifiers },
|
||||
{ localTimeoutMs: 800, remoteTimeoutMs: 2500 }
|
||||
)
|
||||
|
||||
events.forEach((e: NostrEvent) => {
|
||||
const dTag = e.tags?.find((t: string[]) => t[0] === 'd')?.[1] || ''
|
||||
const coordinate = `${e.kind}:${e.pubkey}:${dTag}`
|
||||
idToEvent.set(coordinate, e)
|
||||
// Also store by event ID
|
||||
idToEvent.set(e.id, e)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch addressable events:', error)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`📦 Hydration: fetched ${idToEvent.size} events for ${allItems.length} bookmarks (${noteIds.length} notes, ${coordinates.length} articles)`)
|
||||
const allBookmarks = dedupeBookmarksById([
|
||||
...hydrateItems(publicItemsAll, idToEvent),
|
||||
...hydrateItems(privateItemsAll, idToEvent)
|
||||
|
||||
@@ -14,10 +14,11 @@ export const fetchHighlightsForUrl = async (
|
||||
onHighlight?: (highlight: Highlight) => void,
|
||||
settings?: UserSettings
|
||||
): Promise<Highlight[]> => {
|
||||
const seenIds = new Set<string>()
|
||||
const orderedRelaysUrl = prioritizeLocalRelays(RELAYS)
|
||||
const { local: localRelaysUrl, remote: remoteRelaysUrl } = partitionRelays(orderedRelaysUrl)
|
||||
|
||||
try {
|
||||
const seenIds = new Set<string>()
|
||||
const orderedRelaysUrl = prioritizeLocalRelays(RELAYS)
|
||||
const { local: localRelaysUrl, remote: remoteRelaysUrl } = partitionRelays(orderedRelaysUrl)
|
||||
const local$ = localRelaysUrl.length > 0
|
||||
? relayPool
|
||||
.req(localRelaysUrl, { kinds: [9802], '#r': [url] })
|
||||
@@ -45,11 +46,23 @@ export const fetchHighlightsForUrl = async (
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
||||
|
||||
console.log(`📌 Fetched ${rawEvents.length} highlight events for URL:`, url)
|
||||
|
||||
// Rebroadcast events - but don't let errors here break the highlight display
|
||||
try {
|
||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
||||
} catch (err) {
|
||||
console.warn('Failed to rebroadcast highlight events:', err)
|
||||
}
|
||||
|
||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
|
||||
return sortHighlights(highlights)
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error('Error fetching highlights for URL:', err)
|
||||
// Return highlights that were already streamed via callback
|
||||
// Don't return empty array as that would clear already-displayed highlights
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
196
src/services/readingPositionService.ts
Normal file
196
src/services/readingPositionService.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { IEventStore, mapEventsToStore } from 'applesauce-core'
|
||||
import { EventFactory } from 'applesauce-factory'
|
||||
import { RelayPool, onlyEvents } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { firstValueFrom } from 'rxjs'
|
||||
import { publishEvent } from './writeService'
|
||||
import { RELAYS } from '../config/relays'
|
||||
|
||||
const APP_DATA_KIND = 30078 // NIP-78 Application Data
|
||||
const READING_POSITION_PREFIX = 'boris:reading-position:'
|
||||
|
||||
export interface ReadingPosition {
|
||||
position: number // 0-1 scroll progress
|
||||
timestamp: number // Unix timestamp
|
||||
scrollTop?: number // Optional: pixel position
|
||||
}
|
||||
|
||||
// Helper to extract and parse reading position from an event
|
||||
function getReadingPositionContent(event: NostrEvent): ReadingPosition | undefined {
|
||||
if (!event.content || event.content.length === 0) return undefined
|
||||
try {
|
||||
return JSON.parse(event.content) as ReadingPosition
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique identifier for an article
|
||||
* For Nostr articles: use the naddr directly
|
||||
* For external URLs: use base64url encoding of the URL
|
||||
*/
|
||||
export function generateArticleIdentifier(naddrOrUrl: string): string {
|
||||
// If it starts with "nostr:", extract the naddr
|
||||
if (naddrOrUrl.startsWith('nostr:')) {
|
||||
return naddrOrUrl.replace('nostr:', '')
|
||||
}
|
||||
// For URLs, use base64url encoding (URL-safe)
|
||||
return btoa(naddrOrUrl)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Save reading position to Nostr (Kind 30078)
|
||||
*/
|
||||
export async function saveReadingPosition(
|
||||
relayPool: RelayPool,
|
||||
eventStore: IEventStore,
|
||||
factory: EventFactory,
|
||||
articleIdentifier: string,
|
||||
position: ReadingPosition
|
||||
): Promise<void> {
|
||||
console.log('💾 [ReadingPosition] Saving position:', {
|
||||
identifier: articleIdentifier.slice(0, 32) + '...',
|
||||
position: position.position,
|
||||
positionPercent: Math.round(position.position * 100) + '%',
|
||||
timestamp: position.timestamp,
|
||||
scrollTop: position.scrollTop
|
||||
})
|
||||
|
||||
const dTag = `${READING_POSITION_PREFIX}${articleIdentifier}`
|
||||
|
||||
const draft = await factory.create(async () => ({
|
||||
kind: APP_DATA_KIND,
|
||||
content: JSON.stringify(position),
|
||||
tags: [
|
||||
['d', dTag],
|
||||
['client', 'boris']
|
||||
],
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
}))
|
||||
|
||||
const signed = await factory.sign(draft)
|
||||
|
||||
// Use unified write service
|
||||
await publishEvent(relayPool, eventStore, signed)
|
||||
|
||||
console.log('✅ [ReadingPosition] Position saved successfully, event ID:', signed.id.slice(0, 8))
|
||||
}
|
||||
|
||||
/**
|
||||
* Load reading position from Nostr
|
||||
*/
|
||||
export async function loadReadingPosition(
|
||||
relayPool: RelayPool,
|
||||
eventStore: IEventStore,
|
||||
pubkey: string,
|
||||
articleIdentifier: string
|
||||
): Promise<ReadingPosition | null> {
|
||||
const dTag = `${READING_POSITION_PREFIX}${articleIdentifier}`
|
||||
|
||||
console.log('📖 [ReadingPosition] Loading position:', {
|
||||
pubkey: pubkey.slice(0, 8) + '...',
|
||||
identifier: articleIdentifier.slice(0, 32) + '...',
|
||||
dTag: dTag.slice(0, 50) + '...'
|
||||
})
|
||||
|
||||
// First, check if we already have the position in the local event store
|
||||
try {
|
||||
const localEvent = await firstValueFrom(
|
||||
eventStore.replaceable(APP_DATA_KIND, pubkey, dTag)
|
||||
)
|
||||
if (localEvent) {
|
||||
const content = getReadingPositionContent(localEvent)
|
||||
if (content) {
|
||||
console.log('✅ [ReadingPosition] Loaded from local store:', {
|
||||
position: content.position,
|
||||
positionPercent: Math.round(content.position * 100) + '%',
|
||||
timestamp: content.timestamp
|
||||
})
|
||||
|
||||
// Still fetch from relays in the background to get any updates
|
||||
relayPool
|
||||
.subscription(RELAYS, {
|
||||
kinds: [APP_DATA_KIND],
|
||||
authors: [pubkey],
|
||||
'#d': [dTag]
|
||||
})
|
||||
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||
.subscribe()
|
||||
|
||||
return content
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('📭 No cached reading position 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.log('⏱️ Reading position load timeout - no position found')
|
||||
hasResolved = true
|
||||
resolve(null)
|
||||
}
|
||||
}, 3000) // Shorter timeout for reading positions
|
||||
|
||||
const sub = relayPool
|
||||
.subscription(RELAYS, {
|
||||
kinds: [APP_DATA_KIND],
|
||||
authors: [pubkey],
|
||||
'#d': [dTag]
|
||||
})
|
||||
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||
.subscribe({
|
||||
complete: async () => {
|
||||
clearTimeout(timeout)
|
||||
if (!hasResolved) {
|
||||
hasResolved = true
|
||||
try {
|
||||
const event = await firstValueFrom(
|
||||
eventStore.replaceable(APP_DATA_KIND, pubkey, dTag)
|
||||
)
|
||||
if (event) {
|
||||
const content = getReadingPositionContent(event)
|
||||
if (content) {
|
||||
console.log('✅ [ReadingPosition] Loaded from relays:', {
|
||||
position: content.position,
|
||||
positionPercent: Math.round(content.position * 100) + '%',
|
||||
timestamp: content.timestamp
|
||||
})
|
||||
resolve(content)
|
||||
} else {
|
||||
console.log('⚠️ [ReadingPosition] Event found but no valid content')
|
||||
resolve(null)
|
||||
}
|
||||
} else {
|
||||
console.log('📭 [ReadingPosition] No position found on relays')
|
||||
resolve(null)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('❌ Error loading reading position:', err)
|
||||
resolve(null)
|
||||
}
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('❌ Reading position subscription error:', err)
|
||||
clearTimeout(timeout)
|
||||
if (!hasResolved) {
|
||||
hasResolved = true
|
||||
resolve(null)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
sub.unsubscribe()
|
||||
}, 3000)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -52,6 +52,10 @@ export interface UserSettings {
|
||||
theme?: 'dark' | 'light' | 'system' // default: system
|
||||
darkColorTheme?: 'black' | 'midnight' | 'charcoal' // default: midnight
|
||||
lightColorTheme?: 'paper-white' | 'sepia' | 'ivory' // default: sepia
|
||||
// Reading settings
|
||||
paragraphAlignment?: 'left' | 'justify' // default: justify
|
||||
// Reading position sync
|
||||
syncReadingPosition?: boolean // default: false (opt-in)
|
||||
}
|
||||
|
||||
export async function loadSettings(
|
||||
|
||||
@@ -7,6 +7,27 @@
|
||||
.bookmark-content { color: var(--color-text); margin: 0.5rem 0; line-height: 1.4; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }
|
||||
.bookmark-meta { color: var(--color-text-secondary); font-size: 0.9rem; margin-top: 0.5rem; }
|
||||
|
||||
.bookmarks-section-title {
|
||||
font-size: 0.75rem !important;
|
||||
font-weight: 700 !important;
|
||||
text-transform: uppercase !important;
|
||||
letter-spacing: 0.05em !important;
|
||||
color: var(--color-text-muted) !important;
|
||||
padding: 1.5rem 0.5rem 0.375rem !important;
|
||||
margin: 0 !important;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.bookmarks-section:first-of-type .bookmarks-section-title {
|
||||
border-top: none;
|
||||
padding-top: 0.5rem !important;
|
||||
}
|
||||
.bookmark-section-action {
|
||||
padding: 1.5rem 0.5rem 0.375rem;
|
||||
}
|
||||
.bookmarks-section:first-of-type .bookmark-section-action {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.individual-bookmarks { margin: 1rem 0; }
|
||||
.individual-bookmarks h4 { margin: 0 0 1rem 0; font-size: 1rem; color: var(--color-text); }
|
||||
|
||||
@@ -23,8 +44,8 @@
|
||||
.individual-bookmark:hover { border-color: var(--color-border); background: var(--color-bg-elevated); }
|
||||
|
||||
/* Compact view */
|
||||
.individual-bookmark.compact { padding: 0.5rem 0.5rem; background: transparent; border: none; border-bottom: 1px solid var(--color-bg-elevated); border-radius: 0; box-shadow: none; width: 100%; max-width: 100%; overflow: hidden; }
|
||||
.individual-bookmark.compact:hover { background: var(--color-bg-elevated); border-bottom-color: var(--color-border); transform: none; box-shadow: none; }
|
||||
.individual-bookmark.compact { padding: 0.5rem 0.5rem; background: transparent; border: none !important; border-radius: 0; box-shadow: none; width: 100%; max-width: 100%; overflow: hidden; }
|
||||
.individual-bookmark.compact:hover { background: var(--color-bg-elevated); transform: none; box-shadow: none; border: none !important; }
|
||||
.compact-row { display: flex; align-items: center; gap: 0.5rem; height: 28px; width: 100%; min-width: 0; overflow: hidden; }
|
||||
.compact-thumbnail { width: 24px; height: 24px; flex-shrink: 0; border-radius: 4px; overflow: hidden; background: var(--color-bg-elevated); display: flex; align-items: center; justify-content: center; }
|
||||
.compact-thumbnail img { width: 100%; height: 100%; object-fit: cover; }
|
||||
@@ -57,10 +78,10 @@
|
||||
|
||||
/* Large preview view */
|
||||
.individual-bookmark.large { padding: 0; display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--color-bg-elevated); }
|
||||
.large-preview-image { width: 100%; height: 180px; background: var(--color-bg); background-size: cover; background-position: center; background-repeat: no-repeat; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s ease; border-bottom: 1px solid var(--color-border); position: relative; }
|
||||
.large-preview-image { width: 100%; height: 180px; background: linear-gradient(135deg, var(--color-bg-elevated) 0%, var(--color-bg-subtle) 50%, var(--color-bg-elevated) 100%); background-size: cover; background-position: center; background-repeat: no-repeat; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s ease; border-bottom: 1px solid var(--color-border); position: relative; }
|
||||
.large-preview-image:hover { opacity: 0.9; }
|
||||
.large-preview-image::after { content: ''; position: absolute; inset: 0; background: linear-gradient(to bottom, transparent 60%, rgba(0,0,0,0.3) 100%); pointer-events: none; }
|
||||
.preview-placeholder { font-size: 3rem; color: var(--color-border-subtle); }
|
||||
.preview-placeholder { font-size: 3rem; color: var(--color-border-subtle); opacity: 0.4; }
|
||||
.large-content { padding: 1.25rem; }
|
||||
.large-text { color: var(--color-text); font-size: 0.95rem; line-height: 1.6; margin-bottom: 1rem; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.large-footer { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; font-size: 0.8rem; color: var(--color-text-secondary); padding-top: 0.75rem; border-top: 1px solid var(--color-border); }
|
||||
@@ -84,10 +105,10 @@
|
||||
.blog-post-card.level-mine { border-color: color-mix(in srgb, var(--highlight-color-mine, #fde047) 60%, #333); box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-mine, #fde047) 25%, transparent); }
|
||||
.blog-post-card.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); }
|
||||
.blog-post-card.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); }
|
||||
.blog-post-card-image { width: 100%; height: 200px; overflow: hidden; background: var(--color-bg-subtle); display: flex; align-items: center; justify-content: center; }
|
||||
.blog-post-card-image { width: 100%; height: 200px; overflow: hidden; background: linear-gradient(135deg, var(--color-bg-elevated) 0%, var(--color-bg-subtle) 50%, var(--color-bg-elevated) 100%); display: flex; align-items: center; justify-content: center; position: relative; }
|
||||
.blog-post-card-image img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.3s ease; }
|
||||
.blog-post-card:hover .blog-post-card-image img { transform: scale(1.05); }
|
||||
.blog-post-image-placeholder { font-size: 3rem; color: var(--color-border-subtle); display: flex; align-items: center; justify-content: center; }
|
||||
.blog-post-image-placeholder { font-size: 3rem; color: var(--color-border-subtle); opacity: 0.4; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
|
||||
.blog-post-card-content { padding: 1.5rem; display: flex; flex-direction: column; gap: 1rem; flex: 1; }
|
||||
.blog-post-card-title { font-size: 1.25rem; font-weight: 600; margin: 0; color: var(--color-text); line-height: 1.4; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.blog-post-card-summary { font-size: 0.875rem; color: var(--color-text-secondary); margin: 0; line-height: 1.6; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; flex: 1; }
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
.setting-group.setting-inline { display: flex; align-items: center; 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; }
|
||||
.setting-group.setting-inline label { margin-bottom: 0; min-width: 220px; }
|
||||
.setting-group label { display: block; margin-bottom: 0.5rem; color: var(--color-text); font-weight: 500; text-align: left; }
|
||||
.setting-buttons { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.color-picker { display: flex; align-items: center; gap: 0.5rem; }
|
||||
@@ -41,6 +41,7 @@
|
||||
.preview-content p {
|
||||
margin: 0.75rem 0;
|
||||
word-wrap: break-word;
|
||||
text-align: var(--paragraph-alignment, justify);
|
||||
}
|
||||
.setting-select { width: 100%; padding: 0.5rem; background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 4px; color: var(--color-text); font-size: 1rem; }
|
||||
.setting-inline .setting-select { width: auto; min-width: 200px; flex: 1; }
|
||||
@@ -58,6 +59,10 @@
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.setting-group.setting-inline label {
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
.setting-inline .setting-select {
|
||||
width: 100%;
|
||||
min-width: unset;
|
||||
|
||||
@@ -67,6 +67,10 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.me-tab-content:has(.bookmark-filters) {
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Align highlight list width with profile card width on /me */
|
||||
.me-highlights-list { padding-left: 0; padding-right: 0; }
|
||||
.explore-header .author-card { max-width: 600px; margin: 0 auto; width: 100%; }
|
||||
@@ -79,6 +83,15 @@
|
||||
text-align: left; /* Override center alignment from .app */
|
||||
}
|
||||
|
||||
/* Bookmark filters in Me page */
|
||||
.me-tab-content .bookmark-filters {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* Ensure all reading list elements are left-aligned */
|
||||
.bookmarks-list .individual-bookmark,
|
||||
.bookmarks-list .individual-bookmark * {
|
||||
|
||||
@@ -25,3 +25,23 @@
|
||||
.btn-primary:hover:not(:disabled) { background: var(--color-primary-hover); }
|
||||
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
/* Confirm Dialog */
|
||||
.confirm-dialog-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.7); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 10000; padding: 1rem; }
|
||||
.confirm-dialog { background: var(--color-bg-elevated); border: 1px solid var(--color-border); border-radius: 12px; max-width: 400px; width: 100%; padding: 1.5rem; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); }
|
||||
.confirm-dialog-icon { display: flex; align-items: center; justify-content: center; width: 48px; height: 48px; border-radius: 50%; margin: 0 auto 1rem; font-size: 1.5rem; }
|
||||
.confirm-dialog-icon.danger { background: rgba(220, 38, 38, 0.1); color: rgb(220 38 38); }
|
||||
.confirm-dialog-icon.warning { background: rgba(251, 191, 36, 0.1); color: rgb(251 191 36); }
|
||||
.confirm-dialog-icon.info { background: rgba(59, 130, 246, 0.1); color: rgb(59 130 246); }
|
||||
.confirm-dialog-title { margin: 0 0 0.5rem 0; font-size: 1.25rem; font-weight: 600; color: var(--color-text); text-align: center; }
|
||||
.confirm-dialog-message { margin: 0 0 1.5rem 0; font-size: 0.9rem; color: var(--color-text-secondary); text-align: center; line-height: 1.5; }
|
||||
.confirm-dialog-actions { display: flex; gap: 0.75rem; }
|
||||
.confirm-dialog-btn { flex: 1; padding: 0.75rem 1rem; border: none; border-radius: 8px; font-size: 0.9rem; font-weight: 500; cursor: pointer; transition: all 0.2s; }
|
||||
.confirm-dialog-btn.cancel { background: var(--color-bg); border: 1px solid var(--color-border); color: var(--color-text); }
|
||||
.confirm-dialog-btn.cancel:hover { background: var(--color-border); }
|
||||
.confirm-dialog-btn.confirm.danger { background: rgb(220 38 38); color: white; }
|
||||
.confirm-dialog-btn.confirm.danger:hover { background: rgb(185 28 28); }
|
||||
.confirm-dialog-btn.confirm.warning { background: rgb(251 191 36); color: rgb(17 24 39); }
|
||||
.confirm-dialog-btn.confirm.warning:hover { background: rgb(245 158 11); }
|
||||
.confirm-dialog-btn.confirm.info { background: rgb(59 130 246); color: white; }
|
||||
.confirm-dialog-btn.confirm.info:hover { background: rgb(37 99 235); }
|
||||
|
||||
|
||||
@@ -30,16 +30,29 @@
|
||||
.reader-meta { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
|
||||
.publish-date { display: flex; align-items: center; gap: 0.4rem; font-size: 0.813rem; color: var(--color-text-muted); opacity: 0.85; }
|
||||
.publish-date svg { font-size: 0.75rem; opacity: 0.6; }
|
||||
.publish-date-topright { position: absolute; top: 1rem; right: 1rem; font-size: 0.813rem; color: var(--color-text); padding: 0.4rem 0.75rem; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); z-index: 10; }
|
||||
.publish-date-topright { position: absolute; top: 1rem; right: 1rem; font-size: 0.813rem; color: var(--color-text); padding: 0.4rem 0.75rem; z-index: 10; }
|
||||
.reading-time { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; background: var(--color-bg-elevated); border: 1px solid var(--color-border); border-radius: 6px; font-size: 0.875rem; color: var(--color-text-secondary); }
|
||||
.reading-time svg { font-size: 0.875rem; }
|
||||
.highlight-indicator { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; background: rgba(99, 102, 241, 0.1); border: 1px solid rgba(99, 102, 241, 0.3); border-radius: 6px; font-size: 0.875rem; color: var(--color-text); }
|
||||
.highlight-indicator svg { font-size: 0.875rem; }
|
||||
.reader-html { color: var(--color-text); line-height: 1.6; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; font-family: var(--reading-font); font-size: var(--reading-font-size); }
|
||||
.reader-markdown { color: var(--color-text); line-height: 1.7; font-family: var(--reading-font); font-size: var(--reading-font-size); }
|
||||
/* Ensure content is left-aligned even if source markup uses center */
|
||||
.reader .reader-html *, .reader .reader-markdown * { text-align: left !important; font-family: inherit !important; }
|
||||
.reader center, .reader [align="center"] { text-align: left !important; }
|
||||
/* Ensure font inheritance */
|
||||
.reader .reader-html *, .reader .reader-markdown * { font-family: inherit !important; }
|
||||
/* Apply paragraph alignment from settings */
|
||||
.reader .reader-html p,
|
||||
.reader .reader-markdown p,
|
||||
.reader .reader-html div,
|
||||
.reader .reader-markdown div,
|
||||
.reader .reader-html li,
|
||||
.reader .reader-markdown li,
|
||||
.reader .reader-html blockquote,
|
||||
.reader .reader-markdown blockquote { text-align: var(--paragraph-alignment, justify); }
|
||||
/* Override centered content with user preference */
|
||||
.reader center, .reader [align="center"] { text-align: var(--paragraph-alignment, justify) !important; }
|
||||
/* Keep headings left-aligned */
|
||||
.reader .reader-html h1, .reader .reader-html h2, .reader .reader-html h3, .reader .reader-html h4, .reader .reader-html h5, .reader .reader-html h6,
|
||||
.reader .reader-markdown h1, .reader .reader-markdown h2, .reader .reader-markdown h3, .reader .reader-markdown h4, .reader .reader-markdown h5, .reader .reader-markdown h6 { text-align: left !important; }
|
||||
/* Tame images from external content */
|
||||
.reader .reader-html img, .reader .reader-markdown img { max-width: 100%; max-height: 70vh; height: auto; width: auto; display: block; margin: 0.75rem 0; border-radius: 6px; }
|
||||
/* Headlines with Tailwind typography */
|
||||
@@ -128,6 +141,13 @@
|
||||
.reader-markdown blockquote p, .reader-html blockquote p { margin: 0.5rem 0; }
|
||||
.reader-markdown blockquote p:first-child, .reader-html blockquote p:first-child { margin-top: 0; }
|
||||
.reader-markdown blockquote p:last-child, .reader-html blockquote p:last-child { margin-bottom: 0; }
|
||||
/* Horizontal rule - subtle divider */
|
||||
.reader-markdown hr, .reader-html hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--color-border);
|
||||
opacity: 0.69;
|
||||
margin: 2.5rem 0;
|
||||
}
|
||||
.reader-markdown a { color: var(--color-primary); text-decoration: none; }
|
||||
.reader-markdown a:hover { text-decoration: underline; }
|
||||
.reader-markdown code { background: var(--color-bg-subtle); border: 1px solid var(--color-border); border-radius: 4px; padding: 0.15rem 0.4rem; font-size: 0.9em; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
|
||||
@@ -185,6 +205,7 @@
|
||||
.article-menu-btn { background: none; border: none; color: var(--color-text-secondary); cursor: pointer; padding: 0.5rem 0.75rem; font-size: 0.875rem; display: flex; align-items: center; gap: 0.5rem; transition: all 0.2s ease; border-radius: 6px; }
|
||||
.article-menu-btn:hover { color: var(--color-primary); background: rgba(99, 102, 241, 0.1); }
|
||||
.article-menu { position: absolute; right: 0; top: calc(100% + 4px); background: var(--color-bg-elevated); border: 1px solid var(--color-border-subtle); border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); z-index: 1000; min-width: 180px; overflow: hidden; }
|
||||
.article-menu.open-upward { top: auto; bottom: calc(100% + 4px); }
|
||||
.article-menu-item { width: 100%; background: none; border: none; color: var(--color-text); padding: 0.75rem 1rem; font-size: 0.875rem; display: flex; align-items: center; gap: 0.75rem; cursor: pointer; transition: all 0.15s ease; text-align: left; white-space: nowrap; }
|
||||
.article-menu-item:hover { background: rgba(99, 102, 241, 0.15); color: var(--color-text); }
|
||||
.article-menu-item svg { font-size: 0.875rem; flex-shrink: 0; }
|
||||
@@ -214,8 +235,9 @@
|
||||
.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; }
|
||||
.reader-hero-image { width: calc(100% + 1.5rem); margin: -0.75rem -0.75rem 2rem -0.75rem; border-radius: 0; overflow: hidden; position: relative; min-height: 300px; }
|
||||
.reader-hero-image { width: calc(100% + 1.5rem); margin: -0.75rem -0.75rem 2rem -0.75rem; border-radius: 0; overflow: hidden; position: relative; min-height: 300px; background: linear-gradient(135deg, var(--color-bg-elevated) 0%, var(--color-bg-subtle) 25%, var(--color-bg-elevated) 50%, var(--color-bg-subtle) 75%, var(--color-bg-elevated) 100%); }
|
||||
.reader-hero-image img { width: 100%; height: auto; max-height: 500px; object-fit: cover; display: block; }
|
||||
.reader-hero-placeholder { width: 100%; height: 300px; display: flex; align-items: center; justify-content: center; font-size: 4rem; color: var(--color-border-subtle); opacity: 0.3; }
|
||||
.reader-header-overlay { position: absolute; bottom: 0; left: 0; right: 0; padding: 2rem 2rem 1.5rem; background: linear-gradient(to top, rgba(0, 0, 0, 0.85) 0%, rgba(0, 0, 0, 0.6) 60%, rgba(0, 0, 0, 0) 100%); }
|
||||
.reader-header-overlay .reader-title { color: #fff; text-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); margin-bottom: 0.75rem; font-size: 2.5rem; font-weight: 700; line-height: 1.2; }
|
||||
.reader-header-overlay .reader-summary { color: rgba(255, 255, 255, 0.9); font-size: 1.2rem; line-height: 1.6; margin: 0 0 1rem 0; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); font-family: var(--reading-font); }
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/* Settings view containers */
|
||||
.settings-view { display: flex; flex-direction: column; height: 100%; overflow: hidden; padding: 0.75rem 1rem; text-align: left; }
|
||||
.settings-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; padding: 0; }
|
||||
.settings-header { display: flex; align-items: center; justify-content: space-between; padding: 0; max-width: 900px; margin: 0 auto 1.5rem auto; width: 100%; }
|
||||
.settings-header h2 { margin: 0; font-size: 1.5rem; font-weight: 600; text-align: left; }
|
||||
.settings-header-actions { display: flex; gap: 0.5rem; align-items: center; }
|
||||
.settings-content { overflow-y: auto; flex: 1; margin-bottom: 1rem; text-align: left; padding: 0 0.25rem 2rem 0.25rem; }
|
||||
.settings-content { overflow-y: auto; flex: 1; text-align: left; padding: 0 0.25rem 2rem 0.25rem; max-width: 900px; margin: 0 auto 1rem auto; width: 100%; }
|
||||
.settings-section { margin-bottom: 2.5rem; }
|
||||
.settings-section:last-child { margin-bottom: 0; }
|
||||
.section-title { font-size: 1rem; font-weight: 600; color: var(--color-text); margin: 0 0 1rem 0; padding-bottom: 0.5rem; border-bottom: 1px solid var(--color-border); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
@@ -19,6 +19,7 @@
|
||||
/* Zap splits preset buttons */
|
||||
.zap-preset-buttons { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.zap-preset-btn {
|
||||
flex: 1;
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
@@ -54,35 +55,56 @@
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-elevated);
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
color-mix(in srgb, var(--highlight-color) 50%, transparent) 0%,
|
||||
color-mix(in srgb, var(--highlight-color) 50%, transparent) 50%,
|
||||
color-mix(in srgb, var(--highlight-color-friends) 50%, transparent) 50%,
|
||||
color-mix(in srgb, var(--highlight-color-friends) 50%, transparent) 100%
|
||||
);
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
position: relative;
|
||||
}
|
||||
.zap-split-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--color-primary);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23fff'%3E%3Cpath d='M13 2L3 14h8l-1 8 10-12h-8l1-8z'/%3E%3C/svg%3E");
|
||||
background-size: 14px 14px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
top: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
.zap-split-slider::-webkit-slider-thumb:hover {
|
||||
background: var(--color-primary-hover);
|
||||
background-color: var(--color-primary-hover);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.zap-split-slider::-moz-range-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--color-primary);
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23fff'%3E%3Cpath d='M13 2L3 14h8l-1 8 10-12h-8l1-8z'/%3E%3C/svg%3E");
|
||||
background-size: 14px 14px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
top: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
.zap-split-slider::-moz-range-thumb:hover {
|
||||
background: var(--color-primary-hover);
|
||||
background-color: var(--color-primary-hover);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.zap-split-description {
|
||||
|
||||
@@ -81,7 +81,14 @@
|
||||
.view-mode-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.view-mode-left,
|
||||
.view-mode-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -169,3 +176,38 @@
|
||||
.read-inline-btn { background: rgb(34 197 94); /* green-500 */ color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 4px; cursor: pointer; }
|
||||
.read-inline-btn:hover { background: rgb(22 163 74); /* green-600 */ }
|
||||
|
||||
/* Bookmark filters */
|
||||
.bookmark-filters {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.bookmark-filters .filter-btn {
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
border: none;
|
||||
padding: 0.375rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.875rem;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.bookmark-filters .filter-btn:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.bookmark-filters .filter-btn.active {
|
||||
color: var(--color-primary);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,14 @@ export interface IndividualBookmark {
|
||||
encryptedContent?: string
|
||||
// When the item was added to the bookmark list (synthetic, for sorting)
|
||||
added_at?: number
|
||||
// The kind of the source list/set that produced this bookmark (e.g., 10003, 30003, 30001, or 39701 for web)
|
||||
sourceKind?: number
|
||||
// The 'd' tag value from kind 30003 bookmark sets
|
||||
setName?: string
|
||||
// Metadata from the bookmark set event (kind 30003)
|
||||
setTitle?: string
|
||||
setDescription?: string
|
||||
setImage?: string
|
||||
}
|
||||
|
||||
export interface ActiveAccount {
|
||||
|
||||
42
src/utils/bookmarkTypeClassifier.ts
Normal file
42
src/utils/bookmarkTypeClassifier.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { IndividualBookmark } from '../types/bookmarks'
|
||||
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||
import { classifyUrl } from './helpers'
|
||||
|
||||
export type BookmarkType = 'article' | 'external' | 'video' | 'note' | 'web'
|
||||
|
||||
/**
|
||||
* Classifies a bookmark into one of the content types
|
||||
*/
|
||||
export function classifyBookmarkType(bookmark: IndividualBookmark): BookmarkType {
|
||||
// Kind 30023 is always a nostr-native article
|
||||
if (bookmark.kind === 30023) return 'article'
|
||||
|
||||
const isWebBookmark = bookmark.kind === 39701
|
||||
const webBookmarkUrl = isWebBookmark ? bookmark.tags.find(t => t[0] === 'd')?.[1] : null
|
||||
|
||||
const extractedUrls = webBookmarkUrl
|
||||
? [webBookmarkUrl.startsWith('http') ? webBookmarkUrl : `https://${webBookmarkUrl}`]
|
||||
: extractUrlsFromContent(bookmark.content)
|
||||
|
||||
const firstUrl = extractedUrls[0]
|
||||
if (!firstUrl) return 'note'
|
||||
|
||||
const urlType = classifyUrl(firstUrl)?.type
|
||||
|
||||
if (urlType === 'youtube' || urlType === 'video') return 'video'
|
||||
if (urlType === 'article') return 'external' // External article links
|
||||
|
||||
return 'web'
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters bookmarks by type
|
||||
*/
|
||||
export function filterBookmarksByType(
|
||||
bookmarks: IndividualBookmark[],
|
||||
filterType: 'all' | BookmarkType
|
||||
): IndividualBookmark[] {
|
||||
if (filterType === 'all') return bookmarks
|
||||
return bookmarks.filter(bookmark => classifyBookmarkType(bookmark) === filterType)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import { formatDistanceToNow, differenceInSeconds, differenceInMinutes, differenceInHours, differenceInDays, differenceInMonths, differenceInYears } from 'date-fns'
|
||||
import { ParsedContent, ParsedNode } from '../types/bookmarks'
|
||||
import { ParsedContent, ParsedNode, IndividualBookmark } from '../types/bookmarks'
|
||||
import ResolvedMention from '../components/ResolvedMention'
|
||||
// Note: ContentWithResolvedProfiles is imported by components directly to keep this file component-only for fast refresh
|
||||
|
||||
@@ -82,3 +82,73 @@ export const renderParsedContent = (parsedContent: ParsedContent) => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Sorting and grouping for bookmarks
|
||||
export const sortIndividualBookmarks = (items: IndividualBookmark[]) => {
|
||||
return items
|
||||
.slice()
|
||||
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
|
||||
}
|
||||
|
||||
export function groupIndividualBookmarks(items: IndividualBookmark[]) {
|
||||
const sorted = sortIndividualBookmarks(items)
|
||||
const web = sorted.filter(i => i.kind === 39701 || i.type === 'web')
|
||||
// Only non-encrypted legacy bookmarks go to the amethyst section
|
||||
const amethyst = sorted.filter(i => i.sourceKind === 30001 && !i.isPrivate)
|
||||
const isIn = (list: IndividualBookmark[], x: IndividualBookmark) => list.some(i => i.id === x.id)
|
||||
// Private items include encrypted legacy bookmarks
|
||||
const privateItems = sorted.filter(i => i.isPrivate && !isIn(web, i))
|
||||
const publicItems = sorted.filter(i => !i.isPrivate && !isIn(amethyst, i) && !isIn(web, i))
|
||||
return { privateItems, publicItems, web, amethyst }
|
||||
}
|
||||
|
||||
// Simple filter: only exclude bookmarks with empty/whitespace-only content
|
||||
export function hasContent(bookmark: IndividualBookmark): boolean {
|
||||
return !!(bookmark.content && bookmark.content.trim().length > 0)
|
||||
}
|
||||
|
||||
// Bookmark sets helpers (kind 30003)
|
||||
export interface BookmarkSet {
|
||||
name: string
|
||||
title?: string
|
||||
description?: string
|
||||
image?: string
|
||||
bookmarks: IndividualBookmark[]
|
||||
}
|
||||
|
||||
export function getBookmarkSets(items: IndividualBookmark[]): BookmarkSet[] {
|
||||
// Group bookmarks by setName
|
||||
const setMap = new Map<string, IndividualBookmark[]>()
|
||||
|
||||
items.forEach(bookmark => {
|
||||
if (bookmark.setName) {
|
||||
const existing = setMap.get(bookmark.setName) || []
|
||||
existing.push(bookmark)
|
||||
setMap.set(bookmark.setName, existing)
|
||||
}
|
||||
})
|
||||
|
||||
// Convert to array and extract metadata from the bookmarks
|
||||
const sets: BookmarkSet[] = []
|
||||
setMap.forEach((bookmarks, name) => {
|
||||
// Get metadata from the first bookmark (all bookmarks in a set share the same metadata)
|
||||
const firstBookmark = bookmarks[0]
|
||||
const title = firstBookmark?.setTitle
|
||||
const description = firstBookmark?.setDescription
|
||||
const image = firstBookmark?.setImage
|
||||
|
||||
sets.push({
|
||||
name,
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
bookmarks: sortIndividualBookmarks(bookmarks)
|
||||
})
|
||||
})
|
||||
|
||||
return sets.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
export function getBookmarksWithoutSet(items: IndividualBookmark[]): IndividualBookmark[] {
|
||||
return sortIndividualBookmarks(items.filter(b => !b.setName))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user