feat: add authenticated settings page.

This commit is contained in:
liqupan
2026-02-02 20:12:19 +08:00
parent cb3e16cd16
commit 6c32d845a7
259 changed files with 24685 additions and 0 deletions

View File

@@ -0,0 +1,110 @@
import {
IconTelegram,
IconNotion,
IconFigma,
IconTrello,
IconSlack,
IconZoom,
IconStripe,
IconGmail,
IconMedium,
IconSkype,
IconDocker,
IconGithub,
IconGitlab,
IconDiscord,
IconWhatsapp,
} from '@/assets/brand-icons'
export const apps = [
{
name: 'Telegram',
logo: <IconTelegram />,
connected: false,
desc: 'Connect with Telegram for real-time communication.',
},
{
name: 'Notion',
logo: <IconNotion />,
connected: true,
desc: 'Effortlessly sync Notion pages for seamless collaboration.',
},
{
name: 'Figma',
logo: <IconFigma />,
connected: true,
desc: 'View and collaborate on Figma designs in one place.',
},
{
name: 'Trello',
logo: <IconTrello />,
connected: false,
desc: 'Sync Trello cards for streamlined project management.',
},
{
name: 'Slack',
logo: <IconSlack />,
connected: false,
desc: 'Integrate Slack for efficient team communication',
},
{
name: 'Zoom',
logo: <IconZoom />,
connected: true,
desc: 'Host Zoom meetings directly from the dashboard.',
},
{
name: 'Stripe',
logo: <IconStripe />,
connected: false,
desc: 'Easily manage Stripe transactions and payments.',
},
{
name: 'Gmail',
logo: <IconGmail />,
connected: true,
desc: 'Access and manage Gmail messages effortlessly.',
},
{
name: 'Medium',
logo: <IconMedium />,
connected: false,
desc: 'Explore and share Medium stories on your dashboard.',
},
{
name: 'Skype',
logo: <IconSkype />,
connected: false,
desc: 'Connect with Skype contacts seamlessly.',
},
{
name: 'Docker',
logo: <IconDocker />,
connected: false,
desc: 'Effortlessly manage Docker containers on your dashboard.',
},
{
name: 'GitHub',
logo: <IconGithub />,
connected: false,
desc: 'Streamline code management with GitHub integration.',
},
{
name: 'GitLab',
logo: <IconGitlab />,
connected: false,
desc: 'Efficiently manage code projects with GitLab integration.',
},
{
name: 'Discord',
logo: <IconDiscord />,
connected: false,
desc: 'Connect with Discord for seamless team communication.',
},
{
name: 'WhatsApp',
logo: <IconWhatsapp />,
connected: false,
desc: 'Easily integrate WhatsApp for direct messaging.',
},
]

View File

@@ -0,0 +1,179 @@
import { type ChangeEvent, useState } from 'react'
import { getRouteApi } from '@tanstack/react-router'
import { SlidersHorizontal, ArrowUpAZ, ArrowDownAZ } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Separator } from '@/components/ui/separator'
import { ConfigDrawer } from '@/components/config-drawer'
import { Header } from '@/components/layout/header'
import { Main } from '@/components/layout/main'
import { ProfileDropdown } from '@/components/profile-dropdown'
import { Search } from '@/components/search'
import { ThemeSwitch } from '@/components/theme-switch'
import { apps } from './data/apps'
const route = getRouteApi('/_authenticated/apps/')
type AppType = 'all' | 'connected' | 'notConnected'
const appText = new Map<AppType, string>([
['all', 'All Apps'],
['connected', 'Connected'],
['notConnected', 'Not Connected'],
])
export function Apps() {
const {
filter = '',
type = 'all',
sort: initSort = 'asc',
} = route.useSearch()
const navigate = route.useNavigate()
const [sort, setSort] = useState(initSort)
const [appType, setAppType] = useState(type)
const [searchTerm, setSearchTerm] = useState(filter)
const filteredApps = apps
.sort((a, b) =>
sort === 'asc'
? a.name.localeCompare(b.name)
: b.name.localeCompare(a.name)
)
.filter((app) =>
appType === 'connected'
? app.connected
: appType === 'notConnected'
? !app.connected
: true
)
.filter((app) => app.name.toLowerCase().includes(searchTerm.toLowerCase()))
const handleSearch = (e: ChangeEvent<HTMLInputElement>) => {
setSearchTerm(e.target.value)
navigate({
search: (prev) => ({
...prev,
filter: e.target.value || undefined,
}),
})
}
const handleTypeChange = (value: AppType) => {
setAppType(value)
navigate({
search: (prev) => ({
...prev,
type: value === 'all' ? undefined : value,
}),
})
}
const handleSortChange = (sort: 'asc' | 'desc') => {
setSort(sort)
navigate({ search: (prev) => ({ ...prev, sort }) })
}
return (
<>
{/* ===== Top Heading ===== */}
<Header>
<Search />
<div className='ms-auto flex items-center gap-4'>
<ThemeSwitch />
<ConfigDrawer />
<ProfileDropdown />
</div>
</Header>
{/* ===== Content ===== */}
<Main fixed>
<div>
<h1 className='text-2xl font-bold tracking-tight'>
App Integrations
</h1>
<p className='text-muted-foreground'>
Here&apos;s a list of your apps for the integration!
</p>
</div>
<div className='my-4 flex items-end justify-between sm:my-0 sm:items-center'>
<div className='flex flex-col gap-4 sm:my-4 sm:flex-row'>
<Input
placeholder='Filter apps...'
className='h-9 w-40 lg:w-[250px]'
value={searchTerm}
onChange={handleSearch}
/>
<Select value={appType} onValueChange={handleTypeChange}>
<SelectTrigger className='w-36'>
<SelectValue>{appText.get(appType)}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value='all'>All Apps</SelectItem>
<SelectItem value='connected'>Connected</SelectItem>
<SelectItem value='notConnected'>Not Connected</SelectItem>
</SelectContent>
</Select>
</div>
<Select value={sort} onValueChange={handleSortChange}>
<SelectTrigger className='w-16'>
<SelectValue>
<SlidersHorizontal size={18} />
</SelectValue>
</SelectTrigger>
<SelectContent align='end'>
<SelectItem value='asc'>
<div className='flex items-center gap-4'>
<ArrowUpAZ size={16} />
<span>Ascending</span>
</div>
</SelectItem>
<SelectItem value='desc'>
<div className='flex items-center gap-4'>
<ArrowDownAZ size={16} />
<span>Descending</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<Separator className='shadow-sm' />
<ul className='faded-bottom no-scrollbar grid gap-4 overflow-auto pt-4 pb-16 md:grid-cols-2 lg:grid-cols-3'>
{filteredApps.map((app) => (
<li
key={app.name}
className='rounded-lg border p-4 hover:shadow-md'
>
<div className='mb-8 flex items-center justify-between'>
<div
className={`flex size-10 items-center justify-center rounded-lg bg-muted p-2`}
>
{app.logo}
</div>
<Button
variant='outline'
size='sm'
className={`${app.connected ? 'border border-blue-300 bg-blue-50 hover:bg-blue-100 dark:border-blue-700 dark:bg-blue-950 dark:hover:bg-blue-900' : ''}`}
>
{app.connected ? 'Connected' : 'Connect'}
</Button>
</div>
<div>
<h2 className='mb-1 font-semibold'>{app.name}</h2>
<p className='line-clamp-2 text-gray-500'>{app.desc}</p>
</div>
</li>
))}
</ul>
</Main>
</>
)
}

View File

@@ -0,0 +1,19 @@
import { Logo } from '@/assets/logo'
type AuthLayoutProps = {
children: React.ReactNode
}
export function AuthLayout({ children }: AuthLayoutProps) {
return (
<div className='container grid h-svh max-w-none items-center justify-center'>
<div className='mx-auto flex w-full flex-col justify-center space-y-2 py-8 sm:w-[480px] sm:p-8'>
<div className='mb-4 flex items-center justify-center'>
<Logo className='me-2' />
<h1 className='text-xl font-medium'>Shadcn Admin</h1>
</div>
{children}
</div>
</div>
)
}

View File

@@ -0,0 +1,82 @@
import { useState } from 'react'
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useNavigate } from '@tanstack/react-router'
import { ArrowRight, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import { sleep, cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
const formSchema = z.object({
email: z.email({
error: (iss) => (iss.input === '' ? 'Please enter your email' : undefined),
}),
})
export function ForgotPasswordForm({
className,
...props
}: React.HTMLAttributes<HTMLFormElement>) {
const navigate = useNavigate()
const [isLoading, setIsLoading] = useState(false)
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { email: '' },
})
function onSubmit(data: z.infer<typeof formSchema>) {
setIsLoading(true)
// eslint-disable-next-line no-console
console.log(data)
toast.promise(sleep(2000), {
loading: 'Sending email...',
success: () => {
setIsLoading(false)
form.reset()
navigate({ to: '/otp' })
return `Email sent to ${data.email}`
},
error: 'Error',
})
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className={cn('grid gap-2', className)}
{...props}
>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder='name@example.com' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button className='mt-2' disabled={isLoading}>
Continue
{isLoading ? <Loader2 className='animate-spin' /> : <ArrowRight />}
</Button>
</form>
</Form>
)
}

View File

@@ -0,0 +1,44 @@
import { Link } from '@tanstack/react-router'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { AuthLayout } from '../auth-layout'
import { ForgotPasswordForm } from './components/forgot-password-form'
export function ForgotPassword() {
return (
<AuthLayout>
<Card className='gap-4'>
<CardHeader>
<CardTitle className='text-lg tracking-tight'>
Forgot Password
</CardTitle>
<CardDescription>
Enter your registered email and <br /> we will send you a link to
reset your password.
</CardDescription>
</CardHeader>
<CardContent>
<ForgotPasswordForm />
</CardContent>
<CardFooter>
<p className='mx-auto px-8 text-center text-sm text-balance text-muted-foreground'>
Don't have an account?{' '}
<Link
to='/sign-up'
className='underline underline-offset-4 hover:text-primary'
>
Sign up
</Link>
.
</p>
</CardFooter>
</Card>
</AuthLayout>
)
}

View File

@@ -0,0 +1,100 @@
import { useState } from 'react'
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useNavigate } from '@tanstack/react-router'
import { showSubmittedData } from '@/lib/show-submitted-data'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
InputOTPSeparator,
} from '@/components/ui/input-otp'
const formSchema = z.object({
otp: z
.string()
.min(6, 'Please enter the 6-digit code.')
.max(6, 'Please enter the 6-digit code.'),
})
type OtpFormProps = React.HTMLAttributes<HTMLFormElement>
export function OtpForm({ className, ...props }: OtpFormProps) {
const navigate = useNavigate()
const [isLoading, setIsLoading] = useState(false)
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { otp: '' },
})
// eslint-disable-next-line react-hooks/incompatible-library
const otp = form.watch('otp')
function onSubmit(data: z.infer<typeof formSchema>) {
setIsLoading(true)
showSubmittedData(data)
setTimeout(() => {
setIsLoading(false)
navigate({ to: '/' })
}, 1000)
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className={cn('grid gap-2', className)}
{...props}
>
<FormField
control={form.control}
name='otp'
render={({ field }) => (
<FormItem>
<FormLabel className='sr-only'>One-Time Password</FormLabel>
<FormControl>
<InputOTP
maxLength={6}
{...field}
containerClassName='justify-between sm:[&>[data-slot="input-otp-group"]>div]:w-12'
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button className='mt-2' disabled={otp.length < 6 || isLoading}>
Verify
</Button>
</form>
</Form>
)
}

View File

@@ -0,0 +1,44 @@
import { Link } from '@tanstack/react-router'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { AuthLayout } from '../auth-layout'
import { OtpForm } from './components/otp-form'
export function Otp() {
return (
<AuthLayout>
<Card className='gap-4'>
<CardHeader>
<CardTitle className='text-base tracking-tight'>
Two-factor Authentication
</CardTitle>
<CardDescription>
Please enter the authentication code. <br /> We have sent the
authentication code to your email.
</CardDescription>
</CardHeader>
<CardContent>
<OtpForm />
</CardContent>
<CardFooter>
<p className='px-8 text-center text-sm text-muted-foreground'>
Haven't received it?{' '}
<Link
to='/sign-in'
className='underline underline-offset-4 hover:text-primary'
>
Resend a new code.
</Link>
.
</p>
</CardFooter>
</Card>
</AuthLayout>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

View File

@@ -0,0 +1,150 @@
import { useState } from 'react'
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { Link, useNavigate } from '@tanstack/react-router'
import { Loader2, LogIn } from 'lucide-react'
import { toast } from 'sonner'
import { IconFacebook, IconGithub } from '@/assets/brand-icons'
import { useAuthStore } from '@/stores/auth-store'
import { sleep, cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { PasswordInput } from '@/components/password-input'
const formSchema = z.object({
email: z.email({
error: (iss) => (iss.input === '' ? 'Please enter your email' : undefined),
}),
password: z
.string()
.min(1, 'Please enter your password')
.min(7, 'Password must be at least 7 characters long'),
})
interface UserAuthFormProps extends React.HTMLAttributes<HTMLFormElement> {
redirectTo?: string
}
export function UserAuthForm({
className,
redirectTo,
...props
}: UserAuthFormProps) {
const [isLoading, setIsLoading] = useState(false)
const navigate = useNavigate()
const { auth } = useAuthStore()
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: '',
password: '',
},
})
function onSubmit(data: z.infer<typeof formSchema>) {
setIsLoading(true)
toast.promise(sleep(2000), {
loading: 'Signing in...',
success: () => {
setIsLoading(false)
// Mock successful authentication with expiry computed at success time
const mockUser = {
accountNo: 'ACC001',
email: data.email,
role: ['user'],
exp: Date.now() + 24 * 60 * 60 * 1000, // 24 hours from now
}
// Set user and access token
auth.setUser(mockUser)
auth.setAccessToken('mock-access-token')
// Redirect to the stored location or default to dashboard
const targetPath = redirectTo || '/'
navigate({ to: targetPath, replace: true })
return `Welcome back, ${data.email}!`
},
error: 'Error',
})
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className={cn('grid gap-3', className)}
{...props}
>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder='name@example.com' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem className='relative'>
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput placeholder='********' {...field} />
</FormControl>
<FormMessage />
<Link
to='/forgot-password'
className='absolute end-0 -top-0.5 text-sm font-medium text-muted-foreground hover:opacity-75'
>
Forgot password?
</Link>
</FormItem>
)}
/>
<Button className='mt-2' disabled={isLoading}>
{isLoading ? <Loader2 className='animate-spin' /> : <LogIn />}
Sign in
</Button>
<div className='relative my-2'>
<div className='absolute inset-0 flex items-center'>
<span className='w-full border-t' />
</div>
<div className='relative flex justify-center text-xs uppercase'>
<span className='bg-background px-2 text-muted-foreground'>
Or continue with
</span>
</div>
</div>
<div className='grid grid-cols-2 gap-2'>
<Button variant='outline' type='button' disabled={isLoading}>
<IconGithub className='h-4 w-4' /> GitHub
</Button>
<Button variant='outline' type='button' disabled={isLoading}>
<IconFacebook className='h-4 w-4' /> Facebook
</Button>
</div>
</form>
</Form>
)
}

View File

@@ -0,0 +1,51 @@
import { useSearch } from '@tanstack/react-router'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { AuthLayout } from '../auth-layout'
import { UserAuthForm } from './components/user-auth-form'
export function SignIn() {
const { redirect } = useSearch({ from: '/(auth)/sign-in' })
return (
<AuthLayout>
<Card className='gap-4'>
<CardHeader>
<CardTitle className='text-lg tracking-tight'>Sign in</CardTitle>
<CardDescription>
Enter your email and password below to <br />
log into your account
</CardDescription>
</CardHeader>
<CardContent>
<UserAuthForm redirectTo={redirect} />
</CardContent>
<CardFooter>
<p className='px-8 text-center text-sm text-muted-foreground'>
By clicking sign in, you agree to our{' '}
<a
href='/terms'
className='underline underline-offset-4 hover:text-primary'
>
Terms of Service
</a>{' '}
and{' '}
<a
href='/privacy'
className='underline underline-offset-4 hover:text-primary'
>
Privacy Policy
</a>
.
</p>
</CardFooter>
</Card>
</AuthLayout>
)
}

View File

@@ -0,0 +1,69 @@
import { Logo } from '@/assets/logo'
import { cn } from '@/lib/utils'
import dashboardDark from './assets/dashboard-dark.png'
import dashboardLight from './assets/dashboard-light.png'
import { UserAuthForm } from './components/user-auth-form'
export function SignIn2() {
return (
<div className='relative container grid h-svh flex-col items-center justify-center lg:max-w-none lg:grid-cols-2 lg:px-0'>
<div className='lg:p-8'>
<div className='mx-auto flex w-full flex-col justify-center space-y-2 py-8 sm:w-[480px] sm:p-8'>
<div className='mb-4 flex items-center justify-center'>
<Logo className='me-2' />
<h1 className='text-xl font-medium'>Shadcn Admin</h1>
</div>
</div>
<div className='mx-auto flex w-full max-w-sm flex-col justify-center space-y-2'>
<div className='flex flex-col space-y-2 text-start'>
<h2 className='text-lg font-semibold tracking-tight'>Sign in</h2>
<p className='text-sm text-muted-foreground'>
Enter your email and password below <br />
to log into your account
</p>
</div>
<UserAuthForm />
<p className='px-8 text-center text-sm text-muted-foreground'>
By clicking sign in, you agree to our{' '}
<a
href='/terms'
className='underline underline-offset-4 hover:text-primary'
>
Terms of Service
</a>{' '}
and{' '}
<a
href='/privacy'
className='underline underline-offset-4 hover:text-primary'
>
Privacy Policy
</a>
.
</p>
</div>
</div>
<div
className={cn(
'relative h-full overflow-hidden bg-muted max-lg:hidden',
'[&>img]:absolute [&>img]:top-[15%] [&>img]:left-20 [&>img]:h-full [&>img]:w-full [&>img]:object-cover [&>img]:object-top-left [&>img]:select-none'
)}
>
<img
src={dashboardLight}
className='dark:hidden'
width={1024}
height={1151}
alt='Shadcn-Admin'
/>
<img
src={dashboardDark}
className='hidden dark:block'
width={1024}
height={1138}
alt='Shadcn-Admin'
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,143 @@
import { useState } from 'react'
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { IconFacebook, IconGithub } from '@/assets/brand-icons'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { PasswordInput } from '@/components/password-input'
const formSchema = z
.object({
email: z.email({
error: (iss) =>
iss.input === '' ? 'Please enter your email' : undefined,
}),
password: z
.string()
.min(1, 'Please enter your password')
.min(7, 'Password must be at least 7 characters long'),
confirmPassword: z.string().min(1, 'Please confirm your password'),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match.",
path: ['confirmPassword'],
})
export function SignUpForm({
className,
...props
}: React.HTMLAttributes<HTMLFormElement>) {
const [isLoading, setIsLoading] = useState(false)
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: '',
password: '',
confirmPassword: '',
},
})
function onSubmit(data: z.infer<typeof formSchema>) {
setIsLoading(true)
// eslint-disable-next-line no-console
console.log(data)
setTimeout(() => {
setIsLoading(false)
}, 3000)
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className={cn('grid gap-3', className)}
{...props}
>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder='name@example.com' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput placeholder='********' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='confirmPassword'
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<PasswordInput placeholder='********' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button className='mt-2' disabled={isLoading}>
Create Account
</Button>
<div className='relative my-2'>
<div className='absolute inset-0 flex items-center'>
<span className='w-full border-t' />
</div>
<div className='relative flex justify-center text-xs uppercase'>
<span className='bg-background px-2 text-muted-foreground'>
Or continue with
</span>
</div>
</div>
<div className='grid grid-cols-2 gap-2'>
<Button
variant='outline'
className='w-full'
type='button'
disabled={isLoading}
>
<IconGithub className='h-4 w-4' /> GitHub
</Button>
<Button
variant='outline'
className='w-full'
type='button'
disabled={isLoading}
>
<IconFacebook className='h-4 w-4' /> Facebook
</Button>
</div>
</form>
</Form>
)
}

View File

@@ -0,0 +1,57 @@
import { Link } from '@tanstack/react-router'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { AuthLayout } from '../auth-layout'
import { SignUpForm } from './components/sign-up-form'
export function SignUp() {
return (
<AuthLayout>
<Card className='gap-4'>
<CardHeader>
<CardTitle className='text-lg tracking-tight'>
Create an account
</CardTitle>
<CardDescription>
Enter your email and password to create an account. <br />
Already have an account?{' '}
<Link
to='/sign-in'
className='underline underline-offset-4 hover:text-primary'
>
Sign In
</Link>
</CardDescription>
</CardHeader>
<CardContent>
<SignUpForm />
</CardContent>
<CardFooter>
<p className='px-8 text-center text-sm text-muted-foreground'>
By creating an account, you agree to our{' '}
<a
href='/terms'
className='underline underline-offset-4 hover:text-primary'
>
Terms of Service
</a>{' '}
and{' '}
<a
href='/privacy'
className='underline underline-offset-4 hover:text-primary'
>
Privacy Policy
</a>
.
</p>
</CardFooter>
</Card>
</AuthLayout>
)
}

View File

@@ -0,0 +1,32 @@
import { useState, createContext, useContext, ReactNode } from 'react';
import { Character } from '../data/schema';
interface CharacterDialogContextType {
open: boolean;
setOpen: (open: boolean) => void;
character: Character | null;
setCharacter: (character: Character | null) => void;
}
const CharacterDialogContext = createContext<CharacterDialogContextType | undefined>(
undefined
);
export function CharacterDialogProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
const [character, setCharacter] = useState<Character | null>(null);
return (
<CharacterDialogContext.Provider value={{ open, setOpen, character, setCharacter }}>
{children}
</CharacterDialogContext.Provider>
);
}
export function useCharacterDialog() {
const context = useContext(CharacterDialogContext);
if (!context) {
throw new Error('useCharacterDialog must be used within a CharacterDialogProvider');
}
return context;
}

View File

@@ -0,0 +1,331 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Textarea } from '@/components/ui/textarea';
import { useCharacterDialog } from './character-dialog-context';
import { Character, characterSchema } from '../data/schema';
import { createCharacter, updateCharacter } from '../data/api';
import { useQueryClient } from '@tanstack/react-query';
export function CharacterDialog() {
const { open, setOpen, character } = useCharacterDialog();
const queryClient = useQueryClient();
const isEdit = !!character;
const form = useForm<any>({
resolver: zodResolver(characterSchema),
defaultValues: {
name: '',
status: 'online',
compatibility: 0,
is_active: true,
is_locked: false,
sort_order: 0,
tagline: '',
description: '',
ai_system_prompt: '',
ai_greeting: '',
ai_personality: {},
ai_voice_config: {},
},
});
useEffect(() => {
if (character) {
form.reset(character);
} else {
form.reset({
name: '',
status: 'online',
compatibility: 0,
is_active: true,
is_locked: false,
sort_order: 0,
tagline: '',
description: '',
ai_system_prompt: '',
ai_greeting: '',
});
}
}, [character, form, open]);
const onSubmit = async (values: Character) => {
console.log('🚀 表单提交触发');
console.log('📝 表单值:', values);
console.log('✅ 表单验证状态:', form.formState.isValid);
console.log('❌ 表单错误:', form.formState.errors);
try {
// 确保 JSONB 字段有默认值
const sanitizedValues = {
...values,
ai_personality: values.ai_personality || {},
ai_voice_config: values.ai_voice_config || {},
};
console.log('🔧 处理后的数据:', sanitizedValues);
if (isEdit) {
console.log('🔄 更新角色ID:', character.id);
await updateCharacter({ ...sanitizedValues, id: character.id });
toast.success('角色更新成功');
} else {
console.log(' 创建新角色');
await createCharacter(sanitizedValues);
toast.success('角色创建成功');
}
setOpen(false);
queryClient.invalidateQueries({ queryKey: ['characters'] });
} catch (error) {
console.error('❌ 保存角色失败,详细错误:', error);
// 改进错误信息显示
const errorMessage = error instanceof Error
? error.message
: JSON.stringify(error);
toast.error(`保存角色失败: ${errorMessage}`);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className='max-h-[85vh] overflow-y-auto sm:max-w-2xl'>
<DialogHeader>
<DialogTitle>{isEdit ? '编辑角色' : '创建角色'}</DialogTitle>
<DialogDescription>
{isEdit
? '在下方修改角色详情。'
: '填写详情以创建新角色。'}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={(e) => {
console.log('📋 Form onSubmit 事件触发', e);
console.log('📊 当前表单状态:', form.formState);
console.log('❌ 表单错误:', form.formState.errors);
console.log('📝 当前表单值:', form.getValues());
form.handleSubmit(
onSubmit,
(errors) => {
console.error('🚫 表单验证失败:', errors);
toast.error('请检查表单输入');
}
)(e);
}}
className='space-y-4'
>
<div className='grid grid-cols-2 gap-4'>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder='角色名称' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='status'
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder='选择状态' />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value='online'>线</SelectItem>
<SelectItem value='busy'></SelectItem>
<SelectItem value='offline'>线</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='tagline'
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder='简短描述' {...field} value={field.value || ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='description'
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Textarea placeholder='完整角色描述' {...field} value={field.value || ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className='grid grid-cols-2 gap-4'>
<FormField
control={form.control}
name='compatibility'
render={({ field }) => (
<FormItem>
<FormLabel> (%)</FormLabel>
<FormControl>
<Input
type='number'
min={0}
max={100}
{...field}
onChange={e => field.onChange(parseInt(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='sort_order'
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input
type='number'
{...field}
onChange={e => field.onChange(parseInt(e.target.value))}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className='flex gap-4'>
<FormField
control={form.control}
name='is_active'
render={({ field }) => (
<FormItem className='flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4'>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className='space-y-1 leading-none'>
<FormLabel></FormLabel>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name='is_locked'
render={({ field }) => (
<FormItem className='flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4'>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className='space-y-1 leading-none'>
<FormLabel> ()</FormLabel>
</div>
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name='ai_system_prompt'
render={({ field }) => (
<FormItem>
<FormLabel> (System Prompt)</FormLabel>
<FormControl>
<Textarea
placeholder='你是一个乐于助人的助手...'
className='min-h-[100px]'
{...field}
value={field.value || ''}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='ai_greeting'
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder='你好!' {...field} value={field.value || ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className='flex justify-end gap-2'>
<Button type='button' variant='outline' onClick={() => setOpen(false)}>
</Button>
<Button
type='submit'
disabled={form.formState.isSubmitting}
onClick={() => console.log('💾 保存按钮被点击')}
>
{form.formState.isSubmitting ? '保存中...' : '保存'}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,17 @@
import { Button } from '@/components/ui/button';
import { useCharacterDialog } from './character-dialog-context';
export function CharactersPrimaryButtons() {
const { setOpen, setCharacter } = useCharacterDialog();
const handleCreate = () => {
setCharacter(null); // Clear character for creation
setOpen(true);
};
return (
<div className='flex gap-2'>
<Button onClick={handleCreate}></Button>
</div>
);
}

View File

@@ -0,0 +1,172 @@
import { useEffect, useState } from 'react';
import { getRouteApi } from '@tanstack/react-router';
import {
type SortingState,
type VisibilityState,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
import { cn } from '@/lib/utils';
import { useTableUrlState } from '@/hooks/use-table-url-state';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { DataTablePagination, DataTableToolbar } from '@/components/data-table';
import { statuses } from '../data/data';
import { type Character } from '../data/schema';
import { columns } from './columns';
const route = getRouteApi('/_authenticated/characters');
type DataTableProps = {
data: Character[];
};
export function CharactersTable({ data }: DataTableProps) {
const [rowSelection, setRowSelection] = useState({});
const [sorting, setSorting] = useState<SortingState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const {
globalFilter,
onGlobalFilterChange,
columnFilters,
onColumnFiltersChange,
pagination,
onPaginationChange,
ensurePageInRange,
} = useTableUrlState({
search: route.useSearch(),
navigate: route.useNavigate(),
pagination: { defaultPage: 1, defaultPageSize: 10 },
globalFilter: { enabled: true, key: 'filter' },
columnFilters: [
{ columnId: 'status', searchKey: 'status', type: 'array' },
],
});
const table = useReactTable({
data,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
globalFilter,
pagination,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnVisibilityChange: setColumnVisibility,
globalFilterFn: (row, _columnId, filterValue) => {
const name = String(row.getValue('name')).toLowerCase();
const searchValue = String(filterValue).toLowerCase();
return name.includes(searchValue);
},
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
onPaginationChange,
onGlobalFilterChange,
onColumnFiltersChange,
});
const pageCount = table.getPageCount();
useEffect(() => {
ensurePageInRange(pageCount);
}, [pageCount, ensurePageInRange]);
return (
<div className='flex flex-1 flex-col gap-4'>
<DataTableToolbar
table={table}
searchPlaceholder='Filter by name...'
filters={[
{
columnId: 'status',
title: 'Status',
options: statuses,
},
]}
/>
<div className='overflow-hidden rounded-md border'>
<Table className='min-w-xl'>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
colSpan={header.colSpan}
className={cn(
header.column.columnDef.meta?.className,
header.column.columnDef.meta?.thClassName
)}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={cn(
cell.column.columnDef.meta?.className,
cell.column.columnDef.meta?.tdClassName
)}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className='h-24 text-center'
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<DataTablePagination table={table} className='mt-auto' />
</div>
);
}

View File

@@ -0,0 +1,97 @@
import { ColumnDef } from '@tanstack/react-table';
import { Character } from '../data/schema';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { DataTableColumnHeader } from '@/components/data-table';
import { DataTableRowActions } from './data-table-row-actions';
export const columns: ColumnDef<Character>[] = [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && 'indeterminate')
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label='全选'
className='translate-y-[2px]'
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label='选择行'
className='translate-y-[2px]'
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: 'name',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='名称' />
),
cell: ({ row }) => <div className='w-[150px] font-medium'>{row.getValue('name')}</div>,
enableSorting: false,
enableHiding: false,
},
{
accessorKey: 'status',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='状态' />
),
cell: ({ row }) => {
const status = row.getValue('status') as string;
const statusMap: Record<string, string> = {
online: '在线',
busy: '忙碌',
offline: '离线',
};
return (
<div className='flex w-[100px] items-center'>
<Badge variant={status === 'online' ? 'default' : 'secondary'}>
{statusMap[status] || status}
</Badge>
</div>
);
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id));
},
},
{
accessorKey: 'is_active',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='是否启用' />
),
cell: ({ row }) => {
const isActive = row.getValue('is_active') as boolean;
return (
<Badge variant={isActive ? 'outline' : 'destructive'}>
{isActive ? '启用' : '禁用'}
</Badge>
);
},
},
{
accessorKey: 'compatibility',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='契合度' />
),
cell: ({ row }) => {
return (
<div className='flex items-center'>
<span>{row.getValue('compatibility')}%</span>
</div>
);
},
},
{
id: 'actions',
cell: ({ row }) => <DataTableRowActions row={row} />,
},
];

View File

@@ -0,0 +1,68 @@
import { DotsHorizontalIcon } from '@radix-ui/react-icons';
import { Row } from '@tanstack/react-table';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Character } from '../data/schema';
import { useCharacterDialog } from './character-dialog-context';
import { deleteCharacter } from '../data/api';
import { toast } from 'sonner';
import { useQueryClient } from '@tanstack/react-query';
interface DataTableRowActionsProps<TData> {
row: Row<TData>;
}
export function DataTableRowActions<TData>({
row,
}: DataTableRowActionsProps<TData>) {
const character = row.original as Character;
const { setOpen, setCharacter } = useCharacterDialog();
const queryClient = useQueryClient();
const handleEdit = () => {
setCharacter(character);
setOpen(true);
};
const handleDelete = async () => {
if (confirm('确认删除该角色吗?')) {
try {
if (character.id) {
await deleteCharacter(character.id);
toast.success('删除成功');
queryClient.invalidateQueries({ queryKey: ['characters'] });
}
} catch (error) {
toast.error('删除失败');
console.error(error);
}
}
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
className='flex h-8 w-8 p-0 data-[state=open]:bg-muted'
>
<DotsHorizontalIcon className='h-4 w-4' />
<span className='sr-only'></span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-[160px]'>
<DropdownMenuItem onClick={handleEdit}></DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleDelete}>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,59 @@
import { supabase } from '@/lib/supabase';
import { Character } from './schema';
export async function getCharacters() {
const { data, error } = await supabase
.from('characters')
.select('*')
.order('created_at', { ascending: false });
if (error) {
throw error;
}
// Optimize: validate schema? For now trust Supabase or partial validate
return data as Character[];
}
export async function createCharacter(character: Omit<Character, 'id' | 'created_at' | 'updated_at'>) {
const { data, error } = await supabase
.from('characters')
.insert(character)
.select()
.single();
if (error) {
throw error;
}
return data;
}
export async function updateCharacter(character: Character) {
const { id, ...updates } = character;
if (!id) throw new Error('ID is required for update');
const { data, error } = await supabase
.from('characters')
.update(updates)
.eq('id', id)
.select()
.single();
if (error) {
throw error;
}
return data;
}
export async function deleteCharacter(id: string) {
const { error } = await supabase
.from('characters')
.delete()
.eq('id', id);
if (error) {
throw error;
}
}

View File

@@ -0,0 +1,19 @@
import { Signal, User, UserX } from 'lucide-react';
export const statuses = [
{
value: 'online',
label: 'Online',
icon: Signal,
},
{
value: 'busy',
label: 'Busy',
icon: User,
},
{
value: 'offline',
label: 'Offline',
icon: UserX,
},
];

View File

@@ -0,0 +1,30 @@
import { z } from 'zod';
// 角色状态枚举
export const characterStatusSchema = z.enum(['online', 'busy', 'offline']);
// 角色 Schema
export const characterSchema = z.object({
id: z.string().uuid().optional(),
name: z.string().min(1, 'Name is required'),
tagline: z.string().optional(),
avatar_path: z.string().optional(),
description: z.string().optional(),
compatibility: z.number().min(0).max(100).default(0),
status: characterStatusSchema.default('online'),
is_locked: z.boolean().default(false),
is_active: z.boolean().default(true),
sort_order: z.number().default(0),
ai_system_prompt: z.string().optional(),
ai_greeting: z.string().optional(),
// Use z.string() as key type for Record to satisfy TypeScript and Zod v4+ alignment
ai_personality: z.record(z.string(), z.any()).default({}),
ai_voice_config: z.record(z.string(), z.any()).default({}),
created_at: z.string().optional(),
updated_at: z.string().optional(),
});
export type Character = z.infer<typeof characterSchema>;

View File

@@ -0,0 +1,54 @@
import { useQuery } from '@tanstack/react-query';
import { Header } from '@/components/layout/header';
import { Main } from '@/components/layout/main';
import { ProfileDropdown } from '@/components/profile-dropdown';
import { Search } from '@/components/search';
import { ThemeSwitch } from '@/components/theme-switch';
import { CharactersTable } from './components/characters-table';
import { CharacterDialogProvider } from './components/character-dialog-context';
import { CharactersPrimaryButtons } from './components/characters-primary-buttons';
import { CharacterDialog } from './components/character-dialog';
import { getCharacters } from './data/api';
export default function Characters() {
const { data: characters, isLoading, error } = useQuery({
queryKey: ['characters'],
queryFn: getCharacters,
});
if (error) {
return <div>Error loading characters: {(error as Error).message}</div>;
}
return (
<CharacterDialogProvider>
<Header fixed>
<Search />
<div className='ms-auto flex items-center space-x-4'>
<ThemeSwitch />
<ProfileDropdown />
</div>
</Header>
<Main className='flex flex-1 flex-col gap-4 sm:gap-6'>
<div className='flex flex-wrap items-end justify-between gap-2'>
<div>
<h2 className='text-2xl font-bold tracking-tight'>Characters</h2>
<p className='text-muted-foreground'>
Manage your AI characters and their configurations.
</p>
</div>
<CharactersPrimaryButtons />
</div>
{isLoading ? (
<div>Loading...</div>
) : (
<CharactersTable data={characters || []} />
)}
</Main>
<CharacterDialog />
</CharacterDialogProvider>
);
}

View File

@@ -0,0 +1,127 @@
import { useState } from 'react'
import { Check, X } from 'lucide-react'
import { showSubmittedData } from '@/lib/show-submitted-data'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { type ChatUser } from '../data/chat-types'
type User = Omit<ChatUser, 'messages'>
type NewChatProps = {
users: User[]
open: boolean
onOpenChange: (open: boolean) => void
}
export function NewChat({ users, onOpenChange, open }: NewChatProps) {
const [selectedUsers, setSelectedUsers] = useState<User[]>([])
const handleSelectUser = (user: User) => {
if (!selectedUsers.find((u) => u.id === user.id)) {
setSelectedUsers([...selectedUsers, user])
} else {
handleRemoveUser(user.id)
}
}
const handleRemoveUser = (userId: string) => {
setSelectedUsers(selectedUsers.filter((user) => user.id !== userId))
}
const handleOpenChange = (newOpen: boolean) => {
onOpenChange(newOpen)
// Reset selected users when dialog closes
if (!newOpen) {
setSelectedUsers([])
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className='sm:max-w-[600px]'>
<DialogHeader>
<DialogTitle>New message</DialogTitle>
</DialogHeader>
<div className='flex flex-col gap-4'>
<div className='flex flex-wrap items-baseline-last gap-2'>
<span className='min-h-6 text-sm text-muted-foreground'>To:</span>
{selectedUsers.map((user) => (
<Badge key={user.id} variant='default'>
{user.fullName}
<button
className='ms-1 rounded-full ring-offset-background outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2'
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleRemoveUser(user.id)
}
}}
onClick={() => handleRemoveUser(user.id)}
>
<X className='h-3 w-3 text-muted-foreground hover:text-foreground' />
</button>
</Badge>
))}
</div>
<Command className='rounded-lg border'>
<CommandInput
placeholder='Search people...'
className='text-foreground'
/>
<CommandList>
<CommandEmpty>No people found.</CommandEmpty>
<CommandGroup>
{users.map((user) => (
<CommandItem
key={user.id}
onSelect={() => handleSelectUser(user)}
className='flex items-center justify-between gap-2 hover:bg-accent hover:text-accent-foreground'
>
<div className='flex items-center gap-2'>
<img
src={user.profile || '/placeholder.svg'}
alt={user.fullName}
className='h-8 w-8 rounded-full'
/>
<div className='flex flex-col'>
<span className='text-sm font-medium'>
{user.fullName}
</span>
<span className='text-xs text-accent-foreground/70'>
{user.username}
</span>
</div>
</div>
{selectedUsers.find((u) => u.id === user.id) && (
<Check className='h-4 w-4' />
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
<Button
variant={'default'}
onClick={() => showSubmittedData(selectedUsers)}
disabled={selectedUsers.length === 0}
>
Chat
</Button>
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,4 @@
import { type conversations } from './convo.json'
export type ChatUser = (typeof conversations)[number]
export type Convo = ChatUser['messages'][number]

View File

@@ -0,0 +1,309 @@
{
"conversations": [
{
"id": "conv1",
"profile": "https://randomuser.me/api/portraits/men/32.jpg",
"username": "alex_dev",
"fullName": "Alex John",
"title": "Senior Backend Dev",
"messages": [
{
"sender": "You",
"message": "See you later, Alex!",
"timestamp": "2024-08-24T11:15:15"
},
{
"sender": "Alex",
"message": "Alright, talk to you later!",
"timestamp": "2024-08-24T11:11:30"
},
{
"sender": "You",
"message": "For sure. Anyway, I should get back to reviewing the project.",
"timestamp": "2024-08-23T09:26:50"
},
{
"sender": "Alex",
"message": "Yeah, let me know what you think.",
"timestamp": "2024-08-23T09:25:15"
},
{
"sender": "You",
"message": "Oh, nice! I've been waiting for that. I'll check it out later.",
"timestamp": "2024-08-23T09:24:30"
},
{
"sender": "Alex",
"message": "They've added a dark mode option! It looks really sleek.",
"timestamp": "2024-08-23T09:23:10"
},
{
"sender": "You",
"message": "No, not yet. What's new?",
"timestamp": "2024-08-23T09:22:00"
},
{
"sender": "Alex",
"message": "By the way, have you seen the new feature update?",
"timestamp": "2024-08-23T09:21:05"
},
{
"sender": "You",
"message": "Will do! Thanks, Alex.",
"timestamp": "2024-08-23T09:20:10"
},
{
"sender": "Alex",
"message": "Great! Let me know if you need any help.",
"timestamp": "2024-08-23T09:19:20"
},
{
"sender": "You",
"message": "Almost done. Just need to review a few things.",
"timestamp": "2024-08-23T09:18:45"
},
{
"sender": "Alex",
"message": "I'm good, thanks! Did you finish the project?",
"timestamp": "2024-08-23T09:17:10"
},
{
"sender": "You",
"message": "Hey Alex, I'm doing well! How about you?",
"timestamp": "2024-08-23T09:16:30"
},
{
"sender": "Alex",
"message": "Hey Bob, how are you doing?",
"timestamp": "2024-08-23T09:15:00"
}
]
},
{
"id": "conv2",
"profile": "https://randomuser.me/api/portraits/women/45.jpg",
"username": "taylor.codes",
"fullName": "Taylor Grande",
"title": "Tech Lead",
"messages": [
{
"sender": "Taylor",
"message": "Yeah, it's really well-explained. You should give it a try.",
"timestamp": "2024-08-23T10:35:00"
},
{
"sender": "You",
"message": "Not yet, is it good?",
"timestamp": "2024-08-23T10:32:00"
},
{
"sender": "Taylor",
"message": "Hey, did you check out that new tutorial?",
"timestamp": "2024-08-23T10:30:00"
}
]
},
{
"id": "conv3",
"profile": "https://randomuser.me/api/portraits/men/54.jpg",
"username": "john_stack",
"fullName": "John Doe",
"title": "QA",
"messages": [
{
"sender": "You",
"message": "Yep, see ya. 👋🏼",
"timestamp": "2024-08-22T18:59:00"
},
{
"sender": "John",
"message": "Great, see you then!",
"timestamp": "2024-08-22T18:55:00"
},
{
"sender": "You",
"message": "Yes, same time as usual. I'll send the invite shortly.",
"timestamp": "2024-08-22T18:50:00"
},
{
"sender": "John",
"message": "Are we still on for the meeting tomorrow?",
"timestamp": "2024-08-22T18:45:00"
}
]
},
{
"id": "conv4",
"profile": "https://randomuser.me/api/portraits/women/29.jpg",
"username": "megan_frontend",
"fullName": "Megan Flux",
"title": "Jr Developer",
"messages": [
{
"sender": "You",
"message": "Sure ✌🏼",
"timestamp": "2024-08-23T11:30:00"
},
{
"sender": "Megan",
"message": "Thanks, appreciate it!",
"timestamp": "2024-08-23T11:30:00"
},
{
"sender": "You",
"message": "Sure thing! I'll take a look in the next hour.",
"timestamp": "2024-08-23T11:25:00"
},
{
"sender": "Megan",
"message": "Hey! Do you have time to review my PR today?",
"timestamp": "2024-08-23T11:20:00"
}
]
},
{
"id": "conv5",
"profile": "https://randomuser.me/api/portraits/men/72.jpg",
"username": "dev_david",
"fullName": "David Brown",
"title": "Senior UI/UX Designer",
"messages": [
{
"sender": "You",
"message": "Great, I'll review them now!",
"timestamp": "2024-08-23T12:00:00"
},
{
"sender": "David",
"message": "Just sent you the files. Let me know if you need any changes.",
"timestamp": "2024-08-23T11:58:00"
},
{
"sender": "David",
"message": "I finished the design for the dashboard. Thoughts?",
"timestamp": "2024-08-23T11:55:00"
}
]
},
{
"id": "conv6",
"profile": "https://randomuser.me/api/portraits/women/68.jpg",
"username": "julia.design",
"fullName": "Julia Carter",
"title": "Product Designer",
"messages": [
{
"sender": "Julia",
"message": "Same here! It's coming together nicely.",
"timestamp": "2024-08-22T14:10:00"
},
{
"sender": "You",
"message": "I'm really excited to see the final product!",
"timestamp": "2024-08-22T14:15:00"
},
{
"sender": "You",
"message": "How's the project looking on your end?",
"timestamp": "2024-08-22T14:05:00"
}
]
},
{
"id": "conv7",
"profile": "https://randomuser.me/api/portraits/men/24.jpg",
"username": "brad_dev",
"fullName": "Brad Wilson",
"title": "CEO",
"messages": [
{
"sender": "Brad",
"message": "Got it! Thanks for the update.",
"timestamp": "2024-08-23T15:45:00"
},
{
"sender": "You",
"message": "The release has been delayed to next week.",
"timestamp": "2024-08-23T15:40:00"
},
{
"sender": "Brad",
"message": "Hey, any news on the release?",
"timestamp": "2024-08-23T15:35:00"
}
]
},
{
"id": "conv8",
"profile": "https://randomuser.me/api/portraits/women/34.jpg",
"username": "katie_ui",
"fullName": "Katie Lee",
"title": "QA",
"messages": [
{
"sender": "Katie",
"message": "I'll join the call in a few minutes.",
"timestamp": "2024-08-23T09:50:00"
},
{
"sender": "You",
"message": "Perfect! We'll start as soon as you're in.",
"timestamp": "2024-08-23T09:48:00"
},
{
"sender": "Katie",
"message": "Is the meeting still on?",
"timestamp": "2024-08-23T09:45:00"
}
]
},
{
"id": "conv9",
"profile": "https://randomuser.me/api/portraits/men/67.jpg",
"username": "matt_fullstack",
"fullName": "Matt Green",
"title": "Full-stack Dev",
"messages": [
{
"sender": "Matt",
"message": "Sure thing, I'll send over the updates shortly.",
"timestamp": "2024-08-23T10:25:00"
},
{
"sender": "You",
"message": "Could you update the backend as well?",
"timestamp": "2024-08-23T10:23:00"
},
{
"sender": "Matt",
"message": "The frontend updates are done. How does it look?",
"timestamp": "2024-08-23T10:20:00"
}
]
},
{
"id": "conv10",
"profile": "https://randomuser.me/api/portraits/women/56.jpg",
"username": "sophie_dev",
"fullName": "Sophie Alex",
"title": "Jr. Frontend Dev",
"messages": [
{
"sender": "You",
"message": "Thanks! I'll review your code and get back to you.",
"timestamp": "2024-08-23T16:10:00"
},
{
"sender": "Sophie",
"message": "Let me know if you need anything else.",
"timestamp": "2024-08-23T16:05:00"
},
{
"sender": "Sophie",
"message": "The feature is implemented. Can you review it?",
"timestamp": "2024-08-23T16:00:00"
}
]
}
]
}

View File

@@ -0,0 +1,349 @@
import { useState } from 'react'
import { Fragment } from 'react/jsx-runtime'
import { format } from 'date-fns'
import {
ArrowLeft,
MoreVertical,
Edit,
Paperclip,
Phone,
ImagePlus,
Plus,
Search as SearchIcon,
Send,
Video,
MessagesSquare,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { ConfigDrawer } from '@/components/config-drawer'
import { Header } from '@/components/layout/header'
import { Main } from '@/components/layout/main'
import { ProfileDropdown } from '@/components/profile-dropdown'
import { Search } from '@/components/search'
import { ThemeSwitch } from '@/components/theme-switch'
import { NewChat } from './components/new-chat'
import { type ChatUser, type Convo } from './data/chat-types'
// Fake Data
import { conversations } from './data/convo.json'
export function Chats() {
const [search, setSearch] = useState('')
const [selectedUser, setSelectedUser] = useState<ChatUser | null>(null)
const [mobileSelectedUser, setMobileSelectedUser] = useState<ChatUser | null>(
null
)
const [createConversationDialogOpened, setCreateConversationDialog] =
useState(false)
// Filtered data based on the search query
const filteredChatList = conversations.filter(({ fullName }) =>
fullName.toLowerCase().includes(search.trim().toLowerCase())
)
const currentMessage = selectedUser?.messages.reduce(
(acc: Record<string, Convo[]>, obj) => {
const key = format(obj.timestamp, 'd MMM, yyyy')
// Create an array for the category if it doesn't exist
if (!acc[key]) {
acc[key] = []
}
// Push the current object to the array
acc[key].push(obj)
return acc
},
{}
)
const users = conversations.map(({ messages, ...user }) => user)
return (
<>
{/* ===== Top Heading ===== */}
<Header>
<Search />
<div className='ms-auto flex items-center space-x-4'>
<ThemeSwitch />
<ConfigDrawer />
<ProfileDropdown />
</div>
</Header>
<Main fixed>
<section className='flex h-full gap-6'>
{/* Left Side */}
<div className='flex w-full flex-col gap-2 sm:w-56 lg:w-72 2xl:w-80'>
<div className='sticky top-0 z-10 -mx-4 bg-background px-4 pb-3 shadow-md sm:static sm:z-auto sm:mx-0 sm:p-0 sm:shadow-none'>
<div className='flex items-center justify-between py-2'>
<div className='flex gap-2'>
<h1 className='text-2xl font-bold'>Inbox</h1>
<MessagesSquare size={20} />
</div>
<Button
size='icon'
variant='ghost'
onClick={() => setCreateConversationDialog(true)}
className='rounded-lg'
>
<Edit size={24} className='stroke-muted-foreground' />
</Button>
</div>
<label
className={cn(
'focus-within:ring-1 focus-within:ring-ring focus-within:outline-hidden',
'flex h-10 w-full items-center space-x-0 rounded-md border border-border ps-2'
)}
>
<SearchIcon size={15} className='me-2 stroke-slate-500' />
<span className='sr-only'>Search</span>
<input
type='text'
className='w-full flex-1 bg-inherit text-sm focus-visible:outline-hidden'
placeholder='Search chat...'
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</label>
</div>
<ScrollArea className='-mx-3 h-full overflow-scroll p-3'>
{filteredChatList.map((chatUsr) => {
const { id, profile, username, messages, fullName } = chatUsr
const lastConvo = messages[0]
const lastMsg =
lastConvo.sender === 'You'
? `You: ${lastConvo.message}`
: lastConvo.message
return (
<Fragment key={id}>
<button
type='button'
className={cn(
'group hover:bg-accent hover:text-accent-foreground',
`flex w-full rounded-md px-2 py-2 text-start text-sm`,
selectedUser?.id === id && 'sm:bg-muted'
)}
onClick={() => {
setSelectedUser(chatUsr)
setMobileSelectedUser(chatUsr)
}}
>
<div className='flex gap-2'>
<Avatar>
<AvatarImage src={profile} alt={username} />
<AvatarFallback>{username}</AvatarFallback>
</Avatar>
<div>
<span className='col-start-2 row-span-2 font-medium'>
{fullName}
</span>
<span className='col-start-2 row-span-2 row-start-2 line-clamp-2 text-ellipsis text-muted-foreground group-hover:text-accent-foreground/90'>
{lastMsg}
</span>
</div>
</div>
</button>
<Separator className='my-1' />
</Fragment>
)
})}
</ScrollArea>
</div>
{/* Right Side */}
{selectedUser ? (
<div
className={cn(
'absolute inset-0 start-full z-50 hidden w-full flex-1 flex-col border bg-background shadow-xs sm:static sm:z-auto sm:flex sm:rounded-md',
mobileSelectedUser && 'start-0 flex'
)}
>
{/* Top Part */}
<div className='mb-1 flex flex-none justify-between bg-card p-4 shadow-lg sm:rounded-t-md'>
{/* Left */}
<div className='flex gap-3'>
<Button
size='icon'
variant='ghost'
className='-ms-2 h-full sm:hidden'
onClick={() => setMobileSelectedUser(null)}
>
<ArrowLeft className='rtl:rotate-180' />
</Button>
<div className='flex items-center gap-2 lg:gap-4'>
<Avatar className='size-9 lg:size-11'>
<AvatarImage
src={selectedUser.profile}
alt={selectedUser.username}
/>
<AvatarFallback>{selectedUser.username}</AvatarFallback>
</Avatar>
<div>
<span className='col-start-2 row-span-2 text-sm font-medium lg:text-base'>
{selectedUser.fullName}
</span>
<span className='col-start-2 row-span-2 row-start-2 line-clamp-1 block max-w-32 text-xs text-nowrap text-ellipsis text-muted-foreground lg:max-w-none lg:text-sm'>
{selectedUser.title}
</span>
</div>
</div>
</div>
{/* Right */}
<div className='-me-1 flex items-center gap-1 lg:gap-2'>
<Button
size='icon'
variant='ghost'
className='hidden size-8 rounded-full sm:inline-flex lg:size-10'
>
<Video size={22} className='stroke-muted-foreground' />
</Button>
<Button
size='icon'
variant='ghost'
className='hidden size-8 rounded-full sm:inline-flex lg:size-10'
>
<Phone size={22} className='stroke-muted-foreground' />
</Button>
<Button
size='icon'
variant='ghost'
className='h-10 rounded-md sm:h-8 sm:w-4 lg:h-10 lg:w-6'
>
<MoreVertical className='stroke-muted-foreground sm:size-5' />
</Button>
</div>
</div>
{/* Conversation */}
<div className='flex flex-1 flex-col gap-2 rounded-md px-4 pt-0 pb-4'>
<div className='flex size-full flex-1'>
<div className='chat-text-container relative -me-4 flex flex-1 flex-col overflow-y-hidden'>
<div className='chat-flex flex h-40 w-full grow flex-col-reverse justify-start gap-4 overflow-y-auto py-2 pe-4 pb-4'>
{currentMessage &&
Object.keys(currentMessage).map((key) => (
<Fragment key={key}>
{currentMessage[key].map((msg, index) => (
<div
key={`${msg.sender}-${msg.timestamp}-${index}`}
className={cn(
'chat-box max-w-72 px-3 py-2 wrap-break-word shadow-lg',
msg.sender === 'You'
? 'self-end rounded-[16px_16px_0_16px] bg-primary/90 text-primary-foreground/75'
: 'self-start rounded-[16px_16px_16px_0] bg-muted'
)}
>
{msg.message}{' '}
<span
className={cn(
'mt-1 block text-xs font-light text-foreground/75 italic',
msg.sender === 'You' &&
'text-end text-primary-foreground/85'
)}
>
{format(msg.timestamp, 'h:mm a')}
</span>
</div>
))}
<div className='text-center text-xs'>{key}</div>
</Fragment>
))}
</div>
</div>
</div>
<form className='flex w-full flex-none gap-2'>
<div className='flex flex-1 items-center gap-2 rounded-md border border-input bg-card px-2 py-1 focus-within:ring-1 focus-within:ring-ring focus-within:outline-hidden lg:gap-4'>
<div className='space-x-1'>
<Button
size='icon'
type='button'
variant='ghost'
className='h-8 rounded-md'
>
<Plus size={20} className='stroke-muted-foreground' />
</Button>
<Button
size='icon'
type='button'
variant='ghost'
className='hidden h-8 rounded-md lg:inline-flex'
>
<ImagePlus
size={20}
className='stroke-muted-foreground'
/>
</Button>
<Button
size='icon'
type='button'
variant='ghost'
className='hidden h-8 rounded-md lg:inline-flex'
>
<Paperclip
size={20}
className='stroke-muted-foreground'
/>
</Button>
</div>
<label className='flex-1'>
<span className='sr-only'>Chat Text Box</span>
<input
type='text'
placeholder='Type your messages...'
className='h-8 w-full bg-inherit focus-visible:outline-hidden'
/>
</label>
<Button
variant='ghost'
size='icon'
className='hidden sm:inline-flex'
>
<Send size={20} />
</Button>
</div>
<Button className='h-full sm:hidden'>
<Send size={18} /> Send
</Button>
</form>
</div>
</div>
) : (
<div
className={cn(
'absolute inset-0 start-full z-50 hidden w-full flex-1 flex-col justify-center rounded-md border bg-card shadow-xs sm:static sm:z-auto sm:flex'
)}
>
<div className='flex flex-col items-center space-y-6'>
<div className='flex size-16 items-center justify-center rounded-full border-2 border-border'>
<MessagesSquare className='size-8' />
</div>
<div className='space-y-2 text-center'>
<h1 className='text-xl font-semibold'>Your messages</h1>
<p className='text-sm text-muted-foreground'>
Send a message to start a chat.
</p>
</div>
<Button onClick={() => setCreateConversationDialog(true)}>
Send message
</Button>
</div>
</div>
)}
</section>
<NewChat
users={users}
onOpenChange={setCreateConversationDialog}
open={createConversationDialogOpened}
/>
</Main>
</>
)
}

View File

@@ -0,0 +1,77 @@
import { Area, AreaChart, ResponsiveContainer, XAxis, YAxis } from 'recharts'
const data = [
{
name: 'Mon',
clicks: Math.floor(Math.random() * 900) + 100,
uniques: Math.floor(Math.random() * 700) + 80,
},
{
name: 'Tue',
clicks: Math.floor(Math.random() * 900) + 100,
uniques: Math.floor(Math.random() * 700) + 80,
},
{
name: 'Wed',
clicks: Math.floor(Math.random() * 900) + 100,
uniques: Math.floor(Math.random() * 700) + 80,
},
{
name: 'Thu',
clicks: Math.floor(Math.random() * 900) + 100,
uniques: Math.floor(Math.random() * 700) + 80,
},
{
name: 'Fri',
clicks: Math.floor(Math.random() * 900) + 100,
uniques: Math.floor(Math.random() * 700) + 80,
},
{
name: 'Sat',
clicks: Math.floor(Math.random() * 900) + 100,
uniques: Math.floor(Math.random() * 700) + 80,
},
{
name: 'Sun',
clicks: Math.floor(Math.random() * 900) + 100,
uniques: Math.floor(Math.random() * 700) + 80,
},
]
export function AnalyticsChart() {
return (
<ResponsiveContainer width='100%' height={300}>
<AreaChart data={data}>
<XAxis
dataKey='name'
stroke='#888888'
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke='#888888'
fontSize={12}
tickLine={false}
axisLine={false}
/>
<Area
type='monotone'
dataKey='clicks'
stroke='currentColor'
className='text-primary'
fill='currentColor'
fillOpacity={0.15}
/>
<Area
type='monotone'
dataKey='uniques'
stroke='currentColor'
className='text-muted-foreground'
fill='currentColor'
fillOpacity={0.1}
/>
</AreaChart>
</ResponsiveContainer>
)
}

View File

@@ -0,0 +1,189 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { AnalyticsChart } from './analytics-chart'
export function Analytics() {
return (
<div className='space-y-4'>
<Card>
<CardHeader>
<CardTitle>Traffic Overview</CardTitle>
<CardDescription>Weekly clicks and unique visitors</CardDescription>
</CardHeader>
<CardContent className='px-6'>
<AnalyticsChart />
</CardContent>
</Card>
<div className='grid gap-4 sm:grid-cols-2 lg:grid-cols-4'>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-sm font-medium'>Total Clicks</CardTitle>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
className='h-4 w-4 text-muted-foreground'
>
<path d='M3 3v18h18' />
<path d='M7 15l4-4 4 4 4-6' />
</svg>
</CardHeader>
<CardContent>
<div className='text-2xl font-bold'>1,248</div>
<p className='text-xs text-muted-foreground'>+12.4% vs last week</p>
</CardContent>
</Card>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-sm font-medium'>
Unique Visitors
</CardTitle>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
className='h-4 w-4 text-muted-foreground'
>
<circle cx='12' cy='7' r='4' />
<path d='M6 21v-2a6 6 0 0 1 12 0v2' />
</svg>
</CardHeader>
<CardContent>
<div className='text-2xl font-bold'>832</div>
<p className='text-xs text-muted-foreground'>+5.8% vs last week</p>
</CardContent>
</Card>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-sm font-medium'>Bounce Rate</CardTitle>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
className='h-4 w-4 text-muted-foreground'
>
<path d='M3 12h6l3 6 3-6h6' />
</svg>
</CardHeader>
<CardContent>
<div className='text-2xl font-bold'>42%</div>
<p className='text-xs text-muted-foreground'>-3.2% vs last week</p>
</CardContent>
</Card>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-sm font-medium'>Avg. Session</CardTitle>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
className='h-4 w-4 text-muted-foreground'
>
<circle cx='12' cy='12' r='10' />
<path d='M12 6v6l4 2' />
</svg>
</CardHeader>
<CardContent>
<div className='text-2xl font-bold'>3m 24s</div>
<p className='text-xs text-muted-foreground'>+18s vs last week</p>
</CardContent>
</Card>
</div>
<div className='grid grid-cols-1 gap-4 lg:grid-cols-7'>
<Card className='col-span-1 lg:col-span-4'>
<CardHeader>
<CardTitle>Referrers</CardTitle>
<CardDescription>Top sources driving traffic</CardDescription>
</CardHeader>
<CardContent>
<SimpleBarList
items={[
{ name: 'Direct', value: 512 },
{ name: 'Product Hunt', value: 238 },
{ name: 'Twitter', value: 174 },
{ name: 'Blog', value: 104 },
]}
barClass='bg-primary'
valueFormatter={(n) => `${n}`}
/>
</CardContent>
</Card>
<Card className='col-span-1 lg:col-span-3'>
<CardHeader>
<CardTitle>Devices</CardTitle>
<CardDescription>How users access your app</CardDescription>
</CardHeader>
<CardContent>
<SimpleBarList
items={[
{ name: 'Desktop', value: 74 },
{ name: 'Mobile', value: 22 },
{ name: 'Tablet', value: 4 },
]}
barClass='bg-muted-foreground'
valueFormatter={(n) => `${n}%`}
/>
</CardContent>
</Card>
</div>
</div>
)
}
function SimpleBarList({
items,
valueFormatter,
barClass,
}: {
items: { name: string; value: number }[]
valueFormatter: (n: number) => string
barClass: string
}) {
const max = Math.max(...items.map((i) => i.value), 1)
return (
<ul className='space-y-3'>
{items.map((i) => {
const width = `${Math.round((i.value / max) * 100)}%`
return (
<li key={i.name} className='flex items-center justify-between gap-3'>
<div className='min-w-0 flex-1'>
<div className='mb-1 truncate text-xs text-muted-foreground'>
{i.name}
</div>
<div className='h-2.5 w-full rounded-full bg-muted'>
<div
className={`h-2.5 rounded-full ${barClass}`}
style={{ width }}
/>
</div>
</div>
<div className='ps-2 text-xs font-medium tabular-nums'>
{valueFormatter(i.value)}
</div>
</li>
)
})}
</ul>
)
}

View File

@@ -0,0 +1,82 @@
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis } from 'recharts'
const data = [
{
name: 'Jan',
total: Math.floor(Math.random() * 5000) + 1000,
},
{
name: 'Feb',
total: Math.floor(Math.random() * 5000) + 1000,
},
{
name: 'Mar',
total: Math.floor(Math.random() * 5000) + 1000,
},
{
name: 'Apr',
total: Math.floor(Math.random() * 5000) + 1000,
},
{
name: 'May',
total: Math.floor(Math.random() * 5000) + 1000,
},
{
name: 'Jun',
total: Math.floor(Math.random() * 5000) + 1000,
},
{
name: 'Jul',
total: Math.floor(Math.random() * 5000) + 1000,
},
{
name: 'Aug',
total: Math.floor(Math.random() * 5000) + 1000,
},
{
name: 'Sep',
total: Math.floor(Math.random() * 5000) + 1000,
},
{
name: 'Oct',
total: Math.floor(Math.random() * 5000) + 1000,
},
{
name: 'Nov',
total: Math.floor(Math.random() * 5000) + 1000,
},
{
name: 'Dec',
total: Math.floor(Math.random() * 5000) + 1000,
},
]
export function Overview() {
return (
<ResponsiveContainer width='100%' height={350}>
<BarChart data={data}>
<XAxis
dataKey='name'
stroke='#888888'
fontSize={12}
tickLine={false}
axisLine={false}
/>
<YAxis
direction='ltr'
stroke='#888888'
fontSize={12}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `$${value}`}
/>
<Bar
dataKey='total'
fill='currentColor'
radius={[4, 4, 0, 0]}
className='fill-primary'
/>
</BarChart>
</ResponsiveContainer>
)
}

View File

@@ -0,0 +1,83 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
export function RecentSales() {
return (
<div className='space-y-8'>
<div className='flex items-center gap-4'>
<Avatar className='h-9 w-9'>
<AvatarImage src='/avatars/01.png' alt='Avatar' />
<AvatarFallback>OM</AvatarFallback>
</Avatar>
<div className='flex flex-1 flex-wrap items-center justify-between'>
<div className='space-y-1'>
<p className='text-sm leading-none font-medium'>Olivia Martin</p>
<p className='text-sm text-muted-foreground'>
olivia.martin@email.com
</p>
</div>
<div className='font-medium'>+$1,999.00</div>
</div>
</div>
<div className='flex items-center gap-4'>
<Avatar className='flex h-9 w-9 items-center justify-center space-y-0 border'>
<AvatarImage src='/avatars/02.png' alt='Avatar' />
<AvatarFallback>JL</AvatarFallback>
</Avatar>
<div className='flex flex-1 flex-wrap items-center justify-between'>
<div className='space-y-1'>
<p className='text-sm leading-none font-medium'>Jackson Lee</p>
<p className='text-sm text-muted-foreground'>
jackson.lee@email.com
</p>
</div>
<div className='font-medium'>+$39.00</div>
</div>
</div>
<div className='flex items-center gap-4'>
<Avatar className='h-9 w-9'>
<AvatarImage src='/avatars/03.png' alt='Avatar' />
<AvatarFallback>IN</AvatarFallback>
</Avatar>
<div className='flex flex-1 flex-wrap items-center justify-between'>
<div className='space-y-1'>
<p className='text-sm leading-none font-medium'>Isabella Nguyen</p>
<p className='text-sm text-muted-foreground'>
isabella.nguyen@email.com
</p>
</div>
<div className='font-medium'>+$299.00</div>
</div>
</div>
<div className='flex items-center gap-4'>
<Avatar className='h-9 w-9'>
<AvatarImage src='/avatars/04.png' alt='Avatar' />
<AvatarFallback>WK</AvatarFallback>
</Avatar>
<div className='flex flex-1 flex-wrap items-center justify-between'>
<div className='space-y-1'>
<p className='text-sm leading-none font-medium'>William Kim</p>
<p className='text-sm text-muted-foreground'>will@email.com</p>
</div>
<div className='font-medium'>+$99.00</div>
</div>
</div>
<div className='flex items-center gap-4'>
<Avatar className='h-9 w-9'>
<AvatarImage src='/avatars/05.png' alt='Avatar' />
<AvatarFallback>SD</AvatarFallback>
</Avatar>
<div className='flex flex-1 flex-wrap items-center justify-between'>
<div className='space-y-1'>
<p className='text-sm leading-none font-medium'>Sofia Davis</p>
<p className='text-sm text-muted-foreground'>
sofia.davis@email.com
</p>
</div>
<div className='font-medium'>+$39.00</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,220 @@
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ConfigDrawer } from '@/components/config-drawer'
import { Header } from '@/components/layout/header'
import { Main } from '@/components/layout/main'
import { TopNav } from '@/components/layout/top-nav'
import { ProfileDropdown } from '@/components/profile-dropdown'
import { Search } from '@/components/search'
import { ThemeSwitch } from '@/components/theme-switch'
import { Analytics } from './components/analytics'
import { Overview } from './components/overview'
import { RecentSales } from './components/recent-sales'
export function Dashboard() {
return (
<>
{/* ===== Top Heading ===== */}
<Header>
<TopNav links={topNav} />
<div className='ms-auto flex items-center space-x-4'>
<Search />
<ThemeSwitch />
<ConfigDrawer />
<ProfileDropdown />
</div>
</Header>
{/* ===== Main ===== */}
<Main>
<div className='mb-2 flex items-center justify-between space-y-2'>
<h1 className='text-2xl font-bold tracking-tight'>Dashboard</h1>
<div className='flex items-center space-x-2'>
<Button>Download</Button>
</div>
</div>
<Tabs
orientation='vertical'
defaultValue='overview'
className='space-y-4'
>
<div className='w-full overflow-x-auto pb-2'>
<TabsList>
<TabsTrigger value='overview'>Overview</TabsTrigger>
<TabsTrigger value='analytics'>Analytics</TabsTrigger>
<TabsTrigger value='reports' disabled>
Reports
</TabsTrigger>
<TabsTrigger value='notifications' disabled>
Notifications
</TabsTrigger>
</TabsList>
</div>
<TabsContent value='overview' className='space-y-4'>
<div className='grid gap-4 sm:grid-cols-2 lg:grid-cols-4'>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-sm font-medium'>
Total Revenue
</CardTitle>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
className='h-4 w-4 text-muted-foreground'
>
<path d='M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6' />
</svg>
</CardHeader>
<CardContent>
<div className='text-2xl font-bold'>$45,231.89</div>
<p className='text-xs text-muted-foreground'>
+20.1% from last month
</p>
</CardContent>
</Card>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-sm font-medium'>
Subscriptions
</CardTitle>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
className='h-4 w-4 text-muted-foreground'
>
<path d='M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2' />
<circle cx='9' cy='7' r='4' />
<path d='M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75' />
</svg>
</CardHeader>
<CardContent>
<div className='text-2xl font-bold'>+2350</div>
<p className='text-xs text-muted-foreground'>
+180.1% from last month
</p>
</CardContent>
</Card>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-sm font-medium'>Sales</CardTitle>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
className='h-4 w-4 text-muted-foreground'
>
<rect width='20' height='14' x='2' y='5' rx='2' />
<path d='M2 10h20' />
</svg>
</CardHeader>
<CardContent>
<div className='text-2xl font-bold'>+12,234</div>
<p className='text-xs text-muted-foreground'>
+19% from last month
</p>
</CardContent>
</Card>
<Card>
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
<CardTitle className='text-sm font-medium'>
Active Now
</CardTitle>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 24 24'
fill='none'
stroke='currentColor'
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth='2'
className='h-4 w-4 text-muted-foreground'
>
<path d='M22 12h-4l-3 9L9 3l-3 9H2' />
</svg>
</CardHeader>
<CardContent>
<div className='text-2xl font-bold'>+573</div>
<p className='text-xs text-muted-foreground'>
+201 since last hour
</p>
</CardContent>
</Card>
</div>
<div className='grid grid-cols-1 gap-4 lg:grid-cols-7'>
<Card className='col-span-1 lg:col-span-4'>
<CardHeader>
<CardTitle>Overview</CardTitle>
</CardHeader>
<CardContent className='ps-2'>
<Overview />
</CardContent>
</Card>
<Card className='col-span-1 lg:col-span-3'>
<CardHeader>
<CardTitle>Recent Sales</CardTitle>
<CardDescription>
You made 265 sales this month.
</CardDescription>
</CardHeader>
<CardContent>
<RecentSales />
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value='analytics' className='space-y-4'>
<Analytics />
</TabsContent>
</Tabs>
</Main>
</>
)
}
const topNav = [
{
title: 'Overview',
href: 'dashboard/overview',
isActive: true,
disabled: false,
},
{
title: 'Customers',
href: 'dashboard/customers',
isActive: false,
disabled: true,
},
{
title: 'Products',
href: 'dashboard/products',
isActive: false,
disabled: true,
},
{
title: 'Settings',
href: 'dashboard/settings',
isActive: false,
disabled: true,
},
]

View File

@@ -0,0 +1,25 @@
import { useNavigate, useRouter } from '@tanstack/react-router'
import { Button } from '@/components/ui/button'
export function ForbiddenError() {
const navigate = useNavigate()
const { history } = useRouter()
return (
<div className='h-svh'>
<div className='m-auto flex h-full w-full flex-col items-center justify-center gap-2'>
<h1 className='text-[7rem] leading-tight font-bold'>403</h1>
<span className='font-medium'>Access Forbidden</span>
<p className='text-center text-muted-foreground'>
You don't have necessary permission <br />
to view this resource.
</p>
<div className='mt-6 flex gap-4'>
<Button variant='outline' onClick={() => history.go(-1)}>
Go Back
</Button>
<Button onClick={() => navigate({ to: '/' })}>Back to Home</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,36 @@
import { useNavigate, useRouter } from '@tanstack/react-router'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
type GeneralErrorProps = React.HTMLAttributes<HTMLDivElement> & {
minimal?: boolean
}
export function GeneralError({
className,
minimal = false,
}: GeneralErrorProps) {
const navigate = useNavigate()
const { history } = useRouter()
return (
<div className={cn('h-svh w-full', className)}>
<div className='m-auto flex h-full w-full flex-col items-center justify-center gap-2'>
{!minimal && (
<h1 className='text-[7rem] leading-tight font-bold'>500</h1>
)}
<span className='font-medium'>Oops! Something went wrong {`:')`}</span>
<p className='text-center text-muted-foreground'>
We apologize for the inconvenience. <br /> Please try again later.
</p>
{!minimal && (
<div className='mt-6 flex gap-4'>
<Button variant='outline' onClick={() => history.go(-1)}>
Go Back
</Button>
<Button onClick={() => navigate({ to: '/' })}>Back to Home</Button>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,19 @@
import { Button } from '@/components/ui/button'
export function MaintenanceError() {
return (
<div className='h-svh'>
<div className='m-auto flex h-full w-full flex-col items-center justify-center gap-2'>
<h1 className='text-[7rem] leading-tight font-bold'>503</h1>
<span className='font-medium'>Website is under maintenance!</span>
<p className='text-center text-muted-foreground'>
The site is not available at the moment. <br />
We'll be back online shortly.
</p>
<div className='mt-6 flex gap-4'>
<Button variant='outline'>Learn more</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,25 @@
import { useNavigate, useRouter } from '@tanstack/react-router'
import { Button } from '@/components/ui/button'
export function NotFoundError() {
const navigate = useNavigate()
const { history } = useRouter()
return (
<div className='h-svh'>
<div className='m-auto flex h-full w-full flex-col items-center justify-center gap-2'>
<h1 className='text-[7rem] leading-tight font-bold'>404</h1>
<span className='font-medium'>Oops! Page Not Found!</span>
<p className='text-center text-muted-foreground'>
It seems like the page you're looking for <br />
does not exist or might have been removed.
</p>
<div className='mt-6 flex gap-4'>
<Button variant='outline' onClick={() => history.go(-1)}>
Go Back
</Button>
<Button onClick={() => navigate({ to: '/' })}>Back to Home</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,25 @@
import { useNavigate, useRouter } from '@tanstack/react-router'
import { Button } from '@/components/ui/button'
export function UnauthorisedError() {
const navigate = useNavigate()
const { history } = useRouter()
return (
<div className='h-svh'>
<div className='m-auto flex h-full w-full flex-col items-center justify-center gap-2'>
<h1 className='text-[7rem] leading-tight font-bold'>401</h1>
<span className='font-medium'>Unauthorized Access</span>
<p className='text-center text-muted-foreground'>
Please log in with the appropriate credentials <br /> to access this
resource.
</p>
<div className='mt-6 flex gap-4'>
<Button variant='outline' onClick={() => history.go(-1)}>
Go Back
</Button>
<Button onClick={() => navigate({ to: '/' })}>Back to Home</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,173 @@
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { CaretSortIcon, CheckIcon } from '@radix-ui/react-icons'
import { zodResolver } from '@hookform/resolvers/zod'
import { showSubmittedData } from '@/lib/show-submitted-data'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { DatePicker } from '@/components/date-picker'
const languages = [
{ label: 'English', value: 'en' },
{ label: 'French', value: 'fr' },
{ label: 'German', value: 'de' },
{ label: 'Spanish', value: 'es' },
{ label: 'Portuguese', value: 'pt' },
{ label: 'Russian', value: 'ru' },
{ label: 'Japanese', value: 'ja' },
{ label: 'Korean', value: 'ko' },
{ label: 'Chinese', value: 'zh' },
] as const
const accountFormSchema = z.object({
name: z
.string()
.min(1, 'Please enter your name.')
.min(2, 'Name must be at least 2 characters.')
.max(30, 'Name must not be longer than 30 characters.'),
dob: z.date('Please select your date of birth.'),
language: z.string('Please select a language.'),
})
type AccountFormValues = z.infer<typeof accountFormSchema>
// This can come from your database or API.
const defaultValues: Partial<AccountFormValues> = {
name: '',
}
export function AccountForm() {
const form = useForm<AccountFormValues>({
resolver: zodResolver(accountFormSchema),
defaultValues,
})
function onSubmit(data: AccountFormValues) {
showSubmittedData(data)
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-8'>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder='Your name' {...field} />
</FormControl>
<FormDescription>
This is the name that will be displayed on your profile and in
emails.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='dob'
render={({ field }) => (
<FormItem className='flex flex-col'>
<FormLabel>Date of birth</FormLabel>
<DatePicker selected={field.value} onSelect={field.onChange} />
<FormDescription>
Your date of birth is used to calculate your age.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='language'
render={({ field }) => (
<FormItem className='flex flex-col'>
<FormLabel>Language</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant='outline'
role='combobox'
className={cn(
'w-[200px] justify-between',
!field.value && 'text-muted-foreground'
)}
>
{field.value
? languages.find(
(language) => language.value === field.value
)?.label
: 'Select language'}
<CaretSortIcon className='ms-2 h-4 w-4 shrink-0 opacity-50' />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className='w-[200px] p-0'>
<Command>
<CommandInput placeholder='Search language...' />
<CommandEmpty>No language found.</CommandEmpty>
<CommandGroup>
<CommandList>
{languages.map((language) => (
<CommandItem
value={language.label}
key={language.value}
onSelect={() => {
form.setValue('language', language.value)
}}
>
<CheckIcon
className={cn(
'size-4',
language.value === field.value
? 'opacity-100'
: 'opacity-0'
)}
/>
{language.label}
</CommandItem>
))}
</CommandList>
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
This is the language that will be used in the dashboard.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button type='submit'>Update account</Button>
</form>
</Form>
)
}

View File

@@ -0,0 +1,14 @@
import { ContentSection } from '../components/content-section'
import { AccountForm } from './account-form'
export function SettingsAccount() {
return (
<ContentSection
title='Account'
desc='Update your account settings. Set your preferred language and
timezone.'
>
<AccountForm />
</ContentSection>
)
}

View File

@@ -0,0 +1,162 @@
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { ChevronDownIcon } from '@radix-ui/react-icons'
import { zodResolver } from '@hookform/resolvers/zod'
import { fonts } from '@/config/fonts'
import { showSubmittedData } from '@/lib/show-submitted-data'
import { cn } from '@/lib/utils'
import { useFont } from '@/context/font-provider'
import { useTheme } from '@/context/theme-provider'
import { Button, buttonVariants } from '@/components/ui/button'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
const appearanceFormSchema = z.object({
theme: z.enum(['light', 'dark']),
font: z.enum(fonts),
})
type AppearanceFormValues = z.infer<typeof appearanceFormSchema>
export function AppearanceForm() {
const { font, setFont } = useFont()
const { theme, setTheme } = useTheme()
// This can come from your database or API.
const defaultValues: Partial<AppearanceFormValues> = {
theme: theme as 'light' | 'dark',
font,
}
const form = useForm<AppearanceFormValues>({
resolver: zodResolver(appearanceFormSchema),
defaultValues,
})
function onSubmit(data: AppearanceFormValues) {
if (data.font != font) setFont(data.font)
if (data.theme != theme) setTheme(data.theme)
showSubmittedData(data)
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-8'>
<FormField
control={form.control}
name='font'
render={({ field }) => (
<FormItem>
<FormLabel>Font</FormLabel>
<div className='relative w-max'>
<FormControl>
<select
className={cn(
buttonVariants({ variant: 'outline' }),
'w-[200px] appearance-none font-normal capitalize',
'dark:bg-background dark:hover:bg-background'
)}
{...field}
>
{fonts.map((font) => (
<option key={font} value={font}>
{font}
</option>
))}
</select>
</FormControl>
<ChevronDownIcon className='absolute end-3 top-2.5 h-4 w-4 opacity-50' />
</div>
<FormDescription className='font-manrope'>
Set the font you want to use in the dashboard.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='theme'
render={({ field }) => (
<FormItem>
<FormLabel>Theme</FormLabel>
<FormDescription>
Select the theme for the dashboard.
</FormDescription>
<FormMessage />
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className='grid max-w-md grid-cols-2 gap-8 pt-2'
>
<FormItem>
<FormLabel className='[&:has([data-state=checked])>div]:border-primary'>
<FormControl>
<RadioGroupItem value='light' className='sr-only' />
</FormControl>
<div className='items-center rounded-md border-2 border-muted p-1 hover:border-accent'>
<div className='space-y-2 rounded-sm bg-[#ecedef] p-2'>
<div className='space-y-2 rounded-md bg-white p-2 shadow-xs'>
<div className='h-2 w-[80px] rounded-lg bg-[#ecedef]' />
<div className='h-2 w-[100px] rounded-lg bg-[#ecedef]' />
</div>
<div className='flex items-center space-x-2 rounded-md bg-white p-2 shadow-xs'>
<div className='h-4 w-4 rounded-full bg-[#ecedef]' />
<div className='h-2 w-[100px] rounded-lg bg-[#ecedef]' />
</div>
<div className='flex items-center space-x-2 rounded-md bg-white p-2 shadow-xs'>
<div className='h-4 w-4 rounded-full bg-[#ecedef]' />
<div className='h-2 w-[100px] rounded-lg bg-[#ecedef]' />
</div>
</div>
</div>
<span className='block w-full p-2 text-center font-normal'>
Light
</span>
</FormLabel>
</FormItem>
<FormItem>
<FormLabel className='[&:has([data-state=checked])>div]:border-primary'>
<FormControl>
<RadioGroupItem value='dark' className='sr-only' />
</FormControl>
<div className='items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground'>
<div className='space-y-2 rounded-sm bg-slate-950 p-2'>
<div className='space-y-2 rounded-md bg-slate-800 p-2 shadow-xs'>
<div className='h-2 w-[80px] rounded-lg bg-slate-400' />
<div className='h-2 w-[100px] rounded-lg bg-slate-400' />
</div>
<div className='flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-xs'>
<div className='h-4 w-4 rounded-full bg-slate-400' />
<div className='h-2 w-[100px] rounded-lg bg-slate-400' />
</div>
<div className='flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-xs'>
<div className='h-4 w-4 rounded-full bg-slate-400' />
<div className='h-2 w-[100px] rounded-lg bg-slate-400' />
</div>
</div>
</div>
<span className='block w-full p-2 text-center font-normal'>
Dark
</span>
</FormLabel>
</FormItem>
</RadioGroup>
</FormItem>
)}
/>
<Button type='submit'>Update preferences</Button>
</form>
</Form>
)
}

View File

@@ -0,0 +1,14 @@
import { ContentSection } from '../components/content-section'
import { AppearanceForm } from './appearance-form'
export function SettingsAppearance() {
return (
<ContentSection
title='Appearance'
desc='Customize the appearance of the app. Automatically switch between day
and night themes.'
>
<AppearanceForm />
</ContentSection>
)
}

View File

@@ -0,0 +1,22 @@
import { Separator } from '@/components/ui/separator'
type ContentSectionProps = {
title: string
desc: string
children: React.JSX.Element
}
export function ContentSection({ title, desc, children }: ContentSectionProps) {
return (
<div className='flex flex-1 flex-col'>
<div className='flex-none'>
<h3 className='text-lg font-medium'>{title}</h3>
<p className='text-sm text-muted-foreground'>{desc}</p>
</div>
<Separator className='my-4 flex-none' />
<div className='faded-bottom h-full w-full overflow-y-auto scroll-smooth pe-4 pb-12'>
<div className='-mx-1 px-1.5 lg:max-w-xl'>{children}</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,84 @@
import { useState, type JSX } from 'react'
import { useLocation, useNavigate, Link } from '@tanstack/react-router'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
type SidebarNavProps = React.HTMLAttributes<HTMLElement> & {
items: {
href: string
title: string
icon: JSX.Element
}[]
}
export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
const { pathname } = useLocation()
const navigate = useNavigate()
const [val, setVal] = useState(pathname ?? '/settings')
const handleSelect = (e: string) => {
setVal(e)
navigate({ to: e })
}
return (
<>
<div className='p-1 md:hidden'>
<Select value={val} onValueChange={handleSelect}>
<SelectTrigger className='h-12 sm:w-48'>
<SelectValue placeholder='Theme' />
</SelectTrigger>
<SelectContent>
{items.map((item) => (
<SelectItem key={item.href} value={item.href}>
<div className='flex gap-x-4 px-2 py-1'>
<span className='scale-125'>{item.icon}</span>
<span className='text-md'>{item.title}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<ScrollArea
orientation='horizontal'
type='always'
className='hidden w-full min-w-40 bg-background px-1 py-2 md:block'
>
<nav
className={cn(
'flex space-x-2 py-1 lg:flex-col lg:space-y-1 lg:space-x-0',
className
)}
{...props}
>
{items.map((item) => (
<Link
key={item.href}
to={item.href}
className={cn(
buttonVariants({ variant: 'ghost' }),
pathname === item.href
? 'bg-muted hover:bg-accent'
: 'hover:bg-accent hover:underline',
'justify-start'
)}
>
<span className='me-2'>{item.icon}</span>
{item.title}
</Link>
))}
</nav>
</ScrollArea>
</>
)
}

View File

@@ -0,0 +1,121 @@
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { showSubmittedData } from '@/lib/show-submitted-data'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
const items = [
{
id: 'recents',
label: 'Recents',
},
{
id: 'home',
label: 'Home',
},
{
id: 'applications',
label: 'Applications',
},
{
id: 'desktop',
label: 'Desktop',
},
{
id: 'downloads',
label: 'Downloads',
},
{
id: 'documents',
label: 'Documents',
},
] as const
const displayFormSchema = z.object({
items: z.array(z.string()).refine((value) => value.some((item) => item), {
message: 'You have to select at least one item.',
}),
})
type DisplayFormValues = z.infer<typeof displayFormSchema>
// This can come from your database or API.
const defaultValues: Partial<DisplayFormValues> = {
items: ['recents', 'home'],
}
export function DisplayForm() {
const form = useForm<DisplayFormValues>({
resolver: zodResolver(displayFormSchema),
defaultValues,
})
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => showSubmittedData(data))}
className='space-y-8'
>
<FormField
control={form.control}
name='items'
render={() => (
<FormItem>
<div className='mb-4'>
<FormLabel className='text-base'>Sidebar</FormLabel>
<FormDescription>
Select the items you want to display in the sidebar.
</FormDescription>
</div>
{items.map((item) => (
<FormField
key={item.id}
control={form.control}
name='items'
render={({ field }) => {
return (
<FormItem
key={item.id}
className='flex flex-row items-start'
>
<FormControl>
<Checkbox
checked={field.value?.includes(item.id)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, item.id])
: field.onChange(
field.value?.filter(
(value) => value !== item.id
)
)
}}
/>
</FormControl>
<FormLabel className='font-normal'>
{item.label}
</FormLabel>
</FormItem>
)
}}
/>
))}
<FormMessage />
</FormItem>
)}
/>
<Button type='submit'>Update display</Button>
</form>
</Form>
)
}

View File

@@ -0,0 +1,13 @@
import { ContentSection } from '../components/content-section'
import { DisplayForm } from './display-form'
export function SettingsDisplay() {
return (
<ContentSection
title='Display'
desc="Turn items on or off to control what's displayed in the app."
>
<DisplayForm />
</ContentSection>
)
}

View File

@@ -0,0 +1,74 @@
import { Outlet } from '@tanstack/react-router'
import { Monitor, Bell, Palette, Wrench, UserCog } from 'lucide-react'
import { Separator } from '@/components/ui/separator'
import { ConfigDrawer } from '@/components/config-drawer'
import { Header } from '@/components/layout/header'
import { Main } from '@/components/layout/main'
import { ProfileDropdown } from '@/components/profile-dropdown'
import { Search } from '@/components/search'
import { ThemeSwitch } from '@/components/theme-switch'
import { SidebarNav } from './components/sidebar-nav'
const sidebarNavItems = [
{
title: 'Profile',
href: '/settings',
icon: <UserCog size={18} />,
},
{
title: 'Account',
href: '/settings/account',
icon: <Wrench size={18} />,
},
{
title: 'Appearance',
href: '/settings/appearance',
icon: <Palette size={18} />,
},
{
title: 'Notifications',
href: '/settings/notifications',
icon: <Bell size={18} />,
},
{
title: 'Display',
href: '/settings/display',
icon: <Monitor size={18} />,
},
]
export function Settings() {
return (
<>
{/* ===== Top Heading ===== */}
<Header>
<Search />
<div className='ms-auto flex items-center space-x-4'>
<ThemeSwitch />
<ConfigDrawer />
<ProfileDropdown />
</div>
</Header>
<Main fixed>
<div className='space-y-0.5'>
<h1 className='text-2xl font-bold tracking-tight md:text-3xl'>
Settings
</h1>
<p className='text-muted-foreground'>
Manage your account settings and set e-mail preferences.
</p>
</div>
<Separator className='my-4 lg:my-6' />
<div className='flex flex-1 flex-col space-y-2 overflow-hidden md:space-y-2 lg:flex-row lg:space-y-0 lg:space-x-12'>
<aside className='top-0 lg:sticky lg:w-1/5'>
<SidebarNav items={sidebarNavItems} />
</aside>
<div className='flex w-full overflow-y-hidden p-1'>
<Outlet />
</div>
</div>
</Main>
</>
)
}

View File

@@ -0,0 +1,13 @@
import { ContentSection } from '../components/content-section'
import { NotificationsForm } from './notifications-form'
export function SettingsNotifications() {
return (
<ContentSection
title='Notifications'
desc='Configure how you receive notifications.'
>
<NotificationsForm />
</ContentSection>
)
}

View File

@@ -0,0 +1,220 @@
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { Link } from '@tanstack/react-router'
import { showSubmittedData } from '@/lib/show-submitted-data'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Switch } from '@/components/ui/switch'
const notificationsFormSchema = z.object({
type: z.enum(['all', 'mentions', 'none'], {
error: (iss) =>
iss.input === undefined
? 'Please select a notification type.'
: undefined,
}),
mobile: z.boolean().default(false).optional(),
communication_emails: z.boolean().default(false).optional(),
social_emails: z.boolean().default(false).optional(),
marketing_emails: z.boolean().default(false).optional(),
security_emails: z.boolean(),
})
type NotificationsFormValues = z.infer<typeof notificationsFormSchema>
// This can come from your database or API.
const defaultValues: Partial<NotificationsFormValues> = {
communication_emails: false,
marketing_emails: false,
social_emails: true,
security_emails: true,
}
export function NotificationsForm() {
const form = useForm<NotificationsFormValues>({
resolver: zodResolver(notificationsFormSchema),
defaultValues,
})
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => showSubmittedData(data))}
className='space-y-8'
>
<FormField
control={form.control}
name='type'
render={({ field }) => (
<FormItem className='relative space-y-3'>
<FormLabel>Notify me about...</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className='flex flex-col gap-2'
>
<FormItem className='flex items-center'>
<FormControl>
<RadioGroupItem value='all' />
</FormControl>
<FormLabel className='font-normal'>
All new messages
</FormLabel>
</FormItem>
<FormItem className='flex items-center'>
<FormControl>
<RadioGroupItem value='mentions' />
</FormControl>
<FormLabel className='font-normal'>
Direct messages and mentions
</FormLabel>
</FormItem>
<FormItem className='flex items-center'>
<FormControl>
<RadioGroupItem value='none' />
</FormControl>
<FormLabel className='font-normal'>Nothing</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className='relative'>
<h3 className='mb-4 text-lg font-medium'>Email Notifications</h3>
<div className='space-y-4'>
<FormField
control={form.control}
name='communication_emails'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
Communication emails
</FormLabel>
<FormDescription>
Receive emails about your account activity.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='marketing_emails'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>
Marketing emails
</FormLabel>
<FormDescription>
Receive emails about new products, features, and more.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='social_emails'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>Social emails</FormLabel>
<FormDescription>
Receive emails for friend requests, follows, and more.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name='security_emails'
render={({ field }) => (
<FormItem className='flex flex-row items-center justify-between rounded-lg border p-4'>
<div className='space-y-0.5'>
<FormLabel className='text-base'>Security emails</FormLabel>
<FormDescription>
Receive emails about your account activity and security.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled
aria-readonly
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
<FormField
control={form.control}
name='mobile'
render={({ field }) => (
<FormItem className='relative flex flex-row items-start'>
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<div className='space-y-1 leading-none'>
<FormLabel>
Use different settings for my mobile devices
</FormLabel>
<FormDescription>
You can manage your mobile notifications in the{' '}
<Link
to='/settings'
className='underline decoration-dashed underline-offset-4 hover:decoration-solid'
>
mobile settings
</Link>{' '}
page.
</FormDescription>
</div>
</FormItem>
)}
/>
<Button type='submit'>Update notifications</Button>
</form>
</Form>
)
}

View File

@@ -0,0 +1,13 @@
import { ContentSection } from '../components/content-section'
import { ProfileForm } from './profile-form'
export function SettingsProfile() {
return (
<ContentSection
title='Profile'
desc='This is how others will see you on the site.'
>
<ProfileForm />
</ContentSection>
)
}

View File

@@ -0,0 +1,177 @@
import { z } from 'zod'
import { useFieldArray, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { Link } from '@tanstack/react-router'
import { showSubmittedData } from '@/lib/show-submitted-data'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
const profileFormSchema = z.object({
username: z
.string('Please enter your username.')
.min(2, 'Username must be at least 2 characters.')
.max(30, 'Username must not be longer than 30 characters.'),
email: z.email({
error: (iss) =>
iss.input === undefined
? 'Please select an email to display.'
: undefined,
}),
bio: z.string().max(160).min(4),
urls: z
.array(
z.object({
value: z.url('Please enter a valid URL.'),
})
)
.optional(),
})
type ProfileFormValues = z.infer<typeof profileFormSchema>
// This can come from your database or API.
const defaultValues: Partial<ProfileFormValues> = {
bio: 'I own a computer.',
urls: [
{ value: 'https://shadcn.com' },
{ value: 'http://twitter.com/shadcn' },
],
}
export function ProfileForm() {
const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileFormSchema),
defaultValues,
mode: 'onChange',
})
const { fields, append } = useFieldArray({
name: 'urls',
control: form.control,
})
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => showSubmittedData(data))}
className='space-y-8'
>
<FormField
control={form.control}
name='username'
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder='shadcn' {...field} />
</FormControl>
<FormDescription>
This is your public display name. It can be your real name or a
pseudonym. You can only change this once every 30 days.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder='Select a verified email to display' />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value='m@example.com'>m@example.com</SelectItem>
<SelectItem value='m@google.com'>m@google.com</SelectItem>
<SelectItem value='m@support.com'>m@support.com</SelectItem>
</SelectContent>
</Select>
<FormDescription>
You can manage verified email addresses in your{' '}
<Link to='/'>email settings</Link>.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='bio'
render={({ field }) => (
<FormItem>
<FormLabel>Bio</FormLabel>
<FormControl>
<Textarea
placeholder='Tell us a little bit about yourself'
className='resize-none'
{...field}
/>
</FormControl>
<FormDescription>
You can <span>@mention</span> other users and organizations to
link to them.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div>
{fields.map((field, index) => (
<FormField
control={form.control}
key={field.id}
name={`urls.${index}.value`}
render={({ field }) => (
<FormItem>
<FormLabel className={cn(index !== 0 && 'sr-only')}>
URLs
</FormLabel>
<FormDescription className={cn(index !== 0 && 'sr-only')}>
Add links to your website, blog, or social media profiles.
</FormDescription>
<FormControl className={cn(index !== 0 && 'mt-1.5')}>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
))}
<Button
type='button'
variant='outline'
size='sm'
className='mt-2'
onClick={() => append({ value: '' })}
>
Add URL
</Button>
</div>
<Button type='submit'>Update profile</Button>
</form>
</Form>
)
}

View File

@@ -0,0 +1,193 @@
import { useState } from 'react'
import { type Table } from '@tanstack/react-table'
import { Trash2, CircleArrowUp, ArrowUpDown, Download } from 'lucide-react'
import { toast } from 'sonner'
import { sleep } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
import { priorities, statuses } from '../data/data'
import { type Task } from '../data/schema'
import { TasksMultiDeleteDialog } from './tasks-multi-delete-dialog'
type DataTableBulkActionsProps<TData> = {
table: Table<TData>
}
export function DataTableBulkActions<TData>({
table,
}: DataTableBulkActionsProps<TData>) {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const selectedRows = table.getFilteredSelectedRowModel().rows
const handleBulkStatusChange = (status: string) => {
const selectedTasks = selectedRows.map((row) => row.original as Task)
toast.promise(sleep(2000), {
loading: 'Updating status...',
success: () => {
table.resetRowSelection()
return `Status updated to "${status}" for ${selectedTasks.length} task${selectedTasks.length > 1 ? 's' : ''}.`
},
error: 'Error',
})
table.resetRowSelection()
}
const handleBulkPriorityChange = (priority: string) => {
const selectedTasks = selectedRows.map((row) => row.original as Task)
toast.promise(sleep(2000), {
loading: 'Updating priority...',
success: () => {
table.resetRowSelection()
return `Priority updated to "${priority}" for ${selectedTasks.length} task${selectedTasks.length > 1 ? 's' : ''}.`
},
error: 'Error',
})
table.resetRowSelection()
}
const handleBulkExport = () => {
const selectedTasks = selectedRows.map((row) => row.original as Task)
toast.promise(sleep(2000), {
loading: 'Exporting tasks...',
success: () => {
table.resetRowSelection()
return `Exported ${selectedTasks.length} task${selectedTasks.length > 1 ? 's' : ''} to CSV.`
},
error: 'Error',
})
table.resetRowSelection()
}
return (
<>
<BulkActionsToolbar table={table} entityName='task'>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant='outline'
size='icon'
className='size-8'
aria-label='Update status'
title='Update status'
>
<CircleArrowUp />
<span className='sr-only'>Update status</span>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>
<p>Update status</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent sideOffset={14}>
{statuses.map((status) => (
<DropdownMenuItem
key={status.value}
defaultValue={status.value}
onClick={() => handleBulkStatusChange(status.value)}
>
{status.icon && (
<status.icon className='size-4 text-muted-foreground' />
)}
{status.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant='outline'
size='icon'
className='size-8'
aria-label='Update priority'
title='Update priority'
>
<ArrowUpDown />
<span className='sr-only'>Update priority</span>
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>
<p>Update priority</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent sideOffset={14}>
{priorities.map((priority) => (
<DropdownMenuItem
key={priority.value}
defaultValue={priority.value}
onClick={() => handleBulkPriorityChange(priority.value)}
>
{priority.icon && (
<priority.icon className='size-4 text-muted-foreground' />
)}
{priority.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='outline'
size='icon'
onClick={() => handleBulkExport()}
className='size-8'
aria-label='Export tasks'
title='Export tasks'
>
<Download />
<span className='sr-only'>Export tasks</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Export tasks</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='destructive'
size='icon'
onClick={() => setShowDeleteConfirm(true)}
className='size-8'
aria-label='Delete selected tasks'
title='Delete selected tasks'
>
<Trash2 />
<span className='sr-only'>Delete selected tasks</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete selected tasks</p>
</TooltipContent>
</Tooltip>
</BulkActionsToolbar>
<TasksMultiDeleteDialog
open={showDeleteConfirm}
onOpenChange={setShowDeleteConfirm}
table={table}
/>
</>
)
}

View File

@@ -0,0 +1,83 @@
import { DotsHorizontalIcon } from '@radix-ui/react-icons'
import { type Row } from '@tanstack/react-table'
import { Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { labels } from '../data/data'
import { taskSchema } from '../data/schema'
import { useTasks } from './tasks-provider'
type DataTableRowActionsProps<TData> = {
row: Row<TData>
}
export function DataTableRowActions<TData>({
row,
}: DataTableRowActionsProps<TData>) {
const task = taskSchema.parse(row.original)
const { setOpen, setCurrentRow } = useTasks()
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
className='flex h-8 w-8 p-0 data-[state=open]:bg-muted'
>
<DotsHorizontalIcon className='h-4 w-4' />
<span className='sr-only'>Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-[160px]'>
<DropdownMenuItem
onClick={() => {
setCurrentRow(task)
setOpen('update')
}}
>
Edit
</DropdownMenuItem>
<DropdownMenuItem disabled>Make a copy</DropdownMenuItem>
<DropdownMenuItem disabled>Favorite</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>Labels</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup value={task.label}>
{labels.map((label) => (
<DropdownMenuRadioItem key={label.value} value={label.value}>
{label.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setCurrentRow(task)
setOpen('delete')
}}
>
Delete
<DropdownMenuShortcut>
<Trash2 size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,123 @@
import { type ColumnDef } from '@tanstack/react-table'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { DataTableColumnHeader } from '@/components/data-table'
import { labels, priorities, statuses } from '../data/data'
import { type Task } from '../data/schema'
import { DataTableRowActions } from './data-table-row-actions'
export const tasksColumns: ColumnDef<Task>[] = [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && 'indeterminate')
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label='Select all'
className='translate-y-[2px]'
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label='Select row'
className='translate-y-[2px]'
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: 'id',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Task' />
),
cell: ({ row }) => <div className='w-[80px]'>{row.getValue('id')}</div>,
enableSorting: false,
enableHiding: false,
},
{
accessorKey: 'title',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Title' />
),
meta: {
className: 'ps-1 max-w-0 w-2/3',
tdClassName: 'ps-4',
},
cell: ({ row }) => {
const label = labels.find((label) => label.value === row.original.label)
return (
<div className='flex space-x-2'>
{label && <Badge variant='outline'>{label.label}</Badge>}
<span className='truncate font-medium'>{row.getValue('title')}</span>
</div>
)
},
},
{
accessorKey: 'status',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Status' />
),
meta: { className: 'ps-1', tdClassName: 'ps-4' },
cell: ({ row }) => {
const status = statuses.find(
(status) => status.value === row.getValue('status')
)
if (!status) {
return null
}
return (
<div className='flex w-[100px] items-center gap-2'>
{status.icon && (
<status.icon className='size-4 text-muted-foreground' />
)}
<span>{status.label}</span>
</div>
)
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id))
},
},
{
accessorKey: 'priority',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Priority' />
),
meta: { className: 'ps-1', tdClassName: 'ps-3' },
cell: ({ row }) => {
const priority = priorities.find(
(priority) => priority.value === row.getValue('priority')
)
if (!priority) {
return null
}
return (
<div className='flex items-center gap-2'>
{priority.icon && (
<priority.icon className='size-4 text-muted-foreground' />
)}
<span>{priority.label}</span>
</div>
)
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id))
},
},
{
id: 'actions',
cell: ({ row }) => <DataTableRowActions row={row} />,
},
]

View File

@@ -0,0 +1,72 @@
import { showSubmittedData } from '@/lib/show-submitted-data'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { TasksImportDialog } from './tasks-import-dialog'
import { TasksMutateDrawer } from './tasks-mutate-drawer'
import { useTasks } from './tasks-provider'
export function TasksDialogs() {
const { open, setOpen, currentRow, setCurrentRow } = useTasks()
return (
<>
<TasksMutateDrawer
key='task-create'
open={open === 'create'}
onOpenChange={() => setOpen('create')}
/>
<TasksImportDialog
key='tasks-import'
open={open === 'import'}
onOpenChange={() => setOpen('import')}
/>
{currentRow && (
<>
<TasksMutateDrawer
key={`task-update-${currentRow.id}`}
open={open === 'update'}
onOpenChange={() => {
setOpen('update')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}}
currentRow={currentRow}
/>
<ConfirmDialog
key='task-delete'
destructive
open={open === 'delete'}
onOpenChange={() => {
setOpen('delete')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}}
handleConfirm={() => {
setOpen(null)
setTimeout(() => {
setCurrentRow(null)
}, 500)
showSubmittedData(
currentRow,
'The following task has been deleted:'
)
}}
className='max-w-md'
title={`Delete this task: ${currentRow.id} ?`}
desc={
<>
You are about to delete a task with the ID{' '}
<strong>{currentRow.id}</strong>. <br />
This action cannot be undone.
</>
}
confirmText='Delete'
/>
</>
)}
</>
)
}

View File

@@ -0,0 +1,110 @@
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { showSubmittedData } from '@/lib/show-submitted-data'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
const formSchema = z.object({
file: z
.instanceof(FileList)
.refine((files) => files.length > 0, {
message: 'Please upload a file',
})
.refine(
(files) => ['text/csv'].includes(files?.[0]?.type),
'Please upload csv format.'
),
})
type TaskImportDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
export function TasksImportDialog({
open,
onOpenChange,
}: TaskImportDialogProps) {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { file: undefined },
})
const fileRef = form.register('file')
const onSubmit = () => {
const file = form.getValues('file')
if (file && file[0]) {
const fileDetails = {
name: file[0].name,
size: file[0].size,
type: file[0].type,
}
showSubmittedData(fileDetails, 'You have imported the following file:')
}
onOpenChange(false)
}
return (
<Dialog
open={open}
onOpenChange={(val) => {
onOpenChange(val)
form.reset()
}}
>
<DialogContent className='gap-2 sm:max-w-sm'>
<DialogHeader className='text-start'>
<DialogTitle>Import Tasks</DialogTitle>
<DialogDescription>
Import tasks quickly from a CSV file.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form id='task-import-form' onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name='file'
render={() => (
<FormItem className='my-2'>
<FormLabel>File</FormLabel>
<FormControl>
<Input type='file' {...fileRef} className='h-8 py-0' />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<DialogFooter className='gap-2'>
<DialogClose asChild>
<Button variant='outline'>Close</Button>
</DialogClose>
<Button type='submit' form='task-import-form'>
Import
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,95 @@
'use client'
import { useState } from 'react'
import { type Table } from '@tanstack/react-table'
import { AlertTriangle } from 'lucide-react'
import { toast } from 'sonner'
import { sleep } from '@/lib/utils'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ConfirmDialog } from '@/components/confirm-dialog'
type TaskMultiDeleteDialogProps<TData> = {
open: boolean
onOpenChange: (open: boolean) => void
table: Table<TData>
}
const CONFIRM_WORD = 'DELETE'
export function TasksMultiDeleteDialog<TData>({
open,
onOpenChange,
table,
}: TaskMultiDeleteDialogProps<TData>) {
const [value, setValue] = useState('')
const selectedRows = table.getFilteredSelectedRowModel().rows
const handleDelete = () => {
if (value.trim() !== CONFIRM_WORD) {
toast.error(`Please type "${CONFIRM_WORD}" to confirm.`)
return
}
onOpenChange(false)
toast.promise(sleep(2000), {
loading: 'Deleting tasks...',
success: () => {
setValue('')
table.resetRowSelection()
return `Deleted ${selectedRows.length} ${
selectedRows.length > 1 ? 'tasks' : 'task'
}`
},
error: 'Error',
})
}
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
handleConfirm={handleDelete}
disabled={value.trim() !== CONFIRM_WORD}
title={
<span className='text-destructive'>
<AlertTriangle
className='me-1 inline-block stroke-destructive'
size={18}
/>{' '}
Delete {selectedRows.length}{' '}
{selectedRows.length > 1 ? 'tasks' : 'task'}
</span>
}
desc={
<div className='space-y-4'>
<p className='mb-2'>
Are you sure you want to delete the selected tasks? <br />
This action cannot be undone.
</p>
<Label className='my-4 flex flex-col items-start gap-1.5'>
<span className=''>Confirm by typing "{CONFIRM_WORD}":</span>
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={`Type "${CONFIRM_WORD}" to confirm.`}
/>
</Label>
<Alert variant='destructive'>
<AlertTitle>Warning!</AlertTitle>
<AlertDescription>
Please be careful, this operation can not be rolled back.
</AlertDescription>
</Alert>
</div>
}
confirmText='Delete'
destructive
/>
)
}

View File

@@ -0,0 +1,212 @@
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { showSubmittedData } from '@/lib/show-submitted-data'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { SelectDropdown } from '@/components/select-dropdown'
import { type Task } from '../data/schema'
type TaskMutateDrawerProps = {
open: boolean
onOpenChange: (open: boolean) => void
currentRow?: Task
}
const formSchema = z.object({
title: z.string().min(1, 'Title is required.'),
status: z.string().min(1, 'Please select a status.'),
label: z.string().min(1, 'Please select a label.'),
priority: z.string().min(1, 'Please choose a priority.'),
})
type TaskForm = z.infer<typeof formSchema>
export function TasksMutateDrawer({
open,
onOpenChange,
currentRow,
}: TaskMutateDrawerProps) {
const isUpdate = !!currentRow
const form = useForm<TaskForm>({
resolver: zodResolver(formSchema),
defaultValues: currentRow ?? {
title: '',
status: '',
label: '',
priority: '',
},
})
const onSubmit = (data: TaskForm) => {
// do something with the form data
onOpenChange(false)
form.reset()
showSubmittedData(data)
}
return (
<Sheet
open={open}
onOpenChange={(v) => {
onOpenChange(v)
form.reset()
}}
>
<SheetContent className='flex flex-col'>
<SheetHeader className='text-start'>
<SheetTitle>{isUpdate ? 'Update' : 'Create'} Task</SheetTitle>
<SheetDescription>
{isUpdate
? 'Update the task by providing necessary info.'
: 'Add a new task by providing necessary info.'}
Click save when you&apos;re done.
</SheetDescription>
</SheetHeader>
<Form {...form}>
<form
id='tasks-form'
onSubmit={form.handleSubmit(onSubmit)}
className='flex-1 space-y-6 overflow-y-auto px-4'
>
<FormField
control={form.control}
name='title'
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input {...field} placeholder='Enter a title' />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='status'
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<SelectDropdown
defaultValue={field.value}
onValueChange={field.onChange}
placeholder='Select dropdown'
items={[
{ label: 'In Progress', value: 'in progress' },
{ label: 'Backlog', value: 'backlog' },
{ label: 'Todo', value: 'todo' },
{ label: 'Canceled', value: 'canceled' },
{ label: 'Done', value: 'done' },
]}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='label'
render={({ field }) => (
<FormItem className='relative'>
<FormLabel>Label</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className='flex flex-col space-y-1'
>
<FormItem className='flex items-center'>
<FormControl>
<RadioGroupItem value='documentation' />
</FormControl>
<FormLabel className='font-normal'>
Documentation
</FormLabel>
</FormItem>
<FormItem className='flex items-center'>
<FormControl>
<RadioGroupItem value='feature' />
</FormControl>
<FormLabel className='font-normal'>Feature</FormLabel>
</FormItem>
<FormItem className='flex items-center'>
<FormControl>
<RadioGroupItem value='bug' />
</FormControl>
<FormLabel className='font-normal'>Bug</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='priority'
render={({ field }) => (
<FormItem className='relative'>
<FormLabel>Priority</FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className='flex flex-col space-y-1'
>
<FormItem className='flex items-center'>
<FormControl>
<RadioGroupItem value='high' />
</FormControl>
<FormLabel className='font-normal'>High</FormLabel>
</FormItem>
<FormItem className='flex items-center'>
<FormControl>
<RadioGroupItem value='medium' />
</FormControl>
<FormLabel className='font-normal'>Medium</FormLabel>
</FormItem>
<FormItem className='flex items-center'>
<FormControl>
<RadioGroupItem value='low' />
</FormControl>
<FormLabel className='font-normal'>Low</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<SheetFooter className='gap-2'>
<SheetClose asChild>
<Button variant='outline'>Close</Button>
</SheetClose>
<Button form='tasks-form' type='submit'>
Save changes
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}

View File

@@ -0,0 +1,21 @@
import { Download, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useTasks } from './tasks-provider'
export function TasksPrimaryButtons() {
const { setOpen } = useTasks()
return (
<div className='flex gap-2'>
<Button
variant='outline'
className='space-x-1'
onClick={() => setOpen('import')}
>
<span>Import</span> <Download size={18} />
</Button>
<Button className='space-x-1' onClick={() => setOpen('create')}>
<span>Create</span> <Plus size={18} />
</Button>
</div>
)
}

View File

@@ -0,0 +1,36 @@
import React, { useState } from 'react'
import useDialogState from '@/hooks/use-dialog-state'
import { type Task } from '../data/schema'
type TasksDialogType = 'create' | 'update' | 'delete' | 'import'
type TasksContextType = {
open: TasksDialogType | null
setOpen: (str: TasksDialogType | null) => void
currentRow: Task | null
setCurrentRow: React.Dispatch<React.SetStateAction<Task | null>>
}
const TasksContext = React.createContext<TasksContextType | null>(null)
export function TasksProvider({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useDialogState<TasksDialogType>(null)
const [currentRow, setCurrentRow] = useState<Task | null>(null)
return (
<TasksContext value={{ open, setOpen, currentRow, setCurrentRow }}>
{children}
</TasksContext>
)
}
// eslint-disable-next-line react-refresh/only-export-components
export const useTasks = () => {
const tasksContext = React.useContext(TasksContext)
if (!tasksContext) {
throw new Error('useTasks has to be used within <TasksContext>')
}
return tasksContext
}

View File

@@ -0,0 +1,197 @@
import { useEffect, useState } from 'react'
import { getRouteApi } from '@tanstack/react-router'
import {
type SortingState,
type VisibilityState,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table'
import { cn } from '@/lib/utils'
import { useTableUrlState } from '@/hooks/use-table-url-state'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { DataTablePagination, DataTableToolbar } from '@/components/data-table'
import { priorities, statuses } from '../data/data'
import { type Task } from '../data/schema'
import { DataTableBulkActions } from './data-table-bulk-actions'
import { tasksColumns as columns } from './tasks-columns'
const route = getRouteApi('/_authenticated/tasks/')
type DataTableProps = {
data: Task[]
}
export function TasksTable({ data }: DataTableProps) {
// Local UI-only states
const [rowSelection, setRowSelection] = useState({})
const [sorting, setSorting] = useState<SortingState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
// Local state management for table (uncomment to use local-only state, not synced with URL)
// const [globalFilter, onGlobalFilterChange] = useState('')
// const [columnFilters, onColumnFiltersChange] = useState<ColumnFiltersState>([])
// const [pagination, onPaginationChange] = useState<PaginationState>({ pageIndex: 0, pageSize: 10 })
// Synced with URL states (updated to match route search schema defaults)
const {
globalFilter,
onGlobalFilterChange,
columnFilters,
onColumnFiltersChange,
pagination,
onPaginationChange,
ensurePageInRange,
} = useTableUrlState({
search: route.useSearch(),
navigate: route.useNavigate(),
pagination: { defaultPage: 1, defaultPageSize: 10 },
globalFilter: { enabled: true, key: 'filter' },
columnFilters: [
{ columnId: 'status', searchKey: 'status', type: 'array' },
{ columnId: 'priority', searchKey: 'priority', type: 'array' },
],
})
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
globalFilter,
pagination,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnVisibilityChange: setColumnVisibility,
globalFilterFn: (row, _columnId, filterValue) => {
const id = String(row.getValue('id')).toLowerCase()
const title = String(row.getValue('title')).toLowerCase()
const searchValue = String(filterValue).toLowerCase()
return id.includes(searchValue) || title.includes(searchValue)
},
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
onPaginationChange,
onGlobalFilterChange,
onColumnFiltersChange,
})
const pageCount = table.getPageCount()
useEffect(() => {
ensurePageInRange(pageCount)
}, [pageCount, ensurePageInRange])
return (
<div
className={cn(
'max-sm:has-[div[role="toolbar"]]:mb-16', // Add margin bottom to the table on mobile when the toolbar is visible
'flex flex-1 flex-col gap-4'
)}
>
<DataTableToolbar
table={table}
searchPlaceholder='Filter by title or ID...'
filters={[
{
columnId: 'status',
title: 'Status',
options: statuses,
},
{
columnId: 'priority',
title: 'Priority',
options: priorities,
},
]}
/>
<div className='overflow-hidden rounded-md border'>
<Table className='min-w-xl'>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
colSpan={header.colSpan}
className={cn(
header.column.columnDef.meta?.className,
header.column.columnDef.meta?.thClassName
)}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={cn(
cell.column.columnDef.meta?.className,
cell.column.columnDef.meta?.tdClassName
)}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className='h-24 text-center'
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<DataTablePagination table={table} className='mt-auto' />
<DataTableBulkActions table={table} />
</div>
)
}

View File

@@ -0,0 +1,77 @@
import {
ArrowDown,
ArrowRight,
ArrowUp,
Circle,
CheckCircle,
AlertCircle,
Timer,
HelpCircle,
CircleOff,
} from 'lucide-react'
export const labels = [
{
value: 'bug',
label: 'Bug',
},
{
value: 'feature',
label: 'Feature',
},
{
value: 'documentation',
label: 'Documentation',
},
]
export const statuses = [
{
label: 'Backlog',
value: 'backlog' as const,
icon: HelpCircle,
},
{
label: 'Todo',
value: 'todo' as const,
icon: Circle,
},
{
label: 'In Progress',
value: 'in progress' as const,
icon: Timer,
},
{
label: 'Done',
value: 'done' as const,
icon: CheckCircle,
},
{
label: 'Canceled',
value: 'canceled' as const,
icon: CircleOff,
},
]
export const priorities = [
{
label: 'Low',
value: 'low' as const,
icon: ArrowDown,
},
{
label: 'Medium',
value: 'medium' as const,
icon: ArrowRight,
},
{
label: 'High',
value: 'high' as const,
icon: ArrowUp,
},
{
label: 'Critical',
value: 'critical' as const,
icon: AlertCircle,
},
]

View File

@@ -0,0 +1,13 @@
import { z } from 'zod'
// We're keeping a simple non-relational schema here.
// IRL, you will have a schema for your data models.
export const taskSchema = z.object({
id: z.string(),
title: z.string(),
status: z.string(),
label: z.string(),
priority: z.string(),
})
export type Task = z.infer<typeof taskSchema>

View File

@@ -0,0 +1,29 @@
import { faker } from '@faker-js/faker'
// Set a fixed seed for consistent data generation
faker.seed(12345)
export const tasks = Array.from({ length: 100 }, () => {
const statuses = [
'todo',
'in progress',
'done',
'canceled',
'backlog',
] as const
const labels = ['bug', 'feature', 'documentation'] as const
const priorities = ['low', 'medium', 'high'] as const
return {
id: `TASK-${faker.number.int({ min: 1000, max: 9999 })}`,
title: faker.lorem.sentence({ min: 5, max: 15 }),
status: faker.helpers.arrayElement(statuses),
label: faker.helpers.arrayElement(labels),
priority: faker.helpers.arrayElement(priorities),
createdAt: faker.date.past(),
updatedAt: faker.date.recent(),
assignee: faker.person.fullName(),
description: faker.lorem.paragraph({ min: 1, max: 3 }),
dueDate: faker.date.future(),
}
})

View File

@@ -0,0 +1,41 @@
import { ConfigDrawer } from '@/components/config-drawer'
import { Header } from '@/components/layout/header'
import { Main } from '@/components/layout/main'
import { ProfileDropdown } from '@/components/profile-dropdown'
import { Search } from '@/components/search'
import { ThemeSwitch } from '@/components/theme-switch'
import { TasksDialogs } from './components/tasks-dialogs'
import { TasksPrimaryButtons } from './components/tasks-primary-buttons'
import { TasksProvider } from './components/tasks-provider'
import { TasksTable } from './components/tasks-table'
import { tasks } from './data/tasks'
export function Tasks() {
return (
<TasksProvider>
<Header fixed>
<Search />
<div className='ms-auto flex items-center space-x-4'>
<ThemeSwitch />
<ConfigDrawer />
<ProfileDropdown />
</div>
</Header>
<Main className='flex flex-1 flex-col gap-4 sm:gap-6'>
<div className='flex flex-wrap items-end justify-between gap-2'>
<div>
<h2 className='text-2xl font-bold tracking-tight'>Tasks</h2>
<p className='text-muted-foreground'>
Here&apos;s a list of your tasks for this month!
</p>
</div>
<TasksPrimaryButtons />
</div>
<TasksTable data={tasks} />
</Main>
<TasksDialogs />
</TasksProvider>
)
}

View File

@@ -0,0 +1,139 @@
import { useState } from 'react'
import { type Table } from '@tanstack/react-table'
import { Trash2, UserX, UserCheck, Mail } from 'lucide-react'
import { toast } from 'sonner'
import { sleep } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { DataTableBulkActions as BulkActionsToolbar } from '@/components/data-table'
import { type User } from '../data/schema'
import { UsersMultiDeleteDialog } from './users-multi-delete-dialog'
type DataTableBulkActionsProps<TData> = {
table: Table<TData>
}
export function DataTableBulkActions<TData>({
table,
}: DataTableBulkActionsProps<TData>) {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const selectedRows = table.getFilteredSelectedRowModel().rows
const handleBulkStatusChange = (status: 'active' | 'inactive') => {
const selectedUsers = selectedRows.map((row) => row.original as User)
toast.promise(sleep(2000), {
loading: `${status === 'active' ? 'Activating' : 'Deactivating'} users...`,
success: () => {
table.resetRowSelection()
return `${status === 'active' ? 'Activated' : 'Deactivated'} ${selectedUsers.length} user${selectedUsers.length > 1 ? 's' : ''}`
},
error: `Error ${status === 'active' ? 'activating' : 'deactivating'} users`,
})
table.resetRowSelection()
}
const handleBulkInvite = () => {
const selectedUsers = selectedRows.map((row) => row.original as User)
toast.promise(sleep(2000), {
loading: 'Inviting users...',
success: () => {
table.resetRowSelection()
return `Invited ${selectedUsers.length} user${selectedUsers.length > 1 ? 's' : ''}`
},
error: 'Error inviting users',
})
table.resetRowSelection()
}
return (
<>
<BulkActionsToolbar table={table} entityName='user'>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='outline'
size='icon'
onClick={handleBulkInvite}
className='size-8'
aria-label='Invite selected users'
title='Invite selected users'
>
<Mail />
<span className='sr-only'>Invite selected users</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Invite selected users</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='outline'
size='icon'
onClick={() => handleBulkStatusChange('active')}
className='size-8'
aria-label='Activate selected users'
title='Activate selected users'
>
<UserCheck />
<span className='sr-only'>Activate selected users</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Activate selected users</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='outline'
size='icon'
onClick={() => handleBulkStatusChange('inactive')}
className='size-8'
aria-label='Deactivate selected users'
title='Deactivate selected users'
>
<UserX />
<span className='sr-only'>Deactivate selected users</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Deactivate selected users</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='destructive'
size='icon'
onClick={() => setShowDeleteConfirm(true)}
className='size-8'
aria-label='Delete selected users'
title='Delete selected users'
>
<Trash2 />
<span className='sr-only'>Delete selected users</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete selected users</p>
</TooltipContent>
</Tooltip>
</BulkActionsToolbar>
<UsersMultiDeleteDialog
table={table}
open={showDeleteConfirm}
onOpenChange={setShowDeleteConfirm}
/>
</>
)
}

View File

@@ -0,0 +1,63 @@
import { DotsHorizontalIcon } from '@radix-ui/react-icons'
import { type Row } from '@tanstack/react-table'
import { Trash2, UserPen } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { type User } from '../data/schema'
import { useUsers } from './users-provider'
type DataTableRowActionsProps = {
row: Row<User>
}
export function DataTableRowActions({ row }: DataTableRowActionsProps) {
const { setOpen, setCurrentRow } = useUsers()
return (
<>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
className='flex h-8 w-8 p-0 data-[state=open]:bg-muted'
>
<DotsHorizontalIcon className='h-4 w-4' />
<span className='sr-only'>Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-[160px]'>
<DropdownMenuItem
onClick={() => {
setCurrentRow(row.original)
setOpen('edit')
}}
>
Edit
<DropdownMenuShortcut>
<UserPen size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setCurrentRow(row.original)
setOpen('delete')
}}
className='text-red-500!'
>
Delete
<DropdownMenuShortcut>
<Trash2 size={16} />
</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)
}

View File

@@ -0,0 +1,326 @@
'use client'
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { showSubmittedData } from '@/lib/show-submitted-data'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { PasswordInput } from '@/components/password-input'
import { SelectDropdown } from '@/components/select-dropdown'
import { roles } from '../data/data'
import { type User } from '../data/schema'
const formSchema = z
.object({
firstName: z.string().min(1, 'First Name is required.'),
lastName: z.string().min(1, 'Last Name is required.'),
username: z.string().min(1, 'Username is required.'),
phoneNumber: z.string().min(1, 'Phone number is required.'),
email: z.email({
error: (iss) => (iss.input === '' ? 'Email is required.' : undefined),
}),
password: z.string().transform((pwd) => pwd.trim()),
role: z.string().min(1, 'Role is required.'),
confirmPassword: z.string().transform((pwd) => pwd.trim()),
isEdit: z.boolean(),
})
.refine(
(data) => {
if (data.isEdit && !data.password) return true
return data.password.length > 0
},
{
message: 'Password is required.',
path: ['password'],
}
)
.refine(
({ isEdit, password }) => {
if (isEdit && !password) return true
return password.length >= 8
},
{
message: 'Password must be at least 8 characters long.',
path: ['password'],
}
)
.refine(
({ isEdit, password }) => {
if (isEdit && !password) return true
return /[a-z]/.test(password)
},
{
message: 'Password must contain at least one lowercase letter.',
path: ['password'],
}
)
.refine(
({ isEdit, password }) => {
if (isEdit && !password) return true
return /\d/.test(password)
},
{
message: 'Password must contain at least one number.',
path: ['password'],
}
)
.refine(
({ isEdit, password, confirmPassword }) => {
if (isEdit && !password) return true
return password === confirmPassword
},
{
message: "Passwords don't match.",
path: ['confirmPassword'],
}
)
type UserForm = z.infer<typeof formSchema>
type UserActionDialogProps = {
currentRow?: User
open: boolean
onOpenChange: (open: boolean) => void
}
export function UsersActionDialog({
currentRow,
open,
onOpenChange,
}: UserActionDialogProps) {
const isEdit = !!currentRow
const form = useForm<UserForm>({
resolver: zodResolver(formSchema),
defaultValues: isEdit
? {
...currentRow,
password: '',
confirmPassword: '',
isEdit,
}
: {
firstName: '',
lastName: '',
username: '',
email: '',
role: '',
phoneNumber: '',
password: '',
confirmPassword: '',
isEdit,
},
})
const onSubmit = (values: UserForm) => {
form.reset()
showSubmittedData(values)
onOpenChange(false)
}
const isPasswordTouched = !!form.formState.dirtyFields.password
return (
<Dialog
open={open}
onOpenChange={(state) => {
form.reset()
onOpenChange(state)
}}
>
<DialogContent className='sm:max-w-lg'>
<DialogHeader className='text-start'>
<DialogTitle>{isEdit ? 'Edit User' : 'Add New User'}</DialogTitle>
<DialogDescription>
{isEdit ? 'Update the user here. ' : 'Create new user here. '}
Click save when you&apos;re done.
</DialogDescription>
</DialogHeader>
<div className='h-105 w-[calc(100%+0.75rem)] overflow-y-auto py-1 pe-3'>
<Form {...form}>
<form
id='user-form'
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-4 px-0.5'
>
<FormField
control={form.control}
name='firstName'
render={({ field }) => (
<FormItem className='grid grid-cols-6 items-center space-y-0 gap-x-4 gap-y-1'>
<FormLabel className='col-span-2 text-end'>
First Name
</FormLabel>
<FormControl>
<Input
placeholder='John'
className='col-span-4'
autoComplete='off'
{...field}
/>
</FormControl>
<FormMessage className='col-span-4 col-start-3' />
</FormItem>
)}
/>
<FormField
control={form.control}
name='lastName'
render={({ field }) => (
<FormItem className='grid grid-cols-6 items-center space-y-0 gap-x-4 gap-y-1'>
<FormLabel className='col-span-2 text-end'>
Last Name
</FormLabel>
<FormControl>
<Input
placeholder='Doe'
className='col-span-4'
autoComplete='off'
{...field}
/>
</FormControl>
<FormMessage className='col-span-4 col-start-3' />
</FormItem>
)}
/>
<FormField
control={form.control}
name='username'
render={({ field }) => (
<FormItem className='grid grid-cols-6 items-center space-y-0 gap-x-4 gap-y-1'>
<FormLabel className='col-span-2 text-end'>
Username
</FormLabel>
<FormControl>
<Input
placeholder='john_doe'
className='col-span-4'
{...field}
/>
</FormControl>
<FormMessage className='col-span-4 col-start-3' />
</FormItem>
)}
/>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem className='grid grid-cols-6 items-center space-y-0 gap-x-4 gap-y-1'>
<FormLabel className='col-span-2 text-end'>Email</FormLabel>
<FormControl>
<Input
placeholder='john.doe@gmail.com'
className='col-span-4'
{...field}
/>
</FormControl>
<FormMessage className='col-span-4 col-start-3' />
</FormItem>
)}
/>
<FormField
control={form.control}
name='phoneNumber'
render={({ field }) => (
<FormItem className='grid grid-cols-6 items-center space-y-0 gap-x-4 gap-y-1'>
<FormLabel className='col-span-2 text-end'>
Phone Number
</FormLabel>
<FormControl>
<Input
placeholder='+123456789'
className='col-span-4'
{...field}
/>
</FormControl>
<FormMessage className='col-span-4 col-start-3' />
</FormItem>
)}
/>
<FormField
control={form.control}
name='role'
render={({ field }) => (
<FormItem className='grid grid-cols-6 items-center space-y-0 gap-x-4 gap-y-1'>
<FormLabel className='col-span-2 text-end'>Role</FormLabel>
<SelectDropdown
defaultValue={field.value}
onValueChange={field.onChange}
placeholder='Select a role'
className='col-span-4'
items={roles.map(({ label, value }) => ({
label,
value,
}))}
/>
<FormMessage className='col-span-4 col-start-3' />
</FormItem>
)}
/>
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem className='grid grid-cols-6 items-center space-y-0 gap-x-4 gap-y-1'>
<FormLabel className='col-span-2 text-end'>
Password
</FormLabel>
<FormControl>
<PasswordInput
placeholder='e.g., S3cur3P@ssw0rd'
className='col-span-4'
{...field}
/>
</FormControl>
<FormMessage className='col-span-4 col-start-3' />
</FormItem>
)}
/>
<FormField
control={form.control}
name='confirmPassword'
render={({ field }) => (
<FormItem className='grid grid-cols-6 items-center space-y-0 gap-x-4 gap-y-1'>
<FormLabel className='col-span-2 text-end'>
Confirm Password
</FormLabel>
<FormControl>
<PasswordInput
disabled={!isPasswordTouched}
placeholder='e.g., S3cur3P@ssw0rd'
className='col-span-4'
{...field}
/>
</FormControl>
<FormMessage className='col-span-4 col-start-3' />
</FormItem>
)}
/>
</form>
</Form>
</div>
<DialogFooter>
<Button type='submit' form='user-form'>
Save changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,138 @@
import { type ColumnDef } from '@tanstack/react-table'
import { cn } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { DataTableColumnHeader } from '@/components/data-table'
import { LongText } from '@/components/long-text'
import { callTypes, roles } from '../data/data'
import { type User } from '../data/schema'
import { DataTableRowActions } from './data-table-row-actions'
export const usersColumns: ColumnDef<User>[] = [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && 'indeterminate')
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label='Select all'
className='translate-y-[2px]'
/>
),
meta: {
className: cn('max-md:sticky start-0 z-10 rounded-tl-[inherit]'),
},
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label='Select row'
className='translate-y-[2px]'
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: 'username',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Username' />
),
cell: ({ row }) => (
<LongText className='max-w-36 ps-3'>{row.getValue('username')}</LongText>
),
meta: {
className: cn(
'drop-shadow-[0_1px_2px_rgb(0_0_0_/_0.1)] dark:drop-shadow-[0_1px_2px_rgb(255_255_255_/_0.1)]',
'ps-0.5 max-md:sticky start-6 @4xl/content:table-cell @4xl/content:drop-shadow-none'
),
},
enableHiding: false,
},
{
id: 'fullName',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Name' />
),
cell: ({ row }) => {
const { firstName, lastName } = row.original
const fullName = `${firstName} ${lastName}`
return <LongText className='max-w-36'>{fullName}</LongText>
},
meta: { className: 'w-36' },
},
{
accessorKey: 'email',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Email' />
),
cell: ({ row }) => (
<div className='w-fit ps-2 text-nowrap'>{row.getValue('email')}</div>
),
},
{
accessorKey: 'phoneNumber',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Phone Number' />
),
cell: ({ row }) => <div>{row.getValue('phoneNumber')}</div>,
enableSorting: false,
},
{
accessorKey: 'status',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Status' />
),
cell: ({ row }) => {
const { status } = row.original
const badgeColor = callTypes.get(status)
return (
<div className='flex space-x-2'>
<Badge variant='outline' className={cn('capitalize', badgeColor)}>
{row.getValue('status')}
</Badge>
</div>
)
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id))
},
enableHiding: false,
enableSorting: false,
},
{
accessorKey: 'role',
header: ({ column }) => (
<DataTableColumnHeader column={column} title='Role' />
),
cell: ({ row }) => {
const { role } = row.original
const userType = roles.find(({ value }) => value === role)
if (!userType) {
return null
}
return (
<div className='flex items-center gap-x-2'>
{userType.icon && (
<userType.icon size={16} className='text-muted-foreground' />
)}
<span className='text-sm capitalize'>{row.getValue('role')}</span>
</div>
)
},
filterFn: (row, id, value) => {
return value.includes(row.getValue(id))
},
enableSorting: false,
enableHiding: false,
},
{
id: 'actions',
cell: DataTableRowActions,
},
]

View File

@@ -0,0 +1,81 @@
'use client'
import { useState } from 'react'
import { AlertTriangle } from 'lucide-react'
import { showSubmittedData } from '@/lib/show-submitted-data'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { type User } from '../data/schema'
type UserDeleteDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
currentRow: User
}
export function UsersDeleteDialog({
open,
onOpenChange,
currentRow,
}: UserDeleteDialogProps) {
const [value, setValue] = useState('')
const handleDelete = () => {
if (value.trim() !== currentRow.username) return
onOpenChange(false)
showSubmittedData(currentRow, 'The following user has been deleted:')
}
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
handleConfirm={handleDelete}
disabled={value.trim() !== currentRow.username}
title={
<span className='text-destructive'>
<AlertTriangle
className='me-1 inline-block stroke-destructive'
size={18}
/>{' '}
Delete User
</span>
}
desc={
<div className='space-y-4'>
<p className='mb-2'>
Are you sure you want to delete{' '}
<span className='font-bold'>{currentRow.username}</span>?
<br />
This action will permanently remove the user with the role of{' '}
<span className='font-bold'>
{currentRow.role.toUpperCase()}
</span>{' '}
from the system. This cannot be undone.
</p>
<Label className='my-2'>
Username:
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder='Enter username to confirm deletion.'
/>
</Label>
<Alert variant='destructive'>
<AlertTitle>Warning!</AlertTitle>
<AlertDescription>
Please be careful, this operation can not be rolled back.
</AlertDescription>
</Alert>
</div>
}
confirmText='Delete'
destructive
/>
)
}

View File

@@ -0,0 +1,51 @@
import { UsersActionDialog } from './users-action-dialog'
import { UsersDeleteDialog } from './users-delete-dialog'
import { UsersInviteDialog } from './users-invite-dialog'
import { useUsers } from './users-provider'
export function UsersDialogs() {
const { open, setOpen, currentRow, setCurrentRow } = useUsers()
return (
<>
<UsersActionDialog
key='user-add'
open={open === 'add'}
onOpenChange={() => setOpen('add')}
/>
<UsersInviteDialog
key='user-invite'
open={open === 'invite'}
onOpenChange={() => setOpen('invite')}
/>
{currentRow && (
<>
<UsersActionDialog
key={`user-edit-${currentRow.id}`}
open={open === 'edit'}
onOpenChange={() => {
setOpen('edit')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}}
currentRow={currentRow}
/>
<UsersDeleteDialog
key={`user-delete-${currentRow.id}`}
open={open === 'delete'}
onOpenChange={() => {
setOpen('delete')
setTimeout(() => {
setCurrentRow(null)
}, 500)
}}
currentRow={currentRow}
/>
</>
)}
</>
)
}

View File

@@ -0,0 +1,150 @@
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { MailPlus, Send } from 'lucide-react'
import { showSubmittedData } from '@/lib/show-submitted-data'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { SelectDropdown } from '@/components/select-dropdown'
import { roles } from '../data/data'
const formSchema = z.object({
email: z.email({
error: (iss) =>
iss.input === '' ? 'Please enter an email to invite.' : undefined,
}),
role: z.string().min(1, 'Role is required.'),
desc: z.string().optional(),
})
type UserInviteForm = z.infer<typeof formSchema>
type UserInviteDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
}
export function UsersInviteDialog({
open,
onOpenChange,
}: UserInviteDialogProps) {
const form = useForm<UserInviteForm>({
resolver: zodResolver(formSchema),
defaultValues: { email: '', role: '', desc: '' },
})
const onSubmit = (values: UserInviteForm) => {
form.reset()
showSubmittedData(values)
onOpenChange(false)
}
return (
<Dialog
open={open}
onOpenChange={(state) => {
form.reset()
onOpenChange(state)
}}
>
<DialogContent className='sm:max-w-md'>
<DialogHeader className='text-start'>
<DialogTitle className='flex items-center gap-2'>
<MailPlus /> Invite User
</DialogTitle>
<DialogDescription>
Invite new user to join your team by sending them an email
invitation. Assign a role to define their access level.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
id='user-invite-form'
onSubmit={form.handleSubmit(onSubmit)}
className='space-y-4'
>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type='email'
placeholder='eg: john.doe@gmail.com'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='role'
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<SelectDropdown
defaultValue={field.value}
onValueChange={field.onChange}
placeholder='Select a role'
items={roles.map(({ label, value }) => ({
label,
value,
}))}
/>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='desc'
render={({ field }) => (
<FormItem className=''>
<FormLabel>Description (optional)</FormLabel>
<FormControl>
<Textarea
className='resize-none'
placeholder='Add a personal note to your invitation (optional)'
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<DialogFooter className='gap-y-2'>
<DialogClose asChild>
<Button variant='outline'>Cancel</Button>
</DialogClose>
<Button type='submit' form='user-invite-form'>
Invite <Send />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,95 @@
'use client'
import { useState } from 'react'
import { type Table } from '@tanstack/react-table'
import { AlertTriangle } from 'lucide-react'
import { toast } from 'sonner'
import { sleep } from '@/lib/utils'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ConfirmDialog } from '@/components/confirm-dialog'
type UserMultiDeleteDialogProps<TData> = {
open: boolean
onOpenChange: (open: boolean) => void
table: Table<TData>
}
const CONFIRM_WORD = 'DELETE'
export function UsersMultiDeleteDialog<TData>({
open,
onOpenChange,
table,
}: UserMultiDeleteDialogProps<TData>) {
const [value, setValue] = useState('')
const selectedRows = table.getFilteredSelectedRowModel().rows
const handleDelete = () => {
if (value.trim() !== CONFIRM_WORD) {
toast.error(`Please type "${CONFIRM_WORD}" to confirm.`)
return
}
onOpenChange(false)
toast.promise(sleep(2000), {
loading: 'Deleting users...',
success: () => {
setValue('')
table.resetRowSelection()
return `Deleted ${selectedRows.length} ${
selectedRows.length > 1 ? 'users' : 'user'
}`
},
error: 'Error',
})
}
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
handleConfirm={handleDelete}
disabled={value.trim() !== CONFIRM_WORD}
title={
<span className='text-destructive'>
<AlertTriangle
className='me-1 inline-block stroke-destructive'
size={18}
/>{' '}
Delete {selectedRows.length}{' '}
{selectedRows.length > 1 ? 'users' : 'user'}
</span>
}
desc={
<div className='space-y-4'>
<p className='mb-2'>
Are you sure you want to delete the selected users? <br />
This action cannot be undone.
</p>
<Label className='my-4 flex flex-col items-start gap-1.5'>
<span className=''>Confirm by typing "{CONFIRM_WORD}":</span>
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={`Type "${CONFIRM_WORD}" to confirm.`}
/>
</Label>
<Alert variant='destructive'>
<AlertTitle>Warning!</AlertTitle>
<AlertDescription>
Please be careful, this operation can not be rolled back.
</AlertDescription>
</Alert>
</div>
}
confirmText='Delete'
destructive
/>
)
}

View File

@@ -0,0 +1,21 @@
import { MailPlus, UserPlus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useUsers } from './users-provider'
export function UsersPrimaryButtons() {
const { setOpen } = useUsers()
return (
<div className='flex gap-2'>
<Button
variant='outline'
className='space-x-1'
onClick={() => setOpen('invite')}
>
<span>Invite User</span> <MailPlus size={18} />
</Button>
<Button className='space-x-1' onClick={() => setOpen('add')}>
<span>Add User</span> <UserPlus size={18} />
</Button>
</div>
)
}

View File

@@ -0,0 +1,36 @@
import React, { useState } from 'react'
import useDialogState from '@/hooks/use-dialog-state'
import { type User } from '../data/schema'
type UsersDialogType = 'invite' | 'add' | 'edit' | 'delete'
type UsersContextType = {
open: UsersDialogType | null
setOpen: (str: UsersDialogType | null) => void
currentRow: User | null
setCurrentRow: React.Dispatch<React.SetStateAction<User | null>>
}
const UsersContext = React.createContext<UsersContextType | null>(null)
export function UsersProvider({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useDialogState<UsersDialogType>(null)
const [currentRow, setCurrentRow] = useState<User | null>(null)
return (
<UsersContext value={{ open, setOpen, currentRow, setCurrentRow }}>
{children}
</UsersContext>
)
}
// eslint-disable-next-line react-refresh/only-export-components
export const useUsers = () => {
const usersContext = React.useContext(UsersContext)
if (!usersContext) {
throw new Error('useUsers has to be used within <UsersContext>')
}
return usersContext
}

View File

@@ -0,0 +1,194 @@
import { useEffect, useState } from 'react'
import {
type SortingState,
type VisibilityState,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table'
import { cn } from '@/lib/utils'
import { type NavigateFn, useTableUrlState } from '@/hooks/use-table-url-state'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { DataTablePagination, DataTableToolbar } from '@/components/data-table'
import { roles } from '../data/data'
import { type User } from '../data/schema'
import { DataTableBulkActions } from './data-table-bulk-actions'
import { usersColumns as columns } from './users-columns'
type DataTableProps = {
data: User[]
search: Record<string, unknown>
navigate: NavigateFn
}
export function UsersTable({ data, search, navigate }: DataTableProps) {
// Local UI-only states
const [rowSelection, setRowSelection] = useState({})
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [sorting, setSorting] = useState<SortingState>([])
// Local state management for table (uncomment to use local-only state, not synced with URL)
// const [columnFilters, onColumnFiltersChange] = useState<ColumnFiltersState>([])
// const [pagination, onPaginationChange] = useState<PaginationState>({ pageIndex: 0, pageSize: 10 })
// Synced with URL states (keys/defaults mirror users route search schema)
const {
columnFilters,
onColumnFiltersChange,
pagination,
onPaginationChange,
ensurePageInRange,
} = useTableUrlState({
search,
navigate,
pagination: { defaultPage: 1, defaultPageSize: 10 },
globalFilter: { enabled: false },
columnFilters: [
// username per-column text filter
{ columnId: 'username', searchKey: 'username', type: 'string' },
{ columnId: 'status', searchKey: 'status', type: 'array' },
{ columnId: 'role', searchKey: 'role', type: 'array' },
],
})
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data,
columns,
state: {
sorting,
pagination,
rowSelection,
columnFilters,
columnVisibility,
},
enableRowSelection: true,
onPaginationChange,
onColumnFiltersChange,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnVisibilityChange: setColumnVisibility,
getPaginationRowModel: getPaginationRowModel(),
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
})
useEffect(() => {
ensurePageInRange(table.getPageCount())
}, [table, ensurePageInRange])
return (
<div
className={cn(
'max-sm:has-[div[role="toolbar"]]:mb-16', // Add margin bottom to the table on mobile when the toolbar is visible
'flex flex-1 flex-col gap-4'
)}
>
<DataTableToolbar
table={table}
searchPlaceholder='Filter users...'
searchKey='username'
filters={[
{
columnId: 'status',
title: 'Status',
options: [
{ label: 'Active', value: 'active' },
{ label: 'Inactive', value: 'inactive' },
{ label: 'Invited', value: 'invited' },
{ label: 'Suspended', value: 'suspended' },
],
},
{
columnId: 'role',
title: 'Role',
options: roles.map((role) => ({ ...role })),
},
]}
/>
<div className='overflow-hidden rounded-md border'>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className='group/row'>
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
colSpan={header.colSpan}
className={cn(
'bg-background group-hover/row:bg-muted group-data-[state=selected]/row:bg-muted',
header.column.columnDef.meta?.className,
header.column.columnDef.meta?.thClassName
)}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
className='group/row'
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={cn(
'bg-background group-hover/row:bg-muted group-data-[state=selected]/row:bg-muted',
cell.column.columnDef.meta?.className,
cell.column.columnDef.meta?.tdClassName
)}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className='h-24 text-center'
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<DataTablePagination table={table} className='mt-auto' />
<DataTableBulkActions table={table} />
</div>
)
}

View File

@@ -0,0 +1,35 @@
import { Shield, UserCheck, Users, CreditCard } from 'lucide-react'
import { type UserStatus } from './schema'
export const callTypes = new Map<UserStatus, string>([
['active', 'bg-teal-100/30 text-teal-900 dark:text-teal-200 border-teal-200'],
['inactive', 'bg-neutral-300/40 border-neutral-300'],
['invited', 'bg-sky-200/40 text-sky-900 dark:text-sky-100 border-sky-300'],
[
'suspended',
'bg-destructive/10 dark:bg-destructive/50 text-destructive dark:text-primary border-destructive/10',
],
])
export const roles = [
{
label: 'Superadmin',
value: 'superadmin',
icon: Shield,
},
{
label: 'Admin',
value: 'admin',
icon: UserCheck,
},
{
label: 'Manager',
value: 'manager',
icon: Users,
},
{
label: 'Cashier',
value: 'cashier',
icon: CreditCard,
},
] as const

View File

@@ -0,0 +1,32 @@
import { z } from 'zod'
const userStatusSchema = z.union([
z.literal('active'),
z.literal('inactive'),
z.literal('invited'),
z.literal('suspended'),
])
export type UserStatus = z.infer<typeof userStatusSchema>
const userRoleSchema = z.union([
z.literal('superadmin'),
z.literal('admin'),
z.literal('cashier'),
z.literal('manager'),
])
const userSchema = z.object({
id: z.string(),
firstName: z.string(),
lastName: z.string(),
username: z.string(),
email: z.string(),
phoneNumber: z.string(),
status: userStatusSchema,
role: userRoleSchema,
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
})
export type User = z.infer<typeof userSchema>
export const userListSchema = z.array(userSchema)

View File

@@ -0,0 +1,33 @@
import { faker } from '@faker-js/faker'
// Set a fixed seed for consistent data generation
faker.seed(67890)
export const users = Array.from({ length: 500 }, () => {
const firstName = faker.person.firstName()
const lastName = faker.person.lastName()
return {
id: faker.string.uuid(),
firstName,
lastName,
username: faker.internet
.username({ firstName, lastName })
.toLocaleLowerCase(),
email: faker.internet.email({ firstName }).toLocaleLowerCase(),
phoneNumber: faker.phone.number({ style: 'international' }),
status: faker.helpers.arrayElement([
'active',
'inactive',
'invited',
'suspended',
]),
role: faker.helpers.arrayElement([
'superadmin',
'admin',
'cashier',
'manager',
]),
createdAt: faker.date.past(),
updatedAt: faker.date.recent(),
}
})

View File

@@ -0,0 +1,47 @@
import { getRouteApi } from '@tanstack/react-router'
import { ConfigDrawer } from '@/components/config-drawer'
import { Header } from '@/components/layout/header'
import { Main } from '@/components/layout/main'
import { ProfileDropdown } from '@/components/profile-dropdown'
import { Search } from '@/components/search'
import { ThemeSwitch } from '@/components/theme-switch'
import { UsersDialogs } from './components/users-dialogs'
import { UsersPrimaryButtons } from './components/users-primary-buttons'
import { UsersProvider } from './components/users-provider'
import { UsersTable } from './components/users-table'
import { users } from './data/users'
const route = getRouteApi('/_authenticated/users/')
export function Users() {
const search = route.useSearch()
const navigate = route.useNavigate()
return (
<UsersProvider>
<Header fixed>
<Search />
<div className='ms-auto flex items-center space-x-4'>
<ThemeSwitch />
<ConfigDrawer />
<ProfileDropdown />
</div>
</Header>
<Main className='flex flex-1 flex-col gap-4 sm:gap-6'>
<div className='flex flex-wrap items-end justify-between gap-2'>
<div>
<h2 className='text-2xl font-bold tracking-tight'>User List</h2>
<p className='text-muted-foreground'>
Manage your users and their roles here.
</p>
</div>
<UsersPrimaryButtons />
</div>
<UsersTable data={users} search={search} navigate={navigate} />
</Main>
<UsersDialogs />
</UsersProvider>
)
}