summaryrefslogtreecommitdiffstats
path: root/apps/projects-web/src/app/pages
diff options
context:
space:
mode:
Diffstat (limited to 'apps/projects-web/src/app/pages')
-rw-r--r--apps/projects-web/src/app/pages/_layout.svelte79
-rw-r--r--apps/projects-web/src/app/pages/data.svelte392
-rw-r--r--apps/projects-web/src/app/pages/home.svelte167
-rw-r--r--apps/projects-web/src/app/pages/not-found.svelte24
-rw-r--r--apps/projects-web/src/app/pages/settings.svelte12
-rw-r--r--apps/projects-web/src/app/pages/ui-workbench.svelte48
-rw-r--r--apps/projects-web/src/app/pages/views/category-form/index.svelte144
-rw-r--r--apps/projects-web/src/app/pages/views/data-table-paginator.svelte107
-rw-r--r--apps/projects-web/src/app/pages/views/entry-form/index.svelte196
-rw-r--r--apps/projects-web/src/app/pages/views/entry-form/sections/category.svelte75
-rw-r--r--apps/projects-web/src/app/pages/views/entry-form/sections/date-time.svelte165
-rw-r--r--apps/projects-web/src/app/pages/views/entry-form/sections/labels.svelte65
-rw-r--r--apps/projects-web/src/app/pages/views/profile-modal.svelte156
-rw-r--r--apps/projects-web/src/app/pages/views/settings-categories-tile.svelte127
-rw-r--r--apps/projects-web/src/app/pages/views/settings-labels-tile.svelte112
15 files changed, 1869 insertions, 0 deletions
diff --git a/apps/projects-web/src/app/pages/_layout.svelte b/apps/projects-web/src/app/pages/_layout.svelte
new file mode 100644
index 0000000..24a9370
--- /dev/null
+++ b/apps/projects-web/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-web/src/app/pages/data.svelte b/apps/projects-web/src/app/pages/data.svelte
new file mode 100644
index 0000000..070b98b
--- /dev/null
+++ b/apps/projects-web/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-web/src/app/pages/home.svelte b/apps/projects-web/src/app/pages/home.svelte
new file mode 100644
index 0000000..c3e7af4
--- /dev/null
+++ b/apps/projects-web/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-web/src/app/pages/not-found.svelte b/apps/projects-web/src/app/pages/not-found.svelte
new file mode 100644
index 0000000..46d0d1d
--- /dev/null
+++ b/apps/projects-web/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-web/src/app/pages/settings.svelte b/apps/projects-web/src/app/pages/settings.svelte
new file mode 100644
index 0000000..ca9fd47
--- /dev/null
+++ b/apps/projects-web/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-web/src/app/pages/ui-workbench.svelte b/apps/projects-web/src/app/pages/ui-workbench.svelte
new file mode 100644
index 0000000..5e92c9d
--- /dev/null
+++ b/apps/projects-web/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-web/src/app/pages/views/category-form/index.svelte b/apps/projects-web/src/app/pages/views/category-form/index.svelte
new file mode 100644
index 0000000..e8c0f94
--- /dev/null
+++ b/apps/projects-web/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-web/src/app/pages/views/data-table-paginator.svelte b/apps/projects-web/src/app/pages/views/data-table-paginator.svelte
new file mode 100644
index 0000000..7696ca2
--- /dev/null
+++ b/apps/projects-web/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-web/src/app/pages/views/entry-form/index.svelte b/apps/projects-web/src/app/pages/views/entry-form/index.svelte
new file mode 100644
index 0000000..cb974ed
--- /dev/null
+++ b/apps/projects-web/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-web/src/app/pages/views/entry-form/sections/category.svelte b/apps/projects-web/src/app/pages/views/entry-form/sections/category.svelte
new file mode 100644
index 0000000..f98c045
--- /dev/null
+++ b/apps/projects-web/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-web/src/app/pages/views/entry-form/sections/date-time.svelte b/apps/projects-web/src/app/pages/views/entry-form/sections/date-time.svelte
new file mode 100644
index 0000000..c91e014
--- /dev/null
+++ b/apps/projects-web/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-web/src/app/pages/views/entry-form/sections/labels.svelte b/apps/projects-web/src/app/pages/views/entry-form/sections/labels.svelte
new file mode 100644
index 0000000..06c703d
--- /dev/null
+++ b/apps/projects-web/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-web/src/app/pages/views/profile-modal.svelte b/apps/projects-web/src/app/pages/views/profile-modal.svelte
new file mode 100644
index 0000000..839b59d
--- /dev/null
+++ b/apps/projects-web/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-web/src/app/pages/views/settings-categories-tile.svelte b/apps/projects-web/src/app/pages/views/settings-categories-tile.svelte
new file mode 100644
index 0000000..890609a
--- /dev/null
+++ b/apps/projects-web/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-web/src/app/pages/views/settings-labels-tile.svelte b/apps/projects-web/src/app/pages/views/settings-labels-tile.svelte
new file mode 100644
index 0000000..f59e233
--- /dev/null
+++ b/apps/projects-web/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>