Compare commits

..

32 Commits

Author SHA1 Message Date
Gigi
4d5fe1f425 chore: bump version to 0.4.0 2025-10-10 18:07:06 +01:00
Gigi
c7a4de9786 Merge pull request #1 from dergigi/mobile
Add mobile responsive design
2025-10-10 18:04:54 +01:00
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
17 changed files with 853 additions and 25 deletions

4
.gitignore vendored
View File

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

View File

@@ -5,6 +5,61 @@ 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
@@ -509,6 +564,7 @@ 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

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

@@ -3,7 +3,7 @@
<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://read.withboris.com/" />

View File

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

View File

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

View File

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

View File

@@ -67,6 +67,9 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
})
const {
isMobile,
isSidebarOpen,
toggleSidebar,
isCollapsed,
setIsCollapsed,
isHighlightsCollapsed,
@@ -116,7 +119,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
setReaderLoading,
readerContent,
setReaderContent,
handleSelectUrl
handleSelectUrl: baseHandleSelectUrl
} = useContentSelection({
relayPool,
settings,
@@ -125,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,
@@ -180,6 +191,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
<ThreePaneLayout
isCollapsed={isCollapsed}
isHighlightsCollapsed={isHighlightsCollapsed}
isSidebarOpen={isSidebarOpen}
showSettings={showSettings}
showExplore={showExplore}
bookmarks={bookmarks}
@@ -187,12 +199,16 @@ const Bookmarks: React.FC<BookmarksProps> = ({ relayPool, onLogout }) => {
viewMode={viewMode}
isRefreshing={isRefreshing}
lastFetchTime={lastFetchTime}
onToggleSidebar={() => setIsCollapsed(!isCollapsed)}
onToggleSidebar={isMobile ? toggleSidebar : () => setIsCollapsed(!isCollapsed)}
onLogout={onLogout}
onViewModeChange={setViewMode}
onOpenSettings={() => {
navigate('/settings')
setIsCollapsed(true)
if (isMobile) {
toggleSidebar()
} else {
setIsCollapsed(true)
}
setIsHighlightsCollapsed(true)
}}
onRefresh={handleRefreshAll}

View File

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

View File

@@ -49,6 +49,19 @@ const StartupPreferencesSettings: React.FC<StartupPreferencesSettingsProps> = ({
<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>
)
}

View File

@@ -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, 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 { useEventModel } from 'applesauce-react/hooks'
import { Models } from 'applesauce-core'
@@ -17,9 +17,10 @@ interface SidebarHeaderProps {
onLogout: () => void
onOpenSettings: () => void
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 [showAddModal, setShowAddModal] = useState(false)
const navigate = useNavigate()
@@ -66,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"

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 { IEventStore } from 'applesauce-core'
import { BookmarkList } from './BookmarkList'
@@ -16,11 +18,13 @@ 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
@@ -79,14 +83,167 @@ interface ThreePaneLayoutProps {
}
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}
@@ -99,9 +256,10 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
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}
@@ -139,7 +297,11 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
/>
)}
</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}

View File

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

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;
--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;
@@ -106,6 +140,47 @@ 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;
@@ -301,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;
}
@@ -394,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;
@@ -428,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 {
@@ -442,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%;
@@ -471,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;
@@ -742,6 +1002,20 @@ 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: transparent;
padding: 1rem;
@@ -2303,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;
}
@@ -2357,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;

View File

@@ -45,6 +45,8 @@ export interface UserSettings {
// 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(

9
vercel.json Normal file
View File

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