Compare commits
210 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f2b70779b | ||
|
|
cc9cc47b51 | ||
|
|
a19cb8b6dc | ||
|
|
c564d1608b | ||
|
|
c146a8f7ec | ||
|
|
48cde27a5b | ||
|
|
fdf0644bbb | ||
|
|
ec7371c43b | ||
|
|
35204ee400 | ||
|
|
d1031b3342 | ||
|
|
db67e94b9e | ||
|
|
a0e5ba3a63 | ||
|
|
f3f80449a6 | ||
|
|
bd0b4e848f | ||
|
|
4f5ba99214 | ||
|
|
aab67d8375 | ||
|
|
dbc0a48194 | ||
|
|
6a84646b0b | ||
|
|
e921967082 | ||
|
|
ec34bc3d04 | ||
|
|
96ce12b952 | ||
|
|
1066c43d6c | ||
|
|
914557a61d | ||
|
|
3df2f248ff | ||
|
|
d2770d58e2 | ||
|
|
933182567d | ||
|
|
f9fa2f05f0 | ||
|
|
919bb8151f | ||
|
|
6f82674c9b | ||
|
|
8caf9988fc | ||
|
|
036ee20d98 | ||
|
|
b86545dcc8 | ||
|
|
8bdccd9c9e | ||
|
|
9a14185fa5 | ||
|
|
53a6053464 | ||
|
|
e27d7ee26c | ||
|
|
98203e6b6f | ||
|
|
8469740141 | ||
|
|
8fff2bce52 | ||
|
|
30b98fc744 | ||
|
|
7a190b7d35 | ||
|
|
e3149c40c7 | ||
|
|
91743518bd | ||
|
|
fd2e4079ab | ||
|
|
ec423cad80 | ||
|
|
8f8441b0e0 | ||
|
|
3c20d45dba | ||
|
|
75c4e20dc9 | ||
|
|
9d27595d31 | ||
|
|
b7d90a790b | ||
|
|
c49d850f74 | ||
|
|
4c11c5fc54 | ||
|
|
44befab6d3 | ||
|
|
02a2f4b85e | ||
|
|
43d54b5734 | ||
|
|
b7896be507 | ||
|
|
eeb40306da | ||
|
|
749b47ac5c | ||
|
|
42f59f2b19 | ||
|
|
2bf6e742f1 | ||
|
|
2a2049e678 | ||
|
|
146aa85e76 | ||
|
|
a26c7497b5 | ||
|
|
da67135f5e | ||
|
|
aebb6d1762 | ||
|
|
8f5cf6a0b4 | ||
|
|
875017db96 | ||
|
|
c0f34b684d | ||
|
|
613956bbaf | ||
|
|
041ba5c05b | ||
|
|
05c21cfd6d | ||
|
|
4898f99ae1 | ||
|
|
be920e8c44 | ||
|
|
0fa5ac536b | ||
|
|
cef359af29 | ||
|
|
2de72b73c1 | ||
|
|
a794331c1a | ||
|
|
e09be543bc | ||
|
|
88085c48d2 | ||
|
|
e32010771b | ||
|
|
03e7484e71 | ||
|
|
d9fd4ec286 | ||
|
|
8f14f0347c | ||
|
|
9b5bb8496f | ||
|
|
9264a78c95 | ||
|
|
326d571871 | ||
|
|
744e86b290 | ||
|
|
e46b68da7e | ||
|
|
811a962632 | ||
|
|
eb82e8762a | ||
|
|
d919da153f | ||
|
|
8389d5811a | ||
|
|
0aa0c44441 | ||
|
|
49ea7504a1 | ||
|
|
6602fb9359 | ||
|
|
731eb6915a | ||
|
|
3459179310 | ||
|
|
b1f951daf5 | ||
|
|
caebcec0af | ||
|
|
5f50f4b8d6 | ||
|
|
3039208ba0 | ||
|
|
397c956e87 | ||
|
|
cf47ceb74b | ||
|
|
da7aa2c115 | ||
|
|
c0046bc04c | ||
|
|
2f8f6a0652 | ||
|
|
9a6f788b98 | ||
|
|
c1a628260c | ||
|
|
7b0bd7077c | ||
|
|
7d47f0a86e | ||
|
|
44fcd74cbe | ||
|
|
5ac0e7ed87 | ||
|
|
743968f7fb | ||
|
|
e1a3ae4b4d | ||
|
|
acf13448ae | ||
|
|
a5daa8b56c | ||
|
|
267169c5c1 | ||
|
|
89272dd9a3 | ||
|
|
d059212238 | ||
|
|
0d8a3576a6 | ||
|
|
8910c2750a | ||
|
|
12393d6df4 | ||
|
|
6c0a2439ad | ||
|
|
d83712127b | ||
|
|
55325cd7ad | ||
|
|
82e508fca6 | ||
|
|
8ff32e9363 | ||
|
|
477308632b | ||
|
|
9ffd06f5e3 | ||
|
|
a89c87819a | ||
|
|
b09ae3bae3 | ||
|
|
6ea8c0d40e | ||
|
|
079501337c | ||
|
|
5bf0382227 | ||
|
|
0199c59014 | ||
|
|
44fb63fc59 | ||
|
|
13a28d2dbd | ||
|
|
f87a7da32e | ||
|
|
8fdf9938c2 | ||
|
|
ee4d480961 | ||
|
|
bd866549a0 | ||
|
|
7c39f1d821 | ||
|
|
e6a7bb4c98 | ||
|
|
14cf3189b8 | ||
|
|
66b060627a | ||
|
|
d9bcf14baa | ||
|
|
c571e6ebf7 | ||
|
|
fb06a1aec3 | ||
|
|
5a0d08641b | ||
|
|
8a8419385e | ||
|
|
0d5dc6e785 | ||
|
|
1d90333803 | ||
|
|
91e6e62688 | ||
|
|
619a8a9753 | ||
|
|
0fe38e94d3 | ||
|
|
722e8adbdf | ||
|
|
886d5ac08c | ||
|
|
89d5ba4c37 | ||
|
|
b8b9f82d91 | ||
|
|
b3fc9bb5c3 | ||
|
|
d2ebcd8fbe | ||
|
|
68c9623c35 | ||
|
|
496d1df404 | ||
|
|
ea1046fe13 | ||
|
|
6d58d6e7f3 | ||
|
|
e1420140d1 | ||
|
|
484c2e0c2f | ||
|
|
31f7d53829 | ||
|
|
e3debfa5df | ||
|
|
a1305fba81 | ||
|
|
ca95d6c7f4 | ||
|
|
5513fc9850 | ||
|
|
86de98e644 | ||
|
|
fd374cd705 | ||
|
|
20b4658bef | ||
|
|
0850ba250c | ||
|
|
b71d188fd8 | ||
|
|
579f6b9a96 | ||
|
|
d9403a73c6 | ||
|
|
747811fa94 | ||
|
|
489e480394 | ||
|
|
418bcb0295 | ||
|
|
88f01554e7 | ||
|
|
c85092a644 | ||
|
|
096478bcec | ||
|
|
b8de4a85e0 | ||
|
|
a5b7cedfaa | ||
|
|
0adb8d6766 | ||
|
|
6a6b8c4fad | ||
|
|
4f952816ea | ||
|
|
76835e2509 | ||
|
|
63af770c83 | ||
|
|
165c427e5f | ||
|
|
a0e30aa197 | ||
|
|
3a8203d26e | ||
|
|
ffe848883e | ||
|
|
078a13c45d | ||
|
|
8a69d5bc6b | ||
|
|
6783ff23f9 | ||
|
|
72a264a01e | ||
|
|
5a67be8096 | ||
|
|
9a929a6be4 | ||
|
|
e0ca010026 | ||
|
|
8bd5d7aadf | ||
|
|
9115c38cde | ||
|
|
0c7c1d54d9 | ||
|
|
d529d83eb8 | ||
|
|
a3127c7836 | ||
|
|
4d5fe1f425 | ||
|
|
c7a4de9786 |
6
.cursor/rules/mobile-first-ui-ux.mdc
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description: anything related to UI/UX
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
This is a mobile-first application. All UI elements should be designed with that in mind. The application should work well on small screens, including older smartphones. The UX should be immaculate on mobile, even when in flight mode. (We use local caches and local relays, so that app works offline too.)
|
||||
451
CHANGELOG.md
@@ -8,6 +8,347 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Tailwind CSS integration with preflight enabled
|
||||
- Reading position tracking with visual progress indicator
|
||||
- Document-level scrolling with sticky sidebars on desktop
|
||||
|
||||
### Changed
|
||||
|
||||
- Refactored layout system to use document scroll instead of pane scroll
|
||||
- Migrated reading progress indicator to Tailwind utilities
|
||||
- Simplified global CSS to work with Tailwind preflight
|
||||
- Added CSS variables for user-settable theme colors
|
||||
|
||||
### Fixed
|
||||
|
||||
- Reading position indicator now always visible at bottom of screen
|
||||
- Progress tracking now accurately reflects reading position
|
||||
- Scroll behavior consistent across desktop and mobile
|
||||
|
||||
## [0.5.7] - 2025-01-14
|
||||
|
||||
### Added
|
||||
|
||||
- Vimeo video metadata extraction support
|
||||
- YouTube video metadata extraction with title, description, and captions
|
||||
- Responsive video player with aspect ratio support
|
||||
- Thumbnail images in compact view
|
||||
- URL routing for /me page tabs
|
||||
- Bookmark navigation in reading list
|
||||
- Video duration display for video URLs
|
||||
- Three-dot menu for videos with open/native/copy/share actions
|
||||
- External video embedding in reader using react-player
|
||||
- Video detection for Vimeo, Dailymotion, and other platforms
|
||||
|
||||
### Changed
|
||||
|
||||
- Enhanced borders for reading list cards
|
||||
- Reading list tab colored blue to match bookmarks icon
|
||||
- Left-aligned text in reading list elements
|
||||
- Increased spacing between mobile buttons and profile element
|
||||
- Main pane now full width when displaying videos
|
||||
- Video container breaks out of reader padding for full width
|
||||
- Simplified video container layout
|
||||
|
||||
### Fixed
|
||||
|
||||
- Video player edge-to-edge display with negative margins
|
||||
- Prevent profile element from bleeding off screen on mobile
|
||||
- Resolved TypeScript errors in youtube-meta.ts
|
||||
- Improved type safety in youtube-meta handler
|
||||
- More lenient YouTube description extraction
|
||||
- Corrected setTimeout ref type in Settings
|
||||
- Proper react-player responsive pattern implementation
|
||||
- Removed unused getIconForUrlType in CompactView
|
||||
|
||||
### Style
|
||||
|
||||
- Hide tab counts on mobile for /me page
|
||||
- Remove max-width on main pane, constrain reader instead
|
||||
- Full width layout for videos
|
||||
- Reader-video specific styles
|
||||
|
||||
## [0.5.6] - 2025-10-13
|
||||
|
||||
### Added
|
||||
|
||||
- Three-dot menu for articles and enhanced highlight menus
|
||||
- Prism.js syntax highlighting for code blocks
|
||||
- Inline image rendering in nostr-native blog posts
|
||||
- Image placeholders on blog post cards in `/explore`
|
||||
- Caching on `/me` page for faster loading
|
||||
|
||||
### Changed
|
||||
|
||||
- Reading List on `/me` now uses the same components as the bookmarks sidebar
|
||||
- Improve bookmarks sidebar visual design
|
||||
- Make article menu button more subtle by removing border
|
||||
|
||||
### Fixed
|
||||
|
||||
- Use round checkmark icon (faCheckCircle) for Mark as Read button
|
||||
- Remove extra horizontal divider above article menu
|
||||
- Ensure code blocks consistently use monospace fonts
|
||||
- Preserve reading font settings in markdown images
|
||||
|
||||
### Style
|
||||
|
||||
- Remove horizontal divider above Mark as Read button
|
||||
- Remove horizontal divider below article menu button
|
||||
|
||||
## [0.5.5] - 2025-01-27
|
||||
|
||||
### Added
|
||||
|
||||
- `/me` page with tabbed layout featuring Highlights, Reading List, and Library tabs
|
||||
- Two-pane layout for `/me` page with article sources and highlights
|
||||
- Custom FontAwesome Pro books icon for Archive tab
|
||||
- CompactButton component for highlight cards
|
||||
- Instant mark-as-read functionality with checkmark animation and read status checking
|
||||
|
||||
### Changed
|
||||
|
||||
- Rename Library tab to Archive
|
||||
- Move highlight timestamp to top-right corner of cards
|
||||
- Replace username with AuthorCard component on `/me` page
|
||||
- Use user's custom highlight color for Highlights tab
|
||||
- Render library articles using BlogPostCard component for consistency
|
||||
- Use faBooks icon for Mark as Read button
|
||||
- Make quote icon a CompactButton in top-left corner
|
||||
|
||||
### Fixed
|
||||
|
||||
- Include currentArticle in useEffect deps to satisfy lint
|
||||
- Deduplicate article events in library to prevent showing duplicates
|
||||
- Remove incorrect useSettings hook usage in Me component
|
||||
- Correct fetchBookmarks usage with callback pattern in Me component
|
||||
- Add padding to prevent quote text from overlapping timestamp
|
||||
- Improve spacing and alignment of highlight card elements
|
||||
- Align corner elements symmetrically with proper margins
|
||||
- Group relay icon and author in footer-left for consistent alignment
|
||||
- Position relay indicator in bottom-left corner to prevent overlap with author
|
||||
|
||||
### Style
|
||||
|
||||
- Match `/me` profile card width to highlight cards
|
||||
- Improve Me page mobile tabs and avoid overlap with sidebar buttons
|
||||
- Reduce margins/paddings to make highlight cards more compact
|
||||
- Tighten vertical spacing on highlight cards
|
||||
- Left-align text inside author card
|
||||
- Constrain `/me` page content width to match author card (600px)
|
||||
- Improve tab border styling for dark theme
|
||||
- Make relay indicator match CompactButton (same look as menu)
|
||||
- Align relay indicator within footer with symmetric spacing
|
||||
- Make header and footer full-width with borders and corners
|
||||
|
||||
## [0.5.4] - 2025-10-13
|
||||
|
||||
### Changed
|
||||
|
||||
- Refactor CSS into modular structure
|
||||
- Split 3600+ line monolithic `index.css` into organized modules
|
||||
- Created `src/styles/` directory with base, layout, components, and utils subdirectories
|
||||
- Each file kept under 210 lines for maintainability
|
||||
- Preserved cascade order and selector specificity via ordered `@import` statements
|
||||
- No functional changes to styling
|
||||
|
||||
### Fixed
|
||||
|
||||
- Mobile button positioning now uses safe area insets for symmetrical layout on notched devices
|
||||
|
||||
## [0.5.3] - 2025-10-13
|
||||
|
||||
### Changed
|
||||
|
||||
- Relay status indicator is now more compact
|
||||
- Smaller padding and font sizes on desktop
|
||||
- Auto-collapsed on mobile (icon-only by default, tap to expand)
|
||||
- Matches size of sidebar toggle buttons (44px touch target)
|
||||
- Hides when scrolling down, shows when scrolling up (consistent with other mobile controls)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Invalid bookmarks without IDs no longer appear in bookmark list
|
||||
- Previously showed as "Now" timestamp with no content
|
||||
- Bookmarks without valid IDs are now filtered out entirely
|
||||
- Use bookmark's original timestamp instead of always generating new ones
|
||||
- Profile icon size when logged out now matches other icon buttons in sidebar header
|
||||
|
||||
## [0.5.2] - 2025-10-12
|
||||
|
||||
### Added
|
||||
|
||||
- Three-dot menu to highlight cards for more compact UI
|
||||
- Combines "Open on Nostr" and "Delete" actions into dropdown menu
|
||||
- Uses horizontal ellipsis icon (⋯)
|
||||
- Click-outside functionality to close menu
|
||||
|
||||
### Changed
|
||||
|
||||
- Switch Nostr gateway from njump.me/search.dergigi.com to ants.sh
|
||||
- Centralized gateway URLs in config file
|
||||
- All profile and event links now use ants.sh
|
||||
- Automatic detection of identifier type (profile vs event) for proper routing
|
||||
- Remove loading text from Explore and Me pages (spinner only)
|
||||
- "Open on Nostr" now links to the highlight event itself instead of the article
|
||||
|
||||
### Fixed
|
||||
|
||||
- Gateway URL routing for ants.sh requirements (/p/ for profiles, /e/ for events)
|
||||
- Linting errors in HighlightItem component
|
||||
|
||||
## [0.5.1] - 2025-10-12
|
||||
|
||||
### Added
|
||||
|
||||
- Highlight color customization to UI elements
|
||||
- Apply user's "my highlights" color to highlight creation buttons
|
||||
- Apply highlight group colors to highlight count indicators
|
||||
- Apply "my highlights" color to collapsed highlights panel button
|
||||
|
||||
### Fixed
|
||||
|
||||
- Highlight count indicator styling to match reading-time element
|
||||
- Brightness and border styling for highlight count indicator
|
||||
- User highlight color now applies to both marker and arrow icons
|
||||
- Highlight group color properly applied to count indicator background
|
||||
|
||||
### Removed
|
||||
|
||||
- MOBILE_IMPLEMENTATION.md documentation file
|
||||
|
||||
## [0.5.0] - 2025-10-12
|
||||
|
||||
### Added
|
||||
|
||||
- Upgrade to full PWA with `vite-plugin-pwa`
|
||||
- Replace placeholder icons with branded favicons
|
||||
- Author info card for nostr-native articles
|
||||
|
||||
### Changed
|
||||
|
||||
- Explore: shrink refresh spinner footprint; inline-sized loading row
|
||||
- Explore: preserve posts across navigations; seed from cache; merge streamed and final results
|
||||
- Explore: keep posts visible during refresh; inline spinner; no list wipe
|
||||
- Bookmarks: keep list visible during refresh; show spinner only; no wipe
|
||||
- Bookmarks: avoid clearing list when no new events; decouple refetch from route changes
|
||||
- Highlights: split service into smaller modules to keep files under 210 lines
|
||||
- Lint/TypeScript: satisfy react-hooks dependencies; fix worker typings; clear ESLint/TS issues
|
||||
|
||||
### Fixed
|
||||
|
||||
- Highlights: merge remote results after local for article/url
|
||||
- Explore: always query remote relays after local; stream merge into UI
|
||||
- Improve mobile touch targets for highlight icons
|
||||
- Color `/me` highlights with "my highlights" color setting
|
||||
|
||||
### Performance
|
||||
|
||||
- Local-first then remote follow-up across services (titles, bookmarks, highlights)
|
||||
- Run local and remote fetches concurrently; stream and dedupe results
|
||||
- Stream contacts and early posts from local; merge remote later
|
||||
- Relay queries use local-first with short timeouts; fallback to remote when needed
|
||||
- Stream results to UI; display cached/local immediately (articles, highlights, explore)
|
||||
|
||||
### Documentation
|
||||
|
||||
- PWA implementation summary and launch checklist updates
|
||||
- Update docs to reflect branded icons and final steps
|
||||
- Remove temporary PWA launch checklist and implementation summary
|
||||
|
||||
## [0.4.3] - 2025-10-11
|
||||
|
||||
### Added
|
||||
|
||||
- Mark as read functionality for articles (NIP-25)
|
||||
- Button at the end of each article to mark as read with 📚 emoji
|
||||
- Creates kind:7 reactions for nostr-native articles (`/a/` paths)
|
||||
- Creates kind:17 reactions for external websites (`/r/` paths)
|
||||
- Button shows loading state while publishing reaction
|
||||
- Only visible when user is logged in
|
||||
- Highlight deletion with confirmation dialog (NIP-09)
|
||||
- Small delete button (trash icon) on highlight items
|
||||
- Only visible for user's own highlights
|
||||
- Confirmation dialog prevents accidental deletions
|
||||
- Styled to match relay indicator (subtle, same size)
|
||||
- Removes highlights from UI immediately after deletion request
|
||||
- `/me` page showing user's recent highlights
|
||||
- Accessible by clicking profile picture in bookmark sidebar
|
||||
- Displays all highlights created by the logged-in user
|
||||
- Uses same rendering as Settings and Explore pages
|
||||
- Includes highlight count in header
|
||||
- Confirmation dialog component
|
||||
- Reusable modal with danger/warning/info variants
|
||||
- Backdrop blur effect
|
||||
- Mobile-responsive design
|
||||
- Prevents accidental destructive actions
|
||||
|
||||
### Changed
|
||||
|
||||
- Relay status indicator on mobile now displays in compact mode
|
||||
- Shows only airplane icon by default (44x44px touch target)
|
||||
- Tap to expand for full connection details
|
||||
- Reduces screen clutter on mobile while keeping info accessible
|
||||
- Smooth transition between compact and expanded states
|
||||
- Desktop view remains unchanged (always shows full details)
|
||||
|
||||
## [0.4.2] - 2025-10-11
|
||||
|
||||
### Added
|
||||
|
||||
- NIP-19 identifier resolution in article content (NIP-19, NIP-27)
|
||||
- Support for `nostr:npub1...`, `nostr:note1...`, `nostr:nprofile1...`, `nostr:nevent1...`, `nostr:naddr1...`
|
||||
- Converts nostr: URIs to clickable links with human-readable labels
|
||||
- Automatically fetches and displays article titles for `naddr` references
|
||||
- Falls back to identifier when title fetch fails
|
||||
- Auto-hide mobile UI buttons on scroll down
|
||||
- Floating bookmark/highlights buttons hide when scrolling down
|
||||
- Buttons reappear when scrolling up for distraction-free reading
|
||||
- Smooth opacity transitions for better UX
|
||||
- Scroll direction detection hook (`useScrollDirection`)
|
||||
- Supports both window and element-based scroll detection
|
||||
- Configurable threshold and enable/disable options
|
||||
|
||||
### Changed
|
||||
|
||||
- Article references (`naddr`) now link internally to `/a/{naddr}` instead of external njump.me
|
||||
- Sidebar auto-closes on mobile when navigating to content via routes
|
||||
- Handles clicking on blog posts in Explore view
|
||||
- Complements existing sidebar auto-close for bookmarks
|
||||
- Markdown processing now async to support article title resolution
|
||||
- Article title resolution fetches titles in parallel for better performance
|
||||
|
||||
### Fixed
|
||||
|
||||
- Mobile button scroll detection now correctly monitors main pane element
|
||||
- Previously monitored window scroll which didn't work on mobile
|
||||
- Content scrolls within `.pane.main` div on mobile devices
|
||||
- All ESLint warnings and TypeScript type errors resolved
|
||||
- Added react-hooks plugin to ESLint configuration
|
||||
- Fixed exhaustive-deps warnings in components
|
||||
- Added block scoping to switch case statements
|
||||
- Corrected type references for nostr-tools decode result
|
||||
|
||||
## [0.4.1] - 2025-10-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- Long article summaries overlapping with hero image content on mobile devices
|
||||
- Article summary now moves below hero image on mobile when longer than 150 characters
|
||||
- Article summary line clamp reduced from 3 to 2 lines on mobile for better space utilization
|
||||
|
||||
### Changed
|
||||
|
||||
- Hero image rendering on mobile now uses zoom-to-fit approach with viewport-based sizing
|
||||
- Hero image height on mobile set to 50vh (constrained between 280px-400px)
|
||||
- Improved image cropping with center positioning for better visual presentation
|
||||
- Optimized reader header overlay padding and title sizing on mobile
|
||||
|
||||
## [0.4.0] - 2025-10-10
|
||||
|
||||
### Added
|
||||
|
||||
- Mobile-responsive design with overlay sidebar drawer
|
||||
- Media query hooks for responsive behavior (`useIsMobile`, `useIsTablet`, `useIsCoarsePointer`)
|
||||
- Auto-collapse sidebar setting for mobile devices
|
||||
@@ -19,32 +360,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Mobile-optimized modals (full-screen sheet style)
|
||||
- Mobile-optimized toast notifications (bottom position)
|
||||
- Dynamic viewport height support (100dvh)
|
||||
- Mobile highlights panel as overlay with toggle button
|
||||
|
||||
### Changed
|
||||
|
||||
- Sidebar now displays as overlay drawer on mobile (≤768px)
|
||||
- Highlights panel hidden on mobile for better content focus
|
||||
- Sidebar auto-closes when selecting content on mobile
|
||||
- Hover effects disabled on touch devices
|
||||
- Replace hamburger icon with bookmark icon on mobile
|
||||
|
||||
### Fixed
|
||||
|
||||
- Ensure bookmarks container fills mobile sidepane properly
|
||||
- Restore desktop grid layout for highlights panel
|
||||
- Improve empty state and loading visibility in mobile sidepanes
|
||||
- Add flex properties to mobile bookmark containers for proper filling
|
||||
- Force bookmarks pane expanded on mobile and ensure highlights pane sits above content on desktop
|
||||
- Reduce mobile backdrop opacity and ensure sidepanes appear above it
|
||||
- Replace any type with proper bookmark interface for linter compliance
|
||||
|
||||
## [0.3.8] - 2025-10-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- Add vercel.json configuration to properly handle SPA routing on Vercel deployments (fixes 404 errors on page refresh)
|
||||
|
||||
## [0.3.7] - 2025-10-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- Logout button functionality - now properly clears active account using clearActive() method
|
||||
|
||||
## [0.3.6] - 2025-10-10
|
||||
|
||||
### Added
|
||||
|
||||
- Compact date format for highlights (now, 5m, 3h, 2d, 1mo, 1y)
|
||||
- Ultra-compact date format for bookmarks sidebar
|
||||
- Encode event links as nevent/naddr per NIP-19 for better client compatibility
|
||||
- Render /explore within ThreePaneLayout to keep side panels visible
|
||||
|
||||
### Fixed
|
||||
|
||||
- Remove incorrect padding-right from highlights container
|
||||
- Reduce font size of highlight metadata for cleaner look
|
||||
- Position highlight FAB button relative to article pane instead of viewport
|
||||
@@ -56,6 +414,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Change explore header icon from compass to newspaper
|
||||
|
||||
### Changed
|
||||
|
||||
- Make connecting notification more subtle with muted blue background
|
||||
- Update Boris pubkey for zap splits to npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x
|
||||
- Update domain references to read.withboris.com (URLs, SEO metadata, and documentation)
|
||||
@@ -63,20 +422,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [0.3.5] - 2025-10-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- Ensure connecting state shows for minimum 15 seconds to prevent premature offline display
|
||||
- Add Cloudflare Pages routing config for SPA paths
|
||||
|
||||
### Changed
|
||||
|
||||
- Extend connecting state duration and remove subtitle text for cleaner UI
|
||||
|
||||
## [0.3.4] - 2025-10-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- Add p tag (author tag) to highlights of nostr-native content for proper attribution
|
||||
|
||||
## [0.3.3] - 2025-10-09
|
||||
|
||||
### Added
|
||||
|
||||
- Service Worker for robust offline image caching
|
||||
- /explore route to discover blog posts from friends on Nostr
|
||||
- Explore button (newspaper icon) in bookmarks header
|
||||
@@ -84,12 +447,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Last fetch time display with relative timestamps in bookmarks list
|
||||
|
||||
### Changed
|
||||
|
||||
- Simplify image caching to use Service Worker transparently
|
||||
- Move refresh button from top bar to end of bookmarks list
|
||||
- Make explore page article cards proper links (supports CMD+click to open in new tab)
|
||||
- Reorganize bookmarks UI for better UX
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improve image cache resilience for offline viewing and hard reloads
|
||||
- Correct TypeScript types for cache stats state
|
||||
- Resolve linter errors for unused parameters
|
||||
@@ -100,6 +465,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [0.3.0] - 2025-10-09
|
||||
|
||||
### Added
|
||||
|
||||
- Flight Mode with offline highlight creation and local relay support
|
||||
- Automatic offline sync - rebroadcast local events when back online
|
||||
- Relay indicator icon on highlight items showing sync status
|
||||
@@ -112,6 +478,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- 6th font size option for better UI scaling
|
||||
|
||||
### Fixed
|
||||
|
||||
- Highlight creation resilient to offline/flight mode
|
||||
- TypeScript type errors in offline sync
|
||||
- Relay indicator tooltip accuracy and reliability
|
||||
@@ -126,6 +493,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Include local relays in relay indicator tooltip
|
||||
|
||||
### Changed
|
||||
|
||||
- Rename 'Offline Mode' to 'Flight Mode' throughout UI
|
||||
- Move publication date to top-right corner with subtle border styling
|
||||
- Consolidate relay/status indicators into single unified icon
|
||||
@@ -139,6 +507,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Simplify rebroadcast settings UI with consistent checkbox style
|
||||
|
||||
### Performance
|
||||
|
||||
- Make highlight creation instant with non-blocking relay publish
|
||||
- Reduce relay status polling interval to 20 seconds
|
||||
- Show sync progress and hide indicator after successful sync
|
||||
@@ -146,6 +515,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [0.2.10] - 2025-10-09
|
||||
|
||||
### Added
|
||||
|
||||
- URL-based settings navigation with /settings route
|
||||
- Active zap split preset highlighting
|
||||
- Educational links about relays in reader view
|
||||
@@ -154,10 +524,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Relays section showing active and recently connected relays
|
||||
|
||||
### Fixed
|
||||
|
||||
- Remove trailing slash from relay URLs
|
||||
- Constrain Reading Font dropdown width
|
||||
|
||||
### Changed
|
||||
|
||||
- Rename 'Default View Mode' to 'Default Bookmark View' in settings
|
||||
- Reorganize settings layout for better UX
|
||||
- Use sidebar-style colored buttons for highlight visibility
|
||||
@@ -166,26 +538,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [0.2.9] - 2025-10-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- Deduplicate highlights in streaming callbacks
|
||||
|
||||
## [0.2.8] - 2025-10-09
|
||||
|
||||
### Added
|
||||
|
||||
- Display article summary in header
|
||||
- Overlay title and metadata on hero images
|
||||
- Apply reading font to article titles
|
||||
|
||||
### Fixed
|
||||
|
||||
- Pass article summary through to ReadableContent
|
||||
- Correct Jina AI Reader proxy URL format
|
||||
|
||||
### Changed
|
||||
|
||||
- Update homepage URL to read.withboris.com
|
||||
- Reorder toolbar buttons for better UX
|
||||
|
||||
## [0.2.7] - 2025-10-08
|
||||
|
||||
### Added
|
||||
|
||||
- Web bookmark creation (NIP-B0, kind:39701)
|
||||
- Tags support for web bookmarks per NIP-B0
|
||||
- Auto-fetch title and description when URL is pasted
|
||||
@@ -196,6 +573,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Respect existing zap tags in source content when creating highlights
|
||||
|
||||
### Fixed
|
||||
|
||||
- Revert to fetchReadableContent to avoid CORS issues
|
||||
- Improve modal spacing with proper box-sizing
|
||||
- Prevent sliders from jumping when resetting settings
|
||||
@@ -203,6 +581,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Correct type signature for addZapTags function
|
||||
|
||||
### Changed
|
||||
|
||||
- Reorder toolbar buttons for better UX
|
||||
- DRY up tag extraction with normalizeTags helper
|
||||
- Use url-metadata package for robust metadata extraction
|
||||
@@ -213,16 +592,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [0.2.6] - 2025-10-08
|
||||
|
||||
### Added
|
||||
|
||||
- Home button to bookmark bar
|
||||
- Configurable zap split for highlights on nostr-native content
|
||||
|
||||
## [0.2.5] - 2025-10-07
|
||||
|
||||
### Fixed
|
||||
|
||||
- Wire preview ref to markdown conversion hook
|
||||
- Add missing useEffect dependencies for article loading
|
||||
|
||||
### Changed
|
||||
|
||||
- DRY up highlight classification and URL normalization
|
||||
- Split highlighting utilities into modules
|
||||
- Extract highlights panel components
|
||||
@@ -234,14 +616,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [0.2.4] - 2025-10-07
|
||||
|
||||
### Added
|
||||
- Domain configuration for https://xn--bris-v0b.com/
|
||||
|
||||
- Domain configuration for <https://xn--bris-v0b.com/>
|
||||
- Public assets and deployment configuration
|
||||
- Hide bookmarks without content or URL
|
||||
|
||||
### Fixed
|
||||
|
||||
- Encode/decode URLs in /r/ route to preserve special characters
|
||||
|
||||
### Changed
|
||||
|
||||
- Cleanup after build fixes (remove shims, update locks)
|
||||
- Stop tracking node_modules/dist
|
||||
- Update dependencies and dedupe
|
||||
@@ -250,39 +635,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [0.2.3] - 2025-10-07
|
||||
|
||||
### Added
|
||||
|
||||
- Parse and display summary tag for nostr articles
|
||||
- Merge and flatten bookmarks from multiple lists
|
||||
- Update URL path when opening bookmarks from sidebar
|
||||
|
||||
### Fixed
|
||||
|
||||
- Ensure bookmarks are sorted newest first after merging lists
|
||||
- Hide empty bookmarks without content
|
||||
- Remove encrypted cyphertext display from bookmark list
|
||||
|
||||
### Changed
|
||||
|
||||
- Remove created date from bookmark list display
|
||||
|
||||
## [0.2.2] - 2025-10-06
|
||||
|
||||
### Added
|
||||
|
||||
- Support for web bookmarks (NIP-B0, kind:39701)
|
||||
- Default highlight visibility settings
|
||||
- Proxy.nostr-relay.app relay to configuration
|
||||
- Comprehensive logging to settings service
|
||||
|
||||
### Fixed
|
||||
|
||||
- Handle web bookmarks with URLs in d tag and prevent crash
|
||||
- Load settings from local cache first to eliminate FOUT
|
||||
- Ensure fonts are fully loaded before applying styles
|
||||
- Improve highlight rendering pipeline with comprehensive debugging
|
||||
|
||||
### Changed
|
||||
|
||||
- Use icon toggle buttons for highlight visibility settings
|
||||
- Change nostrverse icon from fa-globe to fa-network-wired
|
||||
|
||||
## [0.2.1] - 2025-10-05
|
||||
|
||||
### Added
|
||||
|
||||
- Local relay support and centralize relay configuration
|
||||
- Optimistic updates for highlight creation
|
||||
- Enable highlight creation from external URLs
|
||||
@@ -291,28 +683,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Boris branding to highlight alt tag
|
||||
|
||||
### Fixed
|
||||
|
||||
- Properly await account loading from localStorage on refresh
|
||||
- Add protected routes to prevent logout on page refresh
|
||||
- Use undo icon for reset to defaults button
|
||||
- Update local relay port to 10547
|
||||
|
||||
### Changed
|
||||
|
||||
- Remove dedicated login page, handle login through main UI
|
||||
- Simplify to single RELAYS constant (DRY)
|
||||
|
||||
## [0.2.0] - 2025-10-05
|
||||
|
||||
### Added
|
||||
|
||||
- Simple highlight creation feature (FAB style)
|
||||
- Reset to defaults button in settings
|
||||
- Load and apply settings upon login
|
||||
|
||||
### Fixed
|
||||
|
||||
- Replace any types with proper NostrEvent types
|
||||
- Move FAB to Bookmarks component for proper floating
|
||||
- Highlight button positioning with scroll
|
||||
|
||||
### Changed
|
||||
|
||||
- Update color palette to include default friends/nostrverse colors
|
||||
- Show author name in highlight cards
|
||||
- Sync highlight level toggles between sidebar and main article text
|
||||
@@ -321,67 +718,81 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [0.1.11] - 2025-10-05
|
||||
|
||||
### Added
|
||||
|
||||
- Stream highlights progressively as they arrive from relays
|
||||
|
||||
### Fixed
|
||||
|
||||
- Display article immediately without waiting for highlights to load
|
||||
- Show highlights immediately when opening panel if already loaded
|
||||
- Prevent bookmark text from being cut off in compact view
|
||||
- Correct default highlight color for 'mine' to yellow (#ffff00)
|
||||
|
||||
### Changed
|
||||
|
||||
- Reduce padding between bookmark items and panel edge
|
||||
- Update default highlight colors to orange for friends and purple for nostrverse
|
||||
|
||||
## [0.1.10] - 2025-10-05
|
||||
|
||||
### Added
|
||||
|
||||
- Three-level highlight system (mine/friends/nostrverse)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Ensure highlights always render on markdown content
|
||||
- Classify highlights before passing to ContentPanel
|
||||
- Position toggle buttons directly adjacent to main panel
|
||||
- Remove redundant setReaderLoading call in error handler
|
||||
|
||||
### Changed
|
||||
|
||||
- Always show friends and user highlight buttons
|
||||
- Remove Highlights title and count from panel
|
||||
|
||||
## [0.1.9] - 2025-10-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- Show markdown content immediately when finalHtml is empty
|
||||
- Prevent highlight bleeding into sidebar
|
||||
|
||||
## [0.1.8] - 2025-10-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- Prevent 'No readable content' flash for markdown articles
|
||||
- Enable highlights display and scroll-to for markdown content
|
||||
|
||||
### Added
|
||||
|
||||
- Persist accounts to localStorage
|
||||
|
||||
### Changed
|
||||
|
||||
- Simplify login by handling it directly in sidebar
|
||||
|
||||
## [0.1.7] - 2025-10-05
|
||||
|
||||
### Added
|
||||
|
||||
- Show highlights in article content and add mode toggle
|
||||
|
||||
### Fixed
|
||||
|
||||
- Show highlights for nostr articles by skipping URL filter
|
||||
- Refresh button now works without login for article highlights
|
||||
- Query highlights using both a-tag and e-tag
|
||||
|
||||
### Changed
|
||||
|
||||
- Keep Bookmarks.tsx under 210 lines by extracting logic
|
||||
|
||||
## [0.1.6] - 2025-10-03
|
||||
|
||||
### Added
|
||||
|
||||
- Native support for rendering Nostr long-form articles (NIP-23)
|
||||
- Display article titles for kind:30023 bookmarks
|
||||
- Enable clicking on kind:30023 articles to open in reader
|
||||
@@ -390,10 +801,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Highlight style setting (marker & underline)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Use bookmark pubkey for article author instead of tag lookup
|
||||
- Ensure highlight color CSS variable inherits from parent
|
||||
|
||||
### Changed
|
||||
|
||||
- Integrate long-form article rendering into existing reader view
|
||||
- Extract components to keep files under 210 lines
|
||||
- Make font size and color buttons match icon button size (33px)
|
||||
@@ -401,6 +814,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [0.1.5] - 2025-10-03
|
||||
|
||||
### Added
|
||||
|
||||
- Settings panel with NIP-78 storage
|
||||
- Auto-save for settings with toast notifications
|
||||
- Reading time estimate to articles
|
||||
@@ -410,12 +824,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Settings subscription to watch for Nostr updates
|
||||
|
||||
### Fixed
|
||||
|
||||
- Prevent settings from saving unnecessarily
|
||||
- Prevent save settings button from being cut off
|
||||
- Replace custom reading time with reading-time-estimator package
|
||||
- Update originalHtmlRef when content changes
|
||||
|
||||
### Changed
|
||||
|
||||
- Reduce file sizes to meet 210 line limit
|
||||
- Extract settings logic into custom hook
|
||||
- Consolidate settings initialization on login
|
||||
@@ -424,6 +840,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [0.1.4] - 2025-10-03
|
||||
|
||||
### Added
|
||||
|
||||
- Inline highlight annotations in content panel
|
||||
- NIP-84 highlights panel with three-pane layout
|
||||
- Toggle button to show/hide highlight underlines
|
||||
@@ -431,12 +848,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Pulsing animation when scrolling to highlight
|
||||
|
||||
### Fixed
|
||||
|
||||
- Apply highlights to markdown content as well as HTML
|
||||
- Use requestAnimationFrame for highlight DOM manipulation
|
||||
- Improve HTML highlight matching with DOM manipulation
|
||||
- Filter highlights panel to show only current article
|
||||
|
||||
### Changed
|
||||
|
||||
- Use applesauce helpers for highlight parsing
|
||||
- DRY up highlightMatching to stay under 210 lines
|
||||
- Change highlights to fluorescent marker style
|
||||
@@ -445,12 +864,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [0.1.3] - 2025-10-03
|
||||
|
||||
### Added
|
||||
|
||||
- View mode switching for bookmarks with compact list view
|
||||
- Large preview view mode
|
||||
- Image preview for large view cards
|
||||
- Hero images using free CORS proxy
|
||||
|
||||
### Changed
|
||||
|
||||
- Make entire compact list row clickable to open reader
|
||||
- Make card view timestamp clickable to open event
|
||||
- Enhance card view design with modern styling
|
||||
@@ -458,15 +879,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [0.1.2] - 2025-10-03
|
||||
|
||||
### Added
|
||||
|
||||
- Open bookmark URLs in reader instead of new window
|
||||
- localStorage caching for fetched articles
|
||||
- Collapsible bookmarks sidebar
|
||||
|
||||
### Fixed
|
||||
|
||||
- Make sidebar and reader scroll independently
|
||||
- Replace relative-time with date-fns for timestamp formatting
|
||||
|
||||
### Changed
|
||||
|
||||
- Display timestamps as relative time
|
||||
- Replace user text with profile image in sidebar header
|
||||
- Move user info and logout to sidebar header bar
|
||||
@@ -475,16 +899,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [0.1.1] - 2025-10-03
|
||||
|
||||
### Added
|
||||
|
||||
- Classify URLs by type and adjust action buttons
|
||||
- Collapse/expand functionality for bookmarks sidebar
|
||||
- IconButton component with square styling
|
||||
- Resolve nprofile/npub mentions to names in content
|
||||
|
||||
### Fixed
|
||||
|
||||
- Enforce 210-char truncation for both plain and parsed content
|
||||
- Use Rules of Hooks correctly
|
||||
|
||||
### Changed
|
||||
|
||||
- Use IconButton for all icon-only actions to enforce square sizing
|
||||
- Sort bookmarks by added_at (recently added first)
|
||||
- Make kind icon square to match IconButton sizing
|
||||
@@ -493,6 +920,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [0.1.0] - 2025-10-03
|
||||
|
||||
### Added
|
||||
|
||||
- Two-pane layout and content fetching pipeline
|
||||
- ContentPanel component to render readable HTML
|
||||
- Lightweight readability fetcher via r.jina.ai proxy
|
||||
@@ -502,11 +930,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- FontAwesome icons for event kinds
|
||||
|
||||
### Fixed
|
||||
|
||||
- Show bookmarked event author instead of list signer
|
||||
- Enable reactive profile fetch via address loader
|
||||
- Left-align content and constrain images in content panel
|
||||
|
||||
### Changed
|
||||
|
||||
- Resolve author names using applesauce ProfileModel
|
||||
- Propagate URL selection through BookmarkList to parent
|
||||
- Display URLs clearly in individual bookmarks
|
||||
@@ -514,16 +944,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [0.0.3] - 2025-10-02
|
||||
|
||||
### Added
|
||||
|
||||
- Manual decryption for unrecognized event kinds
|
||||
- Try NIP-44 then NIP-04 for manual decryption
|
||||
- Detailed debugging for decryption process
|
||||
- Support for hidden bookmarks decryption
|
||||
|
||||
### Fixed
|
||||
|
||||
- Surface manually decrypted hidden tags in UI
|
||||
- Dedupe individual bookmarks by id
|
||||
|
||||
### Changed
|
||||
|
||||
- Sort individual bookmarks by timestamp (newest first)
|
||||
- Increase bookmark loading timeout by 2x
|
||||
- Extract helpers and event processing
|
||||
@@ -531,6 +964,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [0.0.2] - 2025-10-02
|
||||
|
||||
### Added
|
||||
|
||||
- Fetch all NIP-51 events
|
||||
- Unlock private bookmarks via applesauce helpers
|
||||
- Copy-to-clipboard icons for event id and author pubkey
|
||||
@@ -538,12 +972,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Display content identically for private/public bookmarks
|
||||
|
||||
### Fixed
|
||||
|
||||
- Properly configure browser extension signer
|
||||
- Aggregate list(10003) + set(30001)
|
||||
- Handle applesauce bookmark structure correctly
|
||||
- Resolve loading state stuck issue
|
||||
|
||||
### Changed
|
||||
|
||||
- Change bookmarks display from grid to social feed list layout
|
||||
- Simplify bookmark service using applesauce helpers
|
||||
- Extract components and utilities to keep files under 210 lines
|
||||
@@ -551,6 +987,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
## [0.0.1] - 2025-10-02
|
||||
|
||||
### Added
|
||||
|
||||
- Initial release
|
||||
- Browser extension login support
|
||||
- NIP-51 bookmark fetching from nostr relays
|
||||
@@ -559,17 +996,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Basic UI with profile resolution
|
||||
|
||||
### Changed
|
||||
|
||||
- Migrate to applesauce-accounts for proper account management
|
||||
- Use proper applesauce-loaders for NIP-51 bookmark fetching
|
||||
- Optimize relay usage following applesauce-relay best practices
|
||||
- Use applesauce-react event models for better profile handling
|
||||
|
||||
[Unreleased]: https://github.com/dergigi/boris/compare/v0.5.5...HEAD
|
||||
[0.5.5]: https://github.com/dergigi/boris/compare/v0.5.4...v0.5.5
|
||||
[0.5.2]: https://github.com/dergigi/boris/compare/v0.5.1...v0.5.2
|
||||
[0.5.1]: https://github.com/dergigi/boris/compare/v0.5.0...v0.5.1
|
||||
[0.5.0]: https://github.com/dergigi/boris/compare/v0.4.3...v0.5.0
|
||||
[0.4.0]: https://github.com/dergigi/boris/compare/v0.3.8...v0.4.0
|
||||
[0.3.8]: https://github.com/dergigi/boris/compare/v0.3.7...v0.3.8
|
||||
[0.3.7]: https://github.com/dergigi/boris/compare/v0.3.6...v0.3.7
|
||||
[0.3.6]: https://github.com/dergigi/boris/compare/v0.3.5...v0.3.6
|
||||
[0.3.5]: https://github.com/dergigi/boris/compare/v0.3.4...v0.3.5
|
||||
[0.3.4]: https://github.com/dergigi/boris/compare/v0.3.3...v0.3.4
|
||||
[0.3.3]: https://github.com/dergigi/boris/compare/v0.3.2...v0.3.3
|
||||
[0.3.2]: https://github.com/dergigi/boris/compare/v0.3.1...v0.3.2
|
||||
[0.3.1]: https://github.com/dergigi/boris/compare/v0.3.0...v0.3.1
|
||||
[0.3.0]: https://github.com/dergigi/boris/compare/v0.2.10...v0.3.0
|
||||
[0.2.10]: https://github.com/dergigi/boris/compare/v0.2.9...v0.2.10
|
||||
[0.2.9]: https://github.com/dergigi/boris/compare/v0.2.8...v0.2.9
|
||||
@@ -597,4 +1041,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
[0.0.3]: https://github.com/dergigi/boris/compare/v0.0.2...v0.0.3
|
||||
[0.0.2]: https://github.com/dergigi/boris/compare/v0.0.1...v0.0.2
|
||||
[0.0.1]: https://github.com/dergigi/boris/releases/tag/v0.0.1
|
||||
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
# Mobile Implementation Summary
|
||||
|
||||
## Overview
|
||||
Boris is now mobile-friendly! The app now works seamlessly on mobile devices with a responsive design that includes:
|
||||
- Auto-collapsing sidebar that opens as an overlay drawer on small screens
|
||||
- Touch-optimized UI with proper touch target sizes (44x44px minimum)
|
||||
- Safe area insets for notched devices (iPhone X+, etc.)
|
||||
- Focus trap and keyboard navigation in the mobile sidebar
|
||||
- Mobile-optimized modals, toasts, and other UI elements
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Viewport & Base Setup
|
||||
**File: `index.html`**
|
||||
- Updated viewport meta tag to include `viewport-fit=cover` for proper safe area handling
|
||||
|
||||
### 2. Media Query Hooks
|
||||
**File: `src/hooks/useMediaQuery.ts` (NEW)**
|
||||
- `useMediaQuery(query)` - Generic hook for any media query
|
||||
- `useIsMobile()` - Detects mobile viewport (≤768px)
|
||||
- `useIsTablet()` - Detects tablet viewport (≤1024px)
|
||||
- `useIsCoarsePointer()` - Detects touch devices
|
||||
|
||||
### 3. Mobile CSS Styles
|
||||
**File: `src/index.css`**
|
||||
- Added CSS custom properties for mobile breakpoints and safe areas
|
||||
- Mobile-specific three-pane layout that stacks into single column
|
||||
- Overlay sidebar with backdrop and transitions
|
||||
- Touch target improvements (44x44px minimum)
|
||||
- Disabled hover effects on touch devices
|
||||
- Mobile-optimized modals (full-screen sheet style)
|
||||
- Mobile-optimized toasts (bottom position with safe area)
|
||||
- Dynamic viewport height support (`100dvh`)
|
||||
- Overscroll behavior and body scroll locking
|
||||
|
||||
### 4. Sidebar State Management
|
||||
**File: `src/hooks/useBookmarksUI.ts`**
|
||||
- Added `isMobile` state from media query
|
||||
- Added `isSidebarOpen` state for mobile overlay
|
||||
- Added `toggleSidebar()` function
|
||||
- Auto-collapse logic based on `autoCollapseSidebarOnMobile` setting
|
||||
- Mobile sidebar defaults to closed, desktop defaults to open
|
||||
|
||||
### 5. Three-Pane Layout Mobile Support
|
||||
**File: `src/components/ThreePaneLayout.tsx`**
|
||||
- Mobile hamburger button (visible only on mobile)
|
||||
- Mobile backdrop for closing sidebar
|
||||
- Body scroll locking when sidebar is open
|
||||
- ESC key handler to close sidebar
|
||||
- Focus trap in sidebar (Tab navigation stays within sidebar)
|
||||
- Focus restoration when closing sidebar
|
||||
- Accessibility attributes (`aria-hidden`, `aria-expanded`, etc.)
|
||||
|
||||
### 6. Sidebar Header Mobile Controls
|
||||
**File: `src/components/SidebarHeader.tsx`**
|
||||
- Close button (X) visible on mobile instead of collapse chevron
|
||||
- Hamburger button hidden in header (shown in layout instead)
|
||||
|
||||
### 7. Bookmark List Mobile Props
|
||||
**File: `src/components/BookmarkList.tsx`**
|
||||
- Added `isMobile` prop support
|
||||
- Passes mobile state to SidebarHeader
|
||||
|
||||
### 8. Main Bookmarks Component
|
||||
**File: `src/components/Bookmarks.tsx`**
|
||||
- Uses mobile state from `useBookmarksUI`
|
||||
- Auto-closes sidebar when selecting bookmark on mobile
|
||||
- Closes sidebar when opening settings on mobile
|
||||
- Proper desktop/mobile toggle behavior
|
||||
|
||||
### 9. Icon Button Enhancement
|
||||
**File: `src/components/IconButton.tsx`**
|
||||
- Added optional `className` prop for additional styling
|
||||
|
||||
### 10. Mobile Settings
|
||||
**File: `src/services/settingsService.ts`**
|
||||
- Added `autoCollapseSidebarOnMobile?: boolean` setting (default: true)
|
||||
|
||||
**File: `src/components/Settings/StartupPreferencesSettings.tsx`**
|
||||
- Added UI toggle for "Auto-collapse sidebar on small screens"
|
||||
|
||||
## Accessibility Features
|
||||
- Focus trap in mobile sidebar (Tab key navigation stays within drawer)
|
||||
- ESC key closes mobile sidebar
|
||||
- Backdrop click closes mobile sidebar
|
||||
- Proper ARIA attributes (`aria-hidden`, `aria-expanded`, `aria-controls`)
|
||||
- Touch target minimum size enforcement (44x44px)
|
||||
- Focus restoration when closing sidebar
|
||||
|
||||
## Mobile Behaviors
|
||||
1. **Sidebar**: Slides in from left as overlay drawer with backdrop
|
||||
2. **Hamburger Menu**: Fixed position top-left when sidebar closed
|
||||
3. **Selecting Content**: Auto-closes sidebar on mobile
|
||||
4. **Opening Settings**: Auto-closes sidebar on mobile
|
||||
5. **Highlights Panel**: Hidden on mobile (content takes full width)
|
||||
6. **Modals**: Full-screen sheet style from bottom
|
||||
7. **Toasts**: Bottom position with safe area padding
|
||||
|
||||
## Responsive Breakpoints
|
||||
- **Mobile**: ≤768px (sidebar overlay, single column)
|
||||
- **Tablet**: ≤1024px (defined but not actively used yet)
|
||||
- **Desktop**: >768px (three-pane layout as before)
|
||||
|
||||
## Browser Support
|
||||
- Modern browsers with CSS Grid support
|
||||
- iOS Safari (including safe area insets)
|
||||
- Chrome for Android
|
||||
- Firefox Mobile
|
||||
- Safari on iPadOS
|
||||
|
||||
## Safe Area Support
|
||||
The app respects device safe areas (notches, home indicators) through CSS environment variables:
|
||||
- `env(safe-area-inset-top)`
|
||||
- `env(safe-area-inset-bottom)`
|
||||
- `env(safe-area-inset-left)`
|
||||
- `env(safe-area-inset-right)`
|
||||
|
||||
## Future Enhancements
|
||||
Potential improvements for future iterations:
|
||||
- Swipe gesture to open/close sidebar
|
||||
- Pull-to-refresh on mobile
|
||||
- Bottom sheet for highlights panel on mobile
|
||||
- Optimized font sizes for mobile reading
|
||||
- Mobile-specific view mode (perhaps auto-switch to compact on mobile)
|
||||
- Haptic feedback on interactions (iOS/Android)
|
||||
- Share sheet integration
|
||||
- Install prompt for PWA
|
||||
|
||||
## Testing Checklist
|
||||
- [x] Sidebar opens/closes on mobile
|
||||
- [x] Hamburger button visible on mobile
|
||||
- [x] Backdrop closes sidebar
|
||||
- [x] ESC key closes sidebar
|
||||
- [x] Focus trap works in sidebar
|
||||
- [x] Selecting bookmark closes sidebar
|
||||
- [x] No horizontal scroll
|
||||
- [x] Touch targets ≥ 44px
|
||||
- [x] Modals are full-screen on mobile
|
||||
- [x] Toasts appear at bottom with safe area
|
||||
- [x] Build completes without errors
|
||||
- [ ] Test on actual iOS device (iPhone)
|
||||
- [ ] Test on actual Android device
|
||||
- [ ] Test with keyboard navigation
|
||||
- [ ] Test with screen reader
|
||||
- [ ] Test landscape orientation
|
||||
- [ ] Test on various screen sizes (320px, 375px, 414px, 768px)
|
||||
|
||||
## Commit History
|
||||
1. `feat: update viewport meta for mobile support`
|
||||
2. `feat: add media query hooks for responsive design`
|
||||
3. `feat: add mobile sidebar state management to useBookmarksUI`
|
||||
4. `feat: add mobile-responsive CSS with breakpoints and safe areas`
|
||||
5. `feat: implement mobile overlay sidebar with focus trap and ESC handling`
|
||||
6. `feat: add mobile auto-collapse setting`
|
||||
7. `fix: resolve TypeScript errors for mobile implementation`
|
||||
|
||||
188
TAILWIND_MIGRATION.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# Tailwind CSS Migration Status
|
||||
|
||||
## ✅ Completed (Core Infrastructure)
|
||||
|
||||
### Phase 1: Setup & Foundation
|
||||
- [x] Install Tailwind CSS with PostCSS and Autoprefixer
|
||||
- [x] Configure `tailwind.config.js` with content globs and custom keyframes
|
||||
- [x] Create `src/styles/tailwind.css` with base/components/utilities
|
||||
- [x] Import Tailwind before existing CSS in `main.tsx`
|
||||
- [x] Enable Tailwind preflight (CSS reset)
|
||||
|
||||
### Phase 2: Base Styles Reconciliation
|
||||
- [x] Add CSS variables for user-settable theme colors
|
||||
- `--highlight-color-mine`, `--highlight-color-friends`, `--highlight-color-nostrverse`
|
||||
- `--reading-font`, `--reading-font-size`
|
||||
- [x] Simplify `global.css` to work with Tailwind preflight
|
||||
- [x] Remove redundant base styles handled by Tailwind
|
||||
- [x] Keep app-specific overrides (mobile sidebar lock, loading states)
|
||||
|
||||
### Phase 3: Layout System Refactor ⭐ **CRITICAL FIX**
|
||||
- [x] Switch from pane-scrolling to document-scrolling
|
||||
- [x] Make sidebars sticky on desktop (`position: sticky`)
|
||||
- [x] Update `app.css` to remove fixed container heights
|
||||
- [x] Update `ThreePaneLayout.tsx` to use window scroll
|
||||
- [x] Fix reading position tracking to work with document scroll
|
||||
- [x] Maintain mobile overlay behavior
|
||||
|
||||
### Phase 4: Component Migrations
|
||||
- [x] **ReadingProgressIndicator**: Full Tailwind conversion
|
||||
- Removed 80+ lines of CSS
|
||||
- Added shimmer animation to Tailwind config
|
||||
- Z-index layering maintained (1102)
|
||||
|
||||
- [x] **Mobile UI Elements**: Tailwind utilities
|
||||
- Mobile hamburger button
|
||||
- Mobile highlights button
|
||||
- Mobile backdrop
|
||||
- Removed 60+ lines of CSS
|
||||
|
||||
- [x] **App Container**: Tailwind utilities
|
||||
- Responsive padding (p-0 md:p-4)
|
||||
- Min-height viewport support
|
||||
|
||||
## 📊 Impact & Metrics
|
||||
|
||||
### Lines of CSS Removed
|
||||
- `global.css`: ~50 lines removed
|
||||
- `reader.css`: ~80 lines removed (progress indicator)
|
||||
- `app.css`: ~30 lines removed (mobile buttons/backdrop)
|
||||
- `sidebar.css`: ~30 lines removed (mobile hamburger)
|
||||
- **Total**: ~190 lines removed
|
||||
|
||||
### Key Achievements
|
||||
1. **Fixed Core Issue**: Reading position tracking now works correctly with document scroll
|
||||
2. **Tailwind Integration**: Fully functional with preflight enabled
|
||||
3. **No Breaking Changes**: All existing functionality preserved
|
||||
4. **Type Safety**: TypeScript checks passing
|
||||
5. **Lint Clean**: ESLint checks passing
|
||||
6. **Responsive**: Mobile/tablet/desktop layouts working
|
||||
|
||||
## 🔄 Remaining Work (Incremental)
|
||||
|
||||
The following migrations are **optional enhancements** that can be done as components are touched:
|
||||
|
||||
### High-Value Components
|
||||
- [ ] **ContentPanel** - Large component, high impact
|
||||
- Reader header, meta info, loading states
|
||||
- Mark as read button
|
||||
- Article/video menus
|
||||
|
||||
- [ ] **BookmarkList & BookmarkItem** - Core UI
|
||||
- Card layouts (compact/cards/large views)
|
||||
- Bookmark metadata display
|
||||
- Interactive states
|
||||
|
||||
- [ ] **HighlightsPanel** - Feature-rich
|
||||
- Header with toggles
|
||||
- Highlight items
|
||||
- Level-based styling
|
||||
|
||||
- [ ] **Settings Components** - Forms & controls
|
||||
- Color pickers
|
||||
- Font selectors
|
||||
- Toggle switches
|
||||
- Sliders
|
||||
|
||||
### CSS Files to Prune
|
||||
- `src/index.css` - Contains many inline bookmark/highlight styles (~3000+ lines)
|
||||
- `src/styles/components/cards.css` - Bookmark card styles
|
||||
- `src/styles/components/modals.css` - Modal dialogs
|
||||
- `src/styles/layout/highlights.css` - Highlight panel layout
|
||||
|
||||
## 🎯 Migration Strategy
|
||||
|
||||
### For New Components
|
||||
Use Tailwind utilities from the start. Reference:
|
||||
```tsx
|
||||
// Good: Tailwind utilities
|
||||
<div className="flex items-center gap-2 p-4 bg-gray-800 rounded-lg">
|
||||
|
||||
// Avoid: New CSS classes
|
||||
<div className="custom-component">
|
||||
```
|
||||
|
||||
### For Existing Components
|
||||
Migrate incrementally when touching files:
|
||||
1. Replace layout utilities (flex, grid, spacing, sizing)
|
||||
2. Replace color/background utilities
|
||||
3. Replace typography utilities
|
||||
4. Replace responsive variants
|
||||
5. Remove old CSS rules
|
||||
6. Keep file under 210 lines
|
||||
|
||||
### CSS Variable Usage
|
||||
Dynamic values should still use CSS variables or inline styles:
|
||||
```tsx
|
||||
// User-settable colors
|
||||
style={{ backgroundColor: settings.highlightColorMine }}
|
||||
|
||||
// Or reference CSS variable
|
||||
className="bg-[var(--highlight-color-mine)]"
|
||||
```
|
||||
|
||||
## 📝 Technical Notes
|
||||
|
||||
### Z-Index Layering
|
||||
- Mobile sidepanes: `z-[1001]`
|
||||
- Mobile backdrop: `z-[999]`
|
||||
- Progress indicator: `z-[1102]`
|
||||
- Mobile buttons: `z-[900]`
|
||||
- Relay status: `z-[999]`
|
||||
- Modals: `z-[10000]`
|
||||
|
||||
### Responsive Breakpoints
|
||||
- Mobile: `< 768px`
|
||||
- Tablet: `768px - 1024px`
|
||||
- Desktop: `> 1024px`
|
||||
|
||||
Use Tailwind: `md:` (768px), `lg:` (1024px)
|
||||
|
||||
### Safe Area Insets
|
||||
Mobile notch support:
|
||||
```tsx
|
||||
style={{
|
||||
top: 'calc(1rem + env(safe-area-inset-top))',
|
||||
left: 'calc(1rem + env(safe-area-inset-left))'
|
||||
}}
|
||||
```
|
||||
|
||||
### Custom Animations
|
||||
Add to `tailwind.config.js`:
|
||||
```js
|
||||
keyframes: {
|
||||
shimmer: {
|
||||
'0%': { transform: 'translateX(-100%)' },
|
||||
'100%': { transform: 'translateX(100%)' },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## ✅ Success Criteria Met
|
||||
|
||||
- [x] Tailwind CSS fully integrated and functional
|
||||
- [x] Document scrolling working correctly
|
||||
- [x] Reading position tracking accurate
|
||||
- [x] Progress indicator always visible
|
||||
- [x] No TypeScript errors
|
||||
- [x] No linting errors
|
||||
- [x] Mobile responsiveness maintained
|
||||
- [x] Theme colors (user settings) working
|
||||
- [x] All existing features functional
|
||||
|
||||
## 🚀 Next Steps
|
||||
|
||||
1. **Ship It**: Current state is production-ready
|
||||
2. **Incremental Migration**: Convert components as you touch them
|
||||
3. **Monitor**: Watch for any CSS conflicts
|
||||
4. **Cleanup**: Eventually remove unused CSS files
|
||||
5. **Document**: Update component docs with Tailwind patterns
|
||||
|
||||
---
|
||||
|
||||
**Status**: ✅ **CORE MIGRATION COMPLETE**
|
||||
**Date**: 2025-01-14
|
||||
**Commits**: 8 conventional commits
|
||||
**Lines Removed**: ~190 lines of CSS
|
||||
**Breaking Changes**: None
|
||||
|
||||
197
api/video-meta.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
||||
import { getSubtitles, getVideoDetails } from '@treeee/youtube-caption-extractor'
|
||||
|
||||
type Caption = { start: number; dur: number; text: string }
|
||||
|
||||
type CacheEntry = {
|
||||
body: unknown
|
||||
expires: number
|
||||
}
|
||||
|
||||
type VimeoOEmbedResponse = {
|
||||
title: string
|
||||
description: string
|
||||
author_name: string
|
||||
author_url: string
|
||||
provider_name: string
|
||||
provider_url: string
|
||||
type: string
|
||||
version: string
|
||||
width: number
|
||||
height: number
|
||||
html: string
|
||||
thumbnail_url: string
|
||||
thumbnail_width: number
|
||||
thumbnail_height: number
|
||||
}
|
||||
|
||||
// In-memory cache for 7 days
|
||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
||||
const memoryCache = new Map<string, CacheEntry>()
|
||||
|
||||
function buildKey(videoId: string, lang: string, preferAuto?: string | string[], source?: string) {
|
||||
return `${source || 'video'}|${videoId}|${lang}|${preferAuto ? 'auto' : 'manual'}`
|
||||
}
|
||||
|
||||
function ok(res: VercelResponse, data: unknown) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400, s-maxage=604800') // client: 1d, CDN: 7d
|
||||
return res.status(200).json(data)
|
||||
}
|
||||
|
||||
function bad(res: VercelResponse, code: number, message: string) {
|
||||
return res.status(code).json({ error: message })
|
||||
}
|
||||
|
||||
function extractVideoId(url: string): { id: string; source: 'youtube' | 'vimeo' } | null {
|
||||
// YouTube patterns
|
||||
const youtubePatterns = [
|
||||
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
|
||||
/youtube\.com\/v\/([^&\n?#]+)/
|
||||
]
|
||||
|
||||
for (const pattern of youtubePatterns) {
|
||||
const match = url.match(pattern)
|
||||
if (match) {
|
||||
return { id: match[1], source: 'youtube' }
|
||||
}
|
||||
}
|
||||
|
||||
// Vimeo patterns
|
||||
const vimeoPatterns = [
|
||||
/vimeo\.com\/(\d+)/,
|
||||
/player\.vimeo\.com\/video\/(\d+)/
|
||||
]
|
||||
|
||||
for (const pattern of vimeoPatterns) {
|
||||
const match = url.match(pattern)
|
||||
if (match) {
|
||||
return { id: match[1], source: 'vimeo' }
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function pickCaptions(videoID: string, preferredLangs: string[], manualFirst: boolean): Promise<{ caps: Caption[]; lang: string; isAuto: boolean } | null> {
|
||||
for (const lang of preferredLangs) {
|
||||
try {
|
||||
const caps = await getSubtitles({ videoID, lang, auto: !manualFirst ? true : false })
|
||||
if (Array.isArray(caps) && caps.length > 0) {
|
||||
return { caps, lang, isAuto: !manualFirst }
|
||||
}
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function getVimeoMetadata(videoId: string): Promise<{ title: string; description: string }> {
|
||||
const vimeoUrl = `https://vimeo.com/${videoId}`
|
||||
const oembedUrl = `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(vimeoUrl)}`
|
||||
|
||||
const response = await fetch(oembedUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Vimeo oEmbed API returned ${response.status}`)
|
||||
}
|
||||
|
||||
const data: VimeoOEmbedResponse = await response.json()
|
||||
|
||||
return {
|
||||
title: data.title || '',
|
||||
description: data.description || ''
|
||||
}
|
||||
}
|
||||
|
||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
const url = (req.query.url as string | undefined)?.trim()
|
||||
const videoId = (req.query.videoId as string | undefined)?.trim()
|
||||
|
||||
if (!url && !videoId) {
|
||||
return bad(res, 400, 'Missing url or videoId parameter')
|
||||
}
|
||||
|
||||
// Extract video info from URL or use provided videoId
|
||||
let videoInfo: { id: string; source: 'youtube' | 'vimeo' }
|
||||
|
||||
if (url) {
|
||||
const extracted = extractVideoId(url)
|
||||
if (!extracted) {
|
||||
return bad(res, 400, 'Unsupported video URL. Only YouTube and Vimeo are supported.')
|
||||
}
|
||||
videoInfo = extracted
|
||||
} else {
|
||||
// If only videoId is provided, assume YouTube for backward compatibility
|
||||
videoInfo = { id: videoId!, source: 'youtube' }
|
||||
}
|
||||
|
||||
const lang = ((req.query.lang as string | undefined) || 'en').toLowerCase()
|
||||
const uiLocale = (req.headers['x-ui-locale'] as string | undefined)?.toLowerCase()
|
||||
const preferAuto = req.query.preferAuto === 'true'
|
||||
|
||||
const cacheKey = buildKey(videoInfo.id, lang, preferAuto ? 'auto' : undefined, videoInfo.source)
|
||||
const now = Date.now()
|
||||
const cached = memoryCache.get(cacheKey)
|
||||
if (cached && cached.expires > now) {
|
||||
return ok(res, cached.body)
|
||||
}
|
||||
|
||||
try {
|
||||
if (videoInfo.source === 'youtube') {
|
||||
// YouTube handling
|
||||
const details: unknown = await getVideoDetails({ videoID: videoInfo.id, lang })
|
||||
// Be tolerant to possible shapes returned by the extractor
|
||||
const title = (details as { title?: string } | undefined)?.title || ''
|
||||
const d1 = (details as { description?: string } | undefined)?.description
|
||||
const d2 = (details as { shortDescription?: string } | undefined)?.shortDescription
|
||||
const d3 = (details as { descriptionText?: string } | undefined)?.descriptionText
|
||||
const description = d1 || d2 || d3 || ''
|
||||
|
||||
// Language order: manual en -> uiLocale -> lang -> any manual, then auto with same order
|
||||
const langs: string[] = Array.from(new Set(['en', uiLocale, lang].filter(Boolean) as string[]))
|
||||
|
||||
let selected = null as null | { caps: Caption[]; lang: string; isAuto: boolean }
|
||||
// Manual first
|
||||
selected = await pickCaptions(videoInfo.id, langs, true)
|
||||
if (!selected) {
|
||||
// Try auto
|
||||
selected = await pickCaptions(videoInfo.id, langs, false)
|
||||
}
|
||||
|
||||
const captions = selected?.caps || []
|
||||
const transcript = captions.map(c => c.text).join(' ').trim()
|
||||
const response = {
|
||||
title,
|
||||
description,
|
||||
captions,
|
||||
transcript,
|
||||
lang: selected?.lang || lang,
|
||||
isAuto: selected?.isAuto || false,
|
||||
source: 'youtube'
|
||||
}
|
||||
|
||||
memoryCache.set(cacheKey, { body: response, expires: now + WEEK_MS })
|
||||
return ok(res, response)
|
||||
} else if (videoInfo.source === 'vimeo') {
|
||||
// Vimeo handling
|
||||
const { title, description } = await getVimeoMetadata(videoInfo.id)
|
||||
|
||||
const response = {
|
||||
title,
|
||||
description,
|
||||
captions: [], // Vimeo doesn't provide captions through oEmbed API
|
||||
transcript: '', // No transcript available
|
||||
lang: 'en', // Default language
|
||||
isAuto: false, // Not applicable for Vimeo
|
||||
source: 'vimeo'
|
||||
}
|
||||
|
||||
memoryCache.set(cacheKey, { body: response, expires: now + WEEK_MS })
|
||||
return ok(res, response)
|
||||
} else {
|
||||
return bad(res, 400, 'Unsupported video source')
|
||||
}
|
||||
} catch (e) {
|
||||
return bad(res, 500, `Failed to fetch ${videoInfo.source} metadata`)
|
||||
}
|
||||
}
|
||||
93
api/vimeo-meta.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
||||
|
||||
type CacheEntry = {
|
||||
body: unknown
|
||||
expires: number
|
||||
}
|
||||
|
||||
type VimeoOEmbedResponse = {
|
||||
title: string
|
||||
description: string
|
||||
author_name: string
|
||||
author_url: string
|
||||
provider_name: string
|
||||
provider_url: string
|
||||
type: string
|
||||
version: string
|
||||
width: number
|
||||
height: number
|
||||
html: string
|
||||
thumbnail_url: string
|
||||
thumbnail_width: number
|
||||
thumbnail_height: number
|
||||
}
|
||||
|
||||
// In-memory cache for 7 days
|
||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
||||
const memoryCache = new Map<string, CacheEntry>()
|
||||
|
||||
function buildKey(videoId: string) {
|
||||
return `vimeo|${videoId}`
|
||||
}
|
||||
|
||||
function ok(res: VercelResponse, data: unknown) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400, s-maxage=604800') // client: 1d, CDN: 7d
|
||||
return res.status(200).json(data)
|
||||
}
|
||||
|
||||
function bad(res: VercelResponse, code: number, message: string) {
|
||||
return res.status(code).json({ error: message })
|
||||
}
|
||||
|
||||
async function getVimeoMetadata(videoId: string): Promise<{ title: string; description: string }> {
|
||||
const vimeoUrl = `https://vimeo.com/${videoId}`
|
||||
const oembedUrl = `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(vimeoUrl)}`
|
||||
|
||||
const response = await fetch(oembedUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Vimeo oEmbed API returned ${response.status}`)
|
||||
}
|
||||
|
||||
const data: VimeoOEmbedResponse = await response.json()
|
||||
|
||||
return {
|
||||
title: data.title || '',
|
||||
description: data.description || ''
|
||||
}
|
||||
}
|
||||
|
||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
const videoId = (req.query.videoId as string | undefined)?.trim()
|
||||
if (!videoId) return bad(res, 400, 'Missing videoId')
|
||||
|
||||
// Validate that videoId is a number
|
||||
if (!/^\d+$/.test(videoId)) {
|
||||
return bad(res, 400, 'Invalid Vimeo video ID - must be numeric')
|
||||
}
|
||||
|
||||
const cacheKey = buildKey(videoId)
|
||||
const now = Date.now()
|
||||
const cached = memoryCache.get(cacheKey)
|
||||
if (cached && cached.expires > now) {
|
||||
return ok(res, cached.body)
|
||||
}
|
||||
|
||||
try {
|
||||
const { title, description } = await getVimeoMetadata(videoId)
|
||||
|
||||
const response = {
|
||||
title,
|
||||
description,
|
||||
captions: [], // Vimeo doesn't provide captions through oEmbed API
|
||||
transcript: '', // No transcript available
|
||||
lang: 'en', // Default language
|
||||
isAuto: false, // Not applicable for Vimeo
|
||||
source: 'vimeo'
|
||||
}
|
||||
|
||||
memoryCache.set(cacheKey, { body: response, expires: now + WEEK_MS })
|
||||
return ok(res, response)
|
||||
} catch (e) {
|
||||
return bad(res, 500, 'Failed to fetch Vimeo metadata')
|
||||
}
|
||||
}
|
||||
101
api/youtube-meta.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { VercelRequest, VercelResponse } from '@vercel/node'
|
||||
import { getSubtitles } from '@treeee/youtube-caption-extractor'
|
||||
|
||||
type Caption = { start: number; dur: number; text: string }
|
||||
|
||||
type Subtitle = { start: string | number; dur: string | number; text: string }
|
||||
|
||||
type CacheEntry = {
|
||||
body: unknown
|
||||
expires: number
|
||||
}
|
||||
|
||||
// In-memory cache for 7 days
|
||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
|
||||
const memoryCache = new Map<string, CacheEntry>()
|
||||
|
||||
function buildKey(videoId: string, lang: string, preferAuto?: string | string[]) {
|
||||
return `${videoId}|${lang}|${preferAuto ? 'auto' : 'manual'}`
|
||||
}
|
||||
|
||||
function ok(res: VercelResponse, data: unknown) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400, s-maxage=604800') // client: 1d, CDN: 7d
|
||||
return res.status(200).json(data)
|
||||
}
|
||||
|
||||
function bad(res: VercelResponse, code: number, message: string) {
|
||||
return res.status(code).json({ error: message })
|
||||
}
|
||||
|
||||
async function pickCaptions(videoID: string, preferredLangs: string[], manualFirst: boolean): Promise<{ caps: Caption[]; lang: string; isAuto: boolean } | null> {
|
||||
for (const lang of preferredLangs) {
|
||||
try {
|
||||
const caps = await getSubtitles({ videoID, lang })
|
||||
if (Array.isArray(caps) && caps.length > 0) {
|
||||
// Convert the returned subtitles to our Caption format
|
||||
const convertedCaps: Caption[] = caps.map((cap: Subtitle) => ({
|
||||
start: typeof cap.start === 'string' ? parseFloat(cap.start) : cap.start,
|
||||
dur: typeof cap.dur === 'string' ? parseFloat(cap.dur) : cap.dur,
|
||||
text: cap.text
|
||||
}))
|
||||
return { caps: convertedCaps, lang, isAuto: !manualFirst }
|
||||
}
|
||||
} catch {
|
||||
// try next
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
const videoId = (req.query.videoId as string | undefined)?.trim()
|
||||
if (!videoId) return bad(res, 400, 'Missing videoId')
|
||||
|
||||
const lang = ((req.query.lang as string | undefined) || 'en').toLowerCase()
|
||||
const uiLocale = (req.headers['x-ui-locale'] as string | undefined)?.toLowerCase()
|
||||
const preferAuto = req.query.preferAuto === 'true'
|
||||
|
||||
const cacheKey = buildKey(videoId, lang, preferAuto ? 'auto' : undefined)
|
||||
const now = Date.now()
|
||||
const cached = memoryCache.get(cacheKey)
|
||||
if (cached && cached.expires > now) {
|
||||
return ok(res, cached.body)
|
||||
}
|
||||
|
||||
try {
|
||||
// Since getVideoDetails doesn't exist, we'll use a simple approach
|
||||
// In a real implementation, you might want to use YouTube's API or other methods
|
||||
const title = '' // Will be populated from captions or other sources
|
||||
const description = ''
|
||||
|
||||
// Language order: manual en -> uiLocale -> lang -> any manual, then auto with same order
|
||||
const langs: string[] = Array.from(new Set(['en', uiLocale, lang].filter(Boolean) as string[]))
|
||||
|
||||
let selected = null as null | { caps: Caption[]; lang: string; isAuto: boolean }
|
||||
// Manual first
|
||||
selected = await pickCaptions(videoId, langs, true)
|
||||
if (!selected) {
|
||||
// Try auto
|
||||
selected = await pickCaptions(videoId, langs, false)
|
||||
}
|
||||
|
||||
const captions = selected?.caps || []
|
||||
const transcript = captions.map(c => c.text).join(' ').trim()
|
||||
const response = {
|
||||
title,
|
||||
description,
|
||||
captions,
|
||||
transcript,
|
||||
lang: selected?.lang || lang,
|
||||
isAuto: selected?.isAuto || false,
|
||||
source: 'youtube'
|
||||
}
|
||||
|
||||
memoryCache.set(cacheKey, { body: response, expires: now + WEEK_MS })
|
||||
return ok(res, response)
|
||||
} catch (e) {
|
||||
return bad(res, 500, 'Failed to fetch YouTube metadata')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<title>Boris - Nostr Bookmarks</title>
|
||||
<meta name="description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||
<link rel="canonical" href="https://read.withboris.com/" />
|
||||
|
||||
6913
package-lock.json
generated
20
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.3.8",
|
||||
"version": "0.6.0",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
@@ -14,6 +14,8 @@
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||
"@fortawesome/react-fontawesome": "^3.0.2",
|
||||
"@treeee/youtube-caption-extractor": "^1.5.5",
|
||||
"@vercel/node": "^5.3.26",
|
||||
"applesauce-accounts": "^4.0.0",
|
||||
"applesauce-content": "^4.0.0",
|
||||
"applesauce-core": "^4.0.0",
|
||||
@@ -23,24 +25,34 @@
|
||||
"applesauce-relay": "^4.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"nostr-tools": "^2.4.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-player": "^2.16.0",
|
||||
"react-router-dom": "^7.9.3",
|
||||
"reading-time-estimator": "^1.14.0",
|
||||
"rehype-prism-plus": "^2.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.14",
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||
"@typescript-eslint/parser": "^6.14.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.8"
|
||||
"vite": "^5.0.8",
|
||||
"vite-plugin-pwa": "^1.0.3",
|
||||
"workbox-window": "^7.3.0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
@@ -59,7 +71,8 @@
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": [
|
||||
"@typescript-eslint",
|
||||
"react-refresh"
|
||||
"react-refresh",
|
||||
"react-hooks"
|
||||
],
|
||||
"rules": {
|
||||
"react-refresh/only-export-components": [
|
||||
@@ -68,6 +81,7 @@
|
||||
"allowConstantExport": true
|
||||
}
|
||||
],
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
|
||||
7
postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 465 B |
BIN
public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/icon-192.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/icon-512.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
public/icon-maskable-192.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/icon-maskable-512.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
37
public/manifest.webmanifest
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "Boris - Nostr Bookmarks",
|
||||
"short_name": "Boris",
|
||||
"description": "Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"theme_color": "#0f172a",
|
||||
"background_color": "#0b1220",
|
||||
"orientation": "any",
|
||||
"categories": ["productivity", "social", "utilities"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
56
public/sw.js
@@ -1,56 +0,0 @@
|
||||
// Service Worker for Boris - handles offline image caching
|
||||
const CACHE_NAME = 'boris-image-cache-v1'
|
||||
|
||||
// Install event - activate immediately
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('[SW] Installing service worker...')
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
// Activate event - take control immediately
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('[SW] Activating service worker...')
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
// Fetch event - intercept image requests
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url)
|
||||
|
||||
// Only intercept image requests
|
||||
const isImage = event.request.destination === 'image' ||
|
||||
/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname)
|
||||
|
||||
if (!isImage) {
|
||||
return // Let other requests pass through
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
caches.open(CACHE_NAME).then(cache => {
|
||||
return cache.match(event.request).then(cachedResponse => {
|
||||
if (cachedResponse) {
|
||||
console.log('[SW] Serving cached image:', url.pathname)
|
||||
return cachedResponse
|
||||
}
|
||||
|
||||
// Not in cache, try to fetch
|
||||
return fetch(event.request)
|
||||
.then(response => {
|
||||
// Only cache successful responses
|
||||
if (response && response.status === 200) {
|
||||
// Clone the response before caching
|
||||
cache.put(event.request, response.clone())
|
||||
console.log('[SW] Cached new image:', url.pathname)
|
||||
}
|
||||
return response
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('[SW] Fetch failed for:', url.pathname, error)
|
||||
// Return a fallback or let it fail
|
||||
throw error
|
||||
})
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
54
src/App.tsx
@@ -11,6 +11,7 @@ import { createAddressLoader } from 'applesauce-loaders/loaders'
|
||||
import Bookmarks from './components/Bookmarks'
|
||||
import Toast from './components/Toast'
|
||||
import { useToast } from './hooks/useToast'
|
||||
import { useOnlineStatus } from './hooks/useOnlineStatus'
|
||||
import { RELAYS } from './config/relays'
|
||||
|
||||
const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR ||
|
||||
@@ -69,6 +70,37 @@ function AppRoutes({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me"
|
||||
element={<Navigate to="/me/highlights" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/me/highlights"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/reading-list"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/me/archive"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
|
||||
</Routes>
|
||||
)
|
||||
@@ -79,6 +111,7 @@ function App() {
|
||||
const [accountManager, setAccountManager] = useState<AccountManager | null>(null)
|
||||
const [relayPool, setRelayPool] = useState<RelayPool | null>(null)
|
||||
const { toastMessage, toastType, showToast, clearToast } = useToast()
|
||||
const isOnline = useOnlineStatus()
|
||||
|
||||
useEffect(() => {
|
||||
const initializeApp = async () => {
|
||||
@@ -174,6 +207,25 @@ function App() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Monitor online/offline status
|
||||
useEffect(() => {
|
||||
if (!isOnline) {
|
||||
showToast('You are offline. Some features may be limited.')
|
||||
}
|
||||
}, [isOnline, showToast])
|
||||
|
||||
// Listen for service worker updates
|
||||
useEffect(() => {
|
||||
const handleSWUpdate = () => {
|
||||
showToast('New version available! Refresh to update.')
|
||||
}
|
||||
|
||||
window.addEventListener('sw-update-available', handleSWUpdate)
|
||||
return () => {
|
||||
window.removeEventListener('sw-update-available', handleSWUpdate)
|
||||
}
|
||||
}, [showToast])
|
||||
|
||||
if (!eventStore || !accountManager || !relayPool) {
|
||||
return (
|
||||
<div className="loading">
|
||||
@@ -186,7 +238,7 @@ function App() {
|
||||
<EventStoreProvider eventStore={eventStore}>
|
||||
<AccountsProvider manager={accountManager}>
|
||||
<BrowserRouter>
|
||||
<div className="app">
|
||||
<div className="min-h-screen p-0 md:p-4 max-w-none m-0 relative">
|
||||
<AppRoutes relayPool={relayPool} showToast={showToast} />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -139,7 +139,8 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
|
||||
clearTimeout(fetchTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [url]) // Only depend on url
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [url]) // Only depend on url - title, description, tagsInput are intentionally checked but not dependencies
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
43
src/components/AuthorCard.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faUserCircle } from '@fortawesome/free-solid-svg-icons'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
|
||||
interface AuthorCardProps {
|
||||
authorPubkey: string
|
||||
}
|
||||
|
||||
const AuthorCard: React.FC<AuthorCardProps> = ({ authorPubkey }) => {
|
||||
const profile = useEventModel(Models.ProfileModel, [authorPubkey])
|
||||
|
||||
const getAuthorName = () => {
|
||||
if (profile?.name) return profile.name
|
||||
if (profile?.display_name) return profile.display_name
|
||||
return `${authorPubkey.slice(0, 8)}...${authorPubkey.slice(-8)}`
|
||||
}
|
||||
|
||||
const authorImage = profile?.picture || profile?.image
|
||||
const authorBio = profile?.about
|
||||
|
||||
return (
|
||||
<div className="author-card">
|
||||
<div className="author-card-avatar">
|
||||
{authorImage ? (
|
||||
<img src={authorImage} alt={getAuthorName()} />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faUserCircle} />
|
||||
)}
|
||||
</div>
|
||||
<div className="author-card-content">
|
||||
<div className="author-card-name">{getAuthorName()}</div>
|
||||
{authorBio && (
|
||||
<p className="author-card-bio">{authorBio}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AuthorCard
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faCalendar, faUser } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faCalendar, faUser, faNewspaper } from '@fortawesome/free-solid-svg-icons'
|
||||
import { formatDistance } from 'date-fns'
|
||||
import { BlogPostPreview } from '../services/exploreService'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
@@ -28,15 +28,19 @@ const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href }) => {
|
||||
className="blog-post-card"
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
{post.image && (
|
||||
<div className="blog-post-card-image">
|
||||
<div className="blog-post-card-image">
|
||||
{post.image ? (
|
||||
<img
|
||||
src={post.image}
|
||||
alt={post.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<div className="blog-post-image-placeholder">
|
||||
<FontAwesomeIcon icon={faNewspaper} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="blog-post-card-content">
|
||||
<h3 className="blog-post-card-title">{post.title}</h3>
|
||||
{post.summary && (
|
||||
|
||||
@@ -110,8 +110,6 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
hasUrls,
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
getIconForUrlType,
|
||||
firstUrlClassification,
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
@@ -127,8 +125,8 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
|
||||
if (viewMode === 'large') {
|
||||
const previewImage = articleImage || instantPreview || ogImage
|
||||
return <LargeView {...sharedProps} previewImage={previewImage} />
|
||||
return <LargeView {...sharedProps} getIconForUrlType={getIconForUrlType} previewImage={previewImage} />
|
||||
}
|
||||
|
||||
return <CardView {...sharedProps} articleImage={articleImage} />
|
||||
return <CardView {...sharedProps} getIconForUrlType={getIconForUrlType} articleImage={articleImage} />
|
||||
}
|
||||
|
||||
@@ -111,15 +111,18 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<div className="loading">
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
</div>
|
||||
) : allIndividualBookmarks.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No bookmarks found.</p>
|
||||
<p>Add bookmarks using your nostr client to see them here.</p>
|
||||
</div>
|
||||
{allIndividualBookmarks.length === 0 ? (
|
||||
loading ? (
|
||||
<div className="loading">
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state">
|
||||
<p>No bookmarks found.</p>
|
||||
<p>Add bookmarks using your nostr client to see them here.</p>
|
||||
<p>If you aren't on nostr yet, start here: <a href="https://nstart.me/" target="_blank" rel="noopener noreferrer">nstart.me</a></p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="bookmarks-list">
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
|
||||
@@ -8,7 +8,9 @@ import IconButton from '../IconButton'
|
||||
import { classifyUrl } from '../../utils/helpers'
|
||||
import { IconGetter } from './shared'
|
||||
import { useImageCache } from '../../hooks/useImageCache'
|
||||
import { getPreviewImage, fetchOgImage } from '../../utils/imagePreview'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
import { getProfileUrl, getEventUrl } from '../../config/nostrGateways'
|
||||
|
||||
interface CardViewProps {
|
||||
bookmark: IndividualBookmark
|
||||
@@ -17,7 +19,6 @@ interface CardViewProps {
|
||||
extractedUrls: string[]
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
getIconForUrlType: IconGetter
|
||||
firstUrlClassification: { buttonText: string } | null
|
||||
authorNpub: string
|
||||
eventNevent?: string
|
||||
getAuthorDisplayName: () => string
|
||||
@@ -34,7 +35,6 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
getIconForUrlType,
|
||||
firstUrlClassification,
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
@@ -43,17 +43,49 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
articleSummary,
|
||||
settings
|
||||
}) => {
|
||||
const cachedImage = useImageCache(articleImage, settings)
|
||||
const firstUrl = hasUrls ? extractedUrls[0] : null
|
||||
const firstUrlClassificationType = firstUrl ? classifyUrl(firstUrl)?.type : null
|
||||
const instantPreview = firstUrl ? getPreviewImage(firstUrl, firstUrlClassificationType || '') : null
|
||||
|
||||
const [ogImage, setOgImage] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [urlsExpanded, setUrlsExpanded] = useState(false)
|
||||
|
||||
const contentLength = (bookmark.content || '').length
|
||||
const shouldTruncate = !expanded && contentLength > 210
|
||||
const isArticle = bookmark.kind === 30023
|
||||
const isWebBookmark = bookmark.kind === 39701
|
||||
|
||||
// Determine which image to use (article image, instant preview, or OG image)
|
||||
const previewImage = articleImage || instantPreview || ogImage
|
||||
const cachedImage = useImageCache(previewImage || undefined, settings)
|
||||
|
||||
// Fetch OG image if we don't have any other image
|
||||
React.useEffect(() => {
|
||||
if (firstUrl && !articleImage && !instantPreview && !ogImage) {
|
||||
fetchOgImage(firstUrl).then(setOgImage)
|
||||
}
|
||||
}, [firstUrl, articleImage, instantPreview, ogImage])
|
||||
|
||||
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
||||
|
||||
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
triggerOpen()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
||||
{isArticle && cachedImage && (
|
||||
<div
|
||||
key={`${bookmark.id}-${index}`}
|
||||
className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}
|
||||
onClick={triggerOpen}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{cachedImage && (
|
||||
<div
|
||||
className="article-hero-image"
|
||||
style={{ backgroundImage: `url(${cachedImage})` }}
|
||||
@@ -79,11 +111,12 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
|
||||
{eventNevent ? (
|
||||
<a
|
||||
href={`https://search.dergigi.com/e/${eventNevent}`}
|
||||
href={getEventUrl(eventNevent)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="bookmark-date-link"
|
||||
title="Open event in search"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{formatDate(bookmark.created_at)}
|
||||
</a>
|
||||
@@ -95,23 +128,22 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
{extractedUrls.length > 0 && (
|
||||
<div className="bookmark-urls">
|
||||
{(urlsExpanded ? extractedUrls : extractedUrls.slice(0, 1)).map((url, urlIndex) => {
|
||||
const classification = classifyUrl(url)
|
||||
return (
|
||||
<div key={urlIndex} className="url-row">
|
||||
<button
|
||||
className="bookmark-url"
|
||||
onClick={() => onSelectUrl?.(url)}
|
||||
onClick={(e) => { e.stopPropagation(); onSelectUrl?.(url) }}
|
||||
title="Open in reader"
|
||||
>
|
||||
{url}
|
||||
</button>
|
||||
<IconButton
|
||||
icon={getIconForUrlType(url)}
|
||||
ariaLabel={classification.buttonText}
|
||||
title={classification.buttonText}
|
||||
ariaLabel="Open"
|
||||
title="Open"
|
||||
variant="success"
|
||||
size={32}
|
||||
onClick={(e) => { e.preventDefault(); onSelectUrl?.(url) }}
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); onSelectUrl?.(url) }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -119,7 +151,7 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
{extractedUrls.length > 1 && (
|
||||
<button
|
||||
className="expand-toggle-urls"
|
||||
onClick={() => setUrlsExpanded(v => !v)}
|
||||
onClick={(e) => { e.stopPropagation(); setUrlsExpanded(v => !v) }}
|
||||
aria-label={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
|
||||
title={urlsExpanded ? 'Collapse URLs' : 'Expand URLs'}
|
||||
>
|
||||
@@ -148,7 +180,7 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
{contentLength > 210 && (
|
||||
<button
|
||||
className="expand-toggle"
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
onClick={(e) => { e.stopPropagation(); setExpanded(v => !v) }}
|
||||
aria-label={expanded ? 'Collapse' : 'Expand'}
|
||||
title={expanded ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
@@ -159,20 +191,17 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
<div className="bookmark-footer">
|
||||
<div className="bookmark-meta-minimal">
|
||||
<a
|
||||
href={`https://search.dergigi.com/p/${authorNpub}`}
|
||||
href={getProfileUrl(authorNpub)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="author-link-minimal"
|
||||
title="Open author in search"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{getAuthorDisplayName()}
|
||||
</a>
|
||||
</div>
|
||||
{(hasUrls && firstUrlClassification) || bookmark.kind === 30023 ? (
|
||||
<button className="read-now-button-minimal" onClick={handleReadNow}>
|
||||
{bookmark.kind === 30023 ? 'Read Article' : firstUrlClassification?.buttonText}
|
||||
</button>
|
||||
) : null}
|
||||
{/* CTA removed */}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,7 +4,8 @@ import { faBookmark, faUserLock, faGlobe } from '@fortawesome/free-solid-svg-ico
|
||||
import { IndividualBookmark } from '../../types/bookmarks'
|
||||
import { formatDateCompact } from '../../utils/bookmarkUtils'
|
||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||
import { IconGetter } from './shared'
|
||||
import { useImageCache } from '../../hooks/useImageCache'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
|
||||
interface CompactViewProps {
|
||||
bookmark: IndividualBookmark
|
||||
@@ -12,10 +13,9 @@ interface CompactViewProps {
|
||||
hasUrls: boolean
|
||||
extractedUrls: string[]
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
getIconForUrlType: IconGetter
|
||||
firstUrlClassification: { buttonText: string } | null
|
||||
articleImage?: string
|
||||
articleSummary?: string
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
export const CompactView: React.FC<CompactViewProps> = ({
|
||||
@@ -24,14 +24,17 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
hasUrls,
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
getIconForUrlType,
|
||||
firstUrlClassification,
|
||||
articleSummary
|
||||
articleImage,
|
||||
articleSummary,
|
||||
settings
|
||||
}) => {
|
||||
const isArticle = bookmark.kind === 30023
|
||||
const isWebBookmark = bookmark.kind === 39701
|
||||
const isClickable = hasUrls || isArticle || isWebBookmark
|
||||
|
||||
// Get cached image for thumbnail
|
||||
const cachedImage = useImageCache(articleImage || undefined, settings)
|
||||
|
||||
const handleCompactClick = () => {
|
||||
if (!onSelectUrl) return
|
||||
|
||||
@@ -55,6 +58,13 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
role={isClickable ? 'button' : undefined}
|
||||
tabIndex={isClickable ? 0 : undefined}
|
||||
>
|
||||
{/* Thumbnail image */}
|
||||
{cachedImage && (
|
||||
<div className="compact-thumbnail">
|
||||
<img src={cachedImage} alt="" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="bookmark-type-compact">
|
||||
{isWebBookmark ? (
|
||||
<span className="fa-layers fa-fw">
|
||||
@@ -76,22 +86,7 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
</div>
|
||||
)}
|
||||
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>
|
||||
{isClickable && (
|
||||
<button
|
||||
className="compact-read-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (isArticle) {
|
||||
onSelectUrl?.('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
|
||||
} else {
|
||||
onSelectUrl?.(extractedUrls[0])
|
||||
}
|
||||
}}
|
||||
title={isArticle ? 'Read Article' : firstUrlClassification?.buttonText}
|
||||
>
|
||||
<FontAwesomeIcon icon={isArticle ? getIconForUrlType('') : getIconForUrlType(extractedUrls[0])} />
|
||||
</button>
|
||||
)}
|
||||
{/* CTA removed */}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||
import { IconGetter } from './shared'
|
||||
import { useImageCache } from '../../hooks/useImageCache'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
import { getProfileUrl, getEventUrl } from '../../config/nostrGateways'
|
||||
|
||||
interface LargeViewProps {
|
||||
bookmark: IndividualBookmark
|
||||
@@ -14,7 +15,6 @@ interface LargeViewProps {
|
||||
extractedUrls: string[]
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
getIconForUrlType: IconGetter
|
||||
firstUrlClassification: { buttonText: string } | null
|
||||
previewImage: string | null
|
||||
authorNpub: string
|
||||
eventNevent?: string
|
||||
@@ -31,7 +31,6 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
extractedUrls,
|
||||
onSelectUrl,
|
||||
getIconForUrlType,
|
||||
firstUrlClassification,
|
||||
previewImage,
|
||||
authorNpub,
|
||||
eventNevent,
|
||||
@@ -43,12 +42,28 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
const cachedImage = useImageCache(previewImage || undefined, settings)
|
||||
const isArticle = bookmark.kind === 30023
|
||||
|
||||
const triggerOpen = () => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
||||
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
triggerOpen()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark large ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
||||
<div
|
||||
key={`${bookmark.id}-${index}`}
|
||||
className={`individual-bookmark large ${bookmark.isPrivate ? 'private-bookmark' : ''}`}
|
||||
onClick={triggerOpen}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{(hasUrls || (isArticle && cachedImage)) && (
|
||||
<div
|
||||
className="large-preview-image"
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (isArticle) {
|
||||
handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)
|
||||
} else {
|
||||
@@ -79,10 +94,11 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
<div className="large-footer">
|
||||
<span className="large-author">
|
||||
<a
|
||||
href={`https://search.dergigi.com/p/${authorNpub}`}
|
||||
href={getProfileUrl(authorNpub)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="author-link-minimal"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{getAuthorDisplayName()}
|
||||
</a>
|
||||
@@ -90,21 +106,17 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
|
||||
{eventNevent && (
|
||||
<a
|
||||
href={`https://search.dergigi.com/e/${eventNevent}`}
|
||||
href={getEventUrl(eventNevent)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="bookmark-date-link"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{formatDate(bookmark.created_at)}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{(hasUrls && firstUrlClassification) || isArticle ? (
|
||||
<button className="large-read-button" onClick={handleReadNow}>
|
||||
<FontAwesomeIcon icon={isArticle ? getIconForUrlType('') : getIconForUrlType(extractedUrls[0])} />
|
||||
{isArticle ? 'Read Article' : firstUrlClassification?.buttonText}
|
||||
</button>
|
||||
) : null}
|
||||
{/* CTA removed */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||
import { useOfflineSync } from '../hooks/useOfflineSync'
|
||||
import ThreePaneLayout from './ThreePaneLayout'
|
||||
import Explore from './Explore'
|
||||
import Me from './Me'
|
||||
import { classifyHighlights } from '../utils/highlightClassification'
|
||||
|
||||
export type ViewMode = 'compact' | 'cards' | 'large'
|
||||
@@ -35,13 +36,20 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
|
||||
const showSettings = location.pathname === '/settings'
|
||||
const showExplore = location.pathname === '/explore'
|
||||
const showMe = location.pathname.startsWith('/me')
|
||||
|
||||
// Track previous location for going back from settings
|
||||
// Extract tab from me routes
|
||||
const meTab = location.pathname === '/me' ? 'highlights' :
|
||||
location.pathname === '/me/highlights' ? 'highlights' :
|
||||
location.pathname === '/me/reading-list' ? 'reading-list' :
|
||||
location.pathname === '/me/archive' ? 'archive' : 'highlights'
|
||||
|
||||
// Track previous location for going back from settings/me/explore
|
||||
useEffect(() => {
|
||||
if (!showSettings) {
|
||||
if (!showSettings && !showMe && !showExplore) {
|
||||
previousLocationRef.current = location.pathname
|
||||
}
|
||||
}, [location.pathname, showSettings])
|
||||
}, [location.pathname, showSettings, showMe, showExplore])
|
||||
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
@@ -90,6 +98,14 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
setHighlightVisibility
|
||||
} = useBookmarksUI({ settings })
|
||||
|
||||
// Close sidebar on mobile when route changes (e.g., clicking on blog posts in Explore)
|
||||
useEffect(() => {
|
||||
if (isMobile && isSidebarOpen) {
|
||||
toggleSidebar()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.pathname])
|
||||
|
||||
const {
|
||||
bookmarks,
|
||||
bookmarksLoading,
|
||||
@@ -194,6 +210,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
isSidebarOpen={isSidebarOpen}
|
||||
showSettings={showSettings}
|
||||
showExplore={showExplore}
|
||||
showMe={showMe}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
viewMode={viewMode}
|
||||
@@ -236,6 +253,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
onClearSelection={handleClearSelection}
|
||||
currentUserPubkey={activeAccount?.pubkey}
|
||||
followedPubkeys={followedPubkeys}
|
||||
activeAccount={activeAccount}
|
||||
currentArticle={currentArticle}
|
||||
highlights={highlights}
|
||||
highlightsLoading={highlightsLoading}
|
||||
onToggleHighlightsPanel={() => setIsHighlightsCollapsed(!isHighlightsCollapsed)}
|
||||
@@ -249,6 +268,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
explore={showExplore ? (
|
||||
relayPool ? <Explore relayPool={relayPool} /> : null
|
||||
) : undefined}
|
||||
me={showMe ? (
|
||||
relayPool ? <Me relayPool={relayPool} activeTab={meTab} /> : null
|
||||
) : undefined}
|
||||
toastMessage={toastMessage ?? undefined}
|
||||
toastType={toastType}
|
||||
onClearToast={clearToast}
|
||||
|
||||
41
src/components/CompactButton.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import type { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||
|
||||
interface CompactButtonProps {
|
||||
icon?: IconDefinition
|
||||
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
title?: string
|
||||
ariaLabel?: string
|
||||
disabled?: boolean
|
||||
spin?: boolean
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const CompactButton: React.FC<CompactButtonProps> = ({
|
||||
icon,
|
||||
onClick,
|
||||
title,
|
||||
ariaLabel,
|
||||
disabled = false,
|
||||
spin = false,
|
||||
className = '',
|
||||
children
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
className={`compact-button ${className}`.trim()}
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-label={ariaLabel || title}
|
||||
disabled={disabled}
|
||||
>
|
||||
{icon && <FontAwesomeIcon icon={icon} spin={spin} />}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default CompactButton
|
||||
|
||||
56
src/components/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean
|
||||
title: string
|
||||
message: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
variant?: 'danger' | 'warning' | 'info'
|
||||
}
|
||||
|
||||
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
confirmText = 'Confirm',
|
||||
cancelText = 'Cancel',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
variant = 'warning'
|
||||
}) => {
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="confirm-dialog-overlay" onClick={onCancel}>
|
||||
<div className="confirm-dialog" onClick={(e) => e.stopPropagation()}>
|
||||
<div className={`confirm-dialog-icon ${variant}`}>
|
||||
<FontAwesomeIcon icon={faExclamationTriangle} />
|
||||
</div>
|
||||
<h3 className="confirm-dialog-title">{title}</h3>
|
||||
<p className="confirm-dialog-message">{message}</p>
|
||||
<div className="confirm-dialog-actions">
|
||||
<button
|
||||
className="confirm-dialog-btn cancel"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
className={`confirm-dialog-btn confirm ${variant}`}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ConfirmDialog
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import React, { useMemo, useState, useEffect, useRef } from 'react'
|
||||
import ReactPlayer from 'react-player'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeRaw from 'rehype-raw'
|
||||
import rehypePrism from 'rehype-prism-plus'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||
import 'prismjs/themes/prism-tomorrow.css'
|
||||
import { faSpinner, faCheckCircle, faEllipsisH, faExternalLinkAlt, faMobileAlt, faCopy, faShare } from '@fortawesome/free-solid-svg-icons'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { getNostrUrl } from '../config/nostrGateways'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { readingTime } from 'reading-time-estimator'
|
||||
import { hexToRgb } from '../utils/colorHelpers'
|
||||
@@ -12,6 +22,19 @@ import { useMarkdownToHTML } from '../hooks/useMarkdownToHTML'
|
||||
import { useHighlightedContent } from '../hooks/useHighlightedContent'
|
||||
import { useHighlightInteractions } from '../hooks/useHighlightInteractions'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import {
|
||||
createEventReaction,
|
||||
createWebsiteReaction,
|
||||
hasMarkedEventAsRead,
|
||||
hasMarkedWebsiteAsRead
|
||||
} from '../services/reactionService'
|
||||
import AuthorCard from './AuthorCard'
|
||||
import { faBooks } from '../icons/customIcons'
|
||||
import { extractYouTubeId, getYouTubeMeta } from '../services/youtubeMetaService'
|
||||
import { classifyUrl } from '../utils/helpers'
|
||||
import { buildNativeVideoUrl } from '../utils/videoHelpers'
|
||||
import { useReadingPosition } from '../hooks/useReadingPosition'
|
||||
import { ReadingProgressIndicator } from './ReadingProgressIndicator'
|
||||
|
||||
interface ContentPanelProps {
|
||||
loading: boolean
|
||||
@@ -32,6 +55,9 @@ interface ContentPanelProps {
|
||||
currentUserPubkey?: string
|
||||
followedPubkeys?: Set<string>
|
||||
settings?: UserSettings
|
||||
relayPool?: RelayPool | null
|
||||
activeAccount?: IAccount | null
|
||||
currentArticle?: NostrEvent | null
|
||||
// For highlight creation
|
||||
onTextSelection?: (text: string) => void
|
||||
onClearSelection?: () => void
|
||||
@@ -44,13 +70,15 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
markdown,
|
||||
selectedUrl,
|
||||
image,
|
||||
summary,
|
||||
published,
|
||||
highlights = [],
|
||||
showHighlights = true,
|
||||
highlightStyle = 'marker',
|
||||
highlightColor = '#ffff00',
|
||||
settings,
|
||||
relayPool,
|
||||
activeAccount,
|
||||
currentArticle,
|
||||
onHighlightClick,
|
||||
selectedHighlightId,
|
||||
highlightVisibility = { nostrverse: true, friends: true, mine: true },
|
||||
@@ -59,7 +87,15 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
onTextSelection,
|
||||
onClearSelection
|
||||
}) => {
|
||||
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef } = useMarkdownToHTML(markdown)
|
||||
const [isMarkedAsRead, setIsMarkedAsRead] = useState(false)
|
||||
const [isCheckingReadStatus, setIsCheckingReadStatus] = useState(false)
|
||||
const [showCheckAnimation, setShowCheckAnimation] = useState(false)
|
||||
const [showArticleMenu, setShowArticleMenu] = useState(false)
|
||||
const [showVideoMenu, setShowVideoMenu] = useState(false)
|
||||
const articleMenuRef = useRef<HTMLDivElement>(null)
|
||||
const videoMenuRef = useRef<HTMLDivElement>(null)
|
||||
const [ytMeta, setYtMeta] = useState<{ title?: string; description?: string; transcript?: string } | null>(null)
|
||||
const { renderedHtml: renderedMarkdownHtml, previewRef: markdownPreviewRef, processedMarkdown } = useMarkdownToHTML(markdown, relayPool)
|
||||
|
||||
const { finalHtml, relevantHighlights } = useHighlightedContent({
|
||||
html,
|
||||
@@ -74,13 +110,45 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
followedPubkeys
|
||||
})
|
||||
|
||||
const { contentRef, handleMouseUp } = useHighlightInteractions({
|
||||
const { contentRef, handleSelectionEnd } = useHighlightInteractions({
|
||||
onHighlightClick,
|
||||
selectedHighlightId,
|
||||
onTextSelection,
|
||||
onClearSelection
|
||||
})
|
||||
|
||||
// Reading position tracking - only for text content, not videos
|
||||
const isTextContent = !loading && !!(markdown || html) && !selectedUrl?.includes('youtube') && !selectedUrl?.includes('vimeo')
|
||||
const { isReadingComplete, progressPercentage } = useReadingPosition({
|
||||
enabled: isTextContent,
|
||||
onReadingComplete: () => {
|
||||
// Optional: Auto-mark as read when reading is complete
|
||||
if (activeAccount && !isMarkedAsRead) {
|
||||
// Could trigger auto-mark as read here if desired
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Close menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node
|
||||
if (articleMenuRef.current && !articleMenuRef.current.contains(target)) {
|
||||
setShowArticleMenu(false)
|
||||
}
|
||||
if (videoMenuRef.current && !videoMenuRef.current.contains(target)) {
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showArticleMenu || showVideoMenu) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}
|
||||
}, [showArticleMenu, showVideoMenu])
|
||||
|
||||
const readingStats = useMemo(() => {
|
||||
const content = markdown || html || ''
|
||||
if (!content) return null
|
||||
@@ -90,6 +158,201 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
|
||||
const hasHighlights = relevantHighlights.length > 0
|
||||
|
||||
// Determine if we're on a nostr-native article (/a/) or external URL (/r/)
|
||||
const isNostrArticle = selectedUrl && selectedUrl.startsWith('nostr:')
|
||||
const isExternalVideo = !isNostrArticle && !!selectedUrl && ['youtube', 'video'].includes(classifyUrl(selectedUrl).type)
|
||||
|
||||
// Track external video duration (in seconds) for display in header
|
||||
const [videoDurationSec, setVideoDurationSec] = useState<number | null>(null)
|
||||
// Load YouTube metadata/captions when applicable
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
if (!selectedUrl) return setYtMeta(null)
|
||||
const id = extractYouTubeId(selectedUrl)
|
||||
if (!id) return setYtMeta(null)
|
||||
const locale = navigator?.language?.split('-')[0] || 'en'
|
||||
const data = await getYouTubeMeta(id, locale)
|
||||
if (data) setYtMeta({ title: data.title, description: data.description, transcript: data.transcript })
|
||||
} catch {
|
||||
setYtMeta(null)
|
||||
}
|
||||
})()
|
||||
}, [selectedUrl])
|
||||
|
||||
const formatDuration = (totalSeconds: number): string => {
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = Math.floor(totalSeconds % 60)
|
||||
const mm = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes)
|
||||
const ss = String(seconds).padStart(2, '0')
|
||||
return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}`
|
||||
}
|
||||
|
||||
|
||||
// Get article links for menu
|
||||
const getArticleLinks = () => {
|
||||
if (!currentArticle) return null
|
||||
|
||||
const dTag = currentArticle.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const relayHints = RELAYS.filter(r =>
|
||||
!r.includes('localhost') && !r.includes('127.0.0.1')
|
||||
).slice(0, 3)
|
||||
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey: currentArticle.pubkey,
|
||||
identifier: dTag,
|
||||
relays: relayHints
|
||||
})
|
||||
|
||||
return {
|
||||
portal: getNostrUrl(naddr),
|
||||
native: `nostr:${naddr}`
|
||||
}
|
||||
}
|
||||
|
||||
const articleLinks = getArticleLinks()
|
||||
|
||||
const handleMenuToggle = () => {
|
||||
setShowArticleMenu(!showArticleMenu)
|
||||
}
|
||||
|
||||
const toggleVideoMenu = () => setShowVideoMenu(v => !v)
|
||||
|
||||
const handleOpenPortal = () => {
|
||||
if (articleLinks) {
|
||||
window.open(articleLinks.portal, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
setShowArticleMenu(false)
|
||||
}
|
||||
|
||||
const handleOpenNative = () => {
|
||||
if (articleLinks) {
|
||||
window.location.href = articleLinks.native
|
||||
}
|
||||
setShowArticleMenu(false)
|
||||
}
|
||||
|
||||
// Video actions
|
||||
const handleOpenVideoExternal = () => {
|
||||
if (selectedUrl) window.open(selectedUrl, '_blank', 'noopener,noreferrer')
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
|
||||
const handleOpenVideoNative = () => {
|
||||
if (!selectedUrl) return
|
||||
const native = buildNativeVideoUrl(selectedUrl)
|
||||
if (native) {
|
||||
window.location.href = native
|
||||
} else {
|
||||
window.location.href = selectedUrl
|
||||
}
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
|
||||
const handleCopyVideoUrl = async () => {
|
||||
try {
|
||||
if (selectedUrl) await navigator.clipboard.writeText(selectedUrl)
|
||||
} catch (e) {
|
||||
console.warn('Clipboard copy failed', e)
|
||||
} finally {
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleShareVideoUrl = async () => {
|
||||
try {
|
||||
if (selectedUrl && (navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
|
||||
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({ title: title || 'Video', url: selectedUrl })
|
||||
} else if (selectedUrl) {
|
||||
await navigator.clipboard.writeText(selectedUrl)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Share failed', e)
|
||||
} finally {
|
||||
setShowVideoMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if article is already marked as read when URL/article changes
|
||||
useEffect(() => {
|
||||
const checkReadStatus = async () => {
|
||||
if (!activeAccount || !relayPool || !selectedUrl) {
|
||||
setIsMarkedAsRead(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsCheckingReadStatus(true)
|
||||
|
||||
try {
|
||||
let hasRead = false
|
||||
if (isNostrArticle && currentArticle) {
|
||||
hasRead = await hasMarkedEventAsRead(
|
||||
currentArticle.id,
|
||||
activeAccount.pubkey,
|
||||
relayPool
|
||||
)
|
||||
} else {
|
||||
hasRead = await hasMarkedWebsiteAsRead(
|
||||
selectedUrl,
|
||||
activeAccount.pubkey,
|
||||
relayPool
|
||||
)
|
||||
}
|
||||
setIsMarkedAsRead(hasRead)
|
||||
} catch (error) {
|
||||
console.error('Failed to check read status:', error)
|
||||
} finally {
|
||||
setIsCheckingReadStatus(false)
|
||||
}
|
||||
}
|
||||
|
||||
checkReadStatus()
|
||||
}, [selectedUrl, currentArticle, activeAccount, relayPool, isNostrArticle])
|
||||
|
||||
const handleMarkAsRead = () => {
|
||||
if (!activeAccount || !relayPool || isMarkedAsRead) {
|
||||
return
|
||||
}
|
||||
|
||||
// Instantly update UI with checkmark animation
|
||||
setIsMarkedAsRead(true)
|
||||
setShowCheckAnimation(true)
|
||||
|
||||
// Reset animation after it completes
|
||||
setTimeout(() => {
|
||||
setShowCheckAnimation(false)
|
||||
}, 600)
|
||||
|
||||
// Fire-and-forget: publish in background without blocking UI
|
||||
;(async () => {
|
||||
try {
|
||||
if (isNostrArticle && currentArticle) {
|
||||
await createEventReaction(
|
||||
currentArticle.id,
|
||||
currentArticle.pubkey,
|
||||
currentArticle.kind,
|
||||
activeAccount,
|
||||
relayPool
|
||||
)
|
||||
console.log('✅ Marked nostr article as read')
|
||||
} else if (selectedUrl) {
|
||||
await createWebsiteReaction(
|
||||
selectedUrl,
|
||||
activeAccount,
|
||||
relayPool
|
||||
)
|
||||
console.log('✅ Marked website as read')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to mark as read:', error)
|
||||
// Revert UI state on error
|
||||
setIsMarkedAsRead(false)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
if (!selectedUrl) {
|
||||
return (
|
||||
<div className="reader empty">
|
||||
@@ -111,56 +374,225 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
const highlightRgb = hexToRgb(highlightColor)
|
||||
|
||||
return (
|
||||
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
|
||||
{/* Hidden markdown preview to convert markdown to HTML */}
|
||||
<>
|
||||
{/* Reading Progress Indicator - Outside reader for fixed positioning */}
|
||||
{isTextContent && (
|
||||
<ReadingProgressIndicator
|
||||
progress={progressPercentage}
|
||||
isComplete={isReadingComplete}
|
||||
showPercentage={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="reader" style={{ '--highlight-rgb': highlightRgb } as React.CSSProperties}>
|
||||
{/* Hidden markdown preview to convert markdown to HTML */}
|
||||
{markdown && (
|
||||
<div ref={markdownPreviewRef} style={{ display: 'none' }}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{markdown}
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, rehypePrism]}
|
||||
components={{
|
||||
img: ({ src, alt, ...props }) => (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
>
|
||||
{processedMarkdown || markdown}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ReaderHeader
|
||||
title={title}
|
||||
title={ytMeta?.title || title}
|
||||
image={image}
|
||||
summary={summary}
|
||||
summary={undefined}
|
||||
published={published}
|
||||
readingTimeText={readingStats ? readingStats.text : null}
|
||||
readingTimeText={isExternalVideo ? (videoDurationSec !== null ? formatDuration(videoDurationSec) : null) : (readingStats ? readingStats.text : null)}
|
||||
hasHighlights={hasHighlights}
|
||||
highlightCount={relevantHighlights.length}
|
||||
settings={settings}
|
||||
highlights={relevantHighlights}
|
||||
highlightVisibility={highlightVisibility}
|
||||
/>
|
||||
{markdown || html ? (
|
||||
markdown ? (
|
||||
renderedMarkdownHtml && finalHtml ? (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="reader-markdown"
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml }}
|
||||
onMouseUp={handleMouseUp}
|
||||
{isExternalVideo ? (
|
||||
<>
|
||||
<div className="reader-video">
|
||||
<ReactPlayer
|
||||
url={selectedUrl as string}
|
||||
controls
|
||||
width="100%"
|
||||
height="auto"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
aspectRatio: '16/9'
|
||||
}}
|
||||
onDuration={(d) => setVideoDurationSec(Math.floor(d))}
|
||||
/>
|
||||
) : (
|
||||
<div className="reader-markdown">
|
||||
<div className="loading-spinner">
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="sm" />
|
||||
</div>
|
||||
{ytMeta?.description && (
|
||||
<div className="large-text" style={{ color: '#ddd', padding: '0 0.75rem', whiteSpace: 'pre-wrap', marginBottom: '0.75rem' }}>
|
||||
{ytMeta.description}
|
||||
</div>
|
||||
)}
|
||||
{ytMeta?.transcript && (
|
||||
<div style={{ padding: '0 0.75rem 1rem 0.75rem' }}>
|
||||
<h3 style={{ margin: '1rem 0 0.5rem 0', fontSize: '1rem', color: '#aaa' }}>Transcript</h3>
|
||||
<div className="large-text" style={{ whiteSpace: 'pre-wrap', color: '#ddd' }}>
|
||||
{ytMeta.transcript}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="reader-html"
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
|
||||
onMouseUp={handleMouseUp}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div className="article-menu-container">
|
||||
<div className="article-menu-wrapper" ref={videoMenuRef}>
|
||||
<button
|
||||
className="article-menu-btn"
|
||||
onClick={toggleVideoMenu}
|
||||
title="More options"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisH} />
|
||||
</button>
|
||||
{showVideoMenu && (
|
||||
<div className="article-menu">
|
||||
<button className="article-menu-item" onClick={handleOpenVideoExternal}>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open Link</span>
|
||||
</button>
|
||||
<button className="article-menu-item" onClick={handleOpenVideoNative}>
|
||||
<FontAwesomeIcon icon={faMobileAlt} />
|
||||
<span>Open in Native App</span>
|
||||
</button>
|
||||
<button className="article-menu-item" onClick={handleCopyVideoUrl}>
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
<span>Copy URL</span>
|
||||
</button>
|
||||
<button className="article-menu-item" onClick={handleShareVideoUrl}>
|
||||
<FontAwesomeIcon icon={faShare} />
|
||||
<span>Share</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{activeAccount && (
|
||||
<div className="mark-as-read-container">
|
||||
<button
|
||||
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
|
||||
onClick={handleMarkAsRead}
|
||||
disabled={isMarkedAsRead || isCheckingReadStatus}
|
||||
title={isMarkedAsRead ? 'Already Marked as Watched' : 'Mark as Watched'}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
|
||||
spin={isCheckingReadStatus}
|
||||
/>
|
||||
<span>
|
||||
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Marked as Watched' : 'Mark as Watched'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : markdown || html ? (
|
||||
<>
|
||||
{markdown ? (
|
||||
renderedMarkdownHtml && finalHtml ? (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="reader-markdown"
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml }}
|
||||
onMouseUp={handleSelectionEnd}
|
||||
onTouchEnd={handleSelectionEnd}
|
||||
/>
|
||||
) : (
|
||||
<div className="reader-markdown">
|
||||
<div className="loading-spinner">
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="reader-html"
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
|
||||
onMouseUp={handleSelectionEnd}
|
||||
onTouchEnd={handleSelectionEnd}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Article menu for nostr-native articles */}
|
||||
{isNostrArticle && currentArticle && articleLinks && (
|
||||
<div className="article-menu-container">
|
||||
<div className="article-menu-wrapper" ref={articleMenuRef}>
|
||||
<button
|
||||
className="article-menu-btn"
|
||||
onClick={handleMenuToggle}
|
||||
title="More options"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisH} />
|
||||
</button>
|
||||
|
||||
{showArticleMenu && (
|
||||
<div className="article-menu">
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleOpenPortal}
|
||||
>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open on Nostr</span>
|
||||
</button>
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleOpenNative}
|
||||
>
|
||||
<FontAwesomeIcon icon={faMobileAlt} />
|
||||
<span>Open with Native App</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mark as Read button */}
|
||||
{activeAccount && (
|
||||
<div className="mark-as-read-container">
|
||||
<button
|
||||
className={`mark-as-read-btn ${isMarkedAsRead ? 'marked' : ''} ${showCheckAnimation ? 'animating' : ''}`}
|
||||
onClick={handleMarkAsRead}
|
||||
disabled={isMarkedAsRead || isCheckingReadStatus}
|
||||
title={isMarkedAsRead ? 'Already Marked as Read' : 'Mark as Read'}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={isCheckingReadStatus ? faSpinner : isMarkedAsRead ? faCheckCircle : faBooks}
|
||||
spin={isCheckingReadStatus}
|
||||
/>
|
||||
<span>
|
||||
{isCheckingReadStatus ? 'Checking...' : isMarkedAsRead ? 'Marked as Read' : 'Mark as Read'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Author info card for nostr-native articles */}
|
||||
{isNostrArticle && currentArticle && (
|
||||
<div className="author-card-container">
|
||||
<AuthorCard authorPubkey={currentArticle.pubkey} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="reader empty">
|
||||
<p>No readable content found for this URL.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { nip19 } from 'nostr-tools'
|
||||
import { fetchContacts } from '../services/contactService'
|
||||
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
||||
import BlogPostCard from './BlogPostCard'
|
||||
import { getCachedPosts, upsertCachedPost, setCachedPosts } from '../services/exploreCache'
|
||||
|
||||
interface ExploreProps {
|
||||
relayPool: RelayPool
|
||||
@@ -27,11 +28,59 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
// show spinner but keep existing posts
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Seed from in-memory cache if available to avoid empty flash
|
||||
const cached = getCachedPosts(activeAccount.pubkey)
|
||||
if (cached && cached.length > 0 && blogPosts.length === 0) {
|
||||
setBlogPosts(cached)
|
||||
}
|
||||
|
||||
// Fetch the user's contacts (friends)
|
||||
const contacts = await fetchContacts(relayPool, activeAccount.pubkey)
|
||||
const contacts = await fetchContacts(
|
||||
relayPool,
|
||||
activeAccount.pubkey,
|
||||
(partial) => {
|
||||
// When local contacts are available, kick off early posts fetch
|
||||
if (partial.size > 0) {
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
fetchBlogPostsFromAuthors(
|
||||
relayPool,
|
||||
Array.from(partial),
|
||||
relayUrls,
|
||||
(post) => {
|
||||
// merge into UI and cache as we stream
|
||||
setBlogPosts((prev) => {
|
||||
const exists = prev.some(p => p.event.id === post.event.id)
|
||||
if (exists) return prev
|
||||
const next = [...prev, post]
|
||||
return next.sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA
|
||||
})
|
||||
})
|
||||
setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post))
|
||||
}
|
||||
).then((all) => {
|
||||
// Ensure union of streamed + final is displayed
|
||||
setBlogPosts((prev) => {
|
||||
const byId = new Map(prev.map(p => [p.event.id, p]))
|
||||
for (const post of all) byId.set(post.event.id, post)
|
||||
const merged = Array.from(byId.values()).sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA
|
||||
})
|
||||
setCachedPosts(activeAccount.pubkey, merged)
|
||||
return merged
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (contacts.size === 0) {
|
||||
setError('You are not following anyone yet. Follow some people to see their blog posts!')
|
||||
@@ -39,21 +88,25 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
return
|
||||
}
|
||||
|
||||
// Get relay URLs from pool
|
||||
// After full contacts, do a final pass for completeness
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
|
||||
// Fetch blog posts from friends
|
||||
const posts = await fetchBlogPostsFromAuthors(
|
||||
relayPool,
|
||||
Array.from(contacts),
|
||||
relayUrls
|
||||
)
|
||||
const posts = await fetchBlogPostsFromAuthors(relayPool, Array.from(contacts), relayUrls)
|
||||
|
||||
if (posts.length === 0) {
|
||||
setError('No blog posts found from your friends yet')
|
||||
}
|
||||
|
||||
setBlogPosts(posts)
|
||||
setBlogPosts((prev) => {
|
||||
const byId = new Map(prev.map(p => [p.event.id, p]))
|
||||
for (const post of posts) byId.set(post.event.id, post)
|
||||
const merged = Array.from(byId.values()).sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA
|
||||
})
|
||||
setCachedPosts(activeAccount.pubkey, merged)
|
||||
return merged
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to load blog posts:', err)
|
||||
setError('Failed to load blog posts. Please try again.')
|
||||
@@ -63,7 +116,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
}
|
||||
|
||||
loadBlogPosts()
|
||||
}, [relayPool, activeAccount])
|
||||
}, [relayPool, activeAccount, blogPosts.length])
|
||||
|
||||
const getPostUrl = (post: BlogPostPreview) => {
|
||||
// Get the d-tag identifier
|
||||
@@ -79,17 +132,6 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
return `/a/${naddr}`
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="explore-container">
|
||||
<div className="explore-loading">
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||
<p>Loading blog posts from your friends...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="explore-container">
|
||||
@@ -112,6 +154,11 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
Discover blog posts from your friends on Nostr
|
||||
</p>
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="explore-loading" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
</div>
|
||||
)}
|
||||
<div className="explore-grid">
|
||||
{blogPosts.map((post) => (
|
||||
<BlogPostCard
|
||||
@@ -120,6 +167,11 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
href={getPostUrl(post)}
|
||||
/>
|
||||
))}
|
||||
{!loading && blogPosts.length === 0 && (
|
||||
<div className="explore-empty" style={{ gridColumn: '1/-1', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||
<p>No blog posts found yet.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faServer } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faServer, faTrash, faEllipsisH, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models, IEventStore } from 'applesauce-core'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { areAllRelaysLocal } from '../utils/helpers'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { formatDateCompact } from '../utils/bookmarkUtils'
|
||||
import { createDeletionRequest } from '../services/deletionService'
|
||||
import ConfirmDialog from './ConfirmDialog'
|
||||
import { getNostrUrl } from '../config/nostrGateways'
|
||||
import CompactButton from './CompactButton'
|
||||
|
||||
interface HighlightWithLevel extends Highlight {
|
||||
level?: 'mine' | 'friends' | 'nostrverse'
|
||||
@@ -23,21 +28,29 @@ interface HighlightItemProps {
|
||||
relayPool?: RelayPool | null
|
||||
eventStore?: IEventStore | null
|
||||
onHighlightUpdate?: (highlight: Highlight) => void
|
||||
onHighlightDelete?: (highlightId: string) => void
|
||||
}
|
||||
|
||||
export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
highlight,
|
||||
onSelectUrl,
|
||||
// onSelectUrl is not used but kept in props for API compatibility
|
||||
isSelected,
|
||||
onHighlightClick,
|
||||
relayPool,
|
||||
eventStore,
|
||||
onHighlightUpdate
|
||||
onHighlightUpdate,
|
||||
onHighlightDelete
|
||||
}) => {
|
||||
const itemRef = useRef<HTMLDivElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [isSyncing, setIsSyncing] = useState(() => isEventSyncing(highlight.id))
|
||||
const [showOfflineIndicator, setShowOfflineIndicator] = useState(() => highlight.isOfflineCreated && !isSyncing)
|
||||
const [isRebroadcasting, setIsRebroadcasting] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
|
||||
// Resolve the profile of the user who made the highlight
|
||||
const profile = useEventModel(Models.ProfileModel, [highlight.pubkey])
|
||||
@@ -88,61 +101,49 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
}
|
||||
}, [isSelected])
|
||||
|
||||
// Close menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setShowMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showMenu) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}
|
||||
}, [showMenu])
|
||||
|
||||
const handleItemClick = () => {
|
||||
if (onHighlightClick) {
|
||||
onHighlightClick(highlight.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLinkClick = (url: string, e: React.MouseEvent) => {
|
||||
if (onSelectUrl) {
|
||||
e.preventDefault()
|
||||
onSelectUrl(url)
|
||||
const getHighlightLinks = () => {
|
||||
// Encode the highlight event itself (kind 9802) as a nevent
|
||||
// Get non-local relays for the hint
|
||||
const relayHints = RELAYS.filter(r =>
|
||||
!r.includes('localhost') && !r.includes('127.0.0.1')
|
||||
).slice(0, 3) // Include up to 3 relay hints
|
||||
|
||||
const nevent = nip19.neventEncode({
|
||||
id: highlight.id,
|
||||
relays: relayHints,
|
||||
author: highlight.pubkey,
|
||||
kind: 9802
|
||||
})
|
||||
|
||||
return {
|
||||
portal: getNostrUrl(nevent),
|
||||
native: `nostr:${nevent}`
|
||||
}
|
||||
}
|
||||
|
||||
const getSourceLink = () => {
|
||||
if (highlight.eventReference) {
|
||||
// Check if it's a coordinate string (kind:pubkey:identifier) or a simple event ID
|
||||
if (highlight.eventReference.includes(':')) {
|
||||
// It's an addressable event coordinate, encode as naddr
|
||||
const parts = highlight.eventReference.split(':')
|
||||
if (parts.length === 3) {
|
||||
const [kindStr, pubkey, identifier] = parts
|
||||
const kind = parseInt(kindStr, 10)
|
||||
|
||||
// Get non-local relays for the hint
|
||||
const relayHints = RELAYS.filter(r =>
|
||||
!r.includes('localhost') && !r.includes('127.0.0.1')
|
||||
).slice(0, 3) // Include up to 3 relay hints
|
||||
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind,
|
||||
pubkey,
|
||||
identifier,
|
||||
relays: relayHints
|
||||
})
|
||||
return `https://njump.me/${naddr}`
|
||||
}
|
||||
} else {
|
||||
// It's a simple event ID, encode as nevent
|
||||
// Get non-local relays for the hint
|
||||
const relayHints = RELAYS.filter(r =>
|
||||
!r.includes('localhost') && !r.includes('127.0.0.1')
|
||||
).slice(0, 3) // Include up to 3 relay hints
|
||||
|
||||
const nevent = nip19.neventEncode({
|
||||
id: highlight.eventReference,
|
||||
relays: relayHints,
|
||||
author: highlight.author
|
||||
})
|
||||
return `https://njump.me/${nevent}`
|
||||
}
|
||||
}
|
||||
return highlight.urlReference
|
||||
}
|
||||
|
||||
const sourceLink = getSourceLink()
|
||||
const highlightLinks = getHighlightLinks()
|
||||
|
||||
// Handle rebroadcast to all relays
|
||||
const handleRebroadcast = async (e: React.MouseEvent) => {
|
||||
@@ -243,7 +244,69 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
|
||||
const relayIndicator = getRelayIndicatorInfo()
|
||||
|
||||
// Check if current user can delete this highlight
|
||||
const canDelete = activeAccount && highlight.pubkey === activeAccount.pubkey
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!activeAccount || !relayPool) {
|
||||
console.warn('Cannot delete: no account or relay pool')
|
||||
return
|
||||
}
|
||||
|
||||
setIsDeleting(true)
|
||||
setShowDeleteConfirm(false)
|
||||
|
||||
try {
|
||||
await createDeletionRequest(
|
||||
highlight.id,
|
||||
9802, // kind for highlights
|
||||
'Deleted by user',
|
||||
activeAccount,
|
||||
relayPool
|
||||
)
|
||||
|
||||
console.log('✅ Highlight deletion request published')
|
||||
|
||||
// Notify parent to remove this highlight from the list
|
||||
if (onHighlightDelete) {
|
||||
onHighlightDelete(highlight.id)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete highlight:', error)
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelDelete = () => {
|
||||
setShowDeleteConfirm(false)
|
||||
}
|
||||
|
||||
const handleMenuToggle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setShowMenu(!showMenu)
|
||||
}
|
||||
|
||||
const handleOpenPortal = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
window.open(highlightLinks.portal, '_blank', 'noopener,noreferrer')
|
||||
setShowMenu(false)
|
||||
}
|
||||
|
||||
const handleOpenNative = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
window.location.href = highlightLinks.native
|
||||
setShowMenu(false)
|
||||
}
|
||||
|
||||
const handleMenuDeleteClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setShowMenu(false)
|
||||
setShowDeleteConfirm(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={itemRef}
|
||||
className={`highlight-item ${isSelected ? 'selected' : ''} ${highlight.level ? `level-${highlight.level}` : ''}`}
|
||||
@@ -251,20 +314,25 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
onClick={handleItemClick}
|
||||
style={{ cursor: onHighlightClick ? 'pointer' : 'default' }}
|
||||
>
|
||||
<div className="highlight-quote-icon">
|
||||
<FontAwesomeIcon icon={faQuoteLeft} />
|
||||
{relayIndicator && (
|
||||
<div
|
||||
className="highlight-relay-indicator"
|
||||
title={relayIndicator.tooltip}
|
||||
onClick={handleRebroadcast}
|
||||
style={{ cursor: relayPool && eventStore ? 'pointer' : 'default' }}
|
||||
>
|
||||
<FontAwesomeIcon icon={relayIndicator.icon} spin={relayIndicator.spin} />
|
||||
</div>
|
||||
)}
|
||||
<div className="highlight-header">
|
||||
<CompactButton
|
||||
className="highlight-timestamp"
|
||||
title={new Date(highlight.created_at * 1000).toLocaleString()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{formatDateCompact(highlight.created_at)}
|
||||
</CompactButton>
|
||||
</div>
|
||||
|
||||
<CompactButton
|
||||
className="highlight-quote-button"
|
||||
icon={faQuoteLeft}
|
||||
title="Quote"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
{/* relay indicator lives in footer for consistent padding/alignment */}
|
||||
|
||||
<div className="highlight-content">
|
||||
<blockquote className="highlight-text">
|
||||
{highlight.content}
|
||||
@@ -277,30 +345,75 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
)}
|
||||
|
||||
|
||||
<div className="highlight-meta">
|
||||
<span className="highlight-author">
|
||||
{getUserDisplayName()}
|
||||
</span>
|
||||
<span className="highlight-meta-separator">•</span>
|
||||
<span className="highlight-time">
|
||||
{formatDateCompact(highlight.created_at)}
|
||||
</span>
|
||||
<div className="highlight-footer">
|
||||
<div className="highlight-footer-left">
|
||||
{relayIndicator && (
|
||||
<CompactButton
|
||||
className="highlight-relay-indicator"
|
||||
icon={relayIndicator.icon}
|
||||
spin={relayIndicator.spin}
|
||||
title={relayIndicator.tooltip}
|
||||
onClick={handleRebroadcast}
|
||||
disabled={!relayPool || !eventStore}
|
||||
/>
|
||||
)}
|
||||
|
||||
<span className="highlight-author">
|
||||
{getUserDisplayName()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{sourceLink && (
|
||||
<a
|
||||
href={sourceLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => highlight.urlReference && onSelectUrl ? handleLinkClick(highlight.urlReference, e) : undefined}
|
||||
className="highlight-source"
|
||||
title={highlight.eventReference ? 'Open on Nostr' : 'Open source'}
|
||||
>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
</a>
|
||||
)}
|
||||
<div className="highlight-menu-wrapper" ref={menuRef}>
|
||||
<CompactButton
|
||||
icon={faEllipsisH}
|
||||
onClick={handleMenuToggle}
|
||||
title="More options"
|
||||
/>
|
||||
|
||||
{showMenu && (
|
||||
<div className="highlight-menu">
|
||||
<button
|
||||
className="highlight-menu-item"
|
||||
onClick={handleOpenPortal}
|
||||
>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open on Nostr</span>
|
||||
</button>
|
||||
<button
|
||||
className="highlight-menu-item"
|
||||
onClick={handleOpenNative}
|
||||
>
|
||||
<FontAwesomeIcon icon={faMobileAlt} />
|
||||
<span>Open with Native App</span>
|
||||
</button>
|
||||
{canDelete && (
|
||||
<button
|
||||
className="highlight-menu-item highlight-menu-item-danger"
|
||||
onClick={handleMenuDeleteClick}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<FontAwesomeIcon icon={isDeleting ? faSpinner : faTrash} spin={isDeleting} />
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={showDeleteConfirm}
|
||||
title="Delete Highlight?"
|
||||
message="This will request deletion of your highlight. It may still be visible on some relays that don't honor deletion requests."
|
||||
confirmText="Delete"
|
||||
cancelText="Cancel"
|
||||
variant="danger"
|
||||
onConfirm={handleConfirmDelete}
|
||||
onCancel={handleCancelDelete}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import HighlightsPanelCollapsed from './HighlightsPanel/HighlightsPanelCollapsed
|
||||
import HighlightsPanelHeader from './HighlightsPanel/HighlightsPanelHeader'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
|
||||
export interface HighlightVisibility {
|
||||
nostrverse: boolean
|
||||
@@ -32,6 +33,7 @@ interface HighlightsPanelProps {
|
||||
followedPubkeys?: Set<string>
|
||||
relayPool?: RelayPool | null
|
||||
eventStore?: IEventStore | null
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
@@ -50,7 +52,8 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
onHighlightVisibilityChange,
|
||||
followedPubkeys = new Set(),
|
||||
relayPool,
|
||||
eventStore
|
||||
eventStore,
|
||||
settings
|
||||
}) => {
|
||||
const [showHighlights, setShowHighlights] = useState(true)
|
||||
const [localHighlights, setLocalHighlights] = useState(highlights)
|
||||
@@ -72,6 +75,11 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
const handleHighlightDelete = (highlightId: string) => {
|
||||
// Remove highlight from local state
|
||||
setLocalHighlights(prev => prev.filter(h => h.id !== highlightId))
|
||||
}
|
||||
|
||||
const filteredHighlights = useFilteredHighlights({
|
||||
highlights: localHighlights,
|
||||
selectedUrl,
|
||||
@@ -85,6 +93,7 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
<HighlightsPanelCollapsed
|
||||
hasHighlights={filteredHighlights.length > 0}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
settings={settings}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -129,6 +138,7 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
relayPool={relayPool}
|
||||
eventStore={eventStore}
|
||||
onHighlightUpdate={handleHighlightUpdate}
|
||||
onHighlightDelete={handleHighlightDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHighlighter, faChevronRight } from '@fortawesome/free-solid-svg-icons'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
|
||||
interface HighlightsPanelCollapsedProps {
|
||||
hasHighlights: boolean
|
||||
onToggleCollapse: () => void
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
const HighlightsPanelCollapsed: React.FC<HighlightsPanelCollapsedProps> = ({
|
||||
hasHighlights,
|
||||
onToggleCollapse
|
||||
onToggleCollapse,
|
||||
settings
|
||||
}) => {
|
||||
const highlightColor = settings?.highlightColorMine || '#ffff00'
|
||||
|
||||
return (
|
||||
<div className="highlights-container collapsed">
|
||||
<button
|
||||
@@ -19,8 +24,12 @@ const HighlightsPanelCollapsed: React.FC<HighlightsPanelCollapsedProps> = ({
|
||||
title="Expand highlights panel"
|
||||
aria-label="Expand highlights panel"
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} className={hasHighlights ? 'glow' : ''} />
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
<FontAwesomeIcon
|
||||
icon={faHighlighter}
|
||||
className={hasHighlights ? 'glow' : ''}
|
||||
style={{ color: highlightColor }}
|
||||
/>
|
||||
<FontAwesomeIcon icon={faChevronRight} style={{ color: highlightColor }} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
332
src/components/Me.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner, faExclamationCircle, faHighlighter, faBookmark, faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { HighlightItem } from './HighlightItem'
|
||||
import { fetchHighlights } from '../services/highlightService'
|
||||
import { fetchBookmarks } from '../services/bookmarkService'
|
||||
import { fetchReadArticlesWithData } from '../services/libraryService'
|
||||
import { BlogPostPreview } from '../services/exploreService'
|
||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||
import AuthorCard from './AuthorCard'
|
||||
import BlogPostCard from './BlogPostCard'
|
||||
import { BookmarkItem } from './BookmarkItem'
|
||||
import IconButton from './IconButton'
|
||||
import { ViewMode } from './Bookmarks'
|
||||
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||
import { getCachedMeData, setCachedMeData, updateCachedHighlights } from '../services/meCache'
|
||||
import { faBooks } from '../icons/customIcons'
|
||||
|
||||
interface MeProps {
|
||||
relayPool: RelayPool
|
||||
activeTab?: TabType
|
||||
}
|
||||
|
||||
type TabType = 'highlights' | 'reading-list' | 'archive'
|
||||
|
||||
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab }) => {
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const navigate = useNavigate()
|
||||
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||
const [readArticles, setReadArticles] = useState<BlogPostPreview[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
||||
|
||||
// Update local state when prop changes
|
||||
useEffect(() => {
|
||||
if (propActiveTab) {
|
||||
setActiveTab(propActiveTab)
|
||||
}
|
||||
}, [propActiveTab])
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (!activeAccount) {
|
||||
setError('Please log in to view your data')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Seed from cache if available to avoid empty flash
|
||||
const cached = getCachedMeData(activeAccount.pubkey)
|
||||
if (cached) {
|
||||
setHighlights(cached.highlights)
|
||||
setBookmarks(cached.bookmarks)
|
||||
setReadArticles(cached.readArticles)
|
||||
}
|
||||
|
||||
// Fetch highlights and read articles
|
||||
const [userHighlights, userReadArticles] = await Promise.all([
|
||||
fetchHighlights(relayPool, activeAccount.pubkey),
|
||||
fetchReadArticlesWithData(relayPool, activeAccount.pubkey)
|
||||
])
|
||||
|
||||
setHighlights(userHighlights)
|
||||
setReadArticles(userReadArticles)
|
||||
|
||||
// Fetch bookmarks using callback pattern
|
||||
let fetchedBookmarks: Bookmark[] = []
|
||||
try {
|
||||
await fetchBookmarks(relayPool, activeAccount, (newBookmarks) => {
|
||||
fetchedBookmarks = newBookmarks
|
||||
setBookmarks(newBookmarks)
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('Failed to load bookmarks:', err)
|
||||
setBookmarks([])
|
||||
}
|
||||
|
||||
// Update cache with all fetched data
|
||||
setCachedMeData(activeAccount.pubkey, userHighlights, fetchedBookmarks, userReadArticles)
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err)
|
||||
setError('Failed to load data. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadData()
|
||||
}, [relayPool, activeAccount])
|
||||
|
||||
const handleHighlightDelete = (highlightId: string) => {
|
||||
setHighlights(prev => {
|
||||
const updated = prev.filter(h => h.id !== highlightId)
|
||||
// Update cache when highlight is deleted
|
||||
if (activeAccount) {
|
||||
updateCachedHighlights(activeAccount.pubkey, updated)
|
||||
}
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
const getPostUrl = (post: BlogPostPreview) => {
|
||||
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey: post.author,
|
||||
identifier: dTag
|
||||
})
|
||||
return `/a/${naddr}`
|
||||
}
|
||||
|
||||
// Helper to check if a bookmark has either content or a URL (same logic as BookmarkList)
|
||||
const hasContentOrUrl = (ib: IndividualBookmark) => {
|
||||
const hasContent = ib.content && ib.content.trim().length > 0
|
||||
|
||||
let hasUrl = false
|
||||
if (ib.kind === 39701) {
|
||||
const dTag = ib.tags?.find((t: string[]) => t[0] === 'd')?.[1]
|
||||
hasUrl = !!dTag && dTag.trim().length > 0
|
||||
} else {
|
||||
const urls = extractUrlsFromContent(ib.content || '')
|
||||
hasUrl = urls.length > 0
|
||||
}
|
||||
|
||||
if (ib.kind === 30023) return true
|
||||
return hasContent || hasUrl
|
||||
}
|
||||
|
||||
const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => {
|
||||
if (bookmark && bookmark.kind === 30023) {
|
||||
// For kind:30023 articles, navigate to the article route
|
||||
const dTag = bookmark.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
if (dTag && bookmark.pubkey) {
|
||||
const pointer = {
|
||||
identifier: dTag,
|
||||
kind: 30023,
|
||||
pubkey: bookmark.pubkey,
|
||||
}
|
||||
const naddr = nip19.naddrEncode(pointer)
|
||||
navigate(`/a/${naddr}`)
|
||||
}
|
||||
} else if (url) {
|
||||
// For regular URLs, navigate to the reader route
|
||||
navigate(`/r/${encodeURIComponent(url)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Merge and flatten all individual bookmarks (same logic as BookmarkList)
|
||||
const allIndividualBookmarks = bookmarks.flatMap(b => b.individualBookmarks || [])
|
||||
.filter(hasContentOrUrl)
|
||||
.sort((a, b) => ((b.added_at || 0) - (a.added_at || 0)) || ((b.created_at || 0) - (a.created_at || 0)))
|
||||
|
||||
// Only show full loading screen if we don't have any data yet
|
||||
const hasData = highlights.length > 0 || bookmarks.length > 0 || readArticles.length > 0
|
||||
|
||||
if (loading && !hasData) {
|
||||
return (
|
||||
<div className="explore-container">
|
||||
<div className="explore-loading">
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="2x" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="explore-container">
|
||||
<div className="explore-error">
|
||||
<FontAwesomeIcon icon={faExclamationCircle} size="2x" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'highlights':
|
||||
return highlights.length === 0 ? (
|
||||
<div className="explore-error">
|
||||
<p>No highlights yet. Start highlighting content to see them here!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="highlights-list me-highlights-list">
|
||||
{highlights.map((highlight) => (
|
||||
<HighlightItem
|
||||
key={highlight.id}
|
||||
highlight={{ ...highlight, level: 'mine' }}
|
||||
relayPool={relayPool}
|
||||
onHighlightDelete={handleHighlightDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'reading-list':
|
||||
return allIndividualBookmarks.length === 0 ? (
|
||||
<div className="explore-error">
|
||||
<p>No bookmarks yet. Bookmark articles to see them here!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bookmarks-list">
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
{allIndividualBookmarks.map((individualBookmark, index) => (
|
||||
<BookmarkItem
|
||||
key={`${individualBookmark.id}-${index}`}
|
||||
bookmark={individualBookmark}
|
||||
index={index}
|
||||
viewMode={viewMode}
|
||||
onSelectUrl={handleSelectUrl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="view-mode-controls" style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '1rem',
|
||||
marginTop: '1rem',
|
||||
borderTop: '1px solid var(--border-color)'
|
||||
}}>
|
||||
<IconButton
|
||||
icon={faList}
|
||||
onClick={() => setViewMode('compact')}
|
||||
title="Compact list view"
|
||||
ariaLabel="Compact list view"
|
||||
variant={viewMode === 'compact' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faThLarge}
|
||||
onClick={() => setViewMode('cards')}
|
||||
title="Cards view"
|
||||
ariaLabel="Cards view"
|
||||
variant={viewMode === 'cards' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faImage}
|
||||
onClick={() => setViewMode('large')}
|
||||
title="Large preview view"
|
||||
ariaLabel="Large preview view"
|
||||
variant={viewMode === 'large' ? 'primary' : 'ghost'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'archive':
|
||||
return readArticles.length === 0 ? (
|
||||
<div className="explore-error">
|
||||
<p>No read articles yet. Mark articles as read to see them here!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
{readArticles.map((post) => (
|
||||
<BlogPostCard
|
||||
key={post.event.id}
|
||||
post={post}
|
||||
href={getPostUrl(post)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="explore-container">
|
||||
<div className="explore-header">
|
||||
{activeAccount && <AuthorCard authorPubkey={activeAccount.pubkey} />}
|
||||
|
||||
{loading && hasData && (
|
||||
<div className="explore-loading" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="me-tabs">
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
|
||||
data-tab="highlights"
|
||||
onClick={() => navigate('/me/highlights')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span className="tab-label">Highlights</span>
|
||||
<span className="tab-count">({highlights.length})</span>
|
||||
</button>
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'reading-list' ? 'active' : ''}`}
|
||||
data-tab="reading-list"
|
||||
onClick={() => navigate('/me/reading-list')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBookmark} />
|
||||
<span className="tab-label">Reading List</span>
|
||||
<span className="tab-count">({allIndividualBookmarks.length})</span>
|
||||
</button>
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'archive' ? 'active' : ''}`}
|
||||
data-tab="archive"
|
||||
onClick={() => navigate('/me/archive')}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBooks} />
|
||||
<span className="tab-label">Archive</span>
|
||||
<span className="tab-count">({readArticles.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="me-tab-content">
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Me
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import React from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHighlighter, faClock } from '@fortawesome/free-solid-svg-icons'
|
||||
import { format } from 'date-fns'
|
||||
import { useImageCache } from '../hooks/useImageCache'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import { Highlight, HighlightLevel } from '../types/highlights'
|
||||
import { HighlightVisibility } from './HighlightsPanel'
|
||||
import { hexToRgb } from '../utils/colorHelpers'
|
||||
|
||||
interface ReaderHeaderProps {
|
||||
title?: string
|
||||
@@ -14,6 +17,8 @@ interface ReaderHeaderProps {
|
||||
hasHighlights: boolean
|
||||
highlightCount: number
|
||||
settings?: UserSettings
|
||||
highlights?: Highlight[]
|
||||
highlightVisibility?: HighlightVisibility
|
||||
}
|
||||
|
||||
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
@@ -24,40 +29,86 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
readingTimeText,
|
||||
hasHighlights,
|
||||
highlightCount,
|
||||
settings
|
||||
settings,
|
||||
highlights = [],
|
||||
highlightVisibility = { nostrverse: true, friends: true, mine: true }
|
||||
}) => {
|
||||
const cachedImage = useImageCache(image, settings)
|
||||
const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null
|
||||
const isLongSummary = summary && summary.length > 150
|
||||
|
||||
// Determine the dominant highlight color based on visibility and priority
|
||||
const highlightIndicatorStyles = useMemo(() => {
|
||||
if (!highlights.length) return undefined
|
||||
|
||||
// Count highlights by level that are visible
|
||||
const visibleLevels = new Set<HighlightLevel>()
|
||||
highlights.forEach(h => {
|
||||
if (h.level && highlightVisibility[h.level]) {
|
||||
visibleLevels.add(h.level)
|
||||
}
|
||||
})
|
||||
|
||||
let hexColor: string | undefined
|
||||
// Priority: nostrverse > friends > mine
|
||||
if (visibleLevels.has('nostrverse') && highlightVisibility.nostrverse) {
|
||||
hexColor = settings?.highlightColorNostrverse || '#9333ea'
|
||||
} else if (visibleLevels.has('friends') && highlightVisibility.friends) {
|
||||
hexColor = settings?.highlightColorFriends || '#f97316'
|
||||
} else if (visibleLevels.has('mine') && highlightVisibility.mine) {
|
||||
hexColor = settings?.highlightColorMine || '#ffff00'
|
||||
}
|
||||
|
||||
if (!hexColor) return undefined
|
||||
|
||||
const rgb = hexToRgb(hexColor)
|
||||
return {
|
||||
backgroundColor: `rgba(${rgb}, 0.1)`,
|
||||
borderColor: `rgba(${rgb}, 0.3)`,
|
||||
color: '#fff'
|
||||
}
|
||||
}, [highlights, highlightVisibility, settings])
|
||||
|
||||
if (cachedImage) {
|
||||
return (
|
||||
<div className="reader-hero-image">
|
||||
<img src={cachedImage} alt={title || 'Article image'} />
|
||||
{formattedDate && (
|
||||
<div className="publish-date-topright">
|
||||
{formattedDate}
|
||||
</div>
|
||||
)}
|
||||
{title && (
|
||||
<div className="reader-header-overlay">
|
||||
<h2 className="reader-title">{title}</h2>
|
||||
{summary && <p className="reader-summary">{summary}</p>}
|
||||
<div className="reader-meta">
|
||||
{readingTimeText && (
|
||||
<div className="reading-time">
|
||||
<FontAwesomeIcon icon={faClock} />
|
||||
<span>{readingTimeText}</span>
|
||||
</div>
|
||||
)}
|
||||
{hasHighlights && (
|
||||
<div className="highlight-indicator">
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
<>
|
||||
<div className="reader-hero-image">
|
||||
<img src={cachedImage} alt={title || 'Article image'} />
|
||||
{formattedDate && (
|
||||
<div className="publish-date-topright">
|
||||
{formattedDate}
|
||||
</div>
|
||||
)}
|
||||
{title && (
|
||||
<div className="reader-header-overlay">
|
||||
<h2 className="reader-title">{title}</h2>
|
||||
{summary && <p className={`reader-summary ${isLongSummary ? 'hide-on-mobile' : ''}`}>{summary}</p>}
|
||||
<div className="reader-meta">
|
||||
{readingTimeText && (
|
||||
<div className="reading-time">
|
||||
<FontAwesomeIcon icon={faClock} />
|
||||
<span>{readingTimeText}</span>
|
||||
</div>
|
||||
)}
|
||||
{hasHighlights && (
|
||||
<div
|
||||
className="highlight-indicator"
|
||||
style={highlightIndicatorStyles}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isLongSummary && (
|
||||
<div className="reader-summary-below-image">
|
||||
<p className="reader-summary">{summary}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -80,7 +131,10 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
</div>
|
||||
)}
|
||||
{hasHighlights && (
|
||||
<div className="highlight-indicator">
|
||||
<div
|
||||
className="highlight-indicator"
|
||||
style={highlightIndicatorStyles}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span>{highlightCount} highlight{highlightCount !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
|
||||
41
src/components/ReadingProgressIndicator.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react'
|
||||
|
||||
interface ReadingProgressIndicatorProps {
|
||||
progress: number // 0 to 100
|
||||
isComplete?: boolean
|
||||
showPercentage?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> = ({
|
||||
progress,
|
||||
isComplete = false,
|
||||
showPercentage = true,
|
||||
className = ''
|
||||
}) => {
|
||||
const clampedProgress = Math.min(100, Math.max(0, progress))
|
||||
|
||||
return (
|
||||
<div className={`fixed bottom-0 left-0 right-0 z-[1102] bg-[rgba(26,26,26,0.85)] backdrop-blur-sm px-3 py-1 flex items-center gap-2 transition-all duration-300 ${className}`}>
|
||||
<div className="flex-1 h-0.5 bg-white/10 rounded-full overflow-hidden relative">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 relative ${
|
||||
isComplete
|
||||
? 'bg-green-500'
|
||||
: 'bg-indigo-500'
|
||||
}`}
|
||||
style={{ width: `${clampedProgress}%` }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-[shimmer_2s_infinite]" />
|
||||
</div>
|
||||
</div>
|
||||
{showPercentage && (
|
||||
<div className={`text-[0.625rem] font-normal min-w-[32px] text-right tabular-nums ${
|
||||
isComplete ? 'text-green-500' : 'text-gray-500'
|
||||
}`}>
|
||||
{isComplete ? '✓' : `${clampedProgress}%`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,15 +4,22 @@ import { faPlane, faGlobe, faCircle, faSpinner } from '@fortawesome/free-solid-s
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||
import { isLocalRelay } from '../utils/helpers'
|
||||
import { useIsMobile } from '../hooks/useMediaQuery'
|
||||
|
||||
interface RelayStatusIndicatorProps {
|
||||
relayPool: RelayPool | null
|
||||
showOnMobile?: boolean // Control visibility based on scroll
|
||||
}
|
||||
|
||||
export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ relayPool }) => {
|
||||
export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({
|
||||
relayPool,
|
||||
showOnMobile = true
|
||||
}) => {
|
||||
// Poll frequently for responsive offline indicator (5s instead of default 20s)
|
||||
const relayStatuses = useRelayStatus({ relayPool, pollingInterval: 5000 })
|
||||
const [isConnecting, setIsConnecting] = useState(true)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
if (!relayPool) return null
|
||||
|
||||
@@ -52,41 +59,60 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ rela
|
||||
hasRemoteRelay,
|
||||
isConnecting
|
||||
})
|
||||
}, [offlineMode, localOnlyMode, connectedUrls.length, relayStatuses.length, hasLocalRelay, hasRemoteRelay, isConnecting])
|
||||
}, [offlineMode, localOnlyMode, connectedUrls, relayStatuses.length, hasLocalRelay, hasRemoteRelay, isConnecting])
|
||||
|
||||
// Don't show indicator when fully connected (but show when connecting)
|
||||
if (!localOnlyMode && !offlineMode && !isConnecting) return null
|
||||
|
||||
const handleClick = () => {
|
||||
if (isMobile) {
|
||||
setIsExpanded(!isExpanded)
|
||||
}
|
||||
}
|
||||
|
||||
const showDetails = !isMobile || isExpanded
|
||||
|
||||
return (
|
||||
<div className={`relay-status-indicator ${isConnecting ? 'connecting' : ''}`} title={
|
||||
isConnecting
|
||||
? 'Connecting to relays...'
|
||||
: offlineMode
|
||||
? 'Offline - No relays connected'
|
||||
: 'Local Relays Only - Highlights will be marked as local'
|
||||
}>
|
||||
<div
|
||||
className={`relay-status-indicator ${isConnecting ? 'connecting' : ''} ${isMobile ? 'mobile' : ''} ${isExpanded ? 'expanded' : ''} ${isMobile && !showOnMobile ? 'hidden' : 'visible'}`}
|
||||
title={
|
||||
!isMobile ? (
|
||||
isConnecting
|
||||
? 'Connecting to relays...'
|
||||
: offlineMode
|
||||
? 'Offline - No relays connected'
|
||||
: 'Local Relays Only - Highlights will be marked as local'
|
||||
) : undefined
|
||||
}
|
||||
onClick={handleClick}
|
||||
style={{ cursor: isMobile ? 'pointer' : 'default' }}
|
||||
>
|
||||
<div className="relay-status-icon">
|
||||
<FontAwesomeIcon icon={isConnecting ? faSpinner : offlineMode ? faCircle : faPlane} spin={isConnecting} />
|
||||
</div>
|
||||
<div className="relay-status-text">
|
||||
{isConnecting ? (
|
||||
<span className="relay-status-title">Connecting</span>
|
||||
) : offlineMode ? (
|
||||
<>
|
||||
<span className="relay-status-title">Offline</span>
|
||||
<span className="relay-status-subtitle">No relays connected</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="relay-status-title">Flight Mode</span>
|
||||
<span className="relay-status-subtitle">{connectedUrls.length} local relay{connectedUrls.length !== 1 ? 's' : ''}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!offlineMode && !isConnecting && (
|
||||
<div className="relay-status-pulse">
|
||||
<FontAwesomeIcon icon={faGlobe} className="pulse-icon" />
|
||||
</div>
|
||||
{showDetails && (
|
||||
<>
|
||||
<div className="relay-status-text">
|
||||
{isConnecting ? (
|
||||
<span className="relay-status-title">Connecting</span>
|
||||
) : offlineMode ? (
|
||||
<>
|
||||
<span className="relay-status-title">Offline</span>
|
||||
<span className="relay-status-subtitle">No relays connected</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="relay-status-title">Flight Mode</span>
|
||||
<span className="relay-status-subtitle">{connectedUrls.length} local relay{connectedUrls.length !== 1 ? 's' : ''}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!offlineMode && !isConnecting && (
|
||||
<div className="relay-status-pulse">
|
||||
<FontAwesomeIcon icon={faGlobe} className="pulse-icon" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models, Helpers } from 'applesauce-core'
|
||||
import { decode, npubEncode } from 'nostr-tools/nip19'
|
||||
import { getProfileUrl } from '../config/nostrGateways'
|
||||
|
||||
const { getPubkeyFromDecodeResult } = Helpers
|
||||
|
||||
@@ -25,7 +26,7 @@ const ResolvedMention: React.FC<ResolvedMentionProps> = ({ encoded }) => {
|
||||
if (npub) {
|
||||
return (
|
||||
<a
|
||||
href={`https://search.dergigi.com/p/${npub}`}
|
||||
href={getProfileUrl(npub)}
|
||||
className="nostr-mention"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
|
||||
@@ -10,6 +10,7 @@ import StartupPreferencesSettings from './Settings/StartupPreferencesSettings'
|
||||
import ZapSettings from './Settings/ZapSettings'
|
||||
import OfflineModeSettings from './Settings/OfflineModeSettings'
|
||||
import RelaySettings from './Settings/RelaySettings'
|
||||
import PWASettings from './Settings/PWASettings'
|
||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||
|
||||
const DEFAULT_SETTINGS: UserSettings = {
|
||||
@@ -57,7 +58,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
|
||||
return migrated
|
||||
})
|
||||
const isInitialMount = useRef(true)
|
||||
const saveTimeoutRef = useRef<number | null>(null)
|
||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const isLocallyUpdating = useRef(false)
|
||||
|
||||
// Poll for relay status updates
|
||||
@@ -164,6 +165,7 @@ const Settings: React.FC<SettingsProps> = ({ settings, onSave, onClose, relayPoo
|
||||
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<OfflineModeSettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
|
||||
<RelaySettings relayStatuses={relayStatuses} onClose={onClose} />
|
||||
<PWASettings />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
84
src/components/Settings/PWASettings.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React from 'react'
|
||||
import { faDownload, faCheckCircle, faMobileAlt } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { usePWAInstall } from '../../hooks/usePWAInstall'
|
||||
|
||||
const PWASettings: React.FC = () => {
|
||||
const { isInstallable, isInstalled, installApp } = usePWAInstall()
|
||||
|
||||
const handleInstall = async () => {
|
||||
const success = await installApp()
|
||||
if (success) {
|
||||
console.log('App installed successfully')
|
||||
}
|
||||
}
|
||||
|
||||
if (isInstalled) {
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3>Progressive Web App</h3>
|
||||
<div className="setting-item">
|
||||
<div className="setting-info">
|
||||
<FontAwesomeIcon icon={faCheckCircle} style={{ color: '#22c55e', marginRight: '8px' }} />
|
||||
<span>Boris is installed as an app</span>
|
||||
</div>
|
||||
<p className="setting-description">
|
||||
You can launch Boris from your home screen or app drawer.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isInstallable) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3>Progressive Web App</h3>
|
||||
<div className="setting-item">
|
||||
<div className="setting-info">
|
||||
<FontAwesomeIcon icon={faMobileAlt} style={{ marginRight: '8px' }} />
|
||||
<span>Install Boris as an app</span>
|
||||
</div>
|
||||
<p className="setting-description">
|
||||
Install Boris on your device for a native app experience with offline support.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="install-button"
|
||||
style={{
|
||||
marginTop: '12px',
|
||||
padding: '8px 16px',
|
||||
background: 'linear-gradient(135deg, #3b82f6 0%, #1e40af 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(59, 130, 246, 0.3)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faDownload} />
|
||||
Install App
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PWASettings
|
||||
|
||||
@@ -36,7 +36,7 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
accountManager.setActive(account)
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error)
|
||||
alert('Login failed. Please install a nostr browser extension and try again.')
|
||||
alert('Login failed. Please install a nostr browser extension and try again.\n\nIf you aren\'t on nostr yet, start here: https://nstart.me/')
|
||||
} finally {
|
||||
setIsConnecting(false)
|
||||
}
|
||||
@@ -90,8 +90,12 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
<div
|
||||
className="profile-avatar"
|
||||
title={activeAccount ? getUserDisplayName() : "Login"}
|
||||
onClick={!activeAccount ? (isConnecting ? () => {} : handleLogin) : undefined}
|
||||
style={{ cursor: !activeAccount ? 'pointer' : 'default' }}
|
||||
onClick={
|
||||
activeAccount
|
||||
? () => navigate('/me')
|
||||
: (isConnecting ? () => {} : handleLogin)
|
||||
}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{profileImage ? (
|
||||
<img src={profileImage} alt={getUserDisplayName()} />
|
||||
|
||||
@@ -19,6 +19,9 @@ import { HighlightVisibility } from './HighlightsPanel'
|
||||
import { HighlightButtonRef } from './HighlightButton'
|
||||
import { BookmarkReference } from '../utils/contentLoader'
|
||||
import { useIsMobile } from '../hooks/useMediaQuery'
|
||||
import { useScrollDirection } from '../hooks/useScrollDirection'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
|
||||
interface ThreePaneLayoutProps {
|
||||
// Layout state
|
||||
@@ -27,6 +30,7 @@ interface ThreePaneLayoutProps {
|
||||
isSidebarOpen: boolean
|
||||
showSettings: boolean
|
||||
showExplore?: boolean
|
||||
showMe?: boolean
|
||||
|
||||
// Bookmarks pane
|
||||
bookmarks: Bookmark[]
|
||||
@@ -58,6 +62,8 @@ interface ThreePaneLayoutProps {
|
||||
onClearSelection: () => void
|
||||
currentUserPubkey?: string
|
||||
followedPubkeys: Set<string>
|
||||
activeAccount?: IAccount | null
|
||||
currentArticle?: NostrEvent | null
|
||||
|
||||
// Highlights pane
|
||||
highlights: Highlight[]
|
||||
@@ -80,12 +86,24 @@ interface ThreePaneLayoutProps {
|
||||
|
||||
// Optional Explore content
|
||||
explore?: React.ReactNode
|
||||
|
||||
// Optional Me content
|
||||
me?: React.ReactNode
|
||||
}
|
||||
|
||||
const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
const isMobile = useIsMobile()
|
||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||
const highlightsRef = useRef<HTMLDivElement>(null)
|
||||
const mainPaneRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Detect scroll direction to hide/show mobile buttons
|
||||
// Now using window scroll (document scroll) instead of pane scroll
|
||||
const scrollDirection = useScrollDirection({
|
||||
threshold: 10,
|
||||
enabled: isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed
|
||||
})
|
||||
const showMobileButtons = scrollDirection !== 'down'
|
||||
|
||||
// Lock body scroll when mobile sidebar or highlights is open
|
||||
useEffect(() => {
|
||||
@@ -102,22 +120,24 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
|
||||
// Handle ESC key to close sidebar or highlights
|
||||
useEffect(() => {
|
||||
const { isSidebarOpen, isHighlightsCollapsed, onToggleSidebar, onToggleHighlightsPanel } = props
|
||||
|
||||
if (!isMobile) return
|
||||
if (!props.isSidebarOpen && props.isHighlightsCollapsed) return
|
||||
if (!isSidebarOpen && isHighlightsCollapsed) return
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (props.isSidebarOpen) {
|
||||
props.onToggleSidebar()
|
||||
} else if (!props.isHighlightsCollapsed) {
|
||||
props.onToggleHighlightsPanel()
|
||||
if (isSidebarOpen) {
|
||||
onToggleSidebar()
|
||||
} else if (!isHighlightsCollapsed) {
|
||||
onToggleHighlightsPanel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => document.removeEventListener('keydown', handleEscape)
|
||||
}, [isMobile, props.isSidebarOpen, props.isHighlightsCollapsed, props.onToggleSidebar, props.onToggleHighlightsPanel])
|
||||
}, [isMobile, props])
|
||||
|
||||
// Trap focus in sidebar when open on mobile
|
||||
useEffect(() => {
|
||||
@@ -204,7 +224,15 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
{/* Mobile bookmark button - only show when viewing article */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && (
|
||||
<button
|
||||
className="mobile-hamburger-btn"
|
||||
className={`fixed z-[900] bg-[#2a2a2a] border border-[#444] rounded-lg text-[#ddd] flex items-center justify-center transition-all duration-300 active:scale-95 md:hidden ${
|
||||
showMobileButtons ? 'opacity-100 visible' : 'opacity-0 invisible pointer-events-none'
|
||||
}`}
|
||||
style={{
|
||||
top: 'calc(1rem + env(safe-area-inset-top))',
|
||||
left: 'calc(1rem + env(safe-area-inset-left))',
|
||||
width: 'var(--min-touch-target)',
|
||||
height: 'var(--min-touch-target)'
|
||||
}}
|
||||
onClick={props.onToggleSidebar}
|
||||
aria-label="Open bookmarks"
|
||||
aria-expanded={props.isSidebarOpen}
|
||||
@@ -216,7 +244,17 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
{/* Mobile highlights button - only show when viewing article */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && (
|
||||
<button
|
||||
className="mobile-highlights-btn"
|
||||
className={`fixed z-[900] border border-[#444] rounded-lg flex items-center justify-center transition-all duration-300 active:scale-95 md:hidden ${
|
||||
showMobileButtons ? 'opacity-100 visible' : 'opacity-0 invisible pointer-events-none'
|
||||
}`}
|
||||
style={{
|
||||
top: 'calc(1rem + env(safe-area-inset-top))',
|
||||
right: 'calc(1rem + env(safe-area-inset-right))',
|
||||
width: 'var(--min-touch-target)',
|
||||
height: 'var(--min-touch-target)',
|
||||
backgroundColor: props.settings.highlightColorMine || '#ffff00',
|
||||
color: '#000'
|
||||
}}
|
||||
onClick={props.onToggleHighlightsPanel}
|
||||
aria-label="Open highlights"
|
||||
aria-expanded={!props.isHighlightsCollapsed}
|
||||
@@ -228,7 +266,9 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
{/* Mobile backdrop */}
|
||||
{isMobile && (
|
||||
<div
|
||||
className={`mobile-sidebar-backdrop ${(props.isSidebarOpen || !props.isHighlightsCollapsed) ? 'visible' : ''}`}
|
||||
className={`fixed inset-0 bg-black/45 z-[999] transition-opacity duration-300 ${
|
||||
(props.isSidebarOpen || !props.isHighlightsCollapsed) ? 'block opacity-100' : 'hidden opacity-0'
|
||||
}`}
|
||||
onClick={handleBackdropClick}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@@ -259,7 +299,10 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</div>
|
||||
<div className={`pane main ${isMobile && (props.isSidebarOpen || !props.isHighlightsCollapsed) ? 'mobile-hidden' : ''}`}>
|
||||
<div
|
||||
ref={mainPaneRef}
|
||||
className={`pane main ${isMobile && (props.isSidebarOpen || !props.isHighlightsCollapsed) ? 'mobile-hidden' : ''}`}
|
||||
>
|
||||
{props.showSettings ? (
|
||||
<Settings
|
||||
settings={props.settings}
|
||||
@@ -272,6 +315,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
<>
|
||||
{props.explore}
|
||||
</>
|
||||
) : props.showMe && props.me ? (
|
||||
// Render Me inside the main pane to keep side panels
|
||||
<>
|
||||
{props.me}
|
||||
</>
|
||||
) : (
|
||||
<ContentPanel
|
||||
loading={props.readerLoading}
|
||||
@@ -294,6 +342,9 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
currentUserPubkey={props.currentUserPubkey}
|
||||
followedPubkeys={props.followedPubkeys}
|
||||
settings={props.settings}
|
||||
relayPool={props.relayPool}
|
||||
activeAccount={props.activeAccount}
|
||||
currentArticle={props.currentArticle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -319,6 +370,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
followedPubkeys={props.followedPubkeys}
|
||||
relayPool={props.relayPool}
|
||||
eventStore={props.eventStore}
|
||||
settings={props.settings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -326,10 +378,13 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
<HighlightButton
|
||||
ref={props.highlightButtonRef}
|
||||
onHighlight={props.onCreateHighlight}
|
||||
highlightColor={props.settings.highlightColor || '#ffff00'}
|
||||
highlightColor={props.settings.highlightColorMine || '#ffff00'}
|
||||
/>
|
||||
)}
|
||||
<RelayStatusIndicator relayPool={props.relayPool} />
|
||||
<RelayStatusIndicator
|
||||
relayPool={props.relayPool}
|
||||
showOnMobile={showMobileButtons}
|
||||
/>
|
||||
{props.toastMessage && (
|
||||
<Toast
|
||||
message={props.toastMessage}
|
||||
|
||||
34
src/config/nostrGateways.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Nostr gateway URLs for viewing events and profiles on the web
|
||||
*/
|
||||
|
||||
export const NOSTR_GATEWAY = 'https://ants.sh' as const
|
||||
|
||||
/**
|
||||
* Get a profile URL on the gateway
|
||||
*/
|
||||
export function getProfileUrl(npub: string): string {
|
||||
return `${NOSTR_GATEWAY}/p/${npub}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an event URL on the gateway
|
||||
*/
|
||||
export function getEventUrl(nevent: string): string {
|
||||
return `${NOSTR_GATEWAY}/e/${nevent}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a general nostr link on the gateway
|
||||
* Automatically detects if it's a profile (npub/nprofile) or event (note/nevent/naddr)
|
||||
*/
|
||||
export function getNostrUrl(identifier: string): string {
|
||||
// Check the prefix to determine if it's a profile or event
|
||||
if (identifier.startsWith('npub') || identifier.startsWith('nprofile')) {
|
||||
return `${NOSTR_GATEWAY}/p/${identifier}`
|
||||
}
|
||||
|
||||
// Everything else (note, nevent, naddr) goes to /e/
|
||||
return `${NOSTR_GATEWAY}/e/${identifier}`
|
||||
}
|
||||
|
||||
@@ -44,10 +44,14 @@ export const useBookmarksData = ({
|
||||
|
||||
const handleFetchBookmarks = useCallback(async () => {
|
||||
if (!relayPool || !activeAccount) return
|
||||
// don't clear existing bookmarks: we keep UI stable and show spinner unobtrusively
|
||||
setBookmarksLoading(true)
|
||||
try {
|
||||
const fullAccount = accountManager.getActive()
|
||||
await fetchBookmarks(relayPool, fullAccount || activeAccount, setBookmarks, settings)
|
||||
// merge-friendly: updater form that preserves visible list until replacement
|
||||
await fetchBookmarks(relayPool, fullAccount || activeAccount, (next) => {
|
||||
setBookmarks(() => next)
|
||||
}, settings)
|
||||
} finally {
|
||||
setBookmarksLoading(false)
|
||||
}
|
||||
@@ -102,15 +106,21 @@ export const useBookmarksData = ({
|
||||
}
|
||||
}, [relayPool, activeAccount, isRefreshing, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
|
||||
|
||||
// Load initial data
|
||||
// Load initial data (avoid clearing on route-only changes)
|
||||
useEffect(() => {
|
||||
if (!relayPool || !activeAccount) return
|
||||
// Only (re)fetch bookmarks when account or relayPool changes, not on naddr route changes
|
||||
handleFetchBookmarks()
|
||||
}, [relayPool, activeAccount, handleFetchBookmarks])
|
||||
|
||||
// Fetch highlights/contacts independently to avoid disturbing bookmarks
|
||||
useEffect(() => {
|
||||
if (!relayPool || !activeAccount) return
|
||||
if (!naddr) {
|
||||
handleFetchHighlights()
|
||||
}
|
||||
handleFetchContacts()
|
||||
}, [relayPool, activeAccount?.pubkey, naddr, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
|
||||
}, [relayPool, activeAccount, naddr, handleFetchHighlights, handleFetchContacts])
|
||||
|
||||
return {
|
||||
bookmarks,
|
||||
|
||||
@@ -11,7 +11,7 @@ interface UseExternalUrlLoaderProps {
|
||||
setReaderContent: (content: ReadableContent | undefined) => void
|
||||
setReaderLoading: (loading: boolean) => void
|
||||
setIsCollapsed: (collapsed: boolean) => void
|
||||
setHighlights: (highlights: Highlight[]) => void
|
||||
setHighlights: (highlights: Highlight[] | ((prev: Highlight[]) => Highlight[])) => void
|
||||
setHighlightsLoading: (loading: boolean) => void
|
||||
setCurrentArticleCoordinate: (coord: string | undefined) => void
|
||||
setCurrentArticleEventId: (id: string | undefined) => void
|
||||
@@ -57,7 +57,21 @@ export function useExternalUrlLoader({
|
||||
|
||||
// Check if fetchHighlightsForUrl exists, otherwise skip
|
||||
if (typeof fetchHighlightsForUrl === 'function') {
|
||||
const highlightsList = await fetchHighlightsForUrl(relayPool, url)
|
||||
const seen = new Set<string>()
|
||||
const highlightsList = await fetchHighlightsForUrl(
|
||||
relayPool,
|
||||
url,
|
||||
(highlight) => {
|
||||
if (seen.has(highlight.id)) return
|
||||
seen.add(highlight.id)
|
||||
setHighlights((prev) => {
|
||||
if (prev.some(h => h.id === highlight.id)) return prev
|
||||
const next = [...prev, highlight]
|
||||
return next.sort((a, b) => b.created_at - a.created_at)
|
||||
})
|
||||
}
|
||||
)
|
||||
// Ensure final list is sorted and contains all items
|
||||
setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at))
|
||||
console.log(`📌 Found ${highlightsList.length} highlights for URL`)
|
||||
} else {
|
||||
|
||||
@@ -56,8 +56,8 @@ export const useHighlightInteractions = ({
|
||||
}
|
||||
}, [selectedHighlightId])
|
||||
|
||||
// Handle text selection
|
||||
const handleMouseUp = useCallback(() => {
|
||||
// Handle text selection (works for both mouse and touch)
|
||||
const handleSelectionEnd = useCallback(() => {
|
||||
setTimeout(() => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
@@ -76,6 +76,6 @@ export const useHighlightInteractions = ({
|
||||
}, 10)
|
||||
}, [onTextSelection, onClearSelection])
|
||||
|
||||
return { contentRef, handleMouseUp }
|
||||
return { contentRef, handleSelectionEnd }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,34 +1,86 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { extractNaddrUris, replaceNostrUrisInMarkdown, replaceNostrUrisInMarkdownWithTitles } from '../utils/nostrUriResolver'
|
||||
import { fetchArticleTitles } from '../services/articleTitleResolver'
|
||||
|
||||
/**
|
||||
* Hook to convert markdown to HTML using a hidden ReactMarkdown component
|
||||
* Also processes nostr: URIs in the markdown and resolves article titles
|
||||
*/
|
||||
export const useMarkdownToHTML = (markdown?: string): { renderedHtml: string, previewRef: React.RefObject<HTMLDivElement> } => {
|
||||
export const useMarkdownToHTML = (
|
||||
markdown?: string,
|
||||
relayPool?: RelayPool | null
|
||||
): {
|
||||
renderedHtml: string
|
||||
previewRef: React.RefObject<HTMLDivElement>
|
||||
processedMarkdown: string
|
||||
} => {
|
||||
const previewRef = useRef<HTMLDivElement>(null)
|
||||
const [renderedHtml, setRenderedHtml] = useState<string>('')
|
||||
const [processedMarkdown, setProcessedMarkdown] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!markdown) {
|
||||
setRenderedHtml('')
|
||||
setProcessedMarkdown('')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('📝 Converting markdown to HTML...')
|
||||
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
if (previewRef.current) {
|
||||
const html = previewRef.current.innerHTML
|
||||
console.log('✅ Markdown converted to HTML:', html.length, 'chars')
|
||||
setRenderedHtml(html)
|
||||
let isCancelled = false
|
||||
|
||||
const processMarkdown = async () => {
|
||||
// Extract all naddr references
|
||||
const naddrs = extractNaddrUris(markdown)
|
||||
|
||||
let processed: string
|
||||
|
||||
if (naddrs.length > 0 && relayPool) {
|
||||
// Fetch article titles for all naddrs
|
||||
try {
|
||||
const articleTitles = await fetchArticleTitles(relayPool, naddrs)
|
||||
|
||||
if (isCancelled) return
|
||||
|
||||
// Replace nostr URIs with resolved titles
|
||||
processed = replaceNostrUrisInMarkdownWithTitles(markdown, articleTitles)
|
||||
console.log(`📚 Resolved ${articleTitles.size} article titles`)
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch article titles:', error)
|
||||
// Fall back to basic replacement
|
||||
processed = replaceNostrUrisInMarkdown(markdown)
|
||||
}
|
||||
} else {
|
||||
console.warn('⚠️ markdownPreviewRef.current is null')
|
||||
// No articles to resolve, use basic replacement
|
||||
processed = replaceNostrUrisInMarkdown(markdown)
|
||||
}
|
||||
})
|
||||
|
||||
if (isCancelled) return
|
||||
|
||||
setProcessedMarkdown(processed)
|
||||
|
||||
return () => cancelAnimationFrame(rafId)
|
||||
}, [markdown])
|
||||
console.log('📝 Converting markdown to HTML...')
|
||||
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
if (previewRef.current && !isCancelled) {
|
||||
const html = previewRef.current.innerHTML
|
||||
console.log('✅ Markdown converted to HTML:', html.length, 'chars')
|
||||
setRenderedHtml(html)
|
||||
} else if (!isCancelled) {
|
||||
console.warn('⚠️ markdownPreviewRef.current is null')
|
||||
}
|
||||
})
|
||||
|
||||
return { renderedHtml, previewRef }
|
||||
return () => cancelAnimationFrame(rafId)
|
||||
}
|
||||
|
||||
processMarkdown()
|
||||
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [markdown, relayPool])
|
||||
|
||||
return { renderedHtml, previewRef, processedMarkdown }
|
||||
}
|
||||
|
||||
// Removed separate useMarkdownPreviewRef; use useMarkdownToHTML to obtain previewRef
|
||||
|
||||
28
src/hooks/useOnlineStatus.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export function useOnlineStatus() {
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||
|
||||
useEffect(() => {
|
||||
const handleOnline = () => {
|
||||
console.log('🌐 Back online')
|
||||
setIsOnline(true)
|
||||
}
|
||||
|
||||
const handleOffline = () => {
|
||||
console.log('📴 Gone offline')
|
||||
setIsOnline(false)
|
||||
}
|
||||
|
||||
window.addEventListener('online', handleOnline)
|
||||
window.addEventListener('offline', handleOffline)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline)
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return isOnline
|
||||
}
|
||||
|
||||
74
src/hooks/usePWAInstall.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt: () => Promise<void>
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>
|
||||
}
|
||||
|
||||
export function usePWAInstall() {
|
||||
const [deferredPrompt, setDeferredPrompt] = useState<BeforeInstallPromptEvent | null>(null)
|
||||
const [isInstallable, setIsInstallable] = useState(false)
|
||||
const [isInstalled, setIsInstalled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Check if app is already installed
|
||||
if (window.matchMedia('(display-mode: standalone)').matches) {
|
||||
setIsInstalled(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Listen for the beforeinstallprompt event
|
||||
const handleBeforeInstallPrompt = (e: Event) => {
|
||||
e.preventDefault()
|
||||
const installPromptEvent = e as BeforeInstallPromptEvent
|
||||
setDeferredPrompt(installPromptEvent)
|
||||
setIsInstallable(true)
|
||||
}
|
||||
|
||||
// Listen for successful installation
|
||||
const handleAppInstalled = () => {
|
||||
setIsInstalled(true)
|
||||
setIsInstallable(false)
|
||||
setDeferredPrompt(null)
|
||||
}
|
||||
|
||||
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
|
||||
window.addEventListener('appinstalled', handleAppInstalled)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
|
||||
window.removeEventListener('appinstalled', handleAppInstalled)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const installApp = async () => {
|
||||
if (!deferredPrompt) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
await deferredPrompt.prompt()
|
||||
const choiceResult = await deferredPrompt.userChoice
|
||||
|
||||
if (choiceResult.outcome === 'accepted') {
|
||||
console.log('✅ PWA installed')
|
||||
setIsInstallable(false)
|
||||
setDeferredPrompt(null)
|
||||
return true
|
||||
} else {
|
||||
console.log('❌ PWA installation dismissed')
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error installing PWA:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isInstallable,
|
||||
isInstalled,
|
||||
installApp,
|
||||
}
|
||||
}
|
||||
|
||||
73
src/hooks/useReadingPosition.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface UseReadingPositionOptions {
|
||||
enabled?: boolean
|
||||
onPositionChange?: (position: number) => void
|
||||
onReadingComplete?: () => void
|
||||
readingCompleteThreshold?: number // Default 0.9 (90%)
|
||||
}
|
||||
|
||||
export const useReadingPosition = ({
|
||||
enabled = true,
|
||||
onPositionChange,
|
||||
onReadingComplete,
|
||||
readingCompleteThreshold = 0.9
|
||||
}: UseReadingPositionOptions = {}) => {
|
||||
const [position, setPosition] = useState(0)
|
||||
const [isReadingComplete, setIsReadingComplete] = useState(false)
|
||||
const hasTriggeredComplete = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
|
||||
const handleScroll = () => {
|
||||
// Get the main content area (reader content)
|
||||
const readerContent = document.querySelector('.reader-html, .reader-markdown')
|
||||
if (!readerContent) return
|
||||
|
||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
|
||||
const windowHeight = window.innerHeight
|
||||
const documentHeight = document.documentElement.scrollHeight
|
||||
|
||||
// Calculate position based on how much of the content has been scrolled through
|
||||
const scrollProgress = Math.min(scrollTop / (documentHeight - windowHeight), 1)
|
||||
const clampedProgress = Math.max(0, Math.min(1, scrollProgress))
|
||||
|
||||
setPosition(clampedProgress)
|
||||
onPositionChange?.(clampedProgress)
|
||||
|
||||
// Check if reading is complete
|
||||
if (clampedProgress >= readingCompleteThreshold && !hasTriggeredComplete.current) {
|
||||
setIsReadingComplete(true)
|
||||
hasTriggeredComplete.current = true
|
||||
onReadingComplete?.()
|
||||
}
|
||||
}
|
||||
|
||||
// Initial calculation
|
||||
handleScroll()
|
||||
|
||||
// Add scroll listener
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
window.addEventListener('resize', handleScroll, { passive: true })
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
window.removeEventListener('resize', handleScroll)
|
||||
}
|
||||
}, [enabled, onPositionChange, onReadingComplete, readingCompleteThreshold])
|
||||
|
||||
// Reset reading complete state when enabled changes
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setIsReadingComplete(false)
|
||||
hasTriggeredComplete.current = false
|
||||
}
|
||||
}, [enabled])
|
||||
|
||||
return {
|
||||
position,
|
||||
isReadingComplete,
|
||||
progressPercentage: Math.round(position * 100)
|
||||
}
|
||||
}
|
||||
70
src/hooks/useScrollDirection.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useState, useEffect, RefObject } from 'react'
|
||||
|
||||
export type ScrollDirection = 'up' | 'down' | 'none'
|
||||
|
||||
interface UseScrollDirectionOptions {
|
||||
threshold?: number
|
||||
enabled?: boolean
|
||||
elementRef?: RefObject<HTMLElement>
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to detect scroll direction on window or a specific element
|
||||
* @param options Configuration options
|
||||
* @param options.threshold Minimum scroll distance to trigger direction change (default: 10)
|
||||
* @param options.enabled Whether scroll detection is enabled (default: true)
|
||||
* @param options.elementRef Optional ref to a scrollable element (uses window if not provided)
|
||||
* @returns Current scroll direction ('up', 'down', or 'none')
|
||||
*/
|
||||
export function useScrollDirection({
|
||||
threshold = 10,
|
||||
enabled = true,
|
||||
elementRef
|
||||
}: UseScrollDirectionOptions = {}): ScrollDirection {
|
||||
const [scrollDirection, setScrollDirection] = useState<ScrollDirection>('none')
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
|
||||
const scrollElement = elementRef?.current || window
|
||||
const getScrollY = () => {
|
||||
if (elementRef?.current) {
|
||||
return elementRef.current.scrollTop
|
||||
}
|
||||
return window.scrollY
|
||||
}
|
||||
|
||||
let lastScrollY = getScrollY()
|
||||
let ticking = false
|
||||
|
||||
const updateScrollDirection = () => {
|
||||
const scrollY = getScrollY()
|
||||
|
||||
// Only update if scroll distance exceeds threshold
|
||||
if (Math.abs(scrollY - lastScrollY) < threshold) {
|
||||
ticking = false
|
||||
return
|
||||
}
|
||||
|
||||
setScrollDirection(scrollY > lastScrollY ? 'down' : 'up')
|
||||
lastScrollY = scrollY > 0 ? scrollY : 0
|
||||
ticking = false
|
||||
}
|
||||
|
||||
const onScroll = () => {
|
||||
if (!ticking) {
|
||||
window.requestAnimationFrame(updateScrollDirection)
|
||||
ticking = true
|
||||
}
|
||||
}
|
||||
|
||||
scrollElement.addEventListener('scroll', onScroll)
|
||||
|
||||
return () => {
|
||||
scrollElement.removeEventListener('scroll', onScroll)
|
||||
}
|
||||
}, [threshold, enabled, elementRef])
|
||||
|
||||
return scrollDirection
|
||||
}
|
||||
|
||||
1
src/icons/books.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!-- Font Awesome Pro 6.0.0-alpha2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) --><path d="M510.354 435.363L402.686 35.422C396.939 14.078 377.547 0 356.354 0C352.242 0 348.059 0.531 343.896 1.641L282.078 18.125C276.193 19.695 270.939 22.383 266.295 25.758C258.254 10.508 242.436 0 224 0H160C151.213 0 143.084 2.531 136 6.656C128.916 2.531 120.787 0 112 0H48C21.49 0 0 21.492 0 48V464C0 490.508 21.49 512 48 512H112C120.787 512 128.916 509.469 136 505.344C143.084 509.469 151.213 512 160 512H224C250.51 512 272 490.508 272 464V165.281L355.805 476.578C361.553 497.926 380.945 512 402.139 512C406.25 512 410.432 511.469 414.594 510.359L476.412 493.875C502.018 487.043 517.215 460.848 510.354 435.363ZM224 48V96H160V48H224ZM160 144H224V368H160V144ZM112 368H48V144H112V368ZM112 48V96H48V48H112ZM48 464V416H112V464H48ZM160 464V416H224V464H160ZM294.445 64.504L356.271 48.02L356.361 48L368.742 93.93L306.828 110.445L294.445 64.504ZM319.266 156.586L381.18 140.074L439.223 355.41L377.309 371.922L319.266 156.586ZM402.154 464.102L389.746 418.066L451.66 401.555L464.045 447.496L402.154 464.102Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
52
src/icons/customIcons.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { IconDefinition, IconPrefix, IconName } from '@fortawesome/fontawesome-svg-core'
|
||||
import booksSvg from './books.svg?raw'
|
||||
|
||||
/**
|
||||
* Custom icon definitions for FontAwesome Pro icons
|
||||
* or any custom SVG icons that aren't in the free tier
|
||||
*/
|
||||
|
||||
function parseSvgToIconDefinition(svg: string, options: { prefix: IconPrefix, iconName: IconName, unicode?: string }): IconDefinition {
|
||||
const { prefix, iconName, unicode = 'e002' } = options
|
||||
|
||||
// Extract viewBox first; fallback to width/height
|
||||
const viewBoxMatch = svg.match(/viewBox\s*=\s*"([^"]+)"/i)
|
||||
let width = 512
|
||||
let height = 512
|
||||
if (viewBoxMatch) {
|
||||
const parts = viewBoxMatch[1].trim().split(/\s+/)
|
||||
if (parts.length === 4) {
|
||||
const w = Number(parts[2])
|
||||
const h = Number(parts[3])
|
||||
if (!Number.isNaN(w)) width = Math.round(w)
|
||||
if (!Number.isNaN(h)) height = Math.round(h)
|
||||
}
|
||||
} else {
|
||||
const widthMatch = svg.match(/\bwidth\s*=\s*"(\d+(?:\.\d+)?)"/i)
|
||||
const heightMatch = svg.match(/\bheight\s*=\s*"(\d+(?:\.\d+)?)"/i)
|
||||
if (widthMatch) width = Math.round(Number(widthMatch[1]))
|
||||
if (heightMatch) height = Math.round(Number(heightMatch[1]))
|
||||
}
|
||||
|
||||
// Collect all path d attributes
|
||||
const pathDs: string[] = []
|
||||
const pathRegex = /<path[^>]*\sd=\s*"([^"]+)"[^>]*>/gi
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = pathRegex.exec(svg)) !== null) {
|
||||
pathDs.push(m[1])
|
||||
}
|
||||
|
||||
const pathData = pathDs.length <= 1 ? (pathDs[0] || '') : pathDs
|
||||
|
||||
return {
|
||||
prefix,
|
||||
iconName,
|
||||
icon: [width, height, [], unicode, pathData]
|
||||
}
|
||||
}
|
||||
|
||||
export const faBooks: IconDefinition = parseSvgToIconDefinition(booksSvg, {
|
||||
prefix: 'far',
|
||||
iconName: 'books'
|
||||
})
|
||||
|
||||
3082
src/index.css
21
src/main.tsx
@@ -1,23 +1,34 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './styles/tailwind.css'
|
||||
import './index.css'
|
||||
|
||||
// Register Service Worker for offline image caching
|
||||
// Register Service Worker for PWA functionality
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js')
|
||||
.register('/sw.js', { type: 'module' })
|
||||
.then(registration => {
|
||||
console.log('✅ Service Worker registered:', registration.scope)
|
||||
|
||||
// Update service worker when a new version is available
|
||||
// Check for updates periodically
|
||||
setInterval(() => {
|
||||
registration.update()
|
||||
}, 60 * 60 * 1000) // Check every hour
|
||||
|
||||
// Handle service worker updates
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing
|
||||
if (newWorker) {
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'activated') {
|
||||
console.log('🔄 Service Worker updated, page may need reload')
|
||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
// New service worker available
|
||||
console.log('🔄 New version available! Reload to update.')
|
||||
|
||||
// Optionally show a toast notification
|
||||
const updateAvailable = new CustomEvent('sw-update-available')
|
||||
window.dispatchEvent(updateAvailable)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { RelayPool, completeOnEose } from 'applesauce-relay'
|
||||
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { lastValueFrom, take } from 'rxjs'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { AddressPointer } from 'nostr-tools/nip19'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { prioritizeLocalRelays, partitionRelays, createParallelReqStreams } from '../utils/helpers'
|
||||
import { merge, toArray as rxToArray } from 'rxjs'
|
||||
import { UserSettings } from './settingsService'
|
||||
import { rebroadcastEvents } from './rebroadcastService'
|
||||
|
||||
@@ -98,9 +100,11 @@ export async function fetchArticleByNaddr(
|
||||
const pointer = decoded.data as AddressPointer
|
||||
|
||||
// Define relays to query - prefer relays from naddr, fallback to configured relays (including local)
|
||||
const relays = pointer.relays && pointer.relays.length > 0
|
||||
const baseRelays = pointer.relays && pointer.relays.length > 0
|
||||
? pointer.relays
|
||||
: RELAYS
|
||||
const orderedRelays = prioritizeLocalRelays(baseRelays)
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
|
||||
|
||||
// Fetch the article event
|
||||
const filter = {
|
||||
@@ -109,12 +113,10 @@ export async function fetchArticleByNaddr(
|
||||
'#d': [pointer.identifier]
|
||||
}
|
||||
|
||||
// Use applesauce relay pool pattern
|
||||
const events = await lastValueFrom(
|
||||
relayPool
|
||||
.req(relays, filter)
|
||||
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
|
||||
)
|
||||
// Parallel local+remote, stream immediate, collect up to first from each
|
||||
const { local$, remote$ } = createParallelReqStreams(relayPool, localRelays, remoteRelays, filter, 1200, 6000)
|
||||
const collected = await lastValueFrom(merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray()))
|
||||
const events = collected as NostrEvent[]
|
||||
|
||||
if (events.length === 0) {
|
||||
throw new Error('Article not found')
|
||||
|
||||
91
src/services/articleTitleResolver.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { lastValueFrom, take } from 'rxjs'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { AddressPointer } from 'nostr-tools/nip19'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { prioritizeLocalRelays, partitionRelays, createParallelReqStreams } from '../utils/helpers'
|
||||
import { merge, toArray as rxToArray } from 'rxjs'
|
||||
|
||||
const { getArticleTitle } = Helpers
|
||||
|
||||
/**
|
||||
* Fetch article title for a single naddr
|
||||
* Returns the title or null if not found/error
|
||||
*/
|
||||
export async function fetchArticleTitle(
|
||||
relayPool: RelayPool,
|
||||
naddr: string
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const decoded = nip19.decode(naddr)
|
||||
|
||||
if (decoded.type !== 'naddr') {
|
||||
return null
|
||||
}
|
||||
|
||||
const pointer = decoded.data as AddressPointer
|
||||
|
||||
// Define relays to query
|
||||
const baseRelays = pointer.relays && pointer.relays.length > 0
|
||||
? pointer.relays
|
||||
: RELAYS
|
||||
const orderedRelays = prioritizeLocalRelays(baseRelays)
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
|
||||
|
||||
// Fetch the article event
|
||||
const filter = {
|
||||
kinds: [pointer.kind],
|
||||
authors: [pointer.pubkey],
|
||||
'#d': [pointer.identifier]
|
||||
}
|
||||
|
||||
// Parallel local+remote: collect up to one event from each
|
||||
const { local$, remote$ } = createParallelReqStreams(relayPool, localRelays, remoteRelays, filter, 1200, 5000)
|
||||
const events = await lastValueFrom(
|
||||
merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray())
|
||||
) as unknown as { created_at: number }[]
|
||||
|
||||
if (events.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Sort by created_at and take the most recent
|
||||
events.sort((a, b) => b.created_at - a.created_at)
|
||||
const article = events[0] as unknown as Parameters<typeof getArticleTitle>[0]
|
||||
|
||||
return getArticleTitle(article) || null
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch article title for', naddr, err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch titles for multiple naddrs in parallel
|
||||
* Returns a map of naddr -> title
|
||||
*/
|
||||
export async function fetchArticleTitles(
|
||||
relayPool: RelayPool,
|
||||
naddrs: string[]
|
||||
): Promise<Map<string, string>> {
|
||||
const titleMap = new Map<string, string>()
|
||||
|
||||
// Fetch all titles in parallel
|
||||
const results = await Promise.allSettled(
|
||||
naddrs.map(async (naddr) => {
|
||||
const title = await fetchArticleTitle(relayPool, naddr)
|
||||
return { naddr, title }
|
||||
})
|
||||
)
|
||||
|
||||
// Process results
|
||||
results.forEach((result) => {
|
||||
if (result.status === 'fulfilled' && result.value.title) {
|
||||
titleMap.set(result.value.naddr, result.value.title)
|
||||
}
|
||||
})
|
||||
|
||||
return titleMap
|
||||
}
|
||||
|
||||
@@ -60,8 +60,27 @@ export const processApplesauceBookmarks = (
|
||||
if (applesauceBookmarks.articles) allItems.push(...applesauceBookmarks.articles)
|
||||
if (applesauceBookmarks.hashtags) allItems.push(...applesauceBookmarks.hashtags)
|
||||
if (applesauceBookmarks.urls) allItems.push(...applesauceBookmarks.urls)
|
||||
return allItems.map((bookmark: BookmarkData) => ({
|
||||
id: bookmark.id || `${isPrivate ? 'private' : 'public'}-${Date.now()}`,
|
||||
return allItems
|
||||
.filter((bookmark: BookmarkData) => bookmark.id) // Skip bookmarks without valid IDs
|
||||
.map((bookmark: BookmarkData) => ({
|
||||
id: bookmark.id!,
|
||||
content: bookmark.content || '',
|
||||
created_at: bookmark.created_at || Math.floor(Date.now() / 1000),
|
||||
pubkey: activeAccount.pubkey,
|
||||
kind: bookmark.kind || 30001,
|
||||
tags: bookmark.tags || [],
|
||||
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
added_at: bookmark.created_at || Math.floor(Date.now() / 1000)
|
||||
}))
|
||||
}
|
||||
|
||||
const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks]
|
||||
return bookmarkArray
|
||||
.filter((bookmark: BookmarkData) => bookmark.id) // Skip bookmarks without valid IDs
|
||||
.map((bookmark: BookmarkData) => ({
|
||||
id: bookmark.id!,
|
||||
content: bookmark.content || '',
|
||||
created_at: bookmark.created_at || Math.floor(Date.now() / 1000),
|
||||
pubkey: activeAccount.pubkey,
|
||||
@@ -70,23 +89,8 @@ export const processApplesauceBookmarks = (
|
||||
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
added_at: Math.floor(Date.now() / 1000)
|
||||
added_at: bookmark.created_at || Math.floor(Date.now() / 1000)
|
||||
}))
|
||||
}
|
||||
|
||||
const bookmarkArray = Array.isArray(bookmarks) ? bookmarks : [bookmarks]
|
||||
return bookmarkArray.map((bookmark: BookmarkData) => ({
|
||||
id: bookmark.id || `${isPrivate ? 'private' : 'public'}-${Date.now()}`,
|
||||
content: bookmark.content || '',
|
||||
created_at: bookmark.created_at || Math.floor(Date.now() / 1000),
|
||||
pubkey: activeAccount.pubkey,
|
||||
kind: bookmark.kind || 30001,
|
||||
tags: bookmark.tags || [],
|
||||
parsedContent: bookmark.content ? (getParsedContent(bookmark.content) as ParsedContent) : undefined,
|
||||
type: 'event' as const,
|
||||
isPrivate,
|
||||
added_at: Math.floor(Date.now() / 1000)
|
||||
}))
|
||||
}
|
||||
|
||||
// Types and guards around signer/decryption APIs
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { RelayPool, completeOnEose } from 'applesauce-relay'
|
||||
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
|
||||
import {
|
||||
AccountWithExtension,
|
||||
NostrEvent,
|
||||
@@ -16,6 +16,7 @@ import { Bookmark } from '../types/bookmarks'
|
||||
import { collectBookmarksFromEvents } from './bookmarkProcessing.ts'
|
||||
import { UserSettings } from './settingsService'
|
||||
import { rebroadcastEvents } from './rebroadcastService'
|
||||
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
|
||||
|
||||
|
||||
|
||||
@@ -31,14 +32,22 @@ export const fetchBookmarks = async (
|
||||
throw new Error('Invalid account object provided')
|
||||
}
|
||||
// Get relay URLs from the pool
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
const relayUrls = prioritizeLocalRelays(Array.from(relayPool.relays.values()).map(relay => relay.url))
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(relayUrls)
|
||||
// Fetch bookmark events - NIP-51 standards, legacy formats, and web bookmarks (NIP-B0)
|
||||
console.log('🔍 Fetching bookmark events from relays:', relayUrls)
|
||||
const rawEvents = await lastValueFrom(
|
||||
relayPool
|
||||
.req(relayUrls, { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] })
|
||||
.pipe(completeOnEose(), takeUntil(timer(20000)), toArray())
|
||||
)
|
||||
// Try local-first quickly, then full set fallback
|
||||
const local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] })
|
||||
.pipe(completeOnEose(), takeUntil(timer(1200)))
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [10003, 30003, 30001, 39701], authors: [activeAccount.pubkey] })
|
||||
.pipe(completeOnEose(), takeUntil(timer(6000)))
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const rawEvents = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
||||
console.log('📊 Raw events fetched:', rawEvents.length, 'events')
|
||||
|
||||
// Rebroadcast bookmark events to local/all relays based on settings
|
||||
@@ -64,7 +73,7 @@ export const fetchBookmarks = async (
|
||||
const bookmarkListEvents = dedupeNip51Events(rawEvents)
|
||||
console.log('📋 After deduplication:', bookmarkListEvents.length, 'bookmark events')
|
||||
if (bookmarkListEvents.length === 0) {
|
||||
setBookmarks([])
|
||||
// Keep existing bookmarks visible; do not clear list if nothing new found
|
||||
return
|
||||
}
|
||||
// Aggregate across events
|
||||
@@ -102,9 +111,14 @@ export const fetchBookmarks = async (
|
||||
let idToEvent: Map<string, NostrEvent> = new Map()
|
||||
if (noteIds.length > 0) {
|
||||
try {
|
||||
const events = await lastValueFrom(
|
||||
relayPool.req(relayUrls, { ids: noteIds }).pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
|
||||
)
|
||||
const { local: localHydrate, remote: remoteHydrate } = partitionRelays(relayUrls)
|
||||
const localHydrate$ = localHydrate.length > 0
|
||||
? relayPool.req(localHydrate, { ids: noteIds }).pipe(completeOnEose(), takeUntil(timer(800)))
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const remoteHydrate$ = remoteHydrate.length > 0
|
||||
? relayPool.req(remoteHydrate, { ids: noteIds }).pipe(completeOnEose(), takeUntil(timer(2500)))
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const events: NostrEvent[] = await lastValueFrom(merge(localHydrate$, remoteHydrate$).pipe(toArray()))
|
||||
idToEvent = new Map(events.map((e: NostrEvent) => [e.id, e]))
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch events for hydration:', error)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { RelayPool, completeOnEose } from 'applesauce-relay'
|
||||
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { prioritizeLocalRelays } from '../utils/helpers'
|
||||
|
||||
/**
|
||||
* Fetches the contact list (follows) for a specific user
|
||||
@@ -9,40 +10,49 @@ import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
||||
*/
|
||||
export const fetchContacts = async (
|
||||
relayPool: RelayPool,
|
||||
pubkey: string
|
||||
pubkey: string,
|
||||
onPartial?: (contacts: Set<string>) => void
|
||||
): Promise<Set<string>> => {
|
||||
try {
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
const relayUrls = prioritizeLocalRelays(Array.from(relayPool.relays.values()).map(relay => relay.url))
|
||||
|
||||
console.log('🔍 Fetching contacts (kind 3) for user:', pubkey)
|
||||
|
||||
// Local-first quick attempt
|
||||
const localRelays = relayUrls.filter(url => url.includes('localhost') || url.includes('127.0.0.1'))
|
||||
const remoteRelays = relayUrls.filter(url => !url.includes('localhost') && !url.includes('127.0.0.1'))
|
||||
const local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [3], authors: [pubkey] })
|
||||
.pipe(completeOnEose(), takeUntil(timer(1200)))
|
||||
: new Observable<{ created_at: number; tags: string[][] }>((sub) => sub.complete())
|
||||
const remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [3], authors: [pubkey] })
|
||||
.pipe(completeOnEose(), takeUntil(timer(6000)))
|
||||
: new Observable<{ created_at: number; tags: string[][] }>((sub) => sub.complete())
|
||||
const events = await lastValueFrom(
|
||||
relayPool
|
||||
.req(relayUrls, { kinds: [3], authors: [pubkey] })
|
||||
.pipe(completeOnEose(), takeUntil(timer(10000)), toArray())
|
||||
merge(local$, remote$).pipe(toArray())
|
||||
)
|
||||
const followed = new Set<string>()
|
||||
if (events.length > 0) {
|
||||
// Get the most recent contact list
|
||||
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
|
||||
const contactList = sortedEvents[0]
|
||||
// Extract pubkeys from 'p' tags
|
||||
for (const tag of contactList.tags) {
|
||||
if (tag[0] === 'p' && tag[1]) {
|
||||
followed.add(tag[1])
|
||||
}
|
||||
}
|
||||
if (onPartial) onPartial(new Set(followed))
|
||||
}
|
||||
// merged already via streams
|
||||
|
||||
console.log('📊 Contact events fetched:', events.length)
|
||||
|
||||
if (events.length === 0) {
|
||||
return new Set()
|
||||
}
|
||||
|
||||
// Get the most recent contact list
|
||||
const sortedEvents = events.sort((a, b) => b.created_at - a.created_at)
|
||||
const contactList = sortedEvents[0]
|
||||
|
||||
// Extract pubkeys from 'p' tags
|
||||
const followedPubkeys = new Set<string>()
|
||||
for (const tag of contactList.tags) {
|
||||
if (tag[0] === 'p' && tag[1]) {
|
||||
followedPubkeys.add(tag[1])
|
||||
}
|
||||
}
|
||||
|
||||
console.log('👥 Followed contacts:', followedPubkeys.size)
|
||||
|
||||
return followedPubkeys
|
||||
console.log('👥 Followed contacts:', followed.size)
|
||||
return followed
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch contacts:', error)
|
||||
return new Set()
|
||||
|
||||
48
src/services/deletionService.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { EventFactory } from 'applesauce-factory'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { RELAYS } from '../config/relays'
|
||||
|
||||
/**
|
||||
* Creates a kind:5 event deletion request (NIP-09)
|
||||
* @param eventId The ID of the event to delete
|
||||
* @param eventKind The kind of the event being deleted
|
||||
* @param reason Optional reason for deletion
|
||||
* @param account The user's account for signing
|
||||
* @param relayPool The relay pool for publishing
|
||||
* @returns The signed deletion request event
|
||||
*/
|
||||
export async function createDeletionRequest(
|
||||
eventId: string,
|
||||
eventKind: number,
|
||||
reason: string | undefined,
|
||||
account: IAccount,
|
||||
relayPool: RelayPool
|
||||
): Promise<NostrEvent> {
|
||||
const factory = new EventFactory({ signer: account })
|
||||
|
||||
const tags: string[][] = [
|
||||
['e', eventId],
|
||||
['k', eventKind.toString()]
|
||||
]
|
||||
|
||||
const draft = await factory.create(async () => ({
|
||||
kind: 5, // Event Deletion Request
|
||||
content: reason || '',
|
||||
tags,
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
}))
|
||||
|
||||
const signed = await factory.sign(draft)
|
||||
|
||||
console.log('🗑️ Created kind:5 deletion request for event:', eventId.slice(0, 8))
|
||||
|
||||
// Publish to relays
|
||||
await relayPool.publish(RELAYS, signed)
|
||||
|
||||
console.log('✅ Deletion request published to', RELAYS.length, 'relay(s)')
|
||||
|
||||
return signed
|
||||
}
|
||||
|
||||
42
src/services/exploreCache.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
|
||||
export interface CachedBlogPostPreview {
|
||||
event: NostrEvent
|
||||
title: string
|
||||
summary?: string
|
||||
image?: string
|
||||
published?: number
|
||||
author: string
|
||||
}
|
||||
|
||||
type CacheValue = {
|
||||
posts: CachedBlogPostPreview[]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const exploreCache = new Map<string, CacheValue>() // key: pubkey
|
||||
|
||||
export function getCachedPosts(pubkey: string): CachedBlogPostPreview[] | null {
|
||||
const entry = exploreCache.get(pubkey)
|
||||
if (!entry) return null
|
||||
return entry.posts
|
||||
}
|
||||
|
||||
export function setCachedPosts(pubkey: string, posts: CachedBlogPostPreview[]): void {
|
||||
exploreCache.set(pubkey, { posts, timestamp: Date.now() })
|
||||
}
|
||||
|
||||
export function upsertCachedPost(pubkey: string, post: CachedBlogPostPreview): CachedBlogPostPreview[] {
|
||||
const current = exploreCache.get(pubkey)?.posts || []
|
||||
const byId = new Map(current.map(p => [p.event.id, p]))
|
||||
byId.set(post.event.id, post)
|
||||
const merged = Array.from(byId.values()).sort((a, b) => {
|
||||
const ta = a.published || a.event.created_at
|
||||
const tb = b.published || b.event.created_at
|
||||
return tb - ta
|
||||
})
|
||||
setCachedPosts(pubkey, merged)
|
||||
return merged
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { RelayPool, completeOnEose } from 'applesauce-relay'
|
||||
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
|
||||
@@ -24,7 +25,8 @@ export interface BlogPostPreview {
|
||||
export const fetchBlogPostsFromAuthors = async (
|
||||
relayPool: RelayPool,
|
||||
pubkeys: string[],
|
||||
relayUrls: string[]
|
||||
relayUrls: string[],
|
||||
onPost?: (post: BlogPostPreview) => void
|
||||
): Promise<BlogPostPreview[]> => {
|
||||
try {
|
||||
if (pubkeys.length === 0) {
|
||||
@@ -34,42 +36,65 @@ export const fetchBlogPostsFromAuthors = async (
|
||||
|
||||
console.log('📚 Fetching blog posts (kind 30023) from', pubkeys.length, 'authors')
|
||||
|
||||
const events = await lastValueFrom(
|
||||
relayPool
|
||||
.req(relayUrls, {
|
||||
kinds: [30023],
|
||||
authors: pubkeys,
|
||||
limit: 100 // Fetch up to 100 recent posts
|
||||
})
|
||||
.pipe(completeOnEose(), takeUntil(timer(15000)), toArray())
|
||||
)
|
||||
|
||||
console.log('📊 Blog post events fetched:', events.length)
|
||||
|
||||
const prioritized = prioritizeLocalRelays(relayUrls)
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(prioritized)
|
||||
|
||||
// Deduplicate replaceable events by keeping the most recent version
|
||||
// Group by author + d-tag identifier
|
||||
const uniqueEvents = new Map<string, NostrEvent>()
|
||||
|
||||
for (const event of events) {
|
||||
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const key = `${event.pubkey}:${dTag}`
|
||||
|
||||
const existing = uniqueEvents.get(key)
|
||||
if (!existing || event.created_at > existing.created_at) {
|
||||
uniqueEvents.set(key, event)
|
||||
|
||||
const processEvents = (incoming: NostrEvent[]) => {
|
||||
for (const event of incoming) {
|
||||
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
const key = `${event.pubkey}:${dTag}`
|
||||
const existing = uniqueEvents.get(key)
|
||||
if (!existing || event.created_at > existing.created_at) {
|
||||
uniqueEvents.set(key, event)
|
||||
// Emit as we incorporate
|
||||
if (onPost) {
|
||||
const post: BlogPostPreview = {
|
||||
event,
|
||||
title: getArticleTitle(event) || 'Untitled',
|
||||
summary: getArticleSummary(event),
|
||||
image: getArticleImage(event),
|
||||
published: getArticlePublished(event),
|
||||
author: event.pubkey
|
||||
}
|
||||
onPost(post)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [30023], authors: pubkeys, limit: 100 })
|
||||
.pipe(completeOnEose(), takeUntil(timer(1200)))
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [30023], authors: pubkeys, limit: 100 })
|
||||
.pipe(completeOnEose(), takeUntil(timer(6000)))
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const events = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
||||
processEvents(events)
|
||||
|
||||
console.log('📊 Blog post events fetched (unique):', uniqueEvents.size)
|
||||
|
||||
// Convert to blog post previews and sort by published date (most recent first)
|
||||
const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values())
|
||||
.map(event => ({
|
||||
event,
|
||||
title: getArticleTitle(event) || 'Untitled',
|
||||
summary: getArticleSummary(event),
|
||||
image: getArticleImage(event),
|
||||
published: getArticlePublished(event),
|
||||
author: event.pubkey
|
||||
}))
|
||||
.map(event => {
|
||||
const post: BlogPostPreview = {
|
||||
event,
|
||||
title: getArticleTitle(event) || 'Untitled',
|
||||
summary: getArticleSummary(event),
|
||||
image: getArticleImage(event),
|
||||
published: getArticlePublished(event),
|
||||
author: event.pubkey
|
||||
}
|
||||
if (onPost) onPost(post)
|
||||
return post
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
|
||||
@@ -1,204 +1,5 @@
|
||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||
import { lastValueFrom, takeUntil, timer, tap, toArray } from 'rxjs'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor'
|
||||
import { UserSettings } from './settingsService'
|
||||
import { rebroadcastEvents } from './rebroadcastService'
|
||||
export * from './highlights/fetchForArticle'
|
||||
export * from './highlights/fetchForUrl'
|
||||
export * from './highlights/fetchByAuthor'
|
||||
|
||||
/**
|
||||
* Fetches highlights for a specific article by its address coordinate and/or event ID
|
||||
* @param relayPool - The relay pool to query
|
||||
* @param articleCoordinate - The article's address in format "kind:pubkey:identifier" (e.g., "30023:abc...def:my-article")
|
||||
* @param eventId - Optional event ID to also query by 'e' tag
|
||||
* @param onHighlight - Optional callback to receive highlights as they arrive
|
||||
* @param settings - User settings for rebroadcast options
|
||||
*/
|
||||
export const fetchHighlightsForArticle = async (
|
||||
relayPool: RelayPool,
|
||||
articleCoordinate: string,
|
||||
eventId?: string,
|
||||
onHighlight?: (highlight: Highlight) => void,
|
||||
settings?: UserSettings
|
||||
): Promise<Highlight[]> => {
|
||||
try {
|
||||
console.log('🔍 Fetching highlights (kind 9802) for article:', articleCoordinate)
|
||||
console.log('🔍 Event ID:', eventId || 'none')
|
||||
console.log('🔍 From relays (including local):', RELAYS)
|
||||
|
||||
const seenIds = new Set<string>()
|
||||
const processEvent = (event: NostrEvent): Highlight | null => {
|
||||
if (seenIds.has(event.id)) return null
|
||||
seenIds.add(event.id)
|
||||
return eventToHighlight(event)
|
||||
}
|
||||
|
||||
// Query for highlights that reference this article via the 'a' tag
|
||||
const aTagEvents = await lastValueFrom(
|
||||
relayPool
|
||||
.req(RELAYS, { kinds: [9802], '#a': [articleCoordinate] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
const highlight = processEvent(event)
|
||||
if (highlight && onHighlight) {
|
||||
onHighlight(highlight)
|
||||
}
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(10000)),
|
||||
toArray()
|
||||
)
|
||||
)
|
||||
|
||||
console.log('📊 Highlights via a-tag:', aTagEvents.length)
|
||||
|
||||
// If we have an event ID, also query for highlights that reference via the 'e' tag
|
||||
let eTagEvents: NostrEvent[] = []
|
||||
if (eventId) {
|
||||
eTagEvents = await lastValueFrom(
|
||||
relayPool
|
||||
.req(RELAYS, { kinds: [9802], '#e': [eventId] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
const highlight = processEvent(event)
|
||||
if (highlight && onHighlight) {
|
||||
onHighlight(highlight)
|
||||
}
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(10000)),
|
||||
toArray()
|
||||
)
|
||||
)
|
||||
console.log('📊 Highlights via e-tag:', eTagEvents.length)
|
||||
}
|
||||
|
||||
// Combine results from both queries
|
||||
const rawEvents = [...aTagEvents, ...eTagEvents]
|
||||
console.log('📊 Total raw highlight events fetched:', rawEvents.length)
|
||||
|
||||
// Rebroadcast highlight events to local/all relays based on settings
|
||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
||||
|
||||
if (rawEvents.length > 0) {
|
||||
console.log('📄 Sample highlight tags:', JSON.stringify(rawEvents[0].tags, null, 2))
|
||||
} else {
|
||||
console.log('❌ No highlights found. Article coordinate:', articleCoordinate)
|
||||
console.log('❌ Event ID:', eventId || 'none')
|
||||
console.log('💡 Try checking if there are any highlights on this article at https://highlighter.com')
|
||||
}
|
||||
|
||||
// Deduplicate events by ID
|
||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||
console.log('📊 Unique highlight events after deduplication:', uniqueEvents.length)
|
||||
|
||||
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
|
||||
return sortHighlights(highlights)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch highlights for article:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches highlights for a specific URL
|
||||
* @param relayPool - The relay pool to query
|
||||
* @param url - The external URL to find highlights for
|
||||
* @param settings - User settings for rebroadcast options
|
||||
*/
|
||||
export const fetchHighlightsForUrl = async (
|
||||
relayPool: RelayPool,
|
||||
url: string,
|
||||
settings?: UserSettings
|
||||
): Promise<Highlight[]> => {
|
||||
try {
|
||||
console.log('🔍 Fetching highlights (kind 9802) for URL:', url)
|
||||
|
||||
const seenIds = new Set<string>()
|
||||
const rawEvents = await lastValueFrom(
|
||||
relayPool
|
||||
.req(RELAYS, { kinds: [9802], '#r': [url] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
seenIds.add(event.id)
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(10000)),
|
||||
toArray()
|
||||
)
|
||||
)
|
||||
|
||||
console.log('📊 Highlights for URL:', rawEvents.length)
|
||||
|
||||
// Rebroadcast highlight events to local/all relays based on settings
|
||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
||||
|
||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
|
||||
return sortHighlights(highlights)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch highlights for URL:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches highlights created by a specific user
|
||||
* @param relayPool - The relay pool to query
|
||||
* @param pubkey - The user's public key
|
||||
* @param onHighlight - Optional callback to receive highlights as they arrive
|
||||
* @param settings - User settings for rebroadcast options
|
||||
*/
|
||||
export const fetchHighlights = async (
|
||||
relayPool: RelayPool,
|
||||
pubkey: string,
|
||||
onHighlight?: (highlight: Highlight) => void,
|
||||
settings?: UserSettings
|
||||
): Promise<Highlight[]> => {
|
||||
try {
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
|
||||
console.log('🔍 Fetching highlights (kind 9802) by author:', pubkey)
|
||||
|
||||
const seenIds = new Set<string>()
|
||||
const rawEvents = await lastValueFrom(
|
||||
relayPool
|
||||
.req(relayUrls, { kinds: [9802], authors: [pubkey] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
if (!seenIds.has(event.id)) {
|
||||
seenIds.add(event.id)
|
||||
const highlight = eventToHighlight(event)
|
||||
if (onHighlight) {
|
||||
onHighlight(highlight)
|
||||
}
|
||||
}
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(10000)),
|
||||
toArray()
|
||||
)
|
||||
)
|
||||
|
||||
console.log('📊 Raw highlight events fetched:', rawEvents.length)
|
||||
|
||||
// Rebroadcast highlight events to local/all relays based on settings
|
||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
||||
|
||||
// Deduplicate and process events
|
||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||
console.log('📊 Unique highlight events after deduplication:', uniqueEvents.length)
|
||||
|
||||
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
|
||||
return sortHighlights(highlights)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch highlights by author:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
63
src/services/highlights/fetchByAuthor.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Highlight } from '../../types/highlights'
|
||||
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
|
||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
||||
import { UserSettings } from '../settingsService'
|
||||
import { rebroadcastEvents } from '../rebroadcastService'
|
||||
|
||||
export const fetchHighlights = async (
|
||||
relayPool: RelayPool,
|
||||
pubkey: string,
|
||||
onHighlight?: (highlight: Highlight) => void,
|
||||
settings?: UserSettings
|
||||
): Promise<Highlight[]> => {
|
||||
try {
|
||||
const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url)
|
||||
const ordered = prioritizeLocalRelays(relayUrls)
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(ordered)
|
||||
|
||||
const seenIds = new Set<string>()
|
||||
const local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [9802], authors: [pubkey] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
if (!seenIds.has(event.id)) {
|
||||
seenIds.add(event.id)
|
||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||
}
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(1200))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [9802], authors: [pubkey] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
if (!seenIds.has(event.id)) {
|
||||
seenIds.add(event.id)
|
||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||
}
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(6000))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
||||
|
||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||
const highlights = uniqueEvents.map(eventToHighlight)
|
||||
return sortHighlights(highlights)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
98
src/services/highlights/fetchForArticle.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Highlight } from '../../types/highlights'
|
||||
import { RELAYS } from '../../config/relays'
|
||||
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
|
||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
||||
import { UserSettings } from '../settingsService'
|
||||
import { rebroadcastEvents } from '../rebroadcastService'
|
||||
|
||||
export const fetchHighlightsForArticle = async (
|
||||
relayPool: RelayPool,
|
||||
articleCoordinate: string,
|
||||
eventId?: string,
|
||||
onHighlight?: (highlight: Highlight) => void,
|
||||
settings?: UserSettings
|
||||
): Promise<Highlight[]> => {
|
||||
try {
|
||||
const seenIds = new Set<string>()
|
||||
const processEvent = (event: NostrEvent): Highlight | null => {
|
||||
if (seenIds.has(event.id)) return null
|
||||
seenIds.add(event.id)
|
||||
return eventToHighlight(event)
|
||||
}
|
||||
|
||||
const orderedRelays = prioritizeLocalRelays(RELAYS)
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
|
||||
|
||||
const aLocal$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [9802], '#a': [articleCoordinate] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
const highlight = processEvent(event)
|
||||
if (highlight && onHighlight) onHighlight(highlight)
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(1200))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const aRemote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [9802], '#a': [articleCoordinate] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
const highlight = processEvent(event)
|
||||
if (highlight && onHighlight) onHighlight(highlight)
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(6000))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const aTagEvents: NostrEvent[] = await lastValueFrom(merge(aLocal$, aRemote$).pipe(toArray()))
|
||||
|
||||
let eTagEvents: NostrEvent[] = []
|
||||
if (eventId) {
|
||||
const eLocal$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [9802], '#e': [eventId] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
const highlight = processEvent(event)
|
||||
if (highlight && onHighlight) onHighlight(highlight)
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(1200))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const eRemote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [9802], '#e': [eventId] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
const highlight = processEvent(event)
|
||||
if (highlight && onHighlight) onHighlight(highlight)
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(6000))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
eTagEvents = await lastValueFrom(merge(eLocal$, eRemote$).pipe(toArray()))
|
||||
}
|
||||
|
||||
const rawEvents = [...aTagEvents, ...eTagEvents]
|
||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
|
||||
return sortHighlights(highlights)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
57
src/services/highlights/fetchForUrl.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||
import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Highlight } from '../../types/highlights'
|
||||
import { RELAYS } from '../../config/relays'
|
||||
import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers'
|
||||
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
|
||||
import { UserSettings } from '../settingsService'
|
||||
import { rebroadcastEvents } from '../rebroadcastService'
|
||||
|
||||
export const fetchHighlightsForUrl = async (
|
||||
relayPool: RelayPool,
|
||||
url: string,
|
||||
onHighlight?: (highlight: Highlight) => void,
|
||||
settings?: UserSettings
|
||||
): Promise<Highlight[]> => {
|
||||
try {
|
||||
const seenIds = new Set<string>()
|
||||
const orderedRelaysUrl = prioritizeLocalRelays(RELAYS)
|
||||
const { local: localRelaysUrl, remote: remoteRelaysUrl } = partitionRelays(orderedRelaysUrl)
|
||||
const local$ = localRelaysUrl.length > 0
|
||||
? relayPool
|
||||
.req(localRelaysUrl, { kinds: [9802], '#r': [url] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
seenIds.add(event.id)
|
||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(1200))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const remote$ = remoteRelaysUrl.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelaysUrl, { kinds: [9802], '#r': [url] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
tap((event: NostrEvent) => {
|
||||
seenIds.add(event.id)
|
||||
if (onHighlight) onHighlight(eventToHighlight(event))
|
||||
}),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(6000))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
||||
await rebroadcastEvents(rawEvents, relayPool, settings)
|
||||
const uniqueEvents = dedupeHighlights(rawEvents)
|
||||
const highlights: Highlight[] = uniqueEvents.map(eventToHighlight)
|
||||
return sortHighlights(highlights)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
222
src/services/libraryService.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||
import { lastValueFrom, merge, Observable, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
import { RELAYS } from '../config/relays'
|
||||
import { prioritizeLocalRelays, partitionRelays } from '../utils/helpers'
|
||||
import { MARK_AS_READ_EMOJI } from './reactionService'
|
||||
import { BlogPostPreview } from './exploreService'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
|
||||
export interface ReadArticle {
|
||||
id: string
|
||||
url?: string
|
||||
eventId?: string
|
||||
eventAuthor?: string
|
||||
eventKind?: number
|
||||
markedAt: number
|
||||
reactionId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all articles that the user has marked as read
|
||||
* Returns both nostr-native articles (kind:7) and external URLs (kind:17)
|
||||
*/
|
||||
export async function fetchReadArticles(
|
||||
relayPool: RelayPool,
|
||||
userPubkey: string
|
||||
): Promise<ReadArticle[]> {
|
||||
try {
|
||||
const orderedRelays = prioritizeLocalRelays(RELAYS)
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
|
||||
|
||||
// Fetch kind:7 reactions (nostr-native articles)
|
||||
const kind7Local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [7], authors: [userPubkey] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(1200))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const kind7Remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [7], authors: [userPubkey] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(6000))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const kind7Events: NostrEvent[] = await lastValueFrom(
|
||||
merge(kind7Local$, kind7Remote$).pipe(toArray())
|
||||
)
|
||||
|
||||
// Fetch kind:17 reactions (external URLs)
|
||||
const kind17Local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [17], authors: [userPubkey] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(1200))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const kind17Remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [17], authors: [userPubkey] })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(6000))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const kind17Events: NostrEvent[] = await lastValueFrom(
|
||||
merge(kind17Local$, kind17Remote$).pipe(toArray())
|
||||
)
|
||||
|
||||
const readArticles: ReadArticle[] = []
|
||||
|
||||
// Process kind:7 reactions (nostr-native articles)
|
||||
for (const event of kind7Events) {
|
||||
if (event.content === MARK_AS_READ_EMOJI) {
|
||||
const eTag = event.tags.find((t) => t[0] === 'e')
|
||||
const pTag = event.tags.find((t) => t[0] === 'p')
|
||||
const kTag = event.tags.find((t) => t[0] === 'k')
|
||||
|
||||
if (eTag && eTag[1]) {
|
||||
readArticles.push({
|
||||
id: eTag[1],
|
||||
eventId: eTag[1],
|
||||
eventAuthor: pTag?.[1],
|
||||
eventKind: kTag?.[1] ? parseInt(kTag[1]) : undefined,
|
||||
markedAt: event.created_at,
|
||||
reactionId: event.id
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process kind:17 reactions (external URLs)
|
||||
for (const event of kind17Events) {
|
||||
if (event.content === MARK_AS_READ_EMOJI) {
|
||||
const rTag = event.tags.find((t) => t[0] === 'r')
|
||||
|
||||
if (rTag && rTag[1]) {
|
||||
readArticles.push({
|
||||
id: rTag[1],
|
||||
url: rTag[1],
|
||||
markedAt: event.created_at,
|
||||
reactionId: event.id
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by markedAt (most recent first) and dedupe
|
||||
const deduped = new Map<string, ReadArticle>()
|
||||
readArticles
|
||||
.sort((a, b) => b.markedAt - a.markedAt)
|
||||
.forEach((article) => {
|
||||
if (!deduped.has(article.id)) {
|
||||
deduped.set(article.id, article)
|
||||
}
|
||||
})
|
||||
|
||||
return Array.from(deduped.values())
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch read articles:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches full article data for read nostr-native articles
|
||||
* and converts them to BlogPostPreview format for rendering
|
||||
*/
|
||||
export async function fetchReadArticlesWithData(
|
||||
relayPool: RelayPool,
|
||||
userPubkey: string
|
||||
): Promise<BlogPostPreview[]> {
|
||||
try {
|
||||
// First get all read articles
|
||||
const readArticles = await fetchReadArticles(relayPool, userPubkey)
|
||||
|
||||
// Filter to only nostr-native articles (kind 30023)
|
||||
const nostrArticles = readArticles.filter(
|
||||
article => article.eventKind === 30023 && article.eventId
|
||||
)
|
||||
|
||||
if (nostrArticles.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const orderedRelays = prioritizeLocalRelays(RELAYS)
|
||||
const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays)
|
||||
|
||||
// Fetch the actual article events
|
||||
const eventIds = nostrArticles.map(a => a.eventId!).filter(Boolean)
|
||||
|
||||
const local$ = localRelays.length > 0
|
||||
? relayPool
|
||||
.req(localRelays, { kinds: [30023], ids: eventIds })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(1200))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const remote$ = remoteRelays.length > 0
|
||||
? relayPool
|
||||
.req(remoteRelays, { kinds: [30023], ids: eventIds })
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(6000))
|
||||
)
|
||||
: new Observable<NostrEvent>((sub) => sub.complete())
|
||||
|
||||
const articleEvents: NostrEvent[] = await lastValueFrom(
|
||||
merge(local$, remote$).pipe(toArray())
|
||||
)
|
||||
|
||||
// Deduplicate article events by ID
|
||||
const uniqueArticleEvents = new Map<string, NostrEvent>()
|
||||
articleEvents.forEach(event => {
|
||||
if (!uniqueArticleEvents.has(event.id)) {
|
||||
uniqueArticleEvents.set(event.id, event)
|
||||
}
|
||||
})
|
||||
|
||||
// Convert to BlogPostPreview format
|
||||
const blogPosts: BlogPostPreview[] = Array.from(uniqueArticleEvents.values()).map(event => ({
|
||||
event,
|
||||
title: getArticleTitle(event) || 'Untitled Article',
|
||||
summary: getArticleSummary(event),
|
||||
image: getArticleImage(event),
|
||||
published: getArticlePublished(event),
|
||||
author: event.pubkey
|
||||
}))
|
||||
|
||||
// Sort by when they were marked as read (most recent first)
|
||||
const articlesMap = new Map(nostrArticles.map(a => [a.eventId, a]))
|
||||
blogPosts.sort((a, b) => {
|
||||
const markedAtA = articlesMap.get(a.event.id)?.markedAt || 0
|
||||
const markedAtB = articlesMap.get(b.event.id)?.markedAt || 0
|
||||
return markedAtB - markedAtA
|
||||
})
|
||||
|
||||
return blogPosts
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch read articles with data:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
54
src/services/meCache.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import { BlogPostPreview } from './exploreService'
|
||||
|
||||
export interface MeCache {
|
||||
highlights: Highlight[]
|
||||
bookmarks: Bookmark[]
|
||||
readArticles: BlogPostPreview[]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const meCache = new Map<string, MeCache>() // key: pubkey
|
||||
|
||||
export function getCachedMeData(pubkey: string): MeCache | null {
|
||||
const entry = meCache.get(pubkey)
|
||||
if (!entry) return null
|
||||
return entry
|
||||
}
|
||||
|
||||
export function setCachedMeData(
|
||||
pubkey: string,
|
||||
highlights: Highlight[],
|
||||
bookmarks: Bookmark[],
|
||||
readArticles: BlogPostPreview[]
|
||||
): void {
|
||||
meCache.set(pubkey, {
|
||||
highlights,
|
||||
bookmarks,
|
||||
readArticles,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
export function updateCachedHighlights(pubkey: string, highlights: Highlight[]): void {
|
||||
const existing = meCache.get(pubkey)
|
||||
if (existing) {
|
||||
meCache.set(pubkey, { ...existing, highlights, timestamp: Date.now() })
|
||||
}
|
||||
}
|
||||
|
||||
export function updateCachedBookmarks(pubkey: string, bookmarks: Bookmark[]): void {
|
||||
const existing = meCache.get(pubkey)
|
||||
if (existing) {
|
||||
meCache.set(pubkey, { ...existing, bookmarks, timestamp: Date.now() })
|
||||
}
|
||||
}
|
||||
|
||||
export function updateCachedReadArticles(pubkey: string, readArticles: BlogPostPreview[]): void {
|
||||
const existing = meCache.get(pubkey)
|
||||
if (existing) {
|
||||
meCache.set(pubkey, { ...existing, readArticles, timestamp: Date.now() })
|
||||
}
|
||||
}
|
||||
|
||||
199
src/services/reactionService.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { EventFactory } from 'applesauce-factory'
|
||||
import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { RELAYS } from '../config/relays'
|
||||
|
||||
const MARK_AS_READ_EMOJI = '📚'
|
||||
|
||||
export { MARK_AS_READ_EMOJI }
|
||||
|
||||
/**
|
||||
* Creates a kind:7 reaction to a nostr event (for nostr-native articles)
|
||||
* @param eventId The ID of the event being reacted to
|
||||
* @param eventAuthor The pubkey of the event author
|
||||
* @param eventKind The kind of the event being reacted to
|
||||
* @param account The user's account for signing
|
||||
* @param relayPool The relay pool for publishing
|
||||
* @returns The signed reaction event
|
||||
*/
|
||||
export async function createEventReaction(
|
||||
eventId: string,
|
||||
eventAuthor: string,
|
||||
eventKind: number,
|
||||
account: IAccount,
|
||||
relayPool: RelayPool
|
||||
): Promise<NostrEvent> {
|
||||
const factory = new EventFactory({ signer: account })
|
||||
|
||||
const tags: string[][] = [
|
||||
['e', eventId],
|
||||
['p', eventAuthor],
|
||||
['k', eventKind.toString()]
|
||||
]
|
||||
|
||||
const draft = await factory.create(async () => ({
|
||||
kind: 7, // Reaction
|
||||
content: MARK_AS_READ_EMOJI,
|
||||
tags,
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
}))
|
||||
|
||||
const signed = await factory.sign(draft)
|
||||
|
||||
console.log('📚 Created kind:7 reaction (mark as read) for event:', eventId.slice(0, 8))
|
||||
|
||||
// Publish to relays
|
||||
await relayPool.publish(RELAYS, signed)
|
||||
|
||||
console.log('✅ Reaction published to', RELAYS.length, 'relay(s)')
|
||||
|
||||
return signed
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a kind:17 reaction to a website (for external URLs)
|
||||
* @param url The URL being reacted to
|
||||
* @param account The user's account for signing
|
||||
* @param relayPool The relay pool for publishing
|
||||
* @returns The signed reaction event
|
||||
*/
|
||||
export async function createWebsiteReaction(
|
||||
url: string,
|
||||
account: IAccount,
|
||||
relayPool: RelayPool
|
||||
): Promise<NostrEvent> {
|
||||
const factory = new EventFactory({ signer: account })
|
||||
|
||||
// Normalize URL (remove fragments, trailing slashes as per NIP-25)
|
||||
let normalizedUrl = url
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
// Remove fragment
|
||||
parsed.hash = ''
|
||||
normalizedUrl = parsed.toString()
|
||||
// Remove trailing slash if present
|
||||
if (normalizedUrl.endsWith('/')) {
|
||||
normalizedUrl = normalizedUrl.slice(0, -1)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to normalize URL:', error)
|
||||
}
|
||||
|
||||
const tags: string[][] = [
|
||||
['r', normalizedUrl]
|
||||
]
|
||||
|
||||
const draft = await factory.create(async () => ({
|
||||
kind: 17, // Reaction to a website
|
||||
content: MARK_AS_READ_EMOJI,
|
||||
tags,
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
}))
|
||||
|
||||
const signed = await factory.sign(draft)
|
||||
|
||||
console.log('📚 Created kind:17 reaction (mark as read) for URL:', normalizedUrl)
|
||||
|
||||
// Publish to relays
|
||||
await relayPool.publish(RELAYS, signed)
|
||||
|
||||
console.log('✅ Website reaction published to', RELAYS.length, 'relay(s)')
|
||||
|
||||
return signed
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user has already marked a nostr event as read
|
||||
* @param eventId The ID of the event to check
|
||||
* @param userPubkey The user's pubkey
|
||||
* @param relayPool The relay pool for querying
|
||||
* @returns True if the user has already reacted with the mark-as-read emoji
|
||||
*/
|
||||
export async function hasMarkedEventAsRead(
|
||||
eventId: string,
|
||||
userPubkey: string,
|
||||
relayPool: RelayPool
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const filter = {
|
||||
kinds: [7],
|
||||
authors: [userPubkey],
|
||||
'#e': [eventId]
|
||||
}
|
||||
|
||||
const events$ = relayPool
|
||||
.req(RELAYS, filter)
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(2000)),
|
||||
toArray()
|
||||
)
|
||||
|
||||
const events: NostrEvent[] = await lastValueFrom(events$)
|
||||
|
||||
// Check if any reaction has our mark-as-read emoji
|
||||
const hasReadReaction = events.some((event: NostrEvent) => event.content === MARK_AS_READ_EMOJI)
|
||||
|
||||
return hasReadReaction
|
||||
} catch (error) {
|
||||
console.error('Error checking read status:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user has already marked a website as read
|
||||
* @param url The URL to check
|
||||
* @param userPubkey The user's pubkey
|
||||
* @param relayPool The relay pool for querying
|
||||
* @returns True if the user has already reacted with the mark-as-read emoji
|
||||
*/
|
||||
export async function hasMarkedWebsiteAsRead(
|
||||
url: string,
|
||||
userPubkey: string,
|
||||
relayPool: RelayPool
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Normalize URL the same way as when creating reactions
|
||||
let normalizedUrl = url
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
parsed.hash = ''
|
||||
normalizedUrl = parsed.toString()
|
||||
if (normalizedUrl.endsWith('/')) {
|
||||
normalizedUrl = normalizedUrl.slice(0, -1)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to normalize URL:', error)
|
||||
}
|
||||
|
||||
const filter = {
|
||||
kinds: [17],
|
||||
authors: [userPubkey],
|
||||
'#r': [normalizedUrl]
|
||||
}
|
||||
|
||||
const events$ = relayPool
|
||||
.req(RELAYS, filter)
|
||||
.pipe(
|
||||
onlyEvents(),
|
||||
completeOnEose(),
|
||||
takeUntil(timer(2000)),
|
||||
toArray()
|
||||
)
|
||||
|
||||
const events: NostrEvent[] = await lastValueFrom(events$)
|
||||
|
||||
// Check if any reaction has our mark-as-read emoji
|
||||
const hasReadReaction = events.some((event: NostrEvent) => event.content === MARK_AS_READ_EMOJI)
|
||||
|
||||
return hasReadReaction
|
||||
} catch (error) {
|
||||
console.error('Error checking read status:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
77
src/services/youtubeMetaService.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export type Caption = { start: number; dur: number; text: string }
|
||||
export type YouTubeMeta = {
|
||||
title: string
|
||||
description?: string
|
||||
captions: Caption[]
|
||||
transcript?: string
|
||||
lang: string
|
||||
isAuto?: boolean
|
||||
source: 'youtube'
|
||||
}
|
||||
|
||||
type CachedMeta = {
|
||||
data: YouTubeMeta
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const TTL_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||
|
||||
function cacheKey(videoId: string, lang: string) {
|
||||
return `yt_meta_${videoId}_${lang}`
|
||||
}
|
||||
|
||||
function load(videoId: string, lang: string): YouTubeMeta | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(cacheKey(videoId, lang))
|
||||
if (!raw) return null
|
||||
const { data, timestamp } = JSON.parse(raw) as CachedMeta
|
||||
if (Date.now() - timestamp > TTL_MS) {
|
||||
localStorage.removeItem(cacheKey(videoId, lang))
|
||||
return null
|
||||
}
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function save(videoId: string, lang: string, data: YouTubeMeta) {
|
||||
try {
|
||||
const value: CachedMeta = { data, timestamp: Date.now() }
|
||||
localStorage.setItem(cacheKey(videoId, lang), JSON.stringify(value))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function extractYouTubeId(url: string): string | null {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
if (u.hostname === 'youtu.be') {
|
||||
return u.pathname.slice(1)
|
||||
}
|
||||
if (u.searchParams.get('v')) return u.searchParams.get('v')
|
||||
const parts = u.pathname.split('/').filter(Boolean)
|
||||
// /shorts/:id or /embed/:id
|
||||
if ((parts[0] === 'shorts' || parts[0] === 'embed') && parts[1]) return parts[1]
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function getYouTubeMeta(videoId: string, lang = 'en'): Promise<YouTubeMeta | null> {
|
||||
const cached = load(videoId, lang)
|
||||
if (cached) return cached
|
||||
const res = await fetch(`/api/youtube-meta?videoId=${encodeURIComponent(videoId)}&lang=${encodeURIComponent(lang)}`, {
|
||||
headers: {
|
||||
'x-ui-locale': lang
|
||||
}
|
||||
})
|
||||
if (!res.ok) return null
|
||||
const data = (await res.json()) as YouTubeMeta
|
||||
save(videoId, lang, data)
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
26
src/styles/base/global.css
Normal file
@@ -0,0 +1,26 @@
|
||||
/* Global element styles and app container (Tailwind-compatible) */
|
||||
|
||||
/* Body - keep only app-specific overrides */
|
||||
body {
|
||||
overscroll-behavior: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
body.mobile-sidebar-open {
|
||||
overflow: hidden;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* App loading states */
|
||||
.loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
|
||||
56
src/styles/base/variables.css
Normal file
@@ -0,0 +1,56 @@
|
||||
/* CSS variables and color-scheme */
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
|
||||
--reading-font: 'Source Serif 4', serif;
|
||||
--reading-font-size: 18px;
|
||||
|
||||
/* Highlight color variables (user-settable) */
|
||||
--highlight-color-mine: #ffff00;
|
||||
--highlight-color-friends: #f97316;
|
||||
--highlight-color-nostrverse: #9333ea;
|
||||
--highlight-color: #ffff00; /* Default highlight color */
|
||||
|
||||
/* Layout variables */
|
||||
--sidebar-width: 320px;
|
||||
--sidebar-collapsed-width: 64px;
|
||||
--highlights-width: 360px;
|
||||
--highlights-collapsed-width: 56px;
|
||||
--main-max-width: 900px;
|
||||
--main-max-width-video: 1200px;
|
||||
--main-horizontal-padding: 1rem;
|
||||
|
||||
/* Mobile breakpoints */
|
||||
--mobile-breakpoint: 768px;
|
||||
--tablet-breakpoint: 1024px;
|
||||
|
||||
/* Mobile touch target minimum */
|
||||
--min-touch-target: 44px;
|
||||
|
||||
/* Safe area insets for notched devices */
|
||||
--safe-area-top: env(safe-area-inset-top, 0px);
|
||||
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-area-left: env(safe-area-inset-left, 0px);
|
||||
--safe-area-right: env(safe-area-inset-right, 0px);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
100
src/styles/components/cards.css
Normal file
@@ -0,0 +1,100 @@
|
||||
/* Bookmark item and blog post cards */
|
||||
.bookmark-item { background: #1a1a1a; padding: 1.5rem; border-radius: 12px; transition: all 0.2s ease; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); }
|
||||
.bookmark-item:hover { transform: translateY(-2px); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); }
|
||||
.bookmark-item h3 { margin: 0 0 0.5rem 0; color: #fff; font-size: 1.2rem; }
|
||||
.bookmark-url { color: #646cff; text-decoration: none; display: block; margin-bottom: 0.5rem; word-break: break-all; background: none; border: none; padding: 0; font: inherit; cursor: pointer; text-align: left; width: 100%; }
|
||||
.bookmark-url:hover { text-decoration: underline; }
|
||||
.bookmark-content { color: #ccc; margin: 0.5rem 0; line-height: 1.4; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }
|
||||
.bookmark-meta { color: #888; font-size: 0.9rem; margin-top: 0.5rem; }
|
||||
|
||||
.individual-bookmarks { margin: 1rem 0; }
|
||||
.individual-bookmarks h4 { margin: 0 0 1rem 0; font-size: 1rem; color: #fff; }
|
||||
|
||||
.bookmarks-grid { display: flex; flex-direction: column; gap: 1rem; width: 100%; max-width: 100%; }
|
||||
.bookmarks-grid.bookmarks-compact { gap: 0.5rem; }
|
||||
.bookmarks-grid.bookmarks-large { gap: 1.5rem; }
|
||||
@media (max-width: 768px) {
|
||||
.bookmarks-grid { gap: 0.75rem; }
|
||||
.bookmarks-grid.bookmarks-compact { gap: 0.25rem; }
|
||||
.bookmarks-grid.bookmarks-large { gap: 1rem; }
|
||||
}
|
||||
|
||||
.individual-bookmark { background: transparent; padding: 1rem; border-radius: 8px; transition: all 0.2s ease; border: 1px solid #2a2a2a; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; overflow: hidden; }
|
||||
.individual-bookmark:hover { border-color: #3a3a3a; background: #252525; }
|
||||
|
||||
/* Compact view */
|
||||
.individual-bookmark.compact { padding: 0.5rem 0.5rem; background: transparent; border: none; border-bottom: 1px solid #2a2a2a; border-radius: 0; box-shadow: none; width: 100%; max-width: 100%; overflow: hidden; }
|
||||
.individual-bookmark.compact:hover { background: #252525; border-bottom-color: #333; transform: none; box-shadow: none; }
|
||||
.compact-row { display: flex; align-items: center; gap: 0.5rem; height: 28px; width: 100%; min-width: 0; overflow: hidden; }
|
||||
.compact-thumbnail { width: 24px; height: 24px; flex-shrink: 0; border-radius: 4px; overflow: hidden; background: #2a2a2a; display: flex; align-items: center; justify-content: center; }
|
||||
.compact-thumbnail img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.compact-row.clickable { cursor: pointer; }
|
||||
.compact-row.clickable:active { opacity: 0.8; }
|
||||
.bookmark-type-compact { display: flex; align-items: center; gap: 0.25rem; color: #646cff; font-size: 0.85rem; flex-shrink: 0; }
|
||||
.compact-text { flex: 1; min-width: 0; color: #ccc; font-size: 0.85rem; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bookmark-date-compact { font-size: 0.7rem; color: #666; flex-shrink: 0; white-space: nowrap; }
|
||||
.compact-read-btn { background: transparent; color: #888; border: none; padding: 0; border-radius: 4px; cursor: pointer; font-size: 0.75rem; display: flex; align-items: center; justify-content: center; width: 24px; height: 22px; flex-shrink: 0; transition: color 0.2s ease; }
|
||||
.compact-read-btn:hover { color: #ccc; }
|
||||
.compact-read-btn:active { transform: translateY(1px); }
|
||||
|
||||
.bookmark-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.bookmark-type { color: #646cff; font-size: 0.9rem; display: flex; align-items: center; gap: 0.35rem; }
|
||||
.bookmark-id { font-family: monospace; font-size: 0.8rem; color: #888; background: #1a1a1a; padding: 0.25rem 0.5rem; border-radius: 4px; }
|
||||
.bookmark-date { font-size: 0.8rem; color: #666; }
|
||||
.bookmark-date-link { font-size: 0.8rem; color: #666; text-decoration: none; transition: color 0.2s ease; }
|
||||
.bookmark-date-link:hover { color: #8ab4f8; text-decoration: underline; }
|
||||
.individual-bookmark .bookmark-content { margin: 0.75rem 0; color: #ccc; line-height: 1.6; font-size: 0.9rem; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }
|
||||
.expand-toggle { margin: 0.25rem 0; background: transparent; border: none; color: #888; cursor: pointer; width: 100%; height: 22px; display: flex; align-items: center; justify-content: center; }
|
||||
.expand-toggle:hover { color: #bbb; }
|
||||
.bookmark-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 0.75rem; gap: 0.75rem; }
|
||||
.bookmark-meta-minimal { font-size: 0.8rem; color: #888; }
|
||||
.author-link-minimal { color: #888; text-decoration: none; transition: color 0.2s ease; }
|
||||
.author-link-minimal:hover { color: #aaa; }
|
||||
.read-now-button-minimal { background: #646cff; color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem; transition: all 0.2s ease; white-space: nowrap; }
|
||||
.read-now-button-minimal:hover { background: #535bf2; }
|
||||
.expand-toggle-urls { margin-top: 0.5rem; background: transparent; border: none; color: #646cff; cursor: pointer; font-size: 0.8rem; padding: 0.25rem 0; text-decoration: underline; }
|
||||
.expand-toggle-urls:hover { color: #8088ff; }
|
||||
|
||||
/* Large preview view */
|
||||
.individual-bookmark.large { padding: 0; display: flex; flex-direction: column; overflow: hidden; border: 1px solid #2a2a2a; }
|
||||
.large-preview-image { width: 100%; height: 180px; background: #1a1a1a; background-size: cover; background-position: center; background-repeat: no-repeat; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s ease; border-bottom: 1px solid #333; position: relative; }
|
||||
.large-preview-image:hover { opacity: 0.9; }
|
||||
.large-preview-image::after { content: ''; position: absolute; inset: 0; background: linear-gradient(to bottom, transparent 60%, rgba(0,0,0,0.3) 100%); pointer-events: none; }
|
||||
.preview-placeholder { font-size: 3rem; color: #444; }
|
||||
.large-content { padding: 1.25rem; }
|
||||
.large-text { color: #ccc; font-size: 0.95rem; line-height: 1.6; margin-bottom: 1rem; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.large-footer { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; font-size: 0.8rem; color: #888; padding-top: 0.75rem; border-top: 1px solid #333; }
|
||||
.large-author { flex: 1; }
|
||||
.large-read-button { background: #646cff; color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem; transition: all 0.2s ease; display: flex; align-items: center; gap: 0.5rem; }
|
||||
.large-read-button:hover { background: #535bf2; }
|
||||
|
||||
/* Blog cards (Explore) */
|
||||
.explore-container { padding: 2rem; max-width: 1400px; margin: 0 auto; min-height: 100vh; }
|
||||
.explore-header { text-align: center; margin-bottom: 3rem; }
|
||||
.explore-header h1 { font-size: 2.5rem; margin: 0 0 1rem 0; color: #646cff; display: flex; align-items: center; justify-content: center; gap: 1rem; }
|
||||
.explore-subtitle { font-size: 1.125rem; color: rgba(255, 255, 255, 0.7); margin: 0; }
|
||||
.explore-loading, .explore-error { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; color: rgba(255, 255, 255, 0.7); }
|
||||
.explore-loading { min-height: 0; padding: 0.25rem 0; }
|
||||
.explore-error { color: #ff6b6b; }
|
||||
.explore-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 2rem; margin-top: 2rem; }
|
||||
.blog-post-card { background: #1a1a1a; border: 1px solid #333; border-radius: 12px; overflow: hidden; transition: all 0.3s ease; cursor: pointer; display: flex; flex-direction: column; height: 100%; }
|
||||
.blog-post-card:hover { border-color: #646cff; transform: translateY(-4px); box-shadow: 0 8px 24px rgba(100, 108, 255, 0.15); }
|
||||
.blog-post-card-image { width: 100%; height: 200px; overflow: hidden; background: #0f0f0f; display: flex; align-items: center; justify-content: center; }
|
||||
.blog-post-card-image img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.3s ease; }
|
||||
.blog-post-card:hover .blog-post-card-image img { transform: scale(1.05); }
|
||||
.blog-post-image-placeholder { font-size: 3rem; color: #444; display: flex; align-items: center; justify-content: center; }
|
||||
.blog-post-card-content { padding: 1.5rem; display: flex; flex-direction: column; gap: 1rem; flex: 1; }
|
||||
.blog-post-card-title { font-size: 1.25rem; font-weight: 600; margin: 0; color: rgba(255, 255, 255, 0.95); line-height: 1.4; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.blog-post-card-summary { font-size: 0.875rem; color: rgba(255, 255, 255, 0.6); margin: 0; line-height: 1.6; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; flex: 1; }
|
||||
.blog-post-card-meta { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding-top: 0.75rem; border-top: 1px solid #333; font-size: 0.75rem; color: rgba(255, 255, 255, 0.5); flex-wrap: wrap; }
|
||||
.blog-post-card-author, .blog-post-card-date { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.blog-post-card-author svg, .blog-post-card-date svg { opacity: 0.7; }
|
||||
@media (max-width: 768px) {
|
||||
.explore-container { padding: 1rem; }
|
||||
.explore-header h1 { font-size: 2rem; }
|
||||
.explore-grid { grid-template-columns: 1fr; gap: 1.5rem; }
|
||||
.blog-post-card-summary { -webkit-line-clamp: 2; font-size: 0.8rem; }
|
||||
.blog-post-card-content { padding: 1rem; }
|
||||
}
|
||||
|
||||
|
||||
30
src/styles/components/forms.css
Normal file
@@ -0,0 +1,30 @@
|
||||
/* Forms and controls for settings */
|
||||
.setting-group { margin-bottom: 1.5rem; text-align: left; }
|
||||
.setting-group.setting-inline { display: flex; align-items: center; gap: 1rem; }
|
||||
.setting-label { text-align: left; flex: 1; }
|
||||
.setting-control { display: flex; justify-content: flex-end; align-items: center; }
|
||||
.setting-group.setting-inline label { margin-bottom: 0; }
|
||||
.setting-group label { display: block; margin-bottom: 0.5rem; color: #ccc; font-weight: 500; text-align: left; }
|
||||
.setting-buttons { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.color-picker { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.color-swatch { width: 33px; height: 33px; border: 1px solid #444; border-radius: 6px; cursor: pointer; transition: all 0.2s; position: relative; }
|
||||
.color-swatch:hover { border-color: #888; }
|
||||
.color-swatch.active { border-color: #646cff; box-shadow: 0 0 0 2px #646cff; }
|
||||
.color-swatch.active::after { content: '✓'; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: #000; font-size: 0.875rem; font-weight: bold; text-shadow: 0 0 2px #fff; }
|
||||
.font-size-btn { min-width: 33px; height: 33px; padding: 0; background: transparent; border: 1px solid #444; border-radius: 6px; color: #ccc; cursor: pointer; transition: all 0.2s; font-weight: bold; display: flex; align-items: center; justify-content: center; }
|
||||
.font-size-btn:hover { background: #333; border-color: #666; }
|
||||
.font-size-btn.active { background: #646cff; border-color: #646cff; color: white; }
|
||||
.setting-preview { margin: 1.5rem 0; padding: 1rem; background: #1a1a1a; border: 1px solid #333; border-radius: 8px; }
|
||||
.preview-label { font-size: 0.875rem; color: #999; margin-bottom: 0.75rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.preview-content { color: #ddd; line-height: 1.7; }
|
||||
.preview-content h3 { margin: 0 0 1rem 0; font-size: 1.5em; color: #fff; }
|
||||
.preview-content p { margin: 0.75rem 0; }
|
||||
.setting-select { width: 100%; padding: 0.5rem; background: #2a2a2a; border: 1px solid #444; border-radius: 4px; color: #fff; font-size: 1rem; }
|
||||
.setting-inline .setting-select { width: auto; min-width: 200px; flex: 1; }
|
||||
.setting-select:focus { outline: none; border-color: #646cff; }
|
||||
.font-select option { padding: 0.5rem; font-size: 1rem; }
|
||||
.checkbox-label { display: flex !important; align-items: center; gap: 0.75rem; cursor: pointer; user-select: none; text-align: left; justify-content: flex-start; margin-bottom: 0 !important; font-weight: normal !important; }
|
||||
.setting-checkbox { width: 18px; height: 18px; cursor: pointer; flex-shrink: 0; margin: 0; accent-color: #646cff; }
|
||||
.checkbox-label span { color: #ddd; text-align: left; font-weight: 500; }
|
||||
|
||||
|
||||
43
src/styles/components/icon-button.css
Normal file
@@ -0,0 +1,43 @@
|
||||
/* Generic IconButton styling */
|
||||
.icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
background: #2a2a2a;
|
||||
color: #ddd;
|
||||
cursor: pointer;
|
||||
min-width: 33px;
|
||||
min-height: 33px;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.icon-button:hover { background: #333; }
|
||||
.icon-button:active { transform: translateY(1px); }
|
||||
|
||||
.icon-button.primary { background: #646cff; color: white; border-color: #646cff; }
|
||||
.icon-button.primary:hover { filter: brightness(1.05); }
|
||||
|
||||
.icon-button.success { background: #646cff; color: white; border-color: #646cff; }
|
||||
.icon-button.success:hover { filter: brightness(1.1); }
|
||||
|
||||
.icon-button.ghost { background: #2a2a2a; }
|
||||
|
||||
/* Mobile touch target improvements */
|
||||
@media (max-width: 768px) {
|
||||
.icon-button {
|
||||
min-width: var(--min-touch-target);
|
||||
min-height: var(--min-touch-target);
|
||||
}
|
||||
}
|
||||
|
||||
/* Disable hover effects on touch devices */
|
||||
@media (pointer: coarse) {
|
||||
.icon-button:hover { background: #2a2a2a; }
|
||||
.icon-button.ghost:hover { background: #2a2a2a; }
|
||||
.icon-button:active { background: #333; }
|
||||
}
|
||||
|
||||
|
||||
155
src/styles/components/me.css
Normal file
@@ -0,0 +1,155 @@
|
||||
/* Me page tabs */
|
||||
.me-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
overflow-x: auto;
|
||||
max-width: 600px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.me-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-secondary, #999);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.me-tab:hover {
|
||||
color: var(--text-primary, #ddd);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.me-tab.active {
|
||||
color: var(--primary-color, #8b5cf6);
|
||||
border-bottom-color: var(--primary-color, #8b5cf6);
|
||||
}
|
||||
|
||||
/* Highlights tab uses the user's custom "my highlights" color */
|
||||
.me-tab[data-tab="highlights"].active {
|
||||
color: var(--highlight-color-mine, #ffff00);
|
||||
border-bottom-color: var(--highlight-color-mine, #ffff00);
|
||||
}
|
||||
|
||||
/* Reading List tab uses blue color to match bookmarks icon */
|
||||
.me-tab[data-tab="reading-list"].active {
|
||||
color: #646cff;
|
||||
border-bottom-color: #646cff;
|
||||
}
|
||||
|
||||
.me-tab svg {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.me-tab-content {
|
||||
padding: 1.5rem 0;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Align highlight list width with profile card width on /me */
|
||||
.me-highlights-list { padding-left: 0; padding-right: 0; }
|
||||
.explore-header .author-card { max-width: 600px; margin: 0 auto; width: 100%; }
|
||||
|
||||
/* Bookmarks list */
|
||||
.bookmarks-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
text-align: left; /* Override center alignment from .app */
|
||||
}
|
||||
|
||||
/* Ensure all reading list elements are left-aligned */
|
||||
.bookmarks-list .individual-bookmark,
|
||||
.bookmarks-list .individual-bookmark * {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Enhanced border styling for reading list cards */
|
||||
.bookmarks-list .individual-bookmark {
|
||||
border: 1px solid #444 !important;
|
||||
background: #1a1a1a !important;
|
||||
}
|
||||
|
||||
.bookmarks-list .individual-bookmark:hover {
|
||||
border-color: #555 !important;
|
||||
background: #252525 !important;
|
||||
}
|
||||
|
||||
.bookmark-item {
|
||||
padding: 1rem;
|
||||
background: var(--card-bg, #fff);
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.bookmark-item:hover {
|
||||
border-color: var(--primary-color, #8b5cf6);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.bookmark-item a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.bookmark-item h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-primary, #000);
|
||||
}
|
||||
|
||||
.bookmark-item p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary, #666);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Mobile responsiveness */
|
||||
@media (max-width: 768px) {
|
||||
/* Add top breathing room so floating sidebar buttons don't overlap header */
|
||||
.explore-container .explore-header {
|
||||
margin-top: 3.5rem;
|
||||
}
|
||||
|
||||
.me-tabs {
|
||||
gap: 0.25rem;
|
||||
padding: 0 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.me-tab {
|
||||
padding: 0.5rem 0.7rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.me-tab svg {
|
||||
font-size: 0.9rem;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
/* Hide counts on mobile to save space */
|
||||
.me-tab .tab-count {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.me-tab-content {
|
||||
padding: 1.25rem 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
28
src/styles/components/modals.css
Normal file
@@ -0,0 +1,28 @@
|
||||
/* Add Bookmark Modal */
|
||||
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.75); display: flex; align-items: center; justify-content: center; z-index: 10000; padding: 1rem; }
|
||||
.modal-content { background: #1a1a1a; border: 1px solid #333; border-radius: 12px; max-width: 500px; width: 100%; max-height: 90vh; overflow-y: auto; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); box-sizing: border-box; }
|
||||
@media (max-width: 768px) {
|
||||
.modal-overlay { padding: 0; align-items: flex-end; }
|
||||
.modal-content { max-width: 100%; max-height: 95vh; max-height: 95dvh; border-radius: 16px 16px 0 0; margin: 0; padding-bottom: var(--safe-area-bottom); }
|
||||
}
|
||||
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 1.5rem; border-bottom: 1px solid #333; }
|
||||
.modal-header h2 { margin: 0; font-size: 1.5rem; color: #fff; }
|
||||
.modal-form { padding: 1.5rem; }
|
||||
.form-group { margin-bottom: 1.25rem; }
|
||||
.form-group label { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; color: #ccc; font-size: 0.9rem; font-weight: 500; }
|
||||
.fetching-indicator { font-size: 0.8rem; color: #999; font-weight: normal; display: inline-flex; align-items: center; gap: 0.5rem; }
|
||||
.form-group input, .form-group textarea { width: 100%; padding: 0.75rem; background: #2a2a2a; border: 1px solid #444; border-radius: 6px; color: #fff; font-size: 1rem; font-family: inherit; transition: border-color 0.2s; box-sizing: border-box; }
|
||||
.form-group input:focus, .form-group textarea:focus { outline: none; border-color: #646cff; }
|
||||
.form-group input:disabled, .form-group textarea:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.form-group textarea { resize: vertical; min-height: 80px; }
|
||||
.form-helper-text { margin-top: 0.25rem; font-size: 0.8rem; color: #999; line-height: 1.4; }
|
||||
.modal-error { padding: 0.75rem; background: rgba(220, 53, 69, 0.1); border: 1px solid #dc3545; border-radius: 6px; color: #dc3545; font-size: 0.9rem; margin-bottom: 1rem; }
|
||||
.modal-actions { display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 1.5rem; }
|
||||
.btn-secondary { padding: 0.75rem 1.5rem; background: #2a2a2a; border: 1px solid #444; border-radius: 6px; color: #ccc; font-size: 1rem; cursor: pointer; transition: all 0.2s; }
|
||||
.btn-secondary:hover:not(:disabled) { background: #333; border-color: #646cff; color: white; }
|
||||
.btn-secondary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.btn-primary { padding: 0.75rem 1.5rem; background: #646cff; border: none; border-radius: 6px; color: white; font-size: 1rem; cursor: pointer; transition: background-color 0.2s; }
|
||||
.btn-primary:hover:not(:disabled) { background: #535bf2; }
|
||||
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
|
||||
29
src/styles/components/profile.css
Normal file
@@ -0,0 +1,29 @@
|
||||
/* Profile UI fragments */
|
||||
.author-card-container { display: flex; justify-content: center; padding: 2rem 1rem; }
|
||||
.author-card { display: flex; gap: 1rem; padding: 1.5rem; background: #1a1a1a; border: 1px solid #333; border-radius: 12px; max-width: 600px; width: 100%; }
|
||||
.author-card-avatar { flex-shrink: 0; width: 60px; height: 60px; border-radius: 50%; overflow: hidden; background: #2a2a2a; display: flex; align-items: center; justify-content: center; color: #666; }
|
||||
.author-card-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.author-card-avatar svg { font-size: 2.5rem; }
|
||||
.author-card-content { flex: 1; min-width: 0; text-align: left; }
|
||||
.author-card-name { font-size: 1rem; font-weight: 600; color: #ddd; margin-bottom: 0.5rem; text-align: left; }
|
||||
.author-card-bio { font-size: 0.9rem; color: #999; line-height: 1.5; margin: 0; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; text-align: left; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.author-card-container {
|
||||
padding: 1.5rem 1rem;
|
||||
margin: 0 1rem; /* Add horizontal margin to prevent bleeding */
|
||||
max-width: calc(100vw - 2rem); /* Ensure it doesn't exceed screen width */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.author-card {
|
||||
padding: 1rem;
|
||||
max-width: 100%; /* Ensure card doesn't exceed container */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.author-card-avatar { width: 48px; height: 48px; }
|
||||
.author-card-avatar svg { font-size: 2rem; }
|
||||
.author-card-name { font-size: 0.95rem; }
|
||||
.author-card-bio { font-size: 0.85rem; -webkit-line-clamp: 2; }
|
||||
}
|
||||
|
||||
|
||||
117
src/styles/components/reader.css
Normal file
@@ -0,0 +1,117 @@
|
||||
/* Reader view */
|
||||
.reader {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 2rem; /* Add space for progress indicator */
|
||||
}
|
||||
|
||||
/* Video container - responsive wrapper following react-player docs */
|
||||
.reader-video {
|
||||
position: relative;
|
||||
width: 80vw; /* 80% of viewport width */
|
||||
min-width: 400px; /* Minimum width */
|
||||
max-width: 1000px; /* Maximum width */
|
||||
aspect-ratio: 16/9;
|
||||
margin: 0 -0.75rem 1rem -0.75rem; /* Negative margins to counteract reader padding */
|
||||
background: #000;
|
||||
}
|
||||
.reader.empty { color: #888; }
|
||||
.loading-spinner { display: flex; align-items: center; gap: 0.5rem; color: #888; }
|
||||
.loading-spinner svg { font-size: 1.2rem; }
|
||||
.reader-header { margin-bottom: 2rem; position: relative; }
|
||||
.reader-title { margin: 0 0 0.75rem 0; font-family: var(--reading-font); }
|
||||
.reader-summary { color: #aaa; font-size: 1.1rem; line-height: 1.5; margin: 0 0 1rem 0; font-family: var(--reading-font); }
|
||||
.reader-meta { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
|
||||
.publish-date { display: flex; align-items: center; gap: 0.4rem; font-size: 0.813rem; color: rgba(136, 136, 136, 0.7); opacity: 0.85; }
|
||||
.publish-date svg { font-size: 0.75rem; opacity: 0.6; }
|
||||
.publish-date-topright { position: absolute; top: 1rem; right: 1rem; font-size: 0.813rem; color: #fff; padding: 0.4rem 0.75rem; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); z-index: 10; }
|
||||
.reading-time { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; background: rgba(136, 136, 136, 0.1); border: 1px solid rgba(136, 136, 136, 0.3); border-radius: 6px; font-size: 0.875rem; color: #888; }
|
||||
.reading-time svg { font-size: 0.875rem; }
|
||||
.highlight-indicator { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; background: rgba(100, 108, 255, 0.1); border: 1px solid rgba(100, 108, 255, 0.3); border-radius: 6px; font-size: 0.875rem; color: #646cff; }
|
||||
.highlight-indicator svg { font-size: 0.875rem; }
|
||||
.reader-html { color: #ddd; line-height: 1.6; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; font-family: var(--reading-font); font-size: var(--reading-font-size); }
|
||||
.reader-markdown { color: #ddd; line-height: 1.7; font-family: var(--reading-font); font-size: var(--reading-font-size); }
|
||||
/* Ensure content is left-aligned even if source markup uses center */
|
||||
.reader .reader-html *, .reader .reader-markdown * { text-align: left !important; font-family: inherit !important; }
|
||||
.reader center, .reader [align="center"] { text-align: left !important; }
|
||||
/* Tame images from external content */
|
||||
.reader .reader-html img, .reader .reader-markdown img { max-width: 100%; max-height: 70vh; height: auto; width: auto; display: block; margin: 0.75rem 0; border-radius: 6px; }
|
||||
.reader-markdown h1, .reader-markdown h2, .reader-markdown h3, .reader-markdown h4 { margin-top: 1.2rem; }
|
||||
.reader-markdown p { margin: 0.5rem 0; }
|
||||
.reader-html p, .reader-html div, .reader-html span, .reader-html li, .reader-html td, .reader-html th { font-size: 1em !important; }
|
||||
.reader-markdown a { color: #8ab4f8; text-decoration: none; }
|
||||
.reader-markdown a:hover { text-decoration: underline; }
|
||||
.reader-markdown code { background: #1e1e1e; border: 1px solid #333; border-radius: 4px; padding: 0.15rem 0.4rem; font-size: 0.9em; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
|
||||
.reader-markdown pre { background: #1e1e1e; border: 1px solid #333; border-radius: 8px; padding: 1rem; overflow-x: auto; margin: 1rem 0; line-height: 1.5; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
|
||||
.reader-markdown pre code { background: transparent; border: none; padding: 0; font-size: 0.9em; display: block; }
|
||||
/* Prism.js enhancements */
|
||||
.reader-markdown pre[class*="language-"] { background: #1e1e1e; border: 1px solid #333; }
|
||||
.reader-markdown code[class*="language-"] { background: transparent; text-shadow: none; }
|
||||
.reader-html pre { background: #1e1e1e; border: 1px solid #333; border-radius: 8px; padding: 1rem; overflow-x: auto; margin: 1rem 0; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
|
||||
.reader-html code { background: #1e1e1e; border: 1px solid #333; border-radius: 4px; padding: 0.15rem 0.4rem; font-size: 0.9em; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
|
||||
.reader-html pre code { background: transparent; border: none; padding: 0; display: block; }
|
||||
/* Article menu */
|
||||
.article-menu-container { display: flex; justify-content: flex-end; padding: 1.5rem 0 0.5rem; margin-top: 2rem; }
|
||||
.article-menu-wrapper { position: relative; }
|
||||
.article-menu-btn { background: none; border: none; color: #888; cursor: pointer; padding: 0.5rem 0.75rem; font-size: 0.875rem; display: flex; align-items: center; gap: 0.5rem; transition: all 0.2s ease; border-radius: 6px; }
|
||||
.article-menu-btn:hover { color: #646cff; background: rgba(100, 108, 255, 0.1); }
|
||||
.article-menu { position: absolute; right: 0; top: calc(100% + 4px); background: #2a2a2a; border: 1px solid #444; border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); z-index: 1000; min-width: 180px; overflow: hidden; }
|
||||
.article-menu-item { width: 100%; background: none; border: none; color: #ddd; padding: 0.75rem 1rem; font-size: 0.875rem; display: flex; align-items: center; gap: 0.75rem; cursor: pointer; transition: all 0.15s ease; text-align: left; white-space: nowrap; }
|
||||
.article-menu-item:hover { background: rgba(100, 108, 255, 0.15); color: #fff; }
|
||||
.article-menu-item svg { font-size: 0.875rem; flex-shrink: 0; }
|
||||
|
||||
/* Mark as Read button */
|
||||
.mark-as-read-container { display: flex; justify-content: center; align-items: center; padding: 2rem 1rem; margin-top: 1rem; }
|
||||
.mark-as-read-btn { display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem 1.5rem; background: #2a2a2a; color: #ddd; border: 1px solid #444; border-radius: 8px; font-size: 1rem; font-weight: 500; cursor: pointer; transition: all 0.2s ease; min-width: 160px; justify-content: center; }
|
||||
.mark-as-read-btn:hover:not(:disabled) { background: #333; border-color: #555; transform: translateY(-1px); }
|
||||
.mark-as-read-btn:active:not(:disabled) { transform: translateY(0); }
|
||||
.mark-as-read-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.mark-as-read-btn svg { font-size: 1.1rem; }
|
||||
@media (max-width: 768px) {
|
||||
.reader {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
.mark-as-read-container { padding: 1.5rem 1rem; }
|
||||
.mark-as-read-btn { width: 100%; max-width: 300px; }
|
||||
}
|
||||
|
||||
/* Hero image in reader/card views */
|
||||
.article-hero-image { width: 100%; height: 200px; background-size: cover; background-position: center; background-repeat: no-repeat; cursor: pointer; transition: all 0.2s ease; border-radius: 8px 8px 0 0; position: relative; }
|
||||
.article-hero-image:hover { opacity: 0.9; }
|
||||
.article-hero-image::after { content: ''; position: absolute; inset: 0; background: linear-gradient(to bottom, transparent 60%, rgba(0,0,0,0.4) 100%); pointer-events: none; border-radius: 8px 8px 0 0; }
|
||||
.reader-hero-image { width: calc(100% + 1.5rem); margin: -0.75rem -0.75rem 2rem -0.75rem; border-radius: 0; overflow: hidden; position: relative; min-height: 300px; }
|
||||
.reader-hero-image img { width: 100%; height: auto; max-height: 500px; object-fit: cover; display: block; }
|
||||
.reader-header-overlay { position: absolute; bottom: 0; left: 0; right: 0; padding: 2rem 2rem 1.5rem; background: linear-gradient(to top, rgba(0, 0, 0, 0.85) 0%, rgba(0, 0, 0, 0.6) 60%, rgba(0, 0, 0, 0) 100%); }
|
||||
.reader-header-overlay .reader-title { color: #fff; text-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); margin-bottom: 0.75rem; }
|
||||
.reader-header-overlay .reader-summary { color: rgba(255, 255, 255, 0.9); font-size: 1.1rem; line-height: 1.5; margin: 0 0 1rem 0; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); font-family: var(--reading-font); }
|
||||
.reader-header-overlay .reader-meta { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
|
||||
.reader-header-overlay .publish-date { color: rgba(255, 255, 255, 0.65); text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); opacity: 1; }
|
||||
.reader-header-overlay .publish-date svg { opacity: 0.7; }
|
||||
.reader-header-overlay .reading-time, .reader-header-overlay .highlight-indicator { background: rgba(255, 255, 255, 0.15); backdrop-filter: blur(8px); border: 1px solid rgba(255, 255, 255, 0.25); color: #fff; }
|
||||
.reader-header-overlay .highlight-indicator { background: rgba(100, 108, 255, 0.25); border: 1px solid rgba(100, 108, 255, 0.4); }
|
||||
.reader-summary-below-image { display: none; }
|
||||
@media (max-width: 768px) {
|
||||
.reader-header-overlay .reader-summary.hide-on-mobile { display: none; }
|
||||
.reader-summary-below-image { display: block; padding: 0 0 1.5rem 0; margin-top: -1rem; }
|
||||
.reader-summary-below-image .reader-summary { color: #aaa; font-size: 1rem; line-height: 1.6; margin: 0; }
|
||||
.reader-hero-image { min-height: 280px; max-height: 400px; height: 50vh; }
|
||||
.reader-hero-image img { height: 100%; width: 100%; object-fit: cover; object-position: center; }
|
||||
.reader-header-overlay { padding: 1.5rem 1rem 1rem; }
|
||||
.reader-header-overlay .reader-title { font-size: 1.5rem; line-height: 1.3; }
|
||||
}
|
||||
|
||||
/* Reading Progress Indicator - now using Tailwind utilities in component */
|
||||
|
||||
|
||||
15
src/styles/components/settings.css
Normal file
@@ -0,0 +1,15 @@
|
||||
/* Settings view containers */
|
||||
.settings-view { display: flex; flex-direction: column; height: 100%; overflow: hidden; padding: 0.75rem 1rem; text-align: left; }
|
||||
.settings-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; padding: 0; }
|
||||
.settings-header h2 { margin: 0; font-size: 1.5rem; font-weight: 600; text-align: left; }
|
||||
.settings-header-actions { display: flex; gap: 0.5rem; align-items: center; }
|
||||
.settings-content { overflow-y: auto; flex: 1; margin-bottom: 1rem; text-align: left; padding: 0 0.25rem 2rem 0.25rem; }
|
||||
.settings-section { margin-bottom: 2.5rem; }
|
||||
.settings-section:last-child { margin-bottom: 0; }
|
||||
.section-title { font-size: 1rem; font-weight: 600; color: #fff; margin: 0 0 1rem 0; padding-bottom: 0.5rem; border-bottom: 1px solid #333; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.settings-footer { display: flex; justify-content: flex-start; padding: 1rem 0 0.5rem 0; flex-shrink: 0; }
|
||||
.settings-footer .btn-primary { background: #646cff; color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 4px; font-size: 1rem; cursor: pointer; transition: background-color 0.2s; display: flex; align-items: center; gap: 0.5rem; }
|
||||
.settings-footer .btn-primary:hover:not(:disabled) { background: #535bf2; }
|
||||
.settings-footer .btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
|
||||
13
src/styles/components/toast.css
Normal file
@@ -0,0 +1,13 @@
|
||||
/* Toast Notification */
|
||||
.toast { position: fixed; top: 2rem; right: 2rem; background: #1a1a1a; color: #fff; padding: 1rem 1.5rem; border-radius: 8px; border: 1px solid #333; display: flex; align-items: center; gap: 0.75rem; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); animation: toast-slide-in 0.3s ease-out; z-index: 9999; font-size: 0.95rem; }
|
||||
@media (max-width: 768px) {
|
||||
.toast { top: auto; bottom: calc(1rem + var(--safe-area-bottom)); right: 1rem; left: 1rem; max-width: calc(100% - 2rem); }
|
||||
@keyframes toast-slide-in { from { transform: translateY(100px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
||||
}
|
||||
.toast-success { border-color: #28a745; }
|
||||
.toast-success svg { color: #28a745; }
|
||||
.toast-error { border-color: #dc3545; }
|
||||
.toast-error svg { color: #dc3545; }
|
||||
@keyframes toast-slide-in { from { transform: translateX(400px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||
|
||||
|
||||
136
src/styles/layout/app.css
Normal file
@@ -0,0 +1,136 @@
|
||||
/* App-level layout and panes */
|
||||
.bookmarks-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Two-pane layout (legacy support) */
|
||||
.two-pane {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
column-gap: 0;
|
||||
height: calc(100vh - 4rem);
|
||||
transition: grid-template-columns 0.3s ease;
|
||||
}
|
||||
|
||||
.two-pane.sidebar-collapsed { grid-template-columns: 60px 1fr; }
|
||||
|
||||
/* Three-pane layout - document scroll, sticky sidebars */
|
||||
.three-pane {
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-width) 1fr var(--highlights-width);
|
||||
column-gap: 0;
|
||||
transition: grid-template-columns 0.3s ease;
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
height: auto !important;
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.three-pane.sidebar-collapsed { grid-template-columns: var(--sidebar-collapsed-width) 1fr var(--highlights-width); }
|
||||
.three-pane.highlights-collapsed { grid-template-columns: var(--sidebar-width) 1fr var(--highlights-collapsed-width); }
|
||||
.three-pane.sidebar-collapsed.highlights-collapsed { grid-template-columns: var(--sidebar-collapsed-width) 1fr var(--highlights-collapsed-width); }
|
||||
|
||||
/* Desktop: sticky sidebars, document scroll */
|
||||
@media (min-width: 769px) {
|
||||
.pane.sidebar {
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
max-height: calc(100vh - 2rem);
|
||||
overflow-y: auto;
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.pane.main {
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--main-horizontal-padding);
|
||||
min-height: 100vh;
|
||||
overflow: visible !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.pane.highlights {
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
max-height: calc(100vh - 2rem);
|
||||
overflow-y: auto;
|
||||
align-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
/* Remove padding when sidebar is collapsed for zero gap */
|
||||
.three-pane.sidebar-collapsed .pane.main { padding-left: 0; }
|
||||
.three-pane.sidebar-collapsed.highlights-collapsed .pane.main { padding-left: 0; }
|
||||
|
||||
/* Mobile three-pane layout */
|
||||
@media (max-width: 768px) {
|
||||
.three-pane {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto;
|
||||
}
|
||||
.three-pane.sidebar-collapsed,
|
||||
.three-pane.highlights-collapsed,
|
||||
.three-pane.sidebar-collapsed.highlights-collapsed { grid-template-columns: 1fr; }
|
||||
|
||||
.pane.main {
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure panes are stacked in the correct order on desktop */
|
||||
@media (min-width: 769px) {
|
||||
/* Desktop stacking to keep highlights above main without overlap */
|
||||
.three-pane .pane.sidebar { z-index: 1; }
|
||||
.three-pane .pane.main { z-index: 1; }
|
||||
.three-pane .pane.highlights { z-index: 2; }
|
||||
}
|
||||
|
||||
/* Mobile pane styles */
|
||||
@media (max-width: 768px) {
|
||||
/* Both sidepanes slide in as overlays */
|
||||
.pane.sidebar,
|
||||
.pane.highlights {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 85%;
|
||||
max-width: 320px;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
background: #1a1a1a;
|
||||
z-index: 1001; /* Above backdrop */
|
||||
transition: transform 0.3s ease;
|
||||
box-shadow: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
/* Ensure content fills the mobile sidepanes */
|
||||
.pane.sidebar > *,
|
||||
.pane.highlights > * { width: 100%; height: 100%; }
|
||||
/* Remove borders from containers in mobile overlays */
|
||||
.pane.sidebar .bookmarks-container,
|
||||
.pane.highlights .highlights-container { border: none; border-radius: 0; flex: 1; min-height: 0; }
|
||||
/* Bookmarks sidebar from left */
|
||||
.pane.sidebar { left: 0; transform: translateX(-100%); }
|
||||
.pane.sidebar.mobile-open { transform: translateX(0); box-shadow: 4px 0 12px rgba(0, 0, 0, 0.5); }
|
||||
/* Highlights sidebar from right */
|
||||
.pane.highlights { right: 0; transform: translateX(100%); }
|
||||
.pane.highlights.mobile-open { transform: translateX(0); box-shadow: -4px 0 12px rgba(0, 0, 0, 0.5); }
|
||||
.pane.main {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
padding: 0;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
/* Hide main content when sidepanes are open on mobile */
|
||||
.three-pane .pane.main.mobile-hidden { opacity: 0; pointer-events: none; }
|
||||
/* Mobile buttons and backdrop now use Tailwind utilities in component */
|
||||
}
|
||||
|
||||
|
||||
175
src/styles/layout/highlights.css
Normal file
@@ -0,0 +1,175 @@
|
||||
/* Highlights panel layout and interactions */
|
||||
.highlights-container {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.highlights-container.collapsed {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.highlights-container.collapsed .toggle-highlights-btn {
|
||||
background: #2a2a2a;
|
||||
color: #ddd;
|
||||
border: none;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.highlights-container.collapsed .toggle-highlights-btn:hover { background: #333; color: #fff; }
|
||||
.highlights-container.collapsed .toggle-highlights-btn:active { transform: translateY(1px); }
|
||||
.highlights-container.collapsed .toggle-highlights-btn.with-icon { width: auto; padding: 0 0.5rem; gap: 0.5rem; }
|
||||
|
||||
.highlights-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid #333;
|
||||
background: #1a1a1a;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
.highlights-actions { display: flex; align-items: center; justify-content: space-between; width: 100%; }
|
||||
.highlights-actions-left { display: flex; align-items: center; gap: 0.5rem; }
|
||||
|
||||
.highlights-title { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.highlights-title h3 { margin: 0; font-size: 1rem; font-weight: 600; }
|
||||
.highlights-title .count { color: #888; font-size: 0.875rem; }
|
||||
|
||||
.highlight-mode-toggle { display: flex; gap: 0.25rem; padding: 0.25rem; background: rgba(255, 255, 255, 0.05); border-radius: 4px; }
|
||||
.highlight-mode-toggle .mode-btn { background: none; border: none; color: #888; cursor: pointer; padding: 0.375rem 0.5rem; border-radius: 3px; transition: all 0.2s; font-size: 0.9rem; }
|
||||
.highlight-mode-toggle .mode-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
|
||||
.highlight-mode-toggle .mode-btn.active { background: #646cff; color: #fff; }
|
||||
|
||||
/* Three-level highlight toggles */
|
||||
.highlight-level-toggles { display: flex; gap: 0.25rem; padding: 0.25rem; background: rgba(255, 255, 255, 0.05); border-radius: 4px; }
|
||||
.highlight-level-toggles .level-toggle-btn { background: none; border: none; color: #888; cursor: pointer; padding: 0.375rem 0.5rem; border-radius: 3px; transition: all 0.2s; font-size: 0.9rem; }
|
||||
.highlight-level-toggles .level-toggle-btn:hover { background: rgba(255, 255, 255, 0.1); }
|
||||
.highlight-level-toggles .level-toggle-btn.active { background: rgba(255, 255, 255, 0.1); opacity: 1; }
|
||||
.highlight-level-toggles .level-toggle-btn:not(.active) { opacity: 0.4; }
|
||||
.highlight-level-toggles .level-toggle-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||
.highlight-level-toggles .level-toggle-btn:disabled:hover { background: none; }
|
||||
|
||||
.refresh-highlights-btn,
|
||||
.toggle-highlight-display-btn,
|
||||
.toggle-highlights-btn {
|
||||
background: transparent;
|
||||
color: #ddd;
|
||||
border: 1px solid #444;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.refresh-highlights-btn:hover,
|
||||
.toggle-highlight-display-btn:hover,
|
||||
.toggle-highlights-btn:hover { background: #2a2a2a; color: #fff; }
|
||||
.refresh-highlights-btn:active,
|
||||
.toggle-highlight-display-btn:active,
|
||||
.toggle-highlights-btn:active { transform: translateY(1px); }
|
||||
.refresh-highlights-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.refresh-highlights-btn:disabled:hover { background: transparent; color: #ddd; }
|
||||
|
||||
.highlights-loading,
|
||||
.highlights-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem 1rem; color: #888; text-align: center; gap: 0.5rem; }
|
||||
.highlights-empty svg { color: #555; margin-bottom: 0.5rem; }
|
||||
.empty-hint { font-size: 0.875rem; color: #666; margin-top: 0.5rem; }
|
||||
|
||||
.highlights-list { overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.highlight-item { background: #1e1e1e; border-left: 1px solid #333; border-right: 1px solid #333; padding: 0; display: flex; transition: border-color 0.2s ease; position: relative; }
|
||||
.highlight-item:hover { border-color: #646cff; }
|
||||
.highlight-item:hover .highlight-header,
|
||||
.highlight-item:hover .highlight-footer { border-color: #646cff; }
|
||||
.highlight-item.selected { border-color: #646cff; background: #252525; box-shadow: 0 0 0 2px rgba(100, 108, 255, 0.3); }
|
||||
.highlight-item.selected .highlight-header,
|
||||
.highlight-item.selected .highlight-footer { border-color: #646cff; }
|
||||
|
||||
/* Compact button for highlight cards */
|
||||
.compact-button { background: none; border: none; color: #888; cursor: pointer; padding: 0.25rem; font-size: 0.75rem; display: flex; align-items: center; justify-content: center; gap: 0.25rem; transition: all 0.2s ease; border-radius: 4px; min-width: 20px; min-height: 20px; }
|
||||
.compact-button:hover { color: #aaa; background: rgba(255, 255, 255, 0.05); }
|
||||
.compact-button:active { transform: scale(0.95); }
|
||||
.compact-button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.compact-button:disabled:hover { background: none; color: #888; transform: none; }
|
||||
|
||||
.highlight-header { position: absolute; top: 0; left: 0; right: 0; padding: 0.25rem 0.5rem; display: flex; align-items: center; justify-content: flex-end; pointer-events: none; border-top: 1px solid #333; border-top-left-radius: 8px; border-top-right-radius: 8px; transition: border-color 0.2s ease; }
|
||||
.highlight-header .compact-button { pointer-events: auto; }
|
||||
.highlight-timestamp { font-size: 0.75rem; font-weight: 500; white-space: nowrap; }
|
||||
|
||||
/* Level colors in sidebar items */
|
||||
.highlight-item.level-mine { border-color: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 60%, #333); box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 25%, transparent); }
|
||||
.highlight-item.level-mine .highlight-header,
|
||||
.highlight-item.level-mine .highlight-footer { border-color: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 60%, #333); }
|
||||
.highlight-item.level-friends { border-color: color-mix(in srgb, var(--highlight-color-friends, #f97316) 60%, #333); box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-friends, #f97316) 25%, transparent); }
|
||||
.highlight-item.level-friends .highlight-header,
|
||||
.highlight-item.level-friends .highlight-footer { border-color: color-mix(in srgb, var(--highlight-color-friends, #f97316) 60%, #333); }
|
||||
.highlight-item.level-nostrverse { border-color: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 60%, #333); box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 25%, transparent); }
|
||||
.highlight-item.level-nostrverse .highlight-header,
|
||||
.highlight-item.level-nostrverse .highlight-footer { border-color: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 60%, #333); }
|
||||
|
||||
.highlight-quote-button { position: absolute; top: 0.25rem; left: 0.5rem; z-index: 10; }
|
||||
.highlight-item.level-mine .highlight-quote-button { color: var(--highlight-color-mine, #ffff00); }
|
||||
.highlight-item.level-friends .highlight-quote-button { color: var(--highlight-color-friends, #f97316); }
|
||||
.highlight-item.level-nostrverse .highlight-quote-button { color: var(--highlight-color-nostrverse, #9333ea); }
|
||||
.highlight-relay-indicator { flex-shrink: 0; }
|
||||
.highlight-relay-indicator:hover { opacity: 1; }
|
||||
|
||||
/* Mobile: Larger touch targets and better spacing */
|
||||
@media (max-width: 768px) {
|
||||
.highlight-relay-indicator { padding: 8px; min-width: var(--min-touch-target); min-height: var(--min-touch-target); }
|
||||
.compact-button { padding: 0.5rem; min-width: var(--min-touch-target); min-height: var(--min-touch-target); }
|
||||
}
|
||||
|
||||
/* Level-colored quote icon */
|
||||
.highlight-item.level-mine .highlight-quote-icon { color: var(--highlight-color-mine, #ffff00); }
|
||||
.highlight-item.level-friends .highlight-quote-icon { color: var(--highlight-color-friends, #f97316); }
|
||||
.highlight-item.level-nostrverse .highlight-quote-icon { color: var(--highlight-color-nostrverse, #9333ea); }
|
||||
|
||||
.highlight-content { flex: 1; display: flex; flex-direction: column; gap: 0.5rem; padding: 1.75rem 0.75rem; }
|
||||
.highlight-text { margin: 0; padding: 0; font-style: italic; color: #ddd; line-height: 1.6; border-left: none; font-size: 0.95rem; }
|
||||
.highlight-comment { margin-top: 0.5rem; padding: 0.75rem; background: rgba(100, 108, 255, 0.1); border-left: 3px solid #646cff; border-radius: 4px; font-size: 0.875rem; color: #ddd; line-height: 1.5; }
|
||||
|
||||
.highlight-footer { position: absolute; bottom: 0; left: 0; right: 0; display: flex; align-items: center; justify-content: space-between; padding: 0.25rem 0.5rem; font-size: 0.8rem; color: #888; border-bottom: 1px solid #333; border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; transition: border-color 0.2s ease; }
|
||||
.highlight-footer-left { display: flex; align-items: center; gap: 0.4rem; min-width: 0; }
|
||||
.highlight-author { color: #aaa; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; display: inline-flex; align-items: center; min-height: 28px; }
|
||||
|
||||
/* Ensure relay indicator in footer uses normal flow and matches CompactButton spacing */
|
||||
.highlight-item .highlight-footer .highlight-relay-indicator {
|
||||
position: static; /* override any absolute rules from global styles */
|
||||
bottom: auto;
|
||||
left: auto;
|
||||
margin: 0; /* rely on footer gap */
|
||||
padding: 0.25rem; /* CompactButton base */
|
||||
}
|
||||
.highlight-menu-wrapper { position: relative; flex-shrink: 0; display: flex; align-items: center; }
|
||||
.highlight-menu { position: absolute; right: 0; top: calc(100% + 4px); background: #2a2a2a; border: 1px solid #444; border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); z-index: 1000; min-width: 160px; overflow: hidden; }
|
||||
.highlight-menu-item { width: 100%; background: none; border: none; color: #ddd; padding: 0.625rem 0.875rem; font-size: 0.875rem; display: flex; align-items: center; gap: 0.625rem; cursor: pointer; transition: all 0.15s ease; text-align: left; white-space: nowrap; }
|
||||
.highlight-menu-item:hover { background: rgba(100, 108, 255, 0.15); color: #fff; }
|
||||
.highlight-menu-item:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.highlight-menu-item-danger:hover { background: rgba(255, 68, 68, 0.15); color: #ff4444; }
|
||||
.highlight-menu-item svg { font-size: 0.875rem; flex-shrink: 0; }
|
||||
|
||||
|
||||
150
src/styles/layout/sidebar.css
Normal file
@@ -0,0 +1,150 @@
|
||||
/* Bookmarks and sidebar layout */
|
||||
.bookmarks-container {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.bookmarks-container .view-mode-controls {
|
||||
margin-top: auto;
|
||||
padding: 1rem;
|
||||
border-top: 1px solid #333;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.bookmarks-container .bookmarks-list {
|
||||
padding: 0.5rem;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sidebar-header-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 12px 12px 0 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.sidebar-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Mobile hamburger button now uses Tailwind utilities in ThreePaneLayout */
|
||||
|
||||
.mobile-close-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar-header-bar .toggle-sidebar-btn { display: none; }
|
||||
.mobile-close-btn { display: flex; }
|
||||
}
|
||||
|
||||
.view-mode-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 33px;
|
||||
height: 33px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
flex-shrink: 0;
|
||||
color: #ddd;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.profile-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.profile-avatar svg { font-size: 1.25rem; }
|
||||
|
||||
.sidebar-header-bar .toggle-sidebar-btn {
|
||||
background: transparent;
|
||||
color: #ddd;
|
||||
border: 1px solid #444;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 33px;
|
||||
height: 33px;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sidebar-header-bar .toggle-sidebar-btn:hover { background: #2a2a2a; color: #fff; }
|
||||
.sidebar-header-bar .toggle-sidebar-btn:active { transform: translateY(1px); }
|
||||
|
||||
.bookmarks-container.collapsed {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.bookmarks-container.collapsed .toggle-sidebar-btn {
|
||||
background: #2a2a2a;
|
||||
color: #ddd;
|
||||
border: none;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bookmarks-container.collapsed .toggle-sidebar-btn:hover { background: #333; color: #fff; }
|
||||
.bookmarks-container.collapsed .toggle-sidebar-btn:active { transform: translateY(1px); }
|
||||
.bookmarks-container.collapsed .toggle-sidebar-btn.with-icon { width: auto; padding: 0 0.5rem; gap: 0.5rem; }
|
||||
.bookmarks-container.collapsed .toggle-sidebar-btn .glow-blue { color: #646cff; filter: drop-shadow(0 0 4px rgba(100, 108, 255, 0.6)); }
|
||||
|
||||
.user-info { margin: 0.5rem 0 0 0; color: #888; font-size: 0.9rem; font-family: monospace; }
|
||||
.bookmark-count { color: #666; font-size: 0.9rem; margin: 0.5rem 0; }
|
||||
.event-link { color: #8ab4f8; text-decoration: none; font-weight: 500; }
|
||||
.event-link:hover { text-decoration: underline; }
|
||||
|
||||
.bookmark-urls { margin: 0.75rem 0; }
|
||||
.bookmark-url { display: block; margin: 0.25rem 0; color: #007bff; text-decoration: none; word-break: break-all; background: none; border: none; padding: 0; font: inherit; cursor: pointer; text-align: left; width: 100%; }
|
||||
.bookmark-url:hover { text-decoration: underline; }
|
||||
|
||||
.url-row { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.read-inline-btn { background: #28a745; color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 4px; cursor: pointer; }
|
||||
.read-inline-btn:hover { background: #218838; }
|
||||
|
||||
|
||||
7
src/styles/tailwind.css
Normal file
@@ -0,0 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
24
src/styles/utils/animations.css
Normal file
@@ -0,0 +1,24 @@
|
||||
/* Reusable keyframes */
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% { opacity: 0.8; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.1); }
|
||||
}
|
||||
|
||||
@keyframes toast-slide-in {
|
||||
from { transform: translateX(400px); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.4; transform: scale(1); }
|
||||
50% { opacity: 1; transform: scale(1.1); }
|
||||
}
|
||||
|
||||
@keyframes highlight-pulse-animation {
|
||||
0%, 100% { box-shadow: 0 0 8px rgba(var(--highlight-rgb, 255, 255, 0), 0.2); transform: scale(1); }
|
||||
25% { box-shadow: 0 0 20px rgba(var(--highlight-rgb, 255, 255, 0), 0.6); transform: scale(1.02); }
|
||||
50% { box-shadow: 0 0 8px rgba(var(--highlight-rgb, 255, 255, 0), 0.2); transform: scale(1); }
|
||||
75% { box-shadow: 0 0 20px rgba(var(--highlight-rgb, 255, 255, 0), 0.6); transform: scale(1.02); }
|
||||
}
|
||||
|
||||
|
||||
184
src/styles/utils/legacy.css
Normal file
@@ -0,0 +1,184 @@
|
||||
/* Legacy styles for bookmark debugging and nostr content parsing */
|
||||
|
||||
.user-info {
|
||||
margin: 0.5rem 0 0 0;
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.bookmark-count {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.event-link {
|
||||
color: #8ab4f8;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.event-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.bookmark-urls {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.bookmark-url {
|
||||
display: block;
|
||||
margin: 0.25rem 0;
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bookmark-url:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.url-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.read-inline-btn {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.read-inline-btn:hover {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
.bookmark-events {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.bookmark-events h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.event-ids {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.event-id {
|
||||
background: #f5f5f5;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.more-events {
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Nostr content parsing styles */
|
||||
.parsed-content,
|
||||
.nostr-mention,
|
||||
.nostr-link,
|
||||
.nostr-uri-link {
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.parsed-content {
|
||||
margin: 1rem 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.nostr-mention,
|
||||
.nostr-uri-link {
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
font-family: monospace;
|
||||
background: #f8f9fa;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.nostr-uri-link {
|
||||
font-size: 0.9em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.nostr-mention:hover,
|
||||
.nostr-uri-link:hover {
|
||||
background: #e9ecef;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.nostr-link {
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nostr-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.logout-button:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
/* Common state styles */
|
||||
.loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #888;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
42
src/styles/utils/utilities.css
Normal file
@@ -0,0 +1,42 @@
|
||||
/* Inline content highlights - utilities */
|
||||
.content-highlight, .content-highlight-marker { background: rgba(var(--highlight-rgb, 255, 255, 0), 0.35); padding: 0.125rem 0.25rem; cursor: pointer; transition: all 0.2s ease; position: relative; border-radius: 2px; box-shadow: 0 0 8px rgba(var(--highlight-rgb, 255, 255, 0), 0.2); contain: layout style; }
|
||||
.content-highlight:hover, .content-highlight-marker:hover { background: rgba(var(--highlight-rgb, 255, 255, 0), 0.5); box-shadow: 0 0 12px rgba(var(--highlight-rgb, 255, 255, 0), 0.3); }
|
||||
.content-highlight-underline { background: transparent; padding: 0; cursor: pointer; transition: all 0.2s ease; position: relative; text-decoration: underline; text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 0.8); text-decoration-thickness: 2px; text-underline-offset: 2px; contain: layout style; }
|
||||
.content-highlight-underline:hover { text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 1); text-decoration-thickness: 3px; }
|
||||
.content-highlight.highlight-pulse, .content-highlight-marker.highlight-pulse, .content-highlight-underline.highlight-pulse { animation: highlight-pulse-animation 1.5s ease-in-out; }
|
||||
.reader-html .content-highlight, .reader-markdown .content-highlight, .reader-html .content-highlight-marker, .reader-markdown .content-highlight-marker, .reader-html .content-highlight-underline, .reader-markdown .content-highlight-underline { color: inherit; }
|
||||
.reader-html .content-highlight, .reader-markdown .content-highlight, .reader-html .content-highlight-marker, .reader-markdown .content-highlight-marker { text-decoration: none; }
|
||||
/* Three-level highlight colors */
|
||||
.content-highlight-marker.level-mine, .content-highlight.level-mine { background: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 35%, transparent); box-shadow: 0 0 8px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 20%, transparent); }
|
||||
.content-highlight-marker.level-mine:hover, .content-highlight.level-mine:hover { background: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 50%, transparent); box-shadow: 0 0 12px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 30%, transparent); }
|
||||
.content-highlight-marker.level-friends, .content-highlight.level-friends { background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 35%, transparent); box-shadow: 0 0 8px color-mix(in srgb, var(--highlight-color-friends, #f97316) 20%, transparent); }
|
||||
.content-highlight-marker.level-friends:hover, .content-highlight.level-friends:hover { background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 50%, transparent); box-shadow: 0 0 12px color-mix(in srgb, var(--highlight-color-friends, #f97316) 30%, transparent); }
|
||||
.content-highlight-marker.level-nostrverse, .content-highlight.level-nostrverse { background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 35%, transparent); box-shadow: 0 0 8px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 20%, transparent); }
|
||||
.content-highlight-marker.level-nostrverse:hover, .content-highlight.level-nostrverse:hover { background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 50%, transparent); box-shadow: 0 0 12px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 30%, transparent); }
|
||||
/* Underline styles for three levels */
|
||||
.content-highlight-underline.level-mine { text-decoration-color: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 80%, transparent); }
|
||||
.content-highlight-underline.level-mine:hover { text-decoration-color: var(--highlight-color-mine, #ffff00); }
|
||||
.content-highlight-underline.level-friends { text-decoration-color: color-mix(in srgb, var(--highlight-color-friends, #f97316) 80%, transparent); }
|
||||
.content-highlight-underline.level-friends:hover { text-decoration-color: var(--highlight-color-friends, #f97316); }
|
||||
.content-highlight-underline.level-nostrverse { text-decoration-color: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 80%, transparent); }
|
||||
.content-highlight-underline.level-nostrverse:hover { text-decoration-color: var(--highlight-color-nostrverse, #9333ea); }
|
||||
/* Ensure highlights work in both light and dark mode */
|
||||
@media (prefers-color-scheme: light) {
|
||||
.content-highlight, .content-highlight-marker { background: rgba(var(--highlight-rgb, 255, 255, 0), 0.4); box-shadow: 0 0 6px rgba(var(--highlight-rgb, 255, 255, 0), 0.15); }
|
||||
.content-highlight:hover, .content-highlight-marker:hover { background: rgba(var(--highlight-rgb, 255, 255, 0), 0.55); box-shadow: 0 0 10px rgba(var(--highlight-rgb, 255, 255, 0), 0.25); }
|
||||
.content-highlight-underline { text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 0.9); }
|
||||
.content-highlight-underline:hover { text-decoration-color: rgba(var(--highlight-rgb, 255, 255, 0), 1); }
|
||||
/* Three-level overrides for light mode */
|
||||
.content-highlight-marker.level-mine, .content-highlight.level-mine { background: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 40%, transparent); box-shadow: 0 0 6px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 15%, transparent); }
|
||||
.content-highlight-marker.level-mine:hover, .content-highlight.level-mine:hover { background: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 55%, transparent); box-shadow: 0 0 10px color-mix(in srgb, var(--highlight-color-mine, #ffff00) 25%, transparent); }
|
||||
.content-highlight-marker.level-friends, .content-highlight.level-friends { background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 40%, transparent); box-shadow: 0 0 6px color-mix(in srgb, var(--highlight-color-friends, #f97316) 15%, transparent); }
|
||||
.content-highlight-marker.level-friends:hover, .content-highlight.level-friends:hover { background: color-mix(in srgb, var(--highlight-color-friends, #f97316) 55%, transparent); box-shadow: 0 0 10px color-mix(in srgb, var(--highlight-color-friends, #f97316) 25%, transparent); }
|
||||
.content-highlight-marker.level-nostrverse, .content-highlight.level-nostrverse { background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 40%, transparent); box-shadow: 0 0 6px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 15%, transparent); }
|
||||
.content-highlight-marker.level-nostrverse:hover, .content-highlight.level-nostrverse:hover { background: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 55%, transparent); box-shadow: 0 0 10px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 25%, transparent); }
|
||||
.content-highlight-underline.level-mine { text-decoration-color: color-mix(in srgb, var(--highlight-color-mine, #ffff00) 90%, transparent); }
|
||||
.content-highlight-underline.level-friends { text-decoration-color: color-mix(in srgb, var(--highlight-color-friends, #f97316) 90%, transparent); }
|
||||
.content-highlight-underline.level-nostrverse { text-decoration-color: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 90%, transparent); }
|
||||
.highlight-indicator { background: rgba(100, 108, 255, 0.15); border-color: rgba(100, 108, 255, 0.4); }
|
||||
}
|
||||
|
||||
|
||||
111
src/sw.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/// <reference lib="webworker" />
|
||||
/* eslint-env worker */
|
||||
/* global ServiceWorkerGlobalScope, ExtendableMessageEvent, FetchEvent */
|
||||
import { clientsClaim } from 'workbox-core'
|
||||
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching'
|
||||
import { registerRoute, NavigationRoute } from 'workbox-routing'
|
||||
import { StaleWhileRevalidate } from 'workbox-strategies'
|
||||
import { ExpirationPlugin } from 'workbox-expiration'
|
||||
import { CacheableResponsePlugin } from 'workbox-cacheable-response'
|
||||
|
||||
// Narrow the global service worker scope for proper typings
|
||||
const sw = self as unknown as ServiceWorkerGlobalScope
|
||||
|
||||
// Precache all build assets (app shell)
|
||||
// @ts-ignore - __WB_MANIFEST is injected by vite-plugin-pwa
|
||||
precacheAndRoute(self.__WB_MANIFEST)
|
||||
|
||||
// Clean up old caches
|
||||
cleanupOutdatedCaches()
|
||||
|
||||
// Take control immediately
|
||||
sw.skipWaiting()
|
||||
clientsClaim()
|
||||
|
||||
console.log('[SW] Boris service worker loaded')
|
||||
|
||||
// Runtime cache: Cross-origin images
|
||||
// This preserves the existing image caching behavior
|
||||
registerRoute(
|
||||
({ request, url }) => {
|
||||
const isImage = request.destination === 'image' ||
|
||||
/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(url.pathname)
|
||||
return isImage && url.origin !== sw.location.origin
|
||||
},
|
||||
new StaleWhileRevalidate({
|
||||
cacheName: 'boris-images',
|
||||
plugins: [
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 300,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 30, // 30 days
|
||||
}),
|
||||
new CacheableResponsePlugin({
|
||||
statuses: [0, 200],
|
||||
}),
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
// Runtime cache: Cross-origin article HTML
|
||||
// Cache fetched articles for offline reading
|
||||
registerRoute(
|
||||
({ request, url }) => {
|
||||
const accept = request.headers.get('accept') || ''
|
||||
const isHTML = accept.includes('text/html')
|
||||
const isCrossOrigin = url.origin !== sw.location.origin
|
||||
// Exclude relay connections and local URLs
|
||||
const isNotRelay = !url.protocol.includes('ws')
|
||||
return isHTML && isCrossOrigin && isNotRelay
|
||||
},
|
||||
new StaleWhileRevalidate({
|
||||
cacheName: 'boris-articles',
|
||||
plugins: [
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 100,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 14, // 14 days
|
||||
}),
|
||||
new CacheableResponsePlugin({
|
||||
statuses: [0, 200],
|
||||
}),
|
||||
],
|
||||
})
|
||||
)
|
||||
|
||||
// SPA navigation fallback - serve app shell for navigation requests
|
||||
// This ensures the app loads offline
|
||||
const navigationRoute = new NavigationRoute(
|
||||
async ({ request }) => {
|
||||
try {
|
||||
// Try to fetch from network first
|
||||
const response = await fetch(request)
|
||||
return response
|
||||
} catch (error) {
|
||||
// If offline, serve the cached app shell
|
||||
const cache = await caches.match('/index.html')
|
||||
if (cache) {
|
||||
return cache
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
registerRoute(navigationRoute)
|
||||
|
||||
// Listen for messages from the app
|
||||
sw.addEventListener('message', (event: ExtendableMessageEvent) => {
|
||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||
sw.skipWaiting()
|
||||
}
|
||||
})
|
||||
|
||||
// Log fetch errors for debugging (doesn't affect functionality)
|
||||
sw.addEventListener('fetch', (event: FetchEvent) => {
|
||||
const url = new URL(event.request.url)
|
||||
|
||||
// Don't interfere with WebSocket connections (relay traffic)
|
||||
if (url.protocol === 'ws:' || url.protocol === 'wss:') {
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
@@ -10,34 +10,39 @@ export type UrlType = 'video' | 'image' | 'youtube' | 'article'
|
||||
|
||||
export interface UrlClassification {
|
||||
type: UrlType
|
||||
buttonText: string
|
||||
}
|
||||
|
||||
export const classifyUrl = (url: string | undefined): UrlClassification => {
|
||||
if (!url) {
|
||||
return { type: 'article', buttonText: 'READ NOW' }
|
||||
return { type: 'article' }
|
||||
}
|
||||
const urlLower = url.toLowerCase()
|
||||
|
||||
// Check for YouTube
|
||||
if (urlLower.includes('youtube.com') || urlLower.includes('youtu.be')) {
|
||||
return { type: 'youtube', buttonText: 'WATCH NOW' }
|
||||
return { type: 'youtube' }
|
||||
}
|
||||
|
||||
// Check for popular video hosts
|
||||
const videoHosts = ['vimeo.com', 'dailymotion.com', 'dai.ly', 'video.twimg.com']
|
||||
if (videoHosts.some(host => urlLower.includes(host))) {
|
||||
return { type: 'video' }
|
||||
}
|
||||
|
||||
// Check for video extensions
|
||||
const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov', '.avi', '.mkv', '.m4v']
|
||||
if (videoExtensions.some(ext => urlLower.includes(ext))) {
|
||||
return { type: 'video', buttonText: 'WATCH NOW' }
|
||||
return { type: 'video' }
|
||||
}
|
||||
|
||||
// Check for image extensions
|
||||
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp', '.ico']
|
||||
if (imageExtensions.some(ext => urlLower.includes(ext))) {
|
||||
return { type: 'image', buttonText: 'VIEW NOW' }
|
||||
return { type: 'image' }
|
||||
}
|
||||
|
||||
// Default to article
|
||||
return { type: 'article', buttonText: 'READ NOW' }
|
||||
return { type: 'article' }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,3 +68,58 @@ export const hasRemoteRelay = (relayUrls: string[]): boolean => {
|
||||
return relayUrls.some(url => !isLocalRelay(url))
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits relay URLs into local and remote groups
|
||||
*/
|
||||
export const partitionRelays = (
|
||||
relayUrls: string[]
|
||||
): { local: string[]; remote: string[] } => {
|
||||
const local: string[] = []
|
||||
const remote: string[] = []
|
||||
for (const url of relayUrls) {
|
||||
if (isLocalRelay(url)) local.push(url)
|
||||
else remote.push(url)
|
||||
}
|
||||
return { local, remote }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns relays ordered with local first while keeping uniqueness
|
||||
*/
|
||||
export const prioritizeLocalRelays = (relayUrls: string[]): string[] => {
|
||||
const { local, remote } = partitionRelays(relayUrls)
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
for (const url of [...local, ...remote]) {
|
||||
if (!seen.has(url)) {
|
||||
seen.add(url)
|
||||
out.push(url)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Parallel request helper
|
||||
import { completeOnEose, onlyEvents, RelayPool } from 'applesauce-relay'
|
||||
import { Observable, takeUntil, timer } from 'rxjs'
|
||||
|
||||
export function createParallelReqStreams(
|
||||
relayPool: RelayPool,
|
||||
localRelays: string[],
|
||||
remoteRelays: string[],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
filter: any,
|
||||
localTimeoutMs = 1200,
|
||||
remoteTimeoutMs = 6000
|
||||
): { local$: Observable<unknown>; remote$: Observable<unknown> } {
|
||||
const local$ = (localRelays.length > 0)
|
||||
? relayPool.req(localRelays, filter).pipe(onlyEvents(), completeOnEose(), takeUntil(timer(localTimeoutMs)))
|
||||
: new Observable<unknown>((sub) => { sub.complete() })
|
||||
|
||||
const remote$ = (remoteRelays.length > 0)
|
||||
? relayPool.req(remoteRelays, filter).pipe(onlyEvents(), completeOnEose(), takeUntil(timer(remoteTimeoutMs)))
|
||||
: new Observable<unknown>((sub) => { sub.complete() })
|
||||
|
||||
return { local$, remote$ }
|
||||
}
|
||||
|
||||
|
||||
189
src/utils/nostrUriResolver.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { decode, npubEncode, noteEncode } from 'nostr-tools/nip19'
|
||||
import { getNostrUrl } from '../config/nostrGateways'
|
||||
|
||||
/**
|
||||
* Regular expression to match nostr: URIs and bare NIP-19 identifiers
|
||||
* Matches: nostr:npub1..., nostr:note1..., nostr:nprofile1..., nostr:nevent1..., nostr:naddr1...
|
||||
* Also matches bare identifiers without the nostr: prefix
|
||||
*/
|
||||
const NOSTR_URI_REGEX = /(?:nostr:)?((npub|note|nprofile|nevent|naddr)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,})/gi
|
||||
|
||||
/**
|
||||
* Extract all nostr URIs from text
|
||||
*/
|
||||
export function extractNostrUris(text: string): string[] {
|
||||
const matches = text.match(NOSTR_URI_REGEX)
|
||||
if (!matches) return []
|
||||
|
||||
// Extract just the NIP-19 identifier (without nostr: prefix)
|
||||
return matches.map(match => {
|
||||
const cleanMatch = match.replace(/^nostr:/, '')
|
||||
return cleanMatch
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all naddr (article) identifiers from text
|
||||
*/
|
||||
export function extractNaddrUris(text: string): string[] {
|
||||
const allUris = extractNostrUris(text)
|
||||
return allUris.filter(uri => {
|
||||
try {
|
||||
const decoded = decode(uri)
|
||||
return decoded.type === 'naddr'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a NIP-19 identifier and return a human-readable link
|
||||
* For articles (naddr), returns an internal app link
|
||||
* For other types, returns an external gateway link
|
||||
*/
|
||||
export function createNostrLink(encoded: string): string {
|
||||
try {
|
||||
const decoded = decode(encoded)
|
||||
|
||||
switch (decoded.type) {
|
||||
case 'naddr':
|
||||
// For articles, link to our internal app route
|
||||
return `/a/${encoded}`
|
||||
case 'npub':
|
||||
case 'nprofile':
|
||||
case 'note':
|
||||
case 'nevent':
|
||||
return getNostrUrl(encoded)
|
||||
default:
|
||||
return getNostrUrl(encoded)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to decode nostr URI:', encoded, error)
|
||||
return getNostrUrl(encoded)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a display label for a nostr URI
|
||||
*/
|
||||
export function getNostrUriLabel(encoded: string): string {
|
||||
try {
|
||||
const decoded = decode(encoded)
|
||||
|
||||
switch (decoded.type) {
|
||||
case 'npub':
|
||||
return `@${encoded.slice(0, 12)}...`
|
||||
case 'nprofile': {
|
||||
const npub = npubEncode(decoded.data.pubkey)
|
||||
return `@${npub.slice(0, 12)}...`
|
||||
}
|
||||
case 'note':
|
||||
return `note:${encoded.slice(5, 12)}...`
|
||||
case 'nevent': {
|
||||
const note = noteEncode(decoded.data.id)
|
||||
return `note:${note.slice(5, 12)}...`
|
||||
}
|
||||
case 'naddr': {
|
||||
// For articles, show the identifier if available
|
||||
const identifier = decoded.data.identifier
|
||||
if (identifier && identifier.length > 0) {
|
||||
// Truncate long identifiers but keep them readable
|
||||
return identifier.length > 40 ? `${identifier.slice(0, 37)}...` : identifier
|
||||
}
|
||||
return 'nostr article'
|
||||
}
|
||||
default:
|
||||
return encoded.slice(0, 16) + '...'
|
||||
}
|
||||
} catch (error) {
|
||||
return encoded.slice(0, 16) + '...'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace nostr: URIs in markdown with proper markdown links
|
||||
* This converts: nostr:npub1... to [label](link)
|
||||
*/
|
||||
export function replaceNostrUrisInMarkdown(markdown: string): string {
|
||||
return markdown.replace(NOSTR_URI_REGEX, (match) => {
|
||||
// Extract just the NIP-19 identifier (without nostr: prefix)
|
||||
const encoded = match.replace(/^nostr:/, '')
|
||||
const link = createNostrLink(encoded)
|
||||
const label = getNostrUriLabel(encoded)
|
||||
|
||||
return `[${label}](${link})`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace nostr: URIs in markdown with proper markdown links, using resolved titles for articles
|
||||
* This converts: nostr:naddr1... to [Article Title](link)
|
||||
* @param markdown The markdown content to process
|
||||
* @param articleTitles Map of naddr -> title for resolved articles
|
||||
*/
|
||||
export function replaceNostrUrisInMarkdownWithTitles(
|
||||
markdown: string,
|
||||
articleTitles: Map<string, string>
|
||||
): string {
|
||||
return markdown.replace(NOSTR_URI_REGEX, (match) => {
|
||||
// Extract just the NIP-19 identifier (without nostr: prefix)
|
||||
const encoded = match.replace(/^nostr:/, '')
|
||||
const link = createNostrLink(encoded)
|
||||
|
||||
// For articles, use the resolved title if available
|
||||
try {
|
||||
const decoded = decode(encoded)
|
||||
if (decoded.type === 'naddr' && articleTitles.has(encoded)) {
|
||||
const title = articleTitles.get(encoded)!
|
||||
return `[${title}](${link})`
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore decode errors, fall through to default label
|
||||
}
|
||||
|
||||
// For other types or if title not resolved, use default label
|
||||
const label = getNostrUriLabel(encoded)
|
||||
return `[${label}](${link})`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace nostr: URIs in HTML with clickable links
|
||||
* This is used when processing HTML content directly
|
||||
*/
|
||||
export function replaceNostrUrisInHTML(html: string): string {
|
||||
return html.replace(NOSTR_URI_REGEX, (match) => {
|
||||
// Extract just the NIP-19 identifier (without nostr: prefix)
|
||||
const encoded = match.replace(/^nostr:/, '')
|
||||
const link = createNostrLink(encoded)
|
||||
const label = getNostrUriLabel(encoded)
|
||||
|
||||
return `<a href="${link}" class="nostr-uri-link" target="_blank" rel="noopener noreferrer">${label}</a>`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get decoded information from a nostr URI for detailed display
|
||||
*/
|
||||
export function getNostrUriInfo(encoded: string): {
|
||||
type: string
|
||||
decoded: ReturnType<typeof decode> | null
|
||||
link: string
|
||||
label: string
|
||||
} {
|
||||
let decoded: ReturnType<typeof decode> | null = null
|
||||
try {
|
||||
decoded = decode(encoded)
|
||||
} catch (error) {
|
||||
// ignore decoding errors
|
||||
}
|
||||
|
||||
return {
|
||||
type: decoded?.type || 'unknown',
|
||||
decoded,
|
||||
link: createNostrLink(encoded),
|
||||
label: getNostrUriLabel(encoded)
|
||||
}
|
||||
}
|
||||
|
||||
36
src/utils/videoHelpers.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Build native app deep link URL for video platforms
|
||||
* Returns null if the platform doesn't have a known native app URL scheme
|
||||
*/
|
||||
export function buildNativeVideoUrl(url: string): string | null {
|
||||
try {
|
||||
const u = new URL(url)
|
||||
const host = u.hostname
|
||||
|
||||
if (host.includes('youtube.com')) {
|
||||
const id = u.searchParams.get('v')
|
||||
return id ? `youtube://watch?v=${id}` : `youtube://${u.pathname}${u.search}`
|
||||
}
|
||||
|
||||
if (host === 'youtu.be') {
|
||||
const id = u.pathname.replace('/', '')
|
||||
return id ? `youtube://watch?v=${id}` : 'youtube://'
|
||||
}
|
||||
|
||||
if (host.includes('vimeo.com')) {
|
||||
const id = u.pathname.split('/').filter(Boolean)[0]
|
||||
return id ? `vimeo://app.vimeo.com/videos/${id}` : 'vimeo://'
|
||||
}
|
||||
|
||||
if (host.includes('dailymotion.com') || host === 'dai.ly') {
|
||||
const parts = u.pathname.split('/').filter(Boolean)
|
||||
const id = host === 'dai.ly' ? parts[0] : (parts[1] || '')
|
||||
return id ? `dailymotion://video/${id}` : 'dailymotion://'
|
||||
}
|
||||
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
5
src/vite-env.d.ts
vendored
@@ -3,3 +3,8 @@
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_DEFAULT_ARTICLE_NADDR: string
|
||||
}
|
||||
|
||||
declare module '*.svg?raw' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
|
||||
19
tailwind.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
'./index.html',
|
||||
'./src/**/*.{ts,tsx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
keyframes: {
|
||||
shimmer: {
|
||||
'0%': { transform: 'translateX(-100%)' },
|
||||
'100%': { transform: 'translateX(100%)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||