Merge pull request #1 from dergigi/mobile

Add mobile responsive design
This commit is contained in:
Gigi
2025-10-10 18:04:54 +01:00
committed by GitHub
13 changed files with 803 additions and 22 deletions

View File

@@ -5,6 +5,27 @@ 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

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

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