mavi-dashboard 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +43 -0
- package/src/dashboard/app-sidebar.tsx +76 -0
- package/src/dashboard/breadcrumb-trail.ts +41 -0
- package/src/dashboard/button-theme.tsx +38 -0
- package/src/dashboard/dashboard-layout.tsx +54 -0
- package/src/dashboard/index.ts +12 -0
- package/src/dashboard/menu-types.ts +45 -0
- package/src/dashboard/nav-active.ts +16 -0
- package/src/dashboard/nav-brand.tsx +38 -0
- package/src/dashboard/nav-default.tsx +52 -0
- package/src/dashboard/nav-header.tsx +64 -0
- package/src/dashboard/nav-menu-group.tsx +118 -0
- package/src/dashboard/nav-user.tsx +40 -0
- package/src/dashboard/theme-provider.tsx +230 -0
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mavi-dashboard",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "React dashboard layout with sidebar, header, and theme shell",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"react",
|
|
11
|
+
"dashboard",
|
|
12
|
+
"sidebar",
|
|
13
|
+
"layout"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./src/dashboard/index.ts",
|
|
20
|
+
"import": "./src/dashboard/index.ts",
|
|
21
|
+
"default": "./src/dashboard/index.ts"
|
|
22
|
+
},
|
|
23
|
+
"./package.json": "./package.json"
|
|
24
|
+
},
|
|
25
|
+
"types": "./src/dashboard/index.ts",
|
|
26
|
+
"files": [
|
|
27
|
+
"src"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"typecheck": "tsc --noEmit"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"@workspace/ui": "*",
|
|
34
|
+
"lucide-react": "^1.8.0",
|
|
35
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
36
|
+
"react-dom": "^18.0.0 || ^19.0.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/react": "^19.2.10",
|
|
40
|
+
"@types/react-dom": "^19.2.3",
|
|
41
|
+
"typescript": "^5.9.3"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import "@workspace/ui/globals.css"
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
Sidebar,
|
|
8
|
+
SidebarContent,
|
|
9
|
+
SidebarFooter,
|
|
10
|
+
SidebarHeader,
|
|
11
|
+
SidebarRail,
|
|
12
|
+
} from "@workspace/ui/components/sidebar"
|
|
13
|
+
import { NavBrand } from "./nav-brand"
|
|
14
|
+
import { NavDefault } from "./nav-default"
|
|
15
|
+
import { NavMenuGroup } from "./nav-menu-group"
|
|
16
|
+
import type { DashboardMenuConfig, MenuGroup } from "./menu-types"
|
|
17
|
+
|
|
18
|
+
function SidebarMenuSections({
|
|
19
|
+
menu,
|
|
20
|
+
activePathname,
|
|
21
|
+
}: {
|
|
22
|
+
menu: DashboardMenuConfig
|
|
23
|
+
activePathname?: string
|
|
24
|
+
}) {
|
|
25
|
+
return (
|
|
26
|
+
<>
|
|
27
|
+
{menu.sections.flatMap((section, sectionIndex) =>
|
|
28
|
+
section.groups.map((group, groupIndex) => (
|
|
29
|
+
<NavMenuGroup
|
|
30
|
+
key={`${sectionIndex}-${groupIndex}-${group.label}`}
|
|
31
|
+
group={group}
|
|
32
|
+
activePathname={activePathname}
|
|
33
|
+
/>
|
|
34
|
+
))
|
|
35
|
+
)}
|
|
36
|
+
</>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function AppSidebar({
|
|
41
|
+
brand,
|
|
42
|
+
menu,
|
|
43
|
+
footerMenuItems,
|
|
44
|
+
activePathname,
|
|
45
|
+
...props
|
|
46
|
+
}: React.ComponentProps<typeof Sidebar> & {
|
|
47
|
+
brand: { name: string; logo: React.ReactNode; plan: string }
|
|
48
|
+
menu: DashboardMenuConfig
|
|
49
|
+
footerMenuItems: MenuGroup[]
|
|
50
|
+
activePathname?: string
|
|
51
|
+
}) {
|
|
52
|
+
return (
|
|
53
|
+
<Sidebar collapsible="icon" {...props}>
|
|
54
|
+
<SidebarHeader>
|
|
55
|
+
<NavBrand brand={brand} />
|
|
56
|
+
</SidebarHeader>
|
|
57
|
+
<SidebarContent>
|
|
58
|
+
<SidebarMenuSections menu={menu} activePathname={activePathname} />
|
|
59
|
+
</SidebarContent>
|
|
60
|
+
<SidebarFooter>
|
|
61
|
+
{footerMenuItems.length > 0 && (
|
|
62
|
+
<NavDefault
|
|
63
|
+
navMenuItems={footerMenuItems}
|
|
64
|
+
activePathname={activePathname}
|
|
65
|
+
/>
|
|
66
|
+
)}
|
|
67
|
+
</SidebarFooter>
|
|
68
|
+
<div className="flex justify-start rounded-lg bg-accent group-data-[collapsible=icon]:hidden">
|
|
69
|
+
<p className="ml-4 p-1 text-[10px] text-primary">
|
|
70
|
+
Copyright © 2026 Mavi.
|
|
71
|
+
</p>
|
|
72
|
+
</div>
|
|
73
|
+
<SidebarRail />
|
|
74
|
+
</Sidebar>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export type BreadcrumbSegment = { label: string; href?: string }
|
|
2
|
+
|
|
3
|
+
function formatSegmentLabel(segment: string): string {
|
|
4
|
+
if (/^\d+$/.test(segment)) return segment
|
|
5
|
+
return segment
|
|
6
|
+
.split("-")
|
|
7
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
|
8
|
+
.join(" ")
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getBreadcrumbTrail(
|
|
12
|
+
pathname: string | undefined
|
|
13
|
+
): BreadcrumbSegment[] {
|
|
14
|
+
if (pathname == null || pathname === "") {
|
|
15
|
+
return [{ label: "Dashboard" }]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const p =
|
|
19
|
+
pathname === "/" ? "/" : pathname.replace(/\/+$/, "") || "/"
|
|
20
|
+
|
|
21
|
+
if (p === "/") {
|
|
22
|
+
return [{ label: "Dashboard" }]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const parts = p.split("/").filter(Boolean)
|
|
26
|
+
|
|
27
|
+
if (parts[0] === "project" && parts.length >= 2) {
|
|
28
|
+
return [{ label: "Project" }, { label: parts[parts.length - 1] }]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (parts.length === 1) {
|
|
32
|
+
return [{ label: formatSegmentLabel(parts[0]) }]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return parts.map((seg, idx) => ({
|
|
36
|
+
label: formatSegmentLabel(seg),
|
|
37
|
+
...(idx < parts.length - 1
|
|
38
|
+
? { href: `/${parts.slice(0, idx + 1).join("/")}` }
|
|
39
|
+
: {}),
|
|
40
|
+
}))
|
|
41
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { MoonIcon, SunIcon } from "lucide-react"
|
|
5
|
+
import { useEffect, useState } from "react"
|
|
6
|
+
import { Button } from "@workspace/ui/components/button"
|
|
7
|
+
import { useTheme } from "./theme-provider"
|
|
8
|
+
|
|
9
|
+
const ThemeToggleButton = () => {
|
|
10
|
+
const [mounted, setMounted] = useState(false)
|
|
11
|
+
const { theme, setTheme } = useTheme()
|
|
12
|
+
|
|
13
|
+
const toggleTheme = () => {
|
|
14
|
+
setTheme(theme === "dark" ? "light" : "dark")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
setMounted(true)
|
|
19
|
+
}, [])
|
|
20
|
+
|
|
21
|
+
// Prevent SSR flicker and hydration mismatch
|
|
22
|
+
if (!mounted) {
|
|
23
|
+
return <Button variant="ghost" className="rounded-full" size="icon" />
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<Button
|
|
28
|
+
variant="ghost"
|
|
29
|
+
className="rounded-full"
|
|
30
|
+
onClick={toggleTheme}
|
|
31
|
+
size="icon"
|
|
32
|
+
>
|
|
33
|
+
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
|
|
34
|
+
</Button>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default ThemeToggleButton
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import {
|
|
3
|
+
SidebarInset,
|
|
4
|
+
SidebarProvider,
|
|
5
|
+
} from "@workspace/ui/components/sidebar"
|
|
6
|
+
import { TooltipProvider } from "@workspace/ui/components/tooltip"
|
|
7
|
+
import { AppSidebar } from "./app-sidebar"
|
|
8
|
+
import type { DashboardSideConfig } from "./menu-types"
|
|
9
|
+
import { NavHeader } from "./nav-header"
|
|
10
|
+
import { ThemeProvider } from "./theme-provider"
|
|
11
|
+
|
|
12
|
+
export type {
|
|
13
|
+
DashboardMenuConfig,
|
|
14
|
+
DashboardSideConfig,
|
|
15
|
+
MenuGroup,
|
|
16
|
+
NavGroup,
|
|
17
|
+
NavItem,
|
|
18
|
+
NavItemCollaps,
|
|
19
|
+
NavItemDefault,
|
|
20
|
+
NavSection,
|
|
21
|
+
} from "./menu-types"
|
|
22
|
+
|
|
23
|
+
export function DashboardLayout({
|
|
24
|
+
children,
|
|
25
|
+
user,
|
|
26
|
+
side,
|
|
27
|
+
activePathname,
|
|
28
|
+
}: {
|
|
29
|
+
children: React.ReactNode
|
|
30
|
+
user: { name: string; email: string; avatar: string }
|
|
31
|
+
side: DashboardSideConfig
|
|
32
|
+
activePathname?: string
|
|
33
|
+
}) {
|
|
34
|
+
return (
|
|
35
|
+
<TooltipProvider>
|
|
36
|
+
<ThemeProvider>
|
|
37
|
+
<SidebarProvider>
|
|
38
|
+
<AppSidebar
|
|
39
|
+
brand={side.brand}
|
|
40
|
+
menu={side.menu}
|
|
41
|
+
footerMenuItems={side.footerMenuItems}
|
|
42
|
+
activePathname={activePathname}
|
|
43
|
+
/>
|
|
44
|
+
<SidebarInset>
|
|
45
|
+
<NavHeader user={user} activePathname={activePathname} />
|
|
46
|
+
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
|
|
47
|
+
{children}
|
|
48
|
+
</div>
|
|
49
|
+
</SidebarInset>
|
|
50
|
+
</SidebarProvider>
|
|
51
|
+
</ThemeProvider>
|
|
52
|
+
</TooltipProvider>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export * from "./breadcrumb-trail"
|
|
2
|
+
export { default as ThemeToggleButton } from "./button-theme"
|
|
3
|
+
export { DashboardLayout } from "./dashboard-layout"
|
|
4
|
+
export * from "./menu-types"
|
|
5
|
+
export * from "./nav-active"
|
|
6
|
+
export { AppSidebar } from "./app-sidebar"
|
|
7
|
+
export { NavBrand } from "./nav-brand"
|
|
8
|
+
export { NavDefault } from "./nav-default"
|
|
9
|
+
export { BreadcrumbHeader, NavHeader } from "./nav-header"
|
|
10
|
+
export { NavMenuGroup } from "./nav-menu-group"
|
|
11
|
+
export { NavUser } from "./nav-user"
|
|
12
|
+
export { ThemeProvider, useTheme } from "./theme-provider"
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
|
|
3
|
+
export type NavItemDefault = {
|
|
4
|
+
variant: "default"
|
|
5
|
+
title: string
|
|
6
|
+
url: string
|
|
7
|
+
icon: ReactNode
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type NavItemCollaps = {
|
|
11
|
+
variant: "collaps"
|
|
12
|
+
title: string
|
|
13
|
+
icon: ReactNode
|
|
14
|
+
items: { title: string; url: string }[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type NavItem = NavItemDefault | NavItemCollaps
|
|
18
|
+
|
|
19
|
+
export type NavGroup = {
|
|
20
|
+
label: string
|
|
21
|
+
items: NavItem[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type NavSection = {
|
|
25
|
+
groups: NavGroup[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type DashboardMenuConfig = {
|
|
29
|
+
sections: NavSection[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type MenuGroup = {
|
|
33
|
+
label: string
|
|
34
|
+
items: {
|
|
35
|
+
title: string
|
|
36
|
+
url: string
|
|
37
|
+
icon: ReactNode
|
|
38
|
+
}[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type DashboardSideConfig = {
|
|
42
|
+
brand: { name: string; logo: ReactNode; plan: string }
|
|
43
|
+
menu: DashboardMenuConfig
|
|
44
|
+
footerMenuItems: MenuGroup[]
|
|
45
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function isSidebarNavActive(
|
|
2
|
+
pathname: string | undefined,
|
|
3
|
+
href: string
|
|
4
|
+
): boolean {
|
|
5
|
+
if (pathname == null || pathname === "") return false
|
|
6
|
+
if (!href || href === "#" || href.startsWith("#")) return false
|
|
7
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return false
|
|
8
|
+
|
|
9
|
+
return normalizePath(pathname) === normalizePath(href)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function normalizePath(path: string): string {
|
|
13
|
+
if (path === "/" || path === "") return "/"
|
|
14
|
+
const trimmed = path.replace(/\/+$/, "")
|
|
15
|
+
return trimmed === "" ? "/" : trimmed
|
|
16
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
SidebarMenu,
|
|
7
|
+
SidebarMenuButton,
|
|
8
|
+
SidebarMenuItem,
|
|
9
|
+
} from "@workspace/ui/components/sidebar"
|
|
10
|
+
|
|
11
|
+
export function NavBrand({
|
|
12
|
+
brand,
|
|
13
|
+
}: {
|
|
14
|
+
brand: {
|
|
15
|
+
logo: React.ReactNode
|
|
16
|
+
name: string
|
|
17
|
+
plan: string
|
|
18
|
+
}
|
|
19
|
+
}) {
|
|
20
|
+
return (
|
|
21
|
+
<SidebarMenu>
|
|
22
|
+
<SidebarMenuItem>
|
|
23
|
+
<SidebarMenuButton
|
|
24
|
+
size="lg"
|
|
25
|
+
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
|
26
|
+
>
|
|
27
|
+
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
|
28
|
+
{brand.logo}
|
|
29
|
+
</div>
|
|
30
|
+
<div className="grid flex-1 text-left text-sm leading-tight">
|
|
31
|
+
<span className="truncate font-medium">{brand.name}</span>
|
|
32
|
+
<span className="truncate text-xs">{brand.plan}</span>
|
|
33
|
+
</div>
|
|
34
|
+
</SidebarMenuButton>
|
|
35
|
+
</SidebarMenuItem>
|
|
36
|
+
</SidebarMenu>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import {
|
|
5
|
+
SidebarGroup,
|
|
6
|
+
SidebarGroupLabel,
|
|
7
|
+
SidebarMenu,
|
|
8
|
+
SidebarMenuButton,
|
|
9
|
+
SidebarMenuItem,
|
|
10
|
+
} from "@workspace/ui/components/sidebar"
|
|
11
|
+
import { isSidebarNavActive } from "./nav-active"
|
|
12
|
+
|
|
13
|
+
export function NavDefault({
|
|
14
|
+
navMenuItems,
|
|
15
|
+
activePathname,
|
|
16
|
+
}: {
|
|
17
|
+
activePathname?: string
|
|
18
|
+
navMenuItems: Array<{
|
|
19
|
+
label: string
|
|
20
|
+
items: {
|
|
21
|
+
title: string
|
|
22
|
+
url: string
|
|
23
|
+
icon: React.ReactNode
|
|
24
|
+
}[]
|
|
25
|
+
}>
|
|
26
|
+
}) {
|
|
27
|
+
return (
|
|
28
|
+
<>
|
|
29
|
+
{navMenuItems.map((group, index) => (
|
|
30
|
+
<SidebarGroup key={`${group.label}-${index}`} className="">
|
|
31
|
+
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
|
32
|
+
<SidebarMenu>
|
|
33
|
+
{group.items.map((item, itemIndex) => (
|
|
34
|
+
<SidebarMenuItem key={`${item.title}-${itemIndex}`}>
|
|
35
|
+
<SidebarMenuButton
|
|
36
|
+
asChild
|
|
37
|
+
tooltip={item.title}
|
|
38
|
+
isActive={isSidebarNavActive(activePathname, item.url)}
|
|
39
|
+
>
|
|
40
|
+
<a href={item.url}>
|
|
41
|
+
{item.icon}
|
|
42
|
+
<span>{item.title}</span>
|
|
43
|
+
</a>
|
|
44
|
+
</SidebarMenuButton>
|
|
45
|
+
</SidebarMenuItem>
|
|
46
|
+
))}
|
|
47
|
+
</SidebarMenu>
|
|
48
|
+
</SidebarGroup>
|
|
49
|
+
))}
|
|
50
|
+
</>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import {
|
|
3
|
+
Breadcrumb,
|
|
4
|
+
BreadcrumbItem,
|
|
5
|
+
BreadcrumbLink,
|
|
6
|
+
BreadcrumbList,
|
|
7
|
+
BreadcrumbPage,
|
|
8
|
+
BreadcrumbSeparator,
|
|
9
|
+
} from "@workspace/ui/components/breadcrumb"
|
|
10
|
+
import { Separator } from "@workspace/ui/components/separator"
|
|
11
|
+
import { SidebarTrigger } from "@workspace/ui/components/sidebar"
|
|
12
|
+
import { getBreadcrumbTrail } from "./breadcrumb-trail"
|
|
13
|
+
import { NavUser } from "./nav-user"
|
|
14
|
+
import ThemeToggleButton from "./button-theme"
|
|
15
|
+
|
|
16
|
+
export function NavHeader({
|
|
17
|
+
user,
|
|
18
|
+
activePathname,
|
|
19
|
+
}: {
|
|
20
|
+
user: { name: string; email: string; avatar: string }
|
|
21
|
+
activePathname?: string
|
|
22
|
+
}) {
|
|
23
|
+
return (
|
|
24
|
+
<header className="sticky top-0 flex h-16 shrink-0 items-center gap-2 border-b bg-background px-4">
|
|
25
|
+
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
|
26
|
+
<SidebarTrigger className="-ml-1 shrink-0" />
|
|
27
|
+
<Separator
|
|
28
|
+
orientation="vertical"
|
|
29
|
+
className="mx-2 shrink-0 data-[orientation=vertical]:h-10"
|
|
30
|
+
/>
|
|
31
|
+
<BreadcrumbHeader pathname={activePathname} />
|
|
32
|
+
</div>
|
|
33
|
+
<div className="ml-auto flex items-center space-x-4">
|
|
34
|
+
<ThemeToggleButton />
|
|
35
|
+
<NavUser user={user} />
|
|
36
|
+
</div>
|
|
37
|
+
</header>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function BreadcrumbHeader({ pathname }: { pathname?: string }) {
|
|
42
|
+
const trail = getBreadcrumbTrail(pathname)
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<Breadcrumb>
|
|
46
|
+
<BreadcrumbList>
|
|
47
|
+
{trail.map((seg, i) => (
|
|
48
|
+
<React.Fragment key={`${seg.label}-${i}`}>
|
|
49
|
+
{i > 0 ? <BreadcrumbSeparator /> : null}
|
|
50
|
+
<BreadcrumbItem>
|
|
51
|
+
{i === trail.length - 1 ? (
|
|
52
|
+
<BreadcrumbPage>{seg.label}</BreadcrumbPage>
|
|
53
|
+
) : seg.href ? (
|
|
54
|
+
<BreadcrumbLink href={seg.href}>{seg.label}</BreadcrumbLink>
|
|
55
|
+
) : (
|
|
56
|
+
<span className="text-muted-foreground">{seg.label}</span>
|
|
57
|
+
)}
|
|
58
|
+
</BreadcrumbItem>
|
|
59
|
+
</React.Fragment>
|
|
60
|
+
))}
|
|
61
|
+
</BreadcrumbList>
|
|
62
|
+
</Breadcrumb>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import {
|
|
5
|
+
Collapsible,
|
|
6
|
+
CollapsibleContent,
|
|
7
|
+
CollapsibleTrigger,
|
|
8
|
+
} from "@workspace/ui/components/collapsible"
|
|
9
|
+
import {
|
|
10
|
+
SidebarGroup,
|
|
11
|
+
SidebarGroupLabel,
|
|
12
|
+
SidebarMenu,
|
|
13
|
+
SidebarMenuButton,
|
|
14
|
+
SidebarMenuItem,
|
|
15
|
+
SidebarMenuSub,
|
|
16
|
+
SidebarMenuSubButton,
|
|
17
|
+
SidebarMenuSubItem,
|
|
18
|
+
} from "@workspace/ui/components/sidebar"
|
|
19
|
+
import type { NavGroup } from "./menu-types"
|
|
20
|
+
import { isSidebarNavActive } from "./nav-active"
|
|
21
|
+
import { ChevronRightIcon } from "lucide-react"
|
|
22
|
+
|
|
23
|
+
function CollapsibleNavItem({
|
|
24
|
+
item,
|
|
25
|
+
activePathname,
|
|
26
|
+
}: {
|
|
27
|
+
item: Extract<NavGroup["items"][number], { variant: "collaps" }>
|
|
28
|
+
activePathname?: string
|
|
29
|
+
}) {
|
|
30
|
+
const childActive =
|
|
31
|
+
activePathname != null &&
|
|
32
|
+
item.items.some((sub) => isSidebarNavActive(activePathname, sub.url))
|
|
33
|
+
|
|
34
|
+
const [open, setOpen] = React.useState(childActive)
|
|
35
|
+
React.useEffect(() => {
|
|
36
|
+
setOpen(childActive)
|
|
37
|
+
}, [childActive])
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<Collapsible
|
|
41
|
+
asChild
|
|
42
|
+
className="group/collapsible"
|
|
43
|
+
open={open}
|
|
44
|
+
onOpenChange={setOpen}
|
|
45
|
+
>
|
|
46
|
+
<SidebarMenuItem>
|
|
47
|
+
<CollapsibleTrigger asChild>
|
|
48
|
+
<SidebarMenuButton
|
|
49
|
+
tooltip={item.title}
|
|
50
|
+
// isActive={childActive}
|
|
51
|
+
>
|
|
52
|
+
{item.icon}
|
|
53
|
+
<span>{item.title}</span>
|
|
54
|
+
<ChevronRightIcon className="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
|
55
|
+
</SidebarMenuButton>
|
|
56
|
+
</CollapsibleTrigger>
|
|
57
|
+
<CollapsibleContent>
|
|
58
|
+
<SidebarMenuSub>
|
|
59
|
+
{item.items.map((sub, subIndex) => (
|
|
60
|
+
<SidebarMenuSubItem key={`${sub.title}-${subIndex}`}>
|
|
61
|
+
<SidebarMenuSubButton
|
|
62
|
+
asChild
|
|
63
|
+
isActive={isSidebarNavActive(activePathname, sub.url)}
|
|
64
|
+
>
|
|
65
|
+
<a href={sub.url}>
|
|
66
|
+
<span>{sub.title}</span>
|
|
67
|
+
</a>
|
|
68
|
+
</SidebarMenuSubButton>
|
|
69
|
+
</SidebarMenuSubItem>
|
|
70
|
+
))}
|
|
71
|
+
</SidebarMenuSub>
|
|
72
|
+
</CollapsibleContent>
|
|
73
|
+
</SidebarMenuItem>
|
|
74
|
+
</Collapsible>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function NavMenuGroup({
|
|
79
|
+
group,
|
|
80
|
+
activePathname,
|
|
81
|
+
}: {
|
|
82
|
+
group: NavGroup
|
|
83
|
+
activePathname?: string
|
|
84
|
+
}) {
|
|
85
|
+
return (
|
|
86
|
+
<SidebarGroup>
|
|
87
|
+
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
|
88
|
+
<SidebarMenu>
|
|
89
|
+
{group.items.map((item, itemIndex) => {
|
|
90
|
+
const rowKey = `${group.label}-${item.title}-${itemIndex}`
|
|
91
|
+
if (item.variant === "default") {
|
|
92
|
+
return (
|
|
93
|
+
<SidebarMenuItem key={rowKey}>
|
|
94
|
+
<SidebarMenuButton
|
|
95
|
+
asChild
|
|
96
|
+
tooltip={item.title}
|
|
97
|
+
isActive={isSidebarNavActive(activePathname, item.url)}
|
|
98
|
+
>
|
|
99
|
+
<a href={item.url}>
|
|
100
|
+
{item.icon}
|
|
101
|
+
<span>{item.title}</span>
|
|
102
|
+
</a>
|
|
103
|
+
</SidebarMenuButton>
|
|
104
|
+
</SidebarMenuItem>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
return (
|
|
108
|
+
<CollapsibleNavItem
|
|
109
|
+
key={rowKey}
|
|
110
|
+
item={item}
|
|
111
|
+
activePathname={activePathname}
|
|
112
|
+
/>
|
|
113
|
+
)
|
|
114
|
+
})}
|
|
115
|
+
</SidebarMenu>
|
|
116
|
+
</SidebarGroup>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
import { LogOut, Settings, User } from "lucide-react"
|
|
3
|
+
import { Avatar, AvatarImage } from "@workspace/ui/components/avatar"
|
|
4
|
+
import {
|
|
5
|
+
DropdownMenu,
|
|
6
|
+
DropdownMenuContent,
|
|
7
|
+
DropdownMenuItem,
|
|
8
|
+
DropdownMenuLabel,
|
|
9
|
+
DropdownMenuSeparator,
|
|
10
|
+
DropdownMenuTrigger,
|
|
11
|
+
} from "@workspace/ui/components/dropdown-menu"
|
|
12
|
+
|
|
13
|
+
export function NavUser({
|
|
14
|
+
user,
|
|
15
|
+
}: {
|
|
16
|
+
user: { name: string; email: string; avatar: string }
|
|
17
|
+
}) {
|
|
18
|
+
return (
|
|
19
|
+
<DropdownMenu>
|
|
20
|
+
<DropdownMenuTrigger className="rounded-full focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:outline-hidden">
|
|
21
|
+
<Avatar>
|
|
22
|
+
<AvatarImage src={user.avatar} />
|
|
23
|
+
</Avatar>
|
|
24
|
+
</DropdownMenuTrigger>
|
|
25
|
+
<DropdownMenuContent align="end">
|
|
26
|
+
<DropdownMenuLabel>{user.name}</DropdownMenuLabel>
|
|
27
|
+
<DropdownMenuSeparator />
|
|
28
|
+
<DropdownMenuItem>
|
|
29
|
+
<User className="h-4 w-4" /> Profile
|
|
30
|
+
</DropdownMenuItem>
|
|
31
|
+
<DropdownMenuItem>
|
|
32
|
+
<Settings className="h-4 w-4" /> Settings
|
|
33
|
+
</DropdownMenuItem>
|
|
34
|
+
<DropdownMenuItem variant="destructive">
|
|
35
|
+
<LogOut className="h-4 w-4" /> Logout
|
|
36
|
+
</DropdownMenuItem>
|
|
37
|
+
</DropdownMenuContent>
|
|
38
|
+
</DropdownMenu>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/* eslint-disable react-refresh/only-export-components */
|
|
2
|
+
import * as React from "react"
|
|
3
|
+
|
|
4
|
+
type Theme = "dark" | "light" | "system"
|
|
5
|
+
type ResolvedTheme = "dark" | "light"
|
|
6
|
+
|
|
7
|
+
type ThemeProviderProps = {
|
|
8
|
+
children: React.ReactNode
|
|
9
|
+
defaultTheme?: Theme
|
|
10
|
+
storageKey?: string
|
|
11
|
+
disableTransitionOnChange?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type ThemeProviderState = {
|
|
15
|
+
theme: Theme
|
|
16
|
+
setTheme: (theme: Theme) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const COLOR_SCHEME_QUERY = "(prefers-color-scheme: dark)"
|
|
20
|
+
const THEME_VALUES: Theme[] = ["dark", "light", "system"]
|
|
21
|
+
|
|
22
|
+
const ThemeProviderContext = React.createContext<
|
|
23
|
+
ThemeProviderState | undefined
|
|
24
|
+
>(undefined)
|
|
25
|
+
|
|
26
|
+
function isTheme(value: string | null): value is Theme {
|
|
27
|
+
if (value === null) {
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return THEME_VALUES.includes(value as Theme)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getSystemTheme(): ResolvedTheme {
|
|
35
|
+
if (window.matchMedia(COLOR_SCHEME_QUERY).matches) {
|
|
36
|
+
return "dark"
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return "light"
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function disableTransitionsTemporarily() {
|
|
43
|
+
const style = document.createElement("style")
|
|
44
|
+
style.appendChild(
|
|
45
|
+
document.createTextNode(
|
|
46
|
+
"*,*::before,*::after{-webkit-transition:none!important;transition:none!important}"
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
document.head.appendChild(style)
|
|
50
|
+
|
|
51
|
+
return () => {
|
|
52
|
+
window.getComputedStyle(document.body)
|
|
53
|
+
requestAnimationFrame(() => {
|
|
54
|
+
requestAnimationFrame(() => {
|
|
55
|
+
style.remove()
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isEditableTarget(target: EventTarget | null) {
|
|
62
|
+
if (!(target instanceof HTMLElement)) {
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (target.isContentEditable) {
|
|
67
|
+
return true
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const editableParent = target.closest(
|
|
71
|
+
"input, textarea, select, [contenteditable='true']"
|
|
72
|
+
)
|
|
73
|
+
if (editableParent) {
|
|
74
|
+
return true
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return false
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function ThemeProvider({
|
|
81
|
+
children,
|
|
82
|
+
defaultTheme = "system",
|
|
83
|
+
storageKey = "theme",
|
|
84
|
+
disableTransitionOnChange = true,
|
|
85
|
+
...props
|
|
86
|
+
}: ThemeProviderProps) {
|
|
87
|
+
const [theme, setThemeState] = React.useState<Theme>(() => {
|
|
88
|
+
const storedTheme = localStorage.getItem(storageKey)
|
|
89
|
+
if (isTheme(storedTheme)) {
|
|
90
|
+
return storedTheme
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return defaultTheme
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
const setTheme = React.useCallback(
|
|
97
|
+
(nextTheme: Theme) => {
|
|
98
|
+
localStorage.setItem(storageKey, nextTheme)
|
|
99
|
+
setThemeState(nextTheme)
|
|
100
|
+
},
|
|
101
|
+
[storageKey]
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
const applyTheme = React.useCallback(
|
|
105
|
+
(nextTheme: Theme) => {
|
|
106
|
+
const root = document.documentElement
|
|
107
|
+
const resolvedTheme =
|
|
108
|
+
nextTheme === "system" ? getSystemTheme() : nextTheme
|
|
109
|
+
const restoreTransitions = disableTransitionOnChange
|
|
110
|
+
? disableTransitionsTemporarily()
|
|
111
|
+
: null
|
|
112
|
+
|
|
113
|
+
root.classList.remove("light", "dark")
|
|
114
|
+
root.classList.add(resolvedTheme)
|
|
115
|
+
|
|
116
|
+
if (restoreTransitions) {
|
|
117
|
+
restoreTransitions()
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
[disableTransitionOnChange]
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
React.useEffect(() => {
|
|
124
|
+
applyTheme(theme)
|
|
125
|
+
|
|
126
|
+
if (theme !== "system") {
|
|
127
|
+
return undefined
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const mediaQuery = window.matchMedia(COLOR_SCHEME_QUERY)
|
|
131
|
+
const handleChange = () => {
|
|
132
|
+
applyTheme("system")
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
mediaQuery.addEventListener("change", handleChange)
|
|
136
|
+
|
|
137
|
+
return () => {
|
|
138
|
+
mediaQuery.removeEventListener("change", handleChange)
|
|
139
|
+
}
|
|
140
|
+
}, [theme, applyTheme])
|
|
141
|
+
|
|
142
|
+
React.useEffect(() => {
|
|
143
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
144
|
+
if (event.repeat) {
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (event.metaKey || event.ctrlKey || event.altKey) {
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (isEditableTarget(event.target)) {
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (event.key.toLowerCase() !== "d") {
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
setThemeState((currentTheme) => {
|
|
161
|
+
const nextTheme =
|
|
162
|
+
currentTheme === "dark"
|
|
163
|
+
? "light"
|
|
164
|
+
: currentTheme === "light"
|
|
165
|
+
? "dark"
|
|
166
|
+
: getSystemTheme() === "dark"
|
|
167
|
+
? "light"
|
|
168
|
+
: "dark"
|
|
169
|
+
|
|
170
|
+
localStorage.setItem(storageKey, nextTheme)
|
|
171
|
+
return nextTheme
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
window.addEventListener("keydown", handleKeyDown)
|
|
176
|
+
|
|
177
|
+
return () => {
|
|
178
|
+
window.removeEventListener("keydown", handleKeyDown)
|
|
179
|
+
}
|
|
180
|
+
}, [storageKey])
|
|
181
|
+
|
|
182
|
+
React.useEffect(() => {
|
|
183
|
+
const handleStorageChange = (event: StorageEvent) => {
|
|
184
|
+
if (event.storageArea !== localStorage) {
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (event.key !== storageKey) {
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (isTheme(event.newValue)) {
|
|
193
|
+
setThemeState(event.newValue)
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
setThemeState(defaultTheme)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
window.addEventListener("storage", handleStorageChange)
|
|
201
|
+
|
|
202
|
+
return () => {
|
|
203
|
+
window.removeEventListener("storage", handleStorageChange)
|
|
204
|
+
}
|
|
205
|
+
}, [defaultTheme, storageKey])
|
|
206
|
+
|
|
207
|
+
const value = React.useMemo(
|
|
208
|
+
() => ({
|
|
209
|
+
theme,
|
|
210
|
+
setTheme,
|
|
211
|
+
}),
|
|
212
|
+
[theme, setTheme]
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<ThemeProviderContext.Provider {...props} value={value}>
|
|
217
|
+
{children}
|
|
218
|
+
</ThemeProviderContext.Provider>
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export const useTheme = () => {
|
|
223
|
+
const context = React.useContext(ThemeProviderContext)
|
|
224
|
+
|
|
225
|
+
if (context === undefined) {
|
|
226
|
+
throw new Error("useTheme must be used within a ThemeProvider")
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return context
|
|
230
|
+
}
|