feat 使用 shadcn-admin

This commit is contained in:
PoRi
2026-04-25 17:09:20 +08:00
parent 88fa2666d2
commit 6cf1681a42
159 changed files with 11459 additions and 2989 deletions
+19
View File
@@ -0,0 +1,19 @@
import { Logo } from '@/assets/logo'
type AuthLayoutProps = {
children: React.ReactNode
}
export function AuthLayout({ children }: AuthLayoutProps) {
return (
<div className='container grid h-svh max-w-none items-center justify-center'>
<div className='mx-auto flex w-full flex-col justify-center space-y-2 py-8 sm:p-8'>
<div className='mb-4 flex items-center justify-center'>
<Logo className='me-2' />
<h1 className='text-xl font-medium'>Shadcn Admin</h1>
</div>
{children}
</div>
</div>
)
}
@@ -0,0 +1,80 @@
import { useState } from 'react'
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useNavigate } from '@tanstack/react-router'
import { ArrowRight, Loader2 } from 'lucide-react'
import { toast } from 'sonner'
import { sleep, cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
const formSchema = z.object({
email: z.email({
error: (iss) => (iss.input === '' ? 'Please enter your email.' : undefined),
}),
})
export function ForgotPasswordForm({
className,
...props
}: React.HTMLAttributes<HTMLFormElement>) {
const navigate = useNavigate()
const [isLoading, setIsLoading] = useState(false)
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { email: '' },
})
function onSubmit(data: z.infer<typeof formSchema>) {
setIsLoading(true)
toast.promise(sleep(2000), {
loading: 'Sending email...',
success: () => {
setIsLoading(false)
form.reset()
navigate({ to: '/otp' })
return `Email sent to ${data.email}`
},
error: 'Error',
})
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className={cn('grid gap-2', className)}
{...props}
>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder='name@example.com' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button className='mt-2' disabled={isLoading}>
Continue
{isLoading ? <Loader2 className='animate-spin' /> : <ArrowRight />}
</Button>
</form>
</Form>
)
}
@@ -0,0 +1,44 @@
import { Link } from '@tanstack/react-router'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { AuthLayout } from '../auth-layout'
import { ForgotPasswordForm } from './components/forgot-password-form'
export function ForgotPassword() {
return (
<AuthLayout>
<Card className='max-w-sm gap-4 sm:min-w-sm'>
<CardHeader>
<CardTitle className='text-lg tracking-tight'>
Forgot Password
</CardTitle>
<CardDescription>
Enter your registered email and <br /> we will send you a link to
reset your password.
</CardDescription>
</CardHeader>
<CardContent>
<ForgotPasswordForm />
</CardContent>
<CardFooter>
<p className='mx-auto px-8 text-center text-sm text-balance text-muted-foreground'>
Don't have an account?{' '}
<Link
to='/sign-up'
className='underline underline-offset-4 hover:text-primary'
>
Sign up
</Link>
.
</p>
</CardFooter>
</Card>
</AuthLayout>
)
}
@@ -0,0 +1,100 @@
import { useState } from 'react'
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useNavigate } from '@tanstack/react-router'
import { showSubmittedData } from '@/lib/show-submitted-data'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
InputOTPSeparator,
} from '@/components/ui/input-otp'
const formSchema = z.object({
otp: z
.string()
.min(6, 'Please enter the 6-digit code.')
.max(6, 'Please enter the 6-digit code.'),
})
type OtpFormProps = React.HTMLAttributes<HTMLFormElement>
export function OtpForm({ className, ...props }: OtpFormProps) {
const navigate = useNavigate()
const [isLoading, setIsLoading] = useState(false)
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { otp: '' },
})
// eslint-disable-next-line react-hooks/incompatible-library
const otp = form.watch('otp')
function onSubmit(data: z.infer<typeof formSchema>) {
setIsLoading(true)
showSubmittedData(data)
setTimeout(() => {
setIsLoading(false)
navigate({ to: '/' })
}, 1000)
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className={cn('grid gap-2', className)}
{...props}
>
<FormField
control={form.control}
name='otp'
render={({ field }) => (
<FormItem>
<FormLabel className='sr-only'>One-Time Password</FormLabel>
<FormControl>
<InputOTP
maxLength={6}
{...field}
containerClassName='justify-between sm:[&>[data-slot="input-otp-group"]>div]:w-12'
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button className='mt-2' disabled={otp.length < 6 || isLoading}>
Verify
</Button>
</form>
</Form>
)
}
+44
View File
@@ -0,0 +1,44 @@
import { Link } from '@tanstack/react-router'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { AuthLayout } from '../auth-layout'
import { OtpForm } from './components/otp-form'
export function Otp() {
return (
<AuthLayout>
<Card className='max-w-md gap-4'>
<CardHeader>
<CardTitle className='text-base tracking-tight'>
Two-factor Authentication
</CardTitle>
<CardDescription>
Please enter the authentication code. <br /> We have sent the
authentication code to your email.
</CardDescription>
</CardHeader>
<CardContent>
<OtpForm />
</CardContent>
<CardFooter>
<p className='px-8 text-center text-sm text-muted-foreground'>
Haven't received it?{' '}
<Link
to='/sign-in'
className='underline underline-offset-4 hover:text-primary'
>
Resend a new code.
</Link>
.
</p>
</CardFooter>
</Card>
</AuthLayout>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

@@ -0,0 +1,150 @@
import { useState } from 'react'
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { Link, useNavigate } from '@tanstack/react-router'
import { Loader2, LogIn } from 'lucide-react'
import { toast } from 'sonner'
import { IconFacebook, IconGithub } from '@/assets/brand-icons'
import { useAuthStore } from '@/stores/auth-store'
import { sleep, cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { PasswordInput } from '@/components/password-input'
const formSchema = z.object({
email: z.email({
error: (iss) => (iss.input === '' ? 'Please enter your email.' : undefined),
}),
password: z
.string()
.min(1, 'Please enter your password.')
.min(7, 'Password must be at least 7 characters long.'),
})
interface UserAuthFormProps extends React.HTMLAttributes<HTMLFormElement> {
redirectTo?: string
}
export function UserAuthForm({
className,
redirectTo,
...props
}: UserAuthFormProps) {
const [isLoading, setIsLoading] = useState(false)
const navigate = useNavigate()
const { auth } = useAuthStore()
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: '',
password: '',
},
})
function onSubmit(data: z.infer<typeof formSchema>) {
setIsLoading(true)
toast.promise(sleep(2000), {
loading: 'Signing in...',
success: () => {
setIsLoading(false)
// Mock successful authentication with expiry computed at success time
const mockUser = {
accountNo: 'ACC001',
email: data.email,
role: ['user'],
exp: Date.now() + 24 * 60 * 60 * 1000, // 24 hours from now
}
// Set user and access token
auth.setUser(mockUser)
auth.setAccessToken('mock-access-token')
// Redirect to the stored location or default to dashboard
const targetPath = redirectTo || '/'
navigate({ to: targetPath, replace: true })
return `Welcome back, ${data.email}!`
},
error: 'Error',
})
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className={cn('grid gap-3', className)}
{...props}
>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder='name@example.com' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem className='relative'>
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput placeholder='********' {...field} />
</FormControl>
<FormMessage />
<Link
to='/forgot-password'
className='absolute inset-e-0 -top-0.5 text-sm font-medium text-muted-foreground hover:opacity-75'
>
Forgot password?
</Link>
</FormItem>
)}
/>
<Button className='mt-2' disabled={isLoading}>
{isLoading ? <Loader2 className='animate-spin' /> : <LogIn />}
Sign in
</Button>
<div className='relative my-2'>
<div className='absolute inset-0 flex items-center'>
<span className='w-full border-t' />
</div>
<div className='relative flex justify-center text-xs uppercase'>
<span className='bg-background px-2 text-muted-foreground'>
Or continue with
</span>
</div>
</div>
<div className='grid grid-cols-2 gap-2'>
<Button variant='outline' type='button' disabled={isLoading}>
<IconGithub className='h-4 w-4' /> GitHub
</Button>
<Button variant='outline' type='button' disabled={isLoading}>
<IconFacebook className='h-4 w-4' /> Facebook
</Button>
</div>
</form>
</Form>
)
}
@@ -0,0 +1,58 @@
import { Link, useSearch } from '@tanstack/react-router'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { AuthLayout } from '../auth-layout'
import { UserAuthForm } from './components/user-auth-form'
export function SignIn() {
const { redirect } = useSearch({ from: '/(auth)/sign-in' })
return (
<AuthLayout>
<Card className='max-w-sm gap-4'>
<CardHeader>
<CardTitle className='text-lg tracking-tight'>Sign in</CardTitle>
<CardDescription>
Enter your email and password below to log into{' '}
<br className='max-sm:hidden' /> your account. Don't have an
account?{' '}
<Link
to='/sign-up'
className='text-nowrap underline underline-offset-4 hover:text-primary'
>
Sign Up
</Link>
</CardDescription>
</CardHeader>
<CardContent>
<UserAuthForm redirectTo={redirect} />
</CardContent>
<CardFooter>
<p className='px-8 text-center text-sm text-muted-foreground'>
By clicking sign in, you agree to our{' '}
<a
href='/terms'
className='underline underline-offset-4 hover:text-primary'
>
Terms of Service
</a>{' '}
and{' '}
<a
href='/privacy'
className='underline underline-offset-4 hover:text-primary'
>
Privacy Policy
</a>
.
</p>
</CardFooter>
</Card>
</AuthLayout>
)
}
@@ -0,0 +1,77 @@
import { Link } from '@tanstack/react-router'
import { Logo } from '@/assets/logo'
import { cn } from '@/lib/utils'
import dashboardDark from './assets/dashboard-dark.png'
import dashboardLight from './assets/dashboard-light.png'
import { UserAuthForm } from './components/user-auth-form'
export function SignIn2() {
return (
<div className='relative container grid h-svh flex-col items-center justify-center lg:max-w-none lg:grid-cols-2 lg:px-0'>
<div className='lg:p-8'>
<div className='mx-auto flex w-full flex-col justify-center space-y-2 py-8 sm:w-120 sm:p-8'>
<div className='mb-4 flex items-center justify-center'>
<Logo className='me-2' />
<h1 className='text-xl font-medium'>Shadcn Admin</h1>
</div>
</div>
<div className='mx-auto flex w-full max-w-sm flex-col justify-center space-y-2'>
<div className='flex flex-col space-y-2 text-start'>
<h2 className='text-lg font-semibold tracking-tight'>Sign in</h2>
<p className='text-sm text-muted-foreground'>
Enter your email and password below to log into{' '}
<br className='max-sm:hidden' /> your account. Don't have an
account?{' '}
<Link
to='/sign-up'
className='text-nowrap underline underline-offset-4 hover:text-primary'
>
Sign Up
</Link>
</p>
</div>
<UserAuthForm />
<p className='px-8 text-center text-sm text-muted-foreground'>
By clicking sign in, you agree to our{' '}
<a
href='/terms'
className='underline underline-offset-4 hover:text-primary'
>
Terms of Service
</a>{' '}
and{' '}
<a
href='/privacy'
className='underline underline-offset-4 hover:text-primary'
>
Privacy Policy
</a>
.
</p>
</div>
</div>
<div
className={cn(
'relative h-full overflow-hidden bg-muted max-lg:hidden',
'[&>img]:absolute [&>img]:top-[15%] [&>img]:left-20 [&>img]:h-full [&>img]:w-full [&>img]:object-cover [&>img]:object-top-left [&>img]:select-none'
)}
>
<img
src={dashboardLight}
className='dark:hidden'
width={1024}
height={1151}
alt='Shadcn-Admin'
/>
<img
src={dashboardDark}
className='hidden dark:block'
width={1024}
height={1138}
alt='Shadcn-Admin'
/>
</div>
</div>
)
}
@@ -0,0 +1,149 @@
import { useState } from 'react'
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { Loader2, UserPlus } from 'lucide-react'
import { toast } from 'sonner'
import { IconFacebook, IconGithub } from '@/assets/brand-icons'
import { sleep, cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { PasswordInput } from '@/components/password-input'
const formSchema = z
.object({
email: z.email({
error: (iss) =>
iss.input === '' ? 'Please enter your email.' : undefined,
}),
password: z
.string()
.min(1, 'Please enter your password.')
.min(7, 'Password must be at least 7 characters long.'),
confirmPassword: z.string().min(1, 'Please confirm your password.'),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match.",
path: ['confirmPassword'],
})
export function SignUpForm({
className,
...props
}: React.HTMLAttributes<HTMLFormElement>) {
const [isLoading, setIsLoading] = useState(false)
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: '',
password: '',
confirmPassword: '',
},
})
function onSubmit(data: z.infer<typeof formSchema>) {
setIsLoading(true)
toast.promise(sleep(2000), {
loading: 'Creating account...',
success: () => {
setIsLoading(false)
return `Account created for ${data.email}.`
},
error: 'Error',
})
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className={cn('grid gap-3', className)}
{...props}
>
<FormField
control={form.control}
name='email'
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder='name@example.com' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='password'
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput placeholder='********' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='confirmPassword'
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<PasswordInput placeholder='********' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button className='mt-2' disabled={isLoading}>
{isLoading ? <Loader2 className='animate-spin' /> : <UserPlus />}
Create Account
</Button>
<div className='relative my-2'>
<div className='absolute inset-0 flex items-center'>
<span className='w-full border-t' />
</div>
<div className='relative flex justify-center text-xs uppercase'>
<span className='bg-background px-2 text-muted-foreground'>
Or continue with
</span>
</div>
</div>
<div className='grid grid-cols-2 gap-2'>
<Button
variant='outline'
className='w-full'
type='button'
disabled={isLoading}
>
<IconGithub className='h-4 w-4' /> GitHub
</Button>
<Button
variant='outline'
className='w-full'
type='button'
disabled={isLoading}
>
<IconFacebook className='h-4 w-4' /> Facebook
</Button>
</div>
</form>
</Form>
)
}
@@ -0,0 +1,57 @@
import { Link } from '@tanstack/react-router'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { AuthLayout } from '../auth-layout'
import { SignUpForm } from './components/sign-up-form'
export function SignUp() {
return (
<AuthLayout>
<Card className='max-w-sm gap-4'>
<CardHeader>
<CardTitle className='text-lg tracking-tight'>
Create an account
</CardTitle>
<CardDescription>
Enter your email and password to create an account. <br />
Already have an account?{' '}
<Link
to='/sign-in'
className='underline underline-offset-4 hover:text-primary'
>
Sign In
</Link>
</CardDescription>
</CardHeader>
<CardContent>
<SignUpForm />
</CardContent>
<CardFooter>
<p className='px-8 text-center text-sm text-muted-foreground'>
By creating an account, you agree to our{' '}
<a
href='/terms'
className='underline underline-offset-4 hover:text-primary'
>
Terms of Service
</a>{' '}
and{' '}
<a
href='/privacy'
className='underline underline-offset-4 hover:text-primary'
>
Privacy Policy
</a>
.
</p>
</CardFooter>
</Card>
</AuthLayout>
)
}
+25
View File
@@ -0,0 +1,25 @@
import { useNavigate, useRouter } from '@tanstack/react-router'
import { Button } from '@/components/ui/button'
export function ForbiddenError() {
const navigate = useNavigate()
const { history } = useRouter()
return (
<div className='h-svh'>
<div className='m-auto flex h-full w-full flex-col items-center justify-center gap-2'>
<h1 className='text-[7rem] leading-tight font-bold'>403</h1>
<span className='font-medium'>Access Forbidden</span>
<p className='text-center text-muted-foreground'>
You don't have necessary permission <br />
to view this resource.
</p>
<div className='mt-6 flex gap-4'>
<Button variant='outline' onClick={() => history.go(-1)}>
Go Back
</Button>
<Button onClick={() => navigate({ to: '/' })}>Back to Home</Button>
</div>
</div>
</div>
)
}
@@ -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>
)
}
+285
View File
@@ -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>
</>
)
}
+211
View File
@@ -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>
)
}
+72
View File
@@ -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>
)
}