feat: added crud
|  | @ -47,6 +47,7 @@ | ||||||
| 		"pocketbase": "^0.21.0", | 		"pocketbase": "^0.21.0", | ||||||
| 		"radix-icons-svelte": "^1.2.1", | 		"radix-icons-svelte": "^1.2.1", | ||||||
| 		"svelte-headless-table": "^0.18.1", | 		"svelte-headless-table": "^0.18.1", | ||||||
|  | 		"svelte-sonner": "^0.3.17", | ||||||
| 		"tailwind-merge": "^2.2.1", | 		"tailwind-merge": "^2.2.1", | ||||||
| 		"tailwind-variants": "^0.1.20" | 		"tailwind-variants": "^0.1.20" | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
							
								
								
									
										11
									
								
								apps/web/pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							
							
						
						|  | @ -32,6 +32,9 @@ dependencies: | ||||||
|   svelte-headless-table: |   svelte-headless-table: | ||||||
|     specifier: ^0.18.1 |     specifier: ^0.18.1 | ||||||
|     version: 0.18.1(svelte@4.2.9) |     version: 0.18.1(svelte@4.2.9) | ||||||
|  |   svelte-sonner: | ||||||
|  |     specifier: ^0.3.17 | ||||||
|  |     version: 0.3.17(svelte@4.2.9) | ||||||
|   tailwind-merge: |   tailwind-merge: | ||||||
|     specifier: ^2.2.1 |     specifier: ^2.2.1 | ||||||
|     version: 2.2.1 |     version: 2.2.1 | ||||||
|  | @ -2427,6 +2430,14 @@ packages: | ||||||
|       svelte-subscribe: 2.0.1(svelte@4.2.9) |       svelte-subscribe: 2.0.1(svelte@4.2.9) | ||||||
|     dev: false |     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): |   /svelte-subscribe@2.0.1(svelte@4.2.9): | ||||||
|     resolution: {integrity: sha512-eKXIjLxB4C7eQWPqKEdxcGfNXm2g/qJ67zmEZK/GigCZMfrTR3m7DPY93R6MX+5uoqM1FRYxl8LZ1oy4URWi2A==} |     resolution: {integrity: sha512-eKXIjLxB4C7eQWPqKEdxcGfNXm2g/qJ67zmEZK/GigCZMfrTR3m7DPY93R6MX+5uoqM1FRYxl8LZ1oy4URWi2A==} | ||||||
|     peerDependencies: |     peerDependencies: | ||||||
|  |  | ||||||
|  | @ -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="32x32" href="%sveltekit.assets%/favicon-32x32.png" /> | ||||||
| 		<link rel="icon" type="image/png" sizes="16x16" href="%sveltekit.assets%/favicon-16x16.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="manifest" href="%sveltekit.assets%/site.webmanifest" /> | ||||||
| 		<link rel="mask-icon" href="%sveltekit.assets%/safari-pinned-tab.svg" color="#000000" /> | 		<link rel="mask-icon" href="%sveltekit.assets%/safari-pinned-tab.svg" color="#222222" /> | ||||||
| 		<meta name="msapplication-TileColor" content="#da532c" /> | 		<meta name="msapplication-TileColor" content="#222222" /> | ||||||
| 		<meta name="theme-color" content="#ffffff" /> | 		<meta name="theme-color" content="#222222" /> | ||||||
| 		%sveltekit.head% | 		%sveltekit.head% | ||||||
| 	</head> | 	</head> | ||||||
| 	<body | 	<body | ||||||
|  |  | ||||||
|  | @ -22,8 +22,8 @@ export const handle: Handle = async ({ event, resolve }) => { | ||||||
| 			.authRefresh<{ id: string; email: string }>(); | 			.authRefresh<{ id: string; email: string }>(); | ||||||
| 		event.locals.id = auth.record.id; | 		event.locals.id = auth.record.id; | ||||||
| 		event.locals.email = auth.record.email; | 		event.locals.email = auth.record.email; | ||||||
| 	} catch (err) { | 	} catch (_) { | ||||||
| 		console.log('Error: ', err); | 		event.locals.pocketBase.authStore.clear(); | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	const response = await resolve(event); | 	const response = await resolve(event); | ||||||
|  |  | ||||||
|  | @ -3,7 +3,7 @@ | ||||||
| 		topLeft: '#FF6C22', | 		topLeft: '#FF6C22', | ||||||
| 		topRight: '#FF9209', | 		topRight: '#FF9209', | ||||||
| 		bottomLeft: '#FFD099', | 		bottomLeft: '#FFD099', | ||||||
| 		bottomRight: '#2B3499' | 		bottomRight: '#3B48D3' | ||||||
| 	}; | 	}; | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -25,7 +25,9 @@ | ||||||
| 	<Sheet.Content side="right" class="pr-0"> | 	<Sheet.Content side="right" class="pr-0"> | ||||||
| 		<MobileLink href="/" class="flex items-center" bind:open> | 		<MobileLink href="/" class="flex items-center" bind:open> | ||||||
| 			<span class="sr-only">Logo icon (return home)</span> | 			<span class="sr-only">Logo icon (return home)</span> | ||||||
|  | 			<div class="mr-4 rounded-sm bg-gray-950 p-0.5 dark:bg-transparent"> | ||||||
| 				<Icons.logo /> | 				<Icons.logo /> | ||||||
|  | 			</div> | ||||||
| 			<span class="font-mono font-bold tracking-tighter">{siteConfig.name}</span> | 			<span class="font-mono font-bold tracking-tighter">{siteConfig.name}</span> | ||||||
| 		</MobileLink> | 		</MobileLink> | ||||||
| 		<div class="my-4 h-[calc(100vh-8rem)] overflow-auto pl-1 pt-10"> | 		<div class="my-4 h-[calc(100vh-8rem)] overflow-auto pl-1 pt-10"> | ||||||
|  |  | ||||||
|  | @ -58,10 +58,7 @@ | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	mode.subscribe((value) => { | 	mode.subscribe((value) => { | ||||||
| 		console.log('value', value); |  | ||||||
| 		color = value === 'dark' ? '#ffffff' : '#000000'; | 		color = value === 'dark' ? '#ffffff' : '#000000'; | ||||||
| 
 |  | ||||||
| 		// Move the `rgb` calculation inside the `mode.subscribe` callback |  | ||||||
| 		rgb = hexToRgb(color); | 		rgb = hexToRgb(color); | ||||||
| 	}); | 	}); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -13,7 +13,9 @@ | ||||||
| 	<div class="container flex h-14 items-center"> | 	<div class="container flex h-14 items-center"> | ||||||
| 		<a href="/" class="mr-6 flex items-center space-x-2"> | 		<a href="/" class="mr-6 flex items-center space-x-2"> | ||||||
| 			<span class="sr-only">Logo (return home)</span> | 			<span class="sr-only">Logo (return home)</span> | ||||||
|  | 			<div class="rounded-sm bg-gray-950 p-0.5 dark:bg-transparent"> | ||||||
| 				<Icons.logo /> | 				<Icons.logo /> | ||||||
|  | 			</div> | ||||||
| 			<span class="text-xl font-bold tracking-tight">{siteConfig.name}</span> | 			<span class="text-xl font-bold tracking-tight">{siteConfig.name}</span> | ||||||
| 		</a> | 		</a> | ||||||
| 		<MainNav {authenticated} /> | 		<MainNav {authenticated} /> | ||||||
|  |  | ||||||
							
								
								
									
										13
									
								
								apps/web/src/lib/components/ui/card/card-content.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -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> | ||||||
							
								
								
									
										13
									
								
								apps/web/src/lib/components/ui/card/card-description.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -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> | ||||||
							
								
								
									
										13
									
								
								apps/web/src/lib/components/ui/card/card-footer.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -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> | ||||||
							
								
								
									
										13
									
								
								apps/web/src/lib/components/ui/card/card-header.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -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> | ||||||
							
								
								
									
										21
									
								
								apps/web/src/lib/components/ui/card/card-title.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -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> | ||||||
							
								
								
									
										22
									
								
								apps/web/src/lib/components/ui/card/card.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -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> | ||||||
							
								
								
									
										24
									
								
								apps/web/src/lib/components/ui/card/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -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'; | ||||||
							
								
								
									
										1
									
								
								apps/web/src/lib/components/ui/sonner/index.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -0,0 +1 @@ | ||||||
|  | export { default as Toaster } from './sonner.svelte'; | ||||||
							
								
								
									
										21
									
								
								apps/web/src/lib/components/ui/sonner/sonner.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						|  | @ -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} | ||||||
|  | /> | ||||||
|  | @ -54,3 +54,9 @@ export const flyAndScale = ( | ||||||
| 		easing: cubicOut | 		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); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -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> | ||||||
|  | @ -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> | ||||||
|  | @ -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> | ||||||
|  | @ -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> | ||||||
|  | @ -1,24 +1,79 @@ | ||||||
| import type { PageServerLoad } from './$types'; | import type { PageServerLoad } from './$types'; | ||||||
| import { superValidate } from 'sveltekit-superforms/server'; | import { superValidate } from 'sveltekit-superforms/server'; | ||||||
| import { profileFormSchema } from './profile-form.svelte'; |  | ||||||
| import { fail, type Actions } from '@sveltejs/kit'; | 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 () => { | export const load: PageServerLoad = async () => { | ||||||
| 	return { | 	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 = { | export const actions: Actions = { | ||||||
| 	default: async (event) => { | 	name: async ({ request, locals }: { request: Request; locals: App.Locals }) => { | ||||||
| 		const form = await superValidate(event, profileFormSchema); | 		const form = await superValidate(request, nameFormSchema); | ||||||
| 		if (!form.valid) { | 		if (!form.valid) { | ||||||
| 			return fail(400, { | 			return fail(400, { | ||||||
| 				form | 				form | ||||||
| 			}); | 			}); | ||||||
| 		} | 		} | ||||||
| 		return { | 		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 | 				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 }; | ||||||
| 	} | 	} | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | @ -1,10 +1,13 @@ | ||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
| 	import type { PageData } from './$types'; | 	import type { PageData } from './$types'; | ||||||
| 	import ProfileForm from './profile-form.svelte'; |  | ||||||
| 	import { Separator } from '$lib/components/ui/separator'; | 	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 data: PageData; | ||||||
| 	export let { form, user } = data; | 	let { forms, user } = data; | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <div class="space-y-6"> | <div class="space-y-6"> | ||||||
|  | @ -13,5 +16,19 @@ | ||||||
| 		<p class="text-sm text-muted-foreground">Update your account and profile settings.</p> | 		<p class="text-sm text-muted-foreground">Update your account and profile settings.</p> | ||||||
| 	</div> | 	</div> | ||||||
| 	<Separator /> | 	<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> | </div> | ||||||
|  |  | ||||||
|  | @ -12,7 +12,6 @@ export const load: PageServerLoad = async () => { | ||||||
| export const actions: Actions = { | export const actions: Actions = { | ||||||
| 	default: async ({ request, locals }: { request: Request; locals: App.Locals }) => { | 	default: async ({ request, locals }: { request: Request; locals: App.Locals }) => { | ||||||
| 		const form = await superValidate(request, appearanceFormSchema); | 		const form = await superValidate(request, appearanceFormSchema); | ||||||
| 		console.log('form: ', form); |  | ||||||
| 		if (!form.valid) { | 		if (!form.valid) { | ||||||
| 			return fail(400, { | 			return fail(400, { | ||||||
| 				form | 				form | ||||||
|  | @ -21,5 +20,6 @@ export const actions: Actions = { | ||||||
| 		await locals.pocketBase | 		await locals.pocketBase | ||||||
| 			.collection('users') | 			.collection('users') | ||||||
| 			.update(locals.id, { appearanceMode: form.data.theme }); | 			.update(locals.id, { appearanceMode: form.data.theme }); | ||||||
|  | 		return { form }; | ||||||
| 	} | 	} | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | @ -16,12 +16,28 @@ | ||||||
| 	import Label from '$lib/components/ui/label/label.svelte'; | 	import Label from '$lib/components/ui/label/label.svelte'; | ||||||
| 	import { dev } from '$app/environment'; | 	import { dev } from '$app/environment'; | ||||||
| 	import type { PageData } from '../$types'; | 	import type { PageData } from '../$types'; | ||||||
|  | 	import type { FormOptions } from 'formsnap'; | ||||||
|  | 	import { toast } from 'svelte-sonner'; | ||||||
|  | 	export let isLoading = false; | ||||||
| 	export let data: SuperValidated<AppearanceFormSchema>; | 	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']; | 	export let user: PageData['user']; | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <Form.Root | <Form.Root | ||||||
| 	schema={appearanceFormSchema} | 	schema={appearanceFormSchema} | ||||||
|  | 	{options} | ||||||
| 	form={data} | 	form={data} | ||||||
| 	class="space-y-8" | 	class="space-y-8" | ||||||
| 	method="POST" | 	method="POST" | ||||||
|  | @ -33,7 +49,11 @@ | ||||||
| 			<Form.Label>Theme</Form.Label> | 			<Form.Label>Theme</Form.Label> | ||||||
| 			<Form.Description>Select the theme for the dashboard.</Form.Description> | 			<Form.Description>Select the theme for the dashboard.</Form.Description> | ||||||
| 			<Form.Validation /> | 			<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"> | 				<Label for="light" class="[&:has([data-state=checked])>div]:border-primary"> | ||||||
| 					<Form.RadioItem id="light" value="light" class="sr-only" /> | 					<Form.RadioItem id="light" value="light" class="sr-only" /> | ||||||
| 					<div class="items-center rounded-md border-2 border-muted p-1 hover:border-accent"> | 					<div class="items-center rounded-md border-2 border-muted p-1 hover:border-accent"> | ||||||
|  | @ -101,5 +121,5 @@ | ||||||
| 			</Form.RadioGroup> | 			</Form.RadioGroup> | ||||||
| 		</Form.Field> | 		</Form.Field> | ||||||
| 	</Form.Item> | 	</Form.Item> | ||||||
| 	<Form.Button>Update preferences</Form.Button> | 	<Form.Button disabled={isLoading}>Update preferences</Form.Button> | ||||||
| </Form.Root> | </Form.Root> | ||||||
|  |  | ||||||
|  | @ -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> |  | ||||||
|  | @ -6,12 +6,13 @@ | ||||||
| 	import type { LayoutData } from './$types'; | 	import type { LayoutData } from './$types'; | ||||||
| 	import DataIndicator from '$lib/components/site/data-indicator.svelte'; | 	import DataIndicator from '$lib/components/site/data-indicator.svelte'; | ||||||
| 	import { fly } from 'svelte/transition'; | 	import { fly } from 'svelte/transition'; | ||||||
|  | 	import { Toaster } from 'svelte-sonner'; | ||||||
| 
 | 
 | ||||||
| 	export let data: LayoutData; | 	export let data: LayoutData; | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <ModeWatcher /> | <ModeWatcher /> | ||||||
| 
 | <Toaster /> | ||||||
| <Metadata /> | <Metadata /> | ||||||
| 
 | 
 | ||||||
| <div class="relative flex min-h-screen flex-col" id="page"> | <div class="relative flex min-h-screen flex-col" id="page"> | ||||||
|  |  | ||||||
							
								
								
									
										
											BIN
										
									
								
								apps/web/static/android-chrome-192x192.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 9.7 KiB | 
							
								
								
									
										
											BIN
										
									
								
								apps/web/static/android-chrome-512x512.png
									
										
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 23 KiB | 
| Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.4 KiB | 
| Before Width: | Height: | Size: 801 B After Width: | Height: | Size: 1.1 KiB | 
| Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 1.6 KiB | 
| Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB | 
|  | @ -1,7 +1 @@ | ||||||
| <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" | <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> | ||||||
|    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> |  | ||||||
| Before Width: | Height: | Size: 446 B After Width: | Height: | Size: 449 B | 
|  | @ -1,6 +1,6 @@ | ||||||
| { | { | ||||||
| 	"name": "", | 	"name": "Omnidash", | ||||||
| 	"short_name": "", | 	"short_name": "Omnidash", | ||||||
| 	"icons": [ | 	"icons": [ | ||||||
| 		{ | 		{ | ||||||
| 			"src": "/android-chrome-192x192.png", | 			"src": "/android-chrome-192x192.png", | ||||||
|  |  | ||||||