feat 使用 shadcn-admin

This commit is contained in:
PoRi
2026-04-25 17:09:20 +08:00
parent 88fa2666d2
commit 6cf1681a42
159 changed files with 11459 additions and 2989 deletions
+8 -2
View File
@@ -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>
+68 -17
View File
@@ -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"
}
}
+2934 -2290
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 494 B

+4
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 B

+1
View File
@@ -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

Binary file not shown.

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>
)
}
+16
View File
@@ -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'
+41
View File
@@ -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>
)
}
+23
View File
@@ -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>
)
}
+110
View File
@@ -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>
)
}
+24
View File
@@ -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>
)
}
+24
View File
@@ -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
View File
@@ -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

-90
View File
@@ -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>
)
}
+16
View File
@@ -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>
)
}
+91
View File
@@ -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>
)
}
+362
View File
@@ -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>
)
}
+72
View File
@@ -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>
)
}
+51
View File
@@ -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,
},
],
},
],
},
],
}
+50
View File
@@ -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>
)
}
+27
View File
@@ -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}
/>
)
}
+185
View File
@@ -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])
)
}
+124
View File
@@ -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>
)
}
+70
View File
@@ -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>
</>
)
}
+44
View File
@@ -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 }
+44
View File
@@ -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>
)
}
+84
View File
@@ -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}
/>
)
}
+45
View File
@@ -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} />
</>
)
}
+34
View File
@@ -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>
)
}
+62
View File
@@ -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>
)
}
+38
View File
@@ -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'
/>
)
}
+10
View File
@@ -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>
)
}
+58
View File
@@ -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>
)
}
-49
View File
@@ -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 }
-67
View File
@@ -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 }
-103
View File
@@ -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,
}
-19
View File
@@ -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 }
-22
View File
@@ -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 }
-53
View File
@@ -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 }
-192
View File
@@ -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,
}
-28
View File
@@ -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 }
+19
View File
@@ -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
+61
View File
@@ -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
}
+58
View File
@@ -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
}
+85
View File
@@ -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
}
+46
View File
@@ -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
}
+110
View File
@@ -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
}
+19
View File
@@ -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>
)
}
+44
View File
@@ -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>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

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>
)
}

Some files were not shown because too many files have changed in this diff Show More