aboutsummaryrefslogtreecommitdiffstats
path: root/code/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'code/app/src')
-rw-r--r--code/app/src/actions/pwKey.js12
-rw-r--r--code/app/src/actions/pwKey.js.map1
-rw-r--r--code/app/src/actions/pwKey.ts10
-rw-r--r--code/app/src/app.d.ts9
-rw-r--r--code/app/src/app.html14
-rw-r--r--code/app/src/app.pcss34
-rw-r--r--code/app/src/global.d.ts11
-rw-r--r--code/app/src/lib/api/internal-fetch.ts170
-rw-r--r--code/app/src/lib/api/root.ts6
-rw-r--r--code/app/src/lib/api/time-entry.ts83
-rw-r--r--code/app/src/lib/api/user.ts47
-rw-r--r--code/app/src/lib/colors.ts47
-rw-r--r--code/app/src/lib/components/alert.svelte268
-rw-r--r--code/app/src/lib/components/button.svelte103
-rw-r--r--code/app/src/lib/components/checkbox.svelte24
-rw-r--r--code/app/src/lib/components/icons/adjustments.svelte14
-rw-r--r--code/app/src/lib/components/icons/bars-3-center-left.svelte15
-rw-r--r--code/app/src/lib/components/icons/calendar.svelte14
-rw-r--r--code/app/src/lib/components/icons/check-circle.svelte13
-rw-r--r--code/app/src/lib/components/icons/chevron-up-down.svelte13
-rw-r--r--code/app/src/lib/components/icons/database.svelte14
-rw-r--r--code/app/src/lib/components/icons/exclamation-circle.svelte13
-rw-r--r--code/app/src/lib/components/icons/exclamation-triangle.svelte13
-rw-r--r--code/app/src/lib/components/icons/folder-open.svelte14
-rw-r--r--code/app/src/lib/components/icons/home.svelte14
-rw-r--r--code/app/src/lib/components/icons/index.ts41
-rw-r--r--code/app/src/lib/components/icons/information-circle.svelte13
-rw-r--r--code/app/src/lib/components/icons/magnifying-glass.svelte13
-rw-r--r--code/app/src/lib/components/icons/megaphone.svelte14
-rw-r--r--code/app/src/lib/components/icons/menu.svelte14
-rw-r--r--code/app/src/lib/components/icons/queue-list.svelte14
-rw-r--r--code/app/src/lib/components/icons/spinner.svelte20
-rw-r--r--code/app/src/lib/components/icons/x-circle.svelte13
-rw-r--r--code/app/src/lib/components/icons/x-mark.svelte11
-rw-r--r--code/app/src/lib/components/icons/x.svelte14
-rw-r--r--code/app/src/lib/components/index.ts15
-rw-r--r--code/app/src/lib/components/input.svelte103
-rw-r--r--code/app/src/lib/components/locale-switcher.svelte55
-rw-r--r--code/app/src/lib/components/switch.svelte143
-rw-r--r--code/app/src/lib/configuration.ts60
-rw-r--r--code/app/src/lib/helpers.ts497
-rw-r--r--code/app/src/lib/i18n/en/app/index.ts5
-rw-r--r--code/app/src/lib/i18n/en/index.ts50
-rw-r--r--code/app/src/lib/i18n/formatters.ts13
-rw-r--r--code/app/src/lib/i18n/i18n-svelte.ts12
-rw-r--r--code/app/src/lib/i18n/i18n-types.ts359
-rw-r--r--code/app/src/lib/i18n/i18n-util.async.ts42
-rw-r--r--code/app/src/lib/i18n/i18n-util.sync.ts35
-rw-r--r--code/app/src/lib/i18n/i18n-util.ts39
-rw-r--r--code/app/src/lib/i18n/nb/app/index.ts8
-rw-r--r--code/app/src/lib/i18n/nb/index.ts50
-rw-r--r--code/app/src/lib/logger.ts86
-rw-r--r--code/app/src/lib/models/CreateAccountPayload.ts4
-rw-r--r--code/app/src/lib/models/ErrorResult.ts4
-rw-r--r--code/app/src/lib/models/IInternalFetchRequest.ts6
-rw-r--r--code/app/src/lib/models/IInternalFetchResponse.ts6
-rw-r--r--code/app/src/lib/models/ISession.ts8
-rw-r--r--code/app/src/lib/models/IValidationResult.ts31
-rw-r--r--code/app/src/lib/models/LoginPayload.ts5
-rw-r--r--code/app/src/lib/models/TimeCategoryDto.ts9
-rw-r--r--code/app/src/lib/models/TimeEntryDto.ts13
-rw-r--r--code/app/src/lib/models/TimeEntryQuery.ts27
-rw-r--r--code/app/src/lib/models/TimeLabelDto.ts8
-rw-r--r--code/app/src/lib/models/TimeQueryDto.ts29
-rw-r--r--code/app/src/lib/models/UnwrappedEntryDateTime.ts9
-rw-r--r--code/app/src/lib/models/UpdateProfilePayload.ts4
-rw-r--r--code/app/src/lib/persistent-store.ts102
-rw-r--r--code/app/src/lib/session.ts69
-rw-r--r--code/app/src/routes/(main)/(app)/+layout.svelte297
-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.svelte5
-rw-r--r--code/app/src/routes/(main)/(app)/settings/+page.svelte4
-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)/reset-password/+page.svelte82
-rw-r--r--code/app/src/routes/(main)/(public)/reset-password/[id]/+page.server.js11
-rw-r--r--code/app/src/routes/(main)/(public)/reset-password/[id]/+page.server.js.map1
-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.svelte132
-rw-r--r--code/app/src/routes/(main)/(public)/sign-in/+page.svelte133
-rw-r--r--code/app/src/routes/(main)/(public)/sign-in/index.ts19
-rw-r--r--code/app/src/routes/(main)/(public)/sign-in/tests/index.spec.ts12
-rw-r--r--code/app/src/routes/(main)/(public)/sign-up/+page.svelte82
-rw-r--r--code/app/src/routes/(main)/+layout.server.ts34
-rw-r--r--code/app/src/routes/(main)/+layout.svelte29
-rw-r--r--code/app/src/routes/(main)/+layout.ts15
-rw-r--r--code/app/src/routes/(main)/+page.svelte1
-rw-r--r--code/app/src/routes/book/+layout.svelte64
-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/buttons/+page.svelte23
-rw-r--r--code/app/src/routes/book/inputs/+page.svelte48
-rw-r--r--code/app/src/routes/book/toggles/+page.svelte27
97 files changed, 4189 insertions, 0 deletions
diff --git a/code/app/src/actions/pwKey.js b/code/app/src/actions/pwKey.js
new file mode 100644
index 0000000..2c019f3
--- /dev/null
+++ b/code/app/src/actions/pwKey.js
@@ -0,0 +1,12 @@
+import { is_development, is_testing } from "$lib/configuration";
+export default function pwKey(node, value) {
+ if (!value)
+ return;
+ if (!is_testing()) {
+ if (is_development())
+ console.warn("VITE_TESTING is false, so not setting pw-key attributes");
+ return;
+ }
+ node.setAttribute("pw-key", value);
+}
+//# sourceMappingURL=pwKey.js.map \ No newline at end of file
diff --git a/code/app/src/actions/pwKey.js.map b/code/app/src/actions/pwKey.js.map
new file mode 100644
index 0000000..2c37f87
--- /dev/null
+++ b/code/app/src/actions/pwKey.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"pwKey.js","sourceRoot":"","sources":["pwKey.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAEhE,MAAM,CAAC,OAAO,UAAU,KAAK,CAAC,IAAiB,EAAE,KAAyB;IACtE,IAAI,CAAC,KAAK;QAAE,OAAO;IACnB,IAAI,CAAC,UAAU,EAAE,EAAE;QACf,IAAI,cAAc,EAAE;YAAE,OAAO,CAAC,IAAI,CAAC,yDAAyD,CAAC,CAAC;QAC9F,OAAO;KACV;IACD,IAAI,CAAC,YAAY,CAAC,QAAQ,EAAE,KAAK,CAAC,CAAC;AACvC,CAAC"} \ No newline at end of file
diff --git a/code/app/src/actions/pwKey.ts b/code/app/src/actions/pwKey.ts
new file mode 100644
index 0000000..a2f22e7
--- /dev/null
+++ b/code/app/src/actions/pwKey.ts
@@ -0,0 +1,10 @@
+import { is_development, is_testing } from "$lib/configuration";
+
+export default function pwKey(node: HTMLElement, value: string | undefined) {
+ if (!value) return;
+ if (!is_testing()) {
+ if (is_development()) console.warn("VITE_TESTING is false, so not setting pw-key attributes");
+ return;
+ }
+ node.setAttribute("pw-key", value);
+} \ No newline at end of file
diff --git a/code/app/src/app.d.ts b/code/app/src/app.d.ts
new file mode 100644
index 0000000..220ddc1
--- /dev/null
+++ b/code/app/src/app.d.ts
@@ -0,0 +1,9 @@
+// See https://kit.svelte.dev/docs/types#app
+// for information about these interfaces
+// and what to do when importing types
+declare namespace App {
+ interface Locals {}
+ interface Platform {}
+ interface PrivateEnv {}
+ interface PublicEnv {}
+} \ No newline at end of file
diff --git a/code/app/src/app.html b/code/app/src/app.html
new file mode 100644
index 0000000..308b223
--- /dev/null
+++ b/code/app/src/app.html
@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+<html class="h-full bg-white" lang="en">
+
+<head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width" />
+ %sveltekit.head%
+</head>
+
+<body class="h-full">
+ <div>%sveltekit.body%</div>
+</body>
+
+</html> \ No newline at end of file
diff --git a/code/app/src/app.pcss b/code/app/src/app.pcss
new file mode 100644
index 0000000..d256fea
--- /dev/null
+++ b/code/app/src/app.pcss
@@ -0,0 +1,34 @@
+/* Write your global styles here, in PostCSS syntax */
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+pre {
+ font-family: monospace !important;
+}
+
+*:focus-visible {
+ outline: 1px auto;
+}
+
+.c-disabled {
+ cursor: not-allowed !important;
+ filter: opacity(.45);
+ pointer-events: none !important;
+}
+
+.c-disabled.loading {
+ cursor: wait !important;
+}
+
+.link {
+ @apply text-blue-600 hover:text-blue-700 transition duration-300 ease-in-out mb-4;
+
+ &.danger {
+ @apply text-red-600 hover:text-red-700;
+ }
+
+ &.active {
+ @apply underline
+ }
+} \ No newline at end of file
diff --git a/code/app/src/global.d.ts b/code/app/src/global.d.ts
new file mode 100644
index 0000000..13f5e16
--- /dev/null
+++ b/code/app/src/global.d.ts
@@ -0,0 +1,11 @@
+/// <reference types="@sveltejs/kit" />
+
+type Locales = import('$lib/i18n/i18n-types').Locales
+type TranslationFunctions = import('$lib/i18n/i18n-types').TranslationFunctions
+
+declare namespace App {
+ interface Locals {
+ locale: Locales
+ LL: TranslationFunctions
+ }
+} \ No newline at end of file
diff --git a/code/app/src/lib/api/internal-fetch.ts b/code/app/src/lib/api/internal-fetch.ts
new file mode 100644
index 0000000..b21d669
--- /dev/null
+++ b/code/app/src/lib/api/internal-fetch.ts
@@ -0,0 +1,170 @@
+import { Temporal } from "temporal-polyfill";
+import { clear_session_data } from "$lib/session";
+import { resolve_references } from "$lib/helpers";
+import type { IInternalFetchResponse } from "$lib/models/IInternalFetchResponse";
+import type { IInternalFetchRequest } from "$lib/models/IInternalFetchRequest";
+import { redirect } from "@sveltejs/kit";
+
+export async function http_post(url: string, body?: object | string, timeout = -1, skip_401_check = false, abort_signal?: AbortSignal): Promise<IInternalFetchResponse> {
+ const init = {
+ method: "post",
+ } as RequestInit;
+
+ if (abort_signal) {
+ init.signal = abort_signal;
+ }
+
+ if (body) {
+ init.headers = {
+ "Content-Type": "application/json;charset=UTF-8",
+ };
+ init.body = JSON.stringify(body);
+ }
+
+ const response = await internal_fetch({ url, init, timeout });
+ const result = {} as IInternalFetchResponse;
+
+ if (!skip_401_check && await is_401(response)) return result;
+
+ result.ok = response.ok;
+ result.status = response.status;
+ result.http_response = response;
+
+ if (response.status !== 204) {
+ try {
+ const ct = response.headers.get("Content-Type")?.toString() ?? "";
+ if (ct.startsWith("application/json")) {
+ const data = await response.json();
+ result.data = resolve_references(data);
+ } else if (ct.startsWith("text/plain")) {
+ const text = await response.text();
+ result.data = text as string;
+ }
+ } catch {
+ // Ignored
+ }
+ }
+
+ return result;
+}
+
+export async function http_get(url: string, timeout = -1, skip_401_check = false, abort_signal?: AbortSignal): Promise<IInternalFetchResponse> {
+ const init = {
+ method: "get",
+ } as RequestInit;
+
+ if (abort_signal) {
+ init.signal = abort_signal;
+ }
+
+ const response = await internal_fetch({ url, init, timeout });
+ const result = {} as IInternalFetchResponse;
+
+ if (!skip_401_check && await is_401(response)) return result;
+
+ result.ok = response.ok;
+ result.status = response.status;
+ result.http_response = response;
+
+ if (response.status !== 204) {
+ try {
+ const ct = response.headers.get("Content-Type")?.toString() ?? "";
+ if (ct.startsWith("application/json")) {
+ const data = await response.json();
+ result.data = resolve_references(data);
+ } else if (ct.startsWith("text/plain")) {
+ const text = await response.text();
+ result.data = text as string;
+ }
+ } catch {
+ // Ignored
+ }
+ }
+
+ return result;
+}
+
+export async function http_delete(url: string, body?: object | string, timeout = -1, skip_401_check = false, abort_signal?: AbortSignal): Promise<IInternalFetchResponse> {
+ const init = {
+ method: "delete",
+ } as RequestInit;
+
+ if (abort_signal) {
+ init.signal = abort_signal;
+ }
+
+ if (body) {
+ init.headers = {
+ "Content-Type": "application/json;charset=UTF-8",
+ };
+ init.body = JSON.stringify(body);
+ }
+
+ const response = await internal_fetch({ url, init, timeout });
+ const result = {} as IInternalFetchResponse;
+
+ if (!skip_401_check && await is_401(response)) return result;
+
+ result.ok = response.ok;
+ result.status = response.status;
+ result.http_response = response;
+
+ if (response.status !== 204) {
+ try {
+ const ct = response.headers.get("Content-Type")?.toString() ?? "";
+ if (ct.startsWith("application/json")) {
+ const data = await response.json();
+ result.data = resolve_references(data);
+ } else if (ct.startsWith("text/plain")) {
+ const text = await response.text();
+ result.data = text as string;
+ }
+ } catch (error) {
+ // ignored
+ }
+ }
+
+ return result;
+}
+
+async function internal_fetch(request: IInternalFetchRequest): Promise<Response> {
+ if (!request.init) request.init = {};
+ request.init.credentials = "include";
+ request.init.headers = {
+ "X-TimeZone": Temporal.Now.timeZone().id,
+ ...request.init.headers
+ };
+
+ const fetch_request = new Request(request.url, request.init);
+ let response: any;
+
+ try {
+ if (request.timeout && request.timeout > 500) {
+ response = await Promise.race([
+ fetch(fetch_request),
+ new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), request.timeout))
+ ]);
+ } else {
+ response = await fetch(fetch_request);
+ }
+ } catch (error: any) {
+ console.log(error);
+ if (error.message === "Timeout") {
+ console.error("Request timed out");
+ } else if (error.message === "Network request failed") {
+ console.error("No internet connection");
+ } else {
+ throw error; // rethrow other unexpected errors
+ }
+ }
+
+ return response;
+}
+
+async function is_401(response: Response): Promise<boolean> {
+ if (response.status === 401) {
+ clear_session_data();
+ throw redirect(307, "/login");
+ }
+ return false;
+}
diff --git a/code/app/src/lib/api/root.ts b/code/app/src/lib/api/root.ts
new file mode 100644
index 0000000..3e5bda2
--- /dev/null
+++ b/code/app/src/lib/api/root.ts
@@ -0,0 +1,6 @@
+import {http_post} from "$lib/api/internal-fetch";
+import {api_base} from "$lib/configuration";
+
+export function server_log(message: string): void {
+ http_post(api_base("_/api/log"), message);
+}
diff --git a/code/app/src/lib/api/time-entry.ts b/code/app/src/lib/api/time-entry.ts
new file mode 100644
index 0000000..a40b0c2
--- /dev/null
+++ b/code/app/src/lib/api/time-entry.ts
@@ -0,0 +1,83 @@
+import {api_base} from "$lib/configuration";
+import {is_guid} from "$lib/helpers";
+import {http_delete, http_get, http_post} from "./internal-fetch";
+import type {TimeCategoryDto} from "$lib/models/TimeCategoryDto";
+import type {TimeLabelDto} from "$lib/models/TimeLabelDto";
+import type {TimeEntryDto} from "$lib/models/TimeEntryDto";
+import type {TimeEntryQuery} from "$lib/models/TimeEntryQuery";
+import type {IInternalFetchResponse} from "$lib/models/IInternalFetchResponse";
+
+
+// ENTRIES
+
+export async function create_time_entry(payload: TimeEntryDto): Promise<IInternalFetchResponse> {
+ return http_post(api_base("v1/entries/create"), payload);
+}
+
+export async function get_time_entry(entryId: string): Promise<IInternalFetchResponse> {
+ if (is_guid(entryId)) {
+ return http_get(api_base("v1/entries/" + entryId));
+ }
+ throw new Error("entryId is not a valid guid.");
+}
+
+export async function get_time_entries(entryQuery: TimeEntryQuery): Promise<IInternalFetchResponse> {
+ return http_post(api_base("v1/entries/query"), entryQuery);
+}
+
+export async function delete_time_entry(id: string): Promise<IInternalFetchResponse> {
+ if (!is_guid(id)) throw new Error("id is not a valid guid");
+ return http_delete(api_base("v1/entries/" + id + "/delete"));
+}
+
+export async function update_time_entry(entryDto: TimeEntryDto): Promise<IInternalFetchResponse> {
+ if (!is_guid(entryDto.id ?? "")) throw new Error("id is not a valid guid");
+ if (!entryDto.category) throw new Error("category is empty");
+ if (!entryDto.stop) throw new Error("stop is empty");
+ if (!entryDto.start) throw new Error("start is empty");
+ return http_post(api_base("v1/entries/update"), entryDto);
+}
+
+// LABELS
+export async function create_time_label(labelDto: TimeLabelDto): Promise<IInternalFetchResponse> {
+ return http_post(api_base("v1/labels/create"), labelDto);
+}
+
+export async function get_time_labels(): Promise<IInternalFetchResponse> {
+ return http_get(api_base("v1/labels"));
+}
+
+export async function delete_time_label(id: string): Promise<IInternalFetchResponse> {
+ if (!is_guid(id)) throw new Error("id is not a valid guid");
+ return http_delete(api_base("v1/labels/" + id + "/delete"));
+}
+
+export async function update_time_label(labelDto: TimeLabelDto): Promise<IInternalFetchResponse> {
+ if (!is_guid(labelDto.id ?? "")) throw new Error("id is not a valid guid");
+ if (!labelDto.name) throw new Error("name is empty");
+ if (!labelDto.color) throw new Error("color is empty");
+ return http_post(api_base("v1/labels/update"), labelDto);
+}
+
+// CATEGORIES
+export async function create_time_category(category: TimeCategoryDto): Promise<IInternalFetchResponse> {
+ if (!category.name) throw new Error("name is empty");
+ if (!category.color) throw new Error("color is empty");
+ return http_post(api_base("v1/categories/create"), category);
+}
+
+export async function get_time_categories(): Promise<IInternalFetchResponse> {
+ return http_get(api_base("v1/categories"));
+}
+
+export async function delete_time_category(id: string): Promise<IInternalFetchResponse> {
+ if (!is_guid(id)) throw new Error("id is not a valid guid");
+ return http_delete(api_base("v1/categories/" + id + "/delete"));
+}
+
+export async function update_time_category(category: TimeCategoryDto): Promise<IInternalFetchResponse> {
+ if (!is_guid(category.id ?? "")) throw new Error("id is not a valid guid");
+ if (!category.name) throw new Error("name is empty");
+ if (!category.color) throw new Error("color is empty");
+ return http_post(api_base("v1/categories/update"), category);
+}
diff --git a/code/app/src/lib/api/user.ts b/code/app/src/lib/api/user.ts
new file mode 100644
index 0000000..f0dc932
--- /dev/null
+++ b/code/app/src/lib/api/user.ts
@@ -0,0 +1,47 @@
+import {api_base} from "$lib/configuration";
+import {http_delete, http_get, http_post} from "./internal-fetch";
+import type {LoginPayload} from "$lib/models/LoginPayload";
+import type {UpdateProfilePayload} from "$lib/models/UpdateProfilePayload";
+import type {CreateAccountPayload} from "$lib/models/CreateAccountPayload";
+import type {IInternalFetchResponse} from "$lib/models/IInternalFetchResponse";
+
+export async function login(payload: LoginPayload): Promise<IInternalFetchResponse> {
+ return http_post(api_base("_/account/login"), payload);
+}
+
+export async function logout(): Promise<IInternalFetchResponse> {
+ return http_get(api_base("_/account/logout"));
+}
+
+export async function create_forgot_password_request(username: string): Promise<IInternalFetchResponse> {
+ if (!username) throw new Error("Username is empty");
+ return http_get(api_base("_/forgot-password-requests/create?username=" + username));
+}
+
+export async function check_forgot_password_request(public_id: string): Promise<IInternalFetchResponse> {
+ if (!public_id) throw new Error("Id is empty");
+ return http_get(api_base("_/forgot-password-requests/is-valid?id=" + public_id));
+}
+
+export async function fulfill_forgot_password_request(public_id: string, newPassword: string): Promise<IInternalFetchResponse> {
+ if (!public_id) throw new Error("Id is empty");
+ return http_post(api_base("_/forgot-password-requests/fulfill"), {id: public_id, newPassword});
+}
+
+export async function delete_account(): Promise<IInternalFetchResponse> {
+ return http_delete(api_base("_/account/delete"));
+}
+
+export async function update_profile(payload: UpdateProfilePayload): Promise<IInternalFetchResponse> {
+ if (!payload.password && !payload.username) throw new Error("Password and Username is empty");
+ return http_post(api_base("_/account/update"), payload);
+}
+
+export async function create_account(payload: CreateAccountPayload): Promise<IInternalFetchResponse> {
+ if (!payload.password && !payload.username) throw new Error("Password and Username is empty");
+ return http_post(api_base("_/account/create"), payload);
+}
+
+export async function get_profile_for_active_check(): Promise<IInternalFetchResponse> {
+ return http_get(api_base("_/account"), 0, true);
+}
diff --git a/code/app/src/lib/colors.ts b/code/app/src/lib/colors.ts
new file mode 100644
index 0000000..34c7992
--- /dev/null
+++ b/code/app/src/lib/colors.ts
@@ -0,0 +1,47 @@
+export function generate_random_hex_color(skip_contrast_check = false) {
+ let hex = __generate_random_hex_color();
+ if (skip_contrast_check) return hex;
+ while ((__calculate_contrast_ratio("#ffffff", hex) < 4.5) || (__calculate_contrast_ratio("#000000", hex) < 4.5)) {
+ hex = __generate_random_hex_color();
+ }
+
+ return hex;
+}
+
+// Largely copied from chroma js api
+function __generate_random_hex_color(): string {
+ let code = "#";
+ for (let i = 0; i < 6; i++) {
+ code += "0123456789abcdef".charAt(Math.floor(Math.random() * 16));
+ }
+ return code;
+}
+
+function __calculate_contrast_ratio(hex1: string, hex2: string): number {
+ const rgb1 = __hex_to_rgb(hex1);
+ const rgb2 = __hex_to_rgb(hex2);
+ const l1 = __get_luminance(rgb1[0], rgb1[1], rgb1[2]);
+ const l2 = __get_luminance(rgb2[0], rgb2[1], rgb2[2]);
+ const result = l1 > l2 ? (l1 + 0.05) / (l2 + 0.05) : (l2 + 0.05) / (l1 + 0.05);
+ return result;
+}
+
+function __hex_to_rgb(hex: string): number[] {
+ if (!hex.match(/^#([A-Fa-f0-9]{6})$/)) return [];
+ if (hex[0] === "#") hex = hex.substring(1, hex.length);
+ return [parseInt(hex.substring(0, 2), 16), parseInt(hex.substring(2, 4), 16), parseInt(hex.substring(4, 6), 16)];
+}
+
+function __get_luminance(r: any, g: any, b: any) {
+ // relative luminance
+ // see http://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
+ r = __luminance_x(r);
+ g = __luminance_x(g);
+ b = __luminance_x(b);
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+}
+
+function __luminance_x(x: any) {
+ x /= 255;
+ return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
+}
diff --git a/code/app/src/lib/components/alert.svelte b/code/app/src/lib/components/alert.svelte
new file mode 100644
index 0000000..fd57105
--- /dev/null
+++ b/code/app/src/lib/components/alert.svelte
@@ -0,0 +1,268 @@
+<script lang="ts">
+ import { random_string } from "$lib/helpers";
+ import { createEventDispatcher } from "svelte";
+ import { onMount } from "svelte";
+ import pwKey from "$actions/pwKey";
+ import { Temporal } from "temporal-polyfill";
+ import { ExclamationTriangleIcon, CheckCircleIcon, InformationCircleIcon, XCircleIcon, XMarkIcon } from "./icons";
+
+ const dispatch = createEventDispatcher();
+ const noCooldownSetting = "no-cooldown";
+
+ let iconComponent: any;
+ let colorClassPart = "";
+
+ /**
+ * An optional id for this alert, a default is set if not specified.
+ * This value is necessary for closeable cooldown to work.
+ */
+ // if no unique id is supplied, cooldown will not work between page loads.
+ // Therefore we are disabling it with noCooldownSetting in the fallback id.
+ export let id = "alert--" + noCooldownSetting + "--" + random_string(4);
+ /**
+ * The title to communicate, value is optional
+ */
+ export let title = "";
+ /**
+ * The message to communicate, value is optional
+ */
+ export let message = "";
+ /**
+ * Changes the alerts color and icon.
+ */
+ export let type: "info" | "success" | "warning" | "error" = "info";
+ /**
+ * If true the alert can be removed from the DOM by clicking on a X icon on the upper right hand courner
+ */
+ export let closeable = false;
+ /**
+ * The amount of seconds that should go by before this alert is shown again, only works when a unique id is set.
+ * Set to ~ if it should only be shown once per client (State stored in localestorage).
+ **/
+ export let closeableCooldown = "-1";
+ /**
+ * The text that is displayed on the right link
+ */
+ export let rightLinkText = "";
+ /**
+ * An array of list items displayed under the message or title
+ */
+ export let listItems: Array<string> = [];
+ /**
+ * An array of {id:string;text:string;color?:string}, where id is dispatched back as an svelte event with this syntax act$id (ex: on:actcancel).
+ * Text is the button text
+ * Color is the optional tailwind color to used, the value is used in classes like bg-$color-50.
+ */
+ export let actions: Array<{ id: string; text: string; color?: string }> = [];
+ /**
+ * This value is set on a plain anchor tag without any svelte routing,
+ * listen to the on:rightLinkClick if you want to intercept the click without navigating
+ */
+ export let rightLinkHref = "javascript:void(0)";
+ $: cooldownEnabled =
+ id.indexOf(noCooldownSetting) === -1 && closeable && (closeableCooldown === "~" || parseInt(closeableCooldown) > 0);
+ /**
+ * Sets this alerts visibility state, when this is false it is removed from the dom using an {#if} block.
+ */
+ export let visible = closeableCooldown === "~" || parseInt(closeableCooldown) > 0 ? false : true;
+
+ export let _pwKey: string | undefined = undefined;
+
+ const cooldownStorageKey = "lastseen--" + id;
+
+ $: switch (type) {
+ case "info": {
+ colorClassPart = "blue";
+ iconComponent = InformationCircleIcon;
+ break;
+ }
+ case "warning": {
+ colorClassPart = "yellow";
+ iconComponent = ExclamationTriangleIcon;
+ break;
+ }
+ case "error": {
+ colorClassPart = "red";
+ iconComponent = XCircleIcon;
+ break;
+ }
+ case "success": {
+ colorClassPart = "green";
+ iconComponent = CheckCircleIcon;
+ break;
+ }
+ }
+
+ function close() {
+ visible = false;
+ if (cooldownEnabled) {
+ console.log("Cooldown enabled for " + id + ", " + closeableCooldown === "~" ? "with an endless cooldown" : "");
+ localStorage.setItem(cooldownStorageKey, String(Temporal.Now.instant().epochSeconds));
+ }
+ }
+
+ function rightLinkClicked() {
+ dispatch("rightLinkCliked");
+ }
+
+ function actionClicked(name: string) {
+ dispatch("act" + name);
+ }
+
+ // Manages the state of the alert if cooldown is enabled
+ function run_cooldown() {
+ if (!cooldownEnabled) {
+ console.log("Alert cooldown is not enabled for " + id);
+ return;
+ }
+ if (!localStorage.getItem(cooldownStorageKey)) {
+ console.log("Alert " + id + " has not been seen yet, displaying");
+ visible = true;
+ return;
+ }
+ // if (!visible) {
+ // console.log(
+ // "Alert " + id + " is not visible, stopping cooldown change"
+ // );
+ // return;
+ // }
+ if (closeableCooldown === "~") {
+ console.log("Alert " + id + " has an infinite cooldown, hiding");
+ visible = false;
+ return;
+ }
+
+ const lastSeen = Temporal.Instant.fromEpochSeconds(parseInt(localStorage.getItem(cooldownStorageKey) ?? "-1"));
+ if (Temporal.Instant.compare(Temporal.Now.instant(), lastSeen.add({ seconds: parseInt(closeableCooldown) })) === 1) {
+ console.log(
+ "Alert " +
+ id +
+ " has a cooldown of " +
+ closeableCooldown +
+ " and was last seen " +
+ lastSeen.toLocaleString() +
+ " making it due for a showing"
+ );
+ visible = true;
+ } else {
+ visible = false;
+ }
+ }
+
+ onMount(() => {
+ if (cooldownEnabled) {
+ run_cooldown();
+ }
+
+ if (closeable && closeableCooldown && id.indexOf(noCooldownSetting) !== -1) {
+ // TODO: This prints twice before shutting up as it should, in this example look at the only alert with closeableCooldown in alertsbook.
+ // Looks like svelte mounts three times and that my id is only set on the third. Not sure it does at all after logging the id onMount.
+ console.error("Alert cooldown does not work without specifying a unique id, related id: " + id);
+ }
+ });
+</script>
+
+{#if visible}
+ <div class="rounded-md bg-{colorClassPart}-50 p-4 {$$restProps.class ?? ''}" use:pwKey={_pwKey}>
+ <div class="flex">
+ <div class="flex-shrink-0">
+ <svelte:component this={iconComponent} class="text-{colorClassPart}-400" />
+ </div>
+ <div class="ml-3 text-sm w-full">
+ {#if !rightLinkText}
+ {#if title}
+ <h3 class="font-medium text-{colorClassPart}-800">
+ {title}
+ </h3>
+ {/if}
+ {#if message}
+ <div class="{title ? 'mt-2' : ''} text-{colorClassPart}-700 justify-start">
+ <p>
+ {@html message}
+ </p>
+ </div>
+ {/if}
+ {#if listItems?.length ?? 0}
+ <ul class="list-disc space-y-1 pl-5 text-{colorClassPart}-700">
+ {#each listItems as listItem}
+ <li>{listItem}</li>
+ {/each}
+ </ul>
+ {/if}
+ {:else}
+ <div class="flex-1 md:flex md:justify-between">
+ <div>
+ {#if title}
+ <h3 class="font-medium text-{colorClassPart}-800">
+ {title}
+ </h3>
+ {/if}
+ {#if message}
+ <div class="{title ? 'mt-2' : ''} text-{colorClassPart}-700 justify-start">
+ <p>
+ {@html message}
+ </p>
+ </div>
+ {/if}
+ {#if listItems?.length ?? 0}
+ <ul class="list-disc space-y-1 pl-5 text-{colorClassPart}-700">
+ {#each listItems as listItem}
+ <li>{listItem}</li>
+ {/each}
+ </ul>
+ {/if}
+ </div>
+ <p class="mt-3 text-sm md:mt-0 md:ml-6 flex items-end">
+ <a
+ href={rightLinkHref}
+ on:click={() => rightLinkClicked()}
+ class="whitespace-nowrap font-medium text-{colorClassPart}-700 hover:text-{colorClassPart}-600"
+ >
+ {rightLinkText}
+ <span aria-hidden="true"> &rarr;</span>
+ </a>
+ </p>
+ </div>
+ {/if}
+ {#if actions?.length ?? 0}
+ <div class="ml-2 mt-4">
+ <div class="-mx-2 -my-1.5 flex gap-1">
+ {#each actions as action}
+ {@const color = action?.color ?? colorClassPart}
+ <button
+ type="button"
+ on:click={() => actionClicked(action.id)}
+ class="rounded-md
+ bg-{color}-50
+ px-2 py-1.5 text-sm font-medium
+ text-{color}-800
+ hover:bg-{color}-100
+ focus:outline-none focus:ring-2
+ focus:ring-{color}-600
+ focus:ring-offset-2
+ focus:ring-offset-{color}-50"
+ >
+ {action.text}
+ </button>
+ {/each}
+ </div>
+ </div>
+ {/if}
+ </div>
+ {#if closeable}
+ <div class="ml-auto pl-3">
+ <div class="-mx-1.5 -my-1.5">
+ <button
+ type="button"
+ on:click={() => close()}
+ class="inline-flex rounded-md bg-{colorClassPart}-50 p-1.5 text-{colorClassPart}-500 hover:bg-{colorClassPart}-100 focus:outline-none focus:ring-2 focus:ring-{colorClassPart}-600 focus:ring-offset-2 focus:ring-offset-{colorClassPart}-50"
+ >
+ <span class="sr-only">Dismiss</span>
+ <XMarkIcon />
+ </button>
+ </div>
+ </div>
+ {/if}
+ </div>
+ </div>
+{/if}
diff --git a/code/app/src/lib/components/button.svelte b/code/app/src/lib/components/button.svelte
new file mode 100644
index 0000000..cbc09e2
--- /dev/null
+++ b/code/app/src/lib/components/button.svelte
@@ -0,0 +1,103 @@
+<script context="module" lang="ts">
+ export type ButtonKind = "primary" | "secondary" | "white";
+ export type ButtonSize = "sm" | "lg" | "md" | "xl";
+</script>
+
+<script lang="ts">
+ import pwKey from "$actions/pwKey";
+
+ import { SpinnerIcon } from "./icons";
+
+ export let kind = "primary" as ButtonKind;
+ export let size = "md" as ButtonSize;
+ export let type: "button" | "submit" | "reset" = "button";
+ export let id: string | undefined = undefined;
+ export let tabindex: string | undefined = undefined;
+ export let style: string | undefined = undefined;
+ export let title: string | undefined = undefined;
+ export let disabled: boolean | null = false;
+ export let href: string | undefined = undefined;
+ export let text: string;
+ export let loading = false;
+ export let fullWidth = false;
+ export let _pwKey: string | undefined = undefined;
+
+ let sizeClasses = "";
+ let kindClasses = "";
+ let spinnerTextClasses = "";
+ let spinnerMarginClasses = "";
+
+ $: shared_props = {
+ type: type,
+ id: id || null,
+ title: title || null,
+ disabled: disabled || loading || null,
+ tabindex: tabindex || null,
+ style: style || null,
+ } as any;
+
+ $: switch (size) {
+ case "sm":
+ sizeClasses = "px-2.5 py-1.5 text-xs";
+ spinnerMarginClasses = "mr-2";
+ break;
+ case "md":
+ sizeClasses = "px-3 py-2 text-sm";
+ spinnerMarginClasses = "mr-2";
+ break;
+ case "lg":
+ sizeClasses = "px-3 py-2 text-lg";
+ spinnerMarginClasses = "mr-2";
+ break;
+ case "xl":
+ sizeClasses = "px-6 py-3 text-xl";
+ spinnerMarginClasses = "mr-2";
+ break;
+ }
+
+ $: switch (kind) {
+ case "secondary":
+ kindClasses = "border-transparent text-teal-800 bg-teal-100 hover:bg-teal-200 focus:ring-teal-500";
+ spinnerTextClasses = "teal-800";
+ break;
+ case "primary":
+ kindClasses = "border-transparent text-teal-900 bg-teal-300 hover:bg-teal-400 focus:ring-teal-500";
+ spinnerTextClasses = "text-teal-900";
+ break;
+ case "white":
+ kindClasses = "border-gray-300 text-gray-700 bg-white hover:bg-gray-50 focus:ring-gray-400";
+ spinnerTextClasses = "text-gray-700";
+ break;
+ }
+</script>
+
+{#if href}
+ <a
+ use:pwKey={_pwKey}
+ {...shared_props}
+ {href}
+ class="{sizeClasses} {kindClasses} {loading ? 'disabled:' : ''} {$$restProps.class ?? ''} {fullWidth
+ ? 'w-full justify-center'
+ : ''} inline-flex items-center border font-medium rounded shadow-sm focus:outline-none focus:ring-2"
+ >
+ {#if loading}
+ <SpinnerIcon class={spinnerTextClasses + " " + spinnerMarginClasses} />
+ {/if}
+ {text}
+ </a>
+{:else}
+ <button
+ use:pwKey={_pwKey}
+ {...shared_props}
+ on:click
+ class="{sizeClasses} {kindClasses} {$$restProps.class ?? ''}
+ {fullWidth
+ ? 'w-full justify-center'
+ : ''} inline-flex items-center border font-medium rounded shadow-sm focus:outline-none focus:ring-2"
+ >
+ {#if loading}
+ <SpinnerIcon class={spinnerTextClasses + " " + spinnerMarginClasses} />
+ {/if}
+ {text}
+ </button>
+{/if}
diff --git a/code/app/src/lib/components/checkbox.svelte b/code/app/src/lib/components/checkbox.svelte
new file mode 100644
index 0000000..b2fcddb
--- /dev/null
+++ b/code/app/src/lib/components/checkbox.svelte
@@ -0,0 +1,24 @@
+<script lang="ts">
+ import pwKey from "$actions/pwKey";
+ import { random_string } from "$lib/helpers";
+
+ export let label: string;
+ export let id: string | undefined = "input__" + random_string(4);
+ export let name: string | undefined = undefined;
+ export let disabled: boolean | null = null;
+ export let checked: boolean;
+ export let _pwKey: string | undefined = undefined;
+</script>
+
+<div class="flex items-center">
+ <input
+ {name}
+ use:pwKey={_pwKey}
+ {disabled}
+ {id}
+ type="checkbox"
+ bind:checked
+ class="h-4 w-4 text-teal-600 focus:ring-teal-500 border-gray-300 rounded"
+ />
+ <label for={id} class="ml-2 block text-sm text-gray-900">{label}</label>
+</div>
diff --git a/code/app/src/lib/components/icons/adjustments.svelte b/code/app/src/lib/components/icons/adjustments.svelte
new file mode 100644
index 0000000..83bda27
--- /dev/null
+++ b/code/app/src/lib/components/icons/adjustments.svelte
@@ -0,0 +1,14 @@
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ class="h-6 w-6 {$$restProps.class ?? ''}"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ stroke-width="2"
+>
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/bars-3-center-left.svelte b/code/app/src/lib/components/icons/bars-3-center-left.svelte
new file mode 100644
index 0000000..785ece3
--- /dev/null
+++ b/code/app/src/lib/components/icons/bars-3-center-left.svelte
@@ -0,0 +1,15 @@
+<svg
+ class="h-6 w-6 {$$restProps.class ?? ''}"
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ aria-hidden="true"
+>
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M3.75 6.75h16.5M3.75 12H12m-8.25 5.25h16.5"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/calendar.svelte b/code/app/src/lib/components/icons/calendar.svelte
new file mode 100644
index 0000000..e0053ee
--- /dev/null
+++ b/code/app/src/lib/components/icons/calendar.svelte
@@ -0,0 +1,14 @@
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="w-6 h-6 {$$restProps.class ?? ''}"
+>
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5m-9-6h.008v.008H12v-.008zM12 15h.008v.008H12V15zm0 2.25h.008v.008H12v-.008zM9.75 15h.008v.008H9.75V15zm0 2.25h.008v.008H9.75v-.008zM7.5 15h.008v.008H7.5V15zm0 2.25h.008v.008H7.5v-.008zm6.75-4.5h.008v.008h-.008v-.008zm0 2.25h.008v.008h-.008V15zm0 2.25h.008v.008h-.008v-.008zm2.25-4.5h.008v.008H16.5v-.008zm0 2.25h.008v.008H16.5V15z"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/check-circle.svelte b/code/app/src/lib/components/icons/check-circle.svelte
new file mode 100644
index 0000000..e30778e
--- /dev/null
+++ b/code/app/src/lib/components/icons/check-circle.svelte
@@ -0,0 +1,13 @@
+<svg
+ class="h-5 w-5 {$$restProps.class ?? ''}"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+>
+ <path
+ fill-rule="evenodd"
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
+ clip-rule="evenodd"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/chevron-up-down.svelte b/code/app/src/lib/components/icons/chevron-up-down.svelte
new file mode 100644
index 0000000..c07aed5
--- /dev/null
+++ b/code/app/src/lib/components/icons/chevron-up-down.svelte
@@ -0,0 +1,13 @@
+<svg
+ class="h-5 w-5 {$$restProps.class ?? ''}"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+>
+ <path
+ fill-rule="evenodd"
+ d="M10 3a.75.75 0 01.55.24l3.25 3.5a.75.75 0 11-1.1 1.02L10 4.852 7.3 7.76a.75.75 0 01-1.1-1.02l3.25-3.5A.75.75 0 0110 3zm-3.76 9.2a.75.75 0 011.06.04l2.7 2.908 2.7-2.908a.75.75 0 111.1 1.02l-3.25 3.5a.75.75 0 01-1.1 0l-3.25-3.5a.75.75 0 01.04-1.06z"
+ clip-rule="evenodd"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/database.svelte b/code/app/src/lib/components/icons/database.svelte
new file mode 100644
index 0000000..6ffdadb
--- /dev/null
+++ b/code/app/src/lib/components/icons/database.svelte
@@ -0,0 +1,14 @@
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ class="h-6 w-6 {$$restProps.class ?? ''}"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ stroke-width="2"
+>
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/exclamation-circle.svelte b/code/app/src/lib/components/icons/exclamation-circle.svelte
new file mode 100644
index 0000000..2ce79b1
--- /dev/null
+++ b/code/app/src/lib/components/icons/exclamation-circle.svelte
@@ -0,0 +1,13 @@
+<svg
+ class="h-5 w-5 {$$restProps.class ?? ''}"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+>
+ <path
+ fill-rule="evenodd"
+ d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z"
+ clip-rule="evenodd"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/exclamation-triangle.svelte b/code/app/src/lib/components/icons/exclamation-triangle.svelte
new file mode 100644
index 0000000..8d807db
--- /dev/null
+++ b/code/app/src/lib/components/icons/exclamation-triangle.svelte
@@ -0,0 +1,13 @@
+<svg
+ class="h-5 w-5 {$$restProps.class ?? ''}"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+>
+ <path
+ fill-rule="evenodd"
+ d="M8.485 3.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 3.495zM10 6a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 6zm0 9a1 1 0 100-2 1 1 0 000 2z"
+ clip-rule="evenodd"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/folder-open.svelte b/code/app/src/lib/components/icons/folder-open.svelte
new file mode 100644
index 0000000..409c8e2
--- /dev/null
+++ b/code/app/src/lib/components/icons/folder-open.svelte
@@ -0,0 +1,14 @@
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="w-6 h-6 {$$restProps.class ?? ''}"
+>
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/home.svelte b/code/app/src/lib/components/icons/home.svelte
new file mode 100644
index 0000000..ee8305d
--- /dev/null
+++ b/code/app/src/lib/components/icons/home.svelte
@@ -0,0 +1,14 @@
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ class="h-6 w-6 {$$restProps.class ?? ''}"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ stroke-width="2"
+>
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/index.ts b/code/app/src/lib/components/icons/index.ts
new file mode 100644
index 0000000..8c24873
--- /dev/null
+++ b/code/app/src/lib/components/icons/index.ts
@@ -0,0 +1,41 @@
+import XIcon from "./x.svelte";
+import MenuIcon from "./menu.svelte";
+import AdjustmentsIcon from "./adjustments.svelte";
+import DatabaseIcon from "./database.svelte";
+import HomeIcon from "./home.svelte";
+import InformationCircleIcon from "./information-circle.svelte";
+import ExclamationTriangleIcon from "./exclamation-triangle.svelte";
+import XCircleIcon from "./x-circle.svelte";
+import CheckCircleIcon from "./check-circle.svelte";
+import XMarkIcon from "./x-mark.svelte";
+import SpinnerIcon from "./spinner.svelte";
+import ExclamationCircleIcon from "./exclamation-circle.svelte";
+import ChevronUpDownIcon from "./chevron-up-down.svelte";
+import MagnifyingGlassIcon from "./magnifying-glass.svelte";
+import Bars3CenterLeftIcon from "./bars-3-center-left.svelte";
+import CalendarIcon from "./calendar.svelte";
+import FolderOpenIcon from "./folder-open.svelte";
+import MegaphoneIcon from "./megaphone.svelte";
+import QueueListIcon from "./queue-list.svelte";
+
+export {
+ QueueListIcon,
+ FolderOpenIcon,
+ MegaphoneIcon,
+ CalendarIcon,
+ Bars3CenterLeftIcon,
+ MagnifyingGlassIcon,
+ ChevronUpDownIcon,
+ XIcon,
+ MenuIcon,
+ HomeIcon,
+ DatabaseIcon,
+ AdjustmentsIcon,
+ InformationCircleIcon,
+ ExclamationTriangleIcon,
+ ExclamationCircleIcon,
+ XCircleIcon,
+ CheckCircleIcon,
+ XMarkIcon,
+ SpinnerIcon
+} \ No newline at end of file
diff --git a/code/app/src/lib/components/icons/information-circle.svelte b/code/app/src/lib/components/icons/information-circle.svelte
new file mode 100644
index 0000000..68dbc60
--- /dev/null
+++ b/code/app/src/lib/components/icons/information-circle.svelte
@@ -0,0 +1,13 @@
+<svg
+ class="h-5 w-5 {$$restProps.class ?? ''}"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+>
+ <path
+ fill-rule="evenodd"
+ d="M19 10.5a8.5 8.5 0 11-17 0 8.5 8.5 0 0117 0zM8.25 9.75A.75.75 0 019 9h.253a1.75 1.75 0 011.709 2.13l-.46 2.066a.25.25 0 00.245.304H11a.75.75 0 010 1.5h-.253a1.75 1.75 0 01-1.709-2.13l.46-2.066a.25.25 0 00-.245-.304H9a.75.75 0 01-.75-.75zM10 7a1 1 0 100-2 1 1 0 000 2z"
+ clip-rule="evenodd"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/magnifying-glass.svelte b/code/app/src/lib/components/icons/magnifying-glass.svelte
new file mode 100644
index 0000000..f8fdb6e
--- /dev/null
+++ b/code/app/src/lib/components/icons/magnifying-glass.svelte
@@ -0,0 +1,13 @@
+<svg
+ class="h-5 w-5 {$$restProps.class ?? ''}"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+>
+ <path
+ fill-rule="evenodd"
+ d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
+ clip-rule="evenodd"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/megaphone.svelte b/code/app/src/lib/components/icons/megaphone.svelte
new file mode 100644
index 0000000..7ada5f3
--- /dev/null
+++ b/code/app/src/lib/components/icons/megaphone.svelte
@@ -0,0 +1,14 @@
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="w-6 h-6 {$$restProps.class ?? ''}"
+>
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/menu.svelte b/code/app/src/lib/components/icons/menu.svelte
new file mode 100644
index 0000000..471d85f
--- /dev/null
+++ b/code/app/src/lib/components/icons/menu.svelte
@@ -0,0 +1,14 @@
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ class="h-6 w-6 {$$restProps.class ?? ''}"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ stroke-width="2"
+>
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M4 6h16M4 12h16M4 18h16"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/queue-list.svelte b/code/app/src/lib/components/icons/queue-list.svelte
new file mode 100644
index 0000000..6148394
--- /dev/null
+++ b/code/app/src/lib/components/icons/queue-list.svelte
@@ -0,0 +1,14 @@
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="w-6 h-6 {$$restProps.class ?? ''}"
+>
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 010 3.75H5.625a1.875 1.875 0 010-3.75z"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/spinner.svelte b/code/app/src/lib/components/icons/spinner.svelte
new file mode 100644
index 0000000..80cc57c
--- /dev/null
+++ b/code/app/src/lib/components/icons/spinner.svelte
@@ -0,0 +1,20 @@
+<svg
+ class="-ml-1 mr-3 h-5 w-5 animate-spin {$$restProps.class ?? ''}"
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+>
+ <circle
+ class="opacity-25"
+ cx="12"
+ cy="12"
+ r="10"
+ stroke="currentColor"
+ stroke-width="4"
+ />
+ <path
+ class="opacity-75"
+ fill="currentColor"
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/x-circle.svelte b/code/app/src/lib/components/icons/x-circle.svelte
new file mode 100644
index 0000000..3793b5a
--- /dev/null
+++ b/code/app/src/lib/components/icons/x-circle.svelte
@@ -0,0 +1,13 @@
+<svg
+ class="h-5 w-5 {$$restProps.class ?? ''}"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+>
+ <path
+ fill-rule="evenodd"
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z"
+ clip-rule="evenodd"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/x-mark.svelte b/code/app/src/lib/components/icons/x-mark.svelte
new file mode 100644
index 0000000..fd1c6a1
--- /dev/null
+++ b/code/app/src/lib/components/icons/x-mark.svelte
@@ -0,0 +1,11 @@
+<svg
+ class="h-5 w-5 {$$restProps.class ?? ''}"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 20 20"
+ fill="currentColor"
+ aria-hidden="true"
+>
+ <path
+ d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
+ />
+</svg>
diff --git a/code/app/src/lib/components/icons/x.svelte b/code/app/src/lib/components/icons/x.svelte
new file mode 100644
index 0000000..6125ab8
--- /dev/null
+++ b/code/app/src/lib/components/icons/x.svelte
@@ -0,0 +1,14 @@
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ class="h-6 w-6 {$$restProps.class ?? ''}"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
+ stroke-width="2"
+>
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M6 18L18 6M6 6l12 12"
+ />
+</svg>
diff --git a/code/app/src/lib/components/index.ts b/code/app/src/lib/components/index.ts
new file mode 100644
index 0000000..a81e0c3
--- /dev/null
+++ b/code/app/src/lib/components/index.ts
@@ -0,0 +1,15 @@
+import Alert from "./alert.svelte";
+import Button from "./button.svelte";
+import Checkbox from "./checkbox.svelte";
+import Input from "./input.svelte";
+import LocaleSwitcher from "./locale-switcher.svelte";
+import Switch from "./switch.svelte";
+
+export {
+ Alert,
+ Button,
+ Checkbox,
+ Input,
+ LocaleSwitcher,
+ Switch
+} \ No newline at end of file
diff --git a/code/app/src/lib/components/input.svelte b/code/app/src/lib/components/input.svelte
new file mode 100644
index 0000000..c0ed654
--- /dev/null
+++ b/code/app/src/lib/components/input.svelte
@@ -0,0 +1,103 @@
+<script lang="ts">
+ import pwKey from "$actions/pwKey";
+ import { random_string } from "$lib/helpers";
+ import { ExclamationCircleIcon } from "./icons";
+
+ export let label: string | undefined = undefined;
+ export let type: string = "text";
+ export let autocomplete: string | undefined = undefined;
+ export let required: boolean | undefined = undefined;
+ export let id: string | undefined = "input__" + random_string(4);
+ export let name: string | undefined = undefined;
+ export let placeholder: string | undefined = undefined;
+ export let helpText: string | undefined = undefined;
+ export let errorText: string | undefined = undefined;
+ export let disabled = false;
+ export let hideLabel = false;
+ export let cornerHint: string | undefined = undefined;
+ export let icon: any = undefined;
+ export let addon: string | undefined = undefined;
+ export let value: string | undefined;
+ export let wrapperClass: string | undefined = undefined;
+ export let _pwKey: string | undefined = undefined;
+
+ $: ariaErrorDescribedBy = id + "__" + "error";
+ $: attributes = {
+ "aria-describedby": errorText ? ariaErrorDescribedBy : null,
+ "aria-invalid": errorText ? "true" : null,
+ disabled: disabled || null,
+ autocomplete: autocomplete || null,
+ required: required || null,
+ } as any;
+ $: hasBling = icon || addon || errorText;
+ const defaultColorClass = "border-gray-300 focus:border-teal-500 focus:ring-teal-500";
+ let colorClass = defaultColorClass;
+ $: if (errorText) {
+ colorClass = "placeholder-red-300 focus:border-red-500 focus:outline-none focus:ring-red-500 text-red-900 pr-10 border-red-300";
+ } else {
+ colorClass = defaultColorClass;
+ }
+
+ function typeAction(node: HTMLInputElement) {
+ node.type = type;
+ }
+</script>
+
+<div class={wrapperClass}>
+ {#if label && !cornerHint && !hideLabel}
+ <label for={id} class={hideLabel ? "sr-only" : "block text-sm font-medium text-gray-700"}>
+ {label}
+ </label>
+ {:else if cornerHint && !hideLabel}
+ <div class="flex justify-between">
+ {#if label}
+ <label for={id} class={hideLabel ? "sr-only" : "block text-sm font-medium text-gray-700"}>
+ {label}
+ </label>
+ {/if}
+ <span class="text-sm text-gray-500">
+ {cornerHint}
+ </span>
+ </div>
+ {/if}
+ <div class="mt-1 {hasBling ? 'relative rounded-md' : ''} {addon ? 'flex' : ''}">
+ {#if icon}
+ <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
+ <svelte:component this={icon} class={errorText ? "text-red-500" : "text-gray-400"} />
+ </div>
+ {:else if addon}
+ <div class="inline-flex items-center rounded-l-md border border-r-0 border-gray-300 bg-gray-50 px-3 text-gray-500 sm:text-sm">
+ <span class="text-gray-500 sm:text-sm">{addon}</span>
+ </div>
+ {/if}
+ <input
+ use:typeAction
+ use:pwKey={_pwKey}
+ {name}
+ {id}
+ {...attributes}
+ bind:value
+ class="block w-full rounded-md shadow-sm sm:text-sm
+ {colorClass}
+ {disabled ? 'disabled:cursor-not-allowed disabled:border-gray-200 disabled:bg-gray-50 disabled:text-gray-500' : ''}
+ {addon ? 'min-w-0 flex-1 rounded-none rounded-r-md' : ''}
+ {icon ? 'pl-10' : ''}"
+ {placeholder}
+ />
+ {#if errorText}
+ <div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3">
+ <ExclamationCircleIcon class="text-red-500" />
+ </div>
+ {/if}
+ </div>
+ {#if helpText && !errorText}
+ <p class="mt-2 text-sm text-gray-500">
+ {helpText}
+ </p>
+ {/if}
+ {#if errorText}
+ <p class="mt-2 text-sm text-red-600" id={ariaErrorDescribedBy}>
+ {errorText}
+ </p>
+ {/if}
+</div>
diff --git a/code/app/src/lib/components/locale-switcher.svelte b/code/app/src/lib/components/locale-switcher.svelte
new file mode 100644
index 0000000..f880bfb
--- /dev/null
+++ b/code/app/src/lib/components/locale-switcher.svelte
@@ -0,0 +1,55 @@
+<script lang="ts">
+ import pwKey from "$actions/pwKey";
+ import { browser } from "$app/environment";
+ import { page } from "$app/stores";
+ import { CookieNames } from "$lib/configuration";
+ import { setLocale, locale } from "$lib/i18n/i18n-svelte";
+ import type { Locales } from "$lib/i18n/i18n-types";
+ import { locales } from "$lib/i18n/i18n-util";
+ import { loadLocaleAsync } from "$lib/i18n/i18n-util.async";
+ import Cookies from "js-cookie";
+
+ export let _pwKey: string | undefined = undefined;
+
+ async function switch_locale(newLocale: Locales) {
+ if (!newLocale || $locale === newLocale) return;
+ await loadLocaleAsync(newLocale);
+ setLocale(newLocale);
+ document.querySelector("html")?.setAttribute("lang", newLocale);
+ Cookies.set(CookieNames.locale, newLocale, {
+ sameSite: "strict",
+ domain: location.hostname,
+ });
+ console.log("Switched to: " + newLocale);
+ }
+
+ function on_change(event: Event) {
+ const target = event.target as HTMLSelectElement;
+ switch_locale(target.options[target.selectedIndex].value as Locales);
+ }
+
+ $: if (browser) {
+ switch_locale($page.params.lang as Locales);
+ }
+
+ function get_locale_name(iso: string) {
+ switch (iso) {
+ case "nb": {
+ return "Norsk Bokmål";
+ }
+ case "en": {
+ return "English";
+ }
+ }
+ }
+</script>
+
+<select
+ use:pwKey={_pwKey}
+ on:change={on_change}
+ class="mt-1 mr-1 block border-none py-2 pl-3 pr-10 text-base rounded-md right-0 absolute focus:outline-none focus:ring-teal-500 sm:text-sm"
+>
+ {#each locales as aLocale}
+ <option value={aLocale}>{get_locale_name(aLocale)}</option>
+ {/each}
+</select>
diff --git a/code/app/src/lib/components/switch.svelte b/code/app/src/lib/components/switch.svelte
new file mode 100644
index 0000000..16da23a
--- /dev/null
+++ b/code/app/src/lib/components/switch.svelte
@@ -0,0 +1,143 @@
+<script context="module" lang="ts">
+ export type SwitchType = "short" | "icon" | "default";
+</script>
+
+<script lang="ts">
+ import pwKey from "$actions/pwKey";
+
+
+ export let enabled = false;
+ export let type: SwitchType = "default";
+ export let srText = "Use setting";
+ export let label: string | undefined = undefined;
+ export let description: string | undefined = undefined;
+ export let rightAlignedLabelDescription = false;
+ export let _pwKey:string|undefined = undefined;
+
+ $: colorClass = enabled
+ ? "bg-teal-600 focus:ring-teal-500"
+ : "bg-gray-200 focus:ring-teal-500";
+ $: translateClass = enabled ? "translate-x-5" : "translate-x-0";
+ $: hasLabelOrDescription = label || description;
+
+ function toggle() {
+ enabled = !enabled;
+ }
+</script>
+
+<div
+ class="{hasLabelOrDescription
+ ? 'flex items-center'
+ : ''} {rightAlignedLabelDescription ? '' : 'justify-between'}"
+>
+ {#if hasLabelOrDescription && !rightAlignedLabelDescription}
+ <span class="flex flex-grow flex-col">
+ {#if label}
+ <span class="text-sm font-medium text-gray-900">{label}</span>
+ {/if}
+ {#if description}
+ <span class="text-sm text-gray-500">{description}</span>
+ {/if}
+ </span>
+ {/if}
+ {#if type === "short"}
+ <button
+ type="button"
+ class="group relative inline-flex h-5 w-10 flex-shrink-0 cursor-pointer items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2"
+ role="switch"
+ aria-checked={enabled}
+ use:pwKey={_pwKey}
+ on:click={toggle}
+ >
+ <span class="sr-only">{srText}</span>
+ <span
+ aria-hidden="true"
+ class="pointer-events-none absolute h-full w-full rounded-md"
+ />
+ <span
+ aria-hidden="true"
+ class="{colorClass} pointer-events-none absolute mx-auto h-4 w-9 rounded-full transition-colors duration-200 ease-in-out"
+ />
+ <span
+ aria-hidden="true"
+ class="{translateClass} pointer-events-none absolute left-0 inline-block h-5 w-5 transform rounded-full border border-gray-200 bg-white shadow ring-0 transition-transform duration-200 ease-in-out"
+ />
+ </button>
+ {:else if type === "icon"}
+ <button
+ type="button"
+ class="{colorClass} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2"
+ role="switch"
+ aria-checked={enabled}
+ use:pwKey={_pwKey}
+ on:click={toggle}
+ >
+ <span class="sr-only">{srText}</span>
+ <span
+ class="{translateClass} pointer-events-none relative inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
+ >
+ <span
+ class="{enabled
+ ? 'opacity-0 ease-out duration-100'
+ : 'opacity-100 ease-in duration-200'} absolute inset-0 flex h-full w-full items-center justify-center transition-opacity"
+ aria-hidden="true"
+ >
+ <svg
+ class="h-3 w-3 text-gray-400"
+ fill="none"
+ viewBox="0 0 12 12"
+ >
+ <path
+ d="M4 8l2-2m0 0l2-2M6 6L4 4m2 2l2 2"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ />
+ </svg>
+ </span>
+ <span
+ class="{enabled
+ ? 'opacity-100 ease-in duration-200'
+ : 'opacity-0 ease-out duration-100'} absolute inset-0 flex h-full w-full items-center justify-center transition-opacity"
+ aria-hidden="true"
+ >
+ <svg
+ class="h-3 w-3 text-indigo-600"
+ fill="currentColor"
+ viewBox="0 0 12 12"
+ >
+ <path
+ d="M3.707 5.293a1 1 0 00-1.414 1.414l1.414-1.414zM5 8l-.707.707a1 1 0 001.414 0L5 8zm4.707-3.293a1 1 0 00-1.414-1.414l1.414 1.414zm-7.414 2l2 2 1.414-1.414-2-2-1.414 1.414zm3.414 2l4-4-1.414-1.414-4 4 1.414 1.414z"
+ />
+ </svg>
+ </span>
+ </span>
+ </button>
+ {:else if type === "default"}
+ <button
+ type="button"
+ class="{colorClass} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-offset-2"
+ role="switch"
+ aria-checked={enabled}
+ use:pwKey={_pwKey}
+ on:click={toggle}
+ >
+ <span class="sr-only">{srText}</span>
+ <span
+ aria-hidden="true"
+ class="{translateClass} pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
+ />
+ </button>
+ {/if}
+ {#if hasLabelOrDescription && rightAlignedLabelDescription}
+ <span class="ml-3">
+ {#if label}
+ <span class="text-sm font-medium text-gray-900">{label}</span>
+ {/if}
+ {#if description}
+ <span class="text-sm text-gray-500">{description}</span>
+ {/if}
+ </span>
+ {/if}
+</div>
diff --git a/code/app/src/lib/configuration.ts b/code/app/src/lib/configuration.ts
new file mode 100644
index 0000000..5a6a1bf
--- /dev/null
+++ b/code/app/src/lib/configuration.ts
@@ -0,0 +1,60 @@
+export const BASE_DOMAIN = "dev.greatoffice.app";
+export const DEV_BASE_DOMAIN = "http://localhost";
+export const API_ADDRESS = "https://api." + BASE_DOMAIN;
+export const DEV_API_ADDRESS = "http://localhost:5000";
+export const SECONDS_BETWEEN_SESSION_CHECK = 600;
+
+export function api_base(path: string = ""): string {
+ return (is_development() ? DEV_API_ADDRESS : API_ADDRESS) + (path !== "" ? "/" + path : "");
+}
+
+export function is_development(): boolean {
+ return import.meta.env.DEV;
+}
+
+export function is_testing(): boolean {
+ return import.meta.env.VITE_TESTING;
+}
+
+export function is_debug(): boolean {
+ return localStorage.getItem(StorageKeys.debug) !== "true";
+}
+
+export const CookieNames = {
+ theme: "go_theme",
+ locale: "go_locale",
+ session: "go_session"
+};
+
+export function get_test_context(): TestContext {
+ return {
+ user: {
+ username: import.meta.env.VITE_TEST_USERNAME,
+ password: import.meta.env.VITE_TEST_PASSWORD
+ }
+ }
+}
+
+export interface TestContext {
+ user: {
+ username: string,
+ password: string
+ }
+}
+
+export const QueryKeys = {
+ labels: "labels",
+ categories: "categories",
+ entries: "entries",
+};
+
+export const StorageKeys = {
+ session: "sessionData",
+ theme: "theme",
+ debug: "debug",
+ categories: "categories",
+ labels: "labels",
+ entries: "entries",
+ stopwatch: "stopwatchState",
+ logLevel: "logLevel"
+}; \ No newline at end of file
diff --git a/code/app/src/lib/helpers.ts b/code/app/src/lib/helpers.ts
new file mode 100644
index 0000000..3fa1653
--- /dev/null
+++ b/code/app/src/lib/helpers.ts
@@ -0,0 +1,497 @@
+import { browser } from "$app/environment";
+import type { TimeEntryDto } from "$lib/models/TimeEntryDto";
+import type { UnwrappedEntryDateTime } from "$lib/models/UnwrappedEntryDateTime";
+import { logInfo } from "$lib/logger";
+import { Temporal } from "temporal-polyfill";
+
+export const EMAIL_REGEX = new RegExp(/^([a-z0-9]+(?:([._\-])[a-z0-9]+)*@(?:[a-z0-9]+(?:(-)[a-z0-9]+)?\.)+[a-z0-9](?:[a-z0-9]*[a-z0-9])?)$/i);
+export const URL_REGEX = new RegExp(/^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?[a-z0-9]+([\-.][a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$/gm);
+export const GUID_REGEX = new RegExp(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
+export const NORWEGIAN_PHONE_NUMBER_REGEX = new RegExp(/(0047|\+47|47)?\d{8,12}/);
+
+export function get_default_sorted(unsorted: Array<TimeEntryDto>): Array<TimeEntryDto> {
+ if (unsorted.length < 1) return unsorted;
+ const byStart = unsorted.sort((a, b) => {
+ return Temporal.Instant.compare(Temporal.Instant.from(b.start), Temporal.Instant.from(a.start));
+ });
+
+ return byStart.sort((a, b) => {
+ return Temporal.Instant.compare(Temporal.Instant.from(b.stop), Temporal.Instant.from(a.stop));
+ });
+}
+
+export function get_element_by_pw_key(key: string): HTMLElement | null {
+ return document.querySelector("[pw-key='" + key + "']");
+}
+
+export function get_pw_key_selector(key: string): string {
+ return "[pw-key='" + key + "']";
+}
+
+export function is_email(value: string): boolean {
+ return EMAIL_REGEX.test(String(value).toLowerCase());
+}
+
+export function is_url(value: string): boolean {
+ return URL_REGEX.test(String(value).toLowerCase());
+}
+
+export function is_norwegian_phone_number(value: string): boolean {
+ if (value.length < 8 || value.length > 12) {
+ return false;
+ }
+ return NORWEGIAN_PHONE_NUMBER_REGEX.test(String(value));
+}
+
+export function get_cookie(name: string) {
+ const value = `; ${document.cookie}`;
+ const parts = value.split(`; ${name}=`);
+ if (parts.length === 2) return parts.pop()?.split(";").shift();
+}
+
+export function set_cookie(name: string, value: string, baseDomain = window.location.hostname) {
+ document.cookie = name + "=" + encodeURIComponent(value) + (baseDomain ? ";domain=" + baseDomain : "");
+}
+
+export function unwrap_date_time_from_entry(entry: TimeEntryDto): UnwrappedEntryDateTime {
+ if (!entry) throw new Error("entry was undefined");
+ const currentTimeZone = Temporal.Now.timeZone().id;
+ const startInstant = Temporal.Instant.from(entry.start).toZonedDateTimeISO(currentTimeZone);
+ const stopInstant = Temporal.Instant.from(entry.stop).toZonedDateTimeISO(currentTimeZone);
+
+ return {
+ start_date: startInstant.toPlainDate(),
+ stop_date: stopInstant.toPlainDate(),
+ start_time: startInstant.toPlainTime(),
+ stop_time: stopInstant.toPlainTime(),
+ duration: Temporal.Duration.from({
+ hours: stopInstant.hour,
+ minutes: stopInstant.minute,
+ }).subtract(Temporal.Duration.from({
+ hours: startInstant.hour,
+ minutes: startInstant.minute,
+ })),
+ };
+}
+
+
+export function is_guid(value: string): boolean {
+ if (!value) {
+ return false;
+ }
+ if (value[0] === "{") {
+ value = value.substring(1, value.length - 1);
+ }
+ return GUID_REGEX.test(value);
+}
+
+export function is_empty_object(obj: object): boolean {
+ return obj !== void 0 && Object.keys(obj).length > 0;
+}
+
+export function merge_obj_arr<T>(a: Array<T>, b: Array<T>, props: Array<string>): Array<T> {
+ let start = 0;
+ let merge = [];
+
+ while (start < a.length) {
+
+ if (a[start] === b[start]) {
+ //pushing the merged objects into array
+ merge.push({ ...a[start], ...b[start] });
+ }
+ //incrementing start value
+ start = start + 1;
+ }
+ return merge;
+}
+
+export function set_favicon(url: string) {
+ // Find the current favicon element
+ const favicon = document.querySelector("link[rel=\"icon\"]") as HTMLLinkElement;
+ if (favicon) {
+ // Update the new link
+ favicon.href = url;
+ } else {
+ // Create new `link`
+ const link = document.createElement("link");
+ link.rel = "icon";
+ link.href = url;
+
+ // Append to the `head` element
+ document.head.appendChild(link);
+ }
+}
+export function no_type_check(x: any) {
+ return x;
+}
+export function capitalise(value: string): string {
+ return value.charAt(0).toUpperCase() + value.slice(1);
+}
+
+export function set_emoji_favicon(emoji: string) {
+ // Create a canvas element
+ const canvas = document.createElement("canvas");
+ canvas.height = 64;
+ canvas.width = 64;
+
+ // Get the canvas context
+ const context = canvas.getContext("2d") as CanvasRenderingContext2D;
+ context.font = "64px serif";
+ context.fillText(emoji, 0, 64);
+
+ // Get the custom URL
+ const url = canvas.toDataURL();
+
+ // Update the favicon
+ set_favicon(url);
+}
+
+
+// https://stackoverflow.com/a/48400665/11961742
+export function seconds_to_hour_minute_string(seconds: number, hourChar = "h", minuteChar = "m") {
+ const hours = Math.floor(seconds / (60 * 60));
+ seconds -= hours * (60 * 60);
+ const minutes = Math.floor(seconds / 60);
+ return hours + "h" + minutes + "m";
+}
+
+export function seconds_to_hour_minute(seconds: number) {
+ const hours = Math.floor(seconds / (60 * 60));
+ seconds -= hours * (60 * 60);
+ const minutes = Math.floor(seconds / 60);
+ return { hours, minutes };
+}
+
+export function get_query_string(params: any = {}): string {
+ const map = Object.keys(params).reduce((arr: Array<string>, key: string) => {
+ if (params[key] !== undefined) {
+ return arr.concat(`${key}=${encodeURIComponent(params[key])}`);
+ }
+ return arr;
+ }, [] as any);
+
+ if (map.length) {
+ return `?${map.join("&")}`;
+ }
+
+ return "";
+}
+
+export function make_url(url: string, params: object): string {
+ return `${url}${get_query_string(params)}`;
+}
+
+export function noop() {
+}
+
+export async function run_async(functionToRun: Function): Promise<any> {
+ return new Promise((greatSuccess, graveFailure) => {
+ try {
+ greatSuccess(functionToRun());
+ } catch (exception) {
+ graveFailure(exception);
+ }
+ });
+}
+
+// https://stackoverflow.com/a/45215694/11961742
+export function get_selected_options(domElement: HTMLSelectElement): Array<string> {
+ const ret = [];
+
+ // fast but not universally supported
+ if (domElement.selectedOptions !== undefined) {
+ for (let i = 0; i < domElement.selectedOptions.length; i++) {
+ ret.push(domElement.selectedOptions[i].value);
+ }
+
+ // compatible, but can be painfully slow
+ } else {
+ for (let i = 0; i < domElement.options.length; i++) {
+ if (domElement.options[i].selected) {
+ ret.push(domElement.options[i].value);
+ }
+ }
+ }
+ return ret;
+}
+
+export function random_string(length: number): string {
+ if (!length) {
+ throw new Error("length is undefined");
+ }
+ let result = "";
+ const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+ const charactersLength = characters.length;
+ for (let i = 0; i < length; i++) {
+ result += characters.charAt(Math.floor(Math.random() * charactersLength));
+ }
+ return result;
+}
+
+interface CreateElementOptions {
+ name: string,
+ properties?: object,
+ children?: Array<HTMLElement | Function | Node>
+}
+
+export function create_element_from_object(elementOptions: CreateElementOptions): HTMLElement {
+ return create_element(elementOptions.name, elementOptions.properties, elementOptions.children);
+}
+
+export function create_element(name: string, properties?: object, children?: Array<HTMLElement | any>): HTMLElement {
+ if (!name || name.length < 1) {
+ throw new Error("name is required");
+ }
+ const node = document.createElement(name);
+ if (properties) {
+ for (const [key, value] of Object.entries(properties)) {
+ // @ts-ignore
+ node[key] = value;
+ }
+ }
+
+ if (children && children.length > 0) {
+ let actualChildren = children;
+ if (typeof children === "function") {
+ // @ts-ignore
+ actualChildren = children();
+ }
+ for (const child of actualChildren) {
+ node.appendChild(child as Node);
+ }
+ }
+ return node;
+}
+
+export function get_element_position(element: HTMLElement | any) {
+ if (!element) return { x: 0, y: 0 };
+ let x = 0;
+ let y = 0;
+ while (true) {
+ x += element.offsetLeft;
+ y += element.offsetTop;
+ if (element.offsetParent === null) {
+ break;
+ }
+ element = element.offsetParent;
+ }
+ return { x, y };
+}
+
+export function restrict_input_to_numbers(element: HTMLElement, specials: Array<string> = [], mergeSpecialsWithDefaults: boolean = false): void {
+ if (element) {
+ element.addEventListener("keydown", (e) => {
+ const defaultSpecials = ["Backspace", "ArrowLeft", "ArrowRight", "Tab"];
+ let keys = specials.length > 0 ? specials : defaultSpecials;
+ if (mergeSpecialsWithDefaults && specials) {
+ keys = [...specials, ...defaultSpecials];
+ }
+ if (keys.indexOf(e.key) !== -1) {
+ return;
+ }
+ if (isNaN(parseInt(e.key))) {
+ e.preventDefault();
+ }
+ });
+ }
+}
+
+export function element_has_focus(element: HTMLElement): boolean {
+ return element === document.activeElement;
+}
+
+export function move_focus(element: HTMLElement): void {
+ if (!element) {
+ element = document.getElementsByTagName("body")[0];
+ }
+ element.focus();
+ // @ts-ignore
+ if (!element_has_focus(element)) {
+ element.setAttribute("tabindex", "-1");
+ element.focus();
+ }
+}
+
+export function get_url_parameter(name: string): string {
+ // @ts-ignore
+ return new RegExp("[?&]" + name + "=([^&#]*)")?.exec(window.location.href)[1];
+}
+
+export function update_url_parameter(param: string, newVal: string): void {
+ let newAdditionalURL = "";
+ let tempArray = location.href.split("?");
+ const baseURL = tempArray[0];
+ const additionalURL = tempArray[1];
+ let temp = "";
+ if (additionalURL) {
+ tempArray = additionalURL.split("&");
+ for (let i = 0; i < tempArray.length; i++) {
+ if (tempArray[i].split("=")[0] !== param) {
+ newAdditionalURL += temp + tempArray[i];
+ temp = "&";
+ }
+ }
+ }
+ const rows_txt = temp + "" + param + "=" + newVal;
+ const newUrl = baseURL + "?" + newAdditionalURL + rows_txt;
+ window.history.replaceState("", "", newUrl);
+}
+
+
+export function get_style_string(rules: CSSRuleList) {
+ let styleString = "";
+ for (const [key, value] of Object.entries(rules)) {
+ styleString += key + ":" + value + ";";
+ }
+ return styleString;
+}
+
+export function parse_iso_local(s: string) {
+ const b = s.split(/\D/);
+ //@ts-ignore
+ return new Date(b[0], b[1] - 1, b[2], b[3], b[4], b[5]);
+}
+
+export function resolve_references(json: any) {
+ if (!json) return;
+ if (typeof json === "string") {
+ json = JSON.parse(json ?? "{}");
+ }
+ const byid = {}, refs = [];
+ json = function recurse(obj, prop, parent) {
+ if (typeof obj !== "object" || !obj) {
+ return obj;
+ }
+ if (Object.prototype.toString.call(obj) === "[object Array]") {
+ for (let i = 0; i < obj.length; i++) {
+ if (typeof obj[i] !== "object" || !obj[i]) {
+ continue;
+ } else if ("$ref" in obj[i]) {
+ // @ts-ignore
+ obj[i] = recurse(obj[i], i, obj);
+ } else {
+ obj[i] = recurse(obj[i], prop, obj);
+ }
+ }
+ return obj;
+ }
+ if ("$ref" in obj) {
+ let ref = obj.$ref;
+ if (ref in byid) {
+ // @ts-ignore
+ return byid[ref];
+ }
+ refs.push([parent, prop, ref]);
+ return;
+ } else if ("$id" in obj) {
+ let id = obj.$id;
+ delete obj.$id;
+ if ("$values" in obj) {
+ obj = obj.$values.map(recurse);
+ } else {
+ for (let prop2 in obj) {
+ // @ts-ignore
+ obj[prop2] = recurse(obj[prop2], prop2, obj);
+ }
+ }
+ // @ts-ignore
+ byid[id] = obj;
+ }
+ return obj;
+ }(json);
+ for (let i = 0; i < refs.length; i++) {
+ let ref = refs[i];
+ // @ts-ignore
+ ref[0][ref[1]] = byid[ref[2]];
+ }
+ return json;
+}
+
+export function get_random_int(min: number, max: number): number {
+ min = Math.ceil(min);
+ max = Math.floor(max);
+ return Math.floor(Math.random() * (max - min + 1)) + min;
+}
+
+export function to_readable_bytes(bytes: number): string {
+ const s = ["bytes", "kB", "MB", "GB", "TB", "PB"];
+ const e = Math.floor(Math.log(bytes) / Math.log(1024));
+ return (bytes / Math.pow(1024, e)).toFixed(2) + " " + s[e];
+}
+
+export function can_use_dom(): boolean {
+ return !!(typeof window !== "undefined" && window.document && window.document.createElement);
+}
+
+export function session_storage_remove_regex(regex: RegExp): void {
+ if (!browser) {
+ logInfo("sessionStorage is not available in non-browser contexts");
+ return;
+ }
+ let n = sessionStorage.length;
+ while (n--) {
+ const key = sessionStorage.key(n);
+ if (key && regex.test(key)) {
+ sessionStorage.removeItem(key);
+ }
+ }
+}
+
+export function local_storage_remove_regex(regex: RegExp): void {
+ if (!browser) {
+ logInfo("sessionStorage is not available in non-browser contexts");
+ return;
+ }
+ let n = localStorage.length;
+ while (n--) {
+ const key = localStorage.key(n);
+ if (key && regex.test(key)) {
+ localStorage.removeItem(key);
+ }
+ }
+}
+
+export function session_storage_set_json(key: string, value: object): void {
+ if (!browser) {
+ console.warn("sessionStorage is not available in non-browser contexts");
+ return;
+ }
+ sessionStorage.setItem(key, JSON.stringify(value));
+}
+
+export function session_storage_get_json(key: string): object {
+ if (!browser) {
+ console.warn("sessionStorage is not available in non-browser contexts");
+ return {};
+ }
+ return JSON.parse(sessionStorage.getItem(key) ?? "{}");
+}
+
+export function local_storage_set_json(key: string, value: object): void {
+ if (!browser) {
+ console.warn("sessionStorage is not available in non-browser contexts");
+ return;
+ }
+ localStorage.setItem(key, JSON.stringify(value));
+}
+
+export function local_storage_get_json(key: string): object {
+ if (!browser) {
+ console.warn("sessionStorage is not available in non-browser contexts");
+ return {};
+ }
+ return JSON.parse(localStorage.getItem(key) ?? "{}");
+}
+
+export function get_hash_code(value: string): number | undefined {
+ let hash = 0;
+ if (value.length === 0) {
+ return;
+ }
+ for (let i = 0; i < value.length; i++) {
+ const char = value.charCodeAt(i);
+ hash = (hash << 5) - hash + char;
+ hash |= 0;
+ }
+ return hash;
+}
diff --git a/code/app/src/lib/i18n/en/app/index.ts b/code/app/src/lib/i18n/en/app/index.ts
new file mode 100644
index 0000000..7cd05ee
--- /dev/null
+++ b/code/app/src/lib/i18n/en/app/index.ts
@@ -0,0 +1,5 @@
+import type { BaseTranslation } from '../../i18n-types'
+
+const en_app: BaseTranslation = {}
+
+export default en_app \ No newline at end of file
diff --git a/code/app/src/lib/i18n/en/index.ts b/code/app/src/lib/i18n/en/index.ts
new file mode 100644
index 0000000..e084a6c
--- /dev/null
+++ b/code/app/src/lib/i18n/en/index.ts
@@ -0,0 +1,50 @@
+import type { BaseTranslation } from "../i18n-types";
+
+const en: BaseTranslation = {
+ or: "Or",
+ emailAddress: "Email address",
+ password: "Password",
+ pageNotFound: "Page not found",
+ noInternet: "It seems like your device does not have a internet connection, please check your connection.",
+ reset: "Reset",
+ of: "{0} of {1}",
+ isRequired: "{0} is required",
+ submit: "Submit",
+ success: "Success",
+ tryAgainSoon: "Try again soon",
+ createANewAccount: "Create a new account",
+ unexpectedError: "An unexpected error occured",
+ notFound: "Not found",
+ documentation: "Documentation",
+ tos: "Terms of service",
+ privacyPolicy: "Privacy policy",
+ signIntoYourAccount: "Sign into your account",
+ signInPage: {
+ notMyComputer: "This is not my computer",
+ resetPassword: "Reset password",
+ yourPasswordIsUpdated: "Your password is updated",
+ signIn: "Sign In",
+ yourNewPasswordIsApplied: "Your new password is applied",
+ signInBelow: "Sign in below",
+ yourAccountIsDisabled: "Your account is disabled",
+ contactYourAdminIfDisabled: "Contact your administrator if this feels wrong",
+ youHaveReachedInactivityLimit: "You've reached the hidden inactivity limit",
+ feelFreeToSignInAgain: "Feel free to sign in again"
+ },
+ signUpPage: {
+ createYourNewAccount: "Create your new account",
+ },
+ resetPasswordPage: {
+ setANewPassword: "Set a new password",
+ expired: "Expired",
+ requestHasExpired: "Your request has expired",
+ requestANewReset: "Request a new reset",
+ newPassword: "New password",
+ requestSentMessage: "If we find your email address in our systems, you will receive an email with instructions on how to set a new password for your account.",
+ requestAPasswordReset: "Request a password reset",
+ requestNotFound: "Your request was not found",
+ submitANewRequestBelow: "Submit a new reset request below"
+ }
+};
+
+export default en;
diff --git a/code/app/src/lib/i18n/formatters.ts b/code/app/src/lib/i18n/formatters.ts
new file mode 100644
index 0000000..5232b7d
--- /dev/null
+++ b/code/app/src/lib/i18n/formatters.ts
@@ -0,0 +1,13 @@
+import { capitalise } from '$lib/helpers'
+import type { FormattersInitializer } from 'typesafe-i18n'
+import type { Locales, Formatters } from './i18n-types'
+
+export const initFormatters: FormattersInitializer<Locales, Formatters> = (locale: Locales) => {
+
+ const formatters: Formatters = {
+ // add your formatter functions here
+ capitalise: (value: string) => capitalise(value)
+ }
+
+ return formatters
+}
diff --git a/code/app/src/lib/i18n/i18n-svelte.ts b/code/app/src/lib/i18n/i18n-svelte.ts
new file mode 100644
index 0000000..6cdffb3
--- /dev/null
+++ b/code/app/src/lib/i18n/i18n-svelte.ts
@@ -0,0 +1,12 @@
+// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten.
+/* eslint-disable */
+
+import { initI18nSvelte } from 'typesafe-i18n/svelte'
+import type { Formatters, Locales, TranslationFunctions, Translations } from './i18n-types'
+import { loadedFormatters, loadedLocales } from './i18n-util'
+
+const { locale, LL, setLocale } = initI18nSvelte<Locales, Translations, TranslationFunctions, Formatters>(loadedLocales, loadedFormatters)
+
+export { locale, LL, setLocale }
+
+export default LL
diff --git a/code/app/src/lib/i18n/i18n-types.ts b/code/app/src/lib/i18n/i18n-types.ts
new file mode 100644
index 0000000..0df6d1a
--- /dev/null
+++ b/code/app/src/lib/i18n/i18n-types.ts
@@ -0,0 +1,359 @@
+// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten.
+/* eslint-disable */
+import type { BaseTranslation as BaseTranslationType, LocalizedString, RequiredParams } from 'typesafe-i18n'
+
+export type BaseTranslation = BaseTranslationType & DisallowNamespaces
+export type BaseLocale = 'en'
+
+export type Locales =
+ | 'en'
+ | 'nb'
+
+export type Translation = RootTranslation & DisallowNamespaces
+
+export type Translations = RootTranslation &
+{
+ app: NamespaceAppTranslation
+}
+
+type RootTranslation = {
+ /**
+ * O​r
+ */
+ or: string
+ /**
+ * E​m​a​i​l​ ​a​d​d​r​e​s​s
+ */
+ emailAddress: string
+ /**
+ * P​a​s​s​w​o​r​d
+ */
+ password: string
+ /**
+ * P​a​g​e​ ​n​o​t​ ​f​o​u​n​d
+ */
+ pageNotFound: string
+ /**
+ * I​t​ ​s​e​e​m​s​ ​l​i​k​e​ ​y​o​u​r​ ​d​e​v​i​c​e​ ​d​o​e​s​ ​n​o​t​ ​h​a​v​e​ ​a​ ​i​n​t​e​r​n​e​t​ ​c​o​n​n​e​c​t​i​o​n​,​ ​p​l​e​a​s​e​ ​c​h​e​c​k​ ​y​o​u​r​ ​c​o​n​n​e​c​t​i​o​n​.
+ */
+ noInternet: string
+ /**
+ * R​e​s​e​t
+ */
+ reset: string
+ /**
+ * {​0​}​ ​o​f​ ​{​1​}
+ * @param {unknown} 0
+ * @param {unknown} 1
+ */
+ of: RequiredParams<'0' | '1'>
+ /**
+ * {​0​}​ ​i​s​ ​r​e​q​u​i​r​e​d
+ * @param {unknown} 0
+ */
+ isRequired: RequiredParams<'0'>
+ /**
+ * S​u​b​m​i​t
+ */
+ submit: string
+ /**
+ * S​u​c​c​e​s​s
+ */
+ success: string
+ /**
+ * T​r​y​ ​a​g​a​i​n​ ​s​o​o​n
+ */
+ tryAgainSoon: string
+ /**
+ * C​r​e​a​t​e​ ​a​ ​n​e​w​ ​a​c​c​o​u​n​t
+ */
+ createANewAccount: string
+ /**
+ * A​n​ ​u​n​e​x​p​e​c​t​e​d​ ​e​r​r​o​r​ ​o​c​c​u​r​e​d
+ */
+ unexpectedError: string
+ /**
+ * N​o​t​ ​f​o​u​n​d
+ */
+ notFound: string
+ /**
+ * D​o​c​u​m​e​n​t​a​t​i​o​n
+ */
+ documentation: string
+ /**
+ * T​e​r​m​s​ ​o​f​ ​s​e​r​v​i​c​e
+ */
+ tos: string
+ /**
+ * P​r​i​v​a​c​y​ ​p​o​l​i​c​y
+ */
+ privacyPolicy: string
+ /**
+ * S​i​g​n​ ​i​n​t​o​ ​y​o​u​r​ ​a​c​c​o​u​n​t
+ */
+ signIntoYourAccount: string
+ signInPage: {
+ /**
+ * T​h​i​s​ ​i​s​ ​n​o​t​ ​m​y​ ​c​o​m​p​u​t​e​r
+ */
+ notMyComputer: string
+ /**
+ * R​e​s​e​t​ ​p​a​s​s​w​o​r​d
+ */
+ resetPassword: string
+ /**
+ * Y​o​u​r​ ​p​a​s​s​w​o​r​d​ ​i​s​ ​u​p​d​a​t​e​d
+ */
+ yourPasswordIsUpdated: string
+ /**
+ * S​i​g​n​ ​I​n
+ */
+ signIn: string
+ /**
+ * Y​o​u​r​ ​n​e​w​ ​p​a​s​s​w​o​r​d​ ​i​s​ ​a​p​p​l​i​e​d
+ */
+ yourNewPasswordIsApplied: string
+ /**
+ * S​i​g​n​ ​i​n​ ​b​e​l​o​w
+ */
+ signInBelow: string
+ /**
+ * Y​o​u​r​ ​a​c​c​o​u​n​t​ ​i​s​ ​d​i​s​a​b​l​e​d
+ */
+ yourAccountIsDisabled: string
+ /**
+ * C​o​n​t​a​c​t​ ​y​o​u​r​ ​a​d​m​i​n​i​s​t​r​a​t​o​r​ ​i​f​ ​t​h​i​s​ ​f​e​e​l​s​ ​w​r​o​n​g
+ */
+ contactYourAdminIfDisabled: string
+ /**
+ * Y​o​u​'​v​e​ ​r​e​a​c​h​e​d​ ​t​h​e​ ​h​i​d​d​e​n​ ​i​n​a​c​t​i​v​i​t​y​ ​l​i​m​i​t
+ */
+ youHaveReachedInactivityLimit: string
+ /**
+ * F​e​e​l​ ​f​r​e​e​ ​t​o​ ​s​i​g​n​ ​i​n​ ​a​g​a​i​n
+ */
+ feelFreeToSignInAgain: string
+ }
+ signUpPage: {
+ /**
+ * C​r​e​a​t​e​ ​y​o​u​r​ ​n​e​w​ ​a​c​c​o​u​n​t
+ */
+ createYourNewAccount: string
+ }
+ resetPasswordPage: {
+ /**
+ * S​e​t​ ​a​ ​n​e​w​ ​p​a​s​s​w​o​r​d
+ */
+ setANewPassword: string
+ /**
+ * E​x​p​i​r​e​d
+ */
+ expired: string
+ /**
+ * Y​o​u​r​ ​r​e​q​u​e​s​t​ ​h​a​s​ ​e​x​p​i​r​e​d
+ */
+ requestHasExpired: string
+ /**
+ * R​e​q​u​e​s​t​ ​a​ ​n​e​w​ ​r​e​s​e​t
+ */
+ requestANewReset: string
+ /**
+ * N​e​w​ ​p​a​s​s​w​o​r​d
+ */
+ newPassword: string
+ /**
+ * I​f​ ​w​e​ ​f​i​n​d​ ​y​o​u​r​ ​e​m​a​i​l​ ​a​d​d​r​e​s​s​ ​i​n​ ​o​u​r​ ​s​y​s​t​e​m​s​,​ ​y​o​u​ ​w​i​l​l​ ​r​e​c​e​i​v​e​ ​a​n​ ​e​m​a​i​l​ ​w​i​t​h​ ​i​n​s​t​r​u​c​t​i​o​n​s​ ​o​n​ ​h​o​w​ ​t​o​ ​s​e​t​ ​a​ ​n​e​w​ ​p​a​s​s​w​o​r​d​ ​f​o​r​ ​y​o​u​r​ ​a​c​c​o​u​n​t​.
+ */
+ requestSentMessage: string
+ /**
+ * R​e​q​u​e​s​t​ ​a​ ​p​a​s​s​w​o​r​d​ ​r​e​s​e​t
+ */
+ requestAPasswordReset: string
+ /**
+ * Y​o​u​r​ ​r​e​q​u​e​s​t​ ​w​a​s​ ​n​o​t​ ​f​o​u​n​d
+ */
+ requestNotFound: string
+ /**
+ * S​u​b​m​i​t​ ​a​ ​n​e​w​ ​r​e​s​e​t​ ​r​e​q​u​e​s​t​ ​b​e​l​o​w
+ */
+ submitANewRequestBelow: string
+ }
+}
+
+export type NamespaceAppTranslation = {}
+
+export type Namespaces =
+ | 'app'
+
+type DisallowNamespaces = {
+ /**
+ * reserved for 'app'-namespace\
+ * you need to use the `./app/index.ts` file instead
+ */
+ app?: "[typesafe-i18n] reserved for 'app'-namespace. You need to use the `./app/index.ts` file instead."
+}
+
+export type TranslationFunctions = {
+ /**
+ * Or
+ */
+ or: () => LocalizedString
+ /**
+ * Email address
+ */
+ emailAddress: () => LocalizedString
+ /**
+ * Password
+ */
+ password: () => LocalizedString
+ /**
+ * Page not found
+ */
+ pageNotFound: () => LocalizedString
+ /**
+ * It seems like your device does not have a internet connection, please check your connection.
+ */
+ noInternet: () => LocalizedString
+ /**
+ * Reset
+ */
+ reset: () => LocalizedString
+ /**
+ * {0} of {1}
+ */
+ of: (arg0: unknown, arg1: unknown) => LocalizedString
+ /**
+ * {0} is required
+ */
+ isRequired: (arg0: unknown) => LocalizedString
+ /**
+ * Submit
+ */
+ submit: () => LocalizedString
+ /**
+ * Success
+ */
+ success: () => LocalizedString
+ /**
+ * Try again soon
+ */
+ tryAgainSoon: () => LocalizedString
+ /**
+ * Create a new account
+ */
+ createANewAccount: () => LocalizedString
+ /**
+ * An unexpected error occured
+ */
+ unexpectedError: () => LocalizedString
+ /**
+ * Not found
+ */
+ notFound: () => LocalizedString
+ /**
+ * Documentation
+ */
+ documentation: () => LocalizedString
+ /**
+ * Terms of service
+ */
+ tos: () => LocalizedString
+ /**
+ * Privacy policy
+ */
+ privacyPolicy: () => LocalizedString
+ /**
+ * Sign into your account
+ */
+ signIntoYourAccount: () => LocalizedString
+ signInPage: {
+ /**
+ * This is not my computer
+ */
+ notMyComputer: () => LocalizedString
+ /**
+ * Reset password
+ */
+ resetPassword: () => LocalizedString
+ /**
+ * Your password is updated
+ */
+ yourPasswordIsUpdated: () => LocalizedString
+ /**
+ * Sign In
+ */
+ signIn: () => LocalizedString
+ /**
+ * Your new password is applied
+ */
+ yourNewPasswordIsApplied: () => LocalizedString
+ /**
+ * Sign in below
+ */
+ signInBelow: () => LocalizedString
+ /**
+ * Your account is disabled
+ */
+ yourAccountIsDisabled: () => LocalizedString
+ /**
+ * Contact your administrator if this feels wrong
+ */
+ contactYourAdminIfDisabled: () => LocalizedString
+ /**
+ * You've reached the hidden inactivity limit
+ */
+ youHaveReachedInactivityLimit: () => LocalizedString
+ /**
+ * Feel free to sign in again
+ */
+ feelFreeToSignInAgain: () => LocalizedString
+ }
+ signUpPage: {
+ /**
+ * Create your new account
+ */
+ createYourNewAccount: () => LocalizedString
+ }
+ resetPasswordPage: {
+ /**
+ * Set a new password
+ */
+ setANewPassword: () => LocalizedString
+ /**
+ * Expired
+ */
+ expired: () => LocalizedString
+ /**
+ * Your request has expired
+ */
+ requestHasExpired: () => LocalizedString
+ /**
+ * Request a new reset
+ */
+ requestANewReset: () => LocalizedString
+ /**
+ * New password
+ */
+ newPassword: () => LocalizedString
+ /**
+ * If we find your email address in our systems, you will receive an email with instructions on how to set a new password for your account.
+ */
+ requestSentMessage: () => LocalizedString
+ /**
+ * Request a password reset
+ */
+ requestAPasswordReset: () => LocalizedString
+ /**
+ * Your request was not found
+ */
+ requestNotFound: () => LocalizedString
+ /**
+ * Submit a new reset request below
+ */
+ submitANewRequestBelow: () => LocalizedString
+ }
+ app: {
+ }
+}
+
+export type Formatters = {}
diff --git a/code/app/src/lib/i18n/i18n-util.async.ts b/code/app/src/lib/i18n/i18n-util.async.ts
new file mode 100644
index 0000000..00b8e0a
--- /dev/null
+++ b/code/app/src/lib/i18n/i18n-util.async.ts
@@ -0,0 +1,42 @@
+// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten.
+/* eslint-disable */
+
+import { initFormatters } from './formatters'
+import type { Locales, Namespaces, Translations } from './i18n-types'
+import { loadedFormatters, loadedLocales, locales } from './i18n-util'
+
+const localeTranslationLoaders = {
+ en: () => import('./en'),
+ nb: () => import('./nb'),
+}
+
+const localeNamespaceLoaders = {
+ en: {
+ app: () => import('./en/app')
+ },
+ nb: {
+ app: () => import('./nb/app')
+ }
+}
+
+const updateDictionary = (locale: Locales, dictionary: Partial<Translations>) =>
+ loadedLocales[locale] = { ...loadedLocales[locale], ...dictionary }
+
+export const importLocaleAsync = async (locale: Locales) =>
+ (await localeTranslationLoaders[locale]()).default as unknown as Translations
+
+export const loadLocaleAsync = async (locale: Locales): Promise<void> => {
+ updateDictionary(locale, await importLocaleAsync(locale))
+ loadFormatters(locale)
+}
+
+export const loadAllLocalesAsync = (): Promise<void[]> => Promise.all(locales.map(loadLocaleAsync))
+
+export const loadFormatters = (locale: Locales): void =>
+ void (loadedFormatters[locale] = initFormatters(locale))
+
+export const importNamespaceAsync = async<Namespace extends Namespaces>(locale: Locales, namespace: Namespace) =>
+ (await localeNamespaceLoaders[locale][namespace]()).default as unknown as Translations[Namespace]
+
+export const loadNamespaceAsync = async <Namespace extends Namespaces>(locale: Locales, namespace: Namespace): Promise<void> =>
+ void updateDictionary(locale, { [namespace]: await importNamespaceAsync(locale, namespace )})
diff --git a/code/app/src/lib/i18n/i18n-util.sync.ts b/code/app/src/lib/i18n/i18n-util.sync.ts
new file mode 100644
index 0000000..8144fdc
--- /dev/null
+++ b/code/app/src/lib/i18n/i18n-util.sync.ts
@@ -0,0 +1,35 @@
+// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten.
+/* eslint-disable */
+
+import { initFormatters } from './formatters'
+import type { Locales, Translations } from './i18n-types'
+import { loadedFormatters, loadedLocales, locales } from './i18n-util'
+
+import en from './en'
+import nb from './nb'
+
+import en_app from './en/app'
+import nb_app from './nb/app'
+
+const localeTranslations = {
+ en: {
+ ...en,
+ app: en_app
+ },
+ nb: {
+ ...nb,
+ app: nb_app
+ },
+}
+
+export const loadLocale = (locale: Locales): void => {
+ if (loadedLocales[locale]) return
+
+ loadedLocales[locale] = localeTranslations[locale] as unknown as Translations
+ loadFormatters(locale)
+}
+
+export const loadAllLocales = (): void => locales.forEach(loadLocale)
+
+export const loadFormatters = (locale: Locales): void =>
+ void (loadedFormatters[locale] = initFormatters(locale))
diff --git a/code/app/src/lib/i18n/i18n-util.ts b/code/app/src/lib/i18n/i18n-util.ts
new file mode 100644
index 0000000..35f023c
--- /dev/null
+++ b/code/app/src/lib/i18n/i18n-util.ts
@@ -0,0 +1,39 @@
+// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten.
+/* eslint-disable */
+
+import { i18n as initI18n, i18nObject as initI18nObject, i18nString as initI18nString } from 'typesafe-i18n'
+import type { LocaleDetector } from 'typesafe-i18n/detectors'
+import { detectLocale as detectLocaleFn } from 'typesafe-i18n/detectors'
+import type { Formatters, Locales, Namespaces, Translations, TranslationFunctions } from './i18n-types'
+
+export const baseLocale: Locales = 'en'
+
+export const locales: Locales[] = [
+ 'en',
+ 'nb'
+]
+
+export const namespaces: Namespaces[] = [
+ 'app'
+]
+
+export const isLocale = (locale: string) => locales.includes(locale as Locales)
+
+export const isNamespace = (namespace: string) => namespaces.includes(namespace as Namespaces)
+
+export const loadedLocales = {} as Record<Locales, Translations>
+
+export const loadedFormatters = {} as Record<Locales, Formatters>
+
+export const i18nString = (locale: Locales) => initI18nString<Locales, Formatters>(locale, loadedFormatters[locale])
+
+export const i18nObject = (locale: Locales) =>
+ initI18nObject<Locales, Translations, TranslationFunctions, Formatters>(
+ locale,
+ loadedLocales[locale],
+ loadedFormatters[locale]
+ )
+
+export const i18n = () => initI18n<Locales, Translations, TranslationFunctions, Formatters>(loadedLocales, loadedFormatters)
+
+export const detectLocale = (...detectors: LocaleDetector[]) => detectLocaleFn<Locales>(baseLocale, locales, ...detectors)
diff --git a/code/app/src/lib/i18n/nb/app/index.ts b/code/app/src/lib/i18n/nb/app/index.ts
new file mode 100644
index 0000000..15d0b9a
--- /dev/null
+++ b/code/app/src/lib/i18n/nb/app/index.ts
@@ -0,0 +1,8 @@
+import type { NamespaceAppTranslation } from '../../i18n-types'
+
+const nb_app: NamespaceAppTranslation = {
+ // TODO: insert translations
+
+}
+
+export default nb_app
diff --git a/code/app/src/lib/i18n/nb/index.ts b/code/app/src/lib/i18n/nb/index.ts
new file mode 100644
index 0000000..fa81477
--- /dev/null
+++ b/code/app/src/lib/i18n/nb/index.ts
@@ -0,0 +1,50 @@
+import type { Translation } from "../i18n-types";
+
+const nb: Translation = {
+ or: "Eller",
+ emailAddress: "E-postadresse",
+ password: "Passord",
+ pageNotFound: "Fant ikke siden",
+ noInternet: "Det ser ut som at du ikke tilkoblet internettet, sjekk tilkoblingen din for å fortsette",
+ reset: "Tilbakestill",
+ of: "{0} av {1}",
+ isRequired: "{0} er påkrevd",
+ submit: "Send",
+ success: "Suksess",
+ tryAgainSoon: "Prøv igjen snart",
+ createANewAccount: "Lag en ny konto",
+ unexpectedError: "En uventet feil oppstod",
+ notFound: "Ikke funnet",
+ documentation: "Dokumentasjon",
+ tos: "Vilkår",
+ privacyPolicy: "Personvernerklæring",
+ signIntoYourAccount: "Logg inn med din konto",
+ signInPage: {
+ notMyComputer: "Dette er ikke min datamaskin",
+ resetPassword: "Tilbakestill passord",
+ yourPasswordIsUpdated: "Ditt passord er oppdater",
+ signIn: "Logg inn",
+ yourNewPasswordIsApplied: "Ditt nye passord er satt",
+ signInBelow: "Logg inn nedenfor",
+ yourAccountIsDisabled: "Din konto er deaktivert",
+ contactYourAdminIfDisabled: "Ta kontakt med din administrator hvis dette føles feil",
+ youHaveReachedInactivityLimit: "Du har nådd den hemmelige inaktivitetsgrensen",
+ feelFreeToSignInAgain: "Logg gjerne inn igjen"
+ },
+ signUpPage: {
+ createYourNewAccount: "Opprett din nye konto",
+ },
+ resetPasswordPage: {
+ setANewPassword: "Skriv et nytt passord",
+ expired: "Utgått",
+ requestHasExpired: "Din forespørsel er utgått",
+ requestANewReset: "Spør om en ny tilbakestillingslenke",
+ newPassword: "Nytt passord",
+ requestSentMessage: "Hvis vi finner e-postadressen din i våre systemer, vil du få en e-post med instrukser for å sette ditt nye passord.",
+ requestAPasswordReset: "Forespør tilbakestilling av ditt passord",
+ requestNotFound: "Din forespørsel ble ikke funnet",
+ submitANewRequestBelow: "Spør om en ny tilbakestillingslenke nedenfor"
+ }
+}
+
+export default nb; \ No newline at end of file
diff --git a/code/app/src/lib/logger.ts b/code/app/src/lib/logger.ts
new file mode 100644
index 0000000..df0a821
--- /dev/null
+++ b/code/app/src/lib/logger.ts
@@ -0,0 +1,86 @@
+import { browser, dev } from "$app/environment";
+import { StorageKeys } from "$lib/configuration";
+import pino from "pino";
+
+const pinoConfig = dev ? {
+ transport: {
+ target: "pino-pretty",
+ }
+} : {};
+
+const pinoLogger = pino(pinoConfig);
+
+function browserLogLevel(): number {
+ if (browser) return LogLevel.toNumber(sessionStorage.getItem(StorageKeys.logLevel), LogLevel.INFO);
+ throw new Error("Called browser api in server");
+}
+
+function serverLogLevel(): number {
+ if (!browser) return LogLevel.toNumber(import.meta.env.VITE_LOG_LEVEL, LogLevel.ERROR);
+ throw new Error("Called server api in browser");
+}
+
+export const LogLevel = {
+ DEBUG: 0,
+ INFO: 1,
+ ERROR: 2,
+ SILENT: 3,
+ toString(levelInt: number): string {
+ switch (levelInt) {
+ case 0:
+ return "DEBUG";
+ case 1:
+ return "INFO";
+ case 2:
+ return "ERROR";
+ case 3:
+ return "SILENT";
+ default:
+ throw new Error("Log level int is unknown");
+ }
+ },
+ toNumber(levelString?: string | null, fallback?: number): number {
+ if (!levelString && fallback) return fallback;
+ else if (!levelString && !fallback) throw new Error("levelString was empty, and no fallback was specified");
+ switch (levelString?.toUpperCase()) {
+ case "DEBUG":
+ return 0;
+ case "INFO":
+ return 1;
+ case "ERROR":
+ return 2;
+ case "SILENT":
+ return 3;
+ default:
+ if (!fallback) throw new Error("Log level string is unknown");
+ else return fallback;
+ }
+ },
+};
+
+export function logDebug(message: string, ...additional: any[]): void {
+ if (browser && browserLogLevel() <= LogLevel.DEBUG) {
+ pinoLogger.debug(message, additional);
+ }
+ if (!browser && serverLogLevel() <= LogLevel.DEBUG) {
+ pinoLogger.debug(message, additional);
+ }
+}
+
+export function logInfo(message: string, ...additional: any[]): void {
+ if (browser && browserLogLevel() <= LogLevel.INFO) {
+ pinoLogger.info(message, additional);
+ }
+ if (!browser && serverLogLevel() <= LogLevel.INFO) {
+ pinoLogger.info(message, additional);
+ }
+}
+
+export function logError(message: any, ...additional: any[]): void {
+ if (browser && browserLogLevel() <= LogLevel.ERROR) {
+ pinoLogger.error(message, additional);
+ }
+ if (!browser && serverLogLevel() <= LogLevel.ERROR) {
+ pinoLogger.error(message, additional);
+ }
+} \ No newline at end of file
diff --git a/code/app/src/lib/models/CreateAccountPayload.ts b/code/app/src/lib/models/CreateAccountPayload.ts
new file mode 100644
index 0000000..d116308
--- /dev/null
+++ b/code/app/src/lib/models/CreateAccountPayload.ts
@@ -0,0 +1,4 @@
+export interface CreateAccountPayload {
+ username: string,
+ password: string
+}
diff --git a/code/app/src/lib/models/ErrorResult.ts b/code/app/src/lib/models/ErrorResult.ts
new file mode 100644
index 0000000..7c70017
--- /dev/null
+++ b/code/app/src/lib/models/ErrorResult.ts
@@ -0,0 +1,4 @@
+export interface ErrorResult {
+ title: string,
+ text: string
+}
diff --git a/code/app/src/lib/models/IInternalFetchRequest.ts b/code/app/src/lib/models/IInternalFetchRequest.ts
new file mode 100644
index 0000000..68505e2
--- /dev/null
+++ b/code/app/src/lib/models/IInternalFetchRequest.ts
@@ -0,0 +1,6 @@
+export interface IInternalFetchRequest {
+ url: string,
+ init?: RequestInit,
+ timeout?: number
+ retry_count?: number
+}
diff --git a/code/app/src/lib/models/IInternalFetchResponse.ts b/code/app/src/lib/models/IInternalFetchResponse.ts
new file mode 100644
index 0000000..6c91b35
--- /dev/null
+++ b/code/app/src/lib/models/IInternalFetchResponse.ts
@@ -0,0 +1,6 @@
+export interface IInternalFetchResponse {
+ ok: boolean,
+ status: number,
+ data: any,
+ http_response: Response
+}
diff --git a/code/app/src/lib/models/ISession.ts b/code/app/src/lib/models/ISession.ts
new file mode 100644
index 0000000..7587145
--- /dev/null
+++ b/code/app/src/lib/models/ISession.ts
@@ -0,0 +1,8 @@
+export interface ISession {
+ profile: {
+ username: string,
+ displayName: string,
+ id: string,
+ },
+ lastChecked: number,
+} \ No newline at end of file
diff --git a/code/app/src/lib/models/IValidationResult.ts b/code/app/src/lib/models/IValidationResult.ts
new file mode 100644
index 0000000..9a21b13
--- /dev/null
+++ b/code/app/src/lib/models/IValidationResult.ts
@@ -0,0 +1,31 @@
+export interface IValidationResult {
+ errors: Array<IValidationError>,
+ has_errors: Function,
+ add_error: Function,
+ remove_error: Function,
+}
+
+export interface IValidationError {
+ _id?: string,
+ title: string,
+ text?: string
+}
+
+export default class ValidationResult implements IValidationResult {
+ errors: IValidationError[]
+ has_errors(): boolean {
+ return this.errors?.length > 0;
+ }
+ add_error(prop: string, error: IValidationError): void {
+ if (!this.errors) this.errors = [];
+ error._id = prop;
+ this.errors.push(error);
+ }
+ remove_error(property: string): void {
+ const new_errors = [];
+ for (const error of this.errors) {
+ if (error._id != property) new_errors.push(error)
+ }
+ this.errors = new_errors;
+ }
+}
diff --git a/code/app/src/lib/models/LoginPayload.ts b/code/app/src/lib/models/LoginPayload.ts
new file mode 100644
index 0000000..beb96cf
--- /dev/null
+++ b/code/app/src/lib/models/LoginPayload.ts
@@ -0,0 +1,5 @@
+export interface LoginPayload {
+ username: string,
+ password: string,
+ persist: boolean
+}
diff --git a/code/app/src/lib/models/TimeCategoryDto.ts b/code/app/src/lib/models/TimeCategoryDto.ts
new file mode 100644
index 0000000..fcdb7ea
--- /dev/null
+++ b/code/app/src/lib/models/TimeCategoryDto.ts
@@ -0,0 +1,9 @@
+import { Temporal } from "temporal-polyfill";
+
+export interface TimeCategoryDto {
+ selected?: boolean;
+ id?: string,
+ modified_at?: Temporal.PlainDate,
+ name?: string,
+ color?: string
+}
diff --git a/code/app/src/lib/models/TimeEntryDto.ts b/code/app/src/lib/models/TimeEntryDto.ts
new file mode 100644
index 0000000..571c52e
--- /dev/null
+++ b/code/app/src/lib/models/TimeEntryDto.ts
@@ -0,0 +1,13 @@
+import type { TimeLabelDto } from "./TimeLabelDto";
+import type { TimeCategoryDto } from "./TimeCategoryDto";
+import { Temporal } from "temporal-polyfill";
+
+export interface TimeEntryDto {
+ id: string,
+ modified_at?: Temporal.PlainDate,
+ start: string,
+ stop: string,
+ description: string,
+ labels?: Array<TimeLabelDto>,
+ category: TimeCategoryDto,
+}
diff --git a/code/app/src/lib/models/TimeEntryQuery.ts b/code/app/src/lib/models/TimeEntryQuery.ts
new file mode 100644
index 0000000..d983d1a
--- /dev/null
+++ b/code/app/src/lib/models/TimeEntryQuery.ts
@@ -0,0 +1,27 @@
+import type { TimeCategoryDto } from "./TimeCategoryDto";
+import type { TimeLabelDto } from "./TimeLabelDto";
+import type { Temporal } from "temporal-polyfill";
+
+export interface TimeEntryQuery {
+ duration: TimeEntryQueryDuration,
+ categories?: Array<TimeCategoryDto>,
+ labels?: Array<TimeLabelDto>,
+ dateRange?: TimeEntryQueryDateRange,
+ specificDate?: Temporal.PlainDateTime
+ page: number,
+ pageSize: number
+}
+
+export interface TimeEntryQueryDateRange {
+ from: Temporal.PlainDateTime,
+ to: Temporal.PlainDateTime
+}
+
+export enum TimeEntryQueryDuration {
+ TODAY = 0,
+ THIS_WEEK = 1,
+ THIS_MONTH = 2,
+ THIS_YEAR = 3,
+ SPECIFIC_DATE = 4,
+ DATE_RANGE = 5,
+}
diff --git a/code/app/src/lib/models/TimeLabelDto.ts b/code/app/src/lib/models/TimeLabelDto.ts
new file mode 100644
index 0000000..7183bcf
--- /dev/null
+++ b/code/app/src/lib/models/TimeLabelDto.ts
@@ -0,0 +1,8 @@
+import { Temporal } from "temporal-polyfill";
+
+export interface TimeLabelDto {
+ id?: string,
+ modified_at?: Temporal.PlainDate,
+ name?: string,
+ color?: string
+}
diff --git a/code/app/src/lib/models/TimeQueryDto.ts b/code/app/src/lib/models/TimeQueryDto.ts
new file mode 100644
index 0000000..607c51e
--- /dev/null
+++ b/code/app/src/lib/models/TimeQueryDto.ts
@@ -0,0 +1,29 @@
+import type { TimeEntryDto } from "./TimeEntryDto";
+import ValidationResult, { IValidationResult } from "./IValidationResult";
+
+export interface ITimeQueryDto {
+ results: Array<TimeEntryDto>,
+ page: number,
+ pageSize: number,
+ totalRecords: number,
+ totalPageCount: number,
+ is_valid: Function
+}
+
+export class TimeQueryDto implements ITimeQueryDto {
+ results: TimeEntryDto[];
+ page: number;
+ pageSize: number;
+ totalRecords: number;
+ totalPageCount: number;
+
+ is_valid(): IValidationResult {
+ const result = new ValidationResult();
+ if (this.page < 0) {
+ result.add_error("page", {
+ title: "Page cannot be less than zero",
+ })
+ }
+ return result;
+ }
+}
diff --git a/code/app/src/lib/models/UnwrappedEntryDateTime.ts b/code/app/src/lib/models/UnwrappedEntryDateTime.ts
new file mode 100644
index 0000000..d614f91
--- /dev/null
+++ b/code/app/src/lib/models/UnwrappedEntryDateTime.ts
@@ -0,0 +1,9 @@
+import { Temporal } from "temporal-polyfill";
+
+export interface UnwrappedEntryDateTime {
+ start_date: Temporal.PlainDate,
+ stop_date: Temporal.PlainDate,
+ start_time: Temporal.PlainTime,
+ stop_time: Temporal.PlainTime,
+ duration: Temporal.Duration,
+}
diff --git a/code/app/src/lib/models/UpdateProfilePayload.ts b/code/app/src/lib/models/UpdateProfilePayload.ts
new file mode 100644
index 0000000..d2983ff
--- /dev/null
+++ b/code/app/src/lib/models/UpdateProfilePayload.ts
@@ -0,0 +1,4 @@
+export interface UpdateProfilePayload {
+ username?: string,
+ password?: string,
+}
diff --git a/code/app/src/lib/persistent-store.ts b/code/app/src/lib/persistent-store.ts
new file mode 100644
index 0000000..922f3ab
--- /dev/null
+++ b/code/app/src/lib/persistent-store.ts
@@ -0,0 +1,102 @@
+import { writable as _writable, readable as _readable, } from "svelte/store";
+import type { Writable, Readable, StartStopNotifier } from "svelte/store";
+
+enum StoreType {
+ SESSION = 0,
+ LOCAL = 1
+}
+
+interface StoreOptions {
+ store?: StoreType;
+}
+
+const default_store_options = {
+ store: StoreType.SESSION
+} as StoreOptions;
+
+interface WritableStore<T> {
+ name: string,
+ initialState: T,
+ options?: StoreOptions
+}
+
+interface ReadableStore<T> {
+ name: string,
+ initialState: T,
+ callback: StartStopNotifier<any>,
+ options?: StoreOptions
+}
+
+function get_store(type: StoreType): Storage {
+ switch (type) {
+ case StoreType.SESSION:
+ return window.sessionStorage;
+ case StoreType.LOCAL:
+ return window.localStorage;
+ }
+}
+
+function prepared_store_value(value: any): string {
+ try {
+ return JSON.stringify(value);
+ } catch (e) {
+ console.error(e);
+ return "__INVALID__";
+ }
+}
+
+function get_store_value<T>(options: WritableStore<T> | ReadableStore<T>): any {
+ try {
+ const storage = get_store(options.options.store);
+ const value = storage.getItem(options.name);
+ if (!value) return false;
+ return JSON.parse(value);
+ } catch (e) {
+ console.error(e);
+ return { __INVALID__: true };
+ }
+}
+
+function hydrate<T>(store: Writable<T>, options: WritableStore<T> | ReadableStore<T>): void {
+ const value = get_store_value<T>(options);
+ if (value && store.set) store.set(value);
+}
+
+function subscribe<T>(store: Writable<T> | Readable<T>, options: WritableStore<T> | ReadableStore<T>): void {
+ const storage = get_store(options.options.store);
+ if (!store.subscribe) return;
+ store.subscribe((state: any) => {
+ storage.setItem(options.name, prepared_store_value(state));
+ });
+}
+
+function writable_persistent<T>(options: WritableStore<T>): Writable<T> {
+ if (options.options === undefined) options.options = default_store_options;
+ console.log("Creating writable store with options: ", options);
+ const store = _writable<T>(options.initialState);
+ hydrate(store, options);
+ subscribe(store, options);
+ return store;
+}
+
+function readable_persistent<T>(options: ReadableStore<T>): Readable<T> {
+ if (options.options === undefined) options.options = default_store_options;
+ console.log("Creating readable store with options: ", options);
+ const store = _readable<T>(options.initialState, options.callback);
+ // hydrate(store, options);
+ subscribe(store, options);
+ return store;
+}
+
+export {
+ writable_persistent,
+ readable_persistent,
+ StoreType
+};
+
+export type {
+ WritableStore,
+ ReadableStore,
+ StoreOptions
+};
+
diff --git a/code/app/src/lib/session.ts b/code/app/src/lib/session.ts
new file mode 100644
index 0000000..ee79933
--- /dev/null
+++ b/code/app/src/lib/session.ts
@@ -0,0 +1,69 @@
+import {logError, logInfo} from "$lib/logger";
+import { Temporal } from "temporal-polyfill";
+import { get_profile_for_active_check, logout } from "./api/user";
+import { is_guid, session_storage_get_json, session_storage_set_json } from "./helpers";
+import { SECONDS_BETWEEN_SESSION_CHECK, StorageKeys } from "./configuration";
+import type { ISession } from "$lib/models/ISession";
+
+export async function is_active(forceRefresh: boolean = false): Promise<boolean> {
+ const nowEpoch = Temporal.Now.instant().epochSeconds;
+ const data = session_storage_get_json(StorageKeys.session) as ISession;
+ const expiryEpoch = data?.lastChecked + SECONDS_BETWEEN_SESSION_CHECK;
+ const lastCheckIsStaleOrNone = !is_guid(data?.profile?.id) || (expiryEpoch < nowEpoch);
+ if (forceRefresh || lastCheckIsStaleOrNone) {
+ return await call_api();
+ } else {
+ const sessionIsValid = data.profile && is_guid(data.profile.id);
+ if (!sessionIsValid) {
+ clear_session_data();
+ logInfo("Session data is not valid");
+ }
+ return sessionIsValid;
+ }
+}
+
+export async function end_session(cb: Function): Promise<void> {
+ await logout();
+ clear_session_data();
+ cb();
+}
+
+async function call_api(): Promise<boolean> {
+ logInfo("Getting profile data while checking session state");
+ try {
+ const response = await get_profile_for_active_check();
+ if (response.ok) {
+ const userData = await response.data;
+ if (is_guid(userData.id) && userData.username) {
+ const session = {
+ profile: userData,
+ lastChecked: Temporal.Now.instant().epochSeconds
+ } as ISession;
+ session_storage_set_json(StorageKeys.session, session);
+ logInfo("Successfully got profile data while checking session state");
+ return true;
+ } else {
+ logError("Api returned invalid data while getting profile data");
+ clear_session_data();
+ return false;
+ }
+ } else {
+ logError("Api returned unsuccessfully while getting profile data");
+ clear_session_data();
+ return false;
+ }
+ } catch (e) {
+ logError(e);
+ clear_session_data();
+ return false;
+ }
+}
+
+export function clear_session_data() {
+ session_storage_set_json(StorageKeys.session, {});
+ logInfo("Cleared session data.");
+}
+
+export function get_session_data(): ISession {
+ return session_storage_get_json(StorageKeys.session) as ISession;
+}
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..0be6ff3
--- /dev/null
+++ b/code/app/src/routes/(main)/(app)/+layout.svelte
@@ -0,0 +1,297 @@
+<script lang="ts">
+ import {
+ ChevronUpDownIcon,
+ MagnifyingGlassIcon,
+ Bars3CenterLeftIcon,
+ XMarkIcon,
+ HomeIcon,
+ MegaphoneIcon,
+ FolderOpenIcon,
+ QueueListIcon,
+ CalendarIcon,
+ } from "$lib/components/icons";
+ import { Dialog, Menu, MenuButton, MenuItem, MenuItems, Transition, TransitionChild, TransitionRoot } from "@rgossiaux/svelte-headlessui";
+ import { DialogPanel } from "@developermuch/dev-svelte-headlessui";
+ import type { ISession } from "$lib/models/ISession";
+ import { Input } from "$lib/components";
+ import { end_session } from "$lib/session";
+ import { goto } from "$app/navigation";
+ import { page } from "$app/stores";
+
+ const session = {
+ profile: {
+ username: "Brukernavn",
+ displayName: "epost@adresse.no",
+ },
+ } as ISession;
+
+ let sidebarOpen = false;
+ let sidebarSearchValue: string | undefined;
+
+ function sign_out() {
+ 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>
+
+<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">
+ <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..683938a
--- /dev/null
+++ b/code/app/src/routes/(main)/(app)/projects/+page.svelte
@@ -0,0 +1,5 @@
+<script lang="ts">
+ import { createSvelteTable } from "@tanstack/svelte-table";
+</script>
+
+<h1>Projects</h1>
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..ae6d403
--- /dev/null
+++ b/code/app/src/routes/(main)/(app)/settings/+page.svelte
@@ -0,0 +1,4 @@
+<script lang="ts">
+</script>
+
+<h1>Settings</h1>
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..69c29c5
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/+layout.svelte
@@ -0,0 +1,18 @@
+<script>
+ import LL from "$lib/i18n/i18n-svelte";
+</script>
+
+<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/tos" class="link">
+ {$LL.tos()}
+ </a>
+ <a href="https://greatoffice.life/documentation" class="link">
+ {$LL.documentation()}
+ </a>
+</footer>
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..aa26892
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/reset-password/+page.svelte
@@ -0,0 +1,82 @@
+<script lang="ts">
+ import { create_forgot_password_request } from "$lib/api/user";
+ import { Alert, Input, Button } from "$lib/components";
+ import LL from "$lib/i18n/i18n-svelte";
+ import type { ErrorResult } from "$lib/models/ErrorResult";
+
+ const formData = {
+ email: "",
+ };
+
+ $: showErrorAlert =
+ (errorData?.text.length ?? 0 + errorData?.title.length ?? 0) > 0 &&
+ !showSuccessAlert;
+
+ const errorData = {
+ text: "",
+ title: "",
+ } as ErrorResult;
+
+ let loading = false;
+ let showSuccessAlert = false;
+
+ async function submitFormAsync() {
+ errorData.text = "";
+ errorData.title = "";
+ showSuccessAlert = false;
+ loading = true;
+ const request = await create_forgot_password_request(formData.email);
+ loading = false;
+ if (!request.ok) {
+ errorData.text = request.data.text ?? $LL.tryAgainSoon();
+ errorData.title = request.data.title ?? $LL.unexpectedError();
+ return;
+ }
+ showSuccessAlert = true;
+ }
+</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={submitFormAsync}>
+ <Alert
+ title={errorData.title}
+ message={errorData.text}
+ type="error"
+ visible={showErrorAlert}
+ />
+
+ <Alert
+ type="success"
+ title={$LL.success()}
+ message={$LL.resetPasswordPage.requestSentMessage()}
+ visible={showSuccessAlert}
+ />
+
+ <Input
+ id="email"
+ name="email"
+ type="email"
+ autocomplete="email"
+ required
+ bind:value={formData.email}
+ 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/[id]/+page.server.js b/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.server.js
new file mode 100644
index 0000000..1c7fa30
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.server.js
@@ -0,0 +1,11 @@
+import { is_guid } from '$lib/helpers';
+import { redirect } from '@sveltejs/kit';
+export const load = async ({ params }) => {
+ const resetRequestId = params.id ?? "";
+ if (!is_guid(resetRequestId))
+ throw redirect(302, "/reset-password");
+ return {
+ resetRequestId
+ };
+};
+//# sourceMappingURL=+page.server.js.map \ No newline at end of file
diff --git a/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.server.js.map b/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.server.js.map
new file mode 100644
index 0000000..52fb93b
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.server.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"+page.server.js","sourceRoot":"","sources":["+page.server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACvC,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAGzC,MAAM,CAAC,MAAM,IAAI,GAAmB,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE;IACrD,MAAM,cAAc,GAAG,MAAM,CAAC,EAAE,IAAI,EAAE,CAAC;IACvC,IAAI,CAAC,OAAO,CAAC,cAAc,CAAC;QAAE,MAAM,QAAQ,CAAC,GAAG,EAAE,iBAAiB,CAAC,CAAC;IACrE,OAAO;QACH,cAAc;KACjB,CAAC;AACN,CAAC,CAAC"} \ 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..389d04c
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.server.ts
@@ -0,0 +1,11 @@
+import { is_guid } from '$lib/helpers';
+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..562d902
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/reset-password/[id]/+page.svelte
@@ -0,0 +1,132 @@
+<script lang="ts">
+ import {
+ check_forgot_password_request,
+ fulfill_forgot_password_request,
+ } from "$lib/api/user";
+ import { onMount } from "svelte";
+ import LL from "$lib/i18n/i18n-svelte";
+ import { Alert, Input, Button } from "$lib/components";
+ import type { PageServerData } from "./$types";
+ import type { ErrorResult } from "$lib/models/ErrorResult";
+ import { goto } from "$app/navigation";
+ import { Message, messageQueryKey } from "../../sign-in/+page.svelte";
+
+ export let data: PageServerData;
+
+ const formData = {
+ newPassword: "",
+ };
+
+ const errorData = {
+ text: "",
+ title: "",
+ } as ErrorResult;
+
+ let errorState: undefined | "expired" | "404" | "unknown";
+
+ let finishedPreliminaryLoading = false;
+ let loading = false;
+ let canSubmit = true;
+
+ async function submitFormAsync() {
+ if (!canSubmit) return;
+ loading = true;
+ const request = await fulfill_forgot_password_request(
+ data.resetRequestId,
+ formData.newPassword
+ );
+ if (request.ok) {
+ goto(
+ "/sign-in?" +
+ messageQueryKey +
+ "=" +
+ Message.AFTER_PASSWORD_RESET
+ );
+ }
+
+ loading = false;
+ }
+
+ onMount(async () => {
+ errorState = undefined;
+ const isValidRequest = await check_forgot_password_request(
+ data.resetRequestId
+ );
+ if (!isValidRequest.ok && isValidRequest.status !== 404) {
+ errorState = "unknown";
+ canSubmit = false;
+ }
+ if (isValidRequest.status === 404) {
+ errorState = "404";
+ canSubmit = false;
+ }
+ if (isValidRequest.ok && isValidRequest.data !== true) {
+ errorState = "expired";
+ canSubmit = false;
+ }
+ 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 errorState === "404"}
+ <Alert
+ title={$LL.notFound()}
+ message={$LL.resetPasswordPage.requestNotFound()}
+ />
+ {:else if errorState === "expired"}
+ <Alert
+ title={$LL.resetPasswordPage.expired()}
+ message={$LL.resetPasswordPage.requestHasExpired()}
+ rightLinkHref="/reset-password"
+ rightLinkText={$LL.resetPasswordPage.requestANewReset()}
+ />
+ {:else if errorState === "unknown"}
+ <Alert
+ title={$LL.unexpectedError()}
+ message={$LL.tryAgainSoon()}
+ />
+ {/if}
+
+ <Input
+ id="password"
+ name="password"
+ type="password"
+ autocomplete="new-password"
+ required
+ bind:value={formData.newPassword}
+ 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)/sign-in/+page.svelte b/code/app/src/routes/(main)/(public)/sign-in/+page.svelte
new file mode 100644
index 0000000..908e2ba
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/sign-in/+page.svelte
@@ -0,0 +1,133 @@
+<script lang="ts">
+ import { goto } from "$app/navigation";
+ import { login } from "$lib/api/user";
+ import { Button, Checkbox, Input, Alert } from "$lib/components";
+ import LL from "$lib/i18n/i18n-svelte";
+ import type { ErrorResult } from "$lib/models/ErrorResult";
+ import type { LoginPayload } from "$lib/models/LoginPayload";
+ import pwKey from "$actions/pwKey";
+ import { onMount } from "svelte";
+ import { messageQueryKey, signInPageTestKeys, type Message } from ".";
+
+ let loading = false;
+ let messageType: Message | undefined = undefined;
+
+ const data = {
+ username: "",
+ password: "",
+ persist: true,
+ } as LoginPayload;
+
+ let errorData = {
+ text: "",
+ title: "",
+ } as ErrorResult;
+ $: showErrorAlert = (errorData?.text.length ?? 0 + errorData?.title.length ?? 0) > 0;
+
+ onMount(() => {
+ const searcher = new URLSearchParams(window.location.search);
+ if (searcher.get(messageQueryKey)) {
+ messageType = searcher.get(messageQueryKey) as Message;
+ searcher.delete(messageQueryKey);
+ history.replaceState(null, "", window.location.origin + window.location.pathname);
+ }
+ });
+
+ async function submitFormAsync() {
+ errorData = { text: "", title: "" };
+ loading = true;
+ data.persist = !data.persist;
+ const loginResponse = await login(data);
+ if (loginResponse.ok) {
+ await goto("/home");
+ } else {
+ errorData.title = loginResponse.data.title;
+ errorData.text = loginResponse.data.text;
+ }
+ loading = false;
+ }
+</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 showErrorAlert}
+ <Alert title={errorData.title} message={errorData.text} type="error" _pwKey={signInPageTestKeys.formErrorAlert} />
+ {/if}
+ <form class="space-y-6" use:pwKey={signInPageTestKeys.signInForm} on:submit|preventDefault={submitFormAsync}>
+ <Input
+ id="username"
+ _pwKey={signInPageTestKeys.usernameInput}
+ name="username"
+ type="email"
+ label={$LL.emailAddress()}
+ required
+ bind:value={data.username}
+ />
+
+ <Input
+ id="password"
+ name="password"
+ type="password"
+ label={$LL.password()}
+ _pwKey={signInPageTestKeys.passwordInput}
+ autocomplete="current-password"
+ required
+ bind:value={data.password}
+ />
+
+ <div class="flex items-center justify-between">
+ <Checkbox
+ id="remember-me"
+ _pwKey={signInPageTestKeys.rememberMeCheckbox}
+ name="remember-me"
+ bind:checked={data.persist}
+ 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>
+ </div>
+ </div>
+</div>
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..cbdcbf6
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/sign-in/index.ts
@@ -0,0 +1,19 @@
+export enum Message {
+ AFTER_PASSWORD_RESET = "after-password-reset",
+ USER_INACTIVITY = "user-inactivity",
+ USER_DISABLED = "user-disabled",
+}
+
+export const messageQueryKey = "m";
+export const signInPageTestKeys = {
+ passwordInput: "password-input",
+ usernameInput: "username-input",
+ rememberMeCheckbox: "remember-me-checkbox",
+ signInForm: "sign-in-form",
+ userInactivityAlert: Message.USER_INACTIVITY + "-alert",
+ userDisabledAlert: Message.USER_DISABLED + "-alert",
+ afterPasswordResetAlert: Message.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-in/tests/index.spec.ts b/code/app/src/routes/(main)/(public)/sign-in/tests/index.spec.ts
new file mode 100644
index 0000000..ea8c494
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/sign-in/tests/index.spec.ts
@@ -0,0 +1,12 @@
+import { test, expect } from "@playwright/test";
+import { signInPageTestKeys } from "../index";
+import { get_test_context } from "$lib/configuration";
+import { get_pw_key_selector } from "$lib/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-up/+page.svelte b/code/app/src/routes/(main)/(public)/sign-up/+page.svelte
new file mode 100644
index 0000000..0dfa41a
--- /dev/null
+++ b/code/app/src/routes/(main)/(public)/sign-up/+page.svelte
@@ -0,0 +1,82 @@
+<script lang="ts">
+ import { goto } from "$app/navigation";
+ import { create_account } from "$lib/api/user";
+ import { Button, Input, Alert } from "$lib/components";
+ import LL from "$lib/i18n/i18n-svelte";
+ import type { CreateAccountPayload } from "$lib/models/CreateAccountPayload";
+ import type { ErrorResult } from "$lib/models/ErrorResult";
+
+ const formData = {
+ username: "",
+ password: "",
+ } as CreateAccountPayload;
+
+ const errorData = {
+ text: "",
+ title: "",
+ } as ErrorResult;
+ let loading = false;
+ $: showErrorAlert =
+ (errorData?.text.length ?? 0 + errorData?.title.length ?? 0) > 0;
+
+ async function submitFormAsync() {
+ loading = true;
+ errorData.text = "";
+ errorData.title = "";
+ const response = await create_account(formData);
+ loading = false;
+ if (response.ok) {
+ await goto("/home");
+ return;
+ }
+ errorData.title = response.data?.title ?? $LL.unexpectedError();
+ errorData.text = response.data?.text ?? $LL.tryAgainSoon();
+ }
+</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">
+ <Alert
+ title={errorData.title}
+ message={errorData.text}
+ type="error"
+ class="mb-2"
+ visible={showErrorAlert}
+ />
+ <form class="space-y-6" on:submit|preventDefault={submitFormAsync}>
+ <Input
+ label={$LL.emailAddress()}
+ id="email"
+ name="email"
+ autocomplete="email"
+ required
+ type="email"
+ bind:value={formData.username}
+ />
+
+ <Input
+ label={$LL.password()}
+ id="password"
+ name="password"
+ required
+ type="password"
+ bind:value={formData.password}
+ />
+ <Button type="submit" text={$LL.submit()} {loading} fullWidth />
+ </form>
+ </div>
+ </div>
+</div>
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..d2eb2eb
--- /dev/null
+++ b/code/app/src/routes/(main)/+layout.server.ts
@@ -0,0 +1,34 @@
+import { api_base, CookieNames } from "$lib/configuration";
+import { logError } from "$lib/logger";
+import { error, redirect } from "@sveltejs/kit";
+import type { LayoutServerLoad } from "./$types";
+
+export const load: LayoutServerLoad = async ({ routeId, cookies, locals }) => {
+ const isPublicRoute = (routeId?.startsWith("(main)/(public)") || routeId === "(main)") ?? true;
+
+ let sessionIsValid = (await fetch(api_base("_/valid-session"), {
+ headers: {
+ Cookie: CookieNames.session + "=" + cookies.get(CookieNames.session)
+ }
+ }).catch((e) => {
+ logError(e);
+ throw error(503, {
+ message: "We are experiencing a service distruption! Have patience while we resolve the issue."
+ })
+ })).ok;
+
+ console.log("Base Layout loaded", {
+ sessionIsValid,
+ isPublicRoute,
+ routeId
+ });
+
+ if (sessionIsValid && isPublicRoute) {
+ throw redirect(302, "/home");
+ } else if (!sessionIsValid && !isPublicRoute) {
+ throw redirect(302, "/sign-in");
+ }
+ return {
+ locale: locals.locale
+ }
+}; \ No newline at end of file
diff --git a/code/app/src/routes/(main)/+layout.svelte b/code/app/src/routes/(main)/+layout.svelte
new file mode 100644
index 0000000..1a870bb
--- /dev/null
+++ b/code/app/src/routes/(main)/+layout.svelte
@@ -0,0 +1,29 @@
+<script lang="ts">
+ import "../../app.pcss";
+ import { setLocale } from "$lib/i18n/i18n-svelte";
+ import LocaleSwitcher from "$lib/components/locale-switcher.svelte";
+ import { ExclamationTriangleIcon } from "$lib/components/icons";
+ import type { LayoutData } from "./$types";
+
+ let online = true;
+ export let data: LayoutData;
+ setLocale(data.locale);
+</script>
+
+<svelte:window bind:online />
+
+{#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}
+
+<LocaleSwitcher />
+<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..5d0e005
--- /dev/null
+++ b/code/app/src/routes/(main)/+layout.ts
@@ -0,0 +1,15 @@
+import type { LayoutLoad } from './$types'
+import type { Locales } from '$lib/i18n/i18n-types'
+import { loadLocaleAsync } from '$lib/i18n/i18n-util.async'
+import { setLocale } from '$lib/i18n/i18n-svelte'
+
+export const load: LayoutLoad<{ locale: Locales }> = async ({ data: { locale } }) => {
+ // load dictionary into memory
+ await loadLocaleAsync(locale)
+
+ // if you need to output a localized string in a `load` function,
+ // you always need to call `setLocale` right before you access the `LL` store
+ setLocale(locale)
+ // pass locale to the "rendering context"
+ 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..aeed0d4
--- /dev/null
+++ b/code/app/src/routes/book/+layout.svelte
@@ -0,0 +1,64 @@
+<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
+ >
+ </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/+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..d008d85
--- /dev/null
+++ b/code/app/src/routes/book/alerts/+page.svelte
@@ -0,0 +1,70 @@
+<script>
+ import Alert from "$lib/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/buttons/+page.svelte b/code/app/src/routes/book/buttons/+page.svelte
new file mode 100644
index 0000000..19ba163
--- /dev/null
+++ b/code/app/src/routes/book/buttons/+page.svelte
@@ -0,0 +1,23 @@
+<script>
+ import Button from "$lib/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..a693f69
--- /dev/null
+++ b/code/app/src/routes/book/inputs/+page.svelte
@@ -0,0 +1,48 @@
+<script lang="ts">
+ import Input from "$lib/components/input.svelte";
+ import { DatabaseIcon } from "$lib/components/icons";
+</script>
+
+<section>
+ <h2>Default</h2>
+ <Input label="Input me" placeholder="Hello" />
+</section>
+
+<section>
+ <h2>With icon</h2>
+ <Input label="Input me" placeholder="Hello" icon={DatabaseIcon} />
+</section>
+
+<section>
+ <h2>With corner hint</h2>
+ <Input label="Input me ->" placeholder="Hello" cornerHint="Hint hint" />
+</section>
+
+<section>
+ <h2>Disabled</h2>
+ <Input label="No" placeholder="Sorry" disabled />
+</section>
+
+<section>
+ <h2>Errored</h2>
+ <Input
+ label="No"
+ placeholder="Sorry"
+ errorText="That's not right"
+ icon={DatabaseIcon}
+ />
+</section>
+
+<section>
+ <h2>Help</h2>
+ <Input label="Go ahead" placeholder="Write here" helpText="Write above" />
+</section>
+<section>
+ <h2>Addon</h2>
+ <Input
+ label="Go ahead"
+ placeholder="Write here"
+ helpText="Write above"
+ addon="To the right"
+ />
+</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..94228b4
--- /dev/null
+++ b/code/app/src/routes/book/toggles/+page.svelte
@@ -0,0 +1,27 @@
+<script>
+ import Switch from "$lib/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> \ No newline at end of file