Build new shell layout with left sidebar (el-3po)
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "radix-nova",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
Generated
+5694
-50
File diff suppressed because it is too large
Load Diff
+14
-1
@@ -12,29 +12,42 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
"@mui/icons-material": "^9.0.0",
|
"@mui/icons-material": "^9.0.0",
|
||||||
"@mui/material": "^9.0.0",
|
"@mui/material": "^9.0.0",
|
||||||
"@mui/x-data-grid": "^9.0.4",
|
"@mui/x-data-grid": "^9.0.4",
|
||||||
"@tanstack/react-query": "^5.100.6",
|
"@tanstack/react-query": "^5.100.6",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
|
"lucide-react": "^1.14.0",
|
||||||
"oidc-client-ts": "^3.5.0",
|
"oidc-client-ts": "^3.5.0",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5",
|
"react-dom": "^19.2.5",
|
||||||
"react-oidc-context": "^3.3.1",
|
"react-oidc-context": "^3.3.1",
|
||||||
"react-router-dom": "^7.14.2",
|
"react-router-dom": "^7.14.2",
|
||||||
"recharts": "^3.8.1"
|
"recharts": "^3.8.1",
|
||||||
|
"shadcn": "^4.7.0",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tw-animate-css": "^1.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
"@types/node": "^24.12.2",
|
"@types/node": "^24.12.2",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"autoprefixer": "^10.5.0",
|
||||||
"eslint": "^10.2.1",
|
"eslint": "^10.2.1",
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.5.0",
|
"globals": "^17.5.0",
|
||||||
|
"postcss": "^8.5.14",
|
||||||
|
"tailwindcss": "^4.3.0",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"typescript-eslint": "^8.58.2",
|
"typescript-eslint": "^8.58.2",
|
||||||
"vite": "^8.0.10"
|
"vite": "^8.0.10"
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
module.exports = { plugins: { "@tailwindcss/postcss": {}, autoprefixer: {} } }
|
||||||
+371
-262
@@ -4,30 +4,14 @@ import {
|
|||||||
Route,
|
Route,
|
||||||
NavLink,
|
NavLink,
|
||||||
useLocation,
|
useLocation,
|
||||||
|
Outlet,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
QueryClient,
|
QueryClient,
|
||||||
QueryClientProvider,
|
QueryClientProvider,
|
||||||
useQuery,
|
useQuery,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { ThemeProvider } from "@mui/material/styles";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
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 { AuthProvider, useAuth } from "react-oidc-context";
|
||||||
import { Dashboard } from "./pages/Dashboard";
|
import { Dashboard } from "./pages/Dashboard";
|
||||||
import { Monitoring } from "./pages/Monitoring";
|
import { Monitoring } from "./pages/Monitoring";
|
||||||
@@ -37,27 +21,209 @@ import { UsersPage } from "./pages/Users";
|
|||||||
import { FileBrowser } from "./pages/FileBrowser";
|
import { FileBrowser } from "./pages/FileBrowser";
|
||||||
import { Actions } from "./pages/Actions";
|
import { Actions } from "./pages/Actions";
|
||||||
import BackupsPage from "./components/BackupsPage";
|
import BackupsPage from "./components/BackupsPage";
|
||||||
import { getAppTheme } from "./theme";
|
|
||||||
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
|
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
|
||||||
import { fetchAppVersion } from "./api/client";
|
import { fetchAppVersion } from "./api/client";
|
||||||
import { FRONTEND_VERSION_LABEL } from "./version";
|
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({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
|
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,
|
darkMode,
|
||||||
authLabel,
|
authLabel,
|
||||||
onSignOut,
|
onSignOut,
|
||||||
|
onToggleDarkMode,
|
||||||
}: {
|
}: {
|
||||||
darkMode: boolean;
|
darkMode: boolean;
|
||||||
authLabel?: string;
|
authLabel?: string;
|
||||||
onSignOut?: () => void;
|
onSignOut?: () => void;
|
||||||
|
onToggleDarkMode: () => void;
|
||||||
}) {
|
}) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const current = location.pathname;
|
|
||||||
const isMobile = useMediaQuery("(max-width: 900px)");
|
|
||||||
const { data: appVersion } = useQuery({
|
const { data: appVersion } = useQuery({
|
||||||
queryKey: ["app-version"],
|
queryKey: ["app-version"],
|
||||||
queryFn: fetchAppVersion,
|
queryFn: fetchAppVersion,
|
||||||
@@ -65,238 +231,148 @@ function Shell({
|
|||||||
});
|
});
|
||||||
const backendLabel = appVersion?.backend_label || "…";
|
const backendLabel = appVersion?.backend_label || "…";
|
||||||
|
|
||||||
|
const pageTitle =
|
||||||
|
navItems.find((item) => item.path === location.pathname)?.label ||
|
||||||
|
"Dashboard";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<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">
|
||||||
<CssBaseline />
|
<div className="flex items-center gap-3">
|
||||||
<AppBar
|
<MobileDrawer />
|
||||||
position="sticky"
|
<h1 className="text-lg font-semibold">{pageTitle}</h1>
|
||||||
color="inherit"
|
</div>
|
||||||
elevation={0}
|
|
||||||
sx={{
|
<div className="flex items-center gap-2">
|
||||||
borderBottom: 1,
|
<span className="hidden sm:inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium">
|
||||||
borderColor: "divider",
|
FE {FRONTEND_VERSION_LABEL}
|
||||||
backgroundImage: "none",
|
</span>
|
||||||
}}
|
<span className="hidden sm:inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium">
|
||||||
>
|
BE {backendLabel}
|
||||||
<Box
|
</span>
|
||||||
sx={{
|
{authLabel && (
|
||||||
display: "flex",
|
<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">
|
||||||
flexDirection: { xs: "column", md: "row" },
|
{authLabel}
|
||||||
alignItems: { xs: "stretch", md: "center" },
|
</span>
|
||||||
justifyContent: "space-between",
|
)}
|
||||||
gap: 1.5,
|
<Button
|
||||||
px: { xs: 1.5, sm: 2.5, md: 3 },
|
variant="ghost"
|
||||||
py: { xs: 1, md: 1.25 },
|
size="icon"
|
||||||
}}
|
onClick={onToggleDarkMode}
|
||||||
|
className="h-8 w-8"
|
||||||
>
|
>
|
||||||
<Box
|
{darkMode ? (
|
||||||
sx={{
|
<Sun className="h-4 w-4" />
|
||||||
display: "flex",
|
) : (
|
||||||
alignItems: "center",
|
<Moon className="h-4 w-4" />
|
||||||
gap: 1.5,
|
)}
|
||||||
minWidth: 0,
|
</Button>
|
||||||
}}
|
{onSignOut && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onSignOut}
|
||||||
|
className="gap-2"
|
||||||
>
|
>
|
||||||
<Box
|
<LogOut className="h-4 w-4" />
|
||||||
sx={{
|
<span className="hidden sm:inline">Logout</span>
|
||||||
width: 42,
|
</Button>
|
||||||
height: 42,
|
)}
|
||||||
borderRadius: 2,
|
</div>
|
||||||
display: "grid",
|
</header>
|
||||||
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
|
function ShellLayout({
|
||||||
direction="row"
|
darkMode,
|
||||||
spacing={1}
|
authLabel,
|
||||||
sx={{
|
onSignOut,
|
||||||
alignItems: "center",
|
onToggleDarkMode,
|
||||||
flexWrap: "wrap",
|
}: {
|
||||||
justifyContent: { xs: "flex-start", md: "flex-end" },
|
darkMode: boolean;
|
||||||
width: { xs: "100%", md: "auto" },
|
authLabel?: string;
|
||||||
}}
|
onSignOut?: () => void;
|
||||||
>
|
onToggleDarkMode: () => void;
|
||||||
<Chip
|
}) {
|
||||||
label={`FE ${FRONTEND_VERSION_LABEL}`}
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
variant="outlined"
|
const [isMobile, setIsMobile] = useState(() =>
|
||||||
sx={{ maxWidth: { xs: "100%", sm: 220 } }}
|
window.matchMedia("(max-width: 768px)").matches,
|
||||||
/>
|
);
|
||||||
<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>
|
|
||||||
|
|
||||||
<Box sx={{ borderTop: 1, borderColor: "divider" }}>
|
useEffect(() => {
|
||||||
<Tabs
|
const mql = window.matchMedia("(max-width: 768px)");
|
||||||
value={current}
|
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
||||||
textColor="primary"
|
mql.addEventListener("change", handler);
|
||||||
indicatorColor="primary"
|
return () => mql.removeEventListener("change", handler);
|
||||||
variant={isMobile ? "scrollable" : "standard"}
|
}, []);
|
||||||
scrollButtons="auto"
|
|
||||||
allowScrollButtonsMobile
|
return (
|
||||||
sx={{
|
<div className="min-h-screen bg-background">
|
||||||
px: { xs: 0.75, sm: 1.5, md: 3 },
|
<Sidebar
|
||||||
minHeight: 44,
|
collapsed={sidebarCollapsed}
|
||||||
"& .MuiTab-root": {
|
onToggle={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||||
minHeight: 44,
|
isMobile={isMobile}
|
||||||
py: 1,
|
/>
|
||||||
px: 1.5,
|
<div
|
||||||
minWidth: { xs: 88, md: 108 },
|
className={`transition-all duration-300 ${
|
||||||
textTransform: "none",
|
isMobile ? "" : sidebarCollapsed ? "ml-16" : "ml-60"
|
||||||
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 } }}
|
|
||||||
>
|
>
|
||||||
<Routes>
|
<TopBar
|
||||||
<Route path="/" element={<Dashboard />} />
|
darkMode={darkMode}
|
||||||
<Route path="/monitoring" element={<Monitoring />} />
|
authLabel={authLabel}
|
||||||
<Route path="/applications" element={<Applications />} />
|
onSignOut={onSignOut}
|
||||||
<Route path="/media" element={<Applications />} />
|
onToggleDarkMode={onToggleDarkMode}
|
||||||
<Route path="/users" element={<UsersPage />} />
|
/>
|
||||||
<Route path="/actions" element={<Actions />} />
|
<main className="p-4 md:p-6">
|
||||||
<Route path="/files" element={<FileBrowser />} />
|
<Outlet />
|
||||||
<Route path="/backups" element={<BackupsPage />} />
|
</main>
|
||||||
<Route path="/settings" element={<Settings />} />
|
</div>
|
||||||
</Routes>
|
</div>
|
||||||
</Container>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LoadingScreen({ label }: { label: string }) {
|
function LoadingScreen({ label }: { label: string }) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<div className="min-h-screen grid place-items-center p-4">
|
||||||
sx={{
|
<div className="w-full max-w-md rounded-xl border bg-card p-6 shadow-sm">
|
||||||
minHeight: "100vh",
|
<div className="flex flex-col items-center gap-3 text-center">
|
||||||
display: "grid",
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||||
placeItems: "center",
|
<h2 className="text-lg font-semibold">{label}</h2>
|
||||||
p: 2,
|
</div>
|
||||||
}}
|
</div>
|
||||||
>
|
</div>
|
||||||
<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 }) {
|
function SignInScreen({ onSignIn }: { onSignIn: () => void }) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<div className="min-h-screen grid place-items-center p-4">
|
||||||
sx={{
|
<div className="w-full max-w-md rounded-xl border bg-card p-6 shadow-sm">
|
||||||
minHeight: "100vh",
|
<div className="flex flex-col items-center gap-3 text-center">
|
||||||
display: "grid",
|
<h2 className="text-xl font-semibold">Sign in required</h2>
|
||||||
placeItems: "center",
|
<p className="text-sm text-muted-foreground">
|
||||||
p: 2,
|
Use your Authentik account to access Manage.
|
||||||
}}
|
</p>
|
||||||
>
|
<Button onClick={onSignIn}>Sign in with OIDC</Button>
|
||||||
<Card variant="outlined" sx={{ maxWidth: 460, width: "100%" }}>
|
</div>
|
||||||
<CardContent>
|
</div>
|
||||||
<Stack spacing={2} sx={{ alignItems: "center", textAlign: "center" }}>
|
</div>
|
||||||
<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 }) {
|
function AuthenticatedApp() {
|
||||||
const auth = useAuth();
|
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(() => {
|
useEffect(() => {
|
||||||
setAccessToken(auth.user?.access_token ?? null);
|
setAccessToken(auth.user?.access_token ?? null);
|
||||||
}, [auth.user?.access_token]);
|
}, [auth.user?.access_token]);
|
||||||
@@ -329,41 +405,74 @@ function AuthenticatedApp({ darkMode }: { darkMode: boolean }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: "100vh", bgcolor: "background.default" }}>
|
<ShellLayout
|
||||||
<BrowserRouter>
|
darkMode={darkMode}
|
||||||
<Shell
|
authLabel={authLabel}
|
||||||
darkMode={darkMode}
|
onSignOut={() => void auth.signoutRedirect()}
|
||||||
authLabel={authLabel}
|
onToggleDarkMode={() => setDarkMode(!darkMode)}
|
||||||
onSignOut={() => void auth.signoutRedirect()}
|
/>
|
||||||
/>
|
|
||||||
</BrowserRouter>
|
|
||||||
</Box>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppInner() {
|
function AppInner() {
|
||||||
const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)");
|
const [prefersDarkMode, setPrefersDarkMode] = useState(() =>
|
||||||
const theme = useMemo(
|
window.matchMedia("(prefers-color-scheme: dark)").matches,
|
||||||
() => getAppTheme(prefersDarkMode ? "dark" : "light"),
|
|
||||||
[prefersDarkMode],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<ThemeProvider theme={theme}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<QueryClientProvider client={queryClient}>
|
{isOidcConfigured() ? (
|
||||||
{isOidcConfigured() ? (
|
<AuthProvider {...getOidcConfig()}>
|
||||||
<AuthProvider {...getOidcConfig()}>
|
<BrowserRouter>
|
||||||
<AuthenticatedApp darkMode={prefersDarkMode} />
|
<Routes>
|
||||||
</AuthProvider>
|
<Route element={<AuthenticatedApp />}>
|
||||||
) : (
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Box sx={{ minHeight: "100vh", bgcolor: "background.default" }}>
|
<Route path="/monitoring" element={<Monitoring />} />
|
||||||
<BrowserRouter>
|
<Route path="/applications" element={<Applications />} />
|
||||||
<Shell darkMode={prefersDarkMode} />
|
<Route path="/media" element={<Applications />} />
|
||||||
</BrowserRouter>
|
<Route path="/users" element={<UsersPage />} />
|
||||||
</Box>
|
<Route path="/actions" element={<Actions />} />
|
||||||
)}
|
<Route path="/files" element={<FileBrowser />} />
|
||||||
</QueryClientProvider>
|
<Route path="/backups" element={<BackupsPage />} />
|
||||||
</ThemeProvider>
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
outline:
|
||||||
|
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
icon: "size-8",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm":
|
||||||
|
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||||
|
"icon-lg": "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button }
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
function Collapsible({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||||
|
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleTrigger
|
||||||
|
data-slot="collapsible-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleContent({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.CollapsibleContent
|
||||||
|
data-slot="collapsible-content"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as SheetPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "right",
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
|
side?: "top" | "right" | "bottom" | "left"
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
data-slot="sheet-content"
|
||||||
|
data-side={side}
|
||||||
|
className={cn(
|
||||||
|
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<SheetPrimitive.Close data-slot="sheet-close" asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-3 right-3"
|
||||||
|
size="icon-sm"
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</Button>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn("flex flex-col gap-0.5 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base font-medium text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delayDuration = 0,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delayDuration={delayDuration}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
|
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||||
@@ -1,3 +1,52 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
|
||||||
|
--color-background: #fafafa;
|
||||||
|
--color-foreground: #0f172a;
|
||||||
|
--color-card: #ffffff;
|
||||||
|
--color-card-foreground: #0f172a;
|
||||||
|
--color-popover: #ffffff;
|
||||||
|
--color-popover-foreground: #0f172a;
|
||||||
|
--color-primary: #4f8cff;
|
||||||
|
--color-primary-foreground: #ffffff;
|
||||||
|
--color-secondary: #f1f5f9;
|
||||||
|
--color-secondary-foreground: #0f172a;
|
||||||
|
--color-muted: #f1f5f9;
|
||||||
|
--color-muted-foreground: #64748b;
|
||||||
|
--color-accent: #f1f5f9;
|
||||||
|
--color-accent-foreground: #0f172a;
|
||||||
|
--color-destructive: #ef4444;
|
||||||
|
--color-destructive-foreground: #ffffff;
|
||||||
|
--color-border: #e2e8f0;
|
||||||
|
--color-input: #e2e8f0;
|
||||||
|
--color-ring: #4f8cff;
|
||||||
|
--color-chart-1: #4f8cff;
|
||||||
|
--color-chart-2: #22c55e;
|
||||||
|
--color-chart-3: #f59e0b;
|
||||||
|
--color-chart-4: #ef4444;
|
||||||
|
--color-chart-5: #8b5cf6;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--color-sidebar-background: #fafafa;
|
||||||
|
--color-sidebar-foreground: #0f172a;
|
||||||
|
--color-sidebar-primary: #4f8cff;
|
||||||
|
--color-sidebar-primary-foreground: #ffffff;
|
||||||
|
--color-sidebar-accent: #f1f5f9;
|
||||||
|
--color-sidebar-accent-foreground: #0f172a;
|
||||||
|
--color-sidebar-border: #e2e8f0;
|
||||||
|
--color-sidebar-ring: #4f8cff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
background-color: var(--color-background);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
#root {
|
#root {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
+6
-42
@@ -1,43 +1,7 @@
|
|||||||
import { createTheme, type PaletteMode } from "@mui/material/styles";
|
export function getAppTheme(mode: "light" | "dark") {
|
||||||
|
void mode;
|
||||||
export function getAppTheme(mode: PaletteMode) {
|
// Theme is now handled by Tailwind CSS + CSS variables in index.css
|
||||||
const isDark = mode === "dark";
|
// This function is kept as a no-op shim for backward compatibility
|
||||||
return createTheme({
|
// during the MUI → Tailwind migration.
|
||||||
palette: {
|
return {} as unknown;
|
||||||
mode,
|
|
||||||
primary: { main: "#4f8cff" },
|
|
||||||
background: {
|
|
||||||
default: isDark ? "#0f172a" : "#f4f6fb",
|
|
||||||
paper: isDark ? "#111827" : "#ffffff",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
shape: { borderRadius: 12 },
|
|
||||||
typography: {
|
|
||||||
fontFamily:
|
|
||||||
'Inter, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
|
|
||||||
h5: { fontWeight: 700 },
|
|
||||||
},
|
|
||||||
components: {
|
|
||||||
MuiAppBar: {
|
|
||||||
styleOverrides: {
|
|
||||||
root: {
|
|
||||||
backdropFilter: "blur(8px)",
|
|
||||||
backgroundImage: "none",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiCard: {
|
|
||||||
styleOverrides: {
|
|
||||||
root: {
|
|
||||||
borderColor: isDark
|
|
||||||
? "rgba(148,163,184,0.25)"
|
|
||||||
: "rgba(15,23,42,0.08)",
|
|
||||||
boxShadow: isDark
|
|
||||||
? "0 2px 12px rgba(2,6,23,0.35)"
|
|
||||||
: "0 2px 8px rgba(15,23,42,0.04)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
module.exports = { content: ["./src/**/*.{html,js,ts,jsx,tsx}"], theme: { extend: {} }, plugins: [] }
|
||||||
@@ -19,7 +19,13 @@
|
|||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
"ignoreDeprecations": "6.0"
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,9 @@
|
|||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"ignoreDeprecations": "6.0"
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": ["vite.config.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { defineConfig, loadEnv } from "vite";
|
import path from "path";
|
||||||
|
import { defineConfig, loadEnv, type Plugin } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
const env = loadEnv(mode, process.cwd(), "");
|
const env = loadEnv(mode, process.cwd(), "");
|
||||||
@@ -20,7 +22,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
__APP_VERSION__: JSON.stringify(appVersion),
|
__APP_VERSION__: JSON.stringify(appVersion),
|
||||||
__APP_BUILD_INFO__: JSON.stringify(appBuildInfo),
|
__APP_BUILD_INFO__: JSON.stringify(appBuildInfo),
|
||||||
},
|
},
|
||||||
plugins: [react()],
|
plugins: [react(), ...(tailwindcss() as Plugin[])],
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
@@ -30,5 +32,10 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user