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