Build new shell layout with left sidebar (el-3po)

This commit is contained in:
2026-05-12 11:08:33 +02:00
parent a748512d22
commit 43e132971b
16 changed files with 6487 additions and 360 deletions
+371 -262
View File
@@ -4,30 +4,14 @@ import {
Route,
NavLink,
useLocation,
Outlet,
} from "react-router-dom";
import {
QueryClient,
QueryClientProvider,
useQuery,
} from "@tanstack/react-query";
import { ThemeProvider } from "@mui/material/styles";
import {
AppBar,
Typography,
Box,
Tabs,
Tab,
Container,
CssBaseline,
Chip,
useMediaQuery,
Button,
Stack,
Card,
CardContent,
CircularProgress,
} from "@mui/material";
import { useEffect, useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { AuthProvider, useAuth } from "react-oidc-context";
import { Dashboard } from "./pages/Dashboard";
import { Monitoring } from "./pages/Monitoring";
@@ -37,27 +21,209 @@ import { UsersPage } from "./pages/Users";
import { FileBrowser } from "./pages/FileBrowser";
import { Actions } from "./pages/Actions";
import BackupsPage from "./components/BackupsPage";
import { getAppTheme } from "./theme";
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
import { fetchAppVersion } from "./api/client";
import { FRONTEND_VERSION_LABEL } from "./version";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import {
LayoutDashboard,
Activity,
Monitor,
Users,
Zap,
FolderOpen,
Settings as SettingsIcon,
Menu,
Sun,
Moon,
LogOut,
ChevronLeft,
ChevronRight,
} from "lucide-react";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
});
function Shell({
// Navigation items for sidebar
const navItems = [
{ path: "/", label: "Dashboard", icon: LayoutDashboard },
{ path: "/monitoring", label: "Monitoring", icon: Activity },
{ path: "/applications", label: "Media", icon: Monitor },
{ path: "/files", label: "Files", icon: FolderOpen },
{ path: "/users", label: "Users", icon: Users },
{ path: "/actions", label: "Actions", icon: Zap },
{ path: "/settings", label: "Settings", icon: SettingsIcon },
];
function Sidebar({
collapsed,
onToggle,
isMobile,
}: {
collapsed: boolean;
onToggle: () => void;
isMobile: boolean;
}) {
const location = useLocation();
if (isMobile) return null;
return (
<aside
className={`fixed left-0 top-0 z-40 h-screen border-r border-border bg-card transition-all duration-300 ${
collapsed ? "w-16" : "w-60"
}`}
>
<div className="flex h-full flex-col">
{/* Logo area */}
<div className="flex h-14 items-center justify-between border-b border-border px-3">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary font-bold text-primary-foreground">
M
</div>
{!collapsed && (
<span className="font-semibold">Manage</span>
)}
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={onToggle}
>
{collapsed ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronLeft className="h-4 w-4" />
)}
</Button>
</div>
{/* Navigation */}
<nav className="flex-1 space-y-1 p-2">
<TooltipProvider delayDuration={0}>
{navItems.map((item) => {
const isItemActive = location.pathname === item.path;
const Icon = item.icon;
return collapsed ? (
<Tooltip key={item.path}>
<TooltipTrigger asChild>
<NavLink
to={item.path}
className={() =>
`flex h-10 w-full items-center justify-center rounded-md transition-colors ${
isItemActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`
}
>
<Icon className="h-5 w-5" />
</NavLink>
</TooltipTrigger>
<TooltipContent side="right">
{item.label}
</TooltipContent>
</Tooltip>
) : (
<NavLink
key={item.path}
to={item.path}
className={() =>
`flex h-10 w-full items-center gap-3 rounded-md px-3 transition-colors ${
isItemActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`
}
>
<Icon className="h-5 w-5 shrink-0" />
<span className="text-sm font-medium">{item.label}</span>
</NavLink>
);
})}
</TooltipProvider>
</nav>
</div>
</aside>
);
}
function MobileDrawer() {
const [open, setOpen] = useState(false);
const location = useLocation();
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="md:hidden">
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-60 p-0">
<SheetHeader className="border-b border-border p-4">
<SheetTitle className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary font-bold text-primary-foreground">
M
</div>
<span>Manage</span>
</SheetTitle>
</SheetHeader>
<nav className="space-y-1 p-2">
{navItems.map((item) => {
const isItemActive = location.pathname === item.path;
const Icon = item.icon;
return (
<NavLink
key={item.path}
to={item.path}
onClick={() => setOpen(false)}
className={() =>
`flex h-10 w-full items-center gap-3 rounded-md px-3 transition-colors ${
isItemActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`
}
>
<Icon className="h-5 w-5 shrink-0" />
<span className="text-sm font-medium">{item.label}</span>
</NavLink>
);
})}
</nav>
</SheetContent>
</Sheet>
);
}
function TopBar({
darkMode,
authLabel,
onSignOut,
onToggleDarkMode,
}: {
darkMode: boolean;
authLabel?: string;
onSignOut?: () => void;
onToggleDarkMode: () => void;
}) {
const location = useLocation();
const current = location.pathname;
const isMobile = useMediaQuery("(max-width: 900px)");
const { data: appVersion } = useQuery({
queryKey: ["app-version"],
queryFn: fetchAppVersion,
@@ -65,238 +231,148 @@ function Shell({
});
const backendLabel = appVersion?.backend_label || "…";
const pageTitle =
navItems.find((item) => item.path === location.pathname)?.label ||
"Dashboard";
return (
<>
<CssBaseline />
<AppBar
position="sticky"
color="inherit"
elevation={0}
sx={{
borderBottom: 1,
borderColor: "divider",
backgroundImage: "none",
}}
>
<Box
sx={{
display: "flex",
flexDirection: { xs: "column", md: "row" },
alignItems: { xs: "stretch", md: "center" },
justifyContent: "space-between",
gap: 1.5,
px: { xs: 1.5, sm: 2.5, md: 3 },
py: { xs: 1, md: 1.25 },
}}
<header className="sticky top-0 z-30 flex h-14 items-center justify-between border-b border-border bg-background/80 backdrop-blur-md px-4">
<div className="flex items-center gap-3">
<MobileDrawer />
<h1 className="text-lg font-semibold">{pageTitle}</h1>
</div>
<div className="flex items-center gap-2">
<span className="hidden sm:inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium">
FE {FRONTEND_VERSION_LABEL}
</span>
<span className="hidden sm:inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium">
BE {backendLabel}
</span>
{authLabel && (
<span className="hidden md:inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium max-w-[180px] truncate">
{authLabel}
</span>
)}
<Button
variant="ghost"
size="icon"
onClick={onToggleDarkMode}
className="h-8 w-8"
>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
minWidth: 0,
}}
{darkMode ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</Button>
{onSignOut && (
<Button
variant="ghost"
size="sm"
onClick={onSignOut}
className="gap-2"
>
<Box
sx={{
width: 42,
height: 42,
borderRadius: 2,
display: "grid",
placeItems: "center",
fontWeight: 800,
fontSize: 20,
color: "primary.contrastText",
bgcolor: "primary.main",
backgroundImage:
"linear-gradient(135deg, rgba(255,255,255,0.22), rgba(255,255,255,0))",
boxShadow: 1,
flexShrink: 0,
}}
>
M
</Box>
<Box sx={{ minWidth: 0 }}>
<Typography
variant="h6"
sx={{ fontWeight: 800, lineHeight: 1.05 }}
>
Manage
</Typography>
<Typography variant="caption" color="text.secondary" noWrap>
Media operations dashboard
</Typography>
</Box>
</Box>
<LogOut className="h-4 w-4" />
<span className="hidden sm:inline">Logout</span>
</Button>
)}
</div>
</header>
);
}
<Stack
direction="row"
spacing={1}
sx={{
alignItems: "center",
flexWrap: "wrap",
justifyContent: { xs: "flex-start", md: "flex-end" },
width: { xs: "100%", md: "auto" },
}}
>
<Chip
label={`FE ${FRONTEND_VERSION_LABEL}`}
variant="outlined"
sx={{ maxWidth: { xs: "100%", sm: 220 } }}
/>
<Chip
label={`BE ${backendLabel}`}
variant="outlined"
sx={{ maxWidth: { xs: "100%", sm: 220 } }}
/>
{authLabel && (
<Chip
label={authLabel}
variant="outlined"
sx={{ maxWidth: { xs: "100%", sm: 220 } }}
/>
)}
<Chip label={darkMode ? "Dark" : "Light"} variant="outlined" />
{onSignOut && (
<Button
variant="outlined"
onClick={onSignOut}
sx={{ whiteSpace: "nowrap" }}
>
Logout
</Button>
)}
</Stack>
</Box>
function ShellLayout({
darkMode,
authLabel,
onSignOut,
onToggleDarkMode,
}: {
darkMode: boolean;
authLabel?: string;
onSignOut?: () => void;
onToggleDarkMode: () => void;
}) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [isMobile, setIsMobile] = useState(() =>
window.matchMedia("(max-width: 768px)").matches,
);
<Box sx={{ borderTop: 1, borderColor: "divider" }}>
<Tabs
value={current}
textColor="primary"
indicatorColor="primary"
variant={isMobile ? "scrollable" : "standard"}
scrollButtons="auto"
allowScrollButtonsMobile
sx={{
px: { xs: 0.75, sm: 1.5, md: 3 },
minHeight: 44,
"& .MuiTab-root": {
minHeight: 44,
py: 1,
px: 1.5,
minWidth: { xs: 88, md: 108 },
textTransform: "none",
fontWeight: 600,
},
}}
>
<Tab value="/" label="Dashboard" component={NavLink} to="/" />
<Tab
value="/monitoring"
label="Monitoring"
component={NavLink}
to="/monitoring"
/>
<Tab
value="/applications"
label="Applications"
component={NavLink}
to="/applications"
/>
<Tab value="/users" label="Users" component={NavLink} to="/users" />
<Tab
value="/actions"
label="Actions"
component={NavLink}
to="/actions"
/>
<Tab value="/files" label="Files" component={NavLink} to="/files" />
<Tab
value="/backups"
label="Backups"
component={NavLink}
to="/backups"
/>
<Tab
value="/settings"
label="Settings"
component={NavLink}
to="/settings"
/>
</Tabs>
</Box>
</AppBar>
<Container
maxWidth={false}
sx={{ py: { xs: 1.5, md: 2 }, px: { xs: 1.25, sm: 2, md: 2.5 } }}
useEffect(() => {
const mql = window.matchMedia("(max-width: 768px)");
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, []);
return (
<div className="min-h-screen bg-background">
<Sidebar
collapsed={sidebarCollapsed}
onToggle={() => setSidebarCollapsed(!sidebarCollapsed)}
isMobile={isMobile}
/>
<div
className={`transition-all duration-300 ${
isMobile ? "" : sidebarCollapsed ? "ml-16" : "ml-60"
}`}
>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/monitoring" element={<Monitoring />} />
<Route path="/applications" element={<Applications />} />
<Route path="/media" element={<Applications />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/actions" element={<Actions />} />
<Route path="/files" element={<FileBrowser />} />
<Route path="/backups" element={<BackupsPage />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Container>
</>
<TopBar
darkMode={darkMode}
authLabel={authLabel}
onSignOut={onSignOut}
onToggleDarkMode={onToggleDarkMode}
/>
<main className="p-4 md:p-6">
<Outlet />
</main>
</div>
</div>
);
}
function LoadingScreen({ label }: { label: string }) {
return (
<Box
sx={{
minHeight: "100vh",
display: "grid",
placeItems: "center",
p: 2,
}}
>
<Card variant="outlined" sx={{ maxWidth: 420, width: "100%" }}>
<CardContent>
<Stack spacing={2} sx={{ alignItems: "center", textAlign: "center" }}>
<CircularProgress />
<Typography variant="h6">{label}</Typography>
</Stack>
</CardContent>
</Card>
</Box>
<div className="min-h-screen grid place-items-center p-4">
<div className="w-full max-w-md rounded-xl border bg-card p-6 shadow-sm">
<div className="flex flex-col items-center gap-3 text-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<h2 className="text-lg font-semibold">{label}</h2>
</div>
</div>
</div>
);
}
function SignInScreen({ onSignIn }: { onSignIn: () => void }) {
return (
<Box
sx={{
minHeight: "100vh",
display: "grid",
placeItems: "center",
p: 2,
}}
>
<Card variant="outlined" sx={{ maxWidth: 460, width: "100%" }}>
<CardContent>
<Stack spacing={2} sx={{ alignItems: "center", textAlign: "center" }}>
<Typography variant="h5">Sign in required</Typography>
<Typography variant="body2" color="text.secondary">
Use your Authentik account to access Manage.
</Typography>
<Button variant="contained" onClick={onSignIn}>
Sign in with OIDC
</Button>
</Stack>
</CardContent>
</Card>
</Box>
<div className="min-h-screen grid place-items-center p-4">
<div className="w-full max-w-md rounded-xl border bg-card p-6 shadow-sm">
<div className="flex flex-col items-center gap-3 text-center">
<h2 className="text-xl font-semibold">Sign in required</h2>
<p className="text-sm text-muted-foreground">
Use your Authentik account to access Manage.
</p>
<Button onClick={onSignIn}>Sign in with OIDC</Button>
</div>
</div>
</div>
);
}
function AuthenticatedApp({ darkMode }: { darkMode: boolean }) {
function AuthenticatedApp() {
const auth = useAuth();
const [darkMode, setDarkMode] = useState(() =>
window.matchMedia("(prefers-color-scheme: dark)").matches,
);
useEffect(() => {
const mql = window.matchMedia("(prefers-color-scheme: dark)");
const handler = (e: MediaQueryListEvent) => setDarkMode(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, []);
useEffect(() => {
setAccessToken(auth.user?.access_token ?? null);
}, [auth.user?.access_token]);
@@ -329,41 +405,74 @@ function AuthenticatedApp({ darkMode }: { darkMode: boolean }) {
}
return (
<Box sx={{ minHeight: "100vh", bgcolor: "background.default" }}>
<BrowserRouter>
<Shell
darkMode={darkMode}
authLabel={authLabel}
onSignOut={() => void auth.signoutRedirect()}
/>
</BrowserRouter>
</Box>
<ShellLayout
darkMode={darkMode}
authLabel={authLabel}
onSignOut={() => void auth.signoutRedirect()}
onToggleDarkMode={() => setDarkMode(!darkMode)}
/>
);
}
function AppInner() {
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
const theme = useMemo(
() => getAppTheme(prefersDarkMode ? "dark" : "light"),
[prefersDarkMode],
const [prefersDarkMode, setPrefersDarkMode] = useState(() =>
window.matchMedia("(prefers-color-scheme: dark)").matches,
);
useEffect(() => {
const mql = window.matchMedia("(prefers-color-scheme: dark)");
const handler = (e: MediaQueryListEvent) => setPrefersDarkMode(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, []);
return (
<ThemeProvider theme={theme}>
<QueryClientProvider client={queryClient}>
{isOidcConfigured() ? (
<AuthProvider {...getOidcConfig()}>
<AuthenticatedApp darkMode={prefersDarkMode} />
</AuthProvider>
) : (
<Box sx={{ minHeight: "100vh", bgcolor: "background.default" }}>
<BrowserRouter>
<Shell darkMode={prefersDarkMode} />
</BrowserRouter>
</Box>
)}
</QueryClientProvider>
</ThemeProvider>
<QueryClientProvider client={queryClient}>
{isOidcConfigured() ? (
<AuthProvider {...getOidcConfig()}>
<BrowserRouter>
<Routes>
<Route element={<AuthenticatedApp />}>
<Route path="/" element={<Dashboard />} />
<Route path="/monitoring" element={<Monitoring />} />
<Route path="/applications" element={<Applications />} />
<Route path="/media" element={<Applications />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/actions" element={<Actions />} />
<Route path="/files" element={<FileBrowser />} />
<Route path="/backups" element={<BackupsPage />} />
<Route path="/settings" element={<Settings />} />
</Route>
</Routes>
</BrowserRouter>
</AuthProvider>
) : (
<BrowserRouter>
<Routes>
<Route
element={
<ShellLayout
darkMode={prefersDarkMode}
onToggleDarkMode={() =>
setPrefersDarkMode(!prefersDarkMode)
}
/>
}
>
<Route path="/" element={<Dashboard />} />
<Route path="/monitoring" element={<Monitoring />} />
<Route path="/applications" element={<Applications />} />
<Route path="/media" element={<Applications />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/actions" element={<Actions />} />
<Route path="/files" element={<FileBrowser />} />
<Route path="/backups" element={<BackupsPage />} />
<Route path="/settings" element={<Settings />} />
</Route>
</Routes>
</BrowserRouter>
)}
</QueryClientProvider>
);
}