mirror of
https://github.com/bartvdbraak/omnidash.git
synced 2025-06-28 12:19:11 +00:00
chore: move structure to root
This commit is contained in:
parent
3b27d3841b
commit
eed9c4161f
213 changed files with 1 additions and 38 deletions
10
src/routes/(auth)/+layout.server.ts
Normal file
10
src/routes/(auth)/+layout.server.ts
Normal 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;
|
3
src/routes/(auth)/+layout.svelte
Normal file
3
src/routes/(auth)/+layout.svelte
Normal file
|
@ -0,0 +1,3 @@
|
|||
<div class="space-y-6 p-10 pb-16 md:block">
|
||||
<slot />
|
||||
</div>
|
118
src/routes/(auth)/auth/+page.server.ts
Normal file
118
src/routes/(auth)/auth/+page.server.ts
Normal 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;
|
260
src/routes/(auth)/auth/+page.svelte
Normal file
260
src/routes/(auth)/auth/+page.svelte
Normal 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>
|
6
src/routes/(auth)/logout/+server.ts
Normal file
6
src/routes/(auth)/logout/+server.ts
Normal 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');
|
||||
};
|
18
src/routes/(auth)/reset-password/+page.server.ts
Normal file
18
src/routes/(auth)/reset-password/+page.server.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
};
|
64
src/routes/(auth)/reset-password/+page.svelte
Normal file
64
src/routes/(auth)/reset-password/+page.svelte
Normal 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>
|
9
src/routes/(dashboard)/+layout.server.ts
Normal file
9
src/routes/(dashboard)/+layout.server.ts
Normal 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 {};
|
||||
};
|
|
@ -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} />
|
|
@ -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}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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}
|
|
@ -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>
|
|
@ -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}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
216
src/routes/(dashboard)/dashboard/(components)/data-table.svelte
Normal file
216
src/routes/(dashboard)/dashboard/(components)/data-table.svelte
Normal 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>
|
12
src/routes/(dashboard)/dashboard/(components)/index.ts
Normal file
12
src/routes/(dashboard)/dashboard/(components)/index.ts
Normal 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;
|
71
src/routes/(dashboard)/dashboard/(data)/data.ts
Normal file
71
src/routes/(dashboard)/dashboard/(data)/data.ts
Normal 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
|
||||
}
|
||||
];
|
18
src/routes/(dashboard)/dashboard/(data)/schemas.ts
Normal file
18
src/routes/(dashboard)/dashboard/(data)/schemas.ts
Normal 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>;
|
802
src/routes/(dashboard)/dashboard/(data)/tickets.json
Normal file
802
src/routes/(dashboard)/dashboard/(data)/tickets.json
Normal 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": ""
|
||||
}
|
||||
]
|
14
src/routes/(dashboard)/dashboard/+layout.svelte
Normal file
14
src/routes/(dashboard)/dashboard/+layout.svelte
Normal 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>
|
8
src/routes/(dashboard)/dashboard/+page.svelte
Normal file
8
src/routes/(dashboard)/dashboard/+page.svelte
Normal 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>
|
|
@ -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>
|
104
src/routes/(dashboard)/settings/(components)/email-form.svelte
Normal file
104
src/routes/(dashboard)/settings/(components)/email-form.svelte
Normal 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>
|
|
@ -0,0 +1,79 @@
|
|||
<script lang="ts" context="module">
|
||||
import { z } from 'zod';
|
||||
const emptyStringToUndefined = z.literal('').transform(() => undefined);
|
||||
export const nameFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, 'Name must be at least 2 characters.')
|
||||
.max(30, 'Name must not be longer than 30 characters')
|
||||
.optional()
|
||||
.or(emptyStringToUndefined),
|
||||
username: z
|
||||
.string()
|
||||
.min(2, 'Username must be at least 2 characters.')
|
||||
.max(30, 'Username must not be longer than 30 characters')
|
||||
.optional()
|
||||
.or(emptyStringToUndefined)
|
||||
});
|
||||
export type NameFormSchema = typeof nameFormSchema;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Form from '$lib/components/ui/form';
|
||||
import type { SuperValidated } from 'sveltekit-superforms';
|
||||
import type { LayoutData } from '../$types';
|
||||
import type { FormOptions } from 'formsnap';
|
||||
import { toast } from 'svelte-sonner';
|
||||
export let nameFormSchemaData: SuperValidated<NameFormSchema>;
|
||||
export let user: LayoutData['user'];
|
||||
export let debug: boolean;
|
||||
const options: FormOptions<NameFormSchema> = {
|
||||
onSubmit() {
|
||||
toast.info('Updating name...');
|
||||
},
|
||||
onResult({ result }) {
|
||||
if (result.status === 200) toast.success('Your name has been updated!');
|
||||
if (result.status === 400) toast.error('There was an error updating your name.');
|
||||
},
|
||||
dataType: 'form'
|
||||
};
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Change your name</Card.Title>
|
||||
<Card.Description>
|
||||
You can change the name that will be displayed on your profile and in emails as well as your
|
||||
username.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<Form.Root
|
||||
{options}
|
||||
form={nameFormSchemaData}
|
||||
schema={nameFormSchema}
|
||||
let:config
|
||||
action="?/name"
|
||||
method="POST"
|
||||
class="space-y-2"
|
||||
{debug}
|
||||
>
|
||||
<Form.Item>
|
||||
<Form.Field name="name" {config}>
|
||||
<Form.Label>Display name</Form.Label>
|
||||
<Form.Input placeholder={user?.name} />
|
||||
<Form.Validation />
|
||||
</Form.Field>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Form.Field {config} name="username">
|
||||
<Form.Label>Username</Form.Label>
|
||||
<Form.Input placeholder={user?.username} />
|
||||
<Form.Validation />
|
||||
</Form.Field>
|
||||
</Form.Item>
|
||||
<Form.Button>Update name</Form.Button>
|
||||
</Form.Root>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
|
@ -0,0 +1,78 @@
|
|||
<script lang="ts" context="module">
|
||||
import { z } from 'zod';
|
||||
export const passwordFormSchema = z
|
||||
.object({
|
||||
oldPassword: z.string(),
|
||||
password: z.string().min(4),
|
||||
passwordConfirm: z.string().min(4)
|
||||
})
|
||||
.refine(
|
||||
(values) => {
|
||||
return values.password === values.passwordConfirm;
|
||||
},
|
||||
{
|
||||
message: 'Passwords must match.',
|
||||
path: ['passwordConfirm']
|
||||
}
|
||||
);
|
||||
export type PasswordFormSchema = typeof passwordFormSchema;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Form from '$lib/components/ui/form';
|
||||
import type { SuperValidated } from 'sveltekit-superforms';
|
||||
import type { FormOptions } from 'formsnap';
|
||||
import { toast } from 'svelte-sonner';
|
||||
export let passwordFormSchemaData: SuperValidated<PasswordFormSchema>;
|
||||
export let debug: boolean;
|
||||
const options: FormOptions<PasswordFormSchema> = {
|
||||
onSubmit() {
|
||||
toast.info('Changing password...');
|
||||
},
|
||||
onResult({ result }) {
|
||||
if (result.status === 200)
|
||||
toast.success('Your password has been changed! Please log in again.');
|
||||
if (result.status === 400)
|
||||
toast.error('There was an error changing your password. Is the old password correct?');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Password change</Card.Title>
|
||||
<Card.Description>You can change your account password here.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<Form.Root
|
||||
{options}
|
||||
form={passwordFormSchemaData}
|
||||
schema={passwordFormSchema}
|
||||
let:config
|
||||
action="?/password"
|
||||
method="POST"
|
||||
class="space-y-2"
|
||||
{debug}
|
||||
>
|
||||
<Form.Item>
|
||||
<Form.Field {config} name="oldPassword">
|
||||
<Form.Label>Current Password</Form.Label>
|
||||
<Form.Input type="password" />
|
||||
<Form.Validation />
|
||||
</Form.Field>
|
||||
<Form.Field {config} name="password">
|
||||
<Form.Label>New Password</Form.Label>
|
||||
<Form.Input type="password" />
|
||||
<Form.Validation />
|
||||
</Form.Field>
|
||||
<Form.Field {config} name="passwordConfirm">
|
||||
<Form.Label>Confirm new password</Form.Label>
|
||||
<Form.Input type="password" />
|
||||
<Form.Validation />
|
||||
</Form.Field>
|
||||
</Form.Item>
|
||||
<Form.Button>Update password</Form.Button>
|
||||
</Form.Root>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
|
@ -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>
|
35
src/routes/(dashboard)/settings/+layout.svelte
Normal file
35
src/routes/(dashboard)/settings/+layout.svelte
Normal 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>
|
79
src/routes/(dashboard)/settings/+page.server.ts
Normal file
79
src/routes/(dashboard)/settings/+page.server.ts
Normal 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 };
|
||||
}
|
||||
};
|
34
src/routes/(dashboard)/settings/+page.svelte
Normal file
34
src/routes/(dashboard)/settings/+page.svelte
Normal 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>
|
25
src/routes/(dashboard)/settings/appearance/+page.server.ts
Normal file
25
src/routes/(dashboard)/settings/appearance/+page.server.ts
Normal 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 };
|
||||
}
|
||||
};
|
19
src/routes/(dashboard)/settings/appearance/+page.svelte
Normal file
19
src/routes/(dashboard)/settings/appearance/+page.svelte
Normal 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>
|
|
@ -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>
|
|
@ -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
|
||||
};
|
||||
}
|
||||
};
|
16
src/routes/(dashboard)/settings/notifications/+page.svelte
Normal file
16
src/routes/(dashboard)/settings/notifications/+page.svelte
Normal 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>
|
|
@ -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> -->
|
30
src/routes/+layout.server.ts
Normal file
30
src/routes/+layout.server.ts
Normal 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
34
src/routes/+layout.svelte
Normal 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
41
src/routes/+page.svelte
Normal 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>
|
Loading…
Add table
Add a link
Reference in a new issue