mirror of
https://github.com/dergigi/boris.git
synced 2026-02-16 12:34:41 +01:00
Compare commits
97 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d5fe1f425 | ||
|
|
c7a4de9786 | ||
|
|
d873718e88 | ||
|
|
706276839a | ||
|
|
d281ca5f87 | ||
|
|
6a9036bfef | ||
|
|
1b242f75c6 | ||
|
|
7ffd37289d | ||
|
|
cb859ae599 | ||
|
|
a17346c9c2 | ||
|
|
c17a39588d | ||
|
|
33cee9c0c2 | ||
|
|
e6d2920c27 | ||
|
|
d8195dbe2a | ||
|
|
4843f129c4 | ||
|
|
fcd1218dc4 | ||
|
|
eef0f971d7 | ||
|
|
ff09a8aba0 | ||
|
|
0c4b523d05 | ||
|
|
de7a435a01 | ||
|
|
124d399d1f | ||
|
|
e22cf71b15 | ||
|
|
670997ed36 | ||
|
|
1ccb6388e3 | ||
|
|
7d5be8d6aa | ||
|
|
133e4756b2 | ||
|
|
39ada734d5 | ||
|
|
19d88c5fba | ||
|
|
461b0936e2 | ||
|
|
e9ee5e87be | ||
|
|
5e66c5ef76 | ||
|
|
307dc3d726 | ||
|
|
e514a5f063 | ||
|
|
880b7974f4 | ||
|
|
47048f435f | ||
|
|
53ad492729 | ||
|
|
eb4da419ae | ||
|
|
c66dfc9e2e | ||
|
|
a31f05d498 | ||
|
|
6548e89c54 | ||
|
|
8a21b46ebd | ||
|
|
bc5fe1ae30 | ||
|
|
b57ea3f640 | ||
|
|
3b55d64468 | ||
|
|
4caf1f0b22 | ||
|
|
1eb9911645 | ||
|
|
38268c453c | ||
|
|
9686b80b09 | ||
|
|
f32dec16fb | ||
|
|
cb444b532f | ||
|
|
962062130a | ||
|
|
e429931139 | ||
|
|
e56d28f82a | ||
|
|
13a30d35c4 | ||
|
|
e3174d8777 | ||
|
|
829a8d5dca | ||
|
|
00978e2e64 | ||
|
|
a5fcf36e83 | ||
|
|
a92a9ee3a3 | ||
|
|
f39e34c699 | ||
|
|
b58f34d587 | ||
|
|
76d1d4544e | ||
|
|
5e56176e2d | ||
|
|
a2a4e7e454 | ||
|
|
b266288b0f | ||
|
|
1619e328da | ||
|
|
b852dad243 | ||
|
|
1552a5f106 | ||
|
|
0feaffb21b | ||
|
|
9b3a4e20de | ||
|
|
c83b972a68 | ||
|
|
2e96f93d81 | ||
|
|
1e8182d984 | ||
|
|
b20a67d4d0 | ||
|
|
60975b449d | ||
|
|
704fce4d80 | ||
|
|
4d1eb0f9fd | ||
|
|
ceafe277d3 | ||
|
|
8f2ecd5fe1 | ||
|
|
d6be6f364b | ||
|
|
035d4d3bd0 | ||
|
|
43d5554c0c | ||
|
|
724a3e5cfa | ||
|
|
0c49988d36 | ||
|
|
70de68848b | ||
|
|
8a12ae72cb | ||
|
|
f8d5d19a9f | ||
|
|
dbd20e676f | ||
|
|
bbdf47fb94 | ||
|
|
1b754e02dc | ||
|
|
a2e410252a | ||
|
|
c9a14d151d | ||
|
|
b286562e86 | ||
|
|
507288f51c | ||
|
|
e08bc54f15 | ||
|
|
4306069191 | ||
|
|
56e56af8ec |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -7,3 +7,7 @@ dist
|
||||
# Misc
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
# Applesauce Reference
|
||||
applesauce
|
||||
|
||||
|
||||
145
CHANGELOG.md
145
CHANGELOG.md
@@ -5,6 +5,144 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Mobile-responsive design with overlay sidebar drawer
|
||||
- Media query hooks for responsive behavior (`useIsMobile`, `useIsTablet`, `useIsCoarsePointer`)
|
||||
- Auto-collapse sidebar setting for mobile devices
|
||||
- Touch-optimized UI with 44x44px minimum touch targets
|
||||
- Safe area inset support for notched devices
|
||||
- Mobile hamburger menu and backdrop
|
||||
- Focus trap in mobile sidebar with ESC key support
|
||||
- Body scroll locking when mobile sidebar is open
|
||||
- Mobile-optimized modals (full-screen sheet style)
|
||||
- Mobile-optimized toast notifications (bottom position)
|
||||
- Dynamic viewport height support (100dvh)
|
||||
|
||||
### Changed
|
||||
- Sidebar now displays as overlay drawer on mobile (≤768px)
|
||||
- Highlights panel hidden on mobile for better content focus
|
||||
- Sidebar auto-closes when selecting content on mobile
|
||||
- Hover effects disabled on touch devices
|
||||
|
||||
## [0.3.8] - 2025-10-10
|
||||
|
||||
### Fixed
|
||||
- Add vercel.json configuration to properly handle SPA routing on Vercel deployments (fixes 404 errors on page refresh)
|
||||
|
||||
## [0.3.7] - 2025-10-10
|
||||
|
||||
### Fixed
|
||||
- Logout button functionality - now properly clears active account using clearActive() method
|
||||
|
||||
## [0.3.6] - 2025-10-10
|
||||
|
||||
### Added
|
||||
- Compact date format for highlights (now, 5m, 3h, 2d, 1mo, 1y)
|
||||
- Ultra-compact date format for bookmarks sidebar
|
||||
- Encode event links as nevent/naddr per NIP-19 for better client compatibility
|
||||
- Render /explore within ThreePaneLayout to keep side panels visible
|
||||
|
||||
### Fixed
|
||||
- Remove incorrect padding-right from highlights container
|
||||
- Reduce font size of highlight metadata for cleaner look
|
||||
- Position highlight FAB button relative to article pane instead of viewport
|
||||
- Adjust relay indicator position for better visual alignment
|
||||
- Ensure highlight metadata elements align on single visual line with consistent line-height
|
||||
- Prevent bookmark icons from being cut off in compact view
|
||||
- Clean up nested borders in bookmark items and sidebar view mode controls
|
||||
- Align highlight metadata elements on single line in sidebar
|
||||
- Change explore header icon from compass to newspaper
|
||||
|
||||
### Changed
|
||||
- Make connecting notification more subtle with muted blue background
|
||||
- Update Boris pubkey for zap splits to npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x
|
||||
- Update domain references to read.withboris.com (URLs, SEO metadata, and documentation)
|
||||
|
||||
## [0.3.5] - 2025-10-09
|
||||
|
||||
### Fixed
|
||||
- Ensure connecting state shows for minimum 15 seconds to prevent premature offline display
|
||||
- Add Cloudflare Pages routing config for SPA paths
|
||||
|
||||
### Changed
|
||||
- Extend connecting state duration and remove subtitle text for cleaner UI
|
||||
|
||||
## [0.3.4] - 2025-10-09
|
||||
|
||||
### Fixed
|
||||
- Add p tag (author tag) to highlights of nostr-native content for proper attribution
|
||||
|
||||
## [0.3.3] - 2025-10-09
|
||||
|
||||
### Added
|
||||
- Service Worker for robust offline image caching
|
||||
- /explore route to discover blog posts from friends on Nostr
|
||||
- Explore button (newspaper icon) in bookmarks header
|
||||
- "Connecting" status indicator on page load (instead of immediately showing "Offline")
|
||||
- Last fetch time display with relative timestamps in bookmarks list
|
||||
|
||||
### Changed
|
||||
- Simplify image caching to use Service Worker transparently
|
||||
- Move refresh button from top bar to end of bookmarks list
|
||||
- Make explore page article cards proper links (supports CMD+click to open in new tab)
|
||||
- Reorganize bookmarks UI for better UX
|
||||
|
||||
### Fixed
|
||||
- Improve image cache resilience for offline viewing and hard reloads
|
||||
- Correct TypeScript types for cache stats state
|
||||
- Resolve linter errors for unused parameters
|
||||
- Import useEventModel from applesauce-react/hooks for proper type safety
|
||||
- Import Models from applesauce-core instead of applesauce-react
|
||||
- Use correct useEventModel hook for profile loading in BlogPostCard
|
||||
|
||||
## [0.3.0] - 2025-10-09
|
||||
|
||||
### Added
|
||||
- Flight Mode with offline highlight creation and local relay support
|
||||
- Automatic offline sync - rebroadcast local events when back online
|
||||
- Relay indicator icon on highlight items showing sync status
|
||||
- Click-to-rebroadcast functionality for highlights
|
||||
- Flight mode indicator (plane icon) on offline-created highlights
|
||||
- Relay rebroadcast settings for caching and propagation
|
||||
- Local relay status indicator for local-only/offline mode
|
||||
- Second local relay support (localhost:4869)
|
||||
- Relay connection status tracking and display
|
||||
- 6th font size option for better UI scaling
|
||||
|
||||
### Fixed
|
||||
- Highlight creation resilient to offline/flight mode
|
||||
- TypeScript type errors in offline sync
|
||||
- Relay indicator tooltip accuracy and reliability
|
||||
- Always show relay indicator icon on highlights
|
||||
- Show remote relay list for fetched highlights
|
||||
- Publish highlights to all connected relays instead of just one
|
||||
- Keep all relay connections alive, not just local ones
|
||||
- Check actual relay connection status instead of pool membership
|
||||
- Skip rebroadcasting when in flight mode
|
||||
- Update relay info after automatic sync completes
|
||||
- Only show successfully reachable relays in flight mode
|
||||
- Include local relays in relay indicator tooltip
|
||||
|
||||
### Changed
|
||||
- Rename 'Offline Mode' to 'Flight Mode' throughout UI
|
||||
- Move publication date to top-right corner with subtle border styling
|
||||
- Consolidate relay/status indicators into single unified icon
|
||||
- Simplify relay indicator tooltip to show relay list
|
||||
- Move rebroadcast settings to dedicated Flight Mode section
|
||||
- Place Reading Font and Font Size settings side-by-side
|
||||
- Improve font size scale and default value
|
||||
- Use wifi icon for disconnected remote relays
|
||||
- Use airplane icons for local relay indicators
|
||||
- Make Relays heading same level as Flight Mode in settings
|
||||
- Simplify rebroadcast settings UI with consistent checkbox style
|
||||
|
||||
### Performance
|
||||
- Make highlight creation instant with non-blocking relay publish
|
||||
- Reduce relay status polling interval to 20 seconds
|
||||
- Show sync progress and hide indicator after successful sync
|
||||
|
||||
## [0.2.10] - 2025-10-09
|
||||
|
||||
### Added
|
||||
@@ -426,6 +564,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Optimize relay usage following applesauce-relay best practices
|
||||
- Use applesauce-react event models for better profile handling
|
||||
|
||||
[0.3.6]: https://github.com/dergigi/boris/compare/v0.3.5...v0.3.6
|
||||
[0.3.5]: https://github.com/dergigi/boris/compare/v0.3.4...v0.3.5
|
||||
[0.3.4]: https://github.com/dergigi/boris/compare/v0.3.3...v0.3.4
|
||||
[0.3.3]: https://github.com/dergigi/boris/compare/v0.3.2...v0.3.3
|
||||
[0.3.2]: https://github.com/dergigi/boris/compare/v0.3.1...v0.3.2
|
||||
[0.3.1]: https://github.com/dergigi/boris/compare/v0.3.0...v0.3.1
|
||||
[0.3.0]: https://github.com/dergigi/boris/compare/v0.2.10...v0.3.0
|
||||
[0.2.10]: https://github.com/dergigi/boris/compare/v0.2.9...v0.2.10
|
||||
[0.2.9]: https://github.com/dergigi/boris/compare/v0.2.8...v0.2.9
|
||||
[0.2.8]: https://github.com/dergigi/boris/compare/v0.2.7...v0.2.8
|
||||
|
||||
156
MOBILE_IMPLEMENTATION.md
Normal file
156
MOBILE_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# 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`
|
||||
|
||||
@@ -6,7 +6,7 @@ Boris turns your Nostr bookmarks into a calm, fast, and focused reading experien
|
||||
|
||||
## Live
|
||||
|
||||
- App: [https://xn--bris-v0b.com/](https://xn--bris-v0b.com/)
|
||||
- App: [https://read.withboris.com/](https://read.withboris.com/)
|
||||
|
||||
## The Vision
|
||||
|
||||
|
||||
@@ -3,21 +3,21 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<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://xn--bris-v0b.com/" />
|
||||
<link rel="canonical" href="https://read.withboris.com/" />
|
||||
|
||||
<!-- Open Graph / Social Media -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://xn--bris-v0b.com/" />
|
||||
<meta property="og:url" content="https://read.withboris.com/" />
|
||||
<meta property="og:title" content="Boris - Nostr Bookmarks" />
|
||||
<meta property="og:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||
<meta property="og:site_name" content="Boris" />
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:url" content="https://xn--bris-v0b.com/" />
|
||||
<meta name="twitter:url" content="https://read.withboris.com/" />
|
||||
<meta name="twitter:title" content="Boris - Nostr Bookmarks" />
|
||||
<meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
|
||||
</head>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "boris",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"description": "A minimal nostr client for bookmark management",
|
||||
"homepage": "https://read.withboris.com/",
|
||||
"type": "module",
|
||||
|
||||
6
public/_routes.json
Normal file
6
public/_routes.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"include": ["/*"],
|
||||
"exclude": ["/assets/*", "/robots.txt", "/sw.js", "/_headers", "/_redirects"]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://xn--bris-v0b.com/sitemap.xml
|
||||
Sitemap: https://read.withboris.com/sitemap.xml
|
||||
|
||||
|
||||
56
public/sw.js
Normal file
56
public/sw.js
Normal file
@@ -0,0 +1,56 @@
|
||||
// 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
|
||||
})
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
12
src/App.tsx
12
src/App.tsx
@@ -27,8 +27,7 @@ function AppRoutes({
|
||||
const accountManager = Hooks.useAccountManager()
|
||||
|
||||
const handleLogout = () => {
|
||||
accountManager.setActive(undefined as never)
|
||||
localStorage.removeItem('active')
|
||||
accountManager.clearActive()
|
||||
showToast('Logged out successfully')
|
||||
}
|
||||
|
||||
@@ -61,6 +60,15 @@ function AppRoutes({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/explore"
|
||||
element={
|
||||
<Bookmarks
|
||||
relayPool={relayPool}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />
|
||||
</Routes>
|
||||
)
|
||||
|
||||
61
src/components/BlogPostCard.tsx
Normal file
61
src/components/BlogPostCard.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
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 { formatDistance } from 'date-fns'
|
||||
import { BlogPostPreview } from '../services/exploreService'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
|
||||
interface BlogPostCardProps {
|
||||
post: BlogPostPreview
|
||||
href: string
|
||||
}
|
||||
|
||||
const BlogPostCard: React.FC<BlogPostCardProps> = ({ post, href }) => {
|
||||
const profile = useEventModel(Models.ProfileModel, [post.author])
|
||||
const displayName = profile?.name || profile?.display_name ||
|
||||
`${post.author.slice(0, 8)}...${post.author.slice(-4)}`
|
||||
|
||||
const publishedDate = post.published || post.event.created_at
|
||||
const formattedDate = formatDistance(new Date(publishedDate * 1000), new Date(), {
|
||||
addSuffix: true
|
||||
})
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
className="blog-post-card"
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
{post.image && (
|
||||
<div className="blog-post-card-image">
|
||||
<img
|
||||
src={post.image}
|
||||
alt={post.title}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="blog-post-card-content">
|
||||
<h3 className="blog-post-card-title">{post.title}</h3>
|
||||
{post.summary && (
|
||||
<p className="blog-post-card-summary">{post.summary}</p>
|
||||
)}
|
||||
<div className="blog-post-card-meta">
|
||||
<span className="blog-post-card-author">
|
||||
<FontAwesomeIcon icon={faUser} />
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="blog-post-card-date">
|
||||
<FontAwesomeIcon icon={faCalendar} />
|
||||
{formattedDate}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default BlogPostCard
|
||||
|
||||
@@ -11,15 +11,17 @@ import { getPreviewImage, fetchOgImage } from '../utils/imagePreview'
|
||||
import { CompactView } from './BookmarkViews/CompactView'
|
||||
import { LargeView } from './BookmarkViews/LargeView'
|
||||
import { CardView } from './BookmarkViews/CardView'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
|
||||
interface BookmarkItemProps {
|
||||
bookmark: IndividualBookmark
|
||||
index: number
|
||||
onSelectUrl?: (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => void
|
||||
viewMode?: ViewMode
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards' }) => {
|
||||
export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onSelectUrl, viewMode = 'cards', settings }) => {
|
||||
const [ogImage, setOgImage] = useState<string | null>(null)
|
||||
|
||||
const short = (v: string) => `${v.slice(0, 8)}...${v.slice(-8)}`
|
||||
@@ -115,7 +117,8 @@ export const BookmarkItem: React.FC<BookmarkItemProps> = ({ bookmark, index, onS
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleImage,
|
||||
articleSummary
|
||||
articleSummary,
|
||||
settings
|
||||
}
|
||||
|
||||
if (viewMode === 'compact') {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronLeft, faBookmark, faSpinner, faList, faThLarge, faImage, faRotate } from '@fortawesome/free-solid-svg-icons'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { Bookmark, IndividualBookmark } from '../types/bookmarks'
|
||||
import { BookmarkItem } from './BookmarkItem'
|
||||
@@ -8,6 +9,7 @@ import SidebarHeader from './SidebarHeader'
|
||||
import IconButton from './IconButton'
|
||||
import { ViewMode } from './Bookmarks'
|
||||
import { extractUrlsFromContent } from '../services/bookmarkHelpers'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
|
||||
interface BookmarkListProps {
|
||||
bookmarks: Bookmark[]
|
||||
@@ -21,8 +23,11 @@ interface BookmarkListProps {
|
||||
onOpenSettings: () => void
|
||||
onRefresh?: () => void
|
||||
isRefreshing?: boolean
|
||||
lastFetchTime?: number | null
|
||||
loading?: boolean
|
||||
relayPool: RelayPool | null
|
||||
settings?: UserSettings
|
||||
isMobile?: boolean
|
||||
}
|
||||
|
||||
export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
@@ -37,8 +42,11 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
onOpenSettings,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
lastFetchTime,
|
||||
loading = false,
|
||||
relayPool
|
||||
relayPool,
|
||||
settings,
|
||||
isMobile = false
|
||||
}) => {
|
||||
// Helper to check if a bookmark has either content or a URL
|
||||
const hasContentOrUrl = (ib: IndividualBookmark) => {
|
||||
@@ -99,9 +107,8 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onLogout={onLogout}
|
||||
onOpenSettings={onOpenSettings}
|
||||
onRefresh={onRefresh}
|
||||
isRefreshing={isRefreshing}
|
||||
relayPool={relayPool}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
@@ -123,9 +130,38 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
|
||||
index={index}
|
||||
onSelectUrl={onSelectUrl}
|
||||
viewMode={viewMode}
|
||||
settings={settings}
|
||||
/>
|
||||
)}
|
||||
</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">
|
||||
|
||||
@@ -7,6 +7,8 @@ import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||
import IconButton from '../IconButton'
|
||||
import { classifyUrl } from '../../utils/helpers'
|
||||
import { IconGetter } from './shared'
|
||||
import { useImageCache } from '../../hooks/useImageCache'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
|
||||
interface CardViewProps {
|
||||
bookmark: IndividualBookmark
|
||||
@@ -22,6 +24,7 @@ interface CardViewProps {
|
||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
articleImage?: string
|
||||
articleSummary?: string
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
export const CardView: React.FC<CardViewProps> = ({
|
||||
@@ -37,8 +40,10 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleImage,
|
||||
articleSummary
|
||||
articleSummary,
|
||||
settings
|
||||
}) => {
|
||||
const cachedImage = useImageCache(articleImage, settings)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [urlsExpanded, setUrlsExpanded] = useState(false)
|
||||
const contentLength = (bookmark.content || '').length
|
||||
@@ -48,10 +53,10 @@ export const CardView: React.FC<CardViewProps> = ({
|
||||
|
||||
return (
|
||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
||||
{isArticle && articleImage && (
|
||||
{isArticle && cachedImage && (
|
||||
<div
|
||||
className="article-hero-image"
|
||||
style={{ backgroundImage: `url(${articleImage})` }}
|
||||
style={{ backgroundImage: `url(${cachedImage})` }}
|
||||
onClick={() => handleReadNow({ preventDefault: () => {} } as React.MouseEvent<HTMLButtonElement>)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faBookmark, faUserLock, faGlobe } from '@fortawesome/free-solid-svg-icons'
|
||||
import { IndividualBookmark } from '../../types/bookmarks'
|
||||
import { formatDate } from '../../utils/bookmarkUtils'
|
||||
import { formatDateCompact } from '../../utils/bookmarkUtils'
|
||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||
import { IconGetter } from './shared'
|
||||
|
||||
@@ -75,7 +75,7 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
||||
<ContentWithResolvedProfiles content={displayText.slice(0, 60) + (displayText.length > 60 ? '…' : '')} />
|
||||
</div>
|
||||
)}
|
||||
<span className="bookmark-date-compact">{formatDate(bookmark.created_at)}</span>
|
||||
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>
|
||||
{isClickable && (
|
||||
<button
|
||||
className="compact-read-btn"
|
||||
|
||||
@@ -4,6 +4,8 @@ import { IndividualBookmark } from '../../types/bookmarks'
|
||||
import { formatDate } from '../../utils/bookmarkUtils'
|
||||
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
|
||||
import { IconGetter } from './shared'
|
||||
import { useImageCache } from '../../hooks/useImageCache'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
|
||||
interface LargeViewProps {
|
||||
bookmark: IndividualBookmark
|
||||
@@ -19,6 +21,7 @@ interface LargeViewProps {
|
||||
getAuthorDisplayName: () => string
|
||||
handleReadNow: (e: React.MouseEvent<HTMLButtonElement>) => void
|
||||
articleSummary?: string
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
export const LargeView: React.FC<LargeViewProps> = ({
|
||||
@@ -34,13 +37,15 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
eventNevent,
|
||||
getAuthorDisplayName,
|
||||
handleReadNow,
|
||||
articleSummary
|
||||
articleSummary,
|
||||
settings
|
||||
}) => {
|
||||
const cachedImage = useImageCache(previewImage || undefined, settings)
|
||||
const isArticle = bookmark.kind === 30023
|
||||
|
||||
return (
|
||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark large ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
||||
{(hasUrls || (isArticle && previewImage)) && (
|
||||
{(hasUrls || (isArticle && cachedImage)) && (
|
||||
<div
|
||||
className="large-preview-image"
|
||||
onClick={() => {
|
||||
@@ -50,7 +55,7 @@ export const LargeView: React.FC<LargeViewProps> = ({
|
||||
onSelectUrl?.(extractedUrls[0])
|
||||
}
|
||||
}}
|
||||
style={previewImage ? { backgroundImage: `url(${previewImage})` } : undefined}
|
||||
style={cachedImage ? { backgroundImage: `url(${cachedImage})` } : undefined}
|
||||
>
|
||||
{!previewImage && hasUrls && (
|
||||
<div className="preview-placeholder">
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useBookmarksUI } from '../hooks/useBookmarksUI'
|
||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||
import { useOfflineSync } from '../hooks/useOfflineSync'
|
||||
import ThreePaneLayout from './ThreePaneLayout'
|
||||
import Explore from './Explore'
|
||||
import { classifyHighlights } from '../utils/highlightClassification'
|
||||
|
||||
export type ViewMode = 'compact' | 'cards' | 'large'
|
||||
@@ -33,6 +34,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
: undefined
|
||||
|
||||
const showSettings = location.pathname === '/settings'
|
||||
const showExplore = location.pathname === '/explore'
|
||||
|
||||
// Track previous location for going back from settings
|
||||
useEffect(() => {
|
||||
@@ -65,6 +67,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
})
|
||||
|
||||
const {
|
||||
isMobile,
|
||||
isSidebarOpen,
|
||||
toggleSidebar,
|
||||
isCollapsed,
|
||||
setIsCollapsed,
|
||||
isHighlightsCollapsed,
|
||||
@@ -94,6 +99,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
setHighlightsLoading,
|
||||
followedPubkeys,
|
||||
isRefreshing,
|
||||
lastFetchTime,
|
||||
handleFetchHighlights,
|
||||
handleRefreshAll
|
||||
} = useBookmarksData({
|
||||
@@ -113,7 +119,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
setReaderLoading,
|
||||
readerContent,
|
||||
setReaderContent,
|
||||
handleSelectUrl
|
||||
handleSelectUrl: baseHandleSelectUrl
|
||||
} = useContentSelection({
|
||||
relayPool,
|
||||
settings,
|
||||
@@ -122,6 +128,14 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
setCurrentArticle
|
||||
})
|
||||
|
||||
// Wrap handleSelectUrl to close mobile sidebar when selecting content
|
||||
const handleSelectUrl = (url: string, bookmark?: { id: string; kind: number; tags: string[][]; pubkey: string }) => {
|
||||
if (isMobile && isSidebarOpen) {
|
||||
toggleSidebar()
|
||||
}
|
||||
baseHandleSelectUrl(url, bookmark)
|
||||
}
|
||||
|
||||
const {
|
||||
highlightButtonRef,
|
||||
handleTextSelection,
|
||||
@@ -177,17 +191,24 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
<ThreePaneLayout
|
||||
isCollapsed={isCollapsed}
|
||||
isHighlightsCollapsed={isHighlightsCollapsed}
|
||||
isSidebarOpen={isSidebarOpen}
|
||||
showSettings={showSettings}
|
||||
showExplore={showExplore}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
viewMode={viewMode}
|
||||
isRefreshing={isRefreshing}
|
||||
onToggleSidebar={() => setIsCollapsed(!isCollapsed)}
|
||||
lastFetchTime={lastFetchTime}
|
||||
onToggleSidebar={isMobile ? toggleSidebar : () => setIsCollapsed(!isCollapsed)}
|
||||
onLogout={onLogout}
|
||||
onViewModeChange={setViewMode}
|
||||
onOpenSettings={() => {
|
||||
navigate('/settings')
|
||||
setIsCollapsed(true)
|
||||
if (isMobile) {
|
||||
toggleSidebar()
|
||||
} else {
|
||||
setIsCollapsed(true)
|
||||
}
|
||||
setIsHighlightsCollapsed(true)
|
||||
}}
|
||||
onRefresh={handleRefreshAll}
|
||||
@@ -225,6 +246,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
|
||||
highlightButtonRef={highlightButtonRef}
|
||||
onCreateHighlight={handleCreateHighlight}
|
||||
hasActiveAccount={!!(activeAccount && relayPool)}
|
||||
explore={showExplore ? (
|
||||
relayPool ? <Explore relayPool={relayPool} /> : null
|
||||
) : undefined}
|
||||
toastMessage={toastMessage ?? undefined}
|
||||
toastType={toastType}
|
||||
onClearToast={clearToast}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { HighlightVisibility } from './HighlightsPanel'
|
||||
import { useMarkdownToHTML } from '../hooks/useMarkdownToHTML'
|
||||
import { useHighlightedContent } from '../hooks/useHighlightedContent'
|
||||
import { useHighlightInteractions } from '../hooks/useHighlightInteractions'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
|
||||
interface ContentPanelProps {
|
||||
loading: boolean
|
||||
@@ -30,6 +31,7 @@ interface ContentPanelProps {
|
||||
highlightVisibility?: HighlightVisibility
|
||||
currentUserPubkey?: string
|
||||
followedPubkeys?: Set<string>
|
||||
settings?: UserSettings
|
||||
// For highlight creation
|
||||
onTextSelection?: (text: string) => void
|
||||
onClearSelection?: () => void
|
||||
@@ -48,6 +50,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
showHighlights = true,
|
||||
highlightStyle = 'marker',
|
||||
highlightColor = '#ffff00',
|
||||
settings,
|
||||
onHighlightClick,
|
||||
selectedHighlightId,
|
||||
highlightVisibility = { nostrverse: true, friends: true, mine: true },
|
||||
@@ -126,6 +129,7 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
||||
readingTimeText={readingStats ? readingStats.text : null}
|
||||
hasHighlights={hasHighlights}
|
||||
highlightCount={relevantHighlights.length}
|
||||
settings={settings}
|
||||
/>
|
||||
{markdown || html ? (
|
||||
markdown ? (
|
||||
|
||||
129
src/components/Explore.tsx
Normal file
129
src/components/Explore.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSpinner, faExclamationCircle, faNewspaper } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { fetchContacts } from '../services/contactService'
|
||||
import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService'
|
||||
import BlogPostCard from './BlogPostCard'
|
||||
|
||||
interface ExploreProps {
|
||||
relayPool: RelayPool
|
||||
}
|
||||
|
||||
const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
|
||||
const activeAccount = Hooks.useActiveAccount()
|
||||
const [blogPosts, setBlogPosts] = useState<BlogPostPreview[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const loadBlogPosts = async () => {
|
||||
if (!activeAccount) {
|
||||
setError('Please log in to explore content from your friends')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Fetch the user's contacts (friends)
|
||||
const contacts = await fetchContacts(relayPool, activeAccount.pubkey)
|
||||
|
||||
if (contacts.size === 0) {
|
||||
setError('You are not following anyone yet. Follow some people to see their blog posts!')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Get relay URLs from pool
|
||||
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
|
||||
)
|
||||
|
||||
if (posts.length === 0) {
|
||||
setError('No blog posts found from your friends yet')
|
||||
}
|
||||
|
||||
setBlogPosts(posts)
|
||||
} catch (err) {
|
||||
console.error('Failed to load blog posts:', err)
|
||||
setError('Failed to load blog posts. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadBlogPosts()
|
||||
}, [relayPool, activeAccount])
|
||||
|
||||
const getPostUrl = (post: BlogPostPreview) => {
|
||||
// Get the d-tag identifier
|
||||
const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || ''
|
||||
|
||||
// Create naddr
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 30023,
|
||||
pubkey: post.author,
|
||||
identifier: dTag
|
||||
})
|
||||
|
||||
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">
|
||||
<div className="explore-error">
|
||||
<FontAwesomeIcon icon={faExclamationCircle} size="2x" />
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="explore-container">
|
||||
<div className="explore-header">
|
||||
<h1>
|
||||
<FontAwesomeIcon icon={faNewspaper} />
|
||||
Explore
|
||||
</h1>
|
||||
<p className="explore-subtitle">
|
||||
Discover blog posts from your friends on Nostr
|
||||
</p>
|
||||
</div>
|
||||
<div className="explore-grid">
|
||||
{blogPosts.map((post) => (
|
||||
<BlogPostCard
|
||||
key={`${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1]}`}
|
||||
post={post}
|
||||
href={getPostUrl(post)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Explore
|
||||
|
||||
@@ -2,13 +2,14 @@ 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 { Highlight } from '../types/highlights'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models, IEventStore } from 'applesauce-core'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
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'
|
||||
|
||||
interface HighlightWithLevel extends Highlight {
|
||||
level?: 'mine' | 'friends' | 'nostrverse'
|
||||
@@ -102,7 +103,41 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
|
||||
const getSourceLink = () => {
|
||||
if (highlight.eventReference) {
|
||||
return `https://search.dergigi.com/e/${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
|
||||
}
|
||||
@@ -248,7 +283,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
|
||||
</span>
|
||||
<span className="highlight-meta-separator">•</span>
|
||||
<span className="highlight-time">
|
||||
{formatDistanceToNow(new Date(highlight.created_at * 1000), { addSuffix: true })}
|
||||
{formatDateCompact(highlight.created_at)}
|
||||
</span>
|
||||
|
||||
{sourceLink && (
|
||||
|
||||
@@ -11,6 +11,7 @@ interface IconButtonProps {
|
||||
size?: number
|
||||
disabled?: boolean
|
||||
spin?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const IconButton: React.FC<IconButtonProps> = ({
|
||||
@@ -21,11 +22,12 @@ const IconButton: React.FC<IconButtonProps> = ({
|
||||
variant = 'ghost',
|
||||
size = 33,
|
||||
disabled = false,
|
||||
spin = false
|
||||
spin = false,
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
className={`icon-button ${variant}`}
|
||||
className={`icon-button ${variant} ${className}`.trim()}
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-label={ariaLabel || title}
|
||||
|
||||
@@ -2,6 +2,8 @@ import React 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'
|
||||
|
||||
interface ReaderHeaderProps {
|
||||
title?: string
|
||||
@@ -11,6 +13,7 @@ interface ReaderHeaderProps {
|
||||
readingTimeText?: string | null
|
||||
hasHighlights: boolean
|
||||
highlightCount: number
|
||||
settings?: UserSettings
|
||||
}
|
||||
|
||||
const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
@@ -20,13 +23,15 @@ const ReaderHeader: React.FC<ReaderHeaderProps> = ({
|
||||
published,
|
||||
readingTimeText,
|
||||
hasHighlights,
|
||||
highlightCount
|
||||
highlightCount,
|
||||
settings
|
||||
}) => {
|
||||
const cachedImage = useImageCache(image, settings)
|
||||
const formattedDate = published ? format(new Date(published * 1000), 'MMM d, yyyy') : null
|
||||
if (image) {
|
||||
if (cachedImage) {
|
||||
return (
|
||||
<div className="reader-hero-image">
|
||||
<img src={image} alt={title || 'Article image'} />
|
||||
<img src={cachedImage} alt={title || 'Article image'} />
|
||||
{formattedDate && (
|
||||
<div className="publish-date-topright">
|
||||
{formattedDate}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faPlane, faGlobe, faCircle } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faPlane, faGlobe, faCircle, faSpinner } from '@fortawesome/free-solid-svg-icons'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||
import { isLocalRelay } from '../utils/helpers'
|
||||
@@ -10,8 +10,9 @@ interface RelayStatusIndicatorProps {
|
||||
}
|
||||
|
||||
export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ relayPool }) => {
|
||||
// Poll for relay status updates
|
||||
const relayStatuses = useRelayStatus({ relayPool })
|
||||
// Poll frequently for responsive offline indicator (5s instead of default 20s)
|
||||
const relayStatuses = useRelayStatus({ relayPool, pollingInterval: 5000 })
|
||||
const [isConnecting, setIsConnecting] = useState(true)
|
||||
|
||||
if (!relayPool) return null
|
||||
|
||||
@@ -25,32 +26,52 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ rela
|
||||
const localOnlyMode = hasLocalRelay && !hasRemoteRelay
|
||||
const offlineMode = connectedUrls.length === 0
|
||||
|
||||
// Debug logging
|
||||
React.useEffect(() => {
|
||||
if (localOnlyMode || offlineMode) {
|
||||
console.log('✈️ Relay Status Indicator:', {
|
||||
mode: offlineMode ? 'OFFLINE' : 'LOCAL_ONLY',
|
||||
connectedUrls,
|
||||
hasLocalRelay,
|
||||
hasRemoteRelay
|
||||
})
|
||||
// Show "Connecting" for first few seconds or until relays connect
|
||||
useEffect(() => {
|
||||
if (connectedUrls.length > 0) {
|
||||
// Connected! Stop showing connecting state
|
||||
setIsConnecting(false)
|
||||
} else {
|
||||
// No connections yet - show connecting for 8 seconds
|
||||
setIsConnecting(true)
|
||||
const timeout = setTimeout(() => {
|
||||
setIsConnecting(false)
|
||||
}, 8000)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [localOnlyMode, offlineMode, connectedUrls.length])
|
||||
}, [connectedUrls.length])
|
||||
|
||||
// Don't show indicator when fully connected
|
||||
if (!localOnlyMode && !offlineMode) return null
|
||||
// Debug logging
|
||||
useEffect(() => {
|
||||
console.log('🔌 Relay Status Indicator:', {
|
||||
mode: isConnecting ? 'CONNECTING' : offlineMode ? 'OFFLINE' : localOnlyMode ? 'LOCAL_ONLY' : 'ONLINE',
|
||||
totalStatuses: relayStatuses.length,
|
||||
connectedCount: connectedUrls.length,
|
||||
connectedUrls: connectedUrls.map(u => u.replace(/^wss?:\/\//, '')),
|
||||
hasLocalRelay,
|
||||
hasRemoteRelay,
|
||||
isConnecting
|
||||
})
|
||||
}, [offlineMode, localOnlyMode, connectedUrls.length, relayStatuses.length, hasLocalRelay, hasRemoteRelay, isConnecting])
|
||||
|
||||
// Don't show indicator when fully connected (but show when connecting)
|
||||
if (!localOnlyMode && !offlineMode && !isConnecting) return null
|
||||
|
||||
return (
|
||||
<div className="relay-status-indicator" title={
|
||||
offlineMode
|
||||
? 'Offline - No relays connected'
|
||||
: 'Local Relays Only - Highlights will be marked as local'
|
||||
<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-icon">
|
||||
<FontAwesomeIcon icon={offlineMode ? faCircle : faPlane} />
|
||||
<FontAwesomeIcon icon={isConnecting ? faSpinner : offlineMode ? faCircle : faPlane} spin={isConnecting} />
|
||||
</div>
|
||||
<div className="relay-status-text">
|
||||
{offlineMode ? (
|
||||
{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>
|
||||
@@ -62,7 +83,7 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ rela
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!offlineMode && (
|
||||
{!offlineMode && !isConnecting && (
|
||||
<div className="relay-status-pulse">
|
||||
<FontAwesomeIcon icon={faGlobe} className="pulse-icon" />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { faTrash } from '@fortawesome/free-solid-svg-icons'
|
||||
import { UserSettings } from '../../services/settingsService'
|
||||
import { getImageCacheStatsAsync, clearImageCache } from '../../services/imageCacheService'
|
||||
import IconButton from '../IconButton'
|
||||
|
||||
interface OfflineModeSettingsProps {
|
||||
settings: UserSettings
|
||||
@@ -10,16 +13,95 @@ interface OfflineModeSettingsProps {
|
||||
|
||||
const OfflineModeSettings: React.FC<OfflineModeSettingsProps> = ({ settings, onUpdate, onClose }) => {
|
||||
const navigate = useNavigate()
|
||||
const [cacheStats, setCacheStats] = useState<{
|
||||
totalSizeMB: number
|
||||
itemCount: number
|
||||
items: Array<{ url: string, sizeMB: number }>
|
||||
}>({ totalSizeMB: 0, itemCount: 0, items: [] })
|
||||
|
||||
const handleLinkClick = (url: string) => {
|
||||
if (onClose) onClose()
|
||||
navigate(`/r/${encodeURIComponent(url)}`)
|
||||
}
|
||||
|
||||
const handleClearCache = async () => {
|
||||
if (confirm('Are you sure you want to clear all cached images?')) {
|
||||
await clearImageCache()
|
||||
const stats = await getImageCacheStatsAsync()
|
||||
setCacheStats(stats)
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache stats periodically
|
||||
useEffect(() => {
|
||||
const updateStats = async () => {
|
||||
const stats = await getImageCacheStatsAsync()
|
||||
setCacheStats(stats)
|
||||
}
|
||||
|
||||
updateStats() // Initial load
|
||||
const interval = setInterval(updateStats, 3000) // Update every 3 seconds
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Flight Mode</h3>
|
||||
|
||||
<div className="setting-group" style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<label htmlFor="enableImageCache" className="checkbox-label" style={{ marginBottom: 0 }}>
|
||||
<input
|
||||
id="enableImageCache"
|
||||
type="checkbox"
|
||||
checked={settings.enableImageCache ?? true}
|
||||
onChange={(e) => onUpdate({ enableImageCache: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Use local image cache</span>
|
||||
</label>
|
||||
|
||||
{(settings.enableImageCache ?? true) && (
|
||||
<div style={{
|
||||
fontSize: '0.85rem',
|
||||
color: 'var(--text-secondary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem'
|
||||
}}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
||||
( {cacheStats.totalSizeMB.toFixed(1)} MB /
|
||||
<input
|
||||
id="imageCacheSizeMB"
|
||||
type="number"
|
||||
min="10"
|
||||
max="500"
|
||||
value={settings.imageCacheSizeMB ?? 210}
|
||||
onChange={(e) => onUpdate({ imageCacheSizeMB: parseInt(e.target.value) || 210 })}
|
||||
style={{
|
||||
width: '50px',
|
||||
padding: '0.15rem 0.35rem',
|
||||
background: 'var(--surface-secondary)',
|
||||
border: '1px solid var(--border-color, #333)',
|
||||
borderRadius: '4px',
|
||||
color: 'inherit',
|
||||
fontSize: 'inherit',
|
||||
fontFamily: 'inherit',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
/>
|
||||
MB used )
|
||||
</span>
|
||||
<IconButton
|
||||
icon={faTrash}
|
||||
onClick={handleClearCache}
|
||||
title="Clear cache"
|
||||
variant="ghost"
|
||||
size={28}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="useLocalRelayAsCache" className="checkbox-label">
|
||||
<input
|
||||
@@ -33,19 +115,6 @@ const OfflineModeSettings: React.FC<OfflineModeSettingsProps> = ({ settings, onU
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="rebroadcastToAllRelays" className="checkbox-label">
|
||||
<input
|
||||
id="rebroadcastToAllRelays"
|
||||
type="checkbox"
|
||||
checked={settings.rebroadcastToAllRelays ?? false}
|
||||
onChange={(e) => onUpdate({ rebroadcastToAllRelays: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Rebroadcast events while browsing</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginTop: '1.5rem',
|
||||
padding: '1rem',
|
||||
|
||||
@@ -9,7 +9,7 @@ interface StartupPreferencesSettingsProps {
|
||||
const StartupPreferencesSettings: React.FC<StartupPreferencesSettingsProps> = ({ settings, onUpdate }) => {
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Startup Preferences</h3>
|
||||
<h3 className="section-title">Startup & Behavior</h3>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="sidebarCollapsed" className="checkbox-label">
|
||||
@@ -36,6 +36,32 @@ const StartupPreferencesSettings: React.FC<StartupPreferencesSettingsProps> = ({
|
||||
<span>Start with highlights panel collapsed</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="rebroadcastToAllRelays" className="checkbox-label">
|
||||
<input
|
||||
id="rebroadcastToAllRelays"
|
||||
type="checkbox"
|
||||
checked={settings.rebroadcastToAllRelays ?? false}
|
||||
onChange={(e) => onUpdate({ rebroadcastToAllRelays: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Rebroadcast events while browsing</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="setting-group">
|
||||
<label htmlFor="autoCollapseSidebarOnMobile" className="checkbox-label">
|
||||
<input
|
||||
id="autoCollapseSidebarOnMobile"
|
||||
type="checkbox"
|
||||
checked={settings.autoCollapseSidebarOnMobile !== false}
|
||||
onChange={(e) => onUpdate({ autoCollapseSidebarOnMobile: e.target.checked })}
|
||||
className="setting-checkbox"
|
||||
/>
|
||||
<span>Auto-collapse sidebar on small screens</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faRotate, faHome, faPlus } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faHome, faPlus, faNewspaper, faTimes } from '@fortawesome/free-solid-svg-icons'
|
||||
import { Hooks } from 'applesauce-react'
|
||||
import { useEventModel } from 'applesauce-react/hooks'
|
||||
import { Models } from 'applesauce-core'
|
||||
@@ -16,12 +16,11 @@ interface SidebarHeaderProps {
|
||||
onToggleCollapse: () => void
|
||||
onLogout: () => void
|
||||
onOpenSettings: () => void
|
||||
onRefresh?: () => void
|
||||
isRefreshing?: boolean
|
||||
relayPool: RelayPool | null
|
||||
isMobile?: boolean
|
||||
}
|
||||
|
||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, onRefresh, isRefreshing, relayPool }) => {
|
||||
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, relayPool, isMobile = false }) => {
|
||||
const [isConnecting, setIsConnecting] = useState(false)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
@@ -61,11 +60,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
}
|
||||
|
||||
await createWebBookmark(url, title, description, tags, activeAccount, relayPool, RELAYS)
|
||||
|
||||
// Refresh bookmarks after creating
|
||||
if (onRefresh) {
|
||||
onRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
const profileImage = getProfileImage()
|
||||
@@ -73,14 +67,25 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
return (
|
||||
<>
|
||||
<div className="sidebar-header-bar">
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="toggle-sidebar-btn"
|
||||
title="Collapse bookmarks sidebar"
|
||||
aria-label="Collapse bookmarks sidebar"
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</button>
|
||||
{isMobile ? (
|
||||
<IconButton
|
||||
icon={faTimes}
|
||||
onClick={onToggleCollapse}
|
||||
title="Close sidebar"
|
||||
ariaLabel="Close sidebar"
|
||||
variant="ghost"
|
||||
className="mobile-close-btn"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="toggle-sidebar-btn"
|
||||
title="Collapse bookmarks sidebar"
|
||||
aria-label="Collapse bookmarks sidebar"
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</button>
|
||||
)}
|
||||
<div className="sidebar-header-right">
|
||||
<div
|
||||
className="profile-avatar"
|
||||
@@ -101,6 +106,13 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
ariaLabel="Home"
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
icon={faNewspaper}
|
||||
onClick={() => navigate('/explore')}
|
||||
title="Explore"
|
||||
ariaLabel="Explore"
|
||||
variant="ghost"
|
||||
/>
|
||||
<IconButton
|
||||
icon={faGear}
|
||||
onClick={onOpenSettings}
|
||||
@@ -108,17 +120,6 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
|
||||
ariaLabel="Settings"
|
||||
variant="ghost"
|
||||
/>
|
||||
{onRefresh && (
|
||||
<IconButton
|
||||
icon={faRotate}
|
||||
onClick={onRefresh}
|
||||
title="Refresh bookmarks"
|
||||
ariaLabel="Refresh bookmarks"
|
||||
variant="ghost"
|
||||
disabled={isRefreshing}
|
||||
spin={isRefreshing}
|
||||
/>
|
||||
)}
|
||||
{activeAccount && (
|
||||
<IconButton
|
||||
icon={faPlus}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React from 'react'
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faBookmark, faHighlighter } from '@fortawesome/free-solid-svg-icons'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { BookmarkList } from './BookmarkList'
|
||||
@@ -16,18 +18,22 @@ import { UserSettings } from '../services/settingsService'
|
||||
import { HighlightVisibility } from './HighlightsPanel'
|
||||
import { HighlightButtonRef } from './HighlightButton'
|
||||
import { BookmarkReference } from '../utils/contentLoader'
|
||||
import { useIsMobile } from '../hooks/useMediaQuery'
|
||||
|
||||
interface ThreePaneLayoutProps {
|
||||
// Layout state
|
||||
isCollapsed: boolean
|
||||
isHighlightsCollapsed: boolean
|
||||
isSidebarOpen: boolean
|
||||
showSettings: boolean
|
||||
showExplore?: boolean
|
||||
|
||||
// Bookmarks pane
|
||||
bookmarks: Bookmark[]
|
||||
bookmarksLoading: boolean
|
||||
viewMode: ViewMode
|
||||
isRefreshing: boolean
|
||||
lastFetchTime?: number | null
|
||||
onToggleSidebar: () => void
|
||||
onLogout: () => void
|
||||
onViewModeChange: (mode: ViewMode) => void
|
||||
@@ -71,17 +77,173 @@ interface ThreePaneLayoutProps {
|
||||
toastMessage?: string
|
||||
toastType?: 'success' | 'error'
|
||||
onClearToast: () => void
|
||||
|
||||
// Optional Explore content
|
||||
explore?: React.ReactNode
|
||||
}
|
||||
|
||||
const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
const isMobile = useIsMobile()
|
||||
const sidebarRef = useRef<HTMLDivElement>(null)
|
||||
const highlightsRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Lock body scroll when mobile sidebar or highlights is open
|
||||
useEffect(() => {
|
||||
if (isMobile && (props.isSidebarOpen || !props.isHighlightsCollapsed)) {
|
||||
document.body.classList.add('mobile-sidebar-open')
|
||||
} else {
|
||||
document.body.classList.remove('mobile-sidebar-open')
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove('mobile-sidebar-open')
|
||||
}
|
||||
}, [isMobile, props.isSidebarOpen, props.isHighlightsCollapsed])
|
||||
|
||||
// Handle ESC key to close sidebar or highlights
|
||||
useEffect(() => {
|
||||
if (!isMobile) return
|
||||
if (!props.isSidebarOpen && props.isHighlightsCollapsed) return
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (props.isSidebarOpen) {
|
||||
props.onToggleSidebar()
|
||||
} else if (!props.isHighlightsCollapsed) {
|
||||
props.onToggleHighlightsPanel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => document.removeEventListener('keydown', handleEscape)
|
||||
}, [isMobile, props.isSidebarOpen, props.isHighlightsCollapsed, props.onToggleSidebar, props.onToggleHighlightsPanel])
|
||||
|
||||
// Trap focus in sidebar when open on mobile
|
||||
useEffect(() => {
|
||||
if (!isMobile || !props.isSidebarOpen || !sidebarRef.current) return
|
||||
|
||||
const sidebar = sidebarRef.current
|
||||
const focusableElements = sidebar.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
const firstElement = focusableElements[0]
|
||||
const lastElement = focusableElements[focusableElements.length - 1]
|
||||
|
||||
const handleTab = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
e.preventDefault()
|
||||
lastElement?.focus()
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastElement) {
|
||||
e.preventDefault()
|
||||
firstElement?.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sidebar.addEventListener('keydown', handleTab)
|
||||
firstElement?.focus()
|
||||
|
||||
return () => {
|
||||
sidebar.removeEventListener('keydown', handleTab)
|
||||
}
|
||||
}, [isMobile, props.isSidebarOpen])
|
||||
|
||||
// Trap focus in highlights panel when open on mobile
|
||||
useEffect(() => {
|
||||
if (!isMobile || props.isHighlightsCollapsed || !highlightsRef.current) return
|
||||
|
||||
const highlights = highlightsRef.current
|
||||
const focusableElements = highlights.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
const firstElement = focusableElements[0]
|
||||
const lastElement = focusableElements[focusableElements.length - 1]
|
||||
|
||||
const handleTab = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Tab') return
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
e.preventDefault()
|
||||
lastElement?.focus()
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastElement) {
|
||||
e.preventDefault()
|
||||
firstElement?.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
highlights.addEventListener('keydown', handleTab)
|
||||
firstElement?.focus()
|
||||
|
||||
return () => {
|
||||
highlights.removeEventListener('keydown', handleTab)
|
||||
}
|
||||
}, [isMobile, props.isHighlightsCollapsed])
|
||||
|
||||
const handleBackdropClick = () => {
|
||||
if (isMobile) {
|
||||
if (props.isSidebarOpen) {
|
||||
props.onToggleSidebar()
|
||||
} else if (!props.isHighlightsCollapsed) {
|
||||
props.onToggleHighlightsPanel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile bookmark button - only show when viewing article */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && (
|
||||
<button
|
||||
className="mobile-hamburger-btn"
|
||||
onClick={props.onToggleSidebar}
|
||||
aria-label="Open bookmarks"
|
||||
aria-expanded={props.isSidebarOpen}
|
||||
>
|
||||
<FontAwesomeIcon icon={faBookmark} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mobile highlights button - only show when viewing article */}
|
||||
{isMobile && !props.isSidebarOpen && props.isHighlightsCollapsed && (
|
||||
<button
|
||||
className="mobile-highlights-btn"
|
||||
onClick={props.onToggleHighlightsPanel}
|
||||
aria-label="Open highlights"
|
||||
aria-expanded={!props.isHighlightsCollapsed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faHighlighter} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Mobile backdrop */}
|
||||
{isMobile && (
|
||||
<div
|
||||
className={`mobile-sidebar-backdrop ${(props.isSidebarOpen || !props.isHighlightsCollapsed) ? 'visible' : ''}`}
|
||||
onClick={handleBackdropClick}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`three-pane ${props.isCollapsed ? 'sidebar-collapsed' : ''} ${props.isHighlightsCollapsed ? 'highlights-collapsed' : ''}`}>
|
||||
<div className="pane sidebar">
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className={`pane sidebar ${isMobile && props.isSidebarOpen ? 'mobile-open' : ''}`}
|
||||
aria-hidden={isMobile && !props.isSidebarOpen}
|
||||
>
|
||||
<BookmarkList
|
||||
bookmarks={props.bookmarks}
|
||||
onSelectUrl={props.onSelectUrl}
|
||||
isCollapsed={props.isCollapsed}
|
||||
isCollapsed={isMobile ? false : props.isCollapsed}
|
||||
onToggleCollapse={props.onToggleSidebar}
|
||||
onLogout={props.onLogout}
|
||||
viewMode={props.viewMode}
|
||||
@@ -90,11 +252,14 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
onOpenSettings={props.onOpenSettings}
|
||||
onRefresh={props.onRefresh}
|
||||
isRefreshing={props.isRefreshing}
|
||||
lastFetchTime={props.lastFetchTime}
|
||||
loading={props.bookmarksLoading}
|
||||
relayPool={props.relayPool}
|
||||
settings={props.settings}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</div>
|
||||
<div className="pane main">
|
||||
<div className={`pane main ${isMobile && (props.isSidebarOpen || !props.isHighlightsCollapsed) ? 'mobile-hidden' : ''}`}>
|
||||
{props.showSettings ? (
|
||||
<Settings
|
||||
settings={props.settings}
|
||||
@@ -102,6 +267,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
onClose={props.onCloseSettings}
|
||||
relayPool={props.relayPool}
|
||||
/>
|
||||
) : props.showExplore && props.explore ? (
|
||||
// Render Explore inside the main pane to keep side panels
|
||||
<>
|
||||
{props.explore}
|
||||
</>
|
||||
) : (
|
||||
<ContentPanel
|
||||
loading={props.readerLoading}
|
||||
@@ -123,10 +293,15 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
onClearSelection={props.onClearSelection}
|
||||
currentUserPubkey={props.currentUserPubkey}
|
||||
followedPubkeys={props.followedPubkeys}
|
||||
settings={props.settings}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="pane highlights">
|
||||
<div
|
||||
ref={highlightsRef}
|
||||
className={`pane highlights ${isMobile && !props.isHighlightsCollapsed ? 'mobile-open' : ''}`}
|
||||
aria-hidden={isMobile && props.isHighlightsCollapsed}
|
||||
>
|
||||
<HighlightsPanel
|
||||
highlights={props.highlights}
|
||||
loading={props.highlightsLoading}
|
||||
|
||||
@@ -34,6 +34,7 @@ export const useBookmarksData = ({
|
||||
const [highlightsLoading, setHighlightsLoading] = useState(true)
|
||||
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [lastFetchTime, setLastFetchTime] = useState<number | null>(null)
|
||||
|
||||
const handleFetchContacts = useCallback(async () => {
|
||||
if (!relayPool || !activeAccount) return
|
||||
@@ -93,6 +94,7 @@ export const useBookmarksData = ({
|
||||
await handleFetchBookmarks()
|
||||
await handleFetchHighlights()
|
||||
await handleFetchContacts()
|
||||
setLastFetchTime(Date.now())
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh data:', err)
|
||||
} finally {
|
||||
@@ -119,6 +121,7 @@ export const useBookmarksData = ({
|
||||
setHighlightsLoading,
|
||||
followedPubkeys,
|
||||
isRefreshing,
|
||||
lastFetchTime,
|
||||
handleFetchBookmarks,
|
||||
handleFetchHighlights,
|
||||
handleRefreshAll
|
||||
|
||||
@@ -3,12 +3,15 @@ import { NostrEvent } from 'nostr-tools'
|
||||
import { HighlightVisibility } from '../components/HighlightsPanel'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import { ViewMode } from '../components/Bookmarks'
|
||||
import { useIsMobile } from './useMediaQuery'
|
||||
|
||||
interface UseBookmarksUIParams {
|
||||
settings: UserSettings
|
||||
}
|
||||
|
||||
export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => {
|
||||
const isMobile = useIsMobile()
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
|
||||
const [isCollapsed, setIsCollapsed] = useState(true)
|
||||
const [isHighlightsCollapsed, setIsHighlightsCollapsed] = useState(true)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('compact')
|
||||
@@ -23,6 +26,16 @@ export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => {
|
||||
mine: true
|
||||
})
|
||||
|
||||
// Auto-collapse sidebar on mobile based on settings
|
||||
useEffect(() => {
|
||||
const autoCollapse = settings.autoCollapseSidebarOnMobile !== false
|
||||
if (isMobile && autoCollapse) {
|
||||
setIsSidebarOpen(false)
|
||||
} else if (!isMobile) {
|
||||
setIsSidebarOpen(true)
|
||||
}
|
||||
}, [isMobile, settings.autoCollapseSidebarOnMobile])
|
||||
|
||||
// Apply UI settings
|
||||
useEffect(() => {
|
||||
if (settings.defaultViewMode) setViewMode(settings.defaultViewMode)
|
||||
@@ -34,7 +47,15 @@ export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => {
|
||||
})
|
||||
}, [settings])
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setIsSidebarOpen(prev => !prev)
|
||||
}
|
||||
|
||||
return {
|
||||
isMobile,
|
||||
isSidebarOpen,
|
||||
setIsSidebarOpen,
|
||||
toggleSidebar,
|
||||
isCollapsed,
|
||||
setIsCollapsed,
|
||||
isHighlightsCollapsed,
|
||||
|
||||
34
src/hooks/useImageCache.ts
Normal file
34
src/hooks/useImageCache.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
|
||||
/**
|
||||
* Hook to return image URL for display
|
||||
* Service Worker handles all caching transparently
|
||||
* Images are cached on first load and available offline automatically
|
||||
*
|
||||
* @param imageUrl - The URL of the image to display
|
||||
* @returns The image URL (Service Worker handles caching)
|
||||
*/
|
||||
export function useImageCache(
|
||||
imageUrl: string | undefined,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
_settings?: UserSettings
|
||||
): string | undefined {
|
||||
// Service Worker handles everything - just return the URL as-is
|
||||
return imageUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-load image to ensure it's cached by Service Worker
|
||||
* Triggers a fetch so the SW can cache it even if not visible yet
|
||||
*/
|
||||
export function useCacheImageOnLoad(
|
||||
imageUrl: string | undefined,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
_settings?: UserSettings
|
||||
): void {
|
||||
// Service Worker will cache on first fetch
|
||||
// This hook is now a no-op, kept for API compatibility
|
||||
// The browser will automatically fetch and cache images when they're used in <img> tags
|
||||
void imageUrl
|
||||
}
|
||||
|
||||
62
src/hooks/useMediaQuery.ts
Normal file
62
src/hooks/useMediaQuery.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* Hook to detect if a media query matches
|
||||
* @param query The media query string (e.g., '(max-width: 768px)')
|
||||
* @returns true if the media query matches, false otherwise
|
||||
*/
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(() => {
|
||||
if (typeof window === 'undefined') return false
|
||||
return window.matchMedia(query).matches
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const mediaQuery = window.matchMedia(query)
|
||||
|
||||
// Update state if the media query changes
|
||||
const handleChange = (event: MediaQueryListEvent) => {
|
||||
setMatches(event.matches)
|
||||
}
|
||||
|
||||
// Modern browsers
|
||||
if (mediaQuery.addEventListener) {
|
||||
mediaQuery.addEventListener('change', handleChange)
|
||||
return () => mediaQuery.removeEventListener('change', handleChange)
|
||||
}
|
||||
// Legacy browsers
|
||||
else {
|
||||
mediaQuery.addListener(handleChange)
|
||||
return () => mediaQuery.removeListener(handleChange)
|
||||
}
|
||||
}, [query])
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to detect if the user is on a coarse pointer device (touch)
|
||||
* @returns true if the user is using a coarse pointer (touch), false otherwise
|
||||
*/
|
||||
export function useIsCoarsePointer(): boolean {
|
||||
return useMediaQuery('(pointer: coarse)')
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to detect if the viewport is mobile-sized
|
||||
* @returns true if viewport width is <= 768px, false otherwise
|
||||
*/
|
||||
export function useIsMobile(): boolean {
|
||||
return useMediaQuery('(max-width: 768px)')
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to detect if the viewport is tablet-sized
|
||||
* @returns true if viewport width is <= 1024px, false otherwise
|
||||
*/
|
||||
export function useIsTablet(): boolean {
|
||||
return useMediaQuery('(max-width: 1024px)')
|
||||
}
|
||||
|
||||
574
src/index.css
574
src/index.css
@@ -22,12 +22,40 @@
|
||||
--highlights-collapsed-width: 56px;
|
||||
--main-max-width: 900px;
|
||||
--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);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
overscroll-behavior: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Use dynamic viewport height if supported */
|
||||
@supports (height: 100dvh) {
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
}
|
||||
|
||||
body.mobile-sidebar-open {
|
||||
overflow: hidden;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#root {
|
||||
@@ -36,6 +64,12 @@ body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#root {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.app {
|
||||
text-align: center;
|
||||
position: relative;
|
||||
@@ -71,15 +105,16 @@ body {
|
||||
|
||||
.bookmarks-container .view-mode-controls {
|
||||
margin-top: auto;
|
||||
padding: 0.75rem 1rem;
|
||||
padding: 1rem;
|
||||
border-top: 1px solid #333;
|
||||
background: #1a1a1a;
|
||||
border-radius: 0 0 12px 12px;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.bookmarks-container .bookmarks-list {
|
||||
padding: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
@@ -105,16 +140,52 @@ body {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.mobile-hamburger-btn {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
left: 1rem;
|
||||
z-index: 900;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
border-radius: 8px;
|
||||
color: #ddd;
|
||||
width: var(--min-touch-target);
|
||||
height: var(--min-touch-target);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mobile-hamburger-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.mobile-close-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mobile-hamburger-btn {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sidebar-header-bar .toggle-sidebar-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-close-btn {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.view-mode-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
@@ -305,6 +376,29 @@ body {
|
||||
|
||||
.icon-button.ghost { background: #2a2a2a; }
|
||||
|
||||
/* Mobile touch target improvements */
|
||||
@media (max-width: 768px) {
|
||||
.icon-button {
|
||||
min-width: var(--min-touch-target);
|
||||
min-height: var(--min-touch-target);
|
||||
}
|
||||
}
|
||||
|
||||
/* Disable hover effects on touch devices */
|
||||
@media (pointer: coarse) {
|
||||
.icon-button:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.icon-button.ghost:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
.icon-button:active {
|
||||
background: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.bookmark-events {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
@@ -398,12 +492,24 @@ body {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #888;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.bookmarks-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -432,6 +538,13 @@ body {
|
||||
column-gap: 0;
|
||||
height: calc(100vh - 2rem);
|
||||
transition: grid-template-columns 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@supports (height: 100dvh) {
|
||||
.three-pane {
|
||||
height: calc(100dvh - 2rem);
|
||||
}
|
||||
}
|
||||
|
||||
.three-pane.sidebar-collapsed {
|
||||
@@ -446,6 +559,22 @@ body {
|
||||
grid-template-columns: var(--sidebar-collapsed-width) 1fr var(--highlights-collapsed-width);
|
||||
}
|
||||
|
||||
/* Mobile three-pane layout */
|
||||
@media (max-width: 768px) {
|
||||
.three-pane {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
.three-pane.sidebar-collapsed,
|
||||
.three-pane.highlights-collapsed,
|
||||
.three-pane.sidebar-collapsed.highlights-collapsed {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.pane.sidebar {
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
@@ -475,6 +604,133 @@ body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Ensure panes are stacked in the correct order on desktop */
|
||||
@media (min-width: 769px) {
|
||||
/* Desktop stacking to keep highlights above main without overlap */
|
||||
.three-pane .pane.sidebar { z-index: 1; }
|
||||
.three-pane .pane.main { z-index: 1; }
|
||||
.three-pane .pane.highlights { z-index: 2; }
|
||||
}
|
||||
|
||||
/* Mobile pane styles */
|
||||
@media (max-width: 768px) {
|
||||
/* Both sidepanes slide in as overlays */
|
||||
.pane.sidebar,
|
||||
.pane.highlights {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
width: 85%;
|
||||
max-width: 320px;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
background: #1a1a1a;
|
||||
z-index: 1001; /* Above backdrop */
|
||||
transition: transform 0.3s ease;
|
||||
box-shadow: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Ensure content fills the mobile sidepanes */
|
||||
.pane.sidebar > *,
|
||||
.pane.highlights > * {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Remove borders from containers in mobile overlays */
|
||||
.pane.sidebar .bookmarks-container,
|
||||
.pane.highlights .highlights-container {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Bookmarks sidebar from left */
|
||||
.pane.sidebar {
|
||||
left: 0;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.pane.sidebar.mobile-open {
|
||||
transform: translateX(0);
|
||||
box-shadow: 4px 0 12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Highlights sidebar from right */
|
||||
.pane.highlights {
|
||||
right: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
.pane.highlights.mobile-open {
|
||||
transform: translateX(0);
|
||||
box-shadow: -4px 0 12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.pane.main {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
padding: 0.5rem;
|
||||
max-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-sidebar-backdrop {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 999; /* Below sidepanes */
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.mobile-sidebar-backdrop.visible {
|
||||
display: block;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mobile-highlights-btn {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 900;
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
border-radius: 8px;
|
||||
color: #ddd;
|
||||
width: var(--min-touch-target);
|
||||
height: var(--min-touch-target);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mobile-highlights-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mobile-highlights-btn {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.reader {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
@@ -746,12 +1002,26 @@ body {
|
||||
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: #2a2a2a;
|
||||
background: transparent;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
border: 1px solid #333;
|
||||
border: 1px solid transparent;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
@@ -759,23 +1029,26 @@ body {
|
||||
}
|
||||
|
||||
.individual-bookmark:hover {
|
||||
border-color: #444;
|
||||
background: #2d2d2d;
|
||||
border-color: transparent;
|
||||
background: #2a2a2a;
|
||||
}
|
||||
|
||||
/* Compact view styles */
|
||||
.individual-bookmark.compact {
|
||||
padding: 0.3rem 0.25rem;
|
||||
padding: 0.5rem 0.5rem;
|
||||
background: transparent;
|
||||
border-bottom: 1px solid #333;
|
||||
border: none;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.individual-bookmark.compact:hover {
|
||||
background: #2a2a2a;
|
||||
background: #252525;
|
||||
border-bottom-color: #333;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -783,11 +1056,11 @@ body {
|
||||
.compact-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
gap: 0.5rem;
|
||||
height: 28px;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.compact-row.clickable {
|
||||
@@ -808,7 +1081,7 @@ body {
|
||||
}
|
||||
|
||||
.compact-text {
|
||||
flex: 1 1 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: #ccc;
|
||||
font-size: 0.85rem;
|
||||
@@ -816,7 +1089,6 @@ body {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.bookmark-date-compact {
|
||||
@@ -837,10 +1109,9 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
width: 24px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
@@ -1095,9 +1366,9 @@ body {
|
||||
|
||||
/* Hero image in reader view */
|
||||
.reader-hero-image {
|
||||
width: 100%;
|
||||
margin: 0 0 2rem 0;
|
||||
border-radius: 8px;
|
||||
width: calc(100% + 1.5rem);
|
||||
margin: -0.75rem -0.75rem 2rem -0.75rem;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
min-height: 300px;
|
||||
@@ -1208,12 +1479,22 @@ body {
|
||||
}
|
||||
|
||||
.individual-bookmark {
|
||||
background: #f5f5f5;
|
||||
border-color: #ddd;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.individual-bookmark:hover {
|
||||
border-color: #646cff;
|
||||
background: #f5f5f5;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.individual-bookmark.compact {
|
||||
border-bottom-color: #e5e5e5;
|
||||
}
|
||||
|
||||
.individual-bookmark.compact:hover {
|
||||
background: #fafafa;
|
||||
border-bottom-color: #ddd;
|
||||
}
|
||||
|
||||
.individual-bookmarks h4 {
|
||||
@@ -1279,7 +1560,6 @@ body {
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.highlights-container.collapsed {
|
||||
@@ -1570,7 +1850,7 @@ body {
|
||||
|
||||
.highlight-relay-indicator {
|
||||
position: absolute;
|
||||
bottom: -4px;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
font-size: 0.7rem;
|
||||
color: #888;
|
||||
@@ -1635,22 +1915,33 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.highlight-author {
|
||||
color: #aaa;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 150px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.highlight-meta-separator {
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.highlight-time {
|
||||
color: #888;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.highlight-source {
|
||||
@@ -1660,6 +1951,9 @@ body {
|
||||
color: #646cff;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.highlight-source:hover {
|
||||
@@ -2283,6 +2577,27 @@ body {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.toast {
|
||||
top: auto;
|
||||
bottom: calc(1rem + var(--safe-area-bottom));
|
||||
right: 1rem;
|
||||
left: 1rem;
|
||||
max-width: calc(100% - 2rem);
|
||||
}
|
||||
|
||||
@keyframes toast-slide-in {
|
||||
from {
|
||||
transform: translateY(100px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
border-color: #28a745;
|
||||
}
|
||||
@@ -2337,6 +2652,22 @@ body {
|
||||
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;
|
||||
@@ -2494,6 +2825,23 @@ body {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.relay-status-indicator.connecting {
|
||||
background: rgba(100, 108, 255, 0.15);
|
||||
border: 1px solid rgba(100, 108, 255, 0.25);
|
||||
}
|
||||
|
||||
.relay-status-indicator.connecting:hover {
|
||||
background: rgba(100, 108, 255, 0.25);
|
||||
}
|
||||
|
||||
.relay-status-indicator.connecting .relay-status-icon {
|
||||
color: rgba(100, 108, 255, 0.9);
|
||||
}
|
||||
|
||||
.relay-status-indicator.connecting .relay-status-title {
|
||||
color: rgba(100, 108, 255, 1);
|
||||
}
|
||||
|
||||
.relay-status-indicator:hover {
|
||||
background: rgba(245, 158, 11, 1);
|
||||
transform: translateY(-2px);
|
||||
@@ -2555,3 +2903,163 @@ body {
|
||||
.three-pane.sidebar-collapsed .relay-status-indicator {
|
||||
left: calc(var(--sidebar-collapsed-width) + 1.5rem);
|
||||
}
|
||||
|
||||
/* Explore Page Styles */
|
||||
.explore-container {
|
||||
padding: 2rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.explore-header {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.explore-header h1 {
|
||||
font-size: 2.5rem;
|
||||
margin: 0 0 1rem 0;
|
||||
color: #646cff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.explore-subtitle {
|
||||
font-size: 1.125rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.explore-loading,
|
||||
.explore-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
min-height: 50vh;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.explore-error {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.explore-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.blog-post-card {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.blog-post-card:hover {
|
||||
border-color: #646cff;
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(100, 108, 255, 0.15);
|
||||
}
|
||||
|
||||
.blog-post-card-image {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
overflow: hidden;
|
||||
background: #0f0f0f;
|
||||
}
|
||||
|
||||
.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-card-content {
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.blog-post-card-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.blog-post-card-summary {
|
||||
font-size: 0.875rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.blog-post-card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid #333;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.blog-post-card-author,
|
||||
.blog-post-card-date {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.blog-post-card-author svg,
|
||||
.blog-post-card-date svg {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.explore-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.explore-header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.explore-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
26
src/main.tsx
26
src/main.tsx
@@ -3,6 +3,32 @@ import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
// Register Service Worker for offline image caching
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js')
|
||||
.then(registration => {
|
||||
console.log('✅ Service Worker registered:', registration.scope)
|
||||
|
||||
// Update service worker when a new version is available
|
||||
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')
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('❌ Service Worker registration failed:', error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -145,6 +145,8 @@ export async function fetchArticleByNaddr(
|
||||
// Save to cache before returning
|
||||
saveToCache(naddr, content)
|
||||
|
||||
// Image caching is handled automatically by Service Worker
|
||||
|
||||
return content
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch article:', err)
|
||||
|
||||
87
src/services/exploreService.ts
Normal file
87
src/services/exploreService.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { RelayPool, completeOnEose } from 'applesauce-relay'
|
||||
import { lastValueFrom, takeUntil, timer, toArray } from 'rxjs'
|
||||
import { NostrEvent } from 'nostr-tools'
|
||||
import { Helpers } from 'applesauce-core'
|
||||
|
||||
const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers
|
||||
|
||||
export interface BlogPostPreview {
|
||||
event: NostrEvent
|
||||
title: string
|
||||
summary?: string
|
||||
image?: string
|
||||
published?: number
|
||||
author: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches blog posts (kind:30023) from a list of pubkeys (friends)
|
||||
* @param relayPool - The relay pool to query
|
||||
* @param pubkeys - Array of pubkeys to fetch posts from
|
||||
* @param relayUrls - Array of relay URLs to query
|
||||
* @returns Array of blog post previews
|
||||
*/
|
||||
export const fetchBlogPostsFromAuthors = async (
|
||||
relayPool: RelayPool,
|
||||
pubkeys: string[],
|
||||
relayUrls: string[]
|
||||
): Promise<BlogPostPreview[]> => {
|
||||
try {
|
||||
if (pubkeys.length === 0) {
|
||||
console.log('⚠️ No pubkeys to fetch blog posts from')
|
||||
return []
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const timeA = a.published || a.event.created_at
|
||||
const timeB = b.published || b.event.created_at
|
||||
return timeB - timeA // Most recent first
|
||||
})
|
||||
|
||||
console.log('📰 Processed', blogPosts.length, 'unique blog posts')
|
||||
|
||||
return blogPosts
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch blog posts:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ import { areAllRelaysLocal } from '../utils/helpers'
|
||||
import { markEventAsOfflineCreated } from './offlineSyncService'
|
||||
|
||||
// Boris pubkey for zap splits
|
||||
const BORIS_PUBKEY = '6e468422dfb74a5738702a8823b9b28168fc6cfb119d613e49ca0ec5a0bbd0c3'
|
||||
// npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x
|
||||
const BORIS_PUBKEY = '29dea8672f44ed164bfc83db3da5bd472001af70307f42277674cbc64d33013e'
|
||||
|
||||
const {
|
||||
getHighlightText,
|
||||
@@ -75,9 +76,19 @@ export async function createHighlight(
|
||||
// Update the alt tag to identify Boris as the creator
|
||||
const altTagIndex = highlightEvent.tags.findIndex(tag => tag[0] === 'alt')
|
||||
if (altTagIndex !== -1) {
|
||||
highlightEvent.tags[altTagIndex] = ['alt', 'Highlight created by Boris. readwithboris.com']
|
||||
highlightEvent.tags[altTagIndex] = ['alt', 'Highlight created by Boris. read.withboris.com']
|
||||
} else {
|
||||
highlightEvent.tags.push(['alt', 'Highlight created by Boris. readwithboris.com'])
|
||||
highlightEvent.tags.push(['alt', 'Highlight created by Boris. read.withboris.com'])
|
||||
}
|
||||
|
||||
// Add p tag (author tag) for nostr-native content
|
||||
// This tags the original author so they can see highlights of their work
|
||||
if (typeof source === 'object' && 'kind' in source) {
|
||||
// Only add p tag if it doesn't already exist
|
||||
const hasPTag = highlightEvent.tags.some(tag => tag[0] === 'p' && tag[1] === source.pubkey)
|
||||
if (!hasPTag) {
|
||||
highlightEvent.tags.push(['p', source.pubkey])
|
||||
}
|
||||
}
|
||||
|
||||
// Add zap tags for nostr-native content (NIP-57 Appendix G)
|
||||
|
||||
74
src/services/imageCacheService.ts
Normal file
74
src/services/imageCacheService.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Image Cache Service
|
||||
*
|
||||
* Utility functions for managing the Service Worker's image cache
|
||||
* Service Worker automatically caches images on fetch
|
||||
*/
|
||||
|
||||
const CACHE_NAME = 'boris-image-cache-v1'
|
||||
|
||||
/**
|
||||
* Clear all cached images
|
||||
*/
|
||||
export async function clearImageCache(): Promise<void> {
|
||||
try {
|
||||
await caches.delete(CACHE_NAME)
|
||||
console.log('🗑️ Cleared all cached images')
|
||||
} catch (err) {
|
||||
console.error('Failed to clear image cache:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics by inspecting Cache API directly
|
||||
*/
|
||||
export async function getImageCacheStatsAsync(): Promise<{
|
||||
totalSizeMB: number
|
||||
itemCount: number
|
||||
items: Array<{ url: string, sizeMB: number }>
|
||||
}> {
|
||||
try {
|
||||
const cache = await caches.open(CACHE_NAME)
|
||||
const requests = await cache.keys()
|
||||
|
||||
let totalSize = 0
|
||||
const items: Array<{ url: string, sizeMB: number }> = []
|
||||
|
||||
for (const request of requests) {
|
||||
const response = await cache.match(request)
|
||||
if (response) {
|
||||
const blob = await response.blob()
|
||||
const sizeMB = blob.size / (1024 * 1024)
|
||||
totalSize += blob.size
|
||||
items.push({ url: request.url, sizeMB })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalSizeMB: totalSize / (1024 * 1024),
|
||||
itemCount: requests.length,
|
||||
items
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to get cache stats:', err)
|
||||
return { totalSizeMB: 0, itemCount: 0, items: [] }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous wrapper for cache stats (returns approximate values)
|
||||
* For real-time stats, use getImageCacheStatsAsync
|
||||
*/
|
||||
export function getImageCacheStats(): {
|
||||
totalSizeMB: number
|
||||
itemCount: number
|
||||
items: Array<{ url: string, sizeMB: number, lastAccessed: Date }>
|
||||
} {
|
||||
// Return placeholder - actual stats require async Cache API access
|
||||
// Component should use getImageCacheStatsAsync for real values
|
||||
return {
|
||||
totalSizeMB: 0,
|
||||
itemCount: 0,
|
||||
items: []
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,11 @@ export interface UserSettings {
|
||||
// Relay rebroadcast settings
|
||||
useLocalRelayAsCache?: boolean // Rebroadcast events to local relays
|
||||
rebroadcastToAllRelays?: boolean // Rebroadcast events to all relays
|
||||
// Image cache settings
|
||||
enableImageCache?: boolean // Enable caching images in localStorage
|
||||
imageCacheSizeMB?: number // Maximum cache size in megabytes (default: 210MB)
|
||||
// Mobile settings
|
||||
autoCollapseSidebarOnMobile?: boolean // Auto-collapse sidebar on mobile (default: true)
|
||||
}
|
||||
|
||||
export async function loadSettings(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { formatDistanceToNow, differenceInSeconds, differenceInMinutes, differenceInHours, differenceInDays, differenceInMonths, differenceInYears } from 'date-fns'
|
||||
import { ParsedContent, ParsedNode } from '../types/bookmarks'
|
||||
import ResolvedMention from '../components/ResolvedMention'
|
||||
// Note: ContentWithResolvedProfiles is imported by components directly to keep this file component-only for fast refresh
|
||||
@@ -9,6 +9,26 @@ export const formatDate = (timestamp: number) => {
|
||||
return formatDistanceToNow(date, { addSuffix: true })
|
||||
}
|
||||
|
||||
// Ultra-compact date format for tight spaces (e.g., compact view)
|
||||
export const formatDateCompact = (timestamp: number) => {
|
||||
const date = new Date(timestamp * 1000)
|
||||
const now = new Date()
|
||||
|
||||
const seconds = differenceInSeconds(now, date)
|
||||
const minutes = differenceInMinutes(now, date)
|
||||
const hours = differenceInHours(now, date)
|
||||
const days = differenceInDays(now, date)
|
||||
const months = differenceInMonths(now, date)
|
||||
const years = differenceInYears(now, date)
|
||||
|
||||
if (seconds < 60) return 'now'
|
||||
if (minutes < 60) return `${minutes}m`
|
||||
if (hours < 24) return `${hours}h`
|
||||
if (days < 30) return `${days}d`
|
||||
if (months < 12) return `${months}mo`
|
||||
return `${years}y`
|
||||
}
|
||||
|
||||
// Component to render content with resolved nprofile names
|
||||
// Intentionally no exports except components and render helpers
|
||||
|
||||
|
||||
9
vercel.json
Normal file
9
vercel.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "/(.*)",
|
||||
"destination": "/index.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user