[rewrite] Added shadcn ui to frontend and slightly tweaked backend
This commit is contained in:
1 parent
4c8817e853
commit
6e9334ba0e
202 files changed
+19385
-15424
No files matched your search
@@ -0,0 +1,217 @@
|
||||
import { z } from 'zod'
|
||||
import { format } from 'date-fns'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { CalendarIcon, CaretSortIcon, CheckIcon } from '@radix-ui/react-icons'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
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'
|
||||
|
||||
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(2, {
|
||||
message: 'Name must be at least 2 characters.',
|
||||
})
|
||||
.max(30, {
|
||||
message: 'Name must not be longer than 30 characters.',
|
||||
}),
|
||||
dob: z.date({
|
||||
required_error: 'A date of birth is required.',
|
||||
}),
|
||||
language: z.string({
|
||||
required_error: '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) {
|
||||
toast({
|
||||
title: 'You submitted the following values:',
|
||||
description: (
|
||||
<pre className='mt-2 w-[340px] rounded-md bg-slate-950 p-4'>
|
||||
<code className='text-white'>{JSON.stringify(data, null, 2)}</code>
|
||||
</pre>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
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>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant={'outline'}
|
||||
className={cn(
|
||||
'w-[240px] pl-3 text-left font-normal',
|
||||
!field.value && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{field.value ? (
|
||||
format(field.value, 'MMM d, yyyy')
|
||||
) : (
|
||||
<span>Pick a date</span>
|
||||
)}
|
||||
<CalendarIcon className='ml-auto h-4 w-4 opacity-50' />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-auto p-0' align='start'>
|
||||
<Calendar
|
||||
mode='single'
|
||||
selected={field.value}
|
||||
onSelect={field.onChange}
|
||||
disabled={(date: Date) =>
|
||||
date > new Date() || date < new Date('1900-01-01')
|
||||
}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormDescription>
|
||||
Your date of birth is used to calculate your age.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='language'
|
||||
render={({ field }) => (
|
||||
<FormItem className='flex flex-col'>
|
||||
<FormLabel>Language</FormLabel>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant='outline'
|
||||
role='combobox'
|
||||
className={cn(
|
||||
'w-[200px] justify-between',
|
||||
!field.value && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{field.value
|
||||
? languages.find(
|
||||
(language) => language.value === field.value
|
||||
)?.label
|
||||
: 'Select language'}
|
||||
<CaretSortIcon className='ml-2 h-4 w-4 shrink-0 opacity-50' />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-[200px] p-0'>
|
||||
<Command>
|
||||
<CommandInput placeholder='Search language...' />
|
||||
<CommandEmpty>No language found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandList>
|
||||
{languages.map((language) => (
|
||||
<CommandItem
|
||||
value={language.label}
|
||||
key={language.value}
|
||||
onSelect={() => {
|
||||
form.setValue('language', language.value)
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
'mr-2 h-4 w-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 default function SettingsAccount() {
|
||||
return (
|
||||
<ContentSection
|
||||
title='Account'
|
||||
desc='Update your account settings. Set your preferred language and
|
||||
timezone.'
|
||||
>
|
||||
<AccountForm />
|
||||
</ContentSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
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 { cn } from '@/lib/utils'
|
||||
import { useFont } from '@/context/font-context'
|
||||
import { useTheme } from '@/context/theme-context'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
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'], {
|
||||
required_error: 'Please select a theme.',
|
||||
}),
|
||||
font: z.enum(fonts, {
|
||||
invalid_type_error: 'Select a font',
|
||||
required_error: 'Please select a font.',
|
||||
}),
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
toast({
|
||||
title: 'You submitted the following values:',
|
||||
description: (
|
||||
<pre className='mt-2 w-[340px] rounded-md bg-slate-950 p-4'>
|
||||
<code className='text-white'>{JSON.stringify(data, null, 2)}</code>
|
||||
</pre>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-8'>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='font'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Font</FormLabel>
|
||||
<div className='relative w-max'>
|
||||
<FormControl>
|
||||
<select
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'w-[200px] appearance-none font-normal capitalize'
|
||||
)}
|
||||
{...field}
|
||||
>
|
||||
{fonts.map((font) => (
|
||||
<option key={font} value={font}>
|
||||
{font}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</FormControl>
|
||||
<ChevronDownIcon className='absolute right-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 className='space-y-1'>
|
||||
<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-sm'>
|
||||
<div className='h-2 w-[80px] rounded-lg bg-[#ecedef]' />
|
||||
<div className='h-2 w-[100px] rounded-lg bg-[#ecedef]' />
|
||||
</div>
|
||||
<div className='flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm'>
|
||||
<div className='h-4 w-4 rounded-full bg-[#ecedef]' />
|
||||
<div className='h-2 w-[100px] rounded-lg bg-[#ecedef]' />
|
||||
</div>
|
||||
<div className='flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm'>
|
||||
<div className='h-4 w-4 rounded-full bg-[#ecedef]' />
|
||||
<div className='h-2 w-[100px] rounded-lg bg-[#ecedef]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className='block w-full p-2 text-center font-normal'>
|
||||
Light
|
||||
</span>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<FormLabel className='[&:has([data-state=checked])>div]:border-primary'>
|
||||
<FormControl>
|
||||
<RadioGroupItem value='dark' className='sr-only' />
|
||||
</FormControl>
|
||||
<div className='items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground'>
|
||||
<div className='space-y-2 rounded-sm bg-slate-950 p-2'>
|
||||
<div className='space-y-2 rounded-md bg-slate-800 p-2 shadow-sm'>
|
||||
<div className='h-2 w-[80px] rounded-lg bg-slate-400' />
|
||||
<div className='h-2 w-[100px] rounded-lg bg-slate-400' />
|
||||
</div>
|
||||
<div className='flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm'>
|
||||
<div className='h-4 w-4 rounded-full bg-slate-400' />
|
||||
<div className='h-2 w-[100px] rounded-lg bg-slate-400' />
|
||||
</div>
|
||||
<div className='flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm'>
|
||||
<div className='h-4 w-4 rounded-full bg-slate-400' />
|
||||
<div className='h-2 w-[100px] rounded-lg bg-slate-400' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className='block w-full p-2 text-center font-normal'>
|
||||
Dark
|
||||
</span>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type='submit'>Update preferences</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import ContentSection from '../components/content-section'
|
||||
import { AppearanceForm } from './appearance-form'
|
||||
|
||||
export default 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,27 @@
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
|
||||
interface ContentSectionProps {
|
||||
title: string
|
||||
desc: string
|
||||
children: React.JSX.Element
|
||||
}
|
||||
|
||||
export default 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' />
|
||||
<ScrollArea className='faded-bottom -mx-4 flex-1 scroll-smooth px-4 md:pb-16'>
|
||||
<div className='-mx-1 px-1.5 lg:max-w-xl'>{children}</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState, type JSX } from 'react'
|
||||
import { useLocation, useNavigate } from '@tanstack/react-router'
|
||||
import { 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'
|
||||
|
||||
interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
|
||||
items: {
|
||||
href: string
|
||||
title: string
|
||||
icon: JSX.Element
|
||||
}[]
|
||||
}
|
||||
|
||||
export default 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-x-0 lg:space-y-1',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
to={item.href}
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
pathname === item.href
|
||||
? 'bg-muted hover:bg-muted'
|
||||
: 'hover:bg-transparent hover:underline',
|
||||
'justify-start'
|
||||
)}
|
||||
>
|
||||
<span className='mr-2'>{item.icon}</span>
|
||||
{item.title}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</ScrollArea>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
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,
|
||||
})
|
||||
|
||||
function onSubmit(data: DisplayFormValues) {
|
||||
toast({
|
||||
title: 'You submitted the following values:',
|
||||
description: (
|
||||
<pre className='mt-2 w-[340px] rounded-md bg-slate-950 p-4'>
|
||||
<code className='text-white'>{JSON.stringify(data, null, 2)}</code>
|
||||
</pre>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} 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 space-x-3 space-y-0'
|
||||
>
|
||||
<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 default 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,78 @@
|
||||
import { Outlet } from '@tanstack/react-router'
|
||||
import {
|
||||
IconBrowserCheck,
|
||||
IconNotification,
|
||||
IconPalette,
|
||||
IconTool,
|
||||
IconUser,
|
||||
} from '@tabler/icons-react'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
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'
|
||||
|
||||
export default function Settings() {
|
||||
return (
|
||||
<>
|
||||
{/* ===== Top Heading ===== */}
|
||||
<Header>
|
||||
<Search />
|
||||
<div className='ml-auto flex items-center space-x-4'>
|
||||
<ThemeSwitch />
|
||||
<ProfileDropdown />
|
||||
</div>
|
||||
</Header>
|
||||
|
||||
<Main fixed>
|
||||
<div className='space-y-0.5'>
|
||||
<h1 className='text-2xl font-bold tracking-tight md:text-3xl'>
|
||||
Settings
|
||||
</h1>
|
||||
<p className='text-muted-foreground'>
|
||||
Manage your account settings and set e-mail preferences.
|
||||
</p>
|
||||
</div>
|
||||
<Separator className='my-4 lg:my-6' />
|
||||
<div className='flex flex-1 flex-col space-y-2 overflow-hidden md:space-y-2 lg:flex-row lg:space-x-12 lg:space-y-0'>
|
||||
<aside className='top-0 lg:sticky lg:w-1/5'>
|
||||
<SidebarNav items={sidebarNavItems} />
|
||||
</aside>
|
||||
<div className='flex w-full overflow-y-hidden p-1 pr-4'>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</Main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const sidebarNavItems = [
|
||||
{
|
||||
title: 'Profile',
|
||||
icon: <IconUser size={18} />,
|
||||
href: '/settings',
|
||||
},
|
||||
{
|
||||
title: 'Account',
|
||||
icon: <IconTool size={18} />,
|
||||
href: '/settings/account',
|
||||
},
|
||||
{
|
||||
title: 'Appearance',
|
||||
icon: <IconPalette size={18} />,
|
||||
href: '/settings/appearance',
|
||||
},
|
||||
{
|
||||
title: 'Notifications',
|
||||
icon: <IconNotification size={18} />,
|
||||
href: '/settings/notifications',
|
||||
},
|
||||
{
|
||||
title: 'Display',
|
||||
icon: <IconBrowserCheck size={18} />,
|
||||
href: '/settings/display',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
import ContentSection from '../components/content-section'
|
||||
import { NotificationsForm } from './notifications-form'
|
||||
|
||||
export default function SettingsNotifications() {
|
||||
return (
|
||||
<ContentSection
|
||||
title='Notifications'
|
||||
desc='Configure how you receive notifications.'
|
||||
>
|
||||
<NotificationsForm />
|
||||
</ContentSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
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'], {
|
||||
required_error: 'You need to select a notification type.',
|
||||
}),
|
||||
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,
|
||||
})
|
||||
|
||||
function onSubmit(data: NotificationsFormValues) {
|
||||
toast({
|
||||
title: 'You submitted the following values:',
|
||||
description: (
|
||||
<pre className='mt-2 w-[340px] rounded-md bg-slate-950 p-4'>
|
||||
<code className='text-white'>{JSON.stringify(data, null, 2)}</code>
|
||||
</pre>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} 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 space-y-1'
|
||||
>
|
||||
<FormItem className='flex items-center space-x-3 space-y-0'>
|
||||
<FormControl>
|
||||
<RadioGroupItem value='all' />
|
||||
</FormControl>
|
||||
<FormLabel className='font-normal'>
|
||||
All new messages
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className='flex items-center space-x-3 space-y-0'>
|
||||
<FormControl>
|
||||
<RadioGroupItem value='mentions' />
|
||||
</FormControl>
|
||||
<FormLabel className='font-normal'>
|
||||
Direct messages and mentions
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className='flex items-center space-x-3 space-y-0'>
|
||||
<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 space-x-3 space-y-0'>
|
||||
<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 default function SettingsProfile() {
|
||||
return (
|
||||
<ContentSection
|
||||
title='Profile'
|
||||
desc='This is how others will see you on the site.'
|
||||
>
|
||||
<ProfileForm />
|
||||
</ContentSection>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { z } from 'zod'
|
||||
import { useFieldArray, useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
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()
|
||||
.min(2, {
|
||||
message: 'Username must be at least 2 characters.',
|
||||
})
|
||||
.max(30, {
|
||||
message: 'Username must not be longer than 30 characters.',
|
||||
}),
|
||||
email: z
|
||||
.string({
|
||||
required_error: 'Please select an email to display.',
|
||||
})
|
||||
.email(),
|
||||
bio: z.string().max(160).min(4),
|
||||
urls: z
|
||||
.array(
|
||||
z.object({
|
||||
value: z.string().url({ message: '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 default function ProfileForm() {
|
||||
const form = useForm<ProfileFormValues>({
|
||||
resolver: zodResolver(profileFormSchema),
|
||||
defaultValues,
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const { fields, append } = useFieldArray({
|
||||
name: 'urls',
|
||||
control: form.control,
|
||||
})
|
||||
|
||||
function onSubmit(data: ProfileFormValues) {
|
||||
toast({
|
||||
title: 'You submitted the following values:',
|
||||
description: (
|
||||
<pre className='mt-2 w-[340px] rounded-md bg-slate-950 p-4'>
|
||||
<code className='text-white'>{JSON.stringify(data, null, 2)}</code>
|
||||
</pre>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} 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>
|
||||
<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