diff options
Diffstat (limited to 'apps/projects/src')
45 files changed, 3404 insertions, 0 deletions
diff --git a/apps/projects/src/_assets/pre.css b/apps/projects/src/_assets/pre.css new file mode 100644 index 0000000..9c9446e --- /dev/null +++ b/apps/projects/src/_assets/pre.css @@ -0,0 +1,128 @@ +:root { + --loader-primary: hsl(250, 84%, 54%); + --loader-accent: hsl(342, 89%, 48%); + --loader-contrast: hsl(180, 1%, 84%); + --loader-easing: cubic-bezier(0.645, 0.045, 0.355, 1); +} + +[data-theme="dark"] :root { + --loader-primary: hsl(250, 93%, 65%); + --loader-accent: hsl(342, 92%, 54%); + --loader-contrast: hsl(208, 12%, 24%); + --loader-easing: cubic-bezier(0.645, 0.045, 0.355, 1); +} + +[data-theme="dark"] { + background-color: hsl(203, 24%, 13%); +} + +.fill-loader { + position: relative; + overflow: hidden; + display: inline-block; + margin: 3rem; +} + +.fill-loader__fill { + position: absolute; +} + +@supports (-webkit-animation-name: this) or (animation-name: this) { + .fill-loader__label { + position: absolute; + clip: rect(1px, 1px, 1px, 1px); + -webkit-clip-path: inset(50%); + clip-path: inset(50%); + } +} + +@supports (-webkit-animation-name: this) or (animation-name: this) { + .fill-loader--v4 { + width: 90%; + max-width: 300px; + } + + .fill-loader--v4 .fill-loader__base { + height: 4px; + background-color: var(--loader-contrast); + } + + .fill-loader--v4 .fill-loader__fill { + top: 0; + left: 0; + right: 0; + height: 100%; + background-color: var(--loader-primary); + -webkit-animation: fill-loader-4 1.6s infinite var(--loader-easing); + animation: fill-loader-4 1.6s infinite var(--loader-easing); + will-change: left, right; + } +} + +@-webkit-keyframes fill-loader-4 { + 0% { + left: 0; + right: 100%; + background-color: var(--loader-primary); + } + + 10%, + 60% { + left: 0; + } + + 40%, + 90% { + right: 0; + } + + 50% { + left: 100%; + background-color: var(--loader-primary); + } + + 51% { + left: 0; + right: 100%; + background-color: var(--loader-accent); + } + + 100% { + left: 100%; + background-color: var(--loader-accent); + } +} + +@keyframes fill-loader-4 { + 0% { + left: 0; + right: 100%; + background-color: var(--loader-primary); + } + + 10%, + 60% { + left: 0; + } + + 40%, + 90% { + right: 0; + } + + 50% { + left: 100%; + background-color: var(--loader-primary); + } + + 51% { + left: 0; + right: 100%; + background-color: var(--loader-accent); + } + + 100% { + left: 100%; + background-color: var(--loader-accent); + } +} diff --git a/apps/projects/src/_assets/pwa/android-chrome-192x192.png b/apps/projects/src/_assets/pwa/android-chrome-192x192.png Binary files differnew file mode 100644 index 0000000..5c098bc --- /dev/null +++ b/apps/projects/src/_assets/pwa/android-chrome-192x192.png diff --git a/apps/projects/src/_assets/pwa/android-chrome-512x512.png b/apps/projects/src/_assets/pwa/android-chrome-512x512.png Binary files differnew file mode 100644 index 0000000..973a1c3 --- /dev/null +++ b/apps/projects/src/_assets/pwa/android-chrome-512x512.png diff --git a/apps/projects/src/_assets/pwa/apple-touch-icon.png b/apps/projects/src/_assets/pwa/apple-touch-icon.png Binary files differnew file mode 100644 index 0000000..b4d9773 --- /dev/null +++ b/apps/projects/src/_assets/pwa/apple-touch-icon.png diff --git a/apps/projects/src/_assets/pwa/browserconfig.xml b/apps/projects/src/_assets/pwa/browserconfig.xml new file mode 100644 index 0000000..b3930d0 --- /dev/null +++ b/apps/projects/src/_assets/pwa/browserconfig.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<browserconfig> + <msapplication> + <tile> + <square150x150logo src="/mstile-150x150.png"/> + <TileColor>#da532c</TileColor> + </tile> + </msapplication> +</browserconfig> diff --git a/apps/projects/src/_assets/pwa/favicon-16x16.png b/apps/projects/src/_assets/pwa/favicon-16x16.png Binary files differnew file mode 100644 index 0000000..5dde9f9 --- /dev/null +++ b/apps/projects/src/_assets/pwa/favicon-16x16.png diff --git a/apps/projects/src/_assets/pwa/favicon-32x32.png b/apps/projects/src/_assets/pwa/favicon-32x32.png Binary files differnew file mode 100644 index 0000000..9cef4c4 --- /dev/null +++ b/apps/projects/src/_assets/pwa/favicon-32x32.png diff --git a/apps/projects/src/_assets/pwa/favicon.ico b/apps/projects/src/_assets/pwa/favicon.ico Binary files differnew file mode 100644 index 0000000..89c7542 --- /dev/null +++ b/apps/projects/src/_assets/pwa/favicon.ico diff --git a/apps/projects/src/_assets/pwa/favicon.svg b/apps/projects/src/_assets/pwa/favicon.svg new file mode 100644 index 0000000..964dbb8 --- /dev/null +++ b/apps/projects/src/_assets/pwa/favicon.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-stopwatch" viewBox="0 0 16 16"> + <path d="M8.5 5.6a.5.5 0 1 0-1 0v2.9h-3a.5.5 0 0 0 0 1H8a.5.5 0 0 0 .5-.5V5.6z"/> + <path d="M6.5 1A.5.5 0 0 1 7 .5h2a.5.5 0 0 1 0 1v.57c1.36.196 2.594.78 3.584 1.64a.715.715 0 0 1 .012-.013l.354-.354-.354-.353a.5.5 0 0 1 .707-.708l1.414 1.415a.5.5 0 1 1-.707.707l-.353-.354-.354.354a.512.512 0 0 1-.013.012A7 7 0 1 1 7 2.071V1.5a.5.5 0 0 1-.5-.5zM8 3a6 6 0 1 0 .001 12A6 6 0 0 0 8 3z"/> +</svg>
\ No newline at end of file diff --git a/apps/projects/src/_assets/pwa/manifest.json b/apps/projects/src/_assets/pwa/manifest.json new file mode 100644 index 0000000..4c550fe --- /dev/null +++ b/apps/projects/src/_assets/pwa/manifest.json @@ -0,0 +1,28 @@ +{ + "manifest_version": 2, + "version": "0.1", + "name": "Time Tracker", + "short_name": "Time Tracker", + "display": "standalone", + "background_color": "#fff", + "theme_color": "#4D3DF7", + "start_url": ".", + "orientation": "portrait", + "icons": [ + { + "src": "/favicon.svg", + "purpose": "maskable any", + "sizes": "any" + }, + { + "src": "/pwa/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/pwa/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/apps/projects/src/_assets/pwa/mstile-144x144.png b/apps/projects/src/_assets/pwa/mstile-144x144.png Binary files differnew file mode 100644 index 0000000..84d94cb --- /dev/null +++ b/apps/projects/src/_assets/pwa/mstile-144x144.png diff --git a/apps/projects/src/_assets/pwa/mstile-150x150.png b/apps/projects/src/_assets/pwa/mstile-150x150.png Binary files differnew file mode 100644 index 0000000..b1398ae --- /dev/null +++ b/apps/projects/src/_assets/pwa/mstile-150x150.png diff --git a/apps/projects/src/_assets/pwa/mstile-310x150.png b/apps/projects/src/_assets/pwa/mstile-310x150.png Binary files differnew file mode 100644 index 0000000..76b16a0 --- /dev/null +++ b/apps/projects/src/_assets/pwa/mstile-310x150.png diff --git a/apps/projects/src/_assets/pwa/mstile-310x310.png b/apps/projects/src/_assets/pwa/mstile-310x310.png Binary files differnew file mode 100644 index 0000000..d8e4097 --- /dev/null +++ b/apps/projects/src/_assets/pwa/mstile-310x310.png diff --git a/apps/projects/src/_assets/pwa/mstile-70x70.png b/apps/projects/src/_assets/pwa/mstile-70x70.png Binary files differnew file mode 100644 index 0000000..0df1e8c --- /dev/null +++ b/apps/projects/src/_assets/pwa/mstile-70x70.png diff --git a/apps/projects/src/_assets/pwa/safari-pinned-tab.svg b/apps/projects/src/_assets/pwa/safari-pinned-tab.svg new file mode 100644 index 0000000..ba2220c --- /dev/null +++ b/apps/projects/src/_assets/pwa/safari-pinned-tab.svg @@ -0,0 +1,50 @@ +<?xml version="1.0" standalone="no"?> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" + "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd"> +<svg version="1.0" xmlns="http://www.w3.org/2000/svg" + width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000" + preserveAspectRatio="xMidYMid meet"> +<metadata> +Created by potrace 1.14, written by Peter Selinger 2001-2017 +</metadata> +<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)" +fill="#000000" stroke="none"> +<path d="M3195 6780 c-116 -3 -211 -10 -226 -17 -39 -17 -105 -98 -116 -142 +-19 -72 -2 -146 45 -202 26 -31 96 -69 131 -72 25 -2 31 -6 32 -27 1 -27 1 +-198 0 -216 -1 -6 -47 -19 -103 -28 -160 -28 -451 -107 -533 -146 -11 -5 -51 +-21 -90 -36 -60 -23 -246 -112 -325 -155 -431 -236 -834 -619 -1101 -1045 +-207 -328 -364 -733 -423 -1089 -51 -307 -61 -583 -31 -875 26 -261 119 -615 +225 -861 185 -430 432 -773 800 -1108 75 -69 387 -301 405 -301 1 0 33 -18 70 +-40 209 -128 602 -288 796 -325 12 -2 29 -7 39 -10 72 -23 273 -56 435 -73 +144 -14 601 -5 658 13 7 2 37 7 67 10 273 33 616 141 904 283 725 357 1275 +982 1542 1754 55 159 113 395 129 523 4 28 8 57 10 65 2 8 7 47 10 85 3 39 8 +93 10 120 6 66 6 327 0 390 -2 28 -7 82 -10 120 -3 39 -11 99 -16 135 -6 36 +-13 79 -16 95 -15 98 -61 279 -103 405 -121 372 -298 694 -542 993 -27 32 -48 +61 -48 65 0 4 35 41 78 84 l77 76 90 -90 c53 -54 108 -99 134 -110 62 -28 130 +-25 191 8 95 52 135 151 103 257 -13 46 -44 79 -362 397 -322 323 -351 349 +-398 363 -148 44 -287 -61 -285 -215 1 -62 35 -118 126 -208 47 -47 86 -87 86 +-90 0 -6 -91 -101 -132 -138 l-25 -23 -46 38 c-264 223 -584 405 -924 528 -92 +34 -320 100 -376 109 -15 3 -35 7 -45 10 -9 3 -34 7 -54 10 -86 13 -113 18 +-117 22 -2 2 -4 56 -4 121 l0 118 29 9 c66 19 114 47 139 80 72 95 65 215 -18 +296 -58 56 -83 60 -402 63 -159 1 -380 0 -490 -3z m560 -1104 c224 -24 547 +-99 670 -156 11 -5 56 -24 100 -41 90 -37 282 -134 306 -155 8 -8 19 -14 23 +-14 13 0 192 -124 286 -199 97 -77 297 -270 364 -351 237 -288 405 -598 509 +-941 30 -98 44 -157 72 -299 3 -14 8 -47 11 -75 3 -27 7 -56 10 -63 22 -69 21 +-519 -1 -642 -1 -8 -6 -40 -10 -70 -4 -30 -9 -64 -11 -75 -2 -11 -7 -33 -10 +-50 -3 -16 -14 -66 -26 -110 -11 -44 -21 -84 -22 -90 -18 -79 -93 -275 -154 +-408 -39 -83 -158 -296 -171 -307 -3 -3 -26 -34 -50 -70 -116 -169 -312 -384 +-466 -508 -38 -32 -78 -65 -89 -74 -25 -22 -229 -160 -281 -189 -177 -99 -405 +-197 -570 -244 -126 -36 -305 -74 -375 -81 -19 -2 -48 -5 -65 -8 -121 -22 +-509 -22 -618 0 -12 2 -42 6 -67 10 -369 45 -795 215 -1125 448 -192 135 -399 +326 -517 476 -23 30 -48 61 -55 67 -57 60 -227 336 -291 473 -64 135 -150 365 +-167 444 -2 12 -6 30 -9 41 -28 120 -36 156 -41 193 -3 24 -7 53 -10 65 -32 +148 -38 552 -10 707 2 14 7 45 10 70 33 274 160 643 313 910 60 106 201 312 +232 340 3 3 23 28 45 55 22 28 85 96 140 151 347 352 768 590 1252 709 56 14 +118 27 137 30 20 2 61 9 93 14 32 6 92 13 133 17 41 3 76 7 77 8 6 5 368 -2 +428 -8z"/> +<path d="M3423 4754 c-45 -17 -95 -61 -121 -109 -15 -27 -17 -93 -19 -650 -1 +-341 -2 -641 -3 -666 l0 -47 -679 0 -679 -1 -49 -24 c-59 -30 -76 -49 -104 +-112 -54 -122 23 -270 154 -293 23 -5 400 -8 837 -7 l795 1 42 22 c52 27 98 +82 112 136 8 29 10 273 9 826 -3 854 1 796 -59 867 -53 63 -153 87 -236 57z"/> +</g> +</svg> diff --git a/apps/projects/src/app/index.d.ts b/apps/projects/src/app/index.d.ts new file mode 100644 index 0000000..c044583 --- /dev/null +++ b/apps/projects/src/app/index.d.ts @@ -0,0 +1,48 @@ +/* Use this file to declare any custom file extensions for importing */ +/* Use this folder to also add/extend a package d.ts file, if needed. */ + +/* CSS MODULES */ +declare module "*.module.css" { + const classes: { [key: string]: string }; + export default classes; +} +declare module "*.module.scss" { + const classes: { [key: string]: string }; + export default classes; +} + +/* CSS */ +declare module "*.css"; +declare module "*.scss"; + +/* IMAGES */ +declare module "*.svg" { + const ref: string; + export default ref; +} +declare module "*.bmp" { + const ref: string; + export default ref; +} +declare module "*.gif" { + const ref: string; + export default ref; +} +declare module "*.jpg" { + const ref: string; + export default ref; +} +declare module "*.jpeg" { + const ref: string; + export default ref; +} +declare module "*.png" { + const ref: string; + export default ref; +} + +/* CUSTOM: ADD YOUR OWN HERE */ +declare module "*.svelte" { + const value: any; + export default value; +} diff --git a/apps/projects/src/app/index.html b/apps/projects/src/app/index.html new file mode 100644 index 0000000..7e0b0e1 --- /dev/null +++ b/apps/projects/src/app/index.html @@ -0,0 +1,63 @@ +<!doctype html> +<html lang="en"> + +<head> + <meta charset="UTF-8"> + <meta name="viewport" + content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"> + <link rel="apple-touch-icon" + sizes="180x180" + href="../_assets/pwa/apple-touch-icon.png"> + <link rel="icon" + type="image/png" + sizes="32x32" + href="../_assets/pwa/favicon-32x32.png"> + <link rel="icon" + type="image/png" + sizes="16x16" + href="../_assets/pwa/favicon-16x16.png"> + <link rel="manifest" + href="../_assets/pwa/manifest.json"> + <link rel="mask-icon" + href="../_assets/pwa/safari-pinned-tab.svg" + color="#5bbad5"> + <meta name="msapplication-TileColor" + content="#da532c"> + <link rel="icon" + href="../_assets/pwa/favicon.svg"> + <script> + const currentTheme = localStorage.getItem("theme"); + if (currentTheme === "light") { + document.querySelector("html").dataset.theme = "light"; + } else { + document.querySelector("html").dataset.theme = "dark"; + } + </script> + <link rel="stylesheet" + href="../_assets/pre.css"> + <title>Time Tracker</title> +</head> + +<body> + +<noscript> + This page is built with javascript. Allow it and try again. +</noscript> + +<div class="fill-loader fill-loader--v4" + id="loader" + role="alert"> + <p class="fill-loader__label">Loading Time Tracker...</p> + <div aria-hidden="true"> + <div class="fill-loader__base"></div> + <div class="fill-loader__fill"></div> + </div> +</div> + +<div id="root"></div> + +<script type="module" + src="./index.ts"></script> +</body> + +</html> diff --git a/apps/projects/src/app/index.scss b/apps/projects/src/app/index.scss new file mode 100644 index 0000000..4794787 --- /dev/null +++ b/apps/projects/src/app/index.scss @@ -0,0 +1,38 @@ +@use '../../web-shared/src/styles/base'as * with ($breakpoints: ('xs': "768px", + 'sm': "768px", + 'md': "1200px", + 'lg': "1200px", + 'xl': "1600px", + ), + $grid-columns: 12); + +@use '../../web-shared/src/styles/custom-style/colors'; +@use '../../web-shared/src/styles/custom-style/spacing'; +@use '../../web-shared/src/styles/custom-style/shared-styles'; +@use '../../web-shared/src/styles/custom-style/typography'; +@use '../../web-shared/src/styles/custom-style/icons'; +@use '../../web-shared/src/styles/custom-style/buttons'; +@use '../../web-shared/src/styles/custom-style/forms'; +@use '../../web-shared/src/styles/custom-style/util'; + +@use '../../web-shared/src/styles/components/radios-checkboxes'; +@use '../../web-shared/src/styles/components/circle-loader'; +@use '../../web-shared/src/styles/components/list'; +@use '../../web-shared/src/styles/components/form-validator'; +@use '../../web-shared/src/styles/components/btn-states'; +@use '../../web-shared/src/styles/components/alert'; +@use '../../web-shared/src/styles/components/details'; +@use '../../web-shared/src/styles/components/tabbed-navigation'; +@use '../../web-shared/src/styles/components/dropdown'; +@use '../../web-shared/src/styles/components/modal'; +@use '../../web-shared/src/styles/components/chip'; +@use '../../web-shared/src/styles/components/autocomplete'; +@use '../../web-shared/src/styles/components/select-autocomplete'; +@use '../../web-shared/src/styles/components/interactive-table'; +@use '../../web-shared/src/styles/components/pagination'; +@use '../../web-shared/src/styles/components/custom-select'; +@use '../../web-shared/src/styles/components/pre-header'; +@use '../../web-shared/src/styles/components/table'; +@use '../../web-shared/src/styles/components/custom-checkbox'; +@use '../../web-shared/src/styles/components/menu'; +@use '../../web-shared/src/styles/components/user-menu'; diff --git a/apps/projects/src/app/index.svelte b/apps/projects/src/app/index.svelte new file mode 100644 index 0000000..9dd2bf8 --- /dev/null +++ b/apps/projects/src/app/index.svelte @@ -0,0 +1,56 @@ +<svelte:options immutable={true}/> +<svelte:window bind:online={online}/> + +<script> + import {logout_user} from "$app/lib/services/user-service"; + import Router from "svelte-spa-router"; + import {wrap} from "svelte-spa-router/wrap"; + import {is_active} from "$shared/lib/session"; + import UiWorkbench from "$app/pages/ui-workbench.svelte"; + import NotFound from "$app/pages/not-found.svelte"; + import Home from "$app/pages/home.svelte"; + import Settings from "$app/pages/settings.svelte"; + import Data from "$app/pages/data.svelte"; + import PreHeader from "$shared/components/pre-header.svelte"; + + let online = true; + + async function user_is_logged_in() { + if (!await is_active()) { + await logout_user("expired"); + } + return true; + } + + const routes = { + "/home": wrap({ + component: Home, + conditions: [user_is_logged_in], + }), + "/": wrap({ + component: Home, + conditions: [user_is_logged_in], + }), + "/settings": wrap({ + component: Settings, + conditions: [user_is_logged_in], + }), + "/data": wrap({ + component: Data, + conditions: [user_is_logged_in], + }), + "/ui-workbench": UiWorkbench, + "*": NotFound, + }; +</script> +<PreHeader show="{!online}">You seem to be offline, please check your internet connection.</PreHeader> +<Router + {routes} + restoreScrollState={true} + on:routeLoading={() => { + document.getElementById("loader").style.display = "inline-block"; + }} + on:routeLoaded={() => { + document.getElementById("loader").style.display = "none"; + }} +/> diff --git a/apps/projects/src/app/index.ts b/apps/projects/src/app/index.ts new file mode 100644 index 0000000..febb583 --- /dev/null +++ b/apps/projects/src/app/index.ts @@ -0,0 +1,16 @@ +// @ts-ignore +import App from "./index.svelte"; +import "./index.scss"; +import {is_debug, is_development} from "$shared/lib/configuration"; +import {noop} from "$shared/lib/helpers"; + +if (is_development() || is_debug()) { + console.log("%c Debug", "background-color:yellow;color:black;font-size:18px;"); +} else { + console.log("%c Production; Suppressing logs", "background-color:yellow;color:black;font-size:18px;"); + console.log = noop; +} + +export default new App({ + target: document.getElementById("root"), +}); diff --git a/apps/projects/src/app/lib/services/user-service.ts b/apps/projects/src/app/lib/services/user-service.ts new file mode 100644 index 0000000..7bffa49 --- /dev/null +++ b/apps/projects/src/app/lib/services/user-service.ts @@ -0,0 +1,21 @@ +import {delete_account, logout} from "$shared/lib/api/user"; +import {accounts_base} from "$shared/lib/configuration"; +import {clear_session_data} from "$shared/lib/session"; +import {clear_categories} from "$app/lib/stores/categories"; +import {clear_entries} from "$app/lib/stores/entries"; +import {clear_labels} from "$app/lib/stores/labels"; + +export async function logout_user(reason: string = "") { + await logout(); + clear_session_data(); + clear_categories(); + clear_labels(); + clear_entries(); + location.replace(accounts_base("#/login" + (reason ? "?" + reason : ""))); +} + +export async function delete_user() { + await delete_account(); + clear_session_data(); + location.replace(accounts_base("#/login?deleted")); +} diff --git a/apps/projects/src/app/lib/stores/categories.ts b/apps/projects/src/app/lib/stores/categories.ts new file mode 100644 index 0000000..2a63c42 --- /dev/null +++ b/apps/projects/src/app/lib/stores/categories.ts @@ -0,0 +1,44 @@ +import {writable, get} from "svelte/store"; +import {create_time_category, delete_time_category, get_time_categories} from "$shared/lib/api/time-entry"; +import type {TimeCategoryDto} from "$shared/lib/models/TimeCategoryDto"; +import type {IInternalFetchResponse} from "$shared/lib/models/IInternalFetchResponse"; + +const categories = writable<Array<TimeCategoryDto>>([]); + +export async function reload_categories() { + const get_categories_response = await get_time_categories(); + if (!get_categories_response.ok) { + clear_categories(); + return; + } + categories.set(get_categories_response.data ?? []); +} + +export function clear_categories() { + categories.set([]); +} + +export async function create_category_async(request: TimeCategoryDto): Promise<IInternalFetchResponse> { + const create_entry_response = await create_time_category(request); + if (create_entry_response.ok) { + const stored_entries = get(categories); + stored_entries.push(create_entry_response.data); + categories.set(stored_entries); + } + return create_entry_response; +} + +export async function edit_category_async(entry: TimeCategoryDto) { + if (!entry.id) return; +} + +export async function delete_category_async(entry: TimeCategoryDto) { + if (!entry.id) return; + const http_request = await delete_time_category(entry.id); + if (http_request.ok) { + const stored_entries = get(categories); + categories.set(stored_entries.filter(e => e.id !== entry.id)); + } +} + +export default categories; diff --git a/apps/projects/src/app/lib/stores/entries.ts b/apps/projects/src/app/lib/stores/entries.ts new file mode 100644 index 0000000..e933568 --- /dev/null +++ b/apps/projects/src/app/lib/stores/entries.ts @@ -0,0 +1,74 @@ +import {Temporal} from "@js-temporal/polyfill"; +import {writable, get} from "svelte/store"; +import {get_time_entries, create_time_entry, delete_time_entry, update_time_entry} from "$shared/lib/api/time-entry"; +import type {TimeEntryDto} from "$shared/lib/models/TimeEntryDto"; +import type {IInternalFetchResponse} from "$shared/lib/models/IInternalFetchResponse"; +import type {TimeEntryQuery} from "$shared/lib/models/TimeEntryQuery"; + +const entries = writable<Array<TimeEntryDto>>([]); + +export function get_time_entry(id: string): TimeEntryDto { + return get(entries).find(c => c.id === id); +} + +export async function reload_entries(query: TimeEntryQuery): Promise<void> { + const get_entries_response = await get_time_entries(query); + if (!get_entries_response.ok) { + clear_entries(); + return; + } + entries.set(get_default_sorted(get_entries_response.data?.results ?? [])); +} + +export function clear_entries() { + entries.set([]); +} + +function get_default_sorted(unsorted: Array<TimeEntryDto>): Array<TimeEntryDto> { + if (unsorted.length < 1) return unsorted; + const byStart = unsorted.sort((a, b) => { + return Temporal.Instant.compare(Temporal.Instant.from(b.start), Temporal.Instant.from(a.start)); + }); + + return byStart.sort((a, b) => { + return Temporal.Instant.compare(Temporal.Instant.from(b.stop), Temporal.Instant.from(a.stop)); + }); +} + +export async function create_entry_async(request: TimeEntryDto): Promise<IInternalFetchResponse> { + const create_entry_response = await create_time_entry(request); + if (create_entry_response.ok) { + const stored_entries = get(entries) ?? []; + stored_entries.push(create_entry_response.data); + entries.set(get_default_sorted(stored_entries)); + } + return create_entry_response; +} + +export async function edit_entry_async(request: TimeEntryDto): Promise<IInternalFetchResponse> { + if (!request.id) return; + const edit_entry_response = await update_time_entry(request); + if (edit_entry_response.ok) { + const stored_entries = get(entries) ?? []; + const index = stored_entries.findIndex(c => c.id === request.id); + if (index === -1) { + stored_entries.push(edit_entry_response.data); + } else { + stored_entries[index] = edit_entry_response.data; + } + entries.set(get_default_sorted(stored_entries)); + } + return edit_entry_response; +} + +export async function delete_entry_async(entry_id: string): Promise<void> { + if (!entry_id) throw new Error("No id was supplied when deleting query"); + const delete_entry_response = await delete_time_entry(entry_id); + if (delete_entry_response.ok) { + const stored_entries = get(entries) ?? []; + entries.set(get_default_sorted(stored_entries.filter((e) => e.id !== entry_id) ?? [])); + } +} + + +export default entries; diff --git a/apps/projects/src/app/lib/stores/labels.ts b/apps/projects/src/app/lib/stores/labels.ts new file mode 100644 index 0000000..d5ffaa9 --- /dev/null +++ b/apps/projects/src/app/lib/stores/labels.ts @@ -0,0 +1,44 @@ +import {writable, get} from "svelte/store"; +import {create_time_label, delete_time_label, get_time_labels} from "$shared/lib/api/time-entry"; +import type {IInternalFetchResponse} from "$shared/lib/models/IInternalFetchResponse"; +import type {TimeLabelDto} from "$shared/lib/models/TimeLabelDto"; + +const labels = writable<Array<TimeLabelDto>>([]); + +export async function reload_labels() { + const get_labels_response = await get_time_labels(); + if (!get_labels_response.ok) { + clear_labels(); + return; + } + labels.set(get_labels_response.data ?? []); +} + +export function clear_labels() { + labels.set([]); +} + +export async function create_label_async(request: TimeLabelDto): Promise<IInternalFetchResponse> { + const create_label_response = await create_time_label(request); + if (create_label_response.ok) { + const stored_entries = get(labels) ?? []; + stored_entries.push(create_label_response.data); + labels.set(stored_entries); + } + return create_label_response; +} + +export async function edit_label_async(entry: TimeLabelDto) { + if (!entry.id) throw new Error("Label id is required"); +} + +export async function delete_label_async(entry: TimeLabelDto) { + if (!entry.id) return; + const http_request = await delete_time_label(entry.id); + if (http_request.ok) { + const stored_entries = get(labels) ?? []; + labels.set(stored_entries.filter(e => e.id !== entry.id)); + } +} + +export default labels; diff --git a/apps/projects/src/app/pages/_layout.svelte b/apps/projects/src/app/pages/_layout.svelte new file mode 100644 index 0000000..24a9370 --- /dev/null +++ b/apps/projects/src/app/pages/_layout.svelte @@ -0,0 +1,79 @@ +<script> + import {onMount} from "svelte"; + import {location, link} from "svelte-spa-router"; + import {logout_user} from "$app/lib/services/user-service"; + import {random_string, switch_theme} from "$shared/lib/helpers"; + import {get_session_data} from "$shared/lib/session"; + import ProfileModal from "$app/pages/views/profile-modal.svelte"; + import {Menu, MenuItem, MenuItemSeparator} from "$shared/components/menu"; + import Button from "$shared/components/button.svelte"; + import {IconNames} from "$shared/lib/configuration"; + + let ProfileModalFunctions = {}; + let showUserMenu = false; + let userMenuTriggerNode; + const userMenuId = "__menu_" + random_string(3); + const username = get_session_data().profile.username; + + onMount(() => { + userMenuTriggerNode = document.getElementById("open-user-menu"); + }); +</script> + +<ProfileModal bind:functions={ProfileModalFunctions}/> + +<nav class="container max-width-xl@md width-fit-content@md width-100% max-width-none margin-y-xs@md margin-bottom-xs block@md position-relative@md position-absolute bottom-unset@md bottom-0"> + <div class="tabs-nav-v2 justify-between"> + <div class="tab-v2"> + <div class="tab-v2"> + <a href="/home" + use:link + class="tabs-nav-v2__item {$location === '/home' ? 'tabs-nav-v2__item--selected' : ''}">Home</a> + </div> + <div class="tab-v2"> + <a href="/data" + use:link + class="tabs-nav-v2__item {$location === '/data' ? 'tabs-nav-v2__item--selected' : ''}">Data</a> + </div> + <div class="tab-v2"> + <a href="/settings" + use:link + class="tabs-nav-v2__item {$location === '/settings' ? 'tabs-nav-v2__item--selected' : ''}">Settings</a> + </div> + </div> + <div class="tab-v2 padding-x-sm"> + <Button class="user-menu-control" + variant="reset" + id="open-user-menu" + on:click={() => showUserMenu = !showUserMenu} + text={username} + icon={IconNames.chevronDown} + icon_width="2rem" + icon_height="2rem" + icon_right_aligned="true" + title="Toggle user menu" + aria-controls="{userMenuId}" + /> + <Menu bind:show="{showUserMenu}" + trigger={userMenuTriggerNode} + id="{userMenuId}"> + <div slot="options"> + <MenuItem on:click={() => ProfileModalFunctions.open()}> + <span title="Administrate your profile">Profile</span> + </MenuItem> + <MenuItem on:click={() => switch_theme()}> + <span title="Change between a dark and light theme">Switch theme</span> + </MenuItem> + <MenuItemSeparator/> + <MenuItem danger="true" on:click={() => logout_user()}> + <span title="Log out of your profile">Log out</span> + </MenuItem> + </div> + </Menu> + </div> + </div> +</nav> + +<main class="container max-width-xl"> + <slot/> +</main> diff --git a/apps/projects/src/app/pages/data.svelte b/apps/projects/src/app/pages/data.svelte new file mode 100644 index 0000000..070b98b --- /dev/null +++ b/apps/projects/src/app/pages/data.svelte @@ -0,0 +1,392 @@ +<script> + import {IconNames} from "$shared/lib/configuration"; + import {onMount} from "svelte"; + import {Temporal} from "@js-temporal/polyfill"; + import Layout from "./_layout.svelte"; + import Modal from "$shared/components/modal.svelte"; + import Tile from "$shared/components/tile.svelte"; + import Icon from "$shared/components/icon.svelte"; + import EntryForm from "$app/pages/views/entry-form/index.svelte"; + import {Table, THead, TBody, TCell, TRow, TablePaginator} from "$shared/components/table"; + import {TimeEntryQueryDuration} from "$shared/lib/models/TimeEntryQuery"; + import {delete_time_entry, get_time_entries, get_time_entry} from "$shared/lib/api/time-entry"; + import {seconds_to_hour_minute_string, is_guid, move_focus, unwrap_date_time_from_entry} from "$shared/lib/helpers"; + import Button from "$shared/components/button.svelte"; + + let pageCount = 1; + let page = 1; + + const defaultQuery = { + duration: TimeEntryQueryDuration.THIS_YEAR, + categories: [], + labels: [], + page: page, + pageSize: 50, + }; + + let isLoading; + let categories = []; + let labels = []; + let entries = []; + let durationSummary = false; + let EditEntryModal; + let EditEntryForm; + let currentTimespanFilter = TimeEntryQueryDuration.THIS_YEAR; + let currentSpecificDateFilter = Temporal.Now.plainDateTimeISO().subtract({days: 1}).toString().substring(0, 10); + let currentDateRangeFilter = {}; + let currentCategoryFilter = "all"; + let currentLabelFilter = "all"; + let showDateFilterOptions = false; + let secondsLogged = 0; + + function set_duration_summary_string() { + if (entries.length > 0) { + durationSummary = `Showing ${entries.length} ${entries.length === 1 ? "entry" : "entries"}, totalling in ${seconds_to_hour_minute_string(secondsLogged)}`; + } else { + durationSummary = ""; + } + } + + async function load_entries(query = defaultQuery) { + isLoading = true; + const response = await get_time_entries(query); + if (response.status === 200) { + const responseEntries = []; + secondsLogged = 0; + for (const entry of response.data.results) { + const date_time = unwrap_date_time_from_entry(entry); + const seconds = (date_time.duration.hours * 60 * 60) + (date_time.duration.minutes * 60); + responseEntries.push({ + id: entry.id, + date: date_time.start_date, + start: date_time.start_time, + stop: date_time.stop_time, + durationString: date_time.duration.hours + "h" + date_time.duration.minutes + "m", + seconds: seconds, + category: entry.category, + labels: entry.labels, + description: entry.description, + }); + secondsLogged += seconds; + } + entries = responseEntries; + page = response.data.page; + pageCount = response.data.totalPageCount; + } else { + entries = []; + page = 0; + pageCount = 0; + } + isLoading = false; + set_duration_summary_string(); + } + + function load_entries_with_filter(page = 1) { + let query = defaultQuery; + query.duration = currentTimespanFilter; + query.labels = []; + query.categories = []; + query.page = page; + + if (currentTimespanFilter === TimeEntryQueryDuration.SPECIFIC_DATE) { + query.specificDate = currentSpecificDateFilter; + } else { + delete query.specificDate; + } + + if (currentTimespanFilter === TimeEntryQueryDuration.DATE_RANGE) { + query.dateRange = currentDateRangeFilter; + } else { + delete query.dateRange; + } + + if ((currentCategoryFilter !== "all" && currentCategoryFilter?.length > 0) ?? false) { + for (const chosenCategoryId of currentCategoryFilter) { + if (chosenCategoryId === "all") { + continue; + } + query.categories.push({ + id: chosenCategoryId, + }); + } + } + + if ((currentLabelFilter !== "all" && currentLabelFilter?.length > 0) ?? false) { + for (const chosenLabelId of currentLabelFilter) { + if (chosenLabelId === "all") { + continue; + } + query.labels.push({ + id: chosenLabelId, + }); + } + } + + load_entries(query); + } + + async function handle_delete_entry_button_click(e, entryId) { + if (confirm("Are you sure you want to delete this entry?")) { + const response = await delete_time_entry(entryId); + if (response.ok) { + const indexOfEntry = entries.findIndex((c) => c.id === entryId); + if (indexOfEntry !== -1) { + secondsLogged -= entries[indexOfEntry].seconds; + entries.splice(indexOfEntry, 1); + entries = entries; + set_duration_summary_string(); + } + } + } + } + + function handle_edit_entry_form_updated() { + load_entries_with_filter(page); + EditEntryModal.close(); + } + + async function handle_edit_entry_button_click(event, entryId) { + const response = await get_time_entry(entryId); + if (response.status === 200) { + if (is_guid(response.data.id)) { + EditEntryForm.set_values(response.data); + EditEntryModal.open(); + move_focus(document.querySelector("input[id='date']")); + } + } + } + + function close_date_filter_box(event) { + if (!event.target.closest(".date_filter_box_el")) { + showDateFilterOptions = false; + window.removeEventListener("click", close_date_filter_box); + } + } + + function toggle_date_filter_box(event) { + const box = document.getElementById("date_filter_box"); + const rect = event.target.getBoundingClientRect(); + box.style.top = rect.y + "px"; + box.style.left = rect.x - 50 + "px"; + showDateFilterOptions = true; + window.addEventListener("click", close_date_filter_box); + } + + onMount(() => { + isLoading = true; + Promise.all([load_entries()]).then(() => { + isLoading = false; + }); + }); +</script> + +<Modal title="Edit entry" + bind:functions={EditEntryModal} + on:closed={() => EditEntryForm.reset()}> + <EntryForm bind:functions={EditEntryForm} + on:updated={handle_edit_entry_form_updated}/> +</Modal> + +<div id="date_filter_box" + style="margin-top:25px" + class="padding-xs z-index-overlay bg shadow-sm position-absolute date_filter_box_el border {showDateFilterOptions ? '' : 'hide'}"> + <div class="flex items-baseline margin-bottom-xxxxs"> + <label class="text-sm color-contrast-medium margin-right-xs" + for="durationSelect">Timespan:</label> + <div class="select inline-block js-select"> + <select name="durationSelect" + bind:value={currentTimespanFilter} + id="durationSelect"> + <option value={TimeEntryQueryDuration.TODAY} + selected> Today + </option> + <option value={TimeEntryQueryDuration.THIS_WEEK}>This week</option> + <option value={TimeEntryQueryDuration.THIS_MONTH}>This month</option> + <option value={TimeEntryQueryDuration.THIS_YEAR}>This year</option> + <option value={TimeEntryQueryDuration.SPECIFIC_DATE}>Spesific date</option> + <option value={TimeEntryQueryDuration.DATE_RANGE}>Date range</option> + </select> + + <svg class="icon icon--xxxs margin-left-xxs" + viewBox="0 0 8 8"> + <path d="M7.934,1.251A.5.5,0,0,0,7.5,1H.5a.5.5,0,0,0-.432.752l3.5,6a.5.5,0,0,0,.864,0l3.5-6A.5.5,0,0,0,7.934,1.251Z"/> + </svg> + </div> + </div> + + {#if currentTimespanFilter === TimeEntryQueryDuration.SPECIFIC_DATE} + <div class="flex items-baseline margin-bottom-xxxxs justify-between"> + <span class="text-sm color-contrast-medium margin-right-xs">Date:</span> + <span class="text-sm"> + <input type="date" + class="border-none padding-0 color-inherit bg-transparent" + bind:value={currentSpecificDateFilter}/> + </span> + </div> + {/if} + + {#if currentTimespanFilter === TimeEntryQueryDuration.DATE_RANGE} + <div class="flex items-baseline margin-bottom-xxxxs justify-between"> + <span class="text-sm color-contrast-medium margin-right-xs">From:</span> + <span class="text-sm"> + <input type="date" + class="border-none padding-0 color-inherit bg-transparent" + on:change={(e) => (currentDateRangeFilter.from = e.target.value)}/> + </span> + </div> + + <div class="flex items-baseline margin-bottom-xxxxs justify-between"> + <span class="text-sm color-contrast-medium margin-right-xs">To:</span> + <span class="text-sm"> + <input type="date" + class="border-none padding-0 color-inherit bg-transparent" + on:change={(e) => (currentDateRangeFilter.to = e.target.value)}/> + </span> + </div> + {/if} + + <div class="flex items-baseline justify-end"> + <Button variant="subtle" + on:click={() => load_entries_with_filter(page)} + class="text-sm" + text="Save"/> + </div> +</div> + +<Layout> + <Tile class="{isLoading ? 'c-disabled loading' : ''}"> + <nav class="s-tabs text-sm"> + <ul class="s-tabs__list"> + <li><span class="s-tabs__link s-tabs__link--current">All (21)</span></li> + <li><span class="s-tabs__link">Published (19)</span></li> + <li><span class="s-tabs__link">Draft (2)</span></li> + </ul> + </nav> + <div class="max-width-100% overflow-auto" + style="max-height: 82.5vh"> + <Table class="text-sm width-100% int-table--sticky-header"> + <THead> + <TCell type="th" + style="width: 30px;"> + <div class="custom-checkbox int-table__checkbox"> + <input class="custom-checkbox__input" + type="checkbox" + aria-label="Select all rows"/> + <div class="custom-checkbox__control" + aria-hidden="true"></div> + </div> + </TCell> + + <TCell type="th" + style="width: 100px"> + <div class="flex items-center justify-between"> + <span>Date</span> + <div class="date_filter_box_el cursor-pointer" + on:click={toggle_date_filter_box}> + <Icon name="{IconNames.funnel}"/> + </div> + </div> + </TCell> + + <TCell type="th" + style="width: 100px"> + <div class="flex items-center"> + <span>Duration</span> + </div> + </TCell> + + <TCell type="th" + style="width: 100px;"> + <div class="flex items-center"> + <span>Category</span> + </div> + </TCell> + + <TCell type="th" + style="width: 300px;"> + <div class="flex items-center"> + <span>Description</span> + </div> + </TCell> + <TCell type="th" + style="width: 50px"></TCell> + </THead> + <TBody> + {#if entries.length > 0} + {#each entries as entry} + <TRow class="text-nowrap" + data-id={entry.id}> + <TCell type="th" + thScope="row"> + <div class="custom-checkbox int-table__checkbox"> + <input class="custom-checkbox__input" + type="checkbox" + aria-label="Select this row"/> + <div class="custom-checkbox__control" + aria-hidden="true"></div> + </div> + </TCell> + <TCell> + <pre>{entry.date.toLocaleString()}</pre> + </TCell> + <TCell> + <pre class="flex justify-between"> + <div class="flex justify-between"> + <span>{entry.start.toLocaleString(undefined, {timeStyle: "short"})}</span> + <span> - </span> + <span>{entry.stop.toLocaleString(undefined, {timeStyle: "short"})}</span> + </div> + </pre> + </TCell> + <TCell> + <span data-id={entry.category.id}>{entry.category.name}</span> + </TCell> + <TCell class="text-truncate max-width-xxxxs" + title="{entry.description}"> + {entry.description ?? ""} + </TCell> + <TCell class="flex flex-row justify-end items-center"> + <Button icon="{IconNames.pencilSquare}" + variant="reset" + icon_width="1.2rem" + icon_height="1.2rem" + on:click={(e) => handle_edit_entry_button_click(e, entry.id)} + title="Edit entry"/> + <Button icon="{IconNames.trash}" + variant="reset" + icon_width="1.2rem" + icon_height="1.2rem" + on:click={(e) => handle_delete_entry_button_click(e, entry.id)} + title="Delete entry"/> + </TCell> + </TRow> + {/each} + {:else} + <TRow class="text-nowrap"> + <TCell type="th" + thScope="row" + colspan="7"> + {isLoading ? "Loading..." : "No entries"} + </TCell> + </TRow> + {/if} + </TBody> + </Table> + </div> + <div class="flex items-center justify-between"> + <p class="text-sm"> + {#if durationSummary} + <small class={isLoading ? "c-disabled loading" : ""}>{durationSummary}</small> + {:else} + <small class={isLoading ? "c-disabled loading" : ""}>No entries</small> + {/if} + </p> + + <nav class="grid padding-y-sm {isLoading ? 'c-disabled loading' : ''}"> + <TablePaginator {page} + on:value_change={(e) => load_entries_with_filter(e.detail.newValue)} + {pageCount}/> + </nav> + </div> + </Tile> +</Layout> diff --git a/apps/projects/src/app/pages/home.svelte b/apps/projects/src/app/pages/home.svelte new file mode 100644 index 0000000..c3e7af4 --- /dev/null +++ b/apps/projects/src/app/pages/home.svelte @@ -0,0 +1,167 @@ +<script lang="ts"> + import {IconNames} from "$shared/lib/configuration"; + import {TimeEntryDto} from "$shared/lib/models/TimeEntryDto"; + import {Temporal} from "@js-temporal/polyfill"; + import {onMount} from "svelte"; + import Tile from "$shared/components/tile.svelte"; + import Button from "$shared/components/button.svelte"; + import Stopwatch from "$shared/components/stopwatch.svelte"; + import {Table, THead, TBody, TCell, TRow} from "$shared/components/table"; + import Layout from "./_layout.svelte"; + import EntryFrom from "$app/pages/views/entry-form/index.svelte"; + import {seconds_to_hour_minute_string, unwrap_date_time_from_entry} from "$shared/lib/helpers"; + import {TimeEntryQueryDuration} from "$shared/lib/models/TimeEntryQuery"; + import entries, {delete_entry_async, get_time_entry, reload_entries} from "$app/lib/stores/entries"; + + let currentTime = ""; + let isLoading = false; + let EditEntryForm: any; + let timeEntries = [] as Array<TimeEntryDto>; + let timeLoggedTodayString = "0h0m"; + + function set_current_time() { + currentTime = Temporal.Now.plainTimeISO().toLocaleString(undefined, { + timeStyle: "short", + }); + } + + async function on_edit_entry_button_click(event, entryId: string) { + const response = get_time_entry(entryId); + EditEntryForm.set_values(response); + } + + async function on_delete_entry_button_click(event, entryId: string) { + if (confirm("Are you sure you want to delete this entry?")) { + await delete_entry_async(entryId); + } + } + + async function load_todays_entries() { + await reload_entries({ + duration: TimeEntryQueryDuration.TODAY, + page: 1, + pageSize: 100, + }); + } + + function on_create_from_stopwatch(event) { + EditEntryForm.set_time({to: event.detail.to, from: event.detail.from}); + if (event.detail.description) { + EditEntryForm.set_description(event.detail.description); + } + } + + onMount(async () => { + set_current_time(); + setInterval(() => { + set_current_time(); + }, 1e4); + await load_todays_entries(); + entries.subscribe((val) => { + const newEntries = []; + let loggedSecondsToday = 0; + for (const entry of val) { + const date_time = unwrap_date_time_from_entry(entry); + newEntries.push({ + id: entry.id, + start: date_time.start_time, + stop: date_time.stop_time, + category: entry.category, + }); + loggedSecondsToday += (date_time.duration.hours * 60 * 60) + (date_time.duration.minutes * 60); + } + timeLoggedTodayString = seconds_to_hour_minute_string(loggedSecondsToday); + timeEntries = newEntries; + }); + }); +</script> + +<Layout> + <div class="grid gap-md margin-top-xs flex-row@md items-start flex-column-reverse"> + <Tile class="col"> + <h3 class="text-md padding-bottom-xxxs">New entry</h3> + <EntryFrom bind:functions={EditEntryForm}/> + </Tile> + <div class="col grid gap-sm"> + <Tile class="col-6@md col-12"> + <p class="text-xxl">{timeLoggedTodayString}</p> + <p class="text-xs margin-bottom-xxs">Logged time today</p> + <pre class="text-xxl">{currentTime}</pre> + <p class="text-xs">Current time</p> + </Tile> + <Tile class="col-6@md col-12"> + <Stopwatch on:create={on_create_from_stopwatch}> + <h3 slot="header" + class="text-md">Stopwatch</h3> + </Stopwatch> + </Tile> + <Tile class="col-12"> + <h3 class="text-md padding-bottom-xxxs">Today's entries</h3> + <div class="max-width-100% overflow-auto"> + <Table class="width-100% text-sm"> + <THead> + <TCell type="th" + class="text-left"> + <span>Category</span> + </TCell> + <TCell type="th" + class="text-left"> + <span>Timespan</span> + </TCell> + <TCell type="th" + class="text-right"> + <Button icon="{IconNames.refresh}" + variant="reset" + icon_width="1.2rem" + icon_height="1.2rem" + title="Refresh today's entries" + on:click={load_todays_entries}/> + </TCell> + </THead> + <TBody> + {#if timeEntries.length > 0} + {#each timeEntries as entry} + <TRow class="text-nowrap text-left" + data-id={entry.id}> + <TCell> + <span data-id={entry.category?.id}> + {entry.category?.name} + </span> + </TCell> + <TCell> + {entry.start.toLocaleString(undefined, {timeStyle: "short"})} + <span>-</span> + {entry.stop.toLocaleString(undefined, {timeStyle: "short"})} + </TCell> + <TCell class="flex flex-row justify-end items-center"> + <Button icon="{IconNames.pencilSquare}" + variant="reset" + icon_width="1.2rem" + icon_height="1.2rem" + on:click={(e) => on_edit_entry_button_click(e, entry.id)} + title="Edit entry"/> + <Button icon="{IconNames.trash}" + variant="reset" + icon_width="1.2rem" + icon_height="1.2rem" + on:click={(e) => on_delete_entry_button_click(e, entry.id)} + title="Delete entry"/> + </TCell> + </TRow> + {/each} + {:else} + <TRow class="text-nowrap"> + <TCell type="th" + thScope="row" + colspan="7"> + {isLoading ? "Loading..." : "No entries today"} + </TCell> + </TRow> + {/if} + </TBody> + </Table> + </div> + </Tile> + </div> + </div> +</Layout> diff --git a/apps/projects/src/app/pages/not-found.svelte b/apps/projects/src/app/pages/not-found.svelte new file mode 100644 index 0000000..46d0d1d --- /dev/null +++ b/apps/projects/src/app/pages/not-found.svelte @@ -0,0 +1,24 @@ +<script> + import {link} from "svelte-spa-router"; +</script> + +<style> + header { + font-size: 12rem; + } + + main { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + text-align: center; + } +</style> + +<main> + <header>404</header> + <p>Page not found!</p> + <a use:link + href="/">Go to front</a> +</main> diff --git a/apps/projects/src/app/pages/settings.svelte b/apps/projects/src/app/pages/settings.svelte new file mode 100644 index 0000000..ca9fd47 --- /dev/null +++ b/apps/projects/src/app/pages/settings.svelte @@ -0,0 +1,12 @@ +<script> + import Layout from "./_layout.svelte"; + import CategoriesTile from "$app/pages/views/settings-categories-tile.svelte"; + import LabelsTile from "$app/pages/views/settings-labels-tile.svelte"; +</script> + +<Layout> + <section class="grid gap-md"> + <CategoriesTile/> + <LabelsTile/> + </section> +</Layout> diff --git a/apps/projects/src/app/pages/ui-workbench.svelte b/apps/projects/src/app/pages/ui-workbench.svelte new file mode 100644 index 0000000..5e92c9d --- /dev/null +++ b/apps/projects/src/app/pages/ui-workbench.svelte @@ -0,0 +1,48 @@ +<script> + import Dropdown from "$shared/components/dropdown.svelte"; + import {generate_random_hex_color} from "$shared/lib/colors"; + import {uuid_v4} from "$shared/lib/helpers"; + + let entries = []; + + let dropdown; + + for (let i = 1; i < 20; i++) { + entries.push({ + id: uuid_v4(), + name: "Option " + i, + selected: false, + color: generate_random_hex_color(true) + }); + } + + function on_create({detail}) { + const copy = entries; + const entry = {id: uuid_v4(), name: detail.name}; + copy.push(entry); + entries = copy; + console.log("Created", entry); + dropdown.select_entry(entry.id); + } + + function on_select({detail}) { + console.log(detail); + } +</script> + +<main class="grid gap-y-lg padding-md"> + <div class="row"> + <label for="dropdown">Choose an entry</label> + <Dropdown id="dropdown" + name="dropdown" + placeholder="Search or create" + maxlength="50" + creatable="true" + multiple="false" + {entries} + bind:this={dropdown} + on:create={on_create} + on:select={on_select} + /> + </div> +</main> diff --git a/apps/projects/src/app/pages/views/category-form/index.svelte b/apps/projects/src/app/pages/views/category-form/index.svelte new file mode 100644 index 0000000..e8c0f94 --- /dev/null +++ b/apps/projects/src/app/pages/views/category-form/index.svelte @@ -0,0 +1,144 @@ +<script lang="ts"> + import Alert from "$shared/components/alert.svelte"; + import Dropdown from "$shared/components/dropdown.svelte"; + import labels, {reload_labels, create_label_async} from "$app/lib/stores/labels"; + import {generate_random_hex_color} from "$shared/lib/colors"; + import {get} from "svelte/store"; + + let LabelsDropdown; + + const dough = { + error: "", + fields: { + name: { + value: "", + error: "", + validate() { + return false; + } + }, + color: { + value: "", + error: "", + validate() { + return true; + } + }, + labels: { + loading: false, + value: [], + error: "", + validate() { + return true; + }, + async create({name}) { + dough.fields.labels.loading = true; + const response = await create_label_async({ + name: name, + color: generate_random_hex_color(), + }); + dough.fields.labels.loading = false; + if (response.ok) { + // Small pause to allow loading state to update everywhere. + setTimeout(() => LabelsDropdown.select_entry(response.data.id), 50); + } + } + }, + archived: { + value: false, + error: "", + validate() { + return true; + } + } + }, + bake() { + // labels.filter((c) => Object.hasOwn(c, "selected") && c.selected === true); + return { + labels: dough.fields.labels.value, + name: dough.fields.name.value, + color: dough.fields.color.value, + }; + }, + submit(event) { + const bread = dough.bake(); + console.log(bread); + console.log("Submitted"); + } + }; + + const functions = { + set(values) { + functions.set_archived(values.archived); + functions.set_labels(values.labels); + functions.set_color(values.color); + functions.set_name(values.name); + }, + is_valid() { + let isValid = true; + if (!dough.fields.labels.validate()) isValid = false; + if (!dough.fields.color.validate()) isValid = false; + if (!dough.fields.name.validate()) isValid = false; + if (!dough.fields.archived.validate()) isValid = false; + return isValid; + }, + set_archived(value) { + dough.fields.archived.value = value; + }, + set_labels(value) { + dough.fields.labels.value = value; + }, + set_color(value) { + dough.fields.color.value = value; + }, + set_name(value) { + dough.fields.name.value = value; + }, + }; +</script> + +<form on:submit|preventDefault={dough.submit}> + <div class="margin-y-sm"> + <Alert visible={dough.error !== ""} + message={dough.error} + type="error"/> + </div> + <div class="grid gap-x-xs margin-bottom-sm"> + <div class="col-10"> + <label for="name" + class="form-label margin-bottom-xxs">Name</label> + <input type="text" + class="form-control width-100%" + id="name" + bind:value={dough.fields.name.value}/> + {#if dough.fields.name.error} + <small class="color-error">{dough.fields.name.error}</small> + {/if} + </div> + <div class="col-2"> + <label for="color" + class="form-label margin-bottom-xxs">Color</label> + <input type="color" + class="form-control width-100%" + id="color" + style="height: 41px" + bind:value={dough.fields.color.value}/> + {#if dough.fields.color.error} + <small class="color-error">{dough.fields.color.error}</small> + {/if} + </div> + </div> + <div class="margin-bottom-sm"> + <label for="labels" + class="form-label margin-bottom-xxs">Default labels</label> + <Dropdown id="labels" + createable={true} + placeholder="Search or create" + entries={$labels} + multiple={true} + on_create_async={(name) => dough.fields.labels.create({name})}/> + {#if dough.fields.labels.error} + <small class="color-error">{dough.fields.labels.error}</small> + {/if} + </div> +</form> diff --git a/apps/projects/src/app/pages/views/data-table-paginator.svelte b/apps/projects/src/app/pages/views/data-table-paginator.svelte new file mode 100644 index 0000000..7696ca2 --- /dev/null +++ b/apps/projects/src/app/pages/views/data-table-paginator.svelte @@ -0,0 +1,107 @@ +<script> + import {createEventDispatcher, onMount} from "svelte"; + import {restrict_input_to_numbers} from "$shared/lib/helpers"; + + const dispatch = createEventDispatcher(); + export let page = 1; + export let pageCount = 1; + let prevCount = page; + let canIncrement = false; + let canDecrement = false; + $: canIncrement = page < pageCount; + $: canDecrement = page > 1; + + onMount(() => { + restrict_input_to_numbers(document.querySelector("#curr-page")); + }); + + function increment() { + if (canIncrement) { + page++; + } + } + + function decrement() { + if (canDecrement) { + page--; + } + } + + $: if (page) { + handle_change(); + } + + function handle_change() { + if (page === prevCount) { + return; + } + prevCount = page; + if (page > pageCount) { + page = pageCount; + } + dispatch("value_change", { + newValue: page, + }); + } +</script> + +<nav class="pagination" + aria-label="Pagination"> + <ul + class="pagination__list flex flex-wrap gap-xxxs justify-center justify-end@md" + > + <li> + <button + on:click={decrement} + class="reset pagination__item {canDecrement ? '' : 'c-disabled'}" + > + <svg class="icon icon--xs flip-x" + viewBox="0 0 16 16" + ><title>Go to previous page</title> + <polyline + points="6 2 12 8 6 14" + fill="none" + stroke="currentColor" + stroke-linecap="round" + stroke-linejoin="round" + stroke-width="2" + /> + </svg> + </button> + </li> + + <li> + <span class="pagination__jumper flex items-center"> + <input + aria-label="Page number" + class="form-control" + id="curr-page" + type="text" + on:change={handle_change} + value={page} + /> + <em>of {pageCount}</em> + </span> + </li> + + <li> + <button + on:click={increment} + class="reset pagination__item {canIncrement ? '' : 'c-disabled'}" + > + <svg class="icon icon--xs" + viewBox="0 0 16 16" + ><title>Go to next page</title> + <polyline + points="6 2 12 8 6 14" + fill="none" + stroke="currentColor" + stroke-linecap="round" + stroke-linejoin="round" + stroke-width="2" + /> + </svg> + </button> + </li> + </ul> +</nav> diff --git a/apps/projects/src/app/pages/views/entry-form/index.svelte b/apps/projects/src/app/pages/views/entry-form/index.svelte new file mode 100644 index 0000000..cb974ed --- /dev/null +++ b/apps/projects/src/app/pages/views/entry-form/index.svelte @@ -0,0 +1,196 @@ +<script lang="ts"> + import {TimeEntryDto} from "$shared/lib/models/TimeEntryDto"; + import {Temporal} from "@js-temporal/polyfill"; + import {createEventDispatcher, onMount, onDestroy} from "svelte"; + import DateTimePart from "./sections/date-time.svelte"; + import LabelsPart from "./sections/labels.svelte"; + import CategoryPart from "./sections/category.svelte"; + import Button from "$shared/components/button.svelte"; + import {Textarea} from "$shared/components/form"; + import Alert from "$shared/components/alert.svelte"; + import {is_guid} from "$shared/lib/helpers"; + import {create_entry_async, edit_entry_async} from "$app/lib/stores/entries"; + + const dispatch = createEventDispatcher(); + + let formError = ""; + let formIsLoading = false; + let description = ""; + let descriptionError = ""; + let dateTimePart; + let labelsPart; + let categoryPart; + let entryId; + + onMount(() => { + formIsLoading = true; + + Promise.all([categoryPart.load_categories(), labelsPart.load_labels()]).then(() => { + formIsLoading = false; + }); + + window.addEventListener("keydown", handle_window_keydown); + }); + + onDestroy(() => { + window.removeEventListener("keydown", handle_window_keydown); + }); + + function handle_window_keydown(event) { + if (event.ctrlKey && event.code === "Enter") { + submit_form(); + } + } + + function validate_form() { + return dateTimePart.is_valid() && categoryPart.is_valid() && description_is_valid(); + } + + function description_is_valid() { + if (!description) { + descriptionError = "Description is required"; + } else { + descriptionError = ""; + } + + return description; + } + + function get_payload() { + const response = {} as TimeEntryDto; + const values = get_values(); + if (!is_guid(values.id)) { + delete values.id; + } else { + response.id = values.id; + } + + const currentTimeZone = Temporal.Now.zonedDateTimeISO().offset; + response.start = values.date + "T" + values.fromTimeValue + currentTimeZone.toString(); + response.stop = values.date + "T" + values.toTimeValue + currentTimeZone.toString(); + + response.category = { + id: values.category.id, + }; + + const selectedLabels = values.labels; + if (selectedLabels?.length > 0 ?? false) { + response.labels = selectedLabels; + } + + const descriptionContent = description?.trim(); + if (descriptionContent?.length > 0 ?? false) { + response.description = descriptionContent; + } + + return response; + } + + async function submit_form() { + formError = ""; + if (validate_form()) { + const payload = get_payload() as TimeEntryDto; + formIsLoading = true; + if (is_guid(payload.id)) { + const response = await edit_entry_async(payload); + if (response.ok) { + functions.reset(); + dispatch("updated", response.data); + } else { + formError = "An error occured while updating the entry, try again soon"; + formIsLoading = false; + } + } else { + const response = await create_entry_async(payload); + if (response.ok) { + functions.reset(); + dispatch("created"); + } else { + formError = "An error occured while creating the entry, try again soon"; + formIsLoading = false; + } + } + } + } + + function get_values() { + return { + id: entryId, + toTimeValue: dateTimePart.get_to_time_value(), + fromTimeValue: dateTimePart.get_from_time_value(), + date: dateTimePart.get_date(), + category: categoryPart.get_selected(), + labels: labelsPart.get_selected(), + description: description, + }; + } + + export const functions = { + set_values(values) { + entryId = values.id; + dateTimePart.set_values(values); + labelsPart.select_labels(values?.labels.map((c) => c.id) ?? []); + categoryPart.select_category(values?.category?.id); + description = values.description; + }, + set_time(value: {to: Temporal.PlainTime, from: Temporal.PlainTime}) { + dateTimePart.set_times(value); + }, + set_description(value: string) { + if (description) description = description + "\n\n" + value; + else description = value; + }, + reset() { + formIsLoading = false; + entryId = ""; + labelsPart.reset(); + categoryPart.reset(); + dateTimePart.reset(true); + description = ""; + formError = ""; + }, + }; +</script> + +<form on:submit|preventDefault={submit_form} + on:reset={() => functions.reset()}> + <div class="margin-y-sm"> + <Alert visible={formError !== ""} + message={formError} + type="error"/> + </div> + + <div class="margin-bottom-sm"> + <DateTimePart bind:functions={dateTimePart}/> + </div> + + <div class="margin-bottom-sm"> + <CategoryPart bind:functions={categoryPart}/> + </div> + + <div class="margin-bottom-sm"> + <LabelsPart bind:functions={labelsPart}/> + </div> + + <div class="margin-bottom-sm"> + <Textarea class="width-100%" + id="description" + label="Description" + errorText="{descriptionError}" + bind:value={description}></Textarea> + </div> + + <div class="flex flex-row justify-end gap-x-xs"> + {#if entryId} + <Button text="Reset" + on:click={() => functions.reset()} + variant="subtle" + /> + {/if} + <Button loading={formIsLoading} + type="submit" + variant="primary" + text={entryId ? "Save" : "Create"} + /> + </div> +</form> diff --git a/apps/projects/src/app/pages/views/entry-form/sections/category.svelte b/apps/projects/src/app/pages/views/entry-form/sections/category.svelte new file mode 100644 index 0000000..f98c045 --- /dev/null +++ b/apps/projects/src/app/pages/views/entry-form/sections/category.svelte @@ -0,0 +1,75 @@ +<script> + import {generate_random_hex_color} from "$shared/lib/colors"; + import Dropdown from "$shared/components/dropdown.svelte"; + import {is_guid, move_focus} from "$shared/lib/helpers"; + import categories, {reload_categories, create_category_async} from "$app/lib/stores/categories"; + + let categoriesError = ""; + let loading = false; + + let DropdownExports; + + function reset() { + DropdownExports.reset(); + categoriesError = ""; + console.log("Reset category-part"); + } + + async function on_create({name}) { + loading = true; + const response = await create_category_async({ + name: name, + color: generate_random_hex_color(), + }); + loading = false; + if (response.ok) { + // Small pause to allow loading state to update everywhere. + setTimeout(() => select_category(response.data.id), 50); + } + } + + function get_selected() { + return $categories.find((c) => c.selected === true); + } + + function select_category(id) { + DropdownExports.select(id); + } + + function is_valid() { + let isValid = true; + const category = get_selected(); + if (!is_guid(category?.id)) { + categoriesError = "Category is required"; + isValid = false; + move_focus(document.getElementById("category-dropdown")); + } else { + categoriesError = ""; + } + return isValid; + } + + export const functions = { + get_selected, + reset, + is_valid, + select_category, + load_categories: reload_categories, + }; +</script> + +<Dropdown + entries={$categories} + label="Category" + maxlength="50" + createable={true} + placeholder="Search or create" + id="category-dropdown" + loading={loading} + name="category-dropdown" + on_create_async={on_create} + noResultsText="No categories available (Create a new one by searching for it and pressing enter)" + errorText="{categoriesError}" + bind:this={DropdownExports} +/> + diff --git a/apps/projects/src/app/pages/views/entry-form/sections/date-time.svelte b/apps/projects/src/app/pages/views/entry-form/sections/date-time.svelte new file mode 100644 index 0000000..c91e014 --- /dev/null +++ b/apps/projects/src/app/pages/views/entry-form/sections/date-time.svelte @@ -0,0 +1,165 @@ +<script lang="ts"> + import {Temporal} from "@js-temporal/polyfill"; + + // TIME + let fromTimeValue = ""; + let fromTimeError = ""; + let toTimeValue = ""; + let toTimeError = ""; + + function handle_from_time_changed(e) { + fromTimeValue = e.target.value; + if (fromTimeValue) { + fromTimeError = ""; + } + } + + function handle_to_time_changed(e) { + toTimeValue = e.target.value; + if (toTimeValue) { + toTimeError = ""; + } + } + + // DATE + let date = Temporal.Now.plainDateTimeISO().toString().substring(0, 10); + let dateError = ""; + + function is_valid() { + let isValid = true; + let focusIsSet = false; + if (!date) { + dateError = "Date is required"; + isValid = false; + if (!focusIsSet) { + document.getElementById("date")?.focus(); + focusIsSet = true; + } + } else { + dateError = ""; + } + + if (!fromTimeValue) { + fromTimeError = "From is required"; + isValid = false; + if (!focusIsSet) { + document.getElementById("from")?.focus(); + focusIsSet = true; + } + } else if (toTimeValue && fromTimeValue > toTimeValue) { + fromTimeError = "From can not be after To"; + isValid = false; + if (!focusIsSet) { + document.getElementById("from")?.focus(); + focusIsSet = true; + } + } else if (fromTimeValue === toTimeValue) { + fromTimeError = "From and To can not be equal"; + isValid = false; + if (!focusIsSet) { + document.getElementById("from")?.focus(); + focusIsSet = true; + } + } else { + fromTimeError = ""; + } + + if (!toTimeValue) { + toTimeError = "To is required"; + isValid = false; + if (!focusIsSet) { + document.getElementById("to")?.focus(); + focusIsSet = true; + } + } else if (fromTimeValue && toTimeValue < fromTimeValue) { + toTimeError = "To can not be before From"; + isValid = false; + if (!focusIsSet) { + document.getElementById("to")?.focus(); + focusIsSet = true; + } + } else { + toTimeError = ""; + } + + return isValid; + } + + export const functions = { + get_from_time_value() { + return fromTimeValue; + }, + get_to_time_value() { + return toTimeValue; + }, + get_date() { + return date; + }, + is_valid, + reset(focusDate = false) { + fromTimeValue = ""; + toTimeValue = ""; + if (focusDate) { + document.getElementById("date")?.focus(); + } + }, + set_times(value) { + console.log(value); + fromTimeValue = value.from.toString().substring(0, 5); + toTimeValue = value.to.toString().substring(0, 5); + }, + set_date(new_date: Temporal.PlainDate) { + date = new_date.toString(); + }, + set_values(values) { + const currentTimeZone = Temporal.Now.timeZone().id; + const startDate = Temporal.Instant.from(values.start); + const stopDate = Temporal.Instant.from(values.stop); + fromTimeValue = startDate.toZonedDateTimeISO(currentTimeZone).toPlainTime().toString().substring(0, 5); + toTimeValue = stopDate.toZonedDateTimeISO(currentTimeZone).toPlainTime().toString().substring(0, 5); + date = startDate.toZonedDateTimeISO(currentTimeZone).toPlainDate().toString(); + } + }; +</script> + +<div class="grid gap-xs"> + <div class="col-4"> + <label for="date" + class="form-label margin-bottom-xxs">Date</label> + <input type="date" + id="date" + class="form-control width-100%" + bind:value={date}> + {#if dateError} + <small class="color-error">{dateError}</small> + {/if} + </div> + <div class="col-4"> + <label for="from" + class="form-label margin-bottom-xxs">From</label> + <input id="from" + class="form-control width-100%" + pattern="[0-9][0-9]:[0-9][0-9]" + type="time" + bind:value={fromTimeValue} + on:input={handle_from_time_changed} + /> + {#if fromTimeError} + <small class="color-error">{fromTimeError}</small> + {/if} + </div> + <div class="col-4"> + <label for="to" + class="form-label margin-bottom-xxs">To</label> + <input id="to" + class="form-control width-100%" + pattern="[0-9][0-9]:[0-9][0-9]" + type="time" + bind:value={toTimeValue} + on:input={handle_to_time_changed} + /> + {#if toTimeError} + <small class="color-error">{toTimeError}</small> + {/if} + </div> +</div> diff --git a/apps/projects/src/app/pages/views/entry-form/sections/labels.svelte b/apps/projects/src/app/pages/views/entry-form/sections/labels.svelte new file mode 100644 index 0000000..06c703d --- /dev/null +++ b/apps/projects/src/app/pages/views/entry-form/sections/labels.svelte @@ -0,0 +1,65 @@ +<script> + import {generate_random_hex_color} from "$shared/lib/colors"; + import labels, {reload_labels, create_label_async} from "$app/lib/stores/labels"; + import Dropdown from "$shared/components/dropdown.svelte"; + + let labelsError = ""; + let loading = false; + let DropdownExports; + + function reset() { + DropdownExports.reset(); + console.log("Reset labels-part"); + } + + function get_selected() { + return $labels.filter((c) => Object.hasOwn(c, "selected") && c.selected === true); + } + + function select_label(id) { + DropdownExports.select(id); + } + + function select_labels(ids) { + for (const id of ids) { + DropdownExports.select(id); + } + } + + async function on_create({name}) { + loading = true; + const response = await create_label_async({ + name: name, + color: generate_random_hex_color(), + }); + loading = false; + if (response.ok) { + // Small pause to allow loading state to update everywhere. + setTimeout(() => select_label(response.data.id), 50); + } + } + + export const functions = { + get_selected, + reset, + load_labels: reload_labels, + select_labels, + select_label, + }; +</script> + +<Dropdown + entries={$labels} + label="Labels" + maxlength="50" + createable={true} + placeholder="Search or create" + multiple="{true}" + id="labels-search" + name="labels-search" + on_create_async={on_create} + noResultsText="No labels available (Create a new one by searching for it and pressing enter)" + errorText="{labelsError}" + bind:this={DropdownExports} + {loading} +/> diff --git a/apps/projects/src/app/pages/views/profile-modal.svelte b/apps/projects/src/app/pages/views/profile-modal.svelte new file mode 100644 index 0000000..839b59d --- /dev/null +++ b/apps/projects/src/app/pages/views/profile-modal.svelte @@ -0,0 +1,156 @@ +<script> + import {update_profile} from "$shared/lib/api/user"; + import Modal from "$shared/components/modal.svelte"; + import Alert from "$shared/components/alert.svelte"; + import Button from "$shared/components/button.svelte"; + import {is_email} from "$shared/lib/helpers"; + import {api_base} from "$shared/lib/configuration"; + import {delete_user} from "$app/lib/services/user-service"; + import {get_session_data} from "$shared/lib/session"; + + const archiveLink = api_base("_/api/account/archive"); + + let modal; + let understands = false; + + let formIsLoading = false; + let formError; + + let username = get_session_data()?.profile.username; + let usernameFieldMessage; + let usernameFieldMessageClass = "color-error"; + + let password; + let passwordFieldMessage; + let passwordFieldMessageClass = "color-error"; + + async function submit_form(e) { + e.preventDefault(); + if (!username && !password) { + console.error("Not submitting becuase both values is empty"); + return; + } + + usernameFieldMessage = ""; + passwordFieldMessage = ""; + + if (username && !is_email(username)) { + usernameFieldMessage = "Username has to be a valid email"; + return; + } + + if (password && password?.length < 6) { + passwordFieldMessage = "The new password must contain at least 6 characters"; + return; + } + + formIsLoading = true; + + const response = await update_profile({ + username, + password, + }); + + formIsLoading = false; + + if (response.ok) { + if (password) { + passwordFieldMessage = "Successfully updated"; + passwordFieldMessageClass = "color-success"; + password = ""; + } + if (username) { + usernameFieldMessage = "Successfully updated"; + usernameFieldMessageClass = "color-success"; + password = ""; + } + } else { + formError = response.data.title ?? "An unknown error occured"; + } + } + + async function handle_delete_account_button_click() { + if (understands && confirm("Are you absolutely sure that you want to delete your account?")) { + await delete_user(); + } + } + + export const functions = { + open() { + modal.open(); + }, + close() { + // modal.close(); + }, + }; +</script> + +<Modal title="Profile" + bind:functions={modal}> + <section class="margin-bottom-md"> + <p class="text-md margin-bottom-sm">Update your information</p> + <form on:submit={submit_form} + autocomplete="new-password"> + {#if formError} + <small class="color-danger">{formError}</small> + {/if} + <div class="margin-bottom-sm"> + <label for="email" + class="form-label margin-bottom-xxs">New username</label> + <input type="email" + class="form-control width-100%" + id="email" + placeholder={username} + bind:value={username}/> + {#if usernameFieldMessage} + <small class={usernameFieldMessageClass}>{usernameFieldMessage}</small> + {/if} + </div> + <div class="margin-bottom-sm"> + <label for="password" + class="form-label margin-bottom-xxs">New password</label> + <input type="password" + class="form-control width-100%" + id="password" + bind:value={password}/> + {#if passwordFieldMessage} + <small class={passwordFieldMessageClass}>{passwordFieldMessage}</small> + {/if} + </div> + <div class="flex justify-end"> + <Button text="Save" + on:click={submit_form} + variant="primary" + loading={formIsLoading}/> + </div> + </form> + </section> + <section class="margin-bottom-md"> + <p class="text-md margin-bottom-sm">Download your data</p> + <a class="btn btn--subtle" + href={archiveLink} + download>Click here to download your data</a> + </section> + <section> + <p class="text-md margin-bottom-sm">Delete account</p> + <div class="margin-bottom-sm"> + <Alert + message="Deleting your account and data means that all of your data (entries, categories, etc.) will be unrecoverable forever.<br>You should probably download your data before continuing." + type="info" + /> + </div> + <div class="form-check margin-bottom-sm"> + <input type="checkbox" + class="checkbox" + id="the-consequences" + bind:checked={understands}/> + <label for="the-consequences">I understand the consequences of deleting my account and data.</label> + </div> + <div class="flex justify-end"> + <Button text="Delete everything" + variant="accent" + disabled={!understands} + on:click={handle_delete_account_button_click}/> + </div> + </section> +</Modal> diff --git a/apps/projects/src/app/pages/views/settings-categories-tile.svelte b/apps/projects/src/app/pages/views/settings-categories-tile.svelte new file mode 100644 index 0000000..890609a --- /dev/null +++ b/apps/projects/src/app/pages/views/settings-categories-tile.svelte @@ -0,0 +1,127 @@ +<script> + import {IconNames} from "$shared/lib/configuration"; + import {onMount} from "svelte"; + import { + delete_time_category, + get_time_categories, + } from "$shared/lib/api/time-entry"; + import Button from "$shared/components/button.svelte"; + import Tile from "$shared/components/tile.svelte"; + import {Table, THead, TBody, TCell, TRow} from "$shared/components/table"; + + let is_loading = true; + let categories = []; + + $: active_categories = categories.filter(c => !c.archived); + $: archived_categories = categories.filter(c => c.archived); + + async function load_categories() { + is_loading = true; + const response = await get_time_categories(); + if (response.status === 200) { + categories = response.data; + } else if (response.status === 204) { + categories = []; + console.log("Empty response when getting time categories"); + } else { + categories = []; + console.error("Error when getting time categories"); + } + is_loading = false; + } + + async function handle_edit_category_click(event) { + } + + async function handle_delete_category_click(event) { + const row = event.target.closest("tr"); + if ( + row && + row.dataset.id && + confirm( + "Are you sure you want to delete this category?\nThis will delete all relating entries!" + ) + ) { + const response = await delete_time_category(row.dataset.id); + if (response.ok) { + // svelte errors if we remove the row. + row.classList.add("d-none"); + } + } + } + + onMount(() => { + load_categories(); + }); +</script> + +<Tile class="col-6@md col-12 {is_loading ? 'c-disabled loading' : ''}"> + <h2 class="margin-bottom-xxs">Categories</h2> + {#if active_categories.length > 0 && archived_categories.length > 0} + <nav class="s-tabs text-sm"> + <ul class="s-tabs__list"> + <li><a class="s-tabs__link s-tabs__link--current" + href="#0">Active ({active_categories.length})</a></li> + <li><a class="s-tabs__link" + href="#0">Archived ({archived_categories.length})</a></li> + </ul> + </nav> + {/if} + <div class="max-width-100% overflow-auto"> + <Table class="text-sm width-100%"> + <THead class="text-left"> + <TCell type="th" + thScope="col"> + Name + </TCell> + <TCell type="th" + thScope="col"> + Color + </TCell> + <TCell type="th" + thScope="col" + style="width:50px"></TCell> + </THead> + <TBody class="text-left"> + {#if categories.length > 0} + {#each categories as category} + <TRow class="text-nowrap" + data-id={category.id}> + <TCell> + {category.name} + </TCell> + <TCell> + <span style="border-left: 3px solid {category.color}; background-color:{category.color}25;"> + {category.color} + </span> + </TCell> + <TCell> + <Button icon="{IconNames.pencilSquare}" + variant="reset" + icon_width="1.2rem" + class="hide" + icon_height="1.2rem" + on:click={handle_edit_category_click} + title="Edit entry"/> + <Button icon="{IconNames.trash}" + variant="reset" + icon_width="1.2rem" + icon_height="1.2rem" + on:click={handle_delete_category_click} + title="Delete entry"/> + + </TCell> + </TRow> + {/each} + {:else} + <TRow> + <TCell type="th" + thScope="3"> + No categories + </TCell> + </TRow> + {/if} + </TBody> + </Table> + </div> +</Tile> diff --git a/apps/projects/src/app/pages/views/settings-labels-tile.svelte b/apps/projects/src/app/pages/views/settings-labels-tile.svelte new file mode 100644 index 0000000..f59e233 --- /dev/null +++ b/apps/projects/src/app/pages/views/settings-labels-tile.svelte @@ -0,0 +1,112 @@ +<script> + import {IconNames} from "$shared/lib/configuration"; + import {onMount} from "svelte"; + import labels, {reload_labels, delete_label_async} from "$app/lib/stores/labels"; + import Button from "$shared/components/button.svelte"; + import Tile from "$shared/components/tile.svelte"; + import {Table, THead, TBody, TCell, TRow} from "$shared/components/table"; + + let is_loading = true; + + $: active_labels = $labels.filter(c => !c.archived); + $: archived_labels = $labels.filter(c => c.archived); + + async function load_labels() { + is_loading = true; + await reload_labels(); + is_loading = false; + } + + async function handle_edit_label_click(event) { + } + + async function handle_delete_label_click(event) { + const row = event.target.closest("tr"); + if ( + row && + row.dataset.id && + confirm( + "Are you sure you want to delete this label?\nIt will be removed from all related entries!" + ) + ) { + await delete_label_async({id: row.dataset.id}); + row.classList.add("d-none"); + } + } + + onMount(() => { + load_labels(); + }); +</script> + +<Tile class="col-6@md col-12 {is_loading ? 'c-disabled loading' : ''}"> + <h2 class="margin-bottom-xxs">Labels</h2> + {#if active_labels.length > 0 && archived_labels.length > 0} + <nav class="s-tabs text-sm"> + <ul class="s-tabs__list"> + <li><a class="s-tabs__link s-tabs__link--current" + href="#0">Active ({active_labels.length})</a></li> + <li><a class="s-tabs__link" + href="#0">Archived ({archived_labels.length})</a></li> + </ul> + </nav> + {/if} + <div class="max-width-100% overflow-auto"> + <Table class="text-sm width-100%"> + <THead class="text-left"> + <TCell type="th" + thScope="row"> + Name + </TCell> + <TCell type="th" + thScope="row"> + Color + </TCell> + <TCell type="th" + thScope="row" + style="width: 50px;"> + </TCell> + </THead> + <TBody class="text-left"> + {#if $labels.length > 0} + {#each $labels as label} + <TRow class="text-nowrap" + dataId={label.id}> + <TCell> + {label.name} + </TCell> + <TCell> + <span style="border-left: 3px solid {label.color}; background-color:{label.color}25;"> + {label.color} + </span> + </TCell> + <TCell> + <Button icon="{IconNames.pencilSquare}" + variant="reset" + icon_width="1.2rem" + class="hide" + icon_height="1.2rem" + on:click={handle_edit_label_click} + title="Edit entry"/> + <Button icon="{IconNames.trash}" + variant="reset" + icon_width="1.2rem" + icon_height="1.2rem" + on:click={handle_delete_label_click} + title="Delete entry"/> + </TCell> + </TRow> + {/each} + {:else} + <TRow> + <TCell type="th" + thScope="row" + colspan="3"> + No labels + </TCell> + </TRow> + {/if} + </TBody> + </Table> + </div> +</Tile> diff --git a/apps/projects/src/index.html b/apps/projects/src/index.html new file mode 100644 index 0000000..985b62b --- /dev/null +++ b/apps/projects/src/index.html @@ -0,0 +1,63 @@ +<!doctype html> +<html lang="en"> + +<head> + <meta charset="UTF-8"> + <meta name="viewport" + content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"> + <link rel="apple-touch-icon" + sizes="180x180" + href="./_assets/pwa/apple-touch-icon.png"> + <link rel="icon" + type="image/png" + sizes="32x32" + href="./_assets/pwa/favicon-32x32.png"> + <link rel="icon" + type="image/png" + sizes="16x16" + href="./_assets/pwa/favicon-16x16.png"> + <link rel="manifest" + href="./_assets/pwa/manifest.json"> + <link rel="mask-icon" + href="./_assets/pwa/safari-pinned-tab.svg" + color="#5bbad5"> + <meta name="msapplication-TileColor" + content="#da532c"> + <link rel="icon" + href="./_assets/pwa/favicon.svg"> + <script> + const currentTheme = localStorage.getItem("theme"); + if (currentTheme === "light") { + document.querySelector("html").dataset.theme = "light"; + } else { + document.querySelector("html").dataset.theme = "dark"; + } + </script> + <link rel="stylesheet" + href="./_assets/pre.css"> + <title>Time Tracker</title> +</head> + +<body> + +<noscript> + This page is built with javascript. Allow it and try again. +</noscript> + +<div class="fill-loader fill-loader--v4" + id="loader" + role="alert"> + <p class="fill-loader__label">Loading Time Tracker...</p> + <div aria-hidden="true"> + <div class="fill-loader__base"></div> + <div class="fill-loader__fill"></div> + </div> +</div> + +<div id="root"></div> + +<script type="module" + src="./app/index.ts"></script> +</body> + +</html> diff --git a/apps/projects/src/package.json b/apps/projects/src/package.json new file mode 100644 index 0000000..8ff516d --- /dev/null +++ b/apps/projects/src/package.json @@ -0,0 +1,22 @@ +{ + "name": "time-tracker-public", + "version": "0.0.1", + "private": "true", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "1.0.0-next.43", + "sass": "^1.51.0", + "svelte": "^3.48.0", + "svelte-preprocess": "^4.10.6", + "svelte-spa-router": "^3.2.0", + "typescript": "4.6.4", + "vite": "^2.9.8" + }, + "dependencies": { + "@js-temporal/polyfill": "^0.4.1", + "fuzzysort": "^1.9.0" + } +} diff --git a/apps/projects/src/pnpm-lock.yaml b/apps/projects/src/pnpm-lock.yaml new file mode 100644 index 0000000..3b56115 --- /dev/null +++ b/apps/projects/src/pnpm-lock.yaml @@ -0,0 +1,769 @@ +lockfileVersion: 5.4 + +specifiers: + '@js-temporal/polyfill': ^0.4.1 + '@sveltejs/vite-plugin-svelte': 1.0.0-next.43 + fuzzysort: ^1.9.0 + sass: ^1.51.0 + svelte: ^3.48.0 + svelte-preprocess: ^4.10.6 + svelte-spa-router: ^3.2.0 + typescript: 4.6.4 + vite: ^2.9.8 + +dependencies: + '@js-temporal/polyfill': 0.4.1 + fuzzysort: 1.9.0 + +devDependencies: + '@sveltejs/vite-plugin-svelte': 1.0.0-next.43_svelte@3.48.0+vite@2.9.8 + sass: 1.51.0 + svelte: 3.48.0 + svelte-preprocess: 4.10.6_24ezlekk4ocevlsjgs2qnqmjum + svelte-spa-router: 3.2.0 + typescript: 4.6.4 + vite: 2.9.8_sass@1.51.0 + +packages: + + /@js-temporal/polyfill/0.4.1: + resolution: {integrity: sha512-q45ecIocpa2TLem2jNOsCrDwP/sgKZdSkt+C1Rx07OkdKsdbvVfHcD1iDiK9scxBZrBQ38uJ8VQISXBS70ql1w==} + engines: {node: '>=12'} + dependencies: + jsbi: 4.3.0 + tslib: 2.4.0 + dev: false + + /@rollup/pluginutils/4.2.1: + resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==} + engines: {node: '>= 8.0.0'} + dependencies: + estree-walker: 2.0.2 + picomatch: 2.3.1 + dev: true + + /@sveltejs/vite-plugin-svelte/1.0.0-next.43_svelte@3.48.0+vite@2.9.8: + resolution: {integrity: sha512-MzeczqGrnDmbAldw/LfXV/dhpLC2bdUzuMhcx0C2j79V2uNzQERHDinxXnG2AVTCTjSpbQxzQwMMmYflnI7W1g==} + engines: {node: ^14.13.1 || >= 16} + peerDependencies: + diff-match-patch: ^1.0.5 + svelte: ^3.44.0 + vite: ^2.9.0 + peerDependenciesMeta: + diff-match-patch: + optional: true + dependencies: + '@rollup/pluginutils': 4.2.1 + debug: 4.3.4 + deepmerge: 4.2.2 + kleur: 4.1.4 + magic-string: 0.26.1 + svelte: 3.48.0 + svelte-hmr: 0.14.11_svelte@3.48.0 + vite: 2.9.8_sass@1.51.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@types/node/17.0.31: + resolution: {integrity: sha512-AR0x5HbXGqkEx9CadRH3EBYx/VkiUgZIhP4wvPn/+5KIsgpNoyFaRlVe0Zlx9gRtg8fA06a9tskE2MSN7TcG4Q==} + dev: true + + /@types/pug/2.0.6: + resolution: {integrity: sha512-SnHmG9wN1UVmagJOnyo/qkk0Z7gejYxOYYmaAwr5u2yFYfsupN3sg10kyzN8Hep/2zbHxCnsumxOoRIRMBwKCg==} + dev: true + + /@types/sass/1.43.1: + resolution: {integrity: sha512-BPdoIt1lfJ6B7rw35ncdwBZrAssjcwzI5LByIrYs+tpXlj/CAkuVdRsgZDdP4lq5EjyWzwxZCqAoFyHKFwp32g==} + dependencies: + '@types/node': 17.0.31 + dev: true + + /anymatch/3.1.2: + resolution: {integrity: sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==} + engines: {node: '>= 8'} + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + dev: true + + /balanced-match/1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + dev: true + + /binary-extensions/2.2.0: + resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} + engines: {node: '>=8'} + dev: true + + /brace-expansion/1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + dev: true + + /braces/3.0.2: + resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} + engines: {node: '>=8'} + dependencies: + fill-range: 7.0.1 + dev: true + + /buffer-crc32/0.2.13: + resolution: {integrity: sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=} + dev: true + + /chokidar/3.5.3: + resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} + engines: {node: '>= 8.10.0'} + dependencies: + anymatch: 3.1.2 + braces: 3.0.2 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /concat-map/0.0.1: + resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} + dev: true + + /debug/4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.2 + dev: true + + /deepmerge/4.2.2: + resolution: {integrity: sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==} + engines: {node: '>=0.10.0'} + dev: true + + /detect-indent/6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + dev: true + + /es6-promise/3.3.1: + resolution: {integrity: sha1-oIzd6EzNvzTQJ6FFG8kdS80ophM=} + dev: true + + /esbuild-android-64/0.14.38: + resolution: {integrity: sha512-aRFxR3scRKkbmNuGAK+Gee3+yFxkTJO/cx83Dkyzo4CnQl/2zVSurtG6+G86EQIZ+w+VYngVyK7P3HyTBKu3nw==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /esbuild-android-arm64/0.14.38: + resolution: {integrity: sha512-L2NgQRWuHFI89IIZIlpAcINy9FvBk6xFVZ7xGdOwIm8VyhX1vNCEqUJO3DPSSy945Gzdg98cxtNt8Grv1CsyhA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /esbuild-darwin-64/0.14.38: + resolution: {integrity: sha512-5JJvgXkX87Pd1Og0u/NJuO7TSqAikAcQQ74gyJ87bqWRVeouky84ICoV4sN6VV53aTW+NE87qLdGY4QA2S7KNA==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /esbuild-darwin-arm64/0.14.38: + resolution: {integrity: sha512-eqF+OejMI3mC5Dlo9Kdq/Ilbki9sQBw3QlHW3wjLmsLh+quNfHmGMp3Ly1eWm981iGBMdbtSS9+LRvR2T8B3eQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /esbuild-freebsd-64/0.14.38: + resolution: {integrity: sha512-epnPbhZUt93xV5cgeY36ZxPXDsQeO55DppzsIgWM8vgiG/Rz+qYDLmh5ts3e+Ln1wA9dQ+nZmVHw+RjaW3I5Ig==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-freebsd-arm64/0.14.38: + resolution: {integrity: sha512-/9icXUYJWherhk+y5fjPI5yNUdFPtXHQlwP7/K/zg8t8lQdHVj20SqU9/udQmeUo5pDFHMYzcEFfJqgOVeKNNQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-32/0.14.38: + resolution: {integrity: sha512-QfgfeNHRFvr2XeHFzP8kOZVnal3QvST3A0cgq32ZrHjSMFTdgXhMhmWdKzRXP/PKcfv3e2OW9tT9PpcjNvaq6g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-64/0.14.38: + resolution: {integrity: sha512-uuZHNmqcs+Bj1qiW9k/HZU3FtIHmYiuxZ/6Aa+/KHb/pFKr7R3aVqvxlAudYI9Fw3St0VCPfv7QBpUITSmBR1Q==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-arm/0.14.38: + resolution: {integrity: sha512-FiFvQe8J3VKTDXG01JbvoVRXQ0x6UZwyrU4IaLBZeq39Bsbatd94Fuc3F1RGqPF5RbIWW7RvkVQjn79ejzysnA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-arm64/0.14.38: + resolution: {integrity: sha512-HlMGZTEsBrXrivr64eZ/EO0NQM8H8DuSENRok9d+Jtvq8hOLzrxfsAT9U94K3KOGk2XgCmkaI2KD8hX7F97lvA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-mips64le/0.14.38: + resolution: {integrity: sha512-qd1dLf2v7QBiI5wwfil9j0HG/5YMFBAmMVmdeokbNAMbcg49p25t6IlJFXAeLzogv1AvgaXRXvgFNhScYEUXGQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-ppc64le/0.14.38: + resolution: {integrity: sha512-mnbEm7o69gTl60jSuK+nn+pRsRHGtDPfzhrqEUXyCl7CTOCLtWN2bhK8bgsdp6J/2NyS/wHBjs1x8aBWwP2X9Q==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-riscv64/0.14.38: + resolution: {integrity: sha512-+p6YKYbuV72uikChRk14FSyNJZ4WfYkffj6Af0/Tw63/6TJX6TnIKE+6D3xtEc7DeDth1fjUOEqm+ApKFXbbVQ==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-linux-s390x/0.14.38: + resolution: {integrity: sha512-0zUsiDkGJiMHxBQ7JDU8jbaanUY975CdOW1YDrurjrM0vWHfjv9tLQsW9GSyEb/heSK1L5gaweRjzfUVBFoybQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /esbuild-netbsd-64/0.14.38: + resolution: {integrity: sha512-cljBAApVwkpnJZfnRVThpRBGzCi+a+V9Ofb1fVkKhtrPLDYlHLrSYGtmnoTVWDQdU516qYI8+wOgcGZ4XIZh0Q==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-openbsd-64/0.14.38: + resolution: {integrity: sha512-CDswYr2PWPGEPpLDUO50mL3WO/07EMjnZDNKpmaxUPsrW+kVM3LoAqr/CE8UbzugpEiflYqJsGPLirThRB18IQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /esbuild-sunos-64/0.14.38: + resolution: {integrity: sha512-2mfIoYW58gKcC3bck0j7lD3RZkqYA7MmujFYmSn9l6TiIcAMpuEvqksO+ntBgbLep/eyjpgdplF7b+4T9VJGOA==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + + /esbuild-windows-32/0.14.38: + resolution: {integrity: sha512-L2BmEeFZATAvU+FJzJiRLFUP+d9RHN+QXpgaOrs2klshoAm1AE6Us4X6fS9k33Uy5SzScn2TpcgecbqJza1Hjw==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /esbuild-windows-64/0.14.38: + resolution: {integrity: sha512-Khy4wVmebnzue8aeSXLC+6clo/hRYeNIm0DyikoEqX+3w3rcvrhzpoix0S+MF9vzh6JFskkIGD7Zx47ODJNyCw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /esbuild-windows-arm64/0.14.38: + resolution: {integrity: sha512-k3FGCNmHBkqdJXuJszdWciAH77PukEyDsdIryEHn9cKLQFxzhT39dSumeTuggaQcXY57UlmLGIkklWZo2qzHpw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /esbuild/0.14.38: + resolution: {integrity: sha512-12fzJ0fsm7gVZX1YQ1InkOE5f9Tl7cgf6JPYXRJtPIoE0zkWAbHdPHVPPaLi9tYAcEBqheGzqLn/3RdTOyBfcA==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + esbuild-android-64: 0.14.38 + esbuild-android-arm64: 0.14.38 + esbuild-darwin-64: 0.14.38 + esbuild-darwin-arm64: 0.14.38 + esbuild-freebsd-64: 0.14.38 + esbuild-freebsd-arm64: 0.14.38 + esbuild-linux-32: 0.14.38 + esbuild-linux-64: 0.14.38 + esbuild-linux-arm: 0.14.38 + esbuild-linux-arm64: 0.14.38 + esbuild-linux-mips64le: 0.14.38 + esbuild-linux-ppc64le: 0.14.38 + esbuild-linux-riscv64: 0.14.38 + esbuild-linux-s390x: 0.14.38 + esbuild-netbsd-64: 0.14.38 + esbuild-openbsd-64: 0.14.38 + esbuild-sunos-64: 0.14.38 + esbuild-windows-32: 0.14.38 + esbuild-windows-64: 0.14.38 + esbuild-windows-arm64: 0.14.38 + dev: true + + /estree-walker/2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + dev: true + + /fill-range/7.0.1: + resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} + engines: {node: '>=8'} + dependencies: + to-regex-range: 5.0.1 + dev: true + + /fs.realpath/1.0.0: + resolution: {integrity: sha1-FQStJSMVjKpA20onh8sBQRmU6k8=} + dev: true + + /fsevents/2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /function-bind/1.1.1: + resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==} + dev: true + + /fuzzysort/1.9.0: + resolution: {integrity: sha512-MOxCT0qLTwLqmEwc7UtU045RKef7mc8Qz8eR4r2bLNEq9dy/c3ZKMEFp6IEst69otkQdFZ4FfgH2dmZD+ddX1g==} + dev: false + + /glob-parent/5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + dependencies: + is-glob: 4.0.3 + dev: true + + /glob/7.2.0: + resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==} + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + dev: true + + /graceful-fs/4.2.10: + resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==} + dev: true + + /has/1.0.3: + resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==} + engines: {node: '>= 0.4.0'} + dependencies: + function-bind: 1.1.1 + dev: true + + /immutable/4.0.0: + resolution: {integrity: sha512-zIE9hX70qew5qTUjSS7wi1iwj/l7+m54KWU247nhM3v806UdGj1yDndXj+IOYxxtW9zyLI+xqFNZjTuDaLUqFw==} + dev: true + + /inflight/1.0.6: + resolution: {integrity: sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=} + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + dev: true + + /inherits/2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + dev: true + + /is-binary-path/2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + dependencies: + binary-extensions: 2.2.0 + dev: true + + /is-core-module/2.9.0: + resolution: {integrity: sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==} + dependencies: + has: 1.0.3 + dev: true + + /is-extglob/2.1.1: + resolution: {integrity: sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=} + engines: {node: '>=0.10.0'} + dev: true + + /is-glob/4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + dependencies: + is-extglob: 2.1.1 + dev: true + + /is-number/7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + dev: true + + /jsbi/4.3.0: + resolution: {integrity: sha512-SnZNcinB4RIcnEyZqFPdGPVgrg2AcnykiBy0sHVJQKHYeaLUvi3Exj+iaPpLnFVkDPZIV4U0yvgC9/R4uEAZ9g==} + dev: false + + /kleur/4.1.4: + resolution: {integrity: sha512-8QADVssbrFjivHWQU7KkMgptGTl6WAcSdlbBPY4uNF+mWr6DGcKrvY2w4FQJoXch7+fKMjj0dRrL75vk3k23OA==} + engines: {node: '>=6'} + dev: true + + /magic-string/0.25.9: + resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} + dependencies: + sourcemap-codec: 1.4.8 + dev: true + + /magic-string/0.26.1: + resolution: {integrity: sha512-ndThHmvgtieXe8J/VGPjG+Apu7v7ItcD5mhEIvOscWjPF/ccOiLxHaSuCAS2G+3x4GKsAbT8u7zdyamupui8Tg==} + engines: {node: '>=12'} + dependencies: + sourcemap-codec: 1.4.8 + dev: true + + /min-indent/1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + dev: true + + /minimatch/3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + dependencies: + brace-expansion: 1.1.11 + dev: true + + /minimist/1.2.6: + resolution: {integrity: sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==} + dev: true + + /mkdirp/0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + dependencies: + minimist: 1.2.6 + dev: true + + /ms/2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + dev: true + + /nanoid/3.3.4: + resolution: {integrity: sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + dev: true + + /normalize-path/3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + dev: true + + /once/1.4.0: + resolution: {integrity: sha1-WDsap3WWHUsROsF9nFC6753Xa9E=} + dependencies: + wrappy: 1.0.2 + dev: true + + /path-is-absolute/1.0.1: + resolution: {integrity: sha1-F0uSaHNVNP+8es5r9TpanhtcX18=} + engines: {node: '>=0.10.0'} + dev: true + + /path-parse/1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + dev: true + + /picocolors/1.0.0: + resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + dev: true + + /picomatch/2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + dev: true + + /postcss/8.4.13: + resolution: {integrity: sha512-jtL6eTBrza5MPzy8oJLFuUscHDXTV5KcLlqAWHl5q5WYRfnNRGSmOZmOZ1T6Gy7A99mOZfqungmZMpMmCVJ8ZA==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.4 + picocolors: 1.0.0 + source-map-js: 1.0.2 + dev: true + + /readdirp/3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + dependencies: + picomatch: 2.3.1 + dev: true + + /regexparam/2.0.0: + resolution: {integrity: sha512-gJKwd2MVPWHAIFLsaYDZfyKzHNS4o7E/v8YmNf44vmeV2e4YfVoDToTOKTvE7ab68cRJ++kLuEXJBaEeJVt5ow==} + engines: {node: '>=8'} + dev: true + + /resolve/1.22.0: + resolution: {integrity: sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==} + hasBin: true + dependencies: + is-core-module: 2.9.0 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + dev: true + + /rimraf/2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + hasBin: true + dependencies: + glob: 7.2.0 + dev: true + + /rollup/2.72.1: + resolution: {integrity: sha512-NTc5UGy/NWFGpSqF1lFY8z9Adri6uhyMLI6LvPAXdBKoPRFhIIiBUpt+Qg2awixqO3xvzSijjhnb4+QEZwJmxA==} + engines: {node: '>=10.0.0'} + hasBin: true + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /sander/0.5.1: + resolution: {integrity: sha1-dB4kXiMfB8r7b98PEzrfohalAq0=} + dependencies: + es6-promise: 3.3.1 + graceful-fs: 4.2.10 + mkdirp: 0.5.6 + rimraf: 2.7.1 + dev: true + + /sass/1.51.0: + resolution: {integrity: sha512-haGdpTgywJTvHC2b91GSq+clTKGbtkkZmVAb82jZQN/wTy6qs8DdFm2lhEQbEwrY0QDRgSQ3xDurqM977C3noA==} + engines: {node: '>=12.0.0'} + hasBin: true + dependencies: + chokidar: 3.5.3 + immutable: 4.0.0 + source-map-js: 1.0.2 + dev: true + + /sorcery/0.10.0: + resolution: {integrity: sha1-iukK19fLBfxZ8asMY3hF1cFaUrc=} + hasBin: true + dependencies: + buffer-crc32: 0.2.13 + minimist: 1.2.6 + sander: 0.5.1 + sourcemap-codec: 1.4.8 + dev: true + + /source-map-js/1.0.2: + resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} + engines: {node: '>=0.10.0'} + dev: true + + /sourcemap-codec/1.4.8: + resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} + dev: true + + /strip-indent/3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + dependencies: + min-indent: 1.0.1 + dev: true + + /supports-preserve-symlinks-flag/1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + dev: true + + /svelte-hmr/0.14.11_svelte@3.48.0: + resolution: {integrity: sha512-R9CVfX6DXxW1Kn45Jtmx+yUe+sPhrbYSUp7TkzbW0jI5fVPn6lsNG9NEs5dFg5qRhFNAoVdRw5qQDLALNKhwbQ==} + engines: {node: ^12.20 || ^14.13.1 || >= 16} + peerDependencies: + svelte: '>=3.19.0' + dependencies: + svelte: 3.48.0 + dev: true + + /svelte-preprocess/4.10.6_24ezlekk4ocevlsjgs2qnqmjum: + resolution: {integrity: sha512-I2SV1w/AveMvgIQlUF/ZOO3PYVnhxfcpNyGt8pxpUVhPfyfL/CZBkkw/KPfuFix5FJ9TnnNYMhACK3DtSaYVVQ==} + engines: {node: '>= 9.11.2'} + requiresBuild: true + peerDependencies: + '@babel/core': ^7.10.2 + coffeescript: ^2.5.1 + less: ^3.11.3 || ^4.0.0 + node-sass: '*' + postcss: ^7 || ^8 + postcss-load-config: ^2.1.0 || ^3.0.0 + pug: ^3.0.0 + sass: ^1.26.8 + stylus: ^0.55.0 + sugarss: ^2.0.0 + svelte: ^3.23.0 + typescript: ^3.9.5 || ^4.0.0 + peerDependenciesMeta: + '@babel/core': + optional: true + coffeescript: + optional: true + less: + optional: true + node-sass: + optional: true + postcss: + optional: true + postcss-load-config: + optional: true + pug: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + typescript: + optional: true + dependencies: + '@types/pug': 2.0.6 + '@types/sass': 1.43.1 + detect-indent: 6.1.0 + magic-string: 0.25.9 + sass: 1.51.0 + sorcery: 0.10.0 + strip-indent: 3.0.0 + svelte: 3.48.0 + typescript: 4.6.4 + dev: true + + /svelte-spa-router/3.2.0: + resolution: {integrity: sha512-igemo5Vs82TGBBw+DjWt6qKameXYzNs6aDXcTxou5XbEvOjiRcAM6MLkdVRCatn6u8r42dE99bt/br7T4qe/AQ==} + dependencies: + regexparam: 2.0.0 + dev: true + + /svelte/3.48.0: + resolution: {integrity: sha512-fN2YRm/bGumvjUpu6yI3BpvZnpIm9I6A7HR4oUNYd7ggYyIwSA/BX7DJ+UXXffLp6XNcUijyLvttbPVCYa/3xQ==} + engines: {node: '>= 8'} + dev: true + + /to-regex-range/5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + dependencies: + is-number: 7.0.0 + dev: true + + /tslib/2.4.0: + resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==} + dev: false + + /typescript/4.6.4: + resolution: {integrity: sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==} + engines: {node: '>=4.2.0'} + hasBin: true + dev: true + + /vite/2.9.8_sass@1.51.0: + resolution: {integrity: sha512-zsBGwn5UT3YS0NLSJ7hnR54+vUKfgzMUh/Z9CxF1YKEBVIe213+63jrFLmZphgGI5zXwQCSmqIdbPuE8NJywPw==} + engines: {node: '>=12.2.0'} + hasBin: true + peerDependencies: + less: '*' + sass: '*' + stylus: '*' + peerDependenciesMeta: + less: + optional: true + sass: + optional: true + stylus: + optional: true + dependencies: + esbuild: 0.14.38 + postcss: 8.4.13 + resolve: 1.22.0 + rollup: 2.72.1 + sass: 1.51.0 + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /wrappy/1.0.2: + resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=} + dev: true diff --git a/apps/projects/src/tsconfig.json b/apps/projects/src/tsconfig.json new file mode 100644 index 0000000..c60fce6 --- /dev/null +++ b/apps/projects/src/tsconfig.json @@ -0,0 +1,27 @@ +{ + "include": [ + "./**/*.d.ts", + "./**/*.ts", + "./**/*.js", + "./**/*.svelte" + ], + "exclude": [ + "./node_modules" + ], + "compilerOptions": { + "target": "esnext", + "useDefineForClassFields": true, + "module": "esnext", + "moduleResolution": "node", + "allowJs": true, + "checkJs": false, + "paths": { + "$app/*": [ + "./app/*" + ], + "$shared/*": [ + "../../web-shared/src/*" + ] + } + } +} diff --git a/apps/projects/src/vite.config.ts b/apps/projects/src/vite.config.ts new file mode 100644 index 0000000..ac44266 --- /dev/null +++ b/apps/projects/src/vite.config.ts @@ -0,0 +1,31 @@ +import { defineConfig } from "vite"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; +import sveltePreprocess from "svelte-preprocess"; +// @ts-ignore +import path from "path"; + +// https://vitejs.dev/config/ +export default defineConfig({ + resolve: { + alias: { + "$shared": path.resolve(__dirname, "../../web-shared/src"), + "$app": path.resolve(__dirname, "./app"), + "$public": path.resolve(__dirname, "./_public"), + } + }, + build: { + outDir: "build", + emptyOutDir: true, + rollupOptions: { + input: { + main: path.resolve(__dirname, "index.html"), + } + } + }, + + plugins: [ + svelte({ + preprocess: sveltePreprocess() + }) + ], +}); |
