Compare commits
272 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7464a8b505 | ||
|
|
938d79663b | ||
|
|
cc0ad69275 | ||
|
|
810ff060f8 | ||
|
|
5e03ef70a6 | ||
|
|
f05fb29c7b | ||
|
|
e737b1f7f0 | ||
|
|
21a7be2f98 | ||
|
|
4c720aa049 | ||
|
|
6b240b01ec | ||
|
|
945894e3db | ||
|
|
667397e528 | ||
|
|
e4b0d6d1cd | ||
|
|
3cdda2dcb7 | ||
|
|
876ecc808d | ||
|
|
34671bd067 | ||
|
|
a6285f6a1d | ||
|
|
36508d600a | ||
|
|
a304bb7c26 | ||
|
|
04bab96a07 | ||
|
|
22ebbff755 | ||
|
|
b43f40597f | ||
|
|
fe3af25c5f | ||
|
|
ffafc6f64d | ||
|
|
eadab9a37f | ||
|
|
13b1692931 | ||
|
|
3a78289fee | ||
|
|
12c70b06de | ||
|
|
c7c82954ad | ||
|
|
3b639e2783 | ||
|
|
946584236d | ||
|
|
aadbf2084f | ||
|
|
3d7b649cba | ||
|
|
caa07012a7 | ||
|
|
ad5cd875de | ||
|
|
0a4bc2cfbb | ||
|
|
605dd41939 | ||
|
|
8679ae7a37 | ||
|
|
3c1e4312c9 | ||
|
|
53ed6849af | ||
|
|
4b95e6c262 | ||
|
|
40ab215c4d | ||
|
|
823927525f | ||
|
|
6277824b32 | ||
|
|
f94e4ba900 | ||
|
|
acf14ccee9 | ||
|
|
f882b63359 | ||
|
|
7b1e3be39b | ||
|
|
ee17018076 | ||
|
|
1dd2e1dc38 | ||
|
|
4cd1aa89ad | ||
|
|
e667cf05c2 | ||
|
|
7512375728 | ||
|
|
f108e2e70a | ||
|
|
daa43ec4c4 | ||
|
|
ab2223e739 | ||
|
|
e8cbb3af4b | ||
|
|
f374a9af28 | ||
|
|
f2cbc66a97 | ||
|
|
1627d4f53e | ||
|
|
b93a4d072a | ||
|
|
3e4bc97684 | ||
|
|
3c0c20f61c | ||
|
|
dae63e210b | ||
|
|
dc500cc296 | ||
|
|
1fc1e4f102 | ||
|
|
524b5e1559 | ||
|
|
930de76d1f | ||
|
|
b85fc820d1 | ||
|
|
b145aee29d | ||
|
|
a0e65a48f1 | ||
|
|
ccdfc54cdc | ||
|
|
61ce338b8c | ||
|
|
47de9a75b7 | ||
|
|
607f3d46f0 | ||
|
|
bdbc08fdf1 | ||
|
|
3a28160ae8 | ||
|
|
e03696eed7 | ||
|
|
f80fa3de7f | ||
|
|
4518fc16a7 | ||
|
|
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 |
521
CHANGELOG.md
@@ -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
|
||||
|
||||
201
api/video-meta.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
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
|
||||
}
|
||||
|
||||
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 })
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
// Note: getVideoDetails doesn't exist in the library, so we use a simplified approach
|
||||
const title = ''
|
||||
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(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
16
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.4.2",
|
||||
"version": "0.6.3",
|
||||
"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,
|
||||
|
||||
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
|
||||
})
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
81
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,64 @@ 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="/me/writings"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/p/:npub"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/p/:npub/writings"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
|
||||
</Routes>
|
||||
)
|
||||
@@ -79,6 +138,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 +234,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 +265,7 @@ function App() {
|
||||
<EventStoreProvider eventStore={eventStore}>
|
||||
<AccountsProvider manager={accountManager}>
|
||||
<BrowserRouter>
|
||||
<div className="app">
|
||||
<div className="min-h-screen p-0 max-w-none m-0 relative">
|
||||
<AppRoutes relayPool={relayPool} showToast={showToast} />
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faTimes, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||
import IconButton from './IconButton'
|
||||
@@ -183,7 +184,7 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
@@ -280,7 +281,8 @@ const AddBookmarkModal: React.FC<AddBookmarkModalProps> = ({ onClose, onSave })
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
58
src/components/AuthorCard.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
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'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
|
||||
interface AuthorCardProps {
|
||||
authorPubkey: string
|
||||
clickable?: boolean
|
||||
}
|
||||
|
||||
const AuthorCard: React.FC<AuthorCardProps> = ({ authorPubkey, clickable = true }) => {
|
||||
const navigate = useNavigate()
|
||||
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
|
||||
|
||||
const handleClick = () => {
|
||||
if (clickable) {
|
||||
const npub = nip19.npubEncode(authorPubkey)
|
||||
navigate(`/p/${npub}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`author-card ${clickable ? 'author-card-clickable' : ''}`}
|
||||
onClick={handleClick}
|
||||
style={clickable ? { cursor: 'pointer' } : undefined}
|
||||
>
|
||||
<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} />
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import React, { useRef } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage, faRotate } from '@fortawesome/free-solid-svg-icons'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
@@ -10,6 +10,8 @@ import IconButton from './IconButton'
|
||||
import { ViewMode } from './Bookmarks'
|
||||
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import { usePullToRefresh } from '../hooks/usePullToRefresh'
|
||||
import PullToRefreshIndicator from './PullToRefreshIndicator'
|
||||
|
||||
interface BookmarkListProps {
|
||||
bookmarks: Bookmark[]
|
||||
@@ -48,6 +50,19 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
settings,
|
||||
isMobile = false
|
||||
}) => {
|
||||
const bookmarksListRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Pull-to-refresh for bookmarks
|
||||
const pullToRefreshState = usePullToRefresh(bookmarksListRef, {
|
||||
onRefresh: () => {
|
||||
if (onRefresh) {
|
||||
onRefresh()
|
||||
}
|
||||
},
|
||||
isRefreshing: isRefreshing || false,
|
||||
disabled: !onRefresh
|
||||
})
|
||||
|
||||
// Helper to check if a bookmark has either content or a URL
|
||||
const hasContentOrUrl = (ib: IndividualBookmark) => {
|
||||
// Check if has content (text)
|
||||
@@ -111,18 +126,29 @@ 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>
|
||||
<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>
|
||||
{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
|
||||
ref={bookmarksListRef}
|
||||
className={`bookmarks-list pull-to-refresh-container ${pullToRefreshState.isPulling ? 'is-pulling' : ''}`}
|
||||
>
|
||||
<PullToRefreshIndicator
|
||||
isPulling={pullToRefreshState.isPulling}
|
||||
pullDistance={pullToRefreshState.pullDistance}
|
||||
canRefresh={pullToRefreshState.canRefresh}
|
||||
isRefreshing={isRefreshing || false}
|
||||
/>
|
||||
<div className={`bookmarks-grid bookmarks-${viewMode}`}>
|
||||
{allIndividualBookmarks.map((individualBookmark, index) =>
|
||||
<BookmarkItem
|
||||
@@ -135,37 +161,20 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{onRefresh && (
|
||||
<div className="refresh-section" style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '1rem',
|
||||
marginTop: '1rem',
|
||||
borderTop: '1px solid var(--border-color)',
|
||||
fontSize: '0.85rem',
|
||||
color: 'var(--text-secondary)'
|
||||
}}>
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
onClick={onRefresh}
|
||||
title="Refresh bookmarks"
|
||||
ariaLabel="Refresh bookmarks"
|
||||
variant="ghost"
|
||||
disabled={isRefreshing}
|
||||
spin={isRefreshing}
|
||||
/>
|
||||
{lastFetchTime && (
|
||||
<span>
|
||||
Updated {formatDistanceToNow(lastFetchTime, { addSuffix: true })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="view-mode-controls">
|
||||
{onRefresh && (
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
onClick={onRefresh}
|
||||
title={lastFetchTime ? `Refresh bookmarks (updated ${formatDistanceToNow(lastFetchTime, { addSuffix: true })})` : 'Refresh bookmarks'}
|
||||
ariaLabel="Refresh bookmarks"
|
||||
variant="ghost"
|
||||
disabled={isRefreshing}
|
||||
spin={isRefreshing}
|
||||
/>
|
||||
)}
|
||||
<IconButton
|
||||
icon={faList}
|
||||
onClick={() => onViewModeChange('compact')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { useEventStore } from 'applesauce-react/hooks'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useSettings } from '../hooks/useSettings'
|
||||
import { useArticleLoader } from '../hooks/useArticleLoader'
|
||||
import { useExternalUrlLoader } from '../hooks/useExternalUrlLoader'
|
||||
@@ -14,6 +15,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'
|
||||
@@ -24,7 +26,7 @@ interface BookmarksProps {
|
||||
}
|
||||
|
||||
const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
const { naddr } = useParams<{ naddr?: string }>()
|
||||
const { naddr, npub } = useParams<{ naddr?: string; npub?: string }>()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const previousLocationRef = useRef<string>()
|
||||
@@ -35,13 +37,38 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
|
||||
const showSettings = location.pathname === '/settings'
|
||||
const showExplore = location.pathname === '/explore'
|
||||
const showMe = location.pathname.startsWith('/me')
|
||||
const showProfile = location.pathname.startsWith('/p/')
|
||||
|
||||
// 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' :
|
||||
location.pathname === '/me/writings' ? 'writings' : 'highlights'
|
||||
|
||||
// Extract tab from profile routes
|
||||
const profileTab = location.pathname.endsWith('/writings') ? 'writings' : 'highlights'
|
||||
|
||||
// Decode npub to pubkey for profile view
|
||||
let profilePubkey: string | undefined
|
||||
if (npub && showProfile) {
|
||||
try {
|
||||
const decoded = nip19.decode(npub)
|
||||
if (decoded.type === 'npub') {
|
||||
profilePubkey = decoded.data
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to decode npub:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Track previous location for going back from settings/me/explore/profile
|
||||
useEffect(() => {
|
||||
if (!showSettings) {
|
||||
if (!showSettings && !showMe && !showExplore && !showProfile) {
|
||||
previousLocationRef.current = location.pathname
|
||||
}
|
||||
}, [location.pathname, showSettings])
|
||||
}, [location.pathname, showSettings, showMe, showExplore, showProfile])
|
||||
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
@@ -202,6 +229,8 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
isSidebarOpen={isSidebarOpen}
|
||||
showSettings={showSettings}
|
||||
showExplore={showExplore}
|
||||
showMe={showMe}
|
||||
showProfile={showProfile}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
viewMode={viewMode}
|
||||
@@ -244,6 +273,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)}
|
||||
@@ -257,6 +288,12 @@ 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}
|
||||
profile={showProfile && profilePubkey ? (
|
||||
relayPool ? <Me relayPool={relayPool} activeTab={profileTab} pubkey={profilePubkey} /> : 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,9 +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'
|
||||
@@ -13,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
|
||||
@@ -34,9 +56,14 @@ interface ContentPanelProps {
|
||||
followedPubkeys?: Set<string>
|
||||
settings?: UserSettings
|
||||
relayPool?: RelayPool | null
|
||||
activeAccount?: IAccount | null
|
||||
currentArticle?: NostrEvent | null
|
||||
// For highlight creation
|
||||
onTextSelection?: (text: string) => void
|
||||
onClearSelection?: () => void
|
||||
// For reading progress indicator positioning
|
||||
isSidebarCollapsed?: boolean
|
||||
isHighlightsCollapsed?: boolean
|
||||
}
|
||||
|
||||
const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
@@ -54,14 +81,28 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
highlightColor = '#ffff00',
|
||||
settings,
|
||||
relayPool,
|
||||
activeAccount,
|
||||
currentArticle,
|
||||
onHighlightClick,
|
||||
selectedHighlightId,
|
||||
highlightVisibility = { nostrverse: true, friends: true, mine: true },
|
||||
currentUserPubkey,
|
||||
followedPubkeys = new Set(),
|
||||
onTextSelection,
|
||||
onClearSelection
|
||||
onClearSelection,
|
||||
isSidebarCollapsed = false,
|
||||
isHighlightsCollapsed = false
|
||||
}) => {
|
||||
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 [showExternalMenu, setShowExternalMenu] = useState(false)
|
||||
const articleMenuRef = useRef<HTMLDivElement>(null)
|
||||
const videoMenuRef = useRef<HTMLDivElement>(null)
|
||||
const externalMenuRef = 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({
|
||||
@@ -84,6 +125,41 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
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 (externalMenuRef.current && !externalMenuRef.current.contains(target)) {
|
||||
setShowExternalMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showArticleMenu || showVideoMenu || showExternalMenu) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}
|
||||
}, [showArticleMenu, showVideoMenu, showExternalMenu])
|
||||
|
||||
const readingStats = useMemo(() => {
|
||||
const content = markdown || html || ''
|
||||
if (!content) return null
|
||||
@@ -93,6 +169,233 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
// External article actions
|
||||
const toggleExternalMenu = () => setShowExternalMenu(v => !v)
|
||||
|
||||
const handleOpenExternalUrl = () => {
|
||||
if (selectedUrl) window.open(selectedUrl, '_blank', 'noopener,noreferrer')
|
||||
setShowExternalMenu(false)
|
||||
}
|
||||
|
||||
const handleCopyExternalUrl = async () => {
|
||||
try {
|
||||
if (selectedUrl) await navigator.clipboard.writeText(selectedUrl)
|
||||
} catch (e) {
|
||||
console.warn('Clipboard copy failed', e)
|
||||
} finally {
|
||||
setShowExternalMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleShareExternalUrl = async () => {
|
||||
try {
|
||||
if (selectedUrl && (navigator as { share?: (d: { title?: string; url?: string }) => Promise<void> }).share) {
|
||||
await (navigator as { share: (d: { title?: string; url?: string }) => Promise<void> }).share({ title: title || 'Article', url: selectedUrl })
|
||||
} else if (selectedUrl) {
|
||||
await navigator.clipboard.writeText(selectedUrl)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Share failed', e)
|
||||
} finally {
|
||||
setShowExternalMenu(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">
|
||||
@@ -114,58 +417,268 @@ 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}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
isHighlightsCollapsed={isHighlightsCollapsed}
|
||||
/>
|
||||
)}
|
||||
|
||||
<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]}>
|
||||
<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}
|
||||
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 ? (
|
||||
{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>
|
||||
{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 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-markdown"
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml }}
|
||||
className="reader-html"
|
||||
dangerouslySetInnerHTML={{ __html: finalHtml || html || '' }}
|
||||
onMouseUp={handleSelectionEnd}
|
||||
onTouchEnd={handleSelectionEnd}
|
||||
/>
|
||||
) : (
|
||||
<div className="reader-markdown">
|
||||
<div className="loading-spinner">
|
||||
<FontAwesomeIcon icon={faSpinner} spin size="sm" />
|
||||
)}
|
||||
|
||||
{/* Article menu for external URLs */}
|
||||
{!isNostrArticle && !isExternalVideo && selectedUrl && (
|
||||
<div className="article-menu-container">
|
||||
<div className="article-menu-wrapper" ref={externalMenuRef}>
|
||||
<button
|
||||
className="article-menu-btn"
|
||||
onClick={toggleExternalMenu}
|
||||
title="More options"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsisH} />
|
||||
</button>
|
||||
|
||||
{showExternalMenu && (
|
||||
<div className="article-menu">
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleOpenExternalUrl}
|
||||
>
|
||||
<FontAwesomeIcon icon={faExternalLinkAlt} />
|
||||
<span>Open Original URL</span>
|
||||
</button>
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleCopyExternalUrl}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCopy} />
|
||||
<span>Copy URL</span>
|
||||
</button>
|
||||
<button
|
||||
className="article-menu-item"
|
||||
onClick={handleShareExternalUrl}
|
||||
>
|
||||
<FontAwesomeIcon icon={faShare} />
|
||||
<span>Share</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner, faExclamationCircle, faNewspaper } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
@@ -7,6 +7,9 @@ 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'
|
||||
import { usePullToRefresh } from '../hooks/usePullToRefresh'
|
||||
import PullToRefreshIndicator from './PullToRefreshIndicator'
|
||||
|
||||
interface ExploreProps {
|
||||
relayPool: RelayPool
|
||||
@@ -17,6 +20,8 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
const [blogPosts, setBlogPosts] = useState<BlogPostPreview[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const exploreContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const loadBlogPosts = async () => {
|
||||
@@ -27,11 +32,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 +92,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 +120,15 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
}
|
||||
|
||||
loadBlogPosts()
|
||||
}, [relayPool, activeAccount])
|
||||
}, [relayPool, activeAccount, blogPosts.length, refreshTrigger])
|
||||
|
||||
// Pull-to-refresh
|
||||
const pullToRefreshState = usePullToRefresh(exploreContainerRef, {
|
||||
onRefresh: () => {
|
||||
setRefreshTrigger(prev => prev + 1)
|
||||
},
|
||||
isRefreshing: loading
|
||||
})
|
||||
|
||||
const getPostUrl = (post: BlogPostPreview) => {
|
||||
// Get the d-tag identifier
|
||||
@@ -79,17 +144,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">
|
||||
@@ -102,7 +156,16 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="explore-container">
|
||||
<div
|
||||
ref={exploreContainerRef}
|
||||
className={`explore-container pull-to-refresh-container ${pullToRefreshState.isPulling ? 'is-pulling' : ''}`}
|
||||
>
|
||||
<PullToRefreshIndicator
|
||||
isPulling={pullToRefreshState.isPulling}
|
||||
pullDistance={pullToRefreshState.pullDistance}
|
||||
canRefresh={pullToRefreshState.canRefresh}
|
||||
isRefreshing={loading && pullToRefreshState.canRefresh}
|
||||
/>
|
||||
<div className="explore-header">
|
||||
<h1>
|
||||
<FontAwesomeIcon icon={faNewspaper} />
|
||||
@@ -112,6 +175,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 +188,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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useRef } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { HighlightItem } from './HighlightItem'
|
||||
import { useFilteredHighlights } from '../hooks/useFilteredHighlights'
|
||||
import { usePullToRefresh } from '../hooks/usePullToRefresh'
|
||||
import HighlightsPanelCollapsed from './HighlightsPanel/HighlightsPanelCollapsed'
|
||||
import HighlightsPanelHeader from './HighlightsPanel/HighlightsPanelHeader'
|
||||
import PullToRefreshIndicator from './PullToRefreshIndicator'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
|
||||
export interface HighlightVisibility {
|
||||
nostrverse: boolean
|
||||
@@ -32,6 +35,7 @@ interface HighlightsPanelProps {
|
||||
followedPubkeys?: Set<string>
|
||||
relayPool?: RelayPool | null
|
||||
eventStore?: IEventStore | null
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
@@ -50,16 +54,29 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
onHighlightVisibilityChange,
|
||||
followedPubkeys = new Set(),
|
||||
relayPool,
|
||||
eventStore
|
||||
eventStore,
|
||||
settings
|
||||
}) => {
|
||||
const [showHighlights, setShowHighlights] = useState(true)
|
||||
const [localHighlights, setLocalHighlights] = useState(highlights)
|
||||
const highlightsListRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleToggleHighlights = () => {
|
||||
const newValue = !showHighlights
|
||||
setShowHighlights(newValue)
|
||||
onToggleHighlights?.(newValue)
|
||||
}
|
||||
|
||||
// Pull-to-refresh for highlights
|
||||
const pullToRefreshState = usePullToRefresh(highlightsListRef, {
|
||||
onRefresh: () => {
|
||||
if (onRefresh) {
|
||||
onRefresh()
|
||||
}
|
||||
},
|
||||
isRefreshing: loading,
|
||||
disabled: !onRefresh
|
||||
})
|
||||
|
||||
// Keep track of highlight updates
|
||||
React.useEffect(() => {
|
||||
@@ -72,6 +89,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 +107,7 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
<HighlightsPanelCollapsed
|
||||
hasHighlights={filteredHighlights.length > 0}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
settings={settings}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -118,7 +141,16 @@ export const HighlightsPanel: React.FC<HighlightsPanelProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="highlights-list">
|
||||
<div
|
||||
ref={highlightsListRef}
|
||||
className={`highlights-list pull-to-refresh-container ${pullToRefreshState.isPulling ? 'is-pulling' : ''}`}
|
||||
>
|
||||
<PullToRefreshIndicator
|
||||
isPulling={pullToRefreshState.isPulling}
|
||||
pullDistance={pullToRefreshState.pullDistance}
|
||||
canRefresh={pullToRefreshState.canRefresh}
|
||||
isRefreshing={loading}
|
||||
/>
|
||||
{filteredHighlights.map((highlight) => (
|
||||
<HighlightItem
|
||||
key={highlight.id}
|
||||
@@ -129,6 +161,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>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronRight, faEye, faEyeSlash, faRotate, faUser, faUserGroup, faNetworkWired } from '@fortawesome/free-solid-svg-icons'
|
||||
import { HighlightVisibility } from '../HighlightsPanel'
|
||||
import IconButton from '../IconButton'
|
||||
|
||||
interface HighlightsPanelHeaderProps {
|
||||
loading: boolean
|
||||
@@ -32,76 +32,81 @@ const HighlightsPanelHeader: React.FC<HighlightsPanelHeaderProps> = ({
|
||||
<div className="highlights-actions-left">
|
||||
{onHighlightVisibilityChange && (
|
||||
<div className="highlight-level-toggles">
|
||||
<button
|
||||
<IconButton
|
||||
icon={faNetworkWired}
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
nostrverse: !highlightVisibility.nostrverse
|
||||
})}
|
||||
className={`level-toggle-btn ${highlightVisibility.nostrverse ? 'active' : ''}`}
|
||||
title="Toggle nostrverse highlights"
|
||||
aria-label="Toggle nostrverse highlights"
|
||||
style={{ color: highlightVisibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faNetworkWired} />
|
||||
</button>
|
||||
<button
|
||||
ariaLabel="Toggle nostrverse highlights"
|
||||
variant="ghost"
|
||||
style={{
|
||||
color: highlightVisibility.nostrverse ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined,
|
||||
opacity: highlightVisibility.nostrverse ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUserGroup}
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
friends: !highlightVisibility.friends
|
||||
})}
|
||||
className={`level-toggle-btn ${highlightVisibility.friends ? 'active' : ''}`}
|
||||
title={currentUserPubkey ? "Toggle friends highlights" : "Login to see friends highlights"}
|
||||
aria-label="Toggle friends highlights"
|
||||
style={{ color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined }}
|
||||
ariaLabel="Toggle friends highlights"
|
||||
variant="ghost"
|
||||
disabled={!currentUserPubkey}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUserGroup} />
|
||||
</button>
|
||||
<button
|
||||
style={{
|
||||
color: highlightVisibility.friends ? 'var(--highlight-color-friends, #f97316)' : undefined,
|
||||
opacity: highlightVisibility.friends ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUser}
|
||||
onClick={() => onHighlightVisibilityChange({
|
||||
...highlightVisibility,
|
||||
mine: !highlightVisibility.mine
|
||||
})}
|
||||
className={`level-toggle-btn ${highlightVisibility.mine ? 'active' : ''}`}
|
||||
title={currentUserPubkey ? "Toggle my highlights" : "Login to see your highlights"}
|
||||
aria-label="Toggle my highlights"
|
||||
style={{ color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined }}
|
||||
ariaLabel="Toggle my highlights"
|
||||
variant="ghost"
|
||||
disabled={!currentUserPubkey}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
</button>
|
||||
style={{
|
||||
color: highlightVisibility.mine ? 'var(--highlight-color-mine, #eab308)' : undefined,
|
||||
opacity: highlightVisibility.mine ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{onRefresh && (
|
||||
<button
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
onClick={onRefresh}
|
||||
className="refresh-highlights-btn"
|
||||
title="Refresh highlights"
|
||||
aria-label="Refresh highlights"
|
||||
ariaLabel="Refresh highlights"
|
||||
variant="ghost"
|
||||
disabled={loading}
|
||||
>
|
||||
<FontAwesomeIcon icon={faRotate} spin={loading} />
|
||||
</button>
|
||||
spin={loading}
|
||||
/>
|
||||
)}
|
||||
{hasHighlights && (
|
||||
<button
|
||||
<IconButton
|
||||
icon={showHighlights ? faEye : faEyeSlash}
|
||||
onClick={onToggleHighlights}
|
||||
className="toggle-highlight-display-btn"
|
||||
title={showHighlights ? 'Hide highlights' : 'Show highlights'}
|
||||
aria-label={showHighlights ? 'Hide highlights' : 'Show highlights'}
|
||||
>
|
||||
<FontAwesomeIcon icon={showHighlights ? faEye : faEyeSlash} />
|
||||
</button>
|
||||
ariaLabel={showHighlights ? 'Hide highlights' : 'Show highlights'}
|
||||
variant="ghost"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
<IconButton
|
||||
icon={faChevronRight}
|
||||
onClick={onToggleCollapse}
|
||||
className="toggle-highlights-btn"
|
||||
title="Collapse highlights panel"
|
||||
aria-label="Collapse highlights panel"
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} rotation={180} />
|
||||
</button>
|
||||
ariaLabel="Collapse highlights panel"
|
||||
variant="ghost"
|
||||
style={{ transform: 'rotate(180deg)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ interface IconButtonProps {
|
||||
disabled?: boolean
|
||||
spin?: boolean
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
const IconButton: React.FC<IconButtonProps> = ({
|
||||
@@ -23,7 +24,8 @@ const IconButton: React.FC<IconButtonProps> = ({
|
||||
size = 33,
|
||||
disabled = false,
|
||||
spin = false,
|
||||
className = ''
|
||||
className = '',
|
||||
style
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
@@ -31,7 +33,7 @@ const IconButton: React.FC<IconButtonProps> = ({
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-label={ariaLabel || title}
|
||||
style={{ width: size, height: size }}
|
||||
style={{ width: size, height: size, ...style }}
|
||||
disabled={disabled}
|
||||
>
|
||||
<FontAwesomeIcon icon={icon} spin={spin} />
|
||||
|
||||
401
src/components/Me.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner, faExclamationCircle, faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare } 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, fetchBlogPostsFromAuthors } from '../services/exploreService'
|
||||
import { RELAYS } from '../config/relays'
|
||||
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'
|
||||
import { usePullToRefresh } from '../hooks/usePullToRefresh'
|
||||
import PullToRefreshIndicator from './PullToRefreshIndicator'
|
||||
|
||||
interface MeProps {
|
||||
relayPool: RelayPool
|
||||
activeTab?: TabType
|
||||
pubkey?: string // Optional pubkey for viewing other users' profiles
|
||||
}
|
||||
|
||||
type TabType = 'highlights' | 'reading-list' | 'archive' | 'writings'
|
||||
|
||||
const Me: React.FC<MeProps> = ({ relayPool, activeTab: propActiveTab, pubkey: propPubkey }) => {
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const navigate = useNavigate()
|
||||
const [activeTab, setActiveTab] = useState<TabType>(propActiveTab || 'highlights')
|
||||
|
||||
// Use provided pubkey or fall back to active account
|
||||
const viewingPubkey = propPubkey || activeAccount?.pubkey
|
||||
const isOwnProfile = !propPubkey || (activeAccount?.pubkey === propPubkey)
|
||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>([])
|
||||
const [readArticles, setReadArticles] = useState<BlogPostPreview[]>([])
|
||||
const [writings, setWritings] = useState<BlogPostPreview[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('cards')
|
||||
const meContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||
|
||||
// Update local state when prop changes
|
||||
useEffect(() => {
|
||||
if (propActiveTab) {
|
||||
setActiveTab(propActiveTab)
|
||||
}
|
||||
}, [propActiveTab])
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (!viewingPubkey) {
|
||||
setError(isOwnProfile ? 'Please log in to view your data' : 'Invalid profile')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Seed from cache if available to avoid empty flash (own profile only)
|
||||
if (isOwnProfile) {
|
||||
const cached = getCachedMeData(viewingPubkey)
|
||||
if (cached) {
|
||||
setHighlights(cached.highlights)
|
||||
setBookmarks(cached.bookmarks)
|
||||
setReadArticles(cached.readArticles)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch highlights and writings (public data)
|
||||
const [userHighlights, userWritings] = await Promise.all([
|
||||
fetchHighlights(relayPool, viewingPubkey),
|
||||
fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS)
|
||||
])
|
||||
|
||||
setHighlights(userHighlights)
|
||||
setWritings(userWritings)
|
||||
|
||||
// Only fetch private data for own profile
|
||||
if (isOwnProfile && activeAccount) {
|
||||
const userReadArticles = await fetchReadArticlesWithData(relayPool, viewingPubkey)
|
||||
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(viewingPubkey, userHighlights, fetchedBookmarks, userReadArticles)
|
||||
} else {
|
||||
setBookmarks([])
|
||||
setReadArticles([])
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err)
|
||||
setError('Failed to load data. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadData()
|
||||
}, [relayPool, viewingPubkey, isOwnProfile, activeAccount, refreshTrigger])
|
||||
|
||||
// Pull-to-refresh
|
||||
const pullToRefreshState = usePullToRefresh(meContainerRef, {
|
||||
onRefresh: () => {
|
||||
setRefreshTrigger(prev => prev + 1)
|
||||
},
|
||||
isRefreshing: loading
|
||||
})
|
||||
|
||||
const handleHighlightDelete = (highlightId: string) => {
|
||||
setHighlights(prev => {
|
||||
const updated = prev.filter(h => h.id !== highlightId)
|
||||
// Update cache when highlight is deleted (own profile only)
|
||||
if (isOwnProfile && viewingPubkey) {
|
||||
updateCachedHighlights(viewingPubkey, 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 || writings.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>
|
||||
)
|
||||
|
||||
case 'writings':
|
||||
return writings.length === 0 ? (
|
||||
<div className="explore-error">
|
||||
<p>No articles written yet. Publish your first article to see it here!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="explore-grid">
|
||||
{writings.map((post) => (
|
||||
<BlogPostCard
|
||||
key={post.event.id}
|
||||
post={post}
|
||||
href={getPostUrl(post)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={meContainerRef}
|
||||
className={`explore-container pull-to-refresh-container ${pullToRefreshState.isPulling ? 'is-pulling' : ''}`}
|
||||
>
|
||||
<PullToRefreshIndicator
|
||||
isPulling={pullToRefreshState.isPulling}
|
||||
pullDistance={pullToRefreshState.pullDistance}
|
||||
canRefresh={pullToRefreshState.canRefresh}
|
||||
isRefreshing={loading && pullToRefreshState.canRefresh}
|
||||
/>
|
||||
<div className="explore-header">
|
||||
{viewingPubkey && <AuthorCard authorPubkey={viewingPubkey} clickable={false} />}
|
||||
|
||||
{loading && hasData && (
|
||||
<div className="explore-loading" style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0' }}>
|
||||
<FontAwesomeIcon icon={faSpinner} spin />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="me-tabs">
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'highlights' ? 'active' : ''}`}
|
||||
data-tab="highlights"
|
||||
onClick={() => navigate(isOwnProfile ? '/me/highlights' : `/p/${propPubkey && nip19.npubEncode(propPubkey)}`)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<span className="tab-label">Highlights</span>
|
||||
<span className="tab-count">({highlights.length})</span>
|
||||
</button>
|
||||
{isOwnProfile && (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className={`me-tab ${activeTab === 'writings' ? 'active' : ''}`}
|
||||
data-tab="writings"
|
||||
onClick={() => navigate(isOwnProfile ? '/me/writings' : `/p/${propPubkey && nip19.npubEncode(propPubkey)}/writings`)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPenToSquare} />
|
||||
<span className="tab-label">Writings</span>
|
||||
<span className="tab-count">({writings.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="me-tab-content">
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Me
|
||||
|
||||
52
src/components/PullToRefreshIndicator.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faArrowDown } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
interface PullToRefreshIndicatorProps {
|
||||
isPulling: boolean
|
||||
pullDistance: number
|
||||
canRefresh: boolean
|
||||
isRefreshing: boolean
|
||||
threshold?: number
|
||||
}
|
||||
|
||||
const PullToRefreshIndicator: React.FC<PullToRefreshIndicatorProps> = ({
|
||||
isPulling,
|
||||
pullDistance,
|
||||
canRefresh,
|
||||
threshold = 80
|
||||
}) => {
|
||||
// Only show when actively pulling, not when refreshing
|
||||
if (!isPulling) return null
|
||||
|
||||
const opacity = Math.min(pullDistance / threshold, 1)
|
||||
const rotation = (pullDistance / threshold) * 180
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pull-to-refresh-indicator"
|
||||
style={{
|
||||
opacity,
|
||||
transform: `translateY(${-20 + pullDistance / 2}px)`
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="pull-to-refresh-icon"
|
||||
style={{
|
||||
transform: `rotate(${rotation}deg)`
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowDown}
|
||||
style={{ color: canRefresh ? 'var(--accent-color, #3b82f6)' : 'var(--text-secondary)' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="pull-to-refresh-text">
|
||||
{canRefresh ? 'Release to refresh' : 'Pull to refresh'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PullToRefreshIndicator
|
||||
|
||||
@@ -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,12 +29,46 @@ 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 (
|
||||
<>
|
||||
@@ -52,7 +91,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>
|
||||
@@ -89,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>
|
||||
|
||||
59
src/components/ReadingProgressIndicator.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react'
|
||||
|
||||
interface ReadingProgressIndicatorProps {
|
||||
progress: number // 0 to 100
|
||||
isComplete?: boolean
|
||||
showPercentage?: boolean
|
||||
className?: string
|
||||
isSidebarCollapsed?: boolean
|
||||
isHighlightsCollapsed?: boolean
|
||||
}
|
||||
|
||||
export const ReadingProgressIndicator: React.FC<ReadingProgressIndicatorProps> = ({
|
||||
progress,
|
||||
isComplete = false,
|
||||
showPercentage = true,
|
||||
className = '',
|
||||
isSidebarCollapsed = false,
|
||||
isHighlightsCollapsed = false
|
||||
}) => {
|
||||
const clampedProgress = Math.min(100, Math.max(0, progress))
|
||||
|
||||
// Calculate left and right offsets based on sidebar states (desktop only)
|
||||
const leftOffset = isSidebarCollapsed
|
||||
? 'var(--sidebar-collapsed-width)'
|
||||
: 'var(--sidebar-width)'
|
||||
const rightOffset = isHighlightsCollapsed
|
||||
? 'var(--highlights-collapsed-width)'
|
||||
: 'var(--highlights-width)'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`reading-progress-bar 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}`}
|
||||
style={{
|
||||
'--left-offset': leftOffset,
|
||||
'--right-offset': rightOffset
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<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
|
||||
|
||||
@@ -57,36 +64,109 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ rela
|
||||
// Don't show indicator when fully connected (but show when connecting)
|
||||
if (!localOnlyMode && !offlineMode && !isConnecting) return null
|
||||
|
||||
const handleClick = () => {
|
||||
if (isMobile) {
|
||||
setIsExpanded(!isExpanded)
|
||||
}
|
||||
}
|
||||
|
||||
// On mobile, default to collapsed (icon only). On desktop, always show details.
|
||||
const showDetails = !isMobile || isExpanded
|
||||
|
||||
// On mobile when collapsed, make it circular like the highlight button
|
||||
const isCollapsedOnMobile = 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={{
|
||||
position: 'fixed',
|
||||
bottom: '32px',
|
||||
left: '32px',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: showDetails ? '0.5rem' : '0',
|
||||
padding: isCollapsedOnMobile ? '0.875rem' : (showDetails ? '0.75rem 1rem' : '0.75rem'),
|
||||
width: isCollapsedOnMobile ? '56px' : 'auto',
|
||||
height: isCollapsedOnMobile ? '56px' : 'auto',
|
||||
backgroundColor: 'rgba(39, 39, 42, 0.9)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: '1px solid rgb(82, 82, 91)',
|
||||
borderRadius: isCollapsedOnMobile ? '50%' : '12px',
|
||||
color: 'rgb(228, 228, 231)',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
|
||||
cursor: isMobile ? 'pointer' : 'default',
|
||||
opacity: isMobile && !showOnMobile ? 0 : 1,
|
||||
visibility: isMobile && !showOnMobile ? 'hidden' : 'visible',
|
||||
transition: 'all 0.3s ease',
|
||||
userSelect: 'none',
|
||||
justifyContent: isCollapsedOnMobile ? 'center' : 'flex-start'
|
||||
}}
|
||||
>
|
||||
<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"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.125rem'
|
||||
}}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<span className="relay-status-title">Connecting</span>
|
||||
) : offlineMode ? (
|
||||
<>
|
||||
<span className="relay-status-title">Offline</span>
|
||||
<span
|
||||
className="relay-status-subtitle"
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
opacity: 0.7,
|
||||
fontWeight: 400
|
||||
}}
|
||||
>
|
||||
No relays connected
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="relay-status-title">Flight Mode</span>
|
||||
<span
|
||||
className="relay-status-subtitle"
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
opacity: 0.7,
|
||||
fontWeight: 400
|
||||
}}
|
||||
>
|
||||
{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
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHighlighter, faUnderline, faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
import IconButton from '../IconButton'
|
||||
@@ -73,7 +72,7 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
||||
<label className="setting-label">My Highlights</label>
|
||||
<div className="setting-control">
|
||||
<ColorPicker
|
||||
selectedColor={settings.highlightColorMine || '#ffff00'}
|
||||
selectedColor={settings.highlightColorMine || '#fde047'}
|
||||
onColorChange={(color) => onUpdate({ highlightColorMine: color })}
|
||||
/>
|
||||
</div>
|
||||
@@ -102,33 +101,39 @@ const ReadingDisplaySettings: React.FC<ReadingDisplaySettingsProps> = ({ setting
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Default Highlight Visibility</label>
|
||||
<div className="highlight-level-toggles">
|
||||
<button
|
||||
<IconButton
|
||||
icon={faNetworkWired}
|
||||
onClick={() => onUpdate({ defaultHighlightVisibilityNostrverse: !(settings.defaultHighlightVisibilityNostrverse !== false) })}
|
||||
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityNostrverse !== false) ? 'active' : ''}`}
|
||||
title="Nostrverse highlights"
|
||||
aria-label="Toggle nostrverse highlights by default"
|
||||
style={{ color: (settings.defaultHighlightVisibilityNostrverse !== false) ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faNetworkWired} />
|
||||
</button>
|
||||
<button
|
||||
ariaLabel="Toggle nostrverse highlights by default"
|
||||
variant="ghost"
|
||||
style={{
|
||||
color: (settings.defaultHighlightVisibilityNostrverse !== false) ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined,
|
||||
opacity: (settings.defaultHighlightVisibilityNostrverse !== false) ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUserGroup}
|
||||
onClick={() => onUpdate({ defaultHighlightVisibilityFriends: !(settings.defaultHighlightVisibilityFriends !== false) })}
|
||||
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityFriends !== false) ? 'active' : ''}`}
|
||||
title="Friends highlights"
|
||||
aria-label="Toggle friends highlights by default"
|
||||
style={{ color: (settings.defaultHighlightVisibilityFriends !== false) ? 'var(--highlight-color-friends, #f97316)' : undefined }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUserGroup} />
|
||||
</button>
|
||||
<button
|
||||
ariaLabel="Toggle friends highlights by default"
|
||||
variant="ghost"
|
||||
style={{
|
||||
color: (settings.defaultHighlightVisibilityFriends !== false) ? 'var(--highlight-color-friends, #f97316)' : undefined,
|
||||
opacity: (settings.defaultHighlightVisibilityFriends !== false) ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUser}
|
||||
onClick={() => onUpdate({ defaultHighlightVisibilityMine: !(settings.defaultHighlightVisibilityMine !== false) })}
|
||||
className={`level-toggle-btn ${(settings.defaultHighlightVisibilityMine !== false) ? 'active' : ''}`}
|
||||
title="My highlights"
|
||||
aria-label="Toggle my highlights by default"
|
||||
style={{ color: (settings.defaultHighlightVisibilityMine !== false) ? 'var(--highlight-color-mine, #eab308)' : undefined }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
</button>
|
||||
ariaLabel="Toggle my highlights by default"
|
||||
variant="ghost"
|
||||
style={{
|
||||
color: (settings.defaultHighlightVisibilityMine !== false) ? 'var(--highlight-color-mine, #eab308)' : undefined,
|
||||
opacity: (settings.defaultHighlightVisibilityMine !== false) ? 1 : 0.4
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -100,13 +100,16 @@ const RelaySettings: React.FC<RelaySettingsProps> = ({ relayStatuses }) => {
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontSize: '0.9rem',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}>
|
||||
<div
|
||||
className="relay-url"
|
||||
style={{
|
||||
fontSize: '0.9rem',
|
||||
fontFamily: 'var(--font-mono, monospace)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}
|
||||
>
|
||||
{formatRelayUrl(relay.url)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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()} />
|
||||
|
||||
@@ -20,6 +20,8 @@ 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
|
||||
@@ -28,6 +30,8 @@ interface ThreePaneLayoutProps {
|
||||
isSidebarOpen: boolean
|
||||
showSettings: boolean
|
||||
showExplore?: boolean
|
||||
showMe?: boolean
|
||||
showProfile?: boolean
|
||||
|
||||
// Bookmarks pane
|
||||
bookmarks: Bookmark[]
|
||||
@@ -59,6 +63,8 @@ interface ThreePaneLayoutProps {
|
||||
onClearSelection: () => void
|
||||
currentUserPubkey?: string
|
||||
followedPubkeys: Set<string>
|
||||
activeAccount?: IAccount | null
|
||||
currentArticle?: NostrEvent | null
|
||||
|
||||
// Highlights pane
|
||||
highlights: Highlight[]
|
||||
@@ -81,6 +87,12 @@ interface ThreePaneLayoutProps {
|
||||
|
||||
// Optional Explore content
|
||||
explore?: React.ReactNode
|
||||
|
||||
// Optional Me content
|
||||
me?: React.ReactNode
|
||||
|
||||
// Optional Profile content
|
||||
profile?: React.ReactNode
|
||||
}
|
||||
|
||||
const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
@@ -90,11 +102,10 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
const mainPaneRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Detect scroll direction to hide/show mobile buttons
|
||||
// On mobile, scroll happens in the main pane, not on window
|
||||
// Now using window scroll (document scroll) instead of pane scroll
|
||||
const scrollDirection = useScrollDirection({
|
||||
threshold: 10,
|
||||
enabled: isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed,
|
||||
elementRef: mainPaneRef
|
||||
enabled: isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed
|
||||
})
|
||||
const showMobileButtons = scrollDirection !== 'down'
|
||||
|
||||
@@ -214,34 +225,54 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile bookmark button - only show when viewing article */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && (
|
||||
{/* Mobile bookmark button - only show when viewing article (not on settings/explore/me/profile) */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !props.showMe && !props.showProfile && (
|
||||
<button
|
||||
className={`mobile-hamburger-btn ${showMobileButtons ? 'visible' : 'hidden'}`}
|
||||
className={`fixed z-[900] bg-zinc-800/70 border border-zinc-600/40 rounded-lg text-zinc-200 flex items-center justify-center transition-all duration-300 active:scale-95 backdrop-blur-sm md:hidden ${
|
||||
showMobileButtons ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
|
||||
}`}
|
||||
style={{
|
||||
top: 'calc(1rem + env(safe-area-inset-top))',
|
||||
left: 'calc(1rem + env(safe-area-inset-left))',
|
||||
width: '40px',
|
||||
height: '40px'
|
||||
}}
|
||||
onClick={props.onToggleSidebar}
|
||||
aria-label="Open bookmarks"
|
||||
aria-expanded={props.isSidebarOpen}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBookmark} />
|
||||
<FontAwesomeIcon icon={faBookmark} size="sm" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mobile highlights button - only show when viewing article */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && (
|
||||
{/* Mobile highlights button - only show when viewing article (not on settings/explore/me/profile) */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && !props.showSettings && !props.showExplore && !props.showMe && !props.showProfile && (
|
||||
<button
|
||||
className={`mobile-highlights-btn ${showMobileButtons ? 'visible' : 'hidden'}`}
|
||||
className={`fixed z-[900] border border-zinc-600/40 rounded-lg flex items-center justify-center transition-all duration-300 active:scale-95 backdrop-blur-sm md:hidden ${
|
||||
showMobileButtons ? 'opacity-90 visible' : 'opacity-0 invisible pointer-events-none'
|
||||
}`}
|
||||
style={{
|
||||
top: 'calc(1rem + env(safe-area-inset-top))',
|
||||
right: 'calc(1rem + env(safe-area-inset-right))',
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
backgroundColor: `${props.settings.highlightColorMine || '#fde047'}B3`,
|
||||
color: '#000'
|
||||
}}
|
||||
onClick={props.onToggleHighlightsPanel}
|
||||
aria-label="Open highlights"
|
||||
aria-expanded={!props.isHighlightsCollapsed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
<FontAwesomeIcon icon={faHighlighter} size="sm" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 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"
|
||||
/>
|
||||
@@ -288,6 +319,16 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
<>
|
||||
{props.explore}
|
||||
</>
|
||||
) : props.showMe && props.me ? (
|
||||
// Render Me inside the main pane to keep side panels
|
||||
<>
|
||||
{props.me}
|
||||
</>
|
||||
) : props.showProfile && props.profile ? (
|
||||
// Render Profile inside the main pane to keep side panels
|
||||
<>
|
||||
{props.profile}
|
||||
</>
|
||||
) : (
|
||||
<ContentPanel
|
||||
loading={props.readerLoading}
|
||||
@@ -311,6 +352,10 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
followedPubkeys={props.followedPubkeys}
|
||||
settings={props.settings}
|
||||
relayPool={props.relayPool}
|
||||
activeAccount={props.activeAccount}
|
||||
currentArticle={props.currentArticle}
|
||||
isSidebarCollapsed={props.isCollapsed}
|
||||
isHighlightsCollapsed={props.isHighlightsCollapsed}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -336,6 +381,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
followedPubkeys={props.followedPubkeys}
|
||||
relayPool={props.relayPool}
|
||||
eventStore={props.eventStore}
|
||||
settings={props.settings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -343,10 +389,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, naddr, handleFetchBookmarks, handleFetchHighlights, handleFetchContacts])
|
||||
}, [relayPool, activeAccount, naddr, handleFetchHighlights, handleFetchContacts])
|
||||
|
||||
return {
|
||||
bookmarks,
|
||||
|
||||
@@ -4,6 +4,19 @@ import { fetchReadableContent, ReadableContent } from '../services/readerService
|
||||
import { fetchHighlightsForUrl } from '../services/highlightService'
|
||||
import { Highlight } from '../types/highlights'
|
||||
|
||||
// Helper to extract filename from URL
|
||||
function getFilenameFromUrl(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url)
|
||||
const pathname = urlObj.pathname
|
||||
const filename = pathname.substring(pathname.lastIndexOf('/') + 1)
|
||||
// Decode URI component to handle special characters
|
||||
return decodeURIComponent(filename) || url
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
interface UseExternalUrlLoaderProps {
|
||||
url: string | undefined
|
||||
relayPool: RelayPool | null
|
||||
@@ -11,7 +24,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 +70,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 {
|
||||
@@ -70,8 +97,10 @@ export function useExternalUrlLoader({
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load external URL:', err)
|
||||
// For videos and other media files, use the filename as the title
|
||||
const filename = getFilenameFromUrl(url)
|
||||
setReaderContent({
|
||||
title: 'Error Loading Content',
|
||||
title: filename,
|
||||
html: `<p>Failed to load content: ${err instanceof Error ? err.message : 'Unknown error'}</p>`,
|
||||
url
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
@@ -77,8 +78,18 @@ export const useHighlightCreation = ({
|
||||
publishedRelays: newHighlight.publishedRelays
|
||||
})
|
||||
|
||||
// Clear the browser's text selection immediately to allow DOM update
|
||||
const selection = window.getSelection()
|
||||
if (selection) {
|
||||
selection.removeAllRanges()
|
||||
}
|
||||
|
||||
highlightButtonRef.current?.clearSelection()
|
||||
onHighlightCreated(newHighlight)
|
||||
|
||||
// Force synchronous render to show highlight immediately
|
||||
flushSync(() => {
|
||||
onHighlightCreated(newHighlight)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to create highlight:', error)
|
||||
// Re-throw to allow parent to handle
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useCallback, useRef } from 'react'
|
||||
import { useEffect, useCallback, useRef, useState } from 'react'
|
||||
|
||||
interface UseHighlightInteractionsParams {
|
||||
onHighlightClick?: (highlightId: string) => void
|
||||
@@ -14,6 +14,25 @@ export const useHighlightInteractions = ({
|
||||
onClearSelection
|
||||
}: UseHighlightInteractionsParams) => {
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const [contentVersion, setContentVersion] = useState(0)
|
||||
|
||||
// Watch for DOM changes (highlights being added/removed)
|
||||
useEffect(() => {
|
||||
if (!contentRef.current) return
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
// Increment version to trigger re-attachment of handlers
|
||||
setContentVersion(prev => prev + 1)
|
||||
})
|
||||
|
||||
observer.observe(contentRef.current, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: false
|
||||
})
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
// Attach click handlers to highlight marks
|
||||
useEffect(() => {
|
||||
@@ -37,24 +56,33 @@ export const useHighlightInteractions = ({
|
||||
mark.removeEventListener('click', handler)
|
||||
})
|
||||
}
|
||||
}, [onHighlightClick])
|
||||
}, [onHighlightClick, contentVersion])
|
||||
|
||||
// Scroll to selected highlight
|
||||
useEffect(() => {
|
||||
if (!selectedHighlightId || !contentRef.current) return
|
||||
|
||||
const markElement = contentRef.current.querySelector(`mark[data-highlight-id="${selectedHighlightId}"]`)
|
||||
|
||||
if (markElement) {
|
||||
markElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
// Use a small delay to ensure DOM is updated
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!contentRef.current) return
|
||||
|
||||
const htmlElement = markElement as HTMLElement
|
||||
setTimeout(() => {
|
||||
htmlElement.classList.add('highlight-pulse')
|
||||
setTimeout(() => htmlElement.classList.remove('highlight-pulse'), 1500)
|
||||
}, 500)
|
||||
}
|
||||
}, [selectedHighlightId])
|
||||
const markElement = contentRef.current.querySelector(`mark[data-highlight-id="${selectedHighlightId}"]`)
|
||||
|
||||
if (markElement) {
|
||||
markElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
|
||||
const htmlElement = markElement as HTMLElement
|
||||
setTimeout(() => {
|
||||
htmlElement.classList.add('highlight-pulse')
|
||||
setTimeout(() => htmlElement.classList.remove('highlight-pulse'), 1500)
|
||||
}, 500)
|
||||
} else {
|
||||
console.warn('Could not find mark element for highlight:', selectedHighlightId)
|
||||
}
|
||||
}, 100)
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [selectedHighlightId, contentVersion])
|
||||
|
||||
// Handle text selection (works for both mouse and touch)
|
||||
const handleSelectionEnd = useCallback(() => {
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
153
src/hooks/usePullToRefresh.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useRef, useState, RefObject } from 'react'
|
||||
import { useIsCoarsePointer } from './useMediaQuery'
|
||||
|
||||
interface UsePullToRefreshOptions {
|
||||
onRefresh: () => void | Promise<void>
|
||||
isRefreshing?: boolean
|
||||
disabled?: boolean
|
||||
threshold?: number // Distance in pixels to trigger refresh
|
||||
resistance?: number // Resistance factor (higher = harder to pull)
|
||||
}
|
||||
|
||||
interface PullToRefreshState {
|
||||
isPulling: boolean
|
||||
pullDistance: number
|
||||
canRefresh: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to enable pull-to-refresh gesture on touch devices
|
||||
* @param containerRef - Ref to the scrollable container element
|
||||
* @param options - Configuration options
|
||||
* @returns State of the pull gesture
|
||||
*/
|
||||
export function usePullToRefresh(
|
||||
containerRef: RefObject<HTMLElement>,
|
||||
options: UsePullToRefreshOptions
|
||||
): PullToRefreshState {
|
||||
const {
|
||||
onRefresh,
|
||||
isRefreshing = false,
|
||||
disabled = false,
|
||||
threshold = 80,
|
||||
resistance = 2.5
|
||||
} = options
|
||||
|
||||
const isTouch = useIsCoarsePointer()
|
||||
const [pullState, setPullState] = useState<PullToRefreshState>({
|
||||
isPulling: false,
|
||||
pullDistance: 0,
|
||||
canRefresh: false
|
||||
})
|
||||
|
||||
const touchStartY = useRef<number>(0)
|
||||
const startScrollTop = useRef<number>(0)
|
||||
const isDragging = useRef<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container || !isTouch || disabled || isRefreshing) return
|
||||
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
// Only start if scrolled to top
|
||||
const scrollTop = container.scrollTop
|
||||
if (scrollTop <= 0) {
|
||||
touchStartY.current = e.touches[0].clientY
|
||||
startScrollTop.current = scrollTop
|
||||
isDragging.current = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (!isDragging.current) return
|
||||
|
||||
const currentY = e.touches[0].clientY
|
||||
const deltaY = currentY - touchStartY.current
|
||||
const scrollTop = container.scrollTop
|
||||
|
||||
// Only pull down when at top and pulling down
|
||||
if (scrollTop <= 0 && deltaY > 0) {
|
||||
// Prevent default scroll behavior
|
||||
e.preventDefault()
|
||||
|
||||
// Apply resistance to make pulling feel natural
|
||||
const distance = Math.min(deltaY / resistance, threshold * 1.5)
|
||||
const canRefresh = distance >= threshold
|
||||
|
||||
setPullState({
|
||||
isPulling: true,
|
||||
pullDistance: distance,
|
||||
canRefresh
|
||||
})
|
||||
} else {
|
||||
// Reset if scrolled or pulling up
|
||||
isDragging.current = false
|
||||
setPullState({
|
||||
isPulling: false,
|
||||
pullDistance: 0,
|
||||
canRefresh: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchEnd = async () => {
|
||||
if (!isDragging.current) return
|
||||
|
||||
isDragging.current = false
|
||||
|
||||
if (pullState.canRefresh && !isRefreshing) {
|
||||
// Keep the indicator visible while refreshing
|
||||
setPullState(prev => ({
|
||||
...prev,
|
||||
isPulling: false
|
||||
}))
|
||||
|
||||
// Trigger refresh
|
||||
await onRefresh()
|
||||
}
|
||||
|
||||
// Reset state
|
||||
setPullState({
|
||||
isPulling: false,
|
||||
pullDistance: 0,
|
||||
canRefresh: false
|
||||
})
|
||||
}
|
||||
|
||||
const handleTouchCancel = () => {
|
||||
isDragging.current = false
|
||||
setPullState({
|
||||
isPulling: false,
|
||||
pullDistance: 0,
|
||||
canRefresh: false
|
||||
})
|
||||
}
|
||||
|
||||
// Add event listeners with passive: false to allow preventDefault
|
||||
container.addEventListener('touchstart', handleTouchStart, { passive: true })
|
||||
container.addEventListener('touchmove', handleTouchMove, { passive: false })
|
||||
container.addEventListener('touchend', handleTouchEnd, { passive: true })
|
||||
container.addEventListener('touchcancel', handleTouchCancel, { passive: true })
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('touchstart', handleTouchStart)
|
||||
container.removeEventListener('touchmove', handleTouchMove)
|
||||
container.removeEventListener('touchend', handleTouchEnd)
|
||||
container.removeEventListener('touchcancel', handleTouchCancel)
|
||||
}
|
||||
}, [containerRef, isTouch, disabled, isRefreshing, threshold, resistance, onRefresh, pullState.canRefresh])
|
||||
|
||||
// Reset pull state when refresh completes
|
||||
useEffect(() => {
|
||||
if (!isRefreshing && pullState.isPulling) {
|
||||
setPullState({
|
||||
isPulling: false,
|
||||
pullDistance: 0,
|
||||
canRefresh: false
|
||||
})
|
||||
}
|
||||
}, [isRefreshing, pullState.isPulling])
|
||||
|
||||
return pullState
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export function useSettings({ relayPool, eventStore, pubkey, accountManager }: U
|
||||
root.setProperty('--reading-font-size', `${settings.fontSize || 21}px`)
|
||||
|
||||
// Set highlight colors for three levels
|
||||
root.setProperty('--highlight-color-mine', settings.highlightColorMine || '#ffff00')
|
||||
root.setProperty('--highlight-color-mine', settings.highlightColorMine || '#fde047')
|
||||
root.setProperty('--highlight-color-friends', settings.highlightColorFriends || '#f97316')
|
||||
root.setProperty('--highlight-color-nostrverse', settings.highlightColorNostrverse || '#9333ea')
|
||||
|
||||
|
||||
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'
|
||||
})
|
||||
|
||||
3176
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')
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
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 { 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
|
||||
|
||||
@@ -25,9 +27,11 @@ export async function fetchArticleTitle(
|
||||
const pointer = decoded.data as AddressPointer
|
||||
|
||||
// Define relays to query
|
||||
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 = {
|
||||
@@ -36,11 +40,11 @@ export async function fetchArticleTitle(
|
||||
'#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(
|
||||
relayPool
|
||||
.req(relays, filter)
|
||||
.pipe(completeOnEose(), takeUntil(timer(5000)), toArray())
|
||||
)
|
||||
merge(local$.pipe(take(1)), remote$.pipe(take(1))).pipe(rxToArray())
|
||||
) as unknown as { created_at: number }[]
|
||||
|
||||
if (events.length === 0) {
|
||||
return null
|
||||
@@ -48,7 +52,7 @@ export async function fetchArticleTitle(
|
||||
|
||||
// Sort by created_at and take the most recent
|
||||
events.sort((a, b) => b.created_at - a.created_at)
|
||||
const article = events[0]
|
||||
const article = events[0] as unknown as Parameters<typeof getArticleTitle>[0]
|
||||
|
||||
return getArticleTitle(article) || null
|
||||
} catch (err) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
28
src/styles/base/global.css
Normal file
@@ -0,0 +1,28 @@
|
||||
/* Global element styles and app container (Tailwind-compatible) */
|
||||
|
||||
/* Body - keep only app-specific overrides */
|
||||
body {
|
||||
overscroll-behavior: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overflow-x: hidden;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
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: rgb(212 212 216); /* zinc-300 */
|
||||
}
|
||||
|
||||
|
||||
57
src/styles/base/variables.css
Normal file
@@ -0,0 +1,57 @@
|
||||
/* 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) */
|
||||
/* Defaults use Tailwind color palette: yellow-300, orange-500, purple-600 */
|
||||
--highlight-color-mine: #fde047; /* yellow-300 */
|
||||
--highlight-color-friends: #f97316; /* orange-500 */
|
||||
--highlight-color-nostrverse: #9333ea; /* purple-600 */
|
||||
--highlight-color: #fde047; /* 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: rgb(24 24 27); /* zinc-900 */ 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: rgb(255 255 255); /* white */ font-size: 1.2rem; }
|
||||
.bookmark-url { color: rgb(99 102 241); /* indigo-500 */ 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: rgb(212 212 216); /* zinc-300 */ margin: 0.5rem 0; line-height: 1.4; word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; }
|
||||
.bookmark-meta { color: rgb(161 161 170); /* zinc-400 */ 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: rgb(255 255 255); /* white */ }
|
||||
|
||||
.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 rgb(39 39 42); /* zinc-800 */ word-wrap: break-word; overflow-wrap: break-word; word-break: break-word; overflow: hidden; }
|
||||
.individual-bookmark:hover { border-color: rgb(63 63 70); /* zinc-700 */ background: rgb(39 39 42); /* zinc-800 */ }
|
||||
|
||||
/* Compact view */
|
||||
.individual-bookmark.compact { padding: 0.5rem 0.5rem; background: transparent; border: none; border-bottom: 1px solid rgb(39 39 42); /* zinc-800 */ border-radius: 0; box-shadow: none; width: 100%; max-width: 100%; overflow: hidden; }
|
||||
.individual-bookmark.compact:hover { background: rgb(39 39 42); /* zinc-800 */ border-bottom-color: rgb(63 63 70); /* zinc-700 */ 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: rgb(39 39 42); /* zinc-800 */ 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: rgb(99 102 241); /* indigo-500 */ font-size: 0.85rem; flex-shrink: 0; }
|
||||
.compact-text { flex: 1; min-width: 0; color: rgb(212 212 216); /* zinc-300 */ font-size: 0.85rem; line-height: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bookmark-date-compact { font-size: 0.7rem; color: rgb(113 113 122); /* zinc-500 */ flex-shrink: 0; white-space: nowrap; }
|
||||
.compact-read-btn { background: transparent; color: rgb(161 161 170); /* zinc-400 */ 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: rgb(212 212 216); /* zinc-300 */ }
|
||||
.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: rgb(99 102 241); /* indigo-500 */ font-size: 0.9rem; display: flex; align-items: center; gap: 0.35rem; }
|
||||
.bookmark-id { font-family: monospace; font-size: 0.8rem; color: rgb(161 161 170); /* zinc-400 */ background: rgb(24 24 27); /* zinc-900 */ padding: 0.25rem 0.5rem; border-radius: 4px; }
|
||||
.bookmark-date { font-size: 0.8rem; color: rgb(113 113 122); /* zinc-500 */ }
|
||||
.bookmark-date-link { font-size: 0.8rem; color: rgb(113 113 122); /* zinc-500 */ text-decoration: none; transition: color 0.2s ease; }
|
||||
.bookmark-date-link:hover { color: rgb(96 165 250); /* blue-400 */ text-decoration: underline; }
|
||||
.individual-bookmark .bookmark-content { margin: 0.75rem 0; color: rgb(212 212 216); /* zinc-300 */ 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: rgb(161 161 170); /* zinc-400 */ cursor: pointer; width: 100%; height: 22px; display: flex; align-items: center; justify-content: center; }
|
||||
.expand-toggle:hover { color: rgb(212 212 216); /* zinc-300 */ }
|
||||
.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: rgb(161 161 170); /* zinc-400 */ }
|
||||
.author-link-minimal { color: rgb(161 161 170); /* zinc-400 */ text-decoration: none; transition: color 0.2s ease; }
|
||||
.author-link-minimal:hover { color: rgb(212 212 216); /* zinc-300 */ }
|
||||
.read-now-button-minimal { background: rgb(99 102 241); /* indigo-500 */ 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: rgb(79 70 229); /* indigo-600 */ }
|
||||
.expand-toggle-urls { margin-top: 0.5rem; background: transparent; border: none; color: rgb(99 102 241); /* indigo-500 */ cursor: pointer; font-size: 0.8rem; padding: 0.25rem 0; text-decoration: underline; }
|
||||
.expand-toggle-urls:hover { color: rgb(129 140 248); /* indigo-400 */ }
|
||||
|
||||
/* Large preview view */
|
||||
.individual-bookmark.large { padding: 0; display: flex; flex-direction: column; overflow: hidden; border: 1px solid rgb(39 39 42); /* zinc-800 */ }
|
||||
.large-preview-image { width: 100%; height: 180px; background: rgb(24 24 27); /* zinc-900 */ 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 rgb(63 63 70); /* zinc-700 */ 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: rgb(82 82 91); /* zinc-600 */ }
|
||||
.large-content { padding: 1.25rem; }
|
||||
.large-text { color: rgb(212 212 216); /* zinc-300 */ 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: rgb(161 161 170); /* zinc-400 */ padding-top: 0.75rem; border-top: 1px solid rgb(63 63 70); /* zinc-700 */ }
|
||||
.large-author { flex: 1; }
|
||||
.large-read-button { background: rgb(99 102 241); /* indigo-500 */ 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: rgb(79 70 229); /* indigo-600 */ }
|
||||
|
||||
/* 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: rgb(99 102 241); /* indigo-500 */ 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: rgb(239 68 68); /* red-500 */ }
|
||||
.explore-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 2rem; margin-top: 2rem; }
|
||||
.blog-post-card { background: rgb(24 24 27); /* zinc-900 */ border: 1px solid rgb(63 63 70); /* zinc-700 */ 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: rgb(99 102 241); /* indigo-500 */ transform: translateY(-4px); box-shadow: 0 8px 24px rgba(99, 102, 241, 0.15); }
|
||||
.blog-post-card-image { width: 100%; height: 200px; overflow: hidden; background: rgb(9 9 11); /* zinc-950 */ 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: rgb(82 82 91); /* zinc-600 */ 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 rgb(63 63 70); /* zinc-700 */ 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; }
|
||||
}
|
||||
|
||||
|
||||
84
src/styles/components/forms.css
Normal file
@@ -0,0 +1,84 @@
|
||||
/* 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: rgb(212 212 216); /* zinc-300 */ 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 rgb(82 82 91); /* zinc-600 */ border-radius: 6px; cursor: pointer; transition: all 0.2s; position: relative; }
|
||||
.color-swatch:hover { border-color: rgb(161 161 170); /* zinc-400 */ }
|
||||
.color-swatch.active { border-color: rgb(99 102 241); /* indigo-500 */ box-shadow: 0 0 0 2px rgb(99 102 241); /* indigo-500 */ }
|
||||
.color-swatch.active::after { content: '✓'; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); color: rgb(0 0 0); /* black */ font-size: 0.875rem; font-weight: bold; text-shadow: 0 0 2px rgb(255 255 255); /* white */ }
|
||||
.font-size-btn { min-width: 33px; height: 33px; padding: 0; background: transparent; border: 1px solid rgb(82 82 91); /* zinc-600 */ border-radius: 6px; color: rgb(212 212 216); /* zinc-300 */ cursor: pointer; transition: all 0.2s; font-weight: bold; display: flex; align-items: center; justify-content: center; }
|
||||
.font-size-btn:hover { background: rgb(63 63 70); /* zinc-700 */ border-color: rgb(113 113 122); /* zinc-500 */ }
|
||||
.font-size-btn.active { background: rgb(99 102 241); /* indigo-500 */ border-color: rgb(99 102 241); /* indigo-500 */ color: white; }
|
||||
.setting-preview {
|
||||
margin: 1.5rem 0;
|
||||
padding: 1rem;
|
||||
background: rgb(24 24 27); /* zinc-900 */
|
||||
border: 1px solid rgb(63 63 70); /* zinc-700 */
|
||||
border-radius: 8px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.preview-label { font-size: 0.875rem; color: rgb(161 161 170); /* zinc-400 */ margin-bottom: 0.75rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.preview-content {
|
||||
color: rgb(228 228 231); /* zinc-200 */
|
||||
line-height: 1.7;
|
||||
max-width: 100%;
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
word-break: break-word;
|
||||
}
|
||||
.preview-content h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.5em;
|
||||
color: rgb(255 255 255); /* white */
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.preview-content p {
|
||||
margin: 0.75rem 0;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.setting-select { width: 100%; padding: 0.5rem; background: rgb(39 39 42); /* zinc-800 */ border: 1px solid rgb(82 82 91); /* zinc-600 */ border-radius: 4px; color: rgb(255 255 255); /* white */ font-size: 1rem; }
|
||||
.setting-inline .setting-select { width: auto; min-width: 200px; flex: 1; }
|
||||
.setting-select:focus { outline: none; border-color: rgb(99 102 241); /* indigo-500 */ }
|
||||
.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: rgb(99 102 241); /* indigo-500 */ }
|
||||
.checkbox-label span { color: rgb(228 228 231); /* zinc-200 */ text-align: left; font-weight: 500; }
|
||||
|
||||
/* Mobile responsive styles */
|
||||
@media (max-width: 768px) {
|
||||
.setting-group.setting-inline {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.setting-inline .setting-select {
|
||||
width: 100%;
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
.setting-control {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.setting-buttons {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.preview-content h3 {
|
||||
font-size: 1.25em;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
50
src/styles/components/icon-button.css
Normal file
@@ -0,0 +1,50 @@
|
||||
/* Generic IconButton styling */
|
||||
.icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgb(82 82 91); /* zinc-600 */
|
||||
border-radius: 6px;
|
||||
background: rgb(39 39 42); /* zinc-800 */
|
||||
color: rgb(228 228 231); /* zinc-200 */
|
||||
cursor: pointer;
|
||||
min-width: 33px;
|
||||
min-height: 33px;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.icon-button:hover { background: rgb(63 63 70); /* zinc-700 */ }
|
||||
.icon-button:active { transform: translateY(1px); }
|
||||
|
||||
.icon-button.primary { background: rgb(99 102 241); /* indigo-500 */ color: white; border-color: rgb(99 102 241); /* indigo-500 */ }
|
||||
.icon-button.primary:hover { filter: brightness(1.05); }
|
||||
|
||||
.icon-button.success { background: rgb(99 102 241); /* indigo-500 */ color: white; border-color: rgb(99 102 241); /* indigo-500 */ }
|
||||
.icon-button.success:hover { filter: brightness(1.1); }
|
||||
|
||||
.icon-button.ghost { background: rgb(39 39 42); /* zinc-800 */ }
|
||||
|
||||
/* Mobile touch target improvements */
|
||||
@media (max-width: 768px) {
|
||||
.icon-button {
|
||||
min-width: var(--min-touch-target);
|
||||
min-height: var(--min-touch-target);
|
||||
}
|
||||
|
||||
/* Keep icon size consistent to prevent blurriness */
|
||||
.icon-button svg {
|
||||
font-size: 1rem;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
/* Disable hover effects on touch devices */
|
||||
@media (pointer: coarse) {
|
||||
.icon-button:hover { background: rgb(39 39 42); /* zinc-800 */ }
|
||||
.icon-button.ghost:hover { background: rgb(39 39 42); /* zinc-800 */ }
|
||||
.icon-button:active { background: rgb(63 63 70); /* zinc-700 */ }
|
||||
}
|
||||
|
||||
|
||||
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, rgb(161 161 170)); /* zinc-400 */
|
||||
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, rgb(228 228 231)); /* zinc-200 */
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.me-tab.active {
|
||||
color: var(--primary-color, rgb(139 92 246)); /* purple-500 */
|
||||
border-bottom-color: var(--primary-color, rgb(139 92 246)); /* purple-500 */
|
||||
}
|
||||
|
||||
/* Highlights tab uses the user's custom "my highlights" color */
|
||||
.me-tab[data-tab="highlights"].active {
|
||||
color: var(--highlight-color-mine, rgb(253 224 71)); /* yellow-300 */
|
||||
border-bottom-color: var(--highlight-color-mine, rgb(253 224 71)); /* yellow-300 */
|
||||
}
|
||||
|
||||
/* Reading List tab uses blue color to match bookmarks icon */
|
||||
.me-tab[data-tab="reading-list"].active {
|
||||
color: rgb(99 102 241); /* indigo-500 */
|
||||
border-bottom-color: rgb(99 102 241); /* indigo-500 */
|
||||
}
|
||||
|
||||
.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 rgb(82 82 91) !important; /* zinc-600 */
|
||||
background: rgb(24 24 27) !important; /* zinc-900 */
|
||||
}
|
||||
|
||||
.bookmarks-list .individual-bookmark:hover {
|
||||
border-color: rgb(113 113 122) !important; /* zinc-500 */
|
||||
background: rgb(39 39 42) !important; /* zinc-800 */
|
||||
}
|
||||
|
||||
.bookmark-item {
|
||||
padding: 1rem;
|
||||
background: var(--card-bg, rgb(255 255 255)); /* white */
|
||||
border: 1px solid var(--border-color, rgb(228 228 231)); /* zinc-200 */
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.bookmark-item:hover {
|
||||
border-color: var(--primary-color, rgb(139 92 246)); /* purple-500 */
|
||||
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, rgb(0 0 0)); /* black */
|
||||
}
|
||||
|
||||
.bookmark-item p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary, rgb(113 113 122)); /* zinc-500 */
|
||||
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: rgb(24 24 27); /* zinc-900 */ border: 1px solid rgb(63 63 70); /* zinc-700 */ 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 rgb(63 63 70); /* zinc-700 */ }
|
||||
.modal-header h2 { margin: 0; font-size: 1.5rem; color: rgb(255 255 255); /* white */ }
|
||||
.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: rgb(212 212 216); /* zinc-300 */ font-size: 0.9rem; font-weight: 500; }
|
||||
.fetching-indicator { font-size: 0.8rem; color: rgb(161 161 170); /* zinc-400 */ font-weight: normal; display: inline-flex; align-items: center; gap: 0.5rem; }
|
||||
.form-group input, .form-group textarea { width: 100%; padding: 0.75rem; background: rgb(39 39 42); /* zinc-800 */ border: 1px solid rgb(82 82 91); /* zinc-600 */ border-radius: 6px; color: rgb(255 255 255); /* white */ 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: rgb(99 102 241); /* indigo-500 */ }
|
||||
.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: rgb(161 161 170); /* zinc-400 */ line-height: 1.4; }
|
||||
.modal-error { padding: 0.75rem; background: rgba(220, 53, 69, 0.1); border: 1px solid rgb(220 38 38); /* red-600 */ border-radius: 6px; color: rgb(220 38 38); /* red-600 */ 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: rgb(39 39 42); /* zinc-800 */ border: 1px solid rgb(82 82 91); /* zinc-600 */ border-radius: 6px; color: rgb(212 212 216); /* zinc-300 */ font-size: 1rem; cursor: pointer; transition: all 0.2s; }
|
||||
.btn-secondary:hover:not(:disabled) { background: rgb(63 63 70); /* zinc-700 */ border-color: rgb(99 102 241); /* indigo-500 */ color: white; }
|
||||
.btn-secondary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.btn-primary { padding: 0.75rem 1.5rem; background: rgb(99 102 241); /* indigo-500 */ border: none; border-radius: 6px; color: white; font-size: 1rem; cursor: pointer; transition: background-color 0.2s; }
|
||||
.btn-primary:hover:not(:disabled) { background: rgb(79 70 229); /* indigo-600 */ }
|
||||
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
|
||||
31
src/styles/components/profile.css
Normal file
@@ -0,0 +1,31 @@
|
||||
/* Profile UI fragments */
|
||||
.author-card-container { display: flex; justify-content: center; padding: 2rem 1rem; }
|
||||
.author-card { display: flex; gap: 1rem; padding: 1.5rem; background: rgb(24 24 27); /* zinc-900 */ border: 1px solid rgb(63 63 70); /* zinc-700 */ border-radius: 12px; max-width: 600px; width: 100%; transition: all 0.2s ease; }
|
||||
.author-card-clickable:hover { border-color: rgb(99 102 241); /* indigo-500 */ background: rgb(30 30 33); /* slightly lighter */ transform: translateY(-1px); }
|
||||
.author-card-clickable:active { transform: translateY(0); }
|
||||
.author-card-avatar { flex-shrink: 0; width: 60px; height: 60px; border-radius: 50%; overflow: hidden; background: rgb(39 39 42); /* zinc-800 */ display: flex; align-items: center; justify-content: center; color: rgb(113 113 122); /* zinc-500 */ }
|
||||
.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: rgb(228 228 231); /* zinc-200 */ margin-bottom: 0.5rem; text-align: left; }
|
||||
.author-card-bio { font-size: 0.9rem; color: rgb(161 161 170); /* zinc-400 */ 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; }
|
||||
}
|
||||
|
||||
|
||||
53
src/styles/components/pull-to-refresh.css
Normal file
@@ -0,0 +1,53 @@
|
||||
/* Pull-to-refresh indicator styles */
|
||||
.pull-to-refresh-indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.pull-to-refresh-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--background-secondary);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.3s ease;
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.pull-to-refresh-text {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
background: var(--background-secondary);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Container needs relative positioning for absolute indicator */
|
||||
.pull-to-refresh-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Ensure smooth transitions during pull */
|
||||
.pull-to-refresh-container.is-pulling {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
240
src/styles/components/reader.css
Normal file
@@ -0,0 +1,240 @@
|
||||
/* Reader view */
|
||||
.reader {
|
||||
background: rgb(24 24 27); /* zinc-900 */
|
||||
border: 1px solid rgb(63 63 70); /* zinc-700 */
|
||||
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: rgb(0 0 0); /* black */
|
||||
}
|
||||
.reader.empty { color: rgb(161 161 170); /* zinc-400 */ }
|
||||
.loading-spinner { display: flex; align-items: center; gap: 0.5rem; color: rgb(161 161 170); /* zinc-400 */ }
|
||||
.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); font-size: 2.5rem; font-weight: 700; line-height: 1.2; }
|
||||
.reader-summary { color: rgb(212 212 216); /* zinc-300 */ font-size: 1.2rem; line-height: 1.6; 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(161, 161, 170, 0.7); /* zinc-400 */ 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: rgb(255 255 255); /* white */ 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(161, 161, 170, 0.1); /* zinc-400 */ border: 1px solid rgba(161, 161, 170, 0.3); /* zinc-400 */ border-radius: 6px; font-size: 0.875rem; color: rgb(161 161 170); /* zinc-400 */ }
|
||||
.reading-time svg { font-size: 0.875rem; }
|
||||
.highlight-indicator { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0.75rem; background: rgba(99, 102, 241, 0.1); /* indigo-500 */ border: 1px solid rgba(99, 102, 241, 0.3); /* indigo-500 */ border-radius: 6px; font-size: 0.875rem; color: rgb(99 102 241); /* indigo-500 */ }
|
||||
.highlight-indicator svg { font-size: 0.875rem; }
|
||||
.reader-html { color: rgb(228 228 231); /* zinc-200 */ 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: rgb(228 228 231); /* zinc-200 */ 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; }
|
||||
/* Headlines with Tailwind typography */
|
||||
.reader-markdown h1, .reader-html h1 {
|
||||
font-size: 2.25rem; /* text-4xl */
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: rgb(244 244 245); /* zinc-100 */
|
||||
}
|
||||
.reader-markdown h2, .reader-html h2 {
|
||||
font-size: 1.875rem; /* text-3xl */
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
margin-top: 1.75rem;
|
||||
margin-bottom: 0.875rem;
|
||||
color: rgb(244 244 245); /* zinc-100 */
|
||||
}
|
||||
.reader-markdown h3, .reader-html h3 {
|
||||
font-size: 1.5rem; /* text-2xl */
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: rgb(244 244 245); /* zinc-100 */
|
||||
}
|
||||
.reader-markdown h4, .reader-html h4 {
|
||||
font-size: 1.25rem; /* text-xl */
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.625rem;
|
||||
color: rgb(228 228 231); /* zinc-200 */
|
||||
}
|
||||
.reader-markdown h5, .reader-html h5 {
|
||||
font-size: 1.125rem; /* text-lg */
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: rgb(228 228 231); /* zinc-200 */
|
||||
}
|
||||
.reader-markdown h6, .reader-html h6 {
|
||||
font-size: 1rem; /* text-base */
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: rgb(228 228 231); /* zinc-200 */
|
||||
}
|
||||
.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; }
|
||||
/* Lists */
|
||||
.reader-markdown ul, .reader-html ul {
|
||||
list-style-type: disc;
|
||||
margin: 1rem 0;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
.reader-markdown ol, .reader-html ol {
|
||||
list-style-type: decimal;
|
||||
margin: 1rem 0;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
.reader-markdown li, .reader-html li {
|
||||
margin: 0.375rem 0;
|
||||
line-height: 1.6;
|
||||
color: rgb(228 228 231); /* zinc-200 */
|
||||
}
|
||||
.reader-markdown ul ul, .reader-markdown ol ul, .reader-html ul ul, .reader-html ol ul {
|
||||
list-style-type: circle;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
.reader-markdown ul ol, .reader-markdown ol ol, .reader-html ul ol, .reader-html ol ol {
|
||||
list-style-type: lower-alpha;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
.reader-markdown li p, .reader-html li p {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
.reader-markdown blockquote, .reader-html blockquote {
|
||||
margin: 1.5rem 0;
|
||||
padding: 1rem 0 1rem 2rem;
|
||||
font-style: italic;
|
||||
}
|
||||
.reader-markdown blockquote p, .reader-html blockquote p { margin: 0.5rem 0; }
|
||||
.reader-markdown blockquote p:first-child, .reader-html blockquote p:first-child { margin-top: 0; }
|
||||
.reader-markdown blockquote p:last-child, .reader-html blockquote p:last-child { margin-bottom: 0; }
|
||||
.reader-markdown a { color: rgb(96 165 250); /* blue-400 */ text-decoration: none; }
|
||||
.reader-markdown a:hover { text-decoration: underline; }
|
||||
.reader-markdown code { background: rgb(30 30 30); /* ~zinc-850 */ border: 1px solid rgb(63 63 70); /* zinc-700 */ border-radius: 4px; padding: 0.15rem 0.4rem; font-size: 0.9em; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
|
||||
.reader-markdown pre { background: rgb(30 30 30); /* ~zinc-850 */ border: 1px solid rgb(63 63 70); /* zinc-700 */ 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: rgb(30 30 30); /* ~zinc-850 */ border: 1px solid rgb(63 63 70); /* zinc-700 */ }
|
||||
.reader-markdown code[class*="language-"] { background: transparent; text-shadow: none; }
|
||||
.reader-html pre { background: rgb(30 30 30); /* ~zinc-850 */ border: 1px solid rgb(63 63 70); /* zinc-700 */ border-radius: 8px; padding: 1rem; overflow-x: auto; margin: 1rem 0; font-family: 'Monaco', 'Menlo', 'Consolas', 'Courier New', monospace; }
|
||||
.reader-html code { background: rgb(30 30 30); /* ~zinc-850 */ border: 1px solid rgb(63 63 70); /* zinc-700 */ 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; }
|
||||
/* Mobile: prevent code blocks from causing horizontal overflow */
|
||||
@media (max-width: 768px) {
|
||||
.reader-markdown pre, .reader-html pre {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.reader-markdown code, .reader-html code {
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Also handle tables and other wide elements */
|
||||
.reader-markdown table, .reader-html table {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.reader-markdown img, .reader-html img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop: increase horizontal padding for text content */
|
||||
@media (min-width: 769px) {
|
||||
.reader-header,
|
||||
.reader-summary-below-image,
|
||||
.reader-html,
|
||||
.reader-markdown,
|
||||
.article-menu-container,
|
||||
.mark-as-read-container {
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
}
|
||||
/* 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: rgb(161 161 170); /* zinc-400 */ 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: rgb(99 102 241); /* indigo-500 */ background: rgba(99, 102, 241, 0.1); }
|
||||
.article-menu { position: absolute; right: 0; top: calc(100% + 4px); background: rgb(39 39 42); /* zinc-800 */ border: 1px solid rgb(82 82 91); /* zinc-600 */ 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: rgb(228 228 231); /* zinc-200 */ padding: 0.75rem 1rem; font-size: 0.875rem; display: flex; align-items: center; gap: 0.75rem; cursor: pointer; transition: all 0.15s ease; text-align: left; white-space: nowrap; }
|
||||
.article-menu-item:hover { background: rgba(99, 102, 241, 0.15); color: rgb(255 255 255); /* white */ }
|
||||
.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: rgb(39 39 42); /* zinc-800 */ color: rgb(228 228 231); /* zinc-200 */ border: 1px solid rgb(82 82 91); /* zinc-600 */ 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: rgb(63 63 70); /* zinc-700 */ border-color: rgb(113 113 122); /* zinc-500 */ 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; font-size: 2.5rem; font-weight: 700; line-height: 1.2; }
|
||||
.reader-header-overlay .reader-summary { color: rgba(255, 255, 255, 0.9); font-size: 1.2rem; line-height: 1.6; margin: 0 0 1rem 0; text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4); font-family: var(--reading-font); }
|
||||
.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: 2rem; line-height: 1.3; }
|
||||
}
|
||||
|
||||
/* Reading Progress Indicator - now using Tailwind utilities in component */
|
||||
|
||||
|
||||
149
src/styles/components/settings.css
Normal file
@@ -0,0 +1,149 @@
|
||||
/* 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: rgb(255 255 255); /* white */ margin: 0 0 1rem 0; padding-bottom: 0.5rem; border-bottom: 1px solid rgb(63 63 70); /* zinc-700 */ 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: rgb(99 102 241); /* indigo-500 */ 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: rgb(79 70 229); /* indigo-600 */ }
|
||||
.settings-footer .btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
/* Setting groups */
|
||||
.setting-group { margin-bottom: 1.5rem; }
|
||||
.setting-label { display: block; margin-bottom: 0.75rem; font-size: 0.9rem; font-weight: 500; color: rgb(212 212 216); /* zinc-300 */ }
|
||||
|
||||
/* Zap splits preset buttons */
|
||||
.zap-preset-buttons { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.zap-preset-btn {
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: rgb(39 39 42); /* zinc-800 */
|
||||
border: 1px solid rgb(82 82 91); /* zinc-600 */
|
||||
border-radius: 6px;
|
||||
color: rgb(212 212 216); /* zinc-300 */
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
.zap-preset-btn:hover {
|
||||
background: rgb(63 63 70); /* zinc-700 */
|
||||
border-color: rgb(99 102 241); /* indigo-500 */
|
||||
color: rgb(255 255 255); /* white */
|
||||
}
|
||||
.zap-preset-btn.active {
|
||||
background: rgb(99 102 241); /* indigo-500 */
|
||||
border-color: rgb(99 102 241); /* indigo-500 */
|
||||
color: rgb(255 255 255); /* white */
|
||||
}
|
||||
|
||||
/* Zap split sliders */
|
||||
.zap-split-container { width: 100%; }
|
||||
.zap-split-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: rgb(161 161 170); /* zinc-400 */
|
||||
}
|
||||
.zap-split-label { font-weight: 500; }
|
||||
.zap-split-slider {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
background: rgb(39 39 42); /* zinc-800 */
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
.zap-split-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: rgb(99 102 241); /* indigo-500 */
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.zap-split-slider::-webkit-slider-thumb:hover {
|
||||
background: rgb(79 70 229); /* indigo-600 */
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.zap-split-slider::-moz-range-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: rgb(99 102 241); /* indigo-500 */
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.zap-split-slider::-moz-range-thumb:hover {
|
||||
background: rgb(79 70 229); /* indigo-600 */
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.zap-split-description {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: rgb(39 39 42); /* zinc-800 */
|
||||
border: 1px solid rgb(63 63 70); /* zinc-700 */
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
color: rgb(161 161 170); /* zinc-400 */
|
||||
}
|
||||
|
||||
/* Relay items */
|
||||
.relay-item {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.relay-url {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Mobile responsive styles */
|
||||
@media (max-width: 768px) {
|
||||
.settings-view {
|
||||
padding: 0.5rem;
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
padding: 0 0.25rem 2rem 0.25rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.zap-preset-buttons {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.zap-preset-btn {
|
||||
flex: 1 1 auto;
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.relay-item {
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.relay-url {
|
||||
white-space: normal !important;
|
||||
word-break: break-all !important;
|
||||
overflow: visible !important;
|
||||
text-overflow: unset !important;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
13
src/styles/components/toast.css
Normal file
@@ -0,0 +1,13 @@
|
||||
/* Toast Notification */
|
||||
.toast { position: fixed; top: 2rem; right: 2rem; background: rgb(24 24 27); /* zinc-900 */ color: rgb(255 255 255); /* white */ padding: 1rem 1.5rem; border-radius: 8px; border: 1px solid rgb(63 63 70); /* zinc-700 */ 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: rgb(34 197 94); /* green-500 */ }
|
||||
.toast-success svg { color: rgb(34 197 94); /* green-500 */ }
|
||||
.toast-error { border-color: rgb(220 38 38); /* red-600 */ }
|
||||
.toast-error svg { color: rgb(220 38 38); /* red-600 */ }
|
||||
@keyframes toast-slide-in { from { transform: translateX(400px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||
|
||||
|
||||
146
src/styles/layout/app.css
Normal file
@@ -0,0 +1,146 @@
|
||||
/* 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: 0;
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
overflow-y: auto;
|
||||
align-self: start;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.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: 0;
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
overflow-y: auto;
|
||||
align-self: start;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-shadow: -2px 0 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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: rgb(24 24 27); /* zinc-900 */
|
||||
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 */
|
||||
}
|
||||
|
||||
|
||||
153
src/styles/layout/highlights.css
Normal file
@@ -0,0 +1,153 @@
|
||||
/* Highlights panel layout and interactions */
|
||||
.highlights-container {
|
||||
background: rgb(24 24 27); /* zinc-900 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Desktop: no border or rounded corners for edge-to-edge sidebars */
|
||||
@media (min-width: 769px) {
|
||||
.highlights-container {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.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: rgb(39 39 42); /* zinc-800 */
|
||||
color: rgb(228 228 231); /* zinc-200 */
|
||||
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: rgb(63 63 70); /* zinc-700 */ color: rgb(255 255 255); /* white */ }
|
||||
.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 rgb(63 63 70); /* zinc-700 */
|
||||
background: rgb(24 24 27); /* zinc-900 */
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
/* Desktop: no rounded corners for edge-to-edge header */
|
||||
@media (min-width: 769px) {
|
||||
.highlights-header {
|
||||
border-radius: 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: rgb(161 161 170); /* zinc-400 */ 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: rgb(161 161 170); /* zinc-400 */ 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: rgb(255 255 255); /* white */ }
|
||||
.highlight-mode-toggle .mode-btn.active { background: rgb(99 102 241); /* indigo-500 */ color: rgb(255 255 255); /* white */ }
|
||||
|
||||
/* 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; }
|
||||
|
||||
.highlights-loading,
|
||||
.highlights-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem 1rem; color: rgb(161 161 170); /* zinc-400 */ text-align: center; gap: 0.5rem; }
|
||||
.highlights-empty svg { color: rgb(113 113 122); /* zinc-500 */ margin-bottom: 0.5rem; }
|
||||
.empty-hint { font-size: 0.875rem; color: rgb(113 113 122); /* zinc-500 */ margin-top: 0.5rem; }
|
||||
|
||||
.highlights-list { overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.highlight-item { background: rgb(30 30 30); /* ~zinc-850 */ border: 1px solid rgb(63 63 70); /* zinc-700 */ border-radius: 8px; padding: 0; display: flex; transition: border-color 0.2s ease; position: relative; }
|
||||
.highlight-item:hover { border-color: rgb(99 102 241); /* indigo-500 */ }
|
||||
.highlight-item.selected { border-color: rgb(99 102 241); /* indigo-500 */ background: rgb(39 39 42); /* zinc-800 */ box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.3); }
|
||||
|
||||
/* Compact button for highlight cards */
|
||||
.compact-button { background: none; border: none; color: rgb(161 161 170); /* zinc-400 */ 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: rgb(212 212 216); /* zinc-300 */ 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: rgb(161 161 170); /* zinc-400 */ 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-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-friends { border-color: color-mix(in srgb, var(--highlight-color-friends, #f97316) 60%, #333); box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-friends, #f97316) 25%, transparent); }
|
||||
.highlight-item.level-nostrverse { border-color: color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 60%, #333); box-shadow: 0 0 0 1px color-mix(in srgb, var(--highlight-color-nostrverse, #9333ea) 25%, transparent); }
|
||||
|
||||
.highlight-quote-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); }
|
||||
|
||||
/* Keep icon size consistent to prevent blurriness */
|
||||
.compact-button svg {
|
||||
font-size: 0.875rem;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
/* 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: 2.25rem 0.75rem; }
|
||||
.highlight-text { margin: 0; padding: 0; font-style: italic; color: rgb(228 228 231); /* zinc-200 */ line-height: 1.6; border-left: none; font-size: 0.95rem; }
|
||||
.highlight-comment { margin-top: 0.5rem; padding: 0.75rem; background: rgba(99, 102, 241, 0.1); border-left: 3px solid rgb(99 102 241); /* indigo-500 */ border-radius: 4px; font-size: 0.875rem; color: rgb(228 228 231); /* zinc-200 */ 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: rgb(161 161 170); /* zinc-400 */ 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: rgb(212 212 216); /* zinc-300 */ 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: rgb(39 39 42); /* zinc-800 */ border: 1px solid rgb(82 82 91); /* zinc-600 */ 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: rgb(228 228 231); /* zinc-200 */ 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(99, 102, 241, 0.15); color: rgb(255 255 255); /* white */ }
|
||||
.highlight-menu-item:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.highlight-menu-item-danger:hover { background: rgba(255, 68, 68, 0.15); color: rgb(239 68 68); /* red-500 */ }
|
||||
.highlight-menu-item svg { font-size: 0.875rem; flex-shrink: 0; }
|
||||
|
||||
|
||||
172
src/styles/layout/sidebar.css
Normal file
@@ -0,0 +1,172 @@
|
||||
/* Bookmarks and sidebar layout */
|
||||
.bookmarks-container {
|
||||
background: rgb(24 24 27); /* zinc-900 */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Desktop: no border or rounded corners for edge-to-edge sidebars */
|
||||
@media (min-width: 769px) {
|
||||
.bookmarks-container {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.bookmarks-container .view-mode-controls {
|
||||
margin-top: auto;
|
||||
padding: 1rem;
|
||||
border-top: 1px solid rgb(63 63 70); /* zinc-700 */
|
||||
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: rgb(24 24 27); /* zinc-900 */
|
||||
border-bottom: 1px solid rgb(63 63 70); /* zinc-700 */
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Mobile: add borders and rounded corners */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar-header-bar {
|
||||
border: 1px solid rgb(63 63 70); /* zinc-700 */
|
||||
border-radius: 12px 12px 0 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; }
|
||||
|
||||
/* Keep icon sizes consistent to prevent blurriness */
|
||||
.sidebar-header-bar svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.view-mode-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
min-width: 33px;
|
||||
min-height: 33px;
|
||||
width: 33px;
|
||||
height: 33px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgb(39 39 42); /* zinc-800 */
|
||||
border: 1px solid rgb(82 82 91); /* zinc-600 */
|
||||
flex-shrink: 0;
|
||||
color: rgb(228 228 231); /* zinc-200 */
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.profile-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.profile-avatar svg { font-size: 1rem; }
|
||||
|
||||
.sidebar-header-bar .toggle-sidebar-btn {
|
||||
background: transparent;
|
||||
color: rgb(228 228 231); /* zinc-200 */
|
||||
border: 1px solid rgb(82 82 91); /* zinc-600 */
|
||||
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: rgb(39 39 42); /* zinc-800 */ color: rgb(255 255 255); /* white */ }
|
||||
.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: rgb(39 39 42); /* zinc-800 */
|
||||
color: rgb(228 228 231); /* zinc-200 */
|
||||
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: rgb(63 63 70); /* zinc-700 */ color: rgb(255 255 255); /* white */ }
|
||||
.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: rgb(99 102 241); /* indigo-500 */ filter: drop-shadow(0 0 4px rgba(99, 102, 241, 0.6)); }
|
||||
|
||||
.user-info { margin: 0.5rem 0 0 0; color: rgb(161 161 170); /* zinc-400 */ font-size: 0.9rem; font-family: monospace; }
|
||||
.bookmark-count { color: rgb(113 113 122); /* zinc-500 */ font-size: 0.9rem; margin: 0.5rem 0; }
|
||||
.event-link { color: rgb(96 165 250); /* blue-400 */ 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: rgb(59 130 246); /* blue-500 */ 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: rgb(34 197 94); /* green-500 */ color: white; border: none; padding: 0.25rem 0.5rem; border-radius: 4px; cursor: pointer; }
|
||||
.read-inline-btn:hover { background: rgb(22 163 74); /* green-600 */ }
|
||||
|
||||
|
||||
7
src/styles/tailwind.css
Normal file
@@ -0,0 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
32
src/styles/utils/animations.css
Normal file
@@ -0,0 +1,32 @@
|
||||
/* 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); }
|
||||
}
|
||||
|
||||
/* Reading progress bar positioning */
|
||||
@media (min-width: 769px) {
|
||||
.reading-progress-bar {
|
||||
left: var(--left-offset) !important;
|
||||
right: var(--right-offset) !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
62
src/styles/utils/legacy.css
Normal file
@@ -0,0 +1,62 @@
|
||||
/* Nostr content parsing and URI resolution 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: rgb(59 130 246); /* blue-500 */
|
||||
text-decoration: none;
|
||||
font-family: monospace;
|
||||
background: rgb(248 250 252); /* slate-50 */
|
||||
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: rgb(226 232 240); /* slate-200 */
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.nostr-link {
|
||||
color: rgb(59 130 246); /* blue-500 */
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nostr-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: rgb(161 161 170); /* zinc-400 */
|
||||
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); }
|
||||
}
|
||||
|
||||
|
||||