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"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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",
|
||||||
|
|