diff options
| author | ivar <i@oiee.no> | 2024-04-28 22:37:30 +0200 |
|---|---|---|
| committer | ivar <i@oiee.no> | 2024-04-28 22:37:30 +0200 |
| commit | ced66c5807575cd29f6aa5632e8ad02b38c8448a (patch) | |
| tree | 01760648ee293a2aef2288328014b5747d2192b4 /code/frontend | |
| parent | 691ad60d7bff5934053d87267c4e303ef3ed5f97 (diff) | |
| download | greatoffice-ced66c5807575cd29f6aa5632e8ad02b38c8448a.tar.xz greatoffice-ced66c5807575cd29f6aa5632e8ad02b38c8448a.zip | |
WIP new frontend
Diffstat (limited to 'code/frontend')
127 files changed, 4397 insertions, 0 deletions
diff --git a/code/frontend/.dockerignore b/code/frontend/.dockerignore new file mode 100644 index 0000000..00774fa --- /dev/null +++ b/code/frontend/.dockerignore @@ -0,0 +1,7 @@ +.env +.env-example +.svelte-kit +.git +static +node_modules +build
\ No newline at end of file diff --git a/code/frontend/.env-example b/code/frontend/.env-example new file mode 100644 index 0000000..270860f --- /dev/null +++ b/code/frontend/.env-example @@ -0,0 +1,4 @@ +VITE_LOG_LEVEL=DEBUG +VITE_TESTING=true +VITE_TEST_USERNAME="ms@test.tld" +VITE_TEST_PASSWORD="test123"
\ No newline at end of file diff --git a/code/frontend/.eslintignore b/code/frontend/.eslintignore new file mode 100644 index 0000000..3897265 --- /dev/null +++ b/code/frontend/.eslintignore @@ -0,0 +1,13 @@ +.DS_Store +node_modules +/build +/.svelte-kit +/package +.env +.env.* +!.env.example + +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock diff --git a/code/frontend/.eslintrc.cjs b/code/frontend/.eslintrc.cjs new file mode 100644 index 0000000..0b75758 --- /dev/null +++ b/code/frontend/.eslintrc.cjs @@ -0,0 +1,31 @@ +/** @type { import("eslint").Linter.Config } */ +module.exports = { + root: true, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:svelte/recommended', + 'prettier' + ], + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint'], + parserOptions: { + sourceType: 'module', + ecmaVersion: 2020, + extraFileExtensions: ['.svelte'] + }, + env: { + browser: true, + es2017: true, + node: true + }, + overrides: [ + { + files: ['*.svelte'], + parser: 'svelte-eslint-parser', + parserOptions: { + parser: '@typescript-eslint/parser' + } + } + ] +}; diff --git a/code/frontend/.gitignore b/code/frontend/.gitignore new file mode 100644 index 0000000..6635cf5 --- /dev/null +++ b/code/frontend/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +node_modules +/build +/.svelte-kit +/package +.env +.env.* +!.env.example +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/code/frontend/.npmrc b/code/frontend/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/code/frontend/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/code/frontend/.prettierignore b/code/frontend/.prettierignore new file mode 100644 index 0000000..cc41cea --- /dev/null +++ b/code/frontend/.prettierignore @@ -0,0 +1,4 @@ +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock diff --git a/code/frontend/.prettierrc b/code/frontend/.prettierrc new file mode 100644 index 0000000..2b9389a --- /dev/null +++ b/code/frontend/.prettierrc @@ -0,0 +1,9 @@ +{ + "useTabs": true, + "singleQuote": true, + "trailingComma": "none", + "semi": false, + "svelteAllowShorthand": true, + "printWidth": 120, + "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"] +} diff --git a/code/frontend/.typesafe-i18n.json b/code/frontend/.typesafe-i18n.json new file mode 100644 index 0000000..4bb9efd --- /dev/null +++ b/code/frontend/.typesafe-i18n.json @@ -0,0 +1,5 @@ +{ + "adapter": "svelte", + "$schema": "https://unpkg.com/typesafe-i18n@5.26.2/schema/typesafe-i18n.json", + "outputPath": "src/i18n" +}
\ No newline at end of file diff --git a/code/frontend/.version b/code/frontend/.version new file mode 100644 index 0000000..626799f --- /dev/null +++ b/code/frontend/.version @@ -0,0 +1 @@ +v1 diff --git a/code/frontend/.version-dev b/code/frontend/.version-dev new file mode 100644 index 0000000..c227083 --- /dev/null +++ b/code/frontend/.version-dev @@ -0,0 +1 @@ +0
\ No newline at end of file diff --git a/code/frontend/Dockerfile b/code/frontend/Dockerfile new file mode 100644 index 0000000..e381c8d --- /dev/null +++ b/code/frontend/Dockerfile @@ -0,0 +1,13 @@ +FROM registry.hub.docker.com/library/node:lts-buster-slim AS builder +WORKDIR . +COPY package.json . +RUN npm i +COPY . . +RUN npm run build +FROM registry.hub.docker.com/library/node:lts-buster-slim +USER node:node +WORKDIR . +COPY --from=builder --chown=node:node build build +COPY --from=builder --chown=node:node node_modules node_modules +COPY --chown=node:node package.json . +CMD ["node","build"]
\ No newline at end of file diff --git a/code/frontend/build_and_push.sh b/code/frontend/build_and_push.sh new file mode 100644 index 0000000..6143419 --- /dev/null +++ b/code/frontend/build_and_push.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash + +set -Eueo pipefail + +CURRENT_DEV_VERSION=$(cat .version-dev) +CURRENT_DEV_VERSION_INT=${CURRENT_DEV_VERSION//[!0-9]/} +CURRENT_VERSION=$(cat .version) +CURRENT_VERSION_INT=${CURRENT_VERSION//[!0-9]/} +if [ ${1-prod} == "dev" ]; then + NEW_VERSION="v$((CURRENT_DEV_VERSION_INT + 1))-dev" + OLD_VERSION=$CURRENT_DEV_VERSION +else + NEW_VERSION="v$((CURRENT_VERSION_INT + 1))" + OLD_VERSION=$CURRENT_VERSION +fi +IMAGE_NAME="greatoffice/app" +HUB_NAME="dr.ivar.systems/greatoffice/app" + +# Check for uncommited changes and optionally commit them +if [ "$(git status --untracked-files=no --porcelain)" ]; then + echo "Unclean git tree! press CTRL+C to exit or press ENTER to commit and push to the default branch" + read -n 1 + + read -p "Enter commit message: " COMMIT_MESSAGE + git add .. + git commit --quiet -m "$COMMIT_MESSAGE" +fi + +if [ ${1-prod} == "dev" ]; then + echo $NEW_VERSION >|.version-dev + git add .version-dev +else + echo $NEW_VERSION >|.version + git add .version +fi + +echo "Starting build of $HUB_NAME:$NEW_VERSION at $(date -u)..." +echo + +# Put version.txt inside of server +pushd static +echo "$NEW_VERSION" >version.txt +git add version.txt +popd + +git commit --quiet -m "chore(release): Bump version" + +read -p "Do you want to tag this build? (y/n) " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]]; then + read -p "Enter tag message (can be empty): " TAG_MESSAGE + git tag -am "$TAG_MESSAGE" $NEW_VERSION +fi + +read -p "Do you want to push the latest commits and tags to origin? (y/n) " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]]; then + echo "Pushing latest changes to remotes..." + echo + git push --quiet --follow-tags +fi + +# Build docker image +echo "Building docker image" +echo + +docker build -t $IMAGE_NAME:$NEW_VERSION . + +docker tag $IMAGE_NAME:$NEW_VERSION $HUB_NAME:$NEW_VERSION + +if [ ${1-prod} == "dev" ]; then + docker tag $IMAGE_NAME:$NEW_VERSION $HUB_NAME:latest-dev +fi +if [ ${1-prod} == "prod" ]; then + docker tag $IMAGE_NAME:$NEW_VERSION $HUB_NAME:latest +fi + +# Optionally push images to docker registry +echo "Press CTRL+C to exit or press ENTER to push docker image to registry" +read -n 1 +docker push $HUB_NAME:$NEW_VERSION + +if [ ${1-prod} == "dev" ]; then + docker push $HUB_NAME:latest-dev +fi + +if [ ${1-prod} == "prod" ]; then + docker push $HUB_NAME:latest +fi diff --git a/code/frontend/bun.lockb b/code/frontend/bun.lockb Binary files differnew file mode 100755 index 0000000..8da4b45 --- /dev/null +++ b/code/frontend/bun.lockb diff --git a/code/frontend/components.json b/code/frontend/components.json new file mode 100644 index 0000000..cb6136f --- /dev/null +++ b/code/frontend/components.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://shadcn-svelte.com/schema.json", + "style": "new-york", + "tailwind": { + "config": "tailwind.config.js", + "css": "src/app.pcss", + "baseColor": "stone" + }, + "aliases": { + "components": "$components", + "utils": "$utils/ui" + }, + "typescript": true +} diff --git a/code/frontend/package.json b/code/frontend/package.json new file mode 100644 index 0000000..71f27d3 --- /dev/null +++ b/code/frontend/package.json @@ -0,0 +1,65 @@ +{ + "name": "greatoffice-frontend", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "npm-run-all --parallel vite typesafe-i18n", + "typesafe-i18n": "typesafe-i18n", + "vite": "vite dev", + "build": "vite build", + "preview": "vite preview", + "test": "npm run test:integration && npm run test:unit", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "prettier --check . && eslint .", + "format": "prettier --write .", + "test:integration": "playwright test", + "test:unit": "vitest" + }, + "devDependencies": { + "@playwright/test": "^1.28.1", + "@sveltejs/adapter-node": "^5.0.1", + "@sveltejs/kit": "^2.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0", + "@tanstack/svelte-query": "^5.29.0", + "@tanstack/svelte-table": "^8.16.0", + "@types/eslint": "^8.56.0", + "@typescript-eslint/eslint-plugin": "^7.0.0", + "@faker-js/faker": "^8.4.1", + "@typescript-eslint/parser": "^7.0.0", + "@vite-pwa/sveltekit": "^0.4.0", + "npm-run-all": "^4.1.5", + "typesafe-i18n": "^5.26.2", + "autoprefixer": "^10.4.16", + "bits-ui": "^0.21.3", + "clsx": "^2.1.0", + "eslint": "^8.56.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-svelte": "^2.35.1", + "formsnap": "^1.0.0", + "mode-watcher": "^0.3.0", + "paneforge": "^0.0.4", + "postcss": "^8.4.32", + "postcss-load-config": "^5.0.2", + "prettier": "^3.1.1", + "prettier-plugin-svelte": "^3.1.2", + "prettier-plugin-tailwindcss": "^0.5.9", + "scheduler-polyfill": "^1.2.1", + "svelte": "^4.2.7", + "svelte-check": "^3.6.0", + "svelte-interactions": "^0.2.0", + "svelte-radix": "^1.1.0", + "svelte-sonner": "^0.3.22", + "sveltekit-superforms": "^2.12.4", + "tailwind-merge": "^2.2.2", + "tailwind-variants": "^0.2.1", + "tailwindcss": "^3.3.6", + "temporal-polyfill": "^0.2.4", + "tslib": "^2.4.1", + "typescript": "^5.0.0", + "vite": "^5.0.3", + "ofetch": "^1.3.4", + "vitest": "^1.2.0" + }, + "type": "module" +} diff --git a/code/frontend/playwright.config.ts b/code/frontend/playwright.config.ts new file mode 100644 index 0000000..30e57ee --- /dev/null +++ b/code/frontend/playwright.config.ts @@ -0,0 +1,12 @@ +import type { PlaywrightTestConfig } from '@playwright/test'; + +const config: PlaywrightTestConfig = { + webServer: { + command: 'bun run build && bun run preview', + port: 4173 + }, + testDir: 'tests', + testMatch: /(.+\.)?(test|spec)\.[jt]s/ +}; + +export default config; diff --git a/code/frontend/postcss.config.cjs b/code/frontend/postcss.config.cjs new file mode 100644 index 0000000..fe10e55 --- /dev/null +++ b/code/frontend/postcss.config.cjs @@ -0,0 +1,13 @@ +const tailwindcss = require('tailwindcss'); +const autoprefixer = require('autoprefixer'); + +const config = { + plugins: [ + //Some plugins, like tailwindcss/nesting, need to run before Tailwind, + tailwindcss(), + //But others, like autoprefixer, need to run after, + autoprefixer + ] +}; + +module.exports = config; diff --git a/code/frontend/src/actions/pwKey.ts b/code/frontend/src/actions/pwKey.ts new file mode 100644 index 0000000..e8f615c --- /dev/null +++ b/code/frontend/src/actions/pwKey.ts @@ -0,0 +1,4 @@ +export default function pwKey(node: HTMLElement, value: string | undefined) { + if (!value) return; + node.setAttribute("pw-key", value); +}
\ No newline at end of file diff --git a/code/frontend/src/app.d.ts b/code/frontend/src/app.d.ts new file mode 100644 index 0000000..743f07b --- /dev/null +++ b/code/frontend/src/app.d.ts @@ -0,0 +1,13 @@ +// See https://kit.svelte.dev/docs/types#app +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/code/frontend/src/app.html b/code/frontend/src/app.html new file mode 100644 index 0000000..77a5ff5 --- /dev/null +++ b/code/frontend/src/app.html @@ -0,0 +1,12 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <link rel="icon" href="%sveltekit.assets%/favicon.png" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + %sveltekit.head% + </head> + <body data-sveltekit-preload-data="hover"> + <div style="display: contents">%sveltekit.body%</div> + </body> +</html> diff --git a/code/frontend/src/app.pcss b/code/frontend/src/app.pcss new file mode 100644 index 0000000..37c673f --- /dev/null +++ b/code/frontend/src/app.pcss @@ -0,0 +1,108 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 20 14.3% 4.1%; + + --muted: 60 4.8% 95.9%; + --muted-foreground: 25 5.3% 44.7%; + + --popover: 0 0% 100%; + --popover-foreground: 20 14.3% 4.1%; + + --card: 0 0% 100%; + --card-foreground: 20 14.3% 4.1%; + + --border: 20 5.9% 90%; + --input: 20 5.9% 90%; + + --primary: 24 9.8% 10%; + --primary-foreground: 60 9.1% 97.8%; + + --secondary: 60 4.8% 95.9%; + --secondary-foreground: 24 9.8% 10%; + + --accent: 60 4.8% 95.9%; + --accent-foreground: 24 9.8% 10%; + + --destructive: 0 72.2% 50.6%; + --destructive-foreground: 60 9.1% 97.8%; + + --ring: 20 14.3% 4.1%; + + --radius: 0.5rem; + } + + .dark { + --background: 20 14.3% 4.1%; + --foreground: 60 9.1% 97.8%; + + --muted: 12 6.5% 15.1%; + --muted-foreground: 24 5.4% 63.9%; + + --popover: 20 14.3% 4.1%; + --popover-foreground: 60 9.1% 97.8%; + + --card: 20 14.3% 4.1%; + --card-foreground: 60 9.1% 97.8%; + + --border: 12 6.5% 15.1%; + --input: 12 6.5% 15.1%; + + --primary: 60 9.1% 97.8%; + --primary-foreground: 24 9.8% 10%; + + --secondary: 12 6.5% 15.1%; + --secondary-foreground: 60 9.1% 97.8%; + + --accent: 12 6.5% 15.1%; + --accent-foreground: 60 9.1% 97.8%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 60 9.1% 97.8%; + + --ring: 24 5.7% 82.9%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} + +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; +} + +.link { + @apply text-blue-600 hover:text-blue-700 transition duration-300 ease-in-out mb-4 cursor-pointer; + + &.danger { + @apply text-red-600 hover:text-red-700; + } + + &.active { + @apply underline + } +} diff --git a/code/frontend/src/components/locale-switcher.svelte b/code/frontend/src/components/locale-switcher.svelte new file mode 100644 index 0000000..fc03f39 --- /dev/null +++ b/code/frontend/src/components/locale-switcher.svelte @@ -0,0 +1,56 @@ +<script lang="ts"> + import pwKey from "$actions/pwKey"; + import {browser} from "$app/environment"; + import {page} from "$app/stores"; + import {CookieNames} from "$configuration"; + import {setLocale, locale} from "$i18n/i18n-svelte"; + import type {Locales} from "$i18n/i18n-types"; + import {locales} from "$i18n/i18n-util"; + import {loadLocaleAsync} from "$i18n/i18n-util.async"; + import Cookies from "js-cookie"; + + export let _pwKey: string | undefined = undefined; + export let tabindex: number | undefined = undefined; + let currentLocale = Cookies.get(CookieNames.locale); + + async function switch_locale(newLocale: Locales) { + if (!newLocale || $locale === newLocale) return; + await loadLocaleAsync(newLocale); + setLocale(newLocale); + document.querySelector("html")?.setAttribute("lang", newLocale); + Cookies.set(CookieNames.locale, newLocale); + currentLocale = newLocale; + console.log("Switched to: " + newLocale); + } + + function on_change(event: Event) { + const target = event.target as HTMLSelectElement; + switch_locale(target.options[target.selectedIndex].value as Locales); + } + + $: if (browser) { + switch_locale($page.params.lang as Locales); + } + + function get_locale_name(iso: string) { + switch (iso) { + case "nb": { + return "Norsk Bokmål"; + } + case "en": { + return "English"; + } + } + } +</script> + +<select + {tabindex} + use:pwKey={_pwKey} + on:change={on_change} + class="mt-1 mr-1 block border-none py-2 pl-3 pr-10 text-base rounded-md right-0 absolute focus:outline-none focus:ring-teal-500 sm:text-sm" +> + {#each locales as aLocale} + <option value={aLocale} selected={aLocale === currentLocale}>{get_locale_name(aLocale)}</option> + {/each} +</select> diff --git a/code/frontend/src/components/sonner.svelte b/code/frontend/src/components/sonner.svelte new file mode 100644 index 0000000..422e189 --- /dev/null +++ b/code/frontend/src/components/sonner.svelte @@ -0,0 +1,21 @@ +<script lang="ts"> + import { Toaster as Sonner, type ToasterProps as SonnerProps } from 'svelte-sonner' + import { mode } from 'mode-watcher' + + type $$Props = SonnerProps +</script> + +<Sonner + theme={$mode} + class="toaster group" + toastOptions={{ + classes: { + toast: + 'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg', + description: 'group-[.toast]:text-muted-foreground', + actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground', + cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground' + } + }} + {...$$restProps} +/> diff --git a/code/frontend/src/components/style-changer.svelte b/code/frontend/src/components/style-changer.svelte new file mode 100644 index 0000000..b219b10 --- /dev/null +++ b/code/frontend/src/components/style-changer.svelte @@ -0,0 +1,22 @@ +<script lang="ts"> + import Sun from 'svelte-radix/Sun.svelte' + import Moon from 'svelte-radix/Moon.svelte' + import { resetMode, setMode } from 'mode-watcher' + import { Button } from '$components/ui/button' + import * as DropdownMenu from '$components/ui/dropdown-menu' +</script> + +<DropdownMenu.Root> + <DropdownMenu.Trigger asChild let:builder> + <Button builders={[builder]} variant="outline" size="icon"> + <Sun class="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" /> + <Moon class="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" /> + <span class="sr-only">Toggle theme</span> + </Button> + </DropdownMenu.Trigger> + <DropdownMenu.Content align="end"> + <DropdownMenu.Item on:click={() => setMode('light')}>Light</DropdownMenu.Item> + <DropdownMenu.Item on:click={() => setMode('dark')}>Dark</DropdownMenu.Item> + <DropdownMenu.Item on:click={() => resetMode()}>System</DropdownMenu.Item> + </DropdownMenu.Content> +</DropdownMenu.Root> diff --git a/code/frontend/src/components/ui/button/button.svelte b/code/frontend/src/components/ui/button/button.svelte new file mode 100644 index 0000000..196ae77 --- /dev/null +++ b/code/frontend/src/components/ui/button/button.svelte @@ -0,0 +1,25 @@ +<script lang="ts"> + import { Button as ButtonPrimitive } from 'bits-ui' + import { type Events, type Props, buttonVariants } from './index.js' + import { cn } from '$utils/ui.js' + + type $$Props = Props + type $$Events = Events + + let className: $$Props['class'] = undefined + export let variant: $$Props['variant'] = 'default' + export let size: $$Props['size'] = 'default' + export let builders: $$Props['builders'] = [] + export { className as class } +</script> + +<ButtonPrimitive.Root + {builders} + class={cn(buttonVariants({ variant, size, className }))} + type="button" + {...$$restProps} + on:click + on:keydown +> + <slot /> +</ButtonPrimitive.Root> diff --git a/code/frontend/src/components/ui/button/index.ts b/code/frontend/src/components/ui/button/index.ts new file mode 100644 index 0000000..9cfd91c --- /dev/null +++ b/code/frontend/src/components/ui/button/index.ts @@ -0,0 +1,48 @@ +import type { Button as ButtonPrimitive } from 'bits-ui' +import { type VariantProps, tv } from 'tailwind-variants' +import Root from './button.svelte' + +const buttonVariants = tv({ + base: 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', + variants: { + variant: { + default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90', + destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', + outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', + secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline' + }, + size: { + default: 'h-9 px-4 py-2', + sm: 'h-8 rounded-md px-3 text-xs', + lg: 'h-10 rounded-md px-8', + icon: 'h-9 w-9' + } + }, + defaultVariants: { + variant: 'default', + size: 'default' + } +}) + +type Variant = VariantProps<typeof buttonVariants>['variant'] +type Size = VariantProps<typeof buttonVariants>['size'] + +type Props = ButtonPrimitive.Props & { + variant?: Variant + size?: Size +} + +type Events = ButtonPrimitive.Events + +export { + Root, + type Props, + type Events, + // + Root as Button, + type Props as ButtonProps, + type Events as ButtonEvents, + buttonVariants +} diff --git a/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte new file mode 100644 index 0000000..ea02af0 --- /dev/null +++ b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte @@ -0,0 +1,35 @@ +<script lang="ts"> + import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui' + import Check from 'svelte-radix/Check.svelte' + import { cn } from '$utils/ui' + + type $$Props = DropdownMenuPrimitive.CheckboxItemProps + type $$Events = DropdownMenuPrimitive.CheckboxItemEvents + + let className: $$Props['class'] = undefined + export let checked: $$Props['checked'] = undefined + export { className as class } +</script> + +<DropdownMenuPrimitive.CheckboxItem + bind:checked + class={cn( + 'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50', + className + )} + {...$$restProps} + on:click + on:keydown + on:focusin + on:focusout + on:pointerdown + on:pointerleave + on:pointermove +> + <span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <DropdownMenuPrimitive.CheckboxIndicator> + <Check class="h-4 w-4" /> + </DropdownMenuPrimitive.CheckboxIndicator> + </span> + <slot /> +</DropdownMenuPrimitive.CheckboxItem> diff --git a/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-content.svelte b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-content.svelte new file mode 100644 index 0000000..a2b8da7 --- /dev/null +++ b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-content.svelte @@ -0,0 +1,26 @@ +<script lang="ts"> + import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui' + import { cn, flyAndScale } from '$utils/ui' + + type $$Props = DropdownMenuPrimitive.ContentProps + + let className: $$Props['class'] = undefined + export let sideOffset: $$Props['sideOffset'] = 4 + export let transition: $$Props['transition'] = flyAndScale + export let transitionConfig: $$Props['transitionConfig'] = undefined + export { className as class } +</script> + +<DropdownMenuPrimitive.Content + {transition} + {transitionConfig} + {sideOffset} + class={cn( + 'z-50 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-md focus:outline-none', + className + )} + {...$$restProps} + on:keydown +> + <slot /> +</DropdownMenuPrimitive.Content> diff --git a/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-item.svelte b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-item.svelte new file mode 100644 index 0000000..ed45da7 --- /dev/null +++ b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-item.svelte @@ -0,0 +1,31 @@ +<script lang="ts"> + import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui' + import { cn } from '$utils/ui' + + type $$Props = DropdownMenuPrimitive.ItemProps & { + inset?: boolean + } + type $$Events = DropdownMenuPrimitive.ItemEvents + + let className: $$Props['class'] = undefined + export let inset: $$Props['inset'] = undefined + export { className as class } +</script> + +<DropdownMenuPrimitive.Item + class={cn( + 'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50', + inset && 'pl-8', + className + )} + on:click + on:keydown + on:focusin + on:focusout + on:pointerdown + on:pointerleave + on:pointermove + {...$$restProps} +> + <slot /> +</DropdownMenuPrimitive.Item> diff --git a/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-label.svelte b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-label.svelte new file mode 100644 index 0000000..69fddd1 --- /dev/null +++ b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-label.svelte @@ -0,0 +1,19 @@ +<script lang="ts"> + import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui' + import { cn } from '$utils/ui' + + type $$Props = DropdownMenuPrimitive.LabelProps & { + inset?: boolean + } + + let className: $$Props['class'] = undefined + export let inset: $$Props['inset'] = undefined + export { className as class } +</script> + +<DropdownMenuPrimitive.Label + class={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)} + {...$$restProps} +> + <slot /> +</DropdownMenuPrimitive.Label> diff --git a/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte new file mode 100644 index 0000000..c07bd1a --- /dev/null +++ b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte @@ -0,0 +1,11 @@ +<script lang="ts"> + import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui' + + type $$Props = DropdownMenuPrimitive.RadioGroupProps + + export let value: $$Props['value'] = undefined +</script> + +<DropdownMenuPrimitive.RadioGroup {...$$restProps} bind:value> + <slot /> +</DropdownMenuPrimitive.RadioGroup> diff --git a/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte new file mode 100644 index 0000000..c754953 --- /dev/null +++ b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte @@ -0,0 +1,35 @@ +<script lang="ts"> + import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui' + import DotFilled from 'svelte-radix/DotFilled.svelte' + import { cn } from '$utils/ui' + + type $$Props = DropdownMenuPrimitive.RadioItemProps + type $$Events = DropdownMenuPrimitive.RadioItemEvents + + let className: $$Props['class'] = undefined + export let value: DropdownMenuPrimitive.RadioItemProps['value'] + export { className as class } +</script> + +<DropdownMenuPrimitive.RadioItem + class={cn( + 'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50', + className + )} + {value} + {...$$restProps} + on:click + on:keydown + on:focusin + on:focusout + on:pointerdown + on:pointerleave + on:pointermove +> + <span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> + <DropdownMenuPrimitive.RadioIndicator> + <DotFilled class="h-4 w-4 fill-current" /> + </DropdownMenuPrimitive.RadioIndicator> + </span> + <slot /> +</DropdownMenuPrimitive.RadioItem> diff --git a/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-separator.svelte b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-separator.svelte new file mode 100644 index 0000000..b6c5798 --- /dev/null +++ b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-separator.svelte @@ -0,0 +1,11 @@ +<script lang="ts"> + import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui' + import { cn } from '$utils/ui' + + type $$Props = DropdownMenuPrimitive.SeparatorProps + + let className: $$Props['class'] = undefined + export { className as class } +</script> + +<DropdownMenuPrimitive.Separator class={cn('-mx-1 my-1 h-px bg-muted', className)} {...$$restProps} /> diff --git a/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte new file mode 100644 index 0000000..f9e5953 --- /dev/null +++ b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte @@ -0,0 +1,13 @@ +<script lang="ts"> + import type { HTMLAttributes } from 'svelte/elements' + import { cn } from '$utils/ui' + + type $$Props = HTMLAttributes<HTMLSpanElement> + + let className: $$Props['class'] = undefined + export { className as class } +</script> + +<span class={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...$$restProps}> + <slot /> +</span> diff --git a/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte new file mode 100644 index 0000000..7c00a1b --- /dev/null +++ b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte @@ -0,0 +1,29 @@ +<script lang="ts"> + import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui' + import { cn, flyAndScale } from '$utils/ui' + + type $$Props = DropdownMenuPrimitive.SubContentProps + + let className: $$Props['class'] = undefined + export let transition: $$Props['transition'] = flyAndScale + export let transitionConfig: $$Props['transitionConfig'] = { + x: -10, + y: 0 + } + export { className as class } +</script> + +<DropdownMenuPrimitive.SubContent + {transition} + {transitionConfig} + class={cn( + 'z-50 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-lg focus:outline-none', + className + )} + {...$$restProps} + on:keydown + on:focusout + on:pointermove +> + <slot /> +</DropdownMenuPrimitive.SubContent> diff --git a/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte new file mode 100644 index 0000000..4967d2b --- /dev/null +++ b/code/frontend/src/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte @@ -0,0 +1,32 @@ +<script lang="ts"> + import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui' + import ChevronRight from 'svelte-radix/ChevronRight.svelte' + import { cn } from '$utils/ui' + + type $$Props = DropdownMenuPrimitive.SubTriggerProps & { + inset?: boolean + } + type $$Events = DropdownMenuPrimitive.SubTriggerEvents + + let className: $$Props['class'] = undefined + export let inset: $$Props['inset'] = undefined + export { className as class } +</script> + +<DropdownMenuPrimitive.SubTrigger + class={cn( + 'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent data-[state=open]:bg-accent data-[highlighted]:text-accent-foreground data-[state=open]:text-accent-foreground', + inset && 'pl-8', + className + )} + {...$$restProps} + on:click + on:keydown + on:focusin + on:focusout + on:pointerleave + on:pointermove +> + <slot /> + <ChevronRight class="ml-auto h-4 w-4" /> +</DropdownMenuPrimitive.SubTrigger> diff --git a/code/frontend/src/components/ui/dropdown-menu/index.ts b/code/frontend/src/components/ui/dropdown-menu/index.ts new file mode 100644 index 0000000..df959fa --- /dev/null +++ b/code/frontend/src/components/ui/dropdown-menu/index.ts @@ -0,0 +1,48 @@ +import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui' +import Item from './dropdown-menu-item.svelte' +import Label from './dropdown-menu-label.svelte' +import Content from './dropdown-menu-content.svelte' +import Shortcut from './dropdown-menu-shortcut.svelte' +import RadioItem from './dropdown-menu-radio-item.svelte' +import Separator from './dropdown-menu-separator.svelte' +import RadioGroup from './dropdown-menu-radio-group.svelte' +import SubContent from './dropdown-menu-sub-content.svelte' +import SubTrigger from './dropdown-menu-sub-trigger.svelte' +import CheckboxItem from './dropdown-menu-checkbox-item.svelte' + +const Sub = DropdownMenuPrimitive.Sub +const Root = DropdownMenuPrimitive.Root +const Trigger = DropdownMenuPrimitive.Trigger +const Group = DropdownMenuPrimitive.Group + +export { + Sub, + Root, + Item, + Label, + Group, + Trigger, + Content, + Shortcut, + Separator, + RadioItem, + SubContent, + SubTrigger, + RadioGroup, + CheckboxItem, + // + Root as DropdownMenu, + Sub as DropdownMenuSub, + Item as DropdownMenuItem, + Label as DropdownMenuLabel, + Group as DropdownMenuGroup, + Content as DropdownMenuContent, + Trigger as DropdownMenuTrigger, + Shortcut as DropdownMenuShortcut, + RadioItem as DropdownMenuRadioItem, + Separator as DropdownMenuSeparator, + RadioGroup as DropdownMenuRadioGroup, + SubContent as DropdownMenuSubContent, + SubTrigger as DropdownMenuSubTrigger, + CheckboxItem as DropdownMenuCheckboxItem +} diff --git a/code/frontend/src/configuration/index.ts b/code/frontend/src/configuration/index.ts new file mode 100644 index 0000000..1ffd67f --- /dev/null +++ b/code/frontend/src/configuration/index.ts @@ -0,0 +1,38 @@ +export const APP_ADDRESS = "https://stage.greatoffice.app"; +export const API_ADDRESS = "https://stage-api.greatoffice.app"; +export const DEV_APP_ADDRESS = "http://localhost"; +export const DEV_API_ADDRESS = "http://localhost:5000"; + +export function api_base(path: string = "", explicitVersion = 1): string { + if (path && !path.startsWith("_")) path = "v" + explicitVersion + path; + return (is_development() ? DEV_API_ADDRESS : API_ADDRESS) + (path !== "" ? "/" + path : ""); +} + +export function is_development(): boolean { + return import.meta.env.DEV; +} + +export const CookieNames = { + theme: "go_theme", + locale: "go_locale", + session: "go_session", +}; + +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", +}; + +export type PortalMessage = "emailValidated";
\ No newline at end of file diff --git a/code/frontend/src/configuration/test.ts b/code/frontend/src/configuration/test.ts new file mode 100644 index 0000000..12392de --- /dev/null +++ b/code/frontend/src/configuration/test.ts @@ -0,0 +1,21 @@ +import {env} from "$env/dynamic/private"; + +export function get_test_context(): TestContext { + return { + user: { + username: env.TEST_USERNAME, + password: env.TEST_PASSWORD, + }, + }; +} + +export function is_testing(): boolean { + return env.TESTING == "true"; +} + +export interface TestContext { + user: { + username: string, + password: string + }; +} diff --git a/code/frontend/src/global.d.ts b/code/frontend/src/global.d.ts new file mode 100644 index 0000000..13f5e16 --- /dev/null +++ b/code/frontend/src/global.d.ts @@ -0,0 +1,11 @@ +/// <reference types="@sveltejs/kit" /> + +type Locales = import('$lib/i18n/i18n-types').Locales +type TranslationFunctions = import('$lib/i18n/i18n-types').TranslationFunctions + +declare namespace App { + interface Locals { + locale: Locales + LL: TranslationFunctions + } +}
\ No newline at end of file diff --git a/code/frontend/src/hooks.server.ts b/code/frontend/src/hooks.server.ts new file mode 100644 index 0000000..b636e31 --- /dev/null +++ b/code/frontend/src/hooks.server.ts @@ -0,0 +1,48 @@ +import {CookieNames} from "$configuration"; +import {detectLocale, i18n, isLocale, locales} from "$i18n/i18n-util"; +import type {Handle, RequestEvent} from "@sveltejs/kit"; +import {initAcceptLanguageHeaderDetector} from "typesafe-i18n/detectors"; +import type {Locales} from "$i18n/i18n-types"; +import {loadAllLocales} from "$i18n/i18n-util.sync"; + +loadAllLocales(); +const L = i18n(); + +export const handle: Handle = async ({event, resolve}) => { + const localeCookie = event.cookies.get(CookieNames.locale); + const preferredLocale = getPreferredLocale(event); + let finalLocale = localeCookie ?? preferredLocale; + let forceCookieSet = false; + + console.debug("Handling locale", { + locales, + localeCookie, + preferredLocale, + finalLocale, + }); + + if (!isLocale(finalLocale)) { + console.debug(finalLocale + " is not a valid locale or it does not exist, switching to default: en"); + finalLocale = "en"; + forceCookieSet = true; + } + + if (!localeCookie || forceCookieSet) { + // Set a locale cookie + event.cookies.set(CookieNames.locale, finalLocale, { + sameSite: "strict", + path: "/", + httpOnly: false, + }); + } + + event.locals.locale = finalLocale as Locales; + event.locals.LL = L[finalLocale as Locales]; + + return resolve(event, {transformPageChunk: ({html}) => html.replace("%lang%", finalLocale)}); +}; + +function getPreferredLocale(event: RequestEvent) { + const acceptLanguageDetector = initAcceptLanguageHeaderDetector(event.request); + return detectLocale(acceptLanguageDetector); +} diff --git a/code/frontend/src/i18n/en/app/index.ts b/code/frontend/src/i18n/en/app/index.ts new file mode 100644 index 0000000..7ccfc97 --- /dev/null +++ b/code/frontend/src/i18n/en/app/index.ts @@ -0,0 +1,7 @@ +import type { BaseTranslation } from '../../i18n-types' + +const en_app: BaseTranslation = { + members: "Members", +} + +export default en_app
\ No newline at end of file diff --git a/code/frontend/src/i18n/en/index.ts b/code/frontend/src/i18n/en/index.ts new file mode 100644 index 0000000..b38eb48 --- /dev/null +++ b/code/frontend/src/i18n/en/index.ts @@ -0,0 +1,63 @@ +import type { BaseTranslation } from "../i18n-types"; + +const en: BaseTranslation = { + or: "Or", + name: "Name", + emailAddress: "Email address", + password: "Password", + pageNotFound: "Page not found", + noInternet: "It seems like your device does not have a internet connection, please check your connection.", + reset: "Reset", + of: "{0} of {1}", + isRequired: "{0} is required", + submit: "Submit", + success: "Success", + tryAgainSoon: "Try again soon", + createANewAccount: "Create a new account", + unexpectedError: "An unexpected error occured", + notFound: "Not found", + documentation: "Documentation", + tos: "Terms of service", + privacyPolicy: "Privacy policy", + signIntoYourAccount: "Sign into your account", + combobox: { + search: "Search", + noRecordsFound: "No records found", + createRecordHelpText: "Create a record by typing the name in the search bar and pressing enter", + createRecordButtonText: "Press enter or click here to create {0}" + }, + signInPage: { + title: "Sign in", + notMyComputer: "This is not my computer", + resetPassword: "Reset password", + yourPasswordIsUpdated: "Your password is updated", + signIn: "Sign In", + yourNewPasswordIsApplied: "Your new password is applied", + signInBelow: "Sign in below", + yourAccountIsDisabled: "Your account is disabled", + contactYourAdminIfDisabled: "Contact your administrator if this feels wrong", + youHaveReachedInactivityLimit: "You've reached the hidden inactivity limit", + feelFreeToSignInAgain: "Feel free to sign in again" + }, + signUpPage: { + title: "Sign up", + createYourNewAccount: "Create your new account", + }, + resetPasswordPage: { + title: "Reset password", + fulfillTitle: "Set new password", + setANewPassword: "Set a new password", + expired: "Expired", + requestHasExpired: "Your request has expired", + requestANewReset: "Request a new reset", + invalidRequestTitle: "Your request is invalid", + invalidRequestMessage: "This could be due to it being expired, nonexsistent or something else", + newPassword: "New password", + requestSentMessage: "If we find your email address in our systems, you will receive an email with instructions on how to set a new password for your account.", + requestAPasswordReset: "Request a password reset", + requestNotFound: "Your request was not found", + submitANewRequestBelow: "Submit a new reset request below" + } +}; + +export default en; diff --git a/code/frontend/src/i18n/formatters.ts b/code/frontend/src/i18n/formatters.ts new file mode 100644 index 0000000..f310eb7 --- /dev/null +++ b/code/frontend/src/i18n/formatters.ts @@ -0,0 +1,13 @@ +import { capitalise } from "$utils/misc-helpers"; +import type { FormattersInitializer } from "typesafe-i18n"; +import type { Locales, Formatters } from "./i18n-types"; + +export const initFormatters: FormattersInitializer<Locales, Formatters> = (locale: Locales) => { + + const formatters: Formatters = { + // add your formatter functions here + capitalise: (value: string) => capitalise(value), + }; + + return formatters; +}; diff --git a/code/frontend/src/i18n/i18n-svelte.ts b/code/frontend/src/i18n/i18n-svelte.ts new file mode 100644 index 0000000..6cdffb3 --- /dev/null +++ b/code/frontend/src/i18n/i18n-svelte.ts @@ -0,0 +1,12 @@ +// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. +/* eslint-disable */ + +import { initI18nSvelte } from 'typesafe-i18n/svelte' +import type { Formatters, Locales, TranslationFunctions, Translations } from './i18n-types' +import { loadedFormatters, loadedLocales } from './i18n-util' + +const { locale, LL, setLocale } = initI18nSvelte<Locales, Translations, TranslationFunctions, Formatters>(loadedLocales, loadedFormatters) + +export { locale, LL, setLocale } + +export default LL diff --git a/code/frontend/src/i18n/i18n-types.ts b/code/frontend/src/i18n/i18n-types.ts new file mode 100644 index 0000000..ef1d664 --- /dev/null +++ b/code/frontend/src/i18n/i18n-types.ts @@ -0,0 +1,461 @@ +// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. +/* eslint-disable */ +import type { BaseTranslation as BaseTranslationType, LocalizedString, RequiredParams } from 'typesafe-i18n' + +export type BaseTranslation = BaseTranslationType & DisallowNamespaces +export type BaseLocale = 'en' + +export type Locales = + | 'en' + | 'nb' + +export type Translation = RootTranslation & DisallowNamespaces + +export type Translations = RootTranslation & +{ + app: NamespaceAppTranslation +} + +type RootTranslation = { + /** + * O​r + */ + or: string + /** + * N​a​m​e + */ + name: string + /** + * E​m​a​i​l​ ​a​d​d​r​e​s​s + */ + emailAddress: string + /** + * P​a​s​s​w​o​r​d + */ + password: string + /** + * P​a​g​e​ ​n​o​t​ ​f​o​u​n​d + */ + pageNotFound: string + /** + * I​t​ ​s​e​e​m​s​ ​l​i​k​e​ ​y​o​u​r​ ​d​e​v​i​c​e​ ​d​o​e​s​ ​n​o​t​ ​h​a​v​e​ ​a​ ​i​n​t​e​r​n​e​t​ ​c​o​n​n​e​c​t​i​o​n​,​ ​p​l​e​a​s​e​ ​c​h​e​c​k​ ​y​o​u​r​ ​c​o​n​n​e​c​t​i​o​n​. + */ + noInternet: string + /** + * R​e​s​e​t + */ + reset: string + /** + * {​0​}​ ​o​f​ ​{​1​} + * @param {unknown} 0 + * @param {unknown} 1 + */ + of: RequiredParams<'0' | '1'> + /** + * {​0​}​ ​i​s​ ​r​e​q​u​i​r​e​d + * @param {unknown} 0 + */ + isRequired: RequiredParams<'0'> + /** + * S​u​b​m​i​t + */ + submit: string + /** + * S​u​c​c​e​s​s + */ + success: string + /** + * T​r​y​ ​a​g​a​i​n​ ​s​o​o​n + */ + tryAgainSoon: string + /** + * C​r​e​a​t​e​ ​a​ ​n​e​w​ ​a​c​c​o​u​n​t + */ + createANewAccount: string + /** + * A​n​ ​u​n​e​x​p​e​c​t​e​d​ ​e​r​r​o​r​ ​o​c​c​u​r​e​d + */ + unexpectedError: string + /** + * N​o​t​ ​f​o​u​n​d + */ + notFound: string + /** + * D​o​c​u​m​e​n​t​a​t​i​o​n + */ + documentation: string + /** + * T​e​r​m​s​ ​o​f​ ​s​e​r​v​i​c​e + */ + tos: string + /** + * P​r​i​v​a​c​y​ ​p​o​l​i​c​y + */ + privacyPolicy: string + /** + * S​i​g​n​ ​i​n​t​o​ ​y​o​u​r​ ​a​c​c​o​u​n​t + */ + signIntoYourAccount: string + combobox: { + /** + * S​e​a​r​c​h + */ + search: string + /** + * N​o​ ​r​e​c​o​r​d​s​ ​f​o​u​n​d + */ + noRecordsFound: string + /** + * C​r​e​a​t​e​ ​a​ ​r​e​c​o​r​d​ ​b​y​ ​t​y​p​i​n​g​ ​t​h​e​ ​n​a​m​e​ ​i​n​ ​t​h​e​ ​s​e​a​r​c​h​ ​b​a​r​ ​a​n​d​ ​p​r​e​s​s​i​n​g​ ​e​n​t​e​r + */ + createRecordHelpText: string + /** + * P​r​e​s​s​ ​e​n​t​e​r​ ​o​r​ ​c​l​i​c​k​ ​h​e​r​e​ ​t​o​ ​c​r​e​a​t​e​ ​{​0​} + * @param {unknown} 0 + */ + createRecordButtonText: RequiredParams<'0'> + } + signInPage: { + /** + * S​i​g​n​ ​i​n + */ + title: string + /** + * T​h​i​s​ ​i​s​ ​n​o​t​ ​m​y​ ​c​o​m​p​u​t​e​r + */ + notMyComputer: string + /** + * R​e​s​e​t​ ​p​a​s​s​w​o​r​d + */ + resetPassword: string + /** + * Y​o​u​r​ ​p​a​s​s​w​o​r​d​ ​i​s​ ​u​p​d​a​t​e​d + */ + yourPasswordIsUpdated: string + /** + * S​i​g​n​ ​I​n + */ + signIn: string + /** + * Y​o​u​r​ ​n​e​w​ ​p​a​s​s​w​o​r​d​ ​i​s​ ​a​p​p​l​i​e​d + */ + yourNewPasswordIsApplied: string + /** + * S​i​g​n​ ​i​n​ ​b​e​l​o​w + */ + signInBelow: string + /** + * Y​o​u​r​ ​a​c​c​o​u​n​t​ ​i​s​ ​d​i​s​a​b​l​e​d + */ + yourAccountIsDisabled: string + /** + * C​o​n​t​a​c​t​ ​y​o​u​r​ ​a​d​m​i​n​i​s​t​r​a​t​o​r​ ​i​f​ ​t​h​i​s​ ​f​e​e​l​s​ ​w​r​o​n​g + */ + contactYourAdminIfDisabled: string + /** + * Y​o​u​'​v​e​ ​r​e​a​c​h​e​d​ ​t​h​e​ ​h​i​d​d​e​n​ ​i​n​a​c​t​i​v​i​t​y​ ​l​i​m​i​t + */ + youHaveReachedInactivityLimit: string + /** + * F​e​e​l​ ​f​r​e​e​ ​t​o​ ​s​i​g​n​ ​i​n​ ​a​g​a​i​n + */ + feelFreeToSignInAgain: string + } + signUpPage: { + /** + * S​i​g​n​ ​u​p + */ + title: string + /** + * C​r​e​a​t​e​ ​y​o​u​r​ ​n​e​w​ ​a​c​c​o​u​n​t + */ + createYourNewAccount: string + } + resetPasswordPage: { + /** + * R​e​s​e​t​ ​p​a​s​s​w​o​r​d + */ + title: string + /** + * S​e​t​ ​n​e​w​ ​p​a​s​s​w​o​r​d + */ + fulfillTitle: string + /** + * S​e​t​ ​a​ ​n​e​w​ ​p​a​s​s​w​o​r​d + */ + setANewPassword: string + /** + * E​x​p​i​r​e​d + */ + expired: string + /** + * Y​o​u​r​ ​r​e​q​u​e​s​t​ ​h​a​s​ ​e​x​p​i​r​e​d + */ + requestHasExpired: string + /** + * R​e​q​u​e​s​t​ ​a​ ​n​e​w​ ​r​e​s​e​t + */ + requestANewReset: string + /** + * Y​o​u​r​ ​r​e​q​u​e​s​t​ ​i​s​ ​i​n​v​a​l​i​d + */ + invalidRequestTitle: string + /** + * T​h​i​s​ ​c​o​u​l​d​ ​b​e​ ​d​u​e​ ​t​o​ ​i​t​ ​b​e​i​n​g​ ​e​x​p​i​r​e​d​,​ ​n​o​n​e​x​s​i​s​t​e​n​t​ ​o​r​ ​s​o​m​e​t​h​i​n​g​ ​e​l​s​e + */ + invalidRequestMessage: string + /** + * N​e​w​ ​p​a​s​s​w​o​r​d + */ + newPassword: string + /** + * I​f​ ​w​e​ ​f​i​n​d​ ​y​o​u​r​ ​e​m​a​i​l​ ​a​d​d​r​e​s​s​ ​i​n​ ​o​u​r​ ​s​y​s​t​e​m​s​,​ ​y​o​u​ ​w​i​l​l​ ​r​e​c​e​i​v​e​ ​a​n​ ​e​m​a​i​l​ ​w​i​t​h​ ​i​n​s​t​r​u​c​t​i​o​n​s​ ​o​n​ ​h​o​w​ ​t​o​ ​s​e​t​ ​a​ ​n​e​w​ ​p​a​s​s​w​o​r​d​ ​f​o​r​ ​y​o​u​r​ ​a​c​c​o​u​n​t​. + */ + requestSentMessage: string + /** + * R​e​q​u​e​s​t​ ​a​ ​p​a​s​s​w​o​r​d​ ​r​e​s​e​t + */ + requestAPasswordReset: string + /** + * Y​o​u​r​ ​r​e​q​u​e​s​t​ ​w​a​s​ ​n​o​t​ ​f​o​u​n​d + */ + requestNotFound: string + /** + * S​u​b​m​i​t​ ​a​ ​n​e​w​ ​r​e​s​e​t​ ​r​e​q​u​e​s​t​ ​b​e​l​o​w + */ + submitANewRequestBelow: string + } +} + +export type NamespaceAppTranslation = { + /** + * M​e​m​b​e​r​s + */ + members: string +} + +export type Namespaces = + | 'app' + +type DisallowNamespaces = { + /** + * reserved for 'app'-namespace\ + * you need to use the `./app/index.ts` file instead + */ + app?: "[typesafe-i18n] reserved for 'app'-namespace. You need to use the `./app/index.ts` file instead." +} + +export type TranslationFunctions = { + /** + * Or + */ + or: () => LocalizedString + /** + * Name + */ + name: () => LocalizedString + /** + * Email address + */ + emailAddress: () => LocalizedString + /** + * Password + */ + password: () => LocalizedString + /** + * Page not found + */ + pageNotFound: () => LocalizedString + /** + * It seems like your device does not have a internet connection, please check your connection. + */ + noInternet: () => LocalizedString + /** + * Reset + */ + reset: () => LocalizedString + /** + * {0} of {1} + */ + of: (arg0: unknown, arg1: unknown) => LocalizedString + /** + * {0} is required + */ + isRequired: (arg0: unknown) => LocalizedString + /** + * Submit + */ + submit: () => LocalizedString + /** + * Success + */ + success: () => LocalizedString + /** + * Try again soon + */ + tryAgainSoon: () => LocalizedString + /** + * Create a new account + */ + createANewAccount: () => LocalizedString + /** + * An unexpected error occured + */ + unexpectedError: () => LocalizedString + /** + * Not found + */ + notFound: () => LocalizedString + /** + * Documentation + */ + documentation: () => LocalizedString + /** + * Terms of service + */ + tos: () => LocalizedString + /** + * Privacy policy + */ + privacyPolicy: () => LocalizedString + /** + * Sign into your account + */ + signIntoYourAccount: () => LocalizedString + combobox: { + /** + * Search + */ + search: () => LocalizedString + /** + * No records found + */ + noRecordsFound: () => LocalizedString + /** + * Create a record by typing the name in the search bar and pressing enter + */ + createRecordHelpText: () => LocalizedString + /** + * Press enter or click here to create {0} + */ + createRecordButtonText: (arg0: unknown) => LocalizedString + } + signInPage: { + /** + * Sign in + */ + title: () => LocalizedString + /** + * This is not my computer + */ + notMyComputer: () => LocalizedString + /** + * Reset password + */ + resetPassword: () => LocalizedString + /** + * Your password is updated + */ + yourPasswordIsUpdated: () => LocalizedString + /** + * Sign In + */ + signIn: () => LocalizedString + /** + * Your new password is applied + */ + yourNewPasswordIsApplied: () => LocalizedString + /** + * Sign in below + */ + signInBelow: () => LocalizedString + /** + * Your account is disabled + */ + yourAccountIsDisabled: () => LocalizedString + /** + * Contact your administrator if this feels wrong + */ + contactYourAdminIfDisabled: () => LocalizedString + /** + * You've reached the hidden inactivity limit + */ + youHaveReachedInactivityLimit: () => LocalizedString + /** + * Feel free to sign in again + */ + feelFreeToSignInAgain: () => LocalizedString + } + signUpPage: { + /** + * Sign up + */ + title: () => LocalizedString + /** + * Create your new account + */ + createYourNewAccount: () => LocalizedString + } + resetPasswordPage: { + /** + * Reset password + */ + title: () => LocalizedString + /** + * Set new password + */ + fulfillTitle: () => LocalizedString + /** + * Set a new password + */ + setANewPassword: () => LocalizedString + /** + * Expired + */ + expired: () => LocalizedString + /** + * Your request has expired + */ + requestHasExpired: () => LocalizedString + /** + * Request a new reset + */ + requestANewReset: () => LocalizedString + /** + * Your request is invalid + */ + invalidRequestTitle: () => LocalizedString + /** + * This could be due to it being expired, nonexsistent or something else + */ + invalidRequestMessage: () => LocalizedString + /** + * New password + */ + newPassword: () => LocalizedString + /** + * If we find your email address in our systems, you will receive an email with instructions on how to set a new password for your account. + */ + requestSentMessage: () => LocalizedString + /** + * Request a password reset + */ + requestAPasswordReset: () => LocalizedString + /** + * Your request was not found + */ + requestNotFound: () => LocalizedString + /** + * Submit a new reset request below + */ + submitANewRequestBelow: () => LocalizedString + } + app: { + /** + * Members + */ + members: () => LocalizedString + } +} + +export type Formatters = {} diff --git a/code/frontend/src/i18n/i18n-util.async.ts b/code/frontend/src/i18n/i18n-util.async.ts new file mode 100644 index 0000000..2e6717e --- /dev/null +++ b/code/frontend/src/i18n/i18n-util.async.ts @@ -0,0 +1,42 @@ +// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. +/* eslint-disable */ + +import { initFormatters } from './formatters' +import type { Locales, Namespaces, Translations } from './i18n-types' +import { loadedFormatters, loadedLocales, locales } from './i18n-util' + +const localeTranslationLoaders = { + en: () => import('./en'), + nb: () => import('./nb'), +} + +const localeNamespaceLoaders = { + en: { + app: () => import('./en/app') + }, + nb: { + app: () => import('./nb/app') + } +} + +const updateDictionary = (locale: Locales, dictionary: Partial<Translations>): Translations => + loadedLocales[locale] = { ...loadedLocales[locale], ...dictionary } + +export const importLocaleAsync = async (locale: Locales): Promise<Translations> => + (await localeTranslationLoaders[locale]()).default as unknown as Translations + +export const loadLocaleAsync = async (locale: Locales): Promise<void> => { + updateDictionary(locale, await importLocaleAsync(locale)) + loadFormatters(locale) +} + +export const loadAllLocalesAsync = (): Promise<void[]> => Promise.all(locales.map(loadLocaleAsync)) + +export const loadFormatters = (locale: Locales): void => + void (loadedFormatters[locale] = initFormatters(locale)) + +export const importNamespaceAsync = async<Namespace extends Namespaces>(locale: Locales, namespace: Namespace) => + (await localeNamespaceLoaders[locale][namespace]()).default as unknown as Translations[Namespace] + +export const loadNamespaceAsync = async <Namespace extends Namespaces>(locale: Locales, namespace: Namespace): Promise<void> => + void updateDictionary(locale, { [namespace]: await importNamespaceAsync(locale, namespace )}) diff --git a/code/frontend/src/i18n/i18n-util.sync.ts b/code/frontend/src/i18n/i18n-util.sync.ts new file mode 100644 index 0000000..8144fdc --- /dev/null +++ b/code/frontend/src/i18n/i18n-util.sync.ts @@ -0,0 +1,35 @@ +// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. +/* eslint-disable */ + +import { initFormatters } from './formatters' +import type { Locales, Translations } from './i18n-types' +import { loadedFormatters, loadedLocales, locales } from './i18n-util' + +import en from './en' +import nb from './nb' + +import en_app from './en/app' +import nb_app from './nb/app' + +const localeTranslations = { + en: { + ...en, + app: en_app + }, + nb: { + ...nb, + app: nb_app + }, +} + +export const loadLocale = (locale: Locales): void => { + if (loadedLocales[locale]) return + + loadedLocales[locale] = localeTranslations[locale] as unknown as Translations + loadFormatters(locale) +} + +export const loadAllLocales = (): void => locales.forEach(loadLocale) + +export const loadFormatters = (locale: Locales): void => + void (loadedFormatters[locale] = initFormatters(locale)) diff --git a/code/frontend/src/i18n/i18n-util.ts b/code/frontend/src/i18n/i18n-util.ts new file mode 100644 index 0000000..55b52bd --- /dev/null +++ b/code/frontend/src/i18n/i18n-util.ts @@ -0,0 +1,44 @@ +// 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 type { LocaleTranslationFunctions, TranslateByString } from 'typesafe-i18n' +import { detectLocale as detectLocaleFn } from 'typesafe-i18n/detectors' +import { initExtendDictionary } from 'typesafe-i18n/utils' +import type { Formatters, Locales, Namespaces, Translations, TranslationFunctions } from './i18n-types' + +export const baseLocale: Locales = 'en' + +export const locales: Locales[] = [ + 'en', + 'nb' +] + +export const namespaces: Namespaces[] = [ + 'app' +] + +export const isLocale = (locale: string): locale is Locales => locales.includes(locale as Locales) + +export const isNamespace = (namespace: string): namespace is Namespaces => namespaces.includes(namespace as Namespaces) + +export const loadedLocales: Record<Locales, Translations> = {} as Record<Locales, Translations> + +export const loadedFormatters: Record<Locales, Formatters> = {} as Record<Locales, Formatters> + +export const extendDictionary = initExtendDictionary<Translations>() + +export const i18nString = (locale: Locales): TranslateByString => initI18nString<Locales, Formatters>(locale, loadedFormatters[locale]) + +export const i18nObject = (locale: Locales): TranslationFunctions => + initI18nObject<Locales, Translations, TranslationFunctions, Formatters>( + locale, + loadedLocales[locale], + loadedFormatters[locale] + ) + +export const i18n = (): LocaleTranslationFunctions<Locales, Translations, TranslationFunctions> => + initI18n<Locales, Translations, TranslationFunctions, Formatters>(loadedLocales, loadedFormatters) + +export const detectLocale = (...detectors: LocaleDetector[]): Locales => detectLocaleFn<Locales>(baseLocale, locales, ...detectors) diff --git a/code/frontend/src/i18n/nb/app/index.ts b/code/frontend/src/i18n/nb/app/index.ts new file mode 100644 index 0000000..6bf9ba6 --- /dev/null +++ b/code/frontend/src/i18n/nb/app/index.ts @@ -0,0 +1,7 @@ +import type { NamespaceAppTranslation } from '../../i18n-types' + +const nb_app: NamespaceAppTranslation = { + members: "Medlemmer" +} + +export default nb_app diff --git a/code/frontend/src/i18n/nb/index.ts b/code/frontend/src/i18n/nb/index.ts new file mode 100644 index 0000000..ef67504 --- /dev/null +++ b/code/frontend/src/i18n/nb/index.ts @@ -0,0 +1,51 @@ +import type { Translation } from "../i18n-types"; + +const nb: Translation = { + or: "Eller", + name: "Navn", + emailAddress: "E-postadresse", + password: "Passord", + pageNotFound: "Fant ikke siden", + noInternet: "Det ser ut som at du ikke tilkoblet internettet, sjekk tilkoblingen din for å fortsette", + reset: "Tilbakestill", + of: "{0} av {1}", + isRequired: "{0} er påkrevd", + submit: "Send", + success: "Suksess", + tryAgainSoon: "Prøv igjen snart", + createANewAccount: "Lag en ny konto", + unexpectedError: "En uventet feil oppstod", + notFound: "Ikke funnet", + documentation: "Dokumentasjon", + tos: "Vilkår", + privacyPolicy: "Personvernerklæring", + signIntoYourAccount: "Logg inn med din konto", + signInPage: { + notMyComputer: "Dette er ikke min datamaskin", + resetPassword: "Tilbakestill passord", + yourPasswordIsUpdated: "Ditt passord er oppdater", + signIn: "Logg inn", + yourNewPasswordIsApplied: "Ditt nye passord er satt", + signInBelow: "Logg inn nedenfor", + yourAccountIsDisabled: "Din konto er deaktivert", + contactYourAdminIfDisabled: "Ta kontakt med din administrator hvis dette føles feil", + youHaveReachedInactivityLimit: "Du har nådd den hemmelige inaktivitetsgrensen", + feelFreeToSignInAgain: "Logg gjerne inn igjen" + }, + signUpPage: { + createYourNewAccount: "Opprett din nye konto", + }, + resetPasswordPage: { + setANewPassword: "Skriv et nytt passord", + expired: "Utgått", + requestHasExpired: "Din forespørsel er utgått", + requestANewReset: "Spør om en ny tilbakestillingslenke", + newPassword: "Nytt passord", + requestSentMessage: "Hvis vi finner e-postadressen din i våre systemer, vil du få en e-post med instrukser for å sette ditt nye passord.", + requestAPasswordReset: "Forespør tilbakestilling av ditt passord", + requestNotFound: "Din forespørsel ble ikke funnet", + submitANewRequestBelow: "Spør om en ny tilbakestillingslenke nedenfor" + } +} + +export default nb;
\ No newline at end of file diff --git a/code/frontend/src/index.test.ts b/code/frontend/src/index.test.ts new file mode 100644 index 0000000..e07cbbd --- /dev/null +++ b/code/frontend/src/index.test.ts @@ -0,0 +1,7 @@ +import { describe, it, expect } from 'vitest'; + +describe('sum test', () => { + it('adds 1 + 2 to equal 3', () => { + expect(1 + 2).toBe(3); + }); +}); diff --git a/code/frontend/src/models/base/Customer.ts b/code/frontend/src/models/base/Customer.ts new file mode 100644 index 0000000..ff52fbd --- /dev/null +++ b/code/frontend/src/models/base/Customer.ts @@ -0,0 +1,21 @@ +import type {CustomerContact} from "./CustomerContact"; +import type {User} from "./User"; + +export type Customer = { + /** + * Guid id for customer + */ + id: string, + /** + * The name of the company + */ + name: string, + /** + * Responsible contact in the current tenant + */ + tenantContact: User, + /** + * The customers main contact + */ + mainContact: CustomerContact, +}
\ No newline at end of file diff --git a/code/frontend/src/models/base/CustomerContact.ts b/code/frontend/src/models/base/CustomerContact.ts new file mode 100644 index 0000000..e8abea5 --- /dev/null +++ b/code/frontend/src/models/base/CustomerContact.ts @@ -0,0 +1,8 @@ +export type CustomerContact = { + firstName: string, + lastname: string, + email: string, + phone: string, + workTitle: string, + note: string +}
\ No newline at end of file diff --git a/code/frontend/src/models/base/CustomerEvent.ts b/code/frontend/src/models/base/CustomerEvent.ts new file mode 100644 index 0000000..af86511 --- /dev/null +++ b/code/frontend/src/models/base/CustomerEvent.ts @@ -0,0 +1,6 @@ +export type CustomerEvent = { + /** + * A descriptive name for the occured event + */ + name: string, +}
\ No newline at end of file diff --git a/code/frontend/src/models/base/SessionData.ts b/code/frontend/src/models/base/SessionData.ts new file mode 100644 index 0000000..015cbf3 --- /dev/null +++ b/code/frontend/src/models/base/SessionData.ts @@ -0,0 +1,5 @@ +export type SessionData = { + id: string, + username: string, + displayName: string, +}
\ No newline at end of file diff --git a/code/frontend/src/models/base/Tenant.ts b/code/frontend/src/models/base/Tenant.ts new file mode 100644 index 0000000..6307efc --- /dev/null +++ b/code/frontend/src/models/base/Tenant.ts @@ -0,0 +1,8 @@ +import type {User} from "./User"; + +export type Tenant = { + id: string, + name: string, + description: string, + masterUser: User, +}
\ No newline at end of file diff --git a/code/frontend/src/models/base/User.ts b/code/frontend/src/models/base/User.ts new file mode 100644 index 0000000..2b74d0e --- /dev/null +++ b/code/frontend/src/models/base/User.ts @@ -0,0 +1,13 @@ +import type {UserRole} from "./UserRole"; + +export type User = { + /** + * Guid id for user + */ + id: string, + firstName: string, + lastName: string, + role: UserRole, + username: string, + email: string +}
\ No newline at end of file diff --git a/code/frontend/src/models/base/UserRole.ts b/code/frontend/src/models/base/UserRole.ts new file mode 100644 index 0000000..ec32852 --- /dev/null +++ b/code/frontend/src/models/base/UserRole.ts @@ -0,0 +1,5 @@ +export enum UserRole { + REGULAR = "reg", + ADMINISTRATOR = "adm", + OWNER = "own" +}
\ No newline at end of file diff --git a/code/frontend/src/models/internal/FormError.ts b/code/frontend/src/models/internal/FormError.ts new file mode 100644 index 0000000..f6d8978 --- /dev/null +++ b/code/frontend/src/models/internal/FormError.ts @@ -0,0 +1,24 @@ +import type { KnownProblem } from "./KnownProblem"; + +export class FormError { + title: string; + subtitle: string; + constructor(title: string = "", subtitle: string = "") { + this.title = title; + this.title = subtitle; + } + + set(title: string = "", subtitle: string = "") { + this.title = title; + this.subtitle = subtitle; + } + + set_from_known_problem(knownProblem: KnownProblem) { + this.title = knownProblem.title ?? ""; + this.subtitle = knownProblem.subtitle ?? ""; + } + + has_error() { + return this.title?.length > 0 || this.subtitle?.length > 0; + } +}
\ No newline at end of file diff --git a/code/frontend/src/models/internal/IForm.ts b/code/frontend/src/models/internal/IForm.ts new file mode 100644 index 0000000..c14b770 --- /dev/null +++ b/code/frontend/src/models/internal/IForm.ts @@ -0,0 +1,15 @@ +import type { FormError } from "./FormError"; + +export interface IForm { + fields: Record<string, IFormField>; + error: FormError; + get_payload: Function; + submit_async: Function; + isLoading: boolean; + showError: boolean; +} + +export interface IFormField { + value: any; + errors: Array<string>; +} diff --git a/code/frontend/src/models/internal/KnownProblem.ts b/code/frontend/src/models/internal/KnownProblem.ts new file mode 100644 index 0000000..b6923d9 --- /dev/null +++ b/code/frontend/src/models/internal/KnownProblem.ts @@ -0,0 +1,10 @@ +export type KnownProblem = { + title: string, + subtitle: string, + errors: Record<string, string[]>, + traceId: string, +} + +export function is_known_problem(response: Response): boolean { + return response.headers.has("X-IsKnownProblem"); +}
\ No newline at end of file diff --git a/code/frontend/src/models/projects/Project.ts b/code/frontend/src/models/projects/Project.ts new file mode 100644 index 0000000..f265e67 --- /dev/null +++ b/code/frontend/src/models/projects/Project.ts @@ -0,0 +1,13 @@ +import type { Temporal } from "temporal-polyfill" +import type { ProjectMember } from "./ProjectMember" +import type { ProjectStatus } from "./ProjectStatus" + +export type Project = { + id: string, + name: string, + description?: string, + start: Temporal.PlainDate, + stop?: Temporal.PlainDate, + members: Array<ProjectMember>, + status: ProjectStatus +}
\ No newline at end of file diff --git a/code/frontend/src/models/projects/ProjectLabel.ts b/code/frontend/src/models/projects/ProjectLabel.ts new file mode 100644 index 0000000..59aa9d5 --- /dev/null +++ b/code/frontend/src/models/projects/ProjectLabel.ts @@ -0,0 +1,5 @@ +export type ProjectLabel = { + id: string, + name: string, + color: string +}
\ No newline at end of file diff --git a/code/frontend/src/models/projects/ProjectMember.ts b/code/frontend/src/models/projects/ProjectMember.ts new file mode 100644 index 0000000..de348ef --- /dev/null +++ b/code/frontend/src/models/projects/ProjectMember.ts @@ -0,0 +1,10 @@ +import type { ProjectRole } from "./ProjectRole" + +export type ProjectMember = { + id: string, + name: string, + role: ProjectRole, + email: string, + userId?: string, + customerId?: string +}
\ No newline at end of file diff --git a/code/frontend/src/models/projects/ProjectMeta.ts b/code/frontend/src/models/projects/ProjectMeta.ts new file mode 100644 index 0000000..c583b47 --- /dev/null +++ b/code/frontend/src/models/projects/ProjectMeta.ts @@ -0,0 +1,7 @@ +import type { Temporal } from "temporal-polyfill" +import type { User } from "../base/User" + +export type ProjectMeta = { + created: Temporal.PlainDateTime, + createdBy: User, +}
\ No newline at end of file diff --git a/code/frontend/src/models/projects/ProjectRole.ts b/code/frontend/src/models/projects/ProjectRole.ts new file mode 100644 index 0000000..0fa2347 --- /dev/null +++ b/code/frontend/src/models/projects/ProjectRole.ts @@ -0,0 +1,7 @@ +export enum ProjectRole { + EXTERNAL = "ext", + INTERNAL = "int", + RESOURCE = "res", + MANAGER = "man", + OWNER = "own" +}
\ No newline at end of file diff --git a/code/frontend/src/models/projects/ProjectStatus.ts b/code/frontend/src/models/projects/ProjectStatus.ts new file mode 100644 index 0000000..2df4b88 --- /dev/null +++ b/code/frontend/src/models/projects/ProjectStatus.ts @@ -0,0 +1,5 @@ +export enum ProjectStatus { + ACTIVE = "act", + EXPIRED = "exp", + IDLE = "idl" +}
\ No newline at end of file diff --git a/code/frontend/src/models/work/WorkCategory.ts b/code/frontend/src/models/work/WorkCategory.ts new file mode 100644 index 0000000..7dd85d5 --- /dev/null +++ b/code/frontend/src/models/work/WorkCategory.ts @@ -0,0 +1,5 @@ +export type WorkCategory = { + id: string, + name: string, + color: string +} diff --git a/code/frontend/src/models/work/WorkEntry.ts b/code/frontend/src/models/work/WorkEntry.ts new file mode 100644 index 0000000..2108b88 --- /dev/null +++ b/code/frontend/src/models/work/WorkEntry.ts @@ -0,0 +1,13 @@ +import type { WorkLabel } from "./WorkLabel"; +import type { WorkCategory } from "./WorkCategory"; +import type { Project } from "../projects/Project"; + +export type WorkEntry = { + id: string, + start: string, + stop: string, + description: string, + labels?: Array<WorkLabel>, + category?: WorkCategory, + project?: Project +} diff --git a/code/frontend/src/models/work/WorkEntryQueryResponse.ts b/code/frontend/src/models/work/WorkEntryQueryResponse.ts new file mode 100644 index 0000000..a6974f1 --- /dev/null +++ b/code/frontend/src/models/work/WorkEntryQueryResponse.ts @@ -0,0 +1,27 @@ +import type { WorkCategory } from "./WorkCategory"; +import type { WorkLabel } from "./WorkLabel"; +import type { Temporal } from "temporal-polyfill"; + +export interface WorkEntryQueryResponse { + duration: WorkEntryQueryDuration, + categories?: Array<WorkCategory>, + labels?: Array<WorkLabel>, + dateRange?: WorkEntryQueryDateRange, + specificDate?: Temporal.PlainDateTime + page: number, + pageSize: number +} + +export interface WorkEntryQueryDateRange { + from: Temporal.PlainDateTime, + to: Temporal.PlainDateTime +} + +export enum WorkEntryQueryDuration { + TODAY = 0, + THIS_WEEK = 1, + THIS_MONTH = 2, + THIS_YEAR = 3, + SPECIFIC_DATE = 4, + DATE_RANGE = 5, +} diff --git a/code/frontend/src/models/work/WorkLabel.ts b/code/frontend/src/models/work/WorkLabel.ts new file mode 100644 index 0000000..f7e2795 --- /dev/null +++ b/code/frontend/src/models/work/WorkLabel.ts @@ -0,0 +1,5 @@ +export interface WorkLabel { + id?: string, + name?: string, + color?: string +} diff --git a/code/frontend/src/models/work/WorkQuery.ts b/code/frontend/src/models/work/WorkQuery.ts new file mode 100644 index 0000000..93b0aa4 --- /dev/null +++ b/code/frontend/src/models/work/WorkQuery.ts @@ -0,0 +1,17 @@ +import type {WorkEntry} from "./WorkEntry"; + +export interface IWorkQuery { + results: Array<WorkEntry>, + page: number, + pageSize: number, + totalRecords: number, + totalPageCount: number, +} + +export class WorkQuery implements IWorkQuery { + results: WorkEntry[]; + page: number; + pageSize: number; + totalRecords: number; + totalPageCount: number; +} diff --git a/code/frontend/src/routes/(main)/(app)/+layout.svelte b/code/frontend/src/routes/(main)/(app)/+layout.svelte new file mode 100644 index 0000000..3141931 --- /dev/null +++ b/code/frontend/src/routes/(main)/(app)/+layout.svelte @@ -0,0 +1,379 @@ +<script lang="ts"> + import { + ChevronUpDownIcon, + MagnifyingGlassIcon, + Bars3CenterLeftIcon, + XMarkIcon, + HomeIcon, + MegaphoneIcon, + FolderOpenIcon, + QueueListIcon, + CalendarIcon, + } from "$components/icons"; + import { AccountService } from "$services/account-service"; + import { + Dialog, + Menu, + MenuButton, + MenuItem, + MenuItems, + Transition, + TransitionChild, + TransitionRoot, + } from "@rgossiaux/svelte-headlessui"; + import { DialogPanel } from "@developermuch/dev-svelte-headlessui"; + import { Input, Notification } from "$components"; + import { goto } from "$app/navigation"; + import { page } from "$app/stores"; + import { onMount } from "svelte"; + import { fgs, sgs } from "$utils/global-state"; + + const accountService = AccountService.resolve(); + const session = { + profile: { + username: "Brukernavn", + displayName: "epost@adresse.no", + }, + }; + + let sidebarOpen = false; + let sidebarSearchValue: string | undefined; + let showEmailValidatedNotif = false; + + onMount(() => { + showEmailValidatedNotif = + fgs("showEmailValidatedAlertWhenLoggedIn") === "true"; + if (showEmailValidatedNotif) + sgs("showEmailValidatedAlertWhenLoggedIn", false); + }); + + function sign_out() { + accountService.end_session(() => goto("/sign-in")); + } + const navigationItems = [ + { + href: "/home", + name: "Home", + icon: HomeIcon, + }, + { + href: "/projects", + name: "Projects", + icon: CalendarIcon, + }, + { + href: "/tickets", + name: "Tickets", + icon: MegaphoneIcon, + }, + { + href: "/todo", + name: "Todo", + icon: QueueListIcon, + }, + { + href: "/wiki", + name: "Wiki", + icon: FolderOpenIcon, + }, + ]; +</script> + +{#if showEmailValidatedNotif} + <Notification + title="Email successfully validated" + subtitle="Because of this, you now have gained access to more functionality" + show={true} + /> +{/if} + +<div class="min-h-full"> + <!-- Mobile sidebar --> + <TransitionRoot show={sidebarOpen}> + <Dialog + as="div" + class="relative z-40 lg:hidden" + on:close={() => (sidebarOpen = false)} + > + <TransitionChild + as="div" + 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" /> + </TransitionChild> + + <div class="fixed inset-0 z-40 flex"> + <TransitionChild + as="div" + 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 w-full max-w-xs flex-1 flex-col bg-white pt-5 pb-4" + > + <TransitionChild + as="div" + 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 h-10 w-10 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white" + on:click={() => (sidebarOpen = false)} + > + <span class="sr-only">Close sidebar</span> + <XMarkIcon class="text-white" aria-hidden="true" /> + </button> + </div> + </TransitionChild> + <div class="mt-5 h-0 flex-1 overflow-y-auto"> + <nav class="px-2"> + <div class="space-y-1"> + {#each navigationItems as item} + {@const current = $page.url.pathname.startsWith(item.href)} + <a + href={item.href} + aria-current={current ? "page" : undefined} + class="group flex items-center px-2 py-2 text-base leading-5 font-medium rounded-md + {current + ? 'bg-gray-100 text-gray-900' + : 'text-gray-600 hover:text-gray-900 hover:bg-gray-50'}" + > + <svelte:component + this={item.icon} + class="mr-3 flex-shrink-0 h-6 w-6 {current + ? 'text-gray-500' + : 'text-gray-400 group-hover:text-gray-500'}" + aria-hidden="true" + /> + {item.name} + </a> + {/each} + </div> + </nav> + </div> + </DialogPanel> + </TransitionChild> + <div class="w-14 flex-shrink-0" aria-hidden="true"> + <!-- Dummy element to force sidebar to shrink to fit close icon --> + </div> + </div> + </Dialog> + </TransitionRoot> + + <!-- Static sidebar for desktop --> + <div + class="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col lg:border-r lg:border-gray-200 lg:bg-gray-100 lg:pb-4" + > + <div class="flex h-0 flex-1 p-3 flex-col overflow-y-auto"> + <!-- User account dropdown --> + <Menu class="relative inline-block text-left"> + <MenuButton + class="group w-full rounded-md bg-gray-100 px-3.5 py-2 text-left text-sm font-medium text-gray-700 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:ring-offset-2 focus:ring-offset-gray-100" + > + <span class="flex w-full items-center justify-between"> + <span class="flex min-w-0 items-center justify-between space-x-3"> + <span class="flex min-w-0 flex-1 flex-col"> + <span class="truncate text-sm font-medium text-gray-900"> + {session.profile.username} + </span> + <span class="truncate text-sm text-gray-500" + >{session.profile.displayName}</span + > + </span> + </span> + <ChevronUpDownIcon + class="flex-shrink-0 text-gray-400 group-hover:text-gray-500" + aria-hidden="true" + /> + </span> + </MenuButton> + <Transition + leave="transition ease-in duration-75" + enter="transition ease-out duration-100" + enterFrom="transform opacity-0 scale-95" + enterTo="transform opacity-100 scale-100" + leaveFrom="transform opacity-100 scale-100" + leaveTo="transform opacity-0 scale-95" + as="div" + > + <MenuItems + class="absolute right-0 left-0 z-10 mt-1 origin-top divide-y divide-gray-200 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none" + > + <div class="py-1"> + <MenuItem> + <a + href="/profile" + class="text-gray-700 block px-4 py-2 text-sm hover:text-gray-900 hover:bg-gray-100" + > + View profile + </a> + </MenuItem> + <MenuItem> + <a + href="/settings" + class="text-gray-700 block px-4 py-2 text-sm hover:text-gray-900 hover:bg-gray-100" + > + Settings + </a> + </MenuItem> + </div> + <div class="py-1"> + <MenuItem> + <span + on:click={() => sign_out()} + class="text-gray-700 block px-4 py-2 text-sm hover:bg-red-200 hover:text-red-900 cursor-pointer" + > + Sign out + </span> + </MenuItem> + </div> + </MenuItems> + </Transition> + </Menu> + <!-- Sidebar Search --> + <div class="mt-3 hidden"> + <label for="search" class="sr-only">Search</label> + <div class="relative mt-1 rounded-md shadow-sm"> + <Input + type="search" + name="search" + icon={MagnifyingGlassIcon} + placeholder="Search" + bind:value={sidebarSearchValue} + /> + </div> + </div> + <!-- Navigation --> + <nav class="mt-5"> + <div class="space-y-1"> + {#each navigationItems as item} + {@const current = $page.url.pathname.startsWith(item.href)} + <a + href={item.href} + aria-current={current ? "page" : undefined} + class="group flex items-center px-2 py-2 text-base leading-5 font-medium rounded-md + {current + ? 'bg-gray-200 text-gray-900' + : 'text-gray-700 hover:text-gray-900 hover:bg-gray-50'}" + > + <svelte:component + this={item.icon} + class="mr-3 flex-shrink-0 h-6 w-6 {current + ? 'text-gray-500' + : 'text-gray-400 group-hover:text-gray-500'}" + aria-hidden="true" + /> + {item.name} + </a> + {/each} + </div> + </nav> + </div> + </div> + + <!-- Main column --> + <div class="flex flex-col lg:pl-64"> + <!-- Search header --> + <div + class="sticky top-0 z-10 flex h-16 flex-shrink-0 border-b border-gray-200 bg-white lg:hidden" + > + <button + type="button" + class="border-r border-gray-200 px-4 text-gray-500 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-teal-500 lg:hidden" + on:click={() => (sidebarOpen = true)} + > + <span class="sr-only">Open sidebar</span> + <Bars3CenterLeftIcon aria-hidden="true" /> + </button> + <div class="flex flex-1 justify-between px-4 sm:px-6 lg:px-8"> + <div class="flex flex-1"> + <form class="flex w-full md:ml-0" action="#" method="GET"> + <label for="search-field" class="sr-only">Search</label> + <div + class="relative w-full text-gray-400 focus-within:text-gray-600" + > + <Input + bind:value={sidebarSearchValue} + icon={MagnifyingGlassIcon} + id="search-field" + name="search-field" + placeholder="Search" + type="search" + /> + </div> + </form> + </div> + <div class="flex items-center"> + <!-- Profile dropdown --> + <Menu as="div" class="relative ml-3"> + <div> + <MenuButton + class="flex max-w-xs items-center rounded-full bg-white text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:ring-offset-2" + > + <span class="sr-only">Open user menu</span> + </MenuButton> + </div> + <Transition + enterFrom="transform opacity-0 scale-95" + enterTo="transform opacity-100 scale-100" + leaveFrom="transform opacity-100 scale-100" + leaveTo="transform opacity-0 scale-95" + as="div" + > + <MenuItems + class="absolute right-0 z-10 mt-2 w-48 origin-top-right divide-y divide-gray-200 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none" + > + <div class="py-1"> + <MenuItem> + <a + href="/profile" + class="text-gray-700 block px-4 py-2 text-sm" + > + View profile + </a> + </MenuItem> + <MenuItem> + <a + href="/settings" + class="text-gray-700 block px-4 py-2 text-sm hover:text-gray-900 hover:bg-gray-100" + > + Settings + </a> + </MenuItem> + <div class="py-1"> + <MenuItem> + <span + on:click={() => sign_out()} + class="text-gray-700 block px-4 py-2 text-sm" + > + Sign out + </span> + </MenuItem> + </div> + </div> + </MenuItems> + </Transition> + </Menu> + </div> + </div> + </div> + <main class="flex-1 p-3"> + <slot /> + </main> + </div> +</div> diff --git a/code/frontend/src/routes/(main)/(app)/home/+page.svelte b/code/frontend/src/routes/(main)/(app)/home/+page.svelte new file mode 100644 index 0000000..247ee47 --- /dev/null +++ b/code/frontend/src/routes/(main)/(app)/home/+page.svelte @@ -0,0 +1 @@ +<h1>Welcome Home</h1>
\ No newline at end of file diff --git a/code/frontend/src/routes/(main)/(app)/org/+page.svelte b/code/frontend/src/routes/(main)/(app)/org/+page.svelte new file mode 100644 index 0000000..429ec25 --- /dev/null +++ b/code/frontend/src/routes/(main)/(app)/org/+page.svelte @@ -0,0 +1,4 @@ +<script lang="ts"> +</script> + +<h1>$ORGNAME</h1> diff --git a/code/frontend/src/routes/(main)/(app)/profile/+page.svelte b/code/frontend/src/routes/(main)/(app)/profile/+page.svelte new file mode 100644 index 0000000..7c6eb3e --- /dev/null +++ b/code/frontend/src/routes/(main)/(app)/profile/+page.svelte @@ -0,0 +1,4 @@ +<script lang="ts"> +</script> + +<h1>Hi, Ivar</h1> diff --git a/code/frontend/src/routes/(main)/(app)/projects/+page.svelte b/code/frontend/src/routes/(main)/(app)/projects/+page.svelte new file mode 100644 index 0000000..2585331 --- /dev/null +++ b/code/frontend/src/routes/(main)/(app)/projects/+page.svelte @@ -0,0 +1,118 @@ +<script lang="ts"> + import { Button, ProjectStatusBadge, Input } from "$components"; + import type { Project } from "$models/projects/Project"; + import { createTable, Subscribe, Render } from "svelte-headless-table"; + import { addSortBy, addTableFilter } from "svelte-headless-table/plugins"; + import { writable, type Writable } from "svelte/store"; + import { ChevronDownIcon, ChevronUpIcon, ChevronUpDownIcon, MagnifyingGlassIcon, FunnelIcon } from "$components/icons"; + import LL from "$i18n/i18n-svelte"; + import { goto } from "$app/navigation"; + + const projects: Writable<Array<Project>> = writable([]); + + function on_open_project(event) { + if (event.code && (event.code !== "Enter" || event.code !== "Space")) return; + const name = event.target.innerText; + const projectId = $projects.find((p) => p.name === name).id; + goto("/projects/" + projectId); + } + + const table = createTable(projects, { + sort: addSortBy(), + filter: addTableFilter(), + }); + + const columns = table.createColumns([ + table.column({ header: $LL.name(), accessor: "name" }), + table.column({ header: "Status", accessor: "status" }), + table.column({ header: "Start", accessor: "start" }), + table.column({ header: "Description", accessor: "description", plugins: { sort: { disable: true } } }), + ]); + + const { headerRows, rows, tableAttrs, tableBodyAttrs, pluginStates } = table.createViewModel(columns); + const { filterValue } = pluginStates.filter; +</script> + +<div class="sm:flex sm:items-center"> + <div class="sm:flex-auto"> + <h1 class="text-xl font-semibold text-gray-900">Projects</h1> + <p class="mt-2 text-sm text-gray-700">A list of all the projects in your organsation.</p> + </div> + <div class="mt-4 sm:mt-0 sm:ml-16 inline-flex gap-1 sm:flex-none"> + <Input icon={MagnifyingGlassIcon} placeholder="Search" bind:value={$filterValue} /> + <Button text="Create project" href="/projects/create" /> + </div> +</div> +<div class="-mx-2 mt-6 rounded-md shadow overflow-auto max-h-[80vh] sm:-mx-6 md:mx-0"> + <table {...$tableAttrs} class="min-w-full divide-y divide-gray-300"> + <thead class="bg-gray-50"> + {#each $headerRows as headerRow (headerRow.id)} + <Subscribe rowAttrs={headerRow.attrs()} let:rowAttrs> + <tr {...rowAttrs} class="shadow-sm"> + {#each headerRow.cells as cell (cell.id)} + <Subscribe attrs={cell.attrs()} let:attrs props={cell.props()} let:props> + <th + {...attrs} + scope="col" + class="sticky top-0 bg-gray-50 bg-opacity-100 whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-gray-900" + > + <div class="group inline-flex"> + <Render of={cell.render()} /> + <span + on:click={props.sort.toggle} + on:keypress={props.sort.toggle} + class="{props.sort.disabled + ? 'bg-gray-200 text-gray-900 group-hover:bg-gray-300' + : 'invisible text-gray-400 group-hover:visible group-focus:visible'} + {props.sort.disabled ? '' : 'cursor-pointer'} + ml-2 flex-none rounded" + > + {#if props.sort.order === "asc"} + <ChevronUpIcon /> + {:else if props.sort.order === "desc"} + <ChevronDownIcon /> + {:else if !props.sort.disabled} + <ChevronUpDownIcon /> + {/if} + </span> + {#if cell.id === "status"} + <span + class="invisible text-gray-400 cursor-pointer group-hover:visible group-focus:visible ml-2 flex-none rounded" + > + <FunnelIcon /> + </span> + {/if} + </div> + </th> + </Subscribe> + {/each} + </tr> + </Subscribe> + {/each} + </thead> + <tbody {...$tableBodyAttrs} class="divide-y divide-gray-200 bg-white"> + {#each $rows as row (row.id)} + <Subscribe rowAttrs={row.attrs()} let:rowAttrs> + <tr {...rowAttrs}> + {#each row.cells as cell (cell.id)} + {@const materialisedCell = cell.render()} + <Subscribe attrs={cell.attrs()} let:attrs> + <td {...attrs} class="whitespace-nowrap px-2 py-2 text-sm"> + {#if cell.id === "name"} + <span class="link" title="Open project" on:click={on_open_project} on:keypress={on_open_project}> + <Render of={materialisedCell} /> + </span> + {:else if cell.id === "status"} + <ProjectStatusBadge status={materialisedCell.toString()} /> + {:else} + <Render of={materialisedCell} /> + {/if} + </td> + </Subscribe> + {/each} + </tr> + </Subscribe> + {/each} + </tbody> + </table> +</div> diff --git a/code/frontend/src/routes/(main)/(app)/projects/[id]/+page.svelte b/code/frontend/src/routes/(main)/(app)/projects/[id]/+page.svelte new file mode 100644 index 0000000..ca474e2 --- /dev/null +++ b/code/frontend/src/routes/(main)/(app)/projects/[id]/+page.svelte @@ -0,0 +1,5 @@ +<script lang="ts"> + import { page } from "$app/stores"; +</script> + +<h1>{$page.params.id}</h1> diff --git a/code/frontend/src/routes/(main)/(app)/projects/create/+page.svelte b/code/frontend/src/routes/(main)/(app)/projects/create/+page.svelte new file mode 100644 index 0000000..d710edc --- /dev/null +++ b/code/frontend/src/routes/(main)/(app)/projects/create/+page.svelte @@ -0,0 +1,59 @@ +<script lang="ts"> + import { Input, TextArea, Combobox, Button } from "$components"; + import type { ProjectMember } from "$models/projects/ProjectMember"; + import LL from "$i18n/i18n-svelte"; + + let members = []; + const formData = { + name: { + value: "", + errors: [], + }, + description: { + value: "", + errors: [], + }, + start: { + value: "", + errors: [], + }, + stop: { + value: "", + errors: [], + }, + members: { + value: [] as Array<ProjectMember>, + errors: [], + }, + }; + + const formError = { + title: "", + subtitle: "", + }; + + async function submit_form_async() { + alert("Submitted"); + } +</script> + +<h1>Create a new project</h1> +<form on:submit|preventDefault={submit_form_async} class="max-w-md flex flex-col gap-2"> + <Input label="Name" bind:value={formData.name.value} errors={formData.name.errors} required /> + <TextArea label="Description" bind:value={formData.description.value} errors={formData.description.errors} /> + <section class="grid grid-flow-row sm:grid-flow-col gap-2"> + <Input type="date" label="Start" bind:value={formData.start.value} errors={formData.start.errors} /> + <Input type="date" label="Stop" bind:value={formData.stop.value} errors={formData.stop.errors} /> + </section> + <Combobox options={members} label={$LL.app.members()}> + <svelte:fragment slot="no-records"> + <h1>No members found</h1> + {#if !members?.length} + <p> + <a href="/users/create" class="link">Click here</a> to create your first user + </p> + {/if} + </svelte:fragment> + </Combobox> + <Button text={$LL.submit()} /> +</form> diff --git a/code/frontend/src/routes/(main)/(app)/settings/+page.svelte b/code/frontend/src/routes/(main)/(app)/settings/+page.svelte new file mode 100644 index 0000000..8e99661 --- /dev/null +++ b/code/frontend/src/routes/(main)/(app)/settings/+page.svelte @@ -0,0 +1,205 @@ +<script lang="ts"> + import {Input, Button, Switch} from "$components"; +</script> + +<div class="relative mx-auto max-w-4xl md:px-8 xl:px-0"> + <div class="pt-10 pb-16"> + <div class="px-4 sm:px-6 md:px-0"> + <h1 class="text-3xl font-bold tracking-tight text-gray-900">Settings</h1> + </div> + <div class="px-4 sm:px-6 md:px-0"> + <div class="py-6"> + <!-- Tabs --> + <div class="lg:hidden"> + <label for="selected-tab" class="sr-only">Select a tab</label> + <select + id="selected-tab" + name="selected-tab" + class="mt-1 block w-full rounded-md border-gray-300 py-2 pl-3 pr-10 text-base focus:border-purple-500 focus:outline-none focus:ring-purple-500 sm:text-sm" + > + <option selected>General</option> + + <option>Password</option> + + <option>Notifications</option> + +> + + <option>Billing</option> + + <option>Team Members</option> + </select> + </div> + <div class="hidden lg:block"> + <div class="border-b border-gray-200"> + <nav class="-mb-px flex space-x-8"> + <!-- Current: "border-purple-500 text-purple-600", Default: "border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700" --> + <a href="#" + class="border-purple-500 text-purple-600 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" + >General</a + > + + <a + href="#" + class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" + >Password</a + > + + <a + href="#" + class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" + >Notifications</a + > + + <a + href="#" + class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" + >Plan</a + > + + <a + href="#" + class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" + >Billing</a + > + + <a + href="#" + class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" + >Team Members</a + > + </nav> + </div> + </div> + + <!-- Description list with inline editing --> + <div class="mt-10 divide-y divide-gray-200"> + <div class="space-y-1"> + <h3 class="text-lg font-medium leading-6 text-gray-900">Profile</h3> + <p class="max-w-2xl text-sm text-gray-500"> + This information will be displayed publicly so be careful what you share. + </p> + </div> + <div class="mt-6"> + <dl class="divide-y divide-gray-200"> + <div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5"> + <dt class="text-sm font-medium text-gray-500">Name</dt> + <dd class="mt-1 flex text-sm text-gray-900 sm:col-span-2 sm:mt-0"> + <span class="flex-grow">Chelsea Hagon</span> + <span class="ml-4 flex-shrink-0"> + <button + type="button" + class="rounded-md bg-white font-medium text-purple-600 hover:text-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2" + >Update</button + > + </span> + </dd> + </div> + <div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:pt-5"> + <dt class="text-sm font-medium text-gray-500">Photo</dt> + <dd class="mt-1 flex text-sm text-gray-900 sm:col-span-2 sm:mt-0"> + <span class="flex-grow"> + <img + class="h-8 w-8 rounded-full" + src="https://images.unsplash.com/photo-1550525811-e5869dd03032?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80" + alt="" + /> + </span> + <span class="ml-4 flex flex-shrink-0 items-start space-x-4"> + <button + type="button" + class="rounded-md bg-white font-medium text-purple-600 hover:text-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2" + >Update</button + > + <span class="text-gray-300" aria-hidden="true">|</span> + <button + type="button" + class="rounded-md bg-white font-medium text-purple-600 hover:text-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2" + >Remove</button + > + </span> + </dd> + </div> + <div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:pt-5"> + <dt class="text-sm font-medium text-gray-500">Email</dt> + <dd class="mt-1 flex text-sm text-gray-900 sm:col-span-2 sm:mt-0"> + <span class="flex-grow">chelsea.hagon@example.com</span> + <span class="ml-4 flex-shrink-0"> + <button + type="button" + class="rounded-md bg-white font-medium text-purple-600 hover:text-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2" + >Update</button + > + </span> + </dd> + </div> + <div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:border-b sm:border-gray-200 sm:py-5"> + <dt class="text-sm font-medium text-gray-500">Job title</dt> + <dd class="mt-1 flex text-sm text-gray-900 sm:col-span-2 sm:mt-0"> + <span class="flex-grow">Human Resources Manager</span> + <span class="ml-4 flex-shrink-0"> + <button + type="button" + class="rounded-md bg-white font-medium text-purple-600 hover:text-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2" + >Update</button + > + </span> + </dd> + </div> + </dl> + </div> + </div> + + <div class="mt-10 divide-y divide-gray-200"> + <div class="space-y-1"> + <h3 class="text-lg font-medium leading-6 text-gray-900">Account</h3> + <p class="max-w-2xl text-sm text-gray-500">Manage how information is displayed on your + account.</p> + </div> + <div class="mt-6"> + <dl class="divide-y divide-gray-200"> + <div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5"> + <dt class="text-sm font-medium text-gray-500">Language</dt> + <dd class="mt-1 flex text-sm text-gray-900 sm:col-span-2 sm:mt-0"> + <span class="flex-grow">English</span> + <span class="ml-4 flex-shrink-0"> + <button + type="button" + class="rounded-md bg-white font-medium text-purple-600 hover:text-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2" + >Update</button + > + </span> + </dd> + </div> + <div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:pt-5"> + <dt class="text-sm font-medium text-gray-500">Date format</dt> + <dd class="mt-1 flex text-sm text-gray-900 sm:col-span-2 sm:mt-0"> + <span class="flex-grow">DD-MM-YYYY</span> + <span class="ml-4 flex flex-shrink-0 items-start space-x-4"> + <button + type="button" + class="rounded-md bg-white font-medium text-purple-600 hover:text-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2" + >Update</button + > + <span class="text-gray-300" aria-hidden="true">|</span> + <button + type="button" + class="rounded-md bg-white font-medium text-purple-600 hover:text-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2" + >Remove</button + > + </span> + </dd> + </div> + <div class="py-4 sm:grid sm:grid-cols-3 sm:gap-4 sm:py-5 sm:pt-5"> + <dt class="text-sm font-medium text-gray-500" id="timezone-option-label">Automatic + timezone + </dt> + <Switch/> + </div> + </dl> + </div> + </div> + </div> + </div> + </div> +</div> diff --git a/code/frontend/src/routes/(main)/(app)/tickets/+page.svelte b/code/frontend/src/routes/(main)/(app)/tickets/+page.svelte new file mode 100644 index 0000000..2a4792b --- /dev/null +++ b/code/frontend/src/routes/(main)/(app)/tickets/+page.svelte @@ -0,0 +1,4 @@ +<script lang="ts"> +</script> + +<h1>Tickets</h1> diff --git a/code/frontend/src/routes/(main)/(app)/todo/+page.svelte b/code/frontend/src/routes/(main)/(app)/todo/+page.svelte new file mode 100644 index 0000000..e29f263 --- /dev/null +++ b/code/frontend/src/routes/(main)/(app)/todo/+page.svelte @@ -0,0 +1,4 @@ +<script lang="ts"> +</script> + +<h1>Todo</h1> diff --git a/code/frontend/src/routes/(main)/(app)/wiki/+page.svelte b/code/frontend/src/routes/(main)/(app)/wiki/+page.svelte new file mode 100644 index 0000000..1762d43 --- /dev/null +++ b/code/frontend/src/routes/(main)/(app)/wiki/+page.svelte @@ -0,0 +1,4 @@ +<script lang="ts"> +</script> + +<h1>Wiki</h1> diff --git a/code/frontend/src/routes/(main)/(public)/+layout.svelte b/code/frontend/src/routes/(main)/(public)/+layout.svelte new file mode 100644 index 0000000..6da653c --- /dev/null +++ b/code/frontend/src/routes/(main)/(public)/+layout.svelte @@ -0,0 +1,18 @@ +<script> + import { LocaleSwitcher } from "$components"; + import LL from "$i18n/i18n-svelte"; +</script> + +<LocaleSwitcher tabindex={-1} /> +<slot /> +<footer class="grid sm:gap-5 grid-flow-row sm:justify-center px-2 sm:grid-flow-col"> + <a href="https://greatoffice.life/privacy" class="link"> + {$LL.privacyPolicy()} + </a> + <a href="https://greatoffice.life/terms" class="link"> + {$LL.tos()} + </a> + <a href="https://greatoffice.life/docs" class="link"> + {$LL.documentation()} + </a> +</footer> diff --git a/code/frontend/src/routes/(main)/(public)/portal/+page.svelte b/code/frontend/src/routes/(main)/(public)/portal/+page.svelte new file mode 100644 index 0000000..cc16681 --- /dev/null +++ b/code/frontend/src/routes/(main)/(public)/portal/+page.svelte @@ -0,0 +1,26 @@ +<script lang="ts"> + import { onMount } from "svelte"; + import type { PageData } from "./$types"; + import type { PortalMessage } from "$configuration"; + import { goto } from "$app/navigation"; + import { sgs } from "$utils/global-state"; + + export let data: PageData; + + onMount(async () => { + switch (data.message as PortalMessage) { + case "emailValidated": { + sgs("showEmailValidatedAlertWhenLoggedIn", true); + await goto("/home"); + break; + } + default: { + await goto("/home"); + } + } + }); +</script> + +<div class="p-3"> + <h1>Warping...</h1> +</div> diff --git a/code/frontend/src/routes/(main)/(public)/portal/+page.ts b/code/frontend/src/routes/(main)/(public)/portal/+page.ts new file mode 100644 index 0000000..72338cb --- /dev/null +++ b/code/frontend/src/routes/(main)/(public)/portal/+page.ts @@ -0,0 +1,9 @@ +import type { PortalMessage } from '$configuration'; +import { redirect } from '@sveltejs/kit'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = async ({ url }) => { + const message = url.searchParams.get("msg") as PortalMessage; + if (!message) throw redirect(302, "/"); + return { message }; +};
\ No newline at end of file diff --git a/code/frontend/src/routes/(main)/(public)/reset-password/+page.svelte b/code/frontend/src/routes/(main)/(public)/reset-password/+page.svelte new file mode 100644 index 0000000..a45ccdd --- /dev/null +++ b/code/frontend/src/routes/(main)/(public)/reset-password/+page.svelte @@ -0,0 +1,81 @@ +<script lang="ts"> + import { Alert, Input, Button } from "$components"; + import LL from "$i18n/i18n-svelte"; + import { FormError } from "$models/internal/FormError"; + import { PasswordResetService } from "$services/password-reset-service"; + + const formData = { + email: { + value: "", + errors: [], + }, + }; + + const formError = new FormError(); + const passwordResetService = PasswordResetService.resolve(); + + let loading = false; + let showSuccessAlert = false; + let showErrorAlert = false; + + async function submit_form_async() { + formError.set(); + showSuccessAlert = false; + showErrorAlert = false; + loading = true; + const response = await passwordResetService.create_request_async(formData.email.value); + loading = false; + if (response.isCreated) { + showSuccessAlert = true; + } else if (response.knownProblem) { + formError.set_from_known_problem(response.knownProblem); + for (const error of Object.entries(response.knownProblem.errors)) { + if (error[0] === "email") { + let errors = []; + error[1].forEach((e) => errors.push(e)); + formData.email.errors = errors; + } + } + } else { + formError.set($LL.unexpectedError(), $LL.tryAgainSoon()); + } + showErrorAlert = formError.has_error() && !showSuccessAlert; + } +</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 p-2 sm:p-0 sm:max-w-md"> + <h2 class="mt-6 text-3xl tracking-tight font-bold text-gray-900"> + {$LL.resetPasswordPage.requestAPasswordReset()} + </h2> + <p class="mt-2 text-sm text-gray-600"> + {$LL.or().toLowerCase()} + <a href="/sign-in" class="link"> + {$LL.signIntoYourAccount().toLowerCase()} + </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" on:submit|preventDefault={submit_form_async}> + {#if showErrorAlert} + <Alert title={formError.title} message={formError.subtitle} type="error" /> + {:else if showSuccessAlert} + <Alert type="success" title={$LL.success()} message={$LL.resetPasswordPage.requestSentMessage()} /> + {/if} + <Input + id="email" + name="email" + type="email" + autocomplete="email" + errors={formData.email.errors} + bind:value={formData.email.value} + required + label={$LL.emailAddress()} + /> + <Button text={$LL.submit()} type="submit" {loading} fullWidth /> + </form> + </div> + </div> +</div> diff --git a/code/frontend/src/routes/(main)/(public)/reset-password/+page.ts b/code/frontend/src/routes/(main)/(public)/reset-password/+page.ts new file mode 100644 index 0000000..c0859e0 --- /dev/null +++ b/code/frontend/src/routes/(main)/(public)/reset-password/+page.ts @@ -0,0 +1,11 @@ +import LL from '$i18n/i18n-svelte'; +import { get } from 'svelte/store'; +import type { PageLoad } from './$types'; + +const l = get(LL); + +export const load: PageLoad = async () => { + return { + title: l.resetPasswordPage.title(), + }; +};
\ No newline at end of file diff --git a/code/frontend/src/routes/(main)/(public)/reset-password/[id]/+page.server.ts b/code/frontend/src/routes/(main)/(public)/reset-password/[id]/+page.server.ts new file mode 100644 index 0000000..9e24736 --- /dev/null +++ b/code/frontend/src/routes/(main)/(public)/reset-password/[id]/+page.server.ts @@ -0,0 +1,11 @@ +import { is_guid } from "$utils/validators"; +import { redirect } from "@sveltejs/kit"; +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async ({ params }) => { + const resetRequestId = params.id ?? ""; + if (!is_guid(resetRequestId)) throw redirect(302, "/reset-password"); + return { + resetRequestId, + }; +};
\ No newline at end of file diff --git a/code/frontend/src/routes/(main)/(public)/reset-password/[id]/+page.svelte b/code/frontend/src/routes/(main)/(public)/reset-password/[id]/+page.svelte new file mode 100644 index 0000000..27a1af5 --- /dev/null +++ b/code/frontend/src/routes/(main)/(public)/reset-password/[id]/+page.svelte @@ -0,0 +1,82 @@ +<script lang="ts"> + import { onMount } from "svelte"; + import LL from "$i18n/i18n-svelte"; + import { Alert, Input, Button } from "$components"; + import type { PageServerData } from "./$types"; + import { goto } from "$app/navigation"; + import { SignInPageMessage, signInPageMessageQueryKey } from "$routes/(main)/(public)/sign-in"; + import { PasswordResetService } from "$services/password-reset-service"; + + export let data: PageServerData; + const passwordResetService = PasswordResetService.resolve(); + + const formData = { + newPassword: { + value: "", + errors: [], + }, + }; + + let finishedPreliminaryLoading = false; + let loading = false; + let canSubmit = true; + let requestIsInvalid = false; + + async function submitFormAsync() { + if (!canSubmit) return; + loading = true; + const request = await passwordResetService.fulfill_request_async(data.resetRequestId, formData.newPassword.value); + if (request.isFulfilled) { + goto("/sign-in?" + signInPageMessageQueryKey + "=" + SignInPageMessage.AFTER_PASSWORD_RESET); + } else if (request.knownProblem) { + } + loading = false; + } + + onMount(async () => { + const response = await passwordResetService.request_is_valid_async(data.resetRequestId); + requestIsInvalid = !response.isValid; + finishedPreliminaryLoading = true; + }); +</script> + +<div class="min-h-full flex flex-col justify-center py-12 sm:px-6 lg:px-8"> + {#if finishedPreliminaryLoading} + <div class="sm:mx-auto sm:w-full p-2 sm:p-0 sm:max-w-md"> + <h2 class="mt-6 text-3xl tracking-tight font-bold text-gray-900"> + {$LL.resetPasswordPage.setANewPassword()} + </h2> + <p class="mt-2 text-sm text-gray-600"> + {$LL.or().toLowerCase()} + <a href="/sign-in" class="link"> + {$LL.signIntoYourAccount().toLowerCase()} + </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" on:submit|preventDefault={submitFormAsync}> + {#if requestIsInvalid} + <Alert + title={$LL.resetPasswordPage.invalidRequestTitle()} + message={$LL.resetPasswordPage.invalidRequestMessage()} + /> + {/if} + <Input + id="password" + name="password" + type="password" + autocomplete="new-password" + required + bind:value={formData.newPassword.value} + label={$LL.resetPasswordPage.newPassword()} + /> + <Button text={$LL.submit()} type="submit" {loading} fullWidth /> + </form> + </div> + </div> + {:else} + <p>Checking your request...</p> + {/if} +</div> diff --git a/code/frontend/src/routes/(main)/(public)/reset-password/[id]/+page.ts b/code/frontend/src/routes/(main)/(public)/reset-password/[id]/+page.ts new file mode 100644 index 0000000..3252b7a --- /dev/null +++ b/code/frontend/src/routes/(main)/(public)/reset-password/[id]/+page.ts @@ -0,0 +1,11 @@ +import LL from '$i18n/i18n-svelte'; +import { get } from 'svelte/store'; +import type { PageLoad } from './$types'; + +const l = get(LL); + +export const load: PageLoad = async () => { + return { + title: l.resetPasswordPage.fulfillTitle(), + }; +};
\ No newline at end of file diff --git a/code/frontend/src/routes/(main)/(public)/sign-in/+page.svelte b/code/frontend/src/routes/(main)/(public)/sign-in/+page.svelte new file mode 100644 index 0000000..66d4575 --- /dev/null +++ b/code/frontend/src/routes/(main)/(public)/sign-in/+page.svelte @@ -0,0 +1,155 @@ +<script lang="ts"> + import { goto } from "$app/navigation"; + import { Button, Checkbox, Input, Alert } from "$components"; + import LL from "$i18n/i18n-svelte"; + import pwKey from "$actions/pwKey"; + import { onMount } from "svelte"; + import { signInPageMessageQueryKey, signInPageTestKeys, type SignInPageMessage } from "."; + import { AccountService } from "$services/account-service"; + import type { LoginPayload } from "$services/abstractions/IAccountService"; + import { FormError } from "$models/internal/FormError"; + import type { IForm } from "$models/internal/IForm"; + + let messageType: SignInPageMessage | undefined = undefined; + + const accountService = AccountService.resolve(); + const form = { + fields: { + username: { + value: "", + errors: [], + }, + password: { + value: "", + errors: [], + }, + persist: { + value: false, + errors: [], + }, + }, + error: new FormError(), + isLoading: false, + showError: false, + get_payload(): LoginPayload { + return { + password: form.fields.password.value, + username: form.fields.username.value, + persist: !form.fields.persist.value, + }; + }, + async submit_async() { + console.log("sadf"); + form.error.set(); + form.showError = form.error.has_error(); + form.isLoading = true; + const loginResponse = await accountService.login_async(form.get_payload()); + if (loginResponse.isLoggedIn) { + await goto("/home"); + } else if (loginResponse.knownProblem) { + form.error.set_from_known_problem(loginResponse.knownProblem); + } else { + form.error.set($LL.unexpectedError(), $LL.tryAgainSoon()); + } + form.isLoading = false; + form.showError = form.error.has_error(); + }, + } as IForm; + + onMount(() => { + const queryParams = new URLSearchParams(window.location.search); + if (queryParams.get(signInPageMessageQueryKey)) { + messageType = queryParams.get(signInPageMessageQueryKey) as SignInPageMessage; + queryParams.delete(signInPageMessageQueryKey); + window.history.replaceState(null, "", window.location.origin + window.location.pathname); + } + }); +</script> + +<div class="min-h-full flex flex-col justify-center py-12 sm:px-6 lg:px-8"> + {#if messageType} + <div class="sm:max-w-md sm:mx-auto sm:w-full"> + {#if messageType === "after-password-reset"} + <Alert + title={$LL.signInPage.yourNewPasswordIsApplied()} + _pwKey={signInPageTestKeys.afterPasswordResetAlert} + message={$LL.signInPage.signInBelow()} + closeable + /> + {:else if messageType === "user-disabled"} + <Alert + title={$LL.signInPage.yourAccountIsDisabled()} + _pwKey={signInPageTestKeys.userDisabledAlert} + message={$LL.signInPage.contactYourAdminIfDisabled()} + closeable + /> + {:else if messageType === "user-inactivity"} + <Alert + title={$LL.signInPage.youHaveReachedInactivityLimit()} + _pwKey={signInPageTestKeys.userInactivityAlert} + message={$LL.signInPage.feelFreeToSignInAgain()} + closeable + /> + {/if} + </div> + {/if} + <div class="sm:mx-auto sm:w-full p-2 sm:p-0 sm:max-w-md"> + <h2 class="mt-6 text-3xl tracking-tight font-bold text-gray-900"> + {$LL.signInPage.signIn()} + </h2> + <p class="mt-2 text-sm text-gray-600"> + {$LL.or().toLowerCase()} + <a href="/sign-up" use:pwKey={signInPageTestKeys.signUpAnchor} class="link"> + {$LL.createANewAccount().toLowerCase()} + </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 form.showError} + <Alert title={form.error.title} message={form.error.subtitle} type="error" _pwKey={signInPageTestKeys.formErrorAlert} /> + {/if} + <form class="space-y-6 mt-2" use:pwKey={signInPageTestKeys.signInForm} on:submit|preventDefault={() => form.submit_async()}> + <Input + id="username" + _pwKey={signInPageTestKeys.usernameInput} + name="username" + type="email" + label={$LL.emailAddress()} + required + errors={form.fields.username.errors} + bind:value={form.fields.username.value} + /> + + <Input + id="password" + name="password" + type="password" + label={$LL.password()} + _pwKey={signInPageTestKeys.passwordInput} + autocomplete="current-password" + required + errors={form.fields.password.errors} + bind:value={form.fields.password.value} + /> + + <div class="flex items-center justify-between"> + <Checkbox + id="remember-me" + _pwKey={signInPageTestKeys.rememberMeCheckbox} + name="remember-me" + bind:checked={form.fields.persist.value} + label={$LL.signInPage.notMyComputer()} + /> + <div class="text-sm"> + <a href="/reset-password" class="link" use:pwKey={signInPageTestKeys.resetPasswordAnchor}> + {$LL.signInPage.resetPassword()} + </a> + </div> + </div> + + <Button text={$LL.submit()} fullWidth type="submit" loading={form.isLoading} /> + </form> + </div> + </div> +</div> diff --git a/code/frontend/src/routes/(main)/(public)/sign-in/+page.ts b/code/frontend/src/routes/(main)/(public)/sign-in/+page.ts new file mode 100644 index 0000000..bebc459 --- /dev/null +++ b/code/frontend/src/routes/(main)/(public)/sign-in/+page.ts @@ -0,0 +1,11 @@ +import LL from '$i18n/i18n-svelte'; +import { get } from 'svelte/store'; +import type { PageLoad } from './$types'; + +const l = get(LL); + +export const load: PageLoad = async () => { + return { + title: l.signInPage.title(), + }; +};
\ No newline at end of file diff --git a/code/frontend/src/routes/(main)/(public)/sign-in/index.spec.js b/code/frontend/src/routes/(main)/(public)/sign-in/index.spec.js new file mode 100644 index 0000000..3bccf72 --- /dev/null +++ b/code/frontend/src/routes/(main)/(public)/sign-in/index.spec.js @@ -0,0 +1,12 @@ +import { test, expect } from "@playwright/test"; +import { signInPageTestKeys } from "./index.js"; +import { get_test_context } from "$configuration/test"; +import { get_pw_key_selector } from "$utils/testing-helpers"; + +const context = get_test_context(); + +test("form loads", async ({ page }) => { + page.goto("/sign-in"); + const form = page.locator(get_pw_key_selector(signInPageTestKeys.signInForm)); + expect(form.isVisible()).toBeTruthy(); +}); diff --git a/code/frontend/src/routes/(main)/(public)/sign-in/index.ts b/code/frontend/src/routes/(main)/(public)/sign-in/index.ts new file mode 100644 index 0000000..c1a1929 --- /dev/null +++ b/code/frontend/src/routes/(main)/(public)/sign-in/index.ts @@ -0,0 +1,20 @@ +export enum SignInPageMessage { + AFTER_PASSWORD_RESET = "after-password-reset", + USER_INACTIVITY = "user-inactivity", + USER_DISABLED = "user-disabled", + LOGGED_OUT = "logged-out" +} + +export const signInPageMessageQueryKey = "m"; +export const signInPageTestKeys = { + passwordInput: "password-input", + usernameInput: "username-input", + rememberMeCheckbox: "remember-me-checkbox", + signInForm: "sign-in-form", + userInactivityAlert: SignInPageMessage.USER_INACTIVITY + "-alert", + userDisabledAlert: SignInPageMessage.USER_DISABLED + "-alert", + afterPasswordResetAlert: SignInPageMessage.AFTER_PASSWORD_RESET + "-alert", + formErrorAlert: "form-error-alert", + resetPasswordAnchor: "reset-password-anchor", + signUpAnchor: "sign-up-anchor", +};
\ No newline at end of file diff --git a/code/frontend/src/routes/(main)/(public)/sign-up/+page.svelte b/code/frontend/src/routes/(main)/(public)/sign-up/+page.svelte new file mode 100644 index 0000000..470ac5d --- /dev/null +++ b/code/frontend/src/routes/(main)/(public)/sign-up/+page.svelte @@ -0,0 +1,106 @@ +<script lang="ts"> + import { goto } from "$app/navigation"; + import { Button, Input, Alert } from "$components"; + import LL from "$i18n/i18n-svelte"; + import { FormError } from "$models/internal/FormError"; + import type { CreateAccountPayload } from "$services/abstractions/IAccountService"; + import { AccountService } from "$services/account-service"; + + const formData = { + username: { + value: "", + errors: [], + }, + password: { + value: "", + errors: [], + }, + as_payload(): CreateAccountPayload { + return { + username: formData.username.value, + password: formData.password.value, + }; + }, + }; + + const formError = new FormError(); + const accountService = new AccountService(); + + let loading = false; + let showErrorAlert = false; + + async function submit_form_async() { + loading = true; + showErrorAlert = false; + formError.set(); + formData.username.errors = []; + formData.password.errors = []; + const response = await accountService.create_account_async(formData.as_payload()); + if (response.isCreated) { + await goto("/home"); + } else if (response.knownProblem) { + formError.set_from_known_problem(response.knownProblem); + for (const error of Object.entries(response.knownProblem.errors)) { + if (error[0] === "username") { + const errors = []; + error[1].forEach((e) => errors.push(e)); + formData.username.errors = errors; + } + if (error[0] === "password") { + const errors = []; + error[1].forEach((e) => errors.push(e)); + formData.password.errors = errors; + } + } + } else { + formError.set($LL.unexpectedError(), $LL.tryAgainSoon()); + } + loading = false; + showErrorAlert = formError.has_error(); + } +</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 p-2 sm:p-0 sm:max-w-md"> + <h2 class="mt-6 text-3xl tracking-tight font-bold text-gray-900"> + {$LL.signUpPage.createYourNewAccount()} + </h2> + <p class="mt-2 text-sm text-gray-600"> + {$LL.or().toLowerCase()} + <a href="/sign-in" class="link"> + {$LL.signIntoYourAccount().toLowerCase()} + </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 showErrorAlert} + <Alert title={formError.title} message={formError.subtitle} type="error" class="mb-2" /> + {/if} + <form class="space-y-6" on:submit|preventDefault={submit_form_async}> + <Input + label={$LL.emailAddress()} + id="email" + name="email" + autocomplete="email" + required + type="email" + bind:value={formData.username.value} + errors={formData.username.errors} + /> + + <Input + label={$LL.password()} + id="password" + name="password" + required + type="password" + bind:value={formData.password.value} + errors={formData.password.errors} + /> + <Button type="submit" text={$LL.submit()} {loading} fullWidth /> + </form> + </div> + </div> +</div> diff --git a/code/frontend/src/routes/(main)/(public)/sign-up/+page.ts b/code/frontend/src/routes/(main)/(public)/sign-up/+page.ts new file mode 100644 index 0000000..8c86f55 --- /dev/null +++ b/code/frontend/src/routes/(main)/(public)/sign-up/+page.ts @@ -0,0 +1,11 @@ +import LL from '$i18n/i18n-svelte'; +import { get } from 'svelte/store'; +import type { PageLoad } from './$types'; + +const l = get(LL); + +export const load: PageLoad = async () => { + return { + title: l.signUpPage.title(), + }; +};
\ No newline at end of file diff --git a/code/frontend/src/routes/(main)/+page.svelte b/code/frontend/src/routes/(main)/+page.svelte new file mode 100644 index 0000000..e507a19 --- /dev/null +++ b/code/frontend/src/routes/(main)/+page.svelte @@ -0,0 +1 @@ +<p class="text-bold p-1">Hold on...</p> diff --git a/code/frontend/src/routes/+layout.server.ts b/code/frontend/src/routes/+layout.server.ts new file mode 100644 index 0000000..00c8326 --- /dev/null +++ b/code/frontend/src/routes/+layout.server.ts @@ -0,0 +1,44 @@ +import {api_base, CookieNames} from "$configuration"; +import {cached_result_async, CacheKeys} from "$utils/cache"; +import {get_md5_hash} from "$utils/crypto-helpers"; +import {error, redirect} from "@sveltejs/kit"; +import type {LayoutServerLoad} from "./$types"; + +export const load: LayoutServerLoad = async ({route, cookies, locals, fetch}) => { + const isBaseRoute = route.id === "/(main)"; + const isPortalRoute = route.id === "/(main)/(public)/portal"; + const isPublicRoute = (isBaseRoute || (route.id?.startsWith("/(main)/(public)") ?? false)) ?? true; + const sessionCookieValue = cookies.get(CookieNames.session); + let sessionIsValid = false; + if ((sessionCookieValue?.length > 0 ?? false)) { + const sessionHash = get_md5_hash(sessionCookieValue); + sessionIsValid = (await cached_result_async<Response>(sessionHash + "_" + CacheKeys.isAuthenticated, 120, () => fetch(api_base("_/is-authenticated"), { + headers: { + Cookie: CookieNames.session + "=" + sessionCookieValue, + }, + }).catch((e) => { + console.error(e); + throw error(503, { + message: "We are experiencing a service disruption! Have patience while we resolve the issue.", + }); + }))).ok; + } + + console.debug("Base Layout loaded", { + sessionIsValid, + isPublicRoute, + isBaseRoute, + isPortalRoute, + routeId: route.id, + }); + + if (sessionIsValid && isPublicRoute && !isPortalRoute) { + throw redirect(302, "/home"); + } else if (!isPortalRoute && (isBaseRoute || !sessionIsValid && !isPublicRoute)) { + throw redirect(302, "/sign-in"); + } + + return { + locale: locals.locale, + }; +}; diff --git a/code/frontend/src/routes/+layout.svelte b/code/frontend/src/routes/+layout.svelte new file mode 100644 index 0000000..dc3fea6 --- /dev/null +++ b/code/frontend/src/routes/+layout.svelte @@ -0,0 +1,50 @@ +<script lang="ts"> + import "../app.pcss"; + import { page } from "$app/stores"; + import type { LayoutData } from "./$types"; + import Sonner from "$components/sonner.svelte"; + import { ModeWatcher } from "mode-watcher"; + import StyleChanger from "$components/style-changer.svelte"; + import { browser } from "$app/environment"; + import { QueryClient, QueryClientProvider } from "@tanstack/svelte-query"; + import { setLocale } from "$i18n/i18n-svelte"; + import { ExclamationTriangle } from "svelte-radix"; + + let online = true; + export let data: LayoutData; + setLocale(data.locale); + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + enabled: browser + } + } + }); +</script> + +<svelte:window bind:online/> +<svelte:head> + <title>{$page.data.title ? $page.data.title + " - Greatoffice" : "Greatoffice"}</title> +</svelte:head> + +<ModeWatcher/> +<Sonner/> +<StyleChanger/> + +<QueryClientProvider client={queryClient}> + <slot/> +</QueryClientProvider> + +{#if !online} + <div class="bg-yellow-50 relative z-50 p-4"> + <div class="flex"> + <div class="flex-shrink-0"> + <ExclamationTriangle class="bg-yellow-50 text-yellow-500"/> + </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} diff --git a/code/frontend/src/routes/+layout.ts b/code/frontend/src/routes/+layout.ts new file mode 100644 index 0000000..3893260 --- /dev/null +++ b/code/frontend/src/routes/+layout.ts @@ -0,0 +1,10 @@ +import type { LayoutLoad } from "./$types"; +import type { Locales } from "$i18n/i18n-types"; +import { loadLocaleAsync } from "$i18n/i18n-util.async"; +import { setLocale } from "$i18n/i18n-svelte"; + +export const load: LayoutLoad<{ locale: Locales }> = async ({ data: { locale } }) => { + await loadLocaleAsync(locale); + setLocale(locale); + return { locale }; +};
\ No newline at end of file diff --git a/code/frontend/src/services/abstractions/IAccountService.ts b/code/frontend/src/services/abstractions/IAccountService.ts new file mode 100644 index 0000000..d3d48b0 --- /dev/null +++ b/code/frontend/src/services/abstractions/IAccountService.ts @@ -0,0 +1,54 @@ +import type { KnownProblem } from "$models/internal/KnownProblem"; +import type { Writable } from "svelte/store"; + +export interface IAccountService { + session: Writable<Session>, + login_async(payload: LoginPayload): Promise<LoginResponse>, + logout_async(): Promise<void>, + end_session_async(callback?: Function): Promise<void>, + create_account_async(payload: CreateAccountPayload): Promise<CreateAccountResponse>, + delete_current_async(): Promise<DeleteAccountResponse>, + update_current_async(payload: UpdateAccountPayload): Promise<UpdateAccountResponse>, +} + +export type Session = { + username: string, + displayName: string, + id: string, + _lastUpdated: number +} + +export type LoginPayload = { + username: string, + password: string, + persist: boolean +} + +export type LoginResponse = { + isLoggedIn: boolean, + knownProblem?: KnownProblem +} + +export type CreateAccountPayload = { + username: string, + password: string, +} + +export type CreateAccountResponse = { + isCreated: boolean, + knownProblem?: KnownProblem +} + +export type DeleteAccountResponse = { + isDeleted: boolean +} + +export type UpdateAccountPayload = { + username: string, + password: string +} + +export type UpdateAccountResponse = { + isUpdated: boolean, + knownProblem?: KnownProblem +}
\ No newline at end of file diff --git a/code/frontend/src/services/abstractions/IApiTokenService.ts b/code/frontend/src/services/abstractions/IApiTokenService.ts new file mode 100644 index 0000000..fdf82eb --- /dev/null +++ b/code/frontend/src/services/abstractions/IApiTokenService.ts @@ -0,0 +1,34 @@ +import type { Temporal } from "temporal-polyfill" + +export interface IApiTokenService { + create_token_async(payload: CreateTokenPayload): Promise<CreateTokenResponse>, + delete_token_async(payload: DeleteTokenPayload): Promise<DeleteTokenResponse>, + get_tokens_async(query: TokenQuery): Promise<GetTokensResponse> +} +export type GetTokensResponse = { + results: Array<GetTokensTokenModel> +}; +export type GetTokensTokenModel = { + id: string, + name: string, + permissions: string[] +} +export type TokenQuery = { + includeStale: boolean +}; +export type DeleteTokenResponse = { + isDeleted: boolean +}; +export type DeleteTokenPayload = { + id: string +}; +export type CreateTokenResponse = { + isCreated: boolean +}; +export type CreateTokenPayload = { + expiryDate: Temporal.PlainDateTime, + allowRead: boolean, + allowCreate: boolean, + allowUpdate: boolean, + allowDelete: boolean +};
\ No newline at end of file diff --git a/code/frontend/src/services/abstractions/IPasswordResetService.ts b/code/frontend/src/services/abstractions/IPasswordResetService.ts new file mode 100644 index 0000000..59d2bc6 --- /dev/null +++ b/code/frontend/src/services/abstractions/IPasswordResetService.ts @@ -0,0 +1,21 @@ +import type { KnownProblem } from "$models/internal/KnownProblem" + +export interface IPasswordResetService { + create_request_async(email: string): Promise<CreateRequestResponse>, + fulfill_request_async(id: string, newPassword: string): Promise<FulfillRequestResponse>, + request_is_valid_async(id: string): Promise<RequestIsValidResponse> +} + +export type RequestIsValidResponse = { + isValid: boolean +} + +export type FulfillRequestResponse = { + isFulfilled: boolean, + knownProblem?: KnownProblem +} + +export type CreateRequestResponse = { + isCreated: boolean, + knownProblem?: KnownProblem +}
\ No newline at end of file diff --git a/code/frontend/src/services/abstractions/ISettingsService.ts b/code/frontend/src/services/abstractions/ISettingsService.ts new file mode 100644 index 0000000..366e337 --- /dev/null +++ b/code/frontend/src/services/abstractions/ISettingsService.ts @@ -0,0 +1,3 @@ +export interface ISettingsService { + get_user_settings(): Promise<void>, +}
\ No newline at end of file diff --git a/code/frontend/src/services/account-service.ts b/code/frontend/src/services/account-service.ts new file mode 100644 index 0000000..96e75f5 --- /dev/null +++ b/code/frontend/src/services/account-service.ts @@ -0,0 +1,123 @@ +import {http_delete_async, http_get_async, http_post_async} from "$utils/_fetch"; +import {browser} from "$app/environment"; +import {api_base, CookieNames, StorageKeys} from "$configuration"; +import {is_known_problem} from "$models/internal/KnownProblem"; +import {StoreType, create_writable_persistent} from "$utils/persistent-store"; +import {get} from "svelte/store"; +import type {Writable} from "svelte/store"; +import {Temporal} from "temporal-polyfill"; +import type { + CreateAccountPayload, + CreateAccountResponse, + DeleteAccountResponse, + IAccountService, + LoginPayload, + LoginResponse, + Session, + UpdateAccountPayload, + UpdateAccountResponse, +} from "./abstractions/IAccountService"; + +export class AccountService implements IAccountService { + session: Writable<Session> | undefined; + private sessionCooldown = 3600; + + constructor() { + if (browser) { + this.session = create_writable_persistent({ + name: StorageKeys.session, + initialState: {} as Session, + options: { + store: StoreType.LOCAL, + }, + }); + this.refresh_session(); + } else { + this.session = undefined; + } + } + + static resolve(): IAccountService { + return new AccountService(); + } + + async refresh_session(forceRefresh: boolean = false): Promise<void> { + if (!this.session) return; + const currentValue = get(this.session); + const currentEpoch = Temporal.Now.instant().epochSeconds; + if (!forceRefresh && ((currentValue?._lastUpdated ?? 0) + this.sessionCooldown) > currentEpoch) { + console.debug("Session is not stale yet", { + currentEpoch, + staleEpoch: currentValue?._lastUpdated + this.sessionCooldown, + }); + return; + } + const sessionResponse = await http_get_async(api_base("_/session-data")); + if (sessionResponse.ok) { + this.session.set(await sessionResponse.json()); + } else { + this.session.set(null); + } + } + + async end_session_async(callback: Function = undefined): Promise<void> { + if (!this.session) return; + await this.logout_async(); + this.session.set(null); + if (callback && typeof callback === "function") callback(); + } + + async login_async(payload: LoginPayload): Promise<LoginResponse> { + const response = await http_post_async(api_base("_/account/login"), payload); + if (response.ok) return {isLoggedIn: true}; + if (is_known_problem(response)) return { + isLoggedIn: false, + knownProblem: await response.json(), + }; + return { + isLoggedIn: false, + }; + } + + async logout_async(): Promise<void> { + const response = await http_get_async(api_base("_/account/logout")); + if (!response.ok) { + const deleteCookieResponse = await fetch("/delete-cookie?key=" + CookieNames.session); + if (!deleteCookieResponse.ok) { + throw new Error("Could neither logout nor delete session cookie."); + } + } + return; + } + + async create_account_async(payload: CreateAccountPayload): Promise<CreateAccountResponse> { + const response = await http_post_async(api_base("_/account/create"), payload); + if (response.ok) return {isCreated: true}; + if (is_known_problem(response)) return { + isCreated: false, + knownProblem: await response.json(), + }; + return { + isCreated: false, + }; + } + + async delete_current_async(): Promise<DeleteAccountResponse> { + const response = await http_delete_async(api_base("_/account/delete")); + return { + isDeleted: response.ok, + }; + } + + async update_current_async(payload: UpdateAccountPayload): Promise<UpdateAccountResponse> { + const response = await http_post_async(api_base("_/account/update"), payload); + if (response.ok) return {isUpdated: true}; + if (is_known_problem(response)) return { + isUpdated: false, + knownProblem: await response.json(), + }; + return { + isUpdated: false, + }; + } +}
\ No newline at end of file diff --git a/code/frontend/src/services/api-tokens-service.ts b/code/frontend/src/services/api-tokens-service.ts new file mode 100644 index 0000000..e0f2c2a --- /dev/null +++ b/code/frontend/src/services/api-tokens-service.ts @@ -0,0 +1,22 @@ +import { api_base } from "$configuration"; +import { http_delete_async, http_get_async, http_post_async } from "$utils/_fetch"; +import type { CreateTokenPayload, CreateTokenResponse, DeleteTokenPayload, DeleteTokenResponse, GetTokensResponse, IApiTokenService, TokenQuery } from "./abstractions/IApiTokenService"; + +export class ApiTokenService implements IApiTokenService { + constructor() { } + static resolve() { + return new ApiTokenService(); + } + async create_token_async(payload: CreateTokenPayload): Promise<CreateTokenResponse> { + const response = await http_post_async(api_base("v1/api-tokens/create"), payload); + return; + }; + async delete_token_async(payload: DeleteTokenPayload): Promise<DeleteTokenResponse> { + const response = await http_delete_async(api_base("v1/api-tokens/delete"), payload); + return; + }; + async get_tokens_async(query: TokenQuery): Promise<GetTokensResponse> { + const response = await http_get_async(api_base("v1/api-tokens")); + return; + }; +}
\ No newline at end of file diff --git a/code/frontend/src/services/password-reset-service.ts b/code/frontend/src/services/password-reset-service.ts new file mode 100644 index 0000000..edecee2 --- /dev/null +++ b/code/frontend/src/services/password-reset-service.ts @@ -0,0 +1,48 @@ +import { http_get_async, http_post_async } from "$utils/_fetch"; +import { api_base } from "$configuration"; +import { is_known_problem } from "$models/internal/KnownProblem"; +import type { + CreateRequestResponse, + FulfillRequestResponse, + IPasswordResetService, + RequestIsValidResponse, +} from "./abstractions/IPasswordResetService"; + +export class PasswordResetService implements IPasswordResetService { + static resolve(): IPasswordResetService { + return new PasswordResetService(); + } + async create_request_async(email: string): Promise<CreateRequestResponse> { + const response = await http_post_async(api_base("_/password-reset-request/create"), { email }); + if (response.ok) return { isCreated: true }; + if (is_known_problem(response)) return { + isCreated: false, + knownProblem: await response.json(), + }; + + return { + isCreated: false, + }; + } + + async fulfill_request_async(id: string, newPassword: string): Promise<FulfillRequestResponse> { + const response = await http_post_async(api_base("_/password-reset-request/fulfill"), { id: id, newPassword }); + if (response.ok) return { isFulfilled: true }; + if (is_known_problem(response)) return { + isFulfilled: false, + knownProblem: await response.json(), + }; + + return { + isFulfilled: false, + }; + } + + async request_is_valid_async(id: string): Promise<RequestIsValidResponse> { + const response = await http_get_async(api_base("_/password-reset-request/is-valid?id=" + id)); + const responseBody = await response.json() as { isValid: boolean }; + return { + isValid: responseBody.isValid, + }; + } +}
\ No newline at end of file diff --git a/code/frontend/src/services/settings-service.ts b/code/frontend/src/services/settings-service.ts new file mode 100644 index 0000000..a0a77d4 --- /dev/null +++ b/code/frontend/src/services/settings-service.ts @@ -0,0 +1,10 @@ +import type { ISettingsService } from "./abstractions/ISettingsService"; + +export class SettingService implements ISettingsService { + static resolve(): ISettingsService { + return new SettingService(); + } + get_user_settings(): Promise<void> { + throw new Error("Method not implemented."); + } +}
\ No newline at end of file diff --git a/code/frontend/src/utils/_fetch.ts b/code/frontend/src/utils/_fetch.ts new file mode 100644 index 0000000..f884653 --- /dev/null +++ b/code/frontend/src/utils/_fetch.ts @@ -0,0 +1,93 @@ +import { Temporal } from "temporal-polyfill"; +import { redirect } from "@sveltejs/kit"; +import { browser } from "$app/environment"; +import { goto } from "$app/navigation"; +import { SignInPageMessage, signInPageMessageQueryKey } from "$routes/(main)/(public)/sign-in"; +import { AccountService } from "$services/account-service"; + + +export async function http_post_async(url: string, body?: object | string, timeout = -1, skip_401_check = false, abort_signal?: AbortSignal): Promise<Response> { + const init = make_request_init("post", body, abort_signal); + const response = await internal_fetch_async({ url, init, timeout }); + if (!skip_401_check && await redirect_if_401_async(response)) throw new Error("Server returned 401"); + return response; +} + +export async function http_get_async(url: string, timeout = -1, skip_401_check = false, abort_signal?: AbortSignal): Promise<Response> { + const init = make_request_init("get", undefined, abort_signal); + const response = await internal_fetch_async({ url, init, timeout }); + if (!skip_401_check && await redirect_if_401_async(response)) throw new Error("Server returned 401"); + return response; +} + +export async function http_delete_async(url: string, body?: object | string, timeout = -1, skip_401_check = false, abort_signal?: AbortSignal): Promise<Response> { + const init = make_request_init("delete", body, abort_signal); + const response = await internal_fetch_async({ url, init, timeout }); + if (!skip_401_check && await redirect_if_401_async(response)) throw new Error("Server returned 401"); + return response; +} + +async function internal_fetch_async(request: InternalFetchRequest): Promise<Response> { + if (!request.init) throw new Error("request.init is required"); + const fetch_request = new Request(request.url, request.init); + let response: any; + + try { + if (request.timeout && request.timeout > 500) { + response = await Promise.race([ + fetch(fetch_request), + new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), request.timeout)), + ]); + } else { + response = await fetch(fetch_request); + } + } catch (error: any) { + if (error.message === "Timeout") { + console.error("Request timed out", error); + } else if (error.message === "Network request failed") { + console.error("No internet connection", error); + } else { + throw error; + } + } + + return response; +} + +async function redirect_if_401_async(response: Response): Promise<boolean> { + if (response.status === 401) { + const redirectUrl = `/sign-in?${signInPageMessageQueryKey}=${SignInPageMessage.LOGGED_OUT}`; + await AccountService.resolve().end_session_async(); + if (browser) { + await goto(redirectUrl); + } else { + throw redirect(307, redirectUrl); + } + } + return false; +} + +function make_request_init(method: string, body?: any, signal?: AbortSignal): RequestInit { + const init = { + method, + credentials: "include", + signal, + headers: { + "X-TimeZone": Temporal.Now.timeZone().id, + }, + } as RequestInit; + + if (body) { + init.body = JSON.stringify(body); + init.headers["Content-Type"] = "application/json;charset=UTF-8"; + } + + return init; +} + +export type InternalFetchRequest = { + url: string, + init: RequestInit, + timeout?: number + retry_count?: number, +}
\ No newline at end of file diff --git a/code/frontend/src/utils/colors.ts b/code/frontend/src/utils/colors.ts new file mode 100644 index 0000000..34c7992 --- /dev/null +++ b/code/frontend/src/utils/colors.ts @@ -0,0 +1,47 @@ +export function generate_random_hex_color(skip_contrast_check = false) { + let hex = __generate_random_hex_color(); + if (skip_contrast_check) return hex; + while ((__calculate_contrast_ratio("#ffffff", hex) < 4.5) || (__calculate_contrast_ratio("#000000", hex) < 4.5)) { + hex = __generate_random_hex_color(); + } + + return hex; +} + +// Largely copied from chroma js api +function __generate_random_hex_color(): string { + let code = "#"; + for (let i = 0; i < 6; i++) { + code += "0123456789abcdef".charAt(Math.floor(Math.random() * 16)); + } + return code; +} + +function __calculate_contrast_ratio(hex1: string, hex2: string): number { + const rgb1 = __hex_to_rgb(hex1); + const rgb2 = __hex_to_rgb(hex2); + const l1 = __get_luminance(rgb1[0], rgb1[1], rgb1[2]); + const l2 = __get_luminance(rgb2[0], rgb2[1], rgb2[2]); + const result = l1 > l2 ? (l1 + 0.05) / (l2 + 0.05) : (l2 + 0.05) / (l1 + 0.05); + return result; +} + +function __hex_to_rgb(hex: string): number[] { + if (!hex.match(/^#([A-Fa-f0-9]{6})$/)) return []; + if (hex[0] === "#") hex = hex.substring(1, hex.length); + return [parseInt(hex.substring(0, 2), 16), parseInt(hex.substring(2, 4), 16), parseInt(hex.substring(4, 6), 16)]; +} + +function __get_luminance(r: any, g: any, b: any) { + // relative luminance + // see http://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef + r = __luminance_x(r); + g = __luminance_x(g); + b = __luminance_x(b); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +} + +function __luminance_x(x: any) { + x /= 255; + return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4); +} diff --git a/code/frontend/src/utils/crypto-helpers.ts b/code/frontend/src/utils/crypto-helpers.ts new file mode 100644 index 0000000..c2a5275 --- /dev/null +++ b/code/frontend/src/utils/crypto-helpers.ts @@ -0,0 +1,49 @@ +// @ts-nocheck +// A formatted version of a popular md5 implementation. +// Original copyright (c) Paul Johnston & Greg Holt. +// The function itself is now 42 lines long. +// https://stackoverflow.com/a/60467595 "Don't deny." + +export function get_md5_hash(inputString: string): string { + const hc = "0123456789abcdef"; + function rh(n) { var j, s = ""; for (j = 0; j <= 3; j++) s += hc.charAt((n >> (j * 8 + 4)) & 0x0F) + hc.charAt((n >> (j * 8)) & 0x0F); return s; } + function ad(x, y) { var l = (x & 0xFFFF) + (y & 0xFFFF); var m = (x >> 16) + (y >> 16) + (l >> 16); return (m << 16) | (l & 0xFFFF); } + function rl(n, c) { return (n << c) | (n >>> (32 - c)); } + function cm(q, a, b, x, s, t) { return ad(rl(ad(ad(a, q), ad(x, t)), s), b); } + function ff(a, b, c, d, x, s, t) { return cm((b & c) | ((~b) & d), a, b, x, s, t); } + function gg(a, b, c, d, x, s, t) { return cm((b & d) | (c & (~d)), a, b, x, s, t); } + function hh(a, b, c, d, x, s, t) { return cm(b ^ c ^ d, a, b, x, s, t); } + function ii(a, b, c, d, x, s, t) { return cm(c ^ (b | (~d)), a, b, x, s, t); } + function sb(x) { + var i; var nblk = ((x.length + 8) >> 6) + 1; var blks = new Array(nblk * 16); for (i = 0; i < nblk * 16; i++) blks[i] = 0; + for (i = 0; i < x.length; i++) blks[i >> 2] |= x.charCodeAt(i) << ((i % 4) * 8); + blks[i >> 2] |= 0x80 << ((i % 4) * 8); blks[nblk * 16 - 2] = x.length * 8; return blks; + } + var i, x = sb(inputString), a = 1732584193, b = -271733879, c = -1732584194, d = 271733878, olda, oldb, oldc, oldd; + for (i = 0; i < x.length; i += 16) { + olda = a; oldb = b; oldc = c; oldd = d; + a = ff(a, b, c, d, x[i + 0], 7, -680876936); d = ff(d, a, b, c, x[i + 1], 12, -389564586); c = ff(c, d, a, b, x[i + 2], 17, 606105819); + b = ff(b, c, d, a, x[i + 3], 22, -1044525330); a = ff(a, b, c, d, x[i + 4], 7, -176418897); d = ff(d, a, b, c, x[i + 5], 12, 1200080426); + c = ff(c, d, a, b, x[i + 6], 17, -1473231341); b = ff(b, c, d, a, x[i + 7], 22, -45705983); a = ff(a, b, c, d, x[i + 8], 7, 1770035416); + d = ff(d, a, b, c, x[i + 9], 12, -1958414417); c = ff(c, d, a, b, x[i + 10], 17, -42063); b = ff(b, c, d, a, x[i + 11], 22, -1990404162); + a = ff(a, b, c, d, x[i + 12], 7, 1804603682); d = ff(d, a, b, c, x[i + 13], 12, -40341101); c = ff(c, d, a, b, x[i + 14], 17, -1502002290); + b = ff(b, c, d, a, x[i + 15], 22, 1236535329); a = gg(a, b, c, d, x[i + 1], 5, -165796510); d = gg(d, a, b, c, x[i + 6], 9, -1069501632); + c = gg(c, d, a, b, x[i + 11], 14, 643717713); b = gg(b, c, d, a, x[i + 0], 20, -373897302); a = gg(a, b, c, d, x[i + 5], 5, -701558691); + d = gg(d, a, b, c, x[i + 10], 9, 38016083); c = gg(c, d, a, b, x[i + 15], 14, -660478335); b = gg(b, c, d, a, x[i + 4], 20, -405537848); + a = gg(a, b, c, d, x[i + 9], 5, 568446438); d = gg(d, a, b, c, x[i + 14], 9, -1019803690); c = gg(c, d, a, b, x[i + 3], 14, -187363961); + b = gg(b, c, d, a, x[i + 8], 20, 1163531501); a = gg(a, b, c, d, x[i + 13], 5, -1444681467); d = gg(d, a, b, c, x[i + 2], 9, -51403784); + c = gg(c, d, a, b, x[i + 7], 14, 1735328473); b = gg(b, c, d, a, x[i + 12], 20, -1926607734); a = hh(a, b, c, d, x[i + 5], 4, -378558); + d = hh(d, a, b, c, x[i + 8], 11, -2022574463); c = hh(c, d, a, b, x[i + 11], 16, 1839030562); b = hh(b, c, d, a, x[i + 14], 23, -35309556); + a = hh(a, b, c, d, x[i + 1], 4, -1530992060); d = hh(d, a, b, c, x[i + 4], 11, 1272893353); c = hh(c, d, a, b, x[i + 7], 16, -155497632); + b = hh(b, c, d, a, x[i + 10], 23, -1094730640); a = hh(a, b, c, d, x[i + 13], 4, 681279174); d = hh(d, a, b, c, x[i + 0], 11, -358537222); + c = hh(c, d, a, b, x[i + 3], 16, -722521979); b = hh(b, c, d, a, x[i + 6], 23, 76029189); a = hh(a, b, c, d, x[i + 9], 4, -640364487); + d = hh(d, a, b, c, x[i + 12], 11, -421815835); c = hh(c, d, a, b, x[i + 15], 16, 530742520); b = hh(b, c, d, a, x[i + 2], 23, -995338651); + a = ii(a, b, c, d, x[i + 0], 6, -198630844); d = ii(d, a, b, c, x[i + 7], 10, 1126891415); c = ii(c, d, a, b, x[i + 14], 15, -1416354905); + b = ii(b, c, d, a, x[i + 5], 21, -57434055); a = ii(a, b, c, d, x[i + 12], 6, 1700485571); d = ii(d, a, b, c, x[i + 3], 10, -1894986606); + c = ii(c, d, a, b, x[i + 10], 15, -1051523); b = ii(b, c, d, a, x[i + 1], 21, -2054922799); a = ii(a, b, c, d, x[i + 8], 6, 1873313359); + d = ii(d, a, b, c, x[i + 15], 10, -30611744); c = ii(c, d, a, b, x[i + 6], 15, -1560198380); b = ii(b, c, d, a, x[i + 13], 21, 1309151649); + a = ii(a, b, c, d, x[i + 4], 6, -145523070); d = ii(d, a, b, c, x[i + 11], 10, -1120210379); c = ii(c, d, a, b, x[i + 2], 15, 718787259); + b = ii(b, c, d, a, x[i + 9], 21, -343485551); a = ad(a, olda); b = ad(b, oldb); c = ad(c, oldc); d = ad(d, oldd); + } + return rh(a) + rh(b) + rh(c) + rh(d); +} diff --git a/code/frontend/src/utils/global-state.ts b/code/frontend/src/utils/global-state.ts new file mode 100644 index 0000000..b585ced --- /dev/null +++ b/code/frontend/src/utils/global-state.ts @@ -0,0 +1,22 @@ +import { get } from "svelte/store"; +import { create_writable_persistent } from "./persistent-store"; + +const state = create_writable_persistent<any>({ + initialState: {}, + name: "global-state" +}); + +export type GlobalStateKeys = "isLoggedIn" | "showEmailValidatedAlertWhenLoggedIn" | "all"; + +export function fgs(key: GlobalStateKeys): any { + const value = get(state); + if (key === "all") return value; + return value[key]; +} + +export function sgs(key: GlobalStateKeys, value: any) { + if (key === "all") throw new Error("Not allowed to set global state key: all"); + const stateValue = get(state); + stateValue[key] = JSON.stringify(value) + state.set(stateValue); +}
\ No newline at end of file diff --git a/code/frontend/src/utils/misc-helpers.ts b/code/frontend/src/utils/misc-helpers.ts new file mode 100644 index 0000000..afb20e7 --- /dev/null +++ b/code/frontend/src/utils/misc-helpers.ts @@ -0,0 +1,77 @@ +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 no_type_check(x: any) { + return x; +} + +export function capitalise(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} + +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 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; +} + +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 get_hash_code(value: string): number | undefined { + let hash = 0; + if (value.length === 0) { + return; + } + for (let i = 0; i < value.length; i++) { + const char = value.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash |= 0; + } + return hash; +} diff --git a/code/frontend/src/utils/persistent-store.ts b/code/frontend/src/utils/persistent-store.ts new file mode 100644 index 0000000..d880464 --- /dev/null +++ b/code/frontend/src/utils/persistent-store.ts @@ -0,0 +1,110 @@ +import {browser} from "$app/environment"; +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; +} + +interface WritableStoreInit<T> { + name: string, + initialState: T, + options?: StoreOptions +} + +interface ReadableStoreInit<T> { + name: string, + initialState: T, + callback: StartStopNotifier<any>, + options?: StoreOptions +} + +function get_store(type: StoreType): Storage { + if (!browser) return undefined; + 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>(init: WritableStoreInit<T> | ReadableStoreInit<T>): any { + try { + const storage = get_store(init.options.store); + if (!storage) return; + const value = storage.getItem(init.name); + if (!value) return false; + return JSON.parse(value); + } catch (e) { + console.error(e); + return {__INVALID__: true}; + } +} + +function hydrate<T>(store: Writable<T>, init: WritableStoreInit<T> | ReadableStoreInit<T>): void { + const value = get_store_value<T>(init); + if (value && store.set) store.set(value); +} + +function subscribe<T>(store: Writable<T> | Readable<T>, init: WritableStoreInit<T> | ReadableStoreInit<T>): void { + const storage = get_store(init.options.store); + if (!storage) return; + if (!store.subscribe) return; + store.subscribe((state: any) => { + storage.setItem(init.name, prepared_store_value(state)); + }); +} + +function create_writable_persistent<T>(init: WritableStoreInit<T>): Writable<T> { + if (!browser) { + console.warn("Persistent store is only available in the browser"); + return; + } + if (init.options === undefined) throw new Error("init is a required parameter"); + console.debug("Creating writable store with options: ", init); + const store = _writable<T>(init.initialState); + hydrate(store, init); + subscribe(store, init); + return store; +} + +function create_readable_persistent<T>(init: ReadableStoreInit<T>): Readable<T> { + if (!browser) { + console.warning("Persistent store is only available in the browser"); + return; + } + if (init.options === undefined) throw new Error("init is a required parameter"); + console.debug("Creating readable store with options: ", init); + const store = _readable<T>(init.initialState, init.callback); + // hydrate(store, options); + subscribe(store, init); + return store; +} + +export { + create_writable_persistent, + create_readable_persistent, + StoreType, +}; + +export type { + WritableStoreInit as WritableStore, + ReadableStoreInit as ReadableStore, + StoreOptions, +}; + diff --git a/code/frontend/src/utils/storage-helpers.ts b/code/frontend/src/utils/storage-helpers.ts new file mode 100644 index 0000000..cce655c --- /dev/null +++ b/code/frontend/src/utils/storage-helpers.ts @@ -0,0 +1,26 @@ +import { browser } from "$app/environment"; +import { is_empty_object } from "./validators"; + +export type StorageType = "local" | "session"; +export const browserStorage = { + remove_with_regex(type: StorageType, regex: RegExp): void { + if (!browser) return; + const storage = (type === "local" ? window.localStorage : window.sessionStorage); + let n = storage.length; + while (n--) { + const key = storage.key(n); + if (key && regex.test(key)) { + storage.removeItem(key); + } + } + }, + set_stringified(type: StorageType, key: string, value: object): void { + if (!browser) return; + if (is_empty_object(value)) return; + (type === "local" ? window.localStorage : window.sessionStorage).setItem(key, JSON.stringify(value)); + }, + get_stringified<T>(type: StorageType, key: string): T | any { + if (!browser) return; + return JSON.parse((type === "local" ? window.localStorage : window.sessionStorage).getItem(key) ?? "{}"); + } +}
\ No newline at end of file diff --git a/code/frontend/src/utils/testing-helpers.ts b/code/frontend/src/utils/testing-helpers.ts new file mode 100644 index 0000000..f21412e --- /dev/null +++ b/code/frontend/src/utils/testing-helpers.ts @@ -0,0 +1,7 @@ +export function get_element_by_pw_key(key: string): HTMLElement | null { + return document.querySelector("[pw-key='" + key + "']"); +} + +export function get_pw_key_selector(key: string): string { + return "[pw-key='" + key + "']"; +}
\ No newline at end of file diff --git a/code/frontend/src/utils/ui.ts b/code/frontend/src/utils/ui.ts new file mode 100644 index 0000000..019b8f6 --- /dev/null +++ b/code/frontend/src/utils/ui.ts @@ -0,0 +1,56 @@ +import { type ClassValue, clsx } from 'clsx' +import { cubicOut } from 'svelte/easing' +import { twMerge } from 'tailwind-merge' +import type { TransitionConfig } from 'svelte/transition' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + +type FlyAndScaleParams = { + y?: number + x?: number + start?: number + duration?: number +} + +export function flyAndScale( + node: Element, + params: FlyAndScaleParams = { y: -8, x: 0, start: 0.95, duration: 150 } +): TransitionConfig { + const style = getComputedStyle(node) + const transform = style.transform === 'none' ? '' : style.transform + + const scaleConversion = (valueA: number, scaleA: [number, number], scaleB: [number, number]) => { + const [minA, maxA] = scaleA + const [minB, maxB] = scaleB + + const percentage = (valueA - minA) / (maxA - minA) + const valueB = percentage * (maxB - minB) + minB + + return valueB + } + + const styleToString = (style: Record<string, number | string | undefined>): string => { + return Object.keys(style).reduce((str, key) => { + if (style[key] === undefined) return str + return str + `${key}:${style[key]};` + }, '') + } + + return { + duration: params.duration ?? 200, + delay: 0, + css: (t) => { + const y = scaleConversion(t, [0, 1], [params.y ?? 5, 0]) + const x = scaleConversion(t, [0, 1], [params.x ?? 0, 0]) + const scale = scaleConversion(t, [0, 1], [params.start ?? 0.95, 1]) + + return styleToString({ + transform: `${transform} translate3d(${x}px, ${y}px, 0) scale(${scale})`, + opacity: t + }) + }, + easing: cubicOut + } +} diff --git a/code/frontend/src/utils/validators.ts b/code/frontend/src/utils/validators.ts new file mode 100644 index 0000000..b69470e --- /dev/null +++ b/code/frontend/src/utils/validators.ts @@ -0,0 +1,34 @@ +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 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 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 { + if (!obj) return true; + return obj !== void 0 && Object.keys(obj).length > 0; +}
\ No newline at end of file diff --git a/code/frontend/static/favicon.png b/code/frontend/static/favicon.png Binary files differnew file mode 100644 index 0000000..825b9e6 --- /dev/null +++ b/code/frontend/static/favicon.png diff --git a/code/frontend/svelte.config.js b/code/frontend/svelte.config.js new file mode 100644 index 0000000..1152439 --- /dev/null +++ b/code/frontend/svelte.config.js @@ -0,0 +1,23 @@ +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: [vitePreprocess({})], + kit: { + adapter: adapter(), + alias: { + "$actions": "./src/actions", + "$routes": "./src/routes", + "$models": "./src/models", + "$api": "./src/api", + "$components": "./src/components", + "$utils": "./src/utils", + "$i18n": "./src/i18n", + "$services": "./src/services", + "$configuration": "./src/configuration", + } + } +}; + +export default config; diff --git a/code/frontend/tailwind.config.js b/code/frontend/tailwind.config.js new file mode 100644 index 0000000..fa1cb36 --- /dev/null +++ b/code/frontend/tailwind.config.js @@ -0,0 +1,64 @@ +import { fontFamily } from "tailwindcss/defaultTheme"; + +/** @type {import('tailwindcss').Config} */ +const config = { + darkMode: ["class"], + content: ["./src/**/*.{html,js,svelte,ts}"], + safelist: ["dark"], + theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px" + } + }, + extend: { + colors: { + border: "hsl(var(--border) / <alpha-value>)", + input: "hsl(var(--input) / <alpha-value>)", + ring: "hsl(var(--ring) / <alpha-value>)", + background: "hsl(var(--background) / <alpha-value>)", + foreground: "hsl(var(--foreground) / <alpha-value>)", + primary: { + DEFAULT: "hsl(var(--primary) / <alpha-value>)", + foreground: "hsl(var(--primary-foreground) / <alpha-value>)" + }, + secondary: { + DEFAULT: "hsl(var(--secondary) / <alpha-value>)", + foreground: "hsl(var(--secondary-foreground) / <alpha-value>)" + }, + destructive: { + DEFAULT: "hsl(var(--destructive) / <alpha-value>)", + foreground: "hsl(var(--destructive-foreground) / <alpha-value>)" + }, + muted: { + DEFAULT: "hsl(var(--muted) / <alpha-value>)", + foreground: "hsl(var(--muted-foreground) / <alpha-value>)" + }, + accent: { + DEFAULT: "hsl(var(--accent) / <alpha-value>)", + foreground: "hsl(var(--accent-foreground) / <alpha-value>)" + }, + popover: { + DEFAULT: "hsl(var(--popover) / <alpha-value>)", + foreground: "hsl(var(--popover-foreground) / <alpha-value>)" + }, + card: { + DEFAULT: "hsl(var(--card) / <alpha-value>)", + foreground: "hsl(var(--card-foreground) / <alpha-value>)" + } + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)" + }, + fontFamily: { + sans: [...fontFamily.sans] + } + } + }, +}; + +export default config; diff --git a/code/frontend/tests/test.ts b/code/frontend/tests/test.ts new file mode 100644 index 0000000..5816be4 --- /dev/null +++ b/code/frontend/tests/test.ts @@ -0,0 +1,6 @@ +import { expect, test } from '@playwright/test'; + +test('index page has expected h1', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('heading', { name: 'Welcome to SvelteKit' })).toBeVisible(); +}); diff --git a/code/frontend/tsconfig.json b/code/frontend/tsconfig.json new file mode 100644 index 0000000..a8f10c8 --- /dev/null +++ b/code/frontend/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/code/frontend/vite.config.ts b/code/frontend/vite.config.ts new file mode 100644 index 0000000..bc25f5c --- /dev/null +++ b/code/frontend/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "vitest/config"; +import { sveltekit } from "@sveltejs/kit/vite"; +import { SvelteKitPWA } from "@vite-pwa/sveltekit"; + +export default defineConfig({ + plugins: [sveltekit(), SvelteKitPWA()], + build: { + target: "es2020", + }, + test: { + include: ["src/**/*.{test,spec}.{js,ts}"] + }, + optimizeDeps: { + esbuildOptions: { + target: "es2020", + }, + }, +}); |
