feat 使用 shadcn-admin
@@ -1,9 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AHA 模型启动器</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Manrope:wght@200..800&display=swap"
|
||||
rel="stylesheet"
|
||||
>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -5,34 +5,85 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
"tauri": "tauri",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write .",
|
||||
"test": "vitest run --browser.headless",
|
||||
"test:watch": "vitest --browser.headless"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-direction": "^1.1.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@tanstack/react-query": "^5.99.0",
|
||||
"@tanstack/react-router": "^1.168.22",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"axios": "^1.15.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.11.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"shadcn": "^4.4.0",
|
||||
"cmdk": "1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.5",
|
||||
"react-day-picker": "9.14.0",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"react-top-loading-bar": "^3.0.2",
|
||||
"recharts": "^3.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.12",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@faker-js/faker": "^10.4.0",
|
||||
"@tanstack/eslint-plugin-query": "^5.99.0",
|
||||
"@tanstack/react-query-devtools": "^5.99.0",
|
||||
"@tanstack/react-router-devtools": "^1.166.13",
|
||||
"@tanstack/router-plugin": "^1.167.22",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4"
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"knip": "^6.4.1",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"ultracite": "7.6.1",
|
||||
"vite": "^8.0.8",
|
||||
"vitest": "^4.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 494 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="24" height="24"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokewidth="2" strokelinecap="round" strokelinejoin="round">
|
||||
<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3"></path>
|
||||
</svg><style>@media (prefers-color-scheme: light) { :root { filter: contrast(1) brightness(0.8); } }
|
||||
</style></svg>
|
||||
|
After Width: | Height: | Size: 520 B |
|
After Width: | Height: | Size: 247 B |
@@ -0,0 +1 @@
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="948,463.5,24,24"><defs><clipPath id="clip-1"><rect x="948" y="463.5" width="24" height="24" id="clip-1" stroke="none"/></clipPath></defs><g fill="none" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><g><g id="Group 1"><g clip-path="url(#clip-1)" id="Group 1"><path d="M963,469.5v12c0,1.65685 1.34315,3 3,3c1.65685,0 3,-1.34315 3,-3c0,-1.65685 -1.34315,-3 -3,-3h-12c-1.65685,0 -3,1.34315 -3,3c0,1.65685 1.34315,3 3,3c1.65685,0 3,-1.34315 3,-3v-12c0,-1.65685 -1.34315,-3 -3,-3c-1.65685,0 -3,1.34315 -3,3c0,1.65685 1.34315,3 3,3h12c1.65685,0 3,-1.34315 3,-3c0,-1.65685 -1.34315,-3 -3,-3c-1.65685,0 -3,1.34315 -3,3" id="Path 1" stroke="#f2f2f2"/></g></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1013 B |
|
After Width: | Height: | Size: 273 KiB |
@@ -0,0 +1,28 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconDiscord({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
className={cn('[&>path]:stroke-current', className)}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<title>Discord</title>
|
||||
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||
<path d='M8 12a1 1 0 1 0 2 0a1 1 0 0 0 -2 0' />
|
||||
<path d='M14 12a1 1 0 1 0 2 0a1 1 0 0 0 -2 0' />
|
||||
<path d='M15.5 17c0 1 1.5 3 2 3c1.5 0 2.833 -1.667 3.5 -3c.667 -1.667 .5 -5.833 -1.5 -11.5c-1.457 -1.015 -3 -1.34 -4.5 -1.5l-.972 1.923a11.913 11.913 0 0 0 -4.053 0l-.975 -1.923c-1.5 .16 -3.043 .485 -4.5 1.5c-2 5.667 -2.167 9.833 -1.5 11.5c.667 1.333 2 3 3.5 3c.5 0 2 -2 2 -3' />
|
||||
<path d='M7 16.5c3.5 1 6.5 1 10 0' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconDocker({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
className={cn('[&>path]:stroke-current', className)}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<title>Docker</title>
|
||||
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||
<path d='M22 12.54c-1.804 -.345 -2.701 -1.08 -3.523 -2.94c-.487 .696 -1.102 1.568 -.92 2.4c.028 .238 -.32 1 -.557 1h-14c0 5.208 3.164 7 6.196 7c4.124 .022 7.828 -1.376 9.854 -5c1.146 -.101 2.296 -1.505 2.95 -2.46z' />
|
||||
<path d='M5 10h3v3h-3z' />
|
||||
<path d='M8 10h3v3h-3z' />
|
||||
<path d='M11 10h3v3h-3z' />
|
||||
<path d='M8 7h3v3h-3z' />
|
||||
<path d='M11 7h3v3h-3z' />
|
||||
<path d='M11 4h3v3h-3z' />
|
||||
<path d='M4.571 18c1.5 0 2.047 -.074 2.958 -.78' />
|
||||
<path d='M10 16l0 .01' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconFacebook({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
className={cn('[&>path]:stroke-current', className)}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<title>Facebook</title>
|
||||
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||
<path d='M7 10v4h3v7h4v-7h3l1 -4h-4v-2a1 1 0 0 1 1 -1h3v-4h-3a5 5 0 0 0 -5 5v2h-3' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconFigma({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
className={cn('[&>path]:stroke-current', className)}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<title>Figma</title>
|
||||
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||
<path d='M15 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0' />
|
||||
<path d='M6 3m0 3a3 3 0 0 1 3 -3h6a3 3 0 0 1 3 3v0a3 3 0 0 1 -3 3h-6a3 3 0 0 1 -3 -3z' />
|
||||
<path d='M9 9a3 3 0 0 0 0 6h3m-3 0a3 3 0 1 0 3 3v-15' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconGithub({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
className={cn('[&>path]:stroke-current', className)}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<title>GitHub</title>
|
||||
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||
<path d='M9 19c-4.3 1.4 -4.3 -2.5 -6 -3m12 5v-3.5c0 -1 .1 -1.4 -.5 -2c2.8 -.3 5.5 -1.4 5.5 -6a4.6 4.6 0 0 0 -1.3 -3.2a4.2 4.2 0 0 0 -.1 -3.2s-1.1 -.3 -3.5 1.3a12.3 12.3 0 0 0 -6.2 0c-2.4 -1.6 -3.5 -1.3 -3.5 -1.3a4.2 4.2 0 0 0 -.1 3.2a4.6 4.6 0 0 0 -1.3 3.2c0 4.6 2.7 5.7 5.5 6c-.6 .6 -.6 1.2 -.5 2v3.5' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconGitlab({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
className={cn('[&>path]:stroke-current', className)}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<title>GitLab</title>
|
||||
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||
<path d='M21 14l-9 7l-9 -7l3 -11l3 7h6l3 -7z' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconGmail({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
className={cn('[&>path]:stroke-current', className)}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<title>Gmail</title>
|
||||
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||
<path d='M16 20h3a1 1 0 0 0 1 -1v-14a1 1 0 0 0 -1 -1h-3v16z' />
|
||||
<path d='M5 20h3v-16h-3a1 1 0 0 0 -1 1v14a1 1 0 0 0 1 1z' />
|
||||
<path d='M16 4l-4 4l-4 -4' />
|
||||
<path d='M4 6.5l8 7.5l8 -7.5' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconMedium({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
className={cn('[&>path]:stroke-current', className)}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<title>Medium</title>
|
||||
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||
<path d='M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z' />
|
||||
<path d='M8 9h1l3 3l3 -3h1' />
|
||||
<path d='M8 15l2 0' />
|
||||
<path d='M14 15l2 0' />
|
||||
<path d='M9 9l0 6' />
|
||||
<path d='M15 9l0 6' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconNotion({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
className={cn('[&>path]:stroke-current', className)}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<title>Notion</title>
|
||||
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||
<path d='M11 17.5v-6.5h.5l4 6h.5v-6.5' />
|
||||
<path d='M19.077 20.071l-11.53 .887a1 1 0 0 1 -.876 -.397l-2.471 -3.294a1 1 0 0 1 -.2 -.6v-10.741a1 1 0 0 1 .923 -.997l11.389 -.876a2 2 0 0 1 1.262 .33l1.535 1.023a2 2 0 0 1 .891 1.664v12.004a1 1 0 0 1 -.923 .997z' />
|
||||
<path d='M4.5 5.5l2.5 2.5' />
|
||||
<path d='M20 7l-13 1v12.5' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconSkype({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
className={cn('[&>path]:stroke-current', className)}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<title>Skype</title>
|
||||
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||
<path d='M12 3a9 9 0 0 1 8.603 11.65a4.5 4.5 0 0 1 -5.953 5.953a9 9 0 0 1 -11.253 -11.253a4.5 4.5 0 0 1 5.953 -5.954a8.987 8.987 0 0 1 2.65 -.396z' />
|
||||
<path d='M8 14.5c.5 2 2.358 2.5 4 2.5c2.905 0 4 -1.187 4 -2.5c0 -1.503 -1.927 -2.5 -4 -2.5s-4 -1 -4 -2.5c0 -1.313 1.095 -2.5 4 -2.5c1.642 0 3.5 .5 4 2.5' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconSlack({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
className={cn('[&>path]:stroke-current', className)}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<title>Slack</title>
|
||||
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||
<path d='M12 12v-6a2 2 0 0 1 4 0v6m0 -2a2 2 0 1 1 2 2h-6' />
|
||||
<path d='M12 12h6a2 2 0 0 1 0 4h-6m2 0a2 2 0 1 1 -2 2v-6' />
|
||||
<path d='M12 12v6a2 2 0 0 1 -4 0v-6m0 2a2 2 0 1 1 -2 -2h6' />
|
||||
<path d='M12 12h-6a2 2 0 0 1 0 -4h6m-2 0a2 2 0 1 1 2 -2v6' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconStripe({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
className={cn('[&>path]:stroke-current', className)}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<title>Stripe</title>
|
||||
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||
<path d='M11.453 8.056c0 -.623 .518 -.979 1.442 -.979c1.69 0 3.41 .343 4.605 .923l.5 -4c-.948 -.449 -2.82 -1 -5.5 -1c-1.895 0 -3.373 .087 -4.5 1c-1.172 .956 -2 2.33 -2 4c0 3.03 1.958 4.906 5 6c1.961 .69 3 .743 3 1.5c0 .735 -.851 1.5 -2 1.5c-1.423 0 -3.963 -.609 -5.5 -1.5l-.5 4c1.321 .734 3.474 1.5 6 1.5c2 0 3.957 -.468 5.084 -1.36c1.263 -.979 1.916 -2.268 1.916 -4.14c0 -3.096 -1.915 -4.547 -5 -5.637c-1.646 -.605 -2.544 -1.07 -2.544 -1.807z' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconTelegram({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
className={cn('[&>path]:stroke-current', className)}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<title>Telegram</title>
|
||||
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||
<path d='M15 10l-4 4l6 6l4 -16l-18 7l4 2l2 6l3 -4' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconTrello({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
className={cn('[&>path]:stroke-current', className)}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<title>Trello</title>
|
||||
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||
<path d='M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z' />
|
||||
<path d='M7 7h3v10h-3z' />
|
||||
<path d='M14 7h3v6h-3z' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconWhatsapp({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
className={cn('[&>path]:stroke-current', className)}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<title>WhatsApp</title>
|
||||
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||
<path d='M3 21l1.65 -3.8a9 9 0 1 1 3.4 2.9l-5.05 .9' />
|
||||
<path d='M9 10a.5 .5 0 0 0 1 0v-1a.5 .5 0 0 0 -1 0v1a5 5 0 0 0 5 5h1a.5 .5 0 0 0 0 -1h-1a.5 .5 0 0 0 0 1' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconZoom({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
width='24'
|
||||
height='24'
|
||||
className={cn('[&>path]:stroke-current', className)}
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
{...props}
|
||||
>
|
||||
<title>Zoom</title>
|
||||
<path strokeWidth='0' d='M0 0h24v24H0z' fill='none' />
|
||||
<path d='M17.011 9.385v5.128l3.989 3.487v-12z' />
|
||||
<path d='M3.887 6h10.08c1.468 0 3.033 1.203 3.033 2.803v8.196a.991 .991 0 0 1 -.975 1h-10.373c-1.667 0 -2.652 -1.5 -2.652 -3l.01 -8a.882 .882 0 0 1 .208 -.71a.841 .841 0 0 1 .67 -.287z' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export { IconDiscord } from './icon-discord'
|
||||
export { IconDocker } from './icon-docker'
|
||||
export { IconFacebook } from './icon-facebook'
|
||||
export { IconFigma } from './icon-figma'
|
||||
export { IconGithub } from './icon-github'
|
||||
export { IconGitlab } from './icon-gitlab'
|
||||
export { IconGmail } from './icon-gmail'
|
||||
export { IconMedium } from './icon-medium'
|
||||
export { IconNotion } from './icon-notion'
|
||||
export { IconSkype } from './icon-skype'
|
||||
export { IconSlack } from './icon-slack'
|
||||
export { IconStripe } from './icon-stripe'
|
||||
export { IconTelegram } from './icon-telegram'
|
||||
export { IconTrello } from './icon-trello'
|
||||
export { IconWhatsapp } from './icon-whatsapp'
|
||||
export { IconZoom } from './icon-zoom'
|
||||
@@ -0,0 +1,41 @@
|
||||
import { type SVGProps } from 'react'
|
||||
|
||||
export function ClerkFullLogo(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width={77}
|
||||
height={24}
|
||||
viewBox='0 0 77 24'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d='M35.148 16.738a4.198 4.198 0 01-3.06 1.283 3.53 3.53 0 01-2.604-1.034c-.619-.645-.975-1.566-.975-2.665 0-2.199 1.432-3.703 3.58-3.703a3.914 3.914 0 013.034 1.377l1.859-1.644c-1.211-1.47-3.176-2.229-5.042-2.229-3.652 0-6.24 2.517-6.24 6.22 0 1.831.643 3.374 1.728 4.463s2.631 1.728 4.415 1.728c2.317 0 4.166-.94 5.203-2.122l-1.898-1.674zM38.727 3.428h2.766V20.34h-2.766V3.428zM54.818 15.283c.046-.368.07-.74.076-1.11 0-3.507-2.296-6.047-5.847-6.047a5.738 5.738 0 00-4.215 1.725c-1.038 1.089-1.66 2.631-1.66 4.47 0 3.749 2.642 6.216 6.146 6.216 2.35 0 4.043-.951 5.058-2.242l-1.812-1.605-.09-.076a3.749 3.749 0 01-3.008 1.406c-1.778 0-3.061-1.037-3.427-2.737h8.779zm-8.733-2.22a3.365 3.365 0 01.737-1.449 3.082 3.082 0 012.368-.996c1.58 0 2.57.988 2.911 2.445h-6.016zM63.445 8.09v3.084a13.36 13.36 0 00-.838-.05c-2.094 0-3.282 1.505-3.282 3.479v5.736h-2.763V8.261h2.763v1.83h.025c.938-1.283 2.284-1.997 3.75-1.997l.345-.004zM69.887 15.281l-1.998 2.222v2.837h-2.764V3.428h2.764v10.374L72.822 8.3h3.283l-4.341 4.86 4.417 7.18h-3.11l-3.133-5.059h-.051z'
|
||||
fill='#1F0256'
|
||||
/>
|
||||
<path
|
||||
d='M19.116 3.16l-2.88 2.881a.571.571 0 01-.701.084 6.854 6.854 0 00-10.39 5.647 6.867 6.867 0 00.979 3.764.571.571 0 01-.084.699l-2.88 2.88a.57.57 0 01-.865-.063A11.994 11.994 0 0119.051 2.295a.57.57 0 01.065.866z'
|
||||
fill='url(#paint0_linear_26568_214324)'
|
||||
/>
|
||||
<path
|
||||
d='M19.113 20.829l-2.88-2.88a.571.571 0 00-.7-.085 6.854 6.854 0 01-7.081 0 .571.571 0 00-.7.084l-2.881 2.88a.57.57 0 00.062.877 11.994 11.994 0 0014.114 0 .571.571 0 00.066-.876zM11.997 15.422a3.427 3.427 0 100-6.854 3.427 3.427 0 000 6.854z'
|
||||
fill='#1F0256'
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id='paint0_linear_26568_214324'
|
||||
x1={16.4087}
|
||||
y1={-1.75881}
|
||||
x2={-7.88473}
|
||||
y2={22.5365}
|
||||
gradientUnits='userSpaceOnUse'
|
||||
>
|
||||
<stop stopColor='#17CCFC' />
|
||||
<stop offset={0.5} stopColor='#5D31FF' />
|
||||
<stop offset={1} stopColor='#F35AFF' />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function ClerkLogo({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
role='img'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
id='clerk'
|
||||
height='24'
|
||||
width='24'
|
||||
className={cn('[&>path]:fill-foreground', className)}
|
||||
{...props}
|
||||
>
|
||||
<title>Clerk</title>
|
||||
<path
|
||||
d='m21.47 20.829 -2.881 -2.881a0.572 0.572 0 0 0 -0.7 -0.084 6.854 6.854 0 0 1 -7.081 0 0.576 0.576 0 0 0 -0.7 0.084l-2.881 2.881a0.576 0.576 0 0 0 -0.103 0.69 0.57 0.57 0 0 0 0.166 0.186 12 12 0 0 0 14.113 0 0.58 0.58 0 0 0 0.239 -0.423 0.576 0.576 0 0 0 -0.172 -0.453Zm0.002 -17.668 -2.88 2.88a0.569 0.569 0 0 1 -0.701 0.084A6.857 6.857 0 0 0 8.724 8.08a6.862 6.862 0 0 0 -1.222 3.692 6.86 6.86 0 0 0 0.978 3.764 0.573 0.573 0 0 1 -0.083 0.699l-2.881 2.88a0.567 0.567 0 0 1 -0.864 -0.063A11.993 11.993 0 0 1 6.771 2.7a11.99 11.99 0 0 1 14.637 -0.405 0.566 0.566 0 0 1 0.232 0.418 0.57 0.57 0 0 1 -0.168 0.448Zm-7.118 12.261a3.427 3.427 0 1 0 0 -6.854 3.427 3.427 0 0 0 0 6.854Z'
|
||||
strokeWidth='1'
|
||||
></path>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { type Direction } from '@/context/direction-provider'
|
||||
|
||||
type IconDirProps = SVGProps<SVGSVGElement> & {
|
||||
dir: Direction
|
||||
}
|
||||
|
||||
export function IconDir({ dir, className, ...props }: IconDirProps) {
|
||||
return (
|
||||
<svg
|
||||
data-name={`icon-dir-${dir}`}
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 79.86 51.14'
|
||||
className={cn(dir === 'rtl' && 'rotate-y-180', className)}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d='M23.42.51h51.92c2.21 0 4 1.79 4 4v42.18c0 2.21-1.79 4-4 4H23.42s-.04-.02-.04-.04V.55s.02-.04.04-.04z'
|
||||
opacity={0.15}
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.72}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='2px'
|
||||
d='M5.56 14.88L17.78 14.88'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.48}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='2px'
|
||||
d='M5.56 22.09L16.08 22.09'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.55}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='2px'
|
||||
d='M5.56 18.38L14.93 18.38'
|
||||
/>
|
||||
<g strokeLinecap='round' strokeMiterlimit={10}>
|
||||
<circle cx={7.51} cy={7.4} r={2.54} opacity={0.8} />
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.8}
|
||||
strokeWidth='2px'
|
||||
d='M12.06 6.14L17.78 6.14'
|
||||
/>
|
||||
<path fill='none' opacity={0.6} d='M11.85 8.79L16.91 8.79' />
|
||||
</g>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.62}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='3px'
|
||||
d='M29.41 7.4L34.67 7.4'
|
||||
/>
|
||||
<rect
|
||||
x={28.76}
|
||||
y={11.21}
|
||||
width={26.03}
|
||||
height={2.73}
|
||||
rx={0.64}
|
||||
ry={0.64}
|
||||
opacity={0.44}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<rect
|
||||
x={28.76}
|
||||
y={17.01}
|
||||
width={44.25}
|
||||
height={13.48}
|
||||
rx={0.64}
|
||||
ry={0.64}
|
||||
opacity={0.3}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<rect
|
||||
x={28.76}
|
||||
y={33.57}
|
||||
width={44.25}
|
||||
height={4.67}
|
||||
rx={0.64}
|
||||
ry={0.64}
|
||||
opacity={0.21}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<rect
|
||||
x={28.76}
|
||||
y={41.32}
|
||||
width={36.21}
|
||||
height={4.67}
|
||||
rx={0.64}
|
||||
ry={0.64}
|
||||
opacity={0.3}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { type SVGProps } from 'react'
|
||||
|
||||
export function IconLayoutCompact(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
data-name='icon-layout-compact'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 79.86 51.14'
|
||||
{...props}
|
||||
>
|
||||
<rect
|
||||
x={5.84}
|
||||
y={5.2}
|
||||
width={4}
|
||||
height={40}
|
||||
rx={2}
|
||||
ry={2}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<g stroke='#fff' strokeLinecap='round' strokeMiterlimit={10}>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.66}
|
||||
strokeWidth='2px'
|
||||
d='M7.26 11.56L8.37 11.56'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.51}
|
||||
strokeWidth='2px'
|
||||
d='M7.26 14.49L8.37 14.49'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.52}
|
||||
strokeWidth='2px'
|
||||
d='M7.26 17.39L8.37 17.39'
|
||||
/>
|
||||
<circle cx={7.81} cy={7.25} r={1.16} fill='#fff' opacity={0.8} />
|
||||
</g>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.75}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='3px'
|
||||
d='M15.81 14.49L22.89 14.49'
|
||||
/>
|
||||
<rect
|
||||
x={14.93}
|
||||
y={18.39}
|
||||
width={22.19}
|
||||
height={2.73}
|
||||
rx={0.64}
|
||||
ry={0.64}
|
||||
opacity={0.5}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<rect
|
||||
x={14.93}
|
||||
y={5.89}
|
||||
width={59.16}
|
||||
height={2.73}
|
||||
rx={0.64}
|
||||
ry={0.64}
|
||||
opacity={0.9}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<rect
|
||||
x={14.93}
|
||||
y={24.22}
|
||||
width={32.68}
|
||||
height={19.95}
|
||||
rx={2.11}
|
||||
ry={2.11}
|
||||
opacity={0.4}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<g strokeLinecap='round' strokeMiterlimit={10}>
|
||||
<rect
|
||||
x={59.05}
|
||||
y={38.15}
|
||||
width={2.01}
|
||||
height={3.42}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.32}
|
||||
/>
|
||||
<rect
|
||||
x={54.78}
|
||||
y={34.99}
|
||||
width={2.01}
|
||||
height={6.58}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.44}
|
||||
/>
|
||||
<rect
|
||||
x={63.17}
|
||||
y={32.86}
|
||||
width={2.01}
|
||||
height={8.7}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.53}
|
||||
/>
|
||||
<rect
|
||||
x={67.54}
|
||||
y={29.17}
|
||||
width={2.01}
|
||||
height={12.4}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.66}
|
||||
/>
|
||||
</g>
|
||||
<g opacity={0.5}>
|
||||
<circle cx={62.16} cy={18.63} r={7.5} />
|
||||
<path d='M62.16 11.63c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.14-7-7 3.14-7 7-7m0-1c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8-3.58-8-8-8z' />
|
||||
</g>
|
||||
<g opacity={0.74}>
|
||||
<path d='M63.04 18.13l3.38-5.67c.93.64 1.7 1.48 2.26 2.47.56.98.89 2.08.96 3.21h-6.6z' />
|
||||
<path d='M66.57 13.19a6.977 6.977 0 012.52 4.44h-5.17l2.65-4.44m-.31-1.43l-4.1 6.87h8c0-1.39-.36-2.75-1.04-3.95a8.007 8.007 0 00-2.86-2.92z' />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { type SVGProps } from 'react'
|
||||
|
||||
export function IconLayoutDefault(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
data-name='con-layout-default'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 79.86 51.14'
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d='M39.22 15.99h-8.16c-.79 0-1.43-.67-1.43-1.5s.64-1.5 1.43-1.5h8.16c.79 0 1.43.67 1.43 1.5s-.64 1.5-1.43 1.5z'
|
||||
opacity={0.75}
|
||||
/>
|
||||
<rect
|
||||
x={29.63}
|
||||
y={18.39}
|
||||
width={16.72}
|
||||
height={2.73}
|
||||
rx={1.36}
|
||||
ry={1.36}
|
||||
opacity={0.5}
|
||||
/>
|
||||
<path
|
||||
d='M75.1 6.68v1.45c0 .63-.49 1.14-1.09 1.14H30.72c-.6 0-1.09-.51-1.09-1.14V6.68c0-.62.49-1.14 1.09-1.14h43.29c.6 0 1.09.52 1.09 1.14z'
|
||||
opacity={0.9}
|
||||
/>
|
||||
<rect
|
||||
x={29.63}
|
||||
y={24.22}
|
||||
width={21.8}
|
||||
height={19.95}
|
||||
rx={2.11}
|
||||
ry={2.11}
|
||||
opacity={0.4}
|
||||
/>
|
||||
<g strokeLinecap='round' strokeMiterlimit={10}>
|
||||
<rect
|
||||
x={61.06}
|
||||
y={38.15}
|
||||
width={2.01}
|
||||
height={3.42}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.32}
|
||||
/>
|
||||
<rect
|
||||
x={56.78}
|
||||
y={34.99}
|
||||
width={2.01}
|
||||
height={6.58}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.44}
|
||||
/>
|
||||
<rect
|
||||
x={65.17}
|
||||
y={32.86}
|
||||
width={2.01}
|
||||
height={8.7}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.53}
|
||||
/>
|
||||
<rect
|
||||
x={69.55}
|
||||
y={29.17}
|
||||
width={2.01}
|
||||
height={12.4}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.66}
|
||||
/>
|
||||
</g>
|
||||
<g opacity={0.5}>
|
||||
<circle cx={63.17} cy={18.63} r={7.5} />
|
||||
<path d='M63.17 11.63c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.14-7-7 3.14-7 7-7m0-1c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8-3.58-8-8-8z' />
|
||||
</g>
|
||||
<g opacity={0.74}>
|
||||
<path d='M64.05 18.13l3.38-5.67c.93.64 1.7 1.48 2.26 2.47.56.98.89 2.08.96 3.21h-6.6z' />
|
||||
<path d='M67.57 13.19a6.977 6.977 0 012.52 4.44h-5.17l2.65-4.44m-.31-1.43l-4.1 6.87h8c0-1.39-.36-2.75-1.04-3.95a8.007 8.007 0 00-2.86-2.92z' />
|
||||
</g>
|
||||
<g strokeLinecap='round' strokeMiterlimit={10}>
|
||||
<rect
|
||||
x={5.84}
|
||||
y={5.02}
|
||||
width={19.14}
|
||||
height={40}
|
||||
rx={2}
|
||||
ry={2}
|
||||
opacity={0.8}
|
||||
/>
|
||||
<g stroke='#fff'>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.72}
|
||||
strokeWidth='2px'
|
||||
d='M9.02 17.39L21.25 17.39'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.48}
|
||||
strokeWidth='2px'
|
||||
d='M9.02 24.6L19.54 24.6'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.55}
|
||||
strokeWidth='2px'
|
||||
d='M9.02 20.88L18.4 20.88'
|
||||
/>
|
||||
<circle cx={10.98} cy={9.91} r={2.54} fill='#fff' opacity={0.8} />
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.8}
|
||||
strokeWidth='2px'
|
||||
d='M15.53 8.65L21.25 8.65'
|
||||
/>
|
||||
<path fill='none' opacity={0.6} d='M15.32 11.3L20.38 11.3' />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { type SVGProps } from 'react'
|
||||
|
||||
export function IconLayoutFull(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
data-name='icon-layout-full'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 79.86 51.14'
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.75}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='3px'
|
||||
d='M6.85 14.49L15.02 14.49'
|
||||
/>
|
||||
<rect
|
||||
x={5.84}
|
||||
y={18.39}
|
||||
width={25.6}
|
||||
height={2.73}
|
||||
rx={0.64}
|
||||
ry={0.64}
|
||||
opacity={0.5}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<rect
|
||||
x={5.84}
|
||||
y={5.89}
|
||||
width={68.26}
|
||||
height={2.73}
|
||||
rx={0.64}
|
||||
ry={0.64}
|
||||
opacity={0.9}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<rect
|
||||
x={5.84}
|
||||
y={24.22}
|
||||
width={37.71}
|
||||
height={19.95}
|
||||
rx={2.11}
|
||||
ry={2.11}
|
||||
opacity={0.4}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<g strokeLinecap='round' strokeMiterlimit={10}>
|
||||
<rect
|
||||
x={59.05}
|
||||
y={38.15}
|
||||
width={2.01}
|
||||
height={3.42}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.32}
|
||||
/>
|
||||
<rect
|
||||
x={54.78}
|
||||
y={34.99}
|
||||
width={2.01}
|
||||
height={6.58}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.44}
|
||||
/>
|
||||
<rect
|
||||
x={63.17}
|
||||
y={32.86}
|
||||
width={2.01}
|
||||
height={8.7}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.53}
|
||||
/>
|
||||
<rect
|
||||
x={67.54}
|
||||
y={29.17}
|
||||
width={2.01}
|
||||
height={12.4}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.66}
|
||||
/>
|
||||
</g>
|
||||
<g opacity={0.5}>
|
||||
<circle cx={62.16} cy={18.63} r={7.5} />
|
||||
<path d='M62.16 11.63c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.14-7-7 3.14-7 7-7m0-1c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8-3.58-8-8-8z' />
|
||||
</g>
|
||||
<g opacity={0.74}>
|
||||
<path d='M63.04 18.13l3.38-5.67c.93.64 1.7 1.48 2.26 2.47.56.98.89 2.08.96 3.21h-6.6z' />
|
||||
<path d='M66.57 13.19a6.977 6.977 0 012.52 4.44h-5.17l2.65-4.44m-.31-1.43l-4.1 6.87h8c0-1.39-.36-2.75-1.04-3.95a8.007 8.007 0 00-2.86-2.92z' />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { type SVGProps } from 'react'
|
||||
|
||||
export function IconSidebarFloating(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
data-name='icon-sidebar-floating'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 79.86 51.14'
|
||||
{...props}
|
||||
>
|
||||
<rect
|
||||
x={5.89}
|
||||
y={5.15}
|
||||
width={19.74}
|
||||
height={40}
|
||||
rx={2}
|
||||
ry={2}
|
||||
opacity={0.8}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<g stroke='#fff' strokeLinecap='round' strokeMiterlimit={10}>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.72}
|
||||
strokeWidth='2px'
|
||||
d='M9.81 18.36L22.04 18.36'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.48}
|
||||
strokeWidth='2px'
|
||||
d='M9.81 25.57L20.33 25.57'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.55}
|
||||
strokeWidth='2px'
|
||||
d='M9.81 21.85L19.18 21.85'
|
||||
/>
|
||||
<circle cx={11.76} cy={10.88} r={2.54} fill='#fff' opacity={0.8} />
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.8}
|
||||
strokeWidth='2px'
|
||||
d='M16.31 9.62L22.04 9.62'
|
||||
/>
|
||||
<path fill='none' opacity={0.6} d='M16.1 12.27L21.16 12.27' />
|
||||
</g>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.62}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='3px'
|
||||
d='M30.59 9.62L35.85 9.62'
|
||||
/>
|
||||
<rect
|
||||
x={29.94}
|
||||
y={13.42}
|
||||
width={26.03}
|
||||
height={2.73}
|
||||
rx={0.64}
|
||||
ry={0.64}
|
||||
opacity={0.44}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<rect
|
||||
x={29.94}
|
||||
y={19.28}
|
||||
width={43.11}
|
||||
height={25.87}
|
||||
rx={2}
|
||||
ry={2}
|
||||
opacity={0.3}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { type SVGProps } from 'react'
|
||||
|
||||
export function IconSidebarInset(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
data-name='icon-sidebar-inset'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 79.86 51.14'
|
||||
{...props}
|
||||
>
|
||||
<rect
|
||||
x={23.39}
|
||||
y={5.57}
|
||||
width={50.22}
|
||||
height={40}
|
||||
rx={2}
|
||||
ry={2}
|
||||
opacity={0.2}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.72}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='2px'
|
||||
d='M5.08 17.05L17.31 17.05'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.48}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='2px'
|
||||
d='M5.08 24.25L15.6 24.25'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.55}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='2px'
|
||||
d='M5.08 20.54L14.46 20.54'
|
||||
/>
|
||||
<g strokeLinecap='round' strokeMiterlimit={10}>
|
||||
<circle cx={7.04} cy={9.57} r={2.54} opacity={0.8} />
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.8}
|
||||
strokeWidth='2px'
|
||||
d='M11.59 8.3L17.31 8.3'
|
||||
/>
|
||||
<path fill='none' opacity={0.6} d='M11.38 10.95L16.44 10.95' />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type SVGProps } from 'react'
|
||||
|
||||
export function IconSidebarSidebar(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
data-name='icon-sidebar-sidebar'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 79.86 51.14'
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d='M23.42.51h51.99c2.21 0 4 1.79 4 4v42.18c0 2.21-1.79 4-4 4H23.42s-.04-.02-.04-.04V.55s.02-.04.04-.04z'
|
||||
opacity={0.2}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.72}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='2px'
|
||||
d='M5.56 14.88L17.78 14.88'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.48}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='2px'
|
||||
d='M5.56 22.09L16.08 22.09'
|
||||
/>
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.55}
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
strokeWidth='2px'
|
||||
d='M5.56 18.38L14.93 18.38'
|
||||
/>
|
||||
<g strokeLinecap='round' strokeMiterlimit={10}>
|
||||
<circle cx={7.51} cy={7.4} r={2.54} opacity={0.8} />
|
||||
<path
|
||||
fill='none'
|
||||
opacity={0.8}
|
||||
strokeWidth='2px'
|
||||
d='M12.06 6.14L17.78 6.14'
|
||||
/>
|
||||
<path fill='none' opacity={0.6} d='M11.85 8.79L16.91 8.79' />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { type SVGProps } from 'react'
|
||||
|
||||
export function IconThemeDark(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
data-name='icon-theme-dark'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 79.86 51.14'
|
||||
{...props}
|
||||
>
|
||||
<g fill='#1d2b3f'>
|
||||
<rect x={0.53} y={0.5} width={78.83} height={50.14} rx={3.5} ry={3.5} />
|
||||
<path d='M75.86 1c1.65 0 3 1.35 3 3v43.14c0 1.65-1.35 3-3 3H4.03c-1.65 0-3-1.35-3-3V4c0-1.65 1.35-3 3-3h71.83m0-1H4.03c-2.21 0-4 1.79-4 4v43.14c0 2.21 1.79 4 4 4h71.83c2.21 0 4-1.79 4-4V4c0-2.21-1.79-4-4-4z' />
|
||||
</g>
|
||||
<path
|
||||
d='M22.88 0h52.97c2.21 0 4 1.79 4 4v43.14c0 2.21-1.79 4-4 4H22.88V0z'
|
||||
fill='#0d1628'
|
||||
/>
|
||||
<circle cx={6.7} cy={7.04} r={3.54} fill='#426187' />
|
||||
<path
|
||||
d='M18.12 6.39h-5.87c-.6 0-1.09-.45-1.09-1s.49-1 1.09-1h5.87c.6 0 1.09.45 1.09 1s-.49 1-1.09 1zM16.55 9.77h-4.24c-.55 0-1-.45-1-1s.45-1 1-1h4.24c.55 0 1 .45 1 1s-.45 1-1 1zM18.32 17.37H4.59c-.69 0-1.25-.47-1.25-1.05s.56-1.05 1.25-1.05h13.73c.69 0 1.25.47 1.25 1.05s-.56 1.05-1.25 1.05zM15.34 21.26h-11c-.55 0-1-.41-1-.91s.45-.91 1-.91h11c.55 0 1 .41 1 .91s-.45.91-1 .91zM16.46 25.57H4.43c-.6 0-1.09-.44-1.09-.98s.49-.98 1.09-.98h12.03c.6 0 1.09.44 1.09.98s-.49.98-1.09.98z'
|
||||
fill='#426187'
|
||||
/>
|
||||
<g fill='#2a62bc'>
|
||||
<rect
|
||||
x={33.36}
|
||||
y={19.73}
|
||||
width={2.75}
|
||||
height={3.42}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.32}
|
||||
/>
|
||||
<rect
|
||||
x={29.64}
|
||||
y={16.57}
|
||||
width={2.75}
|
||||
height={6.58}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.44}
|
||||
/>
|
||||
<rect
|
||||
x={37.16}
|
||||
y={14.44}
|
||||
width={2.75}
|
||||
height={8.7}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.53}
|
||||
/>
|
||||
<rect
|
||||
x={41.19}
|
||||
y={10.75}
|
||||
width={2.75}
|
||||
height={12.4}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.53}
|
||||
/>
|
||||
</g>
|
||||
<circle cx={62.74} cy={16.32} r={8} fill='#2f5491' opacity={0.5} />
|
||||
<path
|
||||
d='M62.74 16.32l4.1-6.87c1.19.71 2.18 1.72 2.86 2.92s1.04 2.57 1.04 3.95h-8z'
|
||||
fill='#2f5491'
|
||||
opacity={0.74}
|
||||
/>
|
||||
<rect
|
||||
x={29.64}
|
||||
y={27.75}
|
||||
width={41.62}
|
||||
height={18.62}
|
||||
rx={1.69}
|
||||
ry={1.69}
|
||||
fill='#17273f'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { type SVGProps } from 'react'
|
||||
|
||||
export function IconThemeLight(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
data-name='icon-theme-light'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 79.86 51.14'
|
||||
{...props}
|
||||
>
|
||||
<g fill='#d9d9d9'>
|
||||
<rect x={0.53} y={0.5} width={78.83} height={50.14} rx={3.5} ry={3.5} />
|
||||
<path d='M75.86 1c1.65 0 3 1.35 3 3v43.14c0 1.65-1.35 3-3 3H4.03c-1.65 0-3-1.35-3-3V4c0-1.65 1.35-3 3-3h71.83m0-1H4.03c-2.21 0-4 1.79-4 4v43.14c0 2.21 1.79 4 4 4h71.83c2.21 0 4-1.79 4-4V4c0-2.21-1.79-4-4-4z' />
|
||||
</g>
|
||||
<path
|
||||
d='M22.88 0h52.97c2.21 0 4 1.79 4 4v43.14c0 2.21-1.79 4-4 4H22.88V0z'
|
||||
fill='#ecedef'
|
||||
/>
|
||||
<circle cx={6.7} cy={7.04} r={3.54} fill='#fff' />
|
||||
<path
|
||||
d='M18.12 6.39h-5.87c-.6 0-1.09-.45-1.09-1s.49-1 1.09-1h5.87c.6 0 1.09.45 1.09 1s-.49 1-1.09 1zM16.55 9.77h-4.24c-.55 0-1-.45-1-1s.45-1 1-1h4.24c.55 0 1 .45 1 1s-.45 1-1 1zM18.32 17.37H4.59c-.69 0-1.25-.47-1.25-1.05s.56-1.05 1.25-1.05h13.73c.69 0 1.25.47 1.25 1.05s-.56 1.05-1.25 1.05zM15.34 21.26h-11c-.55 0-1-.41-1-.91s.45-.91 1-.91h11c.55 0 1 .41 1 .91s-.45.91-1 .91zM16.46 25.57H4.43c-.6 0-1.09-.44-1.09-.98s.49-.98 1.09-.98h12.03c.6 0 1.09.44 1.09.98s-.49.98-1.09.98z'
|
||||
fill='#fff'
|
||||
/>
|
||||
<g fill='#c0c4c4'>
|
||||
<rect
|
||||
x={33.36}
|
||||
y={19.73}
|
||||
width={2.75}
|
||||
height={3.42}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.32}
|
||||
/>
|
||||
<rect
|
||||
x={29.64}
|
||||
y={16.57}
|
||||
width={2.75}
|
||||
height={6.58}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.44}
|
||||
/>
|
||||
<rect
|
||||
x={37.16}
|
||||
y={14.44}
|
||||
width={2.75}
|
||||
height={8.7}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.53}
|
||||
/>
|
||||
<rect
|
||||
x={41.19}
|
||||
y={10.75}
|
||||
width={2.75}
|
||||
height={12.4}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.53}
|
||||
/>
|
||||
</g>
|
||||
<circle cx={62.74} cy={16.32} r={8} fill='#fff' />
|
||||
<g fill='#d9d9d9'>
|
||||
<path d='M63.62 15.82L67 10.15c.93.64 1.7 1.48 2.26 2.47.56.98.89 2.08.96 3.21h-6.6z' />
|
||||
<path d='M67.14 10.88a6.977 6.977 0 012.52 4.44h-5.17l2.65-4.44m-.31-1.43l-4.1 6.87h8c0-1.39-.36-2.75-1.04-3.95s-1.67-2.21-2.86-2.92z' />
|
||||
</g>
|
||||
<rect
|
||||
x={29.64}
|
||||
y={27.75}
|
||||
width={41.62}
|
||||
height={18.62}
|
||||
rx={1.69}
|
||||
ry={1.69}
|
||||
fill='#fff'
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function IconThemeSystem({
|
||||
className,
|
||||
...props
|
||||
}: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
data-name='icon-theme-system'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 79.86 51.14'
|
||||
className={cn(
|
||||
'overflow-hidden rounded-[6px]',
|
||||
'fill-primary stroke-primary group-data-[state=unchecked]:fill-muted-foreground group-data-[state=unchecked]:stroke-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<path opacity={0.2} d='M0 0.03H22.88V51.17H0z' />
|
||||
<circle
|
||||
cx={6.7}
|
||||
cy={7.04}
|
||||
r={3.54}
|
||||
fill='#fff'
|
||||
opacity={0.8}
|
||||
stroke='#fff'
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
<path
|
||||
d='M18.12 6.39h-5.87c-.6 0-1.09-.45-1.09-1s.49-1 1.09-1h5.87c.6 0 1.09.45 1.09 1s-.49 1-1.09 1zM16.55 9.77h-4.24c-.55 0-1-.45-1-1s.45-1 1-1h4.24c.55 0 1 .45 1 1s-.45 1-1 1z'
|
||||
fill='#fff'
|
||||
stroke='none'
|
||||
opacity={0.75}
|
||||
/>
|
||||
<path
|
||||
d='M18.32 17.37H4.59c-.69 0-1.25-.47-1.25-1.05s.56-1.05 1.25-1.05h13.73c.69 0 1.25.47 1.25 1.05s-.56 1.05-1.25 1.05z'
|
||||
fill='#fff'
|
||||
stroke='none'
|
||||
opacity={0.72}
|
||||
/>
|
||||
<path
|
||||
d='M15.34 21.26h-11c-.55 0-1-.41-1-.91s.45-.91 1-.91h11c.55 0 1 .41 1 .91s-.45.91-1 .91z'
|
||||
fill='#fff'
|
||||
stroke='none'
|
||||
opacity={0.55}
|
||||
/>
|
||||
<path
|
||||
d='M16.46 25.57H4.43c-.6 0-1.09-.44-1.09-.98s.49-.98 1.09-.98h12.03c.6 0 1.09.44 1.09.98s-.49.98-1.09.98z'
|
||||
fill='#fff'
|
||||
stroke='none'
|
||||
opacity={0.67}
|
||||
/>
|
||||
<rect
|
||||
x={33.36}
|
||||
y={19.73}
|
||||
width={2.75}
|
||||
height={3.42}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.31}
|
||||
stroke='none'
|
||||
/>
|
||||
<rect
|
||||
x={29.64}
|
||||
y={16.57}
|
||||
width={2.75}
|
||||
height={6.58}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.4}
|
||||
stroke='none'
|
||||
/>
|
||||
<rect
|
||||
x={37.16}
|
||||
y={14.44}
|
||||
width={2.75}
|
||||
height={8.7}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.26}
|
||||
stroke='none'
|
||||
/>
|
||||
<rect
|
||||
x={41.19}
|
||||
y={10.75}
|
||||
width={2.75}
|
||||
height={12.4}
|
||||
rx={0.33}
|
||||
ry={0.33}
|
||||
opacity={0.37}
|
||||
stroke='none'
|
||||
/>
|
||||
<g>
|
||||
<circle cx={62.74} cy={16.32} r={8} opacity={0.25} />
|
||||
<path
|
||||
d='M62.74 16.32l4.1-6.87c1.19.71 2.18 1.72 2.86 2.92s1.04 2.57 1.04 3.95h-8z'
|
||||
opacity={0.45}
|
||||
/>
|
||||
</g>
|
||||
<rect
|
||||
x={29.64}
|
||||
y={27.75}
|
||||
width={41.62}
|
||||
height={18.62}
|
||||
rx={1.69}
|
||||
ry={1.69}
|
||||
opacity={0.3}
|
||||
stroke='none'
|
||||
strokeLinecap='round'
|
||||
strokeMiterlimit={10}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function Logo({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
id='shadcn-admin-logo'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
height='24'
|
||||
width='24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
className={cn('size-6', className)}
|
||||
{...props}
|
||||
>
|
||||
<title>Shadcn-Admin</title>
|
||||
<path d='M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function Logo({ className, ...props }: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
id='shadcn-admin-logo'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
height='24'
|
||||
width='24'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
strokeWidth='2'
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
className={cn('size-6', className)}
|
||||
{...props}
|
||||
>
|
||||
<title>Shadcn-Admin</title>
|
||||
<path d='M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -1,90 +0,0 @@
|
||||
import { Package, Play, Settings } from "lucide-react"
|
||||
import { cn } from "../lib/utils"
|
||||
|
||||
export type PageView = "models" | "launch"
|
||||
|
||||
interface SidebarProps {
|
||||
activePage: PageView
|
||||
onNavigate: (page: PageView) => void
|
||||
address: string
|
||||
port: string
|
||||
onAddressChange: (v: string) => void
|
||||
onPortChange: (v: string) => void
|
||||
}
|
||||
|
||||
const navItems: { id: PageView; label: string; icon: typeof Package }[] = [
|
||||
{ id: "models", label: "模型列表", icon: Package },
|
||||
{ id: "launch", label: "启动服务", icon: Play },
|
||||
]
|
||||
|
||||
export default function Sidebar({
|
||||
activePage,
|
||||
onNavigate,
|
||||
address,
|
||||
port,
|
||||
onAddressChange,
|
||||
onPortChange,
|
||||
}: SidebarProps) {
|
||||
return (
|
||||
<aside className="w-64 border-r bg-sidebar-background flex flex-col h-full">
|
||||
{/* Logo */}
|
||||
<div className="p-4 border-b">
|
||||
<h1 className="text-lg font-bold flex items-center gap-2">
|
||||
<Play className="w-5 h-5 text-primary" />
|
||||
AHA Launcher
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">模型推理服务启动器</p>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="p-2 space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-colors cursor-pointer",
|
||||
activePage === item.id
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||
: "text-sidebar-foreground hover:bg-sidebar-accent/50"
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{item.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Settings */}
|
||||
<div className="mt-auto p-4 border-t">
|
||||
<div className="flex items-center gap-2 text-sm font-medium mb-3 text-muted-foreground">
|
||||
<Settings className="w-4 h-4" />
|
||||
默认设置
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-muted-foreground mb-1">监听端口</label>
|
||||
<input
|
||||
value={port}
|
||||
onChange={(e) => onPortChange(e.target.value)}
|
||||
className="w-full h-8 px-2.5 rounded-md border bg-background text-xs
|
||||
focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-muted-foreground mb-1">监听地址</label>
|
||||
<input
|
||||
value={address}
|
||||
onChange={(e) => onAddressChange(e.target.value)}
|
||||
className="w-full h-8 px-2.5 rounded-md border bg-background text-xs
|
||||
focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Telescope } from 'lucide-react'
|
||||
|
||||
export function ComingSoon() {
|
||||
return (
|
||||
<div className='h-svh'>
|
||||
<div className='m-auto flex h-full w-full flex-col items-center justify-center gap-2'>
|
||||
<Telescope size={72} />
|
||||
<h1 className='text-4xl leading-tight font-bold'>Coming Soon!</h1>
|
||||
<p className='text-center text-muted-foreground'>
|
||||
This page has not been created yet. <br />
|
||||
Stay tuned though!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import React from 'react'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { ArrowRight, ChevronRight, Laptop, Moon, Sun } from 'lucide-react'
|
||||
import { useSearch } from '@/context/search-provider'
|
||||
import { useTheme } from '@/context/theme-provider'
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command'
|
||||
import { sidebarData } from './layout/data/sidebar-data'
|
||||
import { ScrollArea } from './ui/scroll-area'
|
||||
|
||||
export function CommandMenu() {
|
||||
const navigate = useNavigate()
|
||||
const { setTheme } = useTheme()
|
||||
const { open, setOpen } = useSearch()
|
||||
|
||||
const runCommand = React.useCallback(
|
||||
(command: () => unknown) => {
|
||||
setOpen(false)
|
||||
command()
|
||||
},
|
||||
[setOpen]
|
||||
)
|
||||
|
||||
return (
|
||||
<CommandDialog modal open={open} onOpenChange={setOpen}>
|
||||
<CommandInput placeholder='Type a command or search...' />
|
||||
<CommandList>
|
||||
<ScrollArea type='hover' className='h-72 pe-1'>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
{sidebarData.navGroups.map((group) => (
|
||||
<CommandGroup key={group.title} heading={group.title}>
|
||||
{group.items.map((navItem, i) => {
|
||||
if (navItem.url)
|
||||
return (
|
||||
<CommandItem
|
||||
key={`${navItem.url}-${i}`}
|
||||
value={navItem.title}
|
||||
onSelect={() => {
|
||||
runCommand(() => navigate({ to: navItem.url }))
|
||||
}}
|
||||
>
|
||||
<div className='flex size-4 items-center justify-center'>
|
||||
<ArrowRight className='size-2 text-muted-foreground/80' />
|
||||
</div>
|
||||
{navItem.title}
|
||||
</CommandItem>
|
||||
)
|
||||
|
||||
return navItem.items?.map((subItem, i) => (
|
||||
<CommandItem
|
||||
key={`${navItem.title}-${subItem.url}-${i}`}
|
||||
value={`${navItem.title}-${subItem.url}`}
|
||||
onSelect={() => {
|
||||
runCommand(() => navigate({ to: subItem.url }))
|
||||
}}
|
||||
>
|
||||
<div className='flex size-4 items-center justify-center'>
|
||||
<ArrowRight className='size-2 text-muted-foreground/80' />
|
||||
</div>
|
||||
{navItem.title} <ChevronRight /> {subItem.title}
|
||||
</CommandItem>
|
||||
))
|
||||
})}
|
||||
</CommandGroup>
|
||||
))}
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading='Theme'>
|
||||
<CommandItem onSelect={() => runCommand(() => setTheme('light'))}>
|
||||
<Sun /> <span>Light</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => runCommand(() => setTheme('dark'))}>
|
||||
<Moon className='scale-90' />
|
||||
<span>Dark</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => runCommand(() => setTheme('system'))}>
|
||||
<Laptop />
|
||||
<span>System</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</ScrollArea>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { type SVGProps } from 'react'
|
||||
import { Root as Radio, Item } from '@radix-ui/react-radio-group'
|
||||
import { CircleCheck, RotateCcw, Settings } from 'lucide-react'
|
||||
import { IconDir } from '@/assets/custom/icon-dir'
|
||||
import { IconLayoutCompact } from '@/assets/custom/icon-layout-compact'
|
||||
import { IconLayoutDefault } from '@/assets/custom/icon-layout-default'
|
||||
import { IconLayoutFull } from '@/assets/custom/icon-layout-full'
|
||||
import { IconSidebarFloating } from '@/assets/custom/icon-sidebar-floating'
|
||||
import { IconSidebarInset } from '@/assets/custom/icon-sidebar-inset'
|
||||
import { IconSidebarSidebar } from '@/assets/custom/icon-sidebar-sidebar'
|
||||
import { IconThemeDark } from '@/assets/custom/icon-theme-dark'
|
||||
import { IconThemeLight } from '@/assets/custom/icon-theme-light'
|
||||
import { IconThemeSystem } from '@/assets/custom/icon-theme-system'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useDirection } from '@/context/direction-provider'
|
||||
import { type Collapsible, useLayout } from '@/context/layout-provider'
|
||||
import { useTheme } from '@/context/theme-provider'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from '@/components/ui/sheet'
|
||||
import { useSidebar } from './ui/sidebar'
|
||||
|
||||
export function ConfigDrawer() {
|
||||
const { setOpen } = useSidebar()
|
||||
const { resetDir } = useDirection()
|
||||
const { resetTheme } = useTheme()
|
||||
const { resetLayout } = useLayout()
|
||||
|
||||
const handleReset = () => {
|
||||
setOpen(true)
|
||||
resetDir()
|
||||
resetTheme()
|
||||
resetLayout()
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
size='icon'
|
||||
variant='ghost'
|
||||
aria-label='Open theme settings'
|
||||
className='rounded-full'
|
||||
>
|
||||
<Settings aria-hidden='true' />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className='flex flex-col'>
|
||||
<SheetHeader className='pb-0 text-start'>
|
||||
<SheetTitle>Theme Settings</SheetTitle>
|
||||
<SheetDescription>
|
||||
Adjust the appearance and layout to suit your preferences.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className='space-y-6 overflow-y-auto px-4'>
|
||||
<ThemeConfig />
|
||||
<SidebarConfig />
|
||||
<LayoutConfig />
|
||||
<DirConfig />
|
||||
</div>
|
||||
<SheetFooter className='gap-2'>
|
||||
<Button
|
||||
variant='destructive'
|
||||
onClick={handleReset}
|
||||
aria-label='Reset all settings to default values'
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionTitle({
|
||||
title,
|
||||
showReset = false,
|
||||
onReset,
|
||||
resetAriaLabel,
|
||||
className,
|
||||
}: {
|
||||
title: string
|
||||
showReset?: boolean
|
||||
onReset?: () => void
|
||||
/** Shown on the small per-section reset (RotateCcw) for accessibility and tests. */
|
||||
resetAriaLabel?: string
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mb-2 flex items-center gap-2 text-sm font-semibold text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
{showReset && onReset && (
|
||||
<Button
|
||||
type='button'
|
||||
size='icon'
|
||||
variant='secondary'
|
||||
className='size-4 rounded-full'
|
||||
onClick={onReset}
|
||||
aria-label={resetAriaLabel}
|
||||
>
|
||||
<RotateCcw className='size-3' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
item,
|
||||
isTheme = false,
|
||||
}: {
|
||||
item: {
|
||||
value: string
|
||||
label: string
|
||||
icon: (props: SVGProps<SVGSVGElement>) => React.ReactElement
|
||||
}
|
||||
isTheme?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Item
|
||||
value={item.value}
|
||||
className={cn('group outline-none', 'transition duration-200 ease-in')}
|
||||
aria-label={`Select ${item.label.toLowerCase()}`}
|
||||
aria-describedby={`${item.value}-description`}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative rounded-[6px] ring-[1px] ring-border',
|
||||
'group-data-[state=checked]:shadow-2xl group-data-[state=checked]:ring-primary',
|
||||
'group-focus-visible:ring-2'
|
||||
)}
|
||||
role='img'
|
||||
aria-hidden='false'
|
||||
aria-label={`${item.label} option preview`}
|
||||
>
|
||||
<CircleCheck
|
||||
className={cn(
|
||||
'size-6 fill-primary stroke-white',
|
||||
'group-data-[state=unchecked]:hidden',
|
||||
'absolute top-0 right-0 translate-x-1/2 -translate-y-1/2'
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
<item.icon
|
||||
className={cn(
|
||||
!isTheme &&
|
||||
'fill-primary stroke-primary group-data-[state=unchecked]:fill-muted-foreground group-data-[state=unchecked]:stroke-muted-foreground'
|
||||
)}
|
||||
aria-hidden='true'
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className='mt-1 text-xs'
|
||||
id={`${item.value}-description`}
|
||||
aria-live='polite'
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
||||
function ThemeConfig() {
|
||||
const { defaultTheme, theme, setTheme } = useTheme()
|
||||
return (
|
||||
<div>
|
||||
<SectionTitle
|
||||
title='Theme'
|
||||
showReset={theme !== defaultTheme}
|
||||
onReset={() => setTheme(defaultTheme)}
|
||||
resetAriaLabel='Reset theme preference to default'
|
||||
/>
|
||||
<Radio
|
||||
value={theme}
|
||||
onValueChange={setTheme}
|
||||
className='grid w-full max-w-md grid-cols-3 gap-4'
|
||||
aria-label='Select theme preference'
|
||||
aria-describedby='theme-description'
|
||||
>
|
||||
{[
|
||||
{
|
||||
value: 'system',
|
||||
label: 'System',
|
||||
icon: IconThemeSystem,
|
||||
},
|
||||
{
|
||||
value: 'light',
|
||||
label: 'Light',
|
||||
icon: IconThemeLight,
|
||||
},
|
||||
{
|
||||
value: 'dark',
|
||||
label: 'Dark',
|
||||
icon: IconThemeDark,
|
||||
},
|
||||
].map((item) => (
|
||||
<RadioGroupItem key={item.value} item={item} isTheme />
|
||||
))}
|
||||
</Radio>
|
||||
<div id='theme-description' className='sr-only'>
|
||||
Choose between system preference, light mode, or dark mode
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarConfig() {
|
||||
const { defaultVariant, variant, setVariant } = useLayout()
|
||||
return (
|
||||
<div className='max-md:hidden'>
|
||||
<SectionTitle
|
||||
title='Sidebar'
|
||||
showReset={defaultVariant !== variant}
|
||||
onReset={() => setVariant(defaultVariant)}
|
||||
resetAriaLabel='Reset sidebar style to default'
|
||||
/>
|
||||
<Radio
|
||||
value={variant}
|
||||
onValueChange={setVariant}
|
||||
className='grid w-full max-w-md grid-cols-3 gap-4'
|
||||
aria-label='Select sidebar style'
|
||||
aria-describedby='sidebar-description'
|
||||
>
|
||||
{[
|
||||
{
|
||||
value: 'inset',
|
||||
label: 'Inset',
|
||||
icon: IconSidebarInset,
|
||||
},
|
||||
{
|
||||
value: 'floating',
|
||||
label: 'Floating',
|
||||
icon: IconSidebarFloating,
|
||||
},
|
||||
{
|
||||
value: 'sidebar',
|
||||
label: 'Sidebar',
|
||||
icon: IconSidebarSidebar,
|
||||
},
|
||||
].map((item) => (
|
||||
<RadioGroupItem key={item.value} item={item} />
|
||||
))}
|
||||
</Radio>
|
||||
<div id='sidebar-description' className='sr-only'>
|
||||
Choose between inset, floating, or standard sidebar layout
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutConfig() {
|
||||
const { open, setOpen } = useSidebar()
|
||||
const { defaultCollapsible, collapsible, setCollapsible } = useLayout()
|
||||
|
||||
const radioState = open ? 'default' : collapsible
|
||||
|
||||
return (
|
||||
<div className='max-md:hidden'>
|
||||
<SectionTitle
|
||||
title='Layout'
|
||||
showReset={radioState !== 'default'}
|
||||
onReset={() => {
|
||||
setOpen(true)
|
||||
setCollapsible(defaultCollapsible)
|
||||
}}
|
||||
resetAriaLabel='Reset layout options to default'
|
||||
/>
|
||||
<Radio
|
||||
value={radioState}
|
||||
onValueChange={(v) => {
|
||||
if (v === 'default') {
|
||||
setOpen(true)
|
||||
return
|
||||
}
|
||||
setOpen(false)
|
||||
setCollapsible(v as Collapsible)
|
||||
}}
|
||||
className='grid w-full max-w-md grid-cols-3 gap-4'
|
||||
aria-label='Select layout style'
|
||||
aria-describedby='layout-description'
|
||||
>
|
||||
{[
|
||||
{
|
||||
value: 'default',
|
||||
label: 'Default',
|
||||
icon: IconLayoutDefault,
|
||||
},
|
||||
{
|
||||
value: 'icon',
|
||||
label: 'Compact',
|
||||
icon: IconLayoutCompact,
|
||||
},
|
||||
{
|
||||
value: 'offcanvas',
|
||||
label: 'Full layout',
|
||||
icon: IconLayoutFull,
|
||||
},
|
||||
].map((item) => (
|
||||
<RadioGroupItem key={item.value} item={item} />
|
||||
))}
|
||||
</Radio>
|
||||
<div id='layout-description' className='sr-only'>
|
||||
Choose between default expanded, compact icon-only, or full layout mode
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DirConfig() {
|
||||
const { defaultDir, dir, setDir } = useDirection()
|
||||
return (
|
||||
<div>
|
||||
<SectionTitle
|
||||
title='Direction'
|
||||
showReset={defaultDir !== dir}
|
||||
onReset={() => setDir(defaultDir)}
|
||||
resetAriaLabel='Reset text direction to default'
|
||||
/>
|
||||
<Radio
|
||||
value={dir}
|
||||
onValueChange={setDir}
|
||||
className='grid w-full max-w-md grid-cols-3 gap-4'
|
||||
aria-label='Select site direction'
|
||||
aria-describedby='direction-description'
|
||||
>
|
||||
{[
|
||||
{
|
||||
value: 'ltr',
|
||||
label: 'Left to Right',
|
||||
icon: (props: SVGProps<SVGSVGElement>) => (
|
||||
<IconDir dir='ltr' {...props} />
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'rtl',
|
||||
label: 'Right to Left',
|
||||
icon: (props: SVGProps<SVGSVGElement>) => (
|
||||
<IconDir dir='rtl' {...props} />
|
||||
),
|
||||
},
|
||||
].map((item) => (
|
||||
<RadioGroupItem key={item.value} item={item} />
|
||||
))}
|
||||
</Radio>
|
||||
<div id='direction-description' className='sr-only'>
|
||||
Choose between left-to-right or right-to-left site direction
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
type ConfirmDialogProps = {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: React.ReactNode
|
||||
disabled?: boolean
|
||||
desc: React.JSX.Element | string
|
||||
cancelBtnText?: string
|
||||
confirmText?: React.ReactNode
|
||||
destructive?: boolean
|
||||
isLoading?: boolean
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
} & (
|
||||
| { form: string; handleConfirm?: undefined }
|
||||
| { form?: undefined; handleConfirm: () => void }
|
||||
)
|
||||
|
||||
export function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
const {
|
||||
title,
|
||||
desc,
|
||||
children,
|
||||
className,
|
||||
confirmText,
|
||||
cancelBtnText,
|
||||
destructive,
|
||||
isLoading,
|
||||
disabled = false,
|
||||
form,
|
||||
handleConfirm,
|
||||
...actions
|
||||
} = props
|
||||
return (
|
||||
<AlertDialog {...actions}>
|
||||
<AlertDialogContent className={cn(className && className)}>
|
||||
<AlertDialogHeader className='text-start'>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div>{desc}</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{children}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLoading}>
|
||||
{cancelBtnText ?? 'Cancel'}
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
type={form ? 'submit' : 'button'}
|
||||
form={form}
|
||||
onClick={handleConfirm}
|
||||
variant={destructive ? 'destructive' : 'default'}
|
||||
disabled={disabled || isLoading}
|
||||
>
|
||||
{confirmText ?? 'Continue'}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
type DataTableBulkActionsProps<TData> = {
|
||||
table: Table<TData>
|
||||
entityName: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* A modular toolbar for displaying bulk actions when table rows are selected.
|
||||
*
|
||||
* @template TData The type of data in the table.
|
||||
* @param {object} props The component props.
|
||||
* @param {Table<TData>} props.table The react-table instance.
|
||||
* @param {string} props.entityName The name of the entity being acted upon (e.g., "task", "user").
|
||||
* @param {React.ReactNode} props.children The action buttons to be rendered inside the toolbar.
|
||||
* @returns {React.ReactNode | null} The rendered component or null if no rows are selected.
|
||||
*/
|
||||
export function DataTableBulkActions<TData>({
|
||||
table,
|
||||
entityName,
|
||||
children,
|
||||
}: DataTableBulkActionsProps<TData>): React.ReactNode | null {
|
||||
const selectedRows = table.getFilteredSelectedRowModel().rows
|
||||
const selectedCount = selectedRows.length
|
||||
const toolbarRef = useRef<HTMLDivElement>(null)
|
||||
const [announcement, setAnnouncement] = useState('')
|
||||
|
||||
// Announce selection changes to screen readers
|
||||
useEffect(() => {
|
||||
if (selectedCount > 0) {
|
||||
const message = `${selectedCount} ${entityName}${selectedCount > 1 ? 's' : ''} selected. Bulk actions toolbar is available.`
|
||||
|
||||
// Use queueMicrotask to defer state update and avoid cascading renders
|
||||
queueMicrotask(() => {
|
||||
setAnnouncement(message)
|
||||
})
|
||||
|
||||
// Clear announcement after a delay
|
||||
const timer = setTimeout(() => setAnnouncement(''), 3000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [selectedCount, entityName])
|
||||
|
||||
const handleClearSelection = () => {
|
||||
table.resetRowSelection()
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
const buttons = toolbarRef.current?.querySelectorAll('button')
|
||||
if (!buttons) return
|
||||
|
||||
const currentIndex = Array.from(buttons).findIndex(
|
||||
(button) => button === document.activeElement
|
||||
)
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowRight': {
|
||||
event.preventDefault()
|
||||
const nextIndex = (currentIndex + 1) % buttons.length
|
||||
buttons[nextIndex]?.focus()
|
||||
break
|
||||
}
|
||||
case 'ArrowLeft': {
|
||||
event.preventDefault()
|
||||
const prevIndex =
|
||||
currentIndex === 0 ? buttons.length - 1 : currentIndex - 1
|
||||
buttons[prevIndex]?.focus()
|
||||
break
|
||||
}
|
||||
case 'Home':
|
||||
event.preventDefault()
|
||||
buttons[0]?.focus()
|
||||
break
|
||||
case 'End':
|
||||
event.preventDefault()
|
||||
buttons[buttons.length - 1]?.focus()
|
||||
break
|
||||
case 'Escape': {
|
||||
// Check if the Escape key came from a dropdown trigger or content
|
||||
// We can't check dropdown state because Radix UI closes it before our handler runs
|
||||
const target = event.target as HTMLElement
|
||||
const activeElement = document.activeElement as HTMLElement
|
||||
|
||||
// Check if the event target or currently focused element is a dropdown trigger
|
||||
const isFromDropdownTrigger =
|
||||
target?.getAttribute('data-slot') === 'dropdown-menu-trigger' ||
|
||||
activeElement?.getAttribute('data-slot') ===
|
||||
'dropdown-menu-trigger' ||
|
||||
target?.closest('[data-slot="dropdown-menu-trigger"]') ||
|
||||
activeElement?.closest('[data-slot="dropdown-menu-trigger"]')
|
||||
|
||||
// Check if the focused element is inside dropdown content (which is portaled)
|
||||
const isFromDropdownContent =
|
||||
activeElement?.closest('[data-slot="dropdown-menu-content"]') ||
|
||||
target?.closest('[data-slot="dropdown-menu-content"]')
|
||||
|
||||
if (isFromDropdownTrigger || isFromDropdownContent) {
|
||||
// Escape was meant for the dropdown - don't clear selection
|
||||
return
|
||||
}
|
||||
|
||||
// Escape was meant for the toolbar - clear selection
|
||||
event.preventDefault()
|
||||
handleClearSelection()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedCount === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Live region for screen reader announcements */}
|
||||
<div
|
||||
aria-live='polite'
|
||||
aria-atomic='true'
|
||||
className='sr-only'
|
||||
role='status'
|
||||
>
|
||||
{announcement}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
role='toolbar'
|
||||
aria-label={`Bulk actions for ${selectedCount} selected ${entityName}${selectedCount > 1 ? 's' : ''}`}
|
||||
aria-describedby='bulk-actions-description'
|
||||
tabIndex={-1}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
'fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-xl',
|
||||
'transition-all delay-100 duration-300 ease-out hover:scale-105',
|
||||
'focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'p-2 shadow-xl',
|
||||
'rounded-xl border',
|
||||
'bg-background/95 backdrop-blur-lg supports-backdrop-filter:bg-background/60',
|
||||
'flex items-center gap-x-2'
|
||||
)}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='icon'
|
||||
onClick={handleClearSelection}
|
||||
className='size-6 rounded-full'
|
||||
aria-label='Clear selection'
|
||||
title='Clear selection (Escape)'
|
||||
>
|
||||
<X />
|
||||
<span className='sr-only'>Clear selection</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Clear selection (Escape)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Separator
|
||||
className='h-5'
|
||||
orientation='vertical'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
|
||||
<div
|
||||
className='flex items-center gap-x-1 text-sm'
|
||||
id='bulk-actions-description'
|
||||
>
|
||||
<Badge
|
||||
variant='default'
|
||||
className='min-w-8 rounded-lg'
|
||||
aria-label={`${selectedCount} selected`}
|
||||
>
|
||||
{selectedCount}
|
||||
</Badge>{' '}
|
||||
<span className='hidden sm:inline'>
|
||||
{entityName}
|
||||
{selectedCount > 1 ? 's' : ''}
|
||||
</span>{' '}
|
||||
selected
|
||||
</div>
|
||||
|
||||
<Separator
|
||||
className='h-5'
|
||||
orientation='vertical'
|
||||
aria-hidden='true'
|
||||
/>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
CaretSortIcon,
|
||||
EyeNoneIcon,
|
||||
} from '@radix-ui/react-icons'
|
||||
import { type Column } from '@tanstack/react-table'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
type DataTableColumnHeaderProps<TData, TValue> =
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
column: Column<TData, TValue>
|
||||
title: string
|
||||
}
|
||||
|
||||
export function DataTableColumnHeader<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
className,
|
||||
}: DataTableColumnHeaderProps<TData, TValue>) {
|
||||
if (!column.getCanSort()) {
|
||||
return <div className={cn(className)}>{title}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center space-x-2', className)}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-8 data-[state=open]:bg-accent'
|
||||
>
|
||||
<span>{title}</span>
|
||||
{column.getIsSorted() === 'desc' ? (
|
||||
<ArrowDownIcon className='ms-2 h-4 w-4' />
|
||||
) : column.getIsSorted() === 'asc' ? (
|
||||
<ArrowUpIcon className='ms-2 h-4 w-4' />
|
||||
) : (
|
||||
<CaretSortIcon className='ms-2 h-4 w-4' />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='start'>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
|
||||
<ArrowUpIcon className='size-3.5 text-muted-foreground/70' />
|
||||
Asc
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
|
||||
<ArrowDownIcon className='size-3.5 text-muted-foreground/70' />
|
||||
Desc
|
||||
</DropdownMenuItem>
|
||||
{column.getCanHide() && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
|
||||
<EyeNoneIcon className='size-3.5 text-muted-foreground/70' />
|
||||
Hide
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import * as React from 'react'
|
||||
import { CheckIcon, PlusCircledIcon } from '@radix-ui/react-icons'
|
||||
import { type Column } from '@tanstack/react-table'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/command'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
|
||||
type DataTableFacetedFilterProps<TData, TValue> = {
|
||||
column?: Column<TData, TValue>
|
||||
title?: string
|
||||
options: {
|
||||
label: string
|
||||
value: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
}[]
|
||||
}
|
||||
|
||||
export function DataTableFacetedFilter<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
options,
|
||||
}: DataTableFacetedFilterProps<TData, TValue>) {
|
||||
const facets = column?.getFacetedUniqueValues()
|
||||
const selectedValues = new Set(column?.getFilterValue() as string[])
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant='outline' size='sm' className='h-8 border-dashed'>
|
||||
<PlusCircledIcon className='size-4' />
|
||||
{title}
|
||||
{selectedValues?.size > 0 && (
|
||||
<>
|
||||
<Separator orientation='vertical' className='mx-2 h-4' />
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='rounded-sm px-1 font-normal lg:hidden'
|
||||
>
|
||||
{selectedValues.size}
|
||||
</Badge>
|
||||
<div className='hidden space-x-1 lg:flex'>
|
||||
{selectedValues.size > 2 ? (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
className='rounded-sm px-1 font-normal'
|
||||
>
|
||||
{selectedValues.size} selected
|
||||
</Badge>
|
||||
) : (
|
||||
options
|
||||
.filter((option) => selectedValues.has(option.value))
|
||||
.map((option) => (
|
||||
<Badge
|
||||
variant='secondary'
|
||||
key={option.value}
|
||||
className='rounded-sm px-1 font-normal'
|
||||
>
|
||||
{option.label}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-50 p-0' align='start'>
|
||||
<Command>
|
||||
<CommandInput placeholder={title} />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => {
|
||||
const isSelected = selectedValues.has(option.value)
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
onSelect={() => {
|
||||
if (isSelected) {
|
||||
selectedValues.delete(option.value)
|
||||
} else {
|
||||
selectedValues.add(option.value)
|
||||
}
|
||||
const filterValues = Array.from(selectedValues)
|
||||
column?.setFilterValue(
|
||||
filterValues.length ? filterValues : undefined
|
||||
)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-4 items-center justify-center rounded-sm border border-primary',
|
||||
isSelected
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible'
|
||||
)}
|
||||
>
|
||||
<CheckIcon className={cn('h-4 w-4 text-background')} />
|
||||
</div>
|
||||
{option.icon && (
|
||||
<option.icon className='size-4 text-muted-foreground' />
|
||||
)}
|
||||
<span>{option.label}</span>
|
||||
{facets?.get(option.value) && (
|
||||
<span className='ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs'>
|
||||
{facets.get(option.value)}
|
||||
</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
{selectedValues.size > 0 && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
onSelect={() => column?.setFilterValue(undefined)}
|
||||
className='justify-center text-center'
|
||||
>
|
||||
Clear filters
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { DataTablePagination } from './pagination'
|
||||
export { DataTableColumnHeader } from './column-header'
|
||||
export { DataTableToolbar } from './toolbar'
|
||||
export { DataTableBulkActions } from './bulk-actions'
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
DoubleArrowLeftIcon,
|
||||
DoubleArrowRightIcon,
|
||||
} from '@radix-ui/react-icons'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { cn, getPageNumbers } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
|
||||
type DataTablePaginationProps<TData> = {
|
||||
table: Table<TData>
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function DataTablePagination<TData>({
|
||||
table,
|
||||
className,
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
const currentPage = table.getState().pagination.pageIndex + 1
|
||||
const totalPages = table.getPageCount()
|
||||
const pageNumbers = getPageNumbers(currentPage, totalPages)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between overflow-clip px-2',
|
||||
'@max-2xl/content:flex-col-reverse @max-2xl/content:gap-4',
|
||||
className
|
||||
)}
|
||||
style={{ overflowClipMargin: 1 }}
|
||||
>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<div className='flex w-25 items-center justify-center text-sm font-medium @2xl/content:hidden'>
|
||||
Page {currentPage} of {totalPages}
|
||||
</div>
|
||||
<div className='flex items-center gap-2 @max-2xl/content:flex-row-reverse'>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='h-8 w-17.5'>
|
||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side='top'>
|
||||
{[10, 20, 30, 40, 50].map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className='hidden text-sm font-medium sm:block'>Rows per page</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center sm:space-x-6 lg:space-x-8'>
|
||||
<div className='flex w-25 items-center justify-center text-sm font-medium @max-3xl/content:hidden'>
|
||||
Page {currentPage} of {totalPages}
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='size-8 p-0 @max-md/content:hidden'
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className='sr-only'>Go to first page</span>
|
||||
<DoubleArrowLeftIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='size-8 p-0'
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className='sr-only'>Go to previous page</span>
|
||||
<ChevronLeftIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
|
||||
{/* Page number buttons */}
|
||||
{pageNumbers.map((pageNumber, index) => (
|
||||
<div key={`${pageNumber}-${index}`} className='flex items-center'>
|
||||
{pageNumber === '...' ? (
|
||||
<span className='px-1 text-sm text-muted-foreground'>...</span>
|
||||
) : (
|
||||
<Button
|
||||
variant={currentPage === pageNumber ? 'default' : 'outline'}
|
||||
className='h-8 min-w-8 px-2'
|
||||
onClick={() => table.setPageIndex((pageNumber as number) - 1)}
|
||||
>
|
||||
<span className='sr-only'>Go to page {pageNumber}</span>
|
||||
{pageNumber}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant='outline'
|
||||
className='size-8 p-0'
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className='sr-only'>Go to next page</span>
|
||||
<ChevronRightIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='size-8 p-0 @max-md/content:hidden'
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className='sr-only'>Go to last page</span>
|
||||
<DoubleArrowRightIcon className='h-4 w-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Cross2Icon } from '@radix-ui/react-icons'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { DataTableFacetedFilter } from './faceted-filter'
|
||||
import { DataTableViewOptions } from './view-options'
|
||||
|
||||
type DataTableToolbarProps<TData> = {
|
||||
table: Table<TData>
|
||||
searchPlaceholder?: string
|
||||
searchKey?: string
|
||||
filters?: {
|
||||
columnId: string
|
||||
title: string
|
||||
options: {
|
||||
label: string
|
||||
value: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
}[]
|
||||
}[]
|
||||
}
|
||||
|
||||
export function DataTableToolbar<TData>({
|
||||
table,
|
||||
searchPlaceholder = 'Filter...',
|
||||
searchKey,
|
||||
filters = [],
|
||||
}: DataTableToolbarProps<TData>) {
|
||||
const isFiltered =
|
||||
table.getState().columnFilters.length > 0 || table.getState().globalFilter
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex flex-1 flex-col-reverse items-start gap-y-2 sm:flex-row sm:items-center sm:space-x-2'>
|
||||
{searchKey ? (
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={
|
||||
(table.getColumn(searchKey)?.getFilterValue() as string) ?? ''
|
||||
}
|
||||
onChange={(event) =>
|
||||
table.getColumn(searchKey)?.setFilterValue(event.target.value)
|
||||
}
|
||||
className='h-8 w-37.5 lg:w-62.5'
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={table.getState().globalFilter ?? ''}
|
||||
onChange={(event) => table.setGlobalFilter(event.target.value)}
|
||||
className='h-8 w-37.5 lg:w-62.5'
|
||||
/>
|
||||
)}
|
||||
<div className='flex gap-x-2'>
|
||||
{filters.map((filter) => {
|
||||
const column = table.getColumn(filter.columnId)
|
||||
if (!column) return null
|
||||
return (
|
||||
<DataTableFacetedFilter
|
||||
key={filter.columnId}
|
||||
column={column}
|
||||
title={filter.title}
|
||||
options={filter.options}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{isFiltered && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={() => {
|
||||
table.resetColumnFilters()
|
||||
table.setGlobalFilter('')
|
||||
}}
|
||||
className='h-8 px-2 lg:px-3'
|
||||
>
|
||||
Reset
|
||||
<Cross2Icon className='ms-2 h-4 w-4' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<DataTableViewOptions table={table} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu'
|
||||
import { MixerHorizontalIcon } from '@radix-ui/react-icons'
|
||||
import { type Table } from '@tanstack/react-table'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
type DataTableViewOptionsProps<TData> = {
|
||||
table: Table<TData>
|
||||
}
|
||||
|
||||
export function DataTableViewOptions<TData>({
|
||||
table,
|
||||
}: DataTableViewOptionsProps<TData>) {
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='ms-auto hidden h-8 lg:flex'
|
||||
>
|
||||
<MixerHorizontalIcon className='size-4' />
|
||||
View
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end' className='w-37.5'>
|
||||
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter(
|
||||
(column) =>
|
||||
typeof column.accessorFn !== 'undefined' && column.getCanHide()
|
||||
)
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className='capitalize'
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { format } from 'date-fns'
|
||||
import { Calendar as CalendarIcon } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
|
||||
type DatePickerProps = {
|
||||
selected: Date | undefined
|
||||
onSelect: (date: Date | undefined) => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function DatePicker({
|
||||
selected,
|
||||
onSelect,
|
||||
placeholder = 'Pick a date',
|
||||
}: DatePickerProps) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
data-empty={!selected}
|
||||
className='w-60 justify-start text-start font-normal data-[empty=true]:text-muted-foreground'
|
||||
>
|
||||
{selected ? (
|
||||
format(selected, 'MMM d, yyyy')
|
||||
) : (
|
||||
<span>{placeholder}</span>
|
||||
)}
|
||||
<CalendarIcon className='ms-auto h-4 w-4 opacity-50' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-auto p-0'>
|
||||
<Calendar
|
||||
mode='single'
|
||||
captionLayout='dropdown'
|
||||
selected={selected}
|
||||
onSelect={onSelect}
|
||||
disabled={(date: Date) =>
|
||||
date > new Date() || date < new Date('1900-01-01')
|
||||
}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useLayout } from '@/context/layout-provider'
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarRail,
|
||||
} from '@/components/ui/sidebar'
|
||||
// import { AppTitle } from './app-title'
|
||||
import { sidebarData } from './data/sidebar-data'
|
||||
import { NavGroup } from './nav-group'
|
||||
import { NavUser } from './nav-user'
|
||||
import { TeamSwitcher } from './team-switcher'
|
||||
|
||||
export function AppSidebar() {
|
||||
const { collapsible, variant } = useLayout()
|
||||
return (
|
||||
<Sidebar collapsible={collapsible} variant={variant}>
|
||||
<SidebarHeader>
|
||||
<TeamSwitcher teams={sidebarData.teams} />
|
||||
|
||||
{/* Replace <TeamSwitch /> with the following <AppTitle />
|
||||
/* if you want to use the normal app title instead of TeamSwitch dropdown */}
|
||||
{/* <AppTitle /> */}
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
{sidebarData.navGroups.map((props) => (
|
||||
<NavGroup key={props.title} {...props} />
|
||||
))}
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser user={sidebarData.user} />
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Menu, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar'
|
||||
import { Button } from '../ui/button'
|
||||
|
||||
export function AppTitle() {
|
||||
const { setOpenMobile } = useSidebar()
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
size='lg'
|
||||
className='gap-0 py-0 hover:bg-transparent active:bg-transparent'
|
||||
asChild
|
||||
>
|
||||
<div>
|
||||
<Link
|
||||
to='/'
|
||||
onClick={() => setOpenMobile(false)}
|
||||
className='grid flex-1 text-start text-sm leading-tight'
|
||||
>
|
||||
<span className='truncate font-bold'>Shadcn-Admin</span>
|
||||
<span className='truncate text-xs'>Vite + ShadcnUI</span>
|
||||
</Link>
|
||||
<ToggleSidebar />
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleSidebar({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar='trigger'
|
||||
data-slot='sidebar-trigger'
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className={cn('aspect-square size-8 max-md:scale-125', className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
toggleSidebar()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<X className='md:hidden' />
|
||||
<Menu className='max-md:hidden' />
|
||||
<span className='sr-only'>Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Outlet } from '@tanstack/react-router'
|
||||
import { getCookie } from '@/lib/cookies'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { LayoutProvider } from '@/context/layout-provider'
|
||||
import { SearchProvider } from '@/context/search-provider'
|
||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar'
|
||||
import { AppSidebar } from '@/components/layout/app-sidebar'
|
||||
import { SkipToMain } from '@/components/skip-to-main'
|
||||
|
||||
type AuthenticatedLayoutProps = {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function AuthenticatedLayout({ children }: AuthenticatedLayoutProps) {
|
||||
const defaultOpen = getCookie('sidebar_state') !== 'false'
|
||||
return (
|
||||
<SearchProvider>
|
||||
<LayoutProvider>
|
||||
<SidebarProvider defaultOpen={defaultOpen}>
|
||||
<SkipToMain />
|
||||
<AppSidebar />
|
||||
<SidebarInset
|
||||
className={cn(
|
||||
// Set content container, so we can use container queries
|
||||
'@container/content',
|
||||
|
||||
// If layout is fixed, set the height
|
||||
// to 100svh to prevent overflow
|
||||
'has-data-[layout=fixed]:h-svh',
|
||||
|
||||
// If layout is fixed and sidebar is inset,
|
||||
// set the height to 100svh - spacing (total margins) to prevent overflow
|
||||
'peer-data-[variant=inset]:has-data-[layout=fixed]:h-[calc(100svh-(var(--spacing)*4))]'
|
||||
)}
|
||||
>
|
||||
{children ?? <Outlet />}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
</LayoutProvider>
|
||||
</SearchProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
Package,
|
||||
Play,
|
||||
Settings,
|
||||
UserCog,
|
||||
Wrench,
|
||||
Palette,
|
||||
Bell,
|
||||
Monitor,
|
||||
} from 'lucide-react'
|
||||
import { type SidebarData } from '../types'
|
||||
|
||||
export const sidebarData: SidebarData = {
|
||||
user: {
|
||||
name: 'AHA User',
|
||||
email: 'user@aha.app',
|
||||
avatar: '/avatars/shadcn.jpg',
|
||||
},
|
||||
teams: [
|
||||
{
|
||||
name: 'AHA Launcher',
|
||||
logo: Play,
|
||||
plan: '模型推理服务启动器',
|
||||
},
|
||||
],
|
||||
navGroups: [
|
||||
{
|
||||
title: '导航',
|
||||
items: [
|
||||
{
|
||||
title: '模型列表',
|
||||
url: '/',
|
||||
icon: Package,
|
||||
},
|
||||
{
|
||||
title: '启动服务',
|
||||
url: '/launch',
|
||||
icon: Play,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '设置',
|
||||
items: [
|
||||
{
|
||||
title: 'Settings',
|
||||
icon: Settings,
|
||||
items: [
|
||||
{
|
||||
title: 'Profile',
|
||||
url: '/settings',
|
||||
icon: UserCog,
|
||||
},
|
||||
{
|
||||
title: 'Account',
|
||||
url: '/settings/account',
|
||||
icon: Wrench,
|
||||
},
|
||||
{
|
||||
title: 'Appearance',
|
||||
url: '/settings/appearance',
|
||||
icon: Palette,
|
||||
},
|
||||
{
|
||||
title: 'Notifications',
|
||||
url: '/settings/notifications',
|
||||
icon: Bell,
|
||||
},
|
||||
{
|
||||
title: 'Display',
|
||||
url: '/settings/display',
|
||||
icon: Monitor,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar'
|
||||
|
||||
type HeaderProps = React.HTMLAttributes<HTMLElement> & {
|
||||
fixed?: boolean
|
||||
ref?: React.Ref<HTMLElement>
|
||||
}
|
||||
|
||||
export function Header({ className, fixed, children, ...props }: HeaderProps) {
|
||||
const [offset, setOffset] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
setOffset(document.body.scrollTop || document.documentElement.scrollTop)
|
||||
}
|
||||
|
||||
// Add scroll listener to the body
|
||||
document.addEventListener('scroll', onScroll, { passive: true })
|
||||
|
||||
// Clean up the event listener on unmount
|
||||
return () => document.removeEventListener('scroll', onScroll)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
'z-50 h-16',
|
||||
fixed && 'header-fixed peer/header sticky top-0 w-[inherit]',
|
||||
offset > 10 && fixed ? 'shadow' : 'shadow-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-full items-center gap-3 p-4 sm:gap-4',
|
||||
offset > 10 &&
|
||||
fixed &&
|
||||
'after:absolute after:inset-0 after:-z-10 after:bg-background/20 after:backdrop-blur-lg'
|
||||
)}
|
||||
>
|
||||
<SidebarTrigger variant='outline' className='max-md:scale-125' />
|
||||
<Separator orientation='vertical' className='h-6' />
|
||||
{children}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type MainProps = React.HTMLAttributes<HTMLElement> & {
|
||||
fixed?: boolean
|
||||
fluid?: boolean
|
||||
ref?: React.Ref<HTMLElement>
|
||||
}
|
||||
|
||||
export function Main({ fixed, className, fluid, ...props }: MainProps) {
|
||||
return (
|
||||
<main
|
||||
data-layout={fixed ? 'fixed' : 'auto'}
|
||||
className={cn(
|
||||
'px-4 py-6',
|
||||
|
||||
// If layout is fixed, make the main container flex and grow
|
||||
fixed && 'flex grow flex-col overflow-hidden',
|
||||
|
||||
// If layout is not fluid, set the max-width
|
||||
!fluid &&
|
||||
'@7xl/content:mx-auto @7xl/content:w-full @7xl/content:max-w-7xl',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { Link, useLocation } from '@tanstack/react-router'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupLabel,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar'
|
||||
import { Badge } from '../ui/badge'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '../ui/dropdown-menu'
|
||||
import {
|
||||
type NavCollapsible,
|
||||
type NavItem,
|
||||
type NavLink,
|
||||
type NavGroup as NavGroupProps,
|
||||
} from './types'
|
||||
|
||||
export function NavGroup({ title, items }: NavGroupProps) {
|
||||
const { state, isMobile } = useSidebar()
|
||||
const href = useLocation({ select: (location) => location.href })
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{title}</SidebarGroupLabel>
|
||||
<SidebarMenu>
|
||||
{items.map((item) => {
|
||||
const key = `${item.title}-${item.url}`
|
||||
|
||||
if (!item.items)
|
||||
return <SidebarMenuLink key={key} item={item} href={href} />
|
||||
|
||||
if (state === 'collapsed' && !isMobile)
|
||||
return (
|
||||
<SidebarMenuCollapsedDropdown key={key} item={item} href={href} />
|
||||
)
|
||||
|
||||
return <SidebarMenuCollapsible key={key} item={item} href={href} />
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function NavBadge({ children }: { children: ReactNode }) {
|
||||
return <Badge className='rounded-full px-1 py-0 text-xs'>{children}</Badge>
|
||||
}
|
||||
|
||||
function SidebarMenuLink({ item, href }: { item: NavLink; href: string }) {
|
||||
const { setOpenMobile } = useSidebar()
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={checkIsActive(href, item)}
|
||||
tooltip={item.title}
|
||||
>
|
||||
<Link to={item.url} onClick={() => setOpenMobile(false)}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
{item.badge && <NavBadge>{item.badge}</NavBadge>}
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuCollapsible({
|
||||
item,
|
||||
href,
|
||||
}: {
|
||||
item: NavCollapsible
|
||||
href: string
|
||||
}) {
|
||||
const { setOpenMobile } = useSidebar()
|
||||
return (
|
||||
<Collapsible
|
||||
asChild
|
||||
defaultOpen={checkIsActive(href, item, true)}
|
||||
className='group/collapsible'
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton tooltip={item.title}>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
{item.badge && <NavBadge>{item.badge}</NavBadge>}
|
||||
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90 rtl:rotate-180' />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className='CollapsibleContent'>
|
||||
<SidebarMenuSub>
|
||||
{item.items.map((subItem) => (
|
||||
<SidebarMenuSubItem key={subItem.title}>
|
||||
<SidebarMenuSubButton
|
||||
asChild
|
||||
isActive={checkIsActive(href, subItem)}
|
||||
>
|
||||
<Link to={subItem.url} onClick={() => setOpenMobile(false)}>
|
||||
{subItem.icon && <subItem.icon />}
|
||||
<span>{subItem.title}</span>
|
||||
{subItem.badge && <NavBadge>{subItem.badge}</NavBadge>}
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarMenuCollapsedDropdown({
|
||||
item,
|
||||
href,
|
||||
}: {
|
||||
item: NavCollapsible
|
||||
href: string
|
||||
}) {
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.title}
|
||||
isActive={checkIsActive(href, item)}
|
||||
>
|
||||
{item.icon && <item.icon />}
|
||||
<span>{item.title}</span>
|
||||
{item.badge && <NavBadge>{item.badge}</NavBadge>}
|
||||
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side='right' align='start' sideOffset={4}>
|
||||
<DropdownMenuLabel>
|
||||
{item.title} {item.badge ? `(${item.badge})` : ''}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{item.items.map((sub) => (
|
||||
<DropdownMenuItem key={`${sub.title}-${sub.url}`} asChild>
|
||||
<Link
|
||||
to={sub.url}
|
||||
className={`${checkIsActive(href, sub) ? 'bg-secondary' : ''}`}
|
||||
>
|
||||
{sub.icon && <sub.icon />}
|
||||
<span className='max-w-52 text-wrap'>{sub.title}</span>
|
||||
{sub.badge && (
|
||||
<span className='ms-auto text-xs'>{sub.badge}</span>
|
||||
)}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
function checkIsActive(href: string, item: NavItem, mainNav = false) {
|
||||
return (
|
||||
href === item.url || // /endpint?search=param
|
||||
href.split('?')[0] === item.url || // endpoint
|
||||
!!item?.items?.filter((i) => i.url === href).length || // if child nav is active
|
||||
(mainNav &&
|
||||
href.split('/')[1] !== '' &&
|
||||
href.split('/')[1] === item?.url?.split('/')[1])
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
BadgeCheck,
|
||||
Bell,
|
||||
ChevronsUpDown,
|
||||
CreditCard,
|
||||
LogOut,
|
||||
Sparkles,
|
||||
} from 'lucide-react'
|
||||
import useDialogState from '@/hooks/use-dialog-state'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar'
|
||||
import { SignOutDialog } from '@/components/sign-out-dialog'
|
||||
|
||||
type NavUserProps = {
|
||||
user: {
|
||||
name: string
|
||||
email: string
|
||||
avatar: string
|
||||
}
|
||||
}
|
||||
|
||||
export function NavUser({ user }: NavUserProps) {
|
||||
const { isMobile } = useSidebar()
|
||||
const [open, setOpen] = useDialogState()
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size='lg'
|
||||
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
|
||||
>
|
||||
<Avatar className='h-8 w-8 rounded-lg'>
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className='rounded-lg'>SN</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className='grid flex-1 text-start text-sm leading-tight'>
|
||||
<span className='truncate font-semibold'>{user.name}</span>
|
||||
<span className='truncate text-xs'>{user.email}</span>
|
||||
</div>
|
||||
<ChevronsUpDown className='ms-auto size-4' />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className='w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg'
|
||||
side={isMobile ? 'bottom' : 'right'}
|
||||
align='end'
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className='p-0 font-normal'>
|
||||
<div className='flex items-center gap-2 px-1 py-1.5 text-start text-sm'>
|
||||
<Avatar className='h-8 w-8 rounded-lg'>
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback className='rounded-lg'>SN</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className='grid flex-1 text-start text-sm leading-tight'>
|
||||
<span className='truncate font-semibold'>{user.name}</span>
|
||||
<span className='truncate text-xs'>{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<Sparkles />
|
||||
Upgrade to Pro
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to='/settings/account'>
|
||||
<BadgeCheck />
|
||||
Account
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to='/settings'>
|
||||
<CreditCard />
|
||||
Billing
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to='/settings/notifications'>
|
||||
<Bell />
|
||||
Notifications
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant='destructive'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<LogOut />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
|
||||
<SignOutDialog open={!!open} onOpenChange={setOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import * as React from 'react'
|
||||
import { ChevronsUpDown, Plus } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar'
|
||||
|
||||
type TeamSwitcherProps = {
|
||||
teams: {
|
||||
name: string
|
||||
logo: React.ElementType
|
||||
plan: string
|
||||
}[]
|
||||
}
|
||||
|
||||
export function TeamSwitcher({ teams }: TeamSwitcherProps) {
|
||||
const { isMobile } = useSidebar()
|
||||
const [activeTeam, setActiveTeam] = React.useState(teams[0])
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size='lg'
|
||||
className='data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground'
|
||||
>
|
||||
<div className='flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground'>
|
||||
<activeTeam.logo className='size-4' />
|
||||
</div>
|
||||
<div className='grid flex-1 text-start text-sm leading-tight'>
|
||||
<span className='truncate font-semibold'>
|
||||
{activeTeam.name}
|
||||
</span>
|
||||
<span className='truncate text-xs'>{activeTeam.plan}</span>
|
||||
</div>
|
||||
<ChevronsUpDown className='ms-auto' />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className='w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg'
|
||||
align='start'
|
||||
side={isMobile ? 'bottom' : 'right'}
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className='text-xs text-muted-foreground'>
|
||||
Teams
|
||||
</DropdownMenuLabel>
|
||||
{teams.map((team, index) => (
|
||||
<DropdownMenuItem
|
||||
key={team.name}
|
||||
onClick={() => setActiveTeam(team)}
|
||||
className='gap-2 p-2'
|
||||
>
|
||||
<div className='flex size-6 items-center justify-center rounded-sm border'>
|
||||
<team.logo className='size-4 shrink-0' />
|
||||
</div>
|
||||
{team.name}
|
||||
<DropdownMenuShortcut>⌘{index + 1}</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className='gap-2 p-2'>
|
||||
<div className='flex size-6 items-center justify-center rounded-md border bg-background'>
|
||||
<Plus className='size-4' />
|
||||
</div>
|
||||
<div className='font-medium text-muted-foreground'>Add team</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Menu } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
type TopNavProps = React.HTMLAttributes<HTMLElement> & {
|
||||
links: {
|
||||
title: string
|
||||
href: string
|
||||
isActive: boolean
|
||||
disabled?: boolean
|
||||
}[]
|
||||
}
|
||||
|
||||
export function TopNav({ className, links, ...props }: TopNavProps) {
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size='icon'
|
||||
variant='outline'
|
||||
className={cn('md:size-7 lg:hidden', className)}
|
||||
>
|
||||
<Menu />
|
||||
<span className='sr-only'>Toggle navigation menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side='bottom' align='start'>
|
||||
{links.map(({ title, href, isActive, disabled }) => (
|
||||
<DropdownMenuItem key={`${title}-${href}`} asChild>
|
||||
<Link
|
||||
to={href}
|
||||
className={!isActive ? 'text-muted-foreground' : ''}
|
||||
disabled={disabled}
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<nav
|
||||
className={cn(
|
||||
'hidden items-center space-x-4 lg:flex lg:space-x-4 xl:space-x-6',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{links.map(({ title, href, isActive, disabled }) => (
|
||||
<Link
|
||||
key={`${title}-${href}`}
|
||||
to={href}
|
||||
disabled={disabled}
|
||||
className={`text-sm font-medium transition-colors hover:text-primary ${isActive ? '' : 'text-muted-foreground'}`}
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { type LinkProps } from '@tanstack/react-router'
|
||||
|
||||
type User = {
|
||||
name: string
|
||||
email: string
|
||||
avatar: string
|
||||
}
|
||||
|
||||
type Team = {
|
||||
name: string
|
||||
logo: React.ElementType
|
||||
plan: string
|
||||
}
|
||||
|
||||
type BaseNavItem = {
|
||||
title: string
|
||||
badge?: string
|
||||
icon?: React.ElementType
|
||||
}
|
||||
|
||||
type NavLink = BaseNavItem & {
|
||||
url: LinkProps['to'] | (string & {})
|
||||
items?: never
|
||||
}
|
||||
|
||||
type NavCollapsible = BaseNavItem & {
|
||||
items: (BaseNavItem & { url: LinkProps['to'] | (string & {}) })[]
|
||||
url?: never
|
||||
}
|
||||
|
||||
type NavItem = NavCollapsible | NavLink
|
||||
|
||||
type NavGroup = {
|
||||
title: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
type SidebarData = {
|
||||
user: User
|
||||
teams: Team[]
|
||||
navGroups: NavGroup[]
|
||||
}
|
||||
|
||||
export type { SidebarData, NavGroup, NavItem, NavCollapsible, NavLink }
|
||||
@@ -0,0 +1,44 @@
|
||||
import { type Root, type Content, type Trigger } from '@radix-ui/react-popover'
|
||||
import { CircleQuestionMark } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
|
||||
type LearnMoreProps = React.ComponentProps<typeof Root> & {
|
||||
contentProps?: React.ComponentProps<typeof Content>
|
||||
triggerProps?: React.ComponentProps<typeof Trigger>
|
||||
}
|
||||
|
||||
export function LearnMore({
|
||||
children,
|
||||
contentProps,
|
||||
triggerProps,
|
||||
...props
|
||||
}: LearnMoreProps) {
|
||||
return (
|
||||
<Popover {...props}>
|
||||
<PopoverTrigger
|
||||
asChild
|
||||
{...triggerProps}
|
||||
className={cn('size-5 rounded-full', triggerProps?.className)}
|
||||
>
|
||||
<Button variant='outline' size='icon'>
|
||||
<span className='sr-only'>Learn more</span>
|
||||
<CircleQuestionMark className='size-4 [&>circle]:hidden' />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side='top'
|
||||
align='start'
|
||||
{...contentProps}
|
||||
className={cn('text-sm text-muted-foreground', contentProps?.className)}
|
||||
>
|
||||
{children}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
|
||||
type LongTextProps = {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
contentClassName?: string
|
||||
}
|
||||
|
||||
export function LongText({
|
||||
children,
|
||||
className = '',
|
||||
contentClassName = '',
|
||||
}: LongTextProps) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [isOverflown, setIsOverflown] = useState(false)
|
||||
|
||||
// Use ref callback to check overflow when element is mounted
|
||||
const refCallback = (node: HTMLDivElement | null) => {
|
||||
ref.current = node
|
||||
if (node && checkOverflow(node)) {
|
||||
queueMicrotask(() => setIsOverflown(true))
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOverflown)
|
||||
return (
|
||||
<div ref={refCallback} className={cn('truncate', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='hidden sm:block'>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div ref={refCallback} className={cn('truncate', className)}>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className={contentClassName}>{children}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className='sm:hidden'>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div ref={refCallback} className={cn('truncate', className)}>
|
||||
{children}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className={cn('w-fit', contentClassName)}>
|
||||
<p>{children}</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const checkOverflow = (textContainer: HTMLDivElement | null) => {
|
||||
if (textContainer) {
|
||||
return (
|
||||
textContainer.offsetHeight < textContainer.scrollHeight ||
|
||||
textContainer.offsetWidth < textContainer.scrollWidth
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useRouterState } from '@tanstack/react-router'
|
||||
import LoadingBar, { type LoadingBarRef } from 'react-top-loading-bar'
|
||||
|
||||
export function NavigationProgress() {
|
||||
const ref = useRef<LoadingBarRef>(null)
|
||||
const state = useRouterState()
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === 'pending') {
|
||||
ref.current?.continuousStart()
|
||||
} else {
|
||||
ref.current?.complete()
|
||||
}
|
||||
}, [state.status])
|
||||
|
||||
return (
|
||||
<LoadingBar
|
||||
color='var(--muted-foreground)'
|
||||
ref={ref}
|
||||
shadow={true}
|
||||
height={2}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as React from 'react'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from './ui/button'
|
||||
|
||||
type PasswordInputProps = Omit<
|
||||
React.InputHTMLAttributes<HTMLInputElement>,
|
||||
'type'
|
||||
> & {
|
||||
ref?: React.Ref<HTMLInputElement>
|
||||
}
|
||||
|
||||
export function PasswordInput({
|
||||
className,
|
||||
disabled,
|
||||
ref,
|
||||
...props
|
||||
}: PasswordInputProps) {
|
||||
const [showPassword, setShowPassword] = React.useState(false)
|
||||
|
||||
return (
|
||||
<div className={cn('relative rounded-md', className)}>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
className='flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50'
|
||||
ref={ref}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
size='icon'
|
||||
variant='ghost'
|
||||
disabled={disabled}
|
||||
className='absolute inset-e-1 top-1/2 h-6 w-6 -translate-y-1/2 rounded-md text-muted-foreground'
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
>
|
||||
{showPassword ? <Eye size={18} /> : <EyeOff size={18} />}
|
||||
<span className='sr-only'>
|
||||
{showPassword ? 'Hide password' : 'Show password'}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import useDialogState from '@/hooks/use-dialog-state'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { SignOutDialog } from '@/components/sign-out-dialog'
|
||||
|
||||
export function ProfileDropdown() {
|
||||
const [open, setOpen] = useDialogState()
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='ghost' className='relative h-8 w-8 rounded-full'>
|
||||
<Avatar className='h-8 w-8'>
|
||||
<AvatarImage src='/avatars/01.png' alt='@shadcn' />
|
||||
<AvatarFallback>SN</AvatarFallback>
|
||||
</Avatar>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className='w-56' align='end' forceMount>
|
||||
<DropdownMenuLabel className='font-normal'>
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<p className='text-sm leading-none font-medium'>satnaing</p>
|
||||
<p className='text-xs leading-none text-muted-foreground'>
|
||||
satnaingdev@gmail.com
|
||||
</p>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to='/settings'>
|
||||
Profile
|
||||
<DropdownMenuShortcut>⇧⌘P</DropdownMenuShortcut>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to='/settings'>
|
||||
Billing
|
||||
<DropdownMenuShortcut>⌘B</DropdownMenuShortcut>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to='/settings'>
|
||||
Settings
|
||||
<DropdownMenuShortcut>⌘S</DropdownMenuShortcut>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>New Team</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant='destructive' onClick={() => setOpen(true)}>
|
||||
Sign out
|
||||
<DropdownMenuShortcut className='text-current'>
|
||||
⇧⌘Q
|
||||
</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<SignOutDialog open={!!open} onOpenChange={setOpen} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { SearchIcon } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSearch } from '@/context/search-provider'
|
||||
import { Button } from './ui/button'
|
||||
|
||||
export function Search({
|
||||
className = '',
|
||||
placeholder = 'Search',
|
||||
...props
|
||||
}: React.ComponentProps<'button'> & { placeholder?: string }) {
|
||||
const { setOpen } = useSearch()
|
||||
return (
|
||||
<Button
|
||||
{...props}
|
||||
variant='outline'
|
||||
className={cn(
|
||||
'group relative h-8 w-full flex-1 justify-start rounded-md bg-muted/25 text-sm font-normal text-muted-foreground shadow-none hover:bg-accent sm:w-40 sm:pe-12 md:flex-none lg:w-52 xl:w-64',
|
||||
className
|
||||
)}
|
||||
aria-keyshortcuts='Meta+K Control+K'
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<SearchIcon
|
||||
aria-hidden='true'
|
||||
className='absolute inset-s-1.5 top-1/2 -translate-y-1/2'
|
||||
size={16}
|
||||
/>
|
||||
<span className='ms-4'>{placeholder}</span>
|
||||
<kbd className='pointer-events-none absolute inset-e-[0.3rem] top-[0.3rem] hidden h-5 items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 select-none group-hover:bg-accent sm:flex'>
|
||||
<span className='text-xs'>⌘</span>K
|
||||
</kbd>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Loader } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { FormControl } from '@/components/ui/form'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
|
||||
type SelectDropdownProps = {
|
||||
onValueChange?: (value: string) => void
|
||||
defaultValue: string | undefined
|
||||
placeholder?: string
|
||||
isPending?: boolean
|
||||
items: { label: string; value: string }[] | undefined
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
isControlled?: boolean
|
||||
}
|
||||
|
||||
export function SelectDropdown({
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
isPending,
|
||||
items,
|
||||
placeholder,
|
||||
disabled,
|
||||
className = '',
|
||||
isControlled = false,
|
||||
}: SelectDropdownProps) {
|
||||
const defaultState = isControlled
|
||||
? { value: defaultValue, onValueChange }
|
||||
: { defaultValue, onValueChange }
|
||||
return (
|
||||
<Select {...defaultState}>
|
||||
<FormControl>
|
||||
<SelectTrigger disabled={disabled} className={cn(className)}>
|
||||
<SelectValue placeholder={placeholder ?? 'Select'} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{isPending ? (
|
||||
<SelectItem disabled value='loading' className='h-14'>
|
||||
<div className='flex items-center justify-center gap-2'>
|
||||
<Loader className='h-5 w-5 animate-spin' />
|
||||
{' '}
|
||||
Loading...
|
||||
</div>
|
||||
</SelectItem>
|
||||
) : (
|
||||
items?.map(({ label, value }) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useNavigate, useLocation } from '@tanstack/react-router'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
|
||||
interface SignOutDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { auth } = useAuthStore()
|
||||
|
||||
const handleSignOut = () => {
|
||||
auth.reset()
|
||||
// Preserve current location for redirect after sign-in
|
||||
const currentPath = location.href
|
||||
navigate({
|
||||
to: '/sign-in',
|
||||
search: { redirect: currentPath },
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title='Sign out'
|
||||
desc='Are you sure you want to sign out? You will need to sign in again to access your account.'
|
||||
confirmText='Sign out'
|
||||
destructive
|
||||
handleConfirm={handleSignOut}
|
||||
className='sm:max-w-sm'
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function SkipToMain() {
|
||||
return (
|
||||
<a
|
||||
className={`fixed inset-s-44 z-999 -translate-y-52 bg-primary px-4 py-2 text-sm font-medium whitespace-nowrap text-primary-foreground opacity-95 shadow-sm transition hover:bg-primary/90 focus:translate-y-3 focus:transform focus-visible:ring-1 focus-visible:ring-ring`}
|
||||
href='#content'
|
||||
>
|
||||
Skip to Main
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Check, Moon, Sun } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useTheme } from '@/context/theme-provider'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
|
||||
export function ThemeSwitch() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
/* Update theme-color meta tag
|
||||
* when theme is updated */
|
||||
useEffect(() => {
|
||||
const themeColor = theme === 'dark' ? '#020817' : '#fff'
|
||||
const metaThemeColor = document.querySelector("meta[name='theme-color']")
|
||||
if (metaThemeColor) metaThemeColor.setAttribute('content', themeColor)
|
||||
}, [theme])
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='ghost' size='icon' className='scale-95 rounded-full'>
|
||||
<Sun className='size-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90' />
|
||||
<Moon className='absolute size-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0' />
|
||||
<span className='sr-only'>Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem onClick={() => setTheme('light')}>
|
||||
Light{' '}
|
||||
<Check
|
||||
size={14}
|
||||
className={cn('ms-auto', theme !== 'light' && 'hidden')}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('dark')}>
|
||||
Dark
|
||||
<Check
|
||||
size={14}
|
||||
className={cn('ms-auto', theme !== 'dark' && 'hidden')}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('system')}>
|
||||
System
|
||||
<Check
|
||||
size={14}
|
||||
className={cn('ms-auto', theme !== 'system' && 'hidden')}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -1,67 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -1,103 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -1,22 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -1,53 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -1,192 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
data-align-trigger={position === "item-aligned"}
|
||||
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
data-position={position}
|
||||
className={cn(
|
||||
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
|
||||
position === "popper" && ""
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* List of available font names (visit the url `/settings/appearance`).
|
||||
* This array is used to generate dynamic font classes (e.g., `font-inter`, `font-manrope`).
|
||||
*
|
||||
* 📝 How to Add a New Font (Tailwind v4+):
|
||||
* 1. Add the font name here.
|
||||
* 2. Update the `<link>` tag in 'index.html' to include the new font from Google Fonts (or any other source).
|
||||
* 3. Add the new font family to 'index.css' using the `@theme inline` and `font-family` CSS variable.
|
||||
*
|
||||
* Example:
|
||||
* fonts.ts → Add 'roboto' to this array.
|
||||
* index.html → Add Google Fonts link for Roboto.
|
||||
* index.css → Add the new font in the CSS, e.g.:
|
||||
* @theme inline {
|
||||
* // ... other font families
|
||||
* --font-roboto: 'Roboto', var(--font-sans);
|
||||
* }
|
||||
*/
|
||||
export const fonts = ['inter', 'manrope', 'system'] as const
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { DirectionProvider as RdxDirProvider } from '@radix-ui/react-direction'
|
||||
import { getCookie, setCookie, removeCookie } from '@/lib/cookies'
|
||||
|
||||
export type Direction = 'ltr' | 'rtl'
|
||||
|
||||
const DEFAULT_DIRECTION = 'ltr'
|
||||
const DIRECTION_COOKIE_NAME = 'dir'
|
||||
const DIRECTION_COOKIE_MAX_AGE = 60 * 60 * 24 * 365 // 1 year
|
||||
|
||||
type DirectionContextType = {
|
||||
defaultDir: Direction
|
||||
dir: Direction
|
||||
setDir: (dir: Direction) => void
|
||||
resetDir: () => void
|
||||
}
|
||||
|
||||
const DirectionContext = createContext<DirectionContextType | null>(null)
|
||||
|
||||
export function DirectionProvider({ children }: { children: React.ReactNode }) {
|
||||
const [dir, _setDir] = useState<Direction>(
|
||||
() => (getCookie(DIRECTION_COOKIE_NAME) as Direction) || DEFAULT_DIRECTION
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const htmlElement = document.documentElement
|
||||
htmlElement.setAttribute('dir', dir)
|
||||
}, [dir])
|
||||
|
||||
const setDir = (dir: Direction) => {
|
||||
_setDir(dir)
|
||||
setCookie(DIRECTION_COOKIE_NAME, dir, DIRECTION_COOKIE_MAX_AGE)
|
||||
}
|
||||
|
||||
const resetDir = () => {
|
||||
_setDir(DEFAULT_DIRECTION)
|
||||
removeCookie(DIRECTION_COOKIE_NAME)
|
||||
}
|
||||
|
||||
return (
|
||||
<DirectionContext
|
||||
value={{
|
||||
defaultDir: DEFAULT_DIRECTION,
|
||||
dir,
|
||||
setDir,
|
||||
resetDir,
|
||||
}}
|
||||
>
|
||||
<RdxDirProvider dir={dir}>{children}</RdxDirProvider>
|
||||
</DirectionContext>
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useDirection() {
|
||||
const context = useContext(DirectionContext)
|
||||
if (!context) {
|
||||
throw new Error('useDirection must be used within a DirectionProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { fonts } from '@/config/fonts'
|
||||
import { getCookie, setCookie, removeCookie } from '@/lib/cookies'
|
||||
|
||||
type Font = (typeof fonts)[number]
|
||||
|
||||
const FONT_COOKIE_NAME = 'font'
|
||||
const FONT_COOKIE_MAX_AGE = 60 * 60 * 24 * 365 // 1 year
|
||||
|
||||
type FontContextType = {
|
||||
font: Font
|
||||
setFont: (font: Font) => void
|
||||
resetFont: () => void
|
||||
}
|
||||
|
||||
const FontContext = createContext<FontContextType | null>(null)
|
||||
|
||||
export function FontProvider({ children }: { children: React.ReactNode }) {
|
||||
const [font, _setFont] = useState<Font>(() => {
|
||||
const savedFont = getCookie(FONT_COOKIE_NAME)
|
||||
return fonts.includes(savedFont as Font) ? (savedFont as Font) : fonts[0]
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const applyFont = (font: string) => {
|
||||
const root = document.documentElement
|
||||
root.classList.forEach((cls) => {
|
||||
if (cls.startsWith('font-')) root.classList.remove(cls)
|
||||
})
|
||||
root.classList.add(`font-${font}`)
|
||||
}
|
||||
|
||||
applyFont(font)
|
||||
}, [font])
|
||||
|
||||
const setFont = (font: Font) => {
|
||||
setCookie(FONT_COOKIE_NAME, font, FONT_COOKIE_MAX_AGE)
|
||||
_setFont(font)
|
||||
}
|
||||
|
||||
const resetFont = () => {
|
||||
removeCookie(FONT_COOKIE_NAME)
|
||||
_setFont(fonts[0])
|
||||
}
|
||||
|
||||
return (
|
||||
<FontContext value={{ font, setFont, resetFont }}>{children}</FontContext>
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const useFont = () => {
|
||||
const context = useContext(FontContext)
|
||||
if (!context) {
|
||||
throw new Error('useFont must be used within a FontProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { createContext, useContext, useState } from 'react'
|
||||
import { getCookie, setCookie } from '@/lib/cookies'
|
||||
|
||||
export type Collapsible = 'offcanvas' | 'icon' | 'none'
|
||||
type Variant = 'inset' | 'sidebar' | 'floating'
|
||||
|
||||
// Cookie constants following the pattern from sidebar.tsx
|
||||
const LAYOUT_COLLAPSIBLE_COOKIE_NAME = 'layout_collapsible'
|
||||
const LAYOUT_VARIANT_COOKIE_NAME = 'layout_variant'
|
||||
const LAYOUT_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 // 7 days
|
||||
|
||||
// Default values
|
||||
const DEFAULT_VARIANT = 'inset'
|
||||
const DEFAULT_COLLAPSIBLE = 'icon'
|
||||
|
||||
type LayoutContextType = {
|
||||
resetLayout: () => void
|
||||
|
||||
defaultCollapsible: Collapsible
|
||||
collapsible: Collapsible
|
||||
setCollapsible: (collapsible: Collapsible) => void
|
||||
|
||||
defaultVariant: Variant
|
||||
variant: Variant
|
||||
setVariant: (variant: Variant) => void
|
||||
}
|
||||
|
||||
const LayoutContext = createContext<LayoutContextType | null>(null)
|
||||
|
||||
type LayoutProviderProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function LayoutProvider({ children }: LayoutProviderProps) {
|
||||
const [collapsible, _setCollapsible] = useState<Collapsible>(() => {
|
||||
const saved = getCookie(LAYOUT_COLLAPSIBLE_COOKIE_NAME)
|
||||
return (saved as Collapsible) || DEFAULT_COLLAPSIBLE
|
||||
})
|
||||
|
||||
const [variant, _setVariant] = useState<Variant>(() => {
|
||||
const saved = getCookie(LAYOUT_VARIANT_COOKIE_NAME)
|
||||
return (saved as Variant) || DEFAULT_VARIANT
|
||||
})
|
||||
|
||||
const setCollapsible = (newCollapsible: Collapsible) => {
|
||||
_setCollapsible(newCollapsible)
|
||||
setCookie(
|
||||
LAYOUT_COLLAPSIBLE_COOKIE_NAME,
|
||||
newCollapsible,
|
||||
LAYOUT_COOKIE_MAX_AGE
|
||||
)
|
||||
}
|
||||
|
||||
const setVariant = (newVariant: Variant) => {
|
||||
_setVariant(newVariant)
|
||||
setCookie(LAYOUT_VARIANT_COOKIE_NAME, newVariant, LAYOUT_COOKIE_MAX_AGE)
|
||||
}
|
||||
|
||||
const resetLayout = () => {
|
||||
setCollapsible(DEFAULT_COLLAPSIBLE)
|
||||
setVariant(DEFAULT_VARIANT)
|
||||
}
|
||||
|
||||
const contextValue: LayoutContextType = {
|
||||
resetLayout,
|
||||
defaultCollapsible: DEFAULT_COLLAPSIBLE,
|
||||
collapsible,
|
||||
setCollapsible,
|
||||
defaultVariant: DEFAULT_VARIANT,
|
||||
variant,
|
||||
setVariant,
|
||||
}
|
||||
|
||||
return <LayoutContext value={contextValue}>{children}</LayoutContext>
|
||||
}
|
||||
|
||||
// Define the hook for the provider
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useLayout() {
|
||||
const context = useContext(LayoutContext)
|
||||
if (!context) {
|
||||
throw new Error('useLayout must be used within a LayoutProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { CommandMenu } from '@/components/command-menu'
|
||||
|
||||
type SearchContextType = {
|
||||
open: boolean
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
const SearchContext = createContext<SearchContextType | null>(null)
|
||||
|
||||
type SearchProviderProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function SearchProvider({ children }: SearchProviderProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
setOpen((open) => !open)
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', down)
|
||||
return () => document.removeEventListener('keydown', down)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<SearchContext value={{ open, setOpen }}>
|
||||
{children}
|
||||
<CommandMenu />
|
||||
</SearchContext>
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const useSearch = () => {
|
||||
const searchContext = useContext(SearchContext)
|
||||
|
||||
if (!searchContext) {
|
||||
throw new Error('useSearch has to be used within SearchProvider')
|
||||
}
|
||||
|
||||
return searchContext
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { createContext, useContext, useEffect, useState, useMemo } from 'react'
|
||||
import { getCookie, setCookie, removeCookie } from '@/lib/cookies'
|
||||
|
||||
type Theme = 'dark' | 'light' | 'system'
|
||||
type ResolvedTheme = Exclude<Theme, 'system'>
|
||||
|
||||
const DEFAULT_THEME = 'system'
|
||||
const THEME_COOKIE_NAME = 'vite-ui-theme'
|
||||
const THEME_COOKIE_MAX_AGE = 60 * 60 * 24 * 365 // 1 year
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode
|
||||
defaultTheme?: Theme
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
type ThemeProviderState = {
|
||||
defaultTheme: Theme
|
||||
resolvedTheme: ResolvedTheme
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
resetTheme: () => void
|
||||
}
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
defaultTheme: DEFAULT_THEME,
|
||||
resolvedTheme: 'light',
|
||||
theme: DEFAULT_THEME,
|
||||
setTheme: () => null,
|
||||
resetTheme: () => null,
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeProviderState>(initialState)
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = DEFAULT_THEME,
|
||||
storageKey = THEME_COOKIE_NAME,
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, _setTheme] = useState<Theme>(
|
||||
() => (getCookie(storageKey) as Theme) || defaultTheme
|
||||
)
|
||||
|
||||
// Optimized: Memoize the resolved theme calculation to prevent unnecessary re-computations
|
||||
const resolvedTheme = useMemo((): ResolvedTheme => {
|
||||
if (theme === 'system') {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
}
|
||||
return theme as ResolvedTheme
|
||||
}, [theme])
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
|
||||
const applyTheme = (currentResolvedTheme: ResolvedTheme) => {
|
||||
root.classList.remove('light', 'dark') // Remove existing theme classes
|
||||
root.classList.add(currentResolvedTheme) // Add the new theme class
|
||||
}
|
||||
|
||||
const handleChange = () => {
|
||||
if (theme === 'system') {
|
||||
const systemTheme = mediaQuery.matches ? 'dark' : 'light'
|
||||
applyTheme(systemTheme)
|
||||
}
|
||||
}
|
||||
|
||||
applyTheme(resolvedTheme)
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange)
|
||||
|
||||
return () => mediaQuery.removeEventListener('change', handleChange)
|
||||
}, [theme, resolvedTheme])
|
||||
|
||||
const setTheme = (theme: Theme) => {
|
||||
setCookie(storageKey, theme, THEME_COOKIE_MAX_AGE)
|
||||
_setTheme(theme)
|
||||
}
|
||||
|
||||
const resetTheme = () => {
|
||||
removeCookie(storageKey)
|
||||
_setTheme(DEFAULT_THEME)
|
||||
}
|
||||
|
||||
const contextValue = {
|
||||
defaultTheme,
|
||||
resolvedTheme,
|
||||
resetTheme,
|
||||
theme,
|
||||
setTheme,
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext value={contextValue} {...props}>
|
||||
{children}
|
||||
</ThemeContext>
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext)
|
||||
|
||||
if (!context) throw new Error('useTheme must be used within a ThemeProvider')
|
||||
|
||||
return context
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Logo } from '@/assets/logo'
|
||||
|
||||
type AuthLayoutProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function AuthLayout({ children }: AuthLayoutProps) {
|
||||
return (
|
||||
<div className='container grid h-svh max-w-none items-center justify-center'>
|
||||
<div className='mx-auto flex w-full flex-col justify-center space-y-2 py-8 sm:p-8'>
|
||||
<div className='mb-4 flex items-center justify-center'>
|
||||
<Logo className='me-2' />
|
||||
<h1 className='text-xl font-medium'>Shadcn Admin</h1>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { ArrowRight, Loader2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { sleep, cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.email({
|
||||
error: (iss) => (iss.input === '' ? 'Please enter your email.' : undefined),
|
||||
}),
|
||||
})
|
||||
|
||||
export function ForgotPasswordForm({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLFormElement>) {
|
||||
const navigate = useNavigate()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: { email: '' },
|
||||
})
|
||||
|
||||
function onSubmit(data: z.infer<typeof formSchema>) {
|
||||
setIsLoading(true)
|
||||
|
||||
toast.promise(sleep(2000), {
|
||||
loading: 'Sending email...',
|
||||
success: () => {
|
||||
setIsLoading(false)
|
||||
form.reset()
|
||||
navigate({ to: '/otp' })
|
||||
return `Email sent to ${data.email}`
|
||||
},
|
||||
error: 'Error',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className={cn('grid gap-2', className)}
|
||||
{...props}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='name@example.com' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button className='mt-2' disabled={isLoading}>
|
||||
Continue
|
||||
{isLoading ? <Loader2 className='animate-spin' /> : <ArrowRight />}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { AuthLayout } from '../auth-layout'
|
||||
import { ForgotPasswordForm } from './components/forgot-password-form'
|
||||
|
||||
export function ForgotPassword() {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<Card className='max-w-sm gap-4 sm:min-w-sm'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-lg tracking-tight'>
|
||||
Forgot Password
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your registered email and <br /> we will send you a link to
|
||||
reset your password.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ForgotPasswordForm />
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<p className='mx-auto px-8 text-center text-sm text-balance text-muted-foreground'>
|
||||
Don't have an account?{' '}
|
||||
<Link
|
||||
to='/sign-up'
|
||||
className='underline underline-offset-4 hover:text-primary'
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { showSubmittedData } from '@/lib/show-submitted-data'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
InputOTPSeparator,
|
||||
} from '@/components/ui/input-otp'
|
||||
|
||||
const formSchema = z.object({
|
||||
otp: z
|
||||
.string()
|
||||
.min(6, 'Please enter the 6-digit code.')
|
||||
.max(6, 'Please enter the 6-digit code.'),
|
||||
})
|
||||
|
||||
type OtpFormProps = React.HTMLAttributes<HTMLFormElement>
|
||||
|
||||
export function OtpForm({ className, ...props }: OtpFormProps) {
|
||||
const navigate = useNavigate()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: { otp: '' },
|
||||
})
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const otp = form.watch('otp')
|
||||
|
||||
function onSubmit(data: z.infer<typeof formSchema>) {
|
||||
setIsLoading(true)
|
||||
showSubmittedData(data)
|
||||
|
||||
setTimeout(() => {
|
||||
setIsLoading(false)
|
||||
navigate({ to: '/' })
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className={cn('grid gap-2', className)}
|
||||
{...props}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='otp'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='sr-only'>One-Time Password</FormLabel>
|
||||
<FormControl>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
{...field}
|
||||
containerClassName='justify-between sm:[&>[data-slot="input-otp-group"]>div]:w-12'
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button className='mt-2' disabled={otp.length < 6 || isLoading}>
|
||||
Verify
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { AuthLayout } from '../auth-layout'
|
||||
import { OtpForm } from './components/otp-form'
|
||||
|
||||
export function Otp() {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<Card className='max-w-md gap-4'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-base tracking-tight'>
|
||||
Two-factor Authentication
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Please enter the authentication code. <br /> We have sent the
|
||||
authentication code to your email.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<OtpForm />
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<p className='px-8 text-center text-sm text-muted-foreground'>
|
||||
Haven't received it?{' '}
|
||||
<Link
|
||||
to='/sign-in'
|
||||
className='underline underline-offset-4 hover:text-primary'
|
||||
>
|
||||
Resend a new code.
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
|
After Width: | Height: | Size: 451 KiB |
|
After Width: | Height: | Size: 440 KiB |
@@ -0,0 +1,150 @@
|
||||
import { useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { Loader2, LogIn } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { IconFacebook, IconGithub } from '@/assets/brand-icons'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { sleep, cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { PasswordInput } from '@/components/password-input'
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.email({
|
||||
error: (iss) => (iss.input === '' ? 'Please enter your email.' : undefined),
|
||||
}),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, 'Please enter your password.')
|
||||
.min(7, 'Password must be at least 7 characters long.'),
|
||||
})
|
||||
|
||||
interface UserAuthFormProps extends React.HTMLAttributes<HTMLFormElement> {
|
||||
redirectTo?: string
|
||||
}
|
||||
|
||||
export function UserAuthForm({
|
||||
className,
|
||||
redirectTo,
|
||||
...props
|
||||
}: UserAuthFormProps) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const { auth } = useAuthStore()
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
})
|
||||
|
||||
function onSubmit(data: z.infer<typeof formSchema>) {
|
||||
setIsLoading(true)
|
||||
|
||||
toast.promise(sleep(2000), {
|
||||
loading: 'Signing in...',
|
||||
success: () => {
|
||||
setIsLoading(false)
|
||||
|
||||
// Mock successful authentication with expiry computed at success time
|
||||
const mockUser = {
|
||||
accountNo: 'ACC001',
|
||||
email: data.email,
|
||||
role: ['user'],
|
||||
exp: Date.now() + 24 * 60 * 60 * 1000, // 24 hours from now
|
||||
}
|
||||
|
||||
// Set user and access token
|
||||
auth.setUser(mockUser)
|
||||
auth.setAccessToken('mock-access-token')
|
||||
|
||||
// Redirect to the stored location or default to dashboard
|
||||
const targetPath = redirectTo || '/'
|
||||
navigate({ to: targetPath, replace: true })
|
||||
|
||||
return `Welcome back, ${data.email}!`
|
||||
},
|
||||
error: 'Error',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className={cn('grid gap-3', className)}
|
||||
{...props}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='name@example.com' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem className='relative'>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder='********' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<Link
|
||||
to='/forgot-password'
|
||||
className='absolute inset-e-0 -top-0.5 text-sm font-medium text-muted-foreground hover:opacity-75'
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button className='mt-2' disabled={isLoading}>
|
||||
{isLoading ? <Loader2 className='animate-spin' /> : <LogIn />}
|
||||
Sign in
|
||||
</Button>
|
||||
|
||||
<div className='relative my-2'>
|
||||
<div className='absolute inset-0 flex items-center'>
|
||||
<span className='w-full border-t' />
|
||||
</div>
|
||||
<div className='relative flex justify-center text-xs uppercase'>
|
||||
<span className='bg-background px-2 text-muted-foreground'>
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
<Button variant='outline' type='button' disabled={isLoading}>
|
||||
<IconGithub className='h-4 w-4' /> GitHub
|
||||
</Button>
|
||||
<Button variant='outline' type='button' disabled={isLoading}>
|
||||
<IconFacebook className='h-4 w-4' /> Facebook
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Link, useSearch } from '@tanstack/react-router'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { AuthLayout } from '../auth-layout'
|
||||
import { UserAuthForm } from './components/user-auth-form'
|
||||
|
||||
export function SignIn() {
|
||||
const { redirect } = useSearch({ from: '/(auth)/sign-in' })
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<Card className='max-w-sm gap-4'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-lg tracking-tight'>Sign in</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email and password below to log into{' '}
|
||||
<br className='max-sm:hidden' /> your account. Don't have an
|
||||
account?{' '}
|
||||
<Link
|
||||
to='/sign-up'
|
||||
className='text-nowrap underline underline-offset-4 hover:text-primary'
|
||||
>
|
||||
Sign Up
|
||||
</Link>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<UserAuthForm redirectTo={redirect} />
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<p className='px-8 text-center text-sm text-muted-foreground'>
|
||||
By clicking sign in, you agree to our{' '}
|
||||
<a
|
||||
href='/terms'
|
||||
className='underline underline-offset-4 hover:text-primary'
|
||||
>
|
||||
Terms of Service
|
||||
</a>{' '}
|
||||
and{' '}
|
||||
<a
|
||||
href='/privacy'
|
||||
className='underline underline-offset-4 hover:text-primary'
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Logo } from '@/assets/logo'
|
||||
import { cn } from '@/lib/utils'
|
||||
import dashboardDark from './assets/dashboard-dark.png'
|
||||
import dashboardLight from './assets/dashboard-light.png'
|
||||
import { UserAuthForm } from './components/user-auth-form'
|
||||
|
||||
export function SignIn2() {
|
||||
return (
|
||||
<div className='relative container grid h-svh flex-col items-center justify-center lg:max-w-none lg:grid-cols-2 lg:px-0'>
|
||||
<div className='lg:p-8'>
|
||||
<div className='mx-auto flex w-full flex-col justify-center space-y-2 py-8 sm:w-120 sm:p-8'>
|
||||
<div className='mb-4 flex items-center justify-center'>
|
||||
<Logo className='me-2' />
|
||||
<h1 className='text-xl font-medium'>Shadcn Admin</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mx-auto flex w-full max-w-sm flex-col justify-center space-y-2'>
|
||||
<div className='flex flex-col space-y-2 text-start'>
|
||||
<h2 className='text-lg font-semibold tracking-tight'>Sign in</h2>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
Enter your email and password below to log into{' '}
|
||||
<br className='max-sm:hidden' /> your account. Don't have an
|
||||
account?{' '}
|
||||
<Link
|
||||
to='/sign-up'
|
||||
className='text-nowrap underline underline-offset-4 hover:text-primary'
|
||||
>
|
||||
Sign Up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<UserAuthForm />
|
||||
<p className='px-8 text-center text-sm text-muted-foreground'>
|
||||
By clicking sign in, you agree to our{' '}
|
||||
<a
|
||||
href='/terms'
|
||||
className='underline underline-offset-4 hover:text-primary'
|
||||
>
|
||||
Terms of Service
|
||||
</a>{' '}
|
||||
and{' '}
|
||||
<a
|
||||
href='/privacy'
|
||||
className='underline underline-offset-4 hover:text-primary'
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'relative h-full overflow-hidden bg-muted max-lg:hidden',
|
||||
'[&>img]:absolute [&>img]:top-[15%] [&>img]:left-20 [&>img]:h-full [&>img]:w-full [&>img]:object-cover [&>img]:object-top-left [&>img]:select-none'
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={dashboardLight}
|
||||
className='dark:hidden'
|
||||
width={1024}
|
||||
height={1151}
|
||||
alt='Shadcn-Admin'
|
||||
/>
|
||||
<img
|
||||
src={dashboardDark}
|
||||
className='hidden dark:block'
|
||||
width={1024}
|
||||
height={1138}
|
||||
alt='Shadcn-Admin'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Loader2, UserPlus } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { IconFacebook, IconGithub } from '@/assets/brand-icons'
|
||||
import { sleep, cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { PasswordInput } from '@/components/password-input'
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
email: z.email({
|
||||
error: (iss) =>
|
||||
iss.input === '' ? 'Please enter your email.' : undefined,
|
||||
}),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, 'Please enter your password.')
|
||||
.min(7, 'Password must be at least 7 characters long.'),
|
||||
confirmPassword: z.string().min(1, 'Please confirm your password.'),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match.",
|
||||
path: ['confirmPassword'],
|
||||
})
|
||||
|
||||
export function SignUpForm({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLFormElement>) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
},
|
||||
})
|
||||
|
||||
function onSubmit(data: z.infer<typeof formSchema>) {
|
||||
setIsLoading(true)
|
||||
|
||||
toast.promise(sleep(2000), {
|
||||
loading: 'Creating account...',
|
||||
success: () => {
|
||||
setIsLoading(false)
|
||||
return `Account created for ${data.email}.`
|
||||
},
|
||||
error: 'Error',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className={cn('grid gap-3', className)}
|
||||
{...props}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='name@example.com' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder='********' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='confirmPassword'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirm Password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder='********' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button className='mt-2' disabled={isLoading}>
|
||||
{isLoading ? <Loader2 className='animate-spin' /> : <UserPlus />}
|
||||
Create Account
|
||||
</Button>
|
||||
|
||||
<div className='relative my-2'>
|
||||
<div className='absolute inset-0 flex items-center'>
|
||||
<span className='w-full border-t' />
|
||||
</div>
|
||||
<div className='relative flex justify-center text-xs uppercase'>
|
||||
<span className='bg-background px-2 text-muted-foreground'>
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
type='button'
|
||||
disabled={isLoading}
|
||||
>
|
||||
<IconGithub className='h-4 w-4' /> GitHub
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
type='button'
|
||||
disabled={isLoading}
|
||||
>
|
||||
<IconFacebook className='h-4 w-4' /> Facebook
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { AuthLayout } from '../auth-layout'
|
||||
import { SignUpForm } from './components/sign-up-form'
|
||||
|
||||
export function SignUp() {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<Card className='max-w-sm gap-4'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-lg tracking-tight'>
|
||||
Create an account
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email and password to create an account. <br />
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
to='/sign-in'
|
||||
className='underline underline-offset-4 hover:text-primary'
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SignUpForm />
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<p className='px-8 text-center text-sm text-muted-foreground'>
|
||||
By creating an account, you agree to our{' '}
|
||||
<a
|
||||
href='/terms'
|
||||
className='underline underline-offset-4 hover:text-primary'
|
||||
>
|
||||
Terms of Service
|
||||
</a>{' '}
|
||||
and{' '}
|
||||
<a
|
||||
href='/privacy'
|
||||
className='underline underline-offset-4 hover:text-primary'
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||