feat: implement third-party order sync

This commit is contained in:
2026-06-07 02:05:48 +08:00
commit aa593449bb
398 changed files with 51460 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
VITE_CLERK_PUBLISHABLE_KEY=
+34
View File
@@ -0,0 +1,34 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.env
# Editor directories and files
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Test coverage
/coverage
# Vitest artifacts (browser screenshots/attachments)
**/__screenshots__/
.vitest-attachments/
+19
View File
@@ -0,0 +1,19 @@
# Ignore everything
/*
# Except these files & folders
!/src
!index.html
!package.json
!tailwind.config.js
!tsconfig.json
!tsconfig.node.json
!vite.config.ts
!.prettierrc
!README.md
!eslint.config.js
!postcss.config.js
!.vscode/
# Ignore auto generated routeTree.gen.ts
/src/routeTree.gen.ts
+54
View File
@@ -0,0 +1,54 @@
{
"arrowParens": "always",
"semi": false,
"tabWidth": 2,
"printWidth": 80,
"singleQuote": true,
"jsxSingleQuote": true,
"trailingComma": "es5",
"bracketSpacing": true,
"endOfLine": "lf",
"plugins": [
"@trivago/prettier-plugin-sort-imports",
"prettier-plugin-tailwindcss"
],
"tailwindFunctions": [
"cn",
"clsx"
],
"tailwindStylesheet": "./src/styles/index.css",
"importOrder": [
"^path$",
"^vite$",
"^@vitejs/(.*)$",
"^react$",
"^react-dom/client$",
"^react/(.*)$",
"^globals$",
"^zod$",
"^axios$",
"^date-fns$",
"^react-hook-form$",
"^use-intl$",
"^@radix-ui/(.*)$",
"^@hookform/resolvers/zod$",
"^@tanstack/react-query$",
"^@tanstack/react-router$",
"^@tanstack/react-table$",
"<THIRD_PARTY_MODULES>",
"^@/assets/(.*)",
"^@/api/(.*)$",
"^@/stores/(.*)$",
"^@/lib/(.*)$",
"^@/utils/(.*)$",
"^@/constants/(.*)$",
"^@/context/(.*)$",
"^@/hooks/(.*)$",
"^@/components/layouts/(.*)$",
"^@/components/ui/(.*)$",
"^@/components/errors/(.*)$",
"^@/components/(.*)$",
"^@/features/(.*)$",
"^[./]"
]
}
@@ -0,0 +1,936 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as ClerkRouteRouteImport } from './routes/clerk/route'
import { Route as AuthenticatedRouteRouteImport } from './routes/_authenticated/route'
import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
import { Route as AuthenticatedTicketsRouteImport } from './routes/_authenticated/tickets'
import { Route as AuthenticatedSelfOwnedOrderRouteImport } from './routes/_authenticated/self-owned-order'
import { Route as AuthenticatedOrdersRouteImport } from './routes/_authenticated/orders'
import { Route as AuthenticatedOrderLogsRouteImport } from './routes/_authenticated/order-logs'
import { Route as AuthenticatedCourseQueryRouteImport } from './routes/_authenticated/course-query'
import { Route as errors503RouteImport } from './routes/(errors)/503'
import { Route as errors500RouteImport } from './routes/(errors)/500'
import { Route as errors404RouteImport } from './routes/(errors)/404'
import { Route as errors403RouteImport } from './routes/(errors)/403'
import { Route as errors401RouteImport } from './routes/(errors)/401'
import { Route as authSignUpRouteImport } from './routes/(auth)/sign-up'
import { Route as authSignIn2RouteImport } from './routes/(auth)/sign-in-2'
import { Route as authSignInRouteImport } from './routes/(auth)/sign-in'
import { Route as authOtpRouteImport } from './routes/(auth)/otp'
import { Route as authForgotPasswordRouteImport } from './routes/(auth)/forgot-password'
import { Route as ClerkAuthenticatedRouteRouteImport } from './routes/clerk/_authenticated/route'
import { Route as ClerkauthRouteRouteImport } from './routes/clerk/(auth)/route'
import { Route as AuthenticatedSettingsRouteRouteImport } from './routes/_authenticated/settings/route'
import { Route as AuthenticatedUsersIndexRouteImport } from './routes/_authenticated/users/index'
import { Route as AuthenticatedTasksIndexRouteImport } from './routes/_authenticated/tasks/index'
import { Route as AuthenticatedSettingsIndexRouteImport } from './routes/_authenticated/settings/index'
import { Route as AuthenticatedHelpCenterIndexRouteImport } from './routes/_authenticated/help-center/index'
import { Route as AuthenticatedChatsIndexRouteImport } from './routes/_authenticated/chats/index'
import { Route as AuthenticatedAppsIndexRouteImport } from './routes/_authenticated/apps/index'
import { Route as ClerkAuthenticatedUserManagementRouteImport } from './routes/clerk/_authenticated/user-management'
import { Route as ClerkauthSignUpRouteImport } from './routes/clerk/(auth)/sign-up'
import { Route as ClerkauthSignInRouteImport } from './routes/clerk/(auth)/sign-in'
import { Route as AuthenticatedSystemUsersRouteImport } from './routes/_authenticated/system/users'
import { Route as AuthenticatedSystemThirdPartyRouteImport } from './routes/_authenticated/system/third-party'
import { Route as AuthenticatedSystemAuditLogsRouteImport } from './routes/_authenticated/system/audit-logs'
import { Route as AuthenticatedSettingsNotificationsRouteImport } from './routes/_authenticated/settings/notifications'
import { Route as AuthenticatedSettingsDisplayRouteImport } from './routes/_authenticated/settings/display'
import { Route as AuthenticatedSettingsAppearanceRouteImport } from './routes/_authenticated/settings/appearance'
import { Route as AuthenticatedSettingsAccountRouteImport } from './routes/_authenticated/settings/account'
import { Route as AuthenticatedErrorsErrorRouteImport } from './routes/_authenticated/errors/$error'
import { Route as AuthenticatedCatalogProductsRouteImport } from './routes/_authenticated/catalog/products'
import { Route as AuthenticatedCatalogCategoriesRouteImport } from './routes/_authenticated/catalog/categories'
const ClerkRouteRoute = ClerkRouteRouteImport.update({
id: '/clerk',
path: '/clerk',
getParentRoute: () => rootRouteImport,
} as any)
const AuthenticatedRouteRoute = AuthenticatedRouteRouteImport.update({
id: '/_authenticated',
getParentRoute: () => rootRouteImport,
} as any)
const AuthenticatedIndexRoute = AuthenticatedIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedTicketsRoute = AuthenticatedTicketsRouteImport.update({
id: '/tickets',
path: '/tickets',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedSelfOwnedOrderRoute =
AuthenticatedSelfOwnedOrderRouteImport.update({
id: '/self-owned-order',
path: '/self-owned-order',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedOrdersRoute = AuthenticatedOrdersRouteImport.update({
id: '/orders',
path: '/orders',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedOrderLogsRoute = AuthenticatedOrderLogsRouteImport.update({
id: '/order-logs',
path: '/order-logs',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedCourseQueryRoute =
AuthenticatedCourseQueryRouteImport.update({
id: '/course-query',
path: '/course-query',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const errors503Route = errors503RouteImport.update({
id: '/(errors)/503',
path: '/503',
getParentRoute: () => rootRouteImport,
} as any)
const errors500Route = errors500RouteImport.update({
id: '/(errors)/500',
path: '/500',
getParentRoute: () => rootRouteImport,
} as any)
const errors404Route = errors404RouteImport.update({
id: '/(errors)/404',
path: '/404',
getParentRoute: () => rootRouteImport,
} as any)
const errors403Route = errors403RouteImport.update({
id: '/(errors)/403',
path: '/403',
getParentRoute: () => rootRouteImport,
} as any)
const errors401Route = errors401RouteImport.update({
id: '/(errors)/401',
path: '/401',
getParentRoute: () => rootRouteImport,
} as any)
const authSignUpRoute = authSignUpRouteImport.update({
id: '/(auth)/sign-up',
path: '/sign-up',
getParentRoute: () => rootRouteImport,
} as any)
const authSignIn2Route = authSignIn2RouteImport.update({
id: '/(auth)/sign-in-2',
path: '/sign-in-2',
getParentRoute: () => rootRouteImport,
} as any)
const authSignInRoute = authSignInRouteImport.update({
id: '/(auth)/sign-in',
path: '/sign-in',
getParentRoute: () => rootRouteImport,
} as any)
const authOtpRoute = authOtpRouteImport.update({
id: '/(auth)/otp',
path: '/otp',
getParentRoute: () => rootRouteImport,
} as any)
const authForgotPasswordRoute = authForgotPasswordRouteImport.update({
id: '/(auth)/forgot-password',
path: '/forgot-password',
getParentRoute: () => rootRouteImport,
} as any)
const ClerkAuthenticatedRouteRoute = ClerkAuthenticatedRouteRouteImport.update({
id: '/_authenticated',
getParentRoute: () => ClerkRouteRoute,
} as any)
const ClerkauthRouteRoute = ClerkauthRouteRouteImport.update({
id: '/(auth)',
getParentRoute: () => ClerkRouteRoute,
} as any)
const AuthenticatedSettingsRouteRoute =
AuthenticatedSettingsRouteRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedUsersIndexRoute = AuthenticatedUsersIndexRouteImport.update({
id: '/users/',
path: '/users/',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedTasksIndexRoute = AuthenticatedTasksIndexRouteImport.update({
id: '/tasks/',
path: '/tasks/',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedSettingsIndexRoute =
AuthenticatedSettingsIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => AuthenticatedSettingsRouteRoute,
} as any)
const AuthenticatedHelpCenterIndexRoute =
AuthenticatedHelpCenterIndexRouteImport.update({
id: '/help-center/',
path: '/help-center/',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedChatsIndexRoute = AuthenticatedChatsIndexRouteImport.update({
id: '/chats/',
path: '/chats/',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedAppsIndexRoute = AuthenticatedAppsIndexRouteImport.update({
id: '/apps/',
path: '/apps/',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const ClerkAuthenticatedUserManagementRoute =
ClerkAuthenticatedUserManagementRouteImport.update({
id: '/user-management',
path: '/user-management',
getParentRoute: () => ClerkAuthenticatedRouteRoute,
} as any)
const ClerkauthSignUpRoute = ClerkauthSignUpRouteImport.update({
id: '/sign-up',
path: '/sign-up',
getParentRoute: () => ClerkauthRouteRoute,
} as any)
const ClerkauthSignInRoute = ClerkauthSignInRouteImport.update({
id: '/sign-in',
path: '/sign-in',
getParentRoute: () => ClerkauthRouteRoute,
} as any)
const AuthenticatedSystemUsersRoute =
AuthenticatedSystemUsersRouteImport.update({
id: '/system/users',
path: '/system/users',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedSystemThirdPartyRoute =
AuthenticatedSystemThirdPartyRouteImport.update({
id: '/system/third-party',
path: '/system/third-party',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedSystemAuditLogsRoute =
AuthenticatedSystemAuditLogsRouteImport.update({
id: '/system/audit-logs',
path: '/system/audit-logs',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedSettingsNotificationsRoute =
AuthenticatedSettingsNotificationsRouteImport.update({
id: '/notifications',
path: '/notifications',
getParentRoute: () => AuthenticatedSettingsRouteRoute,
} as any)
const AuthenticatedSettingsDisplayRoute =
AuthenticatedSettingsDisplayRouteImport.update({
id: '/display',
path: '/display',
getParentRoute: () => AuthenticatedSettingsRouteRoute,
} as any)
const AuthenticatedSettingsAppearanceRoute =
AuthenticatedSettingsAppearanceRouteImport.update({
id: '/appearance',
path: '/appearance',
getParentRoute: () => AuthenticatedSettingsRouteRoute,
} as any)
const AuthenticatedSettingsAccountRoute =
AuthenticatedSettingsAccountRouteImport.update({
id: '/account',
path: '/account',
getParentRoute: () => AuthenticatedSettingsRouteRoute,
} as any)
const AuthenticatedErrorsErrorRoute =
AuthenticatedErrorsErrorRouteImport.update({
id: '/errors/$error',
path: '/errors/$error',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedCatalogProductsRoute =
AuthenticatedCatalogProductsRouteImport.update({
id: '/catalog/products',
path: '/catalog/products',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedCatalogCategoriesRoute =
AuthenticatedCatalogCategoriesRouteImport.update({
id: '/catalog/categories',
path: '/catalog/categories',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof AuthenticatedIndexRoute
'/clerk': typeof ClerkauthRouteRouteWithChildren
'/settings': typeof AuthenticatedSettingsRouteRouteWithChildren
'/forgot-password': typeof authForgotPasswordRoute
'/otp': typeof authOtpRoute
'/sign-in': typeof authSignInRoute
'/sign-in-2': typeof authSignIn2Route
'/sign-up': typeof authSignUpRoute
'/401': typeof errors401Route
'/403': typeof errors403Route
'/404': typeof errors404Route
'/500': typeof errors500Route
'/503': typeof errors503Route
'/course-query': typeof AuthenticatedCourseQueryRoute
'/order-logs': typeof AuthenticatedOrderLogsRoute
'/orders': typeof AuthenticatedOrdersRoute
'/self-owned-order': typeof AuthenticatedSelfOwnedOrderRoute
'/tickets': typeof AuthenticatedTicketsRoute
'/catalog/categories': typeof AuthenticatedCatalogCategoriesRoute
'/catalog/products': typeof AuthenticatedCatalogProductsRoute
'/errors/$error': typeof AuthenticatedErrorsErrorRoute
'/settings/account': typeof AuthenticatedSettingsAccountRoute
'/settings/appearance': typeof AuthenticatedSettingsAppearanceRoute
'/settings/display': typeof AuthenticatedSettingsDisplayRoute
'/settings/notifications': typeof AuthenticatedSettingsNotificationsRoute
'/system/audit-logs': typeof AuthenticatedSystemAuditLogsRoute
'/system/third-party': typeof AuthenticatedSystemThirdPartyRoute
'/system/users': typeof AuthenticatedSystemUsersRoute
'/clerk/sign-in': typeof ClerkauthSignInRoute
'/clerk/sign-up': typeof ClerkauthSignUpRoute
'/clerk/user-management': typeof ClerkAuthenticatedUserManagementRoute
'/apps/': typeof AuthenticatedAppsIndexRoute
'/chats/': typeof AuthenticatedChatsIndexRoute
'/help-center/': typeof AuthenticatedHelpCenterIndexRoute
'/settings/': typeof AuthenticatedSettingsIndexRoute
'/tasks/': typeof AuthenticatedTasksIndexRoute
'/users/': typeof AuthenticatedUsersIndexRoute
}
export interface FileRoutesByTo {
'/clerk': typeof ClerkauthRouteRouteWithChildren
'/forgot-password': typeof authForgotPasswordRoute
'/otp': typeof authOtpRoute
'/sign-in': typeof authSignInRoute
'/sign-in-2': typeof authSignIn2Route
'/sign-up': typeof authSignUpRoute
'/401': typeof errors401Route
'/403': typeof errors403Route
'/404': typeof errors404Route
'/500': typeof errors500Route
'/503': typeof errors503Route
'/course-query': typeof AuthenticatedCourseQueryRoute
'/order-logs': typeof AuthenticatedOrderLogsRoute
'/orders': typeof AuthenticatedOrdersRoute
'/self-owned-order': typeof AuthenticatedSelfOwnedOrderRoute
'/tickets': typeof AuthenticatedTicketsRoute
'/': typeof AuthenticatedIndexRoute
'/catalog/categories': typeof AuthenticatedCatalogCategoriesRoute
'/catalog/products': typeof AuthenticatedCatalogProductsRoute
'/errors/$error': typeof AuthenticatedErrorsErrorRoute
'/settings/account': typeof AuthenticatedSettingsAccountRoute
'/settings/appearance': typeof AuthenticatedSettingsAppearanceRoute
'/settings/display': typeof AuthenticatedSettingsDisplayRoute
'/settings/notifications': typeof AuthenticatedSettingsNotificationsRoute
'/system/audit-logs': typeof AuthenticatedSystemAuditLogsRoute
'/system/third-party': typeof AuthenticatedSystemThirdPartyRoute
'/system/users': typeof AuthenticatedSystemUsersRoute
'/clerk/sign-in': typeof ClerkauthSignInRoute
'/clerk/sign-up': typeof ClerkauthSignUpRoute
'/clerk/user-management': typeof ClerkAuthenticatedUserManagementRoute
'/apps': typeof AuthenticatedAppsIndexRoute
'/chats': typeof AuthenticatedChatsIndexRoute
'/help-center': typeof AuthenticatedHelpCenterIndexRoute
'/settings': typeof AuthenticatedSettingsIndexRoute
'/tasks': typeof AuthenticatedTasksIndexRoute
'/users': typeof AuthenticatedUsersIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/_authenticated': typeof AuthenticatedRouteRouteWithChildren
'/clerk': typeof ClerkRouteRouteWithChildren
'/_authenticated/settings': typeof AuthenticatedSettingsRouteRouteWithChildren
'/clerk/(auth)': typeof ClerkauthRouteRouteWithChildren
'/clerk/_authenticated': typeof ClerkAuthenticatedRouteRouteWithChildren
'/(auth)/forgot-password': typeof authForgotPasswordRoute
'/(auth)/otp': typeof authOtpRoute
'/(auth)/sign-in': typeof authSignInRoute
'/(auth)/sign-in-2': typeof authSignIn2Route
'/(auth)/sign-up': typeof authSignUpRoute
'/(errors)/401': typeof errors401Route
'/(errors)/403': typeof errors403Route
'/(errors)/404': typeof errors404Route
'/(errors)/500': typeof errors500Route
'/(errors)/503': typeof errors503Route
'/_authenticated/course-query': typeof AuthenticatedCourseQueryRoute
'/_authenticated/order-logs': typeof AuthenticatedOrderLogsRoute
'/_authenticated/orders': typeof AuthenticatedOrdersRoute
'/_authenticated/self-owned-order': typeof AuthenticatedSelfOwnedOrderRoute
'/_authenticated/tickets': typeof AuthenticatedTicketsRoute
'/_authenticated/': typeof AuthenticatedIndexRoute
'/_authenticated/catalog/categories': typeof AuthenticatedCatalogCategoriesRoute
'/_authenticated/catalog/products': typeof AuthenticatedCatalogProductsRoute
'/_authenticated/errors/$error': typeof AuthenticatedErrorsErrorRoute
'/_authenticated/settings/account': typeof AuthenticatedSettingsAccountRoute
'/_authenticated/settings/appearance': typeof AuthenticatedSettingsAppearanceRoute
'/_authenticated/settings/display': typeof AuthenticatedSettingsDisplayRoute
'/_authenticated/settings/notifications': typeof AuthenticatedSettingsNotificationsRoute
'/_authenticated/system/audit-logs': typeof AuthenticatedSystemAuditLogsRoute
'/_authenticated/system/third-party': typeof AuthenticatedSystemThirdPartyRoute
'/_authenticated/system/users': typeof AuthenticatedSystemUsersRoute
'/clerk/(auth)/sign-in': typeof ClerkauthSignInRoute
'/clerk/(auth)/sign-up': typeof ClerkauthSignUpRoute
'/clerk/_authenticated/user-management': typeof ClerkAuthenticatedUserManagementRoute
'/_authenticated/apps/': typeof AuthenticatedAppsIndexRoute
'/_authenticated/chats/': typeof AuthenticatedChatsIndexRoute
'/_authenticated/help-center/': typeof AuthenticatedHelpCenterIndexRoute
'/_authenticated/settings/': typeof AuthenticatedSettingsIndexRoute
'/_authenticated/tasks/': typeof AuthenticatedTasksIndexRoute
'/_authenticated/users/': typeof AuthenticatedUsersIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/clerk'
| '/settings'
| '/forgot-password'
| '/otp'
| '/sign-in'
| '/sign-in-2'
| '/sign-up'
| '/401'
| '/403'
| '/404'
| '/500'
| '/503'
| '/course-query'
| '/order-logs'
| '/orders'
| '/self-owned-order'
| '/tickets'
| '/catalog/categories'
| '/catalog/products'
| '/errors/$error'
| '/settings/account'
| '/settings/appearance'
| '/settings/display'
| '/settings/notifications'
| '/system/audit-logs'
| '/system/third-party'
| '/system/users'
| '/clerk/sign-in'
| '/clerk/sign-up'
| '/clerk/user-management'
| '/apps/'
| '/chats/'
| '/help-center/'
| '/settings/'
| '/tasks/'
| '/users/'
fileRoutesByTo: FileRoutesByTo
to:
| '/clerk'
| '/forgot-password'
| '/otp'
| '/sign-in'
| '/sign-in-2'
| '/sign-up'
| '/401'
| '/403'
| '/404'
| '/500'
| '/503'
| '/course-query'
| '/order-logs'
| '/orders'
| '/self-owned-order'
| '/tickets'
| '/'
| '/catalog/categories'
| '/catalog/products'
| '/errors/$error'
| '/settings/account'
| '/settings/appearance'
| '/settings/display'
| '/settings/notifications'
| '/system/audit-logs'
| '/system/third-party'
| '/system/users'
| '/clerk/sign-in'
| '/clerk/sign-up'
| '/clerk/user-management'
| '/apps'
| '/chats'
| '/help-center'
| '/settings'
| '/tasks'
| '/users'
id:
| '__root__'
| '/_authenticated'
| '/clerk'
| '/_authenticated/settings'
| '/clerk/(auth)'
| '/clerk/_authenticated'
| '/(auth)/forgot-password'
| '/(auth)/otp'
| '/(auth)/sign-in'
| '/(auth)/sign-in-2'
| '/(auth)/sign-up'
| '/(errors)/401'
| '/(errors)/403'
| '/(errors)/404'
| '/(errors)/500'
| '/(errors)/503'
| '/_authenticated/course-query'
| '/_authenticated/order-logs'
| '/_authenticated/orders'
| '/_authenticated/self-owned-order'
| '/_authenticated/tickets'
| '/_authenticated/'
| '/_authenticated/catalog/categories'
| '/_authenticated/catalog/products'
| '/_authenticated/errors/$error'
| '/_authenticated/settings/account'
| '/_authenticated/settings/appearance'
| '/_authenticated/settings/display'
| '/_authenticated/settings/notifications'
| '/_authenticated/system/audit-logs'
| '/_authenticated/system/third-party'
| '/_authenticated/system/users'
| '/clerk/(auth)/sign-in'
| '/clerk/(auth)/sign-up'
| '/clerk/_authenticated/user-management'
| '/_authenticated/apps/'
| '/_authenticated/chats/'
| '/_authenticated/help-center/'
| '/_authenticated/settings/'
| '/_authenticated/tasks/'
| '/_authenticated/users/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
AuthenticatedRouteRoute: typeof AuthenticatedRouteRouteWithChildren
ClerkRouteRoute: typeof ClerkRouteRouteWithChildren
authForgotPasswordRoute: typeof authForgotPasswordRoute
authOtpRoute: typeof authOtpRoute
authSignInRoute: typeof authSignInRoute
authSignIn2Route: typeof authSignIn2Route
authSignUpRoute: typeof authSignUpRoute
errors401Route: typeof errors401Route
errors403Route: typeof errors403Route
errors404Route: typeof errors404Route
errors500Route: typeof errors500Route
errors503Route: typeof errors503Route
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/clerk': {
id: '/clerk'
path: '/clerk'
fullPath: '/clerk'
preLoaderRoute: typeof ClerkRouteRouteImport
parentRoute: typeof rootRouteImport
}
'/_authenticated': {
id: '/_authenticated'
path: ''
fullPath: '/'
preLoaderRoute: typeof AuthenticatedRouteRouteImport
parentRoute: typeof rootRouteImport
}
'/_authenticated/': {
id: '/_authenticated/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof AuthenticatedIndexRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/tickets': {
id: '/_authenticated/tickets'
path: '/tickets'
fullPath: '/tickets'
preLoaderRoute: typeof AuthenticatedTicketsRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/self-owned-order': {
id: '/_authenticated/self-owned-order'
path: '/self-owned-order'
fullPath: '/self-owned-order'
preLoaderRoute: typeof AuthenticatedSelfOwnedOrderRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/orders': {
id: '/_authenticated/orders'
path: '/orders'
fullPath: '/orders'
preLoaderRoute: typeof AuthenticatedOrdersRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/order-logs': {
id: '/_authenticated/order-logs'
path: '/order-logs'
fullPath: '/order-logs'
preLoaderRoute: typeof AuthenticatedOrderLogsRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/course-query': {
id: '/_authenticated/course-query'
path: '/course-query'
fullPath: '/course-query'
preLoaderRoute: typeof AuthenticatedCourseQueryRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/(errors)/503': {
id: '/(errors)/503'
path: '/503'
fullPath: '/503'
preLoaderRoute: typeof errors503RouteImport
parentRoute: typeof rootRouteImport
}
'/(errors)/500': {
id: '/(errors)/500'
path: '/500'
fullPath: '/500'
preLoaderRoute: typeof errors500RouteImport
parentRoute: typeof rootRouteImport
}
'/(errors)/404': {
id: '/(errors)/404'
path: '/404'
fullPath: '/404'
preLoaderRoute: typeof errors404RouteImport
parentRoute: typeof rootRouteImport
}
'/(errors)/403': {
id: '/(errors)/403'
path: '/403'
fullPath: '/403'
preLoaderRoute: typeof errors403RouteImport
parentRoute: typeof rootRouteImport
}
'/(errors)/401': {
id: '/(errors)/401'
path: '/401'
fullPath: '/401'
preLoaderRoute: typeof errors401RouteImport
parentRoute: typeof rootRouteImport
}
'/(auth)/sign-up': {
id: '/(auth)/sign-up'
path: '/sign-up'
fullPath: '/sign-up'
preLoaderRoute: typeof authSignUpRouteImport
parentRoute: typeof rootRouteImport
}
'/(auth)/sign-in-2': {
id: '/(auth)/sign-in-2'
path: '/sign-in-2'
fullPath: '/sign-in-2'
preLoaderRoute: typeof authSignIn2RouteImport
parentRoute: typeof rootRouteImport
}
'/(auth)/sign-in': {
id: '/(auth)/sign-in'
path: '/sign-in'
fullPath: '/sign-in'
preLoaderRoute: typeof authSignInRouteImport
parentRoute: typeof rootRouteImport
}
'/(auth)/otp': {
id: '/(auth)/otp'
path: '/otp'
fullPath: '/otp'
preLoaderRoute: typeof authOtpRouteImport
parentRoute: typeof rootRouteImport
}
'/(auth)/forgot-password': {
id: '/(auth)/forgot-password'
path: '/forgot-password'
fullPath: '/forgot-password'
preLoaderRoute: typeof authForgotPasswordRouteImport
parentRoute: typeof rootRouteImport
}
'/clerk/_authenticated': {
id: '/clerk/_authenticated'
path: ''
fullPath: '/clerk'
preLoaderRoute: typeof ClerkAuthenticatedRouteRouteImport
parentRoute: typeof ClerkRouteRoute
}
'/clerk/(auth)': {
id: '/clerk/(auth)'
path: ''
fullPath: '/clerk'
preLoaderRoute: typeof ClerkauthRouteRouteImport
parentRoute: typeof ClerkRouteRoute
}
'/_authenticated/settings': {
id: '/_authenticated/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof AuthenticatedSettingsRouteRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/users/': {
id: '/_authenticated/users/'
path: '/users'
fullPath: '/users/'
preLoaderRoute: typeof AuthenticatedUsersIndexRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/tasks/': {
id: '/_authenticated/tasks/'
path: '/tasks'
fullPath: '/tasks/'
preLoaderRoute: typeof AuthenticatedTasksIndexRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/settings/': {
id: '/_authenticated/settings/'
path: '/'
fullPath: '/settings/'
preLoaderRoute: typeof AuthenticatedSettingsIndexRouteImport
parentRoute: typeof AuthenticatedSettingsRouteRoute
}
'/_authenticated/help-center/': {
id: '/_authenticated/help-center/'
path: '/help-center'
fullPath: '/help-center/'
preLoaderRoute: typeof AuthenticatedHelpCenterIndexRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/chats/': {
id: '/_authenticated/chats/'
path: '/chats'
fullPath: '/chats/'
preLoaderRoute: typeof AuthenticatedChatsIndexRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/apps/': {
id: '/_authenticated/apps/'
path: '/apps'
fullPath: '/apps/'
preLoaderRoute: typeof AuthenticatedAppsIndexRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/clerk/_authenticated/user-management': {
id: '/clerk/_authenticated/user-management'
path: '/user-management'
fullPath: '/clerk/user-management'
preLoaderRoute: typeof ClerkAuthenticatedUserManagementRouteImport
parentRoute: typeof ClerkAuthenticatedRouteRoute
}
'/clerk/(auth)/sign-up': {
id: '/clerk/(auth)/sign-up'
path: '/sign-up'
fullPath: '/clerk/sign-up'
preLoaderRoute: typeof ClerkauthSignUpRouteImport
parentRoute: typeof ClerkauthRouteRoute
}
'/clerk/(auth)/sign-in': {
id: '/clerk/(auth)/sign-in'
path: '/sign-in'
fullPath: '/clerk/sign-in'
preLoaderRoute: typeof ClerkauthSignInRouteImport
parentRoute: typeof ClerkauthRouteRoute
}
'/_authenticated/system/users': {
id: '/_authenticated/system/users'
path: '/system/users'
fullPath: '/system/users'
preLoaderRoute: typeof AuthenticatedSystemUsersRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/system/third-party': {
id: '/_authenticated/system/third-party'
path: '/system/third-party'
fullPath: '/system/third-party'
preLoaderRoute: typeof AuthenticatedSystemThirdPartyRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/system/audit-logs': {
id: '/_authenticated/system/audit-logs'
path: '/system/audit-logs'
fullPath: '/system/audit-logs'
preLoaderRoute: typeof AuthenticatedSystemAuditLogsRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/settings/notifications': {
id: '/_authenticated/settings/notifications'
path: '/notifications'
fullPath: '/settings/notifications'
preLoaderRoute: typeof AuthenticatedSettingsNotificationsRouteImport
parentRoute: typeof AuthenticatedSettingsRouteRoute
}
'/_authenticated/settings/display': {
id: '/_authenticated/settings/display'
path: '/display'
fullPath: '/settings/display'
preLoaderRoute: typeof AuthenticatedSettingsDisplayRouteImport
parentRoute: typeof AuthenticatedSettingsRouteRoute
}
'/_authenticated/settings/appearance': {
id: '/_authenticated/settings/appearance'
path: '/appearance'
fullPath: '/settings/appearance'
preLoaderRoute: typeof AuthenticatedSettingsAppearanceRouteImport
parentRoute: typeof AuthenticatedSettingsRouteRoute
}
'/_authenticated/settings/account': {
id: '/_authenticated/settings/account'
path: '/account'
fullPath: '/settings/account'
preLoaderRoute: typeof AuthenticatedSettingsAccountRouteImport
parentRoute: typeof AuthenticatedSettingsRouteRoute
}
'/_authenticated/errors/$error': {
id: '/_authenticated/errors/$error'
path: '/errors/$error'
fullPath: '/errors/$error'
preLoaderRoute: typeof AuthenticatedErrorsErrorRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/catalog/products': {
id: '/_authenticated/catalog/products'
path: '/catalog/products'
fullPath: '/catalog/products'
preLoaderRoute: typeof AuthenticatedCatalogProductsRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/catalog/categories': {
id: '/_authenticated/catalog/categories'
path: '/catalog/categories'
fullPath: '/catalog/categories'
preLoaderRoute: typeof AuthenticatedCatalogCategoriesRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
}
}
interface AuthenticatedSettingsRouteRouteChildren {
AuthenticatedSettingsAccountRoute: typeof AuthenticatedSettingsAccountRoute
AuthenticatedSettingsAppearanceRoute: typeof AuthenticatedSettingsAppearanceRoute
AuthenticatedSettingsDisplayRoute: typeof AuthenticatedSettingsDisplayRoute
AuthenticatedSettingsNotificationsRoute: typeof AuthenticatedSettingsNotificationsRoute
AuthenticatedSettingsIndexRoute: typeof AuthenticatedSettingsIndexRoute
}
const AuthenticatedSettingsRouteRouteChildren: AuthenticatedSettingsRouteRouteChildren =
{
AuthenticatedSettingsAccountRoute: AuthenticatedSettingsAccountRoute,
AuthenticatedSettingsAppearanceRoute: AuthenticatedSettingsAppearanceRoute,
AuthenticatedSettingsDisplayRoute: AuthenticatedSettingsDisplayRoute,
AuthenticatedSettingsNotificationsRoute:
AuthenticatedSettingsNotificationsRoute,
AuthenticatedSettingsIndexRoute: AuthenticatedSettingsIndexRoute,
}
const AuthenticatedSettingsRouteRouteWithChildren =
AuthenticatedSettingsRouteRoute._addFileChildren(
AuthenticatedSettingsRouteRouteChildren,
)
interface AuthenticatedRouteRouteChildren {
AuthenticatedSettingsRouteRoute: typeof AuthenticatedSettingsRouteRouteWithChildren
AuthenticatedCourseQueryRoute: typeof AuthenticatedCourseQueryRoute
AuthenticatedOrderLogsRoute: typeof AuthenticatedOrderLogsRoute
AuthenticatedOrdersRoute: typeof AuthenticatedOrdersRoute
AuthenticatedSelfOwnedOrderRoute: typeof AuthenticatedSelfOwnedOrderRoute
AuthenticatedTicketsRoute: typeof AuthenticatedTicketsRoute
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
AuthenticatedCatalogCategoriesRoute: typeof AuthenticatedCatalogCategoriesRoute
AuthenticatedCatalogProductsRoute: typeof AuthenticatedCatalogProductsRoute
AuthenticatedErrorsErrorRoute: typeof AuthenticatedErrorsErrorRoute
AuthenticatedSystemAuditLogsRoute: typeof AuthenticatedSystemAuditLogsRoute
AuthenticatedSystemThirdPartyRoute: typeof AuthenticatedSystemThirdPartyRoute
AuthenticatedSystemUsersRoute: typeof AuthenticatedSystemUsersRoute
AuthenticatedAppsIndexRoute: typeof AuthenticatedAppsIndexRoute
AuthenticatedChatsIndexRoute: typeof AuthenticatedChatsIndexRoute
AuthenticatedHelpCenterIndexRoute: typeof AuthenticatedHelpCenterIndexRoute
AuthenticatedTasksIndexRoute: typeof AuthenticatedTasksIndexRoute
AuthenticatedUsersIndexRoute: typeof AuthenticatedUsersIndexRoute
}
const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
AuthenticatedSettingsRouteRoute: AuthenticatedSettingsRouteRouteWithChildren,
AuthenticatedCourseQueryRoute: AuthenticatedCourseQueryRoute,
AuthenticatedOrderLogsRoute: AuthenticatedOrderLogsRoute,
AuthenticatedOrdersRoute: AuthenticatedOrdersRoute,
AuthenticatedSelfOwnedOrderRoute: AuthenticatedSelfOwnedOrderRoute,
AuthenticatedTicketsRoute: AuthenticatedTicketsRoute,
AuthenticatedIndexRoute: AuthenticatedIndexRoute,
AuthenticatedCatalogCategoriesRoute: AuthenticatedCatalogCategoriesRoute,
AuthenticatedCatalogProductsRoute: AuthenticatedCatalogProductsRoute,
AuthenticatedErrorsErrorRoute: AuthenticatedErrorsErrorRoute,
AuthenticatedSystemAuditLogsRoute: AuthenticatedSystemAuditLogsRoute,
AuthenticatedSystemThirdPartyRoute: AuthenticatedSystemThirdPartyRoute,
AuthenticatedSystemUsersRoute: AuthenticatedSystemUsersRoute,
AuthenticatedAppsIndexRoute: AuthenticatedAppsIndexRoute,
AuthenticatedChatsIndexRoute: AuthenticatedChatsIndexRoute,
AuthenticatedHelpCenterIndexRoute: AuthenticatedHelpCenterIndexRoute,
AuthenticatedTasksIndexRoute: AuthenticatedTasksIndexRoute,
AuthenticatedUsersIndexRoute: AuthenticatedUsersIndexRoute,
}
const AuthenticatedRouteRouteWithChildren =
AuthenticatedRouteRoute._addFileChildren(AuthenticatedRouteRouteChildren)
interface ClerkauthRouteRouteChildren {
ClerkauthSignInRoute: typeof ClerkauthSignInRoute
ClerkauthSignUpRoute: typeof ClerkauthSignUpRoute
}
const ClerkauthRouteRouteChildren: ClerkauthRouteRouteChildren = {
ClerkauthSignInRoute: ClerkauthSignInRoute,
ClerkauthSignUpRoute: ClerkauthSignUpRoute,
}
const ClerkauthRouteRouteWithChildren = ClerkauthRouteRoute._addFileChildren(
ClerkauthRouteRouteChildren,
)
interface ClerkAuthenticatedRouteRouteChildren {
ClerkAuthenticatedUserManagementRoute: typeof ClerkAuthenticatedUserManagementRoute
}
const ClerkAuthenticatedRouteRouteChildren: ClerkAuthenticatedRouteRouteChildren =
{
ClerkAuthenticatedUserManagementRoute:
ClerkAuthenticatedUserManagementRoute,
}
const ClerkAuthenticatedRouteRouteWithChildren =
ClerkAuthenticatedRouteRoute._addFileChildren(
ClerkAuthenticatedRouteRouteChildren,
)
interface ClerkRouteRouteChildren {
ClerkauthRouteRoute: typeof ClerkauthRouteRouteWithChildren
ClerkAuthenticatedRouteRoute: typeof ClerkAuthenticatedRouteRouteWithChildren
}
const ClerkRouteRouteChildren: ClerkRouteRouteChildren = {
ClerkauthRouteRoute: ClerkauthRouteRouteWithChildren,
ClerkAuthenticatedRouteRoute: ClerkAuthenticatedRouteRouteWithChildren,
}
const ClerkRouteRouteWithChildren = ClerkRouteRoute._addFileChildren(
ClerkRouteRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
AuthenticatedRouteRoute: AuthenticatedRouteRouteWithChildren,
ClerkRouteRoute: ClerkRouteRouteWithChildren,
authForgotPasswordRoute: authForgotPasswordRoute,
authOtpRoute: authOtpRoute,
authSignInRoute: authSignInRoute,
authSignIn2Route: authSignIn2Route,
authSignUpRoute: authSignUpRoute,
errors401Route: errors401Route,
errors403Route: errors403Route,
errors404Route: errors404Route,
errors500Route: errors500Route,
errors503Route: errors503Route,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
+119
View File
@@ -0,0 +1,119 @@
# Shadcn Admin Dashboard
Admin Dashboard UI crafted with Shadcn and Vite. Built with responsiveness and accessibility in mind.
![alt text](public/images/shadcn-admin.png)
[![Sponsored by Clerk](https://img.shields.io/badge/Sponsored%20by-Clerk-5b6ee1?logo=clerk)](https://go.clerk.com/GttUAaK)
I've been creating dashboard UIs at work and for my personal projects. I always wanted to make a reusable collection of dashboard UI for future projects; and here it is now. While I've created a few custom components, some of the code is directly adapted from ShadcnUI examples.
> This is not a starter project (template) though. I'll probably make one in the future.
## Features
- Light/dark mode
- Responsive
- Accessible
- With built-in Sidebar component
- Global search command
- 10+ pages
- Extra custom components
- RTL support
<details>
<summary>Customized Components (click to expand)</summary>
This project uses Shadcn UI components, but some have been slightly modified for better RTL (Right-to-Left) support and other improvements. These customized components differ from the original Shadcn UI versions.
If you want to update components using the Shadcn CLI (e.g., `npx shadcn@latest add <component>`), it's generally safe for non-customized components. For the listed customized ones, you may need to manually merge changes to preserve the project's modifications and avoid overwriting RTL support or other updates.
> If you don't require RTL support, you can safely update the 'RTL Updated Components' via the Shadcn CLI, as these changes are primarily for RTL compatibility. The 'Modified Components' may have other customizations to consider.
### Modified Components
- scroll-area
- sonner
- separator
### RTL Updated Components
- alert-dialog
- calendar
- command
- dialog
- dropdown-menu
- select
- table
- sheet
- sidebar
- switch
**Notes:**
- **Modified Components**: These have general updates, potentially including RTL adjustments.
- **RTL Updated Components**: These have specific changes for RTL language support (e.g., layout, positioning).
- For implementation details, check the source files in `src/components/ui/`.
- All other Shadcn UI components in the project are standard and can be safely updated via the CLI.
</details>
## Tech Stack
**UI:** [ShadcnUI](https://ui.shadcn.com) (TailwindCSS + RadixUI)
**Build Tool:** [Vite](https://vitejs.dev/)
**Routing:** [TanStack Router](https://tanstack.com/router/latest)
**Type Checking:** [TypeScript](https://www.typescriptlang.org/)
**Linting/Formatting:** [ESLint](https://eslint.org/) & [Prettier](https://prettier.io/)
**Icons:** [Lucide Icons](https://lucide.dev/icons/), [Tabler Icons](https://tabler.io/icons) (Brand icons only)
**Auth (partial):** [Clerk](https://go.clerk.com/GttUAaK)
## Run Locally
Clone the project
```bash
git clone https://github.com/satnaing/shadcn-admin.git
```
Go to the project directory
```bash
cd shadcn-admin
```
Install dependencies
```bash
pnpm install
```
Start the server
```bash
pnpm run dev
```
## Sponsoring this project ❤️
If you find this project helpful or use this in your own work, consider [sponsoring me](https://github.com/sponsors/satnaing) to support development and maintenance. You can [buy me a coffee](https://buymeacoffee.com/satnaing) as well. Dont worry, every penny helps. Thank you! 🙏
For questions or sponsorship inquiries, feel free to reach out at [satnaingdev@gmail.com](mailto:satnaingdev@gmail.com).
### Current Sponsor
- [Clerk](https://go.clerk.com/GttUAaK) - authentication and user management for the modern web
## Author
Crafted with 🤍 by [@satnaing](https://github.com/satnaing)
## License
Licensed under the [MIT License](https://choosealicense.com/licenses/mit/)
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+80
View File
@@ -0,0 +1,80 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link
rel="icon"
type="image/svg+xml"
href="/images/favicon.svg"
media="(prefers-color-scheme: light)"
/>
<link
rel="icon"
type="image/svg+xml"
href="/images/favicon_light.svg"
media="(prefers-color-scheme: dark)"
/>
<link
rel="icon"
type="image/png"
href="/images/favicon.png"
media="(prefers-color-scheme: light)"
/>
<link
rel="icon"
type="image/png"
href="/images/favicon_light.png"
media="(prefers-color-scheme: dark)"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Primary Meta Tags -->
<title>Shadcn Admin</title>
<meta name="title" content="Shadcn Admin" />
<meta
name="description"
content="Admin Dashboard UI built with Shadcn and Vite."
/>
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://shadcn-admin.netlify.app" />
<meta property="og:title" content="Shadcn Admin" />
<meta
property="og:description"
content="Admin Dashboard UI built with Shadcn and Vite."
/>
<meta
property="og:image"
content="https://shadcn-admin.netlify.app/images/shadcn-admin.png"
/>
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content="https://shadcn-admin.netlify.app" />
<meta property="twitter:title" content="Shadcn Admin" />
<meta
property="twitter:description"
content="Admin Dashboard UI built with Shadcn and Vite."
/>
<meta
property="twitter:image"
content="https://shadcn-admin.netlify.app/images/shadcn-admin.png"
/>
<!-- font family -->
<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"
/>
<meta name="theme-color" content="#fff" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
import type { KnipConfig } from 'knip'
const config: KnipConfig = {
ignore: [
'src/components/ui/**',
'src/components/layout/app-title.tsx',
'src/tanstack-table.d.ts',
],
}
export default config
+4
View File
@@ -0,0 +1,4 @@
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
+95
View File
@@ -0,0 +1,95 @@
{
"name": "@study-work/frontend",
"private": false,
"version": "2.2.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"format:check": "prettier --check .",
"format": "prettier --write .",
"knip": "knip",
"test": "vitest run --browser.headless",
"test:watch": "vitest --browser.headless",
"test:ui": "vitest --ui --browser.headless",
"test:browser": "vitest",
"test:coverage": "vitest run --coverage --browser.headless",
"test:browser:install": "playwright install chromium --with-deps"
},
"dependencies": {
"@clerk/react": "^6.4.2",
"@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",
"axios": "^1.15.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"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.2",
"tw-animate-css": "^1.4.0",
"zod": "^4.3.6",
"zustand": "^5.0.12"
},
"devDependencies": {
"@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",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/browser-playwright": "^4.1.4",
"@vitest/coverage-v8": "^4.1.4",
"@vitest/ui": "^4.1.4",
"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",
"playwright": "1.59.1",
"prettier": "^3.8.3",
"prettier-plugin-tailwindcss": "^0.7.2",
"typescript": "~6.0.3",
"typescript-eslint": "^8.58.2",
"vite": "^8.0.8",
"vitest": "^4.1.4",
"vitest-browser-react": "^2.2.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 494 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:svgjs="http://svgjs.com/svgjs" width="24" height="24"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokewidth="2" strokelinecap="round" strokelinejoin="round">
<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3"></path>
</svg><style>@media (prefers-color-scheme: light) { :root { filter: contrast(1) brightness(0.8); } }
</style></svg>

After

Width:  |  Height:  |  Size: 520 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 B

@@ -0,0 +1 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="948,463.5,24,24"><defs><clipPath id="clip-1"><rect x="948" y="463.5" width="24" height="24" id="clip-1" stroke="none"/></clipPath></defs><g fill="none" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><g><g id="Group 1"><g clip-path="url(#clip-1)" id="Group 1"><path d="M963,469.5v12c0,1.65685 1.34315,3 3,3c1.65685,0 3,-1.34315 3,-3c0,-1.65685 -1.34315,-3 -3,-3h-12c-1.65685,0 -3,1.34315 -3,3c0,1.65685 1.34315,3 3,3c1.65685,0 3,-1.34315 3,-3v-12c0,-1.65685 -1.34315,-3 -3,-3c-1.65685,0 -3,1.34315 -3,3c0,1.65685 1.34315,3 3,3h12c1.65685,0 3,-1.34315 3,-3c0,-1.65685 -1.34315,-3 -3,-3c-1.65685,0 -3,1.34315 -3,3" id="Path 1" stroke="#f2f2f2"/></g></g></g></g></svg>

After

Width:  |  Height:  |  Size: 1013 B

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>
)
}
@@ -0,0 +1,16 @@
export { IconDiscord } from './icon-discord'
export { IconDocker } from './icon-docker'
export { IconFacebook } from './icon-facebook'
export { IconFigma } from './icon-figma'
export { IconGithub } from './icon-github'
export { IconGitlab } from './icon-gitlab'
export { IconGmail } from './icon-gmail'
export { IconMedium } from './icon-medium'
export { IconNotion } from './icon-notion'
export { IconSkype } from './icon-skype'
export { IconSlack } from './icon-slack'
export { IconStripe } from './icon-stripe'
export { IconTelegram } from './icon-telegram'
export { IconTrello } from './icon-trello'
export { IconWhatsapp } from './icon-whatsapp'
export { IconZoom } from './icon-zoom'
@@ -0,0 +1,41 @@
import { type SVGProps } from 'react'
export function ClerkFullLogo(props: SVGProps<SVGSVGElement>) {
return (
<svg
width={77}
height={24}
viewBox='0 0 77 24'
fill='none'
xmlns='http://www.w3.org/2000/svg'
{...props}
>
<path
d='M35.148 16.738a4.198 4.198 0 01-3.06 1.283 3.53 3.53 0 01-2.604-1.034c-.619-.645-.975-1.566-.975-2.665 0-2.199 1.432-3.703 3.58-3.703a3.914 3.914 0 013.034 1.377l1.859-1.644c-1.211-1.47-3.176-2.229-5.042-2.229-3.652 0-6.24 2.517-6.24 6.22 0 1.831.643 3.374 1.728 4.463s2.631 1.728 4.415 1.728c2.317 0 4.166-.94 5.203-2.122l-1.898-1.674zM38.727 3.428h2.766V20.34h-2.766V3.428zM54.818 15.283c.046-.368.07-.74.076-1.11 0-3.507-2.296-6.047-5.847-6.047a5.738 5.738 0 00-4.215 1.725c-1.038 1.089-1.66 2.631-1.66 4.47 0 3.749 2.642 6.216 6.146 6.216 2.35 0 4.043-.951 5.058-2.242l-1.812-1.605-.09-.076a3.749 3.749 0 01-3.008 1.406c-1.778 0-3.061-1.037-3.427-2.737h8.779zm-8.733-2.22a3.365 3.365 0 01.737-1.449 3.082 3.082 0 012.368-.996c1.58 0 2.57.988 2.911 2.445h-6.016zM63.445 8.09v3.084a13.36 13.36 0 00-.838-.05c-2.094 0-3.282 1.505-3.282 3.479v5.736h-2.763V8.261h2.763v1.83h.025c.938-1.283 2.284-1.997 3.75-1.997l.345-.004zM69.887 15.281l-1.998 2.222v2.837h-2.764V3.428h2.764v10.374L72.822 8.3h3.283l-4.341 4.86 4.417 7.18h-3.11l-3.133-5.059h-.051z'
fill='#1F0256'
/>
<path
d='M19.116 3.16l-2.88 2.881a.571.571 0 01-.701.084 6.854 6.854 0 00-10.39 5.647 6.867 6.867 0 00.979 3.764.571.571 0 01-.084.699l-2.88 2.88a.57.57 0 01-.865-.063A11.994 11.994 0 0119.051 2.295a.57.57 0 01.065.866z'
fill='url(#paint0_linear_26568_214324)'
/>
<path
d='M19.113 20.829l-2.88-2.88a.571.571 0 00-.7-.085 6.854 6.854 0 01-7.081 0 .571.571 0 00-.7.084l-2.881 2.88a.57.57 0 00.062.877 11.994 11.994 0 0014.114 0 .571.571 0 00.066-.876zM11.997 15.422a3.427 3.427 0 100-6.854 3.427 3.427 0 000 6.854z'
fill='#1F0256'
/>
<defs>
<linearGradient
id='paint0_linear_26568_214324'
x1={16.4087}
y1={-1.75881}
x2={-7.88473}
y2={22.5365}
gradientUnits='userSpaceOnUse'
>
<stop stopColor='#17CCFC' />
<stop offset={0.5} stopColor='#5D31FF' />
<stop offset={1} stopColor='#F35AFF' />
</linearGradient>
</defs>
</svg>
)
}
@@ -0,0 +1,23 @@
import { type SVGProps } from 'react'
import { cn } from '@/lib/utils'
export function ClerkLogo({ className, ...props }: SVGProps<SVGSVGElement>) {
return (
<svg
role='img'
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
id='clerk'
height='24'
width='24'
className={cn('[&>path]:fill-foreground', className)}
{...props}
>
<title>Clerk</title>
<path
d='m21.47 20.829 -2.881 -2.881a0.572 0.572 0 0 0 -0.7 -0.084 6.854 6.854 0 0 1 -7.081 0 0.576 0.576 0 0 0 -0.7 0.084l-2.881 2.881a0.576 0.576 0 0 0 -0.103 0.69 0.57 0.57 0 0 0 0.166 0.186 12 12 0 0 0 14.113 0 0.58 0.58 0 0 0 0.239 -0.423 0.576 0.576 0 0 0 -0.172 -0.453Zm0.002 -17.668 -2.88 2.88a0.569 0.569 0 0 1 -0.701 0.084A6.857 6.857 0 0 0 8.724 8.08a6.862 6.862 0 0 0 -1.222 3.692 6.86 6.86 0 0 0 0.978 3.764 0.573 0.573 0 0 1 -0.083 0.699l-2.881 2.88a0.567 0.567 0 0 1 -0.864 -0.063A11.993 11.993 0 0 1 6.771 2.7a11.99 11.99 0 0 1 14.637 -0.405 0.566 0.566 0 0 1 0.232 0.418 0.57 0.57 0 0 1 -0.168 0.448Zm-7.118 12.261a3.427 3.427 0 1 0 0 -6.854 3.427 3.427 0 0 0 0 6.854Z'
strokeWidth='1'
></path>
</svg>
)
}
@@ -0,0 +1,110 @@
import { type SVGProps } from 'react'
import { cn } from '@/lib/utils'
import { type Direction } from '@/context/direction-provider'
type IconDirProps = SVGProps<SVGSVGElement> & {
dir: Direction
}
export function IconDir({ dir, className, ...props }: IconDirProps) {
return (
<svg
data-name={`icon-dir-${dir}`}
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 79.86 51.14'
className={cn(dir === 'rtl' && 'rotate-y-180', className)}
{...props}
>
<path
d='M23.42.51h51.92c2.21 0 4 1.79 4 4v42.18c0 2.21-1.79 4-4 4H23.42s-.04-.02-.04-.04V.55s.02-.04.04-.04z'
opacity={0.15}
/>
<path
fill='none'
opacity={0.72}
strokeLinecap='round'
strokeMiterlimit={10}
strokeWidth='2px'
d='M5.56 14.88L17.78 14.88'
/>
<path
fill='none'
opacity={0.48}
strokeLinecap='round'
strokeMiterlimit={10}
strokeWidth='2px'
d='M5.56 22.09L16.08 22.09'
/>
<path
fill='none'
opacity={0.55}
strokeLinecap='round'
strokeMiterlimit={10}
strokeWidth='2px'
d='M5.56 18.38L14.93 18.38'
/>
<g strokeLinecap='round' strokeMiterlimit={10}>
<circle cx={7.51} cy={7.4} r={2.54} opacity={0.8} />
<path
fill='none'
opacity={0.8}
strokeWidth='2px'
d='M12.06 6.14L17.78 6.14'
/>
<path fill='none' opacity={0.6} d='M11.85 8.79L16.91 8.79' />
</g>
<path
fill='none'
opacity={0.62}
strokeLinecap='round'
strokeMiterlimit={10}
strokeWidth='3px'
d='M29.41 7.4L34.67 7.4'
/>
<rect
x={28.76}
y={11.21}
width={26.03}
height={2.73}
rx={0.64}
ry={0.64}
opacity={0.44}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<rect
x={28.76}
y={17.01}
width={44.25}
height={13.48}
rx={0.64}
ry={0.64}
opacity={0.3}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<rect
x={28.76}
y={33.57}
width={44.25}
height={4.67}
rx={0.64}
ry={0.64}
opacity={0.21}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<rect
x={28.76}
y={41.32}
width={36.21}
height={4.67}
rx={0.64}
ry={0.64}
opacity={0.3}
strokeLinecap='round'
strokeMiterlimit={10}
/>
</svg>
)
}
@@ -0,0 +1,131 @@
import { type SVGProps } from 'react'
export function IconLayoutCompact(props: SVGProps<SVGSVGElement>) {
return (
<svg
data-name='icon-layout-compact'
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 79.86 51.14'
{...props}
>
<rect
x={5.84}
y={5.2}
width={4}
height={40}
rx={2}
ry={2}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<g stroke='#fff' strokeLinecap='round' strokeMiterlimit={10}>
<path
fill='none'
opacity={0.66}
strokeWidth='2px'
d='M7.26 11.56L8.37 11.56'
/>
<path
fill='none'
opacity={0.51}
strokeWidth='2px'
d='M7.26 14.49L8.37 14.49'
/>
<path
fill='none'
opacity={0.52}
strokeWidth='2px'
d='M7.26 17.39L8.37 17.39'
/>
<circle cx={7.81} cy={7.25} r={1.16} fill='#fff' opacity={0.8} />
</g>
<path
fill='none'
opacity={0.75}
strokeLinecap='round'
strokeMiterlimit={10}
strokeWidth='3px'
d='M15.81 14.49L22.89 14.49'
/>
<rect
x={14.93}
y={18.39}
width={22.19}
height={2.73}
rx={0.64}
ry={0.64}
opacity={0.5}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<rect
x={14.93}
y={5.89}
width={59.16}
height={2.73}
rx={0.64}
ry={0.64}
opacity={0.9}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<rect
x={14.93}
y={24.22}
width={32.68}
height={19.95}
rx={2.11}
ry={2.11}
opacity={0.4}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<g strokeLinecap='round' strokeMiterlimit={10}>
<rect
x={59.05}
y={38.15}
width={2.01}
height={3.42}
rx={0.33}
ry={0.33}
opacity={0.32}
/>
<rect
x={54.78}
y={34.99}
width={2.01}
height={6.58}
rx={0.33}
ry={0.33}
opacity={0.44}
/>
<rect
x={63.17}
y={32.86}
width={2.01}
height={8.7}
rx={0.33}
ry={0.33}
opacity={0.53}
/>
<rect
x={67.54}
y={29.17}
width={2.01}
height={12.4}
rx={0.33}
ry={0.33}
opacity={0.66}
/>
</g>
<g opacity={0.5}>
<circle cx={62.16} cy={18.63} r={7.5} />
<path d='M62.16 11.63c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.14-7-7 3.14-7 7-7m0-1c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8-3.58-8-8-8z' />
</g>
<g opacity={0.74}>
<path d='M63.04 18.13l3.38-5.67c.93.64 1.7 1.48 2.26 2.47.56.98.89 2.08.96 3.21h-6.6z' />
<path d='M66.57 13.19a6.977 6.977 0 012.52 4.44h-5.17l2.65-4.44m-.31-1.43l-4.1 6.87h8c0-1.39-.36-2.75-1.04-3.95a8.007 8.007 0 00-2.86-2.92z' />
</g>
</svg>
)
}
@@ -0,0 +1,124 @@
import { type SVGProps } from 'react'
export function IconLayoutDefault(props: SVGProps<SVGSVGElement>) {
return (
<svg
data-name='con-layout-default'
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 79.86 51.14'
{...props}
>
<path
d='M39.22 15.99h-8.16c-.79 0-1.43-.67-1.43-1.5s.64-1.5 1.43-1.5h8.16c.79 0 1.43.67 1.43 1.5s-.64 1.5-1.43 1.5z'
opacity={0.75}
/>
<rect
x={29.63}
y={18.39}
width={16.72}
height={2.73}
rx={1.36}
ry={1.36}
opacity={0.5}
/>
<path
d='M75.1 6.68v1.45c0 .63-.49 1.14-1.09 1.14H30.72c-.6 0-1.09-.51-1.09-1.14V6.68c0-.62.49-1.14 1.09-1.14h43.29c.6 0 1.09.52 1.09 1.14z'
opacity={0.9}
/>
<rect
x={29.63}
y={24.22}
width={21.8}
height={19.95}
rx={2.11}
ry={2.11}
opacity={0.4}
/>
<g strokeLinecap='round' strokeMiterlimit={10}>
<rect
x={61.06}
y={38.15}
width={2.01}
height={3.42}
rx={0.33}
ry={0.33}
opacity={0.32}
/>
<rect
x={56.78}
y={34.99}
width={2.01}
height={6.58}
rx={0.33}
ry={0.33}
opacity={0.44}
/>
<rect
x={65.17}
y={32.86}
width={2.01}
height={8.7}
rx={0.33}
ry={0.33}
opacity={0.53}
/>
<rect
x={69.55}
y={29.17}
width={2.01}
height={12.4}
rx={0.33}
ry={0.33}
opacity={0.66}
/>
</g>
<g opacity={0.5}>
<circle cx={63.17} cy={18.63} r={7.5} />
<path d='M63.17 11.63c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.14-7-7 3.14-7 7-7m0-1c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8-3.58-8-8-8z' />
</g>
<g opacity={0.74}>
<path d='M64.05 18.13l3.38-5.67c.93.64 1.7 1.48 2.26 2.47.56.98.89 2.08.96 3.21h-6.6z' />
<path d='M67.57 13.19a6.977 6.977 0 012.52 4.44h-5.17l2.65-4.44m-.31-1.43l-4.1 6.87h8c0-1.39-.36-2.75-1.04-3.95a8.007 8.007 0 00-2.86-2.92z' />
</g>
<g strokeLinecap='round' strokeMiterlimit={10}>
<rect
x={5.84}
y={5.02}
width={19.14}
height={40}
rx={2}
ry={2}
opacity={0.8}
/>
<g stroke='#fff'>
<path
fill='none'
opacity={0.72}
strokeWidth='2px'
d='M9.02 17.39L21.25 17.39'
/>
<path
fill='none'
opacity={0.48}
strokeWidth='2px'
d='M9.02 24.6L19.54 24.6'
/>
<path
fill='none'
opacity={0.55}
strokeWidth='2px'
d='M9.02 20.88L18.4 20.88'
/>
<circle cx={10.98} cy={9.91} r={2.54} fill='#fff' opacity={0.8} />
<path
fill='none'
opacity={0.8}
strokeWidth='2px'
d='M15.53 8.65L21.25 8.65'
/>
<path fill='none' opacity={0.6} d='M15.32 11.3L20.38 11.3' />
</g>
</g>
</svg>
)
}
@@ -0,0 +1,100 @@
import { type SVGProps } from 'react'
export function IconLayoutFull(props: SVGProps<SVGSVGElement>) {
return (
<svg
data-name='icon-layout-full'
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 79.86 51.14'
{...props}
>
<path
fill='none'
opacity={0.75}
strokeLinecap='round'
strokeMiterlimit={10}
strokeWidth='3px'
d='M6.85 14.49L15.02 14.49'
/>
<rect
x={5.84}
y={18.39}
width={25.6}
height={2.73}
rx={0.64}
ry={0.64}
opacity={0.5}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<rect
x={5.84}
y={5.89}
width={68.26}
height={2.73}
rx={0.64}
ry={0.64}
opacity={0.9}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<rect
x={5.84}
y={24.22}
width={37.71}
height={19.95}
rx={2.11}
ry={2.11}
opacity={0.4}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<g strokeLinecap='round' strokeMiterlimit={10}>
<rect
x={59.05}
y={38.15}
width={2.01}
height={3.42}
rx={0.33}
ry={0.33}
opacity={0.32}
/>
<rect
x={54.78}
y={34.99}
width={2.01}
height={6.58}
rx={0.33}
ry={0.33}
opacity={0.44}
/>
<rect
x={63.17}
y={32.86}
width={2.01}
height={8.7}
rx={0.33}
ry={0.33}
opacity={0.53}
/>
<rect
x={67.54}
y={29.17}
width={2.01}
height={12.4}
rx={0.33}
ry={0.33}
opacity={0.66}
/>
</g>
<g opacity={0.5}>
<circle cx={62.16} cy={18.63} r={7.5} />
<path d='M62.16 11.63c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.14-7-7 3.14-7 7-7m0-1c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8-3.58-8-8-8z' />
</g>
<g opacity={0.74}>
<path d='M63.04 18.13l3.38-5.67c.93.64 1.7 1.48 2.26 2.47.56.98.89 2.08.96 3.21h-6.6z' />
<path d='M66.57 13.19a6.977 6.977 0 012.52 4.44h-5.17l2.65-4.44m-.31-1.43l-4.1 6.87h8c0-1.39-.36-2.75-1.04-3.95a8.007 8.007 0 00-2.86-2.92z' />
</g>
</svg>
)
}
@@ -0,0 +1,82 @@
import { type SVGProps } from 'react'
export function IconSidebarFloating(props: SVGProps<SVGSVGElement>) {
return (
<svg
data-name='icon-sidebar-floating'
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 79.86 51.14'
{...props}
>
<rect
x={5.89}
y={5.15}
width={19.74}
height={40}
rx={2}
ry={2}
opacity={0.8}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<g stroke='#fff' strokeLinecap='round' strokeMiterlimit={10}>
<path
fill='none'
opacity={0.72}
strokeWidth='2px'
d='M9.81 18.36L22.04 18.36'
/>
<path
fill='none'
opacity={0.48}
strokeWidth='2px'
d='M9.81 25.57L20.33 25.57'
/>
<path
fill='none'
opacity={0.55}
strokeWidth='2px'
d='M9.81 21.85L19.18 21.85'
/>
<circle cx={11.76} cy={10.88} r={2.54} fill='#fff' opacity={0.8} />
<path
fill='none'
opacity={0.8}
strokeWidth='2px'
d='M16.31 9.62L22.04 9.62'
/>
<path fill='none' opacity={0.6} d='M16.1 12.27L21.16 12.27' />
</g>
<path
fill='none'
opacity={0.62}
strokeLinecap='round'
strokeMiterlimit={10}
strokeWidth='3px'
d='M30.59 9.62L35.85 9.62'
/>
<rect
x={29.94}
y={13.42}
width={26.03}
height={2.73}
rx={0.64}
ry={0.64}
opacity={0.44}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<rect
x={29.94}
y={19.28}
width={43.11}
height={25.87}
rx={2}
ry={2}
opacity={0.3}
strokeLinecap='round'
strokeMiterlimit={10}
/>
</svg>
)
}
@@ -0,0 +1,58 @@
import { type SVGProps } from 'react'
export function IconSidebarInset(props: SVGProps<SVGSVGElement>) {
return (
<svg
data-name='icon-sidebar-inset'
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 79.86 51.14'
{...props}
>
<rect
x={23.39}
y={5.57}
width={50.22}
height={40}
rx={2}
ry={2}
opacity={0.2}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<path
fill='none'
opacity={0.72}
strokeLinecap='round'
strokeMiterlimit={10}
strokeWidth='2px'
d='M5.08 17.05L17.31 17.05'
/>
<path
fill='none'
opacity={0.48}
strokeLinecap='round'
strokeMiterlimit={10}
strokeWidth='2px'
d='M5.08 24.25L15.6 24.25'
/>
<path
fill='none'
opacity={0.55}
strokeLinecap='round'
strokeMiterlimit={10}
strokeWidth='2px'
d='M5.08 20.54L14.46 20.54'
/>
<g strokeLinecap='round' strokeMiterlimit={10}>
<circle cx={7.04} cy={9.57} r={2.54} opacity={0.8} />
<path
fill='none'
opacity={0.8}
strokeWidth='2px'
d='M11.59 8.3L17.31 8.3'
/>
<path fill='none' opacity={0.6} d='M11.38 10.95L16.44 10.95' />
</g>
</svg>
)
}
@@ -0,0 +1,53 @@
import { type SVGProps } from 'react'
export function IconSidebarSidebar(props: SVGProps<SVGSVGElement>) {
return (
<svg
data-name='icon-sidebar-sidebar'
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 79.86 51.14'
{...props}
>
<path
d='M23.42.51h51.99c2.21 0 4 1.79 4 4v42.18c0 2.21-1.79 4-4 4H23.42s-.04-.02-.04-.04V.55s.02-.04.04-.04z'
opacity={0.2}
strokeLinecap='round'
strokeMiterlimit={10}
/>
<path
fill='none'
opacity={0.72}
strokeLinecap='round'
strokeMiterlimit={10}
strokeWidth='2px'
d='M5.56 14.88L17.78 14.88'
/>
<path
fill='none'
opacity={0.48}
strokeLinecap='round'
strokeMiterlimit={10}
strokeWidth='2px'
d='M5.56 22.09L16.08 22.09'
/>
<path
fill='none'
opacity={0.55}
strokeLinecap='round'
strokeMiterlimit={10}
strokeWidth='2px'
d='M5.56 18.38L14.93 18.38'
/>
<g strokeLinecap='round' strokeMiterlimit={10}>
<circle cx={7.51} cy={7.4} r={2.54} opacity={0.8} />
<path
fill='none'
opacity={0.8}
strokeWidth='2px'
d='M12.06 6.14L17.78 6.14'
/>
<path fill='none' opacity={0.6} d='M11.85 8.79L16.91 8.79' />
</g>
</svg>
)
}
@@ -0,0 +1,79 @@
import { type SVGProps } from 'react'
export function IconThemeDark(props: SVGProps<SVGSVGElement>) {
return (
<svg
data-name='icon-theme-dark'
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 79.86 51.14'
{...props}
>
<g fill='#1d2b3f'>
<rect x={0.53} y={0.5} width={78.83} height={50.14} rx={3.5} ry={3.5} />
<path d='M75.86 1c1.65 0 3 1.35 3 3v43.14c0 1.65-1.35 3-3 3H4.03c-1.65 0-3-1.35-3-3V4c0-1.65 1.35-3 3-3h71.83m0-1H4.03c-2.21 0-4 1.79-4 4v43.14c0 2.21 1.79 4 4 4h71.83c2.21 0 4-1.79 4-4V4c0-2.21-1.79-4-4-4z' />
</g>
<path
d='M22.88 0h52.97c2.21 0 4 1.79 4 4v43.14c0 2.21-1.79 4-4 4H22.88V0z'
fill='#0d1628'
/>
<circle cx={6.7} cy={7.04} r={3.54} fill='#426187' />
<path
d='M18.12 6.39h-5.87c-.6 0-1.09-.45-1.09-1s.49-1 1.09-1h5.87c.6 0 1.09.45 1.09 1s-.49 1-1.09 1zM16.55 9.77h-4.24c-.55 0-1-.45-1-1s.45-1 1-1h4.24c.55 0 1 .45 1 1s-.45 1-1 1zM18.32 17.37H4.59c-.69 0-1.25-.47-1.25-1.05s.56-1.05 1.25-1.05h13.73c.69 0 1.25.47 1.25 1.05s-.56 1.05-1.25 1.05zM15.34 21.26h-11c-.55 0-1-.41-1-.91s.45-.91 1-.91h11c.55 0 1 .41 1 .91s-.45.91-1 .91zM16.46 25.57H4.43c-.6 0-1.09-.44-1.09-.98s.49-.98 1.09-.98h12.03c.6 0 1.09.44 1.09.98s-.49.98-1.09.98z'
fill='#426187'
/>
<g fill='#2a62bc'>
<rect
x={33.36}
y={19.73}
width={2.75}
height={3.42}
rx={0.33}
ry={0.33}
opacity={0.32}
/>
<rect
x={29.64}
y={16.57}
width={2.75}
height={6.58}
rx={0.33}
ry={0.33}
opacity={0.44}
/>
<rect
x={37.16}
y={14.44}
width={2.75}
height={8.7}
rx={0.33}
ry={0.33}
opacity={0.53}
/>
<rect
x={41.19}
y={10.75}
width={2.75}
height={12.4}
rx={0.33}
ry={0.33}
opacity={0.53}
/>
</g>
<circle cx={62.74} cy={16.32} r={8} fill='#2f5491' opacity={0.5} />
<path
d='M62.74 16.32l4.1-6.87c1.19.71 2.18 1.72 2.86 2.92s1.04 2.57 1.04 3.95h-8z'
fill='#2f5491'
opacity={0.74}
/>
<rect
x={29.64}
y={27.75}
width={41.62}
height={18.62}
rx={1.69}
ry={1.69}
fill='#17273f'
/>
</svg>
)
}
@@ -0,0 +1,78 @@
import { type SVGProps } from 'react'
export function IconThemeLight(props: SVGProps<SVGSVGElement>) {
return (
<svg
data-name='icon-theme-light'
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 79.86 51.14'
{...props}
>
<g fill='#d9d9d9'>
<rect x={0.53} y={0.5} width={78.83} height={50.14} rx={3.5} ry={3.5} />
<path d='M75.86 1c1.65 0 3 1.35 3 3v43.14c0 1.65-1.35 3-3 3H4.03c-1.65 0-3-1.35-3-3V4c0-1.65 1.35-3 3-3h71.83m0-1H4.03c-2.21 0-4 1.79-4 4v43.14c0 2.21 1.79 4 4 4h71.83c2.21 0 4-1.79 4-4V4c0-2.21-1.79-4-4-4z' />
</g>
<path
d='M22.88 0h52.97c2.21 0 4 1.79 4 4v43.14c0 2.21-1.79 4-4 4H22.88V0z'
fill='#ecedef'
/>
<circle cx={6.7} cy={7.04} r={3.54} fill='#fff' />
<path
d='M18.12 6.39h-5.87c-.6 0-1.09-.45-1.09-1s.49-1 1.09-1h5.87c.6 0 1.09.45 1.09 1s-.49 1-1.09 1zM16.55 9.77h-4.24c-.55 0-1-.45-1-1s.45-1 1-1h4.24c.55 0 1 .45 1 1s-.45 1-1 1zM18.32 17.37H4.59c-.69 0-1.25-.47-1.25-1.05s.56-1.05 1.25-1.05h13.73c.69 0 1.25.47 1.25 1.05s-.56 1.05-1.25 1.05zM15.34 21.26h-11c-.55 0-1-.41-1-.91s.45-.91 1-.91h11c.55 0 1 .41 1 .91s-.45.91-1 .91zM16.46 25.57H4.43c-.6 0-1.09-.44-1.09-.98s.49-.98 1.09-.98h12.03c.6 0 1.09.44 1.09.98s-.49.98-1.09.98z'
fill='#fff'
/>
<g fill='#c0c4c4'>
<rect
x={33.36}
y={19.73}
width={2.75}
height={3.42}
rx={0.33}
ry={0.33}
opacity={0.32}
/>
<rect
x={29.64}
y={16.57}
width={2.75}
height={6.58}
rx={0.33}
ry={0.33}
opacity={0.44}
/>
<rect
x={37.16}
y={14.44}
width={2.75}
height={8.7}
rx={0.33}
ry={0.33}
opacity={0.53}
/>
<rect
x={41.19}
y={10.75}
width={2.75}
height={12.4}
rx={0.33}
ry={0.33}
opacity={0.53}
/>
</g>
<circle cx={62.74} cy={16.32} r={8} fill='#fff' />
<g fill='#d9d9d9'>
<path d='M63.62 15.82L67 10.15c.93.64 1.7 1.48 2.26 2.47.56.98.89 2.08.96 3.21h-6.6z' />
<path d='M67.14 10.88a6.977 6.977 0 012.52 4.44h-5.17l2.65-4.44m-.31-1.43l-4.1 6.87h8c0-1.39-.36-2.75-1.04-3.95s-1.67-2.21-2.86-2.92z' />
</g>
<rect
x={29.64}
y={27.75}
width={41.62}
height={18.62}
rx={1.69}
ry={1.69}
fill='#fff'
/>
</svg>
)
}
@@ -0,0 +1,116 @@
import { type SVGProps } from 'react'
import { cn } from '@/lib/utils'
export function IconThemeSystem({
className,
...props
}: SVGProps<SVGSVGElement>) {
return (
<svg
data-name='icon-theme-system'
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 79.86 51.14'
className={cn(
'overflow-hidden rounded-[6px]',
'fill-primary stroke-primary group-data-[state=unchecked]:fill-muted-foreground group-data-[state=unchecked]:stroke-muted-foreground',
className
)}
{...props}
>
<path opacity={0.2} d='M0 0.03H22.88V51.17H0z' />
<circle
cx={6.7}
cy={7.04}
r={3.54}
fill='#fff'
opacity={0.8}
stroke='#fff'
strokeLinecap='round'
strokeMiterlimit={10}
/>
<path
d='M18.12 6.39h-5.87c-.6 0-1.09-.45-1.09-1s.49-1 1.09-1h5.87c.6 0 1.09.45 1.09 1s-.49 1-1.09 1zM16.55 9.77h-4.24c-.55 0-1-.45-1-1s.45-1 1-1h4.24c.55 0 1 .45 1 1s-.45 1-1 1z'
fill='#fff'
stroke='none'
opacity={0.75}
/>
<path
d='M18.32 17.37H4.59c-.69 0-1.25-.47-1.25-1.05s.56-1.05 1.25-1.05h13.73c.69 0 1.25.47 1.25 1.05s-.56 1.05-1.25 1.05z'
fill='#fff'
stroke='none'
opacity={0.72}
/>
<path
d='M15.34 21.26h-11c-.55 0-1-.41-1-.91s.45-.91 1-.91h11c.55 0 1 .41 1 .91s-.45.91-1 .91z'
fill='#fff'
stroke='none'
opacity={0.55}
/>
<path
d='M16.46 25.57H4.43c-.6 0-1.09-.44-1.09-.98s.49-.98 1.09-.98h12.03c.6 0 1.09.44 1.09.98s-.49.98-1.09.98z'
fill='#fff'
stroke='none'
opacity={0.67}
/>
<rect
x={33.36}
y={19.73}
width={2.75}
height={3.42}
rx={0.33}
ry={0.33}
opacity={0.31}
stroke='none'
/>
<rect
x={29.64}
y={16.57}
width={2.75}
height={6.58}
rx={0.33}
ry={0.33}
opacity={0.4}
stroke='none'
/>
<rect
x={37.16}
y={14.44}
width={2.75}
height={8.7}
rx={0.33}
ry={0.33}
opacity={0.26}
stroke='none'
/>
<rect
x={41.19}
y={10.75}
width={2.75}
height={12.4}
rx={0.33}
ry={0.33}
opacity={0.37}
stroke='none'
/>
<g>
<circle cx={62.74} cy={16.32} r={8} opacity={0.25} />
<path
d='M62.74 16.32l4.1-6.87c1.19.71 2.18 1.72 2.86 2.92s1.04 2.57 1.04 3.95h-8z'
opacity={0.45}
/>
</g>
<rect
x={29.64}
y={27.75}
width={41.62}
height={18.62}
rx={1.69}
ry={1.69}
opacity={0.3}
stroke='none'
strokeLinecap='round'
strokeMiterlimit={10}
/>
</svg>
)
}
+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>
)
}
@@ -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'></h1>
<p className='text-center text-muted-foreground'>
<br />
</p>
</div>
</div>
)
}
@@ -0,0 +1,91 @@
import React from 'react'
import { useNavigate } from '@tanstack/react-router'
import { ArrowRight, ChevronRight, Laptop, Moon, Sun } from 'lucide-react'
import { useSearch } from '@/context/search-provider'
import { useTheme } from '@/context/theme-provider'
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command'
import { sidebarData } from './layout/data/sidebar-data'
import { ScrollArea } from './ui/scroll-area'
export function CommandMenu() {
const navigate = useNavigate()
const { setTheme } = useTheme()
const { open, setOpen } = useSearch()
const runCommand = React.useCallback(
(command: () => unknown) => {
setOpen(false)
command()
},
[setOpen]
)
return (
<CommandDialog modal open={open} onOpenChange={setOpen}>
<CommandInput placeholder='Type a command or search...' />
<CommandList>
<ScrollArea type='hover' className='h-72 pe-1'>
<CommandEmpty>No results found.</CommandEmpty>
{sidebarData.navGroups.map((group) => (
<CommandGroup key={group.title} heading={group.title}>
{group.items.map((navItem, i) => {
if (navItem.url)
return (
<CommandItem
key={`${navItem.url}-${i}`}
value={navItem.title}
onSelect={() => {
runCommand(() => navigate({ to: navItem.url }))
}}
>
<div className='flex size-4 items-center justify-center'>
<ArrowRight className='size-2 text-muted-foreground/80' />
</div>
{navItem.title}
</CommandItem>
)
return navItem.items?.map((subItem, i) => (
<CommandItem
key={`${navItem.title}-${subItem.url}-${i}`}
value={`${navItem.title}-${subItem.url}`}
onSelect={() => {
runCommand(() => navigate({ to: subItem.url }))
}}
>
<div className='flex size-4 items-center justify-center'>
<ArrowRight className='size-2 text-muted-foreground/80' />
</div>
{navItem.title} <ChevronRight /> {subItem.title}
</CommandItem>
))
})}
</CommandGroup>
))}
<CommandSeparator />
<CommandGroup heading='Theme'>
<CommandItem onSelect={() => runCommand(() => setTheme('light'))}>
<Sun /> <span>Light</span>
</CommandItem>
<CommandItem onSelect={() => runCommand(() => setTheme('dark'))}>
<Moon className='scale-90' />
<span>Dark</span>
</CommandItem>
<CommandItem onSelect={() => runCommand(() => setTheme('system'))}>
<Laptop />
<span>System</span>
</CommandItem>
</CommandGroup>
</ScrollArea>
</CommandList>
</CommandDialog>
)
}
@@ -0,0 +1,319 @@
import { clearCookies } from '@/test-utils/cookies'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, type RenderResult } from 'vitest-browser-react'
import { userEvent } from 'vitest/browser'
import { getCookie, setCookie } from '@/lib/cookies'
import { DirectionProvider } from '@/context/direction-provider'
import { LayoutProvider } from '@/context/layout-provider'
import { ThemeProvider } from '@/context/theme-provider'
import { SidebarProvider } from '@/components/ui/sidebar'
import { ConfigDrawer } from './config-drawer'
async function renderConfigDrawer({
sidebarDefaultOpen = true,
}: {
sidebarDefaultOpen?: boolean
} = {}) {
return await render(
<DirectionProvider>
<ThemeProvider>
<LayoutProvider>
<SidebarProvider defaultOpen={sidebarDefaultOpen}>
<ConfigDrawer />
</SidebarProvider>
</LayoutProvider>
</ThemeProvider>
</DirectionProvider>
)
}
async function openDrawer(screen: RenderResult) {
await userEvent.click(
screen.getByRole('button', { name: '打开界面设置' })
)
await expect
.element(screen.getByText('界面设置'))
.toBeInTheDocument()
}
describe('ConfigDrawer (integration)', () => {
beforeEach(() => {
vi.clearAllMocks()
clearCookies()
document.documentElement.classList.remove('light', 'dark')
document.documentElement.removeAttribute('dir')
})
it('opens the drawer and renders the sections', async () => {
const screen = await renderConfigDrawer()
await openDrawer(screen)
const drawer = screen.getByRole('dialog', { name: '界面设置' })
await expect.element(drawer).toBeInTheDocument()
await expect.element(drawer.getByText('主题')).toBeInTheDocument()
await expect.element(drawer.getByText('布局')).toBeInTheDocument()
await expect
.element(drawer.getByText('侧边栏').first())
.toBeInTheDocument()
await expect.element(drawer.getByText('文字方向')).toBeInTheDocument()
await expect
.element(
screen.getByRole('button', {
name: '恢复默认界面设置',
})
)
.toBeInTheDocument()
})
describe('theme preference', () => {
it('applies light theme to <html> and cookie', async () => {
const screen = await renderConfigDrawer()
await openDrawer(screen)
await userEvent.click(
screen.getByRole('radio', { name: '选择浅色' })
)
await vi.waitFor(() =>
expect(document.documentElement.classList.contains('light')).toBe(true)
)
expect(getCookie('vite-ui-theme')).toBe('light')
})
it('applies dark theme to <html> and cookie', async () => {
const screen = await renderConfigDrawer()
await openDrawer(screen)
await userEvent.click(screen.getByRole('radio', { name: '选择深色' }))
await vi.waitFor(() =>
expect(document.documentElement.classList.contains('dark')).toBe(true)
)
expect(getCookie('vite-ui-theme')).toBe('dark')
})
it('applies system theme: stores cookie and applies a resolved light or dark class', async () => {
// Pre-seed light so mounted theme is not system; re-selecting System alone would not fire setTheme.
setCookie('vite-ui-theme', 'light')
const screen = await renderConfigDrawer()
await openDrawer(screen)
await userEvent.click(
screen.getByRole('radio', { name: '选择跟随系统' })
)
await vi.waitFor(() => expect(getCookie('vite-ui-theme')).toBe('system'))
await vi.waitFor(() => {
const root = document.documentElement
const hasLight = root.classList.contains('light')
const hasDark = root.classList.contains('dark')
expect(hasLight !== hasDark).toBe(true)
})
})
})
describe('sidebar variant', () => {
it('selecting floating updates layout_variant cookie', async () => {
const screen = await renderConfigDrawer()
await openDrawer(screen)
await userEvent.click(
screen.getByRole('radio', { name: '选择浮动' })
)
await vi.waitFor(() =>
expect(getCookie('layout_variant')).toBe('floating')
)
})
it('selecting sidebar updates layout_variant cookie', async () => {
const screen = await renderConfigDrawer()
await openDrawer(screen)
await userEvent.click(
screen.getByRole('radio', { name: '选择标准' })
)
await vi.waitFor(() =>
expect(getCookie('layout_variant')).toBe('sidebar')
)
})
it('selecting inset updates layout_variant cookie after another variant', async () => {
const screen = await renderConfigDrawer()
await openDrawer(screen)
await userEvent.click(
screen.getByRole('radio', { name: '选择浮动' })
)
await vi.waitFor(() =>
expect(getCookie('layout_variant')).toBe('floating')
)
await userEvent.click(
screen.getByRole('radio', { name: '选择内嵌' })
)
await vi.waitFor(() => expect(getCookie('layout_variant')).toBe('inset'))
})
})
it('selecting full layout sets collapsible to offcanvas and closes sidebar', async () => {
const screen = await renderConfigDrawer({ sidebarDefaultOpen: true })
await openDrawer(screen)
await userEvent.click(
screen.getByRole('radio', { name: '选择全宽' })
)
await vi.waitFor(() =>
expect(getCookie('layout_collapsible')).toBe('offcanvas')
)
await vi.waitFor(() => expect(getCookie('sidebar_state')).toBe('false'))
})
describe('section reset buttons', () => {
it('resets theme via section control after choosing dark', async () => {
const screen = await renderConfigDrawer()
await openDrawer(screen)
await userEvent.click(screen.getByRole('radio', { name: '选择深色' }))
await vi.waitFor(() => expect(getCookie('vite-ui-theme')).toBe('dark'))
await userEvent.click(
screen.getByRole('button', {
name: '恢复默认主题',
})
)
await vi.waitFor(() => expect(getCookie('vite-ui-theme')).toBe('system'))
})
it('resets direction via section control after choosing RTL', async () => {
const screen = await renderConfigDrawer()
await openDrawer(screen)
await userEvent.click(
screen.getByRole('radio', { name: '选择从右到左' })
)
await vi.waitFor(() =>
expect(document.documentElement.getAttribute('dir')).toBe('rtl')
)
await userEvent.click(
screen.getByRole('button', {
name: '恢复默认文字方向',
})
)
await vi.waitFor(() =>
expect(document.documentElement.getAttribute('dir')).toBe('ltr')
)
expect(getCookie('dir')).toBe('ltr')
})
it('resets sidebar style via section control after choosing floating', async () => {
const screen = await renderConfigDrawer()
await openDrawer(screen)
await userEvent.click(
screen.getByRole('radio', { name: '选择浮动' })
)
await vi.waitFor(() =>
expect(getCookie('layout_variant')).toBe('floating')
)
await userEvent.click(
screen.getByRole('button', {
name: '恢复默认侧边栏样式',
})
)
await vi.waitFor(() => expect(getCookie('layout_variant')).toBe('inset'))
})
it('resets layout via section control after choosing compact', async () => {
const screen = await renderConfigDrawer({ sidebarDefaultOpen: true })
await openDrawer(screen)
await userEvent.click(
screen.getByRole('radio', { name: '选择紧凑' })
)
await vi.waitFor(() => expect(getCookie('sidebar_state')).toBe('false'))
await userEvent.click(
screen.getByRole('button', {
name: '恢复默认布局',
})
)
await vi.waitFor(() => expect(getCookie('sidebar_state')).toBe('true'))
await vi.waitFor(() =>
expect(getCookie('layout_collapsible')).toBe('icon')
)
})
})
it('changes direction and applies it to <html dir>', async () => {
const screen = await renderConfigDrawer()
await openDrawer(screen)
await userEvent.click(
screen.getByRole('radio', { name: '选择从右到左' })
)
await vi.waitFor(() =>
expect(document.documentElement.getAttribute('dir')).toBe('rtl')
)
expect(getCookie('dir')).toBe('rtl')
})
it('updates layout: selecting non-default closes sidebar and changes layout cookie', async () => {
const screen = await renderConfigDrawer({ sidebarDefaultOpen: true })
await openDrawer(screen)
await expect
.element(screen.getByRole('radio', { name: '选择默认' }))
.toHaveAttribute('data-state', 'checked')
await userEvent.click(
screen.getByRole('radio', { name: '选择紧凑' })
)
await vi.waitFor(() => expect(getCookie('sidebar_state')).toBe('false'))
await vi.waitFor(() => expect(getCookie('layout_collapsible')).toBe('icon'))
})
it('reset restores defaults across sidebar/theme/layout/direction', async () => {
const screen = await renderConfigDrawer({ sidebarDefaultOpen: true })
await openDrawer(screen)
await userEvent.click(screen.getByRole('radio', { name: '选择深色' }))
await userEvent.click(
screen.getByRole('radio', { name: '选择从右到左' })
)
await userEvent.click(
screen.getByRole('radio', { name: '选择浮动' })
)
await userEvent.click(
screen.getByRole('radio', { name: '选择全宽' })
)
await vi.waitFor(() => expect(getCookie('vite-ui-theme')).toBe('dark'))
await vi.waitFor(() => expect(getCookie('dir')).toBe('rtl'))
await vi.waitFor(() => expect(getCookie('layout_variant')).toBe('floating'))
await vi.waitFor(() =>
expect(getCookie('layout_collapsible')).toBe('offcanvas')
)
await userEvent.click(
screen.getByRole('button', {
name: '恢复默认界面设置',
})
)
await vi.waitFor(() => expect(getCookie('sidebar_state')).toBe('true'))
await vi.waitFor(() => expect(getCookie('dir')).toBeUndefined())
await vi.waitFor(() => expect(getCookie('vite-ui-theme')).toBeUndefined())
await vi.waitFor(() => expect(getCookie('layout_variant')).toBe('inset'))
await vi.waitFor(() => expect(getCookie('layout_collapsible')).toBe('icon'))
await vi.waitFor(() =>
expect(document.documentElement.getAttribute('dir')).toBe('ltr')
)
})
})
@@ -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='打开界面设置'
className='rounded-full'
>
<Settings aria-hidden='true' />
</Button>
</SheetTrigger>
<SheetContent className='flex flex-col'>
<SheetHeader className='pb-0 text-start'>
<SheetTitle></SheetTitle>
<SheetDescription>
</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='恢复默认界面设置'
>
</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={`选择${item.label}`}
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}预览`}
>
<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='主题'
showReset={theme !== defaultTheme}
onReset={() => setTheme(defaultTheme)}
resetAriaLabel='恢复默认主题'
/>
<Radio
value={theme}
onValueChange={setTheme}
className='grid w-full max-w-md grid-cols-3 gap-4'
aria-label='选择主题'
aria-describedby='theme-description'
>
{[
{
value: 'system',
label: '跟随系统',
icon: IconThemeSystem,
},
{
value: 'light',
label: '浅色',
icon: IconThemeLight,
},
{
value: 'dark',
label: '深色',
icon: IconThemeDark,
},
].map((item) => (
<RadioGroupItem key={item.value} item={item} isTheme />
))}
</Radio>
<div id='theme-description' className='sr-only'>
</div>
</div>
)
}
function SidebarConfig() {
const { defaultVariant, variant, setVariant } = useLayout()
return (
<div className='max-md:hidden'>
<SectionTitle
title='侧边栏'
showReset={defaultVariant !== variant}
onReset={() => setVariant(defaultVariant)}
resetAriaLabel='恢复默认侧边栏样式'
/>
<Radio
value={variant}
onValueChange={setVariant}
className='grid w-full max-w-md grid-cols-3 gap-4'
aria-label='选择侧边栏样式'
aria-describedby='sidebar-description'
>
{[
{
value: 'inset',
label: '内嵌',
icon: IconSidebarInset,
},
{
value: 'floating',
label: '浮动',
icon: IconSidebarFloating,
},
{
value: 'sidebar',
label: '标准',
icon: IconSidebarSidebar,
},
].map((item) => (
<RadioGroupItem key={item.value} item={item} />
))}
</Radio>
<div id='sidebar-description' className='sr-only'>
</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='布局'
showReset={radioState !== 'default'}
onReset={() => {
setOpen(true)
setCollapsible(defaultCollapsible)
}}
resetAriaLabel='恢复默认布局'
/>
<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='选择布局'
aria-describedby='layout-description'
>
{[
{
value: 'default',
label: '默认',
icon: IconLayoutDefault,
},
{
value: 'icon',
label: '紧凑',
icon: IconLayoutCompact,
},
{
value: 'offcanvas',
label: '全宽',
icon: IconLayoutFull,
},
].map((item) => (
<RadioGroupItem key={item.value} item={item} />
))}
</Radio>
<div id='layout-description' className='sr-only'>
</div>
</div>
)
}
function DirConfig() {
const { defaultDir, dir, setDir } = useDirection()
return (
<div>
<SectionTitle
title='文字方向'
showReset={defaultDir !== dir}
onReset={() => setDir(defaultDir)}
resetAriaLabel='恢复默认文字方向'
/>
<Radio
value={dir}
onValueChange={setDir}
className='grid w-full max-w-md grid-cols-3 gap-4'
aria-label='选择文字方向'
aria-describedby='direction-description'
>
{[
{
value: 'ltr',
label: '从左到右',
icon: (props: SVGProps<SVGSVGElement>) => (
<IconDir dir='ltr' {...props} />
),
},
{
value: 'rtl',
label: '从右到左',
icon: (props: SVGProps<SVGSVGElement>) => (
<IconDir dir='rtl' {...props} />
),
},
].map((item) => (
<RadioGroupItem key={item.value} item={item} />
))}
</Radio>
<div id='direction-description' className='sr-only'>
</div>
</div>
)
}
@@ -0,0 +1,218 @@
import type { SubmitEvent } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-react'
import { userEvent } from 'vitest/browser'
import { ConfirmDialog } from './confirm-dialog'
describe('ConfirmDialog', () => {
it('renders title, description, and default buttons', async () => {
const { getByRole, getByText } = await render(
<ConfirmDialog
open
onOpenChange={vi.fn()}
title='Delete item'
desc='This action cannot be undone.'
handleConfirm={vi.fn()}
/>
)
await expect
.element(getByRole('heading', { name: 'Delete item' }))
.toBeInTheDocument()
await expect
.element(getByText('This action cannot be undone.'))
.toBeInTheDocument()
await expect
.element(getByRole('button', { name: 'Cancel' }))
.toBeInTheDocument()
await expect
.element(getByRole('button', { name: 'Continue' }))
.toBeInTheDocument()
})
it('calls handleConfirm when the confirm button is clicked', async () => {
const handleConfirm = vi.fn()
const { getByRole } = await render(
<ConfirmDialog
open
onOpenChange={vi.fn()}
title='Sign out'
desc='Are you sure?'
confirmText='Sign out'
handleConfirm={handleConfirm}
/>
)
await userEvent.click(getByRole('button', { name: 'Sign out' }))
expect(handleConfirm).toHaveBeenCalledOnce()
})
it('disables confirm when disabled is true', async () => {
const handleConfirm = vi.fn()
const { getByRole } = await render(
<ConfirmDialog
open
onOpenChange={vi.fn()}
title='Danger'
desc='...'
disabled
handleConfirm={handleConfirm}
/>
)
const confirm = getByRole('button', { name: 'Continue' })
await expect.element(confirm).toBeDisabled()
expect(handleConfirm).not.toHaveBeenCalled()
})
it('when isLoading is true, disables cancel and confirm', async () => {
const handleConfirm = vi.fn()
const { getByRole } = await render(
<ConfirmDialog
open
onOpenChange={vi.fn()}
title='Loading'
desc='...'
isLoading
handleConfirm={handleConfirm}
/>
)
await expect.element(getByRole('button', { name: 'Cancel' })).toBeDisabled()
await expect
.element(getByRole('button', { name: 'Continue' }))
.toBeDisabled()
})
it('supports custom button texts', async () => {
const { getByRole } = await render(
<ConfirmDialog
open
onOpenChange={vi.fn()}
title='Delete'
desc='...'
cancelBtnText='No'
confirmText='Yes'
handleConfirm={vi.fn()}
/>
)
await expect
.element(getByRole('button', { name: 'No' }))
.toBeInTheDocument()
await expect
.element(getByRole('button', { name: 'Yes' }))
.toBeInTheDocument()
})
it('renders confirm as submit button linked to desc form when `form` is set', async () => {
const { getByRole } = await render(
<ConfirmDialog
open
onOpenChange={vi.fn()}
title='Delete tasks'
form='tasks-multi-delete-form'
desc={
<form id='tasks-multi-delete-form' className='space-y-4'>
<p>Type DELETE to confirm.</p>
</form>
}
confirmText='Delete'
destructive
/>
)
const deleteBtn = getByRole('button', { name: 'Delete' })
await expect.element(deleteBtn).toHaveAttribute('type', 'submit')
await expect
.element(deleteBtn)
.toHaveAttribute('form', 'tasks-multi-delete-form')
})
it('submits the desc form when confirm is clicked (form prop, no handleConfirm)', async () => {
const handleFormSubmit = vi.fn((e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault()
})
const { getByRole } = await render(
<ConfirmDialog
open
onOpenChange={vi.fn()}
title='Delete'
form='users-delete-form'
desc={
<form
id='users-delete-form'
onSubmit={handleFormSubmit}
className='space-y-4'
>
<p>Confirm deletion.</p>
</form>
}
confirmText='Delete'
destructive
/>
)
await userEvent.click(getByRole('button', { name: 'Delete' }))
expect(handleFormSubmit).toHaveBeenCalledOnce()
})
it('submits the form when Enter key is pressed', async () => {
const handleFormSubmit = vi.fn((e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault()
})
const { getByPlaceholder } = await render(
<ConfirmDialog
open
onOpenChange={vi.fn()}
title='Delete'
form='users-delete-form'
desc={
<form
id='users-delete-form'
onSubmit={handleFormSubmit}
className='space-y-4'
>
<input type='text' name='username' placeholder='username' />
</form>
}
confirmText='Delete'
destructive
/>
)
await userEvent.fill(getByPlaceholder('username'), 'test')
await userEvent.keyboard('{Enter}')
expect(handleFormSubmit).toHaveBeenCalledOnce()
})
it('does not submit the form when confirm is disabled (typed confirmation mismatch)', async () => {
const handleFormSubmit = vi.fn((e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault()
})
const { getByRole } = await render(
<ConfirmDialog
open
onOpenChange={vi.fn()}
title='Delete'
form='users-delete-form'
disabled
desc={
<form id='users-delete-form' onSubmit={handleFormSubmit}>
<p>Enter username to enable Delete.</p>
</form>
}
confirmText='Delete'
destructive
/>
)
const deleteBtn = getByRole('button', { name: 'Delete' })
await expect.element(deleteBtn).toBeDisabled()
expect(handleFormSubmit).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,72 @@
import { cn } from '@/lib/utils'
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
type ConfirmDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
title: React.ReactNode
disabled?: boolean
desc: React.JSX.Element | string
cancelBtnText?: string
confirmText?: React.ReactNode
destructive?: boolean
isLoading?: boolean
className?: string
children?: React.ReactNode
} & (
| { form: string; handleConfirm?: undefined }
| { form?: undefined; handleConfirm: () => void }
)
export function ConfirmDialog(props: ConfirmDialogProps) {
const {
title,
desc,
children,
className,
confirmText,
cancelBtnText,
destructive,
isLoading,
disabled = false,
form,
handleConfirm,
...actions
} = props
return (
<AlertDialog {...actions}>
<AlertDialogContent className={cn(className && className)}>
<AlertDialogHeader className='text-start'>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription asChild>
<div>{desc}</div>
</AlertDialogDescription>
</AlertDialogHeader>
{children}
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}>
{cancelBtnText ?? 'Cancel'}
</AlertDialogCancel>
<Button
type={form ? 'submit' : 'button'}
form={form}
onClick={handleConfirm}
variant={destructive ? 'destructive' : 'default'}
disabled={disabled || isLoading}
>
{confirmText ?? 'Continue'}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
@@ -0,0 +1,213 @@
import { useState, useEffect, useRef } from 'react'
import { type Table } from '@tanstack/react-table'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
type DataTableBulkActionsProps<TData> = {
table: Table<TData>
entityName: string
children: React.ReactNode
}
/**
* A modular toolbar for displaying bulk actions when table rows are selected.
*
* @template TData The type of data in the table.
* @param {object} props The component props.
* @param {Table<TData>} props.table The react-table instance.
* @param {string} props.entityName The name of the entity being acted upon (e.g., "task", "user").
* @param {React.ReactNode} props.children The action buttons to be rendered inside the toolbar.
* @returns {React.ReactNode | null} The rendered component or null if no rows are selected.
*/
export function DataTableBulkActions<TData>({
table,
entityName,
children,
}: DataTableBulkActionsProps<TData>): React.ReactNode | null {
const selectedRows = table.getFilteredSelectedRowModel().rows
const selectedCount = selectedRows.length
const toolbarRef = useRef<HTMLDivElement>(null)
const [announcement, setAnnouncement] = useState('')
// Announce selection changes to screen readers
useEffect(() => {
if (selectedCount > 0) {
const message = `${selectedCount} ${entityName}${selectedCount > 1 ? 's' : ''} selected. Bulk actions toolbar is available.`
// Use queueMicrotask to defer state update and avoid cascading renders
queueMicrotask(() => {
setAnnouncement(message)
})
// Clear announcement after a delay
const timer = setTimeout(() => setAnnouncement(''), 3000)
return () => clearTimeout(timer)
}
}, [selectedCount, entityName])
const handleClearSelection = () => {
table.resetRowSelection()
}
const handleKeyDown = (event: React.KeyboardEvent) => {
const buttons = toolbarRef.current?.querySelectorAll('button')
if (!buttons) return
const currentIndex = Array.from(buttons).findIndex(
(button) => button === document.activeElement
)
switch (event.key) {
case 'ArrowRight': {
event.preventDefault()
const nextIndex = (currentIndex + 1) % buttons.length
buttons[nextIndex]?.focus()
break
}
case 'ArrowLeft': {
event.preventDefault()
const prevIndex =
currentIndex === 0 ? buttons.length - 1 : currentIndex - 1
buttons[prevIndex]?.focus()
break
}
case 'Home':
event.preventDefault()
buttons[0]?.focus()
break
case 'End':
event.preventDefault()
buttons[buttons.length - 1]?.focus()
break
case 'Escape': {
// Check if the Escape key came from a dropdown trigger or content
// We can't check dropdown state because Radix UI closes it before our handler runs
const target = event.target as HTMLElement
const activeElement = document.activeElement as HTMLElement
// Check if the event target or currently focused element is a dropdown trigger
const isFromDropdownTrigger =
target?.getAttribute('data-slot') === 'dropdown-menu-trigger' ||
activeElement?.getAttribute('data-slot') ===
'dropdown-menu-trigger' ||
target?.closest('[data-slot="dropdown-menu-trigger"]') ||
activeElement?.closest('[data-slot="dropdown-menu-trigger"]')
// Check if the focused element is inside dropdown content (which is portaled)
const isFromDropdownContent =
activeElement?.closest('[data-slot="dropdown-menu-content"]') ||
target?.closest('[data-slot="dropdown-menu-content"]')
if (isFromDropdownTrigger || isFromDropdownContent) {
// Escape was meant for the dropdown - don't clear selection
return
}
// Escape was meant for the toolbar - clear selection
event.preventDefault()
handleClearSelection()
break
}
}
}
if (selectedCount === 0) {
return null
}
return (
<>
{/* Live region for screen reader announcements */}
<div
aria-live='polite'
aria-atomic='true'
className='sr-only'
role='status'
>
{announcement}
</div>
<div
ref={toolbarRef}
role='toolbar'
aria-label={`Bulk actions for ${selectedCount} selected ${entityName}${selectedCount > 1 ? 's' : ''}`}
aria-describedby='bulk-actions-description'
tabIndex={-1}
onKeyDown={handleKeyDown}
className={cn(
'fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-xl',
'transition-all delay-100 duration-300 ease-out hover:scale-105',
'focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none'
)}
>
<div
className={cn(
'p-2 shadow-xl',
'rounded-xl border',
'bg-background/95 backdrop-blur-lg supports-backdrop-filter:bg-background/60',
'flex items-center gap-x-2'
)}
>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='outline'
size='icon'
onClick={handleClearSelection}
className='size-6 rounded-full'
aria-label='Clear selection'
title='Clear selection (Escape)'
>
<X />
<span className='sr-only'>Clear selection</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Clear selection (Escape)</p>
</TooltipContent>
</Tooltip>
<Separator
className='h-5'
orientation='vertical'
aria-hidden='true'
/>
<div
className='flex items-center gap-x-1 text-sm'
id='bulk-actions-description'
>
<Badge
variant='default'
className='min-w-8 rounded-lg'
aria-label={`${selectedCount} selected`}
>
{selectedCount}
</Badge>{' '}
<span className='hidden sm:inline'>
{entityName}
{selectedCount > 1 ? 's' : ''}
</span>{' '}
selected
</div>
<Separator
className='h-5'
orientation='vertical'
aria-hidden='true'
/>
{children}
</div>
</div>
</>
)
}
@@ -0,0 +1,74 @@
import {
ArrowDownIcon,
ArrowUpIcon,
CaretSortIcon,
EyeNoneIcon,
} from '@radix-ui/react-icons'
import { type Column } from '@tanstack/react-table'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
type DataTableColumnHeaderProps<TData, TValue> =
React.HTMLAttributes<HTMLDivElement> & {
column: Column<TData, TValue>
title: string
}
export function DataTableColumnHeader<TData, TValue>({
column,
title,
className,
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>
}
return (
<div className={cn('flex items-center space-x-2', className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
size='sm'
className='h-8 data-[state=open]:bg-accent'
>
<span>{title}</span>
{column.getIsSorted() === 'desc' ? (
<ArrowDownIcon className='ms-2 h-4 w-4' />
) : column.getIsSorted() === 'asc' ? (
<ArrowUpIcon className='ms-2 h-4 w-4' />
) : (
<CaretSortIcon className='ms-2 h-4 w-4' />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='start'>
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUpIcon className='size-3.5 text-muted-foreground/70' />
Asc
</DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDownIcon className='size-3.5 text-muted-foreground/70' />
Desc
</DropdownMenuItem>
{column.getCanHide() && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
<EyeNoneIcon className='size-3.5 text-muted-foreground/70' />
Hide
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
@@ -0,0 +1,146 @@
import * as React from 'react'
import { CheckIcon, PlusCircledIcon } from '@radix-ui/react-icons'
import { type Column } from '@tanstack/react-table'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { Separator } from '@/components/ui/separator'
type DataTableFacetedFilterProps<TData, TValue> = {
column?: Column<TData, TValue>
title?: string
options: {
label: string
value: string
icon?: React.ComponentType<{ className?: string }>
}[]
}
export function DataTableFacetedFilter<TData, TValue>({
column,
title,
options,
}: DataTableFacetedFilterProps<TData, TValue>) {
const facets = column?.getFacetedUniqueValues()
const selectedValues = new Set(column?.getFilterValue() as string[])
return (
<Popover>
<PopoverTrigger asChild>
<Button variant='outline' size='sm' className='h-8 border-dashed'>
<PlusCircledIcon className='size-4' />
{title}
{selectedValues?.size > 0 && (
<>
<Separator orientation='vertical' className='mx-2 h-4' />
<Badge
variant='secondary'
className='rounded-sm px-1 font-normal lg:hidden'
>
{selectedValues.size}
</Badge>
<div className='hidden space-x-1 lg:flex'>
{selectedValues.size > 2 ? (
<Badge
variant='secondary'
className='rounded-sm px-1 font-normal'
>
{selectedValues.size} selected
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge
variant='secondary'
key={option.value}
className='rounded-sm px-1 font-normal'
>
{option.label}
</Badge>
))
)}
</div>
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent className='w-50 p-0' align='start'>
<Command>
<CommandInput placeholder={title} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const isSelected = selectedValues.has(option.value)
return (
<CommandItem
key={option.value}
onSelect={() => {
if (isSelected) {
selectedValues.delete(option.value)
} else {
selectedValues.add(option.value)
}
const filterValues = Array.from(selectedValues)
column?.setFilterValue(
filterValues.length ? filterValues : undefined
)
}}
>
<div
className={cn(
'flex size-4 items-center justify-center rounded-sm border border-primary',
isSelected
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
)}
>
<CheckIcon className={cn('h-4 w-4 text-background')} />
</div>
{option.icon && (
<option.icon className='size-4 text-muted-foreground' />
)}
<span>{option.label}</span>
{facets?.get(option.value) && (
<span className='ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs'>
{facets.get(option.value)}
</span>
)}
</CommandItem>
)
})}
</CommandGroup>
{selectedValues.size > 0 && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => column?.setFilterValue(undefined)}
className='justify-center text-center'
>
Clear filters
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,4 @@
export { DataTablePagination } from './pagination'
export { DataTableColumnHeader } from './column-header'
export { DataTableToolbar } from './toolbar'
export { DataTableBulkActions } from './bulk-actions'
@@ -0,0 +1,130 @@
import {
ChevronLeftIcon,
ChevronRightIcon,
DoubleArrowLeftIcon,
DoubleArrowRightIcon,
} from '@radix-ui/react-icons'
import { type Table } from '@tanstack/react-table'
import { cn, getPageNumbers } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
type DataTablePaginationProps<TData> = {
table: Table<TData>
className?: string
}
export function DataTablePagination<TData>({
table,
className,
}: DataTablePaginationProps<TData>) {
const currentPage = table.getState().pagination.pageIndex + 1
const totalPages = table.getPageCount()
const pageNumbers = getPageNumbers(currentPage, totalPages)
return (
<div
className={cn(
'flex items-center justify-between overflow-clip px-2',
'@max-2xl/content:flex-col-reverse @max-2xl/content:gap-4',
className
)}
style={{ overflowClipMargin: 1 }}
>
<div className='flex w-full items-center justify-between'>
<div className='flex w-25 items-center justify-center text-sm font-medium @2xl/content:hidden'>
Page {currentPage} of {totalPages}
</div>
<div className='flex items-center gap-2 @max-2xl/content:flex-row-reverse'>
<Select
value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => {
table.setPageSize(Number(value))
}}
>
<SelectTrigger className='h-8 w-17.5'>
<SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger>
<SelectContent side='top'>
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectContent>
</Select>
<p className='hidden text-sm font-medium sm:block'>Rows per page</p>
</div>
</div>
<div className='flex items-center sm:space-x-6 lg:space-x-8'>
<div className='flex w-25 items-center justify-center text-sm font-medium @max-3xl/content:hidden'>
Page {currentPage} of {totalPages}
</div>
<div className='flex items-center space-x-2'>
<Button
variant='outline'
className='size-8 p-0 @max-md/content:hidden'
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
>
<span className='sr-only'>Go to first page</span>
<DoubleArrowLeftIcon className='h-4 w-4' />
</Button>
<Button
variant='outline'
className='size-8 p-0'
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className='sr-only'>Go to previous page</span>
<ChevronLeftIcon className='h-4 w-4' />
</Button>
{/* Page number buttons */}
{pageNumbers.map((pageNumber, index) => (
<div key={`${pageNumber}-${index}`} className='flex items-center'>
{pageNumber === '...' ? (
<span className='px-1 text-sm text-muted-foreground'>...</span>
) : (
<Button
variant={currentPage === pageNumber ? 'default' : 'outline'}
className='h-8 min-w-8 px-2'
onClick={() => table.setPageIndex((pageNumber as number) - 1)}
>
<span className='sr-only'>Go to page {pageNumber}</span>
{pageNumber}
</Button>
)}
</div>
))}
<Button
variant='outline'
className='size-8 p-0'
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className='sr-only'>Go to next page</span>
<ChevronRightIcon className='h-4 w-4' />
</Button>
<Button
variant='outline'
className='size-8 p-0 @max-md/content:hidden'
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
>
<span className='sr-only'>Go to last page</span>
<DoubleArrowRightIcon className='h-4 w-4' />
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,85 @@
import { Cross2Icon } from '@radix-ui/react-icons'
import { type Table } from '@tanstack/react-table'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { DataTableFacetedFilter } from './faceted-filter'
import { DataTableViewOptions } from './view-options'
type DataTableToolbarProps<TData> = {
table: Table<TData>
searchPlaceholder?: string
searchKey?: string
filters?: {
columnId: string
title: string
options: {
label: string
value: string
icon?: React.ComponentType<{ className?: string }>
}[]
}[]
}
export function DataTableToolbar<TData>({
table,
searchPlaceholder = 'Filter...',
searchKey,
filters = [],
}: DataTableToolbarProps<TData>) {
const isFiltered =
table.getState().columnFilters.length > 0 || table.getState().globalFilter
return (
<div className='flex items-center justify-between'>
<div className='flex flex-1 flex-col-reverse items-start gap-y-2 sm:flex-row sm:items-center sm:space-x-2'>
{searchKey ? (
<Input
placeholder={searchPlaceholder}
value={
(table.getColumn(searchKey)?.getFilterValue() as string) ?? ''
}
onChange={(event) =>
table.getColumn(searchKey)?.setFilterValue(event.target.value)
}
className='h-8 w-37.5 lg:w-62.5'
/>
) : (
<Input
placeholder={searchPlaceholder}
value={table.getState().globalFilter ?? ''}
onChange={(event) => table.setGlobalFilter(event.target.value)}
className='h-8 w-37.5 lg:w-62.5'
/>
)}
<div className='flex gap-x-2'>
{filters.map((filter) => {
const column = table.getColumn(filter.columnId)
if (!column) return null
return (
<DataTableFacetedFilter
key={filter.columnId}
column={column}
title={filter.title}
options={filter.options}
/>
)
})}
</div>
{isFiltered && (
<Button
variant='ghost'
onClick={() => {
table.resetColumnFilters()
table.setGlobalFilter('')
}}
className='h-8 px-2 lg:px-3'
>
Reset
<Cross2Icon className='ms-2 h-4 w-4' />
</Button>
)}
</div>
<DataTableViewOptions table={table} />
</div>
)
}
@@ -0,0 +1,56 @@
import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu'
import { MixerHorizontalIcon } from '@radix-ui/react-icons'
import { type Table } from '@tanstack/react-table'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu'
type DataTableViewOptionsProps<TData> = {
table: Table<TData>
}
export function DataTableViewOptions<TData>({
table,
}: DataTableViewOptionsProps<TData>) {
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant='outline'
size='sm'
className='ms-auto hidden h-8 lg:flex'
>
<MixerHorizontalIcon className='size-4' />
View
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-37.5'>
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
<DropdownMenuSeparator />
{table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== 'undefined' && column.getCanHide()
)
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className='capitalize'
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{column.id}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,51 @@
import { format } from 'date-fns'
import { Calendar as CalendarIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
type DatePickerProps = {
selected: Date | undefined
onSelect: (date: Date | undefined) => void
placeholder?: string
}
export function DatePicker({
selected,
onSelect,
placeholder = 'Pick a date',
}: DatePickerProps) {
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant='outline'
data-empty={!selected}
className='w-60 justify-start text-start font-normal data-[empty=true]:text-muted-foreground'
>
{selected ? (
format(selected, 'MMM d, yyyy')
) : (
<span>{placeholder}</span>
)}
<CalendarIcon className='ms-auto h-4 w-4 opacity-50' />
</Button>
</PopoverTrigger>
<PopoverContent className='w-auto p-0'>
<Calendar
mode='single'
captionLayout='dropdown'
selected={selected}
onSelect={onSelect}
disabled={(date: Date) =>
date > new Date() || date < new Date('1900-01-01')
}
/>
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,80 @@
import { useLayout } from '@/context/layout-provider'
import { canAccess } from '@/lib/permissions'
import { useAuthStore } from '@/stores/auth-store'
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'
import { type NavGroup as NavGroupType, type NavItem } from './types'
function filterNavItem(
item: NavItem,
permissions: Record<string, string[]> | undefined
): NavItem | null {
if (!canAccess(permissions, item.permission)) {
return null
}
if (!item.items) {
return item
}
const items = item.items.filter((subItem) =>
canAccess(permissions, subItem.permission)
)
if (!items.length) {
return null
}
return { ...item, items }
}
function filterNavGroups(
groups: NavGroupType[],
permissions: Record<string, string[]> | undefined
) {
return groups
.map((group) => ({
...group,
items: group.items
.map((item) => filterNavItem(item, permissions))
.filter((item): item is NavItem => Boolean(item)),
}))
.filter((group) => group.items.length)
}
export function AppSidebar() {
const { collapsible, variant } = useLayout()
const permissions = useAuthStore((state) => state.auth.user?.permissions)
const navGroups = filterNavGroups(sidebarData.navGroups, permissions)
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>
{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,161 @@
import {
ClipboardList,
Cable,
Home,
LayoutGrid,
PackageSearch,
ReceiptText,
ScrollText,
Settings,
ShieldCheck,
TicketCheck,
Users,
} from 'lucide-react'
import { type SidebarData } from '../types'
export const sidebarData: SidebarData = {
user: {
name: '管理员',
email: 'root@local.dev',
avatar: '',
},
teams: [
{
name: 'Work Admin',
logo: LayoutGrid,
plan: '商城后台',
},
],
navGroups: [
{
title: '工作台',
items: [
{
title: '后台首页',
url: '/',
icon: Home,
},
],
},
{
title: '商城业务',
items: [
{
title: '商品管理',
icon: PackageSearch,
items: [
{
title: '分类管理',
url: '/catalog/categories',
permission: {
resource: 'catalog/categories',
action: 'read',
},
},
{
title: '商品列表',
url: '/catalog/products',
permission: {
resource: 'catalog/products',
action: 'read',
},
},
],
},
{
title: '订单管理',
icon: ReceiptText,
items: [
{
title: '订单列表',
url: '/orders',
permission: {
resource: 'orders',
action: 'read',
},
},
{
title: '查课下单',
url: '/course-query',
permission: {
resource: 'orders',
action: 'write',
},
},
{
title: '自营下单',
url: '/self-owned-order',
permission: {
resource: 'orders',
action: 'write',
},
},
],
},
{
title: '工单管理',
url: '/tickets',
icon: TicketCheck,
},
{
title: '日志查询',
url: '/order-logs',
icon: ClipboardList,
permission: {
resource: 'order-logs',
action: 'read',
},
},
],
},
{
title: '系统管理',
items: [
{
title: '用户与权限',
icon: ShieldCheck,
items: [
{
title: '用户管理',
url: '/system/users',
icon: Users,
permission: {
resource: 'admin/users',
action: 'read',
},
},
],
},
{
title: '安全审计',
icon: ScrollText,
items: [
{
title: '第三方接口',
url: '/system/third-party',
icon: Cable,
permission: {
resource: 'admin/third-party',
action: 'read',
},
},
{
title: '操作日志',
url: '/system/audit-logs',
icon: ScrollText,
permission: {
resource: 'admin/audit-logs',
action: 'read',
},
},
],
},
{
title: '账号安全',
url: '/settings/account',
icon: Settings,
},
],
},
],
}
@@ -0,0 +1,50 @@
import { useEffect, useState } from 'react'
import { cn } from '@/lib/utils'
import { Separator } from '@/components/ui/separator'
import { SidebarTrigger } from '@/components/ui/sidebar'
type HeaderProps = React.HTMLAttributes<HTMLElement> & {
fixed?: boolean
ref?: React.Ref<HTMLElement>
}
export function Header({ className, fixed, children, ...props }: HeaderProps) {
const [offset, setOffset] = useState(0)
useEffect(() => {
const onScroll = () => {
setOffset(document.body.scrollTop || document.documentElement.scrollTop)
}
// Add scroll listener to the body
document.addEventListener('scroll', onScroll, { passive: true })
// Clean up the event listener on unmount
return () => document.removeEventListener('scroll', onScroll)
}, [])
return (
<header
className={cn(
'z-50 h-16',
fixed && 'header-fixed peer/header sticky top-0 w-[inherit]',
offset > 10 && fixed ? 'shadow' : 'shadow-none',
className
)}
{...props}
>
<div
className={cn(
'relative flex h-full items-center gap-3 p-4 sm:gap-4',
offset > 10 &&
fixed &&
'after:absolute after:inset-0 after:-z-10 after:bg-background/20 after:backdrop-blur-lg'
)}
>
<SidebarTrigger variant='outline' className='max-md:scale-125' />
<Separator orientation='vertical' className='h-6' />
{children}
</div>
</header>
)
}
@@ -0,0 +1,27 @@
import { cn } from '@/lib/utils'
type MainProps = React.HTMLAttributes<HTMLElement> & {
fixed?: boolean
fluid?: boolean
ref?: React.Ref<HTMLElement>
}
export function Main({ fixed, className, fluid, ...props }: MainProps) {
return (
<main
data-layout={fixed ? 'fixed' : 'auto'}
className={cn(
'px-4 py-6',
// If layout is fixed, make the main container flex and grow
fixed && 'flex grow flex-col overflow-hidden',
// If layout is not fluid, set the max-width
!fluid &&
'@7xl/content:mx-auto @7xl/content:w-full @7xl/content:max-w-7xl',
className
)}
{...props}
/>
)
}
@@ -0,0 +1,185 @@
import { type ReactNode } from 'react'
import { Link, useLocation } from '@tanstack/react-router'
import { ChevronRight } from 'lucide-react'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
useSidebar,
} from '@/components/ui/sidebar'
import { Badge } from '../ui/badge'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '../ui/dropdown-menu'
import {
type NavCollapsible,
type NavItem,
type NavLink,
type NavGroup as NavGroupProps,
} from './types'
export function NavGroup({ title, items }: NavGroupProps) {
const { state, isMobile } = useSidebar()
const href = useLocation({ select: (location) => location.href })
return (
<SidebarGroup>
<SidebarGroupLabel>{title}</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => {
const key = `${item.title}-${item.url}`
if (!item.items)
return <SidebarMenuLink key={key} item={item} href={href} />
if (state === 'collapsed' && !isMobile)
return (
<SidebarMenuCollapsedDropdown key={key} item={item} href={href} />
)
return <SidebarMenuCollapsible key={key} item={item} href={href} />
})}
</SidebarMenu>
</SidebarGroup>
)
}
function NavBadge({ children }: { children: ReactNode }) {
return <Badge className='rounded-full px-1 py-0 text-xs'>{children}</Badge>
}
function SidebarMenuLink({ item, href }: { item: NavLink; href: string }) {
const { setOpenMobile } = useSidebar()
return (
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={checkIsActive(href, item)}
tooltip={item.title}
>
<Link to={item.url} onClick={() => setOpenMobile(false)}>
{item.icon && <item.icon />}
<span>{item.title}</span>
{item.badge && <NavBadge>{item.badge}</NavBadge>}
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)
}
function SidebarMenuCollapsible({
item,
href,
}: {
item: NavCollapsible
href: string
}) {
const { setOpenMobile } = useSidebar()
return (
<Collapsible
asChild
defaultOpen={checkIsActive(href, item, true)}
className='group/collapsible'
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={item.title}>
{item.icon && <item.icon />}
<span>{item.title}</span>
{item.badge && <NavBadge>{item.badge}</NavBadge>}
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90 rtl:rotate-180' />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent className='CollapsibleContent'>
<SidebarMenuSub>
{item.items.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton
asChild
isActive={checkIsActive(href, subItem)}
>
<Link to={subItem.url} onClick={() => setOpenMobile(false)}>
{subItem.icon && <subItem.icon />}
<span>{subItem.title}</span>
{subItem.badge && <NavBadge>{subItem.badge}</NavBadge>}
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
)
}
function SidebarMenuCollapsedDropdown({
item,
href,
}: {
item: NavCollapsible
href: string
}) {
return (
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
tooltip={item.title}
isActive={checkIsActive(href, item)}
>
{item.icon && <item.icon />}
<span>{item.title}</span>
{item.badge && <NavBadge>{item.badge}</NavBadge>}
<ChevronRight className='ms-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent side='right' align='start' sideOffset={4}>
<DropdownMenuLabel>
{item.title} {item.badge ? `(${item.badge})` : ''}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{item.items.map((sub) => (
<DropdownMenuItem key={`${sub.title}-${sub.url}`} asChild>
<Link
to={sub.url}
className={`${checkIsActive(href, sub) ? 'bg-secondary' : ''}`}
>
{sub.icon && <sub.icon />}
<span className='max-w-52 text-wrap'>{sub.title}</span>
{sub.badge && (
<span className='ms-auto text-xs'>{sub.badge}</span>
)}
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
)
}
function checkIsActive(href: string, item: NavItem, mainNav = false) {
return (
href === item.url || // /endpint?search=param
href.split('?')[0] === item.url || // endpoint
!!item?.items?.filter((i) => i.url === href).length || // if child nav is active
(mainNav &&
href.split('/')[1] !== '' &&
href.split('/')[1] === item?.url?.split('/')[1])
)
}
@@ -0,0 +1,119 @@
import { Link } from '@tanstack/react-router'
import { BadgeCheck, ChevronsUpDown, LogOut, Users } from 'lucide-react'
import useDialogState from '@/hooks/use-dialog-state'
import { hasPermission } from '@/lib/permissions'
import { useAuthStore } from '@/stores/auth-store'
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()
const authUser = useAuthStore((state) => state.auth.user)
const displayUser = {
name: authUser?.name || user.name,
email: authUser?.email || user.email,
avatar: authUser?.avatar || user.avatar,
roleName: authUser?.roleName || '管理员',
}
const fallback = displayUser.name.slice(0, 1).toUpperCase() || '管'
const canReadUsers = hasPermission(authUser?.permissions, 'admin/users')
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={displayUser.avatar} alt={displayUser.name} />
<AvatarFallback className='rounded-lg'>{fallback}</AvatarFallback>
</Avatar>
<div className='grid flex-1 text-start text-sm leading-tight'>
<span className='truncate font-semibold'>{displayUser.name}</span>
<span className='truncate text-xs'>{displayUser.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={displayUser.avatar} alt={displayUser.name} />
<AvatarFallback className='rounded-lg'>{fallback}</AvatarFallback>
</Avatar>
<div className='grid flex-1 text-start text-sm leading-tight'>
<span className='truncate font-semibold'>
{displayUser.name}
</span>
<span className='truncate text-xs'>{displayUser.email}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<Link to='/settings/account'>
<BadgeCheck />
</Link>
</DropdownMenuItem>
{canReadUsers && (
<DropdownMenuItem asChild>
<Link to='/system/users'>
<Users />
</Link>
</DropdownMenuItem>
)}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
variant='destructive'
onClick={() => setOpen(true)}
>
<LogOut />
退
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
<SignOutDialog open={!!open} onOpenChange={setOpen} />
</>
)
}
@@ -0,0 +1,34 @@
import * as React from 'react'
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from '@/components/ui/sidebar'
type TeamSwitcherProps = {
teams: {
name: string
logo: React.ElementType
plan: string
}[]
}
export function TeamSwitcher({ teams }: TeamSwitcherProps) {
const activeTeam = teams[0]
return (
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size='lg' className='cursor-default'>
<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>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
)
}
@@ -0,0 +1,70 @@
import { Link } from '@tanstack/react-router'
import { Menu } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
type TopNavProps = React.HTMLAttributes<HTMLElement> & {
links: {
title: string
href: string
isActive: boolean
disabled?: boolean
}[]
}
export function TopNav({ className, links, ...props }: TopNavProps) {
return (
<>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
size='icon'
variant='outline'
className={cn('md:size-7 lg:hidden', className)}
>
<Menu />
<span className='sr-only'>Toggle navigation menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side='bottom' align='start'>
{links.map(({ title, href, isActive, disabled }) => (
<DropdownMenuItem key={`${title}-${href}`} asChild>
<Link
to={href}
className={!isActive ? 'text-muted-foreground' : ''}
disabled={disabled}
>
{title}
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<nav
className={cn(
'hidden items-center space-x-4 lg:flex lg:space-x-4 xl:space-x-6',
className
)}
{...props}
>
{links.map(({ title, href, isActive, disabled }) => (
<Link
key={`${title}-${href}`}
to={href}
disabled={disabled}
className={`text-sm font-medium transition-colors hover:text-primary ${isActive ? '' : 'text-muted-foreground'}`}
>
{title}
</Link>
))}
</nav>
</>
)
}
@@ -0,0 +1,48 @@
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
permission?: {
resource: string
action?: string
}
}
type NavLink = BaseNavItem & {
url: LinkProps['to'] | (string & {})
items?: never
}
type NavCollapsible = BaseNavItem & {
items: (BaseNavItem & { url: LinkProps['to'] | (string & {}) })[]
url?: never
}
type NavItem = NavCollapsible | NavLink
type NavGroup = {
title: string
items: NavItem[]
}
type SidebarData = {
user: User
teams: Team[]
navGroups: NavGroup[]
}
export type { SidebarData, NavGroup, NavItem, NavCollapsible, NavLink }
@@ -0,0 +1,44 @@
import { type Root, type Content, type Trigger } from '@radix-ui/react-popover'
import { CircleQuestionMark } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
type LearnMoreProps = React.ComponentProps<typeof Root> & {
contentProps?: React.ComponentProps<typeof Content>
triggerProps?: React.ComponentProps<typeof Trigger>
}
export function LearnMore({
children,
contentProps,
triggerProps,
...props
}: LearnMoreProps) {
return (
<Popover {...props}>
<PopoverTrigger
asChild
{...triggerProps}
className={cn('size-5 rounded-full', triggerProps?.className)}
>
<Button variant='outline' size='icon'>
<span className='sr-only'>Learn more</span>
<CircleQuestionMark className='size-4 [&>circle]:hidden' />
</Button>
</PopoverTrigger>
<PopoverContent
side='top'
align='start'
{...contentProps}
className={cn('text-sm text-muted-foreground', contentProps?.className)}
>
{children}
</PopoverContent>
</Popover>
)
}
@@ -0,0 +1,84 @@
import { useRef, useState } from 'react'
import { cn } from '@/lib/utils'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
type LongTextProps = {
children: React.ReactNode
className?: string
contentClassName?: string
}
export function LongText({
children,
className = '',
contentClassName = '',
}: LongTextProps) {
const ref = useRef<HTMLDivElement>(null)
const [isOverflown, setIsOverflown] = useState(false)
// Use ref callback to check overflow when element is mounted
const refCallback = (node: HTMLDivElement | null) => {
ref.current = node
if (node && checkOverflow(node)) {
queueMicrotask(() => setIsOverflown(true))
}
}
if (!isOverflown)
return (
<div ref={refCallback} className={cn('truncate', className)}>
{children}
</div>
)
return (
<>
<div className='hidden sm:block'>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<div ref={refCallback} className={cn('truncate', className)}>
{children}
</div>
</TooltipTrigger>
<TooltipContent>
<p className={contentClassName}>{children}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className='sm:hidden'>
<Popover>
<PopoverTrigger asChild>
<div ref={refCallback} className={cn('truncate', className)}>
{children}
</div>
</PopoverTrigger>
<PopoverContent className={cn('w-fit', contentClassName)}>
<p>{children}</p>
</PopoverContent>
</Popover>
</div>
</>
)
}
const checkOverflow = (textContainer: HTMLDivElement | null) => {
if (textContainer) {
return (
textContainer.offsetHeight < textContainer.scrollHeight ||
textContainer.offsetWidth < textContainer.scrollWidth
)
}
return false
}
@@ -0,0 +1,25 @@
import { useEffect, useRef } from 'react'
import { useRouterState } from '@tanstack/react-router'
import LoadingBar, { type LoadingBarRef } from 'react-top-loading-bar'
export function NavigationProgress() {
const ref = useRef<LoadingBarRef>(null)
const state = useRouterState()
useEffect(() => {
if (state.status === 'pending') {
ref.current?.continuousStart()
} else {
ref.current?.complete()
}
}, [state.status])
return (
<LoadingBar
color='var(--muted-foreground)'
ref={ref}
shadow={true}
height={2}
/>
)
}
@@ -0,0 +1,99 @@
import { useForm } from 'react-hook-form'
import { describe, expect, it } from 'vitest'
import { render } from 'vitest-browser-react'
import { userEvent } from 'vitest/browser'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
} from '@/components/ui/form'
import { PasswordInput } from './password-input'
describe('PasswordInput', () => {
it('renders the password input correctly', async () => {
const { getByPlaceholder, getByRole } = await render(
<PasswordInput placeholder='password' />
)
const passwordInput = getByPlaceholder('password')
const showPasswordButton = getByRole('button', { name: /show password/i })
await expect.element(passwordInput).toBeInTheDocument()
await expect.element(passwordInput).toHaveAttribute('type', 'password')
await expect.element(showPasswordButton).toBeVisible()
})
it('toggles the password visibility when the show password button is clicked', async () => {
const { getByPlaceholder, getByRole } = await render(
<PasswordInput placeholder='password' />
)
const passwordInput = getByPlaceholder('password')
const showPasswordButton = getByRole('button', { name: /show password/i })
await expect.element(passwordInput).toHaveAttribute('type', 'password')
await expect.element(showPasswordButton).toBeInTheDocument()
await userEvent.click(showPasswordButton)
await expect.element(passwordInput).toHaveAttribute('type', 'text')
const hidePasswordButton = getByRole('button', { name: /hide password/i })
await expect.element(hidePasswordButton).toBeInTheDocument()
await userEvent.click(hidePasswordButton)
await expect.element(passwordInput).toHaveAttribute('type', 'password')
await expect
.element(getByRole('button', { name: /show password/i }))
.toBeInTheDocument()
})
it('disables the show password button when the password input is disabled', async () => {
const { getByPlaceholder, getByRole } = await render(
<PasswordInput placeholder='password' disabled />
)
const passwordInput = getByPlaceholder('password')
const showPasswordButton = getByRole('button', { name: /show password/i })
await expect.element(showPasswordButton).toBeDisabled()
await expect.element(passwordInput).toBeDisabled()
})
it('works with FormLabel and react-hook-form field spread', async () => {
function PasswordInLabeledForm() {
const form = useForm<{ password: string }>({
defaultValues: { password: '' },
})
return (
<Form {...form}>
<form>
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
</FormItem>
)}
/>
</form>
</Form>
)
}
const { getByLabelText } = await render(<PasswordInLabeledForm />)
const password = getByLabelText(/^Password$/i)
await expect.element(password).toHaveAttribute('type', 'password')
await userEvent.type(password, 'secret-value')
await expect.element(password).toHaveValue('secret-value')
})
})
@@ -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 { BadgeCheck, LogOut, Users } from 'lucide-react'
import useDialogState from '@/hooks/use-dialog-state'
import { hasPermission } from '@/lib/permissions'
import { useAuthStore } from '@/stores/auth-store'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { SignOutDialog } from '@/components/sign-out-dialog'
export function ProfileDropdown() {
const [open, setOpen] = useDialogState()
const user = useAuthStore((state) => state.auth.user)
const name = user?.name || '管理员'
const email = user?.email || 'root@local.dev'
const fallback = name.slice(0, 1).toUpperCase() || '管'
const canReadUsers = hasPermission(user?.permissions, 'admin/users')
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={user?.avatar} alt={name} />
<AvatarFallback>{fallback}</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'>{name}</p>
<p className='text-xs leading-none text-muted-foreground'>
{email}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<Link to='/settings/account'>
<BadgeCheck />
</Link>
</DropdownMenuItem>
{canReadUsers && (
<DropdownMenuItem asChild>
<Link to='/system/users'>
<Users />
</Link>
</DropdownMenuItem>
)}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem variant='destructive' onClick={() => setOpen(true)}>
<LogOut />
退
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<SignOutDialog open={!!open} onOpenChange={setOpen} />
</>
)
}
@@ -0,0 +1,34 @@
import { SearchIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useSearch } from '@/context/search-provider'
import { Button } from './ui/button'
export function Search({
className = '',
placeholder = 'Search',
...props
}: React.ComponentProps<'button'> & { placeholder?: string }) {
const { setOpen } = useSearch()
return (
<Button
{...props}
variant='outline'
className={cn(
'group relative h-8 w-full flex-1 justify-start rounded-md bg-muted/25 text-sm font-normal text-muted-foreground shadow-none hover:bg-accent sm:w-40 sm:pe-12 md:flex-none lg:w-52 xl:w-64',
className
)}
aria-keyshortcuts='Meta+K Control+K'
onClick={() => setOpen(true)}
>
<SearchIcon
aria-hidden='true'
className='absolute inset-s-1.5 top-1/2 -translate-y-1/2'
size={16}
/>
<span className='ms-4'>{placeholder}</span>
<kbd className='pointer-events-none absolute inset-e-[0.3rem] top-[0.3rem] hidden h-5 items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 select-none group-hover:bg-accent sm:flex'>
<span className='text-xs'></span>K
</kbd>
</Button>
)
}
@@ -0,0 +1,62 @@
import { Loader } from 'lucide-react'
import { cn } from '@/lib/utils'
import { FormControl } from '@/components/ui/form'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
type SelectDropdownProps = {
onValueChange?: (value: string) => void
defaultValue: string | undefined
placeholder?: string
isPending?: boolean
items: { label: string; value: string }[] | undefined
disabled?: boolean
className?: string
isControlled?: boolean
}
export function SelectDropdown({
defaultValue,
onValueChange,
isPending,
items,
placeholder,
disabled,
className = '',
isControlled = false,
}: SelectDropdownProps) {
const defaultState = isControlled
? { value: defaultValue, onValueChange }
: { defaultValue, onValueChange }
return (
<Select {...defaultState}>
<FormControl>
<SelectTrigger disabled={disabled} className={cn(className)}>
<SelectValue placeholder={placeholder ?? 'Select'} />
</SelectTrigger>
</FormControl>
<SelectContent>
{isPending ? (
<SelectItem disabled value='loading' className='h-14'>
<div className='flex items-center justify-center gap-2'>
<Loader className='h-5 w-5 animate-spin' />
{' '}
Loading...
</div>
</SelectItem>
) : (
items?.map(({ label, value }) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))
)}
</SelectContent>
</Select>
)
}
@@ -0,0 +1,56 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-react'
import { userEvent } from 'vitest/browser'
import { SignOutDialog } from './sign-out-dialog'
const navigate = vi.fn()
const reset = vi.fn()
const MOCK_HREF = 'https://app.test/dashboard?tab=1'
vi.mock('@/stores/auth-store', () => ({
useAuthStore: () => ({
auth: { reset },
}),
}))
vi.mock('@tanstack/react-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-router')>()
return {
...actual,
useNavigate: () => navigate,
useLocation: () => ({ href: MOCK_HREF }),
}
})
describe('SignOutDialog', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('calls auth.reset and navigates to sign-in with current location as redirect', async () => {
const { getByRole } = await render(
<SignOutDialog open onOpenChange={vi.fn()} />
)
await userEvent.click(getByRole('button', { name: /^Sign out$/i }))
expect(reset).toHaveBeenCalledOnce()
expect(navigate).toHaveBeenCalledWith({
to: '/sign-in',
search: { redirect: MOCK_HREF },
replace: true,
})
})
it('does not call reset or navigate when Cancel is clicked', async () => {
const { getByRole } = await render(
<SignOutDialog open onOpenChange={vi.fn()} />
)
await userEvent.click(getByRole('button', { name: /^Cancel$/i }))
expect(reset).not.toHaveBeenCalled()
expect(navigate).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,39 @@
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='退出登录'
desc='确定要退出当前账号吗?退出后需要重新登录才能访问后台。'
cancelBtnText='取消'
confirmText='退出登录'
destructive
handleConfirm={handleSignOut}
className='sm:max-w-sm'
/>
)
}
@@ -0,0 +1,10 @@
export function SkipToMain() {
return (
<a
className={`fixed inset-s-44 z-999 -translate-y-52 bg-primary px-4 py-2 text-sm font-medium whitespace-nowrap text-primary-foreground opacity-95 shadow-sm transition hover:bg-primary/90 focus:translate-y-3 focus:transform focus-visible:ring-1 focus-visible:ring-ring`}
href='#content'
>
Skip to Main
</a>
)
}
@@ -0,0 +1,58 @@
import { useEffect } from 'react'
import { Check, Moon, Sun } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useTheme } from '@/context/theme-provider'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
export function ThemeSwitch() {
const { theme, setTheme } = useTheme()
/* Update theme-color meta tag
* when theme is updated */
useEffect(() => {
const themeColor = theme === 'dark' ? '#020817' : '#fff'
const metaThemeColor = document.querySelector("meta[name='theme-color']")
if (metaThemeColor) metaThemeColor.setAttribute('content', themeColor)
}, [theme])
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant='ghost' size='icon' className='scale-95 rounded-full'>
<Sun className='size-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90' />
<Moon className='absolute size-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0' />
<span className='sr-only'>Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem onClick={() => setTheme('light')}>
Light{' '}
<Check
size={14}
className={cn('ms-auto', theme !== 'light' && 'hidden')}
/>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('dark')}>
Dark
<Check
size={14}
className={cn('ms-auto', theme !== 'dark' && 'hidden')}
/>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme('system')}>
System
<Check
size={14}
className={cn('ms-auto', theme !== 'system' && 'hidden')}
/>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,154 @@
import * as React from 'react'
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot='alert-dialog' {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot='alert-dialog-trigger' {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot='alert-dialog-portal' {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot='alert-dialog-overlay'
className={cn(
'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot='alert-dialog-content'
className={cn(
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg',
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='alert-dialog-header'
className={cn('flex flex-col gap-2 text-center sm:text-start', className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='alert-dialog-footer'
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot='alert-dialog-title'
className={cn('text-lg font-semibold', className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot='alert-dialog-description'
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: 'outline' }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
@@ -0,0 +1,65 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
{
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
},
},
defaultVariants: {
variant: 'default',
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot='alert'
role='alert'
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='alert-title'
className={cn(
'col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight',
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='alert-description'
className={cn(
'col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed',
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }
@@ -0,0 +1,50 @@
import * as React from 'react'
import * as AvatarPrimitive from '@radix-ui/react-avatar'
import { cn } from '@/lib/utils'
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot='avatar'
className={cn(
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot='avatar-image'
className={cn('aspect-square size-full', className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot='avatar-fallback'
className={cn(
'flex size-full items-center justify-center rounded-full bg-muted',
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }
@@ -0,0 +1,45 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary:
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<'span'> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'span'
return (
<Comp
data-slot='badge'
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
@@ -0,0 +1,58 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : 'button'
return (
<Comp
data-slot='button'
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
@@ -0,0 +1,210 @@
import * as React from 'react'
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from 'lucide-react'
import { DayButton, DayPicker, getDefaultClassNames } from 'react-day-picker'
import { cn } from '@/lib/utils'
import { Button, buttonVariants } from '@/components/ui/button'
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = 'label',
buttonVariant = 'ghost',
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>['variant']
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
'group/calendar bg-background p-3 [--cell-size:--spacing(8)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent',
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString('default', { month: 'short' }),
...formatters,
}}
classNames={{
root: cn('w-fit', defaultClassNames.root),
months: cn(
'relative flex flex-col gap-4 md:flex-row',
defaultClassNames.months
),
month: cn('flex w-full flex-col gap-4', defaultClassNames.month),
nav: cn(
'absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1',
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
'size-(--cell-size) p-0 select-none aria-disabled:opacity-50',
defaultClassNames.button_next
),
month_caption: cn(
'flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)',
defaultClassNames.month_caption
),
dropdowns: cn(
'flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium',
defaultClassNames.dropdowns
),
dropdown_root: cn(
'relative rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50',
defaultClassNames.dropdown_root
),
dropdown: cn(
'absolute inset-0 bg-popover opacity-0',
defaultClassNames.dropdown
),
caption_label: cn(
'font-medium select-none',
captionLayout === 'label'
? 'text-sm'
: 'flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground',
defaultClassNames.caption_label
),
table: 'w-full border-collapse',
weekdays: cn('flex', defaultClassNames.weekdays),
weekday: cn(
'flex-1 rounded-md text-[0.8rem] font-normal text-muted-foreground select-none',
defaultClassNames.weekday
),
week: cn('mt-2 flex w-full', defaultClassNames.week),
week_number_header: cn(
'w-(--cell-size) select-none',
defaultClassNames.week_number_header
),
week_number: cn(
'text-[0.8rem] text-muted-foreground select-none',
defaultClassNames.week_number
),
day: cn(
'group/day relative aspect-square h-full w-full p-0 text-center select-none [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md',
defaultClassNames.day
),
range_start: cn(
'rounded-l-md bg-accent',
defaultClassNames.range_start
),
range_middle: cn('rounded-none', defaultClassNames.range_middle),
range_end: cn('rounded-r-md bg-accent', defaultClassNames.range_end),
today: cn(
'rounded-md bg-accent text-accent-foreground data-[selected=true]:rounded-none',
defaultClassNames.today
),
outside: cn(
'text-muted-foreground aria-selected:text-muted-foreground',
defaultClassNames.outside
),
disabled: cn(
'text-muted-foreground opacity-50',
defaultClassNames.disabled
),
hidden: cn('invisible', defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot='calendar'
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === 'left') {
return (
<ChevronLeftIcon className={cn('size-4', className)} {...props} />
)
}
if (orientation === 'right') {
return (
<ChevronRightIcon
className={cn('size-4', className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn('size-4', className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className='flex size-(--cell-size) items-center justify-center text-center'>
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant='ghost'
size='icon'
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
'flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-accent-foreground [&>span]:text-xs [&>span]:opacity-70',
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }
@@ -0,0 +1,91 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card'
className={cn(
'flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm',
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-header'
className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-title'
className={cn('leading-none font-semibold', 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-6', className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='card-footer'
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
@@ -0,0 +1,29 @@
import * as React from 'react'
import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
import { CheckIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot='checkbox'
className={cn(
'peer size-4 shrink-0 rounded-lg border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary',
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot='checkbox-indicator'
className='flex items-center justify-center text-current transition-none'
>
<CheckIcon className='size-3.5' />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
@@ -0,0 +1,31 @@
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot='collapsible' {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot='collapsible-trigger'
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot='collapsible-content'
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
@@ -0,0 +1,181 @@
import * as React from 'react'
import { Command as CommandPrimitive } from 'cmdk'
import { SearchIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot='command'
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className
)}
{...props}
/>
)
}
function CommandDialog({
title = 'Command Palette',
description = 'Search for a command to run...',
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className='sr-only'>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn('overflow-hidden p-0', className)}
showCloseButton={showCloseButton}
>
<Command className='**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5 **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground **:[[cmdk-group]]:px-2 **:[[cmdk-input]]:h-12 **:[[cmdk-item]]:px-2 **:[[cmdk-item]]:py-3'>
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot='command-input-wrapper'
className='flex h-9 items-center gap-2 border-b px-3'
>
<SearchIcon className='size-4 shrink-0 opacity-50' />
<CommandPrimitive.Input
data-slot='command-input'
className={cn(
'flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot='command-list'
className={cn(
'max-h-75 scroll-py-1 overflow-x-hidden overflow-y-auto',
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot='command-empty'
className='py-6 text-center text-sm'
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot='command-group'
className={cn(
'overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground',
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot='command-separator'
className={cn('-mx-1 h-px bg-border', className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot='command-item'
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot='command-shortcut'
className={cn(
'ms-auto text-xs tracking-widest text-muted-foreground',
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
@@ -0,0 +1,142 @@
'use client'
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { XIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot='dialog' {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot='dialog-trigger' {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot='dialog-portal' {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot='dialog-close' {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot='dialog-overlay'
className={cn(
'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot='dialog-portal'>
<DialogOverlay />
<DialogPrimitive.Content
data-slot='dialog-content'
className={cn(
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg',
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot='dialog-close'
className="absolute inset-e-4 top-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className='sr-only'>Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='dialog-header'
className={cn('flex flex-col gap-2 text-center sm:text-start', className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='dialog-footer'
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot='dialog-title'
className={cn('text-lg leading-none font-semibold', className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot='dialog-description'
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
@@ -0,0 +1,254 @@
import * as React from 'react'
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot='dropdown-menu' {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot='dropdown-menu-portal' {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot='dropdown-menu-trigger'
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot='dropdown-menu-content'
sideOffset={sideOffset}
className={cn(
'z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot='dropdown-menu-group' {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = 'default',
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: 'default' | 'destructive'
}) {
return (
<DropdownMenuPrimitive.Item
data-slot='dropdown-menu-item'
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:ps-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot='dropdown-menu-checkbox-item'
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 ps-8 pe-2 text-sm outline-hidden select-none focus:bg-accent 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",
className
)}
checked={checked}
{...props}
>
<span className='pointer-events-none absolute inset-s-2 flex size-3.5 items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className='size-4' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot='dropdown-menu-radio-group'
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot='dropdown-menu-radio-item'
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 ps-8 pe-2 text-sm outline-hidden select-none focus:bg-accent 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",
className
)}
{...props}
>
<span className='pointer-events-none absolute inset-s-2 flex size-3.5 items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className='size-2 fill-current' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot='dropdown-menu-label'
data-inset={inset}
className={cn(
'px-2 py-1.5 text-sm font-medium data-inset:ps-8',
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot='dropdown-menu-separator'
className={cn('-mx-1 my-1 h-px bg-border', className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot='dropdown-menu-shortcut'
className={cn(
'ms-auto text-xs tracking-widest text-muted-foreground',
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot='dropdown-menu-sub' {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot='dropdown-menu-sub-trigger'
data-inset={inset}
className={cn(
'flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:ps-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground',
className
)}
{...props}
>
{children}
<ChevronRightIcon className='ms-auto size-4' />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot='dropdown-menu-sub-content'
className={cn(
'z-50 min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
@@ -0,0 +1,164 @@
import * as React from 'react'
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from 'react-hook-form'
import * as LabelPrimitive from '@radix-ui/react-label'
import { Slot } from '@radix-ui/react-slot'
import { cn } from '@/lib/utils'
import { Label } from '@/components/ui/label'
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>')
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot='form-item'
className={cn('grid gap-2', className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot='form-label'
data-error={!!error}
className={cn('data-[error=true]:text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot='form-control'
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot='form-description'
id={formDescriptionId}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? '') : props.children
if (!body) {
return null
}
return (
<p
data-slot='form-message'
id={formMessageId}
className={cn('text-sm text-destructive', className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}
@@ -0,0 +1,74 @@
import * as React from 'react'
import { OTPInput, OTPInputContext } from 'input-otp'
import { MinusIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string
}) {
return (
<OTPInput
data-slot='input-otp'
containerClassName={cn(
'flex items-center gap-2 has-disabled:opacity-50',
containerClassName
)}
className={cn('disabled:cursor-not-allowed', className)}
{...props}
/>
)
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='input-otp-group'
className={cn('flex items-center', className)}
{...props}
/>
)
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<'div'> & {
index: number
}) {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
return (
<div
data-slot='input-otp-slot'
data-active={isActive}
className={cn(
'relative flex h-9 w-9 items-center justify-center border-y border-r border-input text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md aria-invalid:border-destructive data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-[3px] data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:ring-destructive/20 dark:bg-input/30 dark:data-[active=true]:aria-invalid:ring-destructive/40',
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className='pointer-events-none absolute inset-0 flex items-center justify-center'>
<div className='h-4 w-px animate-caret-blink bg-foreground duration-1000' />
</div>
)}
</div>
)
}
function InputOTPSeparator({ ...props }: React.ComponentProps<'div'>) {
return (
<div data-slot='input-otp-separator' role='separator' {...props}>
<MinusIcon />
</div>
)
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
@@ -0,0 +1,20 @@
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(
'flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30',
'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
className
)}
{...props}
/>
)
}
export { Input }
@@ -0,0 +1,23 @@
'use client'
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
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 }
@@ -0,0 +1,45 @@
import * as React from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import { cn } from '@/lib/utils'
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot='popover' {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot='popover-trigger' {...props} />
}
function PopoverContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot='popover-content'
align={align}
sideOffset={sideOffset}
className={cn(
'z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden 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-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot='popover-anchor' {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

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