From abd8f75efc838556ad50fca3f6031acebe8d2f86 Mon Sep 17 00:00:00 2001 From: matteoscrugli Date: Sun, 21 Dec 2025 17:08:50 +0100 Subject: [PATCH] Improve mobile swipe tabs with native scroll-snap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite SwipeTabs to use CSS scroll-snap for smoother transitions - Add GPU acceleration hints for fluid animations - Use native scrolling behavior instead of manual touch handling - Add swipe-tabs--snap and swipe-tabs--static variants - Improve height transitions and layout containment - Update dimension variables for better spacing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- frontend/src/components/SwipeTabs.tsx | 326 +++++++++++++++++++++ frontend/src/pages/AdminPanel.tsx | 15 +- frontend/src/pages/admin/Features.tsx | 18 +- frontend/src/pages/admin/ThemeSettings.tsx | 172 ++++++----- frontend/src/styles/APIKeys.css | 2 +- frontend/src/styles/AdminAnalytics.css | 4 - frontend/src/styles/Layout.css | 94 +++++- frontend/src/styles/SettingsPage.css | 4 - frontend/src/styles/ThemeSettings.css | 13 + frontend/src/styles/theme/dimensions.css | 16 +- 10 files changed, 552 insertions(+), 112 deletions(-) create mode 100644 frontend/src/components/SwipeTabs.tsx diff --git a/frontend/src/components/SwipeTabs.tsx b/frontend/src/components/SwipeTabs.tsx new file mode 100644 index 0000000..b9b5265 --- /dev/null +++ b/frontend/src/components/SwipeTabs.tsx @@ -0,0 +1,326 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import type { CSSProperties, ReactNode } from 'react'; + +type SwipeTabsProps = { + tabs: readonly T[]; + activeTab: T; + onTabChange: (tab: T) => void; + renderPanel: (tab: T) => ReactNode; + className?: string; + panelClassName?: string; + renderWindow?: number; + threshold?: number; + directionRatio?: number; + respectScrollableTargets?: boolean; +}; + +const SCROLL_END_DELAY = 120; +const COARSE_POINTER_QUERY = '(pointer: coarse)'; +const NO_HOVER_QUERY = '(hover: none)'; +const FALLBACK_WIDTH_QUERY = '(max-width: 1024px)'; + +const getSwipeEnabled = () => { + if (typeof window === 'undefined' || !window.matchMedia) return false; + const coarsePointer = window.matchMedia(COARSE_POINTER_QUERY).matches; + const noHover = window.matchMedia(NO_HOVER_QUERY).matches; + if (coarsePointer || noHover) return true; + const supportsTouch = ('ontouchstart' in window) + || (typeof navigator !== 'undefined' && navigator.maxTouchPoints > 0); + if (!supportsTouch) return false; + return window.matchMedia(FALLBACK_WIDTH_QUERY).matches; +}; + +export function SwipeTabs({ + tabs, + activeTab, + onTabChange, + renderPanel, + className, + panelClassName, + renderWindow = 0, +}: SwipeTabsProps) { + const containerRef = useRef(null); + const panelRefs = useRef(new Map()); + const activeIndex = tabs.indexOf(activeTab); + const safeActiveIndex = activeIndex >= 0 ? activeIndex : 0; + const maxIndex = Math.max(0, tabs.length - 1); + + const [containerWidth, setContainerWidth] = useState(0); + const [containerHeight, setContainerHeight] = useState(null); + const [isScrolling, setIsScrolling] = useState(false); + const [scrollPosition, setScrollPosition] = useState(0); + const [swipeEnabled, setSwipeEnabled] = useState(() => getSwipeEnabled()); + + const scrollEndTimer = useRef(null); + const scrollRaf = useRef(null); + const scrollPositionRef = useRef(0); + const isUserScrolling = useRef(false); + const hasMounted = useRef(false); + const lastReportedIndex = useRef(safeActiveIndex); + + const prefersReducedMotion = useMemo(() => { + if (typeof window === 'undefined' || !window.matchMedia) return true; + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; + }, []); + + useEffect(() => { + if (typeof window === 'undefined' || !window.matchMedia) return; + const pointerMql = window.matchMedia(COARSE_POINTER_QUERY); + const hoverMql = window.matchMedia(NO_HOVER_QUERY); + const widthMql = window.matchMedia(FALLBACK_WIDTH_QUERY); + const update = () => setSwipeEnabled(getSwipeEnabled()); + update(); + + const addListener = (mql: MediaQueryList) => { + if (mql.addEventListener) { + mql.addEventListener('change', update); + return; + } + mql.addListener(update); + }; + + const removeListener = (mql: MediaQueryList) => { + if (mql.removeEventListener) { + mql.removeEventListener('change', update); + return; + } + mql.removeListener(update); + }; + + addListener(pointerMql); + addListener(hoverMql); + addListener(widthMql); + + return () => { + removeListener(pointerMql); + removeListener(hoverMql); + removeListener(widthMql); + }; + }, []); + + useEffect(() => { + if (!containerRef.current) return; + const element = containerRef.current; + const updateSize = () => { + const styles = window.getComputedStyle(element); + const paddingX = parseFloat(styles.paddingLeft) + parseFloat(styles.paddingRight); + const width = element.clientWidth - (Number.isFinite(paddingX) ? paddingX : 0); + setContainerWidth(width > 0 ? width : element.clientWidth); + }; + updateSize(); + + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', updateSize); + return () => window.removeEventListener('resize', updateSize); + } + + const observer = new ResizeObserver(updateSize); + observer.observe(element); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + const panel = panelRefs.current.get(activeTab); + if (!panel) { + setContainerHeight(null); + return; + } + + const updateHeight = () => setContainerHeight(panel.offsetHeight); + updateHeight(); + + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', updateHeight); + return () => window.removeEventListener('resize', updateHeight); + } + + const observer = new ResizeObserver(updateHeight); + observer.observe(panel); + return () => observer.disconnect(); + }, [activeTab]); + + useEffect(() => { + return () => { + if (scrollEndTimer.current !== null) { + window.clearTimeout(scrollEndTimer.current); + } + if (scrollRaf.current !== null) { + cancelAnimationFrame(scrollRaf.current); + } + }; + }, []); + + useEffect(() => { + lastReportedIndex.current = safeActiveIndex; + }, [safeActiveIndex]); + + const scrollToIndex = useCallback((index: number, behavior: ScrollBehavior) => { + const container = containerRef.current; + if (!container || containerWidth <= 0) return; + const targetLeft = index * containerWidth; + if (Math.abs(container.scrollLeft - targetLeft) <= 1) return; + container.scrollTo({ left: targetLeft, behavior }); + }, [containerWidth]); + + const scheduleScrollPositionUpdate = useCallback(() => { + if (scrollRaf.current !== null) return; + scrollRaf.current = window.requestAnimationFrame(() => { + scrollRaf.current = null; + setScrollPosition(scrollPositionRef.current); + }); + }, []); + + const handleScroll = useCallback(() => { + if (!swipeEnabled) return; + const container = containerRef.current; + if (!container || containerWidth <= 0) return; + + if (!isUserScrolling.current) { + isUserScrolling.current = true; + setIsScrolling(true); + } + + const rawIndex = container.scrollLeft / containerWidth; + scrollPositionRef.current = rawIndex; + scheduleScrollPositionUpdate(); + + const roundedIndex = Math.round(rawIndex); + const clampedIndex = Math.max(0, Math.min(roundedIndex, maxIndex)); + if (tabs[clampedIndex] && clampedIndex !== lastReportedIndex.current) { + lastReportedIndex.current = clampedIndex; + onTabChange(tabs[clampedIndex]); + } + + if (scrollEndTimer.current !== null) { + window.clearTimeout(scrollEndTimer.current); + } + + scrollEndTimer.current = window.setTimeout(() => { + const rawIndex = container.scrollLeft / containerWidth; + const nextIndex = Math.round(rawIndex); + const clampedIndex = Math.max(0, Math.min(nextIndex, maxIndex)); + const targetLeft = clampedIndex * containerWidth; + + if (Math.abs(container.scrollLeft - targetLeft) > 1) { + container.scrollTo({ left: targetLeft, behavior: 'auto' }); + } + + if (tabs[clampedIndex] && clampedIndex !== lastReportedIndex.current) { + lastReportedIndex.current = clampedIndex; + onTabChange(tabs[clampedIndex]); + } + + scrollPositionRef.current = targetLeft / containerWidth; + scheduleScrollPositionUpdate(); + + isUserScrolling.current = false; + setIsScrolling(false); + }, SCROLL_END_DELAY); + }, [activeTab, containerWidth, maxIndex, onTabChange, scheduleScrollPositionUpdate, swipeEnabled, tabs]); + + useLayoutEffect(() => { + if (!swipeEnabled) return; + const container = containerRef.current; + if (!container || containerWidth <= 0) return; + if (isUserScrolling.current) return; + const behavior: ScrollBehavior = hasMounted.current && !prefersReducedMotion ? 'smooth' : 'auto'; + scrollToIndex(safeActiveIndex, behavior); + hasMounted.current = true; + const currentPosition = container.scrollLeft / containerWidth; + scrollPositionRef.current = currentPosition; + setScrollPosition(currentPosition); + }, [containerWidth, prefersReducedMotion, safeActiveIndex, scrollToIndex, swipeEnabled]); + + const visibleIndexes = useMemo(() => { + const indexes = new Set(); + if (!swipeEnabled) { + if (safeActiveIndex >= 0) { + indexes.add(safeActiveIndex); + } + return indexes; + } + const centers = new Set(); + centers.add(safeActiveIndex); + if (containerWidth <= 0) { + centers.add(0); + } + if (containerWidth > 0 && Number.isFinite(scrollPosition)) { + const base = Math.floor(scrollPosition); + const next = Math.ceil(scrollPosition); + centers.add(base); + centers.add(next); + } + centers.forEach((center) => { + for (let offset = -renderWindow; offset <= renderWindow; offset += 1) { + const index = center + offset; + if (index >= 0 && index < tabs.length) { + indexes.add(index); + } + } + }); + return indexes; + }, [containerWidth, renderWindow, safeActiveIndex, scrollPosition, swipeEnabled, tabs.length]); + + const containerStyle: CSSProperties = { + height: !isScrolling && containerHeight ? `${containerHeight}px` : 'auto', + transition: !isScrolling && containerHeight ? 'height 160ms ease-out' : 'none', + }; + + if (!swipeEnabled) { + return ( +
+
+
+
{ + if (node) { + panelRefs.current.set(activeTab, node); + } else { + panelRefs.current.delete(activeTab); + } + }} + > + {renderPanel(activeTab)} +
+
+
+
+ ); + } + + return ( +
+
+ {tabs.map((tab, index) => { + const shouldRender = visibleIndexes.has(index); + return ( +
+
{ + if (node) { + panelRefs.current.set(tab, node); + } else { + panelRefs.current.delete(tab); + } + }} + > + {shouldRender ? renderPanel(tab) : null} +
+
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/pages/AdminPanel.tsx b/frontend/src/pages/AdminPanel.tsx index 8d80710..7a52017 100644 --- a/frontend/src/pages/AdminPanel.tsx +++ b/frontend/src/pages/AdminPanel.tsx @@ -4,6 +4,7 @@ import { useTranslation } from '../contexts/LanguageContext'; import { useSidebar } from '../contexts/SidebarContext'; import GeneralTab from '../components/admin/GeneralTab'; import UsersTab from '../components/admin/UsersTab'; +import { SwipeTabs } from '../components/SwipeTabs'; import '../styles/AdminPanel.css'; type TabId = 'general' | 'users'; @@ -13,6 +14,8 @@ export default function AdminPanel({ initialTab = 'general' }: { initialTab?: Ta const { t } = useTranslation(); const { toggleMobileMenu } = useSidebar(); const [activeTab, setActiveTab] = useState(initialTab); + const tabs: TabId[] = ['general', 'users']; + const renderPanel = (tab: TabId) => (tab === 'general' ? : ); if (!currentUser?.is_superuser) { return null; @@ -46,10 +49,14 @@ export default function AdminPanel({ initialTab = 'general' }: { initialTab?: Ta -
- {activeTab === 'general' && } - {activeTab === 'users' && } -
+ ); } diff --git a/frontend/src/pages/admin/Features.tsx b/frontend/src/pages/admin/Features.tsx index 6fe5115..7ad0db2 100644 --- a/frontend/src/pages/admin/Features.tsx +++ b/frontend/src/pages/admin/Features.tsx @@ -5,6 +5,7 @@ import { useSidebar } from '../../contexts/SidebarContext'; import { useModules, TOGGLEABLE_MODULES } from '../../contexts/ModulesContext'; import type { ModuleId } from '../../contexts/ModulesContext'; import Feature1Tab from '../../components/admin/Feature1Tab'; +import { SwipeTabs } from '../../components/SwipeTabs'; import '../../styles/AdminPanel.css'; type TabId = 'config' | 'dashboard' | 'feature1' | 'feature2' | 'feature3' | 'search' | 'notifications'; @@ -281,6 +282,8 @@ export default function Features() { return position === 'bottom'; }); + const tabIds = ['config', ...topOrderModules, ...bottomOrderModules] as TabId[]; + const renderConfigTab = () => { return (
@@ -380,11 +383,11 @@ export default function Features() { ); }; - const renderTabContent = () => { + const renderTabContent = (tabId: TabId) => { if (!hasInitialized || isLoading) { return
Loading...
; } - switch (activeTab) { + switch (tabId) { case 'config': return renderConfigTab(); case 'dashboard': @@ -501,9 +504,14 @@ export default function Features() {
-
- {renderTabContent()} -
+ ); } diff --git a/frontend/src/pages/admin/ThemeSettings.tsx b/frontend/src/pages/admin/ThemeSettings.tsx index 9ecb36e..9cdf5b6 100644 --- a/frontend/src/pages/admin/ThemeSettings.tsx +++ b/frontend/src/pages/admin/ThemeSettings.tsx @@ -6,6 +6,7 @@ import { useAuth } from '../../contexts/AuthContext'; import { useSidebar } from '../../contexts/SidebarContext'; import type { SidebarMode } from '../../contexts/SidebarContext'; import { ChromePicker, HuePicker } from 'react-color'; +import { SwipeTabs } from '../../components/SwipeTabs'; import '../../styles/ThemeSettings.css'; type ThemeTab = 'colors' | 'appearance' | 'preview' | 'advanced'; @@ -43,6 +44,7 @@ export default function ThemeSettings() { const { user } = useAuth(); const { toggleMobileMenu, sidebarMode, setSidebarMode } = useSidebar(); const isAdmin = user?.is_superuser || false; + const tabs: ThemeTab[] = isAdmin ? ['colors', 'appearance', 'preview', 'advanced'] : ['colors', 'appearance', 'preview']; const [tooltip, setTooltip] = useState<{ text: string; left: number; visible: boolean }>({ text: '', left: 0, @@ -262,70 +264,10 @@ export default function ThemeSettings() { setCustomColors({}); }; - return ( -
- {/* Tab Tooltip */} - {tooltip.visible && ( -
- {tooltip.text} -
- )} - {/* Modern Tab Navigation */} -
-
- -
- {t.theme.title} -
-
- - - - {isAdmin && ( - - )} -
-
- -
- {activeTab === 'colors' && ( + const renderPanel = (tab: ThemeTab) => { + switch (tab) { + case 'colors': + return (
@@ -394,12 +336,11 @@ export default function ThemeSettings() {
- )} - - {activeTab === 'appearance' && ( + ); + case 'appearance': + return (
- {/* Border Radius Section */}

{t.theme.borderRadius}

@@ -426,7 +367,6 @@ export default function ThemeSettings() {
- {/* Sidebar Style Section */}

{t.theme.sidebarStyle}

@@ -453,7 +393,6 @@ export default function ThemeSettings() {
- {/* Sidebar Mode Section */}

{t.admin.sidebarMode}

@@ -480,7 +419,6 @@ export default function ThemeSettings() {
- {/* Density Section */}

{t.theme.density}

@@ -508,7 +446,6 @@ export default function ThemeSettings() {
- {/* Font Family Section */}

{t.theme.fontFamily}

@@ -535,9 +472,9 @@ export default function ThemeSettings() {
- )} - - {activeTab === 'preview' && ( + ); + case 'preview': + return (
@@ -579,9 +516,10 @@ export default function ThemeSettings() {
- )} - - {activeTab === 'advanced' && isAdmin && ( + ); + case 'advanced': + if (!isAdmin) return null; + return (
@@ -593,7 +531,6 @@ export default function ThemeSettings() {
- {/* Light Theme Colors */}

{t.theme.lightThemeColors}

@@ -648,7 +585,6 @@ export default function ThemeSettings() {
- {/* Dark Theme Colors */}

{t.theme.darkThemeColors}

@@ -705,9 +641,83 @@ export default function ThemeSettings() {
- )} + ); + default: + return null; + } + }; + + return ( +
+ {/* Tab Tooltip */} + {tooltip.visible && ( +
+ {tooltip.text} +
+ )} + {/* Modern Tab Navigation */} +
+
+ +
+ {t.theme.title} +
+
+ + + + {isAdmin && ( + + )} +
+ + {/* Color Picker Popup */} {colorPickerState && (
div { +.admin-tab-content:not(.swipe-tabs):not(.swipe-panel-content)>div { opacity: 0; animation: tabFadeIn 0.25s ease forwards; min-height: 0; } +.admin-tab-swipe { + width: 100%; + max-width: none; + margin: 0; +} + +/* Swipeable tab panels */ +.swipe-tabs { + position: relative; + background: var(--color-bg-main); + touch-action: pan-y; +} + +.swipe-tabs-track { + display: flex; + width: 100%; +} + +.swipe-tabs-panel { + width: 100%; + min-height: 1px; + background: var(--color-bg-main); +} + +.swipe-tabs--static { + overflow: hidden; +} + +.swipe-tabs--static .swipe-tabs-panel { + flex: 0 0 100%; + overflow: hidden; +} + +.swipe-tabs--snap { + overflow-x: auto; + overflow-y: hidden; + touch-action: pan-x pan-y; + scroll-snap-type: x mandatory; + overscroll-behavior-x: contain; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.swipe-tabs--snap::-webkit-scrollbar { + display: none; +} + +.swipe-tabs--snap .swipe-tabs-panel { + flex: 0 0 100%; + overflow: hidden; + contain: paint; + scroll-snap-align: start; + scroll-snap-stop: always; +} + +.swipe-tabs-panel-inner { + width: 100%; +} + +.admin-tab-content.swipe-tabs { + display: block; +} + +.admin-tab-content.swipe-tabs>div { + opacity: 1; + animation: none; +} + +.swipe-panel-content { + display: block; +} + +.swipe-panel-content>div { + opacity: 1; + animation: none; +} + @keyframes tabFadeIn { 0% { opacity: 0; diff --git a/frontend/src/styles/SettingsPage.css b/frontend/src/styles/SettingsPage.css index 26a4f5b..2c1b9bc 100644 --- a/frontend/src/styles/SettingsPage.css +++ b/frontend/src/styles/SettingsPage.css @@ -13,10 +13,6 @@ font-size: 0.95rem; } -.settings-page-root .page-content { - max-width: 800px; -} - /* Settings Sections - no card, just spacing */ .settings-section { margin-bottom: 3rem; diff --git a/frontend/src/styles/ThemeSettings.css b/frontend/src/styles/ThemeSettings.css index c89d483..487b650 100644 --- a/frontend/src/styles/ThemeSettings.css +++ b/frontend/src/styles/ThemeSettings.css @@ -68,6 +68,11 @@ transition: grid-template-rows 0.25s ease; } +/* Extra side spacing just for the theme editor content */ +.theme-settings-root .admin-tab-content .theme-tab-content { + padding: 0 calc(var(--page-padding-x) * 0.5); +} + /* Smooth transition between tabs */ .theme-tab-content>div { opacity: 0; @@ -1155,6 +1160,10 @@ padding: var(--page-padding-y-tablet) var(--page-padding-x-tablet); } + .theme-settings-root .admin-tab-content .theme-tab-content { + padding: 0 calc(var(--page-padding-x-tablet) * 0.5); + } + .color-grid-enhanced { grid-template-columns: repeat(3, 1fr); gap: 1.25rem; @@ -1182,6 +1191,10 @@ padding: var(--page-padding-y-mobile) var(--page-padding-x-mobile); } + .theme-settings-root .admin-tab-content .theme-tab-content { + padding: 0 calc(var(--page-padding-x-mobile) * 0.5); + } + .theme-section { margin-bottom: 2rem; } diff --git a/frontend/src/styles/theme/dimensions.css b/frontend/src/styles/theme/dimensions.css index 6811515..cd46788 100644 --- a/frontend/src/styles/theme/dimensions.css +++ b/frontend/src/styles/theme/dimensions.css @@ -51,15 +51,15 @@ --sidebar-mobile-width: 280px; /* Page Layout Spacing */ - --page-padding-x: 2rem; + --page-padding-x: 2.5rem; /* Horizontal padding for page content */ - --page-padding-y: 2rem; + --page-padding-y: 2.5rem; /* Vertical padding for page content */ - --page-padding-x-tablet: 1.5rem; - --page-padding-y-tablet: 1.5rem; - --page-padding-x-mobile: 1rem; - --page-padding-y-mobile: 1rem; - --page-max-width: 1400px; + --page-padding-x-tablet: 2rem; + --page-padding-y-tablet: 2rem; + --page-padding-x-mobile: 1.25rem; + --page-padding-y-mobile: 1.25rem; + --page-max-width: 1200px; /* Maximum content width */ /* Container Widths */ @@ -113,6 +113,8 @@ /* increased from 1.25rem */ --section-gap-sm: 1.125rem; /* increased from 0.875rem */ + --section-title-gap: var(--section-gap); + /* Title-to-content spacing for section headers */ /* Semantic Spacing - Elements */ --element-gap: 0.375rem;