Merge pull request from bartvdbraak/auth-forms-refactor

Refactor and add new features
This commit is contained in:
Bart van der Braak 2024-02-21 12:53:20 +01:00 committed by GitHub
commit 1585e4370f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
88 changed files with 2325 additions and 1787 deletions
.github/workflows
package.jsonpnpm-lock.yaml
src
lib
routes

View file

@ -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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -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'

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View file

@ -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>

View 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>

View 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>

View 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>

View 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
};

View file

@ -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>

View file

@ -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} />

View file

@ -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>

View 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>

View 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>

View 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>

View 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>

View file

@ -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
/>

View file

@ -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>

View 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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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} />

View file

@ -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
/>

View file

@ -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}
/>

View file

@ -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
};

View file

@ -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',

View 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';

View 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}

View 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}

View 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}

View file

@ -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;
};

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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 };
}
};

View file

@ -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>

View file

@ -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 };
}
};

View file

@ -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>

View file

@ -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
};
}
};

View file

@ -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>

View file

@ -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> -->

View file

@ -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>

View 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
};
}
};

View 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>

View 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
};
}
};

View 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>

View 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>

View 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>

View 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>

View 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
};
}
};

View file

@ -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>

View 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>

View 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>

View file

@ -0,0 +1,5 @@
import { redirect } from '@sveltejs/kit';
export const load = async () => {
throw redirect(303, '/settings');
};

View file

@ -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 />