feat(mui): adopt AppShell, remove AdminLayout/Sidebar/layout.css

Wires the MUI AppShell into the admin route and deletes the legacy chrome (AdminLayout, Sidebar, layout.css). Spinner CSS (.admin-spinner/.admin-loading) lives in base.css and is unaffected. tsc -b clean, build OK, 148 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-06 20:35:49 +02:00
parent 9dbb5dbaab
commit cc5ffb27b9
4 changed files with 2 additions and 845 deletions

View File

@@ -5,7 +5,7 @@ import { AuthProvider } from "./context/AuthContext";
import { AlertProvider } from "./context/AlertContext"; import { AlertProvider } from "./context/AlertContext";
import { queryClient } from "./lib/queryClient"; import { queryClient } from "./lib/queryClient";
import ErrorBoundary from "./components/ErrorBoundary"; import ErrorBoundary from "./components/ErrorBoundary";
import AdminLayout from "./components/AdminLayout"; import AppShell from "./ui/AppShell";
import AlertContainer from "./components/AlertContainer"; import AlertContainer from "./components/AlertContainer";
import MuiProvider from "./ui/MuiProvider"; import MuiProvider from "./ui/MuiProvider";
import Login from "./pages/Login"; import Login from "./pages/Login";
@@ -14,7 +14,6 @@ import "./variables.css";
import "./base.css"; import "./base.css";
import "./forms.css"; import "./forms.css";
import "./buttons.css"; import "./buttons.css";
import "./layout.css";
import "./components.css"; import "./components.css";
import "./tables.css"; import "./tables.css";
import "./datepicker.css"; import "./datepicker.css";
@@ -104,7 +103,7 @@ export default function AdminApp() {
{import.meta.env.DEV && UiKit && ( {import.meta.env.DEV && UiKit && (
<Route path="ui-kit" element={<UiKit />} /> <Route path="ui-kit" element={<UiKit />} />
)} )}
<Route element={<AdminLayout />}> <Route element={<AppShell />}>
<Route index element={<Dashboard />} /> <Route index element={<Dashboard />} />
<Route path="users" element={<Users />} /> <Route path="users" element={<Users />} />
<Route path="attendance" element={<Attendance />} /> <Route path="attendance" element={<Attendance />} />

View File

@@ -1,136 +0,0 @@
import { useState, useCallback } from "react";
import { Outlet, Navigate, useLocation } from "react-router-dom";
import { motion } from "framer-motion";
import { useAuth } from "../context/AuthContext";
import { useTheme } from "../../context/ThemeContext";
import { setLogoutAlert } from "../utils/api";
import useModalLock from "../hooks/useModalLock";
import Sidebar from "./Sidebar";
import ShortcutsHelp from "./ShortcutsHelp";
export default function AdminLayout() {
const { isAuthenticated, loading, user, logout } = useAuth();
const { theme, toggleTheme } = useTheme();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [loggingOut, setLoggingOut] = useState(false);
const location = useLocation();
// Session is managed by AuthProvider (initial check + proactive refresh via setTimeout).
// Do not call checkSession on route changes — concurrent refresh calls with token rotation
// would invalidate each other and kick the user out.
const handleLogout = useCallback(() => {
setLoggingOut(true);
setSidebarOpen(false);
setLogoutAlert();
setTimeout(() => logout(), 400);
}, [logout]);
useModalLock(sidebarOpen);
if (loading) {
return (
<div className="admin-layout">
<div className="admin-loading" style={{ width: "100%" }}>
<div className="admin-spinner" />
</div>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
// If 2FA is required but user hasn't enabled it, redirect to dashboard (where setup lives)
const needs2FASetup = user?.require2FA && !user?.totpEnabled;
if (needs2FASetup && location.pathname !== "/") {
return <Navigate to="/" replace />;
}
return (
<motion.div
className="admin-layout"
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] }}
>
<Sidebar
isOpen={sidebarOpen}
onClose={() => setSidebarOpen(false)}
onLogout={handleLogout}
/>
<div className="admin-main">
<header className="admin-header">
<button
onClick={() => setSidebarOpen(true)}
className="admin-menu-btn"
aria-label="Otevřít menu"
>
<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>
</button>
<div className="flex-1" />
<button
onClick={toggleTheme}
className="admin-header-theme-btn"
title={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
aria-label={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
>
<span
className={`admin-theme-icon ${theme === "light" ? "visible" : ""}`}
>
<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>
</span>
<span
className={`admin-theme-icon ${theme === "dark" ? "visible" : ""}`}
>
<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>
</span>
</button>
</header>
<main className="admin-content">
<Outlet />
</main>
</div>
<ShortcutsHelp />
</motion.div>
);
}

View File

@@ -1,124 +0,0 @@
import { NavLink, useLocation } from "react-router-dom";
import { useAuth } from "../context/AuthContext";
import { useTheme } from "../../context/ThemeContext";
import { menuSections, isItemActive, hasItemPermission } from "../ui/navData";
interface SidebarProps {
isOpen: boolean;
onClose: () => void;
onLogout: () => void;
}
export default function Sidebar({ isOpen, onClose, onLogout }: SidebarProps) {
const { user, hasPermission } = useAuth();
const { theme } = useTheme();
const location = useLocation();
const visibleSections = menuSections
.map((section) => ({
...section,
items: section.items.filter((item) =>
hasItemPermission(item, hasPermission),
),
}))
.filter((section) => section.items.length > 0);
return (
<>
<div
className={`admin-sidebar-overlay${isOpen ? " open" : ""}`}
onClick={onClose}
/>
<aside className={`admin-sidebar${isOpen ? " open" : ""}`}>
<div className="admin-sidebar-header">
<img
src={
theme === "dark"
? "/api/admin/company-settings/logo?variant=dark"
: "/api/admin/company-settings/logo?variant=light"
}
alt="Logo"
className="admin-sidebar-logo"
onError={(e) => {
(e.target as HTMLImageElement).src =
theme === "dark"
? "/images/logo-dark.png"
: "/images/logo-light.png";
}}
/>
<button
onClick={onClose}
className="admin-sidebar-close"
aria-label="Zavřít menu"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<nav className="admin-sidebar-nav">
{visibleSections.map((section) => (
<div key={section.label} className="admin-nav-section">
<div className="admin-nav-label">{section.label}</div>
{section.items.map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.end}
onClick={onClose}
className={() =>
`admin-nav-item${isItemActive(item, location.pathname) ? " active" : ""}`
}
>
{item.icon}
{item.label}
</NavLink>
))}
</div>
))}
</nav>
<div className="admin-sidebar-footer">
<div className="admin-user-chip">
<div className="admin-user-avatar">
{user?.fullName?.charAt(0) || user?.username?.charAt(0) || "U"}
</div>
<div className="admin-user-details">
<div className="admin-user-name">
{user?.fullName || user?.username}
</div>
<div className="admin-user-role">{user?.roleDisplay}</div>
</div>
</div>
<button
onClick={onLogout}
className="admin-logout-btn"
aria-label="Odhlásit se"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
Odhlásit se
</button>
</div>
</aside>
</>
);
}

View File

@@ -1,582 +0,0 @@
/* ============================================================================
Layout
============================================================================ */
.admin-layout {
display: flex;
min-height: 100vh;
min-height: 100dvh;
background: var(--bg-primary);
}
@media (min-width: 1024px) {
.admin-layout {
align-items: flex-start;
}
}
/* ============================================================================
Sidebar
============================================================================ */
/* -- Theme variables for sidebar -- */
[data-theme="dark"] .admin-sidebar {
--sb-bg: #141414;
--sb-border: #2a2a2a;
--sb-text: #a0a0a0;
--sb-text-hover: #ddd;
--sb-hover-bg: #1f1f1f;
--sb-active-bg: #ffffff;
--sb-active-text: #141414;
--sb-label: #444;
--sb-muted: #555;
--sb-scrollbar: #333;
}
[data-theme="light"] .admin-sidebar {
--sb-bg: #ffffff;
--sb-border: #e8e6e1;
--sb-text: #7c7c84;
--sb-text-hover: #1a1a1a;
--sb-hover-bg: #f5f4f2;
--sb-active-bg: #141414;
--sb-active-text: #ffffff;
--sb-label: #a0a0a0;
--sb-muted: #a0a0a0;
--sb-scrollbar: #ddd;
}
.admin-sidebar {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100vh;
height: 100dvh;
z-index: 50;
background: var(--sb-bg);
border-right: 1px solid var(--sb-border);
display: flex;
flex-direction: column;
padding-top: env(safe-area-inset-top, 0px);
padding-bottom: env(safe-area-inset-bottom, 0px);
padding-left: env(safe-area-inset-left, 0px);
padding-right: env(safe-area-inset-right, 0px);
transform: translateX(-100%);
visibility: hidden;
transition:
transform 0.3s ease,
visibility 0.3s ease;
overflow: hidden;
overscroll-behavior: none;
}
.admin-sidebar.open {
transform: translateX(0);
visibility: visible;
touch-action: none;
}
@media (min-width: 1024px) {
.admin-sidebar {
right: auto;
width: 220px;
height: 100%;
transform: none;
visibility: visible;
padding: 0;
}
}
[data-theme="light"] .admin-sidebar {
box-shadow:
1px 0 0 0 var(--sb-border),
4px 0 16px rgba(0, 0, 0, 0.04);
}
/* Sidebar Overlay (mobile) */
.admin-sidebar-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 49;
backdrop-filter: blur(2px);
}
.admin-sidebar-overlay.open {
display: block;
}
@media (min-width: 1024px) {
.admin-sidebar-overlay {
display: none !important;
}
}
/* Sidebar Header */
.admin-sidebar-header {
padding: 0 18px;
height: 73px;
border-bottom: 1px solid var(--sb-border);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.admin-sidebar-logo {
height: 28px;
width: auto;
}
.admin-sidebar-close {
display: block;
padding: 0.5rem;
background: transparent;
border: none;
color: var(--sb-text);
cursor: pointer;
border-radius: 6px;
}
.admin-sidebar-close:hover {
background: var(--sb-hover-bg);
color: var(--sb-text-hover);
}
@media (min-width: 1024px) {
.admin-sidebar-close {
display: none;
}
}
/* Sidebar Navigation */
.admin-sidebar-nav {
flex: 1;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
padding: 4px 0;
}
.admin-sidebar-nav::-webkit-scrollbar {
width: 5px;
}
.admin-sidebar-nav::-webkit-scrollbar-track {
background: transparent;
}
.admin-sidebar-nav::-webkit-scrollbar-thumb {
background: var(--sb-scrollbar);
border-radius: 99px;
}
/* Nav Section */
.admin-nav-section {
padding: 14px 10px 6px;
}
.admin-nav-label {
font-size: 10px;
font-weight: 600;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--sb-label);
padding: 0 8px;
margin-bottom: 4px;
}
/* Nav Item */
.admin-nav-item {
display: flex;
align-items: center;
gap: 9px;
padding: 7px 10px;
border-radius: 7px;
color: var(--sb-text);
cursor: pointer;
transition: all 0.15s ease;
font-size: 13px;
font-weight: 450;
margin-bottom: 1px;
text-decoration: none;
user-select: none;
}
.admin-nav-item:hover {
background: var(--sb-hover-bg);
color: var(--sb-text-hover);
}
.admin-nav-item.active {
background: var(--sb-active-bg);
color: var(--sb-active-text);
font-weight: 600;
}
.admin-nav-item.active svg {
color: var(--accent-color);
}
.admin-nav-item svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
@media (max-width: 1023px) {
.admin-nav-item {
padding: 10px 12px;
font-size: 15px;
gap: 10px;
}
.admin-nav-item svg {
width: 18px;
height: 18px;
}
}
/* Sidebar Footer */
.admin-sidebar-footer {
margin-top: auto;
border-top: 1px solid var(--sb-border);
padding: 14px 10px;
flex-shrink: 0;
}
.admin-user-chip {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
border-radius: 8px;
margin-bottom: 4px;
}
.admin-user-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--accent-color);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 12px;
flex-shrink: 0;
}
.admin-user-details {
flex: 1;
min-width: 0;
}
.admin-user-name {
color: var(--sb-text-hover);
font-size: 13px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.admin-user-role {
color: var(--sb-muted);
font-size: 11px;
}
.admin-logout-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 7px 10px;
background: transparent;
border: none;
color: var(--sb-text);
cursor: pointer;
border-radius: 7px;
font-size: 12px;
font-family: inherit;
transition: all 0.15s ease;
}
.admin-logout-btn:hover {
background: var(--sb-hover-bg);
color: var(--sb-text-hover);
}
.admin-logout-btn svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
@media (max-width: 480px) {
.admin-sidebar-footer {
padding: 10px 8px;
}
.admin-user-chip {
padding: 6px;
}
.admin-logout-btn {
padding: 8px;
font-size: 13px;
}
}
/* ============================================================================
Main Content Area
============================================================================ */
.admin-main {
flex: 1;
min-width: 0;
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
}
@media (min-width: 1024px) {
.admin-main {
margin-left: 220px;
}
}
/* Header */
.admin-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 30;
height: calc(73px + env(safe-area-inset-top, 0px));
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1rem;
padding-top: env(safe-area-inset-top, 0px);
}
@media (min-width: 1024px) {
.admin-header {
left: 220px;
padding: 0 1.5rem;
}
}
.admin-menu-btn {
display: block;
padding: 0.5rem;
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
border-radius: var(--border-radius-sm);
}
.admin-menu-btn:hover {
background: var(--bg-tertiary);
}
@media (min-width: 1024px) {
.admin-menu-btn {
display: none;
}
}
.admin-header-theme-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 0;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
cursor: pointer;
border-radius: 50%;
transition: var(--transition);
position: relative;
overflow: hidden;
}
.admin-header-theme-btn:hover {
background: var(--bg-tertiary);
border-color: var(--border-color-hover);
transform: scale(1.05);
}
.admin-theme-icon {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary);
transition: all 0.3s ease;
opacity: 0;
transform: rotate(180deg) scale(0.5);
}
.admin-theme-icon.visible {
opacity: 1;
transform: rotate(0) scale(1);
}
/* Content */
.admin-content {
flex: 1;
padding: 1rem;
padding-top: calc(73px + 1rem + env(safe-area-inset-top, 0px));
}
@media (min-width: 1024px) {
.admin-content {
padding: 28px 32px;
padding-top: calc(73px + 28px);
}
}
/* ============================================================================
Page Headers
============================================================================ */
.admin-page-header {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
@media (min-width: 640px) {
.admin-page-header {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
.admin-page-title {
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
font-family: var(--font-heading);
margin-bottom: 0.25rem;
}
.admin-page-subtitle {
color: var(--text-secondary);
font-size: 13px;
}
.admin-page-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
@media (max-width: 640px) {
.admin-page-actions {
width: 100%;
}
.admin-page-actions .admin-btn {
flex: 1;
}
}
/* ============================================================================
Grid System
============================================================================ */
.admin-grid {
display: grid;
gap: 1rem;
}
.admin-grid > .admin-card {
margin-bottom: 0;
}
.admin-grid-3 {
grid-template-columns: 1fr;
}
@media (min-width: 640px) {
.admin-grid-3 {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.admin-grid-3 {
grid-template-columns: repeat(3, 1fr);
}
}
.admin-grid-4 {
grid-template-columns: repeat(2, 1fr);
}
@media (min-width: 768px) {
.admin-grid-4 {
grid-template-columns: repeat(4, 1fr);
}
}
/* Page header on mobile */
@media (max-width: 480px) {
.admin-page-title {
font-size: 18px;
}
.admin-page-subtitle {
font-size: 12px;
}
.admin-content {
padding: 12px !important;
padding-top: calc(73px + 12px + env(safe-area-inset-top, 0px)) !important;
}
}
/* Grid - single column on small mobile */
@media (max-width: 480px) {
.admin-grid-4 {
grid-template-columns: 1fr;
}
}
/* ============================================================================
Settings Grid
============================================================================ */
.admin-settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.admin-settings-grid > .admin-card {
margin-bottom: 0;
}
@media (max-width: 900px) {
.admin-settings-grid {
grid-template-columns: 1fr;
}
}