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; isHovered: boolean; setIsHovered: (isHovered: boolean) => void; showLogo: boolean; setShowLogo: (show: boolean) => void; } const SidebarContext = createContext(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('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 ( setIsHovered(value), showLogo, setShowLogo, }} > {children} ); } export function useSidebar() { const context = useContext(SidebarContext); if (context === undefined) { throw new Error('useSidebar must be used within a SidebarProvider'); } return context; }