aboutsummaryrefslogtreecommitdiffstats
path: root/apps/kit/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/kit/src')
-rw-r--r--apps/kit/src/app.d.ts9
-rw-r--r--apps/kit/src/app.html12
-rw-r--r--apps/kit/src/app.pcss21
-rw-r--r--apps/kit/src/hooks/index.server.ts52
-rw-r--r--apps/kit/src/lib/api/internal-fetch.ts170
-rw-r--r--apps/kit/src/lib/api/root.ts6
-rw-r--r--apps/kit/src/lib/api/time-entry.ts83
-rw-r--r--apps/kit/src/lib/api/user.ts47
-rw-r--r--apps/kit/src/lib/colors.ts47
-rw-r--r--apps/kit/src/lib/components/alert.svelte149
-rw-r--r--apps/kit/src/lib/components/button.svelte72
-rw-r--r--apps/kit/src/lib/components/icons/adjustments.svelte5
-rw-r--r--apps/kit/src/lib/components/icons/database.svelte3
-rw-r--r--apps/kit/src/lib/components/icons/home.svelte5
-rw-r--r--apps/kit/src/lib/components/icons/index.ts13
-rw-r--r--apps/kit/src/lib/components/icons/menu.svelte4
-rw-r--r--apps/kit/src/lib/components/icons/x.svelte4
-rw-r--r--apps/kit/src/lib/components/locale-switcher.svelte52
-rw-r--r--apps/kit/src/lib/configuration.ts45
-rw-r--r--apps/kit/src/lib/helpers.ts493
-rw-r--r--apps/kit/src/lib/i18n/en/index.ts136
-rw-r--r--apps/kit/src/lib/i18n/formatters.ts11
-rw-r--r--apps/kit/src/lib/i18n/i18n-svelte.ts12
-rw-r--r--apps/kit/src/lib/i18n/i18n-types.ts890
-rw-r--r--apps/kit/src/lib/i18n/i18n-util.async.ts27
-rw-r--r--apps/kit/src/lib/i18n/i18n-util.sync.ts26
-rw-r--r--apps/kit/src/lib/i18n/i18n-util.ts33
-rw-r--r--apps/kit/src/lib/i18n/nb/index.ts136
-rw-r--r--apps/kit/src/lib/locale.ts20
-rw-r--r--apps/kit/src/lib/logger.ts87
-rw-r--r--apps/kit/src/lib/models/CreateAccountPayload.ts4
-rw-r--r--apps/kit/src/lib/models/ErrorResult.ts4
-rw-r--r--apps/kit/src/lib/models/IInternalFetchRequest.ts6
-rw-r--r--apps/kit/src/lib/models/IInternalFetchResponse.ts6
-rw-r--r--apps/kit/src/lib/models/ISession.ts7
-rw-r--r--apps/kit/src/lib/models/IValidationResult.ts31
-rw-r--r--apps/kit/src/lib/models/LoginPayload.ts4
-rw-r--r--apps/kit/src/lib/models/TimeCategoryDto.ts9
-rw-r--r--apps/kit/src/lib/models/TimeEntryDto.ts13
-rw-r--r--apps/kit/src/lib/models/TimeEntryQuery.ts27
-rw-r--r--apps/kit/src/lib/models/TimeLabelDto.ts8
-rw-r--r--apps/kit/src/lib/models/TimeQueryDto.ts29
-rw-r--r--apps/kit/src/lib/models/UnwrappedEntryDateTime.ts9
-rw-r--r--apps/kit/src/lib/models/UpdateProfilePayload.ts4
-rw-r--r--apps/kit/src/lib/persistent-store.ts102
-rw-r--r--apps/kit/src/lib/session.ts69
-rw-r--r--apps/kit/src/params/guid.ts5
-rw-r--r--apps/kit/src/params/integer.ts3
-rw-r--r--apps/kit/src/routes/(app)/+layout.svelte215
-rw-r--r--apps/kit/src/routes/(app)/home/+page.svelte1
-rw-r--r--apps/kit/src/routes/(public)/+layout.svelte6
-rw-r--r--apps/kit/src/routes/(public)/login/+page.svelte95
-rw-r--r--apps/kit/src/routes/(public)/reset/+page.svelte29
-rw-r--r--apps/kit/src/routes/(public)/signup/+page.svelte38
-rw-r--r--apps/kit/src/routes/+layout.server.ts13
-rw-r--r--apps/kit/src/routes/+layout.svelte23
56 files changed, 3430 insertions, 0 deletions
diff --git a/apps/kit/src/app.d.ts b/apps/kit/src/app.d.ts
new file mode 100644
index 0000000..4ab4e43
--- /dev/null
+++ b/apps/kit/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 {}
+}
diff --git a/apps/kit/src/app.html b/apps/kit/src/app.html
new file mode 100644
index 0000000..3df27c1
--- /dev/null
+++ b/apps/kit/src/app.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html class="h-full" lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width" />
+ <script src="%sveltekit.assets%/preload.js"></script>
+ %sveltekit.head%
+ </head>
+ <body class="h-full">
+ <div>%sveltekit.body%</div>
+ </body>
+</html>
diff --git a/apps/kit/src/app.pcss b/apps/kit/src/app.pcss
new file mode 100644
index 0000000..f9c290c
--- /dev/null
+++ b/apps/kit/src/app.pcss
@@ -0,0 +1,21 @@
+/* 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;
+}
diff --git a/apps/kit/src/hooks/index.server.ts b/apps/kit/src/hooks/index.server.ts
new file mode 100644
index 0000000..414318d
--- /dev/null
+++ b/apps/kit/src/hooks/index.server.ts
@@ -0,0 +1,52 @@
+import { CookieNames } from "$lib/configuration";
+import { detectLocale, locales } from '$lib/i18n/i18n-util'
+import type { Handle, RequestEvent } from '@sveltejs/kit'
+import { sequence } from "@sveltejs/kit/hooks";
+import { initAcceptLanguageHeaderDetector } from 'typesafe-i18n/detectors'
+import { parse, serialize } from "cookie";
+import { logDebug } from "$lib/logger";
+
+const handleLocale: Handle = async ({ event, resolve }) => {
+ const cookies = parse(event.request.headers.get("Cookie") ?? '');
+ const localeCookie = cookies[CookieNames.locale];
+ const preferredLocale = getPreferredLocale(event);
+ let finalLocale = localeCookie ?? preferredLocale;
+
+ logDebug("Handling locale", {
+ locales,
+ localeCookie,
+ preferredLocale,
+ finalLocale
+ });
+
+ if (locales.findIndex((locale) => locale === finalLocale) === -1) finalLocale = "en";
+ if (!localeCookie) {
+ // Set a locale cookie
+ event.setHeaders({
+ "Set-Cookie": serialize(CookieNames.locale, finalLocale, {
+ path: "/",
+ expires: new Date(2099, 1, 1, 0, 0, 0, 0),
+ sameSite: "strict"
+ })
+ });
+ }
+ // replace html lang attribute with correct language
+ return resolve(event, { transformPageChunk: ({ html }) => html.replace('%lang%', finalLocale) });
+}
+
+function getPreferredLocale(event: RequestEvent) {
+ // detect the preferred language the user has configured in it's browser
+ // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language
+ const headers = transformHeaders(event)
+ const acceptLanguageDetector = initAcceptLanguageHeaderDetector({ headers })
+
+ return detectLocale(acceptLanguageDetector)
+}
+
+function transformHeaders({ request }: RequestEvent) {
+ const headers: Record<string, string> = {}
+ request.headers.forEach((value, key) => (headers[key] = value))
+ return headers
+}
+
+export const handle = sequence(handleLocale);
diff --git a/apps/kit/src/lib/api/internal-fetch.ts b/apps/kit/src/lib/api/internal-fetch.ts
new file mode 100644
index 0000000..b21d669
--- /dev/null
+++ b/apps/kit/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/apps/kit/src/lib/api/root.ts b/apps/kit/src/lib/api/root.ts
new file mode 100644
index 0000000..3e5bda2
--- /dev/null
+++ b/apps/kit/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/apps/kit/src/lib/api/time-entry.ts b/apps/kit/src/lib/api/time-entry.ts
new file mode 100644
index 0000000..a40b0c2
--- /dev/null
+++ b/apps/kit/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/apps/kit/src/lib/api/user.ts b/apps/kit/src/lib/api/user.ts
new file mode 100644
index 0000000..f0dc932
--- /dev/null
+++ b/apps/kit/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/apps/kit/src/lib/colors.ts b/apps/kit/src/lib/colors.ts
new file mode 100644
index 0000000..34c7992
--- /dev/null
+++ b/apps/kit/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/apps/kit/src/lib/components/alert.svelte b/apps/kit/src/lib/components/alert.svelte
new file mode 100644
index 0000000..4a5c7ea
--- /dev/null
+++ b/apps/kit/src/lib/components/alert.svelte
@@ -0,0 +1,149 @@
+<script lang="ts">
+ import { random_string } from "$shared/lib/helpers";
+ import { afterUpdate, onMount } from "svelte";
+ import { Temporal } from "temporal-polyfill";
+
+ const noCooldownSetting = "no-cooldown";
+ // 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);
+ export let title = "";
+ export let message = "";
+ export let type = "info";
+ export let closeable = false;
+ export let closeableCooldown = "-1";
+ export let visible = true;
+
+ const cooldownStorageKey = "lastseen--" + id;
+ $: cooldownEnabled =
+ id.indexOf(noCooldownSetting) === -1 &&
+ closeable &&
+ (closeableCooldown === "~" || parseInt(closeableCooldown) > 0);
+
+ 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)
+ );
+ }
+ }
+
+ // 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(
+ localStorage.getItem(cooldownStorageKey) as number
+ );
+ 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();
+ }
+ });
+
+ afterUpdate(() => {
+ if (type === "default") {
+ type = "primary";
+ }
+ });
+</script>
+
+<div
+ class="alert alert--{type} padding-sm radius-md"
+ {id}
+ class:alert--is-visible={visible}
+ role="alert"
+>
+ <div class="flex justify-between">
+ <div class="flex flex-row items-center">
+ <svg
+ class="icon icon--sm alert__icon margin-right-xxs"
+ viewBox="0 0 24 24"
+ aria-hidden="true"
+ >
+ <path
+ d="M12,0C5.383,0,0,5.383,0,12s5.383,12,12,12s12-5.383,12-12S18.617,0,12,0z M14.658,18.284 c-0.661,0.26-2.952,1.354-4.272,0.191c-0.394-0.346-0.59-0.785-0.59-1.318c0-0.998,0.328-1.868,0.919-3.957 c0.104-0.395,0.231-0.907,0.231-1.313c0-0.701-0.266-0.887-0.987-0.887c-0.352,0-0.742,0.125-1.095,0.257l0.195-0.799 c0.787-0.32,1.775-0.71,2.621-0.71c1.269,0,2.203,0.633,2.203,1.837c0,0.347-0.06,0.955-0.186,1.375l-0.73,2.582 c-0.151,0.522-0.424,1.673-0.001,2.014c0.416,0.337,1.401,0.158,1.887-0.071L14.658,18.284z M13.452,8c-0.828,0-1.5-0.672-1.5-1.5 s0.672-1.5,1.5-1.5s1.5,0.672,1.5,1.5S14.28,8,13.452,8z"
+ />
+ </svg>
+ {#if title}
+ <p class="text-sm">
+ <strong class="error-title">{title}</strong>
+ </p>
+ {:else if message}
+ <div class="text-component text-sm break-word">
+ {@html message}
+ </div>
+ {/if}
+ </div>
+ {#if closeable}
+ <button class="reset alert__close-btn" on:click={close}>
+ <svg
+ class="icon"
+ viewBox="0 0 20 20"
+ fill="none"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ >
+ <title>Close alert</title>
+ <line x1="3" y1="3" x2="17" y2="17" />
+ <line x1="17" y1="3" x2="3" y2="17" />
+ </svg>
+ </button>
+ {/if}
+ </div>
+
+ {#if message && title}
+ <div class="text-component text-sm break-word padding-top-xs">
+ {@html message}
+ </div>
+ {/if}
+</div>
diff --git a/apps/kit/src/lib/components/button.svelte b/apps/kit/src/lib/components/button.svelte
new file mode 100644
index 0000000..5550e5e
--- /dev/null
+++ b/apps/kit/src/lib/components/button.svelte
@@ -0,0 +1,72 @@
+<script lang="ts">
+ export type ButtonKind = "primary" | "secondary" | "white"
+ export type ButtonSize = "xs" | "sm" | "md" | "lg" | "xl";
+ export let kind = "primary" as ButtonKind;
+ export let size = "sm" as ButtonSize;
+ export let type: "button" | "submit" | "reset" = "button";
+ export let id = undefined;
+ export let tabindex = undefined;
+ export let style = undefined;
+ export let title = undefined;
+ export let disabled = false;
+ export let href = undefined;
+ export let text;
+
+ let sizeClasses = "px-3 py-2 text-xs";
+ let kindClasses = "border-transparent text-white bg-indigo-600 hover:bg-indigo-700 focus:ring-indigo-500";
+
+ $: shared_props = {
+ type: type,
+ id: id || null,
+ title: title || null,
+ disabled: disabled || null,
+ tabindex: tabindex || null,
+ style: style || null,
+ };
+
+ $: switch (size) {
+ case "xs":
+ sizeClasses = "px-2.5 py-1.5 text-xs";
+ break;
+ case "sm":
+ sizeClasses = "px-3 py-2 text-sm";
+ break;
+ case "md":
+ sizeClasses = "px-4 py-2 text-sm";
+ break;
+ case "lg":
+ sizeClasses = "px-4 py-2 text-base";
+ break;
+ case "xl":
+ sizeClasses = "px-6 py-3 text-base";
+ break;
+ }
+
+ $: switch (kind) {
+ case "secondary":
+ kindClasses = "border-transparent text-indigo-700 bg-indigo-100 hover:bg-indigo-200";
+ break;
+ case "primary":
+ kindClasses = "border-transparent text-white bg-indigo-600 hover:bg-indigo-700";
+ break;
+ case "white":
+ kindClasses = "border-gray-300 text-gray-700 bg-white hover:bg-gray-50";
+ break;
+ }
+</script>
+{#if href && !disabled}
+ <a {...shared_props}
+ {href}
+ on:click
+ {type}
+ class="{sizeClasses} {kindClasses} inline-flex items-center border font-medium rounded shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
+ {text}
+ </a>
+{:else}
+ <button {...shared_props}
+ on:click
+ {type}
+ class="{sizeClasses} {kindClasses} inline-flex items-center border font-medium rounded shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
+ {text}
+ </button>
+{/if} \ No newline at end of file
diff --git a/apps/kit/src/lib/components/icons/adjustments.svelte b/apps/kit/src/lib/components/icons/adjustments.svelte
new file mode 100644
index 0000000..b6d3f4d
--- /dev/null
+++ b/apps/kit/src/lib/components/icons/adjustments.svelte
@@ -0,0 +1,5 @@
+<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> \ No newline at end of file
diff --git a/apps/kit/src/lib/components/icons/database.svelte b/apps/kit/src/lib/components/icons/database.svelte
new file mode 100644
index 0000000..05c70ed
--- /dev/null
+++ b/apps/kit/src/lib/components/icons/database.svelte
@@ -0,0 +1,3 @@
+<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> \ No newline at end of file
diff --git a/apps/kit/src/lib/components/icons/home.svelte b/apps/kit/src/lib/components/icons/home.svelte
new file mode 100644
index 0000000..cc49c4d
--- /dev/null
+++ b/apps/kit/src/lib/components/icons/home.svelte
@@ -0,0 +1,5 @@
+<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> \ No newline at end of file
diff --git a/apps/kit/src/lib/components/icons/index.ts b/apps/kit/src/lib/components/icons/index.ts
new file mode 100644
index 0000000..d3abf24
--- /dev/null
+++ b/apps/kit/src/lib/components/icons/index.ts
@@ -0,0 +1,13 @@
+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";
+
+export {
+ XIcon,
+ MenuIcon,
+ HomeIcon,
+ DatabaseIcon,
+ AdjustmentsIcon
+} \ No newline at end of file
diff --git a/apps/kit/src/lib/components/icons/menu.svelte b/apps/kit/src/lib/components/icons/menu.svelte
new file mode 100644
index 0000000..12a68a5
--- /dev/null
+++ b/apps/kit/src/lib/components/icons/menu.svelte
@@ -0,0 +1,4 @@
+<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/apps/kit/src/lib/components/icons/x.svelte b/apps/kit/src/lib/components/icons/x.svelte
new file mode 100644
index 0000000..c7e05a8
--- /dev/null
+++ b/apps/kit/src/lib/components/icons/x.svelte
@@ -0,0 +1,4 @@
+<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> \ No newline at end of file
diff --git a/apps/kit/src/lib/components/locale-switcher.svelte b/apps/kit/src/lib/components/locale-switcher.svelte
new file mode 100644
index 0000000..39d6168
--- /dev/null
+++ b/apps/kit/src/lib/components/locale-switcher.svelte
@@ -0,0 +1,52 @@
+<script lang="ts">
+ import {browser} from "$app/environment";
+ import {page} from "$app/stores";
+ 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";
+
+ const switchLocale = async (
+ newLocale: Locales,
+ updateHistoryState = true,
+ ) => {
+ if (!newLocale || $locale === newLocale) return;
+
+ // load new dictionary from server
+ await loadLocaleAsync(newLocale);
+
+ // select locale
+ setLocale(newLocale);
+
+ // update `lang` attribute
+ document.querySelector("html")?.setAttribute("lang", newLocale);
+
+ //TODO set cookie that persists the locale
+ };
+
+ // update locale when navigating via browser back/forward buttons
+ const handlePopStateEvent = async ({state}: PopStateEvent) =>
+ switchLocale(state.locale, false);
+
+ // update locale when page store changes
+ $: if (browser) {
+ const lang = $page.params.lang as Locales;
+ switchLocale(lang, false);
+ }
+</script>
+
+<svelte:window on:popstate={handlePopStateEvent}/>
+
+<ul>
+ {#each locales as l}
+ <li>
+ <button
+ type="button"
+ class:active={l === $locale}
+ on:click={() => switchLocale(l)}
+ >
+ {l}
+ </button>
+ </li>
+ {/each}
+</ul>
diff --git a/apps/kit/src/lib/configuration.ts b/apps/kit/src/lib/configuration.ts
new file mode 100644
index 0000000..d6f6b4f
--- /dev/null
+++ b/apps/kit/src/lib/configuration.ts
@@ -0,0 +1,45 @@
+export const TOP_BASE_DOMAIN = "greatoffice.app";
+export const BASE_DOMAIN = "dev.greatoffice.app";
+export const DEV_BASE_DOMAIN = "http://127.0.0.1";
+export const API_ADDRESS = "https://api." + BASE_DOMAIN;
+export const DEV_API_ADDRESS = "http://127.0.0.1:5000";
+export const SECONDS_BETWEEN_SESSION_CHECK = 600;
+
+export function base_domain(path: string = ""): string {
+ return (is_development() ? DEV_BASE_DOMAIN : TOP_BASE_DOMAIN) + (path !== "" ? "/" + path : "");
+}
+
+export function api_base(path: string = ""): string {
+ return (is_development() ? DEV_API_ADDRESS : API_ADDRESS) + (path !== "" ? "/" + path : "");
+}
+
+export function is_development(): boolean {
+ // @ts-ignore
+ return import.meta.env.DEV;
+}
+
+export function is_debug(): boolean {
+ return localStorage.getItem(StorageKeys.debug) !== "true";
+}
+
+export const CookieNames = {
+ theme: "go_theme",
+ locale: "go_locale"
+};
+
+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/apps/kit/src/lib/helpers.ts b/apps/kit/src/lib/helpers.ts
new file mode 100644
index 0000000..f0f60cd
--- /dev/null
+++ b/apps/kit/src/lib/helpers.ts
@@ -0,0 +1,493 @@
+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 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 switch_theme() {
+// const html = document.querySelector("html");
+// if (html.dataset.theme === "dark") {
+// html.dataset.theme = "light";
+// } else {
+// html.dataset.theme = "dark";
+// }
+// set_cookie(CookieNames.theme, html.dataset.theme, base_domain());
+// }
+
+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.host) {
+ 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 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/apps/kit/src/lib/i18n/en/index.ts b/apps/kit/src/lib/i18n/en/index.ts
new file mode 100644
index 0000000..f3def1b
--- /dev/null
+++ b/apps/kit/src/lib/i18n/en/index.ts
@@ -0,0 +1,136 @@
+import type {BaseTranslation} from "../i18n-types";
+
+const en: BaseTranslation = {
+ nav: {
+ home: "Home",
+ data: "Data",
+ settings: "Settings",
+ usermenu: {
+ logout: "Log out",
+ logoutTitle: "Log out of your profile",
+ profile: "Profile",
+ profileTitle: "Administrate your profile",
+ toggleTitle: "Toggle user menu",
+ }
+ },
+ views: {
+ dataTablePaginator: {
+ goToPrevPage: "Go to previous page",
+ goToNextPage: "Go to next page",
+ of: "of",
+ },
+ categoryForm: {
+ name: "Name",
+ color: "Color",
+ defaultLabels: "Default labels",
+ labelsPlaceholder: "Search or create"
+ },
+ settingsCategoriesTile: {
+ deleteAllConfirm: "Are you sure you want to delete this category?\nThis will delete all relating entries!",
+ active: "Active",
+ archived: "Archived",
+ name: "Name",
+ color: "Color",
+ editEntry: "Edit entry",
+ deleteEntry: "Delete entry",
+ noCategories: "No categories",
+ categories: "Categories"
+ },
+ settingsLabelsTile: {
+ deleteAllConfirm: "Are you sure you want to delete this label?\nIt will be removed from all related entries!",
+ active: "Active",
+ archived: "Archived",
+ name: "Name",
+ color: "Color",
+ editEntry: "Edit label",
+ deleteEntry: "Delete label",
+ noLabels: "No labels",
+ labels: "Labels"
+ },
+ entryForm: {
+ entryUpdateError: "An error occured while updating the entry, try again soon.",
+ entryCreateError: "An error occured while creating the entry, try again soon.",
+ errDescriptionReq: "Description is required",
+ reset: "Reset",
+ description: "Description",
+ save: "Save",
+ create: "Create",
+ category: {
+ category: "Category",
+ placeholder: "Search or create",
+ noResults: "No categories available (Create a new one by searching for it)",
+ errisRequired: "Category is required",
+ _logReset: "Reset category section"
+ },
+ labels: {
+ placeholder: "Search or create",
+ noResults: "No labels available (Create a new one by searching for it)",
+ labels: "Labels",
+ _logReset: "Reset labels section"
+ },
+ dateTime: {
+ errDateIsRequired: "Date is required",
+ errFromIsRequired: "From is required",
+ errFromAfterTo: "From can not be after To",
+ errFromEqTo: "From and To can not be equal",
+ errToIsRequired: "To is required",
+ errToBeforeFrom: "To can not be before From",
+ from: "From",
+ to: "To",
+ date: "Date",
+ _logReset: "Reset date time section"
+ }
+ }
+ },
+ data: {
+ durationSummary: "Showing {entryCountString:string}, totalling in {totalHourMin:string}",
+ hourSingleChar: "h",
+ minSingleChar: "m",
+ entry: "entry",
+ entries: "entries",
+ confirmDeleteEntry: "Are you sure you want to delete this entry?",
+ editEntry: "Edit entry",
+ date: "Date",
+ from: "From",
+ duration: "Duration",
+ category: "Category",
+ description: "Description",
+ loading: "Loading",
+ noEntries: "No entries",
+ to: "to",
+ use: "Use",
+ },
+ home: {
+ confirmDeleteEntry: "Are you sure you want to delete this entry?",
+ newEntry: "New entry",
+ editEntry: "Edit entry",
+ deleteEntry: "Delete entry",
+ loggedTimeToday: "Logged time today",
+ loggedTimeTodayString: "{hours}h{minutes}m",
+ currentTime: "Current time",
+ loading: "Loading",
+ stopwatch: "Stopwatch",
+ todayEntries: "Today's entries",
+ noEntriesToday: "No entries today",
+ refreshTodayEntries: "Refresh today's entries",
+ category: "Category",
+ timespan: "Timespan",
+ },
+ messages: {
+ pageNotFound: "Page not found",
+ goToFrontpage: "Go to frontpage",
+ noInternet: "It seems like your device does not have a internet connection, please check your connection."
+ },
+ login: {
+ loginToYourAccount: "Log in to your account",
+ or: "Or",
+ createANewAccount: "create a new account",
+ emailAddress: "Email address",
+ password: "Password",
+ notMyComputer: "This is not my computer",
+ forgotPassword: "Forgot your password?",
+ logIn: "Log in"
+ },
+};
+
+export default en;
diff --git a/apps/kit/src/lib/i18n/formatters.ts b/apps/kit/src/lib/i18n/formatters.ts
new file mode 100644
index 0000000..78734f9
--- /dev/null
+++ b/apps/kit/src/lib/i18n/formatters.ts
@@ -0,0 +1,11 @@
+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
+ }
+
+ return formatters
+}
diff --git a/apps/kit/src/lib/i18n/i18n-svelte.ts b/apps/kit/src/lib/i18n/i18n-svelte.ts
new file mode 100644
index 0000000..6cdffb3
--- /dev/null
+++ b/apps/kit/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/apps/kit/src/lib/i18n/i18n-types.ts b/apps/kit/src/lib/i18n/i18n-types.ts
new file mode 100644
index 0000000..f3e0f80
--- /dev/null
+++ b/apps/kit/src/lib/i18n/i18n-types.ts
@@ -0,0 +1,890 @@
+// 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
+export type BaseLocale = 'en'
+
+export type Locales =
+ | 'en'
+ | 'nb'
+
+export type Translation = RootTranslation
+
+export type Translations = RootTranslation
+
+type RootTranslation = {
+ nav: {
+ /**
+ * Home
+ */
+ home: string
+ /**
+ * Data
+ */
+ data: string
+ /**
+ * Settings
+ */
+ settings: string
+ usermenu: {
+ /**
+ * Log out
+ */
+ logout: string
+ /**
+ * Log out of your profile
+ */
+ logoutTitle: string
+ /**
+ * Profile
+ */
+ profile: string
+ /**
+ * Administrate your profile
+ */
+ profileTitle: string
+ /**
+ * Toggle user menu
+ */
+ toggleTitle: string
+ }
+ }
+ views: {
+ dataTablePaginator: {
+ /**
+ * Go to previous page
+ */
+ goToPrevPage: string
+ /**
+ * Go to next page
+ */
+ goToNextPage: string
+ /**
+ * of
+ */
+ of: string
+ }
+ categoryForm: {
+ /**
+ * Name
+ */
+ name: string
+ /**
+ * Color
+ */
+ color: string
+ /**
+ * Default labels
+ */
+ defaultLabels: string
+ /**
+ * Search or create
+ */
+ labelsPlaceholder: string
+ }
+ settingsCategoriesTile: {
+ /**
+ * Are you sure you want to delete this category?
+ This will delete all relating entries!
+ */
+ deleteAllConfirm: string
+ /**
+ * Active
+ */
+ active: string
+ /**
+ * Archived
+ */
+ archived: string
+ /**
+ * Name
+ */
+ name: string
+ /**
+ * Color
+ */
+ color: string
+ /**
+ * Edit entry
+ */
+ editEntry: string
+ /**
+ * Delete entry
+ */
+ deleteEntry: string
+ /**
+ * No categories
+ */
+ noCategories: string
+ /**
+ * Categories
+ */
+ categories: string
+ }
+ settingsLabelsTile: {
+ /**
+ * Are you sure you want to delete this label?
+ It will be removed from all related entries!
+ */
+ deleteAllConfirm: string
+ /**
+ * Active
+ */
+ active: string
+ /**
+ * Archived
+ */
+ archived: string
+ /**
+ * Name
+ */
+ name: string
+ /**
+ * Color
+ */
+ color: string
+ /**
+ * Edit label
+ */
+ editEntry: string
+ /**
+ * Delete label
+ */
+ deleteEntry: string
+ /**
+ * No labels
+ */
+ noLabels: string
+ /**
+ * Labels
+ */
+ labels: string
+ }
+ entryForm: {
+ /**
+ * An error occured while updating the entry, try again soon.
+ */
+ entryUpdateError: string
+ /**
+ * An error occured while creating the entry, try again soon.
+ */
+ entryCreateError: string
+ /**
+ * Description is required
+ */
+ errDescriptionReq: string
+ /**
+ * Reset
+ */
+ reset: string
+ /**
+ * Description
+ */
+ description: string
+ /**
+ * Save
+ */
+ save: string
+ /**
+ * Create
+ */
+ create: string
+ category: {
+ /**
+ * Category
+ */
+ category: string
+ /**
+ * Search or create
+ */
+ placeholder: string
+ /**
+ * No categories available (Create a new one by searching for it)
+ */
+ noResults: string
+ /**
+ * Category is required
+ */
+ errisRequired: string
+ /**
+ * Reset category section
+ */
+ _logReset: string
+ }
+ labels: {
+ /**
+ * Search or create
+ */
+ placeholder: string
+ /**
+ * No labels available (Create a new one by searching for it)
+ */
+ noResults: string
+ /**
+ * Labels
+ */
+ labels: string
+ /**
+ * Reset labels section
+ */
+ _logReset: string
+ }
+ dateTime: {
+ /**
+ * Date is required
+ */
+ errDateIsRequired: string
+ /**
+ * From is required
+ */
+ errFromIsRequired: string
+ /**
+ * From can not be after To
+ */
+ errFromAfterTo: string
+ /**
+ * From and To can not be equal
+ */
+ errFromEqTo: string
+ /**
+ * To is required
+ */
+ errToIsRequired: string
+ /**
+ * To can not be before From
+ */
+ errToBeforeFrom: string
+ /**
+ * From
+ */
+ from: string
+ /**
+ * To
+ */
+ to: string
+ /**
+ * Date
+ */
+ date: string
+ /**
+ * Reset date time section
+ */
+ _logReset: string
+ }
+ }
+ }
+ data: {
+ /**
+ * Showing {entryCountString}, totalling in {totalHourMin}
+ * @param {string} entryCountString
+ * @param {string} totalHourMin
+ */
+ durationSummary: RequiredParams<'entryCountString' | 'totalHourMin'>
+ /**
+ * h
+ */
+ hourSingleChar: string
+ /**
+ * m
+ */
+ minSingleChar: string
+ /**
+ * entry
+ */
+ entry: string
+ /**
+ * entries
+ */
+ entries: string
+ /**
+ * Are you sure you want to delete this entry?
+ */
+ confirmDeleteEntry: string
+ /**
+ * Edit entry
+ */
+ editEntry: string
+ /**
+ * Date
+ */
+ date: string
+ /**
+ * From
+ */
+ from: string
+ /**
+ * Duration
+ */
+ duration: string
+ /**
+ * Category
+ */
+ category: string
+ /**
+ * Description
+ */
+ description: string
+ /**
+ * Loading
+ */
+ loading: string
+ /**
+ * No entries
+ */
+ noEntries: string
+ /**
+ * to
+ */
+ to: string
+ /**
+ * Use
+ */
+ use: string
+ }
+ home: {
+ /**
+ * Are you sure you want to delete this entry?
+ */
+ confirmDeleteEntry: string
+ /**
+ * New entry
+ */
+ newEntry: string
+ /**
+ * Edit entry
+ */
+ editEntry: string
+ /**
+ * Delete entry
+ */
+ deleteEntry: string
+ /**
+ * Logged time today
+ */
+ loggedTimeToday: string
+ /**
+ * {hours}h{minutes}m
+ * @param {unknown} hours
+ * @param {unknown} minutes
+ */
+ loggedTimeTodayString: RequiredParams<'hours' | 'minutes'>
+ /**
+ * Current time
+ */
+ currentTime: string
+ /**
+ * Loading
+ */
+ loading: string
+ /**
+ * Stopwatch
+ */
+ stopwatch: string
+ /**
+ * Today's entries
+ */
+ todayEntries: string
+ /**
+ * No entries today
+ */
+ noEntriesToday: string
+ /**
+ * Refresh today's entries
+ */
+ refreshTodayEntries: string
+ /**
+ * Category
+ */
+ category: string
+ /**
+ * Timespan
+ */
+ timespan: string
+ }
+ messages: {
+ /**
+ * Page not found
+ */
+ pageNotFound: string
+ /**
+ * Go to frontpage
+ */
+ goToFrontpage: string
+ /**
+ * It seems like your device does not have a internet connection, please check your connection.
+ */
+ noInternet: string
+ }
+ login: {
+ /**
+ * Log in to your account
+ */
+ loginToYourAccount: string
+ /**
+ * Or
+ */
+ or: string
+ /**
+ * create a new account
+ */
+ createANewAccount: string
+ /**
+ * Email address
+ */
+ emailAddress: string
+ /**
+ * Password
+ */
+ password: string
+ /**
+ * This is not my computer
+ */
+ notMyComputer: string
+ /**
+ * Forgot your password?
+ */
+ forgotPassword: string
+ /**
+ * Log in
+ */
+ logIn: string
+ }
+}
+
+export type TranslationFunctions = {
+ nav: {
+ /**
+ * Home
+ */
+ home: () => LocalizedString
+ /**
+ * Data
+ */
+ data: () => LocalizedString
+ /**
+ * Settings
+ */
+ settings: () => LocalizedString
+ usermenu: {
+ /**
+ * Log out
+ */
+ logout: () => LocalizedString
+ /**
+ * Log out of your profile
+ */
+ logoutTitle: () => LocalizedString
+ /**
+ * Profile
+ */
+ profile: () => LocalizedString
+ /**
+ * Administrate your profile
+ */
+ profileTitle: () => LocalizedString
+ /**
+ * Toggle user menu
+ */
+ toggleTitle: () => LocalizedString
+ }
+ }
+ views: {
+ dataTablePaginator: {
+ /**
+ * Go to previous page
+ */
+ goToPrevPage: () => LocalizedString
+ /**
+ * Go to next page
+ */
+ goToNextPage: () => LocalizedString
+ /**
+ * of
+ */
+ of: () => LocalizedString
+ }
+ categoryForm: {
+ /**
+ * Name
+ */
+ name: () => LocalizedString
+ /**
+ * Color
+ */
+ color: () => LocalizedString
+ /**
+ * Default labels
+ */
+ defaultLabels: () => LocalizedString
+ /**
+ * Search or create
+ */
+ labelsPlaceholder: () => LocalizedString
+ }
+ settingsCategoriesTile: {
+ /**
+ * Are you sure you want to delete this category?
+ This will delete all relating entries!
+ */
+ deleteAllConfirm: () => LocalizedString
+ /**
+ * Active
+ */
+ active: () => LocalizedString
+ /**
+ * Archived
+ */
+ archived: () => LocalizedString
+ /**
+ * Name
+ */
+ name: () => LocalizedString
+ /**
+ * Color
+ */
+ color: () => LocalizedString
+ /**
+ * Edit entry
+ */
+ editEntry: () => LocalizedString
+ /**
+ * Delete entry
+ */
+ deleteEntry: () => LocalizedString
+ /**
+ * No categories
+ */
+ noCategories: () => LocalizedString
+ /**
+ * Categories
+ */
+ categories: () => LocalizedString
+ }
+ settingsLabelsTile: {
+ /**
+ * Are you sure you want to delete this label?
+ It will be removed from all related entries!
+ */
+ deleteAllConfirm: () => LocalizedString
+ /**
+ * Active
+ */
+ active: () => LocalizedString
+ /**
+ * Archived
+ */
+ archived: () => LocalizedString
+ /**
+ * Name
+ */
+ name: () => LocalizedString
+ /**
+ * Color
+ */
+ color: () => LocalizedString
+ /**
+ * Edit label
+ */
+ editEntry: () => LocalizedString
+ /**
+ * Delete label
+ */
+ deleteEntry: () => LocalizedString
+ /**
+ * No labels
+ */
+ noLabels: () => LocalizedString
+ /**
+ * Labels
+ */
+ labels: () => LocalizedString
+ }
+ entryForm: {
+ /**
+ * An error occured while updating the entry, try again soon.
+ */
+ entryUpdateError: () => LocalizedString
+ /**
+ * An error occured while creating the entry, try again soon.
+ */
+ entryCreateError: () => LocalizedString
+ /**
+ * Description is required
+ */
+ errDescriptionReq: () => LocalizedString
+ /**
+ * Reset
+ */
+ reset: () => LocalizedString
+ /**
+ * Description
+ */
+ description: () => LocalizedString
+ /**
+ * Save
+ */
+ save: () => LocalizedString
+ /**
+ * Create
+ */
+ create: () => LocalizedString
+ category: {
+ /**
+ * Category
+ */
+ category: () => LocalizedString
+ /**
+ * Search or create
+ */
+ placeholder: () => LocalizedString
+ /**
+ * No categories available (Create a new one by searching for it)
+ */
+ noResults: () => LocalizedString
+ /**
+ * Category is required
+ */
+ errisRequired: () => LocalizedString
+ /**
+ * Reset category section
+ */
+ _logReset: () => LocalizedString
+ }
+ labels: {
+ /**
+ * Search or create
+ */
+ placeholder: () => LocalizedString
+ /**
+ * No labels available (Create a new one by searching for it)
+ */
+ noResults: () => LocalizedString
+ /**
+ * Labels
+ */
+ labels: () => LocalizedString
+ /**
+ * Reset labels section
+ */
+ _logReset: () => LocalizedString
+ }
+ dateTime: {
+ /**
+ * Date is required
+ */
+ errDateIsRequired: () => LocalizedString
+ /**
+ * From is required
+ */
+ errFromIsRequired: () => LocalizedString
+ /**
+ * From can not be after To
+ */
+ errFromAfterTo: () => LocalizedString
+ /**
+ * From and To can not be equal
+ */
+ errFromEqTo: () => LocalizedString
+ /**
+ * To is required
+ */
+ errToIsRequired: () => LocalizedString
+ /**
+ * To can not be before From
+ */
+ errToBeforeFrom: () => LocalizedString
+ /**
+ * From
+ */
+ from: () => LocalizedString
+ /**
+ * To
+ */
+ to: () => LocalizedString
+ /**
+ * Date
+ */
+ date: () => LocalizedString
+ /**
+ * Reset date time section
+ */
+ _logReset: () => LocalizedString
+ }
+ }
+ }
+ data: {
+ /**
+ * Showing {entryCountString}, totalling in {totalHourMin}
+ */
+ durationSummary: (arg: { entryCountString: string, totalHourMin: string }) => LocalizedString
+ /**
+ * h
+ */
+ hourSingleChar: () => LocalizedString
+ /**
+ * m
+ */
+ minSingleChar: () => LocalizedString
+ /**
+ * entry
+ */
+ entry: () => LocalizedString
+ /**
+ * entries
+ */
+ entries: () => LocalizedString
+ /**
+ * Are you sure you want to delete this entry?
+ */
+ confirmDeleteEntry: () => LocalizedString
+ /**
+ * Edit entry
+ */
+ editEntry: () => LocalizedString
+ /**
+ * Date
+ */
+ date: () => LocalizedString
+ /**
+ * From
+ */
+ from: () => LocalizedString
+ /**
+ * Duration
+ */
+ duration: () => LocalizedString
+ /**
+ * Category
+ */
+ category: () => LocalizedString
+ /**
+ * Description
+ */
+ description: () => LocalizedString
+ /**
+ * Loading
+ */
+ loading: () => LocalizedString
+ /**
+ * No entries
+ */
+ noEntries: () => LocalizedString
+ /**
+ * to
+ */
+ to: () => LocalizedString
+ /**
+ * Use
+ */
+ use: () => LocalizedString
+ }
+ home: {
+ /**
+ * Are you sure you want to delete this entry?
+ */
+ confirmDeleteEntry: () => LocalizedString
+ /**
+ * New entry
+ */
+ newEntry: () => LocalizedString
+ /**
+ * Edit entry
+ */
+ editEntry: () => LocalizedString
+ /**
+ * Delete entry
+ */
+ deleteEntry: () => LocalizedString
+ /**
+ * Logged time today
+ */
+ loggedTimeToday: () => LocalizedString
+ /**
+ * {hours}h{minutes}m
+ */
+ loggedTimeTodayString: (arg: { hours: unknown, minutes: unknown }) => LocalizedString
+ /**
+ * Current time
+ */
+ currentTime: () => LocalizedString
+ /**
+ * Loading
+ */
+ loading: () => LocalizedString
+ /**
+ * Stopwatch
+ */
+ stopwatch: () => LocalizedString
+ /**
+ * Today's entries
+ */
+ todayEntries: () => LocalizedString
+ /**
+ * No entries today
+ */
+ noEntriesToday: () => LocalizedString
+ /**
+ * Refresh today's entries
+ */
+ refreshTodayEntries: () => LocalizedString
+ /**
+ * Category
+ */
+ category: () => LocalizedString
+ /**
+ * Timespan
+ */
+ timespan: () => LocalizedString
+ }
+ messages: {
+ /**
+ * Page not found
+ */
+ pageNotFound: () => LocalizedString
+ /**
+ * Go to frontpage
+ */
+ goToFrontpage: () => LocalizedString
+ /**
+ * It seems like your device does not have a internet connection, please check your connection.
+ */
+ noInternet: () => LocalizedString
+ }
+ login: {
+ /**
+ * Log in to your account
+ */
+ loginToYourAccount: () => LocalizedString
+ /**
+ * Or
+ */
+ or: () => LocalizedString
+ /**
+ * create a new account
+ */
+ createANewAccount: () => LocalizedString
+ /**
+ * Email address
+ */
+ emailAddress: () => LocalizedString
+ /**
+ * Password
+ */
+ password: () => LocalizedString
+ /**
+ * This is not my computer
+ */
+ notMyComputer: () => LocalizedString
+ /**
+ * Forgot your password?
+ */
+ forgotPassword: () => LocalizedString
+ /**
+ * Log in
+ */
+ logIn: () => LocalizedString
+ }
+}
+
+export type Formatters = {}
diff --git a/apps/kit/src/lib/i18n/i18n-util.async.ts b/apps/kit/src/lib/i18n/i18n-util.async.ts
new file mode 100644
index 0000000..3ccef5f
--- /dev/null
+++ b/apps/kit/src/lib/i18n/i18n-util.async.ts
@@ -0,0 +1,27 @@
+// 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'
+
+const localeTranslationLoaders = {
+ en: () => import('./en'),
+ nb: () => import('./nb'),
+}
+
+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))
diff --git a/apps/kit/src/lib/i18n/i18n-util.sync.ts b/apps/kit/src/lib/i18n/i18n-util.sync.ts
new file mode 100644
index 0000000..f1a8e9e
--- /dev/null
+++ b/apps/kit/src/lib/i18n/i18n-util.sync.ts
@@ -0,0 +1,26 @@
+// 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'
+
+const localeTranslations = {
+ en,
+ nb,
+}
+
+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/apps/kit/src/lib/i18n/i18n-util.ts b/apps/kit/src/lib/i18n/i18n-util.ts
new file mode 100644
index 0000000..11d4b23
--- /dev/null
+++ b/apps/kit/src/lib/i18n/i18n-util.ts
@@ -0,0 +1,33 @@
+// 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, Translations, TranslationFunctions } from './i18n-types'
+
+export const baseLocale: Locales = 'en'
+
+export const locales: Locales[] = [
+ 'en',
+ 'nb'
+]
+
+export const isLocale = (locale: string) => locales.includes(locale as Locales)
+
+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/apps/kit/src/lib/i18n/nb/index.ts b/apps/kit/src/lib/i18n/nb/index.ts
new file mode 100644
index 0000000..b350994
--- /dev/null
+++ b/apps/kit/src/lib/i18n/nb/index.ts
@@ -0,0 +1,136 @@
+import type {Translation} from "../i18n-types";
+
+const nb: Translation = {
+ nav: {
+ home: "Hjem",
+ data: "Data",
+ settings: "Innstillinger",
+ usermenu: {
+ logout: "Logg ut",
+ logoutTitle: "Logg ut av din profil",
+ profile: "Profil",
+ profileTitle: "Administrer din profil",
+ toggleTitle: "Vis brukermeny"
+ }
+ },
+ views: {
+ categoryForm: {
+ name: "Navn",
+ color: "Farge",
+ defaultLabels: "Standard merknader",
+ labelsPlaceholder: "Søk eller opprett"
+ },
+ dataTablePaginator: {
+ goToPrevPage: "GÃ¥ til forrige side",
+ goToNextPage: "GÃ¥ til neste side",
+ of: "av",
+ },
+ settingsCategoriesTile: {
+ deleteAllConfirm: "Er du sikker på at du vil slette denne kategorien?\nDette vil slette alle tilhørende rader!",
+ active: "Aktive",
+ archived: "Arkiverte",
+ name: "Navn",
+ color: "Farge",
+ editEntry: "Rediger kategori",
+ deleteEntry: "Slett kategori",
+ noCategories: "Ingen kategorier",
+ categories: "Kategorier"
+ },
+ settingsLabelsTile: {
+ deleteAllConfirm: "Er du sikker på at du vil slette denne merknaden?\nDen vil bli slette fra alle relaterte rader!",
+ active: "Aktive",
+ archived: "Arkiverte",
+ name: "Navn",
+ color: "Farge",
+ editEntry: "Rediger merknad",
+ deleteEntry: "Slett merknad",
+ noLabels: "Ingen merknader",
+ labels: "Merknader"
+ },
+ entryForm: {
+ entryUpdateError: "En feil oppstod med lagringen av din rad, prøv igjen snart.",
+ entryCreateError: "En feil oppstod med opprettelsen av din rad, prøv igjen snart.",
+ errDescriptionReq: "Beskrivelse er påkrevd",
+ reset: "Tilbakestill",
+ description: "Beskrivelse",
+ save: "Lagre",
+ create: "Opprett",
+ category: {
+ category: "Kategori",
+ placeholder: "Søk eller opprett",
+ noResults: "Ingen kategorier tilgjengelig (Opprett en ny ved å skrive navnet i søkefeltet).",
+ errisRequired: "Kategori er påkrevd",
+ _logReset: "Tilbakestilte kategori-seksjonen"
+ },
+ labels: {
+ placeholder: "Søk eller opprett",
+ noResults: "Ingen merkander tilgjengelig (Opprett en ny ved å skrive navnet i søkefeltet).",
+ labels: "Merknader",
+ _logReset: "Tilbakestilte merknader-seksjonen"
+ },
+ dateTime: {
+ errDateIsRequired: "Dato er påkrevd",
+ errFromIsRequired: "Fra er påkrevd",
+ errFromAfterTo: "Fra kan ikke være etter Til",
+ errFromEqTo: "Fra og Til kan ikke ha lik verdi",
+ errToIsRequired: "Til er påkrevd",
+ errToBeforeFrom: "Til kan ikke være før Fra",
+ from: "Fra",
+ to: "Til",
+ date: "Dato",
+ _logReset: "Tilbakestilte dato-seksjonen"
+ }
+ }
+ },
+ data: {
+ durationSummary: "Viser {entryCountString:string}, Tilsammen {totalHourMin:string}",
+ hourSingleChar: "t",
+ minSingleChar: "m",
+ entry: "rad",
+ entries: "rader",
+ confirmDeleteEntry: "Er du sikker på at du vil slette denne raden?",
+ editEntry: "Rediger rad",
+ date: "Dato",
+ from: "Fra",
+ duration: "Tidsrom",
+ category: "Kategori",
+ description: "Beskrivelse",
+ loading: "Laster",
+ noEntries: "Ingen rader",
+ to: "til",
+ use: "Bruk",
+ },
+ home: {
+ loggedTimeTodayString: "{hours}t{minutes}m",
+ confirmDeleteEntry: "Er du sikker på at du vil slette denne raden?",
+ newEntry: "Ny tidsoppføring",
+ editEntry: "Rediger rad",
+ deleteEntry: "Slett rad",
+ loggedTimeToday: "Registrert tid hittil idag",
+ currentTime: "Klokken",
+ loading: "Laster",
+ stopwatch: "Stoppeklokke",
+ todayEntries: "Dagens tidsoppføringer",
+ noEntriesToday: "Ingen oppføringer i dag",
+ refreshTodayEntries: "Last inn dagens tidsoppføringer på nytt",
+ category: "Kategori",
+ timespan: "Tidsrom",
+ },
+ messages: {
+ pageNotFound: "Fant ikke siden",
+ goToFrontpage: "GÃ¥ til forsiden",
+ noInternet: "Det ser ut som at du er uten internettilgang, vennligst sjekk tilkoblingen din."
+ },
+ login: {
+ loginToYourAccount: "Logg inn i din konto",
+ or: "Eller",
+ createANewAccount: "lag en ny konto",
+ emailAddress: "E-postadresse",
+ password: "Passord",
+ notMyComputer: "Dette er ikke min datamaskin",
+ forgotPassword: "Glem passord?",
+ logIn: "Logg inn"
+ },
+};
+
+export default nb;
diff --git a/apps/kit/src/lib/locale.ts b/apps/kit/src/lib/locale.ts
new file mode 100644
index 0000000..002f874
--- /dev/null
+++ b/apps/kit/src/lib/locale.ts
@@ -0,0 +1,20 @@
+import {writable} from "svelte/store";
+import {base_domain, CookieNames} from "./configuration";
+import {get_cookie, set_cookie} from "./helpers";
+
+export function preffered_or_default() {
+ if (/^en\b/i.test(navigator.language)) {
+ return "en";
+ }
+ if (/^nb\b/i.test(navigator.language) || /^nn\b/i.test(navigator.language)) {
+ return "nb";
+ }
+ return "en";
+}
+
+type Locales = "en"|"nb";
+export const currentLocale = writable<Locales>((get_cookie(CookieNames.locale) === "preffered" ? preffered_or_default() : get_cookie(CookieNames.locale) ?? preffered_or_default()) as Locales);
+currentLocale.subscribe(locale => {
+ // @ts-ignore
+ set_cookie(CookieNames.locale, locale, base_domain());
+});
diff --git a/apps/kit/src/lib/logger.ts b/apps/kit/src/lib/logger.ts
new file mode 100644
index 0000000..e017ba0
--- /dev/null
+++ b/apps/kit/src/lib/logger.ts
@@ -0,0 +1,87 @@
+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(process.env.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/apps/kit/src/lib/models/CreateAccountPayload.ts b/apps/kit/src/lib/models/CreateAccountPayload.ts
new file mode 100644
index 0000000..d116308
--- /dev/null
+++ b/apps/kit/src/lib/models/CreateAccountPayload.ts
@@ -0,0 +1,4 @@
+export interface CreateAccountPayload {
+ username: string,
+ password: string
+}
diff --git a/apps/kit/src/lib/models/ErrorResult.ts b/apps/kit/src/lib/models/ErrorResult.ts
new file mode 100644
index 0000000..7c70017
--- /dev/null
+++ b/apps/kit/src/lib/models/ErrorResult.ts
@@ -0,0 +1,4 @@
+export interface ErrorResult {
+ title: string,
+ text: string
+}
diff --git a/apps/kit/src/lib/models/IInternalFetchRequest.ts b/apps/kit/src/lib/models/IInternalFetchRequest.ts
new file mode 100644
index 0000000..68505e2
--- /dev/null
+++ b/apps/kit/src/lib/models/IInternalFetchRequest.ts
@@ -0,0 +1,6 @@
+export interface IInternalFetchRequest {
+ url: string,
+ init?: RequestInit,
+ timeout?: number
+ retry_count?: number
+}
diff --git a/apps/kit/src/lib/models/IInternalFetchResponse.ts b/apps/kit/src/lib/models/IInternalFetchResponse.ts
new file mode 100644
index 0000000..6c91b35
--- /dev/null
+++ b/apps/kit/src/lib/models/IInternalFetchResponse.ts
@@ -0,0 +1,6 @@
+export interface IInternalFetchResponse {
+ ok: boolean,
+ status: number,
+ data: any,
+ http_response: Response
+}
diff --git a/apps/kit/src/lib/models/ISession.ts b/apps/kit/src/lib/models/ISession.ts
new file mode 100644
index 0000000..f7ed46b
--- /dev/null
+++ b/apps/kit/src/lib/models/ISession.ts
@@ -0,0 +1,7 @@
+export interface ISession {
+ profile: {
+ username: string,
+ id: string,
+ },
+ lastChecked: number,
+} \ No newline at end of file
diff --git a/apps/kit/src/lib/models/IValidationResult.ts b/apps/kit/src/lib/models/IValidationResult.ts
new file mode 100644
index 0000000..9a21b13
--- /dev/null
+++ b/apps/kit/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/apps/kit/src/lib/models/LoginPayload.ts b/apps/kit/src/lib/models/LoginPayload.ts
new file mode 100644
index 0000000..ccd9bed
--- /dev/null
+++ b/apps/kit/src/lib/models/LoginPayload.ts
@@ -0,0 +1,4 @@
+export interface LoginPayload {
+ username: string,
+ password: string
+}
diff --git a/apps/kit/src/lib/models/TimeCategoryDto.ts b/apps/kit/src/lib/models/TimeCategoryDto.ts
new file mode 100644
index 0000000..fcdb7ea
--- /dev/null
+++ b/apps/kit/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/apps/kit/src/lib/models/TimeEntryDto.ts b/apps/kit/src/lib/models/TimeEntryDto.ts
new file mode 100644
index 0000000..571c52e
--- /dev/null
+++ b/apps/kit/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/apps/kit/src/lib/models/TimeEntryQuery.ts b/apps/kit/src/lib/models/TimeEntryQuery.ts
new file mode 100644
index 0000000..d983d1a
--- /dev/null
+++ b/apps/kit/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/apps/kit/src/lib/models/TimeLabelDto.ts b/apps/kit/src/lib/models/TimeLabelDto.ts
new file mode 100644
index 0000000..7183bcf
--- /dev/null
+++ b/apps/kit/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/apps/kit/src/lib/models/TimeQueryDto.ts b/apps/kit/src/lib/models/TimeQueryDto.ts
new file mode 100644
index 0000000..607c51e
--- /dev/null
+++ b/apps/kit/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/apps/kit/src/lib/models/UnwrappedEntryDateTime.ts b/apps/kit/src/lib/models/UnwrappedEntryDateTime.ts
new file mode 100644
index 0000000..d614f91
--- /dev/null
+++ b/apps/kit/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/apps/kit/src/lib/models/UpdateProfilePayload.ts b/apps/kit/src/lib/models/UpdateProfilePayload.ts
new file mode 100644
index 0000000..d2983ff
--- /dev/null
+++ b/apps/kit/src/lib/models/UpdateProfilePayload.ts
@@ -0,0 +1,4 @@
+export interface UpdateProfilePayload {
+ username?: string,
+ password?: string,
+}
diff --git a/apps/kit/src/lib/persistent-store.ts b/apps/kit/src/lib/persistent-store.ts
new file mode 100644
index 0000000..922f3ab
--- /dev/null
+++ b/apps/kit/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/apps/kit/src/lib/session.ts b/apps/kit/src/lib/session.ts
new file mode 100644
index 0000000..ee79933
--- /dev/null
+++ b/apps/kit/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/apps/kit/src/params/guid.ts b/apps/kit/src/params/guid.ts
new file mode 100644
index 0000000..d8f7231
--- /dev/null
+++ b/apps/kit/src/params/guid.ts
@@ -0,0 +1,5 @@
+import {is_guid} from "$lib/helpers";
+
+export function match(param: string): boolean {
+ return is_guid(param);
+} \ No newline at end of file
diff --git a/apps/kit/src/params/integer.ts b/apps/kit/src/params/integer.ts
new file mode 100644
index 0000000..6e36cd8
--- /dev/null
+++ b/apps/kit/src/params/integer.ts
@@ -0,0 +1,3 @@
+export function match(param: string): boolean {
+ return /^\d+$/.test(param);
+} \ No newline at end of file
diff --git a/apps/kit/src/routes/(app)/+layout.svelte b/apps/kit/src/routes/(app)/+layout.svelte
new file mode 100644
index 0000000..3f60af3
--- /dev/null
+++ b/apps/kit/src/routes/(app)/+layout.svelte
@@ -0,0 +1,215 @@
+<svelte:options immutable={true}/>
+<svelte:window bind:online={online}/>
+<script lang="ts">
+ import LL, {setLocale} from "$lib/i18n/i18n-svelte";
+ import {Dialog, TransitionChild, TransitionRoot} from '@rgossiaux/svelte-headlessui';
+ import {XIcon, MenuIcon, HomeIcon, DatabaseIcon, AdjustmentsIcon} from "$lib/components/icons";
+
+ let online = true;
+ let sidebarIsOpen = false;
+ const username = "dumb";
+
+ setLocale("nb");
+
+ const navigations = [
+ {
+ name: "Home",
+ icon: HomeIcon
+ },
+ {
+ name: "Data",
+ icon: DatabaseIcon
+ },
+ {
+ name: "Settings",
+ icon: AdjustmentsIcon
+ }
+ ]
+</script>
+{#if !online}
+ <div class="bg-yellow-50 border-l-4 border-yellow-400 p-4">
+ <div class="flex">
+ <div class="flex-shrink-0">
+ <svg class="h-5 w-5 text-yellow-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
+ fill="currentColor" aria-hidden="true">
+ <path fill-rule="evenodd"
+ d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
+ clip-rule="evenodd"/>
+ </svg>
+ </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}
+<div class="h-full flex">
+ <TransitionRoot show={sidebarIsOpen}>
+ <Dialog class="relative z-40 lg:hidden" on:close={() => sidebarIsOpen = !sidebarIsOpen}>
+ <TransitionChild
+ 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"></div>
+ </TransitionChild>
+
+ <div class="fixed inset-0 flex z-40">
+ <TransitionChild
+ 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-1 flex flex-col max-w-xs w-full bg-white focus:outline-none">
+ <TransitionChild
+ 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 items-center justify-center h-10 w-10 rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
+ on:click={() => sidebarIsOpen = false}>
+ <span class="sr-only">Close sidebar</span>
+ <XIcon class="text-white" aria-hidden="true"/>
+ </button>
+ </div>
+ </TransitionChild>
+ <div class="flex-1 h-0 pt-5 pb-4 overflow-y-auto">
+ <div class="flex-shrink-0 flex items-center px-4">
+ <img class="h-8 w-auto"
+ src="https://tailwindui.com/img/logos/workflow-mark.svg?color=indigo&shade=600"
+ alt="Workflow"
+ />
+ </div>
+ <nav aria-label="Sidebar" class="mt-5">
+ <div class="px-2 space-y-1">
+ {#each navigations as item (item.name)}
+ <a href={item.href}
+ class="{item.current? 'bg-gray-100 text-gray-900': 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'} group flex items-center px-2 py-2 text-base font-medium rounded-md">
+ <svelte:component this="{item.icon}"
+ class="{item.current ? 'text-gray-500' : 'text-gray-400 group-hover:text-gray-500'} mr-4 h-6 w-6"
+ aria-hidden="true"></svelte:component>
+ {item.name}
+ </a>
+ {/each}
+ </div>
+ </nav>
+ </div>
+ <div class="flex-shrink-0 flex border-t border-gray-200 p-4">
+ <a href="#" class="flex-shrink-0 group block">
+ <div class="flex items-center">
+ <div>
+ <img class="inline-block h-10 w-10 rounded-full"
+ src="https://images.unsplash.com/photo-1517365830460-955ce3ccd263?ixlib=rb-=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=256&h=256&q=80"
+ alt=""
+ />
+ </div>
+ <div class="ml-3">
+ <p class="text-base font-medium text-gray-700 group-hover:text-gray-900">
+ {username}
+ </p>
+ <p class="text-sm font-medium text-gray-500 group-hover:text-gray-700">
+ {$LL.nav.usermenu.profileTitle()}
+ </p>
+ </div>
+ </div>
+ </a>
+ </div>
+ </DialogPanel>
+ </TransitionChild>
+ <div class="flex-shrink-0 w-14" aria-hidden="true">
+ <!--{/* Force sidebar to shrink to fit close icon */}-->
+ </div>
+ </div>
+ </Dialog>
+ </TransitionRoot>
+
+ <!--{/* Static sidebar for desktop */}-->
+ <div class="hidden lg:flex lg:flex-shrink-0">
+ <div class="flex flex-col w-64">
+ <!--{/* Sidebar component, swap this element with another sidebar if you like */}-->
+ <div class="flex-1 flex flex-col min-h-0 border-r border-gray-200 bg-gray-100">
+ <div class="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
+ <div class="flex items-center flex-shrink-0 px-4">
+ <img class="h-8 w-auto"
+ src="https://tailwindui.com/img/logos/workflow-mark.svg?color=indigo&shade=600"
+ alt="Workflow"
+ />
+ </div>
+ <nav class="mt-5 flex-1" aria-label="Sidebar">
+ <div class="px-2 space-y-1">
+ {#each navigations as item (item.name)}
+ <a key={item.name}
+ href={item.href}
+ class="{item.current ? 'bg-gray-200 text-gray-900' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'} group flex items-center px-2 py-2 text-sm font-medium rounded-md">
+ <svelte:component this="{item.icon}"
+ class="{item.current ? 'text-gray-500' : 'text-gray-400 group-hover:text-gray-500'} mr-3 h-6 w-6"
+ aria-hidden="true"></svelte:component>
+ {item.name}
+ </a>
+ {/each}
+ </div>
+ </nav>
+ </div>
+ <div class="flex-shrink-0 flex border-t border-gray-200 p-4">
+ <a href="#" class="flex-shrink-0 w-full group block">
+ <div class="flex items-center">
+ <div>
+ <img class="inline-block h-9 w-9 rounded-full"
+ src="https://images.unsplash.com/photo-1517365830460-955ce3ccd263?ixlib=rb-=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=256&h=256&q=80"
+ alt=""
+ />
+ </div>
+ <div class="ml-3">
+ <p class="text-base font-medium text-gray-700 group-hover:text-gray-900">
+ {username}
+ </p>
+ <p class="text-sm font-medium text-gray-500 group-hover:text-gray-700">
+ {$LL.nav.usermenu.profileTitle()}
+ </p>
+ </div>
+ </div>
+ </a>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="flex flex-col min-w-0 flex-1 overflow-hidden">
+ <div class="lg:hidden">
+ <div class="flex items-center justify-between bg-gray-50 border-b border-gray-200 px-4 py-1.5">
+ <div>
+ <button type="button"
+ class="-mr-3 h-12 w-12 inline-flex items-center justify-center rounded-md text-gray-500 hover:text-gray-900"
+ on:click={() => sidebarIsOpen = true}>
+ <span class="sr-only">Open sidebar</span>
+ <MenuIcon aria-hidden="true"/>
+ </button>
+ </div>
+ </div>
+ </div>
+ <div class="flex-1 relative z-0 flex overflow-hidden">
+ <main class="flex-1 relative z-0 overflow-y-auto focus:outline-none">
+ <!--
+ MAIN CONTENT
+ -->
+ <slot/>
+ </main>
+ <aside class="hidden relative xl:flex xl:flex-col flex-shrink-0 w-96 border-l border-gray-200 overflow-y-auto">
+ <!--{/* Start secondary column (hidden on smaller screens) */}
+ <div class="absolute inset-0 py-6 px-4 sm:px-6 lg:px-8">
+ <div class="h-full border-2 border-gray-200 border-dashed rounded-lg" />
+ </div>
+ {/* End secondary column */}-->
+ </aside>
+ </div>
+ </div>
+</div> \ No newline at end of file
diff --git a/apps/kit/src/routes/(app)/home/+page.svelte b/apps/kit/src/routes/(app)/home/+page.svelte
new file mode 100644
index 0000000..247ee47
--- /dev/null
+++ b/apps/kit/src/routes/(app)/home/+page.svelte
@@ -0,0 +1 @@
+<h1>Welcome Home</h1> \ No newline at end of file
diff --git a/apps/kit/src/routes/(public)/+layout.svelte b/apps/kit/src/routes/(public)/+layout.svelte
new file mode 100644
index 0000000..1301e50
--- /dev/null
+++ b/apps/kit/src/routes/(public)/+layout.svelte
@@ -0,0 +1,6 @@
+<script>
+ import {setLocale} from "$lib/i18n/i18n-svelte";
+
+ setLocale("nb");
+</script>
+<slot></slot> \ No newline at end of file
diff --git a/apps/kit/src/routes/(public)/login/+page.svelte b/apps/kit/src/routes/(public)/login/+page.svelte
new file mode 100644
index 0000000..800575e
--- /dev/null
+++ b/apps/kit/src/routes/(public)/login/+page.svelte
@@ -0,0 +1,95 @@
+<script lang="ts">
+ import {goto} from "$app/navigation";
+ import {login} from "$lib/api/user";
+ import LL from "$lib/i18n/i18n-svelte";
+ import type {ErrorResult} from "$lib/models/ErrorResult";
+ import type {LoginPayload} from "$lib/models/LoginPayload";
+
+ const data = {
+ username: "",
+ password: ""
+ } as LoginPayload;
+ let error = {
+ text: "",
+ title: ""
+ } as ErrorResult;
+
+ async function submitFormAsync() {
+ error = {text: "", title: ""};
+ const loginResponse = await login(data);
+ if (loginResponse.ok) {
+ await goto("/home")
+ } else {
+ error.title = loginResponse.data.title;
+ error.text = loginResponse.data.text;
+ }
+ }
+</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 sm:max-w-md">
+ <h2 class="mt-6 text-center text-3xl tracking-tight font-bold text-gray-900">{$LL.login.loginToYourAccount()}</h2>
+ <p class="mt-2 text-center text-sm text-gray-600">
+ {$LL.login.or()}
+ <a href="/signup"
+ class="font-medium text-indigo-600 hover:text-indigo-500">{$LL.login.createANewAccount()}</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 error.text || error.title}
+ <div class="rounded-md bg-red-50 p-3 mb-3">
+ {#if error.title}
+ <h3 class="text-sm font-medium text-red-800">{error.title}</h3>
+ {/if}
+ {#if error.text}
+ <div class="mt-2 text-sm text-red-700">
+ {error.text}
+ </div>
+ {/if}
+ </div>
+ {/if}
+ <form class="space-y-6" on:submit|preventDefault={submitFormAsync}>
+ <div>
+ <label for="email"
+ class="block text-sm font-medium text-gray-700">{$LL.login.emailAddress()}</label>
+ <div class="mt-1">
+ <input id="email" name="email" type="email" autocomplete="email" required
+ value={data.username}
+ class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
+ </div>
+ </div>
+
+ <div>
+ <label for="password" class="block text-sm font-medium text-gray-700">{$LL.login.password()}</label>
+ <div class="mt-1">
+ <input id="password" name="password" type="password" autocomplete="current-password" required
+ value={data.password}
+ class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
+ </div>
+ </div>
+
+ <div class="flex items-center justify-between">
+ <div class="flex items-center">
+ <input id="remember-me" name="remember-me" type="checkbox"
+ class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded">
+ <label for="remember-me"
+ class="ml-2 block text-sm text-gray-900">{$LL.login.notMyComputer()}</label>
+ </div>
+
+ <div class="text-sm">
+ <a href="/reset"
+ class="font-medium text-indigo-600 hover:text-indigo-500">{$LL.login.forgotPassword()}</a>
+ </div>
+ </div>
+
+ <div>
+ <button type="submit"
+ class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
+ {$LL.login.logIn()}
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+</div>
diff --git a/apps/kit/src/routes/(public)/reset/+page.svelte b/apps/kit/src/routes/(public)/reset/+page.svelte
new file mode 100644
index 0000000..41c4728
--- /dev/null
+++ b/apps/kit/src/routes/(public)/reset/+page.svelte
@@ -0,0 +1,29 @@
+<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 sm:max-w-md">
+ <h2 class="mt-6 text-center text-3xl tracking-tight font-bold text-gray-900">Request a password reset</h2>
+ <p class="mt-2 text-center text-sm text-gray-600">
+ Or
+ <a href="/login" class="font-medium text-indigo-600 hover:text-indigo-500">go to login page</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" action="#" method="POST">
+ <div>
+ <label for="email" class="block text-sm font-medium text-gray-700"> Email address </label>
+ <div class="mt-1">
+ <input id="email" name="email" type="email" autocomplete="email" required
+ class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
+ </div>
+ </div>
+ <div>
+ <button type="submit"
+ class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
+ Send request
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+</div>
diff --git a/apps/kit/src/routes/(public)/signup/+page.svelte b/apps/kit/src/routes/(public)/signup/+page.svelte
new file mode 100644
index 0000000..d4a1bda
--- /dev/null
+++ b/apps/kit/src/routes/(public)/signup/+page.svelte
@@ -0,0 +1,38 @@
+<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 sm:max-w-md">
+ <h2 class="mt-6 text-center text-3xl tracking-tight font-bold text-gray-900">Create your new account</h2>
+ <p class="mt-2 text-center text-sm text-gray-600">
+ Or
+ <a href="/login" class="font-medium text-indigo-600 hover:text-indigo-500">go to login page</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" action="#" method="POST">
+ <div>
+ <label for="email" class="block text-sm font-medium text-gray-700"> Email address </label>
+ <div class="mt-1">
+ <input id="email" name="email" type="email" autocomplete="email" required
+ class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
+ </div>
+ </div>
+
+ <div>
+ <label for="password" class="block text-sm font-medium text-gray-700"> Password </label>
+ <div class="mt-1">
+ <input id="password" name="password" type="password" autocomplete="current-password" required
+ class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
+ </div>
+ </div>
+
+ <div>
+ <button type="submit"
+ class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
+ Create account
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+</div>
diff --git a/apps/kit/src/routes/+layout.server.ts b/apps/kit/src/routes/+layout.server.ts
new file mode 100644
index 0000000..01aae89
--- /dev/null
+++ b/apps/kit/src/routes/+layout.server.ts
@@ -0,0 +1,13 @@
+// import {is_active} from "$lib/session";
+// import {redirect} from "@sveltejs/kit";
+// import type {LayoutServerLoad} from "./$types";
+//
+// export const load: LayoutServerLoad = async ({routeId}) => {
+// const sessionIsValid = await is_active();
+// const isPublicRoute = routeId?.startsWith("(public)");
+// if (sessionIsValid && isPublicRoute) {
+// throw redirect(302, "/home");
+// } else if (!sessionIsValid && !isPublicRoute) {
+// throw redirect(302, "/login");
+// }
+// }; \ No newline at end of file
diff --git a/apps/kit/src/routes/+layout.svelte b/apps/kit/src/routes/+layout.svelte
new file mode 100644
index 0000000..ee76da9
--- /dev/null
+++ b/apps/kit/src/routes/+layout.svelte
@@ -0,0 +1,23 @@
+<script lang="ts">
+ import "../app.pcss";
+ import {afterNavigate, beforeNavigate, goto} from "$app/navigation";
+ import {is_active} from "$lib/session";
+ import type {Navigation} from "@sveltejs/kit";
+
+ async function redirect_if_necessary(ticket: Navigation) {
+ const sessionIsValid = await is_active();
+ const isPublicRoute = ticket.to?.routeId?.startsWith("(public)");
+ if (sessionIsValid && isPublicRoute) {
+ await goto("/home");
+ } else if (!sessionIsValid && !isPublicRoute) {
+ await goto("/login");
+ }
+ }
+
+ // This should probably be removed in favor of the logic in layout.server.ts.
+ // That requires a more sophisticated server side implementation of session handling,
+ // and i don't want that tbh, i want to stay as much in the browser as possible.
+ afterNavigate(redirect_if_necessary);
+ beforeNavigate(redirect_if_necessary);
+</script>
+<slot></slot> \ No newline at end of file