feat: rewrite dashboard layout with shadcn Sheet + Tailwind sidebar
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,37 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useState, useEffect } from "react";
|
||||
import { ReactNode, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
AppBar,
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Drawer,
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Stack,
|
||||
Toolbar,
|
||||
Typography,
|
||||
useTheme,
|
||||
useMediaQuery,
|
||||
IconButton
|
||||
} from "@mui/material";
|
||||
import MenuIcon from "@mui/icons-material/Menu";
|
||||
import DashboardIcon from "@mui/icons-material/Dashboard";
|
||||
import SwapHorizIcon from "@mui/icons-material/SwapHoriz";
|
||||
import BarChartIcon from "@mui/icons-material/BarChart";
|
||||
import VpnKeyIcon from "@mui/icons-material/VpnKey";
|
||||
import SecurityIcon from "@mui/icons-material/Security";
|
||||
import SettingsIcon from "@mui/icons-material/Settings";
|
||||
import HistoryIcon from "@mui/icons-material/History";
|
||||
import GppBadIcon from "@mui/icons-material/GppBad";
|
||||
import CableIcon from "@mui/icons-material/Cable";
|
||||
import LogoutIcon from "@mui/icons-material/Logout";
|
||||
LayoutDashboard, ArrowLeftRight, Cable, KeyRound, ShieldCheck,
|
||||
ShieldOff, BarChart2, History, Settings, LogOut, Menu, Sun, Moon,
|
||||
} from "lucide-react";
|
||||
import { Sheet, SheetContent } from "@/src/components/ui/sheet";
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/src/components/ui/avatar";
|
||||
import { Separator } from "@/src/components/ui/separator";
|
||||
import { ScrollArea } from "@/src/components/ui/scroll-area";
|
||||
import { cn } from "@/src/lib/utils";
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
@@ -41,204 +23,153 @@ type User = {
|
||||
};
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/", label: "Overview", icon: DashboardIcon },
|
||||
{ href: "/proxy-hosts", label: "Proxy Hosts", icon: SwapHorizIcon },
|
||||
{ href: "/l4-proxy-hosts", label: "L4 Proxy Hosts", icon: CableIcon },
|
||||
{ href: "/access-lists", label: "Access Lists", icon: VpnKeyIcon },
|
||||
{ href: "/certificates", label: "Certificates", icon: SecurityIcon },
|
||||
{ href: "/waf", label: "WAF", icon: GppBadIcon },
|
||||
{ href: "/analytics", label: "Analytics", icon: BarChartIcon },
|
||||
{ href: "/audit-log", label: "Audit Log", icon: HistoryIcon },
|
||||
{ href: "/settings", label: "Settings", icon: SettingsIcon },
|
||||
{ href: "/", label: "Overview", icon: LayoutDashboard },
|
||||
{ href: "/proxy-hosts", label: "Proxy Hosts", icon: ArrowLeftRight },
|
||||
{ href: "/l4-proxy-hosts", label: "L4 Proxy Hosts", icon: Cable },
|
||||
{ href: "/access-lists", label: "Access Lists", icon: KeyRound },
|
||||
{ href: "/certificates", label: "Certificates", icon: ShieldCheck },
|
||||
{ href: "/waf", label: "WAF", icon: ShieldOff },
|
||||
{ href: "/analytics", label: "Analytics", icon: BarChart2 },
|
||||
{ href: "/audit-log", label: "Audit Log", icon: History },
|
||||
{ href: "/settings", label: "Settings", icon: Settings },
|
||||
] as const;
|
||||
|
||||
const DRAWER_WIDTH = 260;
|
||||
const APP_BAR_HEIGHT = 48;
|
||||
function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardLayoutClient({ user, children }: { user: User; children: ReactNode }) {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
|
||||
const pathname = usePathname();
|
||||
function NavContent({ pathname, user, onNavigate }: {
|
||||
pathname: string;
|
||||
user: User;
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleDrawerToggle = () => {
|
||||
setMobileOpen(!mobileOpen);
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Logo */}
|
||||
<div className="px-4 py-6">
|
||||
<p className="text-primary font-bold text-base tracking-tight">Caddy Proxy</p>
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-widest">Manager</p>
|
||||
</div>
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile) setMobileOpen(false);
|
||||
}, [pathname, isMobile]);
|
||||
{/* Nav items */}
|
||||
<ScrollArea className="flex-1 px-2">
|
||||
<nav className="flex flex-col gap-0.5">
|
||||
{NAV_ITEMS.map(({ href, label, icon: Icon }) => {
|
||||
const active = pathname === href;
|
||||
return (
|
||||
<Button
|
||||
key={href}
|
||||
variant={active ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full justify-start gap-3 font-medium",
|
||||
active
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
asChild
|
||||
onClick={onNavigate}
|
||||
>
|
||||
<Link href={href}>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{label}
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</ScrollArea>
|
||||
|
||||
const drawerContent = (
|
||||
<Stack sx={{ height: "100%", p: 2 }}>
|
||||
<Box sx={{ px: 2, py: 3, mb: 1 }}>
|
||||
<Typography variant="h6" color="primary.main" sx={{ letterSpacing: "-0.02em" }}>
|
||||
Caddy Proxy
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ letterSpacing: "0.1em", textTransform: "uppercase" }}>
|
||||
Manager
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<List sx={{ flex: 1, gap: 0.5, display: "flex", flexDirection: "column" }}>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const selected = pathname === item.href;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<ListItemButton
|
||||
key={item.href}
|
||||
component={Link}
|
||||
href={item.href}
|
||||
selected={selected}
|
||||
sx={{
|
||||
mb: 0.5,
|
||||
borderRadius: 3,
|
||||
color: selected ? "primary.contrastText" : "text.secondary",
|
||||
"& .MuiListItemIcon-root": {
|
||||
color: selected ? "inherit" : "text.secondary",
|
||||
minWidth: 40,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Icon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={item.label}
|
||||
primaryTypographyProps={{
|
||||
variant: "body2",
|
||||
fontWeight: selected ? 600 : 500,
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Divider sx={{ mb: 2, borderColor: "rgba(255,255,255,0.05)" }} />
|
||||
<ListItemButton
|
||||
onClick={() => router.push("/profile")}
|
||||
sx={{
|
||||
gap: 2,
|
||||
px: 1,
|
||||
mb: 2,
|
||||
py: 1,
|
||||
borderRadius: 1,
|
||||
color: "text.primary"
|
||||
}}
|
||||
{/* Footer */}
|
||||
<div className="p-3 space-y-2">
|
||||
<Separator />
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-3 px-2 h-auto py-2"
|
||||
onClick={() => { router.push("/profile"); onNavigate?.(); }}
|
||||
>
|
||||
<Avatar
|
||||
src={user.image || undefined}
|
||||
alt={user.name || "User"}
|
||||
sx={{ width: 40, height: 40, border: "2px solid", borderColor: "background.paper" }}
|
||||
>
|
||||
{(user.name?.[0] || "U").toUpperCase()}
|
||||
<Avatar className="h-8 w-8 shrink-0">
|
||||
<AvatarImage src={user.image ?? undefined} alt={user.name ?? "User"} />
|
||||
<AvatarFallback>{(user.name?.[0] ?? "U").toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<Box sx={{ overflow: "hidden" }}>
|
||||
<Typography variant="subtitle2" noWrap sx={{ color: "text.primary" }}>
|
||||
{user.name || "Administrator"}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap display="block">
|
||||
{user.email}
|
||||
</Typography>
|
||||
</Box>
|
||||
</ListItemButton>
|
||||
<div className="flex flex-col items-start overflow-hidden">
|
||||
<span className="text-sm font-medium truncate w-full">{user.name ?? "Administrator"}</span>
|
||||
<span className="text-xs text-muted-foreground truncate w-full">{user.email}</span>
|
||||
</div>
|
||||
</Button>
|
||||
<form action="/api/auth/logout" method="POST">
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
startIcon={<LogoutIcon />}
|
||||
sx={{
|
||||
justifyContent: "flex-start",
|
||||
borderColor: "rgba(255,255,255,0.1)",
|
||||
color: "text.secondary",
|
||||
"&:hover": {
|
||||
borderColor: "rgba(255,255,255,0.2)",
|
||||
bgcolor: "rgba(255,255,255,0.02)",
|
||||
color: "text.primary"
|
||||
}
|
||||
}}
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-2 text-muted-foreground"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Sign Out
|
||||
</Button>
|
||||
</form>
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", minHeight: "100vh" }}>
|
||||
{isMobile && (
|
||||
<AppBar
|
||||
position="fixed"
|
||||
elevation={0}
|
||||
sx={{
|
||||
bgcolor: "background.paper",
|
||||
borderBottom: "1px solid",
|
||||
borderColor: "divider",
|
||||
zIndex: theme.zIndex.drawer + 1,
|
||||
}}
|
||||
>
|
||||
<Toolbar variant="dense" sx={{ gap: 1.5 }}>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
aria-label="open drawer"
|
||||
edge="start"
|
||||
onClick={handleDrawerToggle}
|
||||
sx={{ color: "text.secondary" }}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography variant="subtitle1" color="primary.main" sx={{ fontWeight: 700, letterSpacing: "-0.02em" }}>
|
||||
Caddy Proxy Manager
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
)}
|
||||
|
||||
<Box
|
||||
component="nav"
|
||||
sx={{ width: { md: DRAWER_WIDTH }, flexShrink: { md: 0 } }}
|
||||
>
|
||||
<Drawer
|
||||
variant={isMobile ? "temporary" : "permanent"}
|
||||
open={isMobile ? mobileOpen : true}
|
||||
onClose={handleDrawerToggle}
|
||||
ModalProps={{ keepMounted: true }}
|
||||
sx={{
|
||||
"& .MuiDrawer-paper": {
|
||||
boxSizing: "border-box",
|
||||
width: DRAWER_WIDTH,
|
||||
borderRight: "1px solid",
|
||||
borderColor: "divider",
|
||||
bgcolor: "background.default",
|
||||
top: { xs: `${APP_BAR_HEIGHT}px`, md: 0 },
|
||||
height: { xs: `calc(100% - ${APP_BAR_HEIGHT}px)`, md: "100%" },
|
||||
}
|
||||
}}
|
||||
>
|
||||
{drawerContent}
|
||||
</Drawer>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
p: { xs: 2, md: 5 },
|
||||
width: { md: `calc(100% - ${DRAWER_WIDTH}px)` },
|
||||
mt: { xs: `${APP_BAR_HEIGHT}px`, md: 0 },
|
||||
overflowX: { xs: 'hidden', md: 'visible' },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ maxWidth: 1200, mx: "auto" }}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardLayoutClient({ user, children }: { user: User; children: ReactNode }) {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* Desktop sidebar — fixed, hidden on mobile */}
|
||||
<aside className="hidden md:flex flex-col fixed inset-y-0 left-0 w-64 border-r border-border bg-card z-30">
|
||||
<NavContent pathname={pathname} user={user} />
|
||||
</aside>
|
||||
|
||||
{/* Mobile top bar */}
|
||||
<header className="md:hidden fixed top-0 inset-x-0 h-12 flex items-center justify-between px-4 border-b border-border bg-card z-40">
|
||||
<Button variant="ghost" size="icon" onClick={() => setMobileOpen(true)}>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
<span className="text-primary font-bold text-sm">Caddy Proxy Manager</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<ThemeToggle />
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push("/profile")}>
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage src={user.image ?? undefined} />
|
||||
<AvatarFallback className="text-[10px]">{(user.name?.[0] ?? "U").toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Mobile Sheet drawer */}
|
||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||
<SheetContent side="left" className="w-64 p-0">
|
||||
<NavContent pathname={pathname} user={user} onNavigate={() => setMobileOpen(false)} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 md:ml-64 mt-12 md:mt-0">
|
||||
<div className="max-w-screen-xl mx-auto px-4 md:px-8 py-6">
|
||||
{/* Theme toggle for desktop — top-right corner */}
|
||||
<div className="hidden md:flex justify-end mb-2">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user