Compare commits

..

63 Commits

Author SHA1 Message Date
Gigi
d873718e88 fix: replace any type with proper bookmark interface for linter compliance 2025-10-10 18:03:48 +01:00
Gigi
706276839a fix: reduce mobile backdrop opacity and ensure sidepanes appear above it 2025-10-10 18:01:39 +01:00
Gigi
d281ca5f87 fix: force bookmarks pane expanded on mobile and ensure highlights pane sits above content on desktop 2025-10-10 17:54:32 +01:00
Gigi
6a9036bfef fix: add flex properties to mobile bookmark containers for proper filling 2025-10-10 17:25:40 +01:00
Gigi
1b242f75c6 fix: restore desktop grid layout for highlights panel 2025-10-10 17:24:26 +01:00
Gigi
7ffd37289d fix: improve empty state and loading visibility in mobile sidepanes 2025-10-10 17:23:12 +01:00
Gigi
cb859ae599 fix: restore flex layout to highlights pane for desktop view 2025-10-10 17:22:14 +01:00
Gigi
a17346c9c2 fix: ensure bookmarks container fills mobile sidepane properly 2025-10-10 17:21:06 +01:00
Gigi
c17a39588d refactor: DRY mobile sidepane styles - unified overlay behavior 2025-10-10 17:19:14 +01:00
Gigi
33cee9c0c2 feat: hide main content when sidepanes open on mobile for single-pane view 2025-10-10 17:11:26 +01:00
Gigi
e6d2920c27 feat: add mobile highlights panel as overlay with toggle button 2025-10-10 17:10:48 +01:00
Gigi
d8195dbe2a refactor: replace hamburger icon with bookmark icon on mobile 2025-10-10 17:08:36 +01:00
Gigi
4843f129c4 docs: update CHANGELOG with mobile implementation 2025-10-10 17:03:07 +01:00
Gigi
fcd1218dc4 docs: add comprehensive mobile implementation documentation 2025-10-10 17:02:46 +01:00
Gigi
eef0f971d7 fix: resolve TypeScript errors for mobile implementation 2025-10-10 17:01:57 +01:00
Gigi
ff09a8aba0 feat: add mobile auto-collapse setting 2025-10-10 17:00:52 +01:00
Gigi
0c4b523d05 feat: implement mobile overlay sidebar with focus trap and ESC handling 2025-10-10 17:00:03 +01:00
Gigi
de7a435a01 feat: add mobile-responsive CSS with breakpoints and safe areas 2025-10-10 16:57:56 +01:00
Gigi
124d399d1f feat: add mobile sidebar state management to useBookmarksUI 2025-10-10 16:56:19 +01:00
Gigi
e22cf71b15 feat: add media query hooks for responsive design 2025-10-10 16:55:53 +01:00
Gigi
670997ed36 feat: update viewport meta for mobile support 2025-10-10 16:55:39 +01:00
Gigi
1ccb6388e3 docs: update CHANGELOG for v0.3.8 2025-10-10 16:30:57 +01:00
Gigi
7d5be8d6aa chore: bump version to 0.3.8 2025-10-10 16:30:21 +01:00
Gigi
133e4756b2 fix: add vercel.json to handle SPA routing on Vercel
Without this configuration, page refreshes result in 404 errors because
Vercel tries to serve non-existent files instead of routing through
index.html for client-side routing.
2025-10-10 16:22:33 +01:00
Gigi
39ada734d5 docs: update CHANGELOG for v0.3.7 2025-10-10 13:25:18 +01:00
Gigi
19d88c5fba chore: bump version to 0.3.7 2025-10-10 13:24:31 +01:00
Gigi
461b0936e2 fix: use clearActive() method for logout instead of setActive(null)
Changed logout to use the proper clearActive() method from AccountManager instead of setActive(null), which was causing TypeScript type errors. This is the correct way to clear the active account according to the applesauce-accounts API.
2025-10-10 13:22:50 +01:00
Gigi
e9ee5e87be chore: add applesauce reference directory to gitignore
Added the applesauce directory to .gitignore to exclude the local reference copy of the applesauce monorepo from being committed to the project repository.
2025-10-10 13:21:25 +01:00
Gigi
5e66c5ef76 fix: correct logout functionality by using null instead of undefined
The logout button wasn't working because setActive was being called with 'undefined as never', which is an incorrect type hack. Changed to use null instead, which properly clears the active account. Also removed redundant localStorage.removeItem('active') call since the active$ subscription already handles localStorage cleanup.
2025-10-10 13:19:34 +01:00
Gigi
307dc3d726 docs: update CHANGELOG for v0.3.6 2025-10-10 13:16:05 +01:00
Gigi
e514a5f063 chore: bump version to 0.3.6 2025-10-10 13:14:41 +01:00
Gigi
880b7974f4 style: make connecting notification more subtle with muted blue background 2025-10-10 13:12:03 +01:00
Gigi
47048f435f Revert "fix(ui): prevent highlight panel UI breaks with long content or formatting"
This reverts commit a31f05d498.
2025-10-10 06:04:57 +01:00
Gigi
53ad492729 fix(ui): remove incorrect padding-right from highlights container 2025-10-09 21:31:17 +01:00
Gigi
eb4da419ae chore: update Boris pubkey for zap splits to npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x 2025-10-09 21:30:43 +01:00
Gigi
c66dfc9e2e feat(ui): use compact date format for highlights (now, 5m, 3h, 2d, 1mo, 1y) 2025-10-09 21:28:01 +01:00
Gigi
a31f05d498 fix(ui): prevent highlight panel UI breaks with long content or formatting 2025-10-09 21:27:08 +01:00
Gigi
6548e89c54 fix(ui): reduce font size of highlight metadata for cleaner look 2025-10-09 21:25:54 +01:00
Gigi
8a21b46ebd fix(ui): position highlight FAB button relative to article pane, not viewport 2025-10-09 21:23:21 +01:00
Gigi
bc5fe1ae30 fix(ui): adjust relay indicator position for better visual alignment 2025-10-09 21:22:02 +01:00
Gigi
b57ea3f640 fix(ui): ensure highlight metadata elements align on single visual line with consistent line-height 2025-10-09 21:18:14 +01:00
Gigi
3b55d64468 feat(ui): ultra-compact date format for bookmarks sidebar (now, 5m, 3h, 2d, 1mo, 1y) 2025-10-09 21:17:14 +01:00
Gigi
4caf1f0b22 fix(ui): prevent bookmark icons from being cut off in compact view 2025-10-09 21:16:20 +01:00
Gigi
1eb9911645 feat(highlights): encode event links as nevent/naddr per NIP-19 2025-10-09 21:15:03 +01:00
Gigi
38268c453c fix(ui): clean up nested borders in bookmark items for cleaner look 2025-10-09 21:13:47 +01:00
Gigi
9686b80b09 fix(ui): clean up nested borders in bookmarks sidebar view mode controls 2025-10-09 21:12:50 +01:00
Gigi
f32dec16fb fix(ui): align highlight metadata elements on single line in sidebar 2025-10-09 21:12:06 +01:00
Gigi
cb444b532f fix(explore): change header icon from compass to newspaper 2025-10-09 21:11:17 +01:00
Gigi
962062130a feat(routing): render /explore via Bookmarks to keep side panels 2025-10-09 21:10:51 +01:00
Gigi
e429931139 feat(layout): render Explore within ThreePaneLayout so side panels remain 2025-10-09 21:10:33 +01:00
Gigi
e56d28f82a chore: update highlight alt tag domain to read.withboris.com 2025-10-09 21:08:45 +01:00
Gigi
13a30d35c4 docs: update README app link to https://read.withboris.com/ 2025-10-09 21:08:31 +01:00
Gigi
e3174d8777 chore(seo): update robots.txt sitemap to https://read.withboris.com/ 2025-10-09 21:08:22 +01:00
Gigi
829a8d5dca chore(seo): update canonical and social URLs to https://read.withboris.com/ 2025-10-09 21:08:13 +01:00
Gigi
00978e2e64 chore: commit pending RelayStatusIndicator changes before URL update 2025-10-09 21:08:00 +01:00
Gigi
a5fcf36e83 docs: update CHANGELOG for v0.3.5 2025-10-09 20:28:50 +01:00
Gigi
a92a9ee3a3 chore: bump version to 0.3.5 2025-10-09 20:27:59 +01:00
Gigi
f39e34c699 fix: ensure connecting state shows for minimum 15s to prevent premature offline display 2025-10-09 20:27:20 +01:00
Gigi
b58f34d587 fix: add Cloudflare Pages routing config for SPA paths
Add _routes.json configuration to properly handle direct /r/ and /a/ paths
on Cloudflare Pages deployments. This ensures that client-side routes are
served correctly instead of returning 404 errors.
2025-10-09 20:22:08 +01:00
Gigi
76d1d4544e feat: extend connecting state to 8 seconds and remove subtitle text
- Increase 'Connecting' timeout from 4 to 8 seconds
- Remove explanatory subtitle 'Establishing connections...'
- Cleaner, simpler connecting state display
2025-10-09 20:17:29 +01:00
Gigi
5e56176e2d docs: update CHANGELOG for v0.3.3 and v0.3.4 2025-10-09 18:39:30 +01:00
Gigi
a2a4e7e454 chore: bump version to 0.3.4 2025-10-09 18:38:32 +01:00
Gigi
b266288b0f fix: add p tag (author tag) to highlights of nostr-native content
- Highlights now include p tag referencing original article author
- Allows authors to discover highlights of their work
- Follows NIP-84 best practices for highlight attribution
2025-10-09 18:36:20 +01:00
26 changed files with 1073 additions and 80 deletions

4
.gitignore vendored
View File

@@ -7,3 +7,7 @@ dist
# Misc # Misc
*.log *.log
.DS_Store .DS_Store
# Applesauce Reference
applesauce

View File

@@ -5,6 +5,98 @@ 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/), 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). 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 ## [0.3.0] - 2025-10-09
### Added ### Added
@@ -472,6 +564,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Optimize relay usage following applesauce-relay best practices - Optimize relay usage following applesauce-relay best practices
- Use applesauce-react event models for better profile handling - Use applesauce-react event models for better profile handling
[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.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.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.9]: https://github.com/dergigi/boris/compare/v0.2.8...v0.2.9

156
MOBILE_IMPLEMENTATION.md Normal file
View 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`

View File

@@ -6,7 +6,7 @@ Boris turns your Nostr bookmarks into a calm, fast, and focused reading experien
## Live ## Live
- App: [https://xn--bris-v0b.com/](https://xn--bris-v0b.com/) - App: [https://read.withboris.com/](https://read.withboris.com/)
## The Vision ## The Vision

View File

@@ -3,21 +3,21 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <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> <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." /> <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 --> <!-- Open Graph / Social Media -->
<meta property="og:type" content="website" /> <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: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: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" /> <meta property="og:site_name" content="Boris" />
<!-- Twitter Card --> <!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" /> <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: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." /> <meta name="twitter:description" content="Your reading list for the Nostr world. A minimal nostr client for bookmark management with highlights." />
</head> </head>

View File

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

6
public/_routes.json Normal file
View File

@@ -0,0 +1,6 @@
{
"version": 1,
"include": ["/*"],
"exclude": ["/assets/*", "/robots.txt", "/sw.js", "/_headers", "/_redirects"]
}

View File

@@ -1,5 +1,5 @@
User-agent: * User-agent: *
Allow: / Allow: /
Sitemap: https://xn--bris-v0b.com/sitemap.xml Sitemap: https://read.withboris.com/sitemap.xml

View File

@@ -9,7 +9,6 @@ import { registerCommonAccountTypes } from 'applesauce-accounts/accounts'
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { createAddressLoader } from 'applesauce-loaders/loaders' import { createAddressLoader } from 'applesauce-loaders/loaders'
import Bookmarks from './components/Bookmarks' import Bookmarks from './components/Bookmarks'
import Explore from './components/Explore'
import Toast from './components/Toast' import Toast from './components/Toast'
import { useToast } from './hooks/useToast' import { useToast } from './hooks/useToast'
import { RELAYS } from './config/relays' import { RELAYS } from './config/relays'
@@ -28,8 +27,7 @@ function AppRoutes({
const accountManager = Hooks.useAccountManager() const accountManager = Hooks.useAccountManager()
const handleLogout = () => { const handleLogout = () => {
accountManager.setActive(undefined as never) accountManager.clearActive()
localStorage.removeItem('active')
showToast('Logged out successfully') showToast('Logged out successfully')
} }
@@ -65,7 +63,10 @@ function AppRoutes({
<Route <Route
path="/explore" path="/explore"
element={ element={
<Explore relayPool={relayPool} /> <Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
/>
} }
/> />
<Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} /> <Route path="/" element={<Navigate to={`/a/${DEFAULT_ARTICLE}`} replace />} />

View File

@@ -27,6 +27,7 @@ interface BookmarkListProps {
loading?: boolean loading?: boolean
relayPool: RelayPool | null relayPool: RelayPool | null
settings?: UserSettings settings?: UserSettings
isMobile?: boolean
} }
export const BookmarkList: React.FC<BookmarkListProps> = ({ export const BookmarkList: React.FC<BookmarkListProps> = ({
@@ -44,7 +45,8 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
lastFetchTime, lastFetchTime,
loading = false, loading = false,
relayPool, relayPool,
settings settings,
isMobile = false
}) => { }) => {
// Helper to check if a bookmark has either content or a URL // Helper to check if a bookmark has either content or a URL
const hasContentOrUrl = (ib: IndividualBookmark) => { const hasContentOrUrl = (ib: IndividualBookmark) => {
@@ -106,6 +108,7 @@ export const BookmarkList: React.FC<BookmarkListProps> = ({
onLogout={onLogout} onLogout={onLogout}
onOpenSettings={onOpenSettings} onOpenSettings={onOpenSettings}
relayPool={relayPool} relayPool={relayPool}
isMobile={isMobile}
/> />
{loading ? ( {loading ? (

View File

@@ -2,7 +2,7 @@ import React from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faBookmark, faUserLock, faGlobe } from '@fortawesome/free-solid-svg-icons' import { faBookmark, faUserLock, faGlobe } from '@fortawesome/free-solid-svg-icons'
import { IndividualBookmark } from '../../types/bookmarks' import { IndividualBookmark } from '../../types/bookmarks'
import { formatDate } from '../../utils/bookmarkUtils' import { formatDateCompact } from '../../utils/bookmarkUtils'
import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles' import ContentWithResolvedProfiles from '../ContentWithResolvedProfiles'
import { IconGetter } from './shared' import { IconGetter } from './shared'
@@ -75,7 +75,7 @@ export const CompactView: React.FC<CompactViewProps> = ({
<ContentWithResolvedProfiles content={displayText.slice(0, 60) + (displayText.length > 60 ? '…' : '')} /> <ContentWithResolvedProfiles content={displayText.slice(0, 60) + (displayText.length > 60 ? '…' : '')} />
</div> </div>
)} )}
<span className="bookmark-date-compact">{formatDate(bookmark.created_at)}</span> <span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>
{isClickable && ( {isClickable && (
<button <button
className="compact-read-btn" className="compact-read-btn"

View File

@@ -13,6 +13,7 @@ import { useBookmarksUI } from '../hooks/useBookmarksUI'
import { useRelayStatus } from '../hooks/useRelayStatus' import { useRelayStatus } from '../hooks/useRelayStatus'
import { useOfflineSync } from '../hooks/useOfflineSync' import { useOfflineSync } from '../hooks/useOfflineSync'
import ThreePaneLayout from './ThreePaneLayout' import ThreePaneLayout from './ThreePaneLayout'
import Explore from './Explore'
import { classifyHighlights } from '../utils/highlightClassification' import { classifyHighlights } from '../utils/highlightClassification'
export type ViewMode = 'compact' | 'cards' | 'large' export type ViewMode = 'compact' | 'cards' | 'large'
@@ -33,6 +34,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
: undefined : undefined
const showSettings = location.pathname === '/settings' const showSettings = location.pathname === '/settings'
const showExplore = location.pathname === '/explore'
// Track previous location for going back from settings // Track previous location for going back from settings
useEffect(() => { useEffect(() => {
@@ -65,6 +67,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
}) })
const { const {
isMobile,
isSidebarOpen,
toggleSidebar,
isCollapsed, isCollapsed,
setIsCollapsed, setIsCollapsed,
isHighlightsCollapsed, isHighlightsCollapsed,
@@ -114,7 +119,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
setReaderLoading, setReaderLoading,
readerContent, readerContent,
setReaderContent, setReaderContent,
handleSelectUrl handleSelectUrl: baseHandleSelectUrl
} = useContentSelection({ } = useContentSelection({
relayPool, relayPool,
settings, settings,
@@ -123,6 +128,14 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
setCurrentArticle 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 { const {
highlightButtonRef, highlightButtonRef,
handleTextSelection, handleTextSelection,
@@ -178,18 +191,24 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
<ThreePaneLayout <ThreePaneLayout
isCollapsed={isCollapsed} isCollapsed={isCollapsed}
isHighlightsCollapsed={isHighlightsCollapsed} isHighlightsCollapsed={isHighlightsCollapsed}
isSidebarOpen={isSidebarOpen}
showSettings={showSettings} showSettings={showSettings}
showExplore={showExplore}
bookmarks={bookmarks} bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading} bookmarksLoading={bookmarksLoading}
viewMode={viewMode} viewMode={viewMode}
isRefreshing={isRefreshing} isRefreshing={isRefreshing}
lastFetchTime={lastFetchTime} lastFetchTime={lastFetchTime}
onToggleSidebar={() => setIsCollapsed(!isCollapsed)} onToggleSidebar={isMobile ? toggleSidebar : () => setIsCollapsed(!isCollapsed)}
onLogout={onLogout} onLogout={onLogout}
onViewModeChange={setViewMode} onViewModeChange={setViewMode}
onOpenSettings={() => { onOpenSettings={() => {
navigate('/settings') navigate('/settings')
setIsCollapsed(true) if (isMobile) {
toggleSidebar()
} else {
setIsCollapsed(true)
}
setIsHighlightsCollapsed(true) setIsHighlightsCollapsed(true)
}} }}
onRefresh={handleRefreshAll} onRefresh={handleRefreshAll}
@@ -227,6 +246,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
highlightButtonRef={highlightButtonRef} highlightButtonRef={highlightButtonRef}
onCreateHighlight={handleCreateHighlight} onCreateHighlight={handleCreateHighlight}
hasActiveAccount={!!(activeAccount && relayPool)} hasActiveAccount={!!(activeAccount && relayPool)}
explore={showExplore ? (
relayPool ? <Explore relayPool={relayPool} /> : null
) : undefined}
toastMessage={toastMessage ?? undefined} toastMessage={toastMessage ?? undefined}
toastType={toastType} toastType={toastType}
onClearToast={clearToast} onClearToast={clearToast}

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faSpinner, faExclamationCircle, faCompass } from '@fortawesome/free-solid-svg-icons' import { faSpinner, faExclamationCircle, faNewspaper } from '@fortawesome/free-solid-svg-icons'
import { Hooks } from 'applesauce-react' import { Hooks } from 'applesauce-react'
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { nip19 } from 'nostr-tools' import { nip19 } from 'nostr-tools'
@@ -105,7 +105,7 @@ const Explore: React.FC<ExploreProps> = ({ relayPool }) => {
<div className="explore-container"> <div className="explore-container">
<div className="explore-header"> <div className="explore-header">
<h1> <h1>
<FontAwesomeIcon icon={faCompass} /> <FontAwesomeIcon icon={faNewspaper} />
Explore Explore
</h1> </h1>
<p className="explore-subtitle"> <p className="explore-subtitle">

View File

@@ -2,13 +2,14 @@ import React, { useEffect, useRef, useState } from 'react'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faServer } from '@fortawesome/free-solid-svg-icons' import { faQuoteLeft, faExternalLinkAlt, faPlane, faSpinner, faServer } from '@fortawesome/free-solid-svg-icons'
import { Highlight } from '../types/highlights' import { Highlight } from '../types/highlights'
import { formatDistanceToNow } from 'date-fns'
import { useEventModel } from 'applesauce-react/hooks' import { useEventModel } from 'applesauce-react/hooks'
import { Models, IEventStore } from 'applesauce-core' import { Models, IEventStore } from 'applesauce-core'
import { RelayPool } from 'applesauce-relay' import { RelayPool } from 'applesauce-relay'
import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService' import { onSyncStateChange, isEventSyncing } from '../services/offlineSyncService'
import { RELAYS } from '../config/relays' import { RELAYS } from '../config/relays'
import { areAllRelaysLocal } from '../utils/helpers' import { areAllRelaysLocal } from '../utils/helpers'
import { nip19 } from 'nostr-tools'
import { formatDateCompact } from '../utils/bookmarkUtils'
interface HighlightWithLevel extends Highlight { interface HighlightWithLevel extends Highlight {
level?: 'mine' | 'friends' | 'nostrverse' level?: 'mine' | 'friends' | 'nostrverse'
@@ -102,7 +103,41 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
const getSourceLink = () => { const getSourceLink = () => {
if (highlight.eventReference) { 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 return highlight.urlReference
} }
@@ -248,7 +283,7 @@ export const HighlightItem: React.FC<HighlightItemProps> = ({
</span> </span>
<span className="highlight-meta-separator"></span> <span className="highlight-meta-separator"></span>
<span className="highlight-time"> <span className="highlight-time">
{formatDistanceToNow(new Date(highlight.created_at * 1000), { addSuffix: true })} {formatDateCompact(highlight.created_at)}
</span> </span>
{sourceLink && ( {sourceLink && (

View File

@@ -11,6 +11,7 @@ interface IconButtonProps {
size?: number size?: number
disabled?: boolean disabled?: boolean
spin?: boolean spin?: boolean
className?: string
} }
const IconButton: React.FC<IconButtonProps> = ({ const IconButton: React.FC<IconButtonProps> = ({
@@ -21,11 +22,12 @@ const IconButton: React.FC<IconButtonProps> = ({
variant = 'ghost', variant = 'ghost',
size = 33, size = 33,
disabled = false, disabled = false,
spin = false spin = false,
className = ''
}) => { }) => {
return ( return (
<button <button
className={`icon-button ${variant}`} className={`icon-button ${variant} ${className}`.trim()}
onClick={onClick} onClick={onClick}
title={title} title={title}
aria-label={ariaLabel || title} aria-label={ariaLabel || title}

View File

@@ -32,11 +32,11 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ rela
// Connected! Stop showing connecting state // Connected! Stop showing connecting state
setIsConnecting(false) setIsConnecting(false)
} else { } else {
// No connections yet - show connecting for 4 seconds // No connections yet - show connecting for 8 seconds
setIsConnecting(true) setIsConnecting(true)
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
setIsConnecting(false) setIsConnecting(false)
}, 4000) }, 8000)
return () => clearTimeout(timeout) return () => clearTimeout(timeout)
} }
}, [connectedUrls.length]) }, [connectedUrls.length])
@@ -58,7 +58,7 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ rela
if (!localOnlyMode && !offlineMode && !isConnecting) return null if (!localOnlyMode && !offlineMode && !isConnecting) return null
return ( return (
<div className="relay-status-indicator" title={ <div className={`relay-status-indicator ${isConnecting ? 'connecting' : ''}`} title={
isConnecting isConnecting
? 'Connecting to relays...' ? 'Connecting to relays...'
: offlineMode : offlineMode
@@ -70,10 +70,7 @@ export const RelayStatusIndicator: React.FC<RelayStatusIndicatorProps> = ({ rela
</div> </div>
<div className="relay-status-text"> <div className="relay-status-text">
{isConnecting ? ( {isConnecting ? (
<> <span className="relay-status-title">Connecting</span>
<span className="relay-status-title">Connecting</span>
<span className="relay-status-subtitle">Establishing connections...</span>
</>
) : offlineMode ? ( ) : offlineMode ? (
<> <>
<span className="relay-status-title">Offline</span> <span className="relay-status-title">Offline</span>

View File

@@ -49,6 +49,19 @@ const StartupPreferencesSettings: React.FC<StartupPreferencesSettingsProps> = ({
<span>Rebroadcast events while browsing</span> <span>Rebroadcast events while browsing</span>
</label> </label>
</div> </div>
<div className="setting-group">
<label htmlFor="autoCollapseSidebarOnMobile" className="checkbox-label">
<input
id="autoCollapseSidebarOnMobile"
type="checkbox"
checked={settings.autoCollapseSidebarOnMobile !== false}
onChange={(e) => onUpdate({ autoCollapseSidebarOnMobile: e.target.checked })}
className="setting-checkbox"
/>
<span>Auto-collapse sidebar on small screens</span>
</label>
</div>
</div> </div>
) )
} }

View File

@@ -1,7 +1,7 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faChevronRight, faRightFromBracket, faRightToBracket, faUserCircle, faGear, faHome, faPlus, faNewspaper } 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 { Hooks } from 'applesauce-react'
import { useEventModel } from 'applesauce-react/hooks' import { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core' import { Models } from 'applesauce-core'
@@ -17,9 +17,10 @@ interface SidebarHeaderProps {
onLogout: () => void onLogout: () => void
onOpenSettings: () => void onOpenSettings: () => void
relayPool: RelayPool | null relayPool: RelayPool | null
isMobile?: boolean
} }
const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, relayPool }) => { const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogout, onOpenSettings, relayPool, isMobile = false }) => {
const [isConnecting, setIsConnecting] = useState(false) const [isConnecting, setIsConnecting] = useState(false)
const [showAddModal, setShowAddModal] = useState(false) const [showAddModal, setShowAddModal] = useState(false)
const navigate = useNavigate() const navigate = useNavigate()
@@ -66,14 +67,25 @@ const SidebarHeader: React.FC<SidebarHeaderProps> = ({ onToggleCollapse, onLogou
return ( return (
<> <>
<div className="sidebar-header-bar"> <div className="sidebar-header-bar">
<button {isMobile ? (
onClick={onToggleCollapse} <IconButton
className="toggle-sidebar-btn" icon={faTimes}
title="Collapse bookmarks sidebar" onClick={onToggleCollapse}
aria-label="Collapse bookmarks sidebar" title="Close sidebar"
> ariaLabel="Close sidebar"
<FontAwesomeIcon icon={faChevronRight} /> variant="ghost"
</button> 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="sidebar-header-right">
<div <div
className="profile-avatar" className="profile-avatar"

View File

@@ -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 { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core' import { IEventStore } from 'applesauce-core'
import { BookmarkList } from './BookmarkList' import { BookmarkList } from './BookmarkList'
@@ -16,12 +18,15 @@ import { UserSettings } from '../services/settingsService'
import { HighlightVisibility } from './HighlightsPanel' import { HighlightVisibility } from './HighlightsPanel'
import { HighlightButtonRef } from './HighlightButton' import { HighlightButtonRef } from './HighlightButton'
import { BookmarkReference } from '../utils/contentLoader' import { BookmarkReference } from '../utils/contentLoader'
import { useIsMobile } from '../hooks/useMediaQuery'
interface ThreePaneLayoutProps { interface ThreePaneLayoutProps {
// Layout state // Layout state
isCollapsed: boolean isCollapsed: boolean
isHighlightsCollapsed: boolean isHighlightsCollapsed: boolean
isSidebarOpen: boolean
showSettings: boolean showSettings: boolean
showExplore?: boolean
// Bookmarks pane // Bookmarks pane
bookmarks: Bookmark[] bookmarks: Bookmark[]
@@ -72,17 +77,173 @@ interface ThreePaneLayoutProps {
toastMessage?: string toastMessage?: string
toastType?: 'success' | 'error' toastType?: 'success' | 'error'
onClearToast: () => void onClearToast: () => void
// Optional Explore content
explore?: React.ReactNode
} }
const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => { 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 ( 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={`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 <BookmarkList
bookmarks={props.bookmarks} bookmarks={props.bookmarks}
onSelectUrl={props.onSelectUrl} onSelectUrl={props.onSelectUrl}
isCollapsed={props.isCollapsed} isCollapsed={isMobile ? false : props.isCollapsed}
onToggleCollapse={props.onToggleSidebar} onToggleCollapse={props.onToggleSidebar}
onLogout={props.onLogout} onLogout={props.onLogout}
viewMode={props.viewMode} viewMode={props.viewMode}
@@ -95,9 +256,10 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
loading={props.bookmarksLoading} loading={props.bookmarksLoading}
relayPool={props.relayPool} relayPool={props.relayPool}
settings={props.settings} settings={props.settings}
isMobile={isMobile}
/> />
</div> </div>
<div className="pane main"> <div className={`pane main ${isMobile && (props.isSidebarOpen || !props.isHighlightsCollapsed) ? 'mobile-hidden' : ''}`}>
{props.showSettings ? ( {props.showSettings ? (
<Settings <Settings
settings={props.settings} settings={props.settings}
@@ -105,6 +267,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
onClose={props.onCloseSettings} onClose={props.onCloseSettings}
relayPool={props.relayPool} relayPool={props.relayPool}
/> />
) : props.showExplore && props.explore ? (
// Render Explore inside the main pane to keep side panels
<>
{props.explore}
</>
) : ( ) : (
<ContentPanel <ContentPanel
loading={props.readerLoading} loading={props.readerLoading}
@@ -130,7 +297,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
/> />
)} )}
</div> </div>
<div className="pane highlights"> <div
ref={highlightsRef}
className={`pane highlights ${isMobile && !props.isHighlightsCollapsed ? 'mobile-open' : ''}`}
aria-hidden={isMobile && props.isHighlightsCollapsed}
>
<HighlightsPanel <HighlightsPanel
highlights={props.highlights} highlights={props.highlights}
loading={props.highlightsLoading} loading={props.highlightsLoading}

View File

@@ -3,12 +3,15 @@ import { NostrEvent } from 'nostr-tools'
import { HighlightVisibility } from '../components/HighlightsPanel' import { HighlightVisibility } from '../components/HighlightsPanel'
import { UserSettings } from '../services/settingsService' import { UserSettings } from '../services/settingsService'
import { ViewMode } from '../components/Bookmarks' import { ViewMode } from '../components/Bookmarks'
import { useIsMobile } from './useMediaQuery'
interface UseBookmarksUIParams { interface UseBookmarksUIParams {
settings: UserSettings settings: UserSettings
} }
export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => { export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => {
const isMobile = useIsMobile()
const [isSidebarOpen, setIsSidebarOpen] = useState(false)
const [isCollapsed, setIsCollapsed] = useState(true) const [isCollapsed, setIsCollapsed] = useState(true)
const [isHighlightsCollapsed, setIsHighlightsCollapsed] = useState(true) const [isHighlightsCollapsed, setIsHighlightsCollapsed] = useState(true)
const [viewMode, setViewMode] = useState<ViewMode>('compact') const [viewMode, setViewMode] = useState<ViewMode>('compact')
@@ -23,6 +26,16 @@ export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => {
mine: true 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 // Apply UI settings
useEffect(() => { useEffect(() => {
if (settings.defaultViewMode) setViewMode(settings.defaultViewMode) if (settings.defaultViewMode) setViewMode(settings.defaultViewMode)
@@ -34,7 +47,15 @@ export const useBookmarksUI = ({ settings }: UseBookmarksUIParams) => {
}) })
}, [settings]) }, [settings])
const toggleSidebar = () => {
setIsSidebarOpen(prev => !prev)
}
return { return {
isMobile,
isSidebarOpen,
setIsSidebarOpen,
toggleSidebar,
isCollapsed, isCollapsed,
setIsCollapsed, setIsCollapsed,
isHighlightsCollapsed, isHighlightsCollapsed,

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

View File

@@ -22,12 +22,40 @@
--highlights-collapsed-width: 56px; --highlights-collapsed-width: 56px;
--main-max-width: 900px; --main-max-width: 900px;
--main-horizontal-padding: 1rem; --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 { body {
margin: 0; margin: 0;
min-width: 320px; min-width: 320px;
min-height: 100vh; 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 { #root {
@@ -36,6 +64,12 @@ body {
padding: 1rem; padding: 1rem;
} }
@media (max-width: 768px) {
#root {
padding: 0;
}
}
.app { .app {
text-align: center; text-align: center;
position: relative; position: relative;
@@ -71,15 +105,16 @@ body {
.bookmarks-container .view-mode-controls { .bookmarks-container .view-mode-controls {
margin-top: auto; margin-top: auto;
padding: 0.75rem 1rem; padding: 1rem;
border-top: 1px solid #333; border-top: 1px solid #333;
background: #1a1a1a; background: transparent;
border-radius: 0 0 12px 12px; border-radius: 0;
} }
.bookmarks-container .bookmarks-list { .bookmarks-container .bookmarks-list {
padding: 0.25rem; padding: 0.5rem;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
flex: 1; flex: 1;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
@@ -105,16 +140,52 @@ body {
margin-left: auto; 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 { .view-mode-controls {
display: flex; display: flex;
align-items: center; 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; justify-content: center;
gap: 0.5rem;
} }
.profile-avatar { .profile-avatar {
@@ -305,6 +376,29 @@ body {
.icon-button.ghost { background: #2a2a2a; } .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 { .bookmark-events {
margin: 1rem 0; margin: 1rem 0;
} }
@@ -398,12 +492,24 @@ body {
text-align: center; text-align: center;
padding: 3rem; padding: 3rem;
color: #888; color: #888;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
} }
.empty-state p { .empty-state p {
margin: 0.5rem 0; margin: 0.5rem 0;
} }
.loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.bookmarks-list { .bookmarks-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -432,6 +538,13 @@ body {
column-gap: 0; column-gap: 0;
height: calc(100vh - 2rem); height: calc(100vh - 2rem);
transition: grid-template-columns 0.3s ease; transition: grid-template-columns 0.3s ease;
position: relative;
}
@supports (height: 100dvh) {
.three-pane {
height: calc(100dvh - 2rem);
}
} }
.three-pane.sidebar-collapsed { .three-pane.sidebar-collapsed {
@@ -446,6 +559,22 @@ body {
grid-template-columns: var(--sidebar-collapsed-width) 1fr var(--highlights-collapsed-width); 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 { .pane.sidebar {
overflow-y: auto; overflow-y: auto;
height: 100%; height: 100%;
@@ -475,6 +604,133 @@ body {
height: 100%; 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 { .reader {
background: #1a1a1a; background: #1a1a1a;
border: 1px solid #333; border: 1px solid #333;
@@ -746,12 +1002,26 @@ body {
gap: 1.5rem; 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 { .individual-bookmark {
background: #2a2a2a; background: transparent;
padding: 1rem; padding: 1rem;
border-radius: 8px; border-radius: 8px;
transition: all 0.2s ease; transition: all 0.2s ease;
border: 1px solid #333; border: 1px solid transparent;
word-wrap: break-word; word-wrap: break-word;
overflow-wrap: break-word; overflow-wrap: break-word;
word-break: break-word; word-break: break-word;
@@ -759,23 +1029,26 @@ body {
} }
.individual-bookmark:hover { .individual-bookmark:hover {
border-color: #444; border-color: transparent;
background: #2d2d2d; background: #2a2a2a;
} }
/* Compact view styles */ /* Compact view styles */
.individual-bookmark.compact { .individual-bookmark.compact {
padding: 0.3rem 0.25rem; padding: 0.5rem 0.5rem;
background: transparent; background: transparent;
border-bottom: 1px solid #333; border: none;
border-bottom: 1px solid #2a2a2a;
border-radius: 0; border-radius: 0;
box-shadow: none; box-shadow: none;
width: 100%; width: 100%;
max-width: 100%; max-width: 100%;
overflow: hidden;
} }
.individual-bookmark.compact:hover { .individual-bookmark.compact:hover {
background: #2a2a2a; background: #252525;
border-bottom-color: #333;
transform: none; transform: none;
box-shadow: none; box-shadow: none;
} }
@@ -783,11 +1056,11 @@ body {
.compact-row { .compact-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.5rem;
height: 28px; height: 28px;
justify-content: space-between;
width: 100%; width: 100%;
min-width: 0; min-width: 0;
overflow: hidden;
} }
.compact-row.clickable { .compact-row.clickable {
@@ -808,7 +1081,7 @@ body {
} }
.compact-text { .compact-text {
flex: 1 1 0; flex: 1;
min-width: 0; min-width: 0;
color: #ccc; color: #ccc;
font-size: 0.85rem; font-size: 0.85rem;
@@ -816,7 +1089,6 @@ body {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
max-width: 100%;
} }
.bookmark-date-compact { .bookmark-date-compact {
@@ -837,10 +1109,9 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 26px; width: 24px;
height: 22px; height: 22px;
flex-shrink: 0; flex-shrink: 0;
margin-left: auto;
transition: color 0.2s ease; transition: color 0.2s ease;
} }
@@ -1208,12 +1479,22 @@ body {
} }
.individual-bookmark { .individual-bookmark {
background: #f5f5f5; background: transparent;
border-color: #ddd; border-color: transparent;
} }
.individual-bookmark:hover { .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 { .individual-bookmarks h4 {
@@ -1279,7 +1560,6 @@ body {
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
padding-right: 1rem;
} }
.highlights-container.collapsed { .highlights-container.collapsed {
@@ -1570,7 +1850,7 @@ body {
.highlight-relay-indicator { .highlight-relay-indicator {
position: absolute; position: absolute;
bottom: -4px; bottom: -2px;
left: 0; left: 0;
font-size: 0.7rem; font-size: 0.7rem;
color: #888; color: #888;
@@ -1635,22 +1915,33 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
font-size: 0.875rem; font-size: 0.8rem;
color: #888; color: #888;
flex-wrap: wrap; flex-wrap: nowrap;
min-height: 20px;
} }
.highlight-author { .highlight-author {
color: #aaa; color: #aaa;
font-weight: 500; font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 150px;
line-height: 1;
} }
.highlight-meta-separator { .highlight-meta-separator {
color: #666; color: #666;
flex-shrink: 0;
line-height: 1;
} }
.highlight-time { .highlight-time {
color: #888; color: #888;
white-space: nowrap;
flex-shrink: 0;
line-height: 1;
} }
.highlight-source { .highlight-source {
@@ -1660,6 +1951,9 @@ body {
color: #646cff; color: #646cff;
text-decoration: none; text-decoration: none;
transition: color 0.2s ease; transition: color 0.2s ease;
flex-shrink: 0;
margin-left: auto;
line-height: 1;
} }
.highlight-source:hover { .highlight-source:hover {
@@ -2283,6 +2577,27 @@ body {
font-size: 0.95rem; 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 { .toast-success {
border-color: #28a745; border-color: #28a745;
} }
@@ -2337,6 +2652,22 @@ body {
box-sizing: border-box; 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 { .modal-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -2494,6 +2825,23 @@ body {
cursor: default; 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 { .relay-status-indicator:hover {
background: rgba(245, 158, 11, 1); background: rgba(245, 158, 11, 1);
transform: translateY(-2px); transform: translateY(-2px);

View File

@@ -11,7 +11,8 @@ import { areAllRelaysLocal } from '../utils/helpers'
import { markEventAsOfflineCreated } from './offlineSyncService' import { markEventAsOfflineCreated } from './offlineSyncService'
// Boris pubkey for zap splits // Boris pubkey for zap splits
const BORIS_PUBKEY = '6e468422dfb74a5738702a8823b9b28168fc6cfb119d613e49ca0ec5a0bbd0c3' // npub19802see0gnk3vjlus0dnmfdagusqrtmsxpl5yfmkwn9uvnfnqylqduhr0x
const BORIS_PUBKEY = '29dea8672f44ed164bfc83db3da5bd472001af70307f42277674cbc64d33013e'
const { const {
getHighlightText, getHighlightText,
@@ -75,9 +76,19 @@ export async function createHighlight(
// Update the alt tag to identify Boris as the creator // Update the alt tag to identify Boris as the creator
const altTagIndex = highlightEvent.tags.findIndex(tag => tag[0] === 'alt') const altTagIndex = highlightEvent.tags.findIndex(tag => tag[0] === 'alt')
if (altTagIndex !== -1) { 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 { } 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) // Add zap tags for nostr-native content (NIP-57 Appendix G)

View File

@@ -45,6 +45,8 @@ export interface UserSettings {
// Image cache settings // Image cache settings
enableImageCache?: boolean // Enable caching images in localStorage enableImageCache?: boolean // Enable caching images in localStorage
imageCacheSizeMB?: number // Maximum cache size in megabytes (default: 210MB) imageCacheSizeMB?: number // Maximum cache size in megabytes (default: 210MB)
// Mobile settings
autoCollapseSidebarOnMobile?: boolean // Auto-collapse sidebar on mobile (default: true)
} }
export async function loadSettings( export async function loadSettings(

View File

@@ -1,5 +1,5 @@
import React from 'react' 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 { ParsedContent, ParsedNode } from '../types/bookmarks'
import ResolvedMention from '../components/ResolvedMention' import ResolvedMention from '../components/ResolvedMention'
// Note: ContentWithResolvedProfiles is imported by components directly to keep this file component-only for fast refresh // 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 }) 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 // Component to render content with resolved nprofile names
// Intentionally no exports except components and render helpers // Intentionally no exports except components and render helpers

9
vercel.json Normal file
View File

@@ -0,0 +1,9 @@
{
"rewrites": [
{
"source": "/(.*)",
"destination": "/index.html"
}
]
}