576 lines
18 KiB
JavaScript
576 lines
18 KiB
JavaScript
// File Path: web/frontend/src/components/Layout/Layout.jsx
|
|
// Fixed Layout Component - Mobile Menu Now Works Properly
|
|
|
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
|
import TopNavbar from "./TopNavbar";
|
|
import Sidebar from "./Sidebar";
|
|
import { useInactivityTimeout } from "../../hooks/useInactivityTimeout";
|
|
import { useAuth } from "../../services/Services";
|
|
import { useUIXTheme } from "../UIX/themes/useUIXTheme";
|
|
|
|
function Layout({ children }) {
|
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
|
const [isMobile, setIsMobile] = useState(false);
|
|
const [isTablet, setIsTablet] = useState(false);
|
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
|
const [isIOS, setIsIOS] = useState(false);
|
|
const [isAndroid, setIsAndroid] = useState(false);
|
|
const [iosKeyboardHeight, setIosKeyboardHeight] = useState(0);
|
|
const [androidKeyboardVisible, setAndroidKeyboardVisible] = useState(false);
|
|
|
|
const { meManager } = useAuth();
|
|
const { switchTheme, getThemeClasses } = useUIXTheme();
|
|
|
|
// Initialize inactivity timeout - auto-logout after 15 minutes of inactivity
|
|
// Will redirect to /logout when timeout is reached
|
|
useInactivityTimeout(15 * 60 * 1000, true);
|
|
|
|
// Load user's theme preference on mount (for authenticated pages)
|
|
useEffect(() => {
|
|
const loadThemePreference = async () => {
|
|
try {
|
|
const userProfile = await meManager.getCurrentUser();
|
|
if (userProfile?.themePreference) {
|
|
if (import.meta.env.DEV) {
|
|
console.log("Layout: Applying user's saved theme preference:", userProfile.themePreference);
|
|
}
|
|
switchTheme(userProfile.themePreference);
|
|
}
|
|
} catch (error) {
|
|
if (import.meta.env.DEV) {
|
|
console.error("Layout: Failed to load theme preference:", error);
|
|
}
|
|
// Don't block the UI if theme loading fails
|
|
}
|
|
};
|
|
|
|
loadThemePreference();
|
|
}, [meManager, switchTheme]); // Run when meManager is available
|
|
|
|
// iOS detection function
|
|
const detectIOS = () => {
|
|
return (
|
|
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
|
|
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1)
|
|
);
|
|
};
|
|
|
|
// Android detection function
|
|
const detectAndroid = () => {
|
|
return /Android/.test(navigator.userAgent);
|
|
};
|
|
|
|
// Enhanced device detection with iOS and Android support (memoized)
|
|
const updateDeviceType = useCallback(() => {
|
|
const width = window.innerWidth;
|
|
const mobile = width < 768;
|
|
const tablet = width >= 768 && width < 1024;
|
|
const ios = detectIOS();
|
|
const android = detectAndroid();
|
|
|
|
setIsMobile(mobile);
|
|
setIsTablet(tablet);
|
|
setIsIOS(ios);
|
|
setIsAndroid(android);
|
|
|
|
return { mobile, tablet, ios, android };
|
|
}, []);
|
|
|
|
// Check localStorage for sidebar collapsed state
|
|
useEffect(() => {
|
|
const savedState = localStorage.getItem("sidebarCollapsed");
|
|
const { mobile } = updateDeviceType();
|
|
|
|
if (savedState === "true" && !mobile) {
|
|
setSidebarCollapsed(true);
|
|
}
|
|
}, [updateDeviceType]);
|
|
|
|
// iOS-specific optimizations
|
|
useEffect(() => {
|
|
if (!isIOS) return;
|
|
|
|
// Fix iOS viewport height issues
|
|
const setIOSViewportHeight = () => {
|
|
const vh = window.innerHeight * 0.01;
|
|
document.documentElement.style.setProperty("--vh", `${vh}px`);
|
|
};
|
|
|
|
// iOS keyboard handling
|
|
const handleIOSKeyboard = () => {
|
|
const initialViewport =
|
|
window.visualViewport?.height || window.innerHeight;
|
|
|
|
const onViewportChange = () => {
|
|
if (window.visualViewport) {
|
|
const currentHeight = window.visualViewport.height;
|
|
const keyboardHeight = initialViewport - currentHeight;
|
|
setIosKeyboardHeight(keyboardHeight > 150 ? keyboardHeight : 0);
|
|
}
|
|
};
|
|
|
|
if (window.visualViewport) {
|
|
window.visualViewport.addEventListener("resize", onViewportChange);
|
|
return () =>
|
|
window.visualViewport.removeEventListener("resize", onViewportChange);
|
|
}
|
|
};
|
|
|
|
// Prevent iOS bounce/overscroll
|
|
const preventBounce = (e) => {
|
|
if (e.target.closest(".sidebar-content, .main-content")) return;
|
|
e.preventDefault();
|
|
};
|
|
|
|
// Apply iOS fixes
|
|
setIOSViewportHeight();
|
|
const keyboardCleanup = handleIOSKeyboard();
|
|
|
|
// Prevent zoom on double tap
|
|
let lastTouchEnd = 0;
|
|
const preventZoom = (e) => {
|
|
const now = Date.now();
|
|
if (now - lastTouchEnd <= 300) {
|
|
e.preventDefault();
|
|
}
|
|
lastTouchEnd = now;
|
|
};
|
|
|
|
// Add event listeners
|
|
window.addEventListener("resize", setIOSViewportHeight);
|
|
document.addEventListener("touchend", preventZoom, { passive: false });
|
|
document.addEventListener("touchmove", preventBounce, { passive: false });
|
|
|
|
return () => {
|
|
window.removeEventListener("resize", setIOSViewportHeight);
|
|
document.removeEventListener("touchend", preventZoom);
|
|
document.removeEventListener("touchmove", preventBounce);
|
|
keyboardCleanup?.();
|
|
};
|
|
}, [isIOS]);
|
|
|
|
// Android-specific optimizations
|
|
useEffect(() => {
|
|
if (!isAndroid) return;
|
|
|
|
// Android viewport height fixes for Chrome Mobile
|
|
const setAndroidViewportHeight = () => {
|
|
// Chrome on Android has dynamic toolbar behavior
|
|
const vh = window.innerHeight * 0.01;
|
|
document.documentElement.style.setProperty("--android-vh", `${vh}px`);
|
|
};
|
|
|
|
// Android keyboard detection (different approach than iOS)
|
|
const handleAndroidKeyboard = () => {
|
|
const initialHeight = window.innerHeight;
|
|
let resizeTimer;
|
|
|
|
const onResize = () => {
|
|
clearTimeout(resizeTimer);
|
|
resizeTimer = setTimeout(() => {
|
|
const currentHeight = window.innerHeight;
|
|
const heightDifference = initialHeight - currentHeight;
|
|
|
|
// Android keyboard typically reduces viewport by 150px+
|
|
const keyboardVisible = heightDifference > 150;
|
|
setAndroidKeyboardVisible(keyboardVisible);
|
|
|
|
// Update viewport height
|
|
setAndroidViewportHeight();
|
|
}, 100); // Debounce for performance
|
|
};
|
|
|
|
window.addEventListener("resize", onResize);
|
|
return () => {
|
|
window.removeEventListener("resize", onResize);
|
|
clearTimeout(resizeTimer);
|
|
};
|
|
};
|
|
|
|
// Android Chrome scroll behavior optimization
|
|
const optimizeAndroidScroll = () => {
|
|
// Prevent overscroll in Android Chrome
|
|
const preventOverscroll = (e) => {
|
|
const target = e.target;
|
|
const scrollableParent = target.closest(
|
|
".scrollable, .main-content, .sidebar-content",
|
|
);
|
|
|
|
if (!scrollableParent) {
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
|
|
const { scrollTop, scrollHeight, clientHeight } = scrollableParent;
|
|
const isAtTop = scrollTop === 0;
|
|
const isAtBottom = scrollTop + clientHeight >= scrollHeight;
|
|
|
|
if ((isAtTop && e.deltaY < 0) || (isAtBottom && e.deltaY > 0)) {
|
|
e.preventDefault();
|
|
}
|
|
};
|
|
|
|
// Android-specific touch optimizations
|
|
const optimizeTouch = () => {
|
|
// Improve scrolling performance on Android
|
|
document.body.style.touchAction = "pan-y";
|
|
document.body.style.overscrollBehavior = "none";
|
|
};
|
|
|
|
optimizeTouch();
|
|
document.addEventListener("wheel", preventOverscroll, { passive: false });
|
|
|
|
return () => {
|
|
document.removeEventListener("wheel", preventOverscroll);
|
|
};
|
|
};
|
|
|
|
// Apply Android fixes
|
|
setAndroidViewportHeight();
|
|
const keyboardCleanup = handleAndroidKeyboard();
|
|
const scrollCleanup = optimizeAndroidScroll();
|
|
|
|
return () => {
|
|
keyboardCleanup?.();
|
|
scrollCleanup?.();
|
|
};
|
|
}, [isAndroid]);
|
|
|
|
// Listen for storage changes to sync collapsed state
|
|
useEffect(() => {
|
|
const handleStorageChange = () => {
|
|
const savedState = localStorage.getItem("sidebarCollapsed");
|
|
setSidebarCollapsed(savedState === "true");
|
|
};
|
|
|
|
window.addEventListener("storage", handleStorageChange);
|
|
// Also listen for custom event for same-tab updates
|
|
window.addEventListener("sidebarCollapsedChanged", handleStorageChange);
|
|
|
|
return () => {
|
|
window.removeEventListener("storage", handleStorageChange);
|
|
window.removeEventListener(
|
|
"sidebarCollapsedChanged",
|
|
handleStorageChange,
|
|
);
|
|
};
|
|
}, []);
|
|
|
|
// FIXED: Resize handler with proper dependencies and refs to prevent infinite loops
|
|
useEffect(() => {
|
|
const handleResize = () => {
|
|
const width = window.innerWidth;
|
|
const wasMobile = isMobile;
|
|
const mobile = width < 768;
|
|
const tablet = width >= 768 && width < 1024;
|
|
|
|
setIsMobile(mobile);
|
|
setIsTablet(tablet);
|
|
setIsIOS(detectIOS());
|
|
setIsAndroid(detectAndroid());
|
|
|
|
// Only close sidebar when transitioning FROM desktop TO mobile
|
|
// Use callback to get current sidebar state to avoid stale closure
|
|
setIsSidebarOpen(currentIsOpen => {
|
|
if (!wasMobile && mobile && currentIsOpen) {
|
|
return false;
|
|
}
|
|
return currentIsOpen;
|
|
});
|
|
|
|
// Reset collapsed state when becoming mobile
|
|
if (!wasMobile && mobile) {
|
|
setSidebarCollapsed(false);
|
|
}
|
|
};
|
|
|
|
// Initial device detection
|
|
handleResize();
|
|
|
|
// Only listen to actual window resize events
|
|
window.addEventListener("resize", handleResize);
|
|
return () => window.removeEventListener("resize", handleResize);
|
|
}, [isMobile]); // Include isMobile but use functional state updates to avoid loops
|
|
|
|
// Track last toggle time to prevent double-clicks
|
|
const lastToggleRef = useRef(0);
|
|
|
|
const handleMenuToggle = useCallback(() => {
|
|
const now = Date.now();
|
|
// Prevent double-clicks only (100ms window)
|
|
if (now - lastToggleRef.current < 100) {
|
|
return;
|
|
}
|
|
lastToggleRef.current = now;
|
|
setIsSidebarOpen(current => !current);
|
|
}, []);
|
|
|
|
const handleSidebarClose = useCallback(() => {
|
|
setIsSidebarOpen(false);
|
|
}, []);
|
|
|
|
// Track last collapse toggle time to prevent double-clicks
|
|
const lastCollapseToggleRef = useRef(0);
|
|
|
|
// Handle collapse toggle for desktop view
|
|
const handleCollapseToggle = useCallback(() => {
|
|
const now = Date.now();
|
|
// Prevent double-clicks only (100ms window)
|
|
if (now - lastCollapseToggleRef.current < 100) {
|
|
return;
|
|
}
|
|
lastCollapseToggleRef.current = now;
|
|
setSidebarCollapsed(current => {
|
|
const newCollapsedState = !current;
|
|
localStorage.setItem("sidebarCollapsed", newCollapsedState.toString());
|
|
window.dispatchEvent(new Event("sidebarCollapsedChanged"));
|
|
return newCollapsedState;
|
|
});
|
|
}, []);
|
|
|
|
// Calculate sidebar margins based on device type and state
|
|
const getSidebarMargin = () => {
|
|
if (isMobile) return "";
|
|
|
|
if (isTablet) {
|
|
// Tablet: smaller sidebar widths
|
|
return sidebarCollapsed ? "ml-12" : "ml-48";
|
|
}
|
|
|
|
// Desktop: full sidebar widths
|
|
return sidebarCollapsed ? "ml-16" : "ml-64";
|
|
};
|
|
|
|
// Calculate responsive padding based on device
|
|
const getContentPadding = () => {
|
|
if (isMobile) {
|
|
return "px-3 sm:px-4 py-4";
|
|
}
|
|
if (isTablet) {
|
|
return "px-4 md:px-6 py-5";
|
|
}
|
|
// Desktop and larger screens
|
|
return "px-4 sm:px-6 lg:px-8 py-6";
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={`
|
|
min-h-screen ${getThemeClasses("bg-page")}
|
|
${isIOS ? "ios-layout" : ""}
|
|
${isAndroid ? "android-layout" : ""}
|
|
`}
|
|
style={{
|
|
...(isIOS && {
|
|
height: "calc(var(--vh, 1vh) * 100)",
|
|
paddingTop: "env(safe-area-inset-top)",
|
|
paddingBottom: "env(safe-area-inset-bottom)",
|
|
paddingLeft: "env(safe-area-inset-left)",
|
|
paddingRight: "env(safe-area-inset-right)",
|
|
WebkitOverflowScrolling: "touch",
|
|
touchAction: "manipulation",
|
|
}),
|
|
...(isAndroid && {
|
|
height: "calc(var(--android-vh, 1vh) * 100)",
|
|
touchAction: "pan-y",
|
|
overscrollBehavior: "none",
|
|
WebkitOverflowScrolling: "touch",
|
|
}),
|
|
}}
|
|
>
|
|
{/* Top Navigation Bar */}
|
|
<TopNavbar
|
|
onMenuToggle={handleMenuToggle}
|
|
isMobile={isMobile}
|
|
isTablet={isTablet}
|
|
isIOS={isIOS}
|
|
isAndroid={isAndroid}
|
|
isSidebarOpen={isSidebarOpen}
|
|
sidebarCollapsed={sidebarCollapsed}
|
|
onCollapseToggle={handleCollapseToggle}
|
|
/>
|
|
|
|
{/* Sidebar */}
|
|
<Sidebar
|
|
isOpen={isSidebarOpen}
|
|
onClose={handleSidebarClose}
|
|
isMobile={isMobile}
|
|
isTablet={isTablet}
|
|
sidebarCollapsed={sidebarCollapsed}
|
|
onCollapseToggle={handleCollapseToggle}
|
|
isIOS={isIOS}
|
|
isAndroid={isAndroid}
|
|
/>
|
|
|
|
{/* Main Content Area */}
|
|
<main
|
|
className={`
|
|
pt-[60px] transition-all duration-300 ease-in-out
|
|
main-content scrollable
|
|
${isIOS ? "ios-main-content" : ""}
|
|
${isAndroid ? "android-main-content" : ""}
|
|
`}
|
|
style={{
|
|
...(isIOS && {
|
|
minHeight:
|
|
iosKeyboardHeight > 0
|
|
? `calc(var(--vh, 1vh) * 100 - 60px - ${iosKeyboardHeight}px)`
|
|
: "calc(var(--vh, 1vh) * 100 - 60px)",
|
|
WebkitOverflowScrolling: "touch",
|
|
overflowY: "auto",
|
|
}),
|
|
...(isAndroid && {
|
|
minHeight: androidKeyboardVisible
|
|
? "calc(var(--android-vh, 1vh) * 50)"
|
|
: "calc(var(--android-vh, 1vh) * 100 - 60px)",
|
|
WebkitOverflowScrolling: "touch",
|
|
overflowY: "auto",
|
|
touchAction: "pan-y",
|
|
overscrollBehavior: "contain",
|
|
}),
|
|
...(!isIOS &&
|
|
!isAndroid && {
|
|
minHeight: "calc(100vh - 60px)",
|
|
}),
|
|
}}
|
|
>
|
|
{/* Responsive margin based on device and sidebar state */}
|
|
<div
|
|
className={`
|
|
transition-all duration-300 ease-in-out
|
|
${getSidebarMargin()}
|
|
`}
|
|
>
|
|
{/* Content Container */}
|
|
<div
|
|
className={`
|
|
${getContentPadding()}
|
|
max-w-full
|
|
${isIOS ? "ios-content-container" : ""}
|
|
${isAndroid ? "android-content-container" : ""}
|
|
${isMobile ? "min-h-[calc(100vh-120px)]" : "min-h-[calc(100vh-80px)]"}
|
|
`}
|
|
style={{
|
|
...(isIOS &&
|
|
isMobile && {
|
|
minHeight:
|
|
iosKeyboardHeight > 0
|
|
? `calc(var(--vh, 1vh) * 100 - 180px - ${iosKeyboardHeight}px)`
|
|
: "calc(var(--vh, 1vh) * 100 - 120px)",
|
|
touchAction: "pan-y",
|
|
}),
|
|
...(isAndroid &&
|
|
isMobile && {
|
|
minHeight: androidKeyboardVisible
|
|
? "calc(var(--android-vh, 1vh) * 40)"
|
|
: "calc(var(--android-vh, 1vh) * 100 - 120px)",
|
|
touchAction: "pan-y",
|
|
overscrollBehavior: "contain",
|
|
}),
|
|
}}
|
|
>
|
|
{/* Responsive container for content */}
|
|
<div
|
|
className={`
|
|
w-full
|
|
${!isMobile ? "max-w-[1400px] mx-auto" : ""}
|
|
${isTablet ? "max-w-[900px] mx-auto" : ""}
|
|
`}
|
|
>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
{/* Mobile overlay when sidebar is open - REMOVED the redundant one that was here */}
|
|
|
|
{/* iOS and Android specific styles */}
|
|
{(isIOS || isAndroid) && (
|
|
<style>{`
|
|
/* iOS Optimizations */
|
|
.ios-layout {
|
|
-webkit-user-select: none;
|
|
-webkit-touch-callout: none;
|
|
-webkit-tap-highlight-color: transparent;
|
|
}
|
|
|
|
.ios-main-content {
|
|
-webkit-overflow-scrolling: touch;
|
|
overflow-scrolling: touch;
|
|
}
|
|
|
|
.ios-content-container * {
|
|
-webkit-transform: translateZ(0);
|
|
transform: translateZ(0);
|
|
}
|
|
|
|
@supports (-webkit-backdrop-filter: blur(10px)) {
|
|
.ios-layout .bg-white {
|
|
background-color: rgba(255, 255, 255, 0.8);
|
|
-webkit-backdrop-filter: blur(10px);
|
|
backdrop-filter: blur(10px);
|
|
}
|
|
}
|
|
|
|
/* Android Optimizations */
|
|
.android-layout {
|
|
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);
|
|
-webkit-font-smoothing: antialiased;
|
|
-moz-osx-font-smoothing: grayscale;
|
|
}
|
|
|
|
.android-main-content {
|
|
-webkit-overflow-scrolling: touch;
|
|
overflow-scrolling: touch;
|
|
scroll-behavior: smooth;
|
|
will-change: scroll-position;
|
|
}
|
|
|
|
.android-content-container {
|
|
contain: layout style paint;
|
|
-webkit-transform: translateZ(0);
|
|
transform: translateZ(0);
|
|
}
|
|
|
|
/* Android Chrome specific fixes */
|
|
@media screen and (-webkit-device-pixel-ratio: 1) {
|
|
.android-layout {
|
|
-webkit-transform: translateZ(0);
|
|
transform: translateZ(0);
|
|
}
|
|
}
|
|
|
|
/* Android keyboard adjustments */
|
|
.android-layout.keyboard-visible {
|
|
height: auto !important;
|
|
min-height: auto !important;
|
|
}
|
|
|
|
/* Improve Android scroll performance */
|
|
.android-layout .scrollable {
|
|
-webkit-overflow-scrolling: touch;
|
|
transform: translateZ(0);
|
|
will-change: scroll-position;
|
|
}
|
|
|
|
/* Android-specific button optimizations */
|
|
.android-layout button {
|
|
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.1);
|
|
touch-action: manipulation;
|
|
}
|
|
|
|
/* Android Chrome address bar handling */
|
|
@media screen and (max-width: 768px) {
|
|
.android-layout {
|
|
height: 100vh;
|
|
height: calc(var(--android-vh, 1vh) * 100);
|
|
}
|
|
}
|
|
`}</style>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default Layout;
|