chore: move structure to root

This commit is contained in:
Bart van der Braak 2024-02-16 08:48:59 +01:00
parent 3b27d3841b
commit eed9c4161f
213 changed files with 1 additions and 38 deletions

View file

@ -0,0 +1,10 @@
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
export const load = (async ({ locals }) => {
if (locals.pocketBase.authStore.isValid) {
throw redirect(303, '/');
}
return {};
}) satisfies LayoutServerLoad;

View file

@ -0,0 +1,3 @@
<div class="space-y-6 p-10 pb-16 md:block">
<slot />
</div>

View file

@ -0,0 +1,118 @@
import { error, redirect, type Cookies } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions = {
login: async ({
request,
locals,
cookies
}: {
request: Request;
locals: App.Locals;
cookies: Cookies;
}) => {
const body = Object.fromEntries(await request.formData());
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
};
}
} catch (err) {
console.log('Error: ', err);
throw error(500, 'Something went wrong logging in');
}
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
});
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');
}
cookies.set('pb_auth', JSON.stringify({ token: token }), { path: '/' });
throw redirect(303, '/');
}
} satisfies Actions;

View file

@ -0,0 +1,260 @@
<script lang="ts">
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 * 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';
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);
}
}
</script>
<div class="lg:p-8">
<Tabs.Root
value={form?.showLogin ? 'login' : undefined}
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"
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" 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>
</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"
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" name="password" type="password" disabled={isLoading} />
</div>
<div class="grid gap-2">
<Label for="password">Confirm password</Label>
<Input id="password" 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>
</Tabs.Content>
{#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>
{/if}
</Tabs.Root>
<p class="px-8 py-2 text-center text-xs text-muted-foreground">
Don't have an account? <a class="text-primary underline" href="/register">Sign up.</a> <br />
Forgot password? <a class="text-primary underline" href="/reset-password">Reset password.</a>
</p>
</div>

View file

@ -0,0 +1,6 @@
import { redirect } from '@sveltejs/kit';
export const GET = ({ locals }: { locals: App.Locals }) => {
locals.pocketBase.authStore.clear();
throw redirect(303, '/auth');
};

View file

@ -0,0 +1,18 @@
import { error } from '@sveltejs/kit';
export const actions = {
default: async ({ request, locals }: { request: Request; locals: App.Locals }) => {
const body = Object.fromEntries(await request.formData());
try {
const email = body.email.toString();
await locals.pocketBase.collection('users').requestPasswordReset(email);
return {
success: true
};
} catch (err) {
console.log('Error: ', err);
throw error(500, 'Something went wrong');
}
}
};

View file

@ -0,0 +1,64 @@
<script lang="ts">
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 { cn } from '$lib/utils';
import { CheckCircled } from 'radix-icons-svelte';
import * as Alert from '$lib/components/ui/alert';
let isLoading = false;
export let form;
</script>
<div class="lg:p-8">
<div class="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<div class="flex flex-col space-y-2 text-center">
<h1 class="text-2xl font-semibold tracking-tight">Reset password</h1>
<p class="text-sm text-muted-foreground">
We'll send you an email with a link to reset your password.
</p>
</div>
<div class={cn('grid gap-6')} {...$$restProps}>
<form
method="POST"
use:enhance={() => {
isLoading = true;
return async ({ update }) => {
isLoading = false;
update();
};
}}
>
<div class="grid gap-2">
<div class="grid gap-1">
<Label class="sr-only" for="email">Email</Label>
<Input
id="email"
name="email"
placeholder="name@example.com"
type="email"
autocapitalize="none"
autocomplete="email"
autocorrect="off"
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?.success}
<Alert.Root variant="default" class="mt-2">
<CheckCircled class="h-4 w-4" />
<Alert.Description>An email has been sent to reset your password.</Alert.Description>
</Alert.Root>
{/if}
</form>
</div>
</div>
</div>

View file

@ -0,0 +1,9 @@
import { redirect } from '@sveltejs/kit';
export const load = async ({ locals }: { locals: App.Locals }) => {
if (!locals.pocketBase.authStore.isValid) {
throw redirect(303, '/auth');
}
return {};
};

View file

@ -0,0 +1,12 @@
<script lang="ts">
import { Checkbox } from '$lib/components/ui/checkbox';
import type { HTMLButtonAttributes } from 'svelte/elements';
import type { Writable } from 'svelte/store';
type $$Props = HTMLButtonAttributes & {
checked: Writable<boolean>;
};
export let checked: Writable<boolean>;
</script>
<Checkbox bind:checked={$checked} {...$$restProps} />

View file

@ -0,0 +1,58 @@
<script lang="ts">
import { ArrowDown, ArrowUp, CaretSort } from 'radix-icons-svelte';
import { cn } from '$lib/utils';
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
let className: string | undefined | null = undefined;
export { className as class };
export let props: {
select: never;
sort: {
order: 'desc' | 'asc' | undefined;
toggle: (event: Event) => void;
clear: () => void;
disabled: boolean;
};
filter: never;
};
function handleAscSort(e: Event) {
if (props.sort.order === 'asc') {
return;
}
props.sort.toggle(e);
}
function handleDescSort(e: Event) {
if (props.sort.order === 'desc') {
return;
}
props.sort.toggle(e);
}
</script>
{#if !props.sort.disabled}
<div class={cn('flex items-center', className)}>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild let:builder>
<Button variant="ghost" builders={[builder]} class="-ml-3 h-8 data-[state=open]:bg-accent">
<slot />
{#if props.sort.order === 'desc'}
<ArrowDown class="ml-2 h-4 w-4" />
{:else if props.sort.order === 'asc'}
<ArrowUp class="ml-2 h-4 w-4" />
{:else}
<CaretSort class="ml-2 h-4 w-4" />
{/if}
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start">
<DropdownMenu.Item on:click={handleAscSort}>Asc</DropdownMenu.Item>
<DropdownMenu.Item on:click={handleDescSort}>Desc</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
{:else}
<slot />
{/if}

View file

@ -0,0 +1,96 @@
<script lang="ts">
import { PlusCircled, Check } from 'radix-icons-svelte';
import * as Command from '$lib/components/ui/command';
import * as Popover from '$lib/components/ui/popover';
import { Button } from '$lib/components/ui/button';
import { cn } from '$lib/utils';
import Separator from '$lib/components/ui/separator/separator.svelte';
import Badge from '$lib/components/ui/badge/badge.svelte';
import type { statuses } from '../(data)/data';
export let filterValues: string[] = [];
export let title: string;
export let options = [] as typeof statuses;
let open = false;
const handleSelect = (currentValue: string) => {
if (Array.isArray(filterValues) && filterValues.includes(currentValue)) {
filterValues = filterValues.filter((v) => v !== currentValue);
} else {
filterValues = [...(Array.isArray(filterValues) ? filterValues : []), currentValue];
}
};
</script>
<Popover.Root bind:open>
<Popover.Trigger asChild let:builder>
<Button builders={[builder]} variant="outline" size="sm" class="h-8 border-dashed">
<PlusCircled class="mr-2 h-4 w-4" />
{title}
{#if filterValues.length > 0}
<Separator orientation="vertical" class="mx-2 h-4" />
<Badge variant="secondary" class="rounded-sm px-1 font-normal lg:hidden">
{filterValues.length}
</Badge>
<div class="hidden space-x-1 lg:flex">
{#if filterValues.length > 2}
<Badge variant="secondary" class="rounded-sm px-1 font-normal">
{filterValues.length} Selected
</Badge>
{:else}
{#each filterValues as option}
<Badge variant="secondary" class="rounded-sm px-1 font-normal">
{option}
</Badge>
{/each}
{/if}
</div>
{/if}
</Button>
</Popover.Trigger>
<Popover.Content class="w-[200px] p-0" align="start" side="bottom">
<Command.Root>
<Command.Input placeholder={title} />
<Command.List>
<Command.Empty>No results found.</Command.Empty>
<Command.Group>
{#each options as option}
<Command.Item
value={option.value}
onSelect={(currentValue) => {
handleSelect(currentValue);
}}
>
<div
class={cn(
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
filterValues.includes(option.value)
? 'bg-primary text-primary-foreground'
: 'opacity-50 [&_svg]:invisible'
)}
>
<Check className={cn('h-4 w-4')} />
</div>
<span>
{option.label}
</span>
</Command.Item>
{/each}
</Command.Group>
{#if filterValues.length > 0}
<Command.Separator />
<Command.Item
class="justify-center text-center"
onSelect={() => {
filterValues = [];
}}
>
Clear filters
</Command.Item>
{/if}
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>

View file

@ -0,0 +1,86 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { ChevronRight, ChevronLeft, DoubleArrowRight, DoubleArrowLeft } from 'radix-icons-svelte';
import * as Select from '$lib/components/ui/select';
import type { Ticket } from '../(data)/schemas';
import type { AnyPlugins } from 'svelte-headless-table/plugins';
import type { TableViewModel } from 'svelte-headless-table';
import { defaultPageSize } from '.';
export let tableModel: TableViewModel<Ticket, AnyPlugins>;
const { pageRows, pluginStates, rows } = tableModel;
const { hasNextPage, hasPreviousPage, pageIndex, pageCount, pageSize } = pluginStates.page;
const { selectedDataIds } = pluginStates.select;
</script>
<div class="flex items-center justify-between px-2">
<div class="flex-1 text-sm text-muted-foreground">
{Object.keys($selectedDataIds).length} of{' '}
{$rows.length} row(s) selected.
</div>
<div class="flex items-center space-x-6 lg:space-x-8">
<div class="flex items-center space-x-2">
<p class="text-sm font-medium">Rows per page</p>
<Select.Root
onSelectedChange={(selected) => pageSize.set(Number(selected?.value))}
selected={{ value: defaultPageSize, label: defaultPageSize.toString() }}
>
<Select.Trigger class="w-[180px]">
<Select.Value placeholder="Select page size" />
</Select.Trigger>
<Select.Content>
<Select.Item value="10">10</Select.Item>
<Select.Item value="20">20</Select.Item>
<Select.Item value="30">30</Select.Item>
<Select.Item value="40">40</Select.Item>
<Select.Item value="50">50</Select.Item>
<Select.Item value="100">100</Select.Item>
</Select.Content>
</Select.Root>
</div>
<div class="flex w-[100px] items-center justify-center text-sm font-medium">
Page {$pageIndex + 1} of {$pageCount}
</div>
<div class="flex items-center space-x-2">
<Button
variant="outline"
class="hidden h-8 w-8 p-0 lg:flex"
on:click={() => ($pageIndex = 0)}
disabled={!$hasPreviousPage}
>
<span class="sr-only">Go to first page</span>
<DoubleArrowLeft size={15} />
</Button>
<Button
variant="outline"
class="h-8 w-8 p-0"
on:click={() => ($pageIndex = $pageIndex - 1)}
disabled={!$hasPreviousPage}
>
<span class="sr-only">Go to previous page</span>
<ChevronLeft size={15} />
</Button>
<Button
variant="outline"
class="h-8 w-8 p-0"
on:click={() => ($pageIndex = $pageIndex + 1)}
disabled={!$hasNextPage}
>
<span class="sr-only">Go to next page</span>
<ChevronRight size={15} />
</Button>
<Button
variant="outline"
class="hidden h-8 w-8 p-0 lg:flex"
on:click={() => ($pageIndex = Math.ceil($rows.length / $pageRows.length) - 1)}
disabled={!$hasNextPage}
>
<span class="sr-only">Go to last page</span>
<DoubleArrowRight size={15} />
</Button>
</div>
</div>
</div>

View file

@ -0,0 +1,15 @@
<script lang="ts">
import { priorities } from '../(data)/data';
export let value: string;
const priority = priorities.find((priority) => priority.value === value);
const Icon = priority?.icon;
</script>
{#if priority}
<div class="flex items-center">
{#if Icon}
<Icon class="mr-2 h-4 w-4 text-muted-foreground" />
{/if}
<span>{priority.label}</span>
</div>
{/if}

View file

@ -0,0 +1,46 @@
<script lang="ts">
import { DotsHorizontal } from 'radix-icons-svelte';
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { labels } from '../(data)/data';
import { ticketSchema, type Ticket } from '../(data)/schemas';
export let row: Ticket;
const ticket = ticketSchema.parse(row);
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild let:builder>
<Button
variant="ghost"
builders={[builder]}
class="flex h-8 w-8 p-0 data-[state=open]:bg-muted"
>
<DotsHorizontal class="h-4 w-4" />
<span class="sr-only">Open menu</span>
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content class="w-[160px]" align="end">
<DropdownMenu.Item>Edit</DropdownMenu.Item>
<DropdownMenu.Item>Make a copy</DropdownMenu.Item>
<DropdownMenu.Item>Favorite</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Labels</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.RadioGroup value={ticket.label}>
{#each labels as label}
<DropdownMenu.RadioItem value={label.value}>
{label.label}
</DropdownMenu.RadioItem>
{/each}
</DropdownMenu.RadioGroup>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Separator />
<DropdownMenu.Item>
Delete
<DropdownMenu.Shortcut>⌘⌫</DropdownMenu.Shortcut>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>

View file

@ -0,0 +1,16 @@
<script lang="ts">
import { statuses } from '../(data)/data';
export let value: string;
const status = statuses.find((status) => status.value === value);
const Icon = status?.icon;
</script>
{#if status}
<div class="flex w-[100px] items-center">
{#if Icon}
<Icon class="mr-2 h-4 w-4 text-muted-foreground" />
{/if}
<span>{status.label}</span>
</div>
{/if}

View file

@ -0,0 +1,17 @@
<script lang="ts">
import { Badge } from '$lib/components/ui/badge';
import { labels } from '../(data)/data';
export let value: string;
export let labelValue: string;
const label = labels.find((label) => label.value === labelValue);
</script>
<div class="flex space-x-2">
{#if label}
<Badge variant="outline">{label.label}</Badge>
{/if}
<span class="max-w-[500px] truncate font-medium">
{value}
</span>
</div>

View file

@ -0,0 +1,69 @@
<script lang="ts">
import { Input } from '$lib/components/ui/input';
import { DataTableFacetedFilter, DataTableViewOptions } from '.';
import type { Ticket } from '../(data)/schemas';
import Button from '$lib/components/ui/button/button.svelte';
import { Cross2 } from 'radix-icons-svelte';
import { statuses, priorities } from '../(data)/data';
import type { Writable } from 'svelte/store';
import type { TableViewModel } from 'svelte-headless-table';
import type { AnyPlugins } from 'svelte-headless-table/plugins';
export let tableModel: TableViewModel<Ticket, AnyPlugins>;
const { pluginStates } = tableModel;
const {
filterValue
}: {
filterValue: Writable<string>;
} = pluginStates.filter;
const {
filterValues
}: {
filterValues: Writable<{
status: string[];
priority: string[];
}>;
} = pluginStates.colFilter;
$: showReset = Object.values({ ...$filterValues, $filterValue }).some((v) => v.length > 0);
</script>
<div class="flex items-center justify-between">
<div class="flex flex-1 items-center space-x-2">
<Input
placeholder="Filter tickets..."
class="h-8 w-[150px] lg:w-[250px]"
type="search"
bind:value={$filterValue}
/>
<DataTableFacetedFilter
bind:filterValues={$filterValues.status}
title="Status"
options={statuses}
/>
<DataTableFacetedFilter
bind:filterValues={$filterValues.priority}
title="Priority"
options={priorities}
/>
{#if showReset}
<Button
on:click={() => {
$filterValue = '';
$filterValues.status = [];
$filterValues.priority = [];
}}
variant="ghost"
class="h-8 px-2 lg:px-3"
>
Reset
<Cross2 class="ml-2 h-4 w-4" />
</Button>
{/if}
</div>
<DataTableViewOptions {tableModel} />
</div>

View file

@ -0,0 +1,42 @@
<script lang="ts">
import { MixerHorizontal } from 'radix-icons-svelte';
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import type { Ticket } from '../(data)/schemas';
import type { TableViewModel } from 'svelte-headless-table';
import type { AnyPlugins } from 'svelte-headless-table/plugins';
export let tableModel: TableViewModel<Ticket, AnyPlugins>;
const { pluginStates, flatColumns } = tableModel;
const { hiddenColumnIds } = pluginStates.hide;
const ids = flatColumns.map((col: { id: string }) => col.id);
let hideForId = Object.fromEntries(ids.map((id: string) => [id, true]));
$: $hiddenColumnIds = Object.entries(hideForId)
.filter(([, hide]) => !hide)
.map(([id]) => id);
const hidableCols = ['title', 'status', 'priority'];
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild let:builder>
<Button variant="outline" size="sm" class="ml-auto hidden h-8 lg:flex" builders={[builder]}>
<MixerHorizontal class="mr-2 h-4 w-4" />
View
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Label>Toggle columns</DropdownMenu.Label>
<DropdownMenu.Separator />
{#each flatColumns as col}
{#if hidableCols.includes(col.id)}
<DropdownMenu.CheckboxItem bind:checked={hideForId[col.id]}>
{col.header}
</DropdownMenu.CheckboxItem>
{/if}
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>

View file

@ -0,0 +1,216 @@
<script lang="ts">
import { get, readable } from 'svelte/store';
import { Render, Subscribe, createRender, createTable } from 'svelte-headless-table';
import * as Table from '$lib/components/ui/table';
import {
addColumnFilters,
addHiddenColumns,
addPagination,
addSelectedRows,
addSortBy,
addTableFilter
} from 'svelte-headless-table/plugins';
import {
DataTableCheckbox,
DataTableTitleCell,
DataTableStatusCell,
DataTableRowActions,
DataTablePriorityCell,
DataTableColumnHeader,
DataTableToolbar,
DataTablePagination,
defaultPageSize
} from '.';
import type { Ticket } from '../(data)/schemas';
export let data: Ticket[];
const table = createTable(readable(data), {
select: addSelectedRows(),
sort: addSortBy({
toggleOrder: ['asc', 'desc']
}),
page: addPagination({ initialPageSize: defaultPageSize }),
filter: addTableFilter({
fn: ({ filterValue, value }) => {
return value.toLowerCase().includes(filterValue.toLowerCase());
}
}),
colFilter: addColumnFilters(),
hide: addHiddenColumns()
});
const columns = table.createColumns([
table.display({
id: 'select',
header: (_, { pluginStates }) => {
const { allPageRowsSelected } = pluginStates.select;
return createRender(DataTableCheckbox, {
checked: allPageRowsSelected,
'aria-label': 'Select all'
});
},
cell: ({ row }, { pluginStates }) => {
const { getRowState } = pluginStates.select;
const { isSelected } = getRowState(row);
return createRender(DataTableCheckbox, {
checked: isSelected,
'aria-label': 'Select row',
class: 'translate-y-[2px]'
});
},
plugins: {
sort: {
disable: true
}
}
}),
table.column({
accessor: 'id',
header: () => {
return 'Ticket ID';
},
id: 'ticket',
plugins: {
sort: {
disable: true
}
}
}),
table.column({
accessor: 'title',
header: 'Title',
id: 'title',
cell: ({ value, row }) => {
if (row.isData()) {
return createRender(DataTableTitleCell, {
value,
labelValue: row.original.label
});
}
return value;
}
}),
table.column({
accessor: 'status',
header: 'Status',
id: 'status',
cell: ({ value }) => {
return createRender(DataTableStatusCell, {
value
});
},
plugins: {
colFilter: {
fn: ({ filterValue, value }) => {
if (filterValue.length === 0) return true;
if (!Array.isArray(filterValue) || typeof value !== 'string') return true;
return filterValue.some((filter) => {
return value.includes(filter);
});
},
initialFilterValue: [],
render: ({ filterValue }) => {
return get(filterValue);
}
}
}
}),
table.column({
accessor: 'priority',
id: 'priority',
header: 'Priority',
cell: ({ value }) => {
return createRender(DataTablePriorityCell, {
value
});
},
plugins: {
colFilter: {
fn: ({ filterValue, value }) => {
if (filterValue.length === 0) return true;
if (!Array.isArray(filterValue) || typeof value !== 'string') return true;
return filterValue.some((filter) => {
return value.includes(filter);
});
},
initialFilterValue: [],
render: ({ filterValue }) => {
return get(filterValue);
}
}
}
}),
table.display({
id: 'actions',
header: () => {
return '';
},
cell: ({ row }) => {
if (row.isData() && row.original) {
return createRender(DataTableRowActions, {
row: row.original
});
}
return '';
}
})
]);
const tableModel = table.createViewModel(columns);
const { headerRows, pageRows, tableAttrs, tableBodyAttrs } = tableModel;
</script>
<div class="space-y-4">
<DataTableToolbar {tableModel} />
<div class="rounded-md border">
<Table.Root {...$tableAttrs}>
<Table.Header>
{#each $headerRows as headerRow}
<Subscribe rowAttrs={headerRow.attrs()}>
<Table.Row>
{#each headerRow.cells as cell (cell.id)}
<Subscribe attrs={cell.attrs()} let:attrs props={cell.props()} let:props>
<Table.Head {...attrs}>
{#if cell.id !== 'select' && cell.id !== 'actions'}
<DataTableColumnHeader {props}
><Render of={cell.render()} /></DataTableColumnHeader
>
{:else}
<Render of={cell.render()} />
{/if}
</Table.Head>
</Subscribe>
{/each}
</Table.Row>
</Subscribe>
{/each}
</Table.Header>
<Table.Body {...$tableBodyAttrs}>
{#each $pageRows as row (row.id)}
<Subscribe rowAttrs={row.attrs()} let:rowAttrs>
<Table.Row {...rowAttrs}>
{#each row.cells as cell (cell.id)}
<Subscribe attrs={cell.attrs()} let:attrs>
<Table.Cell {...attrs}>
{#if cell.id === 'ticket'}
<div class="w-[80px]">
<Render of={cell.render()} />
</div>
{:else}
<Render of={cell.render()} />
{/if}
</Table.Cell>
</Subscribe>
{/each}
</Table.Row>
</Subscribe>
{/each}
</Table.Body>
</Table.Root>
</div>
<DataTablePagination {tableModel} />
</div>

View file

@ -0,0 +1,12 @@
export { default as DataTableCheckbox } from './data-table-checkbox.svelte';
export { default as DataTableTitleCell } from './data-table-title-cell.svelte';
export { default as DataTableStatusCell } from './data-table-status-cell.svelte';
export { default as DataTableRowActions } from './data-table-row-actions.svelte';
export { default as DataTablePriorityCell } from './data-table-priority-cell.svelte';
export { default as DataTableColumnHeader } from './data-table-column-header.svelte';
export { default as DataTableToolbar } from './data-table-toolbar.svelte';
export { default as DataTablePagination } from './data-table-pagination.svelte';
export { default as DataTableViewOptions } from './data-table-view-options.svelte';
export { default as DataTableFacetedFilter } from './data-table-faceted-filter.svelte';
export const defaultPageSize = 15;

View file

@ -0,0 +1,71 @@
import {
ArrowDown,
ArrowRight,
ArrowUp,
CheckCircled,
Circle,
CrossCircled,
QuestionMarkCircled,
Stopwatch
} from 'radix-icons-svelte';
export const labels = [
{
value: 'bug',
label: 'Incident'
},
{
value: 'feature',
label: 'Change'
},
{
value: 'documentation',
label: 'Information'
}
];
export const statuses = [
{
value: 'backlog',
label: 'Backlog',
icon: QuestionMarkCircled
},
{
value: 'todo',
label: 'Todo',
icon: Circle
},
{
value: 'in progress',
label: 'In Progress',
icon: Stopwatch
},
{
value: 'done',
label: 'Done',
icon: CheckCircled
},
{
value: 'canceled',
label: 'Canceled',
icon: CrossCircled
}
];
export const priorities = [
{
label: 'Low',
value: 'low',
icon: ArrowDown
},
{
label: 'Medium',
value: 'medium',
icon: ArrowRight
},
{
label: 'High',
value: 'high',
icon: ArrowUp
}
];

View file

@ -0,0 +1,18 @@
import { z } from 'zod';
// We're keeping a simple non-relational schema here.
// IRL, you will have a schema for your data models.
export const ticketSchema = z.object({
id: z.string(),
// solution: z.string().optional(),
title: z.string(),
status: z.string(),
label: z.string(),
// createdAt: z.date(),
// updatedAt: z.date(),
priority: z.string() // z.number().min(0).max(4),
// source: z.number(),
// assignee: z.string(),
});
export type Ticket = z.infer<typeof ticketSchema>;

View file

@ -0,0 +1,802 @@
[
{
"id": "TASK-8782",
"title": "You can't compress the program without quantifying the open-source SSD pixel!",
"status": "in progress",
"label": "documentation",
"priority": "medium",
"assignee": ""
},
{
"id": "TASK-7878",
"title": "Try to calculate the EXE feed, maybe it will index the multi-byte pixel!",
"status": "backlog",
"label": "documentation",
"priority": "medium",
"assignee": ""
},
{
"id": "TASK-7839",
"title": "We need to bypass the neural TCP card!",
"status": "todo",
"label": "bug",
"priority": "high",
"assignee": ""
},
{
"id": "TASK-5562",
"title": "The SAS interface is down, bypass the open-source pixel so we can back up the PNG bandwidth!",
"status": "backlog",
"label": "feature",
"priority": "medium",
"assignee": ""
},
{
"id": "TASK-8686",
"title": "I'll parse the wireless SSL protocol, that should driver the API panel!",
"status": "canceled",
"label": "feature",
"priority": "medium",
"assignee": ""
},
{
"id": "TASK-1280",
"title": "Use the digital TLS panel, then you can transmit the haptic system!",
"status": "done",
"label": "bug",
"priority": "high",
"assignee": ""
},
{
"id": "TASK-7262",
"title": "The UTF8 application is down, parse the neural bandwidth so we can back up the PNG firewall!",
"status": "done",
"label": "feature",
"priority": "high",
"assignee": ""
},
{
"id": "TASK-1138",
"title": "Generating the driver won't do anything, we need to quantify the 1080p SMTP bandwidth!",
"status": "in progress",
"label": "feature",
"priority": "medium",
"assignee": ""
},
{
"id": "TASK-7184",
"title": "We need to program the back-end THX pixel!",
"status": "todo",
"label": "feature",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-5160",
"title": "Calculating the bus won't do anything, we need to navigate the back-end JSON protocol!",
"status": "in progress",
"label": "documentation",
"priority": "high",
"assignee": ""
},
{
"id": "TASK-5618",
"title": "Generating the driver won't do anything, we need to index the online SSL application!",
"status": "done",
"label": "documentation",
"priority": "medium",
"assignee": ""
},
{
"id": "TASK-6699",
"title": "I'll transmit the wireless JBOD capacitor, that should hard drive the SSD feed!",
"status": "backlog",
"label": "documentation",
"priority": "medium",
"assignee": ""
},
{
"id": "TASK-2858",
"title": "We need to override the online UDP bus!",
"status": "backlog",
"label": "bug",
"priority": "medium",
"assignee": ""
},
{
"id": "TASK-9864",
"title": "I'll reboot the 1080p FTP panel, that should matrix the HEX hard drive!",
"status": "done",
"label": "bug",
"priority": "high",
"assignee": ""
},
{
"id": "TASK-8404",
"title": "We need to generate the virtual HEX alarm!",
"status": "in progress",
"label": "bug",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-5365",
"title": "Backing up the pixel won't do anything, we need to transmit the primary IB array!",
"status": "in progress",
"label": "documentation",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-1780",
"title": "The CSS feed is down, index the bluetooth transmitter so we can compress the CLI protocol!",
"status": "todo",
"label": "documentation",
"priority": "high",
"assignee": ""
},
{
"id": "TASK-6938",
"title": "Use the redundant SCSI application, then you can hack the optical alarm!",
"status": "todo",
"label": "documentation",
"priority": "high",
"assignee": ""
},
{
"id": "TASK-9885",
"title": "We need to compress the auxiliary VGA driver!",
"status": "backlog",
"label": "bug",
"priority": "high",
"assignee": ""
},
{
"id": "TASK-3216",
"title": "Transmitting the transmitter won't do anything, we need to compress the virtual HDD sensor!",
"status": "backlog",
"label": "documentation",
"priority": "medium",
"assignee": ""
},
{
"id": "TASK-9285",
"title": "The IP monitor is down, copy the haptic alarm so we can generate the HTTP transmitter!",
"status": "todo",
"label": "bug",
"priority": "high",
"assignee": ""
},
{
"id": "TASK-1024",
"title": "Overriding the microchip won't do anything, we need to transmit the digital OCR transmitter!",
"status": "in progress",
"label": "documentation",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-7068",
"title": "You can't generate the capacitor without indexing the wireless HEX pixel!",
"status": "canceled",
"label": "bug",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-6502",
"title": "Navigating the microchip won't do anything, we need to bypass the back-end SQL bus!",
"status": "todo",
"label": "bug",
"priority": "high",
"assignee": ""
},
{
"id": "TASK-5326",
"title": "We need to hack the redundant UTF8 transmitter!",
"status": "todo",
"label": "bug",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-6274",
"title": "Use the virtual PCI circuit, then you can parse the bluetooth alarm!",
"status": "canceled",
"label": "documentation",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-1571",
"title": "I'll input the neural DRAM circuit, that should protocol the SMTP interface!",
"status": "in progress",
"label": "feature",
"priority": "medium",
"assignee": ""
},
{
"id": "TASK-9518",
"title": "Compressing the interface won't do anything, we need to compress the online SDD matrix!",
"status": "canceled",
"label": "documentation",
"priority": "medium",
"assignee": ""
},
{
"id": "TASK-5581",
"title": "I'll synthesize the digital COM pixel, that should transmitter the UTF8 protocol!",
"status": "backlog",
"label": "documentation",
"priority": "high",
"assignee": ""
},
{
"id": "TASK-2197",
"title": "Parsing the feed won't do anything, we need to copy the bluetooth DRAM bus!",
"status": "todo",
"label": "documentation",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-8484",
"title": "We need to parse the solid state UDP firewall!",
"status": "in progress",
"label": "bug",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-9892",
"title": "If we back up the application, we can get to the UDP application through the multi-byte THX capacitor!",
"status": "done",
"label": "documentation",
"priority": "high",
"assignee": ""
},
{
"id": "TASK-9616",
"title": "We need to synthesize the cross-platform ASCII pixel!",
"status": "in progress",
"label": "feature",
"priority": "medium",
"assignee": ""
},
{
"id": "TASK-9744",
"title": "Use the back-end IP card, then you can input the solid state hard drive!",
"status": "done",
"label": "documentation",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-1376",
"title": "Generating the alarm won't do anything, we need to generate the mobile IP capacitor!",
"status": "backlog",
"label": "documentation",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-7382",
"title": "If we back up the firewall, we can get to the RAM alarm through the primary UTF8 pixel!",
"status": "todo",
"label": "feature",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-2290",
"title": "I'll compress the virtual JSON panel, that should application the UTF8 bus!",
"status": "canceled",
"label": "documentation",
"priority": "high",
"assignee": ""
},
{
"id": "TASK-1533",
"title": "You can't input the firewall without overriding the wireless TCP firewall!",
"status": "done",
"label": "bug",
"priority": "high",
"assignee": ""
},
{
"id": "TASK-4920",
"title": "Bypassing the hard drive won't do anything, we need to input the bluetooth JSON program!",
"status": "in progress",
"label": "bug",
"priority": "high",
"assignee": ""
},
{
"id": "TASK-5168",
"title": "If we synthesize the bus, we can get to the IP panel through the virtual TLS array!",
"status": "in progress",
"label": "feature",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-7103",
"title": "We need to parse the multi-byte EXE bandwidth!",
"status": "canceled",
"label": "feature",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-4314",
"title": "If we compress the program, we can get to the XML alarm through the multi-byte COM matrix!",
"status": "in progress",
"label": "bug",
"priority": "high",
"assignee": ""
},
{
"id": "TASK-3415",
"title": "Use the cross-platform XML application, then you can quantify the solid state feed!",
"status": "todo",
"label": "feature",
"priority": "high",
"assignee": ""
},
{
"id": "TASK-8339",
"title": "Try to calculate the DNS interface, maybe it will input the bluetooth capacitor!",
"status": "in progress",
"label": "feature",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-6995",
"title": "Try to hack the XSS bandwidth, maybe it will override the bluetooth matrix!",
"status": "todo",
"label": "feature",
"priority": "high",
"assignee": ""
},
{
"id": "TASK-8053",
"title": "If we connect the program, we can get to the UTF8 matrix through the digital UDP protocol!",
"status": "todo",
"label": "feature",
"priority": "medium",
"assignee": ""
},
{
"id": "TASK-4336",
"title": "If we synthesize the microchip, we can get to the SAS sensor through the optical UDP program!",
"status": "todo",
"label": "documentation",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-8790",
"title": "I'll back up the optical COM alarm, that should alarm the RSS capacitor!",
"status": "done",
"label": "bug",
"priority": "medium",
"assignee": ""
},
{
"id": "TASK-8980",
"title": "Try to navigate the SQL transmitter, maybe it will back up the virtual firewall!",
"status": "canceled",
"label": "bug",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-7342",
"title": "Use the neural CLI card, then you can parse the online port!",
"status": "backlog",
"label": "documentation",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-5608",
"title": "I'll hack the haptic SSL program, that should bus the UDP transmitter!",
"status": "canceled",
"label": "documentation",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-1606",
"title": "I'll generate the bluetooth PNG firewall, that should pixel the SSL driver!",
"status": "done",
"label": "feature",
"priority": "medium",
"assignee": ""
},
{
"id": "TASK-7872",
"title": "Transmitting the circuit won't do anything, we need to reboot the 1080p RSS monitor!",
"status": "canceled",
"label": "feature",
"priority": "medium",
"assignee": ""
},
{
"id": "TASK-4167",
"title": "Use the cross-platform SMS circuit, then you can synthesize the optical feed!",
"status": "canceled",
"label": "bug",
"priority": "medium",
"assignee": ""
},
{
"id": "TASK-9581",
"title": "You can't index the port without hacking the cross-platform XSS monitor!",
"status": "backlog",
"label": "documentation",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-8806",
"title": "We need to bypass the back-end SSL panel!",
"status": "done",
"label": "bug",
"priority": "medium",
"assignee": ""
},
{
"id": "TASK-6542",
"title": "Try to quantify the RSS firewall, maybe it will quantify the open-source system!",
"status": "done",
"label": "feature",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-6806",
"title": "The VGA protocol is down, reboot the back-end matrix so we can parse the CSS panel!",
"status": "canceled",
"label": "documentation",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-9549",
"title": "You can't bypass the bus without connecting the neural JBOD bus!",
"status": "todo",
"label": "feature",
"priority": "high",
"assignee": ""
},
{
"id": "TASK-1075",
"title": "Backing up the driver won't do anything, we need to parse the redundant RAM pixel!",
"status": "done",
"label": "feature",
"priority": "medium",
"assignee": ""
},
{
"id": "TASK-1427",
"title": "Use the auxiliary PCI circuit, then you can calculate the cross-platform interface!",
"status": "done",
"label": "documentation",
"priority": "high",
"assignee": ""
},
{
"id": "TASK-1907",
"title": "Hacking the circuit won't do anything, we need to back up the online DRAM system!",
"status": "todo",
"label": "documentation",
"priority": "high",
"assignee": ""
},
{
"id": "TASK-4309",
"title": "If we generate the system, we can get to the TCP sensor through the optical GB pixel!",
"status": "backlog",
"label": "bug",
"priority": "medium",
"assignee": ""
},
{
"id": "TASK-3973",
"title": "I'll parse the back-end ADP array, that should bandwidth the RSS bandwidth!",
"status": "todo",
"label": "feature",
"priority": "medium",
"assignee": ""
},
{
"id": "TASK-7962",
"title": "Use the wireless RAM program, then you can hack the cross-platform feed!",
"status": "canceled",
"label": "bug",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-3360",
"title": "You can't quantify the program without synthesizing the neural OCR interface!",
"status": "done",
"label": "feature",
"priority": "medium",
"assignee": ""
},
{
"id": "TASK-9887",
"title": "Use the auxiliary ASCII sensor, then you can connect the solid state port!",
"status": "backlog",
"label": "bug",
"priority": "medium",
"assignee": ""
},
{
"id": "TASK-3649",
"title": "I'll input the virtual USB system, that should circuit the DNS monitor!",
"status": "in progress",
"label": "feature",
"priority": "medium",
"assignee": ""
},
{
"id": "TASK-3586",
"title": "If we quantify the circuit, we can get to the CLI feed through the mobile SMS hard drive!",
"status": "in progress",
"label": "bug",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-5150",
"title": "I'll hack the wireless XSS port, that should transmitter the IP interface!",
"status": "canceled",
"label": "feature",
"priority": "medium",
"assignee": ""
},
{
"id": "TASK-3652",
"title": "The SQL interface is down, override the optical bus so we can program the ASCII interface!",
"status": "backlog",
"label": "feature",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-6884",
"title": "Use the digital PCI circuit, then you can synthesize the multi-byte microchip!",
"status": "canceled",
"label": "feature",
"priority": "high",
"assignee": ""
},
{
"id": "TASK-1591",
"title": "We need to connect the mobile XSS driver!",
"status": "in progress",
"label": "feature",
"priority": "high",
"assignee": ""
},
{
"id": "TASK-3802",
"title": "Try to override the ASCII protocol, maybe it will parse the virtual matrix!",
"status": "in progress",
"label": "feature",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-7253",
"title": "Programming the capacitor won't do anything, we need to bypass the neural IB hard drive!",
"status": "backlog",
"label": "bug",
"priority": "high",
"assignee": ""
},
{
"id": "TASK-9739",
"title": "We need to hack the multi-byte HDD bus!",
"status": "done",
"label": "documentation",
"priority": "medium",
"assignee": ""
},
{
"id": "TASK-4424",
"title": "Try to hack the HEX alarm, maybe it will connect the optical pixel!",
"status": "in progress",
"label": "documentation",
"priority": "medium",
"assignee": ""
},
{
"id": "TASK-3922",
"title": "You can't back up the capacitor without generating the wireless PCI program!",
"status": "backlog",
"label": "bug",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-4921",
"title": "I'll index the open-source IP feed, that should system the GB application!",
"status": "canceled",
"label": "bug",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-5814",
"title": "We need to calculate the 1080p AGP feed!",
"status": "backlog",
"label": "bug",
"priority": "high",
"assignee": ""
},
{
"id": "TASK-2645",
"title": "Synthesizing the system won't do anything, we need to navigate the multi-byte HDD firewall!",
"status": "todo",
"label": "documentation",
"priority": "medium",
"assignee": ""
},
{
"id": "TASK-4535",
"title": "Try to copy the JSON circuit, maybe it will connect the wireless feed!",
"status": "in progress",
"label": "feature",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-4463",
"title": "We need to copy the solid state AGP monitor!",
"status": "done",
"label": "documentation",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-9745",
"title": "If we connect the protocol, we can get to the GB system through the bluetooth PCI microchip!",
"status": "canceled",
"label": "feature",
"priority": "high",
"assignee": ""
},
{
"id": "TASK-2080",
"title": "If we input the bus, we can get to the RAM matrix through the auxiliary RAM card!",
"status": "todo",
"label": "bug",
"priority": "medium",
"assignee": ""
},
{
"id": "TASK-3838",
"title": "I'll bypass the online TCP application, that should panel the AGP system!",
"status": "backlog",
"label": "bug",
"priority": "high",
"assignee": ""
},
{
"id": "TASK-1340",
"title": "We need to navigate the virtual PNG circuit!",
"status": "todo",
"label": "bug",
"priority": "medium",
"assignee": ""
},
{
"id": "TASK-6665",
"title": "If we parse the monitor, we can get to the SSD hard drive through the cross-platform AGP alarm!",
"status": "canceled",
"label": "feature",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-7585",
"title": "If we calculate the hard drive, we can get to the SSL program through the multi-byte CSS microchip!",
"status": "backlog",
"label": "feature",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-6319",
"title": "We need to copy the multi-byte SCSI program!",
"status": "backlog",
"label": "bug",
"priority": "high",
"assignee": ""
},
{
"id": "TASK-4369",
"title": "Try to input the SCSI bus, maybe it will generate the 1080p pixel!",
"status": "backlog",
"label": "bug",
"priority": "high",
"assignee": ""
},
{
"id": "TASK-9035",
"title": "We need to override the solid state PNG array!",
"status": "canceled",
"label": "documentation",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-3970",
"title": "You can't index the transmitter without quantifying the haptic ASCII card!",
"status": "todo",
"label": "documentation",
"priority": "medium",
"assignee": ""
},
{
"id": "TASK-4473",
"title": "You can't bypass the protocol without overriding the neural RSS program!",
"status": "todo",
"label": "documentation",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-4136",
"title": "You can't hack the hard drive without hacking the primary JSON program!",
"status": "canceled",
"label": "bug",
"priority": "medium",
"assignee": ""
},
{
"id": "TASK-3939",
"title": "Use the back-end SQL firewall, then you can connect the neural hard drive!",
"status": "done",
"label": "feature",
"priority": "low",
"assignee": ""
},
{
"id": "TASK-2007",
"title": "I'll input the back-end USB protocol, that should bandwidth the PCI system!",
"status": "backlog",
"label": "bug",
"priority": "high",
"assignee": ""
},
{
"id": "TASK-7516",
"title": "Use the primary SQL program, then you can generate the auxiliary transmitter!",
"status": "done",
"label": "documentation",
"priority": "medium",
"assignee": ""
},
{
"id": "TASK-6906",
"title": "Try to back up the DRAM system, maybe it will reboot the online transmitter!",
"status": "done",
"label": "feature",
"priority": "high",
"assignee": ""
},
{
"id": "TASK-5207",
"title": "The SMS interface is down, copy the bluetooth bus so we can quantify the VGA card!",
"status": "in progress",
"label": "bug",
"priority": "low",
"assignee": ""
}
]

View file

@ -0,0 +1,14 @@
<script>
import Separator from '$lib/components/ui/separator/separator.svelte';
</script>
<div class="space-y-6 p-10 pb-16 md:block">
<div class="space-y-0.5">
<h2 class="text-2xl font-bold tracking-tight">Dashboard</h2>
<p class="text-muted-foreground">
Utilize the filtering options and actions to manage your tickets.
</p>
</div>
<Separator class="my-6" />
<slot />
</div>

View file

@ -0,0 +1,8 @@
<script lang="ts">
import DataTable from './(components)/data-table.svelte';
import ticketData from './(data)/tickets.json';
</script>
<div class="space-y-6">
<DataTable data={ticketData} />
</div>

View file

@ -0,0 +1,85 @@
<script lang="ts" context="module">
const MAX_IMAGE_SIZE = 5;
const ACCEPTED_IMAGE_TYPES = [
'image/jpeg',
'image/png',
'image/svg+xml',
'image/gif',
'image/webp'
];
import { z } from 'zod';
const sizeInMB = (sizeInBytes: number, decimalsNum = 2) => {
const result = sizeInBytes / (1024 * 1024);
return +result.toFixed(decimalsNum);
};
export const avatarFormSchema = z.object({
avatar: z.any()
// .custom<FileList>()
// .refine((files) => {
// return Array.from(files ?? []).length !== 0;
// }, 'Image is required')
// .refine((files) => {
// return Array.from(files ?? []).every((file) => sizeInMB(file.size) <= MAX_IMAGE_SIZE);
// }, `The maximum image size is ${MAX_IMAGE_SIZE}MB`)
// .refine((files) => {
// return Array.from(files ?? []).every((file) => ACCEPTED_IMAGE_TYPES.includes(file.type));
// }, 'File type is not supported')
});
export type AvatarFormSchema = typeof avatarFormSchema;
</script>
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import * as Form from '$lib/components/ui/form';
import type { SuperValidated } from 'sveltekit-superforms';
import type { LayoutData } from '../../$types';
import * as Avatar from '$lib/components/ui/avatar';
import { toast } from 'svelte-sonner';
import type { FormOptions } from 'formsnap';
export let avatarFormSchemaData: SuperValidated<AvatarFormSchema>;
export let user: LayoutData['user'];
export let debug: boolean;
const options: FormOptions<AvatarFormSchema> = {
onSubmit() {
toast.info('Uploading avatar...');
},
onResult({ result }) {
if (result.status === 200) toast.success('Your avatar has been updated!');
if (result.status === 400) toast.error('There was an error updating your avatar.');
},
dataType: 'form'
};
</script>
<Card.Root>
<Card.Header>
<Card.Title>Avatar image</Card.Title>
<Card.Description>
This is the image that will be displayed on your profile, in dashboards and in emails.
</Card.Description>
</Card.Header>
<Card.Content>
<Form.Root
{options}
form={avatarFormSchemaData}
schema={avatarFormSchema}
let:config
action="?/avatar"
method="POST"
class="space-y-2"
{debug}
><Form.Item>
<Form.Field {config} name="avatar">
<Form.Label class="sr-only">Avatar</Form.Label>
<Avatar.Root class="aspect-square h-auto w-full">
<Avatar.Image src={user?.avatarUrl} alt={user?.name} />
<Avatar.Fallback class="text-8xl">{user?.initials}</Avatar.Fallback>
</Avatar.Root>
<Form.Input type="file" />
<Form.Validation />
</Form.Field>
</Form.Item>
<Form.Button>Update avatar</Form.Button>
</Form.Root>
</Card.Content>
</Card.Root>

View file

@ -0,0 +1,104 @@
<script lang="ts" context="module">
import { z } from 'zod';
export const emailRequestFormSchema = z.object({
newEmail: z.string().email()
});
export const emailConfirmFormSchema = z.object({
token: z.string(),
password: z.string()
});
export type EmailRequestFormSchema = typeof emailRequestFormSchema;
export type EmailConfirmFormSchema = typeof emailConfirmFormSchema;
</script>
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import * as Form from '$lib/components/ui/form';
import { toast } from 'svelte-sonner';
import type { SuperValidated } from 'sveltekit-superforms';
import type { LayoutData } from '../$types';
import type { FormOptions } from 'formsnap';
export let emailRequestFormSchemaData: SuperValidated<EmailRequestFormSchema>;
export let emailConfirmFormSchemaData: SuperValidated<EmailConfirmFormSchema>;
const requestOptions: FormOptions<EmailRequestFormSchema> = {
onSubmit() {
toast.info('Requesting token...');
},
onResult({ result }) {
if (result.status === 200) toast.success('Token sent! Check your email.');
if (result.status === 400)
toast.error('There was an error sending the token. Is this email already in use?');
}
};
const confirmOptions: FormOptions<EmailConfirmFormSchema> = {
onSubmit() {
toast.info('Changing email...');
},
onResult({ result }) {
console.log(result);
if (result.status === 200) toast.success('Your email has been changed!');
if (result.status === 400)
toast.error('There was an error changing your email. Is the token correct?');
}
};
export let user: LayoutData['user'];
export let debug: boolean;
</script>
<Card.Root>
<Card.Header>
<Card.Title>Modify your email</Card.Title>
<Card.Description
>Use a different email by requesting a token and entering it for verification below.</Card.Description
>
</Card.Header>
<Card.Content>
<Form.Root
schema={emailRequestFormSchema}
form={emailRequestFormSchemaData}
options={requestOptions}
let:config
method="POST"
action="?/emailRequest"
class="space-y-8"
{debug}
>
<Form.Item>
<Form.Field {config} name="newEmail">
<Form.Label>New email</Form.Label>
<div class="flex space-x-2">
<Form.Input placeholder={user?.email} />
<Form.Button variant="secondary">Request token</Form.Button>
</div>
<Form.Validation />
</Form.Field>
</Form.Item>
</Form.Root>
<Form.Root
options={confirmOptions}
form={emailConfirmFormSchemaData}
schema={emailConfirmFormSchema}
let:config
action="?/emailConfirm"
method="POST"
class="space-y-2"
{debug}
>
<Form.Item>
<Form.Field {config} name="token">
<Form.Label>Email verification token</Form.Label>
<Form.Input />
<Form.Validation />
</Form.Field>
</Form.Item>
<Form.Item>
<Form.Field {config} name="password">
<Form.Label>Password</Form.Label>
<Form.Input />
<Form.Validation />
</Form.Field>
</Form.Item>
<Form.Button>Confirm email change</Form.Button>
</Form.Root>
</Card.Content>
</Card.Root>

View file

@ -0,0 +1,79 @@
<script lang="ts" context="module">
import { z } from 'zod';
const emptyStringToUndefined = z.literal('').transform(() => undefined);
export const nameFormSchema = z.object({
name: z
.string()
.min(2, 'Name must be at least 2 characters.')
.max(30, 'Name must not be longer than 30 characters')
.optional()
.or(emptyStringToUndefined),
username: z
.string()
.min(2, 'Username must be at least 2 characters.')
.max(30, 'Username must not be longer than 30 characters')
.optional()
.or(emptyStringToUndefined)
});
export type NameFormSchema = typeof nameFormSchema;
</script>
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import * as Form from '$lib/components/ui/form';
import type { SuperValidated } from 'sveltekit-superforms';
import type { LayoutData } from '../$types';
import type { FormOptions } from 'formsnap';
import { toast } from 'svelte-sonner';
export let nameFormSchemaData: SuperValidated<NameFormSchema>;
export let user: LayoutData['user'];
export let debug: boolean;
const options: FormOptions<NameFormSchema> = {
onSubmit() {
toast.info('Updating name...');
},
onResult({ result }) {
if (result.status === 200) toast.success('Your name has been updated!');
if (result.status === 400) toast.error('There was an error updating your name.');
},
dataType: 'form'
};
</script>
<Card.Root>
<Card.Header>
<Card.Title>Change your name</Card.Title>
<Card.Description>
You can change the name that will be displayed on your profile and in emails as well as your
username.
</Card.Description>
</Card.Header>
<Card.Content>
<Form.Root
{options}
form={nameFormSchemaData}
schema={nameFormSchema}
let:config
action="?/name"
method="POST"
class="space-y-2"
{debug}
>
<Form.Item>
<Form.Field name="name" {config}>
<Form.Label>Display name</Form.Label>
<Form.Input placeholder={user?.name} />
<Form.Validation />
</Form.Field>
</Form.Item>
<Form.Item>
<Form.Field {config} name="username">
<Form.Label>Username</Form.Label>
<Form.Input placeholder={user?.username} />
<Form.Validation />
</Form.Field>
</Form.Item>
<Form.Button>Update name</Form.Button>
</Form.Root>
</Card.Content>
</Card.Root>

View file

@ -0,0 +1,78 @@
<script lang="ts" context="module">
import { z } from 'zod';
export const passwordFormSchema = z
.object({
oldPassword: z.string(),
password: z.string().min(4),
passwordConfirm: z.string().min(4)
})
.refine(
(values) => {
return values.password === values.passwordConfirm;
},
{
message: 'Passwords must match.',
path: ['passwordConfirm']
}
);
export type PasswordFormSchema = typeof passwordFormSchema;
</script>
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import * as Form from '$lib/components/ui/form';
import type { SuperValidated } from 'sveltekit-superforms';
import type { FormOptions } from 'formsnap';
import { toast } from 'svelte-sonner';
export let passwordFormSchemaData: SuperValidated<PasswordFormSchema>;
export let debug: boolean;
const options: FormOptions<PasswordFormSchema> = {
onSubmit() {
toast.info('Changing password...');
},
onResult({ result }) {
if (result.status === 200)
toast.success('Your password has been changed! Please log in again.');
if (result.status === 400)
toast.error('There was an error changing your password. Is the old password correct?');
}
};
</script>
<Card.Root>
<Card.Header>
<Card.Title>Password change</Card.Title>
<Card.Description>You can change your account password here.</Card.Description>
</Card.Header>
<Card.Content>
<Form.Root
{options}
form={passwordFormSchemaData}
schema={passwordFormSchema}
let:config
action="?/password"
method="POST"
class="space-y-2"
{debug}
>
<Form.Item>
<Form.Field {config} name="oldPassword">
<Form.Label>Current Password</Form.Label>
<Form.Input type="password" />
<Form.Validation />
</Form.Field>
<Form.Field {config} name="password">
<Form.Label>New Password</Form.Label>
<Form.Input type="password" />
<Form.Validation />
</Form.Field>
<Form.Field {config} name="passwordConfirm">
<Form.Label>Confirm new password</Form.Label>
<Form.Input type="password" />
<Form.Validation />
</Form.Field>
</Form.Item>
<Form.Button>Update password</Form.Button>
</Form.Root>
</Card.Content>
</Card.Root>

View file

@ -0,0 +1,40 @@
<script lang="ts">
import { cn } from '$lib/utils';
import { page } from '$app/stores';
import { Button } from '$lib/components/ui/button';
import { cubicInOut } from 'svelte/easing';
import { crossfade } from 'svelte/transition';
let className: string | undefined | null = undefined;
export let items: { href: string; title: string }[];
export { className as class };
const [send, receive] = crossfade({
duration: 250,
easing: cubicInOut
});
</script>
<nav class={cn('flex space-x-2 lg:flex-col lg:space-x-0 lg:space-y-1', className)}>
{#each items as item}
{@const isActive = $page.url.pathname === item.href}
<Button
href={item.href}
variant="ghost"
class={cn(!isActive && 'hover:underline', 'relative justify-start hover:bg-transparent')}
data-sveltekit-noscroll
>
{#if isActive}
<div
class="absolute inset-0 rounded-md bg-muted"
in:send={{ key: 'active-sidebar-tab' }}
out:receive={{ key: 'active-sidebar-tab' }}
/>
{/if}
<div class="relative">
{item.title}
</div>
</Button>
{/each}
</nav>

View file

@ -0,0 +1,35 @@
<script lang="ts">
import { Separator } from '$lib/components/ui/separator';
import SidebarNav from './(components)/sidebar-nav.svelte';
const sidebarNavItems = [
{
title: 'Profile',
href: '/settings'
},
{
title: 'Appearance',
href: '/settings/appearance'
},
{
title: 'Notifications',
href: '/settings/notifications'
}
];
</script>
<div class="space-y-6 p-10 pb-16 md:block">
<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>
</div>
<Separator class="my-6" />
<div class="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
<aside class="-mx-4 lg:w-1/5">
<SidebarNav items={sidebarNavItems} />
</aside>
<div class="flex-1">
<slot />
</div>
</div>
</div>

View file

@ -0,0 +1,79 @@
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

@ -0,0 +1,34 @@
<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

@ -0,0 +1,25 @@
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

@ -0,0 +1,19 @@
<script lang="ts">
import { Separator } from '$lib/components/ui/separator';
import type { PageData } from './$types';
import AppearanceForm from './appearance-form.svelte';
export let data: PageData;
export let { form, user } = data;
</script>
<div class="space-y-6">
<div>
<h3 class="text-lg font-medium">Appearance</h3>
<p class="text-sm text-muted-foreground">
Customize the appearance of the app. Automatically switch between day and night themes.
</p>
</div>
<Separator />
<AppearanceForm data={form} {user} />
</div>

View file

@ -0,0 +1,125 @@
<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

@ -0,0 +1,24 @@
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

@ -0,0 +1,16 @@
<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

@ -0,0 +1,96 @@
<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-sm text-center 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-sm text-center 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-sm text-center 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

@ -0,0 +1,30 @@
import type { LayoutServerLoad } from './$types';
const fullNameToInitials = (fullName: string) =>
fullName
.split(' ')
.filter((word) => word)
.map((word) => word[0].toUpperCase())
.slice(0, 2)
.join('');
export const load: LayoutServerLoad = async ({
locals,
url
}: {
locals: App.Locals;
url: { pathname: string };
}) => {
const user = locals.pocketBase.authStore.model;
if (user) {
user.avatarUrl = locals.pocketBase.getFileUrl(user, user.avatar);
user.initials = fullNameToInitials(user.name || user.username);
}
return {
url: url.pathname,
authenticated: locals.pocketBase.authStore.isValid,
user,
providers: (await locals.pocketBase.collection('users').listAuthMethods()).authProviders
};
};

34
src/routes/+layout.svelte Normal file
View file

@ -0,0 +1,34 @@
<script lang="ts">
import { dev } from '$app/environment';
import { Metadata, SiteFooter, SiteNavBar, TailwindIndicator } from '$lib/components/site';
import { ModeWatcher } 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';
export let data: LayoutData;
</script>
<ModeWatcher />
<Toaster />
<Metadata />
<div class="relative flex min-h-screen flex-col" id="page">
<SiteNavBar authenticated={data.authenticated} user={data.user} />
<main class="container relative flex-1">
{#key `/${data.url.split('/')[1]}`}
<div in:fly={{ x: -200, duration: 200, delay: 100 }} out:fly={{ x: 200, duration: 100 }}>
<slot />
</div>
{/key}
</main>
<SiteFooter />
{#if dev}
<div class="fixed bottom-1 left-1 z-50 flex font-mono uppercase">
<DataIndicator {data} />
<TailwindIndicator />
</div>
{/if}
</div>

41
src/routes/+page.svelte Normal file
View file

@ -0,0 +1,41 @@
<script lang="ts">
import { Icons } from '$lib/components/site';
import Particles from '$lib/components/site/particles.svelte';
import Button from '$lib/components/ui/button/button.svelte';
import { siteConfig } from '$lib/config/site';
</script>
<div class="absolute inset-0 -z-10">
<Particles />
</div>
<div class="pb-16 pt-52 md:pb-32 md:pt-60">
<div class="container mx-auto text-center">
<h1
class="bg-gradient-to-r from-zinc-800 via-zinc-800/60 to-zinc-800 bg-clip-text pb-4 text-4xl font-extrabold tracking-tight text-transparent dark:from-zinc-200/60 dark:via-zinc-200 dark:to-zinc-200/60 sm:text-5xl md:text-6xl lg:text-7xl"
>
<span class="inline-block text-balance align-top decoration-inherit">
One Dashboard
<br />
Countless Solutions
</span>
</h1>
<p class="mb-8 text-sm text-zinc-800 dark:text-zinc-300 md:text-xl">
Tame ticket overload and keep your operations teams sane
</p>
<div
class="mx-auto flex max-w-xs flex-col items-center gap-4 sm:inline-flex sm:max-w-none sm:flex-row sm:justify-center"
>
<Button
href="/dashboard"
class="group flex w-full items-center transition duration-150 ease-in-out"
>
Get Started <Icons.arrowRight
class="text-primary-500 ml-1 h-3 w-3 tracking-normal transition-transform duration-150 ease-in-out group-hover:translate-x-0.5"
/>
</Button>
<Button href={siteConfig.links.gitHubProject} target="_blank" variant="outline"
>Star on GitHub</Button
>
</div>
</div>
</div>