feat 使用 shadcn-admin
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
import { Logo } from '@/assets/logo'
|
||||
|
||||
type AuthLayoutProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function AuthLayout({ children }: AuthLayoutProps) {
|
||||
return (
|
||||
<div className='container grid h-svh max-w-none items-center justify-center'>
|
||||
<div className='mx-auto flex w-full flex-col justify-center space-y-2 py-8 sm:p-8'>
|
||||
<div className='mb-4 flex items-center justify-center'>
|
||||
<Logo className='me-2' />
|
||||
<h1 className='text-xl font-medium'>Shadcn Admin</h1>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { ArrowRight, Loader2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { sleep, cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.email({
|
||||
error: (iss) => (iss.input === '' ? 'Please enter your email.' : undefined),
|
||||
}),
|
||||
})
|
||||
|
||||
export function ForgotPasswordForm({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLFormElement>) {
|
||||
const navigate = useNavigate()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: { email: '' },
|
||||
})
|
||||
|
||||
function onSubmit(data: z.infer<typeof formSchema>) {
|
||||
setIsLoading(true)
|
||||
|
||||
toast.promise(sleep(2000), {
|
||||
loading: 'Sending email...',
|
||||
success: () => {
|
||||
setIsLoading(false)
|
||||
form.reset()
|
||||
navigate({ to: '/otp' })
|
||||
return `Email sent to ${data.email}`
|
||||
},
|
||||
error: 'Error',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className={cn('grid gap-2', className)}
|
||||
{...props}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='name@example.com' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button className='mt-2' disabled={isLoading}>
|
||||
Continue
|
||||
{isLoading ? <Loader2 className='animate-spin' /> : <ArrowRight />}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { AuthLayout } from '../auth-layout'
|
||||
import { ForgotPasswordForm } from './components/forgot-password-form'
|
||||
|
||||
export function ForgotPassword() {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<Card className='max-w-sm gap-4 sm:min-w-sm'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-lg tracking-tight'>
|
||||
Forgot Password
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your registered email and <br /> we will send you a link to
|
||||
reset your password.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ForgotPasswordForm />
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<p className='mx-auto px-8 text-center text-sm text-balance text-muted-foreground'>
|
||||
Don't have an account?{' '}
|
||||
<Link
|
||||
to='/sign-up'
|
||||
className='underline underline-offset-4 hover:text-primary'
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { showSubmittedData } from '@/lib/show-submitted-data'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
InputOTPSeparator,
|
||||
} from '@/components/ui/input-otp'
|
||||
|
||||
const formSchema = z.object({
|
||||
otp: z
|
||||
.string()
|
||||
.min(6, 'Please enter the 6-digit code.')
|
||||
.max(6, 'Please enter the 6-digit code.'),
|
||||
})
|
||||
|
||||
type OtpFormProps = React.HTMLAttributes<HTMLFormElement>
|
||||
|
||||
export function OtpForm({ className, ...props }: OtpFormProps) {
|
||||
const navigate = useNavigate()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: { otp: '' },
|
||||
})
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const otp = form.watch('otp')
|
||||
|
||||
function onSubmit(data: z.infer<typeof formSchema>) {
|
||||
setIsLoading(true)
|
||||
showSubmittedData(data)
|
||||
|
||||
setTimeout(() => {
|
||||
setIsLoading(false)
|
||||
navigate({ to: '/' })
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className={cn('grid gap-2', className)}
|
||||
{...props}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='otp'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className='sr-only'>One-Time Password</FormLabel>
|
||||
<FormControl>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
{...field}
|
||||
containerClassName='justify-between sm:[&>[data-slot="input-otp-group"]>div]:w-12'
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button className='mt-2' disabled={otp.length < 6 || isLoading}>
|
||||
Verify
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { AuthLayout } from '../auth-layout'
|
||||
import { OtpForm } from './components/otp-form'
|
||||
|
||||
export function Otp() {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<Card className='max-w-md gap-4'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-base tracking-tight'>
|
||||
Two-factor Authentication
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Please enter the authentication code. <br /> We have sent the
|
||||
authentication code to your email.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<OtpForm />
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<p className='px-8 text-center text-sm text-muted-foreground'>
|
||||
Haven't received it?{' '}
|
||||
<Link
|
||||
to='/sign-in'
|
||||
className='underline underline-offset-4 hover:text-primary'
|
||||
>
|
||||
Resend a new code.
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 451 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 440 KiB |
@@ -0,0 +1,150 @@
|
||||
import { useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Link, useNavigate } from '@tanstack/react-router'
|
||||
import { Loader2, LogIn } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { IconFacebook, IconGithub } from '@/assets/brand-icons'
|
||||
import { useAuthStore } from '@/stores/auth-store'
|
||||
import { sleep, cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { PasswordInput } from '@/components/password-input'
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.email({
|
||||
error: (iss) => (iss.input === '' ? 'Please enter your email.' : undefined),
|
||||
}),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, 'Please enter your password.')
|
||||
.min(7, 'Password must be at least 7 characters long.'),
|
||||
})
|
||||
|
||||
interface UserAuthFormProps extends React.HTMLAttributes<HTMLFormElement> {
|
||||
redirectTo?: string
|
||||
}
|
||||
|
||||
export function UserAuthForm({
|
||||
className,
|
||||
redirectTo,
|
||||
...props
|
||||
}: UserAuthFormProps) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
const { auth } = useAuthStore()
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
})
|
||||
|
||||
function onSubmit(data: z.infer<typeof formSchema>) {
|
||||
setIsLoading(true)
|
||||
|
||||
toast.promise(sleep(2000), {
|
||||
loading: 'Signing in...',
|
||||
success: () => {
|
||||
setIsLoading(false)
|
||||
|
||||
// Mock successful authentication with expiry computed at success time
|
||||
const mockUser = {
|
||||
accountNo: 'ACC001',
|
||||
email: data.email,
|
||||
role: ['user'],
|
||||
exp: Date.now() + 24 * 60 * 60 * 1000, // 24 hours from now
|
||||
}
|
||||
|
||||
// Set user and access token
|
||||
auth.setUser(mockUser)
|
||||
auth.setAccessToken('mock-access-token')
|
||||
|
||||
// Redirect to the stored location or default to dashboard
|
||||
const targetPath = redirectTo || '/'
|
||||
navigate({ to: targetPath, replace: true })
|
||||
|
||||
return `Welcome back, ${data.email}!`
|
||||
},
|
||||
error: 'Error',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className={cn('grid gap-3', className)}
|
||||
{...props}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='name@example.com' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem className='relative'>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder='********' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<Link
|
||||
to='/forgot-password'
|
||||
className='absolute inset-e-0 -top-0.5 text-sm font-medium text-muted-foreground hover:opacity-75'
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button className='mt-2' disabled={isLoading}>
|
||||
{isLoading ? <Loader2 className='animate-spin' /> : <LogIn />}
|
||||
Sign in
|
||||
</Button>
|
||||
|
||||
<div className='relative my-2'>
|
||||
<div className='absolute inset-0 flex items-center'>
|
||||
<span className='w-full border-t' />
|
||||
</div>
|
||||
<div className='relative flex justify-center text-xs uppercase'>
|
||||
<span className='bg-background px-2 text-muted-foreground'>
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
<Button variant='outline' type='button' disabled={isLoading}>
|
||||
<IconGithub className='h-4 w-4' /> GitHub
|
||||
</Button>
|
||||
<Button variant='outline' type='button' disabled={isLoading}>
|
||||
<IconFacebook className='h-4 w-4' /> Facebook
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Link, useSearch } from '@tanstack/react-router'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { AuthLayout } from '../auth-layout'
|
||||
import { UserAuthForm } from './components/user-auth-form'
|
||||
|
||||
export function SignIn() {
|
||||
const { redirect } = useSearch({ from: '/(auth)/sign-in' })
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<Card className='max-w-sm gap-4'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-lg tracking-tight'>Sign in</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email and password below to log into{' '}
|
||||
<br className='max-sm:hidden' /> your account. Don't have an
|
||||
account?{' '}
|
||||
<Link
|
||||
to='/sign-up'
|
||||
className='text-nowrap underline underline-offset-4 hover:text-primary'
|
||||
>
|
||||
Sign Up
|
||||
</Link>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<UserAuthForm redirectTo={redirect} />
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<p className='px-8 text-center text-sm text-muted-foreground'>
|
||||
By clicking sign in, you agree to our{' '}
|
||||
<a
|
||||
href='/terms'
|
||||
className='underline underline-offset-4 hover:text-primary'
|
||||
>
|
||||
Terms of Service
|
||||
</a>{' '}
|
||||
and{' '}
|
||||
<a
|
||||
href='/privacy'
|
||||
className='underline underline-offset-4 hover:text-primary'
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { Logo } from '@/assets/logo'
|
||||
import { cn } from '@/lib/utils'
|
||||
import dashboardDark from './assets/dashboard-dark.png'
|
||||
import dashboardLight from './assets/dashboard-light.png'
|
||||
import { UserAuthForm } from './components/user-auth-form'
|
||||
|
||||
export function SignIn2() {
|
||||
return (
|
||||
<div className='relative container grid h-svh flex-col items-center justify-center lg:max-w-none lg:grid-cols-2 lg:px-0'>
|
||||
<div className='lg:p-8'>
|
||||
<div className='mx-auto flex w-full flex-col justify-center space-y-2 py-8 sm:w-120 sm:p-8'>
|
||||
<div className='mb-4 flex items-center justify-center'>
|
||||
<Logo className='me-2' />
|
||||
<h1 className='text-xl font-medium'>Shadcn Admin</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mx-auto flex w-full max-w-sm flex-col justify-center space-y-2'>
|
||||
<div className='flex flex-col space-y-2 text-start'>
|
||||
<h2 className='text-lg font-semibold tracking-tight'>Sign in</h2>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
Enter your email and password below to log into{' '}
|
||||
<br className='max-sm:hidden' /> your account. Don't have an
|
||||
account?{' '}
|
||||
<Link
|
||||
to='/sign-up'
|
||||
className='text-nowrap underline underline-offset-4 hover:text-primary'
|
||||
>
|
||||
Sign Up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<UserAuthForm />
|
||||
<p className='px-8 text-center text-sm text-muted-foreground'>
|
||||
By clicking sign in, you agree to our{' '}
|
||||
<a
|
||||
href='/terms'
|
||||
className='underline underline-offset-4 hover:text-primary'
|
||||
>
|
||||
Terms of Service
|
||||
</a>{' '}
|
||||
and{' '}
|
||||
<a
|
||||
href='/privacy'
|
||||
className='underline underline-offset-4 hover:text-primary'
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'relative h-full overflow-hidden bg-muted max-lg:hidden',
|
||||
'[&>img]:absolute [&>img]:top-[15%] [&>img]:left-20 [&>img]:h-full [&>img]:w-full [&>img]:object-cover [&>img]:object-top-left [&>img]:select-none'
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={dashboardLight}
|
||||
className='dark:hidden'
|
||||
width={1024}
|
||||
height={1151}
|
||||
alt='Shadcn-Admin'
|
||||
/>
|
||||
<img
|
||||
src={dashboardDark}
|
||||
className='hidden dark:block'
|
||||
width={1024}
|
||||
height={1138}
|
||||
alt='Shadcn-Admin'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useState } from 'react'
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Loader2, UserPlus } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { IconFacebook, IconGithub } from '@/assets/brand-icons'
|
||||
import { sleep, cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { PasswordInput } from '@/components/password-input'
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
email: z.email({
|
||||
error: (iss) =>
|
||||
iss.input === '' ? 'Please enter your email.' : undefined,
|
||||
}),
|
||||
password: z
|
||||
.string()
|
||||
.min(1, 'Please enter your password.')
|
||||
.min(7, 'Password must be at least 7 characters long.'),
|
||||
confirmPassword: z.string().min(1, 'Please confirm your password.'),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Passwords don't match.",
|
||||
path: ['confirmPassword'],
|
||||
})
|
||||
|
||||
export function SignUpForm({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLFormElement>) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
},
|
||||
})
|
||||
|
||||
function onSubmit(data: z.infer<typeof formSchema>) {
|
||||
setIsLoading(true)
|
||||
|
||||
toast.promise(sleep(2000), {
|
||||
loading: 'Creating account...',
|
||||
success: () => {
|
||||
setIsLoading(false)
|
||||
return `Account created for ${data.email}.`
|
||||
},
|
||||
error: 'Error',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className={cn('grid gap-3', className)}
|
||||
{...props}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='email'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder='name@example.com' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='password'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder='********' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='confirmPassword'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirm Password</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput placeholder='********' {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button className='mt-2' disabled={isLoading}>
|
||||
{isLoading ? <Loader2 className='animate-spin' /> : <UserPlus />}
|
||||
Create Account
|
||||
</Button>
|
||||
|
||||
<div className='relative my-2'>
|
||||
<div className='absolute inset-0 flex items-center'>
|
||||
<span className='w-full border-t' />
|
||||
</div>
|
||||
<div className='relative flex justify-center text-xs uppercase'>
|
||||
<span className='bg-background px-2 text-muted-foreground'>
|
||||
Or continue with
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
type='button'
|
||||
disabled={isLoading}
|
||||
>
|
||||
<IconGithub className='h-4 w-4' /> GitHub
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-full'
|
||||
type='button'
|
||||
disabled={isLoading}
|
||||
>
|
||||
<IconFacebook className='h-4 w-4' /> Facebook
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card'
|
||||
import { AuthLayout } from '../auth-layout'
|
||||
import { SignUpForm } from './components/sign-up-form'
|
||||
|
||||
export function SignUp() {
|
||||
return (
|
||||
<AuthLayout>
|
||||
<Card className='max-w-sm gap-4'>
|
||||
<CardHeader>
|
||||
<CardTitle className='text-lg tracking-tight'>
|
||||
Create an account
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email and password to create an account. <br />
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
to='/sign-in'
|
||||
className='underline underline-offset-4 hover:text-primary'
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SignUpForm />
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<p className='px-8 text-center text-sm text-muted-foreground'>
|
||||
By creating an account, you agree to our{' '}
|
||||
<a
|
||||
href='/terms'
|
||||
className='underline underline-offset-4 hover:text-primary'
|
||||
>
|
||||
Terms of Service
|
||||
</a>{' '}
|
||||
and{' '}
|
||||
<a
|
||||
href='/privacy'
|
||||
className='underline underline-offset-4 hover:text-primary'
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react"
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { listen } from "@tauri-apps/api/event"
|
||||
import { Play, Square, Terminal, RotateCw, Server } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Header } from "@/components/layout/header"
|
||||
import { Main } from "@/components/layout/main"
|
||||
import { ProfileDropdown } from "@/components/profile-dropdown"
|
||||
import { ThemeSwitch } from "@/components/theme-switch"
|
||||
|
||||
interface ModelInfo {
|
||||
model_id: string
|
||||
owner: string
|
||||
model_type: string
|
||||
downloaded: boolean
|
||||
}
|
||||
|
||||
interface LaunchConfig {
|
||||
model_id: string
|
||||
address: string
|
||||
port: number
|
||||
weight_path?: string | null
|
||||
save_dir?: string | null
|
||||
gguf_path?: string | null
|
||||
mmproj_path?: string | null
|
||||
}
|
||||
|
||||
interface ServerStatus {
|
||||
running: boolean
|
||||
pid: number | null
|
||||
logs: string[]
|
||||
}
|
||||
|
||||
export function LaunchPage() {
|
||||
const [models, setModels] = useState<ModelInfo[]>([])
|
||||
const [selectedModel, setSelectedModel] = useState("")
|
||||
const [address, setAddress] = useState("127.0.0.1")
|
||||
const [port, setPort] = useState("10100")
|
||||
const [weightPath, setWeightPath] = useState("")
|
||||
const [status, setStatus] = useState<ServerStatus>({ running: false, pid: null, logs: [] })
|
||||
const logEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
invoke<ModelInfo[]>("list_models").then(setModels).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const refreshStatus = useCallback(async () => {
|
||||
try {
|
||||
const s = await invoke<ServerStatus>("get_server_status")
|
||||
setStatus(s)
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const unlistens: (() => void)[] = []
|
||||
|
||||
listen<string>("server-log", (event) => {
|
||||
setStatus((prev) => ({
|
||||
...prev,
|
||||
logs: [...prev.logs.slice(-1999), event.payload],
|
||||
}))
|
||||
}).then((fn) => unlistens.push(fn))
|
||||
|
||||
listen<number>("server-started", () => {
|
||||
refreshStatus()
|
||||
}).then((fn) => unlistens.push(fn))
|
||||
|
||||
listen("server-stopped", () => {
|
||||
refreshStatus()
|
||||
}).then((fn) => unlistens.push(fn))
|
||||
|
||||
return () => unlistens.forEach((fn) => fn())
|
||||
}, [refreshStatus])
|
||||
|
||||
useEffect(() => {
|
||||
refreshStatus()
|
||||
}, [refreshStatus])
|
||||
|
||||
useEffect(() => {
|
||||
logEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
}, [status.logs])
|
||||
|
||||
const handleStart = async () => {
|
||||
if (!selectedModel) return
|
||||
try {
|
||||
await invoke("start_server", {
|
||||
config: {
|
||||
model_id: selectedModel,
|
||||
address,
|
||||
port: parseInt(port) || 10100,
|
||||
weight_path: weightPath || null,
|
||||
save_dir: null,
|
||||
gguf_path: null,
|
||||
mmproj_path: null,
|
||||
} satisfies LaunchConfig,
|
||||
})
|
||||
} catch (e) {
|
||||
setStatus((prev) => ({
|
||||
...prev,
|
||||
logs: [...prev.logs, `[错误] ${e}`],
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const handleStop = async () => {
|
||||
try {
|
||||
await invoke("stop_server")
|
||||
} catch (e) {
|
||||
setStatus((prev) => ({
|
||||
...prev,
|
||||
logs: [...prev.logs, `[错误] ${e}`],
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearLogs = async () => {
|
||||
try {
|
||||
await invoke("clear_logs")
|
||||
setStatus((prev) => ({ ...prev, logs: [] }))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const downloadedModels = models.filter((m) => m.downloaded)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<div className="flex items-center gap-2 ms-auto">
|
||||
<ThemeSwitch />
|
||||
<ProfileDropdown />
|
||||
</div>
|
||||
</Header>
|
||||
|
||||
<Main>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<Server className="w-6 h-6" />
|
||||
启动服务
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
配置并启动模型推理服务
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
{/* 配置区 */}
|
||||
<Card className="p-4 space-y-4">
|
||||
<h3 className="text-sm font-medium">启动配置</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model-select">模型</Label>
|
||||
<select
|
||||
id="model-select"
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={status.running}
|
||||
>
|
||||
<option value="">选择模型...</option>
|
||||
{downloadedModels.map((m) => (
|
||||
<option key={m.model_id} value={m.model_id}>
|
||||
{m.model_id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{downloadedModels.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
没有已下载的模型,请先在"模型列表"页面下载模型
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address">监听地址</Label>
|
||||
<Input
|
||||
id="address"
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
disabled={status.running}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="port">端口</Label>
|
||||
<Input
|
||||
id="port"
|
||||
value={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
disabled={status.running}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="weight-path">
|
||||
权重路径 <span className="text-muted-foreground font-normal">(可选)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="weight-path"
|
||||
value={weightPath}
|
||||
onChange={(e) => setWeightPath(e.target.value)}
|
||||
placeholder="留空则使用默认路径"
|
||||
disabled={status.running}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 状态 + 控制 */}
|
||||
<Card className="p-4 flex flex-col">
|
||||
<h3 className="text-sm font-medium mb-3">服务状态</h3>
|
||||
<div className="flex-1 space-y-2 text-sm mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`w-2.5 h-2.5 rounded-full inline-block ${
|
||||
status.running ? "bg-green-500" : "bg-gray-300"
|
||||
}`}
|
||||
/>
|
||||
<span>{status.running ? "运行中" : "已停止"}</span>
|
||||
</div>
|
||||
{status.pid && (
|
||||
<div className="text-muted-foreground">PID: {status.pid}</div>
|
||||
)}
|
||||
{selectedModel && (
|
||||
<div className="text-muted-foreground">模型: {selectedModel}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{!status.running ? (
|
||||
<Button
|
||||
onClick={handleStart}
|
||||
disabled={!selectedModel}
|
||||
className="flex-1"
|
||||
>
|
||||
<Play className="w-4 h-4 mr-1.5" />
|
||||
启动服务
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleStop}
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
>
|
||||
<Square className="w-4 h-4 mr-1.5" />
|
||||
停止服务
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 日志区域 */}
|
||||
<Card className="flex-1 min-h-0">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Terminal className="w-4 h-4" />
|
||||
日志输出
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleClearLogs}>
|
||||
<RotateCw className="w-3 h-3 mr-1" />
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
<div className="bg-[#1e1e2e] rounded-b-lg p-4 h-64 overflow-y-auto font-mono text-sm leading-relaxed">
|
||||
{status.logs.length === 0 ? (
|
||||
<div className="text-gray-500 italic">等待日志输出...</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{status.logs.map((line, i) => (
|
||||
<div key={i} className="text-gray-300 whitespace-pre-wrap break-all">
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useState, useEffect } from "react"
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { Download, Trash2, RefreshCw, HardDrive, Package } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Header } from "@/components/layout/header"
|
||||
import { Main } from "@/components/layout/main"
|
||||
import { ProfileDropdown } from "@/components/profile-dropdown"
|
||||
import { ThemeSwitch } from "@/components/theme-switch"
|
||||
|
||||
interface ModelInfo {
|
||||
model_id: string
|
||||
owner: string
|
||||
model_type: string
|
||||
downloaded: boolean
|
||||
size: number | null
|
||||
size_human: string | null
|
||||
path: string | null
|
||||
}
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
llm: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
|
||||
vlm: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200",
|
||||
ocr: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
|
||||
asr: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
|
||||
tts: "bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-200",
|
||||
image: "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200",
|
||||
embedding: "bg-teal-100 text-teal-800 dark:bg-teal-900 dark:text-teal-200",
|
||||
reranker: "bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200",
|
||||
}
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
llm: "LLM",
|
||||
vlm: "VLM",
|
||||
ocr: "OCR",
|
||||
asr: "ASR",
|
||||
tts: "TTS",
|
||||
image: "图像",
|
||||
embedding: "嵌入",
|
||||
reranker: "重排序",
|
||||
}
|
||||
|
||||
export function ModelsPage() {
|
||||
const [models, setModels] = useState<ModelInfo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [downloading, setDownloading] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const loadModels = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await invoke<ModelInfo[]>("list_models")
|
||||
setModels(data)
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadModels()
|
||||
}, [])
|
||||
|
||||
const handleDownload = async (modelId: string) => {
|
||||
setDownloading(modelId)
|
||||
setError(null)
|
||||
try {
|
||||
await invoke("download_model", { modelId })
|
||||
await loadModels()
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
}
|
||||
setDownloading(null)
|
||||
}
|
||||
|
||||
const handleDelete = async (modelId: string) => {
|
||||
if (!confirm(`确定删除模型 ${modelId}?`)) return
|
||||
try {
|
||||
await invoke("delete_model", { modelId })
|
||||
await loadModels()
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<div className="flex items-center gap-2 ms-auto">
|
||||
<ThemeSwitch />
|
||||
<ProfileDropdown />
|
||||
</div>
|
||||
</Header>
|
||||
|
||||
<Main>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<Package className="w-6 h-6" />
|
||||
模型列表
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">
|
||||
管理已下载和可用的模型
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={loadModels} disabled={loading}>
|
||||
<RefreshCw className={`w-4 h-4 mr-1.5 ${loading ? "animate-spin" : ""}`} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/20 rounded-md text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/50 border-b">
|
||||
<th className="text-left px-4 py-3 font-medium">模型 ID</th>
|
||||
<th className="text-left px-4 py-3 font-medium">类型</th>
|
||||
<th className="text-left px-4 py-3 font-medium">大小</th>
|
||||
<th className="text-left px-4 py-3 font-medium">状态</th>
|
||||
<th className="text-right px-4 py-3 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{models.length === 0 && !loading ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
|
||||
暂无模型数据
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
models.map((m) => (
|
||||
<tr key={m.model_id} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium">{m.model_id}</div>
|
||||
{m.path && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5 truncate max-w-[400px]">
|
||||
{m.path}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={typeColors[m.model_type] || ""}
|
||||
>
|
||||
{typeLabels[m.model_type] || m.model_type}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground">
|
||||
{m.size_human ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<HardDrive className="w-3.5 h-3.5" />
|
||||
{m.size_human}
|
||||
</span>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{m.downloaded ? (
|
||||
<Badge variant="outline" className="text-green-600 border-green-200 bg-green-50 dark:bg-green-950 dark:border-green-800">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500 mr-1.5 inline-block" />
|
||||
已下载
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">未下载</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{!m.downloaded ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleDownload(m.model_id)}
|
||||
disabled={downloading === m.model_id}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5 mr-1" />
|
||||
{downloading === m.model_id ? "下载中..." : "下载"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleDelete(m.model_id)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 mr-1" />
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</Main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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-50 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-50 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>
|
||||
)
|
||||
}
|
||||
@@ -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-50 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 inset-e-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-20 rounded-lg bg-[#ecedef]' />
|
||||
<div className='h-2 w-25 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-25 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-25 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-20 rounded-lg bg-slate-400' />
|
||||
<div className='h-2 w-25 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-25 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-25 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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 className='me-auto' />
|
||||
<ThemeSwitch />
|
||||
<ConfigDrawer />
|
||||
<ProfileDropdown />
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user