From 500d038ed000642de81225cdf2ea55f66e2159f9 Mon Sep 17 00:00:00 2001 From: matteoscrugli Date: Tue, 23 Dec 2025 21:48:19 +0100 Subject: [PATCH] Add swipe-to-open sidebar on all pages + fix bottom bar styling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create SwipeableContent component for sidebar swipe on non-tab pages - Add swipe-to-close sidebar from overlay - Make swipe work from entire page (ignoring interactive elements) - Show title and divider on desktop when tab bar is at bottom - Hide title/divider only on mobile for bottom position 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- frontend/src/components/Sidebar.tsx | 150 ++++++++++++++++- frontend/src/components/SwipeTabs.tsx | 62 ++++++- frontend/src/components/SwipeableContent.tsx | 164 +++++++++++++++++++ frontend/src/contexts/SidebarContext.tsx | 11 ++ frontend/src/pages/APIKeys.tsx | 5 +- frontend/src/pages/AdminPanel.tsx | 24 ++- frontend/src/pages/Dashboard.tsx | 5 +- frontend/src/pages/Feature1.tsx | 5 +- frontend/src/pages/Feature2.tsx | 5 +- frontend/src/pages/Feature3.tsx | 5 +- frontend/src/pages/Notifications.tsx | 5 +- frontend/src/pages/Search.tsx | 5 +- frontend/src/pages/Settings.tsx | 5 +- frontend/src/pages/Users.tsx | 5 +- frontend/src/pages/admin/Analytics.tsx | 5 +- frontend/src/pages/admin/AuditLogs.tsx | 5 +- frontend/src/pages/admin/Features.tsx | 22 ++- frontend/src/pages/admin/Settings.tsx | 5 +- frontend/src/pages/admin/Sources.tsx | 5 +- frontend/src/pages/admin/ThemeSettings.tsx | 24 ++- frontend/src/styles/Layout.css | 24 +-- frontend/src/styles/Sidebar.css | 10 ++ 22 files changed, 493 insertions(+), 63 deletions(-) create mode 100644 frontend/src/components/SwipeableContent.tsx diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 1cd5583..f4905d2 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from 'react'; +import { useState, useRef, useCallback, type TouchEvent } from 'react'; import { NavLink, useNavigate } from 'react-router-dom'; import { useTranslation } from '../contexts/LanguageContext'; import { useSidebar } from '../contexts/SidebarContext'; @@ -12,6 +12,9 @@ import { appModules } from '../modules'; import UserMenu from './UserMenu'; import '../styles/Sidebar.css'; +const SIDEBAR_WIDTH = 280; +const DRAG_START_DISTANCE = 3; + export default function Sidebar() { const { t, language, setLanguage } = useTranslation(); const { config } = useSiteConfig(); @@ -24,7 +27,11 @@ export default function Sidebar() { closeMobileMenu, isHovered, setIsHovered, - showLogo: showLogoContext + showLogo: showLogoContext, + sidebarDragProgress, + setSidebarDragProgress, + isSidebarDragging, + setIsSidebarDragging } = useSidebar(); const { viewMode, toggleViewMode, isUserModeEnabled } = useViewMode(); const { user } = useAuth(); @@ -167,10 +174,137 @@ export default function Sidebar() { setTooltip((prev) => prev.visible ? { ...prev, text } : prev); }; + // Swipe-to-close handlers for mobile sidebar + const closeDragRef = useRef({ + touchId: null as number | null, + startX: 0, + startY: 0, + isActive: false, + isDragging: false + }); + + const handleSidebarTouchStart = useCallback((event: TouchEvent) => { + // Only handle when sidebar is open on mobile + if (!isMobileOpen) return; + if (closeDragRef.current.isActive) return; + + const touch = event.touches[0]; + if (!touch) return; + + closeDragRef.current = { + touchId: touch.identifier, + startX: touch.clientX, + startY: touch.clientY, + isActive: true, + isDragging: false + }; + }, [isMobileOpen]); + + const handleSidebarTouchMove = useCallback((event: TouchEvent) => { + const state = closeDragRef.current; + if (!state.isActive || state.touchId === null) return; + + const touch = Array.from(event.touches).find(t => t.identifier === state.touchId); + if (!touch) return; + + const dx = touch.clientX - state.startX; + const dy = touch.clientY - state.startY; + + // Check if we should start dragging + if (!state.isDragging) { + if (Math.abs(dx) < DRAG_START_DISTANCE && Math.abs(dy) < DRAG_START_DISTANCE) { + return; + } + // Only start horizontal drags going left (negative dx) + if (dx >= 0 || Math.abs(dx) <= Math.abs(dy)) { + state.isActive = false; + return; + } + state.isDragging = true; + // Initialize progress to 1 (fully open) before setting dragging state + setSidebarDragProgress(1); + setIsSidebarDragging(true); + } + + // Calculate progress (1 = fully open, 0 = closed) + // dx is negative when swiping left + const progress = Math.max(0, Math.min(1, 1 + dx / SIDEBAR_WIDTH)); + setSidebarDragProgress(progress); + }, [setSidebarDragProgress, setIsSidebarDragging]); + + const handleSidebarTouchEnd = useCallback((event: TouchEvent) => { + const state = closeDragRef.current; + if (!state.isActive || state.touchId === null) return; + + const touch = Array.from(event.changedTouches).find(t => t.identifier === state.touchId); + if (!touch) return; + + const wasDragging = state.isDragging; + const dx = touch.clientX - state.startX; + + // Reset state + closeDragRef.current = { + touchId: null, + startX: 0, + startY: 0, + isActive: false, + isDragging: false + }; + + if (!wasDragging) return; + + setIsSidebarDragging(false); + setSidebarDragProgress(0); + + // Close sidebar if dragged past 30% threshold (dx is negative) + if (dx < -SIDEBAR_WIDTH * 0.3) { + closeMobileMenu(); + } + }, [closeMobileMenu, setSidebarDragProgress, setIsSidebarDragging]); + + const handleSidebarTouchCancel = useCallback(() => { + if (closeDragRef.current.isDragging) { + setIsSidebarDragging(false); + setSidebarDragProgress(0); + } + closeDragRef.current = { + touchId: null, + startX: 0, + startY: 0, + isActive: false, + isDragging: false + }; + }, [setSidebarDragProgress, setIsSidebarDragging]); + + // Calculate sidebar transform for close gesture (when dragging to close) + const getSidebarCloseTransform = () => { + if (!isSidebarDragging || !isMobileOpen) return undefined; + // When closing, progress goes from 1 (open) to 0 (closed) + // Transform: 0% when open (progress=1), -110% when closed (progress=0) + const translatePercent = (1 - sidebarDragProgress) * -110; + return `translateX(${translatePercent}%)`; + }; + + const sidebarTransform = isSidebarDragging + ? (isMobileOpen ? getSidebarCloseTransform() : `translateX(calc(-110% + ${sidebarDragProgress * 110}%))`) + : undefined; + return ( <> {/* Mobile overlay */} -
+
0 ? 'visible' : 'hidden', + transition: 'none' + } : undefined} + /> {/* Sidebar Tooltip */} {tooltip.visible && ( @@ -186,11 +320,19 @@ export default function Sidebar() { )}
- + ); } diff --git a/frontend/src/pages/Feature3.tsx b/frontend/src/pages/Feature3.tsx index 1c692f2..4f04356 100644 --- a/frontend/src/pages/Feature3.tsx +++ b/frontend/src/pages/Feature3.tsx @@ -1,6 +1,7 @@ import { useTranslation } from '../contexts/LanguageContext'; import { useSidebar } from '../contexts/SidebarContext'; import TabsScroller from '../components/TabsScroller'; +import { SwipeableContent } from '../components/SwipeableContent'; import '../styles/AdminPanel.css'; export default function Feature3() { @@ -8,7 +9,7 @@ export default function Feature3() { const { toggleMobileMenu } = useSidebar(); return ( -
+
- + ); } diff --git a/frontend/src/pages/Notifications.tsx b/frontend/src/pages/Notifications.tsx index fbc4452..4cd8e3f 100644 --- a/frontend/src/pages/Notifications.tsx +++ b/frontend/src/pages/Notifications.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from '../contexts/LanguageContext'; import { useSidebar } from '../contexts/SidebarContext'; import TabsScroller from '../components/TabsScroller'; +import { SwipeableContent } from '../components/SwipeableContent'; import { useNotifications } from '../contexts/NotificationsContext'; import { notificationsAPI } from '../api/client'; import type { NotificationItem } from '../api/client'; @@ -94,7 +95,7 @@ export default function Notifications() { }; return ( -
+
)} -
+ ); } diff --git a/frontend/src/pages/Search.tsx b/frontend/src/pages/Search.tsx index b9414ab..3f1e77e 100644 --- a/frontend/src/pages/Search.tsx +++ b/frontend/src/pages/Search.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from '../contexts/LanguageContext'; import { useSidebar } from '../contexts/SidebarContext'; import TabsScroller from '../components/TabsScroller'; +import { SwipeableContent } from '../components/SwipeableContent'; import '../styles/Search.css'; type SearchResult = { @@ -76,7 +77,7 @@ export default function Search() { : t.searchPage.hint; return ( -
+
-
+ ); } diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 9ebab58..55183a6 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from '../contexts/LanguageContext'; import { useSidebar } from '../contexts/SidebarContext'; import TabsScroller from '../components/TabsScroller'; +import { SwipeableContent } from '../components/SwipeableContent'; import { sessionsAPI, twoFactorAPI } from '../api/client'; import type { UserSession } from '../api/client'; import '../styles/SettingsPage.css'; @@ -151,7 +152,7 @@ export default function Settings() { }; return ( -
+
-
+ ); } diff --git a/frontend/src/pages/Users.tsx b/frontend/src/pages/Users.tsx index 1171e84..df414c5 100644 --- a/frontend/src/pages/Users.tsx +++ b/frontend/src/pages/Users.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import type { FormEvent } from 'react'; import Sidebar from '../components/Sidebar'; import TabsScroller from '../components/TabsScroller'; +import { SwipeableContent } from '../components/SwipeableContent'; import { useAuth } from '../contexts/AuthContext'; import { useTranslation } from '../contexts/LanguageContext'; import { useSidebar } from '../contexts/SidebarContext'; @@ -180,7 +181,7 @@ export default function Users() { return (
-
+
)}
- + {isModalOpen && (
diff --git a/frontend/src/pages/admin/Analytics.tsx b/frontend/src/pages/admin/Analytics.tsx index 674735d..0022438 100644 --- a/frontend/src/pages/admin/Analytics.tsx +++ b/frontend/src/pages/admin/Analytics.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from '../../contexts/LanguageContext'; import { useSidebar } from '../../contexts/SidebarContext'; import TabsScroller from '../../components/TabsScroller'; +import { SwipeableContent } from '../../components/SwipeableContent'; import { analyticsAPI } from '../../api/client'; import type { AnalyticsOverview } from '../../api/client'; import '../../styles/AdminAnalytics.css'; @@ -45,7 +46,7 @@ export default function Analytics() { const maxActionCount = useMemo(() => Math.max(1, ...actions.map((a) => a.count)), [actions]); return ( -
+
-
+ ); } diff --git a/frontend/src/pages/admin/AuditLogs.tsx b/frontend/src/pages/admin/AuditLogs.tsx index f8cb357..32a43fd 100644 --- a/frontend/src/pages/admin/AuditLogs.tsx +++ b/frontend/src/pages/admin/AuditLogs.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from '../../contexts/LanguageContext'; import { useSidebar } from '../../contexts/SidebarContext'; import TabsScroller from '../../components/TabsScroller'; +import { SwipeableContent } from '../../components/SwipeableContent'; import { auditAPI } from '../../api/client'; import type { AuditLogItem } from '../../api/client'; import '../../styles/AdminAudit.css'; @@ -58,7 +59,7 @@ export default function AuditLogs() { }; return ( -
+
- + ); } diff --git a/frontend/src/pages/admin/Features.tsx b/frontend/src/pages/admin/Features.tsx index e8709c1..6a84fb4 100644 --- a/frontend/src/pages/admin/Features.tsx +++ b/frontend/src/pages/admin/Features.tsx @@ -25,7 +25,23 @@ const findTouchById = (touches: TouchList, id: number) => { export default function Features() { const { user: currentUser } = useAuth(); const { t } = useTranslation(); - const { toggleMobileMenu, openMobileMenu } = useSidebar(); + const { toggleMobileMenu, openMobileMenu, setSidebarDragProgress, setIsSidebarDragging } = useSidebar(); + + const handleSidebarDragStart = useCallback(() => { + setIsSidebarDragging(true); + }, [setIsSidebarDragging]); + + const handleSidebarDragProgress = useCallback((progress: number) => { + setSidebarDragProgress(progress); + }, [setSidebarDragProgress]); + + const handleSidebarDragEnd = useCallback((shouldOpen: boolean) => { + setIsSidebarDragging(false); + setSidebarDragProgress(0); + if (shouldOpen) { + openMobileMenu(); + } + }, [openMobileMenu, setIsSidebarDragging, setSidebarDragProgress]); const { moduleStates, moduleOrder, setModuleEnabled, setModulePosition, setModuleOrder, saveModulesToBackend, saveModuleOrder, hasInitialized, isLoading } = useModules(); const [activeTab, setActiveTab] = useState('config'); const hasUserMadeChanges = useRef(false); @@ -806,7 +822,9 @@ export default function Features() { onTabChange={handleTabChange} renderPanel={renderTabContent} panelClassName="admin-tab-content swipe-panel-content" - onSwipePastStart={openMobileMenu} + onSwipePastStartDragStart={handleSidebarDragStart} + onSwipePastStartProgress={handleSidebarDragProgress} + onSwipePastStartDragEnd={handleSidebarDragEnd} /> ); diff --git a/frontend/src/pages/admin/Settings.tsx b/frontend/src/pages/admin/Settings.tsx index db6d449..cf55e15 100644 --- a/frontend/src/pages/admin/Settings.tsx +++ b/frontend/src/pages/admin/Settings.tsx @@ -3,6 +3,7 @@ import { useTranslation } from '../../contexts/LanguageContext'; import { useSidebar } from '../../contexts/SidebarContext'; import Sidebar from '../../components/Sidebar'; import TabsScroller from '../../components/TabsScroller'; +import { SwipeableContent } from '../../components/SwipeableContent'; import { settingsAPI } from '../../api/client'; import '../../styles/Settings.css'; @@ -59,7 +60,7 @@ export default function Settings() { return (
-
+
- + ); } diff --git a/frontend/src/pages/admin/Sources.tsx b/frontend/src/pages/admin/Sources.tsx index aee18bd..8606bd1 100644 --- a/frontend/src/pages/admin/Sources.tsx +++ b/frontend/src/pages/admin/Sources.tsx @@ -2,6 +2,7 @@ import { useAuth } from '../../contexts/AuthContext'; import { useTranslation } from '../../contexts/LanguageContext'; import { useSidebar } from '../../contexts/SidebarContext'; import TabsScroller from '../../components/TabsScroller'; +import { SwipeableContent } from '../../components/SwipeableContent'; import '../../styles/AdminPanel.css'; export default function Sources() { @@ -14,7 +15,7 @@ export default function Sources() { } return ( -
+
-
+ ); } diff --git a/frontend/src/pages/admin/ThemeSettings.tsx b/frontend/src/pages/admin/ThemeSettings.tsx index f29cd05..dfd4d1c 100644 --- a/frontend/src/pages/admin/ThemeSettings.tsx +++ b/frontend/src/pages/admin/ThemeSettings.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, useCallback } from 'react'; import { useTheme, COLOR_PALETTES } from '../../contexts/ThemeContext'; import type { AccentColor, BorderRadius, SidebarStyle, Density, FontFamily, ColorPalette, TabBarPosition } from '../../contexts/ThemeContext'; import { useTranslation } from '../../contexts/LanguageContext'; @@ -45,8 +45,24 @@ export default function ThemeSettings() { } = useTheme(); const { t } = useTranslation(); const { user } = useAuth(); - const { toggleMobileMenu, openMobileMenu, sidebarMode, setSidebarMode } = useSidebar(); + const { toggleMobileMenu, openMobileMenu, sidebarMode, setSidebarMode, setSidebarDragProgress, setIsSidebarDragging } = useSidebar(); const isAdmin = user?.is_superuser || false; + + const handleSidebarDragStart = useCallback(() => { + setIsSidebarDragging(true); + }, [setIsSidebarDragging]); + + const handleSidebarDragProgress = useCallback((progress: number) => { + setSidebarDragProgress(progress); + }, [setSidebarDragProgress]); + + const handleSidebarDragEnd = useCallback((shouldOpen: boolean) => { + setIsSidebarDragging(false); + setSidebarDragProgress(0); + if (shouldOpen) { + openMobileMenu(); + } + }, [openMobileMenu, setIsSidebarDragging, setSidebarDragProgress]); const tabs: ThemeTab[] = isAdmin ? ['colors', 'appearance', 'preview', 'advanced'] : ['colors', 'appearance', 'preview']; const [tooltip, setTooltip] = useState<{ text: string; left: number; visible: boolean }>({ text: '', @@ -820,7 +836,9 @@ export default function ThemeSettings() { onTabChange={setActiveTab} renderPanel={renderPanel} panelClassName="admin-tab-content swipe-panel-content" - onSwipePastStart={openMobileMenu} + onSwipePastStartDragStart={handleSidebarDragStart} + onSwipePastStartProgress={handleSidebarDragProgress} + onSwipePastStartDragEnd={handleSidebarDragEnd} /> {/* Color Picker Popup */} diff --git a/frontend/src/styles/Layout.css b/frontend/src/styles/Layout.css index 9aac148..4a1187d 100644 --- a/frontend/src/styles/Layout.css +++ b/frontend/src/styles/Layout.css @@ -887,22 +887,6 @@ label, justify-content: flex-start; } -/* Hide title section when bar is at bottom (only on pages with tabs) */ -[data-tab-position='bottom'] .page-tabs-slider[data-has-tabs='true'] .page-title-section, -[data-tab-position='bottom'] .page-tabs-slider[data-has-tabs='true'] .admin-title-section, -[data-tab-position='bottom'] .admin-tabs-slider[data-has-tabs='true'] .page-title-section, -[data-tab-position='bottom'] .admin-tabs-slider[data-has-tabs='true'] .admin-title-section { - display: none; -} - -/* Hide divider when bar is at bottom */ -[data-tab-position='bottom'] .page-tabs-slider .page-tabs-divider, -[data-tab-position='bottom'] .page-tabs-slider .admin-tabs-divider, -[data-tab-position='bottom'] .admin-tabs-slider .page-tabs-divider, -[data-tab-position='bottom'] .admin-tabs-slider .admin-tabs-divider { - display: none; -} - /* Add bottom padding and remove top padding when bar is at bottom */ [data-tab-position='bottom'] .main-content { padding-bottom: calc(72px + env(safe-area-inset-bottom, 0px)); @@ -926,7 +910,13 @@ label, left: 0; } - /* Keep proper spacing for mobile menu button - inherits padding-left: 72px from mobile rules */ + /* Hide divider on mobile when bar is at bottom */ + [data-tab-position='bottom'] .page-tabs-slider .page-tabs-divider, + [data-tab-position='bottom'] .page-tabs-slider .admin-tabs-divider, + [data-tab-position='bottom'] .admin-tabs-slider .page-tabs-divider, + [data-tab-position='bottom'] .admin-tabs-slider .admin-tabs-divider { + display: none; + } } /* Responsive position - top on desktop, bottom on mobile */ diff --git a/frontend/src/styles/Sidebar.css b/frontend/src/styles/Sidebar.css index 50f58b9..df93829 100644 --- a/frontend/src/styles/Sidebar.css +++ b/frontend/src/styles/Sidebar.css @@ -678,6 +678,16 @@ button.nav-item { transform: translateX(0); } + /* During drag, disable transitions for 1:1 gesture tracking */ + .sidebar.dragging { + transition: none !important; + } + + .sidebar-overlay.dragging { + transition: none !important; + pointer-events: auto; + } + /* Reset ALL collapsed styles on mobile - sidebar should always be fully expanded when open */ .sidebar.collapsed { width: 280px;