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
+25
View File
@@ -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": {}
}
+5694 -50
View File
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -12,29 +12,42 @@
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fontsource-variable/geist": "^5.2.8",
"@mui/icons-material": "^9.0.0",
"@mui/material": "^9.0.0",
"@mui/x-data-grid": "^9.0.4",
"@tanstack/react-query": "^5.100.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"d3": "^7.9.0",
"lucide-react": "^1.14.0",
"oidc-client-ts": "^3.5.0",
"radix-ui": "^1.4.3",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-oidc-context": "^3.3.1",
"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": {
"@eslint/js": "^10.0.1",
"@tailwindcss/postcss": "^4.3.0",
"@tailwindcss/vite": "^4.3.0",
"@types/d3": "^7.4.3",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.5.0",
"eslint": "^10.2.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"postcss": "^8.5.14",
"tailwindcss": "^4.3.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "^8.0.10"
+1
View File
@@ -0,0 +1 @@
module.exports = { plugins: { "@tailwindcss/postcss": {}, autoprefixer: {} } }
+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>
);
}
+67
View File
@@ -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 }
+145
View File
@@ -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,
}
+55
View File
@@ -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 }
+49
View File
@@ -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,
body,
#root {
+6
View File
@@ -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
View File
@@ -1,43 +1,7 @@
import { createTheme, type PaletteMode } from "@mui/material/styles";
export function getAppTheme(mode: PaletteMode) {
const isDark = mode === "dark";
return createTheme({
palette: {
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)",
},
},
},
},
});
export function getAppTheme(mode: "light" | "dark") {
void mode;
// Theme is now handled by Tailwind CSS + CSS variables in index.css
// This function is kept as a no-op shim for backward compatibility
// during the MUI → Tailwind migration.
return {} as unknown;
}
+1
View File
@@ -0,0 +1 @@
module.exports = { content: ["./src/**/*.{html,js,ts,jsx,tsx}"], theme: { extend: {} }, plugins: [] }
+7 -1
View File
@@ -19,7 +19,13 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"ignoreDeprecations": "6.0"
},
"include": ["src"]
}
+4 -2
View File
@@ -18,7 +18,9 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
"ignoreDeprecations": "6.0"
},
"include": ["vite.config.ts"]
"include": ["vite.config.ts"],
"exclude": ["node_modules"]
}
+9 -2
View File
@@ -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 tailwindcss from "@tailwindcss/vite";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
@@ -20,7 +22,7 @@ export default defineConfig(({ mode }) => {
__APP_VERSION__: JSON.stringify(appVersion),
__APP_BUILD_INFO__: JSON.stringify(appBuildInfo),
},
plugins: [react()],
plugins: [react(), ...(tailwindcss() as Plugin[])],
server: {
proxy: {
"/api": {
@@ -30,5 +32,10 @@ export default defineConfig(({ mode }) => {
},
},
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
};
});