Files
manage/frontend/src/App.tsx
T
2026-05-04 20:20:54 +02:00

321 lines
7.5 KiB
TypeScript

import {
BrowserRouter,
Routes,
Route,
NavLink,
useLocation,
} from "react-router-dom";
import { QueryClient, QueryClientProvider } 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 { AuthProvider, useAuth } from "react-oidc-context";
import { Dashboard } from "./pages/Dashboard";
import { Monitoring } from "./pages/Monitoring";
import { Media } from "./pages/Media";
import { UsersPage } from "./pages/Users";
import { FileBrowser } from "./pages/FileBrowser";
import { getAppTheme } from "./theme";
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
});
function Shell({
darkMode,
authLabel,
onSignOut,
}: {
darkMode: boolean;
authLabel?: string;
onSignOut?: () => void;
}) {
const location = useLocation();
const current = location.pathname;
const isMobile = useMediaQuery("(max-width: 900px)");
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 },
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1.5,
minWidth: 0,
}}
>
<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>
<Stack
direction="row"
spacing={1}
sx={{
alignItems: "center",
flexWrap: "wrap",
justifyContent: { xs: "flex-start", md: "flex-end" },
width: { xs: "100%", md: "auto" },
}}
>
{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>
<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: 96, md: 120 },
textTransform: "none",
fontWeight: 600,
},
}}
>
<Tab value="/" label="Dashboard" component={NavLink} to="/" />
<Tab
value="/monitoring"
label="Monitoring"
component={NavLink}
to="/monitoring"
/>
<Tab value="/media" label="Media" component={NavLink} to="/media" />
<Tab value="/users" label="Users" component={NavLink} to="/users" />
<Tab value="/files" label="Files" component={NavLink} to="/files" />
</Tabs>
</Box>
</AppBar>
<Container
maxWidth={false}
sx={{ py: { xs: 2, md: 3 }, px: { xs: 1.5, sm: 2.5, md: 3 } }}
>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/monitoring" element={<Monitoring />} />
<Route path="/media" element={<Media />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/files" element={<FileBrowser />} />
</Routes>
</Container>
</>
);
}
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>
);
}
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>
);
}
function AuthenticatedApp({ darkMode }: { darkMode: boolean }) {
const auth = useAuth();
useEffect(() => {
setAccessToken(auth.user?.access_token ?? null);
}, [auth.user?.access_token]);
const authLabel = useMemo(() => {
const profile = auth.user?.profile as Record<string, unknown> | undefined;
return String(
profile?.name ??
profile?.preferred_username ??
profile?.email ??
auth.user?.profile?.sub ??
"Authenticated",
);
}, [auth.user]);
if (auth.isLoading || auth.activeNavigator) {
return <LoadingScreen label="Checking sign-in…" />;
}
if (auth.error) {
return (
<LoadingScreen
label={`Authentication error: ${auth.error.message || "Unable to sign in"}`}
/>
);
}
if (!auth.isAuthenticated) {
return <SignInScreen onSignIn={() => void auth.signinRedirect()} />;
}
return (
<Box sx={{ minHeight: "100vh", bgcolor: "background.default" }}>
<BrowserRouter>
<Shell
darkMode={darkMode}
authLabel={authLabel}
onSignOut={() => void auth.signoutRedirect()}
/>
</BrowserRouter>
</Box>
);
}
function AppInner() {
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
const theme = useMemo(
() => getAppTheme(prefersDarkMode ? "dark" : "light"),
[prefersDarkMode],
);
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>
);
}
export default function App() {
return <AppInner />;
}