feat(mui): AppShell — responsive Drawer + topbar, guards + logout animation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
188
src/admin/ui/AppShell.tsx
Normal file
188
src/admin/ui/AppShell.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { Outlet, Navigate, useLocation } from "react-router-dom";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Drawer from "@mui/material/Drawer";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import ScopedCssBaseline from "@mui/material/ScopedCssBaseline";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import { useTheme as useAppTheme } from "../../context/ThemeContext";
|
||||||
|
import { setLogoutAlert } from "../utils/api";
|
||||||
|
import SidebarNav from "./SidebarNav";
|
||||||
|
import ShortcutsHelp from "../components/ShortcutsHelp";
|
||||||
|
|
||||||
|
const DRAWER_WIDTH = 248;
|
||||||
|
|
||||||
|
export default function AppShell() {
|
||||||
|
const { isAuthenticated, loading, user, logout } = useAuth();
|
||||||
|
const { theme, toggleTheme } = useAppTheme();
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
const [loggingOut, setLoggingOut] = useState(false);
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const handleLogout = useCallback(() => {
|
||||||
|
setLoggingOut(true);
|
||||||
|
setMobileOpen(false);
|
||||||
|
setLogoutAlert();
|
||||||
|
setTimeout(() => logout(), 400);
|
||||||
|
}, [logout]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<ScopedCssBaseline>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
minHeight: "100vh",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
bgcolor: "background.default",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="admin-spinner" />
|
||||||
|
</Box>
|
||||||
|
</ScopedCssBaseline>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||||
|
if (user?.require2FA && !user?.totpEnabled && location.pathname !== "/") {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScopedCssBaseline>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.98 }}
|
||||||
|
animate={
|
||||||
|
loggingOut
|
||||||
|
? { scale: 1.5, opacity: 0, filter: "blur(12px)" }
|
||||||
|
: { scale: 1, opacity: 1, filter: "none" }
|
||||||
|
}
|
||||||
|
transition={{
|
||||||
|
duration: loggingOut ? 0.4 : 0.25,
|
||||||
|
ease: [0.4, 0, 0.2, 1],
|
||||||
|
}}
|
||||||
|
style={{ minHeight: "100vh" }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
minHeight: "100vh",
|
||||||
|
bgcolor: "background.default",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Drawer
|
||||||
|
variant="permanent"
|
||||||
|
sx={{
|
||||||
|
display: { xs: "none", md: "block" },
|
||||||
|
width: DRAWER_WIDTH,
|
||||||
|
flexShrink: 0,
|
||||||
|
"& .MuiDrawer-paper": {
|
||||||
|
width: DRAWER_WIDTH,
|
||||||
|
boxSizing: "border-box",
|
||||||
|
border: 0,
|
||||||
|
bgcolor: "background.default",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SidebarNav onLogout={handleLogout} />
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
variant="temporary"
|
||||||
|
open={mobileOpen}
|
||||||
|
onClose={() => setMobileOpen(false)}
|
||||||
|
ModalProps={{ keepMounted: true }}
|
||||||
|
sx={{
|
||||||
|
display: { xs: "block", md: "none" },
|
||||||
|
"& .MuiDrawer-paper": {
|
||||||
|
width: DRAWER_WIDTH,
|
||||||
|
boxSizing: "border-box",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SidebarNav
|
||||||
|
onNavigate={() => setMobileOpen(false)}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
/>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="header"
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 1,
|
||||||
|
px: 2,
|
||||||
|
py: 1.5,
|
||||||
|
minHeight: 64,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setMobileOpen(true)}
|
||||||
|
aria-label="Otevřít menu"
|
||||||
|
sx={{ display: { md: "none" } }}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<line x1="3" y1="12" x2="21" y2="12" />
|
||||||
|
<line x1="3" y1="6" x2="21" y2="6" />
|
||||||
|
<line x1="3" y1="18" x2="21" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</IconButton>
|
||||||
|
<Box sx={{ flex: 1 }} />
|
||||||
|
<IconButton
|
||||||
|
onClick={toggleTheme}
|
||||||
|
aria-label={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
|
||||||
|
title={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
|
||||||
|
>
|
||||||
|
{theme === "dark" ? (
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="5" />
|
||||||
|
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
<Box component="main" sx={{ flex: 1, px: { xs: 2, md: 3 }, pb: 4 }}>
|
||||||
|
<Outlet />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<ShortcutsHelp />
|
||||||
|
</motion.div>
|
||||||
|
</ScopedCssBaseline>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user