Skip to content

Commit 4b2fe18

Browse files
committed
feat: add settings page
1 parent ce20f87 commit 4b2fe18

13 files changed

+862
-11
lines changed

components/layout/SidebarNavFooter.vue

+2-2
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ defineProps<{
99
}
1010
}>()
1111
12-
const { isMobile } = useSidebar()
12+
const { isMobile, setOpenMobile } = useSidebar()
1313
1414
function handleLogout() {
1515
navigateTo('/login')
@@ -73,7 +73,7 @@ const showModalTheme = ref(false)
7373
Account
7474
</DropdownMenuItem>
7575
<DropdownMenuItem as-child>
76-
<NuxtLink to="/settings">
76+
<NuxtLink to="/settings" @click="setOpenMobile(false)">
7777
<Icon name="i-lucide-settings" />
7878
Settings
7979
</NuxtLink>

components/settings/AccountForm.vue

+188
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
<script setup lang="ts">
2+
import { cn } from '@/lib/utils'
3+
import { CalendarDate, DateFormatter, getLocalTimeZone, today } from '@internationalized/date'
4+
import { toTypedSchema } from '@vee-validate/zod'
5+
import { Check, ChevronsUpDown } from 'lucide-vue-next'
6+
import { toDate } from 'radix-vue/date'
7+
import { h, ref } from 'vue'
8+
import * as z from 'zod'
9+
import { toast } from '~/components/ui/toast'
10+
11+
const open = ref(false)
12+
const dateValue = ref()
13+
const placeholder = ref()
14+
15+
const languages = [
16+
{ label: 'English', value: 'en' },
17+
{ label: 'French', value: 'fr' },
18+
{ label: 'German', value: 'de' },
19+
{ label: 'Indonesia', value: 'id' },
20+
{ label: 'Spanish', value: 'es' },
21+
{ label: 'Portuguese', value: 'pt' },
22+
{ label: 'Russian', value: 'ru' },
23+
{ label: 'Japanese', value: 'ja' },
24+
{ label: 'Korean', value: 'ko' },
25+
{ label: 'Chinese', value: 'zh' },
26+
] as const
27+
28+
const df = new DateFormatter('en-US', {
29+
dateStyle: 'long',
30+
})
31+
32+
const accountFormSchema = toTypedSchema(z.object({
33+
name: z
34+
.string({
35+
required_error: 'Required.',
36+
})
37+
.min(2, {
38+
message: 'Name must be at least 2 characters.',
39+
})
40+
.max(30, {
41+
message: 'Name must not be longer than 30 characters.',
42+
}),
43+
dob: z.string().datetime().optional().refine(date => date !== undefined, 'Please select a valid date.'),
44+
language: z.string().min(1, 'Please select a language.'),
45+
}))
46+
47+
// https://github.com/logaretm/vee-validate/issues/3521
48+
// https://github.com/logaretm/vee-validate/discussions/3571
49+
async function onSubmit(values: any) {
50+
toast({
51+
title: 'You submitted the following values:',
52+
description: h('pre', { class: 'mt-2 w-[340px] rounded-md bg-slate-950 p-4' }, h('code', { class: 'text-white' }, JSON.stringify(values, null, 2))),
53+
})
54+
}
55+
</script>
56+
57+
<template>
58+
<div>
59+
<h3 class="text-lg font-medium">
60+
Account
61+
</h3>
62+
<p class="text-sm text-muted-foreground">
63+
Update your account settings. Set your preferred language and timezone.
64+
</p>
65+
</div>
66+
<Separator />
67+
<Form v-slot="{ setFieldValue }" :validation-schema="accountFormSchema" class="space-y-8" @submit="onSubmit">
68+
<FormField v-slot="{ componentField }" name="name">
69+
<FormItem>
70+
<FormLabel>Name</FormLabel>
71+
<FormControl>
72+
<Input type="text" placeholder="Your name" v-bind="componentField" />
73+
</FormControl>
74+
<FormDescription>
75+
This is the name that will be displayed on your profile and in emails.
76+
</FormDescription>
77+
<FormMessage />
78+
</FormItem>
79+
</FormField>
80+
81+
<FormField v-slot="{ field, value }" name="dob">
82+
<FormItem class="flex flex-col">
83+
<FormLabel>Date of birth</FormLabel>
84+
<Popover>
85+
<PopoverTrigger as-child>
86+
<FormControl>
87+
<Button
88+
variant="outline" :class="cn(
89+
'w-[240px] justify-start text-left font-normal',
90+
!value && 'text-muted-foreground',
91+
)"
92+
>
93+
<Icon name="i-radix-icons-calendar" class="mr-2 h-4 w-4 opacity-50" />
94+
<span>{{ value ? df.format(toDate(dateValue, getLocalTimeZone())) : "Pick a date" }}</span>
95+
</Button>
96+
</FormControl>
97+
</PopoverTrigger>
98+
<PopoverContent class="p-0">
99+
<Calendar
100+
v-model:placeholder="placeholder"
101+
v-model="dateValue"
102+
calendar-label="Date of birth"
103+
initial-focus
104+
:min-value="new CalendarDate(1900, 1, 1)"
105+
:max-value="today(getLocalTimeZone())"
106+
@update:model-value="(v: any) => {
107+
if (v) {
108+
dateValue = v
109+
setFieldValue('dob', toDate(v).toISOString())
110+
}
111+
else {
112+
dateValue = undefined
113+
setFieldValue('dob', undefined)
114+
}
115+
}"
116+
/>
117+
</PopoverContent>
118+
</Popover>
119+
<FormDescription>
120+
Your date of birth is used to calculate your age.
121+
</FormDescription>
122+
<FormMessage />
123+
</FormItem>
124+
<input type="hidden" v-bind="field">
125+
</FormField>
126+
127+
<FormField v-slot="{ value }" name="language">
128+
<FormItem class="flex flex-col">
129+
<FormLabel>Language</FormLabel>
130+
131+
<Popover v-model:open="open">
132+
<PopoverTrigger as-child>
133+
<FormControl>
134+
<Button
135+
variant="outline" role="combobox" :aria-expanded="open" :class="cn(
136+
'w-[200px] justify-between',
137+
!value && 'text-muted-foreground',
138+
)"
139+
>
140+
{{ value ? languages.find(
141+
(language) => language.value === value,
142+
)?.label : 'Select language...' }}
143+
144+
<ChevronsUpDown class="ml-2 h-4 w-4 shrink-0 opacity-50" />
145+
</Button>
146+
</FormControl>
147+
</PopoverTrigger>
148+
<PopoverContent class="w-[200px] p-0">
149+
<Command>
150+
<CommandInput placeholder="Search language..." />
151+
<CommandEmpty>No language found.</CommandEmpty>
152+
<CommandList>
153+
<CommandGroup>
154+
<CommandItem
155+
v-for="language in languages" :key="language.value" :value="language.label"
156+
@select="() => {
157+
setFieldValue('language', language.value)
158+
open = false
159+
}"
160+
>
161+
<Check
162+
:class="cn(
163+
'mr-2 h-4 w-4',
164+
value === language.value ? 'opacity-100' : 'opacity-0',
165+
)"
166+
/>
167+
{{ language.label }}
168+
</CommandItem>
169+
</CommandGroup>
170+
</CommandList>
171+
</Command>
172+
</PopoverContent>
173+
</Popover>
174+
175+
<FormDescription>
176+
This is the language that will be used in the dashboard.
177+
</FormDescription>
178+
<FormMessage />
179+
</FormItem>
180+
</FormField>
181+
182+
<div class="flex justify-start">
183+
<Button type="submit">
184+
Update account
185+
</Button>
186+
</div>
187+
</Form>
188+
</template>
+161
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
<script setup lang="ts">
2+
import { cn } from '@/lib/utils'
3+
import { toTypedSchema } from '@vee-validate/zod'
4+
import { useForm } from 'vee-validate'
5+
import { h } from 'vue'
6+
import * as z from 'zod'
7+
import { buttonVariants } from '~/components/ui/button'
8+
import { toast } from '~/components/ui/toast'
9+
10+
const appearanceFormSchema = toTypedSchema(z.object({
11+
theme: z.enum(['light', 'dark'], {
12+
required_error: 'Please select a theme.',
13+
}),
14+
font: z.enum(['inter', 'manrope', 'system'], {
15+
invalid_type_error: 'Select a font',
16+
required_error: 'Please select a font.',
17+
}),
18+
}))
19+
20+
const { handleSubmit } = useForm({
21+
validationSchema: appearanceFormSchema,
22+
initialValues: {
23+
theme: 'light',
24+
font: 'inter',
25+
},
26+
})
27+
28+
const color = useColorMode()
29+
30+
const onSubmit = handleSubmit((values) => {
31+
toast({
32+
title: 'You submitted the following values:',
33+
description: h('pre', { class: 'mt-2 w-[340px] rounded-md bg-slate-950 p-4' }, h('code', { class: 'text-white' }, JSON.stringify(values, null, 2))),
34+
})
35+
if (values.theme === 'dark') {
36+
color.preference = 'dark'
37+
}
38+
else {
39+
color.preference = 'light'
40+
}
41+
})
42+
</script>
43+
44+
<template>
45+
<div>
46+
<h3 class="text-lg font-medium">
47+
Appearance
48+
</h3>
49+
<p class="text-sm text-muted-foreground">
50+
Customize the appearance of the app. Automatically switch between day and night themes.
51+
</p>
52+
</div>
53+
<Separator />
54+
<form class="space-y-8" @submit="onSubmit">
55+
<FormField v-slot="{ field }" name="font">
56+
<FormItem>
57+
<FormLabel>Font</FormLabel>
58+
<div class="relative w-[200px]">
59+
<FormControl>
60+
<select
61+
:class="cn(
62+
buttonVariants({ variant: 'outline' }),
63+
'w-[200px] appearance-none font-normal',
64+
)"
65+
v-bind="field"
66+
>
67+
<option value="inter">
68+
Inter
69+
</option>
70+
<option value="manrope">
71+
Manrope
72+
</option>
73+
<option value="system">
74+
System
75+
</option>
76+
</select>
77+
</FormControl>
78+
<Icon name="i-radix-icons-chevron-down" class="pointer-events-none absolute right-3 top-2.5 h-4 w-4 opacity-50" />
79+
</div>
80+
<FormDescription>
81+
Set the font you want to use in the dashboard.
82+
</FormDescription>
83+
<FormMessage />
84+
</FormItem>
85+
</FormField>
86+
87+
<FormField v-slot="{ componentField }" type="radio" name="theme">
88+
<FormItem class="space-y-1">
89+
<FormLabel>Theme</FormLabel>
90+
<FormDescription>
91+
Select the theme for the dashboard.
92+
</FormDescription>
93+
<FormMessage />
94+
95+
<RadioGroup
96+
class="grid grid-cols-2 max-w-md gap-8 pt-2"
97+
v-bind="componentField"
98+
>
99+
<FormItem>
100+
<FormLabel class="[&:has([data-state=checked])>div]:border-primary">
101+
<FormControl>
102+
<RadioGroupItem value="light" class="sr-only" />
103+
</FormControl>
104+
<div class="items-center border-2 border-muted rounded-md p-1 hover:border-accent">
105+
<div class="rounded-sm bg-[#ecedef] p-2 space-y-2">
106+
<div class="rounded-md bg-white p-2 shadow-sm space-y-2">
107+
<div class="h-2 w-20 rounded-lg bg-[#ecedef]" />
108+
<div class="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
109+
</div>
110+
<div class="flex items-center rounded-md bg-white p-2 shadow-sm space-x-2">
111+
<div class="h-4 w-4 rounded-full bg-[#ecedef]" />
112+
<div class="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
113+
</div>
114+
<div class="flex items-center rounded-md bg-white p-2 shadow-sm space-x-2">
115+
<div class="h-4 w-4 rounded-full bg-[#ecedef]" />
116+
<div class="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
117+
</div>
118+
</div>
119+
</div>
120+
<span class="block w-full p-2 text-center font-normal">
121+
Light
122+
</span>
123+
</FormLabel>
124+
</FormItem>
125+
<FormItem>
126+
<FormLabel class="[&:has([data-state=checked])>div]:border-primary">
127+
<FormControl>
128+
<RadioGroupItem value="dark" class="sr-only" />
129+
</FormControl>
130+
<div class="items-center border-2 border-muted rounded-md bg-popover p-1 hover:bg-accent hover:text-accent-foreground">
131+
<div class="rounded-sm bg-slate-950 p-2 space-y-2">
132+
<div class="rounded-md bg-slate-800 p-2 shadow-sm space-y-2">
133+
<div class="h-2 w-20 rounded-lg bg-slate-400" />
134+
<div class="h-2 w-[100px] rounded-lg bg-slate-400" />
135+
</div>
136+
<div class="flex items-center rounded-md bg-slate-800 p-2 shadow-sm space-x-2">
137+
<div class="h-4 w-4 rounded-full bg-slate-400" />
138+
<div class="h-2 w-[100px] rounded-lg bg-slate-400" />
139+
</div>
140+
<div class="flex items-center rounded-md bg-slate-800 p-2 shadow-sm space-x-2">
141+
<div class="h-4 w-4 rounded-full bg-slate-400" />
142+
<div class="h-2 w-[100px] rounded-lg bg-slate-400" />
143+
</div>
144+
</div>
145+
</div>
146+
<span class="block w-full p-2 text-center font-normal">
147+
Dark
148+
</span>
149+
</FormLabel>
150+
</FormItem>
151+
</RadioGroup>
152+
</FormItem>
153+
</FormField>
154+
155+
<div class="flex justify-start">
156+
<Button type="submit">
157+
Update preferences
158+
</Button>
159+
</div>
160+
</form>
161+
</template>

0 commit comments

Comments
 (0)