From 670997ed36c3c56ff888630dea116d028b55f7fa Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 10 Oct 2025 16:55:39 +0100 Subject: [PATCH 01/21] feat: update viewport meta for mobile support --- index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.html b/index.html index 3049eb46..72296006 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - + Boris - Nostr Bookmarks From e22cf71b15cef38d691ef307deb5679072bbc8d5 Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 10 Oct 2025 16:55:53 +0100 Subject: [PATCH 02/21] feat: add media query hooks for responsive design --- src/hooks/useMediaQuery.ts | 62 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/hooks/useMediaQuery.ts diff --git a/src/hooks/useMediaQuery.ts b/src/hooks/useMediaQuery.ts new file mode 100644 index 00000000..d8c76c03 --- /dev/null +++ b/src/hooks/useMediaQuery.ts @@ -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)') +} + From 124d399d1f71e430bbe58369c1f37a7c18495f8f Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 10 Oct 2025 16:56:19 +0100 Subject: [PATCH 03/21] feat: add mobile sidebar state management to useBookmarksUI --- src/hooks/useBookmarksUI.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/hooks/useBookmarksUI.ts b/src/hooks/useBookmarksUI.ts index be2f8d1a..9e02af3f 100644 --- a/src/hooks/useBookmarksUI.ts +++ b/src/hooks/useBookmarksUI.ts @@ -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('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, From de7a435a0104ac7fd7ce9fa973ff08f65090be64 Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 10 Oct 2025 16:57:56 +0100 Subject: [PATCH 04/21] feat: add mobile-responsive CSS with breakpoints and safe areas --- src/index.css | 224 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 224 insertions(+) diff --git a/src/index.css b/src/index.css index 70e13c59..e6d43659 100644 --- a/src/index.css +++ b/src/index.css @@ -22,12 +22,40 @@ --highlights-collapsed-width: 56px; --main-max-width: 900px; --main-horizontal-padding: 1rem; + + /* Mobile breakpoints */ + --mobile-breakpoint: 768px; + --tablet-breakpoint: 1024px; + + /* Mobile touch target minimum */ + --min-touch-target: 44px; + + /* Safe area insets for notched devices */ + --safe-area-top: env(safe-area-inset-top, 0px); + --safe-area-bottom: env(safe-area-inset-bottom, 0px); + --safe-area-left: env(safe-area-inset-left, 0px); + --safe-area-right: env(safe-area-inset-right, 0px); } body { margin: 0; min-width: 320px; min-height: 100vh; + overscroll-behavior: none; + -webkit-overflow-scrolling: touch; +} + +/* Use dynamic viewport height if supported */ +@supports (height: 100dvh) { + body { + min-height: 100dvh; + } +} + +body.mobile-sidebar-open { + overflow: hidden; + position: fixed; + width: 100%; } #root { @@ -36,6 +64,12 @@ body { padding: 1rem; } +@media (max-width: 768px) { + #root { + padding: 0; + } +} + .app { text-align: center; position: relative; @@ -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; } @@ -428,6 +526,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 +547,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 +592,58 @@ body { height: 100%; } +/* Mobile pane styles */ +@media (max-width: 768px) { + .pane.sidebar { + position: fixed; + top: 0; + left: 0; + width: 85%; + max-width: 320px; + height: 100vh; + height: 100dvh; + background: #1a1a1a; + z-index: 1000; + transform: translateX(-100%); + transition: transform 0.3s ease; + box-shadow: none; + } + + .pane.sidebar.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%; + } + + .pane.highlights { + display: none; + } + + .mobile-sidebar-backdrop { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + z-index: 999; + opacity: 0; + transition: opacity 0.3s ease; + } + + .mobile-sidebar-backdrop.visible { + display: block; + opacity: 1; + } +} + .reader { background: #1a1a1a; border: 1px solid #333; @@ -742,6 +915,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 +2490,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 +2565,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; From 0c4b523d058b48962b7ca2f6bdd8765c373ffd09 Mon Sep 17 00:00:00 2001 From: Gigi Date: Fri, 10 Oct 2025 17:00:03 +0100 Subject: [PATCH 05/21] feat: implement mobile overlay sidebar with focus trap and ESC handling --- src/components/BookmarkList.tsx | 7 +- src/components/Bookmarks.tsx | 22 +++++- src/components/SidebarHeader.tsx | 32 ++++++--- src/components/ThreePaneLayout.tsx | 106 ++++++++++++++++++++++++++++- 4 files changed, 151 insertions(+), 16 deletions(-) diff --git a/src/components/BookmarkList.tsx b/src/components/BookmarkList.tsx index f4bf67be..ba4729cf 100644 --- a/src/components/BookmarkList.tsx +++ b/src/components/BookmarkList.tsx @@ -27,6 +27,8 @@ interface BookmarkListProps { loading?: boolean relayPool: RelayPool | null settings?: UserSettings + isMobile?: boolean + isSidebarOpen?: boolean } export const BookmarkList: React.FC = ({ @@ -44,7 +46,9 @@ export const BookmarkList: React.FC = ({ lastFetchTime, loading = false, relayPool, - settings + settings, + isMobile = false, + isSidebarOpen = false }) => { // Helper to check if a bookmark has either content or a URL const hasContentOrUrl = (ib: IndividualBookmark) => { @@ -106,6 +110,7 @@ export const BookmarkList: React.FC = ({ onLogout={onLogout} onOpenSettings={onOpenSettings} relayPool={relayPool} + isMobile={isMobile} /> {loading ? ( diff --git a/src/components/Bookmarks.tsx b/src/components/Bookmarks.tsx index c65af17e..9657fcba 100644 --- a/src/components/Bookmarks.tsx +++ b/src/components/Bookmarks.tsx @@ -67,6 +67,9 @@ const Bookmarks: React.FC = ({ relayPool, onLogout }) => { }) const { + isMobile, + isSidebarOpen, + toggleSidebar, isCollapsed, setIsCollapsed, isHighlightsCollapsed, @@ -116,7 +119,7 @@ const Bookmarks: React.FC = ({ relayPool, onLogout }) => { setReaderLoading, readerContent, setReaderContent, - handleSelectUrl + handleSelectUrl: baseHandleSelectUrl } = useContentSelection({ relayPool, settings, @@ -125,6 +128,14 @@ const Bookmarks: React.FC = ({ relayPool, onLogout }) => { setCurrentArticle }) + // Wrap handleSelectUrl to close mobile sidebar when selecting content + const handleSelectUrl = (url: string, bookmark?: any) => { + if (isMobile && isSidebarOpen) { + toggleSidebar() + } + baseHandleSelectUrl(url, bookmark) + } + const { highlightButtonRef, handleTextSelection, @@ -180,6 +191,7 @@ const Bookmarks: React.FC = ({ relayPool, onLogout }) => { = ({ 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} diff --git a/src/components/SidebarHeader.tsx b/src/components/SidebarHeader.tsx index 2bcb76f6..c3f092d1 100644 --- a/src/components/SidebarHeader.tsx +++ b/src/components/SidebarHeader.tsx @@ -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 = ({ onToggleCollapse, onLogout, onOpenSettings, relayPool }) => { +const SidebarHeader: React.FC = ({ 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 = ({ onToggleCollapse, onLogou return ( <>
- + {isMobile ? ( + + ) : ( + + )}
= (props) => { + const isMobile = useIsMobile() + const sidebarRef = useRef(null) + + // Lock body scroll when mobile sidebar is open + useEffect(() => { + if (isMobile && props.isSidebarOpen) { + 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]) + + // Handle ESC key to close sidebar + useEffect(() => { + if (!isMobile || !props.isSidebarOpen) return + + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + props.onToggleSidebar() + } + } + + document.addEventListener('keydown', handleEscape) + return () => document.removeEventListener('keydown', handleEscape) + }, [isMobile, props.isSidebarOpen, props.onToggleSidebar]) + + // Trap focus in sidebar when open on mobile + useEffect(() => { + if (!isMobile || !props.isSidebarOpen || !sidebarRef.current) return + + const sidebar = sidebarRef.current + const focusableElements = sidebar.querySelectorAll( + '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]) + + const handleBackdropClick = () => { + if (isMobile && props.isSidebarOpen) { + props.onToggleSidebar() + } + } + return ( <> + {/* Mobile hamburger button */} + {isMobile && !props.isSidebarOpen && ( + + )} + + {/* Mobile backdrop */} + {isMobile && ( +