refactor(web): split dashboard route components

- extract DashboardHeader and UserAccountDropdown into
  apps/web/src/components/dashboard
- remove inline implementations from apps/web/src/routes/dashboard.tsx
- no functional changes; improves modularity and reusability
This commit is contained in:
2025-10-07 12:32:28 +07:00
parent 22d1975ae5
commit 4aa87aee54
3 changed files with 215 additions and 170 deletions

View File

@@ -0,0 +1,80 @@
import { useRouter } from "@tanstack/react-router";
import type { LucideIcon } from "lucide-react";
import {
ArrowLeft,
Database,
LayoutDashboard,
Puzzle,
Users,
} from "lucide-react";
import UserMenu from "@/components/UserMenu";
import { Button } from "@/components/ui/button";
import { SidebarTrigger } from "@/components/ui/sidebar";
import type { FileRoutesByTo } from "@/routeTree.gen";
type DashboardRoute = Extract<
keyof FileRoutesByTo,
| "/dashboard"
| "/dashboard/users"
| "/dashboard/libraries"
| "/dashboard/extensions"
>;
interface MenuItem {
readonly title: string;
readonly url: DashboardRoute;
readonly icon: LucideIcon;
}
const menuItems: readonly MenuItem[] = [
{
title: "Dashboard",
url: "/dashboard",
icon: LayoutDashboard,
},
{
title: "Users",
url: "/dashboard/users",
icon: Users,
},
{
title: "Libraries",
url: "/dashboard/libraries",
icon: Database,
},
{
title: "Extensions",
url: "/dashboard/extensions",
icon: Puzzle,
},
];
export function DashboardHeader() {
const router = useRouter();
const pathname = router.state.location.pathname;
const getHeaderTitle = () => {
const menuItem = menuItems.find((item) => item.url === pathname);
return menuItem?.title || "Dashboard";
};
return (
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
<div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" />
<Button
variant="ghost"
size="icon"
onClick={() => router.history.back()}
aria-label="Go back"
>
<ArrowLeft className="size-5" />
</Button>
</div>
<div className="flex flex-1 items-center justify-between">
<h1 className="font-semibold text-lg">{getHeaderTitle()}</h1>
<UserMenu />
</div>
</header>
);
}

View File

@@ -0,0 +1,132 @@
import { useNavigate } from "@tanstack/react-router";
import { ChevronUp, LogOut, Settings, User } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SidebarMenuButton } from "@/components/ui/sidebar";
import { Skeleton } from "@/components/ui/skeleton";
import { authClient } from "@/lib/auth-client";
export function UserAccountDropdown() {
const navigate = useNavigate();
const { data: session, isPending } = authClient.useSession();
if (isPending) {
return (
<div className="flex items-center gap-2 px-2 py-1.5">
<Skeleton className="size-8 rounded-lg" />
<div className="flex flex-1 flex-col gap-1">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-32" />
</div>
</div>
);
}
if (!session) {
return null;
}
const user = session.user;
const userInitials = user.name
? user.name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
: user.email?.charAt(0).toUpperCase() || "U";
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="size-8 rounded-lg">
<AvatarImage
src={user.image || undefined}
alt={user.name || "User"}
/>
<AvatarFallback className="rounded-lg">
{userInitials}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{user.name || "User"}
</span>
<span className="truncate text-muted-foreground text-xs">
{user.email}
</span>
</div>
<ChevronUp className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side="top"
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="size-8 rounded-lg">
<AvatarImage
src={user.image || undefined}
alt={user.name || "User"}
/>
<AvatarFallback className="rounded-lg">
{userInitials}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{user.name || "User"}
</span>
<span className="truncate text-muted-foreground text-xs">
{user.email}
</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<User />
Profile
</DropdownMenuItem>
<DropdownMenuItem>
<Settings />
Settings
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600!"
onClick={() => {
authClient.signOut({
fetchOptions: {
onSuccess: () => {
navigate({
to: "/",
});
},
},
});
}}
>
<LogOut className="text-red-600!" />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,35 +1,17 @@
import {
createFileRoute,
Link,
Outlet,
useNavigate,
useRouter,
} from "@tanstack/react-router";
import { createFileRoute, Link, Outlet } from "@tanstack/react-router";
import type { LucideIcon } from "lucide-react";
import {
ArrowLeft,
ChevronUp,
Database,
LayoutDashboard,
LogOut,
Puzzle,
Settings,
User,
Users,
} from "lucide-react";
import logoSrc from "@/assets/logo.png";
import UserMenu from "@/components/UserMenu";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { DashboardHeader } from "@/components/dashboard/DashboardHeader";
import { UserAccountDropdown } from "@/components/dashboard/UserAccountDropdown";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Empty,
EmptyContent,
@@ -50,11 +32,8 @@ import {
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { Skeleton } from "@/components/ui/skeleton";
import { canAccessDashboard } from "@/functions/auth";
import { authClient } from "@/lib/auth-client";
import type { FileRoutesByTo } from "@/routeTree.gen";
export const Route = createFileRoute("/dashboard")({
@@ -103,152 +82,6 @@ const menuItems: readonly MenuItem[] = [
},
];
function UserAccountDropdown() {
const navigate = useNavigate();
const { data: session, isPending } = authClient.useSession();
if (isPending) {
return (
<div className="flex items-center gap-2 px-2 py-1.5">
<Skeleton className="size-8 rounded-lg" />
<div className="flex flex-1 flex-col gap-1">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-32" />
</div>
</div>
);
}
if (!session) {
return null;
}
const user = session.user;
const userInitials = user.name
? user.name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
: user.email?.charAt(0).toUpperCase() || "U";
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="size-8 rounded-lg">
<AvatarImage
src={user.image || undefined}
alt={user.name || "User"}
/>
<AvatarFallback className="rounded-lg">
{userInitials}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{user.name || "User"}
</span>
<span className="truncate text-muted-foreground text-xs">
{user.email}
</span>
</div>
<ChevronUp className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side="top"
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="size-8 rounded-lg">
<AvatarImage
src={user.image || undefined}
alt={user.name || "User"}
/>
<AvatarFallback className="rounded-lg">
{userInitials}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{user.name || "User"}
</span>
<span className="truncate text-muted-foreground text-xs">
{user.email}
</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<User />
Profile
</DropdownMenuItem>
<DropdownMenuItem>
<Settings />
Settings
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600!"
onClick={() => {
authClient.signOut({
fetchOptions: {
onSuccess: () => {
navigate({
to: "/",
});
},
},
});
}}
>
<LogOut className="text-red-600!" />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
function DashboardHeader() {
const router = useRouter();
const pathname = router.state.location.pathname;
const getHeaderTitle = () => {
const menuItem = menuItems.find((item) => item.url === pathname);
return menuItem?.title || "Dashboard";
};
return (
<header className="flex h-16 shrink-0 items-center gap-2 border-b px-4">
<div className="flex items-center gap-2">
<SidebarTrigger className="-ml-1" />
<Button
variant="ghost"
size="icon"
onClick={() => router.history.back()}
aria-label="Go back"
>
<ArrowLeft className="size-5" />
</Button>
</div>
<div className="flex flex-1 items-center justify-between">
<h1 className="font-semibold text-lg">{getHeaderTitle()}</h1>
<UserMenu />
</div>
</header>
);
}
function RouteComponent() {
const loaderData = Route.useLoaderData();