Swipe from left edge (50px) to open sidebar menu 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
217 lines
6.6 KiB
TypeScript
217 lines
6.6 KiB
TypeScript
import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
|
|
import type { ReactNode } from 'react';
|
|
import { useAuth } from './AuthContext';
|
|
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';
|
|
|
|
interface SidebarContextType {
|
|
isCollapsed: boolean;
|
|
isMobileOpen: boolean;
|
|
sidebarMode: SidebarMode;
|
|
canToggle: boolean;
|
|
toggleCollapse: () => void;
|
|
toggleMobileMenu: () => void;
|
|
closeMobileMenu: () => void;
|
|
setSidebarMode: (mode: SidebarMode) => Promise<void>;
|
|
isHovered: boolean;
|
|
setIsHovered: (isHovered: boolean) => void;
|
|
showLogo: boolean;
|
|
setShowLogo: (show: boolean) => void;
|
|
}
|
|
|
|
const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
|
|
|
|
export function SidebarProvider({ children }: { children: ReactNode }) {
|
|
const { token } = useAuth();
|
|
const [userCollapsed, setUserCollapsed] = useState(() => {
|
|
const key = 'user_sidebar_collapsed';
|
|
const saved = localStorage.getItem(key);
|
|
if (saved === 'true') return true;
|
|
if (saved === 'false') return false;
|
|
|
|
// Backward-compatibility: migrate legacy key
|
|
const legacy = localStorage.getItem('sidebarCollapsed');
|
|
if (legacy === 'true' || legacy === 'false') {
|
|
localStorage.setItem(key, legacy);
|
|
localStorage.removeItem('sidebarCollapsed');
|
|
return legacy === 'true';
|
|
}
|
|
|
|
return true;
|
|
});
|
|
const [isMobileOpen, setIsMobileOpen] = useState(false);
|
|
const [sidebarMode, setSidebarModeState] = useState<SidebarMode>('toggle');
|
|
const [isHovered, setIsHovered] = useState(false);
|
|
const [showLogo, setShowLogo] = useState(false);
|
|
|
|
// Load sidebar mode from backend
|
|
useEffect(() => {
|
|
const loadSidebarMode = async () => {
|
|
try {
|
|
if (!token) {
|
|
setSidebarModeState('toggle');
|
|
setShowLogo(false);
|
|
return;
|
|
}
|
|
|
|
const data = await settingsAPI.getUi();
|
|
const parseBool = (val: any, defaultVal: boolean): boolean => {
|
|
if (val === undefined || val === null) return defaultVal;
|
|
if (val === true || val === 'true' || val === 'True' || val === 1 || val === '1') return true;
|
|
if (val === false || val === 'false' || val === 'False' || val === 0 || val === '0') return false;
|
|
return defaultVal;
|
|
};
|
|
|
|
if (data.sidebar_mode && ['collapsed', 'expanded', 'toggle', 'dynamic'].includes(data.sidebar_mode as string)) {
|
|
setSidebarModeState(data.sidebar_mode as SidebarMode);
|
|
}
|
|
if (data.show_logo !== undefined) {
|
|
setShowLogo(parseBool(data.show_logo, false));
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load sidebar mode:', error);
|
|
}
|
|
};
|
|
loadSidebarMode();
|
|
}, [token]);
|
|
|
|
// Compute isCollapsed based on mode
|
|
const isCollapsed = sidebarMode === 'collapsed' ? true :
|
|
sidebarMode === 'dynamic' ? true :
|
|
sidebarMode === 'expanded' ? false :
|
|
userCollapsed;
|
|
|
|
// Can only toggle if mode is 'toggle'
|
|
const canToggle = sidebarMode === 'toggle';
|
|
|
|
// Sync collapsed state to document root for CSS selectors
|
|
useEffect(() => {
|
|
const root = document.documentElement;
|
|
root.setAttribute('data-sidebar-collapsed', String(isCollapsed));
|
|
}, [isCollapsed]);
|
|
|
|
const toggleCollapse = () => {
|
|
if (canToggle) {
|
|
setUserCollapsed((prev) => {
|
|
const next = !prev;
|
|
localStorage.setItem('user_sidebar_collapsed', String(next));
|
|
return next;
|
|
});
|
|
}
|
|
};
|
|
|
|
const toggleMobileMenu = () => {
|
|
setIsMobileOpen((prev) => !prev);
|
|
};
|
|
|
|
const closeMobileMenu = () => {
|
|
setIsMobileOpen(false);
|
|
};
|
|
|
|
const openMobileMenu = useCallback(() => {
|
|
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) => {
|
|
try {
|
|
if (!token) return;
|
|
await settingsAPI.updateSetting('sidebar_mode', mode);
|
|
setSidebarModeState(mode);
|
|
} catch (error) {
|
|
console.error('Failed to save sidebar mode:', error);
|
|
}
|
|
}, [token]);
|
|
|
|
return (
|
|
<SidebarContext.Provider
|
|
value={{
|
|
isCollapsed,
|
|
isMobileOpen,
|
|
sidebarMode,
|
|
canToggle,
|
|
toggleCollapse,
|
|
toggleMobileMenu,
|
|
closeMobileMenu,
|
|
setSidebarMode,
|
|
isHovered,
|
|
setIsHovered: (value: boolean) => setIsHovered(value),
|
|
showLogo,
|
|
setShowLogo,
|
|
}}
|
|
>
|
|
{children}
|
|
</SidebarContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useSidebar() {
|
|
const context = useContext(SidebarContext);
|
|
if (context === undefined) {
|
|
throw new Error('useSidebar must be used within a SidebarProvider');
|
|
}
|
|
return context;
|
|
}
|