aboutsummaryrefslogtreecommitdiffstats
path: root/code/app/src/lib
diff options
context:
space:
mode:
authorivarlovlie <git@ivarlovlie.no>2022-10-25 11:51:37 +0200
committerivarlovlie <git@ivarlovlie.no>2022-10-25 11:51:37 +0200
commit0005595703b2f3f7083ce4ba19bf5770057c75bd (patch)
tree193a897f61a9a5e566961601de4cf42ae85984a0 /code/app/src/lib
parent585c5c8537eb21dfc9f16108548e63d9ced3d971 (diff)
downloadgreatoffice-0005595703b2f3f7083ce4ba19bf5770057c75bd.tar.xz
greatoffice-0005595703b2f3f7083ce4ba19bf5770057c75bd.zip
.
Diffstat (limited to 'code/app/src/lib')
-rw-r--r--code/app/src/lib/api/_fetch.ts121
-rw-r--r--code/app/src/lib/api/account/index.ts39
-rw-r--r--code/app/src/lib/api/account/models/CreateAccountPayload.ts (renamed from code/app/src/lib/models/internal/CreateAccountPayload.ts)0
-rw-r--r--code/app/src/lib/api/account/models/LoginPayload.ts (renamed from code/app/src/lib/models/internal/LoginPayload.ts)0
-rw-r--r--code/app/src/lib/api/account/models/UpdateProfilePayload.ts (renamed from code/app/src/lib/models/internal/UpdateProfilePayload.ts)0
-rw-r--r--code/app/src/lib/api/internal-fetch.ts170
-rw-r--r--code/app/src/lib/api/password-reset-request/index.ts17
-rw-r--r--code/app/src/lib/api/root.ts12
-rw-r--r--code/app/src/lib/api/time-entry.ts83
-rw-r--r--code/app/src/lib/api/user.ts47
-rw-r--r--code/app/src/lib/components/checkbox.svelte7
-rw-r--r--code/app/src/lib/components/combobox.svelte57
-rw-r--r--code/app/src/lib/components/input.svelte3
-rw-r--r--code/app/src/lib/components/textarea.svelte21
-rw-r--r--code/app/src/lib/i18n/en/app/index.ts4
-rw-r--r--code/app/src/lib/i18n/i18n-types.ts11
-rw-r--r--code/app/src/lib/i18n/nb/app/index.ts3
-rw-r--r--code/app/src/lib/models/base/SessionData.ts5
-rw-r--r--code/app/src/lib/models/internal/ErrorResult.ts2
-rw-r--r--code/app/src/lib/models/internal/IInternalFetchRequest.ts6
-rw-r--r--code/app/src/lib/models/internal/IInternalFetchResponse.ts6
-rw-r--r--code/app/src/lib/models/internal/ISession.ts2
-rw-r--r--code/app/src/lib/models/internal/IValidationResult.ts31
-rw-r--r--code/app/src/lib/models/internal/UnwrappedEntryDateTime.ts9
-rw-r--r--code/app/src/lib/services/abstractions/ISettingsService.ts3
-rw-r--r--code/app/src/lib/services/settings-service.ts0
-rw-r--r--code/app/src/lib/session.ts16
-rw-r--r--code/app/src/lib/swr.ts6
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 = {
+ /**
+ * M​e​m​b​e​r​s
+ */
+ 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),
+});