Files
bakery-ia/frontend/src/components/layout/AppShell/AppShell.tsx

276 lines
8.0 KiB
TypeScript
Raw Normal View History

2025-08-28 10:41:04 +02:00
import React, { useState, useCallback, forwardRef } from 'react';
import { clsx } from 'clsx';
import { useAuthUser, useIsAuthenticated } from '../../../stores';
import { useTheme } from '../../../contexts/ThemeContext';
import { Header } from '../Header';
import { Sidebar } from '../Sidebar';
import { Footer } from '../Footer';
export interface AppShellProps {
children: React.ReactNode;
className?: string;
/**
* Control sidebar visibility
*/
showSidebar?: boolean;
/**
* Control header visibility
*/
showHeader?: boolean;
/**
* Control footer visibility
*/
showFooter?: boolean;
/**
* Make the layout fullscreen (hide header and footer)
*/
fullScreen?: boolean;
/**
* Add padding to the main content area
*/
padded?: boolean;
/**
* Initial sidebar collapsed state
*/
initialSidebarCollapsed?: boolean;
/**
* Custom loading component
*/
loadingComponent?: React.ReactNode;
/**
* Custom error boundary
*/
errorBoundary?: React.ComponentType<{ children: React.ReactNode; error?: Error }>;
}
export interface AppShellRef {
toggleSidebar: () => void;
collapseSidebar: () => void;
expandSidebar: () => void;
isSidebarOpen: boolean;
isSidebarCollapsed: boolean;
}
/**
* AppShell - Main application shell that combines header, sidebar, and main content area
*
* Features:
* - Responsive layout with collapsible sidebar
* - Mobile-friendly with drawer overlay
* - Content area that adjusts to sidebar state
* - Supports fullscreen mode for special pages
* - Integrated with authentication and theming
*/
export const AppShell = forwardRef<AppShellRef, AppShellProps>(({
children,
className,
showSidebar = true,
showHeader = true,
showFooter = true,
fullScreen = false,
padded = true,
initialSidebarCollapsed = false,
loadingComponent,
errorBoundary: ErrorBoundary,
}, ref) => {
const isAuthenticated = useIsAuthenticated();
const authLoading = false; // Since we're in a protected route, auth loading should be false
const { resolvedTheme } = useTheme();
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(initialSidebarCollapsed);
const [error, setError] = useState<Error | null>(null);
// Sidebar control functions
const toggleSidebar = useCallback(() => {
if (window.innerWidth < 1024) {
// Mobile: toggle drawer
setIsSidebarOpen(prev => !prev);
} else {
// Desktop: toggle collapsed state
setIsSidebarCollapsed(prev => !prev);
}
}, []);
const collapseSidebar = useCallback(() => {
if (window.innerWidth < 1024) {
setIsSidebarOpen(false);
} else {
setIsSidebarCollapsed(true);
}
}, []);
const expandSidebar = useCallback(() => {
if (window.innerWidth < 1024) {
setIsSidebarOpen(true);
} else {
setIsSidebarCollapsed(false);
}
}, []);
// Close mobile sidebar when clicking outside
const handleOverlayClick = useCallback(() => {
if (window.innerWidth < 1024) {
setIsSidebarOpen(false);
}
}, []);
// Expose ref methods
React.useImperativeHandle(ref, () => ({
toggleSidebar,
collapseSidebar,
expandSidebar,
isSidebarOpen,
isSidebarCollapsed,
}), [toggleSidebar, collapseSidebar, expandSidebar, isSidebarOpen, isSidebarCollapsed]);
2025-08-28 23:40:44 +02:00
// Handle responsive sidebar state and prevent body scroll on mobile
2025-08-28 10:41:04 +02:00
React.useEffect(() => {
const handleResize = () => {
if (window.innerWidth >= 1024) {
// Desktop: close mobile drawer
setIsSidebarOpen(false);
}
};
2025-08-28 23:40:44 +02:00
// Prevent body scroll when mobile sidebar is open
if (isSidebarOpen && window.innerWidth < 1024) {
document.body.style.overflow = 'hidden';
document.body.style.position = 'fixed';
document.body.style.width = '100%';
} else {
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
}
2025-08-28 10:41:04 +02:00
window.addEventListener('resize', handleResize);
2025-08-28 23:40:44 +02:00
return () => {
window.removeEventListener('resize', handleResize);
// Cleanup on unmount
document.body.style.overflow = '';
document.body.style.position = '';
document.body.style.width = '';
};
}, [isSidebarOpen]);
2025-08-28 10:41:04 +02:00
// Error boundary handling
React.useEffect(() => {
const handleError = (event: ErrorEvent) => {
setError(new Error(event.message));
};
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
setError(new Error(event.reason?.message || 'Promise rejection'));
};
window.addEventListener('error', handleError);
window.addEventListener('unhandledrejection', handleUnhandledRejection);
return () => {
window.removeEventListener('error', handleError);
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
};
}, []);
// Show loading state
if (authLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-[var(--bg-primary)]">
{loadingComponent || (
<div className="flex flex-col items-center gap-4">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[var(--color-primary)]"></div>
<p className="text-[var(--text-secondary)]">Cargando aplicación...</p>
</div>
)}
</div>
);
}
// Show error state
if (error && ErrorBoundary) {
return <ErrorBoundary error={error}>{children}</ErrorBoundary>;
}
const shouldShowSidebar = showSidebar && isAuthenticated && !fullScreen;
const shouldShowHeader = showHeader && !fullScreen;
const shouldShowFooter = showFooter && !fullScreen;
return (
<div
className={clsx(
'min-h-screen bg-[var(--bg-primary)] flex flex-col',
resolvedTheme,
className
)}
data-testid="app-shell"
>
{/* Header */}
{shouldShowHeader && (
<Header
onMenuClick={toggleSidebar}
sidebarCollapsed={isSidebarCollapsed}
className="z-[var(--z-fixed)]"
/>
)}
<div className="flex flex-1 relative">
{/* Sidebar */}
{shouldShowSidebar && (
<>
<Sidebar
isOpen={isSidebarOpen}
isCollapsed={isSidebarCollapsed}
onClose={() => setIsSidebarOpen(false)}
2025-08-28 18:07:16 +02:00
onToggleCollapse={toggleSidebar}
2025-08-28 10:41:04 +02:00
className="z-[var(--z-fixed)]"
/>
{/* Mobile overlay */}
{isSidebarOpen && (
<div
2025-08-28 23:40:44 +02:00
className="fixed inset-0 bg-black/50 z-[var(--z-modal-backdrop)] lg:hidden backdrop-blur-sm"
2025-08-28 10:41:04 +02:00
onClick={handleOverlayClick}
2025-08-28 23:40:44 +02:00
onTouchStart={handleOverlayClick}
2025-08-28 10:41:04 +02:00
aria-hidden="true"
/>
)}
</>
)}
{/* Main content */}
<main
className={clsx(
'flex-1 flex flex-col transition-all duration-300 ease-in-out',
// Add header offset
shouldShowHeader && 'pt-[var(--header-height)]',
2025-08-28 18:07:16 +02:00
// Adjust margins based on sidebar state
shouldShowSidebar && isAuthenticated && !isSidebarCollapsed && 'lg:ml-[var(--sidebar-width)]',
shouldShowSidebar && isAuthenticated && isSidebarCollapsed && 'lg:ml-[var(--sidebar-collapsed-width)]',
2025-08-28 10:41:04 +02:00
// Add padding to content
2025-08-28 18:07:16 +02:00
padded && 'px-4 lg:px-6 pb-4 lg:pb-6'
2025-08-28 10:41:04 +02:00
)}
role="main"
aria-label="Contenido principal"
>
{children}
</main>
</div>
{/* Footer */}
{shouldShowFooter && (
<Footer
compact={true}
showPrivacyLinks={true}
className={clsx(
'transition-all duration-300 ease-in-out',
2025-08-28 18:07:16 +02:00
shouldShowSidebar && isAuthenticated && !isSidebarCollapsed && 'lg:ml-[var(--sidebar-width)]',
shouldShowSidebar && isAuthenticated && isSidebarCollapsed && 'lg:ml-[var(--sidebar-collapsed-width)]'
2025-08-28 10:41:04 +02:00
)}
/>
)}
</div>
);
});
AppShell.displayName = 'AppShell';