feat: add authenticated settings page.
This commit is contained in:
110
shadcn-admin/src/features/apps/data/apps.tsx
Normal file
110
shadcn-admin/src/features/apps/data/apps.tsx
Normal 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.',
|
||||
},
|
||||
]
|
||||
179
shadcn-admin/src/features/apps/index.tsx
Normal file
179
shadcn-admin/src/features/apps/index.tsx
Normal 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'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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
19
shadcn-admin/src/features/auth/auth-layout.tsx
Normal file
19
shadcn-admin/src/features/auth/auth-layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
44
shadcn-admin/src/features/auth/forgot-password/index.tsx
Normal file
44
shadcn-admin/src/features/auth/forgot-password/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
100
shadcn-admin/src/features/auth/otp/components/otp-form.tsx
Normal file
100
shadcn-admin/src/features/auth/otp/components/otp-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
shadcn-admin/src/features/auth/otp/index.tsx
Normal file
44
shadcn-admin/src/features/auth/otp/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
BIN
shadcn-admin/src/features/auth/sign-in/assets/dashboard-dark.png
Normal file
BIN
shadcn-admin/src/features/auth/sign-in/assets/dashboard-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 451 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 440 KiB |
@@ -0,0 +1,150 @@
|
||||
import { useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { Loader2, LogIn } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { IconFacebook, IconGithub } from '@/assets/brand-icons'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { sleep, cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { PasswordInput } from '@/components/password-input'
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.email({
|
||||
error: (iss) => (iss.input === '' ? 'Please enter your email' : undefined),
|
||||
}),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, 'Please enter your password')
|
||||
.min(7, 'Password must be at least 7 characters long'),
|
||||
})
|
||||
|
||||
interface UserAuthFormProps extends React.HTMLAttributes<HTMLFormElement> {
|
||||
redirectTo?: string
|
||||
}
|
||||
|
||||
export function UserAuthForm({
|
||||
className,
|
||||
redirectTo,
|
||||
...props
|
||||
}: UserAuthFormProps) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const { auth } = useAuthStore()
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
})
|
||||
|
||||
function onSubmit(data: z.infer<typeof formSchema>) {
|
||||
setIsLoading(true)
|
||||
|
||||
toast.promise(sleep(2000), {
|
||||
loading: 'Signing in...',
|
||||
success: () => {
|
||||
setIsLoading(false)
|
||||
|
||||
// Mock successful authentication with expiry computed at success time
|
||||
const mockUser = {
|
||||
accountNo: 'ACC001',
|
||||
email: data.email,
|
||||
role: ['user'],
|
||||
exp: Date.now() + 24 * 60 * 60 * 1000, // 24 hours from now
|
||||
}
|
||||
|
||||
// Set user and access token
|
||||
auth.setUser(mockUser)
|
||||
auth.setAccessToken('mock-access-token')
|
||||
|
||||
// Redirect to the stored location or default to dashboard
|
||||
const targetPath = redirectTo || '/'
|
||||
navigate({ to: targetPath, replace: true })
|
||||
|
||||
return `Welcome back, ${data.email}!`
|
||||
},
|
||||
error: 'Error',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className={cn('grid gap-3', className)}
|
||||
{...props}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='name@example.com' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem className='relative'>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder='********' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<Link
|
||||
to='/forgot-password'
|
||||
className='absolute 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>
|
||||
)
|
||||
}
|
||||
51
shadcn-admin/src/features/auth/sign-in/index.tsx
Normal file
51
shadcn-admin/src/features/auth/sign-in/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
69
shadcn-admin/src/features/auth/sign-in/sign-in-2.tsx
Normal file
69
shadcn-admin/src/features/auth/sign-in/sign-in-2.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
57
shadcn-admin/src/features/auth/sign-up/index.tsx
Normal file
57
shadcn-admin/src/features/auth/sign-up/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
97
shadcn-admin/src/features/characters/components/columns.tsx
Normal file
97
shadcn-admin/src/features/characters/components/columns.tsx
Normal 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} />,
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
59
shadcn-admin/src/features/characters/data/api.ts
Normal file
59
shadcn-admin/src/features/characters/data/api.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
19
shadcn-admin/src/features/characters/data/data.ts
Normal file
19
shadcn-admin/src/features/characters/data/data.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
30
shadcn-admin/src/features/characters/data/schema.ts
Normal file
30
shadcn-admin/src/features/characters/data/schema.ts
Normal 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>;
|
||||
54
shadcn-admin/src/features/characters/index.tsx
Normal file
54
shadcn-admin/src/features/characters/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
shadcn-admin/src/features/chats/components/new-chat.tsx
Normal file
127
shadcn-admin/src/features/chats/components/new-chat.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
4
shadcn-admin/src/features/chats/data/chat-types.ts
Normal file
4
shadcn-admin/src/features/chats/data/chat-types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { type conversations } from './convo.json'
|
||||
|
||||
export type ChatUser = (typeof conversations)[number]
|
||||
export type Convo = ChatUser['messages'][number]
|
||||
309
shadcn-admin/src/features/chats/data/convo.json
Normal file
309
shadcn-admin/src/features/chats/data/convo.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
349
shadcn-admin/src/features/chats/index.tsx
Normal file
349
shadcn-admin/src/features/chats/index.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
189
shadcn-admin/src/features/dashboard/components/analytics.tsx
Normal file
189
shadcn-admin/src/features/dashboard/components/analytics.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
82
shadcn-admin/src/features/dashboard/components/overview.tsx
Normal file
82
shadcn-admin/src/features/dashboard/components/overview.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
220
shadcn-admin/src/features/dashboard/index.tsx
Normal file
220
shadcn-admin/src/features/dashboard/index.tsx
Normal 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,
|
||||
},
|
||||
]
|
||||
25
shadcn-admin/src/features/errors/forbidden.tsx
Normal file
25
shadcn-admin/src/features/errors/forbidden.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
shadcn-admin/src/features/errors/general-error.tsx
Normal file
36
shadcn-admin/src/features/errors/general-error.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
shadcn-admin/src/features/errors/maintenance-error.tsx
Normal file
19
shadcn-admin/src/features/errors/maintenance-error.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
shadcn-admin/src/features/errors/not-found-error.tsx
Normal file
25
shadcn-admin/src/features/errors/not-found-error.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
shadcn-admin/src/features/errors/unauthorized-error.tsx
Normal file
25
shadcn-admin/src/features/errors/unauthorized-error.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
173
shadcn-admin/src/features/settings/account/account-form.tsx
Normal file
173
shadcn-admin/src/features/settings/account/account-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
14
shadcn-admin/src/features/settings/account/index.tsx
Normal file
14
shadcn-admin/src/features/settings/account/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
14
shadcn-admin/src/features/settings/appearance/index.tsx
Normal file
14
shadcn-admin/src/features/settings/appearance/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
121
shadcn-admin/src/features/settings/display/display-form.tsx
Normal file
121
shadcn-admin/src/features/settings/display/display-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
13
shadcn-admin/src/features/settings/display/index.tsx
Normal file
13
shadcn-admin/src/features/settings/display/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
shadcn-admin/src/features/settings/index.tsx
Normal file
74
shadcn-admin/src/features/settings/index.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
13
shadcn-admin/src/features/settings/notifications/index.tsx
Normal file
13
shadcn-admin/src/features/settings/notifications/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
13
shadcn-admin/src/features/settings/profile/index.tsx
Normal file
13
shadcn-admin/src/features/settings/profile/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
177
shadcn-admin/src/features/settings/profile/profile-form.tsx
Normal file
177
shadcn-admin/src/features/settings/profile/profile-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
123
shadcn-admin/src/features/tasks/components/tasks-columns.tsx
Normal file
123
shadcn-admin/src/features/tasks/components/tasks-columns.tsx
Normal 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} />,
|
||||
},
|
||||
]
|
||||
72
shadcn-admin/src/features/tasks/components/tasks-dialogs.tsx
Normal file
72
shadcn-admin/src/features/tasks/components/tasks-dialogs.tsx
Normal 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'
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
197
shadcn-admin/src/features/tasks/components/tasks-table.tsx
Normal file
197
shadcn-admin/src/features/tasks/components/tasks-table.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
77
shadcn-admin/src/features/tasks/data/data.tsx
Normal file
77
shadcn-admin/src/features/tasks/data/data.tsx
Normal 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,
|
||||
},
|
||||
]
|
||||
13
shadcn-admin/src/features/tasks/data/schema.ts
Normal file
13
shadcn-admin/src/features/tasks/data/schema.ts
Normal 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>
|
||||
29
shadcn-admin/src/features/tasks/data/tasks.ts
Normal file
29
shadcn-admin/src/features/tasks/data/tasks.ts
Normal 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(),
|
||||
}
|
||||
})
|
||||
41
shadcn-admin/src/features/tasks/index.tsx
Normal file
41
shadcn-admin/src/features/tasks/index.tsx
Normal 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's a list of your tasks for this month!
|
||||
</p>
|
||||
</div>
|
||||
<TasksPrimaryButtons />
|
||||
</div>
|
||||
<TasksTable data={tasks} />
|
||||
</Main>
|
||||
|
||||
<TasksDialogs />
|
||||
</TasksProvider>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
138
shadcn-admin/src/features/users/components/users-columns.tsx
Normal file
138
shadcn-admin/src/features/users/components/users-columns.tsx
Normal 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,
|
||||
},
|
||||
]
|
||||
@@ -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
|
||||
/>
|
||||
)
|
||||
}
|
||||
51
shadcn-admin/src/features/users/components/users-dialogs.tsx
Normal file
51
shadcn-admin/src/features/users/components/users-dialogs.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
194
shadcn-admin/src/features/users/components/users-table.tsx
Normal file
194
shadcn-admin/src/features/users/components/users-table.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
35
shadcn-admin/src/features/users/data/data.ts
Normal file
35
shadcn-admin/src/features/users/data/data.ts
Normal 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
|
||||
32
shadcn-admin/src/features/users/data/schema.ts
Normal file
32
shadcn-admin/src/features/users/data/schema.ts
Normal 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)
|
||||
33
shadcn-admin/src/features/users/data/users.ts
Normal file
33
shadcn-admin/src/features/users/data/users.ts
Normal 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(),
|
||||
}
|
||||
})
|
||||
47
shadcn-admin/src/features/users/index.tsx
Normal file
47
shadcn-admin/src/features/users/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user