Compare commits

..

88 Commits

Author SHA1 Message Date
Gigi
63f58e010f feat: use classic/regular bookmark icon for To Read filter
- Change from solid bookmark to regular (outline) bookmark icon
- Matches classic FontAwesome bookmark style
2025-10-15 22:46:15 +02:00
Gigi
d0b814e39d fix: update Archive filter icons for consistency
- Change 'All' icon to asterisk (*) to match Bookmarks filter
- Change 'Marked as Read' icon to faBooks (custom icon)
- Maintains consistent iconography across filter types
2025-10-15 22:40:52 +02:00
Gigi
f4a227e40a fix: improve reading position calculation to reach 100%
- Add 5px threshold to detect when scrolled to bottom
- Set position to exactly 1.0 (100%) when within 5px of bottom
- Remove upper limit on saving positions (now saves 100% completion)
- Always save when reaching 100% completion (important milestone)
- Don't restore position for completed articles (100%), start from top
- Better handling of edge cases in position detection
- Matches ReadingProgressIndicator calculation logic
2025-10-15 22:39:51 +02:00
Gigi
6ef0a6dd71 refactor: match ArchiveFilters styling to BookmarkFilters
- Use same CSS classes (filter-btn) as BookmarkFilters
- Show icons only, no text labels for consistency
- Add title and aria-label for accessibility
- Keep code DRY by following established pattern
2025-10-15 22:35:45 +02:00
Gigi
5502d71ac4 feat: add filter buttons to Archive tab
- Create ArchiveFilters component with 5 filter options
- All: Show all archived articles
- To Read: Articles with 0% progress (not started)
- Reading: Articles with progress between 0-95%
- Completed: Articles with 95%+ reading progress
- Marked: Manually marked as read (no position data)
- Filter logic based on reading position data
- Show empty state when no articles match filter
- Matches BookmarkFilters styling and UX pattern
2025-10-15 22:30:44 +02:00
Gigi
5e1146b015 fix: position reading progress bar as dividing line in cards
- Move progress indicator between summary and meta sections
- Replace the border-top dividing line with progress bar
- Show 3px progress bar when reading position exists
- Show 1px gray divider when no progress (maintains original look)
- Remove absolute positioning from bottom of card
- Remove border-top from meta section to avoid double lines
2025-10-15 22:26:48 +02:00
Gigi
8f89165711 debug: add comprehensive logging for reading position sync
- Add detailed console logs with emoji prefixes for easy filtering
- Log save/load operations in readingPositionService
- Log position restore in ContentPanel with requirements check
- Log Archive tab position loading with article details
- All logs prefixed with component/service name for clarity
- Log shows position percentages, identifiers, and timestamps
- Helps debug why positions may not be showing or syncing
2025-10-15 22:23:40 +02:00
Gigi
674634326f feat: add visual reading progress indicator to archive cards
- Display reading position as a horizontal progress bar at bottom of blog post cards
- Use blue (#6366f1) for progress <95%, green (#10b981) for >=95% complete
- Load reading positions for all articles in Archive tab
- Progress bar fills from left to right showing how much has been read
- Only shown when reading progress exists and is >0%
- Smooth transition animations on progress updates
2025-10-15 22:19:18 +02:00
Gigi
30eaec5770 refactor: remove redundant handleHighlightClick from Explore
- HighlightItem now handles navigation internally
- Remove duplicate navigation logic from Explore component
- Simplifies code and ensures consistent behavior across all highlight displays
2025-10-15 22:13:14 +02:00
Gigi
0ff3c864a9 feat: add click-to-open article navigation on highlights
- Click on highlights in /me/highlights or /p/:npub pages to open referenced article
- Parse eventReference to detect kind:30023 articles and navigate to /a/{naddr}
- Fall back to urlReference for external URLs, navigate to /r/{url}
- Maintain backward compatibility with existing onHighlightClick prop
- Show pointer cursor when highlight has navigable reference
2025-10-15 22:12:03 +02:00
Gigi
ab2ca1f5e7 fix: remove unused IEventStore import in ContentPanel 2025-10-15 22:09:58 +02:00
Gigi
cf2d227f61 feat: add reading position sync across devices using Nostr Kind 30078
- Create readingPositionService.ts for save/load operations
- Add syncReadingPosition setting (opt-in via Settings > Layout & Behavior)
- Enhance useReadingPosition hook with auto-save (debounced 5s) and immediate save on navigation
- Integrate position restore in ContentPanel with smooth scroll to saved position
- Support both Nostr articles (naddr) and external URLs
- Reading positions stored privately to user's relays
- Auto-save excludes first 5% and last 5% of content to avoid noise
- Position automatically restored when returning to article
2025-10-15 22:08:12 +02:00
Gigi
2c9e6cc54e docs: update CHANGELOG.md for v0.6.20 2025-10-15 21:54:02 +02:00
Gigi
8da0a06711 chore: bump version to 0.6.20 2025-10-15 21:53:06 +02:00
Gigi
be8d857223 Merge pull request #12 from dergigi/bookmark-filter-buttons
Add bookmark filter buttons by content type
2025-10-15 21:52:37 +02:00
Gigi
d50bcd700e fix(ui): make highlight button fixed to viewport 2025-10-15 21:51:24 +02:00
Gigi
820ab1d902 fix(ui): make highlight button sticky and always visible
- Wrap button in sticky positioned container with height: 0
- Button now floats and stays visible while scrolling
- Remains within reader pane boundaries on desktop
- Uses flexbox to align button to the right side
2025-10-15 21:48:41 +02:00
Gigi
f5e9e5bf61 fix(ui): position highlight button inside reader pane
- Move HighlightButton from fixed viewport positioning to absolute positioning within main pane
- Add position: relative to .pane.main for both desktop and mobile layouts
- Button now stays within the article/reader view instead of floating outside on desktop
- Maintains proper z-index and responsive behavior
2025-10-15 21:47:28 +02:00
Gigi
40b43532e8 style: use faLink icon for external articles
- Replace faArrowUpRightFromSquare with simpler faLink icon
- More concise visual representation for external article links
2025-10-15 21:40:31 +02:00
Gigi
51a3008730 feat: add separate filter for external articles with distinct icon
- Add 'external' type to differentiate external article links from nostr-native articles
- Nostr-native articles (kind:30023) use newspaper icon
- External article links use arrow-up-right icon (faArrowUpRightFromSquare)
- Add new 'External Articles' filter button
- Update classification logic and icon display accordingly
2025-10-15 21:39:10 +02:00
Gigi
e30cbc72c3 style: dramatically reduce whitespace around bookmark filters
- Remove all padding from filter buttons
- Reduce top padding from 0.75rem to 0.25rem
- Reduce bottom margin from 0.5rem to 0.25rem
- Much tighter, more compact layout
2025-10-15 21:35:44 +02:00
Gigi
6f913262f4 style: reduce whitespace around bookmark filters on /me page
- Reduce padding on bookmark filters from 1rem to 0.5rem
- Reduce top padding of tab content when filters are present
- Tighten spacing for more compact layout
2025-10-15 21:35:11 +02:00
Gigi
0f0462e6ac feat: add bookmark filters to /me page bookmarks tab
- Add filter buttons to reading-list tab in Me component
- Apply same filtering logic as main bookmarks sidebar
- Center-align filters and remove border for cleaner look
- Show empty state message when no bookmarks match filter
2025-10-15 21:24:19 +02:00
Gigi
e353f0e2d6 style: refine bookmark filter buttons
- Make buttons smaller (32px) and more compact
- Remove borders for cleaner look
- Active state uses primary color without background
- Match icon styling used on bookmark cards
2025-10-15 21:19:16 +02:00
Gigi
ee1365d3ca feat: add bookmark filter buttons by content type
- Add BookmarkFilters component with icon-based filter buttons
- Create bookmarkTypeClassifier utility for content type classification
- Filter bookmarks by article, video, note, or web types
- Apply filters across all bookmark lists (private, public, web, sets)
- Style filter buttons to match existing UI design
2025-10-15 21:17:27 +02:00
Gigi
a215d0b026 refactor: remove lock icon from individual bookmarks
- Private bookmarks are now grouped in 'Private Bookmarks' section
- No need for redundant lock icon on each individual bookmark
- Cleaner UI with less visual clutter
- Removed faUserLock import and conditional rendering from all three views
2025-10-15 20:37:40 +02:00
Gigi
b8d76c0bd8 feat: move encrypted legacy bookmarks to Private Bookmarks section
- Only non-encrypted legacy bookmarks (kind:30001) now appear in Legacy section
- Encrypted legacy bookmarks are grouped with other private bookmarks
- Improves organization by grouping by privacy level rather than source
2025-10-15 20:36:00 +02:00
Gigi
233169b082 feat: improve bookmark section labels for clarity
- Capitalize all bookmark section labels for consistency
- Change 'Old Bookmarks (Legacy)' to 'Legacy Bookmarks' for cleaner look
- Updated labels in both BookmarkList and Me components
2025-10-15 20:35:19 +02:00
Gigi
72b9a04cd2 docs: update CHANGELOG.md for v0.6.19 2025-10-15 20:01:43 +02:00
Gigi
432715efb6 chore: bump version to 0.6.19 2025-10-15 20:01:07 +02:00
Gigi
8b2b954dde fix: prevent useBookmarksData from overwriting external URL highlights
The issue was that useBookmarksData was fetching general highlights
whenever there was no naddr, which included external URL routes (/r/*).
This caused the URL-specific highlights loaded by useExternalUrlLoader
to be overwritten after a couple seconds.

Now we skip fetching general highlights when viewing external URLs,
letting useExternalUrlLoader manage those highlights instead.
2025-10-15 19:59:54 +02:00
Gigi
c2d2bd8106 fix: prevent highlights from disappearing on external URLs
- Improve error handling in fetchHighlightsForUrl to prevent silent failures
- Remove redundant setHighlights call that was overwriting streamed highlights
- Add logging to help diagnose highlight fetching issues
- Isolate rebroadcast errors so they don't break highlight display
2025-10-15 19:56:07 +02:00
Gigi
a5c3085c59 docs: update CHANGELOG.md for v0.6.18 2025-10-15 19:49:13 +02:00
Gigi
c0332f08d6 chore: bump version to 0.6.18 2025-10-15 19:48:00 +02:00
Gigi
38a1d6caec fix: always show PWA install section with disabled button states 2025-10-15 19:43:44 +02:00
Gigi
39dd607e7b style: make zap preset buttons expand to match slider width on desktop 2025-10-15 19:43:11 +02:00
Gigi
9dc0db3e06 fix: always show App & Airplane Mode section regardless of PWA status 2025-10-15 19:42:27 +02:00
Gigi
b1eb58a385 fix: display zap split share and percentage on same line 2025-10-15 19:41:26 +02:00
Gigi
f3c6404f76 refactor: simplify zap split labels and update terminology 2025-10-15 19:39:04 +02:00
Gigi
1a42a6422d fix: disable PWA install button when installation is not possible on device 2025-10-15 19:37:57 +02:00
Gigi
2e2de4ccda docs: update CHANGELOG.md for v0.6.17 2025-10-15 19:36:50 +02:00
Gigi
4325d3a519 chore: bump version to 0.6.17 2025-10-15 19:35:36 +02:00
Gigi
51115c5f68 refactor: move Default Highlight Visibility back after Paragraph Alignment 2025-10-15 19:34:03 +02:00
Gigi
2aa6fe860b refactor: merge Layout & Navigation and Startup & Behavior into Layout & Behavior section 2025-10-15 19:33:22 +02:00
Gigi
86f39eacf8 refactor: move Default Highlight Visibility after Font Size in reading settings 2025-10-15 19:32:13 +02:00
Gigi
d15daef3ea fix: properly align Font Size buttons to right using setting-control wrapper 2025-10-15 19:31:04 +02:00
Gigi
281c70cdea style: align Font Size buttons to the right to match highlight color buttons 2025-10-15 19:29:22 +02:00
Gigi
d6d6087543 refactor: move Layout & Navigation section below Zap Splits 2025-10-15 19:28:33 +02:00
Gigi
d06e38bc19 refactor: reorder settings sections - move Startup & Behavior after Zap Splits 2025-10-15 19:28:05 +02:00
Gigi
cfc8eb0bbc feat: use friend-highlight color at 50% opacity for right side of zap sliders 2025-10-15 19:26:43 +02:00
Gigi
b85f9b79c3 feat: add zaps.svg illustration to Zap Splits section with responsive layout 2025-10-15 19:26:10 +02:00
Gigi
1b0045c737 refactor: add 50% opacity to slider track highlight color 2025-10-15 19:24:27 +02:00
Gigi
3dc8d7d440 fix: improve lightning bolt icon centering and sizing on slider thumbs 2025-10-15 19:18:57 +02:00
Gigi
bf9ca48d64 feat: replace slider thumb circles with lightning bolt icons for zap splits 2025-10-15 19:17:55 +02:00
Gigi
70441f3d59 refactor: use default highlight color for zap slider 50% mark instead of primary color 2025-10-15 19:16:16 +02:00
Gigi
431f28e861 refactor: update zap split description to match offline-first paragraph style 2025-10-15 19:15:43 +02:00
Gigi
3b1fc095c4 feat: add 50% visual indicators to zap split sliders with gradient background and tick marks 2025-10-15 19:15:14 +02:00
Gigi
9a6c7a29d0 feat: restrict settings page width to 900px matching article view max-width 2025-10-15 19:13:22 +02:00
Gigi
c1d173f40e fix: move offline-first paragraph inside flex container to prevent overlap with image 2025-10-15 19:12:17 +02:00
Gigi
f03ec5df8c refactor: move 'Use local relays as cache' checkbox after local relay paragraph 2025-10-15 19:11:36 +02:00
Gigi
6c74a12636 feat: add offline-first description at the beginning of App & Airplane Mode section 2025-10-15 19:10:38 +02:00
Gigi
39797803d3 refactor: rename section title from 'PWA & Flight Mode' to 'App & Airplane Mode' 2025-10-15 19:07:54 +02:00
Gigi
c66c1e928d refactor: swap paragraph order - Note about relays first, Install Boris second 2025-10-15 19:06:55 +02:00
Gigi
f934b641bb refactor: replace IconButton with plain icon for clear cache trash button 2025-10-15 19:06:22 +02:00
Gigi
1128a11603 refactor: reorder PWA settings - checkboxes first, then paragraphs, then install button 2025-10-15 19:05:03 +02:00
Gigi
9f90718918 refactor: reduce clear cache button size from 28 to 20 2025-10-15 19:03:43 +02:00
Gigi
067a07fc00 refactor: further reduce spacing between PWA settings elements from 0.5rem to 0.25rem 2025-10-15 19:02:09 +02:00
Gigi
1811cf045e refactor: split PWA description into two paragraphs and update text 2025-10-15 19:01:34 +02:00
Gigi
270b4f429f refactor: remove 'Install Boris as a PWA' title from settings section 2025-10-15 18:59:48 +02:00
Gigi
380acbb55f feat: hide PWA SVG illustration on mobile devices 2025-10-15 18:59:24 +02:00
Gigi
c384f0b4fb refactor: reduce spacing between PWA settings elements from 1rem to 0.5rem 2025-10-15 18:58:33 +02:00
Gigi
27cf393a03 refactor: set PWA SVG width to 30% for responsive scaling 2025-10-15 18:57:35 +02:00
Gigi
8831726913 refactor: reduce PWA SVG size to 150px width 2025-10-15 18:57:02 +02:00
Gigi
2f4327874c refactor: format and clean up pwa.svg with proper indentation and Inkscape metadata 2025-10-15 18:55:23 +02:00
Gigi
483845962e refactor: combine relay info text with PWA description into single paragraph 2025-10-15 18:53:38 +02:00
Gigi
c44b1d6349 refactor: set PWA SVG height to 100% with auto width for full vertical span 2025-10-15 18:52:20 +02:00
Gigi
79f28a142d refactor: increase PWA SVG illustration size from 120px to 200px 2025-10-15 18:51:54 +02:00
Gigi
02dd537cd9 refactor: make PWA SVG illustration span full section height 2025-10-15 18:50:50 +02:00
Gigi
5af1f14a0b refactor: merge PWA and Flight Mode settings into single section 2025-10-15 18:49:25 +02:00
Gigi
664f59a9cc refactor: show PWA button state with checkmark when installed instead of hiding section 2025-10-15 18:48:03 +02:00
Gigi
7d3641aab7 refactor: simplify PWA install text to 'Install Boris as a PWA' 2025-10-15 18:45:38 +02:00
Gigi
7924df4c67 refactor: simplify PWA section title to 'App' 2025-10-15 18:45:25 +02:00
Gigi
68a8eed4af refactor: expand PWA install text to include full terminology 2025-10-15 18:45:07 +02:00
Gigi
887db84ce7 refactor: change PWA section title to 'Boris as an App' 2025-10-15 18:44:37 +02:00
Gigi
05348fbfeb feat: add pwa.svg illustration to PWA settings section 2025-10-15 18:44:18 +02:00
Gigi
38eb6716f8 refactor: move PWA settings above Relays section 2025-10-15 18:42:31 +02:00
Gigi
d7f9cd30eb feat: always show PWA install button for testing/styling purposes 2025-10-15 18:41:40 +02:00
Gigi
922d041e0e docs: update CHANGELOG.md for v0.6.16 2025-10-15 18:31:46 +02:00
36 changed files with 1973 additions and 494 deletions

View File

@@ -7,6 +7,165 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ## [0.6.15] - 2025-10-15
### Added ### Added
@@ -1482,7 +1641,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Optimize relay usage following applesauce-relay best practices - Optimize relay usage following applesauce-relay best practices
- Use applesauce-react event models for better profile handling - Use applesauce-react event models for better profile handling
[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.15...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.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.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.13]: https://github.com/dergigi/boris/compare/v0.6.12...v0.6.13

View File

@@ -1,6 +1,6 @@
{ {
"name": "boris", "name": "boris",
"version": "0.6.16", "version": "0.6.20",
"description": "A minimal nostr client for bookmark management", "description": "A minimal nostr client for bookmark management",
"homepage": "https://read.withboris.com/", "homepage": "https://read.withboris.com/",
"type": "module", "type": "module",

215
public/pwa.svg Normal file
View 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
View 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

View 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

View File

@@ -11,9 +11,10 @@ interface BlogPostCardProps {
post: BlogPostPreview post: BlogPostPreview
href: string href: string
level?: 'mine' | 'friends' | 'nostrverse' 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 profile = useEventModel(Models.ProfileModel, [post.author])
const displayName = profile?.name || profile?.display_name || const displayName = profile?.name || profile?.display_name ||
`${post.author.slice(0, 8)}...${post.author.slice(-4)}` `${post.author.slice(0, 8)}...${post.author.slice(-4)}`
@@ -23,6 +24,10 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level }) => {
addSuffix: true 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 ( return (
<Link <Link
to={href} to={href}
@@ -47,7 +52,37 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href, level }) => {
{post.summary && ( {post.summary && (
<p className="blog-post-card-summary">{post.summary}</p> <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"> <span className="blog-post-card-author">
<FontAwesomeIcon icon={faUser} /> <FontAwesomeIcon icon={faUser} />
{displayName} {displayName}

View 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

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { faNewspaper, faStickyNote, faCirclePlay, faCamera, faFileLines } from '@fortawesome/free-regular-svg-icons' import { faNewspaper, faStickyNote, faCirclePlay, faCamera, faFileLines } from '@fortawesome/free-regular-svg-icons'
import { faGlobe } from '@fortawesome/free-solid-svg-icons' import { faGlobe, faLink } from '@fortawesome/free-solid-svg-icons'
import { IconDefinition } from '@fortawesome/fontawesome-svg-core' import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { useEventModel } from 'applesauce-react/hooks' import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core' import { Models } from 'applesauce-core'
@@ -70,7 +70,7 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
// Get content type icon based on bookmark kind and URL classification // Get content type icon based on bookmark kind and URL classification
const getContentTypeIcon = (): IconDefinition => { const getContentTypeIcon = (): IconDefinition => {
if (isArticle) return faNewspaper if (isArticle) return faNewspaper // Nostr-native article
// For web bookmarks, classify the URL to determine icon // For web bookmarks, classify the URL to determine icon
if (isWebBookmark && firstUrlClassification) { if (isWebBookmark && firstUrlClassification) {
@@ -81,7 +81,7 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
case 'image': case 'image':
return faCamera return faCamera
case 'article': case 'article':
return faNewspaper return faLink // External article
default: default:
return faGlobe return faGlobe
} }
@@ -89,6 +89,7 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
if (!hasUrls) return faStickyNote // Just a text note if (!hasUrls) return faStickyNote // Just a text note
if (firstUrlClassification?.type === 'youtube' || firstUrlClassification?.type === 'video') return faCirclePlay if (firstUrlClassification?.type === 'youtube' || firstUrlClassification?.type === 'video') return faCirclePlay
if (firstUrlClassification?.type === 'article') return faLink // External article
return faFileLines return faFileLines
} }

View File

@@ -19,6 +19,8 @@ import AddBookmarkModal from './AddBookmarkModal'
import { createWebBookmark } from '../services/webBookmarkService' import { createWebBookmark } from '../services/webBookmarkService'
import { RELAYS } from '../config/relays' import { RELAYS } from '../config/relays'
import { Hooks } from 'applesauce-react' import { Hooks } from 'applesauce-react'
import BookmarkFilters, { BookmarkFilterType } from './BookmarkFilters'
import { filterBookmarksByType } from '../utils/bookmarkTypeClassifier'
interface BookmarkListProps { interface BookmarkListProps {
bookmarks: Bookmark[] bookmarks: Bookmark[]
@@ -61,6 +63,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
const bookmarksListRef = useRef<HTMLDivElement>(null) const bookmarksListRef = useRef<HTMLDivElement>(null)
const friendsColor = settings?.highlightColorFriends || '#f97316' const friendsColor = settings?.highlightColorFriends || '#f97316'
const [showAddModal, setShowAddModal] = useState(false) const [showAddModal, setShowAddModal] = useState(false)
const [selectedFilter, setSelectedFilter] = useState<BookmarkFilterType>('all')
const activeAccount = Hooks.useActiveAccount() const activeAccount = Hooks.useActiveAccount()
const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => { const handleSaveBookmark = async (url: string, title?: string, description?: string, tags?: string[]) => {
@@ -87,17 +90,20 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || []) const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
.filter(hasContent) .filter(hasContent)
// Apply filter
const filteredBookmarks = filterBookmarksByType(allIndividualBookmarks, selectedFilter)
// Separate bookmarks with setName (kind 30003) from regular bookmarks // Separate bookmarks with setName (kind 30003) from regular bookmarks
const bookmarksWithoutSet = getBookmarksWithoutSet(allIndividualBookmarks) const bookmarksWithoutSet = getBookmarksWithoutSet(filteredBookmarks)
const bookmarkSets = getBookmarkSets(allIndividualBookmarks) const bookmarkSets = getBookmarkSets(filteredBookmarks)
// Group non-set bookmarks as before // Group non-set bookmarks as before
const groups = groupIndividualBookmarks(bookmarksWithoutSet) const groups = groupIndividualBookmarks(bookmarksWithoutSet)
const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [ const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
{ key: 'private', title: 'Private bookmarks', items: groups.privateItems }, { key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
{ key: 'public', title: 'Public bookmarks', items: groups.publicItems }, { key: 'public', title: 'Public Bookmarks', items: groups.publicItems },
{ key: 'web', title: 'Web bookmarks', items: groups.web }, { key: 'web', title: 'Web Bookmarks', items: groups.web },
{ key: 'amethyst', title: 'Old Bookmarks (Legacy)', items: groups.amethyst } { key: 'amethyst', title: 'Legacy Bookmarks', items: groups.amethyst }
] ]
// Add bookmark sets as additional sections // Add bookmark sets as additional sections
@@ -140,7 +146,18 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
isMobile={isMobile} 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 ? ( loading ? (
<div className={`bookmarks-list ${viewMode}`} aria-busy="true"> <div className={`bookmarks-list ${viewMode}`} aria-busy="true">
<div className={`bookmarks-grid bookmarks-${viewMode}`}> <div className={`bookmarks-grid bookmarks-${viewMode}`}>

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUserLock, faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons' import { faChevronDown, faChevronUp } from '@fortawesome/free-solid-svg-icons'
import { IconDefinition } from '@fortawesome/fontawesome-svg-core' import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { IndividualBookmark } from '../../types/bookmarks' import { IndividualBookmark } from '../../types/bookmarks'
import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils' import { formatDate, renderParsedContent } from '../../utils/bookmarkUtils'
@@ -91,9 +91,6 @@ export const CardView: React.FC<CardViewProps> = ({
<div className="bookmark-header"> <div className="bookmark-header">
<span className="bookmark-type"> <span className="bookmark-type">
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" /> <FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
{bookmark.isPrivate && (
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
)}
</span> </span>
{eventNevent ? ( {eventNevent ? (

View File

@@ -1,6 +1,5 @@
import React from 'react' import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUserLock } from '@fortawesome/free-solid-svg-icons'
import { IconDefinition } from '@fortawesome/fontawesome-svg-core' import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { IndividualBookmark } from '../../types/bookmarks' import { IndividualBookmark } from '../../types/bookmarks'
import { formatDateCompact } from '../../utils/bookmarkUtils' import { formatDateCompact } from '../../utils/bookmarkUtils'
@@ -54,9 +53,6 @@ export const CompactView: React.FC<CompactViewProps> = ({
> >
<span className="bookmark-type-compact"> <span className="bookmark-type-compact">
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" /> <FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
{bookmark.isPrivate && (
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
)}
</span> </span>
{displayText && ( {displayText && (
<div className="compact-text"> <div className="compact-text">

View File

@@ -1,7 +1,6 @@
import React from 'react' import React from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faUserLock } from '@fortawesome/free-solid-svg-icons'
import { IconDefinition } from '@fortawesome/fontawesome-svg-core' import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { IndividualBookmark } from '../../types/bookmarks' import { IndividualBookmark } from '../../types/bookmarks'
import { formatDate } from '../../utils/bookmarkUtils' import { formatDate } from '../../utils/bookmarkUtils'
@@ -96,9 +95,6 @@ export const LargeView: React.FC<LargeViewProps> = ({
<div className="large-footer"> <div className="large-footer">
<span className="bookmark-type-large"> <span className="bookmark-type-large">
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" /> <FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
{bookmark.isPrivate && (
<FontAwesomeIcon icon={faUserLock} className="bookmark-visibility private" />
)}
</span> </span>
<span className="large-author"> <span className="large-author">
<Link <Link

View File

@@ -167,6 +167,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
activeAccount, activeAccount,
accountManager, accountManager,
naddr, naddr,
externalUrl,
currentArticleCoordinate, currentArticleCoordinate,
currentArticleEventId, currentArticleEventId,
settings settings

View File

@@ -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 ReactPlayer from 'react-player'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
@@ -36,6 +36,13 @@ import { classifyUrl } from '../utils/helpers'
import { buildNativeVideoUrl } from '../utils/videoHelpers' import { buildNativeVideoUrl } from '../utils/videoHelpers'
import { useReadingPosition } from '../hooks/useReadingPosition' import { useReadingPosition } from '../hooks/useReadingPosition'
import { ReadingProgressIndicator } from './ReadingProgressIndicator' import { ReadingProgressIndicator } from './ReadingProgressIndicator'
import { EventFactory } from 'applesauce-factory'
import { Hooks } from 'applesauce-react'
import {
generateArticleIdentifier,
loadReadingPosition,
saveReadingPosition
} from '../services/readingPositionService'
interface ContentPanelProps { interface ContentPanelProps {
loading: boolean loading: boolean
@@ -129,10 +136,58 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
onClearSelection onClearSelection
}) })
// Get event store for reading position service
const eventStore = Hooks.useEventStore()
// Reading position tracking - only for text content, not videos // Reading position tracking - only for text content, not videos
const isTextContent = !loading && !!(markdown || html) && !selectedUrl?.includes('youtube') && !selectedUrl?.includes('vimeo') 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, enabled: isTextContent,
syncEnabled: settings?.syncReadingPosition,
onSave: handleSavePosition,
onReadingComplete: () => { onReadingComplete: () => {
// Optional: Auto-mark as read when reading is complete // Optional: Auto-mark as read when reading is complete
if (activeAccount && !isMarkedAsRead) { if (activeAccount && !isMarkedAsRead) {
@@ -141,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 // Close menu when clicking outside
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {

View File

@@ -237,35 +237,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
return `/a/${naddr}` 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 // Classify highlights with levels based on user context and apply visibility filters
const classifiedHighlights = useMemo(() => { const classifiedHighlights = useMemo(() => {
@@ -357,7 +328,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
key={highlight.id} key={highlight.id}
highlight={highlight} highlight={highlight}
relayPool={relayPool} relayPool={relayPool}
onHighlightClick={handleHighlightClick}
/> />
))} ))}
</div> </div>

View File

@@ -16,6 +16,7 @@ import { createDeletionRequest } from '../services/deletionService'
import { getNostrUrl } from '../config/nostrGateways' import { getNostrUrl } from '../config/nostrGateways'
import CompactButton from './CompactButton' import CompactButton from './CompactButton'
import { HighlightCitation } from './HighlightCitation' import { HighlightCitation } from './HighlightCitation'
import { useNavigate } from 'react-router-dom'
// Helper to detect if a URL is an image // Helper to detect if a URL is an image
const isImageUrl = (url: string): boolean => { const isImageUrl = (url: string): boolean => {
@@ -206,6 +207,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
const [showMenu, setShowMenu] = useState(false) const [showMenu, setShowMenu] = useState(false)
const activeAccount = Hooks.useActiveAccount() const activeAccount = Hooks.useActiveAccount()
const navigate = useNavigate()
// Resolve the profile of the user who made the highlight // Resolve the profile of the user who made the highlight
const profile = useEventModel(Models.ProfileModel, [highlight.pubkey]) const profile = useEventModel(Models.ProfileModel, [highlight.pubkey])
@@ -274,8 +276,34 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
}, [showMenu, showDeleteConfirm]) }, [showMenu, showDeleteConfirm])
const handleItemClick = () => { const handleItemClick = () => {
// If onHighlightClick is provided, use it (legacy behavior)
if (onHighlightClick) { if (onHighlightClick) {
onHighlightClick(highlight.id) 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)}`)
} }
} }
@@ -473,7 +501,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
className={`highlight-item ${isSelected ? 'selected' : ''} ${highlight.level ? `level-${highlight.level}` : ''}`} className={`highlight-item ${isSelected ? 'selected' : ''} ${highlight.level ? `level-${highlight.level}` : ''}`}
data-highlight-id={highlight.id} data-highlight-id={highlight.id}
onClick={handleItemClick} onClick={handleItemClick}
style={{ cursor: onHighlightClick ? 'pointer' : 'default' }} style={{ cursor: (onHighlightClick || highlight.eventReference || highlight.urlReference) ? 'pointer' : 'default' }}
> >
<div className="highlight-header"> <div className="highlight-header">
<CompactButton <CompactButton

View File

@@ -24,6 +24,10 @@ import { faBooks } from '../icons/customIcons'
import { usePullToRefresh } from 'use-pull-to-refresh' import { usePullToRefresh } from 'use-pull-to-refresh'
import RefreshIndicator from './RefreshIndicator' import RefreshIndicator from './RefreshIndicator'
import { groupIndividualBookmarks, hasContent } from '../utils/bookmarkUtils' 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 { interface MeProps {
relayPool: RelayPool relayPool: RelayPool
@@ -35,6 +39,7 @@ type TabType = 'highlights' | 'reading-list' | 'archive' | 'writings'
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => { const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
const activeAccount = Hooks.useActiveAccount() const activeAccount = Hooks.useActiveAccount()
const eventStore = Hooks.useEventStore()
const navigate = useNavigate() const navigate = useNavigate()
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights') 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 [loading, setLoading] = useState(true)
const [viewMode, setViewMode] = useState<ViewMode>('cards') const [viewMode, setViewMode] = useState<ViewMode>('cards')
const [refreshTrigger, setRefreshTrigger] = useState(0) 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 // Update local state when prop changes
useEffect(() => { useEffect(() => {
@@ -119,6 +127,65 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
loadData() loadData()
}, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger]) }, [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 // Pull-to-refresh
const { isRefreshing, pullPosition } = usePullToRefresh({ const { isRefreshing, pullPosition } = usePullToRefresh({
onRefresh: () => { onRefresh: () => {
@@ -172,12 +239,40 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
// Merge and flatten all individual bookmarks // Merge and flatten all individual bookmarks
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || []) const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
.filter(hasContent) .filter(hasContent)
const groups = groupIndividualBookmarks(allIndividualBookmarks)
// 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[] }> = [ const sections: Array<{ key: string; title: string; items: IndividualBookmark[] }> = [
{ key: 'private', title: 'Private bookmarks', items: groups.privateItems }, { key: 'private', title: 'Private Bookmarks', items: groups.privateItems },
{ key: 'public', title: 'Public bookmarks', items: groups.publicItems }, { key: 'public', title: 'Public Bookmarks', items: groups.publicItems },
{ key: 'web', title: 'Web bookmarks', items: groups.web }, { key: 'web', title: 'Web Bookmarks', items: groups.web },
{ key: 'amethyst', title: 'Old Bookmarks (Legacy)', items: groups.amethyst } { key: 'amethyst', title: 'Legacy Bookmarks', items: groups.amethyst }
] ]
// Show content progressively - no blocking error screens // Show content progressively - no blocking error screens
@@ -231,7 +326,18 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
</div> </div>
) : ( ) : (
<div className="bookmarks-list"> <div className="bookmarks-list">
{sections.filter(s => s.items.length > 0).map(section => ( {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"> <div key={section.key} className="bookmarks-section">
<h3 className="bookmarks-section-title">{section.title}</h3> <h3 className="bookmarks-section-title">{section.title}</h3>
<div className={`bookmarks-grid bookmarks-${viewMode}`}> <div className={`bookmarks-grid bookmarks-${viewMode}`}>
@@ -246,7 +352,7 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
))} ))}
</div> </div>
</div> </div>
))} )))}
<div className="view-mode-controls" style={{ <div className="view-mode-controls" style={{
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
@@ -295,15 +401,30 @@ const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: pr
<FontAwesomeIcon icon={faSpinner} spin size="2x" /> <FontAwesomeIcon icon={faSpinner} spin size="2x" />
</div> </div>
) : ( ) : (
<div className="explore-grid"> <>
{readArticles.map((post) => ( {readArticles.length > 0 && (
<BlogPostCard <ArchiveFilters
key={post.event.id} selectedFilter={archiveFilter}
post={post} onFilterChange={setArchiveFilter}
href={getPostUrl(post)}
/> />
))} )}
</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': case 'writings':

View File

@@ -6,10 +6,8 @@ import IconButton from './IconButton'
import { loadFont } from '../utils/fontLoader' import { loadFont } from '../utils/fontLoader'
import ThemeSettings from './Settings/ThemeSettings' import ThemeSettings from './Settings/ThemeSettings'
import ReadingDisplaySettings from './Settings/ReadingDisplaySettings' import ReadingDisplaySettings from './Settings/ReadingDisplaySettings'
import LayoutNavigationSettings from './Settings/LayoutNavigationSettings' import LayoutBehaviorSettings from './Settings/LayoutBehaviorSettings'
import StartupPreferencesSettings from './Settings/StartupPreferencesSettings'
import ZapSettings from './Settings/ZapSettings' import ZapSettings from './Settings/ZapSettings'
import OfflineModeSettings from './Settings/OfflineModeSettings'
import RelaySettings from './Settings/RelaySettings' import RelaySettings from './Settings/RelaySettings'
import PWASettings from './Settings/PWASettings' import PWASettings from './Settings/PWASettings'
import { useRelayStatus } from '../hooks/useRelayStatus' import { useRelayStatus } from '../hooks/useRelayStatus'
@@ -36,6 +34,7 @@ const DEFAULT_SETTINGS: UserSettings = {
useLocalRelayAsCache: true, useLocalRelayAsCache: true,
rebroadcastToAllRelays: false, rebroadcastToAllRelays: false,
paragraphAlignment: 'justify', paragraphAlignment: 'justify',
syncReadingPosition: false,
} }
interface SettingsProps { interface SettingsProps {
@@ -163,12 +162,10 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
<div className="settings-content"> <div className="settings-content">
<ThemeSettings settings={localSettings} onUpdate={handleUpdate} /> <ThemeSettings settings={localSettings} onUpdate={handleUpdate} />
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} /> <ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
<LayoutNavigationSettings settings={localSettings} onUpdate={handleUpdate} />
<StartupPreferencesSettings settings={localSettings} onUpdate={handleUpdate} />
<ZapSettings 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} /> <RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
<PWASettings />
</div> </div>
</div> </div>
) )

View 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

View File

@@ -3,15 +3,15 @@ import { faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
import { UserSettings } from '../../services/settingsService' import { UserSettings } from '../../services/settingsService'
import IconButton from '../IconButton' import IconButton from '../IconButton'
interface LayoutNavigationSettingsProps { interface LayoutBehaviorSettingsProps {
settings: UserSettings settings: UserSettings
onUpdate: (updates: Partial<UserSettings>) => void onUpdate: (updates: Partial<UserSettings>) => void
} }
const LayoutNavigationSettings: React.FC<LayoutNavigationSettingsProps> = ({ settings, onUpdate }) => { const LayoutBehaviorSettings: React.FC<LayoutBehaviorSettingsProps> = ({ settings, onUpdate }) => {
return ( return (
<div className="settings-section"> <div className="settings-section">
<h3 className="section-title">Layout & Navigation</h3> <h3 className="section-title">Layout & Behavior</h3>
<div className="setting-group setting-inline"> <div className="setting-group setting-inline">
<label>Default Bookmark View</label> <label>Default Bookmark View</label>
@@ -52,9 +52,61 @@ const LayoutNavigationSettings: React.FC<LayoutNavigationSettingsProps> = ({ set
<span>Collapse bookmark bar when opening an article</span> <span>Collapse bookmark bar when opening an article</span>
</label> </label>
</div> </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> </div>
) )
} }
export default LayoutNavigationSettings export default LayoutBehaviorSettings

View File

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

View File

@@ -1,58 +1,206 @@
import React from 'react' import React, { useState, useEffect } from 'react'
import { faDownload, faCheckCircle, faMobileAlt } from '@fortawesome/free-solid-svg-icons' import { useNavigate } from 'react-router-dom'
import { faDownload, faCheckCircle, faTrash } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { usePWAInstall } from '../../hooks/usePWAInstall' 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 { 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 () => { const handleInstall = async () => {
if (isInstalled) return
const success = await installApp() const success = await installApp()
if (success) { if (success) {
console.log('App installed successfully') console.log('App installed successfully')
} }
} }
if (isInstalled) { const handleLinkClick = (url: string) => {
return ( if (onClose) onClose()
<div className="settings-section"> navigate(`/r/${encodeURIComponent(url)}`)
<h3 className="section-title">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>
)
} }
if (!isInstallable) { const handleClearCache = async () => {
return null 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 ( return (
<div className="settings-section"> <div className="settings-section">
<h3 className="section-title">Progressive Web App</h3> <h3 className="section-title">App & Airplane Mode</h3>
<div className="setting-group">
<div className="setting-info"> <div style={{ display: 'flex', gap: '2rem', alignItems: 'stretch' }}>
<FontAwesomeIcon icon={faMobileAlt} style={{ marginRight: '8px' }} /> <div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
<span>Install Boris as an app</span> <p className="setting-description" style={{ marginBottom: '1rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
Boris is offlinefirst 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> </div>
<p className="setting-description" style={{ marginTop: '0.5rem', marginBottom: '1rem', color: 'var(--color-text-secondary)', fontSize: '0.875rem' }}>
Install Boris on your device for a native app experience with offline support. {!isMobile && (
</p> <img
<button src="/pwa.svg"
onClick={handleInstall} alt="Progressive Web App"
className="zap-preset-btn" style={{ width: '30%', height: 'auto', flexShrink: 0, opacity: 0.8 }}
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }} />
> )}
<FontAwesomeIcon icon={faDownload} />
Install App
</button>
</div> </div>
</div> </div>
) )

View File

@@ -98,19 +98,19 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
</div> </div>
</div> </div>
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}> <div className="setting-group setting-inline">
<div className="setting-group setting-inline" style={{ flex: '1 1 auto', minWidth: '200px' }}> <label htmlFor="readingFont">Reading Font</label>
<label htmlFor="readingFont">Reading Font</label> <div className="setting-control">
<div className="setting-control"> <FontSelector
<FontSelector value={settings.readingFont || 'source-serif-4'}
value={settings.readingFont || 'source-serif-4'} onChange={(font) => onUpdate({ readingFont: font })}
onChange={(font) => onUpdate({ readingFont: font })} />
/>
</div>
</div> </div>
</div>
<div className="setting-group setting-inline" style={{ flex: '0 1 auto' }}> <div className="setting-group setting-inline">
<label>Font Size</label> <label className="setting-label">Font Size</label>
<div className="setting-control">
<div className="setting-buttons"> <div className="setting-buttons">
{[16, 18, 21, 24, 28, 32].map(size => ( {[16, 18, 21, 24, 28, 32].map(size => (
<button <button

View File

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

View File

@@ -1,5 +1,6 @@
import React from 'react' import React from 'react'
import { UserSettings } from '../../services/settingsService' import { UserSettings } from '../../services/settingsService'
import { useIsMobile } from '../../hooks/useMediaQuery'
interface ZapSettingsProps { interface ZapSettingsProps {
settings: UserSettings settings: UserSettings
@@ -7,6 +8,7 @@ interface ZapSettingsProps {
} }
const ZapSettings: React.FC<ZapSettingsProps> = ({ settings, onUpdate }) => { const ZapSettings: React.FC<ZapSettingsProps> = ({ settings, onUpdate }) => {
const isMobile = useIsMobile()
const highlighterWeight = settings.zapSplitHighlighterWeight ?? 50 const highlighterWeight = settings.zapSplitHighlighterWeight ?? 50
const borisWeight = settings.zapSplitBorisWeight ?? 2.1 const borisWeight = settings.zapSplitBorisWeight ?? 2.1
const authorWeight = settings.zapSplitAuthorWeight ?? 50 const authorWeight = settings.zapSplitAuthorWeight ?? 50
@@ -42,98 +44,119 @@ const ZapSettings: React.FC<ZapSettingsProps> = ({ settings, onUpdate }) => {
<div className="settings-section"> <div className="settings-section">
<h3 className="section-title">Zap Splits</h3> <h3 className="section-title">Zap Splits</h3>
<div className="setting-group"> <div style={{ display: 'flex', gap: '2rem', alignItems: 'stretch' }}>
<label className="setting-label">Presets</label> <div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
<div className="zap-preset-buttons"> <div className="setting-group">
<button <label className="setting-label">Presets</label>
onClick={() => applyPreset(presets.default)} <div className="zap-preset-buttons">
className={`zap-preset-btn ${isPresetActive(presets.default) ? 'active' : ''}`} <button
title="You: 49%, Author: 49%, Boris: 2%" onClick={() => applyPreset(presets.default)}
> className={`zap-preset-btn ${isPresetActive(presets.default) ? 'active' : ''}`}
Default title="You: 49%, Author: 49%, Boris: 2%"
</button> >
<button Default
onClick={() => applyPreset(presets.generous)} </button>
className={`zap-preset-btn ${isPresetActive(presets.generous) ? 'active' : ''}`} <button
title="You: 6%, Author: 83%, Boris: 11%" onClick={() => applyPreset(presets.generous)}
> className={`zap-preset-btn ${isPresetActive(presets.generous) ? 'active' : ''}`}
Generous title="You: 6%, Author: 83%, Boris: 11%"
</button> >
<button Generous
onClick={() => applyPreset(presets.selfless)} </button>
className={`zap-preset-btn ${isPresetActive(presets.selfless) ? 'active' : ''}`} <button
title="You: 1%, Author: 80%, Boris: 19%" onClick={() => applyPreset(presets.selfless)}
> className={`zap-preset-btn ${isPresetActive(presets.selfless) ? 'active' : ''}`}
Selfless title="You: 1%, Author: 80%, Boris: 19%"
</button> >
<button Selfless
onClick={() => applyPreset(presets.boris)} </button>
className={`zap-preset-btn ${isPresetActive(presets.boris) ? 'active' : ''}`} <button
title="You: 10%, Author: 10%, Boris: 80%" onClick={() => applyPreset(presets.boris)}
> className={`zap-preset-btn ${isPresetActive(presets.boris) ? 'active' : ''}`}
Boris 🧡 title="You: 10%, Author: 10%, Boris: 80%"
</button> >
</div> Boris 🧡
</div> </button>
</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> </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"> <div className="setting-group">
<label className="setting-label">Author(s) Share</label> <div className="zap-split-container">
<div className="zap-split-container"> <div className="zap-split-labels">
<div className="zap-split-labels"> <span className="zap-split-label">Your Share: {highlighterWeight}</span>
<span className="zap-split-label">Weight: {authorWeight}</span> <span className="zap-split-label">({highlighterPercentage.toFixed(1)}%)</span>
<span className="zap-split-label">({authorPercentage.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> </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"> <div className="setting-group">
<label className="setting-label">Support Boris</label> <div className="zap-split-container">
<div className="zap-split-container"> <div className="zap-split-labels">
<div className="zap-split-labels"> <span className="zap-split-label">Author's Share: {authorWeight}</span>
<span className="zap-split-label">Weight: {borisWeight.toFixed(1)}</span> <span className="zap-split-label">({authorPercentage.toFixed(1)}%)</span>
<span className="zap-split-label">({borisPercentage.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> </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"> <div className="setting-group">
Weights determine zap splits when highlighting nostr-native content. <div className="zap-split-container">
If the content has multiple authors, their share is divided proportionally. <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>
</div> </div>
) )

View File

@@ -13,6 +13,7 @@ interface UseBookmarksDataParams {
activeAccount: IAccount | undefined activeAccount: IAccount | undefined
accountManager: AccountManager accountManager: AccountManager
naddr?: string naddr?: string
externalUrl?: string
currentArticleCoordinate?: string currentArticleCoordinate?: string
currentArticleEventId?: string currentArticleEventId?: string
settings?: UserSettings settings?: UserSettings
@@ -23,6 +24,7 @@ export const useBookmarksData = ({
activeAccount, activeAccount,
accountManager, accountManager,
naddr, naddr,
externalUrl,
currentArticleCoordinate, currentArticleCoordinate,
currentArticleEventId, currentArticleEventId,
settings settings
@@ -115,11 +117,13 @@ export const useBookmarksData = ({
// Fetch highlights/contacts independently to avoid disturbing bookmarks // Fetch highlights/contacts independently to avoid disturbing bookmarks
useEffect(() => { useEffect(() => {
if (!relayPool || !activeAccount) return 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() handleFetchHighlights()
} }
handleFetchContacts() handleFetchContacts()
}, [relayPool, activeAccount, naddr, handleFetchHighlights, handleFetchContacts]) }, [relayPool, activeAccount, naddr, externalUrl, handleFetchHighlights, handleFetchContacts])
return { return {
bookmarks, bookmarks,

View File

@@ -71,7 +71,7 @@ export function useExternalUrlLoader({
// Check if fetchHighlightsForUrl exists, otherwise skip // Check if fetchHighlightsForUrl exists, otherwise skip
if (typeof fetchHighlightsForUrl === 'function') { if (typeof fetchHighlightsForUrl === 'function') {
const seen = new Set<string>() const seen = new Set<string>()
const highlightsList = await fetchHighlightsForUrl( await fetchHighlightsForUrl(
relayPool, relayPool,
url, url,
(highlight) => { (highlight) => {
@@ -84,9 +84,9 @@ export function useExternalUrlLoader({
}) })
} }
) )
// Ensure final list is sorted and contains all items // Highlights are already set via the streaming callback
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at)) // No need to set them again as that could cause a flash/disappearance
console.log(`📌 Found ${highlightsList.length} highlights for URL`) console.log(`📌 Finished fetching highlights for URL`)
} else { } else {
console.log('📌 Highlight fetching for URLs not yet implemented') console.log('📌 Highlight fetching for URLs not yet implemented')
} }

View File

@@ -1,21 +1,72 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState, useCallback } from 'react'
interface UseReadingPositionOptions { interface UseReadingPositionOptions {
enabled?: boolean enabled?: boolean
onPositionChange?: (position: number) => void onPositionChange?: (position: number) => void
onReadingComplete?: () => void onReadingComplete?: () => void
readingCompleteThreshold?: number // Default 0.9 (90%) 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 = ({ export const useReadingPosition = ({
enabled = true, enabled = true,
onPositionChange, onPositionChange,
onReadingComplete, onReadingComplete,
readingCompleteThreshold = 0.9 readingCompleteThreshold = 0.9,
syncEnabled = false,
onSave,
autoSaveInterval = 5000
}: UseReadingPositionOptions = {}) => { }: UseReadingPositionOptions = {}) => {
const [position, setPosition] = useState(0) const [position, setPosition] = useState(0)
const [isReadingComplete, setIsReadingComplete] = useState(false) const [isReadingComplete, setIsReadingComplete] = useState(false)
const hasTriggeredComplete = useRef(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(() => { useEffect(() => {
if (!enabled) return if (!enabled) return
@@ -30,12 +81,20 @@ export const useReadingPosition = ({
const documentHeight = document.documentElement.scrollHeight const documentHeight = document.documentElement.scrollHeight
// Calculate position based on how much of the content has been scrolled through // Calculate position based on how much of the content has been scrolled through
const scrollProgress = Math.min(scrollTop / (documentHeight - windowHeight), 1) // Add a small threshold (5px) to account for rounding and make it easier to reach 100%
const clampedProgress = Math.max(0, Math.min(1, scrollProgress)) 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) setPosition(clampedProgress)
onPositionChange?.(clampedProgress) onPositionChange?.(clampedProgress)
// Schedule auto-save if sync is enabled
scheduleSave(clampedProgress)
// Check if reading is complete // Check if reading is complete
if (clampedProgress >= readingCompleteThreshold && !hasTriggeredComplete.current) { if (clampedProgress >= readingCompleteThreshold && !hasTriggeredComplete.current) {
setIsReadingComplete(true) setIsReadingComplete(true)
@@ -54,8 +113,13 @@ export const useReadingPosition = ({
return () => { return () => {
window.removeEventListener('scroll', handleScroll) window.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', 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 // Reset reading complete state when enabled changes
useEffect(() => { useEffect(() => {
@@ -68,6 +132,7 @@ export const useReadingPosition = ({
return { return {
position, position,
isReadingComplete, isReadingComplete,
progressPercentage: Math.round(position * 100) progressPercentage: Math.round(position * 100),
saveNow
} }
} }

View File

@@ -14,10 +14,11 @@ export const fetchHighlightsForUrl = async (
onHighlight?: (highlight: Highlight) => void, onHighlight?: (highlight: Highlight) => void,
settings?: UserSettings settings?: UserSettings
): Promise<Highlight[]> => { ): Promise<Highlight[]> => {
const seenIds = new Set<string>()
const orderedRelaysUrl = prioritizeLocalRelays(RELAYS)
const { local: localRelaysUrl, remote: remoteRelaysUrl } = partitionRelays(orderedRelaysUrl)
try { try {
const seenIds = new Set<string>()
const orderedRelaysUrl = prioritizeLocalRelays(RELAYS)
const { local: localRelaysUrl, remote: remoteRelaysUrl } = partitionRelays(orderedRelaysUrl)
const local$ = localRelaysUrl.length > 0 const local$ = localRelaysUrl.length > 0
? relayPool ? relayPool
.req(localRelaysUrl, { kinds: [9802], '#r': [url] }) .req(localRelaysUrl, { kinds: [9802], '#r': [url] })
@@ -45,11 +46,23 @@ export const fetchHighlightsForUrl = async (
) )
: new Observable<NostrEvent>((sub) => sub.complete()) : new Observable<NostrEvent>((sub) => sub.complete())
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray())) 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 uniqueEvents = dedupeHighlights(rawEvents)
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight) const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
return sortHighlights(highlights) 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 [] return []
} }
} }

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

View File

@@ -54,6 +54,8 @@ export interface UserSettings {
lightColorTheme?: 'paper-white' | 'sepia' | 'ivory' // default: sepia lightColorTheme?: 'paper-white' | 'sepia' | 'ivory' // default: sepia
// Reading settings // Reading settings
paragraphAlignment?: 'left' | 'justify' // default: justify paragraphAlignment?: 'left' | 'justify' // default: justify
// Reading position sync
syncReadingPosition?: boolean // default: false (opt-in)
} }
export async function loadSettings( export async function loadSettings(

View File

@@ -67,6 +67,10 @@
width: 100%; width: 100%;
} }
.me-tab-content:has(.bookmark-filters) {
padding-top: 0.25rem;
}
/* Align highlight list width with profile card width on /me */ /* Align highlight list width with profile card width on /me */
.me-highlights-list { padding-left: 0; padding-right: 0; } .me-highlights-list { padding-left: 0; padding-right: 0; }
.explore-header .author-card { max-width: 600px; margin: 0 auto; width: 100%; } .explore-header .author-card { max-width: 600px; margin: 0 auto; width: 100%; }
@@ -79,6 +83,15 @@
text-align: left; /* Override center alignment from .app */ 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 */ /* Ensure all reading list elements are left-aligned */
.bookmarks-list .individual-bookmark, .bookmarks-list .individual-bookmark,
.bookmarks-list .individual-bookmark * { .bookmarks-list .individual-bookmark * {

View File

@@ -1,9 +1,9 @@
/* Settings view containers */ /* Settings view containers */
.settings-view { display: flex; flex-direction: column; height: 100%; overflow: hidden; padding: 0.75rem 1rem; text-align: left; } .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 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-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 { margin-bottom: 2.5rem; }
.settings-section:last-child { margin-bottom: 0; } .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; } .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 splits preset buttons */
.zap-preset-buttons { display: flex; gap: 0.5rem; flex-wrap: wrap; } .zap-preset-buttons { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.zap-preset-btn { .zap-preset-btn {
flex: 1;
padding: 0.625rem 1.25rem; padding: 0.625rem 1.25rem;
background: var(--color-bg-elevated); background: var(--color-bg-elevated);
border: 1px solid var(--color-border-subtle); border: 1px solid var(--color-border-subtle);
@@ -54,35 +55,56 @@
width: 100%; width: 100%;
height: 8px; height: 8px;
border-radius: 4px; 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; outline: none;
-webkit-appearance: none; -webkit-appearance: none;
position: relative;
} }
.zap-split-slider::-webkit-slider-thumb { .zap-split-slider::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
width: 20px; width: 24px;
height: 20px; height: 24px;
border-radius: 50%; border-radius: 4px;
background: var(--color-primary); 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; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
position: relative;
top: 0;
margin-top: 0;
} }
.zap-split-slider::-webkit-slider-thumb:hover { .zap-split-slider::-webkit-slider-thumb:hover {
background: var(--color-primary-hover); background-color: var(--color-primary-hover);
transform: scale(1.1); transform: scale(1.1);
} }
.zap-split-slider::-moz-range-thumb { .zap-split-slider::-moz-range-thumb {
width: 20px; width: 24px;
height: 20px; height: 24px;
border-radius: 50%; border-radius: 4px;
background: var(--color-primary); 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; cursor: pointer;
border: none; border: none;
transition: all 0.2s ease; transition: all 0.2s ease;
position: relative;
top: 0;
margin-top: 0;
} }
.zap-split-slider::-moz-range-thumb:hover { .zap-split-slider::-moz-range-thumb:hover {
background: var(--color-primary-hover); background-color: var(--color-primary-hover);
transform: scale(1.1); transform: scale(1.1);
} }
.zap-split-description { .zap-split-description {

View File

@@ -176,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 { 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 */ } .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;
}

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

View File

@@ -92,10 +92,12 @@ export const sortIndividualBookmarks = (items: IndividualBookmark[]) => {
export function groupIndividualBookmarks(items: IndividualBookmark[]) { export function groupIndividualBookmarks(items: IndividualBookmark[]) {
const sorted = sortIndividualBookmarks(items) const sorted = sortIndividualBookmarks(items)
const amethyst = sorted.filter(i => i.sourceKind === 30001)
const web = sorted.filter(i => i.kind === 39701 || i.type === 'web') 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) const isIn = (list: IndividualBookmark[], x: IndividualBookmark) => list.some(i => i.id === x.id)
const privateItems = sorted.filter(i => i.isPrivate && !isIn(amethyst, i) && !isIn(web, i)) // 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)) const publicItems = sorted.filter(i => !i.isPrivate && !isIn(amethyst, i) && !isIn(web, i))
return { privateItems, publicItems, web, amethyst } return { privateItems, publicItems, web, amethyst }
} }