diff --git a/.github/workflows/dashboard-ci.yml b/.github/workflows/dashboard-ci.yml
@@ -3,12 +3,8 @@ name: Dashboard CI
on:
push:
- paths:
- - './dashboard/**'
branches: [ "master" ]
pull_request:
- paths:
- - './dashboard/**'
jobs:
build:
diff --git a/dashboard/.eslintrc.cjs b/dashboard/.eslintrc.cjs
@@ -10,6 +10,8 @@ module.exports = {
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
+ '@typescript-eslint/ban-ts-comment': ['off'],
+ '@typescript-eslint/no-explicit-any': ['off'],
'react-refresh/only-export-components': ['off'],
},
-}
+};
diff --git a/dashboard/package.json b/dashboard/package.json
@@ -13,7 +13,9 @@
"test:coverage": "vitest run --coverage"
},
"dependencies": {
+ "@heroicons/react": "^2.1.1",
"@hookform/resolvers": "^3.3.4",
+ "@kodingdotninja/use-tailwind-breakpoint": "^0.0.5",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-aspect-ratio": "^1.0.3",
"@radix-ui/react-checkbox": "^1.0.4",
@@ -22,11 +24,13 @@
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7",
+ "@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-toggle": "^1.0.3",
"@radix-ui/react-tooltip": "^1.0.7",
+ "@react-hook/media-query": "^1.1.1",
"@remix-run/router": "^1.15.3",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@tailwindcss/forms": "^0.5.7",
diff --git a/dashboard/pnpm-lock.yaml b/dashboard/pnpm-lock.yaml
@@ -5,9 +5,15 @@ settings:
excludeLinksFromLockfile: false
dependencies:
+ '@heroicons/react':
+ specifier: ^2.1.1
+ version: 2.1.1(react@18.2.0)
'@hookform/resolvers':
specifier: ^3.3.4
version: 3.3.4(react-hook-form@7.51.1)
+ '@kodingdotninja/use-tailwind-breakpoint':
+ specifier: ^0.0.5
+ version: 0.0.5(react@18.2.0)
'@radix-ui/react-alert-dialog':
specifier: ^1.0.5
version: 1.0.5(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0)(react@18.2.0)
@@ -32,6 +38,9 @@ dependencies:
'@radix-ui/react-popover':
specifier: ^1.0.7
version: 1.0.7(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0)(react@18.2.0)
+ '@radix-ui/react-radio-group':
+ specifier: ^1.1.3
+ version: 1.1.3(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-separator':
specifier: ^1.0.3
version: 1.0.3(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0)(react@18.2.0)
@@ -47,6 +56,9 @@ dependencies:
'@radix-ui/react-tooltip':
specifier: ^1.0.7
version: 1.0.7(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0)(react@18.2.0)
+ '@react-hook/media-query':
+ specifier: ^1.1.1
+ version: 1.1.1(react@18.2.0)
'@remix-run/router':
specifier: ^1.15.3
version: 1.15.3
@@ -474,6 +486,14 @@ packages:
resolution: {integrity: sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==}
dev: false
+ /@heroicons/react@2.1.1(react@18.2.0):
+ resolution: {integrity: sha512-JyyN9Lo66kirbCMuMMRPtJxtKJoIsXKS569ebHGGRKbl8s4CtUfLnyKJxteA+vIKySocO4s1SkTkGS4xtG/yEA==}
+ peerDependencies:
+ react: '>= 16'
+ dependencies:
+ react: 18.2.0
+ dev: false
+
/@hookform/resolvers@3.3.4(react-hook-form@7.51.1):
resolution: {integrity: sha512-o5cgpGOuJYrd+iMKvkttOclgwRW86EsWJZZRC23prf0uU2i48Htq4PuT73AVb9ionFyZrwYEITuOFGF+BydEtQ==}
peerDependencies:
@@ -545,6 +565,17 @@ packages:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.4.15
+ /@kodingdotninja/use-tailwind-breakpoint@0.0.5(react@18.2.0):
+ resolution: {integrity: sha512-7h52zL+l+14u9diyjVcGoS5NRIwrc6euIGlHDwtJXlrR3h31RNyTrOfimy7PJTvS4fFdNgbzyrNcGfhj3sDRfQ==}
+ peerDependencies:
+ react: '>=16.8'
+ peerDependenciesMeta:
+ react:
+ optional: true
+ dependencies:
+ react: 18.2.0
+ dev: false
+
/@nodelib/fs.scandir@2.1.5:
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -1095,6 +1126,36 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
+ /@radix-ui/react-radio-group@1.1.3(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0)(react@18.2.0):
+ resolution: {integrity: sha512-x+yELayyefNeKeTx4fjK6j99Fs6c4qKm3aY38G3swQVTN6xMpsrbigC0uHs2L//g8q4qR7qOcww8430jJmi2ag==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0
+ react-dom: ^16.8 || ^17.0 || ^18.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+ dependencies:
+ '@babel/runtime': 7.24.0
+ '@radix-ui/primitive': 1.0.1
+ '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.64)(react@18.2.0)
+ '@radix-ui/react-context': 1.0.1(@types/react@18.2.64)(react@18.2.0)
+ '@radix-ui/react-direction': 1.0.1(@types/react@18.2.64)(react@18.2.0)
+ '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0)(react@18.2.0)
+ '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0)(react@18.2.0)
+ '@radix-ui/react-roving-focus': 1.0.4(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0)(react@18.2.0)
+ '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.64)(react@18.2.0)
+ '@radix-ui/react-use-previous': 1.0.1(@types/react@18.2.64)(react@18.2.0)
+ '@radix-ui/react-use-size': 1.0.1(@types/react@18.2.64)(react@18.2.0)
+ '@types/react': 18.2.64
+ '@types/react-dom': 18.2.21
+ react: 18.2.0
+ react-dom: 18.2.0(react@18.2.0)
+ dev: false
+
/@radix-ui/react-roving-focus@1.0.4(@types/react-dom@18.2.21)(@types/react@18.2.64)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==}
peerDependencies:
@@ -1371,6 +1432,14 @@ packages:
'@babel/runtime': 7.24.0
dev: false
+ /@react-hook/media-query@1.1.1(react@18.2.0):
+ resolution: {integrity: sha512-VM14wDOX5CW5Dn6b2lTiMd79BFMTut9AZj2+vIRT3LCKgMCYmdqruTtzDPSnIVDQdtxdPgtOzvU9oK20LopuOw==}
+ peerDependencies:
+ react: '>=16.8'
+ dependencies:
+ react: 18.2.0
+ dev: false
+
/@remix-run/router@1.15.3:
resolution: {integrity: sha512-Oy8rmScVrVxWZVOpEF57ovlnhpZ8CCPlnIIumVcV9nFdiSIrus99+Lw78ekXyGvVDlIsFJbSfmSovJUhCWYV3w==}
engines: {node: '>=14.0.0'}
@@ -3707,7 +3776,6 @@ packages:
/ufo@1.5.2:
resolution: {integrity: sha512-eiutMaL0J2MKdhcOM1tUy13pIrYnyR87fEd8STJQFrrAwImwvlXkxlZEjaKah8r2viPohld08lt73QfLG1NxMg==}
- dev: true
/undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
diff --git a/dashboard/src/components/header/index.tsx b/dashboard/src/components/header/index.tsx
@@ -0,0 +1,21 @@
+import { ThemeToggle } from "@marzneshin/features/theme-switch"
+import { Link } from "@tanstack/react-router"
+import { FC, PropsWithChildren } from "react"
+
+export const Header: FC<PropsWithChildren> = ({ children }) => {
+ return (
+ <div className="flex flex-row justify-between items-center p-1 px-4 w-full h-full bg-secondary text-primary-foreground dark:bg-primary-foreground">
+ <div className="flex flex-row gap-4 justify-center items-center">
+ <Link to="/">
+ <div className="flex flex-col justify-center items-center p-2 h-10 text-2xl font-bold rounded-lg font-header bg-accent text-primary border-accent">
+ M
+ </div>
+ </Link>
+ {children}
+ </div>
+ <div className="flex flex-row justify-reverse">
+ <ThemeToggle />
+ </div>
+ </div>
+ )
+}
diff --git a/dashboard/src/components/index.ts b/dashboard/src/components/index.ts
@@ -1 +1,3 @@
export * from './ui'
+export * from './header'
+export * from './sidebar'
diff --git a/dashboard/src/components/nav-dir/index.tsx b/dashboard/src/components/nav-dir/index.tsx
@@ -0,0 +1,144 @@
+
+import { Link, useRouterState } from "@tanstack/react-router";
+import React, { FC } from 'react'
+import {
+ BreadcrumbPage,
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerTrigger,
+ Breadcrumb,
+ BreadcrumbEllipsis,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbSeparator,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+ Button,
+ SidebarObject,
+} from "@marzneshin/components"
+import { useBreakpoint } from "@marzneshin/hooks/use-screen-breakpoint";
+import { AppRouterPaths } from "@marzneshin/types";
+import i18n from "@marzneshin/features/i18n";
+
+interface Dir {
+ href: AppRouterPaths;
+ label: string;
+}
+
+interface NavigationDirectoryProps {
+ sidebar: SidebarObject;
+}
+
+
+function getPathDirs(currentPath: string, sidebar: SidebarObject): Dir[] {
+ const dirs: Dir[] = [];
+ const pathArray = currentPath.split('/').filter(Boolean); // Split path and remove empty strings
+ let fullPath = '/';
+ for (let i = 0; i < pathArray.length; i++) {
+ const dir = pathArray[i];
+ fullPath += `/${dir}`;
+ if (sidebar[dir]) {
+ dirs.push({ href: sidebar[dir][0].to, label: sidebar[dir][0].title });
+ } else {
+ dirs.push({ href: fullPath as AppRouterPaths, label: i18n.t(dir) });
+ }
+ }
+ return dirs;
+}
+
+const ITEMS_TO_DISPLAY = 3
+
+export const NavigationDirectory: FC<NavigationDirectoryProps> = ({ sidebar }) => {
+ const [open, setOpen] = React.useState(false)
+ const currentPath = useRouterState().location.pathname;
+ const items = getPathDirs(currentPath, sidebar);
+ const isDesktop = !useBreakpoint("sm");
+ return (
+ <Breadcrumb>
+ <BreadcrumbList>
+ {items.length > ITEMS_TO_DISPLAY ? (
+ <>
+ <BreadcrumbItem>
+ {isDesktop ? (
+ <DropdownMenu open={open} onOpenChange={setOpen}>
+ <DropdownMenuTrigger
+ className="flex gap-1 items-center"
+ aria-label="Toggle menu"
+ >
+ <BreadcrumbEllipsis className="w-4 h-4" />
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="start">
+ {items.slice(1, -2).map((item, index) => (
+ <DropdownMenuItem key={index}>
+ <Link to={item.href}>
+ {item.label}
+ </Link>
+ </DropdownMenuItem>
+ ))}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ ) : (
+ <Drawer open={open} onOpenChange={setOpen}>
+ <DrawerTrigger aria-label="Toggle Menu">
+ <BreadcrumbEllipsis className="w-4 h-4" />
+ </DrawerTrigger>
+ <DrawerContent>
+ <DrawerHeader className="text-left">
+ <DrawerTitle>Navigate to</DrawerTitle>
+ <DrawerDescription>
+ Select a page to navigate to.
+ </DrawerDescription>
+ </DrawerHeader>
+ <div className="grid gap-1 px-4">
+ {items.slice(1, -2).map((item, index) => (
+ <Link
+ key={index}
+ to="/"
+ className="py-1 text-sm"
+ >
+ {item.label}
+ </Link>
+ ))}
+ </div>
+ <DrawerFooter className="pt-4">
+ <DrawerClose asChild>
+ <Button variant="outline">Close</Button>
+ </DrawerClose>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )}
+ </BreadcrumbItem>
+ </>
+ ) : null}
+ {items.slice(-ITEMS_TO_DISPLAY + 1).map((item, index) => (
+ <BreadcrumbItem key={index}>
+ {item.href ? (
+ <>
+ <BreadcrumbLink
+ asChild
+ className="md:max-w-none max-w-20 truncate"
+ >
+ <Link to={item.href}>{item.label}</Link>
+ </BreadcrumbLink>
+ <BreadcrumbSeparator />
+ </>
+ ) : (
+ <BreadcrumbPage className="md:max-w-none max-w-20 truncate">
+ {item.label}
+ </BreadcrumbPage>
+ )}
+ </BreadcrumbItem>
+ ))}
+ </BreadcrumbList>
+ </Breadcrumb>
+ )
+}
diff --git a/dashboard/src/components/sidebar/index.ts b/dashboard/src/components/sidebar/index.ts
@@ -0,0 +1,2 @@
+export * from './types'
+export * from './sidebar'
diff --git a/dashboard/src/components/sidebar/sidebar-body.tsx b/dashboard/src/components/sidebar/sidebar-body.tsx
@@ -0,0 +1,14 @@
+import { FC, PropsWithChildren } from "react"
+
+export interface SidebarBodyProps
+ extends PropsWithChildren {
+
+}
+
+export const SidebarBody: FC<SidebarBodyProps> = ({ children }) => {
+ return (
+ <ul className="flex flex-col justify-center items-start w-full divide-y-[3px] divide-accent">
+ {children}
+ </ul>
+ )
+}
diff --git a/dashboard/src/components/sidebar/sidebar-footer.tsx b/dashboard/src/components/sidebar/sidebar-footer.tsx
@@ -0,0 +1,12 @@
+import { PropsWithChildren } from "react"
+
+export interface SidebarFooterProps
+ extends PropsWithChildren {
+}
+
+
+export const SidebarFooter = () => {
+ return (
+ <div>SidebarHeader = </div>
+ )
+}
diff --git a/dashboard/src/components/sidebar/sidebar-group.tsx b/dashboard/src/components/sidebar/sidebar-group.tsx
@@ -0,0 +1,20 @@
+
+import { FC, PropsWithChildren } from "react"
+import { Label } from "@marzneshin/components"
+import { useSidebarContext } from "./sidebar-provider";
+
+export interface SidebarGroupProps
+ extends PropsWithChildren {
+
+}
+
+export const SidebarGroup: FC<SidebarGroupProps> = ({ children }) => {
+ const { collapsed } = useSidebarContext();
+
+ if (!collapsed)
+ return (
+ <Label className="text-gray-500 font-header">
+ {children}
+ </Label>
+ )
+}
diff --git a/dashboard/src/components/sidebar/sidebar-header.tsx b/dashboard/src/components/sidebar/sidebar-header.tsx
@@ -0,0 +1,12 @@
+import { cn } from "@marzneshin/utils"
+import { FC, PropsWithChildren } from "react"
+
+export interface SidebarHeaderProps extends
+ PropsWithChildren,
+ React.HTMLAttributes<HTMLLinkElement> { }
+
+export const SidebarHeader: FC<SidebarHeaderProps> = ({ children, className }) => {
+ return (
+ <div className={cn(className)}>{children}</div>
+ )
+}
diff --git a/dashboard/src/components/sidebar/sidebar-item.tsx b/dashboard/src/components/sidebar/sidebar-item.tsx
@@ -0,0 +1,38 @@
+
+import { Link } from '@tanstack/react-router'
+import { FC } from 'react'
+import { SidebarItem as SidebarItemType } from './types'
+import { cn } from '@marzneshin/utils';
+import { VariantProps, cva } from 'class-variance-authority';
+import { useSidebarContext } from './sidebar-provider';
+
+const sidebarItemVariants = cva("w-full rounded-lg border p-2", {
+ variants: {
+ variant: {
+ default: "bg-primary-foreground text-primary hover:bg-accent",
+ active: "bg-primary text-primary-foreground"
+ },
+ size: {
+ default: "",
+ collapsed: ""
+ }
+ }
+});
+
+
+export interface SidebarItemProps extends
+ React.HTMLAttributes<HTMLLinkElement>,
+ VariantProps<typeof sidebarItemVariants> {
+ item: SidebarItemType;
+}
+
+export const SidebarItem: FC<SidebarItemProps> = ({ item, className, variant }) => {
+ const { collapsed } = useSidebarContext();
+ return (
+ <li key={item.title} className={cn(sidebarItemVariants({ variant, className }))}>
+ <Link to={item.to} className={cn("flex flex-row items-center justify-center", { "-justify-center gap-2": !collapsed })}>
+ {item.icon}{!collapsed && item.title}
+ </Link>
+ </li>
+ )
+}
diff --git a/dashboard/src/components/sidebar/sidebar-provider.tsx b/dashboard/src/components/sidebar/sidebar-provider.tsx
@@ -0,0 +1,17 @@
+import { createContext, useContext } from "react";
+import { SidebarItemGroup } from "./types";
+
+interface SidebarContextProps {
+ sidebar: SidebarItemGroup;
+ collapsed: boolean;
+ setCollapsed: (state: boolean) => void;
+}
+
+export const SidebarContext = createContext<SidebarContextProps | null>(null);
+
+export const useSidebarContext = () => {
+ const ctx = useContext(SidebarContext);
+ if (!ctx)
+ throw new Error('Sidebar.* component must be rendered as child of Sidebar ');
+ return ctx;
+}
diff --git a/dashboard/src/components/sidebar/sidebar-title.tsx b/dashboard/src/components/sidebar/sidebar-title.tsx
diff --git a/dashboard/src/components/sidebar/sidebar.tsx b/dashboard/src/components/sidebar/sidebar.tsx
@@ -0,0 +1,54 @@
+import { FC, PropsWithChildren } from "react";
+import { SidebarContext } from "./sidebar-provider"
+import { SidebarItemGroup } from "./types"
+import { VariantProps, cva } from "class-variance-authority"
+import { cn } from "@marzneshin/utils";
+import { SidebarHeader } from "./sidebar-header";
+import { SidebarFooter } from "./sidebar-footer";
+import { SidebarBody } from "./sidebar-body";
+import { SidebarItem } from "./sidebar-item";
+import { SidebarGroup } from "./sidebar-group";
+
+const sidebarVariants = cva("flex flex-col justify-between h-full w-full", {
+ variants: {
+ variant: {
+ default: ""
+ }
+ }
+});
+
+interface ContentComposition {
+ Header: typeof SidebarHeader;
+ Footer: typeof SidebarFooter;
+ Body: typeof SidebarBody;
+ Group: typeof SidebarGroup;
+ Item: typeof SidebarItem;
+}
+
+export interface SidebarProps
+ extends React.HTMLAttributes<HTMLDivElement>,
+ VariantProps<typeof sidebarVariants> {
+ sidebar: SidebarItemGroup;
+ collapsed: boolean;
+ setCollapsed: (state: boolean) => void;
+}
+
+const Sidebar: FC<SidebarProps & PropsWithChildren> & ContentComposition =
+ ({ collapsed, setCollapsed, sidebar, variant, className, children, ...props }) => {
+ return (
+ <SidebarContext.Provider value={{ sidebar, collapsed, setCollapsed }} >
+ <div className={cn(sidebarVariants({ variant, className }))} {...props}>
+ {children}
+ </div>
+ </SidebarContext.Provider>
+ )
+ }
+
+Sidebar.Header = SidebarHeader;
+Sidebar.Footer = SidebarFooter;
+Sidebar.Body = SidebarBody;
+Sidebar.Item = SidebarItem;
+Sidebar.Group = SidebarGroup;
+Sidebar.displayName = "Sidebar"
+
+export { Sidebar, sidebarVariants }
diff --git a/dashboard/src/components/sidebar/types.ts b/dashboard/src/components/sidebar/types.ts
@@ -0,0 +1,17 @@
+import { AppRouterPaths } from "@marzneshin/types";
+import { ReactNode } from "react";
+
+
+export interface SidebarItem {
+ title: string;
+ to: AppRouterPaths;
+ icon: ReactNode;
+ isParent: boolean;
+ subItem?: SidebarItem[];
+}
+
+export interface SidebarObject {
+ [key: string]: SidebarItem[];
+}
+
+export type SidebarItemGroup = Record<string, SidebarItem[]>;
diff --git a/dashboard/src/components/ui/radio-group.tsx b/dashboard/src/components/ui/radio-group.tsx
@@ -0,0 +1,42 @@
+import * as React from "react"
+import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
+import { Circle } from "lucide-react"
+
+import { cn } from "@marzneshin/utils"
+
+const RadioGroup = React.forwardRef<
+ React.ElementRef<typeof RadioGroupPrimitive.Root>,
+ React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
+>(({ className, ...props }, ref) => {
+ return (
+ <RadioGroupPrimitive.Root
+ className={cn("grid gap-2", className)}
+ {...props}
+ ref={ref}
+ />
+ )
+})
+RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
+
+const RadioGroupItem = React.forwardRef<
+ React.ElementRef<typeof RadioGroupPrimitive.Item>,
+ React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
+>(({ className, ...props }, ref) => {
+ return (
+ <RadioGroupPrimitive.Item
+ ref={ref}
+ className={cn(
+ "aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
+ className
+ )}
+ {...props}
+ >
+ <RadioGroupPrimitive.Indicator className="flex items-center justify-center">
+ <Circle className="h-2.5 w-2.5 fill-current text-current" />
+ </RadioGroupPrimitive.Indicator>
+ </RadioGroupPrimitive.Item>
+ )
+})
+RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
+
+export { RadioGroup, RadioGroupItem }
diff --git a/dashboard/src/features/sidebar/index.ts b/dashboard/src/features/sidebar/index.ts
@@ -0,0 +1,4 @@
+export * from './sidebar';
+export * from './items';
+export * from './use-panel-toggle';
+export * from './toggle';
diff --git a/dashboard/src/features/sidebar/items.tsx b/dashboard/src/features/sidebar/items.tsx
@@ -0,0 +1,39 @@
+import { SidebarObject } from '@marzneshin/components';
+import { Box, Home, Server, ServerCog, UsersIcon } from 'lucide-react';
+
+export const sidebarItems: SidebarObject = {
+ Dashboard: [
+ {
+ title: 'Home',
+ to: '/',
+ icon: <Home />,
+ isParent: false,
+ },
+ ],
+ Management: [
+ {
+ title: 'Users',
+ to: '/users',
+ icon: <UsersIcon />,
+ isParent: false,
+ },
+ {
+ title: 'Services',
+ to: '/services',
+ icon: <Server />,
+ isParent: false,
+ },
+ {
+ title: 'Nodes',
+ to: '/nodes',
+ icon: <Box />,
+ isParent: false,
+ },
+ {
+ title: 'Hosts',
+ to: '/hosts',
+ icon: <ServerCog />,
+ isParent: false,
+ },
+ ]
+};
diff --git a/dashboard/src/features/sidebar/sidebar.tsx b/dashboard/src/features/sidebar/sidebar.tsx
@@ -0,0 +1,51 @@
+import { Sidebar, SidebarItem } from "@marzneshin/components";
+import { useRouterState } from "@tanstack/react-router";
+import { FC, useEffect } from "react";
+import { sidebarItems as sidebar } from ".";
+
+
+interface DashboardSidebarProps {
+ collapsed: boolean;
+ setCollapsed: (state: boolean) => void;
+}
+
+export const DashboardSidebar: FC<DashboardSidebarProps> = ({ collapsed, setCollapsed }) => {
+ const router = useRouterState();
+ const currentActivePath = router.location.pathname;
+
+ useEffect(() => { }, [collapsed]);
+
+ return (
+ <aside>
+ <nav className="w-full">
+ <Sidebar
+ sidebar={sidebar}
+ collapsed={collapsed}
+ setCollapsed={setCollapsed}
+ >
+ <div className="flex flex-col p-4 w-full h-full">
+ <Sidebar.Body>
+
+ {Object.keys(sidebar).map((key) =>
+ (
+ <div className="my-2 w-full" key={key}>
+ <Sidebar.Group>{key}</Sidebar.Group>
+ {sidebar[key].map((item: SidebarItem, i) => (
+ <Sidebar.Item
+ variant={currentActivePath === item.to ? "active" : "default"}
+ className="my-2"
+ item={item}
+ key={i}
+ />
+ )
+ )}
+ </div>
+ )
+ )}
+ </Sidebar.Body>
+ </div>
+ </Sidebar>
+ </nav>
+ </aside>
+ )
+}
diff --git a/dashboard/src/features/sidebar/toggle.tsx b/dashboard/src/features/sidebar/toggle.tsx
@@ -0,0 +1,23 @@
+
+import { Button } from '@marzneshin/components';
+import { PanelBottomClose, PanelBottomOpen, PanelRightClose, PanelRightOpen } from 'lucide-react';
+import { FC } from 'react';
+
+interface ToggleButtonProps {
+ isDesktop: boolean;
+ collapsed: boolean;
+ open: boolean;
+ onToggle: () => void;
+}
+
+export const ToggleButton: FC<ToggleButtonProps> = ({ isDesktop, collapsed, open, onToggle }) => {
+ const Icon = isDesktop ?
+ (collapsed ? PanelRightClose : PanelRightOpen) :
+ (open ? PanelBottomClose : PanelBottomOpen);
+
+ return (
+ <Button className="p-2 bg-muted-foreground text-primary-foreground" onClick={onToggle}>
+ <Icon className="w-full h-full" />
+ </Button>
+ );
+};
diff --git a/dashboard/src/features/sidebar/use-panel-toggle.ts b/dashboard/src/features/sidebar/use-panel-toggle.ts
@@ -0,0 +1,26 @@
+import { useRef, useState } from "react";
+import { ImperativePanelHandle } from "react-resizable-panels";
+
+export const usePanelToggle = (isDesktop: boolean) => {
+ const [collapsed, setCollapsed] = useState(false);
+ const panelRef = useRef<ImperativePanelHandle>(null);
+ const [open, setOpen] = useState(false);
+
+ const toggleCollapse = () => {
+ const panel = panelRef.current;
+ if (isDesktop && panel) {
+ collapsed ? panel.expand() : panel.collapse();
+ setCollapsed(!collapsed);
+ setOpen(false);
+ }
+ };
+
+ const toggleOpen = () => {
+ if (!isDesktop) {
+ setCollapsed(false);
+ setOpen(!open);
+ }
+ };
+
+ return { collapsed, open, panelRef, setCollapsed, setOpen, toggleCollapse, toggleOpen };
+};
diff --git a/dashboard/src/features/theme-switch/theme-toggle/index.tsx b/dashboard/src/features/theme-switch/theme-toggle/index.tsx
@@ -15,9 +15,9 @@ export function ThemeToggle() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
- <Button variant="outline" size="icon">
- <Sun className="transition-all scale-100 rotate-0 dark:scale-0 dark:-rotate-90 h-[1.2rem] w-[1.2rem]" />
- <Moon className="absolute transition-all scale-0 rotate-90 dark:scale-100 dark:rotate-0 h-[1.2rem] w-[1.2rem]" />
+ <Button variant="outline" className="bg-primary-foreground text-primary" size="icon">
+ <Sun className="transition-all scale-100 rotate-0 dark:scale-0 dark:-rotate-90 h-[1rem] w-[1rem]" />
+ <Moon className="absolute transition-all scale-0 rotate-90 dark:scale-100 dark:rotate-0 h-[1rem] w-[1rem]" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
diff --git a/dashboard/src/hooks/index.ts b/dashboard/src/hooks/index.ts
diff --git a/dashboard/src/hooks/use-screen-breakpoint.ts b/dashboard/src/hooks/use-screen-breakpoint.ts
@@ -0,0 +1,14 @@
+//@ts-expect-error
+import create from "@kodingdotninja/use-tailwind-breakpoint";
+import resolveConfig from "tailwindcss/resolveConfig";
+
+//@ts-expect-error
+import tailwindConfig from "@marzneshin/../tailwind.config.js";
+
+const config = resolveConfig(tailwindConfig);
+
+export const {
+ useBreakpoint,
+ useBreakpointEffect,
+ useBreakpointValue
+} = create(config.theme.screens);
diff --git a/dashboard/src/routeTree.gen.ts b/dashboard/src/routeTree.gen.ts
@@ -1,6 +1,6 @@
/* prettier-ignore-start */
-/* eslint-disable */
+
// @ts-nocheck
@@ -14,6 +14,10 @@ import { Route as rootRoute } from './routes/__root'
import { Route as DashboardImport } from './routes/_dashboard'
import { Route as AuthImport } from './routes/_auth'
import { Route as DashboardIndexImport } from './routes/_dashboard/index'
+import { Route as DashboardUsersImport } from './routes/_dashboard/users'
+import { Route as DashboardServicesImport } from './routes/_dashboard/services'
+import { Route as DashboardNodesImport } from './routes/_dashboard/nodes'
+import { Route as DashboardHostsImport } from './routes/_dashboard/hosts'
import { Route as AuthLoginImport } from './routes/_auth/login'
// Create/Update Routes
@@ -33,6 +37,26 @@ const DashboardIndexRoute = DashboardIndexImport.update({
getParentRoute: () => DashboardRoute,
} as any)
+const DashboardUsersRoute = DashboardUsersImport.update({
+ path: '/users',
+ getParentRoute: () => DashboardRoute,
+} as any)
+
+const DashboardServicesRoute = DashboardServicesImport.update({
+ path: '/services',
+ getParentRoute: () => DashboardRoute,
+} as any)
+
+const DashboardNodesRoute = DashboardNodesImport.update({
+ path: '/nodes',
+ getParentRoute: () => DashboardRoute,
+} as any)
+
+const DashboardHostsRoute = DashboardHostsImport.update({
+ path: '/hosts',
+ getParentRoute: () => DashboardRoute,
+} as any)
+
const AuthLoginRoute = AuthLoginImport.update({
path: '/login',
getParentRoute: () => AuthRoute,
@@ -54,6 +78,22 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthLoginImport
parentRoute: typeof AuthImport
}
+ '/_dashboard/hosts': {
+ preLoaderRoute: typeof DashboardHostsImport
+ parentRoute: typeof DashboardImport
+ }
+ '/_dashboard/nodes': {
+ preLoaderRoute: typeof DashboardNodesImport
+ parentRoute: typeof DashboardImport
+ }
+ '/_dashboard/services': {
+ preLoaderRoute: typeof DashboardServicesImport
+ parentRoute: typeof DashboardImport
+ }
+ '/_dashboard/users': {
+ preLoaderRoute: typeof DashboardUsersImport
+ parentRoute: typeof DashboardImport
+ }
'/_dashboard/': {
preLoaderRoute: typeof DashboardIndexImport
parentRoute: typeof DashboardImport
@@ -65,7 +105,13 @@ declare module '@tanstack/react-router' {
export const routeTree = rootRoute.addChildren([
AuthRoute.addChildren([AuthLoginRoute]),
- DashboardRoute.addChildren([DashboardIndexRoute]),
+ DashboardRoute.addChildren([
+ DashboardHostsRoute,
+ DashboardNodesRoute,
+ DashboardServicesRoute,
+ DashboardUsersRoute,
+ DashboardIndexRoute,
+ ]),
])
/* prettier-ignore-end */
diff --git a/dashboard/src/routes/_auth.tsx b/dashboard/src/routes/_auth.tsx
@@ -6,7 +6,7 @@ const AuthLayout = () => {
<div className="w-full h-full">
<Outlet />
</div>
- <div className='hidden w-full h-full md:block bg-primary'></div>
+ <div className='hidden w-full h-full md:block bg-primary dark:bg-accent'></div>
</div>
)
}
diff --git a/dashboard/src/routes/_dashboard.tsx b/dashboard/src/routes/_dashboard.tsx
@@ -1,8 +1,78 @@
+import {
+ Drawer,
+ DrawerContent,
+ Header,
+ ResizableHandle,
+ ResizablePanel,
+ ResizablePanelGroup
+} from '@marzneshin/components'
+import { NavigationDirectory } from '@marzneshin/components/nav-dir';
import { useAuth } from '@marzneshin/features/auth'
-import { createFileRoute, redirect } from '@tanstack/react-router'
+import { DashboardSidebar, ToggleButton, sidebarItems } from '@marzneshin/features/sidebar';
+import { usePanelToggle } from '@marzneshin/features/sidebar/use-panel-toggle';
+import { useBreakpoint } from '@marzneshin/hooks/use-screen-breakpoint';
+import { cn } from '@marzneshin/utils';
+import { Outlet, createFileRoute, redirect } from '@tanstack/react-router'
+
+export const DashboardLayout = () => {
+ const isDesktop = useBreakpoint("sm");
+ const { collapsed, panelRef, open, setCollapsed, setOpen, toggleCollapse, toggleOpen } = usePanelToggle(isDesktop);
+
+ return (
+ <div className="flex flex-col w-screen h-screen">
+ <header className="h-[3.5rem]">
+ <Header>
+ <ToggleButton
+ isDesktop={isDesktop}
+ collapsed={collapsed}
+ open={open}
+ onToggle={isDesktop ? toggleCollapse : toggleOpen}
+ />
+ <NavigationDirectory sidebar={sidebarItems} />
+ </Header>
+ </header>
+ {isDesktop ?
+ (
+ <ResizablePanelGroup direction="horizontal" className="block sm:hidden">
+ <ResizablePanel
+ collapsible
+ collapsedSize={5}
+ onCollapse={() => setCollapsed(true)}
+ onExpand={() => setCollapsed(false)}
+ minSize={15}
+ className={cn("w-[120px] min-w-[100px]")}
+ defaultSize={20}
+ ref={panelRef}
+ maxSize={30}>
+ <DashboardSidebar collapsed={collapsed} setCollapsed={setCollapsed} />
+ </ResizablePanel>
+ <ResizableHandle withHandle className="w-[2px]" />
+ <ResizablePanel>
+ <main>
+ <Outlet />
+ </main>
+ </ResizablePanel>
+ </ResizablePanelGroup>
+ ) : (
+ <div>
+ <aside>
+ <Drawer open={open} onOpenChange={setOpen}>
+ <DrawerContent>
+ <DashboardSidebar collapsed={collapsed} setCollapsed={setCollapsed} />
+ </DrawerContent>
+ </Drawer>
+ </aside>
+ <main className="sm:block">
+ <Outlet />
+ </main>
+ </div>
+ )}
+ </div>
+ )
+}
export const Route = createFileRoute('/_dashboard')({
- component: () => <div>Hello /_dashboard!</div>,
+ component: () => <DashboardLayout />,
beforeLoad: async () => {
if (!useAuth.getState().isLoggedIn) {
diff --git a/dashboard/src/routes/_dashboard/hosts.tsx b/dashboard/src/routes/_dashboard/hosts.tsx
@@ -0,0 +1,5 @@
+import { createFileRoute } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/_dashboard/hosts')({
+ component: () => <div>Hello /_dashboard/hosts!</div>
+})
\ No newline at end of file
diff --git a/dashboard/src/routes/_dashboard/nodes.tsx b/dashboard/src/routes/_dashboard/nodes.tsx
@@ -0,0 +1,5 @@
+import { createFileRoute } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/_dashboard/nodes')({
+ component: () => <div>Hello /_dashboard/nodes!</div>
+})
\ No newline at end of file
diff --git a/dashboard/src/routes/_dashboard/services.tsx b/dashboard/src/routes/_dashboard/services.tsx
@@ -0,0 +1,5 @@
+import { createFileRoute } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/_dashboard/services')({
+ component: () => <div>Hello /_dashboard/services!</div>
+})
diff --git a/dashboard/src/routes/_dashboard/users.tsx b/dashboard/src/routes/_dashboard/users.tsx
@@ -0,0 +1,5 @@
+import { createFileRoute } from '@tanstack/react-router'
+
+export const Route = createFileRoute('/_dashboard/users')({
+ component: () => <div>Hello /_dashboard/users!</div>
+})
\ No newline at end of file
diff --git a/dashboard/src/types/index.ts b/dashboard/src/types/index.ts
@@ -0,0 +1,4 @@
+import { routeTree } from "@marzneshin/routeTree.gen";
+import { RoutePaths } from "@tanstack/react-router";
+
+export type AppRouterPaths = RoutePaths<typeof routeTree>;