init
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import { useRef } from "react"
|
||||
import { useAuthStore } from "@/shared/stores/auth-store"
|
||||
import type { AuthUser } from "@garage/api"
|
||||
|
||||
/**
|
||||
* Synchronously initializes the auth store from server-side token/user before
|
||||
* any child component renders. This avoids the first-render race condition where
|
||||
* useEffect-based hydration hasn't fired yet and API requests go out without a token.
|
||||
*/
|
||||
export function AuthStoreInitializer({ token, user }: { token: string; user: AuthUser }) {
|
||||
const initialized = useRef(false)
|
||||
if (!initialized.current) {
|
||||
initialized.current = true
|
||||
useAuthStore.setState({ token, user, isAuthenticated: true })
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import type { NavGroup, NavItem } from "@/base/types/navigation"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/shared/components/ui/collapsible"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarRail,
|
||||
useSidebar,
|
||||
} from "@/shared/components/ui/sidebar"
|
||||
|
||||
type AppSidebarProps = React.ComponentProps<typeof Sidebar> & {
|
||||
navGroups: NavGroup[]
|
||||
logo?: React.ReactNode
|
||||
}
|
||||
|
||||
export function AppSidebar({ navGroups, logo, ...props }: AppSidebarProps) {
|
||||
const { state, isMobile } = useSidebar()
|
||||
const isCollapsed = state === "collapsed" && !isMobile
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" {...props} className="bg-card">
|
||||
{logo && (
|
||||
<SidebarHeader className="flex p-4">
|
||||
{logo}
|
||||
</SidebarHeader>
|
||||
)}
|
||||
<SidebarContent className={cn("transition-[padding] duration-200", !isCollapsed && "ps-2")}>
|
||||
{navGroups.map((group, groupIndex) => (
|
||||
<SidebarGroup key={group.label ?? groupIndex}>
|
||||
{group.label && (
|
||||
<SidebarGroupLabel className="uppercase text-xs tracking-wider text-muted-foreground">
|
||||
{group.label}
|
||||
</SidebarGroupLabel>
|
||||
)}
|
||||
<SidebarMenu>
|
||||
{group.items.map((item) =>
|
||||
item.items && item.items.length > 0 ? (
|
||||
<CollapsibleNavItem key={item.href} item={item} isCollapsed={isCollapsed} />
|
||||
) : (
|
||||
<SimpleNavItem key={item.href} item={item} isCollapsed={isCollapsed} />
|
||||
)
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
))}
|
||||
</SidebarContent>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
|
||||
function SimpleNavItem({ item, isCollapsed }: { item: NavItem; isCollapsed: boolean }) {
|
||||
const pathname = usePathname()
|
||||
const isActive = item.isActive ?? pathname === item.href
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
|
||||
asChild
|
||||
isActive={isActive}
|
||||
tooltip={item.title}
|
||||
className="dashboard-nav-item"
|
||||
data-collapsed={isCollapsed}
|
||||
>
|
||||
<Link href={item.href}>
|
||||
{item.icon}
|
||||
{
|
||||
!isCollapsed &&
|
||||
<span>{item.title}</span>
|
||||
}
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleNavItem({ item, isCollapsed }: { item: NavItem; isCollapsed: boolean }) {
|
||||
const pathname = usePathname()
|
||||
const isChildActive = item.items?.some((sub) => pathname === sub.href)
|
||||
const isActive = item.isActive ?? (pathname === item.href || isChildActive === true)
|
||||
|
||||
// Collapsed sidebar → flyout dropdown with sub-items
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
|
||||
isActive={isActive}
|
||||
tooltip={item.title}
|
||||
className="dashboard-nav-item"
|
||||
data-collapsed={isCollapsed}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"transition-transform duration-300",
|
||||
isActive && "text-primary"
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
{
|
||||
!isCollapsed &&
|
||||
<span>{item.title}</span>
|
||||
}
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="right"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
className="min-w-45"
|
||||
>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
{item.title}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{item.items?.map((sub) => {
|
||||
const isSubActive = sub.isActive ?? pathname === sub.href
|
||||
return (
|
||||
<DropdownMenuItem key={sub.href} asChild>
|
||||
<Link
|
||||
href={sub.href}
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
isSubActive && "bg-primary/10 text-primary font-medium"
|
||||
)}
|
||||
>
|
||||
{sub.icon ? (
|
||||
<span className={cn("shrink-0 [&>svg]:size-4", isSubActive ? "text-primary" : "text-muted-foreground/70")}>
|
||||
{sub.icon}
|
||||
</span>
|
||||
) : (
|
||||
<Circle
|
||||
className={cn(
|
||||
"size-1.5",
|
||||
isSubActive ? "fill-primary text-primary" : "fill-muted-foreground/50 text-muted-foreground/50"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{sub.title}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
// Expanded sidebar → collapsible/accordion sub-menu
|
||||
return (
|
||||
<Collapsible asChild defaultOpen={isActive} className="group/collapsible">
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton tooltip={item.title} isActive={isActive} className="dashboard-nav-item" data-collapsed={isCollapsed}>
|
||||
<span
|
||||
className={cn(
|
||||
"transition-transform duration-300",
|
||||
isActive && "text-primary"
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
|
||||
|
||||
<span>{item.title}</span>
|
||||
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"ms-auto size-4 shrink-0 transition-transform duration-300 ease-[cubic-bezier(0.87,0,0.13,1)]",
|
||||
"group-data-[state=open]/collapsible:rotate-90"
|
||||
)}
|
||||
/>
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="overflow-hidden py-2 data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up">
|
||||
<SidebarMenuSub>
|
||||
{item.items?.map((sub) => {
|
||||
const isSubActive = sub.isActive ?? pathname === sub.href
|
||||
return (
|
||||
<SidebarMenuSubItem key={sub.href}>
|
||||
<SidebarMenuSubButton asChild isActive={isSubActive} className="dashboard-nav-sub-item my-0.5">
|
||||
<Link href={sub.href}>
|
||||
{sub.icon ? (
|
||||
<span className={cn("shrink-0 transition-colors duration-200 [&>svg]:size-4", isSubActive ? "text-primary" : "text-muted-foreground/70 group-hover/menu-sub-item:text-primary")}>
|
||||
{sub.icon}
|
||||
</span>
|
||||
) : (
|
||||
<Circle
|
||||
className={cn(
|
||||
"size-1.5 transition-colors duration-200",
|
||||
isSubActive
|
||||
? "fill-primary text-primary"
|
||||
: "fill-muted-foreground/40 text-muted-foreground/40 group-hover/menu-sub-item:fill-foreground group-hover/menu-sub-item:text-primary"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span>{sub.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
)
|
||||
})}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTheme } from "next-themes"
|
||||
import {
|
||||
BellIcon,
|
||||
LogOutIcon,
|
||||
MoonIcon,
|
||||
SearchIcon,
|
||||
SunIcon,
|
||||
UserIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import type { UserInfo } from "@/base/types/navigation"
|
||||
import { useAuthStore } from "@/shared/stores/auth-store"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { SidebarTrigger } from "@/shared/components/ui/sidebar"
|
||||
import {
|
||||
CommandDialog,
|
||||
Command,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
} from "@/shared/components/ui/command"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import { Separator } from "@/shared/components/ui/separator"
|
||||
|
||||
type DashboardHeaderProps = {
|
||||
user?: UserInfo
|
||||
actions?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function DashboardHeader({ actions, className }: DashboardHeaderProps) {
|
||||
const { resolvedTheme, setTheme } = useTheme()
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const { logout, user } = useAuthStore((s) => s)
|
||||
const router = useRouter()
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
await logout()
|
||||
router.push("/login")
|
||||
}, [logout, router])
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault()
|
||||
setSearchOpen((prev) => !prev)
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", onKeyDown)
|
||||
return () => window.removeEventListener("keydown", onKeyDown)
|
||||
}, [])
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme(resolvedTheme === "dark" ? "light" : "dark")
|
||||
}, [resolvedTheme, setTheme])
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"sticky top-0 z-30 flex h-18 shrink-0 items-center gap-2 border-b bg-card px-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Sidebar toggle — mobile: hamburger, desktop: collapse */}
|
||||
<SidebarTrigger className="-ms-2" />
|
||||
<Separator orientation="vertical" />
|
||||
|
||||
{/* Left side — default actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* User dropdown */}
|
||||
{/* {user && ( */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="flex items-center gap-2 px-2">
|
||||
<Avatar >
|
||||
{user?.avatar && <AvatarImage src={user?.avatar as string} alt={user?.name} />}
|
||||
<AvatarFallback>
|
||||
{user?.initials ?? user?.name.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden text-sm font-medium md:inline-block">
|
||||
{user?.name}
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{/* User info header */}
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex items-center gap-3 py-1">
|
||||
<Avatar size="lg">
|
||||
{user?.avatar && <AvatarImage src={user?.avatar as string} alt={user?.name} />}
|
||||
<AvatarFallback className="text-base">
|
||||
{user?.initials ?? user?.name.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-foreground">{user?.name}</span>
|
||||
{user?.email && (
|
||||
<span className="text-xs text-muted-foreground">{user?.email}</span>
|
||||
)}
|
||||
{user?.role && (
|
||||
<span className="mt-0.5 text-xs font-medium text-primary">{user?.role}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/profile">
|
||||
<UserIcon />
|
||||
Profile
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem variant="destructive" onSelect={handleLogout}>
|
||||
<LogOutIcon />
|
||||
Logout
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{/* )} */}
|
||||
|
||||
|
||||
{/* Search trigger */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-56 justify-start gap-2 text-muted-foreground md:flex"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
>
|
||||
<SearchIcon className="size-4" />
|
||||
<span className="text-sm">Search…</span>
|
||||
<kbd className="pointer-events-none ms-auto inline-flex h-5 select-none items-center gap-0.5 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium text-muted-foreground">
|
||||
⌘K
|
||||
</kbd>
|
||||
</Button>
|
||||
|
||||
{/* Mobile search icon */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="md:hidden"
|
||||
aria-label="Search"
|
||||
onClick={() => setSearchOpen(true)}
|
||||
>
|
||||
<SearchIcon className="size-4" />
|
||||
</Button>
|
||||
|
||||
{/* Theme toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Toggle theme"
|
||||
onClick={toggleTheme}
|
||||
>
|
||||
<SunIcon className="size-4 rotate-0 scale-100 transition-transform dark:-rotate-90 dark:scale-0" />
|
||||
<MoonIcon className="absolute size-4 rotate-90 scale-0 transition-transform dark:rotate-0 dark:scale-100" />
|
||||
</Button>
|
||||
|
||||
{/* Notifications */}
|
||||
<Button variant="ghost" size="icon-sm" aria-label="Notifications">
|
||||
<BellIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search command dialog */}
|
||||
<CommandDialog open={searchOpen} onOpenChange={setSearchOpen}>
|
||||
<Command>
|
||||
<CommandInput placeholder="Type to search…" />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup heading="Quick Actions">
|
||||
<CommandItem>Dashboard</CommandItem>
|
||||
<CommandItem>Job Cards</CommandItem>
|
||||
<CommandItem>Customers</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</CommandDialog>
|
||||
|
||||
{/* Right side — custom actions */}
|
||||
{actions && (
|
||||
<div className="ms-auto flex items-center gap-2">{actions}</div>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client"
|
||||
|
||||
import type { NavGroup, UserInfo } from "@/base/types/navigation"
|
||||
import { SidebarInset, SidebarProvider } from "@/shared/components/ui/sidebar"
|
||||
import { TooltipProvider } from "@/shared/components/ui/tooltip"
|
||||
import { AppSidebar } from "./app-sidebar"
|
||||
import { DashboardHeader } from "./dashboard-header"
|
||||
|
||||
type DashboardLayoutProps = {
|
||||
children: React.ReactNode
|
||||
/** Navigation groups rendered in the sidebar */
|
||||
navGroups: NavGroup[]
|
||||
/** Logo element displayed at the top of the sidebar */
|
||||
logo?: React.ReactNode
|
||||
/** Current user info shown in the header */
|
||||
user?: UserInfo
|
||||
/** Custom actions rendered in the header (e.g. session timer, clock-in button) */
|
||||
headerActions?: React.ReactNode
|
||||
/** Default sidebar open state */
|
||||
defaultOpen?: boolean
|
||||
}
|
||||
|
||||
export function DashboardLayout({
|
||||
children,
|
||||
navGroups,
|
||||
logo,
|
||||
user,
|
||||
headerActions,
|
||||
defaultOpen = true,
|
||||
}: DashboardLayoutProps) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<SidebarProvider defaultOpen={defaultOpen}>
|
||||
<AppSidebar navGroups={navGroups} logo={logo} />
|
||||
<SidebarInset>
|
||||
{children}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { cn } from '@/shared/lib/utils'
|
||||
import { title } from 'process'
|
||||
import React from 'react'
|
||||
|
||||
export default function DashboardPage({ children, header, title, fullscreen }: { children: React.ReactNode, header: React.ReactNode, title?: string, fullscreen?: boolean }) {
|
||||
return (
|
||||
<div className='page'>
|
||||
<header>
|
||||
{header}
|
||||
</header>
|
||||
<main className={cn('p-4 lg:p-8 w-full h-full', fullscreen && 'h-screen p-0 lg:p-0')}>
|
||||
{
|
||||
title &&
|
||||
<h2 className='text-lg lg:text-2xl font-bold mb-4'> {title}</h2>
|
||||
}
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { DashboardLayout } from "./dashboard-layout"
|
||||
export { AppSidebar } from "./app-sidebar"
|
||||
export { DashboardHeader } from "./dashboard-header"
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ReactNode } from "react"
|
||||
|
||||
|
||||
export type NavItem = {
|
||||
title: string
|
||||
href: string
|
||||
icon?: ReactNode
|
||||
isActive?: boolean
|
||||
badge?: string | number
|
||||
items?: NavSubItem[]
|
||||
}
|
||||
|
||||
export type NavSubItem = {
|
||||
title: string
|
||||
href: string
|
||||
icon?: ReactNode
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
export type NavGroup = {
|
||||
label?: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
export type UserInfo = {
|
||||
name: string
|
||||
email?: string
|
||||
avatar?: string
|
||||
initials?: string
|
||||
role?: string
|
||||
}
|
||||
Reference in New Issue
Block a user