mirror of
https://github.com/bartvdbraak/omnidash.git
synced 2025-05-02 18:01:20 +00:00
Merge pull request #221 from bartvdbraak/auth-forms-refactor
Refactor and add new features
This commit is contained in:
commit
1585e4370f
88 changed files with 2325 additions and 1787 deletions
.github/workflows
package.jsonpnpm-lock.yamlsrc
lib
components
site
ui
button
calendar
calendar-cell.sveltecalendar-day.sveltecalendar-grid-body.sveltecalendar-grid-head.sveltecalendar-grid-row.sveltecalendar-grid.sveltecalendar-head-cell.sveltecalendar-header.sveltecalendar-heading.sveltecalendar-months.sveltecalendar-next-button.sveltecalendar-prev-button.sveltecalendar.svelteindex.ts
form
form-button.svelteform-checkbox.svelteform-description.svelteform-element-field.svelteform-field-errors.svelteform-field.svelteform-fieldset.svelteform-input.svelteform-label.svelteform-legend.svelteform-native-select.svelteform-radio-group.svelteform-select-trigger.svelteform-select.svelteform-switch.svelteform-textarea.svelteform-validation.svelteindex.ts
config
routes
(auth)/auth
(dashboard)/settings
(user)
+layout.server.ts
+layout.sveltedashboard
(components)
data-table-checkbox.sveltedata-table-column-header.sveltedata-table-faceted-filter.sveltedata-table-pagination.sveltedata-table-priority-cell.sveltedata-table-row-actions.sveltedata-table-status-cell.sveltedata-table-title-cell.sveltedata-table-toolbar.sveltedata-table-view-options.sveltedata-table.svelteindex.ts
(data)
+layout.svelte+page.sveltesettings
2
.github/workflows/unlighthouse.yaml
vendored
2
.github/workflows/unlighthouse.yaml
vendored
|
@ -79,7 +79,7 @@ jobs:
|
|||
- name: Run Unlighthouse
|
||||
run: |
|
||||
export SCAN_URL="${{ github.ref == 'refs/heads/main' && env.WEBSITE_URL || steps.vercel_preview_url.outputs.preview_url }}"
|
||||
export AUTH_COOKIE="$(curl "https://$SCAN_URL/auth?/login" -H "Origin: https://$SCAN_URL" -F "email=test_user" -F "password=${{ secrets.TEST_USER_PASSWORD }}" --verbose 2>&1 | awk -F'pb_auth=' '/pb_auth/{print $2;exit}' | awk -F';' '{print $1}')"
|
||||
export AUTH_COOKIE="$(curl "https://$SCAN_URL/auth?/login" -H "Origin: https://$SCAN_URL" -F "usernameEmail=test_user" -F "password=${{ secrets.TEST_USER_PASSWORD }}" --verbose 2>&1 | awk -F'pb_auth=' '/pb_auth/{print $2;exit}' | awk -F';' '{print $1}')"
|
||||
|
||||
pnpm run unlighthouse
|
||||
|
||||
|
|
51
package.json
51
package.json
|
@ -13,43 +13,44 @@
|
|||
"unlighthouse": "unlighthouse-ci"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
"@sveltejs/kit": "^2.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
||||
"@sveltejs/adapter-auto": "^3.1.1",
|
||||
"@sveltejs/kit": "^2.5.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.2",
|
||||
"@types/eslint": "8.56.2",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.2",
|
||||
"@typescript-eslint/parser": "^7.0.2",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"clsx": "^2.1.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.35.1",
|
||||
"postcss": "^8.4.32",
|
||||
"postcss-load-config": "^5.0.2",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
"prettier-plugin-tailwindcss": "^0.5.9",
|
||||
"svelte": "^4.2.7",
|
||||
"svelte-check": "^3.6.0",
|
||||
"sveltekit-superforms": "^2.0.0",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.0.0",
|
||||
"formsnap": "^0.5.0",
|
||||
"postcss": "^8.4.35",
|
||||
"postcss-load-config": "^5.0.3",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-svelte": "^3.2.1",
|
||||
"prettier-plugin-tailwindcss": "^0.5.11",
|
||||
"svelte": "^4.2.11",
|
||||
"svelte-check": "^3.6.4",
|
||||
"sveltekit-superforms": "^2.3.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tslib": "^2.6.2",
|
||||
"typescript": "^5.3.3",
|
||||
"unlighthouse": "^0.10.6",
|
||||
"vite": "^5.0.3",
|
||||
"vite": "^5.1.3",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"bits-ui": "^0.18.0",
|
||||
"clsx": "^2.1.0",
|
||||
"@internationalized/date": "^3.5.2",
|
||||
"bits-ui": "^0.18.1",
|
||||
"cmdk-sv": "^0.0.13",
|
||||
"formsnap": "^0.4.3",
|
||||
"lucide-svelte": "^0.334.0",
|
||||
"mode-watcher": "^0.2.0",
|
||||
"pocketbase": "^0.21.0",
|
||||
"mode-watcher": "^0.2.1",
|
||||
"pocketbase": "^0.21.1",
|
||||
"radix-icons-svelte": "^1.2.1",
|
||||
"svelte-headless-table": "^0.18.1",
|
||||
"svelte-sonner": "^0.3.17",
|
||||
"svelte-headless-table": "^0.18.2",
|
||||
"svelte-sonner": "^0.3.9",
|
||||
"tailwind-merge": "^2.2.1",
|
||||
"tailwind-variants": "^0.2.0"
|
||||
}
|
||||
|
|
1088
pnpm-lock.yaml
1088
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
@ -34,8 +34,9 @@
|
|||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.Item href="/settings">Profile</DropdownMenu.Item>
|
||||
<DropdownMenu.Item href="/settings/account">Account</DropdownMenu.Item>
|
||||
<DropdownMenu.Item href="/settings/appearance">Appearance</DropdownMenu.Item>
|
||||
<DropdownMenu.Item href="/settings/notifications">Notifications</DropdownMenu.Item>
|
||||
<!-- <DropdownMenu.Item href="/settings/notifications">Notifications</DropdownMenu.Item> -->
|
||||
</DropdownMenu.Group>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item href="/logout">
|
||||
|
|
|
@ -9,7 +9,7 @@ const buttonVariants = tv({
|
|||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline'
|
||||
|
|
21
src/lib/components/ui/calendar/calendar-cell.svelte
Normal file
21
src/lib/components/ui/calendar/calendar-cell.svelte
Normal file
|
@ -0,0 +1,21 @@
|
|||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
type $$Props = CalendarPrimitive.CellProps;
|
||||
|
||||
export let date: $$Props['date'];
|
||||
let className: $$Props['class'] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Cell
|
||||
{date}
|
||||
class={cn(
|
||||
'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([data-selected])]:rounded-md [&:has([data-selected])]:bg-accent [&:has([data-selected][data-outside-month])]:bg-accent/50',
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</CalendarPrimitive.Cell>
|
43
src/lib/components/ui/calendar/calendar-day.svelte
Normal file
43
src/lib/components/ui/calendar/calendar-day.svelte
Normal file
|
@ -0,0 +1,43 @@
|
|||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||
import { buttonVariants } from '$lib/components/ui/button';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
type $$Props = CalendarPrimitive.DayProps;
|
||||
type $$Events = CalendarPrimitive.DayEvents;
|
||||
|
||||
export let date: $$Props['date'];
|
||||
export let month: $$Props['month'];
|
||||
let className: $$Props['class'] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Day
|
||||
on:click
|
||||
{date}
|
||||
{month}
|
||||
class={cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'h-8 w-8 p-0 font-normal',
|
||||
// Today
|
||||
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
|
||||
// Selected
|
||||
'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:opacity-100 data-[selected]:hover:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground',
|
||||
// Disabled
|
||||
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
|
||||
// Unavailable
|
||||
'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through',
|
||||
// Outside months
|
||||
'data-[outside-month]:pointer-events-none data-[outside-month]:text-muted-foreground data-[outside-month]:opacity-50 [&[data-outside-month][data-selected]]:bg-accent/50 [&[data-outside-month][data-selected]]:text-muted-foreground [&[data-outside-month][data-selected]]:opacity-30',
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
let:selected
|
||||
let:disabled
|
||||
let:unavailable
|
||||
let:builder
|
||||
>
|
||||
<slot {selected} {disabled} {unavailable} {builder}>
|
||||
{date.day}
|
||||
</slot>
|
||||
</CalendarPrimitive.Day>
|
13
src/lib/components/ui/calendar/calendar-grid-body.svelte
Normal file
13
src/lib/components/ui/calendar/calendar-grid-body.svelte
Normal file
|
@ -0,0 +1,13 @@
|
|||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
type $$Props = CalendarPrimitive.GridBodyProps;
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.GridBody class={cn(className)} {...$$restProps}>
|
||||
<slot />
|
||||
</CalendarPrimitive.GridBody>
|
13
src/lib/components/ui/calendar/calendar-grid-head.svelte
Normal file
13
src/lib/components/ui/calendar/calendar-grid-head.svelte
Normal file
|
@ -0,0 +1,13 @@
|
|||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
type $$Props = CalendarPrimitive.GridHeadProps;
|
||||
|
||||
let className: string | undefined | null = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.GridHead class={cn(className)} {...$$restProps}>
|
||||
<slot />
|
||||
</CalendarPrimitive.GridHead>
|
13
src/lib/components/ui/calendar/calendar-grid-row.svelte
Normal file
13
src/lib/components/ui/calendar/calendar-grid-row.svelte
Normal file
|
@ -0,0 +1,13 @@
|
|||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
type $$Props = CalendarPrimitive.GridRowProps;
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.GridRow class={cn('flex', className)} {...$$restProps}>
|
||||
<slot />
|
||||
</CalendarPrimitive.GridRow>
|
13
src/lib/components/ui/calendar/calendar-grid.svelte
Normal file
13
src/lib/components/ui/calendar/calendar-grid.svelte
Normal file
|
@ -0,0 +1,13 @@
|
|||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
type $$Props = CalendarPrimitive.GridProps;
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Grid class={cn('w-full border-collapse space-y-1', className)} {...$$restProps}>
|
||||
<slot />
|
||||
</CalendarPrimitive.Grid>
|
16
src/lib/components/ui/calendar/calendar-head-cell.svelte
Normal file
16
src/lib/components/ui/calendar/calendar-head-cell.svelte
Normal file
|
@ -0,0 +1,16 @@
|
|||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
type $$Props = CalendarPrimitive.HeadCellProps;
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.HeadCell
|
||||
class={cn('w-8 rounded-md text-[0.8rem] font-normal text-muted-foreground', className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</CalendarPrimitive.HeadCell>
|
16
src/lib/components/ui/calendar/calendar-header.svelte
Normal file
16
src/lib/components/ui/calendar/calendar-header.svelte
Normal file
|
@ -0,0 +1,16 @@
|
|||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
type $$Props = CalendarPrimitive.HeaderProps;
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Header
|
||||
class={cn('relative flex w-full items-center justify-between pt-1', className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</CalendarPrimitive.Header>
|
19
src/lib/components/ui/calendar/calendar-heading.svelte
Normal file
19
src/lib/components/ui/calendar/calendar-heading.svelte
Normal file
|
@ -0,0 +1,19 @@
|
|||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
type $$Props = CalendarPrimitive.HeadingProps;
|
||||
|
||||
let className: string | undefined | null = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Heading
|
||||
let:headingValue
|
||||
class={cn('text-sm font-medium', className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot {headingValue}>
|
||||
{headingValue}
|
||||
</slot>
|
||||
</CalendarPrimitive.Heading>
|
|
@ -3,10 +3,14 @@
|
|||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
type $$Props = HTMLAttributes<HTMLDivElement>;
|
||||
let className: string | undefined | null = undefined;
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<div class={cn('space-y-2', className)} {...$$restProps}>
|
||||
<div
|
||||
class={cn('mt-4 flex flex-col space-y-4 sm:flex-row sm:space-x-4 sm:space-y-0', className)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</div>
|
27
src/lib/components/ui/calendar/calendar-next-button.svelte
Normal file
27
src/lib/components/ui/calendar/calendar-next-button.svelte
Normal file
|
@ -0,0 +1,27 @@
|
|||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||
import { ChevronRight } from 'radix-icons-svelte';
|
||||
import { buttonVariants } from '$lib/components/ui/button';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
type $$Props = CalendarPrimitive.NextButtonProps;
|
||||
type $$Events = CalendarPrimitive.NextButtonEvents;
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.NextButton
|
||||
on:click
|
||||
class={cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
let:builder
|
||||
>
|
||||
<slot {builder}>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</slot>
|
||||
</CalendarPrimitive.NextButton>
|
27
src/lib/components/ui/calendar/calendar-prev-button.svelte
Normal file
27
src/lib/components/ui/calendar/calendar-prev-button.svelte
Normal file
|
@ -0,0 +1,27 @@
|
|||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||
import { ChevronLeft } from 'radix-icons-svelte';
|
||||
import { buttonVariants } from '$lib/components/ui/button';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
type $$Props = CalendarPrimitive.PrevButtonProps;
|
||||
type $$Events = CalendarPrimitive.PrevButtonEvents;
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.PrevButton
|
||||
on:click
|
||||
class={cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
let:builder
|
||||
>
|
||||
<slot {builder}>
|
||||
<ChevronLeft class="h-4 w-4" />
|
||||
</slot>
|
||||
</CalendarPrimitive.PrevButton>
|
58
src/lib/components/ui/calendar/calendar.svelte
Normal file
58
src/lib/components/ui/calendar/calendar.svelte
Normal file
|
@ -0,0 +1,58 @@
|
|||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||
import * as Calendar from '.';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
type $$Props = CalendarPrimitive.Props;
|
||||
type $$Events = CalendarPrimitive.Events;
|
||||
|
||||
export let value: $$Props['value'] = undefined;
|
||||
export let placeholder: $$Props['placeholder'] = undefined;
|
||||
export let weekdayFormat: $$Props['weekdayFormat'] = 'short';
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Root
|
||||
bind:value
|
||||
bind:placeholder
|
||||
{weekdayFormat}
|
||||
class={cn('p-3', className)}
|
||||
{...$$restProps}
|
||||
on:keydown
|
||||
let:months
|
||||
let:weekdays
|
||||
>
|
||||
<Calendar.Header>
|
||||
<Calendar.PrevButton />
|
||||
<Calendar.Heading />
|
||||
<Calendar.NextButton />
|
||||
</Calendar.Header>
|
||||
<Calendar.Months>
|
||||
{#each months as month}
|
||||
<Calendar.Grid>
|
||||
<Calendar.GridHead>
|
||||
<Calendar.GridRow class="flex">
|
||||
{#each weekdays as weekday}
|
||||
<Calendar.HeadCell>
|
||||
{weekday.slice(0, 2)}
|
||||
</Calendar.HeadCell>
|
||||
{/each}
|
||||
</Calendar.GridRow>
|
||||
</Calendar.GridHead>
|
||||
<Calendar.GridBody>
|
||||
{#each month.weeks as weekDates}
|
||||
<Calendar.GridRow class="mt-2 w-full">
|
||||
{#each weekDates as date}
|
||||
<Calendar.Cell {date}>
|
||||
<Calendar.Day {date} month={month.value} />
|
||||
</Calendar.Cell>
|
||||
{/each}
|
||||
</Calendar.GridRow>
|
||||
{/each}
|
||||
</Calendar.GridBody>
|
||||
</Calendar.Grid>
|
||||
{/each}
|
||||
</Calendar.Months>
|
||||
</CalendarPrimitive.Root>
|
30
src/lib/components/ui/calendar/index.ts
Normal file
30
src/lib/components/ui/calendar/index.ts
Normal file
|
@ -0,0 +1,30 @@
|
|||
import Root from './calendar.svelte';
|
||||
import Cell from './calendar-cell.svelte';
|
||||
import Day from './calendar-day.svelte';
|
||||
import Grid from './calendar-grid.svelte';
|
||||
import Header from './calendar-header.svelte';
|
||||
import Months from './calendar-months.svelte';
|
||||
import GridRow from './calendar-grid-row.svelte';
|
||||
import Heading from './calendar-heading.svelte';
|
||||
import GridBody from './calendar-grid-body.svelte';
|
||||
import GridHead from './calendar-grid-head.svelte';
|
||||
import HeadCell from './calendar-head-cell.svelte';
|
||||
import NextButton from './calendar-next-button.svelte';
|
||||
import PrevButton from './calendar-prev-button.svelte';
|
||||
|
||||
export {
|
||||
Day,
|
||||
Cell,
|
||||
Grid,
|
||||
Header,
|
||||
Months,
|
||||
GridRow,
|
||||
Heading,
|
||||
GridBody,
|
||||
GridHead,
|
||||
HeadCell,
|
||||
NextButton,
|
||||
PrevButton,
|
||||
//
|
||||
Root as Calendar
|
||||
};
|
|
@ -1,9 +1,9 @@
|
|||
<script lang="ts">
|
||||
import * as Button from '$lib/components/ui/button';
|
||||
|
||||
type $$Props = Button.Props;
|
||||
type $$Events = Button.Events;
|
||||
</script>
|
||||
|
||||
<Button.Root type="submit" {...$$restProps} on:click on:keydown>
|
||||
<Button.Root type="submit" {...$$restProps}>
|
||||
<slot />
|
||||
</Button.Root>
|
||||
|
|
|
@ -1,26 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { getFormField } from 'formsnap';
|
||||
import type { Checkbox as CheckboxPrimitive } from 'bits-ui';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
type $$Props = CheckboxPrimitive.Props;
|
||||
type $$Events = CheckboxPrimitive.Events;
|
||||
|
||||
export let onCheckedChange: $$Props['onCheckedChange'] = undefined;
|
||||
|
||||
const { name, setValue, attrStore, value } = getFormField();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { name: nameAttr, value: valueAttr, ...rest } = $attrStore;
|
||||
</script>
|
||||
|
||||
<Checkbox
|
||||
{...rest}
|
||||
checked={typeof $value === 'boolean' ? $value : false}
|
||||
onCheckedChange={(v) => {
|
||||
onCheckedChange?.(v);
|
||||
setValue(v);
|
||||
}}
|
||||
{...$$restProps}
|
||||
on:click
|
||||
on:keydown
|
||||
/>
|
||||
<input hidden {name} value={$value} />
|
|
@ -1,16 +1,16 @@
|
|||
<script lang="ts">
|
||||
import { Form as FormPrimitive } from 'formsnap';
|
||||
import * as FormPrimitive from 'formsnap';
|
||||
import { cn } from '$lib/utils';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
type $$Props = HTMLAttributes<HTMLSpanElement>;
|
||||
let className: string | undefined | null = undefined;
|
||||
type $$Props = FormPrimitive.DescriptionProps;
|
||||
let className: $$Props['class'] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<FormPrimitive.Description
|
||||
class={cn('text-[0.8rem] text-muted-foreground', className)}
|
||||
{...$$restProps}
|
||||
let:descriptionAttrs
|
||||
>
|
||||
<slot />
|
||||
<slot {descriptionAttrs} />
|
||||
</FormPrimitive.Description>
|
||||
|
|
26
src/lib/components/ui/form/form-element-field.svelte
Normal file
26
src/lib/components/ui/form/form-element-field.svelte
Normal file
|
@ -0,0 +1,26 @@
|
|||
<script lang="ts" context="module">
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import type { FormPathLeaves, SuperForm } from 'sveltekit-superforms';
|
||||
type T = Record<string, unknown>;
|
||||
type U = unknown;
|
||||
</script>
|
||||
|
||||
<script lang="ts" generics="T extends Record<string, unknown>, U extends FormPathLeaves<T>">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import * as FormPrimitive from 'formsnap';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
type $$Props = FormPrimitive.ElementFieldProps<T, U> & HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export let form: SuperForm<T>;
|
||||
export let name: U;
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<FormPrimitive.ElementField {form} {name} let:constraints let:errors let:tainted let:value>
|
||||
<div class={cn('space-y-2', className)}>
|
||||
<slot {constraints} {errors} {tainted} {value} />
|
||||
</div>
|
||||
</FormPrimitive.ElementField>
|
26
src/lib/components/ui/form/form-field-errors.svelte
Normal file
26
src/lib/components/ui/form/form-field-errors.svelte
Normal file
|
@ -0,0 +1,26 @@
|
|||
<script lang="ts">
|
||||
import * as FormPrimitive from 'formsnap';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
type $$Props = FormPrimitive.FieldErrorsProps & {
|
||||
errorClasses?: string | undefined | null;
|
||||
};
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
export { className as class };
|
||||
export let errorClasses: $$Props['class'] = undefined;
|
||||
</script>
|
||||
|
||||
<FormPrimitive.FieldErrors
|
||||
class={cn('text-[0.8rem] font-medium text-destructive', className)}
|
||||
{...$$restProps}
|
||||
let:errors
|
||||
let:fieldErrorsAttrs
|
||||
let:errorAttrs
|
||||
>
|
||||
<slot {errors} {fieldErrorsAttrs} {errorAttrs}>
|
||||
{#each errors as error}
|
||||
<div {...errorAttrs} class={cn(errorClasses)}>{error}</div>
|
||||
{/each}
|
||||
</slot>
|
||||
</FormPrimitive.FieldErrors>
|
26
src/lib/components/ui/form/form-field.svelte
Normal file
26
src/lib/components/ui/form/form-field.svelte
Normal file
|
@ -0,0 +1,26 @@
|
|||
<script lang="ts" context="module">
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import type { FormPath, SuperForm } from 'sveltekit-superforms';
|
||||
type T = Record<string, unknown>;
|
||||
type U = unknown;
|
||||
</script>
|
||||
|
||||
<script lang="ts" generics="T extends Record<string, unknown>, U extends FormPath<T>">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import * as FormPrimitive from 'formsnap';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
type $$Props = FormPrimitive.FieldProps<T, U> & HTMLAttributes<HTMLElement>;
|
||||
|
||||
export let form: SuperForm<T>;
|
||||
export let name: U;
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<FormPrimitive.Field {form} {name} let:constraints let:errors let:tainted let:value>
|
||||
<div class={cn('space-y-2', className)}>
|
||||
<slot {constraints} {errors} {tainted} {value} />
|
||||
</div>
|
||||
</FormPrimitive.Field>
|
31
src/lib/components/ui/form/form-fieldset.svelte
Normal file
31
src/lib/components/ui/form/form-fieldset.svelte
Normal file
|
@ -0,0 +1,31 @@
|
|||
<script lang="ts" context="module">
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import type { FormPath, SuperForm } from 'sveltekit-superforms';
|
||||
type T = Record<string, unknown>;
|
||||
type U = unknown;
|
||||
</script>
|
||||
|
||||
<script lang="ts" generics="T extends Record<string, unknown>, U extends FormPath<T>">
|
||||
import * as FormPrimitive from 'formsnap';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
type $$Props = FormPrimitive.FieldsetProps<T, U>;
|
||||
|
||||
export let form: SuperForm<T>;
|
||||
export let name: U;
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<FormPrimitive.Fieldset
|
||||
{form}
|
||||
{name}
|
||||
let:constraints
|
||||
let:errors
|
||||
let:tainted
|
||||
let:value
|
||||
class={cn('space-y-2', className)}
|
||||
>
|
||||
<slot {constraints} {errors} {tainted} {value} />
|
||||
</FormPrimitive.Fieldset>
|
|
@ -1,28 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { getFormField } from 'formsnap';
|
||||
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||
import { Input, type InputEvents } from '$lib/components/ui/input';
|
||||
|
||||
type $$Props = HTMLInputAttributes;
|
||||
type $$Events = InputEvents;
|
||||
|
||||
const { attrStore, value } = getFormField();
|
||||
</script>
|
||||
|
||||
<Input
|
||||
{...$attrStore}
|
||||
bind:value={$value}
|
||||
{...$$restProps}
|
||||
on:blur
|
||||
on:change
|
||||
on:click
|
||||
on:focus
|
||||
on:keydown
|
||||
on:keypress
|
||||
on:keyup
|
||||
on:mouseover
|
||||
on:mouseenter
|
||||
on:mouseleave
|
||||
on:paste
|
||||
on:input
|
||||
/>
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import type { Label as LabelPrimitive } from 'bits-ui';
|
||||
import { getFormField } from 'formsnap';
|
||||
import { getFormControl } from 'formsnap';
|
||||
import { cn } from '$lib/utils';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
|
||||
|
@ -9,9 +9,9 @@
|
|||
let className: $$Props['class'] = undefined;
|
||||
export { className as class };
|
||||
|
||||
const { errors, ids } = getFormField();
|
||||
const { labelAttrs } = getFormControl();
|
||||
</script>
|
||||
|
||||
<Label for={$ids.input} class={cn($errors && 'text-destructive', className)} {...$$restProps}>
|
||||
<slot />
|
||||
<Label {...$labelAttrs} class={cn('data-[fs-error]:text-destructive', className)} {...$$restProps}>
|
||||
<slot {labelAttrs} />
|
||||
</Label>
|
||||
|
|
17
src/lib/components/ui/form/form-legend.svelte
Normal file
17
src/lib/components/ui/form/form-legend.svelte
Normal file
|
@ -0,0 +1,17 @@
|
|||
<script lang="ts">
|
||||
import * as FormPrimitive from 'formsnap';
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
type $$Props = FormPrimitive.LegendProps;
|
||||
|
||||
let className: $$Props['class'] = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<FormPrimitive.Legend
|
||||
{...$$restProps}
|
||||
class={cn('text-sm font-medium leading-none data-[fs-error]:text-destructive', className)}
|
||||
let:legendAttrs
|
||||
>
|
||||
<slot {legendAttrs} />
|
||||
</FormPrimitive.Legend>
|
|
@ -1,26 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { Form as FormPrimitive } from 'formsnap';
|
||||
import { buttonVariants } from '$lib/components/ui/button';
|
||||
import { cn } from '$lib/utils';
|
||||
import { CaretSort } from 'radix-icons-svelte';
|
||||
import type { HTMLSelectAttributes } from 'svelte/elements';
|
||||
|
||||
type $$Props = HTMLSelectAttributes;
|
||||
|
||||
let className: string | undefined | null = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<div class="relative">
|
||||
<FormPrimitive.Select
|
||||
class={cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'appearance-none bg-transparent font-normal',
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
</FormPrimitive.Select>
|
||||
<CaretSort class="absolute right-3 top-2.5 h-4 w-4 opacity-50" />
|
||||
</div>
|
|
@ -1,22 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { getFormField } from 'formsnap';
|
||||
import type { RadioGroup as RadioGroupPrimitive } from 'bits-ui';
|
||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||
|
||||
type $$Props = RadioGroupPrimitive.Props;
|
||||
const { attrStore, setValue, name, value } = getFormField();
|
||||
|
||||
export let onValueChange: $$Props['onValueChange'] = undefined;
|
||||
</script>
|
||||
|
||||
<RadioGroup.Root
|
||||
{...$attrStore}
|
||||
onValueChange={(v) => {
|
||||
onValueChange?.(v);
|
||||
setValue(v);
|
||||
}}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
<input hidden {name} value={$value} />
|
||||
</RadioGroup.Root>
|
|
@ -1,18 +0,0 @@
|
|||
<script lang="ts">
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import type { Select as SelectPrimitive } from 'bits-ui';
|
||||
import { getFormField } from 'formsnap';
|
||||
|
||||
type $$Props = SelectPrimitive.TriggerProps & {
|
||||
placeholder?: string;
|
||||
};
|
||||
type $$Events = SelectPrimitive.TriggerEvents;
|
||||
const { attrStore, value } = getFormField();
|
||||
export let placeholder = '';
|
||||
</script>
|
||||
|
||||
<Select.Trigger {...$$restProps} {...$attrStore} on:click on:keydown>
|
||||
<slot value={$value}>
|
||||
<Select.Value {placeholder} />
|
||||
</slot>
|
||||
</Select.Trigger>
|
|
@ -1,20 +0,0 @@
|
|||
<script lang="ts">
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { getFormField } from 'formsnap';
|
||||
import type { Select as SelectPrimitive } from 'bits-ui';
|
||||
|
||||
type $$Props = SelectPrimitive.Props<unknown>;
|
||||
const { setValue, name, value } = getFormField();
|
||||
export let onSelectedChange: $$Props['onSelectedChange'] = undefined;
|
||||
</script>
|
||||
|
||||
<Select.Root
|
||||
onSelectedChange={(v) => {
|
||||
onSelectedChange?.(v);
|
||||
setValue(v ? v.value : undefined);
|
||||
}}
|
||||
{...$$restProps}
|
||||
>
|
||||
<slot />
|
||||
<input hidden {name} value={$value} />
|
||||
</Select.Root>
|
|
@ -1,24 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { getFormField } from 'formsnap';
|
||||
import type { Switch as SwitchPrimitive } from 'bits-ui';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
type $$Props = SwitchPrimitive.Props;
|
||||
type $$Events = SwitchPrimitive.Events;
|
||||
|
||||
export let onCheckedChange: $$Props['onCheckedChange'] = undefined;
|
||||
|
||||
const { name, setValue, attrStore, value } = getFormField();
|
||||
</script>
|
||||
|
||||
<Switch
|
||||
{...$attrStore}
|
||||
checked={typeof $value === 'boolean' ? $value : false}
|
||||
onCheckedChange={(v) => {
|
||||
onCheckedChange?.(v);
|
||||
setValue(v);
|
||||
}}
|
||||
{...$$restProps}
|
||||
on:click
|
||||
on:keydown
|
||||
/>
|
||||
<input hidden {name} value={$value} />
|
|
@ -1,29 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { getFormField } from 'formsnap';
|
||||
import type { HTMLTextareaAttributes } from 'svelte/elements';
|
||||
import type { TextareaGetFormField } from '.';
|
||||
import { Textarea, type TextareaEvents } from '$lib/components/ui/textarea';
|
||||
|
||||
type $$Props = HTMLTextareaAttributes;
|
||||
type $$Events = TextareaEvents;
|
||||
|
||||
const { attrStore, value } = getFormField() as TextareaGetFormField;
|
||||
</script>
|
||||
|
||||
<Textarea
|
||||
{...$attrStore}
|
||||
bind:value={$value}
|
||||
{...$$restProps}
|
||||
on:blur
|
||||
on:change
|
||||
on:click
|
||||
on:focus
|
||||
on:keydown
|
||||
on:keypress
|
||||
on:keyup
|
||||
on:mouseover
|
||||
on:mouseenter
|
||||
on:mouseleave
|
||||
on:paste
|
||||
on:input
|
||||
/>
|
|
@ -1,14 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { Form as FormPrimitive } from 'formsnap';
|
||||
import { cn } from '$lib/utils';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
type $$Props = HTMLAttributes<HTMLParagraphElement>;
|
||||
let className: string | undefined | null = undefined;
|
||||
export { className as class };
|
||||
</script>
|
||||
|
||||
<FormPrimitive.Validation
|
||||
class={cn('text-[0.8rem] font-medium text-destructive', className)}
|
||||
{...$$restProps}
|
||||
/>
|
|
@ -1,82 +1,33 @@
|
|||
import { Form as FormPrimitive, getFormField } from 'formsnap';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import * as RadioGroupComp from '$lib/components/ui/radio-group';
|
||||
import * as SelectComp from '$lib/components/ui/select';
|
||||
import Item from './form-item.svelte';
|
||||
import Input from './form-input.svelte';
|
||||
import Textarea from './form-textarea.svelte';
|
||||
import * as FormPrimitive from 'formsnap';
|
||||
import Description from './form-description.svelte';
|
||||
import Label from './form-label.svelte';
|
||||
import Validation from './form-validation.svelte';
|
||||
import Checkbox from './form-checkbox.svelte';
|
||||
import Switch from './form-switch.svelte';
|
||||
import NativeSelect from './form-native-select.svelte';
|
||||
import RadioGroup from './form-radio-group.svelte';
|
||||
import Select from './form-select.svelte';
|
||||
import SelectTrigger from './form-select-trigger.svelte';
|
||||
import FieldErrors from './form-field-errors.svelte';
|
||||
import Field from './form-field.svelte';
|
||||
import Button from './form-button.svelte';
|
||||
import Fieldset from './form-fieldset.svelte';
|
||||
import Legend from './form-legend.svelte';
|
||||
import ElementField from './form-element-field.svelte';
|
||||
|
||||
const Root = FormPrimitive.Root;
|
||||
const Field = FormPrimitive.Field;
|
||||
const Control = FormPrimitive.Control;
|
||||
const RadioItem = RadioGroupComp.Item;
|
||||
const NativeRadio = FormPrimitive.Radio;
|
||||
const SelectContent = SelectComp.Content;
|
||||
const SelectLabel = SelectComp.Label;
|
||||
const SelectGroup = SelectComp.Group;
|
||||
const SelectItem = SelectComp.Item;
|
||||
const SelectSeparator = SelectComp.Separator;
|
||||
|
||||
export type TextareaGetFormField = Omit<ReturnType<typeof getFormField>, 'value'> & {
|
||||
value: Writable<string>;
|
||||
};
|
||||
|
||||
export {
|
||||
Root,
|
||||
Field,
|
||||
Control,
|
||||
Item,
|
||||
Input,
|
||||
Label,
|
||||
Button,
|
||||
Switch,
|
||||
Select,
|
||||
Checkbox,
|
||||
Textarea,
|
||||
Validation,
|
||||
RadioGroup,
|
||||
RadioItem,
|
||||
FieldErrors,
|
||||
Description,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
NativeSelect,
|
||||
NativeRadio,
|
||||
Fieldset,
|
||||
Legend,
|
||||
ElementField,
|
||||
Button,
|
||||
//
|
||||
Root as Form,
|
||||
Field as FormField,
|
||||
Control as FormControl,
|
||||
Item as FormItem,
|
||||
Input as FormInput,
|
||||
Textarea as FormTextarea,
|
||||
Description as FormDescription,
|
||||
Label as FormLabel,
|
||||
Validation as FormValidation,
|
||||
NativeSelect as FormNativeSelect,
|
||||
NativeRadio as FormNativeRadio,
|
||||
Checkbox as FormCheckbox,
|
||||
Switch as FormSwitch,
|
||||
RadioGroup as FormRadioGroup,
|
||||
RadioItem as FormRadioItem,
|
||||
Select as FormSelect,
|
||||
SelectContent as FormSelectContent,
|
||||
SelectLabel as FormSelectLabel,
|
||||
SelectGroup as FormSelectGroup,
|
||||
SelectItem as FormSelectItem,
|
||||
SelectSeparator as FormSelectSeparator,
|
||||
SelectTrigger as FormSelectTrigger,
|
||||
FieldErrors as FormFieldErrors,
|
||||
Fieldset as FormFieldset,
|
||||
Legend as FormLegend,
|
||||
ElementField as FormElementField,
|
||||
Button as FormButton
|
||||
};
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
const SITE_URL =
|
||||
import.meta.env.VERCEL_ENV === 'preview' ? import.meta.env.VERCEL_URL : 'omnidash.io';
|
||||
|
||||
export const debugForms = import.meta.env.DEBUG_FORMS === 'true' || false;
|
||||
|
||||
export const siteConfig = {
|
||||
name: 'Omnidash',
|
||||
author: 'Bart van der Braak',
|
||||
|
|
3
src/routes/(auth)/auth/(components)/index.ts
Normal file
3
src/routes/(auth)/auth/(components)/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export { default as LoginForm } from './login-form.svelte';
|
||||
export { default as RegisterForm } from './register-form.svelte';
|
||||
export { default as SSOForm } from './sso-form.svelte';
|
80
src/routes/(auth)/auth/(components)/login-form.svelte
Normal file
80
src/routes/(auth)/auth/(components)/login-form.svelte
Normal file
|
@ -0,0 +1,80 @@
|
|||
<script lang="ts" context="module">
|
||||
import { z } from 'zod';
|
||||
export const loginFormSchema = z.object({
|
||||
usernameEmail: z.string(),
|
||||
password: z.string()
|
||||
});
|
||||
export type LoginFormSchema = typeof loginFormSchema;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import * as Form from '$lib/components/ui/form';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { type SuperValidated, type Infer, superForm } from 'sveltekit-superforms';
|
||||
import SuperDebug from 'sveltekit-superforms';
|
||||
import { zodClient } from 'sveltekit-superforms/adapters';
|
||||
import { browser, dev } from '$app/environment';
|
||||
// import { PUBLIC_DEBUG_FORMS } from '$env/static/public';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { Icons } from '$lib/components/site';
|
||||
import { cn } from '$lib/utils';
|
||||
import { debugForms } from '$lib/config/site';
|
||||
|
||||
export let data: SuperValidated<Infer<LoginFormSchema>>;
|
||||
let isLoading = false;
|
||||
|
||||
const form = superForm(data, {
|
||||
validators: zodClient(loginFormSchema),
|
||||
onSubmit: () => {
|
||||
isLoading = true;
|
||||
toast.loading('Logging in...');
|
||||
},
|
||||
onUpdated: ({ form: f }) => {
|
||||
isLoading = false;
|
||||
if (f.valid) {
|
||||
toast.success('Succesfully logged in.');
|
||||
} else {
|
||||
toast.error('Please fix the errors.');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const { form: formData, enhance } = form;
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col space-y-2 text-center">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Log into your account</h1>
|
||||
<p class="pb-6 text-sm text-muted-foreground">
|
||||
Enter your credentials below to log into your account
|
||||
</p>
|
||||
</div>
|
||||
<div class={cn('grid gap-6')} {...$$restProps}>
|
||||
<form method="POST" action="?/login" class="grid gap-2" use:enhance>
|
||||
<Form.Field {form} name="usernameEmail" class="grid gap-2">
|
||||
<Form.Control let:attrs>
|
||||
<Form.Label>Username or email</Form.Label>
|
||||
<Input {...attrs} bind:value={$formData.usernameEmail} />
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="password" class="grid gap-2">
|
||||
<Form.Control let:attrs>
|
||||
<Form.Label>Password</Form.Label>
|
||||
<Input {...attrs} bind:value={$formData.password} type="password" />
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Button disabled={isLoading}>
|
||||
{#if isLoading}
|
||||
<Icons.spinner class="mr-2 h-4 w-4 animate-spin" />
|
||||
{/if}
|
||||
Log in
|
||||
</Form.Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{#if dev && debugForms && browser}
|
||||
<div class="pt-4">
|
||||
<SuperDebug data={$formData} />
|
||||
</div>
|
||||
{/if}
|
94
src/routes/(auth)/auth/(components)/register-form.svelte
Normal file
94
src/routes/(auth)/auth/(components)/register-form.svelte
Normal file
|
@ -0,0 +1,94 @@
|
|||
<script lang="ts" context="module">
|
||||
import { z } from 'zod';
|
||||
export const registerFormSchema = z.object({
|
||||
username: z.string().min(3).max(24),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
passwordConfirm: z.string().min(8)
|
||||
});
|
||||
export type RegisterFormSchema = typeof registerFormSchema;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import * as Form from '$lib/components/ui/form';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { type SuperValidated, type Infer, superForm } from 'sveltekit-superforms';
|
||||
import SuperDebug from 'sveltekit-superforms';
|
||||
import { zodClient } from 'sveltekit-superforms/adapters';
|
||||
import { browser, dev } from '$app/environment';
|
||||
// import { PUBLIC_DEBUG_FORMS } from '$env/static/public';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { Icons } from '$lib/components/site';
|
||||
import { cn } from '$lib/utils';
|
||||
import { debugForms } from '$lib/config/site';
|
||||
|
||||
export let data: SuperValidated<Infer<RegisterFormSchema>>;
|
||||
let isLoading = false;
|
||||
|
||||
const form = superForm(data, {
|
||||
validators: zodClient(registerFormSchema),
|
||||
onSubmit: () => {
|
||||
isLoading = true;
|
||||
toast.loading('Creating account...');
|
||||
},
|
||||
onUpdated: ({ form: f }) => {
|
||||
isLoading = false;
|
||||
if (f.valid) {
|
||||
toast.success('Account created succesfully.');
|
||||
} else {
|
||||
toast.error('Please fix the errors.');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const { form: formData, enhance } = form;
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col space-y-2 text-center">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Create your account</h1>
|
||||
<p class="pb-6 text-sm text-muted-foreground">Enter your details below to create a new account</p>
|
||||
</div>
|
||||
<div class={cn('grid gap-6')} {...$$restProps}>
|
||||
<form method="POST" action="?/register" class="grid gap-2" use:enhance>
|
||||
<Form.Field {form} name="email" class="grid gap-2">
|
||||
<Form.Control let:attrs>
|
||||
<Form.Label>Email</Form.Label>
|
||||
<Input {...attrs} bind:value={$formData.email} />
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="username" class="grid gap-2">
|
||||
<Form.Control let:attrs>
|
||||
<Form.Label>Username</Form.Label>
|
||||
<Input {...attrs} bind:value={$formData.username} />
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="password" class="grid gap-2">
|
||||
<Form.Control let:attrs>
|
||||
<Form.Label>Password</Form.Label>
|
||||
<Input {...attrs} bind:value={$formData.password} type="password" />
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="passwordConfirm" class="grid gap-2">
|
||||
<Form.Control let:attrs>
|
||||
<Form.Label>Confirm password</Form.Label>
|
||||
<Input {...attrs} bind:value={$formData.passwordConfirm} type="password" />
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Button disabled={isLoading}>
|
||||
{#if isLoading}
|
||||
<Icons.spinner class="mr-2 h-4 w-4 animate-spin" />
|
||||
{/if}
|
||||
Log in
|
||||
</Form.Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{#if dev && debugForms && browser}
|
||||
<div class="pt-4">
|
||||
<SuperDebug data={$formData} />
|
||||
</div>
|
||||
{/if}
|
145
src/routes/(auth)/auth/(components)/sso-form.svelte
Normal file
145
src/routes/(auth)/auth/(components)/sso-form.svelte
Normal file
|
@ -0,0 +1,145 @@
|
|||
<script lang="ts" context="module">
|
||||
import { z } from 'zod';
|
||||
export const ssoFormSchema = z.object({
|
||||
token: z.string()
|
||||
});
|
||||
export type SsoFormSchema = typeof ssoFormSchema;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import * as Form from '$lib/components/ui/form';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { type SuperValidated, type Infer, superForm } from 'sveltekit-superforms';
|
||||
import SuperDebug from 'sveltekit-superforms';
|
||||
import { zodClient } from 'sveltekit-superforms/adapters';
|
||||
import { browser, dev } from '$app/environment';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { Icons } from '$lib/components/site';
|
||||
import PocketBase from 'pocketbase';
|
||||
import { PUBLIC_CLIENT_PB } from '$env/static/public';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { ChevronDown } from 'radix-icons-svelte';
|
||||
import { debugForms } from '$lib/config/site';
|
||||
|
||||
export let data: SuperValidated<Infer<SsoFormSchema>>;
|
||||
let isLoading = false;
|
||||
|
||||
const form = superForm(data, {
|
||||
validators: zodClient(ssoFormSchema),
|
||||
onSubmit: () => {
|
||||
isLoading = true;
|
||||
toast.loading('Logging in...');
|
||||
},
|
||||
onUpdated: ({ form: f }) => {
|
||||
isLoading = false;
|
||||
if (f.valid) {
|
||||
toast.success('Succesfully logged in.');
|
||||
} else {
|
||||
toast.error('Please fix the errors.');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const { form: formData, enhance } = form;
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
export let providers: { name: string; icon?: any; displayName: string }[];
|
||||
let currentProvider = providers[0];
|
||||
|
||||
const pb = new PocketBase(PUBLIC_CLIENT_PB);
|
||||
let oauth2Form: HTMLFormElement;
|
||||
async function loginWithOauth2(provider: string) {
|
||||
try {
|
||||
await pb.collection('users').authWithOAuth2({ provider });
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'token';
|
||||
input.value = pb.authStore.token;
|
||||
oauth2Form.appendChild(input);
|
||||
oauth2Form.submit();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form method="POST" action="?/sso" class="grid gap-2" use:enhance>
|
||||
<Form.Field {form} name="token" class="grid gap-2">
|
||||
<Form.Control let:attrs>
|
||||
<Input {...attrs} bind:value={$formData.token} class="hidden" />
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<input type="hidden" name="provider" bind:value={currentProvider.name} />
|
||||
<div class="flex w-full items-center justify-center space-x-2">
|
||||
<Button
|
||||
on:click={() => loginWithOauth2(currentProvider.name)}
|
||||
name={currentProvider.name}
|
||||
variant="ghost"
|
||||
class="w-full"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{#if isLoading}
|
||||
<Icons.spinner class="mr-2 h-4 w-4 animate-spin" />
|
||||
{:else if currentProvider.icon === undefined}
|
||||
<img
|
||||
src={`${PUBLIC_CLIENT_PB}/_/images/oauth2/${currentProvider.name}.svg`}
|
||||
alt={currentProvider.name}
|
||||
class="mr-2 h-4 w-4"
|
||||
/>
|
||||
{:else}
|
||||
<svelte:component this={currentProvider.icon} class="mr-2 h-4 w-4" />
|
||||
{/if}
|
||||
{currentProvider.displayName}
|
||||
</Button>
|
||||
</div>
|
||||
{#if providers.length > 1}
|
||||
<div class="flex items-center space-x-2">
|
||||
<Separator orientation="vertical" class="h-[20px] bg-secondary" />
|
||||
<div class="flex items-center space-x-2">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild let:builder>
|
||||
<Button builders={[builder]} variant="ghost" class="px-2 shadow-none">
|
||||
<ChevronDown class="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content class="" align="center">
|
||||
<DropdownMenu.Label class="sr-only">Login Providers</DropdownMenu.Label>
|
||||
{#each providers as provider}
|
||||
{#if provider.name !== currentProvider.name}
|
||||
<DropdownMenu.Item
|
||||
class="flex justify-center"
|
||||
on:click={() => (currentProvider = provider)}
|
||||
>
|
||||
{#if provider.icon === undefined}
|
||||
<img
|
||||
src={`${PUBLIC_CLIENT_PB}/_/images/oauth2/${provider.name}.svg`}
|
||||
alt={provider.name}
|
||||
class="mr-2 h-4 w-4"
|
||||
/>
|
||||
{:else}
|
||||
<svelte:component this={provider.icon} class="mr-2 h-4 w-4" />
|
||||
{/if}
|
||||
{provider.displayName}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#if dev && debugForms && browser}
|
||||
<div class="pt-4">
|
||||
<SuperDebug data={$formData} />
|
||||
</div>
|
||||
{/if}
|
|
@ -1,118 +1,79 @@
|
|||
import { error, redirect, type Cookies } from '@sveltejs/kit';
|
||||
import type { Actions } from './$types';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { superValidate } from 'sveltekit-superforms';
|
||||
import { zod } from 'sveltekit-superforms/adapters';
|
||||
import { loginFormSchema } from './(components)/login-form.svelte';
|
||||
import { registerFormSchema } from './(components)/register-form.svelte';
|
||||
import { ssoFormSchema } from './(components)/sso-form.svelte';
|
||||
import { fail, type Actions, redirect } from '@sveltejs/kit';
|
||||
|
||||
export const actions = {
|
||||
login: async ({
|
||||
request,
|
||||
locals,
|
||||
cookies
|
||||
}: {
|
||||
request: Request;
|
||||
locals: App.Locals;
|
||||
cookies: Cookies;
|
||||
}) => {
|
||||
const body = Object.fromEntries(await request.formData());
|
||||
export const load: PageServerLoad = async () => {
|
||||
return {
|
||||
loginForm: await superValidate(zod(loginFormSchema)),
|
||||
registerForm: await superValidate(zod(registerFormSchema)),
|
||||
ssoForm: await superValidate(zod(ssoFormSchema))
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
login: async ({ request, locals, cookies }) => {
|
||||
const form = await superValidate(request, zod(loginFormSchema));
|
||||
if (!form.valid) {
|
||||
return fail(400, {
|
||||
form
|
||||
});
|
||||
}
|
||||
try {
|
||||
const email = body.email.toString();
|
||||
const password = body.password.toString();
|
||||
await locals.pocketBase.collection('users').authWithPassword(email, password);
|
||||
if (!locals.pocketBase?.authStore?.model?.verified) {
|
||||
locals.pocketBase.authStore.clear();
|
||||
return {
|
||||
notVerified: true
|
||||
};
|
||||
}
|
||||
await locals.pocketBase
|
||||
.collection('users')
|
||||
.authWithPassword(form.data.usernameEmail, form.data.password);
|
||||
} catch (err) {
|
||||
console.log('Error: ', err);
|
||||
throw error(500, 'Something went wrong logging in');
|
||||
return fail(500, {
|
||||
form
|
||||
});
|
||||
}
|
||||
cookies.set('pb_auth', JSON.stringify({ token: locals.pocketBase.authStore.token }), {
|
||||
path: '/'
|
||||
});
|
||||
|
||||
throw redirect(303, '/');
|
||||
},
|
||||
register: async ({ request, locals }: { request: Request; locals: App.Locals }) => {
|
||||
if (locals.pocketBase.authStore.isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
const name = formData.get('name');
|
||||
const email = formData.get('email');
|
||||
const password = formData.get('password');
|
||||
const passwordConfirm = formData.get('passwordConfirm');
|
||||
|
||||
try {
|
||||
if (typeof name !== 'string') {
|
||||
throw new Error('Name must be a string');
|
||||
}
|
||||
|
||||
if (name.length === 0) {
|
||||
throw new Error('Please enter a valid name');
|
||||
}
|
||||
|
||||
if (typeof email !== 'string') {
|
||||
throw new Error('Email must be a string');
|
||||
}
|
||||
|
||||
if (email.length < 5) {
|
||||
throw new Error('Please enter a valid e-mail address');
|
||||
}
|
||||
|
||||
if (typeof password !== 'string') {
|
||||
throw new Error('Password must be a string');
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
throw new Error('Password must be at least 8 characters in length');
|
||||
}
|
||||
|
||||
if (password !== passwordConfirm) {
|
||||
throw new Error('Passwords do not match');
|
||||
}
|
||||
|
||||
await locals.pocketBase.collection('users').create({
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
passwordConfirm
|
||||
register: async ({ request, locals }) => {
|
||||
const form = await superValidate(request, zod(registerFormSchema));
|
||||
if (!form.valid) {
|
||||
return fail(400, {
|
||||
form
|
||||
});
|
||||
}
|
||||
try {
|
||||
await locals.pocketBase.collection('users').create(form.data);
|
||||
return {
|
||||
form
|
||||
};
|
||||
} catch (err) {
|
||||
return fail(500, {
|
||||
form
|
||||
});
|
||||
|
||||
await locals.pocketBase.collection('users').authWithPassword(email, password);
|
||||
if (!locals.pocketBase?.authStore?.model?.verified) {
|
||||
locals.pocketBase.authStore.clear();
|
||||
return {
|
||||
showLogin: true,
|
||||
isLoading: false,
|
||||
notVerified: true
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
if (!(error instanceof Error)) {
|
||||
return {
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
error: 'Unknown error occured when signing up user'
|
||||
};
|
||||
}
|
||||
|
||||
return { error: error.message, name, email, password };
|
||||
}
|
||||
|
||||
throw redirect(303, '/');
|
||||
},
|
||||
oauth2: async ({ request, cookies }) => {
|
||||
const form = await request.formData();
|
||||
const token = form.get('token');
|
||||
if (!token || typeof token !== 'string') {
|
||||
throw redirect(303, '/auth');
|
||||
sso: async ({ request, cookies }) => {
|
||||
const form = await superValidate(request, zod(ssoFormSchema));
|
||||
if (!form.valid) {
|
||||
return fail(400, {
|
||||
form
|
||||
});
|
||||
}
|
||||
const token = form.data.token;
|
||||
try {
|
||||
if (!token || typeof token !== 'string') {
|
||||
throw redirect(303, '/auth');
|
||||
}
|
||||
cookies.set('pb_auth', JSON.stringify({ token: token }), { path: '/' });
|
||||
return {
|
||||
form
|
||||
};
|
||||
} catch (err) {
|
||||
return fail(500, {
|
||||
form
|
||||
});
|
||||
}
|
||||
cookies.set('pb_auth', JSON.stringify({ token: token }), { path: '/' });
|
||||
throw redirect(303, '/');
|
||||
}
|
||||
} satisfies Actions;
|
||||
};
|
||||
|
|
|
@ -1,48 +1,20 @@
|
|||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { enhance } from '$app/forms';
|
||||
import { Icons } from '$lib/components/site/icons';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Icons } from '$lib/components/site/index.js';
|
||||
import * as Tabs from '$lib/components/ui/tabs';
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { cn } from '$lib/utils';
|
||||
import { ChevronDown } from 'radix-icons-svelte';
|
||||
import Separator from '$lib/components/ui/separator/separator.svelte';
|
||||
import type { PageData } from './$types';
|
||||
import PocketBase from 'pocketbase';
|
||||
import { PUBLIC_CLIENT_PB } from '$env/static/public';
|
||||
import type { PageData } from './$types.js';
|
||||
import { LoginForm, RegisterForm, SSOForm } from './(components)';
|
||||
|
||||
let isLoading = false;
|
||||
export let form;
|
||||
export let data: PageData;
|
||||
|
||||
const { providers } = data;
|
||||
const providersWithIcons = providers.map((provider) => ({
|
||||
...provider,
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
icon: (Icons as { [key: string]: any })[provider.name] || undefined
|
||||
}));
|
||||
let currentProvider = providersWithIcons[0];
|
||||
|
||||
const pb = new PocketBase(PUBLIC_CLIENT_PB);
|
||||
let oauth2Form: HTMLFormElement;
|
||||
async function loginWithOauth2(provider: string) {
|
||||
try {
|
||||
await pb.collection('users').authWithOAuth2({ provider });
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'token';
|
||||
input.value = pb.authStore.token;
|
||||
oauth2Form.appendChild(input);
|
||||
oauth2Form.submit();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
let tab: string = 'login';
|
||||
|
||||
if (browser) {
|
||||
const urlSearchParams = new URLSearchParams(window.location.search);
|
||||
tab = urlSearchParams.get('tab') || 'login';
|
||||
|
@ -50,223 +22,22 @@
|
|||
</script>
|
||||
|
||||
<div class="lg:p-8">
|
||||
<Tabs.Root
|
||||
value={form?.showLogin ? 'login' : tab}
|
||||
class="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]"
|
||||
>
|
||||
<Tabs.Root value={tab} class="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
|
||||
<Tabs.List class="grid w-full grid-cols-2">
|
||||
<Tabs.Trigger value="login">Login</Tabs.Trigger>
|
||||
<Tabs.Trigger value="register">Register</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="login">
|
||||
<div class="flex flex-col space-y-2 text-center">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Log into your account</h1>
|
||||
<p class="pb-6 text-sm text-muted-foreground">
|
||||
Enter your credentials below to log into your account
|
||||
</p>
|
||||
</div>
|
||||
<div class={cn('grid gap-6')} {...$$restProps}>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/login"
|
||||
use:enhance={() => {
|
||||
isLoading = true;
|
||||
return async ({ update }) => {
|
||||
isLoading = false;
|
||||
update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<div class="grid gap-2">
|
||||
<Label for="email">Email or username</Label>
|
||||
<Input
|
||||
id="email-login"
|
||||
name="email"
|
||||
type="email"
|
||||
autocapitalize="none"
|
||||
autocomplete="email"
|
||||
autocorrect="off"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="password">Password</Label>
|
||||
<Input id="password-login" name="password" type="password" disabled={isLoading} />
|
||||
</div>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{#if isLoading}
|
||||
<Icons.spinner class="mr-2 h-4 w-4 animate-spin" />
|
||||
{/if}
|
||||
Sign In
|
||||
</Button>
|
||||
</div>
|
||||
{#if form?.notVerified}
|
||||
<Alert.Root class="mt-4">
|
||||
<Alert.Title></Alert.Title>
|
||||
<Alert.Description>You must verify your email before you can login.</Alert.Description
|
||||
>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
<LoginForm data={data.loginForm} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="register">
|
||||
<div class="flex flex-col space-y-2 text-center">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Create your account</h1>
|
||||
<p class="pb-6 text-sm text-muted-foreground">
|
||||
Enter your details below to create a new account
|
||||
</p>
|
||||
</div>
|
||||
<div class={cn('grid gap-6')} {...$$restProps}>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/register"
|
||||
use:enhance={() => {
|
||||
isLoading = true;
|
||||
return async ({ update }) => {
|
||||
isLoading = false;
|
||||
update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<div class="grid gap-2">
|
||||
<div class="grid gap-2">
|
||||
<Label for="email">Name</Label>
|
||||
<Input id="name" name="name" type="name" disabled={isLoading} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="email">Email</Label>
|
||||
<Input
|
||||
id="email-register"
|
||||
name="email"
|
||||
type="email"
|
||||
autocapitalize="none"
|
||||
autocomplete="email"
|
||||
autocorrect="off"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="password">Password</Label>
|
||||
<Input id="password-register" name="password" type="password" disabled={isLoading} />
|
||||
</div>
|
||||
<div class="grid gap-2">
|
||||
<Label for="password">Confirm password</Label>
|
||||
<Input
|
||||
id="confirm-password-register"
|
||||
name="passwordConfirm"
|
||||
type="password"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{#if isLoading}
|
||||
<Icons.spinner class="mr-2 h-4 w-4 animate-spin" />
|
||||
{/if}
|
||||
Register
|
||||
</Button>
|
||||
</div>
|
||||
{#if form?.notVerified}
|
||||
<Alert.Root class="mt-4">
|
||||
<Alert.Title></Alert.Title>
|
||||
<Alert.Description>You must verify your email before you can login.</Alert.Description
|
||||
>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
</form>
|
||||
</div>
|
||||
<RegisterForm data={data.registerForm} />
|
||||
</Tabs.Content>
|
||||
<p class="px-8 text-center text-xs text-muted-foreground">
|
||||
Don't have an account? <a class="text-primary underline" href="/auth?tab=register">Register</a
|
||||
>.<br />
|
||||
Forgot password? <a class="text-primary underline" href="/reset-password">Reset password.</a>
|
||||
</p>
|
||||
{#if providers.length}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/oauth2"
|
||||
bind:this={oauth2Form}
|
||||
use:enhance={() => {
|
||||
isLoading = true;
|
||||
return async ({ update }) => {
|
||||
isLoading = false;
|
||||
update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<span class="w-full border-t" />
|
||||
</div>
|
||||
<div class="relative flex justify-center text-xs uppercase">
|
||||
<span class="bg-background px-2 py-4 text-muted-foreground"> Or continue with </span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
<input type="hidden" name="provider" bind:value={currentProvider.name} />
|
||||
<div class="flex w-full items-center justify-center space-x-2">
|
||||
<Button
|
||||
on:click={() => loginWithOauth2(currentProvider.name)}
|
||||
name={currentProvider.name}
|
||||
variant="ghost"
|
||||
class="w-full"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{#if isLoading}
|
||||
<Icons.spinner class="mr-2 h-4 w-4 animate-spin" />
|
||||
{:else if currentProvider.icon === undefined}
|
||||
<img
|
||||
src={`${PUBLIC_CLIENT_PB}/_/images/oauth2/${currentProvider.name}.svg`}
|
||||
alt={currentProvider.name}
|
||||
class="mr-2 h-4 w-4"
|
||||
/>
|
||||
{:else}
|
||||
<svelte:component this={currentProvider.icon} class="mr-2 h-4 w-4" />
|
||||
{/if}
|
||||
{currentProvider.displayName}
|
||||
</Button>
|
||||
</div>
|
||||
{#if providers.length > 1}
|
||||
<div class="flex items-center space-x-2">
|
||||
<Separator orientation="vertical" class="h-[20px] bg-secondary" />
|
||||
<div class="flex items-center space-x-2">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger asChild let:builder>
|
||||
<Button builders={[builder]} variant="ghost" class="px-2 shadow-none">
|
||||
<ChevronDown class="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content class="" align="center">
|
||||
<DropdownMenu.Label class="sr-only">Login Providers</DropdownMenu.Label>
|
||||
{#each providersWithIcons as provider}
|
||||
{#if provider.name !== currentProvider.name}
|
||||
<DropdownMenu.Item
|
||||
class="flex justify-center"
|
||||
on:click={() => (currentProvider = provider)}
|
||||
>
|
||||
{#if provider.icon === undefined}
|
||||
<img
|
||||
src={`${PUBLIC_CLIENT_PB}/_/images/oauth2/${provider.name}.svg`}
|
||||
alt={provider.name}
|
||||
class="mr-2 h-4 w-4"
|
||||
/>
|
||||
{:else}
|
||||
<svelte:component this={provider.icon} class="mr-2 h-4 w-4" />
|
||||
{/if}
|
||||
{provider.displayName}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
<SSOForm data={data.ssoForm} providers={providersWithIcons} />
|
||||
{/if}
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
|
|
|
@ -1,85 +0,0 @@
|
|||
<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>
|
|
@ -1,104 +0,0 @@
|
|||
<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>
|
|
@ -1,79 +0,0 @@
|
|||
<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>
|
|
@ -1,78 +0,0 @@
|
|||
<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,79 +0,0 @@
|
|||
import type { PageServerLoad } from './$types';
|
||||
import { superValidate } from 'sveltekit-superforms/server';
|
||||
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 {
|
||||
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 = {
|
||||
name: async ({ request, locals }: { request: Request; locals: App.Locals }) => {
|
||||
const form = await superValidate(request, nameFormSchema);
|
||||
if (!form.valid) {
|
||||
return fail(400, {
|
||||
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,34 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
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;
|
||||
let { forms, user } = data;
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium">Account</h3>
|
||||
<p class="text-sm text-muted-foreground">Update your account and profile settings.</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<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>
|
|
@ -1,25 +0,0 @@
|
|||
import { superValidate } from 'sveltekit-superforms/server';
|
||||
import type { PageServerLoad } from '../$types';
|
||||
import { appearanceFormSchema } from './appearance-form.svelte';
|
||||
import { fail, type Actions } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
return {
|
||||
form: await superValidate(appearanceFormSchema)
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, locals }: { request: Request; locals: App.Locals }) => {
|
||||
const form = await superValidate(request, appearanceFormSchema);
|
||||
if (!form.valid) {
|
||||
return fail(400, {
|
||||
form
|
||||
});
|
||||
}
|
||||
await locals.pocketBase
|
||||
.collection('users')
|
||||
.update(locals.id, { appearanceMode: form.data.theme });
|
||||
return { form };
|
||||
}
|
||||
};
|
|
@ -1,125 +0,0 @@
|
|||
<script lang="ts" context="module">
|
||||
import type { SuperValidated } from 'sveltekit-superforms';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const appearanceFormSchema = z.object({
|
||||
theme: z.enum(['light', 'dark', 'system'], {
|
||||
required_error: 'Please select a theme.'
|
||||
})
|
||||
});
|
||||
|
||||
export type AppearanceFormSchema = typeof appearanceFormSchema;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import * as Form from '$lib/components/ui/form';
|
||||
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"
|
||||
let:config
|
||||
debug={dev ? true : false}
|
||||
>
|
||||
<Form.Item>
|
||||
<Form.Field {config} name="theme">
|
||||
<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}
|
||||
>
|
||||
<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">
|
||||
<div class="space-y-2 rounded-sm bg-[#ecedef] p-2">
|
||||
<div class="space-y-2 rounded-md bg-white p-2 shadow-sm">
|
||||
<div class="h-2 w-[80px] rounded-lg bg-[#ecedef]" />
|
||||
<div class="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
|
||||
<div class="h-4 w-4 rounded-full bg-[#ecedef]" />
|
||||
<div class="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
|
||||
<div class="h-4 w-4 rounded-full bg-[#ecedef]" />
|
||||
<div class="h-2 w-[100px] rounded-lg bg-[#ecedef]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="block w-full p-2 text-center font-normal"> Light </span>
|
||||
</Label>
|
||||
<Label for="dark" class="[&:has([data-state=checked])>div]:border-primary">
|
||||
<Form.RadioItem id="dark" value="dark" class="sr-only" />
|
||||
<div
|
||||
class="items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<div class="space-y-2 rounded-sm bg-slate-950 p-2">
|
||||
<div class="space-y-2 rounded-md bg-slate-800 p-2 shadow-sm">
|
||||
<div class="h-2 w-[80px] rounded-lg bg-slate-400" />
|
||||
<div class="h-2 w-[100px] rounded-lg bg-slate-400" />
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
|
||||
<div class="h-4 w-4 rounded-full bg-slate-400" />
|
||||
<div class="h-2 w-[100px] rounded-lg bg-slate-400" />
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
|
||||
<div class="h-4 w-4 rounded-full bg-slate-400" />
|
||||
<div class="h-2 w-[100px] rounded-lg bg-slate-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="block w-full p-2 text-center font-normal"> Dark </span>
|
||||
</Label>
|
||||
<Label for="system" class="[&:has([data-state=checked])>div]:border-primary">
|
||||
<Form.RadioItem id="system" value="system" class="sr-only" />
|
||||
<div
|
||||
class="items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<div class="space-y-2 rounded-sm bg-slate-500 p-2">
|
||||
<div class="space-y-2 rounded-md bg-slate-400 p-2 shadow-sm">
|
||||
<div class="h-2 w-[80px] rounded-lg bg-slate-200" />
|
||||
<div class="h-2 w-[100px] rounded-lg bg-slate-200" />
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 rounded-md bg-slate-400 p-2 shadow-sm">
|
||||
<div class="h-4 w-4 rounded-full bg-slate-200" />
|
||||
<div class="h-2 w-[100px] rounded-lg bg-slate-200" />
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 rounded-md bg-slate-400 p-2 shadow-sm">
|
||||
<div class="h-4 w-4 rounded-full bg-slate-200" />
|
||||
<div class="h-2 w-[100px] rounded-lg bg-slate-200" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="block w-full p-2 text-center font-normal"> System </span>
|
||||
</Label>
|
||||
</Form.RadioGroup>
|
||||
</Form.Field>
|
||||
</Form.Item>
|
||||
<Form.Button disabled={isLoading}>Update preferences</Form.Button>
|
||||
</Form.Root>
|
|
@ -1,24 +0,0 @@
|
|||
import { superValidate } from 'sveltekit-superforms/server';
|
||||
import type { PageServerLoad } from '../$types';
|
||||
import { notificationsFormSchema } from './notifications-form.svelte';
|
||||
import { fail, type Actions } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
return {
|
||||
form: await superValidate(notificationsFormSchema)
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async (event) => {
|
||||
const form = await superValidate(event, notificationsFormSchema);
|
||||
if (!form.valid) {
|
||||
return fail(400, {
|
||||
form
|
||||
});
|
||||
}
|
||||
return {
|
||||
form
|
||||
};
|
||||
}
|
||||
};
|
|
@ -1,16 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import NotificationsForm from './notifications-form.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium">Notifications</h3>
|
||||
<p class="text-sm text-muted-foreground">Configure how you receive notifications.</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<NotificationsForm data={data.form} />
|
||||
</div>
|
|
@ -1,107 +0,0 @@
|
|||
<script lang="ts" context="module">
|
||||
import { z } from 'zod';
|
||||
export const notificationsFormSchema = z.object({
|
||||
type: z.enum(['all', 'tickets', 'none'], {
|
||||
required_error: 'You need to select a notification type.'
|
||||
})
|
||||
});
|
||||
type NotificationFormSchema = typeof notificationsFormSchema;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { SuperValidated } from 'sveltekit-superforms';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Form from '$lib/components/ui/form';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
export let data: SuperValidated<NotificationFormSchema>;
|
||||
import { dev } from '$app/environment';
|
||||
import { Bell, EyeNone, Person } from 'radix-icons-svelte';
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header class="pb-3">
|
||||
<Card.Title>Notifications</Card.Title>
|
||||
<Card.Description>Choose what you want to be notified about.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="grid gap-1">
|
||||
<Form.Root
|
||||
form={data}
|
||||
schema={notificationsFormSchema}
|
||||
let:config
|
||||
method="POST"
|
||||
class="space-y-8"
|
||||
debug={dev ? true : false}
|
||||
>
|
||||
<Form.Item>
|
||||
<Form.Field {config} name="type">
|
||||
<Form.RadioGroup class="grid max-w-xl grid-cols-3 gap-8 pt-2" orientation="horizontal">
|
||||
<!-- value={user?.appearanceMode} -->
|
||||
<Label
|
||||
for="all"
|
||||
class="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground [&:has([data-state=checked])]:border-primary"
|
||||
>
|
||||
<Form.RadioItem id="all" value="all" class="sr-only" />
|
||||
<Bell class="mb-3 h-6 w-6" />
|
||||
<span class="block w-full p-2 text-center font-normal">Everything</span>
|
||||
<span class="text-center text-sm text-muted-foreground">New tickets and updates.</span
|
||||
>
|
||||
</Label>
|
||||
<Label
|
||||
for="tickets"
|
||||
class="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground [&:has([data-state=checked])]:border-primary"
|
||||
>
|
||||
<Form.RadioItem id="tickets" value="tickets" class="sr-only" />
|
||||
<Person class="mb-3 h-6 w-6" />
|
||||
<span class="block w-full p-2 text-center font-normal">New tickets</span>
|
||||
<span class="text-center text-sm text-muted-foreground"
|
||||
>Only new unassigned tickets</span
|
||||
>
|
||||
</Label>
|
||||
<Label
|
||||
for="none"
|
||||
class="flex flex-col items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground [&:has([data-state=checked])]:border-primary"
|
||||
>
|
||||
<Form.RadioItem id="none" value="none" class="sr-only" />
|
||||
<EyeNone class="mb-3 h-6 w-6" />
|
||||
<span class="block w-full p-2 text-center font-normal">Ignore</span>
|
||||
<span class="text-center text-sm text-muted-foreground"
|
||||
>Turn off all notifications.</span
|
||||
>
|
||||
</Label>
|
||||
</Form.RadioGroup>
|
||||
<Form.Validation />
|
||||
</Form.Field>
|
||||
</Form.Item>
|
||||
</Form.Root>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- <Form.Root
|
||||
form={data}
|
||||
schema={notificationsFormSchema}
|
||||
let:config
|
||||
method="POST"
|
||||
class="space-y-8"
|
||||
debug={dev ? true : false}
|
||||
>
|
||||
<Form.Item>
|
||||
<Form.Field {config} name="type">
|
||||
<Form.Label>Notify me about...</Form.Label>
|
||||
<Form.RadioGroup class="flex flex-col space-y-1">
|
||||
<div class="flex items-center space-x-3">
|
||||
<Form.RadioItem value="all" id="all" />
|
||||
<Label for="all" class="font-normal">New tickets and SLA breaches</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<Form.RadioItem value="tickets" id="mentions" />
|
||||
<Label for="mentions" class="font-normal">New tickets</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<Form.RadioItem value="none" id="none" />
|
||||
<Label for="none" class="font-normal">Nothing</Label>
|
||||
</div>
|
||||
</Form.RadioGroup>
|
||||
</Form.Field>
|
||||
</Form.Item>
|
||||
<Form.Button>Update notifications</Form.Button>
|
||||
</Form.Root> -->
|
|
@ -8,17 +8,17 @@
|
|||
href: '/settings'
|
||||
},
|
||||
{
|
||||
title: 'Appearance',
|
||||
href: '/settings/appearance'
|
||||
title: 'Account',
|
||||
href: '/settings/account'
|
||||
},
|
||||
{
|
||||
title: 'Notifications',
|
||||
href: '/settings/notifications'
|
||||
title: 'Appearance',
|
||||
href: '/settings/appearance'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="space-y-6 p-10 pb-16 md:block">
|
||||
<div class="space-y-6 p-10 pb-16">
|
||||
<div class="space-y-0.5">
|
||||
<h2 class="text-2xl font-bold tracking-tight">Settings</h2>
|
||||
<p class="text-muted-foreground">Manage your account settings and set e-mail preferences.</p>
|
||||
|
@ -28,7 +28,7 @@
|
|||
<aside class="-mx-4 lg:w-1/5">
|
||||
<SidebarNav items={sidebarNavItems} />
|
||||
</aside>
|
||||
<div class="flex-1">
|
||||
<div class="flex-1 lg:max-w-2xl">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
26
src/routes/(user)/settings/+page.server.ts
Normal file
26
src/routes/(user)/settings/+page.server.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import type { PageServerLoad } from './$types';
|
||||
import { superValidate } from 'sveltekit-superforms';
|
||||
import { zod } from 'sveltekit-superforms/adapters';
|
||||
import { profileFormSchema } from './profile-form.svelte';
|
||||
import { fail, type Actions } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
return {
|
||||
form: await superValidate(zod(profileFormSchema))
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, locals }) => {
|
||||
const form = await superValidate(request, zod(profileFormSchema));
|
||||
if (!form.valid) {
|
||||
return fail(400, {
|
||||
form
|
||||
});
|
||||
}
|
||||
await locals.pocketBase.collection('users').update(locals.id, form.data);
|
||||
return {
|
||||
form
|
||||
};
|
||||
}
|
||||
};
|
16
src/routes/(user)/settings/+page.svelte
Normal file
16
src/routes/(user)/settings/+page.svelte
Normal file
|
@ -0,0 +1,16 @@
|
|||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import ProfileForm from './profile-form.svelte';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
|
||||
export let data: PageData;
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium">Profile</h3>
|
||||
<p class="text-sm text-muted-foreground">This is how others will see you on the site.</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<ProfileForm user={data.user} data={data.form} />
|
||||
</div>
|
69
src/routes/(user)/settings/account/+page.server.ts
Normal file
69
src/routes/(user)/settings/account/+page.server.ts
Normal file
|
@ -0,0 +1,69 @@
|
|||
import type { PageServerLoad } from './$types';
|
||||
import { superValidate } from 'sveltekit-superforms';
|
||||
import { zod } from 'sveltekit-superforms/adapters';
|
||||
import { usernameFormSchema } from './username-form.svelte';
|
||||
import { emailRequestFormSchema, emailConfirmFormSchema } from './email-form.svelte';
|
||||
import { passwordFormSchema } from './password-form.svelte';
|
||||
import { fail, type Actions } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
return {
|
||||
usernameForm: await superValidate(zod(usernameFormSchema)),
|
||||
emailRequestForm: await superValidate(zod(emailRequestFormSchema)),
|
||||
emailConfirmForm: await superValidate(zod(emailConfirmFormSchema)),
|
||||
passwordForm: await superValidate(zod(passwordFormSchema))
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
username: async ({ request, locals }) => {
|
||||
const form = await superValidate(request, zod(usernameFormSchema));
|
||||
if (!form.valid) {
|
||||
return fail(400, {
|
||||
form
|
||||
});
|
||||
}
|
||||
await locals.pocketBase.collection('users').update(locals.id, form.data);
|
||||
return {
|
||||
form
|
||||
};
|
||||
},
|
||||
emailRequest: async ({ request, locals }) => {
|
||||
const form = await superValidate(request, zod(emailRequestFormSchema));
|
||||
if (!form.valid) {
|
||||
return fail(400, {
|
||||
form
|
||||
});
|
||||
}
|
||||
await locals.pocketBase.collection('users').requestEmailChange(form.data.newEmail);
|
||||
return {
|
||||
form
|
||||
};
|
||||
},
|
||||
emailConfirm: async ({ request, locals }) => {
|
||||
const form = await superValidate(request, zod(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 }) => {
|
||||
const form = await superValidate(request, zod(passwordFormSchema));
|
||||
if (!form.valid) {
|
||||
return fail(400, {
|
||||
form
|
||||
});
|
||||
}
|
||||
await locals.pocketBase.collection('users').update(locals.id, form.data);
|
||||
return {
|
||||
form
|
||||
};
|
||||
}
|
||||
};
|
24
src/routes/(user)/settings/account/+page.svelte
Normal file
24
src/routes/(user)/settings/account/+page.svelte
Normal file
|
@ -0,0 +1,24 @@
|
|||
<script lang="ts">
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import type { PageData } from './$types';
|
||||
import UsernameForm from './username-form.svelte';
|
||||
import EmailForm from './email-form.svelte';
|
||||
import PasswordForm from './password-form.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium">Account</h3>
|
||||
<p class="text-sm text-muted-foreground">Update your account settings.</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<UsernameForm user={data.user} data={data.usernameForm} />
|
||||
<EmailForm
|
||||
user={data.user}
|
||||
requestData={data.emailRequestForm}
|
||||
confirmData={data.emailConfirmForm}
|
||||
/>
|
||||
<PasswordForm data={data.passwordForm} />
|
||||
</div>
|
139
src/routes/(user)/settings/account/email-form.svelte
Normal file
139
src/routes/(user)/settings/account/email-form.svelte
Normal file
|
@ -0,0 +1,139 @@
|
|||
<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 { Input } from '$lib/components/ui/input';
|
||||
import { type SuperValidated, type Infer, superForm } from 'sveltekit-superforms';
|
||||
import SuperDebug from 'sveltekit-superforms';
|
||||
import { zodClient } from 'sveltekit-superforms/adapters';
|
||||
import { browser, dev } from '$app/environment';
|
||||
import type { LayoutData } from '../$types';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { Icons } from '$lib/components/site';
|
||||
import { debugForms } from '$lib/config/site';
|
||||
|
||||
export let user: LayoutData['user'];
|
||||
export let requestData: SuperValidated<Infer<EmailRequestFormSchema>>;
|
||||
export let confirmData: SuperValidated<Infer<EmailConfirmFormSchema>>;
|
||||
let isLoading = false;
|
||||
|
||||
const requestForm = superForm(requestData, {
|
||||
validators: zodClient(emailRequestFormSchema),
|
||||
onSubmit: () => {
|
||||
isLoading = true;
|
||||
toast.success('Sending verification token...');
|
||||
},
|
||||
onUpdated: ({ form: f }) => {
|
||||
isLoading = false;
|
||||
if (f.valid) {
|
||||
toast.success('Verification token has been sent.');
|
||||
} else {
|
||||
toast.error('Please fix the errors in the form.');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const confirmForm = superForm(confirmData, {
|
||||
validators: zodClient(emailConfirmFormSchema),
|
||||
onSubmit: () => {
|
||||
isLoading = true;
|
||||
toast.loading('Updating email...');
|
||||
},
|
||||
onUpdated: ({ form: f }) => {
|
||||
isLoading = false;
|
||||
if (f.valid) {
|
||||
toast.success('Your email has been updated.');
|
||||
} else {
|
||||
toast.error('Please fix the errors in the form.');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const { form: requestFormData, enhance: requestEnhance } = requestForm;
|
||||
const { form: confirmFormData, enhance: confirmEnhance } = confirmForm;
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Request an email change</Card.Title>
|
||||
<Card.Description
|
||||
>Receive a verification token on this email, which you can enter in the next section.</Card.Description
|
||||
>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<form method="POST" action="?/emailRequest" class="space-y-8" use:requestEnhance>
|
||||
<Form.Field form={requestForm} name="newEmail">
|
||||
<Form.Control let:attrs>
|
||||
<Form.Label>New email</Form.Label>
|
||||
<div class="flex space-x-2">
|
||||
<Input placeholder={user?.email} {...attrs} bind:value={$requestFormData.newEmail} />
|
||||
<Form.Button variant="secondary" disabled={isLoading}>
|
||||
{#if isLoading}
|
||||
<Icons.spinner class="mr-2 h-4 w-4 animate-spin" />
|
||||
{/if}
|
||||
Request token
|
||||
</Form.Button>
|
||||
</div>
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
</form>
|
||||
{#if dev && debugForms && browser}
|
||||
<div class="pt-4">
|
||||
<SuperDebug data={$requestFormData} />
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Card.Header>
|
||||
<Card.Title>Confirm email change</Card.Title>
|
||||
<Card.Description
|
||||
>Enter your verification token below to confirm the email change.</Card.Description
|
||||
>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<form method="POST" action="?/emailConfirm" class="space-y-2" use:confirmEnhance>
|
||||
<Form.Field form={confirmForm} name="token">
|
||||
<Form.Control let:attrs>
|
||||
<Form.Label>Token</Form.Label>
|
||||
<Input {...attrs} bind:value={$confirmFormData.token} />
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field form={confirmForm} name="password">
|
||||
<Form.Control let:attrs>
|
||||
<Form.Label>Password</Form.Label>
|
||||
<Input {...attrs} bind:value={$confirmFormData.password} type="password" />
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Button disabled={isLoading}>
|
||||
{#if isLoading}
|
||||
<Icons.spinner class="mr-2 h-4 w-4 animate-spin" />
|
||||
{/if}
|
||||
Update email
|
||||
</Form.Button>
|
||||
</form>
|
||||
|
||||
{#if dev && debugForms && browser}
|
||||
<div class="pt-4">
|
||||
<SuperDebug data={$confirmFormData} />
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
91
src/routes/(user)/settings/account/password-form.svelte
Normal file
91
src/routes/(user)/settings/account/password-form.svelte
Normal file
|
@ -0,0 +1,91 @@
|
|||
<script lang="ts" context="module">
|
||||
import { z } from 'zod';
|
||||
export const passwordFormSchema = z.object({
|
||||
oldPassword: z.string(),
|
||||
password: z.string().min(8),
|
||||
passwordConfirm: z.string().min(8)
|
||||
});
|
||||
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 { Input } from '$lib/components/ui/input';
|
||||
import { type SuperValidated, type Infer, superForm } from 'sveltekit-superforms';
|
||||
import SuperDebug from 'sveltekit-superforms';
|
||||
import { zodClient } from 'sveltekit-superforms/adapters';
|
||||
import { browser, dev } from '$app/environment';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { Icons } from '$lib/components/site';
|
||||
|
||||
export let data: SuperValidated<Infer<PasswordFormSchema>>;
|
||||
let isLoading = false;
|
||||
|
||||
const form = superForm(data, {
|
||||
validators: zodClient(passwordFormSchema),
|
||||
onSubmit: () => {
|
||||
isLoading = true;
|
||||
toast.loading('Updating password...');
|
||||
},
|
||||
onUpdated: ({ form: f }) => {
|
||||
if (f.valid) {
|
||||
toast.success('Your password has been updated.');
|
||||
} else {
|
||||
toast.error('Please fix the errors in the form.');
|
||||
}
|
||||
isLoading = false;
|
||||
},
|
||||
onError: (e) => {
|
||||
toast.error(e.result.error.message);
|
||||
isLoading = false;
|
||||
}
|
||||
});
|
||||
|
||||
const { form: formData, enhance } = form;
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Change your password</Card.Title>
|
||||
<Card.Description>You can change your password here.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<form method="POST" action="?/password" class="space-y-2" use:enhance>
|
||||
<Form.Field {form} name="oldPassword">
|
||||
<Form.Control let:attrs>
|
||||
<Form.Label>Current password</Form.Label>
|
||||
<Input {...attrs} bind:value={$formData.oldPassword} type="password" />
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="password">
|
||||
<Form.Control let:attrs>
|
||||
<Form.Label>New password</Form.Label>
|
||||
<Input {...attrs} bind:value={$formData.password} type="password" />
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="passwordConfirm">
|
||||
<Form.Control let:attrs>
|
||||
<Form.Label>Confirm new password</Form.Label>
|
||||
<Input {...attrs} bind:value={$formData.passwordConfirm} type="password" />
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
|
||||
<Form.Button disabled={isLoading}>
|
||||
{#if isLoading}
|
||||
<Icons.spinner class="mr-2 h-4 w-4 animate-spin" />
|
||||
{/if}
|
||||
Update password
|
||||
</Form.Button>
|
||||
</form>
|
||||
|
||||
{#if dev && PUBLIC_DEBUG_FORMS == 'true' && browser}
|
||||
<div class="pt-4">
|
||||
<SuperDebug data={$formData} />
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
76
src/routes/(user)/settings/account/username-form.svelte
Normal file
76
src/routes/(user)/settings/account/username-form.svelte
Normal file
|
@ -0,0 +1,76 @@
|
|||
<script lang="ts" context="module">
|
||||
import { z } from 'zod';
|
||||
export const usernameFormSchema = z.object({
|
||||
username: z.string().min(2).max(16)
|
||||
});
|
||||
export type UsernameFormSchema = typeof usernameFormSchema;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Form from '$lib/components/ui/form';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { type SuperValidated, type Infer, superForm } from 'sveltekit-superforms';
|
||||
import SuperDebug from 'sveltekit-superforms';
|
||||
import { zodClient } from 'sveltekit-superforms/adapters';
|
||||
import { browser, dev } from '$app/environment';
|
||||
import type { LayoutData } from '../$types';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { Icons } from '$lib/components/site';
|
||||
import { debugForms } from '$lib/config/site';
|
||||
|
||||
export let user: LayoutData['user'];
|
||||
export let data: SuperValidated<Infer<UsernameFormSchema>>;
|
||||
let isLoading = false;
|
||||
|
||||
const form = superForm(data, {
|
||||
validators: zodClient(usernameFormSchema),
|
||||
onSubmit: () => {
|
||||
isLoading = true;
|
||||
toast.loading('Updating username...');
|
||||
},
|
||||
onUpdated: ({ form: f }) => {
|
||||
isLoading = false;
|
||||
if (f.valid) {
|
||||
toast.success('Your username has been updated.');
|
||||
} else {
|
||||
toast.error('Please fix the errors in the form.');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const { form: formData, enhance } = form;
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Change your username</Card.Title>
|
||||
<Card.Description>
|
||||
You can modify the username used for logging in and as your handle.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<form method="POST" action="?/username" class="space-y-8" use:enhance>
|
||||
<Form.Field {form} name="username">
|
||||
<Form.Control let:attrs>
|
||||
<Form.Label>Username</Form.Label>
|
||||
<Input placeholder={user?.username} {...attrs} bind:value={$formData.username} />
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
|
||||
<Form.Button disabled={isLoading}>
|
||||
{#if isLoading}
|
||||
<Icons.spinner class="mr-2 h-4 w-4 animate-spin" />
|
||||
{/if}
|
||||
Update username
|
||||
</Form.Button>
|
||||
</form>
|
||||
|
||||
{#if dev && debugForms && browser}
|
||||
<div class="pt-4">
|
||||
<SuperDebug data={$formData} />
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
26
src/routes/(user)/settings/appearance/+page.server.ts
Normal file
26
src/routes/(user)/settings/appearance/+page.server.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { superValidate } from 'sveltekit-superforms';
|
||||
import { zod } from 'sveltekit-superforms/adapters';
|
||||
import type { PageServerLoad } from '../$types';
|
||||
import { appearanceFormSchema } from './appearance-form.svelte';
|
||||
import { fail, type Actions } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
return {
|
||||
form: await superValidate(zod(appearanceFormSchema))
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, locals }) => {
|
||||
const form = await superValidate(request, zod(appearanceFormSchema));
|
||||
if (!form.valid) {
|
||||
return fail(400, {
|
||||
form
|
||||
});
|
||||
}
|
||||
await locals.pocketBase.collection('users').update(locals.id, form.data);
|
||||
return {
|
||||
form
|
||||
};
|
||||
}
|
||||
};
|
|
@ -4,7 +4,6 @@
|
|||
import AppearanceForm from './appearance-form.svelte';
|
||||
|
||||
export let data: PageData;
|
||||
export let { form, user } = data;
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
|
@ -15,5 +14,5 @@
|
|||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<AppearanceForm data={form} {user} />
|
||||
<AppearanceForm data={data.form} />
|
||||
</div>
|
148
src/routes/(user)/settings/appearance/appearance-form.svelte
Normal file
148
src/routes/(user)/settings/appearance/appearance-form.svelte
Normal file
|
@ -0,0 +1,148 @@
|
|||
<script lang="ts" context="module">
|
||||
import { z } from 'zod';
|
||||
|
||||
export const appearanceFormSchema = z.object({
|
||||
appearanceMode: z.enum(['system', 'light', 'dark'], {
|
||||
required_error: 'Please select a theme.'
|
||||
})
|
||||
});
|
||||
|
||||
export type AppearanceFormSchema = typeof appearanceFormSchema;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { browser, dev } from '$app/environment';
|
||||
import SuperDebug, { type SuperValidated, type Infer, superForm } from 'sveltekit-superforms';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Form from '$lib/components/ui/form';
|
||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import { zodClient } from 'sveltekit-superforms/adapters';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { Icons } from '$lib/components/site';
|
||||
import { setMode } from 'mode-watcher';
|
||||
import { debugForms } from '$lib/config/site';
|
||||
|
||||
export let data: SuperValidated<Infer<AppearanceFormSchema>>;
|
||||
let isLoading = false;
|
||||
|
||||
const form = superForm(data, {
|
||||
validators: zodClient(appearanceFormSchema),
|
||||
onSubmit: () => {
|
||||
isLoading = true;
|
||||
toast.loading('Updating appearance...');
|
||||
},
|
||||
onUpdated: ({ form: f }) => {
|
||||
isLoading = false;
|
||||
setMode(f.data.appearanceMode);
|
||||
if (f.valid) {
|
||||
toast.success('Appearance has been updated.');
|
||||
} else {
|
||||
toast.error('Please fix the errors in the form.');
|
||||
}
|
||||
}
|
||||
});
|
||||
const { form: formData, enhance } = form;
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Change your mode</Card.Title>
|
||||
<Card.Description>You can modify the mode for your theme preference.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<form method="POST" use:enhance class="space-y-2">
|
||||
<Form.Fieldset {form} name="appearanceMode">
|
||||
<Form.Legend>Theme</Form.Legend>
|
||||
<Form.FieldErrors />
|
||||
<RadioGroup.Root
|
||||
class="grid grid-cols-3 gap-8 pt-2"
|
||||
orientation="horizontal"
|
||||
bind:value={$formData.appearanceMode}
|
||||
>
|
||||
<Form.Control let:attrs>
|
||||
<Label class="[&:has([data-state=checked])>div]:border-primary">
|
||||
<RadioGroup.Item {...attrs} value="system" class="sr-only" />
|
||||
<div
|
||||
class="items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<div class="space-y-2 rounded-sm bg-slate-500 p-2">
|
||||
<div class="space-y-2 rounded-md bg-slate-400 p-2 shadow-sm">
|
||||
<div class="h-2 w-4 rounded-lg bg-slate-200" />
|
||||
<div class="h-2 w-full rounded-lg bg-slate-200" />
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 rounded-md bg-slate-400 p-2 shadow-sm">
|
||||
<div class="h-4 w-4 rounded-full bg-slate-200" />
|
||||
<div class="h-2 w-full rounded-lg bg-slate-200" />
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 rounded-md bg-slate-400 p-2 shadow-sm">
|
||||
<div class="h-4 w-4 rounded-full bg-slate-200" />
|
||||
<div class="h-2 w-full rounded-lg bg-slate-200" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="block w-full p-2 text-center font-normal"> System </span>
|
||||
</Label>
|
||||
</Form.Control>
|
||||
<Form.Control let:attrs>
|
||||
<Label class="[&:has([data-state=checked])>div]:border-primary">
|
||||
<RadioGroup.Item {...attrs} value="light" class="sr-only" />
|
||||
<div class="items-center rounded-md border-2 border-muted p-1 hover:border-accent">
|
||||
<div class="space-y-2 rounded-sm bg-[#ecedef] p-2">
|
||||
<div class="space-y-2 rounded-md bg-white p-2 shadow-sm">
|
||||
<div class="h-2 w-4 rounded-lg bg-[#ecedef]" />
|
||||
<div class="h-2 w-full rounded-lg bg-[#ecedef]" />
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
|
||||
<div class="h-4 w-4 rounded-full bg-[#ecedef]" />
|
||||
<div class="h-2 w-full rounded-lg bg-[#ecedef]" />
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 rounded-md bg-white p-2 shadow-sm">
|
||||
<div class="h-4 w-4 rounded-full bg-[#ecedef]" />
|
||||
<div class="h-2 w-full rounded-lg bg-[#ecedef]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="block w-full p-2 text-center font-normal"> Light </span>
|
||||
</Label>
|
||||
</Form.Control>
|
||||
<Form.Control let:attrs>
|
||||
<Label class="[&:has([data-state=checked])>div]:border-primary">
|
||||
<RadioGroup.Item {...attrs} value="dark" class="sr-only" />
|
||||
<div
|
||||
class="items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<div class="space-y-2 rounded-sm bg-slate-950 p-2">
|
||||
<div class="space-y-2 rounded-md bg-slate-800 p-2 shadow-sm">
|
||||
<div class="h-2 w-4 rounded-lg bg-slate-400" />
|
||||
<div class="h-2 w-full rounded-lg bg-slate-400" />
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
|
||||
<div class="h-4 w-4 rounded-full bg-slate-400" />
|
||||
<div class="h-2 w-full rounded-lg bg-slate-400" />
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-sm">
|
||||
<div class="h-4 w-4 rounded-full bg-slate-400" />
|
||||
<div class="h-2 w-full rounded-lg bg-slate-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="block w-full p-2 text-center font-normal"> Dark </span>
|
||||
</Label>
|
||||
</Form.Control>
|
||||
<RadioGroup.Input name="appearanceMode" />
|
||||
</RadioGroup.Root>
|
||||
</Form.Fieldset>
|
||||
<Form.Button disabled={isLoading}>
|
||||
{#if isLoading}
|
||||
<Icons.spinner class="mr-2 h-4 w-4 animate-spin" />
|
||||
{/if}
|
||||
Update appearance
|
||||
</Form.Button>
|
||||
</form>
|
||||
|
||||
{#if dev && debugForms && browser}
|
||||
<SuperDebug data={$formData} />
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
77
src/routes/(user)/settings/profile-form.svelte
Normal file
77
src/routes/(user)/settings/profile-form.svelte
Normal file
|
@ -0,0 +1,77 @@
|
|||
<script lang="ts" context="module">
|
||||
import { z } from 'zod';
|
||||
export const profileFormSchema = z.object({
|
||||
name: z.string().min(3).max(50)
|
||||
});
|
||||
export type ProfileFormSchema = typeof profileFormSchema;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Form from '$lib/components/ui/form';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { type SuperValidated, type Infer, superForm } from 'sveltekit-superforms';
|
||||
import SuperDebug from 'sveltekit-superforms';
|
||||
import { zodClient } from 'sveltekit-superforms/adapters';
|
||||
import { browser, dev } from '$app/environment';
|
||||
import type { LayoutData } from '../$types';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { Icons } from '$lib/components/site';
|
||||
import { debugForms } from '$lib/config/site';
|
||||
|
||||
export let user: LayoutData['user'];
|
||||
export let data: SuperValidated<Infer<ProfileFormSchema>>;
|
||||
let isLoading = false;
|
||||
|
||||
const form = superForm(data, {
|
||||
validators: zodClient(profileFormSchema),
|
||||
onSubmit: () => {
|
||||
isLoading = true;
|
||||
toast.loading('Updating your name...');
|
||||
},
|
||||
onUpdated: ({ form: f }) => {
|
||||
isLoading = false;
|
||||
if (f.valid) {
|
||||
toast.success('Your name has been updated.');
|
||||
} else {
|
||||
toast.error('Please fix the errors in the form.');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const { form: formData, enhance } = form;
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Change your name</Card.Title>
|
||||
<Card.Description>
|
||||
You can modify the displayed profile name, which also determines your ticket ownership.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<form method="POST" class="space-y-2" use:enhance>
|
||||
<Form.Field {form} name="name">
|
||||
<Form.Control let:attrs>
|
||||
<Form.Label>Name</Form.Label>
|
||||
<Input placeholder={user?.name} {...attrs} bind:value={$formData.name} />
|
||||
</Form.Control>
|
||||
<Form.Description>This is your public display name.</Form.Description>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
|
||||
<Form.Button disabled={isLoading}>
|
||||
{#if isLoading}
|
||||
<Icons.spinner class="mr-2 h-4 w-4 animate-spin" />
|
||||
{/if}
|
||||
Update name
|
||||
</Form.Button>
|
||||
</form>
|
||||
|
||||
{#if dev && debugForms && browser}
|
||||
<div class="pt-4">
|
||||
<SuperDebug data={$formData} />
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
5
src/routes/(user)/settings/profile/+page.server.ts
Normal file
5
src/routes/(user)/settings/profile/+page.server.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load = async () => {
|
||||
throw redirect(303, '/settings');
|
||||
};
|
|
@ -1,17 +1,22 @@
|
|||
<script lang="ts">
|
||||
import { dev } from '$app/environment';
|
||||
import { Metadata, SiteFooter, SiteNavBar, TailwindIndicator } from '$lib/components/site';
|
||||
import { ModeWatcher } from 'mode-watcher';
|
||||
import { ModeWatcher, setMode } from 'mode-watcher';
|
||||
import '../app.pcss';
|
||||
import type { LayoutData } from './$types';
|
||||
import DataIndicator from '$lib/components/site/data-indicator.svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { Toaster } from 'svelte-sonner';
|
||||
import {} from 'mode-watcher';
|
||||
|
||||
export let data: LayoutData;
|
||||
|
||||
if (data.user?.appearanceMode) {
|
||||
setMode(data.user.appearanceMode);
|
||||
}
|
||||
</script>
|
||||
|
||||
<ModeWatcher />
|
||||
<ModeWatcher defaultMode={data.user?.appearanceMode ?? 'system'} />
|
||||
<Toaster />
|
||||
<Metadata />
|
||||
|
||||
|
|
Loading…
Reference in a new issue