Files
app-service/frontend/src/contexts/SidebarContext.tsx
matteoscrugli 18c4760b5d Re-add edge swipe to open sidebar on mobile
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>
2025-12-23 02:28:02 +01:00

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;
}