diff options
Diffstat (limited to 'apps/kit/src')
56 files changed, 3430 insertions, 0 deletions
diff --git a/apps/kit/src/app.d.ts b/apps/kit/src/app.d.ts new file mode 100644 index 0000000..4ab4e43 --- /dev/null +++ b/apps/kit/src/app.d.ts @@ -0,0 +1,9 @@ +// See https://kit.svelte.dev/docs/types#app +// for information about these interfaces +// and what to do when importing types +declare namespace App { + interface Locals {} + interface Platform {} + interface PrivateEnv {} + interface PublicEnv {} +} diff --git a/apps/kit/src/app.html b/apps/kit/src/app.html new file mode 100644 index 0000000..3df27c1 --- /dev/null +++ b/apps/kit/src/app.html @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<html class="h-full" lang="en"> + <head> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width" /> + <script src="%sveltekit.assets%/preload.js"></script> + %sveltekit.head% + </head> + <body class="h-full"> + <div>%sveltekit.body%</div> + </body> +</html> diff --git a/apps/kit/src/app.pcss b/apps/kit/src/app.pcss new file mode 100644 index 0000000..f9c290c --- /dev/null +++ b/apps/kit/src/app.pcss @@ -0,0 +1,21 @@ +/* Write your global styles here, in PostCSS syntax */ +@tailwind base; +@tailwind components; +@tailwind utilities; +pre { + font-family: monospace !important; +} + +*:focus-visible { + outline: 1px auto; +} + +.c-disabled { + cursor: not-allowed !important; + filter: opacity(.45); + pointer-events: none !important; +} + +.c-disabled.loading { + cursor: wait !important; +} diff --git a/apps/kit/src/hooks/index.server.ts b/apps/kit/src/hooks/index.server.ts new file mode 100644 index 0000000..414318d --- /dev/null +++ b/apps/kit/src/hooks/index.server.ts @@ -0,0 +1,52 @@ +import { CookieNames } from "$lib/configuration"; +import { detectLocale, locales } from '$lib/i18n/i18n-util' +import type { Handle, RequestEvent } from '@sveltejs/kit' +import { sequence } from "@sveltejs/kit/hooks"; +import { initAcceptLanguageHeaderDetector } from 'typesafe-i18n/detectors' +import { parse, serialize } from "cookie"; +import { logDebug } from "$lib/logger"; + +const handleLocale: Handle = async ({ event, resolve }) => { + const cookies = parse(event.request.headers.get("Cookie") ?? ''); + const localeCookie = cookies[CookieNames.locale]; + const preferredLocale = getPreferredLocale(event); + let finalLocale = localeCookie ?? preferredLocale; + + logDebug("Handling locale", { + locales, + localeCookie, + preferredLocale, + finalLocale + }); + + if (locales.findIndex((locale) => locale === finalLocale) === -1) finalLocale = "en"; + if (!localeCookie) { + // Set a locale cookie + event.setHeaders({ + "Set-Cookie": serialize(CookieNames.locale, finalLocale, { + path: "/", + expires: new Date(2099, 1, 1, 0, 0, 0, 0), + sameSite: "strict" + }) + }); + } + // replace html lang attribute with correct language + return resolve(event, { transformPageChunk: ({ html }) => html.replace('%lang%', finalLocale) }); +} + +function getPreferredLocale(event: RequestEvent) { + // detect the preferred language the user has configured in it's browser + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language + const headers = transformHeaders(event) + const acceptLanguageDetector = initAcceptLanguageHeaderDetector({ headers }) + + return detectLocale(acceptLanguageDetector) +} + +function transformHeaders({ request }: RequestEvent) { + const headers: Record<string, string> = {} + request.headers.forEach((value, key) => (headers[key] = value)) + return headers +} + +export const handle = sequence(handleLocale); diff --git a/apps/kit/src/lib/api/internal-fetch.ts b/apps/kit/src/lib/api/internal-fetch.ts new file mode 100644 index 0000000..b21d669 --- /dev/null +++ b/apps/kit/src/lib/api/internal-fetch.ts @@ -0,0 +1,170 @@ +import { Temporal } from "temporal-polyfill"; +import { clear_session_data } from "$lib/session"; +import { resolve_references } from "$lib/helpers"; +import type { IInternalFetchResponse } from "$lib/models/IInternalFetchResponse"; +import type { IInternalFetchRequest } from "$lib/models/IInternalFetchRequest"; +import { redirect } from "@sveltejs/kit"; + +export async function http_post(url: string, body?: object | string, timeout = -1, skip_401_check = false, abort_signal?: AbortSignal): Promise<IInternalFetchResponse> { + const init = { + method: "post", + } as RequestInit; + + if (abort_signal) { + init.signal = abort_signal; + } + + if (body) { + init.headers = { + "Content-Type": "application/json;charset=UTF-8", + }; + init.body = JSON.stringify(body); + } + + const response = await internal_fetch({ url, init, timeout }); + const result = {} as IInternalFetchResponse; + + if (!skip_401_check && await is_401(response)) return result; + + result.ok = response.ok; + result.status = response.status; + result.http_response = response; + + if (response.status !== 204) { + try { + const ct = response.headers.get("Content-Type")?.toString() ?? ""; + if (ct.startsWith("application/json")) { + const data = await response.json(); + result.data = resolve_references(data); + } else if (ct.startsWith("text/plain")) { + const text = await response.text(); + result.data = text as string; + } + } catch { + // Ignored + } + } + + return result; +} + +export async function http_get(url: string, timeout = -1, skip_401_check = false, abort_signal?: AbortSignal): Promise<IInternalFetchResponse> { + const init = { + method: "get", + } as RequestInit; + + if (abort_signal) { + init.signal = abort_signal; + } + + const response = await internal_fetch({ url, init, timeout }); + const result = {} as IInternalFetchResponse; + + if (!skip_401_check && await is_401(response)) return result; + + result.ok = response.ok; + result.status = response.status; + result.http_response = response; + + if (response.status !== 204) { + try { + const ct = response.headers.get("Content-Type")?.toString() ?? ""; + if (ct.startsWith("application/json")) { + const data = await response.json(); + result.data = resolve_references(data); + } else if (ct.startsWith("text/plain")) { + const text = await response.text(); + result.data = text as string; + } + } catch { + // Ignored + } + } + + return result; +} + +export async function http_delete(url: string, body?: object | string, timeout = -1, skip_401_check = false, abort_signal?: AbortSignal): Promise<IInternalFetchResponse> { + const init = { + method: "delete", + } as RequestInit; + + if (abort_signal) { + init.signal = abort_signal; + } + + if (body) { + init.headers = { + "Content-Type": "application/json;charset=UTF-8", + }; + init.body = JSON.stringify(body); + } + + const response = await internal_fetch({ url, init, timeout }); + const result = {} as IInternalFetchResponse; + + if (!skip_401_check && await is_401(response)) return result; + + result.ok = response.ok; + result.status = response.status; + result.http_response = response; + + if (response.status !== 204) { + try { + const ct = response.headers.get("Content-Type")?.toString() ?? ""; + if (ct.startsWith("application/json")) { + const data = await response.json(); + result.data = resolve_references(data); + } else if (ct.startsWith("text/plain")) { + const text = await response.text(); + result.data = text as string; + } + } catch (error) { + // ignored + } + } + + return result; +} + +async function internal_fetch(request: IInternalFetchRequest): Promise<Response> { + if (!request.init) request.init = {}; + request.init.credentials = "include"; + request.init.headers = { + "X-TimeZone": Temporal.Now.timeZone().id, + ...request.init.headers + }; + + const fetch_request = new Request(request.url, request.init); + let response: any; + + try { + if (request.timeout && request.timeout > 500) { + response = await Promise.race([ + fetch(fetch_request), + new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), request.timeout)) + ]); + } else { + response = await fetch(fetch_request); + } + } catch (error: any) { + console.log(error); + if (error.message === "Timeout") { + console.error("Request timed out"); + } else if (error.message === "Network request failed") { + console.error("No internet connection"); + } else { + throw error; // rethrow other unexpected errors + } + } + + return response; +} + +async function is_401(response: Response): Promise<boolean> { + if (response.status === 401) { + clear_session_data(); + throw redirect(307, "/login"); + } + return false; +} diff --git a/apps/kit/src/lib/api/root.ts b/apps/kit/src/lib/api/root.ts new file mode 100644 index 0000000..3e5bda2 --- /dev/null +++ b/apps/kit/src/lib/api/root.ts @@ -0,0 +1,6 @@ +import {http_post} from "$lib/api/internal-fetch"; +import {api_base} from "$lib/configuration"; + +export function server_log(message: string): void { + http_post(api_base("_/api/log"), message); +} diff --git a/apps/kit/src/lib/api/time-entry.ts b/apps/kit/src/lib/api/time-entry.ts new file mode 100644 index 0000000..a40b0c2 --- /dev/null +++ b/apps/kit/src/lib/api/time-entry.ts @@ -0,0 +1,83 @@ +import {api_base} from "$lib/configuration"; +import {is_guid} from "$lib/helpers"; +import {http_delete, http_get, http_post} from "./internal-fetch"; +import type {TimeCategoryDto} from "$lib/models/TimeCategoryDto"; +import type {TimeLabelDto} from "$lib/models/TimeLabelDto"; +import type {TimeEntryDto} from "$lib/models/TimeEntryDto"; +import type {TimeEntryQuery} from "$lib/models/TimeEntryQuery"; +import type {IInternalFetchResponse} from "$lib/models/IInternalFetchResponse"; + + +// ENTRIES + +export async function create_time_entry(payload: TimeEntryDto): Promise<IInternalFetchResponse> { + return http_post(api_base("v1/entries/create"), payload); +} + +export async function get_time_entry(entryId: string): Promise<IInternalFetchResponse> { + if (is_guid(entryId)) { + return http_get(api_base("v1/entries/" + entryId)); + } + throw new Error("entryId is not a valid guid."); +} + +export async function get_time_entries(entryQuery: TimeEntryQuery): Promise<IInternalFetchResponse> { + return http_post(api_base("v1/entries/query"), entryQuery); +} + +export async function delete_time_entry(id: string): Promise<IInternalFetchResponse> { + if (!is_guid(id)) throw new Error("id is not a valid guid"); + return http_delete(api_base("v1/entries/" + id + "/delete")); +} + +export async function update_time_entry(entryDto: TimeEntryDto): Promise<IInternalFetchResponse> { + if (!is_guid(entryDto.id ?? "")) throw new Error("id is not a valid guid"); + if (!entryDto.category) throw new Error("category is empty"); + if (!entryDto.stop) throw new Error("stop is empty"); + if (!entryDto.start) throw new Error("start is empty"); + return http_post(api_base("v1/entries/update"), entryDto); +} + +// LABELS +export async function create_time_label(labelDto: TimeLabelDto): Promise<IInternalFetchResponse> { + return http_post(api_base("v1/labels/create"), labelDto); +} + +export async function get_time_labels(): Promise<IInternalFetchResponse> { + return http_get(api_base("v1/labels")); +} + +export async function delete_time_label(id: string): Promise<IInternalFetchResponse> { + if (!is_guid(id)) throw new Error("id is not a valid guid"); + return http_delete(api_base("v1/labels/" + id + "/delete")); +} + +export async function update_time_label(labelDto: TimeLabelDto): Promise<IInternalFetchResponse> { + if (!is_guid(labelDto.id ?? "")) throw new Error("id is not a valid guid"); + if (!labelDto.name) throw new Error("name is empty"); + if (!labelDto.color) throw new Error("color is empty"); + return http_post(api_base("v1/labels/update"), labelDto); +} + +// CATEGORIES +export async function create_time_category(category: TimeCategoryDto): Promise<IInternalFetchResponse> { + if (!category.name) throw new Error("name is empty"); + if (!category.color) throw new Error("color is empty"); + return http_post(api_base("v1/categories/create"), category); +} + +export async function get_time_categories(): Promise<IInternalFetchResponse> { + return http_get(api_base("v1/categories")); +} + +export async function delete_time_category(id: string): Promise<IInternalFetchResponse> { + if (!is_guid(id)) throw new Error("id is not a valid guid"); + return http_delete(api_base("v1/categories/" + id + "/delete")); +} + +export async function update_time_category(category: TimeCategoryDto): Promise<IInternalFetchResponse> { + if (!is_guid(category.id ?? "")) throw new Error("id is not a valid guid"); + if (!category.name) throw new Error("name is empty"); + if (!category.color) throw new Error("color is empty"); + return http_post(api_base("v1/categories/update"), category); +} diff --git a/apps/kit/src/lib/api/user.ts b/apps/kit/src/lib/api/user.ts new file mode 100644 index 0000000..f0dc932 --- /dev/null +++ b/apps/kit/src/lib/api/user.ts @@ -0,0 +1,47 @@ +import {api_base} from "$lib/configuration"; +import {http_delete, http_get, http_post} from "./internal-fetch"; +import type {LoginPayload} from "$lib/models/LoginPayload"; +import type {UpdateProfilePayload} from "$lib/models/UpdateProfilePayload"; +import type {CreateAccountPayload} from "$lib/models/CreateAccountPayload"; +import type {IInternalFetchResponse} from "$lib/models/IInternalFetchResponse"; + +export async function login(payload: LoginPayload): Promise<IInternalFetchResponse> { + return http_post(api_base("_/account/login"), payload); +} + +export async function logout(): Promise<IInternalFetchResponse> { + return http_get(api_base("_/account/logout")); +} + +export async function create_forgot_password_request(username: string): Promise<IInternalFetchResponse> { + if (!username) throw new Error("Username is empty"); + return http_get(api_base("_/forgot-password-requests/create?username=" + username)); +} + +export async function check_forgot_password_request(public_id: string): Promise<IInternalFetchResponse> { + if (!public_id) throw new Error("Id is empty"); + return http_get(api_base("_/forgot-password-requests/is-valid?id=" + public_id)); +} + +export async function fulfill_forgot_password_request(public_id: string, newPassword: string): Promise<IInternalFetchResponse> { + if (!public_id) throw new Error("Id is empty"); + return http_post(api_base("_/forgot-password-requests/fulfill"), {id: public_id, newPassword}); +} + +export async function delete_account(): Promise<IInternalFetchResponse> { + return http_delete(api_base("_/account/delete")); +} + +export async function update_profile(payload: UpdateProfilePayload): Promise<IInternalFetchResponse> { + if (!payload.password && !payload.username) throw new Error("Password and Username is empty"); + return http_post(api_base("_/account/update"), payload); +} + +export async function create_account(payload: CreateAccountPayload): Promise<IInternalFetchResponse> { + if (!payload.password && !payload.username) throw new Error("Password and Username is empty"); + return http_post(api_base("_/account/create"), payload); +} + +export async function get_profile_for_active_check(): Promise<IInternalFetchResponse> { + return http_get(api_base("_/account"), 0, true); +} diff --git a/apps/kit/src/lib/colors.ts b/apps/kit/src/lib/colors.ts new file mode 100644 index 0000000..34c7992 --- /dev/null +++ b/apps/kit/src/lib/colors.ts @@ -0,0 +1,47 @@ +export function generate_random_hex_color(skip_contrast_check = false) { + let hex = __generate_random_hex_color(); + if (skip_contrast_check) return hex; + while ((__calculate_contrast_ratio("#ffffff", hex) < 4.5) || (__calculate_contrast_ratio("#000000", hex) < 4.5)) { + hex = __generate_random_hex_color(); + } + + return hex; +} + +// Largely copied from chroma js api +function __generate_random_hex_color(): string { + let code = "#"; + for (let i = 0; i < 6; i++) { + code += "0123456789abcdef".charAt(Math.floor(Math.random() * 16)); + } + return code; +} + +function __calculate_contrast_ratio(hex1: string, hex2: string): number { + const rgb1 = __hex_to_rgb(hex1); + const rgb2 = __hex_to_rgb(hex2); + const l1 = __get_luminance(rgb1[0], rgb1[1], rgb1[2]); + const l2 = __get_luminance(rgb2[0], rgb2[1], rgb2[2]); + const result = l1 > l2 ? (l1 + 0.05) / (l2 + 0.05) : (l2 + 0.05) / (l1 + 0.05); + return result; +} + +function __hex_to_rgb(hex: string): number[] { + if (!hex.match(/^#([A-Fa-f0-9]{6})$/)) return []; + if (hex[0] === "#") hex = hex.substring(1, hex.length); + return [parseInt(hex.substring(0, 2), 16), parseInt(hex.substring(2, 4), 16), parseInt(hex.substring(4, 6), 16)]; +} + +function __get_luminance(r: any, g: any, b: any) { + // relative luminance + // see http://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef + r = __luminance_x(r); + g = __luminance_x(g); + b = __luminance_x(b); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +function __luminance_x(x: any) { + x /= 255; + return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4); +} diff --git a/apps/kit/src/lib/components/alert.svelte b/apps/kit/src/lib/components/alert.svelte new file mode 100644 index 0000000..4a5c7ea --- /dev/null +++ b/apps/kit/src/lib/components/alert.svelte @@ -0,0 +1,149 @@ +<script lang="ts"> + import { random_string } from "$shared/lib/helpers"; + import { afterUpdate, onMount } from "svelte"; + import { Temporal } from "temporal-polyfill"; + + const noCooldownSetting = "no-cooldown"; + // if no unique id is supplied, cooldown will not work between page loads. + // Therefore we are disabling it with noCooldownSetting in the fallback id. + export let id = "alert--" + noCooldownSetting + "--" + random_string(4); + export let title = ""; + export let message = ""; + export let type = "info"; + export let closeable = false; + export let closeableCooldown = "-1"; + export let visible = true; + + const cooldownStorageKey = "lastseen--" + id; + $: cooldownEnabled = + id.indexOf(noCooldownSetting) === -1 && + closeable && + (closeableCooldown === "~" || parseInt(closeableCooldown) > 0); + + function close() { + visible = false; + if (cooldownEnabled) { + console.log( + "Cooldown enabled for " + id + ", " + closeableCooldown === "~" + ? "with an endless cooldown" + : "" + ); + localStorage.setItem( + cooldownStorageKey, + String(Temporal.Now.instant().epochSeconds) + ); + } + } + + // Manages the state of the alert if cooldown is enabled + function run_cooldown() { + if (!cooldownEnabled) { + console.log("Alert cooldown is not enabled for " + id); + return; + } + if (!localStorage.getItem(cooldownStorageKey)) { + console.log("Alert " + id + " has not been seen yet, displaying"); + visible = true; + return; + } + if (!visible) { + console.log( + "Alert " + id + " is not visible, stopping cooldown change" + ); + return; + } + if (closeableCooldown === "~") { + console.log("Alert " + id + " has an infinite cooldown, hiding"); + visible = false; + return; + } + + const lastSeen = Temporal.Instant.fromEpochSeconds( + localStorage.getItem(cooldownStorageKey) as number + ); + if ( + Temporal.Instant.compare( + Temporal.Now.instant(), + lastSeen.add({ seconds: parseInt(closeableCooldown) }) + ) === 1 + ) { + console.log( + "Alert " + + id + + " has a cooldown of " + + closeableCooldown + + " and was last seen " + + lastSeen.toLocaleString() + + " making it due for a showing" + ); + visible = true; + } else { + visible = false; + } + } + + onMount(() => { + if (cooldownEnabled) { + run_cooldown(); + } + }); + + afterUpdate(() => { + if (type === "default") { + type = "primary"; + } + }); +</script> + +<div + class="alert alert--{type} padding-sm radius-md" + {id} + class:alert--is-visible={visible} + role="alert" +> + <div class="flex justify-between"> + <div class="flex flex-row items-center"> + <svg + class="icon icon--sm alert__icon margin-right-xxs" + viewBox="0 0 24 24" + aria-hidden="true" + > + <path + d="M12,0C5.383,0,0,5.383,0,12s5.383,12,12,12s12-5.383,12-12S18.617,0,12,0z M14.658,18.284 c-0.661,0.26-2.952,1.354-4.272,0.191c-0.394-0.346-0.59-0.785-0.59-1.318c0-0.998,0.328-1.868,0.919-3.957 c0.104-0.395,0.231-0.907,0.231-1.313c0-0.701-0.266-0.887-0.987-0.887c-0.352,0-0.742,0.125-1.095,0.257l0.195-0.799 c0.787-0.32,1.775-0.71,2.621-0.71c1.269,0,2.203,0.633,2.203,1.837c0,0.347-0.06,0.955-0.186,1.375l-0.73,2.582 c-0.151,0.522-0.424,1.673-0.001,2.014c0.416,0.337,1.401,0.158,1.887-0.071L14.658,18.284z M13.452,8c-0.828,0-1.5-0.672-1.5-1.5 s0.672-1.5,1.5-1.5s1.5,0.672,1.5,1.5S14.28,8,13.452,8z" + /> + </svg> + {#if title} + <p class="text-sm"> + <strong class="error-title">{title}</strong> + </p> + {:else if message} + <div class="text-component text-sm break-word"> + {@html message} + </div> + {/if} + </div> + {#if closeable} + <button class="reset alert__close-btn" on:click={close}> + <svg + class="icon" + viewBox="0 0 20 20" + fill="none" + stroke="currentColor" + stroke-linecap="round" + stroke-linejoin="round" + stroke-width="2" + > + <title>Close alert</title> + <line x1="3" y1="3" x2="17" y2="17" /> + <line x1="17" y1="3" x2="3" y2="17" /> + </svg> + </button> + {/if} + </div> + + {#if message && title} + <div class="text-component text-sm break-word padding-top-xs"> + {@html message} + </div> + {/if} +</div> diff --git a/apps/kit/src/lib/components/button.svelte b/apps/kit/src/lib/components/button.svelte new file mode 100644 index 0000000..5550e5e --- /dev/null +++ b/apps/kit/src/lib/components/button.svelte @@ -0,0 +1,72 @@ +<script lang="ts"> + export type ButtonKind = "primary" | "secondary" | "white" + export type ButtonSize = "xs" | "sm" | "md" | "lg" | "xl"; + export let kind = "primary" as ButtonKind; + export let size = "sm" as ButtonSize; + export let type: "button" | "submit" | "reset" = "button"; + export let id = undefined; + export let tabindex = undefined; + export let style = undefined; + export let title = undefined; + export let disabled = false; + export let href = undefined; + export let text; + + let sizeClasses = "px-3 py-2 text-xs"; + let kindClasses = "border-transparent text-white bg-indigo-600 hover:bg-indigo-700 focus:ring-indigo-500"; + + $: shared_props = { + type: type, + id: id || null, + title: title || null, + disabled: disabled || null, + tabindex: tabindex || null, + style: style || null, + }; + + $: switch (size) { + case "xs": + sizeClasses = "px-2.5 py-1.5 text-xs"; + break; + case "sm": + sizeClasses = "px-3 py-2 text-sm"; + break; + case "md": + sizeClasses = "px-4 py-2 text-sm"; + break; + case "lg": + sizeClasses = "px-4 py-2 text-base"; + break; + case "xl": + sizeClasses = "px-6 py-3 text-base"; + break; + } + + $: switch (kind) { + case "secondary": + kindClasses = "border-transparent text-indigo-700 bg-indigo-100 hover:bg-indigo-200"; + break; + case "primary": + kindClasses = "border-transparent text-white bg-indigo-600 hover:bg-indigo-700"; + break; + case "white": + kindClasses = "border-gray-300 text-gray-700 bg-white hover:bg-gray-50"; + break; + } +</script> +{#if href && !disabled} + <a {...shared_props} + {href} + on:click + {type} + class="{sizeClasses} {kindClasses} inline-flex items-center border font-medium rounded shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"> + {text} + </a> +{:else} + <button {...shared_props} + on:click + {type} + class="{sizeClasses} {kindClasses} inline-flex items-center border font-medium rounded shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"> + {text} + </button> +{/if}
\ No newline at end of file diff --git a/apps/kit/src/lib/components/icons/adjustments.svelte b/apps/kit/src/lib/components/icons/adjustments.svelte new file mode 100644 index 0000000..b6d3f4d --- /dev/null +++ b/apps/kit/src/lib/components/icons/adjustments.svelte @@ -0,0 +1,5 @@ +<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 {$$restProps.class??''}" fill="none" viewBox="0 0 24 24" + stroke="currentColor" stroke-width="2"> + <path stroke-linecap="round" stroke-linejoin="round" + d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/> +</svg>
\ No newline at end of file diff --git a/apps/kit/src/lib/components/icons/database.svelte b/apps/kit/src/lib/components/icons/database.svelte new file mode 100644 index 0000000..05c70ed --- /dev/null +++ b/apps/kit/src/lib/components/icons/database.svelte @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 {$$restProps.class ?? ''}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> + <path stroke-linecap="round" stroke-linejoin="round" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" /> +</svg>
\ No newline at end of file diff --git a/apps/kit/src/lib/components/icons/home.svelte b/apps/kit/src/lib/components/icons/home.svelte new file mode 100644 index 0000000..cc49c4d --- /dev/null +++ b/apps/kit/src/lib/components/icons/home.svelte @@ -0,0 +1,5 @@ +<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 {$$restProps.class ?? ''}" fill="none" viewBox="0 0 24 24" + stroke="currentColor" stroke-width="2"> + <path stroke-linecap="round" stroke-linejoin="round" + d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/> +</svg>
\ No newline at end of file diff --git a/apps/kit/src/lib/components/icons/index.ts b/apps/kit/src/lib/components/icons/index.ts new file mode 100644 index 0000000..d3abf24 --- /dev/null +++ b/apps/kit/src/lib/components/icons/index.ts @@ -0,0 +1,13 @@ +import XIcon from "./x.svelte"; +import MenuIcon from "./menu.svelte"; +import AdjustmentsIcon from "./adjustments.svelte"; +import DatabaseIcon from "./database.svelte"; +import HomeIcon from "./home.svelte"; + +export { + XIcon, + MenuIcon, + HomeIcon, + DatabaseIcon, + AdjustmentsIcon +}
\ No newline at end of file diff --git a/apps/kit/src/lib/components/icons/menu.svelte b/apps/kit/src/lib/components/icons/menu.svelte new file mode 100644 index 0000000..12a68a5 --- /dev/null +++ b/apps/kit/src/lib/components/icons/menu.svelte @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 {$$restProps.class ?? ''}" fill="none" viewBox="0 0 24 24" stroke="currentColor" + stroke-width="2"> + <path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"/> +</svg> diff --git a/apps/kit/src/lib/components/icons/x.svelte b/apps/kit/src/lib/components/icons/x.svelte new file mode 100644 index 0000000..c7e05a8 --- /dev/null +++ b/apps/kit/src/lib/components/icons/x.svelte @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 {$$restProps.class ?? ''}" fill="none" viewBox="0 0 24 24" + stroke="currentColor" stroke-width="2"> + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/> +</svg>
\ No newline at end of file diff --git a/apps/kit/src/lib/components/locale-switcher.svelte b/apps/kit/src/lib/components/locale-switcher.svelte new file mode 100644 index 0000000..39d6168 --- /dev/null +++ b/apps/kit/src/lib/components/locale-switcher.svelte @@ -0,0 +1,52 @@ +<script lang="ts"> + import {browser} from "$app/environment"; + import {page} from "$app/stores"; + import {setLocale, locale} from "$lib/i18n/i18n-svelte"; + import type {Locales} from "$lib/i18n/i18n-types"; + import {locales} from "$lib/i18n/i18n-util"; + import {loadLocaleAsync} from "$lib/i18n/i18n-util.async"; + + const switchLocale = async ( + newLocale: Locales, + updateHistoryState = true, + ) => { + if (!newLocale || $locale === newLocale) return; + + // load new dictionary from server + await loadLocaleAsync(newLocale); + + // select locale + setLocale(newLocale); + + // update `lang` attribute + document.querySelector("html")?.setAttribute("lang", newLocale); + + //TODO set cookie that persists the locale + }; + + // update locale when navigating via browser back/forward buttons + const handlePopStateEvent = async ({state}: PopStateEvent) => + switchLocale(state.locale, false); + + // update locale when page store changes + $: if (browser) { + const lang = $page.params.lang as Locales; + switchLocale(lang, false); + } +</script> + +<svelte:window on:popstate={handlePopStateEvent}/> + +<ul> + {#each locales as l} + <li> + <button + type="button" + class:active={l === $locale} + on:click={() => switchLocale(l)} + > + {l} + </button> + </li> + {/each} +</ul> diff --git a/apps/kit/src/lib/configuration.ts b/apps/kit/src/lib/configuration.ts new file mode 100644 index 0000000..d6f6b4f --- /dev/null +++ b/apps/kit/src/lib/configuration.ts @@ -0,0 +1,45 @@ +export const TOP_BASE_DOMAIN = "greatoffice.app"; +export const BASE_DOMAIN = "dev.greatoffice.app"; +export const DEV_BASE_DOMAIN = "http://127.0.0.1"; +export const API_ADDRESS = "https://api." + BASE_DOMAIN; +export const DEV_API_ADDRESS = "http://127.0.0.1:5000"; +export const SECONDS_BETWEEN_SESSION_CHECK = 600; + +export function base_domain(path: string = ""): string { + return (is_development() ? DEV_BASE_DOMAIN : TOP_BASE_DOMAIN) + (path !== "" ? "/" + path : ""); +} + +export function api_base(path: string = ""): string { + return (is_development() ? DEV_API_ADDRESS : API_ADDRESS) + (path !== "" ? "/" + path : ""); +} + +export function is_development(): boolean { + // @ts-ignore + return import.meta.env.DEV; +} + +export function is_debug(): boolean { + return localStorage.getItem(StorageKeys.debug) !== "true"; +} + +export const CookieNames = { + theme: "go_theme", + locale: "go_locale" +}; + +export const QueryKeys = { + labels: "labels", + categories: "categories", + entries: "entries", +}; + +export const StorageKeys = { + session: "sessionData", + theme: "theme", + debug: "debug", + categories: "categories", + labels: "labels", + entries: "entries", + stopwatch: "stopwatchState", + logLevel: "logLevel" +};
\ No newline at end of file diff --git a/apps/kit/src/lib/helpers.ts b/apps/kit/src/lib/helpers.ts new file mode 100644 index 0000000..f0f60cd --- /dev/null +++ b/apps/kit/src/lib/helpers.ts @@ -0,0 +1,493 @@ +import {browser} from "$app/environment"; +import type {TimeEntryDto} from "$lib/models/TimeEntryDto"; +import type {UnwrappedEntryDateTime} from "$lib/models/UnwrappedEntryDateTime"; +import {logInfo} from "$lib/logger"; +import {Temporal} from "temporal-polyfill"; + +export const EMAIL_REGEX = new RegExp(/^([a-z0-9]+(?:([._\-])[a-z0-9]+)*@(?:[a-z0-9]+(?:(-)[a-z0-9]+)?\.)+[a-z0-9](?:[a-z0-9]*[a-z0-9])?)$/i); +export const URL_REGEX = new RegExp(/^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?[a-z0-9]+([\-.][a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$/gm); +export const GUID_REGEX = new RegExp(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i); +export const NORWEGIAN_PHONE_NUMBER_REGEX = new RegExp(/(0047|\+47|47)?\d{8,12}/); + +export function get_default_sorted(unsorted: Array<TimeEntryDto>): Array<TimeEntryDto> { + if (unsorted.length < 1) return unsorted; + const byStart = unsorted.sort((a, b) => { + return Temporal.Instant.compare(Temporal.Instant.from(b.start), Temporal.Instant.from(a.start)); + }); + + return byStart.sort((a, b) => { + return Temporal.Instant.compare(Temporal.Instant.from(b.stop), Temporal.Instant.from(a.stop)); + }); +} + +export function is_email(value: string): boolean { + return EMAIL_REGEX.test(String(value).toLowerCase()); +} + +export function is_url(value: string): boolean { + return URL_REGEX.test(String(value).toLowerCase()); +} + +export function is_norwegian_phone_number(value: string): boolean { + if (value.length < 8 || value.length > 12) { + return false; + } + return NORWEGIAN_PHONE_NUMBER_REGEX.test(String(value)); +} + +// export function switch_theme() { +// const html = document.querySelector("html"); +// if (html.dataset.theme === "dark") { +// html.dataset.theme = "light"; +// } else { +// html.dataset.theme = "dark"; +// } +// set_cookie(CookieNames.theme, html.dataset.theme, base_domain()); +// } + +export function get_cookie(name: string) { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop()?.split(";").shift(); +} + +export function set_cookie(name: string, value: string, baseDomain = window.location.host) { + document.cookie = name + "=" + encodeURIComponent(value) + (baseDomain ? ";domain=" + baseDomain : ""); +} + +export function unwrap_date_time_from_entry(entry: TimeEntryDto): UnwrappedEntryDateTime { + if (!entry) throw new Error("entry was undefined"); + const currentTimeZone = Temporal.Now.timeZone().id; + const startInstant = Temporal.Instant.from(entry.start).toZonedDateTimeISO(currentTimeZone); + const stopInstant = Temporal.Instant.from(entry.stop).toZonedDateTimeISO(currentTimeZone); + + return { + start_date: startInstant.toPlainDate(), + stop_date: stopInstant.toPlainDate(), + start_time: startInstant.toPlainTime(), + stop_time: stopInstant.toPlainTime(), + duration: Temporal.Duration.from({ + hours: stopInstant.hour, + minutes: stopInstant.minute, + }).subtract(Temporal.Duration.from({ + hours: startInstant.hour, + minutes: startInstant.minute, + })), + }; +} + + +export function is_guid(value: string): boolean { + if (!value) { + return false; + } + if (value[0] === "{") { + value = value.substring(1, value.length - 1); + } + return GUID_REGEX.test(value); +} + +export function is_empty_object(obj: object): boolean { + return obj !== void 0 && Object.keys(obj).length > 0; +} + +export function merge_obj_arr<T>(a: Array<T>, b: Array<T>, props: Array<string>): Array<T> { + let start = 0; + let merge = []; + + while (start < a.length) { + + if (a[start] === b[start]) { + //pushing the merged objects into array + merge.push({...a[start], ...b[start]}); + } + //incrementing start value + start = start + 1; + } + return merge; +} + +export function set_favicon(url: string) { + // Find the current favicon element + const favicon = document.querySelector("link[rel=\"icon\"]") as HTMLLinkElement; + if (favicon) { + // Update the new link + favicon.href = url; + } else { + // Create new `link` + const link = document.createElement("link"); + link.rel = "icon"; + link.href = url; + + // Append to the `head` element + document.head.appendChild(link); + } +} + +export function set_emoji_favicon(emoji: string) { + // Create a canvas element + const canvas = document.createElement("canvas"); + canvas.height = 64; + canvas.width = 64; + + // Get the canvas context + const context = canvas.getContext("2d") as CanvasRenderingContext2D; + context.font = "64px serif"; + context.fillText(emoji, 0, 64); + + // Get the custom URL + const url = canvas.toDataURL(); + + // Update the favicon + set_favicon(url); +} + + +// https://stackoverflow.com/a/48400665/11961742 +export function seconds_to_hour_minute_string(seconds: number, hourChar = "h", minuteChar = "m") { + const hours = Math.floor(seconds / (60 * 60)); + seconds -= hours * (60 * 60); + const minutes = Math.floor(seconds / 60); + return hours + "h" + minutes + "m"; +} + +export function seconds_to_hour_minute(seconds: number) { + const hours = Math.floor(seconds / (60 * 60)); + seconds -= hours * (60 * 60); + const minutes = Math.floor(seconds / 60); + return {hours, minutes}; +} + +export function get_query_string(params: any = {}): string { + const map = Object.keys(params).reduce((arr: Array<string>, key: string) => { + if (params[key] !== undefined) { + return arr.concat(`${key}=${encodeURIComponent(params[key])}`); + } + return arr; + }, [] as any); + + if (map.length) { + return `?${map.join("&")}`; + } + + return ""; +} + +export function make_url(url: string, params: object): string { + return `${url}${get_query_string(params)}`; +} + +export function noop() { +} + +export async function run_async(functionToRun: Function): Promise<any> { + return new Promise((greatSuccess, graveFailure) => { + try { + greatSuccess(functionToRun()); + } catch (exception) { + graveFailure(exception); + } + }); +} + +// https://stackoverflow.com/a/45215694/11961742 +export function get_selected_options(domElement: HTMLSelectElement): Array<string> { + const ret = []; + + // fast but not universally supported + if (domElement.selectedOptions !== undefined) { + for (let i = 0; i < domElement.selectedOptions.length; i++) { + ret.push(domElement.selectedOptions[i].value); + } + + // compatible, but can be painfully slow + } else { + for (let i = 0; i < domElement.options.length; i++) { + if (domElement.options[i].selected) { + ret.push(domElement.options[i].value); + } + } + } + return ret; +} + +export function random_string(length: number): string { + if (!length) { + throw new Error("length is undefined"); + } + let result = ""; + const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const charactersLength = characters.length; + for (let i = 0; i < length; i++) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + } + return result; +} + +interface CreateElementOptions { + name: string, + properties?: object, + children?: Array<HTMLElement | Function | Node> +} + +export function create_element_from_object(elementOptions: CreateElementOptions): HTMLElement { + return create_element(elementOptions.name, elementOptions.properties, elementOptions.children); +} + +export function create_element(name: string, properties?: object, children?: Array<HTMLElement | any>): HTMLElement { + if (!name || name.length < 1) { + throw new Error("name is required"); + } + const node = document.createElement(name); + if (properties) { + for (const [key, value] of Object.entries(properties)) { + // @ts-ignore + node[key] = value; + } + } + + if (children && children.length > 0) { + let actualChildren = children; + if (typeof children === "function") { + // @ts-ignore + actualChildren = children(); + } + for (const child of actualChildren) { + node.appendChild(child as Node); + } + } + return node; +} + +export function get_element_position(element: HTMLElement | any) { + if (!element) return {x: 0, y: 0}; + let x = 0; + let y = 0; + while (true) { + x += element.offsetLeft; + y += element.offsetTop; + if (element.offsetParent === null) { + break; + } + element = element.offsetParent; + } + return {x, y}; +} + +export function restrict_input_to_numbers(element: HTMLElement, specials: Array<string> = [], mergeSpecialsWithDefaults: boolean = false): void { + if (element) { + element.addEventListener("keydown", (e) => { + const defaultSpecials = ["Backspace", "ArrowLeft", "ArrowRight", "Tab"]; + let keys = specials.length > 0 ? specials : defaultSpecials; + if (mergeSpecialsWithDefaults && specials) { + keys = [...specials, ...defaultSpecials]; + } + if (keys.indexOf(e.key) !== -1) { + return; + } + if (isNaN(parseInt(e.key))) { + e.preventDefault(); + } + }); + } +} + +export function element_has_focus(element: HTMLElement): boolean { + return element === document.activeElement; +} + +export function move_focus(element: HTMLElement): void { + if (!element) { + element = document.getElementsByTagName("body")[0]; + } + element.focus(); + // @ts-ignore + if (!element_has_focus(element)) { + element.setAttribute("tabindex", "-1"); + element.focus(); + } +} + +export function get_url_parameter(name: string): string { + // @ts-ignore + return new RegExp("[?&]" + name + "=([^&#]*)")?.exec(window.location.href)[1]; +} + +export function update_url_parameter(param: string, newVal: string): void { + let newAdditionalURL = ""; + let tempArray = location.href.split("?"); + const baseURL = tempArray[0]; + const additionalURL = tempArray[1]; + let temp = ""; + if (additionalURL) { + tempArray = additionalURL.split("&"); + for (let i = 0; i < tempArray.length; i++) { + if (tempArray[i].split("=")[0] !== param) { + newAdditionalURL += temp + tempArray[i]; + temp = "&"; + } + } + } + const rows_txt = temp + "" + param + "=" + newVal; + const newUrl = baseURL + "?" + newAdditionalURL + rows_txt; + window.history.replaceState("", "", newUrl); +} + + +export function get_style_string(rules: CSSRuleList) { + let styleString = ""; + for (const [key, value] of Object.entries(rules)) { + styleString += key + ":" + value + ";"; + } + return styleString; +} + +export function parse_iso_local(s: string) { + const b = s.split(/\D/); + //@ts-ignore + return new Date(b[0], b[1] - 1, b[2], b[3], b[4], b[5]); +} + +export function resolve_references(json: any) { + if (!json) return; + if (typeof json === "string") { + json = JSON.parse(json ?? "{}"); + } + const byid = {}, refs = []; + json = function recurse(obj, prop, parent) { + if (typeof obj !== "object" || !obj) { + return obj; + } + if (Object.prototype.toString.call(obj) === "[object Array]") { + for (let i = 0; i < obj.length; i++) { + if (typeof obj[i] !== "object" || !obj[i]) { + continue; + } else if ("$ref" in obj[i]) { + // @ts-ignore + obj[i] = recurse(obj[i], i, obj); + } else { + obj[i] = recurse(obj[i], prop, obj); + } + } + return obj; + } + if ("$ref" in obj) { + let ref = obj.$ref; + if (ref in byid) { + // @ts-ignore + return byid[ref]; + } + refs.push([parent, prop, ref]); + return; + } else if ("$id" in obj) { + let id = obj.$id; + delete obj.$id; + if ("$values" in obj) { + obj = obj.$values.map(recurse); + } else { + for (let prop2 in obj) { + // @ts-ignore + obj[prop2] = recurse(obj[prop2], prop2, obj); + } + } + // @ts-ignore + byid[id] = obj; + } + return obj; + }(json); + for (let i = 0; i < refs.length; i++) { + let ref = refs[i]; + // @ts-ignore + ref[0][ref[1]] = byid[ref[2]]; + } + return json; +} + +export function get_random_int(min: number, max: number): number { + min = Math.ceil(min); + max = Math.floor(max); + return Math.floor(Math.random() * (max - min + 1)) + min; +} + +export function to_readable_bytes(bytes: number): string { + const s = ["bytes", "kB", "MB", "GB", "TB", "PB"]; + const e = Math.floor(Math.log(bytes) / Math.log(1024)); + return (bytes / Math.pow(1024, e)).toFixed(2) + " " + s[e]; +} + +export function can_use_dom(): boolean { + return !!(typeof window !== "undefined" && window.document && window.document.createElement); +} + +export function session_storage_remove_regex(regex: RegExp): void { + if (!browser) { + logInfo("sessionStorage is not available in non-browser contexts"); + return; + } + let n = sessionStorage.length; + while (n--) { + const key = sessionStorage.key(n); + if (key && regex.test(key)) { + sessionStorage.removeItem(key); + } + } +} + +export function local_storage_remove_regex(regex: RegExp): void { + if (!browser) { + logInfo("sessionStorage is not available in non-browser contexts"); + return; + } + let n = localStorage.length; + while (n--) { + const key = localStorage.key(n); + if (key && regex.test(key)) { + localStorage.removeItem(key); + } + } +} + +export function session_storage_set_json(key: string, value: object): void { + if (!browser) { + console.warn("sessionStorage is not available in non-browser contexts"); + return; + } + sessionStorage.setItem(key, JSON.stringify(value)); +} + +export function session_storage_get_json(key: string): object { + if (!browser) { + console.warn("sessionStorage is not available in non-browser contexts"); + return {}; + } + return JSON.parse(sessionStorage.getItem(key) ?? "{}"); +} + +export function local_storage_set_json(key: string, value: object): void { + if (!browser) { + console.warn("sessionStorage is not available in non-browser contexts"); + return; + } + localStorage.setItem(key, JSON.stringify(value)); +} + +export function local_storage_get_json(key: string): object { + if (!browser) { + console.warn("sessionStorage is not available in non-browser contexts"); + return {}; + } + return JSON.parse(localStorage.getItem(key) ?? "{}"); +} + +export function get_hash_code(value: string): number | undefined { + let hash = 0; + if (value.length === 0) { + return; + } + for (let i = 0; i < value.length; i++) { + const char = value.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash |= 0; + } + return hash; +} diff --git a/apps/kit/src/lib/i18n/en/index.ts b/apps/kit/src/lib/i18n/en/index.ts new file mode 100644 index 0000000..f3def1b --- /dev/null +++ b/apps/kit/src/lib/i18n/en/index.ts @@ -0,0 +1,136 @@ +import type {BaseTranslation} from "../i18n-types"; + +const en: BaseTranslation = { + nav: { + home: "Home", + data: "Data", + settings: "Settings", + usermenu: { + logout: "Log out", + logoutTitle: "Log out of your profile", + profile: "Profile", + profileTitle: "Administrate your profile", + toggleTitle: "Toggle user menu", + } + }, + views: { + dataTablePaginator: { + goToPrevPage: "Go to previous page", + goToNextPage: "Go to next page", + of: "of", + }, + categoryForm: { + name: "Name", + color: "Color", + defaultLabels: "Default labels", + labelsPlaceholder: "Search or create" + }, + settingsCategoriesTile: { + deleteAllConfirm: "Are you sure you want to delete this category?\nThis will delete all relating entries!", + active: "Active", + archived: "Archived", + name: "Name", + color: "Color", + editEntry: "Edit entry", + deleteEntry: "Delete entry", + noCategories: "No categories", + categories: "Categories" + }, + settingsLabelsTile: { + deleteAllConfirm: "Are you sure you want to delete this label?\nIt will be removed from all related entries!", + active: "Active", + archived: "Archived", + name: "Name", + color: "Color", + editEntry: "Edit label", + deleteEntry: "Delete label", + noLabels: "No labels", + labels: "Labels" + }, + entryForm: { + entryUpdateError: "An error occured while updating the entry, try again soon.", + entryCreateError: "An error occured while creating the entry, try again soon.", + errDescriptionReq: "Description is required", + reset: "Reset", + description: "Description", + save: "Save", + create: "Create", + category: { + category: "Category", + placeholder: "Search or create", + noResults: "No categories available (Create a new one by searching for it)", + errisRequired: "Category is required", + _logReset: "Reset category section" + }, + labels: { + placeholder: "Search or create", + noResults: "No labels available (Create a new one by searching for it)", + labels: "Labels", + _logReset: "Reset labels section" + }, + dateTime: { + errDateIsRequired: "Date is required", + errFromIsRequired: "From is required", + errFromAfterTo: "From can not be after To", + errFromEqTo: "From and To can not be equal", + errToIsRequired: "To is required", + errToBeforeFrom: "To can not be before From", + from: "From", + to: "To", + date: "Date", + _logReset: "Reset date time section" + } + } + }, + data: { + durationSummary: "Showing {entryCountString:string}, totalling in {totalHourMin:string}", + hourSingleChar: "h", + minSingleChar: "m", + entry: "entry", + entries: "entries", + confirmDeleteEntry: "Are you sure you want to delete this entry?", + editEntry: "Edit entry", + date: "Date", + from: "From", + duration: "Duration", + category: "Category", + description: "Description", + loading: "Loading", + noEntries: "No entries", + to: "to", + use: "Use", + }, + home: { + confirmDeleteEntry: "Are you sure you want to delete this entry?", + newEntry: "New entry", + editEntry: "Edit entry", + deleteEntry: "Delete entry", + loggedTimeToday: "Logged time today", + loggedTimeTodayString: "{hours}h{minutes}m", + currentTime: "Current time", + loading: "Loading", + stopwatch: "Stopwatch", + todayEntries: "Today's entries", + noEntriesToday: "No entries today", + refreshTodayEntries: "Refresh today's entries", + category: "Category", + timespan: "Timespan", + }, + messages: { + pageNotFound: "Page not found", + goToFrontpage: "Go to frontpage", + noInternet: "It seems like your device does not have a internet connection, please check your connection." + }, + login: { + loginToYourAccount: "Log in to your account", + or: "Or", + createANewAccount: "create a new account", + emailAddress: "Email address", + password: "Password", + notMyComputer: "This is not my computer", + forgotPassword: "Forgot your password?", + logIn: "Log in" + }, +}; + +export default en; diff --git a/apps/kit/src/lib/i18n/formatters.ts b/apps/kit/src/lib/i18n/formatters.ts new file mode 100644 index 0000000..78734f9 --- /dev/null +++ b/apps/kit/src/lib/i18n/formatters.ts @@ -0,0 +1,11 @@ +import type { FormattersInitializer } from 'typesafe-i18n' +import type { Locales, Formatters } from './i18n-types' + +export const initFormatters: FormattersInitializer<Locales, Formatters> = (locale: Locales) => { + + const formatters: Formatters = { + // add your formatter functions here + } + + return formatters +} diff --git a/apps/kit/src/lib/i18n/i18n-svelte.ts b/apps/kit/src/lib/i18n/i18n-svelte.ts new file mode 100644 index 0000000..6cdffb3 --- /dev/null +++ b/apps/kit/src/lib/i18n/i18n-svelte.ts @@ -0,0 +1,12 @@ +// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. +/* eslint-disable */ + +import { initI18nSvelte } from 'typesafe-i18n/svelte' +import type { Formatters, Locales, TranslationFunctions, Translations } from './i18n-types' +import { loadedFormatters, loadedLocales } from './i18n-util' + +const { locale, LL, setLocale } = initI18nSvelte<Locales, Translations, TranslationFunctions, Formatters>(loadedLocales, loadedFormatters) + +export { locale, LL, setLocale } + +export default LL diff --git a/apps/kit/src/lib/i18n/i18n-types.ts b/apps/kit/src/lib/i18n/i18n-types.ts new file mode 100644 index 0000000..f3e0f80 --- /dev/null +++ b/apps/kit/src/lib/i18n/i18n-types.ts @@ -0,0 +1,890 @@ +// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. +/* eslint-disable */ +import type { BaseTranslation as BaseTranslationType, LocalizedString, RequiredParams } from 'typesafe-i18n' + +export type BaseTranslation = BaseTranslationType +export type BaseLocale = 'en' + +export type Locales = + | 'en' + | 'nb' + +export type Translation = RootTranslation + +export type Translations = RootTranslation + +type RootTranslation = { + nav: { + /** + * Home + */ + home: string + /** + * Data + */ + data: string + /** + * Settings + */ + settings: string + usermenu: { + /** + * Log out + */ + logout: string + /** + * Log out of your profile + */ + logoutTitle: string + /** + * Profile + */ + profile: string + /** + * Administrate your profile + */ + profileTitle: string + /** + * Toggle user menu + */ + toggleTitle: string + } + } + views: { + dataTablePaginator: { + /** + * Go to previous page + */ + goToPrevPage: string + /** + * Go to next page + */ + goToNextPage: string + /** + * of + */ + of: string + } + categoryForm: { + /** + * Name + */ + name: string + /** + * Color + */ + color: string + /** + * Default labels + */ + defaultLabels: string + /** + * Search or create + */ + labelsPlaceholder: string + } + settingsCategoriesTile: { + /** + * Are you sure you want to delete this category? + This will delete all relating entries! + */ + deleteAllConfirm: string + /** + * Active + */ + active: string + /** + * Archived + */ + archived: string + /** + * Name + */ + name: string + /** + * Color + */ + color: string + /** + * Edit entry + */ + editEntry: string + /** + * Delete entry + */ + deleteEntry: string + /** + * No categories + */ + noCategories: string + /** + * Categories + */ + categories: string + } + settingsLabelsTile: { + /** + * Are you sure you want to delete this label? + It will be removed from all related entries! + */ + deleteAllConfirm: string + /** + * Active + */ + active: string + /** + * Archived + */ + archived: string + /** + * Name + */ + name: string + /** + * Color + */ + color: string + /** + * Edit label + */ + editEntry: string + /** + * Delete label + */ + deleteEntry: string + /** + * No labels + */ + noLabels: string + /** + * Labels + */ + labels: string + } + entryForm: { + /** + * An error occured while updating the entry, try again soon. + */ + entryUpdateError: string + /** + * An error occured while creating the entry, try again soon. + */ + entryCreateError: string + /** + * Description is required + */ + errDescriptionReq: string + /** + * Reset + */ + reset: string + /** + * Description + */ + description: string + /** + * Save + */ + save: string + /** + * Create + */ + create: string + category: { + /** + * Category + */ + category: string + /** + * Search or create + */ + placeholder: string + /** + * No categories available (Create a new one by searching for it) + */ + noResults: string + /** + * Category is required + */ + errisRequired: string + /** + * Reset category section + */ + _logReset: string + } + labels: { + /** + * Search or create + */ + placeholder: string + /** + * No labels available (Create a new one by searching for it) + */ + noResults: string + /** + * Labels + */ + labels: string + /** + * Reset labels section + */ + _logReset: string + } + dateTime: { + /** + * Date is required + */ + errDateIsRequired: string + /** + * From is required + */ + errFromIsRequired: string + /** + * From can not be after To + */ + errFromAfterTo: string + /** + * From and To can not be equal + */ + errFromEqTo: string + /** + * To is required + */ + errToIsRequired: string + /** + * To can not be before From + */ + errToBeforeFrom: string + /** + * From + */ + from: string + /** + * To + */ + to: string + /** + * Date + */ + date: string + /** + * Reset date time section + */ + _logReset: string + } + } + } + data: { + /** + * Showing {entryCountString}, totalling in {totalHourMin} + * @param {string} entryCountString + * @param {string} totalHourMin + */ + durationSummary: RequiredParams<'entryCountString' | 'totalHourMin'> + /** + * h + */ + hourSingleChar: string + /** + * m + */ + minSingleChar: string + /** + * entry + */ + entry: string + /** + * entries + */ + entries: string + /** + * Are you sure you want to delete this entry? + */ + confirmDeleteEntry: string + /** + * Edit entry + */ + editEntry: string + /** + * Date + */ + date: string + /** + * From + */ + from: string + /** + * Duration + */ + duration: string + /** + * Category + */ + category: string + /** + * Description + */ + description: string + /** + * Loading + */ + loading: string + /** + * No entries + */ + noEntries: string + /** + * to + */ + to: string + /** + * Use + */ + use: string + } + home: { + /** + * Are you sure you want to delete this entry? + */ + confirmDeleteEntry: string + /** + * New entry + */ + newEntry: string + /** + * Edit entry + */ + editEntry: string + /** + * Delete entry + */ + deleteEntry: string + /** + * Logged time today + */ + loggedTimeToday: string + /** + * {hours}h{minutes}m + * @param {unknown} hours + * @param {unknown} minutes + */ + loggedTimeTodayString: RequiredParams<'hours' | 'minutes'> + /** + * Current time + */ + currentTime: string + /** + * Loading + */ + loading: string + /** + * Stopwatch + */ + stopwatch: string + /** + * Today's entries + */ + todayEntries: string + /** + * No entries today + */ + noEntriesToday: string + /** + * Refresh today's entries + */ + refreshTodayEntries: string + /** + * Category + */ + category: string + /** + * Timespan + */ + timespan: string + } + messages: { + /** + * Page not found + */ + pageNotFound: string + /** + * Go to frontpage + */ + goToFrontpage: string + /** + * It seems like your device does not have a internet connection, please check your connection. + */ + noInternet: string + } + login: { + /** + * Log in to your account + */ + loginToYourAccount: string + /** + * Or + */ + or: string + /** + * create a new account + */ + createANewAccount: string + /** + * Email address + */ + emailAddress: string + /** + * Password + */ + password: string + /** + * This is not my computer + */ + notMyComputer: string + /** + * Forgot your password? + */ + forgotPassword: string + /** + * Log in + */ + logIn: string + } +} + +export type TranslationFunctions = { + nav: { + /** + * Home + */ + home: () => LocalizedString + /** + * Data + */ + data: () => LocalizedString + /** + * Settings + */ + settings: () => LocalizedString + usermenu: { + /** + * Log out + */ + logout: () => LocalizedString + /** + * Log out of your profile + */ + logoutTitle: () => LocalizedString + /** + * Profile + */ + profile: () => LocalizedString + /** + * Administrate your profile + */ + profileTitle: () => LocalizedString + /** + * Toggle user menu + */ + toggleTitle: () => LocalizedString + } + } + views: { + dataTablePaginator: { + /** + * Go to previous page + */ + goToPrevPage: () => LocalizedString + /** + * Go to next page + */ + goToNextPage: () => LocalizedString + /** + * of + */ + of: () => LocalizedString + } + categoryForm: { + /** + * Name + */ + name: () => LocalizedString + /** + * Color + */ + color: () => LocalizedString + /** + * Default labels + */ + defaultLabels: () => LocalizedString + /** + * Search or create + */ + labelsPlaceholder: () => LocalizedString + } + settingsCategoriesTile: { + /** + * Are you sure you want to delete this category? + This will delete all relating entries! + */ + deleteAllConfirm: () => LocalizedString + /** + * Active + */ + active: () => LocalizedString + /** + * Archived + */ + archived: () => LocalizedString + /** + * Name + */ + name: () => LocalizedString + /** + * Color + */ + color: () => LocalizedString + /** + * Edit entry + */ + editEntry: () => LocalizedString + /** + * Delete entry + */ + deleteEntry: () => LocalizedString + /** + * No categories + */ + noCategories: () => LocalizedString + /** + * Categories + */ + categories: () => LocalizedString + } + settingsLabelsTile: { + /** + * Are you sure you want to delete this label? + It will be removed from all related entries! + */ + deleteAllConfirm: () => LocalizedString + /** + * Active + */ + active: () => LocalizedString + /** + * Archived + */ + archived: () => LocalizedString + /** + * Name + */ + name: () => LocalizedString + /** + * Color + */ + color: () => LocalizedString + /** + * Edit label + */ + editEntry: () => LocalizedString + /** + * Delete label + */ + deleteEntry: () => LocalizedString + /** + * No labels + */ + noLabels: () => LocalizedString + /** + * Labels + */ + labels: () => LocalizedString + } + entryForm: { + /** + * An error occured while updating the entry, try again soon. + */ + entryUpdateError: () => LocalizedString + /** + * An error occured while creating the entry, try again soon. + */ + entryCreateError: () => LocalizedString + /** + * Description is required + */ + errDescriptionReq: () => LocalizedString + /** + * Reset + */ + reset: () => LocalizedString + /** + * Description + */ + description: () => LocalizedString + /** + * Save + */ + save: () => LocalizedString + /** + * Create + */ + create: () => LocalizedString + category: { + /** + * Category + */ + category: () => LocalizedString + /** + * Search or create + */ + placeholder: () => LocalizedString + /** + * No categories available (Create a new one by searching for it) + */ + noResults: () => LocalizedString + /** + * Category is required + */ + errisRequired: () => LocalizedString + /** + * Reset category section + */ + _logReset: () => LocalizedString + } + labels: { + /** + * Search or create + */ + placeholder: () => LocalizedString + /** + * No labels available (Create a new one by searching for it) + */ + noResults: () => LocalizedString + /** + * Labels + */ + labels: () => LocalizedString + /** + * Reset labels section + */ + _logReset: () => LocalizedString + } + dateTime: { + /** + * Date is required + */ + errDateIsRequired: () => LocalizedString + /** + * From is required + */ + errFromIsRequired: () => LocalizedString + /** + * From can not be after To + */ + errFromAfterTo: () => LocalizedString + /** + * From and To can not be equal + */ + errFromEqTo: () => LocalizedString + /** + * To is required + */ + errToIsRequired: () => LocalizedString + /** + * To can not be before From + */ + errToBeforeFrom: () => LocalizedString + /** + * From + */ + from: () => LocalizedString + /** + * To + */ + to: () => LocalizedString + /** + * Date + */ + date: () => LocalizedString + /** + * Reset date time section + */ + _logReset: () => LocalizedString + } + } + } + data: { + /** + * Showing {entryCountString}, totalling in {totalHourMin} + */ + durationSummary: (arg: { entryCountString: string, totalHourMin: string }) => LocalizedString + /** + * h + */ + hourSingleChar: () => LocalizedString + /** + * m + */ + minSingleChar: () => LocalizedString + /** + * entry + */ + entry: () => LocalizedString + /** + * entries + */ + entries: () => LocalizedString + /** + * Are you sure you want to delete this entry? + */ + confirmDeleteEntry: () => LocalizedString + /** + * Edit entry + */ + editEntry: () => LocalizedString + /** + * Date + */ + date: () => LocalizedString + /** + * From + */ + from: () => LocalizedString + /** + * Duration + */ + duration: () => LocalizedString + /** + * Category + */ + category: () => LocalizedString + /** + * Description + */ + description: () => LocalizedString + /** + * Loading + */ + loading: () => LocalizedString + /** + * No entries + */ + noEntries: () => LocalizedString + /** + * to + */ + to: () => LocalizedString + /** + * Use + */ + use: () => LocalizedString + } + home: { + /** + * Are you sure you want to delete this entry? + */ + confirmDeleteEntry: () => LocalizedString + /** + * New entry + */ + newEntry: () => LocalizedString + /** + * Edit entry + */ + editEntry: () => LocalizedString + /** + * Delete entry + */ + deleteEntry: () => LocalizedString + /** + * Logged time today + */ + loggedTimeToday: () => LocalizedString + /** + * {hours}h{minutes}m + */ + loggedTimeTodayString: (arg: { hours: unknown, minutes: unknown }) => LocalizedString + /** + * Current time + */ + currentTime: () => LocalizedString + /** + * Loading + */ + loading: () => LocalizedString + /** + * Stopwatch + */ + stopwatch: () => LocalizedString + /** + * Today's entries + */ + todayEntries: () => LocalizedString + /** + * No entries today + */ + noEntriesToday: () => LocalizedString + /** + * Refresh today's entries + */ + refreshTodayEntries: () => LocalizedString + /** + * Category + */ + category: () => LocalizedString + /** + * Timespan + */ + timespan: () => LocalizedString + } + messages: { + /** + * Page not found + */ + pageNotFound: () => LocalizedString + /** + * Go to frontpage + */ + goToFrontpage: () => LocalizedString + /** + * It seems like your device does not have a internet connection, please check your connection. + */ + noInternet: () => LocalizedString + } + login: { + /** + * Log in to your account + */ + loginToYourAccount: () => LocalizedString + /** + * Or + */ + or: () => LocalizedString + /** + * create a new account + */ + createANewAccount: () => LocalizedString + /** + * Email address + */ + emailAddress: () => LocalizedString + /** + * Password + */ + password: () => LocalizedString + /** + * This is not my computer + */ + notMyComputer: () => LocalizedString + /** + * Forgot your password? + */ + forgotPassword: () => LocalizedString + /** + * Log in + */ + logIn: () => LocalizedString + } +} + +export type Formatters = {} diff --git a/apps/kit/src/lib/i18n/i18n-util.async.ts b/apps/kit/src/lib/i18n/i18n-util.async.ts new file mode 100644 index 0000000..3ccef5f --- /dev/null +++ b/apps/kit/src/lib/i18n/i18n-util.async.ts @@ -0,0 +1,27 @@ +// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. +/* eslint-disable */ + +import { initFormatters } from './formatters' +import type { Locales, Translations } from './i18n-types' +import { loadedFormatters, loadedLocales, locales } from './i18n-util' + +const localeTranslationLoaders = { + en: () => import('./en'), + nb: () => import('./nb'), +} + +const updateDictionary = (locale: Locales, dictionary: Partial<Translations>) => + loadedLocales[locale] = { ...loadedLocales[locale], ...dictionary } + +export const importLocaleAsync = async (locale: Locales) => + (await localeTranslationLoaders[locale]()).default as unknown as Translations + +export const loadLocaleAsync = async (locale: Locales): Promise<void> => { + updateDictionary(locale, await importLocaleAsync(locale)) + loadFormatters(locale) +} + +export const loadAllLocalesAsync = (): Promise<void[]> => Promise.all(locales.map(loadLocaleAsync)) + +export const loadFormatters = (locale: Locales): void => + void (loadedFormatters[locale] = initFormatters(locale)) diff --git a/apps/kit/src/lib/i18n/i18n-util.sync.ts b/apps/kit/src/lib/i18n/i18n-util.sync.ts new file mode 100644 index 0000000..f1a8e9e --- /dev/null +++ b/apps/kit/src/lib/i18n/i18n-util.sync.ts @@ -0,0 +1,26 @@ +// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. +/* eslint-disable */ + +import { initFormatters } from './formatters' +import type { Locales, Translations } from './i18n-types' +import { loadedFormatters, loadedLocales, locales } from './i18n-util' + +import en from './en' +import nb from './nb' + +const localeTranslations = { + en, + nb, +} + +export const loadLocale = (locale: Locales): void => { + if (loadedLocales[locale]) return + + loadedLocales[locale] = localeTranslations[locale] as unknown as Translations + loadFormatters(locale) +} + +export const loadAllLocales = (): void => locales.forEach(loadLocale) + +export const loadFormatters = (locale: Locales): void => + void (loadedFormatters[locale] = initFormatters(locale)) diff --git a/apps/kit/src/lib/i18n/i18n-util.ts b/apps/kit/src/lib/i18n/i18n-util.ts new file mode 100644 index 0000000..11d4b23 --- /dev/null +++ b/apps/kit/src/lib/i18n/i18n-util.ts @@ -0,0 +1,33 @@ +// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. +/* eslint-disable */ + +import { i18n as initI18n, i18nObject as initI18nObject, i18nString as initI18nString } from 'typesafe-i18n' +import type { LocaleDetector } from 'typesafe-i18n/detectors' +import { detectLocale as detectLocaleFn } from 'typesafe-i18n/detectors' +import type { Formatters, Locales, Translations, TranslationFunctions } from './i18n-types' + +export const baseLocale: Locales = 'en' + +export const locales: Locales[] = [ + 'en', + 'nb' +] + +export const isLocale = (locale: string) => locales.includes(locale as Locales) + +export const loadedLocales = {} as Record<Locales, Translations> + +export const loadedFormatters = {} as Record<Locales, Formatters> + +export const i18nString = (locale: Locales) => initI18nString<Locales, Formatters>(locale, loadedFormatters[locale]) + +export const i18nObject = (locale: Locales) => + initI18nObject<Locales, Translations, TranslationFunctions, Formatters>( + locale, + loadedLocales[locale], + loadedFormatters[locale] + ) + +export const i18n = () => initI18n<Locales, Translations, TranslationFunctions, Formatters>(loadedLocales, loadedFormatters) + +export const detectLocale = (...detectors: LocaleDetector[]) => detectLocaleFn<Locales>(baseLocale, locales, ...detectors) diff --git a/apps/kit/src/lib/i18n/nb/index.ts b/apps/kit/src/lib/i18n/nb/index.ts new file mode 100644 index 0000000..b350994 --- /dev/null +++ b/apps/kit/src/lib/i18n/nb/index.ts @@ -0,0 +1,136 @@ +import type {Translation} from "../i18n-types"; + +const nb: Translation = { + nav: { + home: "Hjem", + data: "Data", + settings: "Innstillinger", + usermenu: { + logout: "Logg ut", + logoutTitle: "Logg ut av din profil", + profile: "Profil", + profileTitle: "Administrer din profil", + toggleTitle: "Vis brukermeny" + } + }, + views: { + categoryForm: { + name: "Navn", + color: "Farge", + defaultLabels: "Standard merknader", + labelsPlaceholder: "Søk eller opprett" + }, + dataTablePaginator: { + goToPrevPage: "Gå til forrige side", + goToNextPage: "Gå til neste side", + of: "av", + }, + settingsCategoriesTile: { + deleteAllConfirm: "Er du sikker på at du vil slette denne kategorien?\nDette vil slette alle tilhørende rader!", + active: "Aktive", + archived: "Arkiverte", + name: "Navn", + color: "Farge", + editEntry: "Rediger kategori", + deleteEntry: "Slett kategori", + noCategories: "Ingen kategorier", + categories: "Kategorier" + }, + settingsLabelsTile: { + deleteAllConfirm: "Er du sikker på at du vil slette denne merknaden?\nDen vil bli slette fra alle relaterte rader!", + active: "Aktive", + archived: "Arkiverte", + name: "Navn", + color: "Farge", + editEntry: "Rediger merknad", + deleteEntry: "Slett merknad", + noLabels: "Ingen merknader", + labels: "Merknader" + }, + entryForm: { + entryUpdateError: "En feil oppstod med lagringen av din rad, prøv igjen snart.", + entryCreateError: "En feil oppstod med opprettelsen av din rad, prøv igjen snart.", + errDescriptionReq: "Beskrivelse er påkrevd", + reset: "Tilbakestill", + description: "Beskrivelse", + save: "Lagre", + create: "Opprett", + category: { + category: "Kategori", + placeholder: "Søk eller opprett", + noResults: "Ingen kategorier tilgjengelig (Opprett en ny ved å skrive navnet i søkefeltet).", + errisRequired: "Kategori er påkrevd", + _logReset: "Tilbakestilte kategori-seksjonen" + }, + labels: { + placeholder: "Søk eller opprett", + noResults: "Ingen merkander tilgjengelig (Opprett en ny ved å skrive navnet i søkefeltet).", + labels: "Merknader", + _logReset: "Tilbakestilte merknader-seksjonen" + }, + dateTime: { + errDateIsRequired: "Dato er påkrevd", + errFromIsRequired: "Fra er påkrevd", + errFromAfterTo: "Fra kan ikke være etter Til", + errFromEqTo: "Fra og Til kan ikke ha lik verdi", + errToIsRequired: "Til er påkrevd", + errToBeforeFrom: "Til kan ikke være før Fra", + from: "Fra", + to: "Til", + date: "Dato", + _logReset: "Tilbakestilte dato-seksjonen" + } + } + }, + data: { + durationSummary: "Viser {entryCountString:string}, Tilsammen {totalHourMin:string}", + hourSingleChar: "t", + minSingleChar: "m", + entry: "rad", + entries: "rader", + confirmDeleteEntry: "Er du sikker på at du vil slette denne raden?", + editEntry: "Rediger rad", + date: "Dato", + from: "Fra", + duration: "Tidsrom", + category: "Kategori", + description: "Beskrivelse", + loading: "Laster", + noEntries: "Ingen rader", + to: "til", + use: "Bruk", + }, + home: { + loggedTimeTodayString: "{hours}t{minutes}m", + confirmDeleteEntry: "Er du sikker på at du vil slette denne raden?", + newEntry: "Ny tidsoppføring", + editEntry: "Rediger rad", + deleteEntry: "Slett rad", + loggedTimeToday: "Registrert tid hittil idag", + currentTime: "Klokken", + loading: "Laster", + stopwatch: "Stoppeklokke", + todayEntries: "Dagens tidsoppføringer", + noEntriesToday: "Ingen oppføringer i dag", + refreshTodayEntries: "Last inn dagens tidsoppføringer på nytt", + category: "Kategori", + timespan: "Tidsrom", + }, + messages: { + pageNotFound: "Fant ikke siden", + goToFrontpage: "Gå til forsiden", + noInternet: "Det ser ut som at du er uten internettilgang, vennligst sjekk tilkoblingen din." + }, + login: { + loginToYourAccount: "Logg inn i din konto", + or: "Eller", + createANewAccount: "lag en ny konto", + emailAddress: "E-postadresse", + password: "Passord", + notMyComputer: "Dette er ikke min datamaskin", + forgotPassword: "Glem passord?", + logIn: "Logg inn" + }, +}; + +export default nb; diff --git a/apps/kit/src/lib/locale.ts b/apps/kit/src/lib/locale.ts new file mode 100644 index 0000000..002f874 --- /dev/null +++ b/apps/kit/src/lib/locale.ts @@ -0,0 +1,20 @@ +import {writable} from "svelte/store"; +import {base_domain, CookieNames} from "./configuration"; +import {get_cookie, set_cookie} from "./helpers"; + +export function preffered_or_default() { + if (/^en\b/i.test(navigator.language)) { + return "en"; + } + if (/^nb\b/i.test(navigator.language) || /^nn\b/i.test(navigator.language)) { + return "nb"; + } + return "en"; +} + +type Locales = "en"|"nb"; +export const currentLocale = writable<Locales>((get_cookie(CookieNames.locale) === "preffered" ? preffered_or_default() : get_cookie(CookieNames.locale) ?? preffered_or_default()) as Locales); +currentLocale.subscribe(locale => { + // @ts-ignore + set_cookie(CookieNames.locale, locale, base_domain()); +}); diff --git a/apps/kit/src/lib/logger.ts b/apps/kit/src/lib/logger.ts new file mode 100644 index 0000000..e017ba0 --- /dev/null +++ b/apps/kit/src/lib/logger.ts @@ -0,0 +1,87 @@ +import {browser, dev} from "$app/environment"; +import {StorageKeys} from "$lib/configuration"; +import pino from "pino"; + +const pinoConfig = dev ? { + transport: { + target: "pino-pretty", + }, +} : {}; + +const pinoLogger = pino(pinoConfig); + +function browserLogLevel(): number { + if (browser) return LogLevel.toNumber(sessionStorage.getItem(StorageKeys.logLevel), LogLevel.INFO); + throw new Error("Called browser api in server"); +} + +function serverLogLevel(): number { + if (!browser) return LogLevel.toNumber(process.env.LOG_LEVEL, LogLevel.ERROR); + throw new Error("Called server api in browser"); +} + +export const LogLevel = { + DEBUG: 0, + INFO: 1, + ERROR: 2, + SILENT: 3, + toString(levelInt: number): string { + switch (levelInt) { + case 0: + return "DEBUG"; + case 1: + return "INFO"; + case 2: + return "ERROR"; + case 3: + return "SILENT"; + default: + throw new Error("Log level int is unknown"); + } + }, + toNumber(levelString?: string | null, fallback?: number): number { + if (!levelString && fallback) return fallback; + else if (!levelString && !fallback) throw new Error("levelString was empty, and no fallback was specified"); + switch (levelString?.toUpperCase()) { + case "DEBUG": + return 0; + case "INFO": + return 1; + case "ERROR": + return 2; + case "SILENT": + return 3; + default: + if (!fallback) throw new Error("Log level string is unknown"); + else return fallback; + } + }, +}; + +export function logDebug(message: string, ...additional: any[]): void { + if (browser && browserLogLevel() <= LogLevel.DEBUG) { + pinoLogger.debug(message, additional); + } + + if (!browser && serverLogLevel() <= LogLevel.DEBUG) { + pinoLogger.debug(message, additional); + } +} + +export function logInfo(message: string, ...additional: any[]): void { + if (browser && browserLogLevel() <= LogLevel.INFO) { + pinoLogger.info(message, additional); + } + if (!browser && serverLogLevel() <= LogLevel.INFO) { + pinoLogger.info(message, additional); + } +} + +export function logError(message: any, ...additional: any[]): void { + if (browser && browserLogLevel() <= LogLevel.ERROR) { + pinoLogger.error(message, additional); + } + if (!browser && serverLogLevel() <= LogLevel.ERROR) { + pinoLogger.error(message, additional); + } +}
\ No newline at end of file diff --git a/apps/kit/src/lib/models/CreateAccountPayload.ts b/apps/kit/src/lib/models/CreateAccountPayload.ts new file mode 100644 index 0000000..d116308 --- /dev/null +++ b/apps/kit/src/lib/models/CreateAccountPayload.ts @@ -0,0 +1,4 @@ +export interface CreateAccountPayload { + username: string, + password: string +} diff --git a/apps/kit/src/lib/models/ErrorResult.ts b/apps/kit/src/lib/models/ErrorResult.ts new file mode 100644 index 0000000..7c70017 --- /dev/null +++ b/apps/kit/src/lib/models/ErrorResult.ts @@ -0,0 +1,4 @@ +export interface ErrorResult { + title: string, + text: string +} diff --git a/apps/kit/src/lib/models/IInternalFetchRequest.ts b/apps/kit/src/lib/models/IInternalFetchRequest.ts new file mode 100644 index 0000000..68505e2 --- /dev/null +++ b/apps/kit/src/lib/models/IInternalFetchRequest.ts @@ -0,0 +1,6 @@ +export interface IInternalFetchRequest { + url: string, + init?: RequestInit, + timeout?: number + retry_count?: number +} diff --git a/apps/kit/src/lib/models/IInternalFetchResponse.ts b/apps/kit/src/lib/models/IInternalFetchResponse.ts new file mode 100644 index 0000000..6c91b35 --- /dev/null +++ b/apps/kit/src/lib/models/IInternalFetchResponse.ts @@ -0,0 +1,6 @@ +export interface IInternalFetchResponse { + ok: boolean, + status: number, + data: any, + http_response: Response +} diff --git a/apps/kit/src/lib/models/ISession.ts b/apps/kit/src/lib/models/ISession.ts new file mode 100644 index 0000000..f7ed46b --- /dev/null +++ b/apps/kit/src/lib/models/ISession.ts @@ -0,0 +1,7 @@ +export interface ISession { + profile: { + username: string, + id: string, + }, + lastChecked: number, +}
\ No newline at end of file diff --git a/apps/kit/src/lib/models/IValidationResult.ts b/apps/kit/src/lib/models/IValidationResult.ts new file mode 100644 index 0000000..9a21b13 --- /dev/null +++ b/apps/kit/src/lib/models/IValidationResult.ts @@ -0,0 +1,31 @@ +export interface IValidationResult { + errors: Array<IValidationError>, + has_errors: Function, + add_error: Function, + remove_error: Function, +} + +export interface IValidationError { + _id?: string, + title: string, + text?: string +} + +export default class ValidationResult implements IValidationResult { + errors: IValidationError[] + has_errors(): boolean { + return this.errors?.length > 0; + } + add_error(prop: string, error: IValidationError): void { + if (!this.errors) this.errors = []; + error._id = prop; + this.errors.push(error); + } + remove_error(property: string): void { + const new_errors = []; + for (const error of this.errors) { + if (error._id != property) new_errors.push(error) + } + this.errors = new_errors; + } +} diff --git a/apps/kit/src/lib/models/LoginPayload.ts b/apps/kit/src/lib/models/LoginPayload.ts new file mode 100644 index 0000000..ccd9bed --- /dev/null +++ b/apps/kit/src/lib/models/LoginPayload.ts @@ -0,0 +1,4 @@ +export interface LoginPayload { + username: string, + password: string +} diff --git a/apps/kit/src/lib/models/TimeCategoryDto.ts b/apps/kit/src/lib/models/TimeCategoryDto.ts new file mode 100644 index 0000000..fcdb7ea --- /dev/null +++ b/apps/kit/src/lib/models/TimeCategoryDto.ts @@ -0,0 +1,9 @@ +import { Temporal } from "temporal-polyfill"; + +export interface TimeCategoryDto { + selected?: boolean; + id?: string, + modified_at?: Temporal.PlainDate, + name?: string, + color?: string +} diff --git a/apps/kit/src/lib/models/TimeEntryDto.ts b/apps/kit/src/lib/models/TimeEntryDto.ts new file mode 100644 index 0000000..571c52e --- /dev/null +++ b/apps/kit/src/lib/models/TimeEntryDto.ts @@ -0,0 +1,13 @@ +import type { TimeLabelDto } from "./TimeLabelDto"; +import type { TimeCategoryDto } from "./TimeCategoryDto"; +import { Temporal } from "temporal-polyfill"; + +export interface TimeEntryDto { + id: string, + modified_at?: Temporal.PlainDate, + start: string, + stop: string, + description: string, + labels?: Array<TimeLabelDto>, + category: TimeCategoryDto, +} diff --git a/apps/kit/src/lib/models/TimeEntryQuery.ts b/apps/kit/src/lib/models/TimeEntryQuery.ts new file mode 100644 index 0000000..d983d1a --- /dev/null +++ b/apps/kit/src/lib/models/TimeEntryQuery.ts @@ -0,0 +1,27 @@ +import type { TimeCategoryDto } from "./TimeCategoryDto"; +import type { TimeLabelDto } from "./TimeLabelDto"; +import type { Temporal } from "temporal-polyfill"; + +export interface TimeEntryQuery { + duration: TimeEntryQueryDuration, + categories?: Array<TimeCategoryDto>, + labels?: Array<TimeLabelDto>, + dateRange?: TimeEntryQueryDateRange, + specificDate?: Temporal.PlainDateTime + page: number, + pageSize: number +} + +export interface TimeEntryQueryDateRange { + from: Temporal.PlainDateTime, + to: Temporal.PlainDateTime +} + +export enum TimeEntryQueryDuration { + TODAY = 0, + THIS_WEEK = 1, + THIS_MONTH = 2, + THIS_YEAR = 3, + SPECIFIC_DATE = 4, + DATE_RANGE = 5, +} diff --git a/apps/kit/src/lib/models/TimeLabelDto.ts b/apps/kit/src/lib/models/TimeLabelDto.ts new file mode 100644 index 0000000..7183bcf --- /dev/null +++ b/apps/kit/src/lib/models/TimeLabelDto.ts @@ -0,0 +1,8 @@ +import { Temporal } from "temporal-polyfill"; + +export interface TimeLabelDto { + id?: string, + modified_at?: Temporal.PlainDate, + name?: string, + color?: string +} diff --git a/apps/kit/src/lib/models/TimeQueryDto.ts b/apps/kit/src/lib/models/TimeQueryDto.ts new file mode 100644 index 0000000..607c51e --- /dev/null +++ b/apps/kit/src/lib/models/TimeQueryDto.ts @@ -0,0 +1,29 @@ +import type { TimeEntryDto } from "./TimeEntryDto"; +import ValidationResult, { IValidationResult } from "./IValidationResult"; + +export interface ITimeQueryDto { + results: Array<TimeEntryDto>, + page: number, + pageSize: number, + totalRecords: number, + totalPageCount: number, + is_valid: Function +} + +export class TimeQueryDto implements ITimeQueryDto { + results: TimeEntryDto[]; + page: number; + pageSize: number; + totalRecords: number; + totalPageCount: number; + + is_valid(): IValidationResult { + const result = new ValidationResult(); + if (this.page < 0) { + result.add_error("page", { + title: "Page cannot be less than zero", + }) + } + return result; + } +} diff --git a/apps/kit/src/lib/models/UnwrappedEntryDateTime.ts b/apps/kit/src/lib/models/UnwrappedEntryDateTime.ts new file mode 100644 index 0000000..d614f91 --- /dev/null +++ b/apps/kit/src/lib/models/UnwrappedEntryDateTime.ts @@ -0,0 +1,9 @@ +import { Temporal } from "temporal-polyfill"; + +export interface UnwrappedEntryDateTime { + start_date: Temporal.PlainDate, + stop_date: Temporal.PlainDate, + start_time: Temporal.PlainTime, + stop_time: Temporal.PlainTime, + duration: Temporal.Duration, +} diff --git a/apps/kit/src/lib/models/UpdateProfilePayload.ts b/apps/kit/src/lib/models/UpdateProfilePayload.ts new file mode 100644 index 0000000..d2983ff --- /dev/null +++ b/apps/kit/src/lib/models/UpdateProfilePayload.ts @@ -0,0 +1,4 @@ +export interface UpdateProfilePayload { + username?: string, + password?: string, +} diff --git a/apps/kit/src/lib/persistent-store.ts b/apps/kit/src/lib/persistent-store.ts new file mode 100644 index 0000000..922f3ab --- /dev/null +++ b/apps/kit/src/lib/persistent-store.ts @@ -0,0 +1,102 @@ +import { writable as _writable, readable as _readable, } from "svelte/store"; +import type { Writable, Readable, StartStopNotifier } from "svelte/store"; + +enum StoreType { + SESSION = 0, + LOCAL = 1 +} + +interface StoreOptions { + store?: StoreType; +} + +const default_store_options = { + store: StoreType.SESSION +} as StoreOptions; + +interface WritableStore<T> { + name: string, + initialState: T, + options?: StoreOptions +} + +interface ReadableStore<T> { + name: string, + initialState: T, + callback: StartStopNotifier<any>, + options?: StoreOptions +} + +function get_store(type: StoreType): Storage { + switch (type) { + case StoreType.SESSION: + return window.sessionStorage; + case StoreType.LOCAL: + return window.localStorage; + } +} + +function prepared_store_value(value: any): string { + try { + return JSON.stringify(value); + } catch (e) { + console.error(e); + return "__INVALID__"; + } +} + +function get_store_value<T>(options: WritableStore<T> | ReadableStore<T>): any { + try { + const storage = get_store(options.options.store); + const value = storage.getItem(options.name); + if (!value) return false; + return JSON.parse(value); + } catch (e) { + console.error(e); + return { __INVALID__: true }; + } +} + +function hydrate<T>(store: Writable<T>, options: WritableStore<T> | ReadableStore<T>): void { + const value = get_store_value<T>(options); + if (value && store.set) store.set(value); +} + +function subscribe<T>(store: Writable<T> | Readable<T>, options: WritableStore<T> | ReadableStore<T>): void { + const storage = get_store(options.options.store); + if (!store.subscribe) return; + store.subscribe((state: any) => { + storage.setItem(options.name, prepared_store_value(state)); + }); +} + +function writable_persistent<T>(options: WritableStore<T>): Writable<T> { + if (options.options === undefined) options.options = default_store_options; + console.log("Creating writable store with options: ", options); + const store = _writable<T>(options.initialState); + hydrate(store, options); + subscribe(store, options); + return store; +} + +function readable_persistent<T>(options: ReadableStore<T>): Readable<T> { + if (options.options === undefined) options.options = default_store_options; + console.log("Creating readable store with options: ", options); + const store = _readable<T>(options.initialState, options.callback); + // hydrate(store, options); + subscribe(store, options); + return store; +} + +export { + writable_persistent, + readable_persistent, + StoreType +}; + +export type { + WritableStore, + ReadableStore, + StoreOptions +}; + diff --git a/apps/kit/src/lib/session.ts b/apps/kit/src/lib/session.ts new file mode 100644 index 0000000..ee79933 --- /dev/null +++ b/apps/kit/src/lib/session.ts @@ -0,0 +1,69 @@ +import {logError, logInfo} from "$lib/logger"; +import { Temporal } from "temporal-polyfill"; +import { get_profile_for_active_check, logout } from "./api/user"; +import { is_guid, session_storage_get_json, session_storage_set_json } from "./helpers"; +import { SECONDS_BETWEEN_SESSION_CHECK, StorageKeys } from "./configuration"; +import type { ISession } from "$lib/models/ISession"; + +export async function is_active(forceRefresh: boolean = false): Promise<boolean> { + const nowEpoch = Temporal.Now.instant().epochSeconds; + const data = session_storage_get_json(StorageKeys.session) as ISession; + const expiryEpoch = data?.lastChecked + SECONDS_BETWEEN_SESSION_CHECK; + const lastCheckIsStaleOrNone = !is_guid(data?.profile?.id) || (expiryEpoch < nowEpoch); + if (forceRefresh || lastCheckIsStaleOrNone) { + return await call_api(); + } else { + const sessionIsValid = data.profile && is_guid(data.profile.id); + if (!sessionIsValid) { + clear_session_data(); + logInfo("Session data is not valid"); + } + return sessionIsValid; + } +} + +export async function end_session(cb: Function): Promise<void> { + await logout(); + clear_session_data(); + cb(); +} + +async function call_api(): Promise<boolean> { + logInfo("Getting profile data while checking session state"); + try { + const response = await get_profile_for_active_check(); + if (response.ok) { + const userData = await response.data; + if (is_guid(userData.id) && userData.username) { + const session = { + profile: userData, + lastChecked: Temporal.Now.instant().epochSeconds + } as ISession; + session_storage_set_json(StorageKeys.session, session); + logInfo("Successfully got profile data while checking session state"); + return true; + } else { + logError("Api returned invalid data while getting profile data"); + clear_session_data(); + return false; + } + } else { + logError("Api returned unsuccessfully while getting profile data"); + clear_session_data(); + return false; + } + } catch (e) { + logError(e); + clear_session_data(); + return false; + } +} + +export function clear_session_data() { + session_storage_set_json(StorageKeys.session, {}); + logInfo("Cleared session data."); +} + +export function get_session_data(): ISession { + return session_storage_get_json(StorageKeys.session) as ISession; +} diff --git a/apps/kit/src/params/guid.ts b/apps/kit/src/params/guid.ts new file mode 100644 index 0000000..d8f7231 --- /dev/null +++ b/apps/kit/src/params/guid.ts @@ -0,0 +1,5 @@ +import {is_guid} from "$lib/helpers"; + +export function match(param: string): boolean { + return is_guid(param); +}
\ No newline at end of file diff --git a/apps/kit/src/params/integer.ts b/apps/kit/src/params/integer.ts new file mode 100644 index 0000000..6e36cd8 --- /dev/null +++ b/apps/kit/src/params/integer.ts @@ -0,0 +1,3 @@ +export function match(param: string): boolean { + return /^\d+$/.test(param); +}
\ No newline at end of file diff --git a/apps/kit/src/routes/(app)/+layout.svelte b/apps/kit/src/routes/(app)/+layout.svelte new file mode 100644 index 0000000..3f60af3 --- /dev/null +++ b/apps/kit/src/routes/(app)/+layout.svelte @@ -0,0 +1,215 @@ +<svelte:options immutable={true}/> +<svelte:window bind:online={online}/> +<script lang="ts"> + import LL, {setLocale} from "$lib/i18n/i18n-svelte"; + import {Dialog, TransitionChild, TransitionRoot} from '@rgossiaux/svelte-headlessui'; + import {XIcon, MenuIcon, HomeIcon, DatabaseIcon, AdjustmentsIcon} from "$lib/components/icons"; + + let online = true; + let sidebarIsOpen = false; + const username = "dumb"; + + setLocale("nb"); + + const navigations = [ + { + name: "Home", + icon: HomeIcon + }, + { + name: "Data", + icon: DatabaseIcon + }, + { + name: "Settings", + icon: AdjustmentsIcon + } + ] +</script> +{#if !online} + <div class="bg-yellow-50 border-l-4 border-yellow-400 p-4"> + <div class="flex"> + <div class="flex-shrink-0"> + <svg class="h-5 w-5 text-yellow-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" + fill="currentColor" aria-hidden="true"> + <path fill-rule="evenodd" + d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" + clip-rule="evenodd"/> + </svg> + </div> + <div class="ml-3"> + <p class="text-sm text-yellow-700"> + You seem to be offline, please check your internet connection. + </p> + </div> + </div> + </div> +{/if} +<div class="h-full flex"> + <TransitionRoot show={sidebarIsOpen}> + <Dialog class="relative z-40 lg:hidden" on:close={() => sidebarIsOpen = !sidebarIsOpen}> + <TransitionChild + enter="transition-opacity ease-linear duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="transition-opacity ease-linear duration-300" + leaveFrom="opacity-100" + leaveTo="opacity-0"> + <div class="fixed inset-0 bg-gray-600 bg-opacity-75"></div> + </TransitionChild> + + <div class="fixed inset-0 flex z-40"> + <TransitionChild + enter="transition ease-in-out duration-300 transform" + enterFrom="-translate-x-full" + enterTo="translate-x-0" + leave="transition ease-in-out duration-300 transform" + leaveFrom="translate-x-0" + leaveTo="-translate-x-full"> + <DialogPanel class="relative flex-1 flex flex-col max-w-xs w-full bg-white focus:outline-none"> + <TransitionChild + enter="ease-in-out duration-300" + enterFrom="opacity-0" + enterTo="opacity-100" + leave="ease-in-out duration-300" + leaveFrom="opacity-100" + leaveTo="opacity-0"> + <div class="absolute top-0 right-0 -mr-12 pt-2"> + <button type="button" + class="ml-1 flex items-center justify-center h-10 w-10 rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white" + on:click={() => sidebarIsOpen = false}> + <span class="sr-only">Close sidebar</span> + <XIcon class="text-white" aria-hidden="true"/> + </button> + </div> + </TransitionChild> + <div class="flex-1 h-0 pt-5 pb-4 overflow-y-auto"> + <div class="flex-shrink-0 flex items-center px-4"> + <img class="h-8 w-auto" + src="https://tailwindui.com/img/logos/workflow-mark.svg?color=indigo&shade=600" + alt="Workflow" + /> + </div> + <nav aria-label="Sidebar" class="mt-5"> + <div class="px-2 space-y-1"> + {#each navigations as item (item.name)} + <a href={item.href} + class="{item.current? 'bg-gray-100 text-gray-900': 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'} group flex items-center px-2 py-2 text-base font-medium rounded-md"> + <svelte:component this="{item.icon}" + class="{item.current ? 'text-gray-500' : 'text-gray-400 group-hover:text-gray-500'} mr-4 h-6 w-6" + aria-hidden="true"></svelte:component> + {item.name} + </a> + {/each} + </div> + </nav> + </div> + <div class="flex-shrink-0 flex border-t border-gray-200 p-4"> + <a href="#" class="flex-shrink-0 group block"> + <div class="flex items-center"> + <div> + <img class="inline-block h-10 w-10 rounded-full" + src="https://images.unsplash.com/photo-1517365830460-955ce3ccd263?ixlib=rb-=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=256&h=256&q=80" + alt="" + /> + </div> + <div class="ml-3"> + <p class="text-base font-medium text-gray-700 group-hover:text-gray-900"> + {username} + </p> + <p class="text-sm font-medium text-gray-500 group-hover:text-gray-700"> + {$LL.nav.usermenu.profileTitle()} + </p> + </div> + </div> + </a> + </div> + </DialogPanel> + </TransitionChild> + <div class="flex-shrink-0 w-14" aria-hidden="true"> + <!--{/* Force sidebar to shrink to fit close icon */}--> + </div> + </div> + </Dialog> + </TransitionRoot> + + <!--{/* Static sidebar for desktop */}--> + <div class="hidden lg:flex lg:flex-shrink-0"> + <div class="flex flex-col w-64"> + <!--{/* Sidebar component, swap this element with another sidebar if you like */}--> + <div class="flex-1 flex flex-col min-h-0 border-r border-gray-200 bg-gray-100"> + <div class="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto"> + <div class="flex items-center flex-shrink-0 px-4"> + <img class="h-8 w-auto" + src="https://tailwindui.com/img/logos/workflow-mark.svg?color=indigo&shade=600" + alt="Workflow" + /> + </div> + <nav class="mt-5 flex-1" aria-label="Sidebar"> + <div class="px-2 space-y-1"> + {#each navigations as item (item.name)} + <a key={item.name} + href={item.href} + class="{item.current ? 'bg-gray-200 text-gray-900' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'} group flex items-center px-2 py-2 text-sm font-medium rounded-md"> + <svelte:component this="{item.icon}" + class="{item.current ? 'text-gray-500' : 'text-gray-400 group-hover:text-gray-500'} mr-3 h-6 w-6" + aria-hidden="true"></svelte:component> + {item.name} + </a> + {/each} + </div> + </nav> + </div> + <div class="flex-shrink-0 flex border-t border-gray-200 p-4"> + <a href="#" class="flex-shrink-0 w-full group block"> + <div class="flex items-center"> + <div> + <img class="inline-block h-9 w-9 rounded-full" + src="https://images.unsplash.com/photo-1517365830460-955ce3ccd263?ixlib=rb-=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=8&w=256&h=256&q=80" + alt="" + /> + </div> + <div class="ml-3"> + <p class="text-base font-medium text-gray-700 group-hover:text-gray-900"> + {username} + </p> + <p class="text-sm font-medium text-gray-500 group-hover:text-gray-700"> + {$LL.nav.usermenu.profileTitle()} + </p> + </div> + </div> + </a> + </div> + </div> + </div> + </div> + <div class="flex flex-col min-w-0 flex-1 overflow-hidden"> + <div class="lg:hidden"> + <div class="flex items-center justify-between bg-gray-50 border-b border-gray-200 px-4 py-1.5"> + <div> + <button type="button" + class="-mr-3 h-12 w-12 inline-flex items-center justify-center rounded-md text-gray-500 hover:text-gray-900" + on:click={() => sidebarIsOpen = true}> + <span class="sr-only">Open sidebar</span> + <MenuIcon aria-hidden="true"/> + </button> + </div> + </div> + </div> + <div class="flex-1 relative z-0 flex overflow-hidden"> + <main class="flex-1 relative z-0 overflow-y-auto focus:outline-none"> + <!-- + MAIN CONTENT + --> + <slot/> + </main> + <aside class="hidden relative xl:flex xl:flex-col flex-shrink-0 w-96 border-l border-gray-200 overflow-y-auto"> + <!--{/* Start secondary column (hidden on smaller screens) */} + <div class="absolute inset-0 py-6 px-4 sm:px-6 lg:px-8"> + <div class="h-full border-2 border-gray-200 border-dashed rounded-lg" /> + </div> + {/* End secondary column */}--> + </aside> + </div> + </div> +</div>
\ No newline at end of file diff --git a/apps/kit/src/routes/(app)/home/+page.svelte b/apps/kit/src/routes/(app)/home/+page.svelte new file mode 100644 index 0000000..247ee47 --- /dev/null +++ b/apps/kit/src/routes/(app)/home/+page.svelte @@ -0,0 +1 @@ +<h1>Welcome Home</h1>
\ No newline at end of file diff --git a/apps/kit/src/routes/(public)/+layout.svelte b/apps/kit/src/routes/(public)/+layout.svelte new file mode 100644 index 0000000..1301e50 --- /dev/null +++ b/apps/kit/src/routes/(public)/+layout.svelte @@ -0,0 +1,6 @@ +<script> + import {setLocale} from "$lib/i18n/i18n-svelte"; + + setLocale("nb"); +</script> +<slot></slot>
\ No newline at end of file diff --git a/apps/kit/src/routes/(public)/login/+page.svelte b/apps/kit/src/routes/(public)/login/+page.svelte new file mode 100644 index 0000000..800575e --- /dev/null +++ b/apps/kit/src/routes/(public)/login/+page.svelte @@ -0,0 +1,95 @@ +<script lang="ts"> + import {goto} from "$app/navigation"; + import {login} from "$lib/api/user"; + import LL from "$lib/i18n/i18n-svelte"; + import type {ErrorResult} from "$lib/models/ErrorResult"; + import type {LoginPayload} from "$lib/models/LoginPayload"; + + const data = { + username: "", + password: "" + } as LoginPayload; + let error = { + text: "", + title: "" + } as ErrorResult; + + async function submitFormAsync() { + error = {text: "", title: ""}; + const loginResponse = await login(data); + if (loginResponse.ok) { + await goto("/home") + } else { + error.title = loginResponse.data.title; + error.text = loginResponse.data.text; + } + } +</script> +<div class="min-h-full flex flex-col justify-center py-12 sm:px-6 lg:px-8"> + <div class="sm:mx-auto sm:w-full sm:max-w-md"> + <h2 class="mt-6 text-center text-3xl tracking-tight font-bold text-gray-900">{$LL.login.loginToYourAccount()}</h2> + <p class="mt-2 text-center text-sm text-gray-600"> + {$LL.login.or()} + <a href="/signup" + class="font-medium text-indigo-600 hover:text-indigo-500">{$LL.login.createANewAccount()}</a> + </p> + </div> + + <div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md"> + <div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10"> + {#if error.text || error.title} + <div class="rounded-md bg-red-50 p-3 mb-3"> + {#if error.title} + <h3 class="text-sm font-medium text-red-800">{error.title}</h3> + {/if} + {#if error.text} + <div class="mt-2 text-sm text-red-700"> + {error.text} + </div> + {/if} + </div> + {/if} + <form class="space-y-6" on:submit|preventDefault={submitFormAsync}> + <div> + <label for="email" + class="block text-sm font-medium text-gray-700">{$LL.login.emailAddress()}</label> + <div class="mt-1"> + <input id="email" name="email" type="email" autocomplete="email" required + value={data.username} + class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"> + </div> + </div> + + <div> + <label for="password" class="block text-sm font-medium text-gray-700">{$LL.login.password()}</label> + <div class="mt-1"> + <input id="password" name="password" type="password" autocomplete="current-password" required + value={data.password} + class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"> + </div> + </div> + + <div class="flex items-center justify-between"> + <div class="flex items-center"> + <input id="remember-me" name="remember-me" type="checkbox" + class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"> + <label for="remember-me" + class="ml-2 block text-sm text-gray-900">{$LL.login.notMyComputer()}</label> + </div> + + <div class="text-sm"> + <a href="/reset" + class="font-medium text-indigo-600 hover:text-indigo-500">{$LL.login.forgotPassword()}</a> + </div> + </div> + + <div> + <button type="submit" + class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> + {$LL.login.logIn()} + </button> + </div> + </form> + </div> + </div> +</div> diff --git a/apps/kit/src/routes/(public)/reset/+page.svelte b/apps/kit/src/routes/(public)/reset/+page.svelte new file mode 100644 index 0000000..41c4728 --- /dev/null +++ b/apps/kit/src/routes/(public)/reset/+page.svelte @@ -0,0 +1,29 @@ +<div class="min-h-full flex flex-col justify-center py-12 sm:px-6 lg:px-8"> + <div class="sm:mx-auto sm:w-full sm:max-w-md"> + <h2 class="mt-6 text-center text-3xl tracking-tight font-bold text-gray-900">Request a password reset</h2> + <p class="mt-2 text-center text-sm text-gray-600"> + Or + <a href="/login" class="font-medium text-indigo-600 hover:text-indigo-500">go to login page</a> + </p> + </div> + + <div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md"> + <div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10"> + <form class="space-y-6" action="#" method="POST"> + <div> + <label for="email" class="block text-sm font-medium text-gray-700"> Email address </label> + <div class="mt-1"> + <input id="email" name="email" type="email" autocomplete="email" required + class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"> + </div> + </div> + <div> + <button type="submit" + class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> + Send request + </button> + </div> + </form> + </div> + </div> +</div> diff --git a/apps/kit/src/routes/(public)/signup/+page.svelte b/apps/kit/src/routes/(public)/signup/+page.svelte new file mode 100644 index 0000000..d4a1bda --- /dev/null +++ b/apps/kit/src/routes/(public)/signup/+page.svelte @@ -0,0 +1,38 @@ +<div class="min-h-full flex flex-col justify-center py-12 sm:px-6 lg:px-8"> + <div class="sm:mx-auto sm:w-full sm:max-w-md"> + <h2 class="mt-6 text-center text-3xl tracking-tight font-bold text-gray-900">Create your new account</h2> + <p class="mt-2 text-center text-sm text-gray-600"> + Or + <a href="/login" class="font-medium text-indigo-600 hover:text-indigo-500">go to login page</a> + </p> + </div> + + <div class="mt-8 sm:mx-auto sm:w-full sm:max-w-md"> + <div class="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10"> + <form class="space-y-6" action="#" method="POST"> + <div> + <label for="email" class="block text-sm font-medium text-gray-700"> Email address </label> + <div class="mt-1"> + <input id="email" name="email" type="email" autocomplete="email" required + class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"> + </div> + </div> + + <div> + <label for="password" class="block text-sm font-medium text-gray-700"> Password </label> + <div class="mt-1"> + <input id="password" name="password" type="password" autocomplete="current-password" required + class="appearance-none block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"> + </div> + </div> + + <div> + <button type="submit" + class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"> + Create account + </button> + </div> + </form> + </div> + </div> +</div> diff --git a/apps/kit/src/routes/+layout.server.ts b/apps/kit/src/routes/+layout.server.ts new file mode 100644 index 0000000..01aae89 --- /dev/null +++ b/apps/kit/src/routes/+layout.server.ts @@ -0,0 +1,13 @@ +// import {is_active} from "$lib/session"; +// import {redirect} from "@sveltejs/kit"; +// import type {LayoutServerLoad} from "./$types"; +// +// export const load: LayoutServerLoad = async ({routeId}) => { +// const sessionIsValid = await is_active(); +// const isPublicRoute = routeId?.startsWith("(public)"); +// if (sessionIsValid && isPublicRoute) { +// throw redirect(302, "/home"); +// } else if (!sessionIsValid && !isPublicRoute) { +// throw redirect(302, "/login"); +// } +// };
\ No newline at end of file diff --git a/apps/kit/src/routes/+layout.svelte b/apps/kit/src/routes/+layout.svelte new file mode 100644 index 0000000..ee76da9 --- /dev/null +++ b/apps/kit/src/routes/+layout.svelte @@ -0,0 +1,23 @@ +<script lang="ts"> + import "../app.pcss"; + import {afterNavigate, beforeNavigate, goto} from "$app/navigation"; + import {is_active} from "$lib/session"; + import type {Navigation} from "@sveltejs/kit"; + + async function redirect_if_necessary(ticket: Navigation) { + const sessionIsValid = await is_active(); + const isPublicRoute = ticket.to?.routeId?.startsWith("(public)"); + if (sessionIsValid && isPublicRoute) { + await goto("/home"); + } else if (!sessionIsValid && !isPublicRoute) { + await goto("/login"); + } + } + + // This should probably be removed in favor of the logic in layout.server.ts. + // That requires a more sophisticated server side implementation of session handling, + // and i don't want that tbh, i want to stay as much in the browser as possible. + afterNavigate(redirect_if_necessary); + beforeNavigate(redirect_if_necessary); +</script> +<slot></slot>
\ No newline at end of file |
