Add swipe-to-open sidebar on first tab
When swiping right on the first tab, opens the mobile sidebar menu. Added onSwipePastStart prop to SwipeTabs component. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@ type SwipeTabsProps<T extends string | number> = {
|
|||||||
threshold?: number;
|
threshold?: number;
|
||||||
renderWindow?: number;
|
renderWindow?: number;
|
||||||
scrollToTopOnChange?: boolean;
|
scrollToTopOnChange?: boolean;
|
||||||
|
onSwipePastStart?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DRAG_START_DISTANCE = 3;
|
const DRAG_START_DISTANCE = 3;
|
||||||
@@ -57,7 +58,8 @@ export function SwipeTabs<T extends string | number>({
|
|||||||
swipeDisabled = false,
|
swipeDisabled = false,
|
||||||
threshold = DEFAULT_THRESHOLD,
|
threshold = DEFAULT_THRESHOLD,
|
||||||
renderWindow = DEFAULT_RENDER_WINDOW,
|
renderWindow = DEFAULT_RENDER_WINDOW,
|
||||||
scrollToTopOnChange = true
|
scrollToTopOnChange = true,
|
||||||
|
onSwipePastStart
|
||||||
}: SwipeTabsProps<T>) {
|
}: SwipeTabsProps<T>) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const trackRef = useRef<HTMLDivElement>(null);
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -336,6 +338,11 @@ export function SwipeTabs<T extends string | number>({
|
|||||||
if (!cancelled && Math.abs(dx) > thresholdPx) {
|
if (!cancelled && Math.abs(dx) > thresholdPx) {
|
||||||
const direction = dx < 0 ? 1 : -1;
|
const direction = dx < 0 ? 1 : -1;
|
||||||
targetIndex = Math.min(Math.max(state.startIndex + direction, 0), tabs.length - 1);
|
targetIndex = Math.min(Math.max(state.startIndex + direction, 0), tabs.length - 1);
|
||||||
|
|
||||||
|
// If swiping right on first tab, call onSwipePastStart
|
||||||
|
if (state.startIndex === 0 && dx > 0 && onSwipePastStart) {
|
||||||
|
onSwipePastStart();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dragOffsetRef.current = 0;
|
dragOffsetRef.current = 0;
|
||||||
@@ -354,7 +361,7 @@ export function SwipeTabs<T extends string | number>({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeTab, applyTransform, onTabChange, setDraggingClass, tabs, threshold]
|
[activeTab, applyTransform, onTabChange, onSwipePastStart, setDraggingClass, tabs, threshold]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
|
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useAuth } from './AuthContext';
|
import { useAuth } from './AuthContext';
|
||||||
import { settingsAPI } from '../api/client';
|
import { settingsAPI } from '../api/client';
|
||||||
|
|
||||||
const EDGE_SWIPE_THRESHOLD = 50; // pixels from left edge to start swipe
|
|
||||||
const SWIPE_MIN_DISTANCE = 40; // minimum swipe distance to trigger
|
|
||||||
|
|
||||||
export type SidebarMode = 'collapsed' | 'expanded' | 'toggle' | 'dynamic';
|
export type SidebarMode = 'collapsed' | 'expanded' | 'toggle' | 'dynamic';
|
||||||
|
|
||||||
interface SidebarContextType {
|
interface SidebarContextType {
|
||||||
@@ -15,6 +12,7 @@ interface SidebarContextType {
|
|||||||
canToggle: boolean;
|
canToggle: boolean;
|
||||||
toggleCollapse: () => void;
|
toggleCollapse: () => void;
|
||||||
toggleMobileMenu: () => void;
|
toggleMobileMenu: () => void;
|
||||||
|
openMobileMenu: () => void;
|
||||||
closeMobileMenu: () => void;
|
closeMobileMenu: () => void;
|
||||||
setSidebarMode: (mode: SidebarMode) => Promise<void>;
|
setSidebarMode: (mode: SidebarMode) => Promise<void>;
|
||||||
isHovered: boolean;
|
isHovered: boolean;
|
||||||
@@ -116,65 +114,6 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
|
|||||||
setIsMobileOpen(true);
|
setIsMobileOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Edge swipe detection for mobile
|
|
||||||
const swipeRef = useRef({
|
|
||||||
startX: 0,
|
|
||||||
startY: 0,
|
|
||||||
isEdgeSwipe: false,
|
|
||||||
isMobile: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const updateMobile = () => {
|
|
||||||
swipeRef.current.isMobile = window.innerWidth <= 768;
|
|
||||||
};
|
|
||||||
updateMobile();
|
|
||||||
window.addEventListener('resize', updateMobile, { passive: true });
|
|
||||||
|
|
||||||
const handleTouchStart = (e: TouchEvent) => {
|
|
||||||
if (!swipeRef.current.isMobile) return;
|
|
||||||
const touch = e.touches[0];
|
|
||||||
if (!touch) return;
|
|
||||||
|
|
||||||
if (touch.clientX <= EDGE_SWIPE_THRESHOLD) {
|
|
||||||
swipeRef.current.startX = touch.clientX;
|
|
||||||
swipeRef.current.startY = touch.clientY;
|
|
||||||
swipeRef.current.isEdgeSwipe = true;
|
|
||||||
} else {
|
|
||||||
swipeRef.current.isEdgeSwipe = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTouchMove = (e: TouchEvent) => {
|
|
||||||
if (!swipeRef.current.isEdgeSwipe) return;
|
|
||||||
const touch = e.touches[0];
|
|
||||||
if (!touch) return;
|
|
||||||
|
|
||||||
const dx = touch.clientX - swipeRef.current.startX;
|
|
||||||
const dy = Math.abs(touch.clientY - swipeRef.current.startY);
|
|
||||||
|
|
||||||
if (dx > SWIPE_MIN_DISTANCE && dx > dy * 2) {
|
|
||||||
openMobileMenu();
|
|
||||||
swipeRef.current.isEdgeSwipe = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTouchEnd = () => {
|
|
||||||
swipeRef.current.isEdgeSwipe = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('touchstart', handleTouchStart, { passive: true });
|
|
||||||
document.addEventListener('touchmove', handleTouchMove, { passive: true });
|
|
||||||
document.addEventListener('touchend', handleTouchEnd, { passive: true });
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('resize', updateMobile);
|
|
||||||
document.removeEventListener('touchstart', handleTouchStart);
|
|
||||||
document.removeEventListener('touchmove', handleTouchMove);
|
|
||||||
document.removeEventListener('touchend', handleTouchEnd);
|
|
||||||
};
|
|
||||||
}, [openMobileMenu]);
|
|
||||||
|
|
||||||
const setSidebarMode = useCallback(async (mode: SidebarMode) => {
|
const setSidebarMode = useCallback(async (mode: SidebarMode) => {
|
||||||
try {
|
try {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
@@ -194,6 +133,7 @@ export function SidebarProvider({ children }: { children: ReactNode }) {
|
|||||||
canToggle,
|
canToggle,
|
||||||
toggleCollapse,
|
toggleCollapse,
|
||||||
toggleMobileMenu,
|
toggleMobileMenu,
|
||||||
|
openMobileMenu,
|
||||||
closeMobileMenu,
|
closeMobileMenu,
|
||||||
setSidebarMode,
|
setSidebarMode,
|
||||||
isHovered,
|
isHovered,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ type TabId = 'general' | 'users';
|
|||||||
export default function AdminPanel({ initialTab = 'general' }: { initialTab?: TabId } = {}) {
|
export default function AdminPanel({ initialTab = 'general' }: { initialTab?: TabId } = {}) {
|
||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser } = useAuth();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { toggleMobileMenu } = useSidebar();
|
const { toggleMobileMenu, openMobileMenu } = useSidebar();
|
||||||
const [activeTab, setActiveTab] = useState<TabId>(initialTab);
|
const [activeTab, setActiveTab] = useState<TabId>(initialTab);
|
||||||
const tabs: TabId[] = ['general', 'users'];
|
const tabs: TabId[] = ['general', 'users'];
|
||||||
const renderPanel = (tab: TabId) => (tab === 'general' ? <GeneralTab /> : <UsersTab />);
|
const renderPanel = (tab: TabId) => (tab === 'general' ? <GeneralTab /> : <UsersTab />);
|
||||||
@@ -57,6 +57,7 @@ export default function AdminPanel({ initialTab = 'general' }: { initialTab?: Ta
|
|||||||
onTabChange={setActiveTab}
|
onTabChange={setActiveTab}
|
||||||
renderPanel={renderPanel}
|
renderPanel={renderPanel}
|
||||||
panelClassName="admin-tab-content swipe-panel-content"
|
panelClassName="admin-tab-content swipe-panel-content"
|
||||||
|
onSwipePastStart={openMobileMenu}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const findTouchById = (touches: TouchList, id: number) => {
|
|||||||
export default function Features() {
|
export default function Features() {
|
||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser } = useAuth();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { toggleMobileMenu } = useSidebar();
|
const { toggleMobileMenu, openMobileMenu } = useSidebar();
|
||||||
const { moduleStates, moduleOrder, setModuleEnabled, setModulePosition, setModuleOrder, saveModulesToBackend, saveModuleOrder, hasInitialized, isLoading } = useModules();
|
const { moduleStates, moduleOrder, setModuleEnabled, setModulePosition, setModuleOrder, saveModulesToBackend, saveModuleOrder, hasInitialized, isLoading } = useModules();
|
||||||
const [activeTab, setActiveTab] = useState<TabId>('config');
|
const [activeTab, setActiveTab] = useState<TabId>('config');
|
||||||
const hasUserMadeChanges = useRef(false);
|
const hasUserMadeChanges = useRef(false);
|
||||||
@@ -785,6 +785,7 @@ export default function Features() {
|
|||||||
onTabChange={handleTabChange}
|
onTabChange={handleTabChange}
|
||||||
renderPanel={renderTabContent}
|
renderPanel={renderTabContent}
|
||||||
panelClassName="admin-tab-content swipe-panel-content"
|
panelClassName="admin-tab-content swipe-panel-content"
|
||||||
|
onSwipePastStart={openMobileMenu}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export default function ThemeSettings() {
|
|||||||
} = useTheme();
|
} = useTheme();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { toggleMobileMenu, sidebarMode, setSidebarMode } = useSidebar();
|
const { toggleMobileMenu, openMobileMenu, sidebarMode, setSidebarMode } = useSidebar();
|
||||||
const isAdmin = user?.is_superuser || false;
|
const isAdmin = user?.is_superuser || false;
|
||||||
const tabs: ThemeTab[] = isAdmin ? ['colors', 'appearance', 'preview', 'advanced'] : ['colors', 'appearance', 'preview'];
|
const tabs: ThemeTab[] = isAdmin ? ['colors', 'appearance', 'preview', 'advanced'] : ['colors', 'appearance', 'preview'];
|
||||||
const [tooltip, setTooltip] = useState<{ text: string; left: number; visible: boolean }>({
|
const [tooltip, setTooltip] = useState<{ text: string; left: number; visible: boolean }>({
|
||||||
@@ -764,6 +764,7 @@ export default function ThemeSettings() {
|
|||||||
onTabChange={setActiveTab}
|
onTabChange={setActiveTab}
|
||||||
renderPanel={renderPanel}
|
renderPanel={renderPanel}
|
||||||
panelClassName="admin-tab-content swipe-panel-content"
|
panelClassName="admin-tab-content swipe-panel-content"
|
||||||
|
onSwipePastStart={openMobileMenu}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Color Picker Popup */}
|
{/* Color Picker Popup */}
|
||||||
|
|||||||
Reference in New Issue
Block a user