diff options
Diffstat (limited to 'code/app/src/lib')
28 files changed, 279 insertions, 402 deletions
diff --git a/code/app/src/lib/api/_fetch.ts b/code/app/src/lib/api/_fetch.ts new file mode 100644 index 0000000..b28e398 --- /dev/null +++ b/code/app/src/lib/api/_fetch.ts @@ -0,0 +1,121 @@ +import { Temporal } from "temporal-polyfill"; +import { clear_session_data } from "$lib/session"; +import type { Result } from "rustic"; +import { Err, Ok } from "rustic"; +import { redirect } from "@sveltejs/kit"; +import { browser } from "$app/environment"; +import { goto } from "$app/navigation"; +import { SignInPageMessage, signInPageMessageQueryKey } from "$routes/(main)/(public)/sign-in"; +import { log_error } from "$lib/logger"; + +export async function http_post_async<T>(url: string, body?: object | string, timeout = -1, skip_401_check = false, abort_signal?: AbortSignal): Promise<InternalFetchResponse<T>> { + const init = make_request_init("post", body, abort_signal); + const response = await internal_fetch_async({ url, init, timeout }); + if (!skip_401_check && await redirect_if_401_async(response)) return Err("Server returned 401"); + return make_response_async(response); +} + +export async function http_get_async<T>(url: string, timeout = -1, skip_401_check = false, abort_signal?: AbortSignal): Promise<Result<InternalFetchResponse<T>, string>> { + const init = make_request_init("get", undefined, abort_signal); + const response = await internal_fetch_async({ url, init, timeout }); + if (!skip_401_check && await redirect_if_401_async(response)) return Err("Server returned 401"); + return make_response_async(response); +} + +export async function http_delete_async<T>(url: string, body?: object | string, timeout = -1, skip_401_check = false, abort_signal?: AbortSignal): Promise<Result<InternalFetchResponse<T>, string>> { + const init = make_request_init("delete", body, abort_signal); + const response = await internal_fetch_async({ url, init, timeout }); + if (!skip_401_check && await redirect_if_401_async(response)) return Err("Server returned 401"); + return make_response_async(response); +} + +async function internal_fetch_async(request: InternalFetchRequest): Promise<Response> { + if (!request.init) throw new Error("request.init is required"); + 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) { + log_error(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; + } + } + + return response; +} + +async function redirect_if_401_async(response: Response): Promise<boolean> { + if (response.status === 401) { + const redirectUrl = `/sign-in?${signInPageMessageQueryKey}=${SignInPageMessage.LOGGED_OUT}`; + clear_session_data(); + if (browser) { + await goto(redirectUrl) + } else { + throw redirect(307, redirectUrl); + } + } + return false; +} + +async function make_response_async<T>(response: Response): Promise<Result<InternalFetchResponse<T>, string>> { + const result = { + ok: response.ok, + status: response.status, + http_response: response, + } as InternalFetchResponse<T>; + + if (response.status !== 204) { + try { + result.data = await response.json() as T; + } catch (error) { + log_error("", { error, result }) + return Err("Deserialisation threw"); + } + } + return Ok(result); +} + +function make_request_init(method: string, body?: any, signal?: AbortSignal): RequestInit { + const init = { + method, + signal, + headers: { + "X-TimeZone": Temporal.Now.timeZone().id, + } + } as RequestInit; + + if (body) { + init.body = JSON.stringify(body); + init.headers["Content-Type"] = "application/json;charset=UTF-8" + } + + return init; +} + + +export type InternalFetchRequest = { + url: string, + init: RequestInit, + timeout?: number + retry_count?: number, +} + +export type InternalFetchResponse<T> = { + ok: boolean, + status: number, + data: T | undefined, + http_response: Response +}
\ No newline at end of file diff --git a/code/app/src/lib/api/account/index.ts b/code/app/src/lib/api/account/index.ts new file mode 100644 index 0000000..305bd9f --- /dev/null +++ b/code/app/src/lib/api/account/index.ts @@ -0,0 +1,39 @@ +import { api_base } from "$lib/configuration"; +import { http_delete_async, http_get_async, http_post_async, type InternalFetchResponse } from "../_fetch"; +import type { LoginPayload } from "$lib/api/account/models/LoginPayload"; +import type { Result } from "rustic"; +import { isOk, Ok, Err } from "rustic"; +import type { SessionData } from "$lib/models/base/SessionData"; +import type { CreateAccountPayload } from "./models/CreateAccountPayload"; +import type { UpdateProfilePayload } from "./models/UpdateProfilePayload"; +import type { ErrorResult } from "$lib/models/internal/ErrorResult"; + +export const http_account = { + async login_async(payload: LoginPayload): Promise<InternalFetchResponse<Result<void, ErrorResult>>> { + const response = await http_post_async<Result<void, ErrorResult>>(api_base("_/account/login"), payload); + if (isOk(response)) { + return Ok(); + } + return Err(response.data); + }, + logout_async(): Promise<InternalFetchResponse<void>> { + return http_get_async<void>(api_base("_/account/logout")); + }, + delete_account_async(): Promise<InternalFetchResponse> { + return http_delete_async(api_base("_/account/delete")); + }, + update_profile_async(payload: UpdateProfilePayload): Promise<InternalFetchResponse> { + if (!payload.password && !payload.username) throw new Error("Password and Username is empty"); + return http_post_async(api_base("_/account/update"), payload); + }, + create_account_async(payload: CreateAccountPayload): Promise<InternalFetchResponse> { + if (!payload.password && !payload.username) throw new Error("Password and Username is empty"); + return http_post_async(api_base("_/account/create"), payload); + }, + async get_profile_async(suppress_401: boolean): Promise<Result<SessionData, string>> { + const response = await http_get_async<SessionData>(api_base("_/account"), 0, true); + if (isOk(response)) { + return Ok(response.data.data); + } + } +}
\ No newline at end of file diff --git a/code/app/src/lib/models/internal/CreateAccountPayload.ts b/code/app/src/lib/api/account/models/CreateAccountPayload.ts index d116308..d116308 100644 --- a/code/app/src/lib/models/internal/CreateAccountPayload.ts +++ b/code/app/src/lib/api/account/models/CreateAccountPayload.ts diff --git a/code/app/src/lib/models/internal/LoginPayload.ts b/code/app/src/lib/api/account/models/LoginPayload.ts index beb96cf..beb96cf 100644 --- a/code/app/src/lib/models/internal/LoginPayload.ts +++ b/code/app/src/lib/api/account/models/LoginPayload.ts diff --git a/code/app/src/lib/models/internal/UpdateProfilePayload.ts b/code/app/src/lib/api/account/models/UpdateProfilePayload.ts index d2983ff..d2983ff 100644 --- a/code/app/src/lib/models/internal/UpdateProfilePayload.ts +++ b/code/app/src/lib/api/account/models/UpdateProfilePayload.ts diff --git a/code/app/src/lib/api/internal-fetch.ts b/code/app/src/lib/api/internal-fetch.ts deleted file mode 100644 index b21d669..0000000 --- a/code/app/src/lib/api/internal-fetch.ts +++ /dev/null @@ -1,170 +0,0 @@ -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/password-reset-request/index.ts b/code/app/src/lib/api/password-reset-request/index.ts new file mode 100644 index 0000000..9d6f0dc --- /dev/null +++ b/code/app/src/lib/api/password-reset-request/index.ts @@ -0,0 +1,17 @@ +import { api_base } from "$lib/configuration"; +import { http_get_async, http_post_async, type InternalFetchResponse } from "../_fetch"; + +export const http_password_reset_request = { + create_forgot_password_request(username: string): Promise<InternalFetchResponse> { + if (!username) throw new Error("Username is empty"); + return http_get_async(api_base("_/forgot-password-requests/create?username=" + username)); + }, + check_forgot_password_request(public_id: string): Promise<InternalFetchResponse> { + if (!public_id) throw new Error("Id is empty"); + return http_get_async(api_base("_/forgot-password-requests/is-valid?id=" + public_id)); + }, + fulfill_forgot_password_request(public_id: string, newPassword: string): Promise<InternalFetchResponse> { + if (!public_id) throw new Error("Id is empty"); + return http_post_async(api_base("_/forgot-password-requests/fulfill"), { id: public_id, newPassword }); + }, +}
\ No newline at end of file diff --git a/code/app/src/lib/api/root.ts b/code/app/src/lib/api/root.ts index 3e5bda2..661f24b 100644 --- a/code/app/src/lib/api/root.ts +++ b/code/app/src/lib/api/root.ts @@ -1,6 +1,12 @@ -import {http_post} from "$lib/api/internal-fetch"; -import {api_base} from "$lib/configuration"; +import { http_get_async, http_post_async } from "$lib/api/_fetch"; +import { api_base } from "$lib/configuration"; +import type { IInternalFetchResponse } from "$lib/models/internal/IInternalFetchResponse"; +import type { Result } from "rustic"; export function server_log(message: string): void { - http_post(api_base("_/api/log"), message); + http_post_async(api_base("_/api/log"), message); } + +export function server_version(): Promise<Result<IInternalFetchResponse<string>, string>> { + return http_get_async(api_base("/version.txt")); +}
\ No newline at end of file diff --git a/code/app/src/lib/api/time-entry.ts b/code/app/src/lib/api/time-entry.ts deleted file mode 100644 index faedb48..0000000 --- a/code/app/src/lib/api/time-entry.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { api_base } from "$lib/configuration"; -import { is_guid } from "$lib/helpers"; -import { http_delete, http_get, http_post } from "./internal-fetch"; -import type { WorkCategory } from "$lib/models/work/WorkCategory"; -import type { WorkLabel } from "$lib/models/work/WorkLabel"; -import type { WorkEntry } from "$lib/models/work/WorkEntry"; -import type { WorkQuery } from "$lib/models/work/WorkQuery"; -import type { IInternalFetchResponse } from "$lib/models/internal/IInternalFetchResponse"; - - -// ENTRIES - -export async function create_time_entry(payload: WorkEntry): 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: WorkQuery): 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: WorkEntry): 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: WorkLabel): 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: WorkLabel): 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: WorkCategory): 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: WorkCategory): 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 deleted file mode 100644 index f08fb6d..0000000 --- a/code/app/src/lib/api/user.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { api_base } from "$lib/configuration"; -import { http_delete, http_get, http_post } from "./internal-fetch"; -import type { LoginPayload } from "$lib/models/internal/LoginPayload"; -import type { UpdateProfilePayload } from "$lib/models/internal/UpdateProfilePayload"; -import type { CreateAccountPayload } from "$lib/models/internal/CreateAccountPayload"; -import type { IInternalFetchResponse } from "$lib/models/internal/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/components/checkbox.svelte b/code/app/src/lib/components/checkbox.svelte index b2fcddb..12ebedb 100644 --- a/code/app/src/lib/components/checkbox.svelte +++ b/code/app/src/lib/components/checkbox.svelte @@ -7,6 +7,7 @@ export let name: string | undefined = undefined; export let disabled: boolean | null = null; export let checked: boolean; + export let required: boolean | null = null; export let _pwKey: string | undefined = undefined; </script> @@ -16,9 +17,13 @@ use:pwKey={_pwKey} {disabled} {id} + {required} 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> + <label for={id} class="ml-2 block text-sm text-gray-900"> + {@html required ? "<span class='text-red-500'>*</span>" : ""} + {label} + </label> </div> diff --git a/code/app/src/lib/components/combobox.svelte b/code/app/src/lib/components/combobox.svelte index ee69917..4e7b1cd 100644 --- a/code/app/src/lib/components/combobox.svelte +++ b/code/app/src/lib/components/combobox.svelte @@ -7,7 +7,7 @@ </script> <script lang="ts"> - import { CheckCircleIcon, ChevronUpDownIcon, XIcon } from "$lib/components/icons"; + import { CheckCircleIcon, ChevronUpDownIcon, XIcon } from "./icons"; import { element_has_focus, random_string } from "$lib/helpers"; import { go, highlight } from "fuzzysort"; import Badge from "./badge.svelte"; @@ -20,19 +20,19 @@ export let disabled: boolean | undefined = undefined; export let required: boolean | undefined = undefined; export let maxlength: number | undefined = undefined; - export let placeholder = $LL.combobox.search(); + export let placeholder: string = $LL.combobox.search(); export let options: Array<ComboboxOption> | undefined = []; export let createable = false; export let loading = false; export let multiple = false; - export let noResultsText = $LL.combobox.noRecordsFound(); + export let noResultsText: string = $LL.combobox.noRecordsFound(); export let on_create_async = async ({ name: string }) => {}; export const reset = () => methods.reset(); export const select = (id: string) => methods.select_entry(id); export const deselect = (id: string) => methods.deselect_entry(id); - const INTERNAL_ID = "INTERNAL__combobox-" + random_string(3); + const INTERNAL_ID = "INTERNAL__" + id; let optionsListId = id + "--options"; let searchInputNode; @@ -75,7 +75,7 @@ if (!value) { return ""; } - return value.toString().trim().toLowerCase(); + return value.trim().toLowerCase(); }, do() { const query = search.normalise_value(searchValue); @@ -85,9 +85,9 @@ return; } - //@ts-ignore + // @ts-ignore searchResults = go(query, options, { - limit: 10, + limit: 15, allowTypo: true, threshold: -10000, key: "name", @@ -206,11 +206,11 @@ if (searchValue) { return options; } - return (options as any).sort((a, b) => { - search.normalise_value(a.name).localeCompare(search.normalise_value(b.name)); - }); + + return options.sort((a, b) => search.normalise_value(a.name).localeCompare(search.normalise_value(b.name))); }, }; + const windowEvents = { on_mousemove(event: any) { if (!event.target) return; @@ -230,7 +230,7 @@ const spacePressed = event.code === "Space"; const arrowDownPressed = event.code === "ArrowDown"; const searchInputHasFocus = element_has_focus(searchInputNode); - const focusedEntry = document.querySelector("#" + INTERNAL_ID + " ul .focus"); + const focusedEntry = document.querySelector("#" + INTERNAL_ID + " ul li.focus") as HTMLLIElement; if (showDropdown && (enterPressed || arrowDownPressed || arrowUpPressed)) { event.preventDefault(); @@ -262,16 +262,18 @@ focusedEntry.nextElementSibling.classList.add("focus"); focusedEntry.nextElementSibling.scrollIntoView(false); } else { - document.querySelector("#" + INTERNAL_ID + " ul li:first-of-type").classList.add("focus"); - document.querySelector("#" + INTERNAL_ID + " ul li:first-of-type").scrollIntoView(false); + const firstLIEl = document.querySelector("#" + INTERNAL_ID + " ul li:first-of-type"); + firstLIEl.classList.add("focus"); + firstLIEl.scrollIntoView(false); } } else if (arrowUpPressed) { if (focusedEntry.previousElementSibling) { focusedEntry.previousElementSibling.classList.add("focus"); focusedEntry.previousElementSibling.scrollIntoView(false); } else { - document.querySelector("#" + INTERNAL_ID + " ul li:last-of-type").classList.add("focus"); - document.querySelector("#" + INTERNAL_ID + " ul li:last-of-type").scrollIntoView(false); + const lastLIEl = document.querySelector("#" + INTERNAL_ID + " ul li:last-of-type"); + lastLIEl.classList.add("focus"); + lastLIEl.scrollIntoView(false); } } focusedEntry.classList.remove("focus"); @@ -279,7 +281,6 @@ } if (focusedEntry && (spacePressed || enterPressed)) { - //@ts-ignore methods.select_entry(focusedEntry.dataset.id); return; } @@ -303,14 +304,17 @@ <div id={INTERNAL_ID} class:cursor-wait={loading}> {#if label} - <label for={id} class="block text-sm font-medium text-gray-700">{label}</label> + <label for={id} class="block text-sm font-medium text-gray-700"> + {label} + {@html required ? "<span class='text-red-500'>*</span>" : ""} + </label> {/if} <div class="relative {label ? 'mt-1' : ''}"> <div on:click={search.on_input_wrapper_focus} on:keypress={search.on_input_wrapper_focus} - class="cursor-text w-full rounded-md border bg-white py-2 pl-3 pr-12 sm:text-sm - {inputHasFocus ? `border-${colorName}-500 outline-none ring-1 ring-{colorName}-500` : 'shadow-sm border-gray-300'}" + class="cursor-text w-full flex rounded-md border bg-white py-2 pl-3 pr-12 sm:text-sm + {inputHasFocus ? `border-${colorName}-500 outline-none ring-1 ring-${colorName}-500` : 'shadow-sm border-gray-300'}" > {#if multiple === true && hasSelection} <div class="flex gap-1 flex-wrap"> @@ -325,7 +329,7 @@ {/each} </div> {/if} - <div class={multiple === true && hasSelection ? "mt-2" : ""}> + <div> <input {...attributes} type="text" @@ -346,12 +350,13 @@ type="button" on:click={() => reset()} title={$LL.reset()} + tabindex="-1" class="text-gray-400 absolute cursor-pointer inset-y-0 right-0 flex items-center rounded-r-md px-2" > <XIcon /> </button> {:else} - <span class="text-gray-400 absolute inset-y-0 right-0 flex items-center rounded-r-md px-2"> + <span tabindex="-1" class="text-gray-400 absolute inset-y-0 right-0 flex items-center rounded-r-md px-2"> <ChevronUpDownIcon /> </span> {/if} @@ -406,10 +411,12 @@ </li> {/each} {:else} - <p class="px-2">{noResultsText}</p> - {#if createable && !searchValue} - <p class="px-2 text-gray-500">{$LL.combobox.createRecordHelpText()}</p> - {/if} + <slot name="no-records"> + <p class="px-2">{noResultsText}</p> + {#if createable && !searchValue} + <p class="px-2 text-gray-500">{$LL.combobox.createRecordHelpText()}</p> + {/if} + </slot> {/if} </ul> {#if showCreationHint} diff --git a/code/app/src/lib/components/input.svelte b/code/app/src/lib/components/input.svelte index efd8946..5d38597 100644 --- a/code/app/src/lib/components/input.svelte +++ b/code/app/src/lib/components/input.svelte @@ -1,6 +1,7 @@ <script lang="ts"> import pwKey from "$actions/pwKey"; import { random_string } from "$lib/helpers"; + import { htmlLangAttributeDetector } from "typesafe-i18n/detectors"; import { ExclamationCircleIcon } from "./icons"; export let label: string | undefined = undefined; @@ -47,12 +48,14 @@ {#if label && !cornerHint && !hideLabel} <label for={id} class={hideLabel ? "sr-only" : "block text-sm font-medium text-gray-700"}> {label} + {@html required ? "<span class='text-red-500'>*</span>" : ""} </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} + {@html required ? "<span class='text-red-500'>*</span>" : ""} </label> {/if} <span class="text-sm text-gray-500"> diff --git a/code/app/src/lib/components/textarea.svelte b/code/app/src/lib/components/textarea.svelte index 65127af..6629260 100644 --- a/code/app/src/lib/components/textarea.svelte +++ b/code/app/src/lib/components/textarea.svelte @@ -9,27 +9,31 @@ export let placeholder = ""; export let value; export let label = ""; + export let required = false; export let errorText = ""; - $: shared_props = { + $: attributes = { rows: rows || null, cols: cols || null, name: name || null, id: id || null, disabled: disabled || null, + required: required || null, }; - let textarea; + let textareaElement; let scrollHeight = 0; 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; } - $: if (textarea) { - scrollHeight = textarea.scrollHeight; + + $: if (textareaElement) { + scrollHeight = textareaElement.scrollHeight; } function on_input(event) { @@ -40,17 +44,20 @@ <div> {#if label} - <label for={id} class="block text-sm font-medium text-gray-700">{label}</label> + <label for={id} class="block text-sm font-medium text-gray-700"> + {label} + {@html required ? "<span class='text-red-500'>*</span>" : ""} + </label> {/if} <div class="mt-1"> <textarea {rows} {name} {id} - {...shared_props} + {...attributes} style="overflow-y:hidden;min-height:calc(1.5em + .75rem + 2px);{scrollHeight ? 'height:{scrollHeight}px' : ''};" bind:value - bind:this={textarea} + bind:this={textareaElement} on:input={on_input} {placeholder} class="block w-full rounded-md {colorClass} shadow-sm sm:text-sm" diff --git a/code/app/src/lib/i18n/en/app/index.ts b/code/app/src/lib/i18n/en/app/index.ts index 7cd05ee..7ccfc97 100644 --- a/code/app/src/lib/i18n/en/app/index.ts +++ b/code/app/src/lib/i18n/en/app/index.ts @@ -1,5 +1,7 @@ import type { BaseTranslation } from '../../i18n-types' -const en_app: BaseTranslation = {} +const en_app: BaseTranslation = { + members: "Members", +} export default en_app
\ No newline at end of file diff --git a/code/app/src/lib/i18n/i18n-types.ts b/code/app/src/lib/i18n/i18n-types.ts index 63387e8..870bf23 100644 --- a/code/app/src/lib/i18n/i18n-types.ts +++ b/code/app/src/lib/i18n/i18n-types.ts @@ -203,7 +203,12 @@ type RootTranslation = { } } -export type NamespaceAppTranslation = {} +export type NamespaceAppTranslation = { + /** + * Members + */ + members: string +} export type Namespaces = | 'app' @@ -398,6 +403,10 @@ export type TranslationFunctions = { submitANewRequestBelow: () => LocalizedString } app: { + /** + * Members + */ + members: () => LocalizedString } } diff --git a/code/app/src/lib/i18n/nb/app/index.ts b/code/app/src/lib/i18n/nb/app/index.ts index 15d0b9a..6bf9ba6 100644 --- a/code/app/src/lib/i18n/nb/app/index.ts +++ b/code/app/src/lib/i18n/nb/app/index.ts @@ -1,8 +1,7 @@ import type { NamespaceAppTranslation } from '../../i18n-types' const nb_app: NamespaceAppTranslation = { - // TODO: insert translations - + members: "Medlemmer" } export default nb_app diff --git a/code/app/src/lib/models/base/SessionData.ts b/code/app/src/lib/models/base/SessionData.ts new file mode 100644 index 0000000..015cbf3 --- /dev/null +++ b/code/app/src/lib/models/base/SessionData.ts @@ -0,0 +1,5 @@ +export type SessionData = { + id: string, + username: string, + displayName: string, +}
\ No newline at end of file diff --git a/code/app/src/lib/models/internal/ErrorResult.ts b/code/app/src/lib/models/internal/ErrorResult.ts index 7c70017..930b9f3 100644 --- a/code/app/src/lib/models/internal/ErrorResult.ts +++ b/code/app/src/lib/models/internal/ErrorResult.ts @@ -1,4 +1,4 @@ -export interface ErrorResult { +export type ErrorResult = { title: string, text: string } diff --git a/code/app/src/lib/models/internal/IInternalFetchRequest.ts b/code/app/src/lib/models/internal/IInternalFetchRequest.ts deleted file mode 100644 index 68505e2..0000000 --- a/code/app/src/lib/models/internal/IInternalFetchRequest.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface IInternalFetchRequest { - url: string, - init?: RequestInit, - timeout?: number - retry_count?: number -} diff --git a/code/app/src/lib/models/internal/IInternalFetchResponse.ts b/code/app/src/lib/models/internal/IInternalFetchResponse.ts deleted file mode 100644 index 6c91b35..0000000 --- a/code/app/src/lib/models/internal/IInternalFetchResponse.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface IInternalFetchResponse { - ok: boolean, - status: number, - data: any, - http_response: Response -} diff --git a/code/app/src/lib/models/internal/ISession.ts b/code/app/src/lib/models/internal/ISession.ts index 7587145..a452e20 100644 --- a/code/app/src/lib/models/internal/ISession.ts +++ b/code/app/src/lib/models/internal/ISession.ts @@ -1,4 +1,4 @@ -export interface ISession { +export type Session = { profile: { username: string, displayName: string, diff --git a/code/app/src/lib/models/internal/IValidationResult.ts b/code/app/src/lib/models/internal/IValidationResult.ts deleted file mode 100644 index 9a21b13..0000000 --- a/code/app/src/lib/models/internal/IValidationResult.ts +++ /dev/null @@ -1,31 +0,0 @@ -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/internal/UnwrappedEntryDateTime.ts b/code/app/src/lib/models/internal/UnwrappedEntryDateTime.ts deleted file mode 100644 index da71bc9..0000000 --- a/code/app/src/lib/models/internal/UnwrappedEntryDateTime.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { 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/services/abstractions/ISettingsService.ts b/code/app/src/lib/services/abstractions/ISettingsService.ts new file mode 100644 index 0000000..366e337 --- /dev/null +++ b/code/app/src/lib/services/abstractions/ISettingsService.ts @@ -0,0 +1,3 @@ +export interface ISettingsService { + get_user_settings(): Promise<void>, +}
\ No newline at end of file diff --git a/code/app/src/lib/services/settings-service.ts b/code/app/src/lib/services/settings-service.ts new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/code/app/src/lib/services/settings-service.ts diff --git a/code/app/src/lib/session.ts b/code/app/src/lib/session.ts index 7cd5fcf..48dcc50 100644 --- a/code/app/src/lib/session.ts +++ b/code/app/src/lib/session.ts @@ -1,13 +1,13 @@ import { log_error, log_info } from "$lib/logger"; import { Temporal } from "temporal-polyfill"; -import { get_profile_for_active_check, logout } from "./api/user"; +import { http_account } from "$lib/api/account"; 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/internal/ISession"; +import type { Session } from "$lib/models/internal/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 data = session_storage_get_json(StorageKeys.session) as Session; const expiryEpoch = data?.lastChecked + SECONDS_BETWEEN_SESSION_CHECK; const lastCheckIsStaleOrNone = !is_guid(data?.profile?.id) || (expiryEpoch < nowEpoch); if (forceRefresh || lastCheckIsStaleOrNone) { @@ -23,7 +23,7 @@ export async function is_active(forceRefresh: boolean = false): Promise<boolean> } export async function end_session(cb: Function): Promise<void> { - await logout(); + await http_account.logout_async(); clear_session_data(); cb(); } @@ -31,14 +31,14 @@ export async function end_session(cb: Function): Promise<void> { async function call_api(): Promise<boolean> { log_info("Getting profile data while checking session state"); try { - const response = await get_profile_for_active_check(); + const response = await http_account.get_profile_async(true); 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; + } as Session; session_storage_set_json(StorageKeys.session, session); log_info("Successfully got profile data while checking session state"); return true; @@ -64,6 +64,6 @@ export function clear_session_data() { log_info("Cleared session data."); } -export function get_session_data(): ISession { - return session_storage_get_json(StorageKeys.session) as ISession; +export function get_session_data(): Session { + return session_storage_get_json(StorageKeys.session) as Session; } diff --git a/code/app/src/lib/swr.ts b/code/app/src/lib/swr.ts new file mode 100644 index 0000000..39c8665 --- /dev/null +++ b/code/app/src/lib/swr.ts @@ -0,0 +1,6 @@ +import { createDefaultSWR } from "sswr"; +import { http_get_async } from "./api/_fetch"; + +export const swr = createDefaultSWR({ + fetcher: (key: string) => http_get_async(key), +}); |
