feat: added crud
|  | @ -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" | ||||
| 	} | ||||
|  |  | |||
							
								
								
									
										11
									
								
								apps/web/pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							
							
						
						|  | @ -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: | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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); | ||||
|  |  | |||
|  | @ -3,7 +3,7 @@ | |||
| 		topLeft: '#FF6C22', | ||||
| 		topRight: '#FF9209', | ||||
| 		bottomLeft: '#FFD099', | ||||
| 		bottomRight: '#2B3499' | ||||
| 		bottomRight: '#3B48D3' | ||||
| 	}; | ||||
| </script> | ||||
| 
 | ||||
|  |  | |||
|  | @ -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"> | ||||
|  |  | |||
|  | @ -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); | ||||
| 	}); | ||||
| 
 | ||||
|  |  | |||
|  | @ -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} /> | ||||
|  |  | |||
							
								
								
									
										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 | ||||
| 	}; | ||||
| }; | ||||
| 
 | ||||
| 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 { 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 }; | ||||
| 	} | ||||
| }; | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -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 }; | ||||
| 	} | ||||
| }; | ||||
|  |  | |||
|  | @ -15,5 +15,5 @@ | |||
| 		</p> | ||||
| 	</div> | ||||
| 	<Separator /> | ||||
| 	<AppearanceForm data={form} {user}  /> | ||||
| 	<AppearanceForm data={form} {user} /> | ||||
| </div> | ||||
|  |  | |||
|  | @ -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> | ||||
|  |  | |||
|  | @ -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 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"> | ||||
|  |  | |||
							
								
								
									
										
											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" | ||||
|    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> | ||||
| Before Width: | Height: | Size: 446 B After Width: | Height: | Size: 449 B | 
|  | @ -1,6 +1,6 @@ | |||
| { | ||||
| 	"name": "", | ||||
| 	"short_name": "", | ||||
| 	"name": "Omnidash", | ||||
| 	"short_name": "Omnidash", | ||||
| 	"icons": [ | ||||
| 		{ | ||||
| 			"src": "/android-chrome-192x192.png", | ||||
|  |  | |||