aboutsummaryrefslogtreecommitdiffstats
path: root/code/app/src/routes
diff options
context:
space:
mode:
authorivarlovlie <git@ivarlovlie.no>2023-02-25 13:15:44 +0100
committerivarlovlie <git@ivarlovlie.no>2023-02-25 13:15:44 +0100
commit900bb5e845c3ad44defbd427cae3d44a4a43321f (patch)
treedf3d96a93771884add571e82336c29fc3d9c7a1c /code/app/src/routes
downloadgreatoffice-900bb5e845c3ad44defbd427cae3d44a4a43321f.tar.xz
greatoffice-900bb5e845c3ad44defbd427cae3d44a4a43321f.zip
feat: Initial commit
Diffstat (limited to 'code/app/src/routes')
-rw-r--r--code/app/src/routes/(api)/delete-cookie/+server.ts8
-rw-r--r--code/app/src/routes/(main)/(app)/+layout.svelte379
-rw-r--r--code/app/src/routes/(main)/(app)/home/+page.svelte1
-rw-r--r--code/app/src/routes/(main)/(app)/org/+page.svelte4
-rw-r--r--code/app/src/routes/(main)/(app)/profile/+page.svelte4
-rw-r--r--code/app/src/routes/(main)/(app)/projects/+page.svelte118
-rw-r--r--code/app/src/routes/(main)/(app)/projects/[id]/+page.svelte5
-rw-r--r--code/app/src/routes/(main)/(app)/projects/create/+page.svelte59
-rw-r--r--code/app/src/routes/(main)/(app)/settings/+page.svelte205
-rw-r--r--code/app/src/routes/(main)/(app)/tickets/+page.svelte4
-rw-r--r--code/app/src/routes/(main)/(app)/todo/+page.svelte4
-rw-r--r--code/app/src/routes/(main)/(app)/wiki/+page.svelte4
-rw-r--r--code/app/src/routes/(main)/(public)/+layout.svelte18
-rw-r--r--code/app/src/routes/(main)/(public)/portal/+page.svelte26
-rw-r--r--code/app/src/routes/(main)/(public)/portal/+page.ts9
-rw-r--r--code/app/src/routes/(main)/(public)/reset-password/+page.svelte81
-rw-r--r--code/app/src/routes/(main)/(public)/reset-password/+page.ts11
-rw-r--r--code/app/src/routes/(main)/(public)/reset-password/[id]/+page.server.ts11
-rw-r--r--code/app/src/routes/(main)/(public)/reset-password/[id]/+page.svelte82
-rw-r--r--code/app/src/routes/(main)/(public)/reset-password/[id]/+page.ts11
-rw-r--r--code/app/src/routes/(main)/(public)/sign-in/+page.svelte155
-rw-r--r--code/app/src/routes/(main)/(public)/sign-in/+page.ts11
-rw-r--r--code/app/src/routes/(main)/(public)/sign-in/index.spec.js12
-rw-r--r--code/app/src/routes/(main)/(public)/sign-in/index.ts20
-rw-r--r--code/app/src/routes/(main)/(public)/sign-up/+page.svelte106
-rw-r--r--code/app/src/routes/(main)/(public)/sign-up/+page.ts11
-rw-r--r--code/app/src/routes/(main)/+layout.server.ts45
-rw-r--r--code/app/src/routes/(main)/+layout.svelte31
-rw-r--r--code/app/src/routes/(main)/+layout.ts10
-rw-r--r--code/app/src/routes/(main)/+page.svelte1
-rw-r--r--code/app/src/routes/book/+layout.svelte46
-rw-r--r--code/app/src/routes/book/+layout.ts3
-rw-r--r--code/app/src/routes/book/+page.svelte1
-rw-r--r--code/app/src/routes/book/alerts/+page.svelte70
-rw-r--r--code/app/src/routes/book/badges/+page.svelte19
-rw-r--r--code/app/src/routes/book/buttons/+page.svelte23
-rw-r--r--code/app/src/routes/book/inputs/+page.svelte75
-rw-r--r--code/app/src/routes/book/notifications/+page.svelte50
-rw-r--r--code/app/src/routes/book/toggles/+page.svelte27
39 files changed, 1760 insertions, 0 deletions
diff --git a/code/app/src/routes/(api)/delete-cookie/+server.ts b/code/app/src/routes/(api)/delete-cookie/+server.ts
new file mode 100644
index 0000000..ee5e1dc
--- /dev/null
+++ b/code/app/src/routes/(api)/delete-cookie/+server.ts
@@ -0,0 +1,8 @@
+import type { RequestHandler } from './$types';
+
+export const GET: RequestHandler = async ({ cookies, url }) => {
+ const cookieToDelete = url.searchParams.get("key");
+ if (!cookieToDelete || cookies.get(cookieToDelete) === undefined) return;
+ cookies.delete(cookieToDelete)
+ return new Response();
+}; \ No newline at end of file
diff --git a/code/app/src/routes/(main)/(app)/+layout.svelte b/code/app/src/routes/(main)/(app)/+layout.svelte
new file mode 100644
index 0000000..09dbb47
--- /dev/null
+++ b/code/app/src/routes/(main)/(app)/+layout.svelte
@@ -0,0 +1,379 @@
+<script lang="ts">
+ import {
+ ChevronUpDownIcon,
+ MagnifyingGlassIcon,
+ Bars3CenterLeftIcon,
+ XMarkIcon,
+ HomeIcon,
+ MegaphoneIcon,
+ FolderOpenIcon,
+ QueueListIcon,
+ CalendarIcon,
+ } from "$components/icons";
+ import { AccountService } from "$services/account-service";
+ import {
+ Dialog,
+ Menu,
+ MenuButton,
+ MenuItem,
+ MenuItems,
+ Transition,
+ TransitionChild,
+ TransitionRoot,
+ } from "@rgossiaux/svelte-headlessui";
+ import { DialogPanel } from "@developermuch/dev-svelte-headlessui";
+ import { Input, Notification } from "$components";
+ import { goto } from "$app/navigation";
+ import { page } from "$app/stores";
+ import { onMount } from "svelte";
+ import { fgs, sgs } from "$utilities/global-state";
+
+ const accountService = AccountService.resolve();
+ const session = {
+ profile: {
+ username: "Brukernavn",
+ displayName: "epost@adresse.no",
+ },
+ };
+
+ let sidebarOpen = false;
+ let sidebarSearchValue: string | undefined;
+ let showEmailValidatedNotif = false;
+
+ onMount(() => {
+ showEmailValidatedNotif =
+ fgs("showEmailValidatedAlertWhenLoggedIn") === "true";
+ if (showEmailValidatedNotif)
+ sgs("showEmailValidatedAlertWhenLoggedIn", false);
+ });
+
+ function sign_out() {
+ accountService.end_session(() => goto("/sign-in"));
+ }
+ const navigationItems = [
+ {
+ href: "/home",
+ name: "Home",
+ icon: HomeIcon,
+ },
+ {
+ href: "/projects",
+ name: "Projects",
+ icon: CalendarIcon,
+ },
+ {
+ href: "/tickets",
+ name: "Tickets",
+ icon: MegaphoneIcon,
+ },
+ {
+ href: "/todo",
+ name: "Todo",
+ icon: QueueListIcon,
+ },
+ {
+ href: "/wiki",
+ name: "Wiki",
+ icon: FolderOpenIcon,
+ },
+ ];
+</script>
+
+{#if showEmailValidatedNotif}
+ <Notification
+ title="Email successfully validated"
+ subtitle="Because of this, you now have gained access to more functionality"
+ show={true}
+ />
+{/if}
+
+<div class="min-h-full">
+ <!-- Mobile sidebar -->
+ <TransitionRoot show={sidebarOpen}>
+ <Dialog
+ as="div"
+ class="relative z-40 lg:hidden"
+ on:close={() => (sidebarOpen = false)}
+ >
+ <TransitionChild
+ as="div"
+ enter="transition-opacity ease-linear duration-300"
+ enterFrom="opacity-0"
+ enterTo="opacity-100"
+ leave="transition-opacity ease-linear duration-300"
+ leaveFrom="opacity-100"
+ leaveTo="opacity-0"
+ >
+ <div class="fixed inset-0 bg-gray-600 bg-opacity-75" />
+ </TransitionChild>
+
+ <div class="fixed inset-0 z-40 flex">
+ <TransitionChild
+ as="div"
+ enter="transition ease-in-out duration-300 transform"
+ enterFrom="-translate-x-full"
+ enterTo="translate-x-0"
+ leave="transition ease-in-out duration-300 transform"
+ leaveFrom="translate-x-0"
+ leaveTo="-translate-x-full"
+ >
+ <DialogPanel
+ class="relative flex w-full max-w-xs flex-1 flex-col bg-white pt-5 pb-4"
+ >
+ <TransitionChild
+ as="div"
+ enter="ease-in-out duration-300"
+ enterFrom="opacity-0"
+ enterTo="opacity-100"
+ leave="ease-in-out duration-300"
+ leaveFrom="opacity-100"
+ leaveTo="opacity-0"
+ >
+ <div class="absolute top-0 right-0 -mr-12 pt-2">
+ <button
+ type="button"
+ class="ml-1 flex h-10 w-10 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
+ on:click={() => (sidebarOpen = false)}
+ >
+ <span class="sr-only">Close sidebar</span>
+ <XMarkIcon class="text-white" aria-hidden="true" />
+ </button>
+ </div>
+ </TransitionChild>
+ <div class="mt-5 h-0 flex-1 overflow-y-auto">
+ <nav class="px-2">
+ <div class="space-y-1">
+ {#each navigationItems as item}
+ {@const current = $page.url.pathname.startsWith(item.href)}
+ <a
+ href={item.href}
+ aria-current={current ? "page" : undefined}
+ class="group flex items-center px-2 py-2 text-base leading-5 font-medium rounded-md
+ {current
+ ? 'bg-gray-100 text-gray-900'
+ : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'}"
+ >
+ <svelte:component
+ this={item.icon}
+ class="mr-3 flex-shrink-0 h-6 w-6 {current
+ ? 'text-gray-500'
+ : 'text-gray-400 group-hover:text-gray-500'}"
+ aria-hidden="true"
+ />
+ {item.name}
+ </a>
+ {/each}
+ </div>
+ </nav>
+ </div>
+ </DialogPanel>
+ </TransitionChild>
+ <div class="w-14 flex-shrink-0" aria-hidden="true">
+ <!-- Dummy element to force sidebar to shrink to fit close icon -->
+ </div>
+ </div>
+ </Dialog>
+ </TransitionRoot>
+
+ <!-- Static sidebar for desktop -->
+ <div
+ class="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col lg:border-r lg:border-gray-200 lg:bg-gray-100 lg:pb-4"
+ >
+ <div class="flex h-0 flex-1 p-3 flex-col overflow-y-auto">
+ <!-- User account dropdown -->
+ <Menu class="relative inline-block text-left">
+ <MenuButton
+ class="group w-full rounded-md bg-gray-100 px-3.5 py-2 text-left text-sm font-medium text-gray-700 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:ring-offset-2 focus:ring-offset-gray-100"
+ >
+ <span class="flex w-full items-center justify-between">
+ <span class="flex min-w-0 items-center justify-between space-x-3">
+ <span class="flex min-w-0 flex-1 flex-col">
+ <span class="truncate text-sm font-medium text-gray-900">
+ {session.profile.username}
+ </span>
+ <span class="truncate text-sm text-gray-500"
+ >{session.profile.displayName}</span
+ >
+ </span>
+ </span>
+ <ChevronUpDownIcon
+ class="flex-shrink-0 text-gray-400 group-hover:text-gray-500"
+ aria-hidden="true"
+ />
+ </span>
+ </MenuButton>
+ <Transition
+ leave="transition ease-in duration-75"
+ enter="transition ease-out duration-100"
+ enterFrom="transform opacity-0 scale-95"
+ enterTo="transform opacity-100 scale-100"
+ leaveFrom="transform opacity-100 scale-100"
+ leaveTo="transform opacity-0 scale-95"
+ as="div"
+ >
+ <MenuItems
+ class="absolute right-0 left-0 z-10 mt-1 origin-top divide-y divide-gray-200 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
+ >
+ <div class="py-1">
+ <MenuItem>
+ <a
+ href="/profile"
+ class="text-gray-700 block px-4 py-2 text-sm hover:text-gray-900 hover:bg-gray-100"
+ >
+ View profile
+ </a>
+ </MenuItem>
+ <MenuItem>
+ <a
+ href="/settings"
+ class="text-gray-700 block px-4 py-2 text-sm hover:text-gray-900 hover:bg-gray-100"
+ >
+ Settings
+ </a>
+ </MenuItem>
+ </div>
+ <div class="py-1">
+ <MenuItem>
+ <span
+ on:click={() => sign_out()}
+ class="text-gray-700 block px-4 py-2 text-sm hover:bg-red-200 hover:text-red-900 cursor-pointer"
+ >
+ Sign out
+ </span>
+ </MenuItem>
+ </div>
+ </MenuItems>
+ </Transition>
+ </Menu>
+ <!-- Sidebar Search -->
+ <div class="mt-3 hidden">
+ <label for="search" class="sr-only">Search</label>
+ <div class="relative mt-1 rounded-md shadow-sm">
+ <Input
+ type="search"
+ name="search"
+ icon={MagnifyingGlassIcon}
+ placeholder="Search"
+ bind:value={sidebarSearchValue}
+ />
+ </div>
+ </div>
+ <!-- Navigation -->
+ <nav class="mt-5">
+ <div class="space-y-1">
+ {#each navigationItems as item}
+ {@const current = $page.url.pathname.startsWith(item.href)}
+ <a
+ href={item.href}
+ aria-current={current ? "page" : undefined}
+ class="group flex items-center px-2 py-2 text-base leading-5 font-medium rounded-md
+ {current
+ ? 'bg-gray-200 text-gray-900'
+ : 'text-gray-700 hover:text-gray-900 hover:bg-gray-50'}"
+ >
+ <svelte:component
+ this={item.icon}
+ class="mr-3 flex-shrink-0 h-6 w-6 {current
+ ? 'text-gray-500'
+ : 'text-gray-400 group-hover:text-gray-500'}"
+ aria-hidden="true"
+ />
+ {item.name}
+ </a>
+ {/each}
+ </div>
+ </nav>
+ </div>
+ </div>
+
+ <!-- Main column -->
+ <div class="flex flex-col lg:pl-64">
+ <!-- Search header -->
+ <div
+ class="sticky top-0 z-10 flex h-16 flex-shrink-0 border-b border-gray-200 bg-white lg:hidden"
+ >
+ <button
+ type="button"
+ class="border-r border-gray-200 px-4 text-gray-500 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-teal-500 lg:hidden"
+ on:click={() => (sidebarOpen = true)}
+ >
+ <span class="sr-only">Open sidebar</span>
+ <Bars3CenterLeftIcon aria-hidden="true" />
+ </button>
+ <div class="flex flex-1 justify-between px-4 sm:px-6 lg:px-8">
+ <div class="flex flex-1">
+ <form class="flex w-full md:ml-0" action="#" method="GET">
+ <label for="search-field" class="sr-only">Search</label>
+ <div
+ class="relative w-full text-gray-400 focus-within:text-gray-600"
+ >
+ <Input
+ bind:value={sidebarSearchValue}
+ icon={MagnifyingGlassIcon}
+ id="search-field"
+ name="search-field"
+ placeholder="Search"
+ type="search"
+ />
+ </div>
+ </form>
+ </div>
+ <div class="flex items-center">
+ <!-- Profile dropdown -->
+ <Menu as="div" class="relative ml-3">
+ <div>
+ <MenuButton
+ class="flex max-w-xs items-center rounded-full bg-white text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:ring-offset-2"
+ >
+ <span class="sr-only">Open user menu</span>
+ </MenuButton>
+ </div>
+ <Transition
+ enterFrom="transform opacity-0 scale-95"
+ enterTo="transform opacity-100 scale-100"
+ leaveFrom="transform opacity-100 scale-100"
+ leaveTo="transform opacity-0 scale-95"
+ as="div"
+ >
+ <MenuItems
+ class="absolute right-0 z-10 mt-2 w-48 origin-top-right divide-y divide-gray-200 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
+ >
+ <div class="py-1">
+ <MenuItem>
+ <a
+ href="/profile"
+ class="text-gray-700 block px-4 py-2 text-sm"
+ >
+ View profile
+ </a>
+ </MenuItem>
+ <MenuItem>
+ <a
+ href="/settings"
+ class="text-gray-700 block px-4 py-2 text-sm hover:text-gray-900 hover:bg-gray-100"
+ >
+ Settings
+ </a>
+ </MenuItem>
+ <div class="py-1">
+ <MenuItem>
+ <span
+ on:click={() => sign_out()}
+ class="text-gray-700 block px-4 py-2 text-sm"
+ >
+ Sign out
+ </span>
+ </MenuItem>
+ </div>
+ </div>
+ </MenuItems>
+ </Transition>
+ </Menu>
+ </div>
+ </div>
+ </div>
+ <main class="flex-1 p-3">
+ <slot />
+ </main>
+ </div>
+</div>
diff --git a/code/app/src/routes/(main)/(app)/home/+page.svelte b/code/app/src/routes/(main)/(app)/home/+page.svelte
new file mode 100644
index 0000000..247ee47
--- /dev/null
+++ b/code/app/src/routes/(main)/(app)/home/+page.svelte
@@ -0,0 +1 @@
+<h1>Welcome Home</h1> \ No newline at end of file
diff --git a/code/app/src/routes/(main)/(app)/org/+page.svelte b/code/app/src/routes/(main)/(app)/org/+page.svelte
new file mode 100644
index 0000000..429ec25
--- /dev/null
+++ b/code/app/src/routes/(main)/(app)/org/+page.svelte
@@ -0,0 +1,4 @@
+<script lang="ts">
+</script>
+
+<h1>$ORGNAME</h1>
diff --git a/code/app/src/routes/(main)/(app)/profile/+page.svelte b/code/app/src/routes/(main)/(app)/profile/+page.svelte
new file mode 100644
index 0000000..7c6eb3e
--- /dev/null
+++ b/code/app/src/routes/(main)/(app)/profile/+page.svelte
@@ -0,0 +1,4 @@
+<script lang="ts">
+</script>
+
+<h1>Hi, Ivar</h1>
diff --git a/code/app/src/routes/(main)/(app)/projects/+page.svelte b/code/app/src/routes/(main)/(app)/projects/+page.svelte
new file mode 100644
index 0000000..2585331
--- /dev/null
+++ b/code/app/src/routes/(main)/(app)/projects/+page.svelte
@@ -0,0 +1,118 @@
+<script lang="ts">
+ import { Button, ProjectStatusBadge, Input } from "$components";
+ import type { Project } from "$models/projects/Project";
+ import { createTable, Subscribe, Render } from "svelte-headless-table";
+ import { addSortBy, addTableFilter } from "svelte-headless-table/plugins";
+ import { writable, type Writable } from "svelte/store";
+ import { ChevronDownIcon, ChevronUpIcon, ChevronUpDownIcon, MagnifyingGlassIcon, FunnelIcon } from "$components/icons";
+ import LL from "$i18n/i18n-svelte";
+ import { goto } from "$app/navigation";
+
+ const projects: Writable<Array<Project>> = writable([]);
+
+ function on_open_project(event) {
+ if (event.code && (event.code !== "Enter" || event.code !== "Space")) return;
+ const name = event.target.innerText;
+ const projectId = $projects.find((p) => p.name === name).id;
+ goto("/projects/" + projectId);
+ }
+
+ const table = createTable(projects, {
+ sort: addSortBy(),
+ filter: addTableFilter(),
+ });
+
+ const columns = table.createColumns([
+ table.column({ header: $LL.name(), accessor: "name" }),
+ table.column({ header: "Status", accessor: "status" }),
+ table.column({ header: "Start", accessor: "start" }),
+ table.column({ header: "Description", accessor: "description", plugins: { sort: { disable: true } } }),
+ ]);
+
+ const { headerRows, rows, tableAttrs, tableBodyAttrs, pluginStates } = table.createViewModel(columns);
+ const { filterValue } = pluginStates.filter;
+</script>
+
+<div class="sm:flex sm:items-center">
+ <div class="sm:flex-auto">
+ <h1 class="text-xl font-semibold text-gray-900">Projects</h1>
+ <p class="mt-2 text-sm text-gray-700">A list of all the projects in your organsation.</p>
+ </div>
+ <div class="mt-4 sm:mt-0 sm:ml-16 inline-flex gap-1 sm:flex-none">
+ <Input icon={MagnifyingGlassIcon} placeholder="Search" bind:value={$filterValue} />
+ <Button text="Create project" href="/projects/create" />
+ </div>
+</div>
+<div class="-mx-2 mt-6 rounded-md shadow overflow-auto max-h-[80vh] sm:-mx-6 md:mx-0">
+ <table {...$tableAttrs} class="min-w-full divide-y divide-gray-300">
+ <thead class="bg-gray-50">
+ {#each $headerRows as headerRow (headerRow.id)}
+ <Subscribe rowAttrs={headerRow.attrs()} let:rowAttrs>
+ <tr {...rowAttrs} class="shadow-sm">
+ {#each headerRow.cells as cell (cell.id)}
+ <Subscribe attrs={cell.attrs()} let:attrs props={cell.props()} let:props>
+ <th
+ {...attrs}
+ scope="col"
+ class="sticky top-0 bg-gray-50 bg-opacity-100 whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-gray-900"
+ >
+ <div class="group inline-flex">
+ <Render of={cell.render()} />
+ <span
+ on:click={props.sort.toggle}
+ on:keypress={props.sort.toggle}
+ class="{props.sort.disabled
+ ? 'bg-gray-200 text-gray-900 group-hover:bg-gray-300'
+ : 'invisible text-gray-400 group-hover:visible group-focus:visible'}
+ {props.sort.disabled ? '' : 'cursor-pointer'}
+ ml-2 flex-none rounded"
+ >
+ {#if props.sort.order === "asc"}
+ <ChevronUpIcon />
+ {:else if props.sort.order === "desc"}
+ <ChevronDownIcon />
+ {:else if !props.sort.disabled}
+ <ChevronUpDownIcon />
+ {/if}
+ </span>
+ {#if cell.id === "status"}
+ <span
+ class="invisible text-gray-400 cursor-pointer group-hover:visible group-focus:visible ml-2 flex-none rounded"
+ >
+ <FunnelIcon />
+ </span>
+ {/if}
+ </div>
+ </th>
+ </Subscribe>
+ {/each}
+ </tr>
+ </Subscribe>
+ {/each}
+ </thead>
+ <tbody {...$tableBodyAttrs} class="divide-y divide-gray-200 bg-white">
+ {#each $rows as row (row.id)}
+ <Subscribe rowAttrs={row.attrs()} let:rowAttrs>
+ <tr {...rowAttrs}>
+ {#each row.cells as cell (cell.id)}
+ {@const materialisedCell = cell.render()}
+ <Subscribe attrs={cell.attrs()} let:attrs>
+ <td {...attrs} class="whitespace-nowrap px-2 py-2 text-sm">
+ {#if cell.id === "name"}
+ <span class="link" title="Open project" on:click={on_open_project} on:keypress={on_open_project}>
+ <Render of={materialisedCell} />
+ </span>
+ {:else if cell.id === "status"}
+ <ProjectStatusBadge status={materialisedCell.toString()} />
+ {:else}
+ <Render of={materialisedCell} />
+ {/if}
+ </td>
+ </Subscribe>
+ {/each}
+ </tr>
+ </Subscribe>
+ {/each}
+ </tbody>
+ </table>
+</div>
diff --git a/code/app/src/routes/(main)/(app)/projects/[id]/+page.svelte b/code/app/src/routes/(main)/(app)/projects/[id]/+page.svelte
new file mode 100644
index 0000000..ca474e2
--- /dev/null
+++ b/code/app/src/routes/(main)/(app)/projects/[id]/+page.svelte
@@ -0,0 +1,5 @@
+<script lang="ts">
+ import { page } from "$app/stores";
+</script>
+
+<h1>{$page.params.id}</h1>
diff --git a/code/app/src/routes/(main)/(app)/projects/create/+page.svelte b/code/app/src/routes/(main)/(app)/projects/create/+page.svelte
new file mode 100644
index 0000000..d710edc
--- /dev/null
+++ b/code/app/src/routes/(main)/(app)/projects/create/+page.svelte
@@ -0,0 +1,59 @@
+<script lang="ts">
+ import { Input, TextArea, Combobox, Button } from "$components";
+ import type { ProjectMember } from "$models/projects/ProjectMember";
+ import LL from "$i18n/i18n-svelte";
+
+ let members = [];
+ const formData = {
+ name: {
+ value: "",
+ errors: [],
+ },
+ description: {
+ value: "",
+ errors: [],
+ },
+ start: {
+ value: "",
+ errors: [],
+ },
+ stop: {
+ value: "",
+ errors: [],
+ },
+ members: {
+ value: [] as Array<ProjectMember>,
+ errors: [],
+ },
+ };
+
+ const formError = {
+ title: "",
+ subtitle: "",
+ };
+
+ async function submit_form_async() {
+ alert("Submitted");
+ }
+</script>
+
+<h1>Create a new project</h1>
+<form on:submit|preventDefault={submit_form_async} class="max-w-md flex flex-col gap-2">
+ <Input label="Name" bind:value={formData.name.value} errors={formData.name.errors} required />
+ <TextArea label="Description" bind:value={formData.description.value} errors={formData.description.errors} />
+ <section class="grid grid-flow-row sm:grid-flow-col gap-2">
+ <Input type="date" label="Start" bind:value={formData.start.value} errors={formData.start.errors} />
+ <Input type="date" label="Stop" bind:value={formData.stop.value} errors={formData.stop.errors} />
+ </section>
+ <Combobox options={members} label={$LL.app.members()}>
+ <svelte:fragment slot="no-records">
+ <h1>No members found</h1>
+ {#if !members?.length}
+ <p>
+ <a href="/users/create" class="link">Click here</a> to create your first user
+ </p>
+ {/if}
+ </svelte:fragment>
+ </Combobox>
+ <Button text={$LL.submit()} />
+</form>
diff --git a/code/app/src/routes/(main)/(app)/settings/+page.svelte b/code/app/src/routes/(main)/(app)/settings/+page.svelte
new file mode 100644
index 0000000..8e99661
--- /dev/null
+++ b/code/app/src/routes/(main)/(app)/settings/+page.svelte
@@ -0,0 +1,205 @@
+<script lang="ts">
+ import {Input, Button, Switch} from "$components";
+</script>
+
+<div class="relative mx-auto max-w-4xl md:px-8 xl:px-0">
+ <div class="pt-10 pb-16">
+ <div class="px-4 sm:px-6 md:px-0">
+ <h1 class="text-3xl font-bold tracking-tight text-gray-900">Settings</h1>
+ </div>
+ <div class="px-4 sm:px-6 md:px-0">
+ <div class="py-6">
+ <!-- Tabs -->
+ <div class="lg:hidden">
+ <label for="selected-tab" class="sr-only">Select a tab</label>
+ <select
+ id="selected-tab"
+ name="selected-tab"
+ class="mt-1 block w-full rounded-md border-gray-300 py-2 pl-3 pr-10 text-base focus:border-purple-500 focus:outline-none focus:ring-purple-500 sm:text-sm"
+ >
+ <option selected>General</option>
+
+ <option>Password</option>
+
+ <option>Notifications</option>
+
+>
+
+ <option>Billing</option>
+
+ <option>Team Members</option>
+ </select>
+ </div>
+ <div class="hidden lg:block">
+ <div class="border-b border-gray-200">
+ <nav class="-mb-px flex space-x-8">
+ <!-- Current: "border-purple-500 text-purple-600", Default: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700" -->
+ <a href="#"
+ class="border-purple-500 text-purple-600 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm"
+ >General</a
+ >
+
+ <a
+ href="#"
+ class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm"
+ >Password</a
+ >
+
+ <a
+ href="#"
+ class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm"
+ >Notifications</a
+ >
+
+ <a
+ href="#"
+ class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm"
+ >Plan</a
+ >
+
+ <a
+ href="#"
+ class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm"
+ >Billing</a
+ >
+
+ <a
+ href="#"
+ class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm"
+ >Team Members</a
+ >
+ </nav>
+ </div>
+ </div>
+
+ <!-- Description list with inline editing -->
+ <div class="mt-10 divide-y divide-gray-200">
+ <div class="space-y-1">
+ <h3 class="text-lg font-medium leading-6 text-gray-900">Profile</h3>
+ <p class="max-w-2xl text-sm text-gray-500">
+ This information will be displayed publicly so be careful what you share.
+ </p>
+ </div>
+ <div class="mt-6">
+ <dl class="divide-y divide-gray-200">
+ <div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5">
+ <dt class="text-sm font-medium text-gray-500">Name</dt>
+ <dd class="mt-1 flex text-sm text-gray-900 sm:col-span-2 sm:mt-0">
+ <span class="flex-grow">Chelsea Hagon</span>
+ <span class="ml-4 flex-shrink-0">
+ <button
+ type="button"
+ class="rounded-md bg-white font-medium text-purple-600 hover:text-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2"
+ >Update</button
+ >
+ </span>
+ </dd>
+ </div>
+ <div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:pt-5">
+ <dt class="text-sm font-medium text-gray-500">Photo</dt>
+ <dd class="mt-1 flex text-sm text-gray-900 sm:col-span-2 sm:mt-0">
+ <span class="flex-grow">
+ <img
+ class="h-8 w-8 rounded-full"
+ src="https://images.unsplash.com/photo-1550525811-e5869dd03032?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
+ alt=""
+ />
+ </span>
+ <span class="ml-4 flex flex-shrink-0 items-start space-x-4">
+ <button
+ type="button"
+ class="rounded-md bg-white font-medium text-purple-600 hover:text-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2"
+ >Update</button
+ >
+ <span class="text-gray-300" aria-hidden="true">|</span>
+ <button
+ type="button"
+ class="rounded-md bg-white font-medium text-purple-600 hover:text-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2"
+ >Remove</button
+ >
+ </span>
+ </dd>
+ </div>
+ <div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:pt-5">
+ <dt class="text-sm font-medium text-gray-500">Email</dt>
+ <dd class="mt-1 flex text-sm text-gray-900 sm:col-span-2 sm:mt-0">
+ <span class="flex-grow">chelsea.hagon@example.com</span>
+ <span class="ml-4 flex-shrink-0">
+ <button
+ type="button"
+ class="rounded-md bg-white font-medium text-purple-600 hover:text-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2"
+ >Update</button
+ >
+ </span>
+ </dd>
+ </div>
+ <div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:border-b sm:border-gray-200 sm:py-5">
+ <dt class="text-sm font-medium text-gray-500">Job title</dt>
+ <dd class="mt-1 flex text-sm text-gray-900 sm:col-span-2 sm:mt-0">
+ <span class="flex-grow">Human Resources Manager</span>
+ <span class="ml-4 flex-shrink-0">
+ <button
+ type="button"
+ class="rounded-md bg-white font-medium text-purple-600 hover:text-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2"
+ >Update</button
+ >
+ </span>
+ </dd>
+ </div>
+ </dl>
+ </div>
+ </div>
+
+ <div class="mt-10 divide-y divide-gray-200">
+ <div class="space-y-1">
+ <h3 class="text-lg font-medium leading-6 text-gray-900">Account</h3>
+ <p class="max-w-2xl text-sm text-gray-500">Manage how information is displayed on your
+ account.</p>
+ </div>
+ <div class="mt-6">
+ <dl class="divide-y divide-gray-200">
+ <div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5">
+ <dt class="text-sm font-medium text-gray-500">Language</dt>
+ <dd class="mt-1 flex text-sm text-gray-900 sm:col-span-2 sm:mt-0">
+ <span class="flex-grow">English</span>
+ <span class="ml-4 flex-shrink-0">
+ <button
+ type="button"
+ class="rounded-md bg-white font-medium text-purple-600 hover:text-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2"
+ >Update</button
+ >
+ </span>
+ </dd>
+ </div>
+ <div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:pt-5">
+ <dt class="text-sm font-medium text-gray-500">Date format</dt>
+ <dd class="mt-1 flex text-sm text-gray-900 sm:col-span-2 sm:mt-0">
+ <span class="flex-grow">DD-MM-YYYY</span>
+ <span class="ml-4 flex flex-shrink-0 items-start space-x-4">
+ <button
+ type="button"
+ class="rounded-md bg-white font-medium text-purple-600 hover:text-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2"
+ >Update</button
+ >
+ <span class="text-gray-300" aria-hidden="true">|</span>
+ <button
+ type="button"
+ class="rounded-md bg-white font-medium text-purple-600 hover:text-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2"
+ >Remove</button
+ >
+ </span>
+ </dd>
+ </div>
+ <div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:pt-5">
+ <dt class="text-sm font-medium text-gray-500" id="timezone-option-label">Automatic
+ timezone
+ </dt>
+ <Switch/>
+ </div>
+ </dl>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
diff --git a/code/app/src/routes/(main)/(app)/tickets/+page.svelte b/code/app/src/routes/(main)/(app)/tickets/+page.svelte
new file mode 100644
index 0000000..2a4792b
--- /dev/null
+++ b/code/app/src/routes/(main)/(app)/tickets/+page.svelte
@@ -0,0 +1,4 @@
+<script lang="ts">
+</script>
+
+<h1>Tickets</h1>
diff --git a/code/app/src/routes/(main)/(app)/todo/+page.svelte b/code/app/src/routes/(main)/(app)/todo/+page.svelte
new file mode 100644
index 0000000..e29f263
--- /dev/null
+++ b/code/app/src/routes/(main)/(app)/todo/+page.svelte
@@ -0,0 +1,4 @@
+<script lang="ts">
+</script>
+
+<h1>Todo</h1>
diff --git a/code/app/src/routes/(main)/(app)/wiki/+page.svelte b/code/app/src/routes/(main)/(app)/wiki/+page.svelte
new file mode 100644
index 0000000..1762d43
--- /dev/null
+++ b/code/app/src/routes/(main)/(app)/wiki/+page.svelte
@@ -0,0 +1,4 @@
+<script lang="ts">
+</script>
+
+<h1>Wiki</h1>
diff --git a/code/app/src/routes/(main)/(public)/+layout.svelte b/code/app/src/routes/(main)/(public)/+layout.svelte
new file mode 100644
index 0000000..6da653c
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/+layout.svelte
@@ -0,0 +1,18 @@
+<script>
+ import { LocaleSwitcher } from "$components";
+ import LL from "$i18n/i18n-svelte";
+</script>
+
+<LocaleSwitcher tabindex={-1} />
+<slot />
+<footer class="grid sm:gap-5 grid-flow-row sm:justify-center px-2 sm:grid-flow-col">
+ <a href="https://greatoffice.life/privacy" class="link">
+ {$LL.privacyPolicy()}
+ </a>
+ <a href="https://greatoffice.life/terms" class="link">
+ {$LL.tos()}
+ </a>
+ <a href="https://greatoffice.life/docs" class="link">
+ {$LL.documentation()}
+ </a>
+</footer>
diff --git a/code/app/src/routes/(main)/(public)/portal/+page.svelte b/code/app/src/routes/(main)/(public)/portal/+page.svelte
new file mode 100644
index 0000000..b363e4b
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/portal/+page.svelte
@@ -0,0 +1,26 @@
+<script lang="ts">
+ import { onMount } from "svelte";
+ import type { PageData } from "./$types";
+ import type { PortalMessage } from "$configuration";
+ import { goto } from "$app/navigation";
+ import { sgs } from "$utilities/global-state";
+
+ export let data: PageData;
+
+ onMount(async () => {
+ switch (data.message as PortalMessage) {
+ case "emailValidated": {
+ sgs("showEmailValidatedAlertWhenLoggedIn", true);
+ await goto("/home");
+ break;
+ }
+ default: {
+ await goto("/home");
+ }
+ }
+ });
+</script>
+
+<div class="p-3">
+ <h1>Warping...</h1>
+</div>
diff --git a/code/app/src/routes/(main)/(public)/portal/+page.ts b/code/app/src/routes/(main)/(public)/portal/+page.ts
new file mode 100644
index 0000000..72338cb
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/portal/+page.ts
@@ -0,0 +1,9 @@
+import type { PortalMessage } from '$configuration';
+import { redirect } from '@sveltejs/kit';
+import type { PageLoad } from './$types';
+
+export const load: PageLoad = async ({ url }) => {
+ const message = url.searchParams.get("msg") as PortalMessage;
+ if (!message) throw redirect(302, "/");
+ return { message };
+}; \ No newline at end of file
diff --git a/code/app/src/routes/(main)/(public)/reset-password/+page.svelte b/code/app/src/routes/(main)/(public)/reset-password/+page.svelte
new file mode 100644
index 0000000..a45ccdd
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/reset-password/+page.svelte
@@ -0,0 +1,81 @@
+<script lang="ts">
+ import { Alert, Input, Button } from "$components";
+ import LL from "$i18n/i18n-svelte";
+ import { FormError } from "$models/internal/FormError";
+ import { PasswordResetService } from "$services/password-reset-service";
+
+ const formData = {
+ email: {
+ value: "",
+ errors: [],
+ },
+ };
+
+ const formError = new FormError();
+ const passwordResetService = PasswordResetService.resolve();
+
+ let loading = false;
+ let showSuccessAlert = false;
+ let showErrorAlert = false;
+
+ async function submit_form_async() {
+ formError.set();
+ showSuccessAlert = false;
+ showErrorAlert = false;
+ loading = true;
+ const response = await passwordResetService.create_request_async(formData.email.value);
+ loading = false;
+ if (response.isCreated) {
+ showSuccessAlert = true;
+ } else if (response.knownProblem) {
+ formError.set_from_known_problem(response.knownProblem);
+ for (const error of Object.entries(response.knownProblem.errors)) {
+ if (error[0] === "email") {
+ let errors = [];
+ error[1].forEach((e) => errors.push(e));
+ formData.email.errors = errors;
+ }
+ }
+ } else {
+ formError.set($LL.unexpectedError(), $LL.tryAgainSoon());
+ }
+ showErrorAlert = formError.has_error() && !showSuccessAlert;
+ }
+</script>
+
+<div class="min-h-full flex flex-col justify-center py-12 sm:px-6 lg:px-8">
+ <div class="sm:mx-auto sm:w-full p-2 sm:p-0 sm:max-w-md">
+ <h2 class="mt-6 text-3xl tracking-tight font-bold text-gray-900">
+ {$LL.resetPasswordPage.requestAPasswordReset()}
+ </h2>
+ <p class="mt-2 text-sm text-gray-600">
+ {$LL.or().toLowerCase()}
+ <a href="/sign-in" class="link">
+ {$LL.signIntoYourAccount().toLowerCase()}
+ </a>
+ </p>
+ </div>
+
+ <div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
+ <div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
+ <form class="space-y-6" on:submit|preventDefault={submit_form_async}>
+ {#if showErrorAlert}
+ <Alert title={formError.title} message={formError.subtitle} type="error" />
+ {:else if showSuccessAlert}
+ <Alert type="success" title={$LL.success()} message={$LL.resetPasswordPage.requestSentMessage()} />
+ {/if}
+ <Input
+ id="email"
+ name="email"
+ type="email"
+ autocomplete="email"
+ errors={formData.email.errors}
+ bind:value={formData.email.value}
+ required
+ label={$LL.emailAddress()}
+ />
+ <Button text={$LL.submit()} type="submit" {loading} fullWidth />
+ </form>
+ </div>
+ </div>
+</div>
diff --git a/code/app/src/routes/(main)/(public)/reset-password/+page.ts b/code/app/src/routes/(main)/(public)/reset-password/+page.ts
new file mode 100644
index 0000000..c0859e0
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/reset-password/+page.ts
@@ -0,0 +1,11 @@
+import LL from '$i18n/i18n-svelte';
+import { get } from 'svelte/store';
+import type { PageLoad } from './$types';
+
+const l = get(LL);
+
+export const load: PageLoad = async () => {
+ return {
+ title: l.resetPasswordPage.title(),
+ };
+}; \ No newline at end of file
diff --git a/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.server.ts b/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.server.ts
new file mode 100644
index 0000000..22fa29d
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.server.ts
@@ -0,0 +1,11 @@
+import { is_guid } from "$utilities/validators";
+import { redirect } from "@sveltejs/kit";
+import type { PageServerLoad } from "./$types";
+
+export const load: PageServerLoad = async ({ params }) => {
+ const resetRequestId = params.id ?? "";
+ if (!is_guid(resetRequestId)) throw redirect(302, "/reset-password");
+ return {
+ resetRequestId,
+ };
+}; \ No newline at end of file
diff --git a/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.svelte b/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.svelte
new file mode 100644
index 0000000..27a1af5
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.svelte
@@ -0,0 +1,82 @@
+<script lang="ts">
+ import { onMount } from "svelte";
+ import LL from "$i18n/i18n-svelte";
+ import { Alert, Input, Button } from "$components";
+ import type { PageServerData } from "./$types";
+ import { goto } from "$app/navigation";
+ import { SignInPageMessage, signInPageMessageQueryKey } from "$routes/(main)/(public)/sign-in";
+ import { PasswordResetService } from "$services/password-reset-service";
+
+ export let data: PageServerData;
+ const passwordResetService = PasswordResetService.resolve();
+
+ const formData = {
+ newPassword: {
+ value: "",
+ errors: [],
+ },
+ };
+
+ let finishedPreliminaryLoading = false;
+ let loading = false;
+ let canSubmit = true;
+ let requestIsInvalid = false;
+
+ async function submitFormAsync() {
+ if (!canSubmit) return;
+ loading = true;
+ const request = await passwordResetService.fulfill_request_async(data.resetRequestId, formData.newPassword.value);
+ if (request.isFulfilled) {
+ goto("/sign-in?" + signInPageMessageQueryKey + "=" + SignInPageMessage.AFTER_PASSWORD_RESET);
+ } else if (request.knownProblem) {
+ }
+ loading = false;
+ }
+
+ onMount(async () => {
+ const response = await passwordResetService.request_is_valid_async(data.resetRequestId);
+ requestIsInvalid = !response.isValid;
+ finishedPreliminaryLoading = true;
+ });
+</script>
+
+<div class="min-h-full flex flex-col justify-center py-12 sm:px-6 lg:px-8">
+ {#if finishedPreliminaryLoading}
+ <div class="sm:mx-auto sm:w-full p-2 sm:p-0 sm:max-w-md">
+ <h2 class="mt-6 text-3xl tracking-tight font-bold text-gray-900">
+ {$LL.resetPasswordPage.setANewPassword()}
+ </h2>
+ <p class="mt-2 text-sm text-gray-600">
+ {$LL.or().toLowerCase()}
+ <a href="/sign-in" class="link">
+ {$LL.signIntoYourAccount().toLowerCase()}
+ </a>
+ </p>
+ </div>
+
+ <div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
+ <div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
+ <form class="space-y-6" on:submit|preventDefault={submitFormAsync}>
+ {#if requestIsInvalid}
+ <Alert
+ title={$LL.resetPasswordPage.invalidRequestTitle()}
+ message={$LL.resetPasswordPage.invalidRequestMessage()}
+ />
+ {/if}
+ <Input
+ id="password"
+ name="password"
+ type="password"
+ autocomplete="new-password"
+ required
+ bind:value={formData.newPassword.value}
+ label={$LL.resetPasswordPage.newPassword()}
+ />
+ <Button text={$LL.submit()} type="submit" {loading} fullWidth />
+ </form>
+ </div>
+ </div>
+ {:else}
+ <p>Checking your request...</p>
+ {/if}
+</div>
diff --git a/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.ts b/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.ts
new file mode 100644
index 0000000..3252b7a
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.ts
@@ -0,0 +1,11 @@
+import LL from '$i18n/i18n-svelte';
+import { get } from 'svelte/store';
+import type { PageLoad } from './$types';
+
+const l = get(LL);
+
+export const load: PageLoad = async () => {
+ return {
+ title: l.resetPasswordPage.fulfillTitle(),
+ };
+}; \ No newline at end of file
diff --git a/code/app/src/routes/(main)/(public)/sign-in/+page.svelte b/code/app/src/routes/(main)/(public)/sign-in/+page.svelte
new file mode 100644
index 0000000..66d4575
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/sign-in/+page.svelte
@@ -0,0 +1,155 @@
+<script lang="ts">
+ import { goto } from "$app/navigation";
+ import { Button, Checkbox, Input, Alert } from "$components";
+ import LL from "$i18n/i18n-svelte";
+ import pwKey from "$actions/pwKey";
+ import { onMount } from "svelte";
+ import { signInPageMessageQueryKey, signInPageTestKeys, type SignInPageMessage } from ".";
+ import { AccountService } from "$services/account-service";
+ import type { LoginPayload } from "$services/abstractions/IAccountService";
+ import { FormError } from "$models/internal/FormError";
+ import type { IForm } from "$models/internal/IForm";
+
+ let messageType: SignInPageMessage | undefined = undefined;
+
+ const accountService = AccountService.resolve();
+ const form = {
+ fields: {
+ username: {
+ value: "",
+ errors: [],
+ },
+ password: {
+ value: "",
+ errors: [],
+ },
+ persist: {
+ value: false,
+ errors: [],
+ },
+ },
+ error: new FormError(),
+ isLoading: false,
+ showError: false,
+ get_payload(): LoginPayload {
+ return {
+ password: form.fields.password.value,
+ username: form.fields.username.value,
+ persist: !form.fields.persist.value,
+ };
+ },
+ async submit_async() {
+ console.log("sadf");
+ form.error.set();
+ form.showError = form.error.has_error();
+ form.isLoading = true;
+ const loginResponse = await accountService.login_async(form.get_payload());
+ if (loginResponse.isLoggedIn) {
+ await goto("/home");
+ } else if (loginResponse.knownProblem) {
+ form.error.set_from_known_problem(loginResponse.knownProblem);
+ } else {
+ form.error.set($LL.unexpectedError(), $LL.tryAgainSoon());
+ }
+ form.isLoading = false;
+ form.showError = form.error.has_error();
+ },
+ } as IForm;
+
+ onMount(() => {
+ const queryParams = new URLSearchParams(window.location.search);
+ if (queryParams.get(signInPageMessageQueryKey)) {
+ messageType = queryParams.get(signInPageMessageQueryKey) as SignInPageMessage;
+ queryParams.delete(signInPageMessageQueryKey);
+ window.history.replaceState(null, "", window.location.origin + window.location.pathname);
+ }
+ });
+</script>
+
+<div class="min-h-full flex flex-col justify-center py-12 sm:px-6 lg:px-8">
+ {#if messageType}
+ <div class="sm:max-w-md sm:mx-auto sm:w-full">
+ {#if messageType === "after-password-reset"}
+ <Alert
+ title={$LL.signInPage.yourNewPasswordIsApplied()}
+ _pwKey={signInPageTestKeys.afterPasswordResetAlert}
+ message={$LL.signInPage.signInBelow()}
+ closeable
+ />
+ {:else if messageType === "user-disabled"}
+ <Alert
+ title={$LL.signInPage.yourAccountIsDisabled()}
+ _pwKey={signInPageTestKeys.userDisabledAlert}
+ message={$LL.signInPage.contactYourAdminIfDisabled()}
+ closeable
+ />
+ {:else if messageType === "user-inactivity"}
+ <Alert
+ title={$LL.signInPage.youHaveReachedInactivityLimit()}
+ _pwKey={signInPageTestKeys.userInactivityAlert}
+ message={$LL.signInPage.feelFreeToSignInAgain()}
+ closeable
+ />
+ {/if}
+ </div>
+ {/if}
+ <div class="sm:mx-auto sm:w-full p-2 sm:p-0 sm:max-w-md">
+ <h2 class="mt-6 text-3xl tracking-tight font-bold text-gray-900">
+ {$LL.signInPage.signIn()}
+ </h2>
+ <p class="mt-2 text-sm text-gray-600">
+ {$LL.or().toLowerCase()}
+ <a href="/sign-up" use:pwKey={signInPageTestKeys.signUpAnchor} class="link">
+ {$LL.createANewAccount().toLowerCase()}
+ </a>
+ </p>
+ </div>
+ <div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
+ <div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
+ {#if form.showError}
+ <Alert title={form.error.title} message={form.error.subtitle} type="error" _pwKey={signInPageTestKeys.formErrorAlert} />
+ {/if}
+ <form class="space-y-6 mt-2" use:pwKey={signInPageTestKeys.signInForm} on:submit|preventDefault={() => form.submit_async()}>
+ <Input
+ id="username"
+ _pwKey={signInPageTestKeys.usernameInput}
+ name="username"
+ type="email"
+ label={$LL.emailAddress()}
+ required
+ errors={form.fields.username.errors}
+ bind:value={form.fields.username.value}
+ />
+
+ <Input
+ id="password"
+ name="password"
+ type="password"
+ label={$LL.password()}
+ _pwKey={signInPageTestKeys.passwordInput}
+ autocomplete="current-password"
+ required
+ errors={form.fields.password.errors}
+ bind:value={form.fields.password.value}
+ />
+
+ <div class="flex items-center justify-between">
+ <Checkbox
+ id="remember-me"
+ _pwKey={signInPageTestKeys.rememberMeCheckbox}
+ name="remember-me"
+ bind:checked={form.fields.persist.value}
+ label={$LL.signInPage.notMyComputer()}
+ />
+ <div class="text-sm">
+ <a href="/reset-password" class="link" use:pwKey={signInPageTestKeys.resetPasswordAnchor}>
+ {$LL.signInPage.resetPassword()}
+ </a>
+ </div>
+ </div>
+
+ <Button text={$LL.submit()} fullWidth type="submit" loading={form.isLoading} />
+ </form>
+ </div>
+ </div>
+</div>
diff --git a/code/app/src/routes/(main)/(public)/sign-in/+page.ts b/code/app/src/routes/(main)/(public)/sign-in/+page.ts
new file mode 100644
index 0000000..bebc459
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/sign-in/+page.ts
@@ -0,0 +1,11 @@
+import LL from '$i18n/i18n-svelte';
+import { get } from 'svelte/store';
+import type { PageLoad } from './$types';
+
+const l = get(LL);
+
+export const load: PageLoad = async () => {
+ return {
+ title: l.signInPage.title(),
+ };
+}; \ No newline at end of file
diff --git a/code/app/src/routes/(main)/(public)/sign-in/index.spec.js b/code/app/src/routes/(main)/(public)/sign-in/index.spec.js
new file mode 100644
index 0000000..9d0122d
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/sign-in/index.spec.js
@@ -0,0 +1,12 @@
+import { test, expect } from "@playwright/test";
+import { signInPageTestKeys } from "./index.js";
+import { get_test_context } from "$configuration";
+import { get_pw_key_selector } from "$utilities/testing-helpers";
+
+const context = get_test_context();
+
+test("form loads", async ({ page }) => {
+ page.goto("/sign-in");
+ const form = page.locator(get_pw_key_selector(signInPageTestKeys.signInForm));
+ expect(form.isVisible()).toBeTruthy();
+});
diff --git a/code/app/src/routes/(main)/(public)/sign-in/index.ts b/code/app/src/routes/(main)/(public)/sign-in/index.ts
new file mode 100644
index 0000000..c1a1929
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/sign-in/index.ts
@@ -0,0 +1,20 @@
+export enum SignInPageMessage {
+ AFTER_PASSWORD_RESET = "after-password-reset",
+ USER_INACTIVITY = "user-inactivity",
+ USER_DISABLED = "user-disabled",
+ LOGGED_OUT = "logged-out"
+}
+
+export const signInPageMessageQueryKey = "m";
+export const signInPageTestKeys = {
+ passwordInput: "password-input",
+ usernameInput: "username-input",
+ rememberMeCheckbox: "remember-me-checkbox",
+ signInForm: "sign-in-form",
+ userInactivityAlert: SignInPageMessage.USER_INACTIVITY + "-alert",
+ userDisabledAlert: SignInPageMessage.USER_DISABLED + "-alert",
+ afterPasswordResetAlert: SignInPageMessage.AFTER_PASSWORD_RESET + "-alert",
+ formErrorAlert: "form-error-alert",
+ resetPasswordAnchor: "reset-password-anchor",
+ signUpAnchor: "sign-up-anchor",
+}; \ No newline at end of file
diff --git a/code/app/src/routes/(main)/(public)/sign-up/+page.svelte b/code/app/src/routes/(main)/(public)/sign-up/+page.svelte
new file mode 100644
index 0000000..470ac5d
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/sign-up/+page.svelte
@@ -0,0 +1,106 @@
+<script lang="ts">
+ import { goto } from "$app/navigation";
+ import { Button, Input, Alert } from "$components";
+ import LL from "$i18n/i18n-svelte";
+ import { FormError } from "$models/internal/FormError";
+ import type { CreateAccountPayload } from "$services/abstractions/IAccountService";
+ import { AccountService } from "$services/account-service";
+
+ const formData = {
+ username: {
+ value: "",
+ errors: [],
+ },
+ password: {
+ value: "",
+ errors: [],
+ },
+ as_payload(): CreateAccountPayload {
+ return {
+ username: formData.username.value,
+ password: formData.password.value,
+ };
+ },
+ };
+
+ const formError = new FormError();
+ const accountService = new AccountService();
+
+ let loading = false;
+ let showErrorAlert = false;
+
+ async function submit_form_async() {
+ loading = true;
+ showErrorAlert = false;
+ formError.set();
+ formData.username.errors = [];
+ formData.password.errors = [];
+ const response = await accountService.create_account_async(formData.as_payload());
+ if (response.isCreated) {
+ await goto("/home");
+ } else if (response.knownProblem) {
+ formError.set_from_known_problem(response.knownProblem);
+ for (const error of Object.entries(response.knownProblem.errors)) {
+ if (error[0] === "username") {
+ const errors = [];
+ error[1].forEach((e) => errors.push(e));
+ formData.username.errors = errors;
+ }
+ if (error[0] === "password") {
+ const errors = [];
+ error[1].forEach((e) => errors.push(e));
+ formData.password.errors = errors;
+ }
+ }
+ } else {
+ formError.set($LL.unexpectedError(), $LL.tryAgainSoon());
+ }
+ loading = false;
+ showErrorAlert = formError.has_error();
+ }
+</script>
+
+<div class="min-h-full flex flex-col justify-center py-12 sm:px-6 lg:px-8">
+ <div class="sm:mx-auto sm:w-full p-2 sm:p-0 sm:max-w-md">
+ <h2 class="mt-6 text-3xl tracking-tight font-bold text-gray-900">
+ {$LL.signUpPage.createYourNewAccount()}
+ </h2>
+ <p class="mt-2 text-sm text-gray-600">
+ {$LL.or().toLowerCase()}
+ <a href="/sign-in" class="link">
+ {$LL.signIntoYourAccount().toLowerCase()}
+ </a>
+ </p>
+ </div>
+
+ <div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
+ <div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
+ {#if showErrorAlert}
+ <Alert title={formError.title} message={formError.subtitle} type="error" class="mb-2" />
+ {/if}
+ <form class="space-y-6" on:submit|preventDefault={submit_form_async}>
+ <Input
+ label={$LL.emailAddress()}
+ id="email"
+ name="email"
+ autocomplete="email"
+ required
+ type="email"
+ bind:value={formData.username.value}
+ errors={formData.username.errors}
+ />
+
+ <Input
+ label={$LL.password()}
+ id="password"
+ name="password"
+ required
+ type="password"
+ bind:value={formData.password.value}
+ errors={formData.password.errors}
+ />
+ <Button type="submit" text={$LL.submit()} {loading} fullWidth />
+ </form>
+ </div>
+ </div>
+</div>
diff --git a/code/app/src/routes/(main)/(public)/sign-up/+page.ts b/code/app/src/routes/(main)/(public)/sign-up/+page.ts
new file mode 100644
index 0000000..8c86f55
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/sign-up/+page.ts
@@ -0,0 +1,11 @@
+import LL from '$i18n/i18n-svelte';
+import { get } from 'svelte/store';
+import type { PageLoad } from './$types';
+
+const l = get(LL);
+
+export const load: PageLoad = async () => {
+ return {
+ title: l.signUpPage.title(),
+ };
+}; \ No newline at end of file
diff --git a/code/app/src/routes/(main)/+layout.server.ts b/code/app/src/routes/(main)/+layout.server.ts
new file mode 100644
index 0000000..25043aa
--- /dev/null
+++ b/code/app/src/routes/(main)/+layout.server.ts
@@ -0,0 +1,45 @@
+import { api_base, CookieNames } from "$configuration";
+import { cached_result_async, CacheKeys } from "$utilities/cache";
+import { log_debug, log_error } from "$utilities/logger";
+import { get_md5_hash } from "$utilities/crypto-helpers";
+import { error, redirect } from "@sveltejs/kit";
+import type { LayoutServerLoad } from "./$types";
+
+export const load: LayoutServerLoad = async ({ route, cookies, locals, fetch }) => {
+ const isBaseRoute = route.id === "/(main)";
+ const isPortalRoute = route.id === "/(main)/(public)/portal";
+ const isPublicRoute = (isBaseRoute || (route.id?.startsWith("/(main)/(public)") ?? false)) ?? true;
+ const sessionCookieValue = cookies.get(CookieNames.session);
+ let sessionIsValid = false;
+ if ((sessionCookieValue?.length > 0 ?? false)) {
+ const sessionHash = get_md5_hash(sessionCookieValue);
+ sessionIsValid = (await cached_result_async<Response>(sessionHash + "_" + CacheKeys.isAuthenticated, 120, () => fetch(api_base("_/is-authenticated"), {
+ headers: {
+ Cookie: CookieNames.session + "=" + sessionCookieValue,
+ },
+ }).catch((e) => {
+ log_error(e);
+ throw error(503, {
+ message: "We are experiencing a service disruption! Have patience while we resolve the issue.",
+ });
+ }))).ok;
+ }
+
+ log_debug("Base Layout loaded", {
+ sessionIsValid,
+ isPublicRoute,
+ isBaseRoute,
+ isPortalRoute,
+ routeId: route.id,
+ });
+
+ if (sessionIsValid && isPublicRoute && !isPortalRoute) {
+ throw redirect(302, "/home");
+ } else if (!isPortalRoute && (isBaseRoute || !sessionIsValid && !isPublicRoute)) {
+ throw redirect(302, "/sign-in");
+ }
+
+ return {
+ locale: locals.locale,
+ };
+};
diff --git a/code/app/src/routes/(main)/+layout.svelte b/code/app/src/routes/(main)/+layout.svelte
new file mode 100644
index 0000000..7662d6a
--- /dev/null
+++ b/code/app/src/routes/(main)/+layout.svelte
@@ -0,0 +1,31 @@
+<script lang="ts">
+ import "../../app.pcss";
+ import { setLocale } from "$i18n/i18n-svelte";
+ import { ExclamationTriangleIcon } from "$components/icons";
+ import { page } from "$app/stores";
+ import type { LayoutData } from "./$types";
+
+ let online = true;
+ export let data: LayoutData;
+ setLocale(data.locale);
+</script>
+
+<svelte:window bind:online />
+<svelte:head>
+ <title>{$page.data.title ? $page.data.title + " - Greatoffice" : "Greatoffice"}</title>
+</svelte:head>
+
+{#if !online}
+ <div class="bg-yellow-50 relative z-50 p-4">
+ <div class="flex">
+ <div class="flex-shrink-0">
+ <ExclamationTriangleIcon class="bg-yellow-50 text-yellow-500" />
+ </div>
+ <div class="ml-3">
+ <p class="text-sm text-yellow-700">You seem to be offline, please check your internet connection.</p>
+ </div>
+ </div>
+ </div>
+{/if}
+
+<slot />
diff --git a/code/app/src/routes/(main)/+layout.ts b/code/app/src/routes/(main)/+layout.ts
new file mode 100644
index 0000000..3893260
--- /dev/null
+++ b/code/app/src/routes/(main)/+layout.ts
@@ -0,0 +1,10 @@
+import type { LayoutLoad } from "./$types";
+import type { Locales } from "$i18n/i18n-types";
+import { loadLocaleAsync } from "$i18n/i18n-util.async";
+import { setLocale } from "$i18n/i18n-svelte";
+
+export const load: LayoutLoad<{ locale: Locales }> = async ({ data: { locale } }) => {
+ await loadLocaleAsync(locale);
+ setLocale(locale);
+ return { locale };
+}; \ No newline at end of file
diff --git a/code/app/src/routes/(main)/+page.svelte b/code/app/src/routes/(main)/+page.svelte
new file mode 100644
index 0000000..e507a19
--- /dev/null
+++ b/code/app/src/routes/(main)/+page.svelte
@@ -0,0 +1 @@
+<p class="text-bold p-1">Hold on...</p>
diff --git a/code/app/src/routes/book/+layout.svelte b/code/app/src/routes/book/+layout.svelte
new file mode 100644
index 0000000..385d0a6
--- /dev/null
+++ b/code/app/src/routes/book/+layout.svelte
@@ -0,0 +1,46 @@
+<script>
+ import { page } from "$app/stores";
+ import "../../app.pcss";
+</script>
+
+<div id="wrapper">
+ <nav>
+ <a href="/book/alerts" class="link" class:active={$page.url.pathname.startsWith("/book/alerts")}>Alerts</a>
+ <a href="/book/buttons" class="link" class:active={$page.url.pathname.startsWith("/book/buttons")}>Buttons</a>
+ <a href="/book/toggles" class="link" class:active={$page.url.pathname.startsWith("/book/toggles")}>Toggles</a>
+ <a href="/book/inputs" class="link" class:active={$page.url.pathname.startsWith("/book/inputs")}>Inputs</a>
+ <a href="/book/badges" class="link" class:active={$page.url.pathname.startsWith("/book/badges")}>Badges</a>
+ <a href="/book/notifications" class="link" class:active={$page.url.pathname.startsWith("/book/notifications")}>Notifications</a>
+ </nav>
+ <main>
+ <slot />
+ </main>
+</div>
+
+<style global lang="postcss">
+ #wrapper {
+ display: flex;
+ flex-direction: row;
+ }
+ nav {
+ min-width: 120px;
+ padding: 10px;
+ display: flex;
+ flex-direction: column;
+ position: sticky;
+ position: -webkit-sticky;
+ top: 0;
+ height: fit-content;
+ }
+ main {
+ width: 100%;
+ padding: 10px;
+ }
+ section {
+ margin-bottom: 25px;
+
+ h2 {
+ margin-bottom: 5px;
+ }
+ }
+</style>
diff --git a/code/app/src/routes/book/+layout.ts b/code/app/src/routes/book/+layout.ts
new file mode 100644
index 0000000..d297dfd
--- /dev/null
+++ b/code/app/src/routes/book/+layout.ts
@@ -0,0 +1,3 @@
+export const ssr = import.meta.env.DEV;
+export const csr = import.meta.env.DEV;
+export const prerender = import.meta.env.DEV; \ No newline at end of file
diff --git a/code/app/src/routes/book/+page.svelte b/code/app/src/routes/book/+page.svelte
new file mode 100644
index 0000000..635b3c2
--- /dev/null
+++ b/code/app/src/routes/book/+page.svelte
@@ -0,0 +1 @@
+<p>A showcase of greatoffices components</p>
diff --git a/code/app/src/routes/book/alerts/+page.svelte b/code/app/src/routes/book/alerts/+page.svelte
new file mode 100644
index 0000000..ed4c92b
--- /dev/null
+++ b/code/app/src/routes/book/alerts/+page.svelte
@@ -0,0 +1,70 @@
+<script>
+ import Alert from "$components/alert.svelte";
+</script>
+
+<section>
+ <h2>Info</h2>
+ <Alert type="info" message="This is message" title="This is title"/>
+</section>
+<section>
+ <h2>Warning</h2>
+ <Alert type="warning" message="This is message" title="This is title"/>
+</section>
+<section>
+ <h2>Error</h2>
+ <Alert type="error" message="This is message" title="This is title"/>
+</section>
+<section>
+ <h2>Success</h2>
+ <Alert type="success" message="This is message" title="This is title"/>
+</section>
+<section>
+ <h2>Actions</h2>
+ <Alert
+ type="info"
+ message="This is message"
+ title="This is title"
+ closeable
+ actions={[
+ {
+ id: "confirm",
+ text: "Yes!",
+ },
+ {
+ id: "cancel",
+ text: "No!",
+ color: "red",
+ },
+ ]}
+ />
+</section>
+<section>
+ <h2>Right link</h2>
+ <Alert
+ on:rightLinkCliked={() => alert("Right link clicked")}
+ rightLinkText="Link or action"
+ title="Go here"
+ message="Hehe"
+ type="error"
+ />
+</section>
+<section>
+ <h2>List</h2>
+ <Alert
+ title="This is title"
+ listItems={["Message 1", "Message 2"]}
+ type="error"
+ message="This is bad dude"
+ closeable
+ closeableCooldown="60"
+ id="alert-1"
+ on:actrepeat={() => {
+ alert("Repeat requested");
+ }}
+ actions={[{ id: "repeat", text: "Try again" }]}
+ />
+</section>
+<section>
+ <h2>Closeable</h2>
+ <Alert message="This is message" closeable type="info"/>
+</section>
diff --git a/code/app/src/routes/book/badges/+page.svelte b/code/app/src/routes/book/badges/+page.svelte
new file mode 100644
index 0000000..50ae61e
--- /dev/null
+++ b/code/app/src/routes/book/badges/+page.svelte
@@ -0,0 +1,19 @@
+<script lang="ts">
+ import Badge from "$components/badge.svelte";
+</script>
+
+<section>
+ <h2>Variants</h2>
+ <Badge text="default"/>
+ <Badge type="blue" text="blue"/>
+ <Badge type="green" text="green"/>
+ <Badge type="red" text="red"/>
+ <Badge type="tame" text="tame"/>
+ <Badge type="yellow" text="yellow"/>
+ <Badge size="large" text="large"/>
+ <Badge text="with dot" withDot type="blue"/>
+ <Badge text="removable" removable id="badge-1" on:remove={(e) => alert("removed " + e.detail.id)}/>
+ <Badge text="with dot" size="large" withDot type="blue"/>
+ <Badge text="removable" removable size="large" id="badge-2" uppercase
+ on:remove={(e) => alert("removed " + e.detail.id)}/>
+</section>
diff --git a/code/app/src/routes/book/buttons/+page.svelte b/code/app/src/routes/book/buttons/+page.svelte
new file mode 100644
index 0000000..6668a64
--- /dev/null
+++ b/code/app/src/routes/book/buttons/+page.svelte
@@ -0,0 +1,23 @@
+<script>
+ import Button from "$components/button.svelte";
+</script>
+
+<section>
+ <h2>Primary</h2>
+ <Button kind="primary" text="Small" size="sm"/>
+ <Button kind="primary" text="Medium/Default"/>
+ <Button kind="primary" text="Large" size="lg"/>
+ <Button kind="primary" text="Extra large" size="xl"/>
+</section>
+<section>
+ <h2>Secondary</h2>
+ <Button kind="secondary" text="Click me!"/>
+</section>
+<section>
+ <h2>White</h2>
+ <Button kind="white" text="Click me!"/>
+</section>
+<section>
+ <h2>Loading</h2>
+ <Button kind="primary" loading={true} text="Wait"/>
+</section>
diff --git a/code/app/src/routes/book/inputs/+page.svelte b/code/app/src/routes/book/inputs/+page.svelte
new file mode 100644
index 0000000..433607b
--- /dev/null
+++ b/code/app/src/routes/book/inputs/+page.svelte
@@ -0,0 +1,75 @@
+<script lang="ts">
+ import {TextArea, Input, Combobox} from "$components";
+ import {DatabaseIcon} from "$components/icons";
+
+ let value;
+ let i = 0;
+ let options = [];
+ let tempOptions = [];
+ while (i < 101) {
+ tempOptions.push({
+ id: crypto.randomUUID(),
+ name: "Option " + i,
+ });
+ options = tempOptions;
+ i++;
+ }
+
+ async function add({name}) {
+ const copy = options;
+ copy.push({
+ id: crypto.randomUUID(),
+ name: name,
+ });
+ options = copy;
+ }
+</script>
+
+<section>
+ <h2>Combobox</h2>
+ <Combobox {options} label="Wiii" multiple createable on_create_async={add}/>
+</section>
+
+<section>
+ <h2>Default</h2>
+ <Input label="Input me" placeholder="Hello" bind:value/>
+</section>
+
+<section>
+ <h2>With icon</h2>
+ <Input label="Input me" placeholder="Hello" icon={DatabaseIcon} bind:value/>
+</section>
+
+<section>
+ <h2>With corner hint</h2>
+ <Input label="Input me ->" placeholder="Hello" cornerHint="Hint hint" bind:value/>
+</section>
+
+<section>
+ <h2>Disabled</h2>
+ <Input label="No" placeholder="Sorry" disabled bind:value/>
+</section>
+
+<section>
+ <h2>Errored</h2>
+ <Input label="No" placeholder="Sorry" errorText="That's not right" bind:value icon={DatabaseIcon}/>
+</section>
+
+<section>
+ <h2>Many errors</h2>
+ <Input label="No" placeholder="Sorry" errors={["That's not right", "Call help!", "Get it together"]} bind:value
+ icon={DatabaseIcon}/>
+</section>
+
+<section>
+ <h2>Help</h2>
+ <Input label="Go ahead" placeholder="Write here" helpText="Write above" bind:value/>
+</section>
+<section>
+ <h2>Addon</h2>
+ <Input label="Go ahead" placeholder="Write here" bind:value helpText="Write above" addon="To the right"/>
+</section>
+<section>
+ <h2>Textarea</h2>
+ <TextArea bind:value label="Hi"/>
+</section>
diff --git a/code/app/src/routes/book/notifications/+page.svelte b/code/app/src/routes/book/notifications/+page.svelte
new file mode 100644
index 0000000..1a6144d
--- /dev/null
+++ b/code/app/src/routes/book/notifications/+page.svelte
@@ -0,0 +1,50 @@
+<script lang="ts">
+ import { Notification } from "$components";
+ import type { NotificationType } from "$components/notification.svelte";
+
+ let type = "info" as NotificationType;
+ let nonClosable = false;
+ let title = "Title";
+ let subtitle = "Subtitle";
+ let hideAfterSeconds = -1;
+ let timeout;
+
+ function open(newtype: NotificationType) {
+ console.log(newtype);
+ type = newtype;
+ }
+</script>
+
+<section style="display: flex;flex-direction: column; max-width:200px;gap:5px">
+ <h2>Type:</h2>
+ <select
+ on:change={(e) => {
+ //@ts-ignore
+ open(e.target.selectedOptions[0].value);
+ }}
+ >
+ <option value="info">info</option>
+ <option value="warning">warning</option>
+ <option value="error">error</option>
+ <option value="success">success</option>
+ <option value="subtle">subtle</option>
+ </select>
+ <label for="nonClosable">
+ <input type="checkbox" id="nonClosable" bind:checked={nonClosable} />
+ nonClosable
+ </label>
+ <input type="text" bind:value={title} />
+ <input type="text" bind:value={subtitle} />
+ <input type="number" bind:value={timeout} placeholder="hideAfterSeconds" />
+ <small class="text-sm justify-end">
+ <span class="link" on:click={() => (hideAfterSeconds = timeout ?? -1)}>Apply</span>
+ <span
+ class="link"
+ on:click={() => {
+ hideAfterSeconds = -1;
+ timeout = 0;
+ }}>Reset</span
+ >
+ </small>
+ <Notification {title} {subtitle} show={true} {type} {nonClosable} {hideAfterSeconds} />
+</section>
diff --git a/code/app/src/routes/book/toggles/+page.svelte b/code/app/src/routes/book/toggles/+page.svelte
new file mode 100644
index 0000000..cb0adec
--- /dev/null
+++ b/code/app/src/routes/book/toggles/+page.svelte
@@ -0,0 +1,27 @@
+<script>
+ import Switch from "$components/switch.svelte";
+</script>
+
+<section>
+ <h2>Default</h2>
+ <Switch />
+</section>
+<section>
+ <h2>Short</h2>
+ <Switch type="short" />
+</section>
+<section>
+ <h2>Icon</h2>
+ <Switch type="icon" />
+</section>
+<section>
+ <h2>Label / Description</h2>
+ <div class="max-w-md">
+ <Switch label="Label" description="Some text" />
+ </div>
+</section>
+
+<section>
+ <h2>Label / Description (right aligned)</h2>
+ <Switch label="Label" description="Some text" rightAlignedLabelDescription />
+</section>