diff --git a/apps/web/package.json b/apps/web/package.json index 6f45161..b8c8223 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -47,6 +47,7 @@ "pocketbase": "^0.21.0", "radix-icons-svelte": "^1.2.1", "svelte-headless-table": "^0.18.1", + "svelte-sonner": "^0.3.17", "tailwind-merge": "^2.2.1", "tailwind-variants": "^0.1.20" } diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index 6ea71e9..03132f0 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -32,6 +32,9 @@ dependencies: svelte-headless-table: specifier: ^0.18.1 version: 0.18.1(svelte@4.2.9) + svelte-sonner: + specifier: ^0.3.17 + version: 0.3.17(svelte@4.2.9) tailwind-merge: specifier: ^2.2.1 version: 2.2.1 @@ -2427,6 +2430,14 @@ packages: svelte-subscribe: 2.0.1(svelte@4.2.9) dev: false + /svelte-sonner@0.3.17(svelte@4.2.9): + resolution: {integrity: sha512-jociRGESILpHi6fIIcqGYE1bWAfK4ZeTLHuXxNgGMhG1FwhNRIK1bsCbXEl8/AjRV8aiNXtkS/+IlQtvb2jR9g==} + peerDependencies: + svelte: '>=3 <5' + dependencies: + svelte: 4.2.9 + dev: false + /svelte-subscribe@2.0.1(svelte@4.2.9): resolution: {integrity: sha512-eKXIjLxB4C7eQWPqKEdxcGfNXm2g/qJ67zmEZK/GigCZMfrTR3m7DPY93R6MX+5uoqM1FRYxl8LZ1oy4URWi2A==} peerDependencies: diff --git a/apps/web/src/app.html b/apps/web/src/app.html index 8e74737..b8c3939 100644 --- a/apps/web/src/app.html +++ b/apps/web/src/app.html @@ -8,9 +8,9 @@ <link rel="icon" type="image/png" sizes="32x32" href="%sveltekit.assets%/favicon-32x32.png" /> <link rel="icon" type="image/png" sizes="16x16" href="%sveltekit.assets%/favicon-16x16.png" /> <link rel="manifest" href="%sveltekit.assets%/site.webmanifest" /> - <link rel="mask-icon" href="%sveltekit.assets%/safari-pinned-tab.svg" color="#000000" /> - <meta name="msapplication-TileColor" content="#da532c" /> - <meta name="theme-color" content="#ffffff" /> + <link rel="mask-icon" href="%sveltekit.assets%/safari-pinned-tab.svg" color="#222222" /> + <meta name="msapplication-TileColor" content="#222222" /> + <meta name="theme-color" content="#222222" /> %sveltekit.head% </head> <body diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts index 6107f63..69fa803 100644 --- a/apps/web/src/hooks.server.ts +++ b/apps/web/src/hooks.server.ts @@ -22,8 +22,8 @@ export const handle: Handle = async ({ event, resolve }) => { .authRefresh<{ id: string; email: string }>(); event.locals.id = auth.record.id; event.locals.email = auth.record.email; - } catch (err) { - console.log('Error: ', err); + } catch (_) { + event.locals.pocketBase.authStore.clear(); } const response = await resolve(event); diff --git a/apps/web/src/lib/components/site/icons/logo.svelte b/apps/web/src/lib/components/site/icons/logo.svelte index 5167ff6..199d086 100644 --- a/apps/web/src/lib/components/site/icons/logo.svelte +++ b/apps/web/src/lib/components/site/icons/logo.svelte @@ -3,7 +3,7 @@ topLeft: '#FF6C22', topRight: '#FF9209', bottomLeft: '#FFD099', - bottomRight: '#2B3499' + bottomRight: '#3B48D3' }; </script> diff --git a/apps/web/src/lib/components/site/nav/mobile-nav.svelte b/apps/web/src/lib/components/site/nav/mobile-nav.svelte index e213c81..5712101 100644 --- a/apps/web/src/lib/components/site/nav/mobile-nav.svelte +++ b/apps/web/src/lib/components/site/nav/mobile-nav.svelte @@ -25,7 +25,9 @@ <Sheet.Content side="right" class="pr-0"> <MobileLink href="/" class="flex items-center" bind:open> <span class="sr-only">Logo icon (return home)</span> - <Icons.logo /> + <div class="mr-4 rounded-sm bg-gray-950 p-0.5 dark:bg-transparent"> + <Icons.logo /> + </div> <span class="font-mono font-bold tracking-tighter">{siteConfig.name}</span> </MobileLink> <div class="my-4 h-[calc(100vh-8rem)] overflow-auto pl-1 pt-10"> diff --git a/apps/web/src/lib/components/site/particles.svelte b/apps/web/src/lib/components/site/particles.svelte index 6fc5880..675f3ab 100644 --- a/apps/web/src/lib/components/site/particles.svelte +++ b/apps/web/src/lib/components/site/particles.svelte @@ -58,10 +58,7 @@ } mode.subscribe((value) => { - console.log('value', value); color = value === 'dark' ? '#ffffff' : '#000000'; - - // Move the `rgb` calculation inside the `mode.subscribe` callback rgb = hexToRgb(color); }); diff --git a/apps/web/src/lib/components/site/site-navbar.svelte b/apps/web/src/lib/components/site/site-navbar.svelte index 0f5ec7b..bdfc3cb 100644 --- a/apps/web/src/lib/components/site/site-navbar.svelte +++ b/apps/web/src/lib/components/site/site-navbar.svelte @@ -13,7 +13,9 @@ <div class="container flex h-14 items-center"> <a href="/" class="mr-6 flex items-center space-x-2"> <span class="sr-only">Logo (return home)</span> - <Icons.logo /> + <div class="rounded-sm bg-gray-950 p-0.5 dark:bg-transparent"> + <Icons.logo /> + </div> <span class="text-xl font-bold tracking-tight">{siteConfig.name}</span> </a> <MainNav {authenticated} /> diff --git a/apps/web/src/lib/components/ui/card/card-content.svelte b/apps/web/src/lib/components/ui/card/card-content.svelte new file mode 100644 index 0000000..c87d58a --- /dev/null +++ b/apps/web/src/lib/components/ui/card/card-content.svelte @@ -0,0 +1,13 @@ +<script lang="ts"> + import { cn } from '$lib/utils'; + import type { HTMLAttributes } from 'svelte/elements'; + + type $$Props = HTMLAttributes<HTMLDivElement>; + + let className: $$Props['class'] = undefined; + export { className as class }; +</script> + +<div class={cn('p-6 pt-0', className)} {...$$restProps}> + <slot /> +</div> diff --git a/apps/web/src/lib/components/ui/card/card-description.svelte b/apps/web/src/lib/components/ui/card/card-description.svelte new file mode 100644 index 0000000..ffc81f9 --- /dev/null +++ b/apps/web/src/lib/components/ui/card/card-description.svelte @@ -0,0 +1,13 @@ +<script lang="ts"> + import type { HTMLAttributes } from 'svelte/elements'; + import { cn } from '$lib/utils'; + + type $$Props = HTMLAttributes<HTMLParagraphElement>; + + let className: $$Props['class'] = undefined; + export { className as class }; +</script> + +<p class={cn('text-sm text-muted-foreground', className)} {...$$restProps}> + <slot /> +</p> diff --git a/apps/web/src/lib/components/ui/card/card-footer.svelte b/apps/web/src/lib/components/ui/card/card-footer.svelte new file mode 100644 index 0000000..414ded9 --- /dev/null +++ b/apps/web/src/lib/components/ui/card/card-footer.svelte @@ -0,0 +1,13 @@ +<script lang="ts"> + import type { HTMLAttributes } from 'svelte/elements'; + import { cn } from '$lib/utils'; + + type $$Props = HTMLAttributes<HTMLDivElement>; + + let className: $$Props['class'] = undefined; + export { className as class }; +</script> + +<div class={cn('flex items-center p-6 pt-0', className)} {...$$restProps}> + <slot /> +</div> diff --git a/apps/web/src/lib/components/ui/card/card-header.svelte b/apps/web/src/lib/components/ui/card/card-header.svelte new file mode 100644 index 0000000..8079df3 --- /dev/null +++ b/apps/web/src/lib/components/ui/card/card-header.svelte @@ -0,0 +1,13 @@ +<script lang="ts"> + import type { HTMLAttributes } from 'svelte/elements'; + import { cn } from '$lib/utils'; + + type $$Props = HTMLAttributes<HTMLDivElement>; + + let className: $$Props['class'] = undefined; + export { className as class }; +</script> + +<div class={cn('flex flex-col space-y-1.5 p-6', className)} {...$$restProps}> + <slot /> +</div> diff --git a/apps/web/src/lib/components/ui/card/card-title.svelte b/apps/web/src/lib/components/ui/card/card-title.svelte new file mode 100644 index 0000000..d0d98c0 --- /dev/null +++ b/apps/web/src/lib/components/ui/card/card-title.svelte @@ -0,0 +1,21 @@ +<script lang="ts"> + import type { HTMLAttributes } from 'svelte/elements'; + import { cn } from '$lib/utils'; + import type { HeadingLevel } from '.'; + + type $$Props = HTMLAttributes<HTMLHeadingElement> & { + tag?: HeadingLevel; + }; + + let className: $$Props['class'] = undefined; + export let tag: $$Props['tag'] = 'h3'; + export { className as class }; +</script> + +<svelte:element + this={tag} + class={cn('font-semibold leading-none tracking-tight', className)} + {...$$restProps} +> + <slot /> +</svelte:element> diff --git a/apps/web/src/lib/components/ui/card/card.svelte b/apps/web/src/lib/components/ui/card/card.svelte new file mode 100644 index 0000000..8bc551c --- /dev/null +++ b/apps/web/src/lib/components/ui/card/card.svelte @@ -0,0 +1,22 @@ +<script lang="ts"> + import type { HTMLAttributes } from 'svelte/elements'; + import { cn } from '$lib/utils'; + + type $$Props = HTMLAttributes<HTMLDivElement>; + + let className: $$Props['class'] = undefined; + export { className as class }; +</script> + +<!-- svelte-ignore a11y-no-static-element-interactions --> +<div + class={cn('rounded-xl border bg-card text-card-foreground shadow', className)} + {...$$restProps} + on:click + on:focusin + on:focusout + on:mouseenter + on:mouseleave +> + <slot /> +</div> diff --git a/apps/web/src/lib/components/ui/card/index.ts b/apps/web/src/lib/components/ui/card/index.ts new file mode 100644 index 0000000..86c5408 --- /dev/null +++ b/apps/web/src/lib/components/ui/card/index.ts @@ -0,0 +1,24 @@ +import Root from './card.svelte'; +import Content from './card-content.svelte'; +import Description from './card-description.svelte'; +import Footer from './card-footer.svelte'; +import Header from './card-header.svelte'; +import Title from './card-title.svelte'; + +export { + Root, + Content, + Description, + Footer, + Header, + Title, + // + Root as Card, + Content as CardContent, + Description as CardDescription, + Footer as CardFooter, + Header as CardHeader, + Title as CardTitle +}; + +export type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; diff --git a/apps/web/src/lib/components/ui/sonner/index.ts b/apps/web/src/lib/components/ui/sonner/index.ts new file mode 100644 index 0000000..fcaf06b --- /dev/null +++ b/apps/web/src/lib/components/ui/sonner/index.ts @@ -0,0 +1 @@ +export { default as Toaster } from './sonner.svelte'; diff --git a/apps/web/src/lib/components/ui/sonner/sonner.svelte b/apps/web/src/lib/components/ui/sonner/sonner.svelte new file mode 100644 index 0000000..f1d41c5 --- /dev/null +++ b/apps/web/src/lib/components/ui/sonner/sonner.svelte @@ -0,0 +1,21 @@ +<script lang="ts"> + import { Toaster as Sonner, type ToasterProps as SonnerProps } from 'svelte-sonner'; + import { mode } from 'mode-watcher'; + + type $$Props = SonnerProps; +</script> + +<Sonner + theme={$mode} + class="toaster group" + toastOptions={{ + classes: { + toast: + 'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg', + description: 'group-[.toast]:text-muted-foreground', + actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground', + cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground' + } + }} + {...$$restProps} +/> diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index eba19d8..a02cbcf 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -54,3 +54,9 @@ export const flyAndScale = ( easing: cubicOut }; }; + +import { z } from 'zod'; +const emptyStringToUndefined = z.literal('').transform(() => undefined); +export function asOptionalStringWithoutEmpty<T extends z.ZodString>(schema: T) { + return schema.optional().or(emptyStringToUndefined); +} diff --git a/apps/web/src/routes/(dashboard)/settings/(components)/avatar-form.svelte b/apps/web/src/routes/(dashboard)/settings/(components)/avatar-form.svelte new file mode 100644 index 0000000..e3df797 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/settings/(components)/avatar-form.svelte @@ -0,0 +1,85 @@ +<script lang="ts" context="module"> + const MAX_IMAGE_SIZE = 5; + const ACCEPTED_IMAGE_TYPES = [ + 'image/jpeg', + 'image/png', + 'image/svg+xml', + 'image/gif', + 'image/webp' + ]; + import { z } from 'zod'; + const sizeInMB = (sizeInBytes: number, decimalsNum = 2) => { + const result = sizeInBytes / (1024 * 1024); + return +result.toFixed(decimalsNum); + }; + export const avatarFormSchema = z.object({ + avatar: z.any() + // .custom<FileList>() + // .refine((files) => { + // return Array.from(files ?? []).length !== 0; + // }, 'Image is required') + // .refine((files) => { + // return Array.from(files ?? []).every((file) => sizeInMB(file.size) <= MAX_IMAGE_SIZE); + // }, `The maximum image size is ${MAX_IMAGE_SIZE}MB`) + // .refine((files) => { + // return Array.from(files ?? []).every((file) => ACCEPTED_IMAGE_TYPES.includes(file.type)); + // }, 'File type is not supported') + }); + export type AvatarFormSchema = typeof avatarFormSchema; +</script> + +<script lang="ts"> + import * as Card from '$lib/components/ui/card'; + import * as Form from '$lib/components/ui/form'; + import type { SuperValidated } from 'sveltekit-superforms'; + import type { LayoutData } from '../../$types'; + import * as Avatar from '$lib/components/ui/avatar'; + import { toast } from 'svelte-sonner'; + import type { FormOptions } from 'formsnap'; + export let avatarFormSchemaData: SuperValidated<AvatarFormSchema>; + export let user: LayoutData['user']; + export let debug: boolean; + const options: FormOptions<AvatarFormSchema> = { + onSubmit() { + toast.info('Uploading avatar...'); + }, + onResult({ result }) { + if (result.status === 200) toast.success('Your avatar has been updated!'); + if (result.status === 400) toast.error('There was an error updating your avatar.'); + }, + dataType: 'form' + }; +</script> + +<Card.Root> + <Card.Header> + <Card.Title>Avatar image</Card.Title> + <Card.Description> + This is the image that will be displayed on your profile, in dashboards and in emails. + </Card.Description> + </Card.Header> + <Card.Content> + <Form.Root + {options} + form={avatarFormSchemaData} + schema={avatarFormSchema} + let:config + action="?/avatar" + method="POST" + class="space-y-2" + {debug} + ><Form.Item> + <Form.Field {config} name="avatar"> + <Form.Label class="sr-only">Avatar</Form.Label> + <Avatar.Root class="aspect-square h-auto w-full"> + <Avatar.Image src={user?.avatarUrl} alt={user?.name} /> + <Avatar.Fallback class="text-8xl">{user?.initials}</Avatar.Fallback> + </Avatar.Root> + <Form.Input type="file" /> + <Form.Validation /> + </Form.Field> + </Form.Item> + <Form.Button>Update avatar</Form.Button> + </Form.Root> + </Card.Content> +</Card.Root> diff --git a/apps/web/src/routes/(dashboard)/settings/(components)/email-form.svelte b/apps/web/src/routes/(dashboard)/settings/(components)/email-form.svelte new file mode 100644 index 0000000..acd356b --- /dev/null +++ b/apps/web/src/routes/(dashboard)/settings/(components)/email-form.svelte @@ -0,0 +1,104 @@ +<script lang="ts" context="module"> + import { z } from 'zod'; + export const emailRequestFormSchema = z.object({ + newEmail: z.string().email() + }); + export const emailConfirmFormSchema = z.object({ + token: z.string(), + password: z.string() + }); + export type EmailRequestFormSchema = typeof emailRequestFormSchema; + export type EmailConfirmFormSchema = typeof emailConfirmFormSchema; +</script> + +<script lang="ts"> + import * as Card from '$lib/components/ui/card'; + import * as Form from '$lib/components/ui/form'; + import { toast } from 'svelte-sonner'; + import type { SuperValidated } from 'sveltekit-superforms'; + import type { LayoutData } from '../$types'; + import type { FormOptions } from 'formsnap'; + export let emailRequestFormSchemaData: SuperValidated<EmailRequestFormSchema>; + export let emailConfirmFormSchemaData: SuperValidated<EmailConfirmFormSchema>; + const requestOptions: FormOptions<EmailRequestFormSchema> = { + onSubmit() { + toast.info('Requesting token...'); + }, + onResult({ result }) { + if (result.status === 200) toast.success('Token sent! Check your email.'); + if (result.status === 400) + toast.error('There was an error sending the token. Is this email already in use?'); + } + }; + const confirmOptions: FormOptions<EmailConfirmFormSchema> = { + onSubmit() { + toast.info('Changing email...'); + }, + onResult({ result }) { + console.log(result); + if (result.status === 200) toast.success('Your email has been changed!'); + if (result.status === 400) + toast.error('There was an error changing your email. Is the token correct?'); + } + }; + export let user: LayoutData['user']; + export let debug: boolean; +</script> + +<Card.Root> + <Card.Header> + <Card.Title>Modify your email</Card.Title> + <Card.Description + >Use a different email by requesting a token and entering it for verification below.</Card.Description + > + </Card.Header> + <Card.Content> + <Form.Root + schema={emailRequestFormSchema} + form={emailRequestFormSchemaData} + options={requestOptions} + let:config + method="POST" + action="?/emailRequest" + class="space-y-8" + {debug} + > + <Form.Item> + <Form.Field {config} name="newEmail"> + <Form.Label>New email</Form.Label> + <div class="flex space-x-2"> + <Form.Input placeholder={user?.email} /> + <Form.Button variant="secondary">Request token</Form.Button> + </div> + <Form.Validation /> + </Form.Field> + </Form.Item> + </Form.Root> + <Form.Root + options={confirmOptions} + form={emailConfirmFormSchemaData} + schema={emailConfirmFormSchema} + let:config + action="?/emailConfirm" + method="POST" + class="space-y-2" + {debug} + > + <Form.Item> + <Form.Field {config} name="token"> + <Form.Label>Email verification token</Form.Label> + <Form.Input /> + <Form.Validation /> + </Form.Field> + </Form.Item> + <Form.Item> + <Form.Field {config} name="password"> + <Form.Label>Password</Form.Label> + <Form.Input /> + <Form.Validation /> + </Form.Field> + </Form.Item> + <Form.Button>Confirm email change</Form.Button> + </Form.Root> + </Card.Content> +</Card.Root> diff --git a/apps/web/src/routes/(dashboard)/settings/(components)/name-form.svelte b/apps/web/src/routes/(dashboard)/settings/(components)/name-form.svelte new file mode 100644 index 0000000..c084ca6 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/settings/(components)/name-form.svelte @@ -0,0 +1,79 @@ +<script lang="ts" context="module"> + import { z } from 'zod'; + const emptyStringToUndefined = z.literal('').transform(() => undefined); + export const nameFormSchema = z.object({ + name: z + .string() + .min(2, 'Name must be at least 2 characters.') + .max(30, 'Name must not be longer than 30 characters') + .optional() + .or(emptyStringToUndefined), + username: z + .string() + .min(2, 'Username must be at least 2 characters.') + .max(30, 'Username must not be longer than 30 characters') + .optional() + .or(emptyStringToUndefined) + }); + export type NameFormSchema = typeof nameFormSchema; +</script> + +<script lang="ts"> + import * as Card from '$lib/components/ui/card'; + import * as Form from '$lib/components/ui/form'; + import type { SuperValidated } from 'sveltekit-superforms'; + import type { LayoutData } from '../$types'; + import type { FormOptions } from 'formsnap'; + import { toast } from 'svelte-sonner'; + export let nameFormSchemaData: SuperValidated<NameFormSchema>; + export let user: LayoutData['user']; + export let debug: boolean; + const options: FormOptions<NameFormSchema> = { + onSubmit() { + toast.info('Updating name...'); + }, + onResult({ result }) { + if (result.status === 200) toast.success('Your name has been updated!'); + if (result.status === 400) toast.error('There was an error updating your name.'); + }, + dataType: 'form' + }; +</script> + +<Card.Root> + <Card.Header> + <Card.Title>Change your name</Card.Title> + <Card.Description> + You can change the name that will be displayed on your profile and in emails as well as your + username. + </Card.Description> + </Card.Header> + <Card.Content> + <Form.Root + {options} + form={nameFormSchemaData} + schema={nameFormSchema} + let:config + action="?/name" + method="POST" + class="space-y-2" + {debug} + > + <Form.Item> + <Form.Field name="name" {config}> + <Form.Label>Display name</Form.Label> + <Form.Input placeholder={user?.name} /> + <Form.Validation /> + </Form.Field> + </Form.Item> + <Form.Item> + <Form.Field {config} name="username"> + <Form.Label>Username</Form.Label> + <Form.Input placeholder={user?.username} /> + <Form.Validation /> + </Form.Field> + </Form.Item> + <Form.Button>Update name</Form.Button> + </Form.Root> + </Card.Content> +</Card.Root> diff --git a/apps/web/src/routes/(dashboard)/settings/(components)/password-form.svelte b/apps/web/src/routes/(dashboard)/settings/(components)/password-form.svelte new file mode 100644 index 0000000..8b07ec1 --- /dev/null +++ b/apps/web/src/routes/(dashboard)/settings/(components)/password-form.svelte @@ -0,0 +1,78 @@ +<script lang="ts" context="module"> + import { z } from 'zod'; + export const passwordFormSchema = z + .object({ + oldPassword: z.string(), + password: z.string().min(4), + passwordConfirm: z.string().min(4) + }) + .refine( + (values) => { + return values.password === values.passwordConfirm; + }, + { + message: 'Passwords must match.', + path: ['passwordConfirm'] + } + ); + export type PasswordFormSchema = typeof passwordFormSchema; +</script> + +<script lang="ts"> + import * as Card from '$lib/components/ui/card'; + import * as Form from '$lib/components/ui/form'; + import type { SuperValidated } from 'sveltekit-superforms'; + import type { FormOptions } from 'formsnap'; + import { toast } from 'svelte-sonner'; + export let passwordFormSchemaData: SuperValidated<PasswordFormSchema>; + export let debug: boolean; + const options: FormOptions<PasswordFormSchema> = { + onSubmit() { + toast.info('Changing password...'); + }, + onResult({ result }) { + if (result.status === 200) + toast.success('Your password has been changed! Please log in again.'); + if (result.status === 400) + toast.error('There was an error changing your password. Is the old password correct?'); + } + }; +</script> + +<Card.Root> + <Card.Header> + <Card.Title>Password change</Card.Title> + <Card.Description>You can change your account password here.</Card.Description> + </Card.Header> + <Card.Content> + <Form.Root + {options} + form={passwordFormSchemaData} + schema={passwordFormSchema} + let:config + action="?/password" + method="POST" + class="space-y-2" + {debug} + > + <Form.Item> + <Form.Field {config} name="oldPassword"> + <Form.Label>Current Password</Form.Label> + <Form.Input type="password" /> + <Form.Validation /> + </Form.Field> + <Form.Field {config} name="password"> + <Form.Label>New Password</Form.Label> + <Form.Input type="password" /> + <Form.Validation /> + </Form.Field> + <Form.Field {config} name="passwordConfirm"> + <Form.Label>Confirm new password</Form.Label> + <Form.Input type="password" /> + <Form.Validation /> + </Form.Field> + </Form.Item> + <Form.Button>Update password</Form.Button> + </Form.Root> + </Card.Content> +</Card.Root> diff --git a/apps/web/src/routes/(dashboard)/settings/+page.server.ts b/apps/web/src/routes/(dashboard)/settings/+page.server.ts index 7307867..8f1e189 100644 --- a/apps/web/src/routes/(dashboard)/settings/+page.server.ts +++ b/apps/web/src/routes/(dashboard)/settings/+page.server.ts @@ -1,24 +1,79 @@ import type { PageServerLoad } from './$types'; import { superValidate } from 'sveltekit-superforms/server'; -import { profileFormSchema } from './profile-form.svelte'; import { fail, type Actions } from '@sveltejs/kit'; +import { nameFormSchema } from './(components)/name-form.svelte'; +import { emailConfirmFormSchema, emailRequestFormSchema } from './(components)/email-form.svelte'; +import { passwordFormSchema } from './(components)/password-form.svelte'; +import { avatarFormSchema } from './(components)/avatar-form.svelte'; export const load: PageServerLoad = async () => { return { - form: await superValidate(profileFormSchema) + forms: { + name: await superValidate(nameFormSchema), + emailRequest: await superValidate(emailRequestFormSchema), + emailConfirm: await superValidate(emailConfirmFormSchema), + password: await superValidate(passwordFormSchema), + avatar: await superValidate(avatarFormSchema), + debug: false + } }; }; export const actions: Actions = { - default: async (event) => { - const form = await superValidate(event, profileFormSchema); + name: async ({ request, locals }: { request: Request; locals: App.Locals }) => { + const form = await superValidate(request, nameFormSchema); if (!form.valid) { return fail(400, { form }); } - return { - form - }; + await locals.pocketBase.collection('users').update(locals.id, form.data); + return { form }; + }, + emailRequest: async ({ request, locals }: { request: Request; locals: App.Locals }) => { + console.log(request); + const form = await superValidate(request, emailRequestFormSchema); + if (!form.valid) { + return fail(400, { + form + }); + } + await locals.pocketBase.collection('users').requestEmailChange(form.data.newEmail); + return { form }; + }, + emailConfirm: async ({ request, locals }: { request: Request; locals: App.Locals }) => { + const form = await superValidate(request, emailConfirmFormSchema); + if (!form.valid) { + return fail(400, { + form + }); + } + await locals.pocketBase + .collection('users') + .confirmEmailChange(form.data.token, form.data.password); + return { form }; + }, + password: async ({ request, locals }: { request: Request; locals: App.Locals }) => { + const form = await superValidate(request, passwordFormSchema); + console.log(form); + if (!form.valid) { + return fail(400, { + form + }); + } + await locals.pocketBase.collection('users').update(locals.id, form.data); + return { form }; + }, + avatar: async ({ request, locals }: { request: Request; locals: App.Locals }) => { + const formData = await request.formData(); + const form = await superValidate(request, avatarFormSchema); + + if (!form.valid) return fail(400, { form }); + + const file = formData.get('file'); + if (file instanceof File) { + await locals.pocketBase.collection('users').update(locals.id, { avatar: file }); + } + return { form }; } }; diff --git a/apps/web/src/routes/(dashboard)/settings/+page.svelte b/apps/web/src/routes/(dashboard)/settings/+page.svelte index 121af54..21cadb4 100644 --- a/apps/web/src/routes/(dashboard)/settings/+page.svelte +++ b/apps/web/src/routes/(dashboard)/settings/+page.svelte @@ -1,10 +1,13 @@ <script lang="ts"> import type { PageData } from './$types'; - import ProfileForm from './profile-form.svelte'; import { Separator } from '$lib/components/ui/separator'; + import NameForm from './(components)/name-form.svelte'; + import EmailForm from './(components)/email-form.svelte'; + import PasswordForm from './(components)/password-form.svelte'; + import AvatarForm from './(components)/avatar-form.svelte'; export let data: PageData; - export let { form, user } = data; + let { forms, user } = data; </script> <div class="space-y-6"> @@ -13,5 +16,19 @@ <p class="text-sm text-muted-foreground">Update your account and profile settings.</p> </div> <Separator /> - <ProfileForm data={form} {user} /> + <div class="grid grid-cols-1 gap-4 sm:grid-cols-[4fr,3fr]"> + <div class="space-y-8"> + <NameForm nameFormSchemaData={forms.name} {user} debug={forms.debug} /> + <EmailForm + emailRequestFormSchemaData={forms.emailRequest} + emailConfirmFormSchemaData={forms.emailConfirm} + {user} + debug={forms.debug} + /> + <PasswordForm passwordFormSchemaData={forms.password} debug={forms.debug} /> + </div> + <div> + <AvatarForm avatarFormSchemaData={forms.avatar} {user} debug={forms.debug} /> + </div> + </div> </div> diff --git a/apps/web/src/routes/(dashboard)/settings/appearance/+page.server.ts b/apps/web/src/routes/(dashboard)/settings/appearance/+page.server.ts index 3401cd6..e435a79 100644 --- a/apps/web/src/routes/(dashboard)/settings/appearance/+page.server.ts +++ b/apps/web/src/routes/(dashboard)/settings/appearance/+page.server.ts @@ -12,7 +12,6 @@ export const load: PageServerLoad = async () => { export const actions: Actions = { default: async ({ request, locals }: { request: Request; locals: App.Locals }) => { const form = await superValidate(request, appearanceFormSchema); - console.log('form: ', form); if (!form.valid) { return fail(400, { form @@ -21,5 +20,6 @@ export const actions: Actions = { await locals.pocketBase .collection('users') .update(locals.id, { appearanceMode: form.data.theme }); + return { form }; } }; diff --git a/apps/web/src/routes/(dashboard)/settings/appearance/+page.svelte b/apps/web/src/routes/(dashboard)/settings/appearance/+page.svelte index bf51e55..6dea5a0 100644 --- a/apps/web/src/routes/(dashboard)/settings/appearance/+page.svelte +++ b/apps/web/src/routes/(dashboard)/settings/appearance/+page.svelte @@ -15,5 +15,5 @@ </p> </div> <Separator /> - <AppearanceForm data={form} {user} /> + <AppearanceForm data={form} {user} /> </div> diff --git a/apps/web/src/routes/(dashboard)/settings/appearance/appearance-form.svelte b/apps/web/src/routes/(dashboard)/settings/appearance/appearance-form.svelte index e7d8c5b..09d8e33 100644 --- a/apps/web/src/routes/(dashboard)/settings/appearance/appearance-form.svelte +++ b/apps/web/src/routes/(dashboard)/settings/appearance/appearance-form.svelte @@ -16,12 +16,28 @@ import Label from '$lib/components/ui/label/label.svelte'; import { dev } from '$app/environment'; import type { PageData } from '../$types'; + import type { FormOptions } from 'formsnap'; + import { toast } from 'svelte-sonner'; + export let isLoading = false; export let data: SuperValidated<AppearanceFormSchema>; + const options: FormOptions<AppearanceFormSchema> = { + onSubmit() { + isLoading = true; + toast.info('Updating appearance...'); + }, + onResult({ result }) { + isLoading = false; + if (result.status === 200) toast.success('Your appearance has been updated!'); + if (result.status === 400) toast.error('There was an error updating your appearance.'); + }, + dataType: 'form' + }; export let user: PageData['user']; </script> <Form.Root schema={appearanceFormSchema} + {options} form={data} class="space-y-8" method="POST" @@ -33,7 +49,11 @@ <Form.Label>Theme</Form.Label> <Form.Description>Select the theme for the dashboard.</Form.Description> <Form.Validation /> - <Form.RadioGroup class="grid max-w-xl grid-cols-3 gap-8 pt-2" orientation="horizontal" value={user?.appearanceMode}> + <Form.RadioGroup + class="grid max-w-xl grid-cols-3 gap-8 pt-2" + orientation="horizontal" + value={user?.appearanceMode} + > <Label for="light" class="[&:has([data-state=checked])>div]:border-primary"> <Form.RadioItem id="light" value="light" class="sr-only" /> <div class="items-center rounded-md border-2 border-muted p-1 hover:border-accent"> @@ -101,5 +121,5 @@ </Form.RadioGroup> </Form.Field> </Form.Item> - <Form.Button>Update preferences</Form.Button> + <Form.Button disabled={isLoading}>Update preferences</Form.Button> </Form.Root> diff --git a/apps/web/src/routes/(dashboard)/settings/profile-form.svelte b/apps/web/src/routes/(dashboard)/settings/profile-form.svelte deleted file mode 100644 index 247c5a3..0000000 --- a/apps/web/src/routes/(dashboard)/settings/profile-form.svelte +++ /dev/null @@ -1,90 +0,0 @@ -<script lang="ts" context="module"> - import { z } from 'zod'; - export const profileFormSchema = z.object({ - name: z - .string({ - required_error: 'Required.' - }) - .min(2, 'Name must be at least 2 characters.') - .max(30, 'Name must not be longer than 30 characters'), - username: z - .string() - .min(2, 'Username must be at least 2 characters.') - .max(30, 'Username must not be longer than 30 characters'), - email: z.string({ required_error: 'Please enter a valid email' }).email(), - avatar: z.any().refine((val) => val.length > 0, 'File is required') - }); - export type ProfileFormSchema = typeof profileFormSchema; -</script> - -<script lang="ts"> - import * as Form from '$lib/components/ui/form'; - import type { SuperValidated } from 'sveltekit-superforms'; - import { dev } from '$app/environment'; - import type { LayoutData } from '../$types'; - import * as Avatar from '$lib/components/ui/avatar'; - - export let data: SuperValidated<ProfileFormSchema>; - export let user: LayoutData['user']; -</script> - -<Form.Root - form={data} - schema={profileFormSchema} - let:config - method="POST" - class="space-y-8" - debug={dev ? true : false} -> - <div class="grid grid-cols-[1fr,16rem] gap-4"> - <div> - <Form.Item> - <Form.Field name="name" {config}> - <Form.Label>Name</Form.Label> - <Form.Input placeholder={user?.name} /> - <Form.Description> - This is the name that will be displayed on your profile and in emails. - </Form.Description> - <Form.Validation /> - </Form.Field> - </Form.Item> - <Form.Item> - <Form.Field {config} name="username"> - <Form.Label>Username</Form.Label> - <Form.Input placeholder={user?.username} /> - <Form.Description> - This is your public display name. It can be your real name or a pseudonym. - </Form.Description> - <Form.Validation /> - </Form.Field> - </Form.Item> - <Form.Item> - <Form.Field {config} name="email"> - <Form.Label>Email</Form.Label> - <Form.Input placeholder={user?.email} /> - <Form.Description> - <Form.Description> - This is the email address associated with your account. - </Form.Description> - </Form.Description> - <Form.Validation /> - </Form.Field> - </Form.Item> - </div> - <Form.Item> - <Form.Field {config} name="avatar"> - <Form.Label>Profile Picture</Form.Label> - <Avatar.Root class="aspect-square h-auto w-full"> - <Avatar.Image src={user?.avatarUrl} alt={user?.name} /> - <Avatar.Fallback class="text-8xl">{user?.initials}</Avatar.Fallback> - </Avatar.Root> - <Form.Input type="file" placeholder={user?.email} /> - <Form.Description> - <Form.Description>Your avatar image displayed.</Form.Description> - </Form.Description> - <Form.Validation /> - </Form.Field> - </Form.Item> - </div> - <Form.Button>Update profile</Form.Button> -</Form.Root> diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index 9d562f9..9ed5cec 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -6,12 +6,13 @@ import type { LayoutData } from './$types'; import DataIndicator from '$lib/components/site/data-indicator.svelte'; import { fly } from 'svelte/transition'; + import { Toaster } from 'svelte-sonner'; export let data: LayoutData; </script> <ModeWatcher /> - +<Toaster /> <Metadata /> <div class="relative flex min-h-screen flex-col" id="page"> diff --git a/apps/web/static/android-chrome-192x192.png b/apps/web/static/android-chrome-192x192.png new file mode 100644 index 0000000..e9f34af Binary files /dev/null and b/apps/web/static/android-chrome-192x192.png differ diff --git a/apps/web/static/android-chrome-512x512.png b/apps/web/static/android-chrome-512x512.png new file mode 100644 index 0000000..208e5a7 Binary files /dev/null and b/apps/web/static/android-chrome-512x512.png differ diff --git a/apps/web/static/apple-touch-icon.png b/apps/web/static/apple-touch-icon.png index 89fbaf5..a77caaa 100644 Binary files a/apps/web/static/apple-touch-icon.png and b/apps/web/static/apple-touch-icon.png differ diff --git a/apps/web/static/favicon-16x16.png b/apps/web/static/favicon-16x16.png index 8936240..5d06772 100644 Binary files a/apps/web/static/favicon-16x16.png and b/apps/web/static/favicon-16x16.png differ diff --git a/apps/web/static/favicon-32x32.png b/apps/web/static/favicon-32x32.png index 4c4bcca..ba5a747 100644 Binary files a/apps/web/static/favicon-32x32.png and b/apps/web/static/favicon-32x32.png differ diff --git a/apps/web/static/favicon.ico b/apps/web/static/favicon.ico index eabb654..a54848e 100644 Binary files a/apps/web/static/favicon.ico and b/apps/web/static/favicon.ico differ diff --git a/apps/web/static/omnidash.svg b/apps/web/static/omnidash.svg index 4e83575..424fa5a 100644 --- a/apps/web/static/omnidash.svg +++ b/apps/web/static/omnidash.svg @@ -1,7 +1 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" - stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> - <rect width="7" height="9" x="3" y="3" rx="1" stroke="#FF6C22" /> - <rect width="7" height="5" x="14" y="3" rx="1" stroke="#FF9209" /> - <rect width="7" height="9" x="14" y="12" rx="1" stroke="#2B3499" /> - <rect width="7" height="5" x="3" y="16" rx="1" stroke="#FFD099" /> -</svg> +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="9" x="3" y="3" rx="1" stroke="#FF6C22"></rect><rect width="7" height="5" x="14" y="3" rx="1" stroke="#FF9209"></rect><rect width="7" height="5" x="3" y="16" rx="1" stroke="#FFD099"></rect><rect width="7" height="9" x="14" y="12" rx="1" stroke="#3B48D3"></rect></svg> \ No newline at end of file diff --git a/apps/web/static/site.webmanifest b/apps/web/static/site.webmanifest index 9591150..cda3fd3 100644 --- a/apps/web/static/site.webmanifest +++ b/apps/web/static/site.webmanifest @@ -1,6 +1,6 @@ { - "name": "", - "short_name": "", + "name": "Omnidash", + "short_name": "Omnidash", "icons": [ { "src": "/android-chrome-192x192.png",