diff options
Diffstat (limited to 'old-apps/projects/src/app')
44 files changed, 5065 insertions, 0 deletions
diff --git a/old-apps/projects/src/app/index.d.ts b/old-apps/projects/src/app/index.d.ts new file mode 100644 index 0000000..c044583 --- /dev/null +++ b/old-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/old-apps/projects/src/app/index.html b/old-apps/projects/src/app/index.html new file mode 100644 index 0000000..7e0b0e1 --- /dev/null +++ b/old-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/old-apps/projects/src/app/index.scss b/old-apps/projects/src/app/index.scss new file mode 100644 index 0000000..f83b1a1 --- /dev/null +++ b/old-apps/projects/src/app/index.scss @@ -0,0 +1,40 @@ +@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'; +@use '../../web-shared/src/styles/components/light-dark-switch'; +@use '../../web-shared/src/styles/components/side-navigation-v4'; diff --git a/old-apps/projects/src/app/index.svelte b/old-apps/projects/src/app/index.svelte new file mode 100644 index 0000000..c121a32 --- /dev/null +++ b/old-apps/projects/src/app/index.svelte @@ -0,0 +1,96 @@ +<svelte:options immutable={true}/> +<svelte:window bind:online={online}/> + +<script lang="ts"> + import { Locales } from "$app/lib/i18n/i18n-types"; + import { logout_user } from "$app/lib/services/user-service"; + import { currentLocale, preffered_or_default } from "$shared/lib/locale"; + import { CookieNames } from "$shared/lib/configuration"; + import { get_cookie } from "$shared/lib/helpers"; + import { Temporal } from "@js-temporal/polyfill"; + import { onMount } from "svelte"; + import Router from "svelte-spa-router"; + import { wrap } from "svelte-spa-router/wrap"; + import { QueryClient, QueryClientProvider } from "@sveltestack/svelte-query"; + 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"; + import { setLocale } from "$app/lib/i18n/i18n-svelte"; + import { loadLocaleAsync } from "$app/lib/i18n/i18n-util.async"; + import { i18nObject } from "$app/lib/i18n/i18n-util"; + + let online = true; + let notOnlineText; + let LL; + + console.log("Projects Startup Report", { + prefferedLocale: navigator.language, + timeZone: Temporal.Now.timeZone().id, + themeCookie: {name: CookieNames.theme, value: get_cookie(CookieNames.theme)}, + localeCookie: {name: CookieNames.locale, value: get_cookie(CookieNames.locale)}, + prefersColorScheme: window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" + }); + + currentLocale.subscribe(async locale => { + if (locale === "preffered") locale = preffered_or_default(); + await loadLocaleAsync(locale as Locales); + LL = i18nObject(locale as Locales); + setLocale(locale as Locales); + }); + + onMount(async () => { + await loadLocaleAsync($currentLocale); + LL = i18nObject($currentLocale); + setLocale($currentLocale); + notOnlineText = LL.messages.noInternet(); + }); + + async function user_is_logged_in() { + if (!await is_active()) { + await logout_user("expired"); + } + return true; + } + + const queryClient = new QueryClient(); + + 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}">{notOnlineText}</PreHeader> + +<QueryClientProvider client={queryClient}> + <Router + {routes} + restoreScrollState={true} + on:routeLoading={() => { + document.getElementById("loader").style.display = "inline-block"; + }} + on:routeLoaded={() => { + document.getElementById("loader").style.display = "none"; + }} + /> +</QueryClientProvider> diff --git a/old-apps/projects/src/app/index.ts b/old-apps/projects/src/app/index.ts new file mode 100644 index 0000000..febb583 --- /dev/null +++ b/old-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/old-apps/projects/src/app/lib/i18n/en/index.ts b/old-apps/projects/src/app/lib/i18n/en/index.ts new file mode 100644 index 0000000..a85af7b --- /dev/null +++ b/old-apps/projects/src/app/lib/i18n/en/index.ts @@ -0,0 +1,126 @@ +import type {BaseTranslation} from "../i18n-types"; + +const en: BaseTranslation = { + nav: { + home: "Home", + data: "Data", + settings: "Settings", + usermenu: { + logout: "Log out", + logoutTitle: "Log out of your profile", + profile: "Profile", + profileTitle: "Administrate your profile", + toggleTitle: "Toggle user menu", + } + }, + views: { + dataTablePaginator: { + goToPrevPage: "Go to previous page", + goToNextPage: "Go to next page", + of: "of", + }, + categoryForm: { + name: "Name", + color: "Color", + defaultLabels: "Default labels", + labelsPlaceholder: "Search or create" + }, + settingsCategoriesTile: { + deleteAllConfirm: "Are you sure you want to delete this category?\nThis will delete all relating entries!", + active: "Active", + archived: "Archived", + name: "Name", + color: "Color", + editEntry: "Edit entry", + deleteEntry: "Delete entry", + noCategories: "No categories", + categories: "Categories" + }, + settingsLabelsTile: { + deleteAllConfirm: "Are you sure you want to delete this label?\nIt will be removed from all related entries!", + active: "Active", + archived: "Archived", + name: "Name", + color: "Color", + editEntry: "Edit label", + deleteEntry: "Delete label", + noLabels: "No labels", + labels: "Labels" + }, + entryForm: { + entryUpdateError: "An error occured while updating the entry, try again soon.", + entryCreateError: "An error occured while creating the entry, try again soon.", + errDescriptionReq: "Description is required", + reset: "Reset", + description: "Description", + save: "Save", + create: "Create", + category: { + category: "Category", + placeholder: "Search or create", + noResults: "No categories available (Create a new one by searching for it)", + errisRequired: "Category is required", + _logReset: "Reset category section" + }, + labels: { + placeholder: "Search or create", + noResults: "No labels available (Create a new one by searching for it)", + labels: "Labels", + _logReset: "Reset labels section" + }, + dateTime: { + errDateIsRequired: "Date is required", + errFromIsRequired: "From is required", + errFromAfterTo: "From can not be after To", + errFromEqTo: "From and To can not be equal", + errToIsRequired: "To is required", + errToBeforeFrom: "To can not be before From", + from: "From", + to: "To", + date: "Date", + _logReset: "Reset date time section" + } + } + }, + data: { + durationSummary: "Showing {entryCountString:string}, totalling in {totalHourMin:string}", + hourSingleChar: "h", + minSingleChar: "m", + entry: "entry", + entries: "entries", + confirmDeleteEntry: "Are you sure you want to delete this entry?", + editEntry: "Edit entry", + date: "Date", + from: "From", + duration: "Duration", + category: "Category", + description: "Description", + loading: "Loading", + noEntries: "No entries", + to: "to", + use: "Use", + }, + home: { + confirmDeleteEntry: "Are you sure you want to delete this entry?", + newEntry: "New entry", + editEntry: "Edit entry", + deleteEntry: "Delete entry", + loggedTimeToday: "Logged time today", + loggedTimeTodayString: "{hours}h{minutes}m", + currentTime: "Current time", + loading: "Loading", + stopwatch: "Stopwatch", + todayEntries: "Today's entries", + noEntriesToday: "No entries today", + refreshTodayEntries: "Refresh today's entries", + category: "Category", + timespan: "Timespan", + }, + messages: { + pageNotFound: "Page not found", + goToFrontpage: "Go to frontpage", + noInternet: "It seems like your device does not have a internet connection, please check your connection." + } +}; + +export default en; diff --git a/old-apps/projects/src/app/lib/i18n/formatters.ts b/old-apps/projects/src/app/lib/i18n/formatters.ts new file mode 100644 index 0000000..78734f9 --- /dev/null +++ b/old-apps/projects/src/app/lib/i18n/formatters.ts @@ -0,0 +1,11 @@ +import type { FormattersInitializer } from 'typesafe-i18n' +import type { Locales, Formatters } from './i18n-types' + +export const initFormatters: FormattersInitializer<Locales, Formatters> = (locale: Locales) => { + + const formatters: Formatters = { + // add your formatter functions here + } + + return formatters +} diff --git a/old-apps/projects/src/app/lib/i18n/i18n-svelte.ts b/old-apps/projects/src/app/lib/i18n/i18n-svelte.ts new file mode 100644 index 0000000..6cdffb3 --- /dev/null +++ b/old-apps/projects/src/app/lib/i18n/i18n-svelte.ts @@ -0,0 +1,12 @@ +// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. +/* eslint-disable */ + +import { initI18nSvelte } from 'typesafe-i18n/svelte' +import type { Formatters, Locales, TranslationFunctions, Translations } from './i18n-types' +import { loadedFormatters, loadedLocales } from './i18n-util' + +const { locale, LL, setLocale } = initI18nSvelte<Locales, Translations, TranslationFunctions, Formatters>(loadedLocales, loadedFormatters) + +export { locale, LL, setLocale } + +export default LL diff --git a/old-apps/projects/src/app/lib/i18n/i18n-types.ts b/old-apps/projects/src/app/lib/i18n/i18n-types.ts new file mode 100644 index 0000000..acba223 --- /dev/null +++ b/old-apps/projects/src/app/lib/i18n/i18n-types.ts @@ -0,0 +1,822 @@ +// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. +/* eslint-disable */ +import type { BaseTranslation as BaseTranslationType, LocalizedString, RequiredParams } from 'typesafe-i18n' + +export type BaseTranslation = BaseTranslationType +export type BaseLocale = 'en' + +export type Locales = + | 'en' + | 'nb' + +export type Translation = RootTranslation + +export type Translations = RootTranslation + +type RootTranslation = { + nav: { + /** + * Home + */ + home: string + /** + * Data + */ + data: string + /** + * Settings + */ + settings: string + usermenu: { + /** + * Log out + */ + logout: string + /** + * Log out of your profile + */ + logoutTitle: string + /** + * Profile + */ + profile: string + /** + * Administrate your profile + */ + profileTitle: string + /** + * Toggle user menu + */ + toggleTitle: string + } + } + views: { + dataTablePaginator: { + /** + * Go to previous page + */ + goToPrevPage: string + /** + * Go to next page + */ + goToNextPage: string + /** + * of + */ + of: string + } + categoryForm: { + /** + * Name + */ + name: string + /** + * Color + */ + color: string + /** + * Default labels + */ + defaultLabels: string + /** + * Search or create + */ + labelsPlaceholder: string + } + settingsCategoriesTile: { + /** + * Are you sure you want to delete this category? + This will delete all relating entries! + */ + deleteAllConfirm: string + /** + * Active + */ + active: string + /** + * Archived + */ + archived: string + /** + * Name + */ + name: string + /** + * Color + */ + color: string + /** + * Edit entry + */ + editEntry: string + /** + * Delete entry + */ + deleteEntry: string + /** + * No categories + */ + noCategories: string + /** + * Categories + */ + categories: string + } + settingsLabelsTile: { + /** + * Are you sure you want to delete this label? + It will be removed from all related entries! + */ + deleteAllConfirm: string + /** + * Active + */ + active: string + /** + * Archived + */ + archived: string + /** + * Name + */ + name: string + /** + * Color + */ + color: string + /** + * Edit label + */ + editEntry: string + /** + * Delete label + */ + deleteEntry: string + /** + * No labels + */ + noLabels: string + /** + * Labels + */ + labels: string + } + entryForm: { + /** + * An error occured while updating the entry, try again soon. + */ + entryUpdateError: string + /** + * An error occured while creating the entry, try again soon. + */ + entryCreateError: string + /** + * Description is required + */ + errDescriptionReq: string + /** + * Reset + */ + reset: string + /** + * Description + */ + description: string + /** + * Save + */ + save: string + /** + * Create + */ + create: string + category: { + /** + * Category + */ + category: string + /** + * Search or create + */ + placeholder: string + /** + * No categories available (Create a new one by searching for it) + */ + noResults: string + /** + * Category is required + */ + errisRequired: string + /** + * Reset category section + */ + _logReset: string + } + labels: { + /** + * Search or create + */ + placeholder: string + /** + * No labels available (Create a new one by searching for it) + */ + noResults: string + /** + * Labels + */ + labels: string + /** + * Reset labels section + */ + _logReset: string + } + dateTime: { + /** + * Date is required + */ + errDateIsRequired: string + /** + * From is required + */ + errFromIsRequired: string + /** + * From can not be after To + */ + errFromAfterTo: string + /** + * From and To can not be equal + */ + errFromEqTo: string + /** + * To is required + */ + errToIsRequired: string + /** + * To can not be before From + */ + errToBeforeFrom: string + /** + * From + */ + from: string + /** + * To + */ + to: string + /** + * Date + */ + date: string + /** + * Reset date time section + */ + _logReset: string + } + } + } + data: { + /** + * Showing {entryCountString}, totalling in {totalHourMin} + * @param {string} entryCountString + * @param {string} totalHourMin + */ + durationSummary: RequiredParams<'entryCountString' | 'totalHourMin'> + /** + * h + */ + hourSingleChar: string + /** + * m + */ + minSingleChar: string + /** + * entry + */ + entry: string + /** + * entries + */ + entries: string + /** + * Are you sure you want to delete this entry? + */ + confirmDeleteEntry: string + /** + * Edit entry + */ + editEntry: string + /** + * Date + */ + date: string + /** + * From + */ + from: string + /** + * Duration + */ + duration: string + /** + * Category + */ + category: string + /** + * Description + */ + description: string + /** + * Loading + */ + loading: string + /** + * No entries + */ + noEntries: string + /** + * to + */ + to: string + /** + * Use + */ + use: string + } + home: { + /** + * Are you sure you want to delete this entry? + */ + confirmDeleteEntry: string + /** + * New entry + */ + newEntry: string + /** + * Edit entry + */ + editEntry: string + /** + * Delete entry + */ + deleteEntry: string + /** + * Logged time today + */ + loggedTimeToday: string + /** + * {hours}h{minutes}m + * @param {unknown} hours + * @param {unknown} minutes + */ + loggedTimeTodayString: RequiredParams<'hours' | 'minutes'> + /** + * Current time + */ + currentTime: string + /** + * Loading + */ + loading: string + /** + * Stopwatch + */ + stopwatch: string + /** + * Today's entries + */ + todayEntries: string + /** + * No entries today + */ + noEntriesToday: string + /** + * Refresh today's entries + */ + refreshTodayEntries: string + /** + * Category + */ + category: string + /** + * Timespan + */ + timespan: string + } + messages: { + /** + * Page not found + */ + pageNotFound: string + /** + * Go to frontpage + */ + goToFrontpage: string + /** + * It seems like your device does not have a internet connection, please check your connection. + */ + noInternet: string + } +} + +export type TranslationFunctions = { + nav: { + /** + * Home + */ + home: () => LocalizedString + /** + * Data + */ + data: () => LocalizedString + /** + * Settings + */ + settings: () => LocalizedString + usermenu: { + /** + * Log out + */ + logout: () => LocalizedString + /** + * Log out of your profile + */ + logoutTitle: () => LocalizedString + /** + * Profile + */ + profile: () => LocalizedString + /** + * Administrate your profile + */ + profileTitle: () => LocalizedString + /** + * Toggle user menu + */ + toggleTitle: () => LocalizedString + } + } + views: { + dataTablePaginator: { + /** + * Go to previous page + */ + goToPrevPage: () => LocalizedString + /** + * Go to next page + */ + goToNextPage: () => LocalizedString + /** + * of + */ + of: () => LocalizedString + } + categoryForm: { + /** + * Name + */ + name: () => LocalizedString + /** + * Color + */ + color: () => LocalizedString + /** + * Default labels + */ + defaultLabels: () => LocalizedString + /** + * Search or create + */ + labelsPlaceholder: () => LocalizedString + } + settingsCategoriesTile: { + /** + * Are you sure you want to delete this category? + This will delete all relating entries! + */ + deleteAllConfirm: () => LocalizedString + /** + * Active + */ + active: () => LocalizedString + /** + * Archived + */ + archived: () => LocalizedString + /** + * Name + */ + name: () => LocalizedString + /** + * Color + */ + color: () => LocalizedString + /** + * Edit entry + */ + editEntry: () => LocalizedString + /** + * Delete entry + */ + deleteEntry: () => LocalizedString + /** + * No categories + */ + noCategories: () => LocalizedString + /** + * Categories + */ + categories: () => LocalizedString + } + settingsLabelsTile: { + /** + * Are you sure you want to delete this label? + It will be removed from all related entries! + */ + deleteAllConfirm: () => LocalizedString + /** + * Active + */ + active: () => LocalizedString + /** + * Archived + */ + archived: () => LocalizedString + /** + * Name + */ + name: () => LocalizedString + /** + * Color + */ + color: () => LocalizedString + /** + * Edit label + */ + editEntry: () => LocalizedString + /** + * Delete label + */ + deleteEntry: () => LocalizedString + /** + * No labels + */ + noLabels: () => LocalizedString + /** + * Labels + */ + labels: () => LocalizedString + } + entryForm: { + /** + * An error occured while updating the entry, try again soon. + */ + entryUpdateError: () => LocalizedString + /** + * An error occured while creating the entry, try again soon. + */ + entryCreateError: () => LocalizedString + /** + * Description is required + */ + errDescriptionReq: () => LocalizedString + /** + * Reset + */ + reset: () => LocalizedString + /** + * Description + */ + description: () => LocalizedString + /** + * Save + */ + save: () => LocalizedString + /** + * Create + */ + create: () => LocalizedString + category: { + /** + * Category + */ + category: () => LocalizedString + /** + * Search or create + */ + placeholder: () => LocalizedString + /** + * No categories available (Create a new one by searching for it) + */ + noResults: () => LocalizedString + /** + * Category is required + */ + errisRequired: () => LocalizedString + /** + * Reset category section + */ + _logReset: () => LocalizedString + } + labels: { + /** + * Search or create + */ + placeholder: () => LocalizedString + /** + * No labels available (Create a new one by searching for it) + */ + noResults: () => LocalizedString + /** + * Labels + */ + labels: () => LocalizedString + /** + * Reset labels section + */ + _logReset: () => LocalizedString + } + dateTime: { + /** + * Date is required + */ + errDateIsRequired: () => LocalizedString + /** + * From is required + */ + errFromIsRequired: () => LocalizedString + /** + * From can not be after To + */ + errFromAfterTo: () => LocalizedString + /** + * From and To can not be equal + */ + errFromEqTo: () => LocalizedString + /** + * To is required + */ + errToIsRequired: () => LocalizedString + /** + * To can not be before From + */ + errToBeforeFrom: () => LocalizedString + /** + * From + */ + from: () => LocalizedString + /** + * To + */ + to: () => LocalizedString + /** + * Date + */ + date: () => LocalizedString + /** + * Reset date time section + */ + _logReset: () => LocalizedString + } + } + } + data: { + /** + * Showing {entryCountString}, totalling in {totalHourMin} + */ + durationSummary: (arg: { entryCountString: string, totalHourMin: string }) => LocalizedString + /** + * h + */ + hourSingleChar: () => LocalizedString + /** + * m + */ + minSingleChar: () => LocalizedString + /** + * entry + */ + entry: () => LocalizedString + /** + * entries + */ + entries: () => LocalizedString + /** + * Are you sure you want to delete this entry? + */ + confirmDeleteEntry: () => LocalizedString + /** + * Edit entry + */ + editEntry: () => LocalizedString + /** + * Date + */ + date: () => LocalizedString + /** + * From + */ + from: () => LocalizedString + /** + * Duration + */ + duration: () => LocalizedString + /** + * Category + */ + category: () => LocalizedString + /** + * Description + */ + description: () => LocalizedString + /** + * Loading + */ + loading: () => LocalizedString + /** + * No entries + */ + noEntries: () => LocalizedString + /** + * to + */ + to: () => LocalizedString + /** + * Use + */ + use: () => LocalizedString + } + home: { + /** + * Are you sure you want to delete this entry? + */ + confirmDeleteEntry: () => LocalizedString + /** + * New entry + */ + newEntry: () => LocalizedString + /** + * Edit entry + */ + editEntry: () => LocalizedString + /** + * Delete entry + */ + deleteEntry: () => LocalizedString + /** + * Logged time today + */ + loggedTimeToday: () => LocalizedString + /** + * {hours}h{minutes}m + */ + loggedTimeTodayString: (arg: { hours: unknown, minutes: unknown }) => LocalizedString + /** + * Current time + */ + currentTime: () => LocalizedString + /** + * Loading + */ + loading: () => LocalizedString + /** + * Stopwatch + */ + stopwatch: () => LocalizedString + /** + * Today's entries + */ + todayEntries: () => LocalizedString + /** + * No entries today + */ + noEntriesToday: () => LocalizedString + /** + * Refresh today's entries + */ + refreshTodayEntries: () => LocalizedString + /** + * Category + */ + category: () => LocalizedString + /** + * Timespan + */ + timespan: () => LocalizedString + } + messages: { + /** + * Page not found + */ + pageNotFound: () => LocalizedString + /** + * Go to frontpage + */ + goToFrontpage: () => LocalizedString + /** + * It seems like your device does not have a internet connection, please check your connection. + */ + noInternet: () => LocalizedString + } +} + +export type Formatters = {} diff --git a/old-apps/projects/src/app/lib/i18n/i18n-util.async.ts b/old-apps/projects/src/app/lib/i18n/i18n-util.async.ts new file mode 100644 index 0000000..3ccef5f --- /dev/null +++ b/old-apps/projects/src/app/lib/i18n/i18n-util.async.ts @@ -0,0 +1,27 @@ +// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. +/* eslint-disable */ + +import { initFormatters } from './formatters' +import type { Locales, Translations } from './i18n-types' +import { loadedFormatters, loadedLocales, locales } from './i18n-util' + +const localeTranslationLoaders = { + en: () => import('./en'), + nb: () => import('./nb'), +} + +const updateDictionary = (locale: Locales, dictionary: Partial<Translations>) => + loadedLocales[locale] = { ...loadedLocales[locale], ...dictionary } + +export const importLocaleAsync = async (locale: Locales) => + (await localeTranslationLoaders[locale]()).default as unknown as Translations + +export const loadLocaleAsync = async (locale: Locales): Promise<void> => { + updateDictionary(locale, await importLocaleAsync(locale)) + loadFormatters(locale) +} + +export const loadAllLocalesAsync = (): Promise<void[]> => Promise.all(locales.map(loadLocaleAsync)) + +export const loadFormatters = (locale: Locales): void => + void (loadedFormatters[locale] = initFormatters(locale)) diff --git a/old-apps/projects/src/app/lib/i18n/i18n-util.sync.ts b/old-apps/projects/src/app/lib/i18n/i18n-util.sync.ts new file mode 100644 index 0000000..f1a8e9e --- /dev/null +++ b/old-apps/projects/src/app/lib/i18n/i18n-util.sync.ts @@ -0,0 +1,26 @@ +// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. +/* eslint-disable */ + +import { initFormatters } from './formatters' +import type { Locales, Translations } from './i18n-types' +import { loadedFormatters, loadedLocales, locales } from './i18n-util' + +import en from './en' +import nb from './nb' + +const localeTranslations = { + en, + nb, +} + +export const loadLocale = (locale: Locales): void => { + if (loadedLocales[locale]) return + + loadedLocales[locale] = localeTranslations[locale] as unknown as Translations + loadFormatters(locale) +} + +export const loadAllLocales = (): void => locales.forEach(loadLocale) + +export const loadFormatters = (locale: Locales): void => + void (loadedFormatters[locale] = initFormatters(locale)) diff --git a/old-apps/projects/src/app/lib/i18n/i18n-util.ts b/old-apps/projects/src/app/lib/i18n/i18n-util.ts new file mode 100644 index 0000000..cad1e7a --- /dev/null +++ b/old-apps/projects/src/app/lib/i18n/i18n-util.ts @@ -0,0 +1,31 @@ +// This file was auto-generated by 'typesafe-i18n'. Any manual changes will be overwritten. +/* eslint-disable */ + +import { i18n as initI18n, i18nObject as initI18nObject, i18nString as initI18nString } from 'typesafe-i18n' +import type { LocaleDetector } from 'typesafe-i18n/detectors' +import { detectLocale as detectLocaleFn } from 'typesafe-i18n/detectors' +import type { Formatters, Locales, Translations, TranslationFunctions } from './i18n-types' + +export const baseLocale: Locales = 'en' + +export const locales: Locales[] = [ + 'en', + 'nb' +] + +export const loadedLocales = {} as Record<Locales, Translations> + +export const loadedFormatters = {} as Record<Locales, Formatters> + +export const i18nString = (locale: Locales) => initI18nString<Locales, Formatters>(locale, loadedFormatters[locale]) + +export const i18nObject = (locale: Locales) => + initI18nObject<Locales, Translations, TranslationFunctions, Formatters>( + locale, + loadedLocales[locale], + loadedFormatters[locale] + ) + +export const i18n = () => initI18n<Locales, Translations, TranslationFunctions, Formatters>(loadedLocales, loadedFormatters) + +export const detectLocale = (...detectors: LocaleDetector[]) => detectLocaleFn<Locales>(baseLocale, locales, ...detectors) diff --git a/old-apps/projects/src/app/lib/i18n/nb/index.ts b/old-apps/projects/src/app/lib/i18n/nb/index.ts new file mode 100644 index 0000000..1638345 --- /dev/null +++ b/old-apps/projects/src/app/lib/i18n/nb/index.ts @@ -0,0 +1,126 @@ +import type {Translation} from "../i18n-types"; + +const nb: Translation = { + nav: { + home: "Hjem", + data: "Data", + settings: "Innstillinger", + usermenu: { + logout: "Logg ut", + logoutTitle: "Logg ut av din profil", + profile: "Profil", + profileTitle: "Administrer din profil", + toggleTitle: "Vis brukermeny" + } + }, + views: { + categoryForm: { + name: "Navn", + color: "Farge", + defaultLabels: "Standard merknader", + labelsPlaceholder: "Søk eller opprett" + }, + dataTablePaginator: { + goToPrevPage: "Gå til forrige side", + goToNextPage: "Gå til neste side", + of: "av", + }, + settingsCategoriesTile: { + deleteAllConfirm: "Er du sikker på at du vil slette denne kategorien?\nDette vil slette alle tilhørende rader!", + active: "Aktive", + archived: "Arkiverte", + name: "Navn", + color: "Farge", + editEntry: "Rediger kategori", + deleteEntry: "Slett kategori", + noCategories: "Ingen kategorier", + categories: "Kategorier" + }, + settingsLabelsTile: { + deleteAllConfirm: "Er du sikker på at du vil slette denne merknaden?\nDen vil bli slette fra alle relaterte rader!", + active: "Aktive", + archived: "Arkiverte", + name: "Navn", + color: "Farge", + editEntry: "Rediger merknad", + deleteEntry: "Slett merknad", + noLabels: "Ingen merknader", + labels: "Merknader" + }, + entryForm: { + entryUpdateError: "En feil oppstod med lagringen av din rad, prøv igjen snart.", + entryCreateError: "En feil oppstod med opprettelsen av din rad, prøv igjen snart.", + errDescriptionReq: "Beskrivelse er påkrevd", + reset: "Tilbakestill", + description: "Beskrivelse", + save: "Lagre", + create: "Opprett", + category: { + category: "Kategori", + placeholder: "Søk eller opprett", + noResults: "Ingen kategorier tilgjengelig (Opprett en ny ved å skrive navnet i søkefeltet).", + errisRequired: "Kategori er påkrevd", + _logReset: "Tilbakestilte kategori-seksjonen" + }, + labels: { + placeholder: "Søk eller opprett", + noResults: "Ingen merkander tilgjengelig (Opprett en ny ved å skrive navnet i søkefeltet).", + labels: "Merknader", + _logReset: "Tilbakestilte merknader-seksjonen" + }, + dateTime: { + errDateIsRequired: "Dato er påkrevd", + errFromIsRequired: "Fra er påkrevd", + errFromAfterTo: "Fra kan ikke være etter Til", + errFromEqTo: "Fra og Til kan ikke ha lik verdi", + errToIsRequired: "Til er påkrevd", + errToBeforeFrom: "Til kan ikke være før Fra", + from: "Fra", + to: "Til", + date: "Dato", + _logReset: "Tilbakestilte dato-seksjonen" + } + } + }, + data: { + durationSummary: "Viser {entryCountString:string}, Tilsammen {totalHourMin:string}", + hourSingleChar: "t", + minSingleChar: "m", + entry: "rad", + entries: "rader", + confirmDeleteEntry: "Er du sikker på at du vil slette denne raden?", + editEntry: "Rediger rad", + date: "Dato", + from: "Fra", + duration: "Tidsrom", + category: "Kategori", + description: "Beskrivelse", + loading: "Laster", + noEntries: "Ingen rader", + to: "til", + use: "Bruk", + }, + home: { + loggedTimeTodayString: "{hours}t{minutes}m", + confirmDeleteEntry: "Er du sikker på at du vil slette denne raden?", + newEntry: "Ny tidsoppføring", + editEntry: "Rediger rad", + deleteEntry: "Slett rad", + loggedTimeToday: "Registrert tid hittil idag", + currentTime: "Klokken", + loading: "Laster", + stopwatch: "Stoppeklokke", + todayEntries: "Dagens tidsoppføringer", + noEntriesToday: "Ingen oppføringer i dag", + refreshTodayEntries: "Last inn dagens tidsoppføringer på nytt", + category: "Kategori", + timespan: "Tidsrom", + }, + messages: { + pageNotFound: "Fant ikke siden", + goToFrontpage: "Gå til forsiden", + noInternet: "Det ser ut som at du er uten internettilgang, vennligst sjekk tilkoblingen din." + } +}; + +export default nb; diff --git a/old-apps/projects/src/app/lib/services/user-service.ts b/old-apps/projects/src/app/lib/services/user-service.ts new file mode 100644 index 0000000..4155819 --- /dev/null +++ b/old-apps/projects/src/app/lib/services/user-service.ts @@ -0,0 +1,14 @@ +import {portal_base} from "$shared/lib/configuration"; +import {end_session} 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 end_session(() => { + clear_categories(); + clear_labels(); + clear_entries(); + location.replace(portal_base("#/login" + (reason ? "?" + reason : ""))); + }); +} diff --git a/old-apps/projects/src/app/lib/stores/categories.ts b/old-apps/projects/src/app/lib/stores/categories.ts new file mode 100644 index 0000000..2a63c42 --- /dev/null +++ b/old-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/old-apps/projects/src/app/lib/stores/entries.ts b/old-apps/projects/src/app/lib/stores/entries.ts new file mode 100644 index 0000000..e933568 --- /dev/null +++ b/old-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/old-apps/projects/src/app/lib/stores/labels.ts b/old-apps/projects/src/app/lib/stores/labels.ts new file mode 100644 index 0000000..d5ffaa9 --- /dev/null +++ b/old-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/old-apps/projects/src/app/pages/_layout.svelte b/old-apps/projects/src/app/pages/_layout.svelte new file mode 100644 index 0000000..07a4a25 --- /dev/null +++ b/old-apps/projects/src/app/pages/_layout.svelte @@ -0,0 +1,66 @@ +<script> + import {onMount} from "svelte"; + import {location, link} from "svelte-spa-router"; + import {logout_user} from "$app/lib/services/user-service"; + import {random_string} 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"; + import LL from "$app/lib/i18n/i18n-svelte"; + import BlowoutToolbelt from "$shared/components/blowout-toolbelt.svelte"; + import {NavWrapper, NavItem} from "./nav"; + + 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}/> +<BlowoutToolbelt/> + +<NavWrapper> + <ul slot="navigation-items"> + <NavItem to="/home" text="{$LL.nav.home()}"/> + <NavItem to="/data" text="{$LL.nav.data()}"/> + <NavItem to="/settings" text="{$LL.nav.settings()}"/> + </ul> + <div slot="navigation-footer" class="tabs-nav-v2 justify-between"> + <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="{$LL.nav.usermenu.toggleTitle()}" + aria-controls="{userMenuId}" + /> + <Menu bind:show="{showUserMenu}" + trigger={userMenuTriggerNode} + id="{userMenuId}"> + <div slot="options"> + <MenuItem on:click={() => ProfileModalFunctions.open()}> + <span title="{$LL.nav.usermenu.profileTitle()}">{$LL.nav.usermenu.profile()}</span> + </MenuItem> + <MenuItemSeparator/> + <MenuItem danger="true" + on:click={() => logout_user()}> + <span title="{$LL.nav.usermenu.logoutTitle()}">{$LL.nav.usermenu.logout()}</span> + </MenuItem> + </div> + </Menu> + </div> + </div> + <slot slot="main-content"/> +</NavWrapper>
\ No newline at end of file diff --git a/old-apps/projects/src/app/pages/data.svelte b/old-apps/projects/src/app/pages/data.svelte new file mode 100644 index 0000000..190c641 --- /dev/null +++ b/old-apps/projects/src/app/pages/data.svelte @@ -0,0 +1,396 @@ +<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"; + import LL from "$app/lib/i18n/i18n-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 = $LL.data.durationSummary({ + entryCountString: `${entries.length} ${entries.length === 1 ? $LL.data.entry() : $LL.data.entries()}`, + totalHourMin: 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 + $LL.data.hourSingleChar() + date_time.duration.minutes + $LL.data.minSingleChar(), + 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($LL.data.confirmDeleteEntry())) { + 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="{$LL.data.editEntry()}" + 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">{$LL.data.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">{$LL.data.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">{$LL.data.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="{$LL.data.use()}"/> + </div> +</div> + +<Layout> + <Tile class="{isLoading ? 'c-disabled loading' : ''}"> + <nav class="s-tabs text-sm hide"> + <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>{$LL.data.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>{$LL.data.duration()}</span> + </div> + </TCell> + + <TCell type="th" + style="width: 100px;"> + <div class="flex items-center"> + <span>{$LL.data.category()}</span> + </div> + </TCell> + + <TCell type="th" + style="width: 300px;"> + <div class="flex items-center"> + <span>{$LL.data.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 ? $LL.data.loading() + "..." : $LL.data.noEntries()} + </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" : ""}>{$LL.data.noEntries()}</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/old-apps/projects/src/app/pages/home.svelte b/old-apps/projects/src/app/pages/home.svelte new file mode 100644 index 0000000..1f398b5 --- /dev/null +++ b/old-apps/projects/src/app/pages/home.svelte @@ -0,0 +1,178 @@ +<script lang="ts"> + import LL from "$app/lib/i18n/i18n-svelte"; + import { delete_time_entry, get_time_entries, get_time_entry } from "$shared/lib/api/time-entry"; + import { IconNames, QueryKeys } from "$shared/lib/configuration"; + import { TimeEntryDto } from "$shared/lib/models/TimeEntryDto"; + import { Temporal } from "@js-temporal/polyfill"; + import { useMutation, useQuery, useQueryClient } from "@sveltestack/svelte-query"; + 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, unwrap_date_time_from_entry } from "$shared/lib/helpers"; + import { TimeEntryQueryDuration } from "$shared/lib/models/TimeEntryQuery"; + + let currentTime = ""; + let isLoading = false; + let EditEntryForm: any; + let timeEntries = [] as Array<TimeEntryDto>; + let timeLoggedTodayString = $LL.home.loggedTimeTodayString({hours: 0, minutes: 0}); + let loggedSecondsToday = 0; + + const queryClient = useQueryClient(); + const queryResult = useQuery(QueryKeys.entries, async () => await get_time_entries({ + duration: TimeEntryQueryDuration.TODAY, + page: 1, + pageSize: 100, + })?.data ?? [] + ); + + function set_current_time() { + currentTime = Temporal.Now.plainTimeISO().toLocaleString(undefined, { + timeStyle: "short", + }); + } + + const delete_entry_mutation = useMutation(delete_time_entry, { + onSuccess: (data) => { + queryClient.invalidateQueries([QueryKeys.entries, data.data.id]); + }, + }); + + async function on_edit_entry_button_click(event, entryId: string) { + const response = useQuery([QueryKeys.entries, entryId], () => { + return get_time_entry(entryId); + }); + + EditEntryForm.set_values(response); + } + + async function on_delete_entry_button_click(event, entryId: string) { + if (confirm($LL.home.confirmDeleteEntry())) { + $delete_entry_mutation.mutate(entryId); + } + } + + 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); + queryResult.subscribe((result) => { + const newEntries = []; + loggedSecondsToday = 0; + for (const entry of result.data?.results ?? []) { + 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 = $LL.home.loggedTimeTodayString(seconds_to_hour_minute(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">{$LL.home.newEntry()}</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">{$LL.home.loggedTimeToday()}</p> + <pre class="text-xxl">{currentTime}</pre> + <p class="text-xs">{$LL.home.currentTime()}</p> + </Tile> + <Tile class="col-6@md col-12"> + <Stopwatch on:create={on_create_from_stopwatch}> + <h3 slot="header" + class="text-md">{$LL.home.stopwatch()}</h3> + </Stopwatch> + </Tile> + <Tile class="col-12"> + <h3 class="text-md padding-bottom-xxxs">{$LL.home.todayEntries()}</h3> + <div class="max-width-100% overflow-auto"> + <Table class="width-100% text-sm"> + <THead> + <TCell type="th" + class="text-left"> + <span>{$LL.home.category()}</span> + </TCell> + <TCell type="th" + class="text-left"> + <span>{$LL.home.timespan()}</span> + </TCell> + <TCell type="th" + class="text-right"> + <Button icon="{IconNames.refresh}" + variant="reset" + icon_width="1.2rem" + icon_height="1.2rem" + title="{$LL.home.refreshTodayEntries()}" + on:click={() => queryClient.invalidateQueries(QueryKeys.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="{$LL.home.editEntry()}"/> + <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="{$LL.home.deleteEntry()}"/> + </TCell> + </TRow> + {/each} + {:else} + <TRow class="text-nowrap"> + <TCell type="th" + thScope="row" + colspan="7"> + {isLoading ? $LL.home.loading() + "..." : $LL.home.noEntriesToday()} + </TCell> + </TRow> + {/if} + </TBody> + </Table> + </div> + </Tile> + </div> + </div> +</Layout> diff --git a/old-apps/projects/src/app/pages/nav/css/1_responsive-sidebar.css b/old-apps/projects/src/app/pages/nav/css/1_responsive-sidebar.css new file mode 100644 index 0000000..515a9f2 --- /dev/null +++ b/old-apps/projects/src/app/pages/nav/css/1_responsive-sidebar.css @@ -0,0 +1,179 @@ +/* -------------------------------- + +File#: _1_responsive-sidebar +Title: Responsive Sidebar +Descr: Responsive sidebar container +Usage: codyhouse.co/license + +-------------------------------- */ +/* mobile version only (--default) 👇 */ +.sidebar:not(.sidebar--static) { + position: fixed; + top: 0; + left: 0; + z-index: var(--z-index-fixed-element, 10); + width: 100%; + height: 100%; + visibility: hidden; + transition: visibility 0s 0.3s; +} +.sidebar:not(.sidebar--static)::after { + /* overlay layer */ + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: hsla(var(--color-black-h), var(--color-black-s), var(--color-black-l), 0); + transition: background-color 0.3s; + z-index: 1; +} +.sidebar:not(.sidebar--static) .sidebar__panel { + /* content */ + position: absolute; + top: 0; + left: 0; + z-index: 2; + width: 100%; + max-width: 380px; + height: 100%; + overflow: auto; + -webkit-overflow-scrolling: touch; + background-color: var(--color-bg); + -webkit-transform: translateX(-100%); + transform: translateX(-100%); + transition: box-shadow 0.3s, -webkit-transform 0.3s; + transition: box-shadow 0.3s, transform 0.3s; + transition: box-shadow 0.3s, transform 0.3s, -webkit-transform 0.3s; +} +.sidebar:not(.sidebar--static).sidebar--right-on-mobile .sidebar__panel { + left: auto; + right: 0; + -webkit-transform: translateX(100%); + transform: translateX(100%); +} +.sidebar:not(.sidebar--static).sidebar--is-visible { + visibility: visible; + transition: none; +} +.sidebar:not(.sidebar--static).sidebar--is-visible::after { + background-color: hsla(var(--color-black-h), var(--color-black-s), var(--color-black-l), 0.85); +} +.sidebar:not(.sidebar--static).sidebar--is-visible .sidebar__panel { + -webkit-transform: translateX(0); + transform: translateX(0); + box-shadow: var(--shadow-md); +} + +/* end mobile version */ +.sidebar__header { + display: flex; + align-items: center; + justify-content: space-between; + position: -webkit-sticky; + position: sticky; + top: 0; +} + +.sidebar__close-btn { + --size: 32px; + width: var(--size); + height: var(--size); + display: flex; + border-radius: 50%; + background-color: var(--color-bg-light); + box-shadow: var(--inner-glow), var(--shadow-sm); + transition: 0.2s; + flex-shrink: 0; +} +.sidebar__close-btn .icon { + display: block; + margin: auto; +} +.sidebar__close-btn:hover { + background-color: var(--color-bg-lighter); + box-shadow: var(--inner-glow), var(--shadow-md); +} + +/* desktop version only (--static) 👇 */ +.sidebar--static { + flex-shrink: 0; + flex-grow: 1; +} +.sidebar--static .sidebar__header { + display: none; +} + +.sidebar--sticky-on-desktop { + position: -webkit-sticky; + position: sticky; + top: var(--space-sm); + max-height: calc(100vh - var(--space-sm)); + overflow: auto; + -webkit-overflow-scrolling: touch; +} + +/* end desktop version */ +.sidebar, .sidebar-loaded\:show { + opacity: 0; + /* hide sidebar - or other elements using the .sidebar-loaded:show class - while it is initialized in JS */ +} + +.sidebar--loaded { + opacity: 1; +} + +/* detect when the sidebar needs to switch from the mobile layout to a static one - used in JS */ +[class*=sidebar--static]::before { + display: none; +} + +.sidebar--static::before { + content: "static"; +} + +.sidebar--static\@xs::before { + content: "mobile"; +} +@media (min-width: 32rem) { + .sidebar--static\@xs::before { + content: "static"; + } +} + +.sidebar--static\@sm::before { + content: "mobile"; +} +@media (min-width: 48rem) { + .sidebar--static\@sm::before { + content: "static"; + } +} + +.sidebar--static\@md::before { + content: "mobile"; +} +@media (min-width: 64rem) { + .sidebar--static\@md::before { + content: "static"; + } +} + +.sidebar--static\@lg::before { + content: "mobile"; +} +@media (min-width: 80rem) { + .sidebar--static\@lg::before { + content: "static"; + } +} + +.sidebar--static\@xl::before { + content: "mobile"; +} +@media (min-width: 90rem) { + .sidebar--static\@xl::before { + content: "static"; + } +}
\ No newline at end of file diff --git a/old-apps/projects/src/app/pages/nav/css/2_side-navigation-v4.css b/old-apps/projects/src/app/pages/nav/css/2_side-navigation-v4.css new file mode 100644 index 0000000..ec5fcdf --- /dev/null +++ b/old-apps/projects/src/app/pages/nav/css/2_side-navigation-v4.css @@ -0,0 +1,213 @@ +/* -------------------------------- + +File#: _2_side-navigation-v4 +Title: Side Navigation v4 +Descr: Main, side navigation +Usage: codyhouse.co/license + +-------------------------------- */ +.sidenav-v4 { + --sidenav-v4-icon-size: 20px; + --sidenav-v4-icon-margin-right: var(--space-xxs); +} + +.sidenav-v4__item { + position: relative; +} + +.sidenav-v4__link, +.sidenav-v4__sub-link, +.sidenav-v4__separator { + padding: var(--space-sm); +} + +.sidenav-v4__link, .sidenav-v4__sub-link { + display: flex; + align-items: center; + width: 100%; + border-radius: var(--radius-md); + text-decoration: none; + color: inherit; + line-height: 1; + font-size: var(--text-md); + transition: 0.2s; +} +.sidenav-v4__link:hover, .sidenav-v4__sub-link:hover { + color: var(--color-primary); + background-color: hsla(var(--color-contrast-higher-h), var(--color-contrast-higher-s), var(--color-contrast-higher-l), 0.075); +} +.sidenav-v4__link[aria-current=page], .sidenav-v4__sub-link[aria-current=page] { + color: var(--color-primary); +} + +.sidenav-v4__sub-link { + position: relative; + color: var(--color-contrast-medium); + /* dot indicator */ +} +.sidenav-v4__sub-link::before { + content: ""; + display: block; + --size: 6px; + width: var(--size); + height: var(--size); + background: currentColor; + border-radius: 50%; + margin-left: calc(var(--sidenav-v4-icon-size)/2 - var(--size)/2); + margin-right: calc(var(--sidenav-v4-icon-size)/2 - var(--size)/2 + var(--sidenav-v4-icon-margin-right)); + opacity: 0; + /* visible only if current */ +} +.sidenav-v4__sub-link[aria-current=page]::before { + /* show dot indicator */ + opacity: 1; +} + +.sidenav-v4__notification-marker { + margin-left: auto; + background-color: var(--color-accent); + border-radius: var(--radius-md); + height: 16px; + line-height: 16px; + padding: 0 4px; + color: var(--color-white); + font-size: 12px; + /* hide - visible only on desktop */ + display: none; +} + +/* label icon */ +.sidenav-v4__icon { + --size: var(--sidenav-v4-icon-size); + margin-right: var(--sidenav-v4-icon-margin-right); +} + +/* arrow icon - visible on mobile if item is expandable */ +.sidenav-v4__arrow-icon { + --size: 20px; + /* hide icon for links - show only for buttons created in JS */ +} +.sidenav-v4__arrow-icon .icon__group { + will-change: transform; + -webkit-transform-origin: 50% 50%; + transform-origin: 50% 50%; + -webkit-transform: rotate(-90deg); + transform: rotate(-90deg); + transition: -webkit-transform 0.3s var(--ease-out); + transition: transform 0.3s var(--ease-out); + transition: transform 0.3s var(--ease-out), -webkit-transform 0.3s var(--ease-out); +} +.sidenav-v4__arrow-icon .icon__group > * { + -webkit-transform-origin: 50% 50%; + transform-origin: 50% 50%; + stroke-dasharray: 20; + stroke-dashoffset: 0; + -webkit-transform: translateY(0px); + transform: translateY(0px); + transition: stroke-dashoffset 0.3s, -webkit-transform 0.3s; + transition: transform 0.3s, stroke-dashoffset 0.3s; + transition: transform 0.3s, stroke-dashoffset 0.3s, -webkit-transform 0.3s; + transition-timing-function: var(--ease-out); +} +.sidenav-v4__item--collapsed .sidenav-v4__arrow-icon .icon__group { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); +} +.sidenav-v4__item--collapsed .sidenav-v4__arrow-icon .icon__group > * { + -webkit-transform: translateY(4px); + transform: translateY(4px); +} +.sidenav-v4__item--collapsed .sidenav-v4__arrow-icon .icon__group > *:first-child { + stroke-dashoffset: 10.15; +} +.sidenav-v4__item--collapsed .sidenav-v4__arrow-icon .icon__group > *:last-child { + stroke-dashoffset: 10.15; +} +.sidenav-v4__link--href .sidenav-v4__arrow-icon { + display: none; +} + +/* current item */ +.sidenav-v4__item--current .sidenav-v4__sub-list { + display: block; + /* show sublist */ +} + +/* separator */ +.sidenav-v4__separator span { + display: block; + width: var(--sidenav-v4-icon-size); + height: 1px; + background-color: var(--color-contrast-lower); +} + +/* mobile only */ +@media not all and (min-width: 64rem) { + .sidenav-v4__item--collapsed .sidenav-v4__sub-list { + display: none; + } + + .sidenav-v4__link--href { + display: none; + /* hide link -> show button */ + } +} +/* desktop */ +@media (min-width: 64rem) { + .sidenav-v4__sub-list { + display: none; + } + + .sidenav-v4__link, +.sidenav-v4__sub-link, +.sidenav-v4__separator { + padding: var(--space-xs); + } + + .sidenav-v4__link, +.sidenav-v4__sub-link { + font-size: var(--text-sm); + } + + .sidenav-v4__link--btn { + display: none; + /* hide button -> show link */ + } + + /* tooltip */ + .sidenav-v4__item:not(.sidenav-v4__item--current) .sidenav-v4__sub-list { + width: 220px; + position: absolute; + z-index: var(--z-index-overlay); + left: 100%; + top: 0; + background-color: var(--color-bg-light); + box-shadow: var(--inner-glow), var(--shadow-md); + border-radius: var(--radius-md); + overflow: hidden; + } + .sidenav-v4__item:not(.sidenav-v4__item--current) .sidenav-v4__sub-link { + border-radius: 0; + color: var(--color-contrast-high); + } + .sidenav-v4__item:not(.sidenav-v4__item--current) .sidenav-v4__sub-link::before { + display: none; + /* remove dot indicator */ + } + .sidenav-v4__item:not(.sidenav-v4__item--current) .sidenav-v4__sub-link:hover { + color: var(--color-primary); + } + .sidenav-v4__item:not(.sidenav-v4__item--current).sidenav-v4__item--hover .sidenav-v4__sub-list, .sidenav-v4__item:not(.sidenav-v4__item--current):focus-within .sidenav-v4__sub-list { + display: block; + } + .sidenav-v4__item:not(.sidenav-v4__item--current):hover .sidenav-v4__link { + /* highlight main link if tooltip is visible */ + color: var(--color-primary); + background-color: hsla(var(--color-contrast-higher-h), var(--color-contrast-higher-s), var(--color-contrast-higher-l), 0.075); + } + + /* notification marker */ + .sidenav-v4__notification-marker { + display: block; + } +}
\ No newline at end of file diff --git a/old-apps/projects/src/app/pages/nav/html/side-navigation-v4.html b/old-apps/projects/src/app/pages/nav/html/side-navigation-v4.html new file mode 100644 index 0000000..1131b4d --- /dev/null +++ b/old-apps/projects/src/app/pages/nav/html/side-navigation-v4.html @@ -0,0 +1,211 @@ +<div class="padding-component hide@md no-js:is-hidden"> + <button class="btn btn--primary" aria-controls="sidenav-v4">Show sidebar</button> +</div> + +<div class="flex@md"> + <aside id="sidenav-v4" class="sidebar sidebar--static@md js-sidebar" data-static-class="position-relative z-index-2 bg width-100% max-width-xxxxs shadow-sm"> + <div class="sidebar__panel"> + <!-- 👇 header visible only on mobile --> + <header class="sidebar__header bg padding-y-sm padding-left-md padding-right-sm border-bottom z-index-2"> + <h1 class="text-md text-truncate" id="sidebar-title">Menu</h1> + + <button class="reset sidebar__close-btn js-sidebar__close-btn js-tab-focus"> + <svg class="icon icon--xs" viewBox="0 0 16 16"><title>Close panel</title><g stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10"><line x1="13.5" y1="2.5" x2="2.5" y2="13.5"></line><line x1="2.5" y1="2.5" x2="13.5" y2="13.5"></line></g></svg> + </button> + </header> + + <div class="position-relative z-index-1"> + <nav class="sidenav-v4 padding-xs js-sidenav-v4"> + <ul> + <li class="sidenav-v4__item"> + <a class="sidenav-v4__link js-sidenav-v4__link" href="#0"> + <svg class="sidenav-v4__icon icon" viewBox="0 0 20 20"> + <g fill="currentColor"> + <path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 12v7H4v-7"></path> + <path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 10l9-9 9 9"></path> + <path d="M10 14a2 2 0 0 1 2 2v2H8v-2a2 2 0 0 1 2-2z"></path> + </g> + </svg> + + <span>Overview</span> + + <svg class="sidenav-v4__arrow-icon icon margin-left-auto" viewBox="0 0 20 20"> + <g class="icon__group" fill="none" stroke="currentColor" stroke-width="2px" stroke-linecap="round" stroke-linejoin="round"> + <line x1="3" y1="3" x2="17" y2="17" /> + <line x1="17" y1="3" x2="3" y2="17" /> + </g> + </svg> + </a> + + <ul class="sidenav-v4__sub-list"> + <li> + <a class="sidenav-v4__sub-link" href="#0">All Data</a> + </li> + + <li> + <a class="sidenav-v4__sub-link" href="#0">Category 1</a> + </li> + + <li> + <a class="sidenav-v4__sub-link" href="#0">Category 2</a> + </li> + </ul> + </li> + + <li class="sidenav-v4__item sidenav-v4__item--current"> + <a class="sidenav-v4__link js-sidenav-v4__link" href="#0"> + <svg class="sidenav-v4__icon icon" viewBox="0 0 20 20"> + <g fill="currentColor"> + <path d="M10 20a2 2 0 0 1-2-2h4a2 2 0 0 1-2 2z"></path> + <path d="M19 15a3 3 0 0 1-3-3V7a6 6 0 0 0-6-6 6 6 0 0 0-6 6v5a3 3 0 0 1-3 3h18z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path> + </g> + </svg> + + <span>Notifications</span> + + <span class="sidenav-v4__notification-marker">8 <i class="sr-only">notifications</i></span> + + <svg class="sidenav-v4__arrow-icon icon margin-left-auto" viewBox="0 0 20 20"> + <g class="icon__group" fill="none" stroke="currentColor" stroke-width="2px" stroke-linecap="round" stroke-linejoin="round"> + <line x1="3" y1="3" x2="17" y2="17" /> + <line x1="17" y1="3" x2="3" y2="17" /> + </g> + </svg> + </a> + + <ul class="sidenav-v4__sub-list"> + <li> + <a class="sidenav-v4__sub-link" href="#0">All Notifications</a> + </li> + + <li> + <a class="sidenav-v4__sub-link" href="#0" aria-current="page">Friends</a> + </li> + + <li> + <a class="sidenav-v4__sub-link" href="#0">Other</a> + </li> + </ul> + </li> + + <li class="sidenav-v4__item"> + <a class="sidenav-v4__link js-sidenav-v4__link" href="#0"> + <svg class="sidenav-v4__icon icon" viewBox="0 0 20 20"> + <g fill="currentColor"> + <path d="M17 2H3a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h4l3 4 3-4h4a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path> + </g> + </svg> + + <span>Comments</span> + + <svg class="sidenav-v4__arrow-icon icon margin-left-auto" viewBox="0 0 20 20"> + <g class="icon__group" fill="none" stroke="currentColor" stroke-width="2px" stroke-linecap="round" stroke-linejoin="round"> + <line x1="3" y1="3" x2="17" y2="17" /> + <line x1="17" y1="3" x2="3" y2="17" /> + </g> + </svg> + </a> + + <ul class="sidenav-v4__sub-list"> + <li> + <a class="sidenav-v4__sub-link" href="#0">All Comments</a> + </li> + + <li> + <a class="sidenav-v4__sub-link" href="#0">+ New Comment</a> + </li> + + <li> + <a class="sidenav-v4__sub-link" href="#0">Spam</a> + </li> + </ul> + </li> + + <li class="sidenav-v4__separator" role="presentation"><span></span></li> + + <li class="sidenav-v4__item"> + <a class="sidenav-v4__link js-sidenav-v4__link" href="#0"> + <svg class="sidenav-v4__icon icon" viewBox="0 0 20 20"> + <g fill="currentColor"> + <rect x="2" y="2" width="16" height="16" rx="2" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></rect> + <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 14l6-6 2 6H6z"></path><circle cx="6.5" cy="6.5" r="1.5"></circle> + </g> + </svg> + + <span>Assets</span> + + <svg class="sidenav-v4__arrow-icon icon margin-left-auto" viewBox="0 0 20 20"> + <g class="icon__group" fill="none" stroke="currentColor" stroke-width="2px" stroke-linecap="round" stroke-linejoin="round"> + <line x1="3" y1="3" x2="17" y2="17" /> + <line x1="17" y1="3" x2="3" y2="17" /> + </g> + </svg> + </a> + + <ul class="sidenav-v4__sub-list"> + <li> + <a class="sidenav-v4__sub-link" href="#0">All Assets</a> + </li> + + <li> + <a class="sidenav-v4__sub-link" href="#0">Upload</a> + </li> + </ul> + </li> + + <li class="sidenav-v4__item"> + <a class="sidenav-v4__link js-sidenav-v4__link" href="#0"> + <svg class="sidenav-v4__icon icon" viewBox="0 0 20 20"> + <g fill="currentColor"> + <circle cx="10" cy="4" r="3" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></circle> + <path d="M10 11a8 8 0 0 0-7.562 5.383A2 2 0 0 0 4.347 19h11.306a2 2 0 0 0 1.909-2.617A8 8 0 0 0 10 11z" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path> + </g> + </svg> + + <span>Users</span> + + <svg class="sidenav-v4__arrow-icon icon margin-left-auto" viewBox="0 0 20 20"> + <g class="icon__group" fill="none" stroke="currentColor" stroke-width="2px" stroke-linecap="round" stroke-linejoin="round"> + <line x1="3" y1="3" x2="17" y2="17" /> + <line x1="17" y1="3" x2="3" y2="17" /> + </g> + </svg> + </a> + + <ul class="sidenav-v4__sub-list"> + <li> + <a class="sidenav-v4__sub-link" href="#0">All Users</a> + </li> + + <li> + <a class="sidenav-v4__sub-link" href="#0">+ New User</a> + </li> + </ul> + </li> + + <li class="sidenav-v4__item"> + <a class="sidenav-v4__link js-sidenav-v4__link" href="#0"> + <svg class="sidenav-v4__icon icon" viewBox="0 0 20 20"> + <g fill="currentColor"> + <path d="M11 16l-1.55 1.55a4.95 4.95 0 0 1-7 0 4.95 4.95 0 0 1 0-7l2.192-2.192a4.95 4.95 0 0 1 7 0A4.907 4.907 0 0 1 12.731 10" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path> + <path d="M9 4l1.55-1.55a4.95 4.95 0 0 1 7 0 4.95 4.95 0 0 1 0 7l-2.192 2.192a4.95 4.95 0 0 1-7 0A4.907 4.907 0 0 1 7.269 10" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path> + </g> + </svg> + + <span>Link</span> + </a> + </li> + </ul> + </nav> + </div> + </div> + </aside> + + <main class="position-relative z-index-1 flex-grow height-100vh sidebar-loaded:show"> + <!-- start main content --> + <div class="text-component padding-md"> + <p>Main content.</p> + </div> + <!-- end main content --> + </main> +</div>
\ No newline at end of file diff --git a/old-apps/projects/src/app/pages/nav/index.ts b/old-apps/projects/src/app/pages/nav/index.ts new file mode 100644 index 0000000..ca91c20 --- /dev/null +++ b/old-apps/projects/src/app/pages/nav/index.ts @@ -0,0 +1,6 @@ +import NavWrapper from "./nav-wrapper.svelte"; +import NavItem from "./nav-item.svelte"; +export { + NavWrapper, + NavItem +}
\ No newline at end of file diff --git a/old-apps/projects/src/app/pages/nav/js/_1_diagonal-movement.js b/old-apps/projects/src/app/pages/nav/js/_1_diagonal-movement.js new file mode 100644 index 0000000..ed4a47d --- /dev/null +++ b/old-apps/projects/src/app/pages/nav/js/_1_diagonal-movement.js @@ -0,0 +1,296 @@ +// File#: _1_diagonal-movement +// Usage: codyhouse.co/license +/* + Modified version of the jQuery-menu-aim plugin + https://github.com/kamens/jQuery-menu-aim + - Replaced jQuery with Vanilla JS + - Minor changes +*/ +(function() { + var menuAim = function(opts) { + init(opts); + }; + + window.menuAim = menuAim; + + function init(opts) { + var activeRow = null, + mouseLocs = [], + lastDelayLoc = null, + timeoutId = null, + options = Util.extend({ + menu: '', + rows: false, //if false, get direct children - otherwise pass nodes list + submenuSelector: "*", + submenuDirection: "right", + tolerance: 75, // bigger = more forgivey when entering submenu + enter: function(){}, + exit: function(){}, + activate: function(){}, + deactivate: function(){}, + exitMenu: function(){} + }, opts), + menu = options.menu; + + var MOUSE_LOCS_TRACKED = 3, // number of past mouse locations to track + DELAY = 300; // ms delay when user appears to be entering submenu + + /** + * Keep track of the last few locations of the mouse. + */ + var mouseMoveFallback = function(event) { + (!window.requestAnimationFrame) ? mousemoveDocument(event) : window.requestAnimationFrame(function(){mousemoveDocument(event);}); + }; + + var mousemoveDocument = function(e) { + mouseLocs.push({x: e.pageX, y: e.pageY}); + + if (mouseLocs.length > MOUSE_LOCS_TRACKED) { + mouseLocs.shift(); + } + }; + + /** + * Cancel possible row activations when leaving the menu entirely + */ + var mouseleaveMenu = function() { + if (timeoutId) { + clearTimeout(timeoutId); + } + + // If exitMenu is supplied and returns true, deactivate the + // currently active row on menu exit. + if (options.exitMenu(this)) { + if (activeRow) { + options.deactivate(activeRow); + } + + activeRow = null; + } + }; + + /** + * Trigger a possible row activation whenever entering a new row. + */ + var mouseenterRow = function() { + if (timeoutId) { + // Cancel any previous activation delays + clearTimeout(timeoutId); + } + + options.enter(this); + possiblyActivate(this); + }, + mouseleaveRow = function() { + options.exit(this); + }; + + /* + * Immediately activate a row if the user clicks on it. + */ + var clickRow = function() { + activate(this); + }; + + /** + * Activate a menu row. + */ + var activate = function(row) { + if (row == activeRow) { + return; + } + + if (activeRow) { + options.deactivate(activeRow); + } + + options.activate(row); + activeRow = row; + }; + + /** + * Possibly activate a menu row. If mouse movement indicates that we + * shouldn't activate yet because user may be trying to enter + * a submenu's content, then delay and check again later. + */ + var possiblyActivate = function(row) { + var delay = activationDelay(); + + if (delay) { + timeoutId = setTimeout(function() { + possiblyActivate(row); + }, delay); + } else { + activate(row); + } + }; + + /** + * Return the amount of time that should be used as a delay before the + * currently hovered row is activated. + * + * Returns 0 if the activation should happen immediately. Otherwise, + * returns the number of milliseconds that should be delayed before + * checking again to see if the row should be activated. + */ + var activationDelay = function() { + if (!activeRow || !Util.is(activeRow, options.submenuSelector)) { + // If there is no other submenu row already active, then + // go ahead and activate immediately. + return 0; + } + + function getOffset(element) { + var rect = element.getBoundingClientRect(); + return { top: rect.top + window.pageYOffset, left: rect.left + window.pageXOffset }; + }; + + var offset = getOffset(menu), + upperLeft = { + x: offset.left, + y: offset.top - options.tolerance + }, + upperRight = { + x: offset.left + menu.offsetWidth, + y: upperLeft.y + }, + lowerLeft = { + x: offset.left, + y: offset.top + menu.offsetHeight + options.tolerance + }, + lowerRight = { + x: offset.left + menu.offsetWidth, + y: lowerLeft.y + }, + loc = mouseLocs[mouseLocs.length - 1], + prevLoc = mouseLocs[0]; + + if (!loc) { + return 0; + } + + if (!prevLoc) { + prevLoc = loc; + } + + if (prevLoc.x < offset.left || prevLoc.x > lowerRight.x || prevLoc.y < offset.top || prevLoc.y > lowerRight.y) { + // If the previous mouse location was outside of the entire + // menu's bounds, immediately activate. + return 0; + } + + if (lastDelayLoc && loc.x == lastDelayLoc.x && loc.y == lastDelayLoc.y) { + // If the mouse hasn't moved since the last time we checked + // for activation status, immediately activate. + return 0; + } + + // Detect if the user is moving towards the currently activated + // submenu. + // + // If the mouse is heading relatively clearly towards + // the submenu's content, we should wait and give the user more + // time before activating a new row. If the mouse is heading + // elsewhere, we can immediately activate a new row. + // + // We detect this by calculating the slope formed between the + // current mouse location and the upper/lower right points of + // the menu. We do the same for the previous mouse location. + // If the current mouse location's slopes are + // increasing/decreasing appropriately compared to the + // previous's, we know the user is moving toward the submenu. + // + // Note that since the y-axis increases as the cursor moves + // down the screen, we are looking for the slope between the + // cursor and the upper right corner to decrease over time, not + // increase (somewhat counterintuitively). + function slope(a, b) { + return (b.y - a.y) / (b.x - a.x); + }; + + var decreasingCorner = upperRight, + increasingCorner = lowerRight; + + // Our expectations for decreasing or increasing slope values + // depends on which direction the submenu opens relative to the + // main menu. By default, if the menu opens on the right, we + // expect the slope between the cursor and the upper right + // corner to decrease over time, as explained above. If the + // submenu opens in a different direction, we change our slope + // expectations. + if (options.submenuDirection == "left") { + decreasingCorner = lowerLeft; + increasingCorner = upperLeft; + } else if (options.submenuDirection == "below") { + decreasingCorner = lowerRight; + increasingCorner = lowerLeft; + } else if (options.submenuDirection == "above") { + decreasingCorner = upperLeft; + increasingCorner = upperRight; + } + + var decreasingSlope = slope(loc, decreasingCorner), + increasingSlope = slope(loc, increasingCorner), + prevDecreasingSlope = slope(prevLoc, decreasingCorner), + prevIncreasingSlope = slope(prevLoc, increasingCorner); + + if (decreasingSlope < prevDecreasingSlope && increasingSlope > prevIncreasingSlope) { + // Mouse is moving from previous location towards the + // currently activated submenu. Delay before activating a + // new menu row, because user may be moving into submenu. + lastDelayLoc = loc; + return DELAY; + } + + lastDelayLoc = null; + return 0; + }; + + var reset = function(triggerDeactivate) { + if (timeoutId) { + clearTimeout(timeoutId); + } + + if (activeRow && triggerDeactivate) { + options.deactivate(activeRow); + } + + activeRow = null; + }; + + var destroyInstance = function() { + menu.removeEventListener('mouseleave', mouseleaveMenu); + document.removeEventListener('mousemove', mouseMoveFallback); + if(rows.length > 0) { + for(var i = 0; i < rows.length; i++) { + rows[i].removeEventListener('mouseenter', mouseenterRow); + rows[i].removeEventListener('mouseleave', mouseleaveRow); + rows[i].removeEventListener('click', clickRow); + } + } + + }; + + /** + * Hook up initial menu events + */ + menu.addEventListener('mouseleave', mouseleaveMenu); + var rows = (options.rows) ? options.rows : menu.children; + if(rows.length > 0) { + for(var i = 0; i < rows.length; i++) {(function(i){ + rows[i].addEventListener('mouseenter', mouseenterRow); + rows[i].addEventListener('mouseleave', mouseleaveRow); + rows[i].addEventListener('click', clickRow); + })(i);} + } + + document.addEventListener('mousemove', mouseMoveFallback); + + /* Reset/destroy menu */ + menu.addEventListener('reset', function(event){ + reset(event.detail); + }); + menu.addEventListener('destroy', destroyInstance); + }; +}()); + diff --git a/old-apps/projects/src/app/pages/nav/js/_1_responsive-sidebar.js b/old-apps/projects/src/app/pages/nav/js/_1_responsive-sidebar.js new file mode 100644 index 0000000..f9599d8 --- /dev/null +++ b/old-apps/projects/src/app/pages/nav/js/_1_responsive-sidebar.js @@ -0,0 +1,215 @@ +// File#: _1_responsive-sidebar +// Usage: codyhouse.co/license +(function() { + var Sidebar = function(element) { + this.element = element; + this.triggers = document.querySelectorAll('[aria-controls="'+this.element.getAttribute('id')+'"]'); + this.firstFocusable = null; + this.lastFocusable = null; + this.selectedTrigger = null; + this.showClass = "sidebar--is-visible"; + this.staticClass = "sidebar--static"; + this.customStaticClass = ""; + this.readyClass = "sidebar--loaded"; + this.contentReadyClass = "sidebar-loaded:show"; + this.layout = false; // this will be static or mobile + this.preventScrollEl = getPreventScrollEl(this); + getCustomStaticClass(this); // custom classes for static version + initSidebar(this); + }; + + function getPreventScrollEl(element) { + var scrollEl = false; + var querySelector = element.element.getAttribute('data-sidebar-prevent-scroll'); + if(querySelector) scrollEl = document.querySelector(querySelector); + return scrollEl; + }; + + function getCustomStaticClass(element) { + var customClasses = element.element.getAttribute('data-static-class'); + if(customClasses) element.customStaticClass = ' '+customClasses; + }; + + function initSidebar(sidebar) { + initSidebarResize(sidebar); // handle changes in layout -> mobile to static and viceversa + + if ( sidebar.triggers ) { // open sidebar when clicking on trigger buttons - mobile layout only + for(var i = 0; i < sidebar.triggers.length; i++) { + sidebar.triggers[i].addEventListener('click', function(event) { + event.preventDefault(); + toggleSidebar(sidebar, event.target); + }); + } + } + + // use the 'openSidebar' event to trigger the sidebar + sidebar.element.addEventListener('openSidebar', function(event) { + toggleSidebar(sidebar, event.detail); + }); + }; + + function toggleSidebar(sidebar, target) { + if(Util.hasClass(sidebar.element, sidebar.showClass)) { + sidebar.selectedTrigger = target; + closeSidebar(sidebar); + return; + } + sidebar.selectedTrigger = target; + showSidebar(sidebar); + initSidebarEvents(sidebar); + }; + + function showSidebar(sidebar) { // mobile layout only + Util.addClass(sidebar.element, sidebar.showClass); + getFocusableElements(sidebar); + Util.moveFocus(sidebar.element); + // change the overflow of the preventScrollEl + if(sidebar.preventScrollEl) sidebar.preventScrollEl.style.overflow = 'hidden'; + }; + + function closeSidebar(sidebar) { // mobile layout only + Util.removeClass(sidebar.element, sidebar.showClass); + sidebar.firstFocusable = null; + sidebar.lastFocusable = null; + if(sidebar.selectedTrigger) sidebar.selectedTrigger.focus(); + sidebar.element.removeAttribute('tabindex'); + //remove listeners + cancelSidebarEvents(sidebar); + // change the overflow of the preventScrollEl + if(sidebar.preventScrollEl) sidebar.preventScrollEl.style.overflow = ''; + }; + + function initSidebarEvents(sidebar) { // mobile layout only + //add event listeners + sidebar.element.addEventListener('keydown', handleEvent.bind(sidebar)); + sidebar.element.addEventListener('click', handleEvent.bind(sidebar)); + }; + + function cancelSidebarEvents(sidebar) { // mobile layout only + //remove event listeners + sidebar.element.removeEventListener('keydown', handleEvent.bind(sidebar)); + sidebar.element.removeEventListener('click', handleEvent.bind(sidebar)); + }; + + function handleEvent(event) { // mobile layout only + switch(event.type) { + case 'click': { + initClick(this, event); + } + case 'keydown': { + initKeyDown(this, event); + } + } + }; + + function initKeyDown(sidebar, event) { // mobile layout only + if( event.keyCode && event.keyCode == 27 || event.key && event.key == 'Escape' ) { + //close sidebar window on esc + closeSidebar(sidebar); + } else if( event.keyCode && event.keyCode == 9 || event.key && event.key == 'Tab' ) { + //trap focus inside sidebar + trapFocus(sidebar, event); + } + }; + + function initClick(sidebar, event) { // mobile layout only + //close sidebar when clicking on close button or sidebar bg layer + if( !event.target.closest('.js-sidebar__close-btn') && !Util.hasClass(event.target, 'js-sidebar') ) return; + event.preventDefault(); + closeSidebar(sidebar); + }; + + function trapFocus(sidebar, event) { // mobile layout only + if( sidebar.firstFocusable == document.activeElement && event.shiftKey) { + //on Shift+Tab -> focus last focusable element when focus moves out of sidebar + event.preventDefault(); + sidebar.lastFocusable.focus(); + } + if( sidebar.lastFocusable == document.activeElement && !event.shiftKey) { + //on Tab -> focus first focusable element when focus moves out of sidebar + event.preventDefault(); + sidebar.firstFocusable.focus(); + } + }; + + function initSidebarResize(sidebar) { + // custom event emitted when window is resized - detect only if the sidebar--static@{breakpoint} class was added + var beforeContent = getComputedStyle(sidebar.element, ':before').getPropertyValue('content'); + if(beforeContent && beforeContent !='' && beforeContent !='none') { + checkSidebarLayout(sidebar); + + sidebar.element.addEventListener('update-sidebar', function(event){ + checkSidebarLayout(sidebar); + }); + } + // check if there a main element to show + var mainContent = document.getElementsByClassName(sidebar.contentReadyClass); + if(mainContent.length > 0) Util.removeClass(mainContent[0], sidebar.contentReadyClass); + Util.addClass(sidebar.element, sidebar.readyClass); + }; + + function checkSidebarLayout(sidebar) { + var layout = getComputedStyle(sidebar.element, ':before').getPropertyValue('content').replace(/\'|"/g, ''); + if(layout == sidebar.layout) return; + sidebar.layout = layout; + if(layout != 'static') Util.addClass(sidebar.element, 'is-hidden'); + Util.toggleClass(sidebar.element, sidebar.staticClass + sidebar.customStaticClass, layout == 'static'); + if(layout != 'static') setTimeout(function(){Util.removeClass(sidebar.element, 'is-hidden')}); + // reset element role + (layout == 'static') ? sidebar.element.removeAttribute('role', 'alertdialog') : sidebar.element.setAttribute('role', 'alertdialog'); + // reset mobile behaviour + if(layout == 'static' && Util.hasClass(sidebar.element, sidebar.showClass)) closeSidebar(sidebar); + }; + + function getFocusableElements(sidebar) { + //get all focusable elements inside the drawer + var allFocusable = sidebar.element.querySelectorAll('[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, [tabindex]:not([tabindex="-1"]), [contenteditable], audio[controls], video[controls], summary'); + getFirstVisible(sidebar, allFocusable); + getLastVisible(sidebar, allFocusable); + }; + + function getFirstVisible(sidebar, elements) { + //get first visible focusable element inside the sidebar + for(var i = 0; i < elements.length; i++) { + if( elements[i].offsetWidth || elements[i].offsetHeight || elements[i].getClientRects().length ) { + sidebar.firstFocusable = elements[i]; + return true; + } + } + }; + + function getLastVisible(sidebar, elements) { + //get last visible focusable element inside the sidebar + for(var i = elements.length - 1; i >= 0; i--) { + if( elements[i].offsetWidth || elements[i].offsetHeight || elements[i].getClientRects().length ) { + sidebar.lastFocusable = elements[i]; + return true; + } + } + }; + + window.Sidebar = Sidebar; + + //initialize the Sidebar objects + var sidebar = document.getElementsByClassName('js-sidebar'); + if( sidebar.length > 0 ) { + for( var i = 0; i < sidebar.length; i++) { + (function(i){new Sidebar(sidebar[i]);})(i); + } + // switch from mobile to static layout + var customEvent = new CustomEvent('update-sidebar'); + window.addEventListener('resize', function(event){ + (!window.requestAnimationFrame) ? setTimeout(function(){resetLayout();}, 250) : window.requestAnimationFrame(resetLayout); + }); + + (window.requestAnimationFrame) // init sidebar layout + ? window.requestAnimationFrame(resetLayout) + : resetLayout(); + + function resetLayout() { + for( var i = 0; i < sidebar.length; i++) { + (function(i){sidebar[i].dispatchEvent(customEvent)})(i); + }; + }; + } +}());
\ No newline at end of file diff --git a/old-apps/projects/src/app/pages/nav/js/_2_side-navigation-v4.js b/old-apps/projects/src/app/pages/nav/js/_2_side-navigation-v4.js new file mode 100644 index 0000000..63ef9c4 --- /dev/null +++ b/old-apps/projects/src/app/pages/nav/js/_2_side-navigation-v4.js @@ -0,0 +1,73 @@ +// File#: _2_side-navigation-v4 +// Usage: codyhouse.co/license +(function() { + function initSideNav(nav) { + // create btns - visible on mobile only + createBtns(nav); + // toggle sublists on mobile when clicking on buttons + toggleSubLists(nav); + // init diagonal movement + initDiagonalMove(nav); + }; + + function createBtns(nav) { + // on mobile -> create a <button> element for each link with a submenu + var expandableLinks = nav.getElementsByClassName('js-sidenav-v4__link'); + for(var i = 0; i < expandableLinks.length; i++) { + createSingleBtn(expandableLinks[i]); + } + }; + + function createSingleBtn(link) { + if(!hasSubList(link)) return; + // create btn and insert it into the DOM + var btnClasses = link.getAttribute('class').replace('js-sidenav-v4__link', 'js-sidenav-v4__btn'); + btnClasses = btnClasses +' sidenav-v4__link--btn'; + var btnHtml = '<button class="reset '+btnClasses+'">'+link.innerHTML+'</button>'; + link.insertAdjacentHTML('afterend', btnHtml); + // add class to link element + Util.addClass(link, 'sidenav-v4__link--href'); + // check if we need to add the collpsed class to the <li> element + var listItem = link.parentElement; + if(!Util.hasClass(listItem, 'sidenav-v4__item--current')) Util.addClass(listItem, 'sidenav-v4__item--collapsed'); + }; + + function hasSubList(link) { + // check if link has submenu + var sublist = link.nextElementSibling; + if(!sublist) return false; + return Util.hasClass(sublist, 'sidenav-v4__sub-list'); + }; + + function toggleSubLists(nav) { + // open/close sublist on mobile + nav.addEventListener('click', function(event){ + var btn = event.target.closest('.js-sidenav-v4__btn'); + if(!btn) return; + Util.toggleClass(btn.parentElement, 'sidenav-v4__item--collapsed', !Util.hasClass(btn.parentElement, 'sidenav-v4__item--collapsed')); + }); + }; + + function initDiagonalMove(nav) { + // improve dropdown navigation + new menuAim({ + menu: nav.querySelector('ul'), + activate: function(row) { + Util.addClass(row, 'sidenav-v4__item--hover'); + }, + deactivate: function(row) { + Util.removeClass(row, 'sidenav-v4__item--hover'); + }, + exitMenu: function() { + return true; + }, + }); + }; + + var sideNavs = document.getElementsByClassName('js-sidenav-v4'); + if( sideNavs.length > 0 ) { + for( var i = 0; i < sideNavs.length; i++) { + (function(i){initSideNav(sideNavs[i]);})(i); + } + } +}());
\ No newline at end of file diff --git a/old-apps/projects/src/app/pages/nav/nav-item.svelte b/old-apps/projects/src/app/pages/nav/nav-item.svelte new file mode 100644 index 0000000..335cbbb --- /dev/null +++ b/old-apps/projects/src/app/pages/nav/nav-item.svelte @@ -0,0 +1,18 @@ +<script lang="ts"> + import {link} from "svelte-spa-router"; + import Icon from "$shared/components/icon.svelte"; + + export let external = ""; + export let to = ""; + export let text; + export let icon; +</script> + +<li class="sidenav-v4__item"> + <a class="sidenav-v4__link" href={to ?? external} use:link={external === ""}> + {#if icon} + <Icon class="sidenav-v4__icon icon" name="{icon}" /> + {/if} + <span>{text}</span> + </a> +</li> diff --git a/old-apps/projects/src/app/pages/nav/nav-wrapper.svelte b/old-apps/projects/src/app/pages/nav/nav-wrapper.svelte new file mode 100644 index 0000000..8321544 --- /dev/null +++ b/old-apps/projects/src/app/pages/nav/nav-wrapper.svelte @@ -0,0 +1,20 @@ +<script lang="ts"> + import {random_string} from "$shared/lib/helpers"; + + export let id = "nav__" + random_string(4); + const staticClasses = "position-relative z-index-2 bg width-100% max-width-xxxxs shadow-sm" +</script> +<div class="flex@md"> + <aside id="{id}" class="sidebar sidebar--static@md {staticClasses}"> + <div class="sidebar__panel"> + <div class="position-relative z-index-1"> + <nav class="sidenav-v4 padding-xs"> + <slot name="navigation-items"></slot> + </nav> + </div> + </div> + </aside> + <main class="container max-width-xl position-relative z-index-1 flex-grow min-height-100vh position-sticky@md top-0@md height-100vh@md overflow-auto@m"> + <slot name="main-content"></slot> + </main> +</div>
\ No newline at end of file diff --git a/old-apps/projects/src/app/pages/nav/scss/_1_responsive-sidebar.scss b/old-apps/projects/src/app/pages/nav/scss/_1_responsive-sidebar.scss new file mode 100644 index 0000000..e4304f1 --- /dev/null +++ b/old-apps/projects/src/app/pages/nav/scss/_1_responsive-sidebar.scss @@ -0,0 +1,147 @@ +@use '../base' as *; + +/* -------------------------------- + +File#: _1_responsive-sidebar +Title: Responsive Sidebar +Descr: Responsive sidebar container +Usage: codyhouse.co/license + +-------------------------------- */ + +/* mobile version only (--default) 👇 */ +.sidebar:not(.sidebar--static) { + position: fixed; + top: 0; + left: 0; + z-index: var(--z-index-fixed-element, 10); + width: 100%; + height: 100%; + visibility: hidden; + transition: visibility 0s 0.3s; + + &::after { /* overlay layer */ + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: alpha(var(--color-black), 0); + transition: background-color .3s; + z-index: 1; + } + + .sidebar__panel { /* content */ + position: absolute; + top: 0; + left: 0; + z-index: 2; + width: 100%; + max-width: 380px; + height: 100%; + overflow: auto; + -webkit-overflow-scrolling: touch; + background-color: var(--color-bg); + transform: translateX(-100%); + transition: box-shadow 0.3s,transform 0.3s; + } + + &.sidebar--right-on-mobile { + .sidebar__panel { + left: auto; + right: 0; + transform: translateX(100%); + } + } + + &.sidebar--is-visible { + visibility: visible; + transition: none; + + &::after { + background-color: alpha(var(--color-black), 0.85); + } + + .sidebar__panel { + transform: translateX(0); + box-shadow: var(--shadow-md); + } + } +} +/* end mobile version */ + +.sidebar__header { + display: flex; + align-items: center; + justify-content: space-between; + position: sticky; + top: 0; +} + +.sidebar__close-btn { + --size: 32px; + width: var(--size); + height: var(--size); + display: flex; + border-radius: 50%; + background-color: var(--color-bg-light); + box-shadow: var(--inner-glow), var(--shadow-sm); + transition: .2s; + flex-shrink: 0; + + .icon { + display: block; + margin: auto; + } + + &:hover { + background-color: var(--color-bg-lighter); + box-shadow: var(--inner-glow), var(--shadow-md); + } +} + +/* desktop version only (--static) 👇 */ +.sidebar--static { + flex-shrink: 0; + flex-grow: 1; + + .sidebar__header { + display: none; + } +} + +.sidebar--sticky-on-desktop { + position: sticky; + top: var(--space-sm); + max-height: calc(100vh - var(--space-sm)); + overflow: auto; + -webkit-overflow-scrolling: touch; +} +/* end desktop version */ + +.sidebar, .sidebar-loaded\:show { + opacity: 0; /* hide sidebar - or other elements using the .sidebar-loaded:show class - while it is initialized in JS */ +} + +.sidebar--loaded { + opacity: 1; +} + +/* detect when the sidebar needs to switch from the mobile layout to a static one - used in JS */ +[class*="sidebar--static"]::before { + display: none; +} + +.sidebar--static::before { + content: 'static'; +} + +@each $breakpoint, $value in $breakpoints { + .sidebar--static\@#{$breakpoint}::before { + content: 'mobile'; + @include breakpoint(#{$breakpoint}) { + content: 'static'; + } + } +}
\ No newline at end of file diff --git a/old-apps/projects/src/app/pages/nav/scss/_2_side-navigation-v4.scss b/old-apps/projects/src/app/pages/nav/scss/_2_side-navigation-v4.scss new file mode 100644 index 0000000..2b421df --- /dev/null +++ b/old-apps/projects/src/app/pages/nav/scss/_2_side-navigation-v4.scss @@ -0,0 +1,237 @@ +@use '../base' as *; +@use '_1_responsive-sidebar.scss' as *; + +/* -------------------------------- + +File#: _2_side-navigation-v4 +Title: Side Navigation v4 +Descr: Main, side navigation +Usage: codyhouse.co/license + +-------------------------------- */ + +.sidenav-v4 { + --sidenav-v4-icon-size: 20px; + --sidenav-v4-icon-margin-right: var(--space-xxs); +} + +.sidenav-v4__item { + position: relative; +} + +.sidenav-v4__link, +.sidenav-v4__sub-link, +.sidenav-v4__separator { + padding: var(--space-sm); +} + +.sidenav-v4__link, .sidenav-v4__sub-link { + display: flex; + align-items: center; + + width: 100%; + border-radius: var(--radius-md); + + text-decoration: none; + color: inherit; + line-height: 1; + font-size: var(--text-md); + + transition: .2s; + + &:hover { + color: var(--color-primary); + background-color: alpha(var(--color-contrast-higher), 0.075); + } + + &[aria-current="page"] { + color: var(--color-primary); + } +} + +.sidenav-v4__sub-link { + position: relative; + color: var(--color-contrast-medium); + + /* dot indicator */ + &::before { + content: ''; + display: block; + --size: 6px; + width: var(--size); + height: var(--size); + background: currentColor; + border-radius: 50%; + margin-left: calc(var(--sidenav-v4-icon-size)/2 - var(--size)/2); + margin-right: calc(var(--sidenav-v4-icon-size)/2 - var(--size)/2 + var(--sidenav-v4-icon-margin-right)); + + opacity: 0; /* visible only if current */ + } + + &[aria-current="page"] { + &::before { /* show dot indicator */ + opacity: 1; + } + } +} + +.sidenav-v4__notification-marker { + margin-left: auto; + background-color: var(--color-accent); + border-radius: var(--radius-md); + + height: 16px; + line-height: 16px; + padding: 0 4px; + color: var(--color-white); + font-size: 12px; + + /* hide - visible only on desktop */ + display: none; +} + +/* label icon */ +.sidenav-v4__icon { + --size: var(--sidenav-v4-icon-size); + margin-right: var(--sidenav-v4-icon-margin-right); +} + +/* arrow icon - visible on mobile if item is expandable */ +.sidenav-v4__arrow-icon { + --size: 20px; + + .icon__group { + will-change: transform; + transform-origin: 50% 50%; + transform: rotate(-90deg); + transition: transform .3s var(--ease-out); + + > * { + transform-origin: 50% 50%; + stroke-dasharray: 20; + stroke-dashoffset: 0; + transform: translateY(0px); + transition: transform .3s, stroke-dashoffset .3s; + transition-timing-function: var(--ease-out); + } + + .sidenav-v4__item--collapsed & { + transform: rotate(0deg); + + > * { + transform: translateY(4px); + } + + > *:first-child { + stroke-dashoffset: 10.15; + } + + > *:last-child { + stroke-dashoffset: 10.15; + } + } + } + + /* hide icon for links - show only for buttons created in JS */ + .sidenav-v4__link--href & { + display: none; + } +} + +/* current item */ +.sidenav-v4__item--current { + .sidenav-v4__sub-list { + display: block; /* show sublist */ + } +} + +/* separator */ +.sidenav-v4__separator { + span { + display: block; + width: var(--sidenav-v4-icon-size); + height: 1px; + background-color: var(--color-contrast-lower); + } +} + +/* mobile only */ +@include breakpoint(md, "not all") { + .sidenav-v4__item--collapsed { + .sidenav-v4__sub-list { + display: none; + } + } + + .sidenav-v4__link--href { + display: none; /* hide link -> show button */ + } +} + +/* desktop */ +@include breakpoint(md) { + .sidenav-v4__sub-list { + display: none; + } + + .sidenav-v4__link, + .sidenav-v4__sub-link, + .sidenav-v4__separator { + padding: var(--space-xs); + } + + .sidenav-v4__link, + .sidenav-v4__sub-link { + font-size: var(--text-sm); + } + + .sidenav-v4__link--btn { + display: none; /* hide button -> show link */ + } + + /* tooltip */ + .sidenav-v4__item:not(.sidenav-v4__item--current) { + .sidenav-v4__sub-list { + width: 220px; + position: absolute; + z-index: var(--z-index-overlay); + left: 100%; + top: 0; + + background-color: var(--color-bg-light); + box-shadow: var(--inner-glow), var(--shadow-md); + border-radius: var(--radius-md); + + overflow: hidden; + } + + .sidenav-v4__sub-link { + border-radius: 0; + color: var(--color-contrast-high); + + &::before { + display: none; /* remove dot indicator */ + } + + &:hover { + color: var(--color-primary); + } + } + + &.sidenav-v4__item--hover, &:focus-within { + .sidenav-v4__sub-list { + display: block; + } + } + + &:hover .sidenav-v4__link { /* highlight main link if tooltip is visible */ + color: var(--color-primary); + background-color: alpha(var(--color-contrast-higher), 0.075); + } + } + + /* notification marker */ + .sidenav-v4__notification-marker { + display: block; + } +}
\ No newline at end of file diff --git a/old-apps/projects/src/app/pages/nav/side-navigation-v4.zip b/old-apps/projects/src/app/pages/nav/side-navigation-v4.zip Binary files differnew file mode 100644 index 0000000..d034eaf --- /dev/null +++ b/old-apps/projects/src/app/pages/nav/side-navigation-v4.zip diff --git a/old-apps/projects/src/app/pages/not-found.svelte b/old-apps/projects/src/app/pages/not-found.svelte new file mode 100644 index 0000000..8822e0e --- /dev/null +++ b/old-apps/projects/src/app/pages/not-found.svelte @@ -0,0 +1,25 @@ +<script> + import LL from "$app/lib/i18n/i18n-svelte"; + 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>{$LL.messages.pageNotFound()}</p> + <a use:link + href="/">{$LL.messages.goToFrontpage()}</a> +</main> diff --git a/old-apps/projects/src/app/pages/settings.svelte b/old-apps/projects/src/app/pages/settings.svelte new file mode 100644 index 0000000..ca9fd47 --- /dev/null +++ b/old-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/old-apps/projects/src/app/pages/ui-workbench.svelte b/old-apps/projects/src/app/pages/ui-workbench.svelte new file mode 100644 index 0000000..ff2b058 --- /dev/null +++ b/old-apps/projects/src/app/pages/ui-workbench.svelte @@ -0,0 +1,7 @@ +<script> + import {NavWrapper} from "./nav/index"; +</script> + +<NavWrapper> + +</NavWrapper>
\ No newline at end of file diff --git a/old-apps/projects/src/app/pages/views/category-form/index.svelte b/old-apps/projects/src/app/pages/views/category-form/index.svelte new file mode 100644 index 0000000..21024c3 --- /dev/null +++ b/old-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, {create_label_async} from "$app/lib/stores/labels"; + import {generate_random_hex_color} from "$shared/lib/colors"; + import LL from "$app/lib/i18n/i18n-svelte"; + + 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">{$LL.views.categoryForm.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">{$LL.views.categoryForm.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">{$LL.views.categoryForm.defaultLabels()}</label> + <Dropdown id="labels" + createable={true} + placeholder="{$LL.views.categoryForm.labelsPlaceholder()}" + 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/old-apps/projects/src/app/pages/views/data-table-paginator.svelte b/old-apps/projects/src/app/pages/views/data-table-paginator.svelte new file mode 100644 index 0000000..b2649eb --- /dev/null +++ b/old-apps/projects/src/app/pages/views/data-table-paginator.svelte @@ -0,0 +1,101 @@ +<script> + import LL from "$app/lib/i18n/i18n-svelte"; + 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>{$LL.views.dataTablePaginator.goToPrevPage()}</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>{$LL.views.dataTablePaginator.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>{$LL.views.dataTablePaginator.goToNextPage()}</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/old-apps/projects/src/app/pages/views/entry-form/index.svelte b/old-apps/projects/src/app/pages/views/entry-form/index.svelte new file mode 100644 index 0000000..e43d2a9 --- /dev/null +++ b/old-apps/projects/src/app/pages/views/entry-form/index.svelte @@ -0,0 +1,199 @@ +<script lang="ts"> + import LL from "$app/lib/i18n/i18n-svelte"; + 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 isSubmitting = 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 = $LL.views.entryForm.errDescriptionReq(); + } 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; + isSubmitting = true; + if (is_guid(payload.id)) { + const response = await edit_entry_async(payload); + if (response.ok) { + functions.reset(); + dispatch("updated", response.data); + } else { + formError = $LL.views.entryForm.entryUpdateError(); + isSubmitting = false; + } + } else { + const response = await create_entry_async(payload); + if (response.ok) { + functions.reset(); + dispatch("created"); + } else { + formError = $LL.views.entryForm.entryCreateError(); + isSubmitting = 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() { + isSubmitting = false; + 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="{$LL.views.entryForm.description()}" + errorText="{descriptionError}" + bind:value={description}></Textarea> + </div> + + <div class="flex flex-row justify-end gap-x-xs"> + {#if entryId} + <Button text="{$LL.views.entryForm.reset()}" + on:click={() => functions.reset()} + variant="subtle" + /> + {/if} + <Button loading={isSubmitting} + type="submit" + variant="primary" + text={entryId ? $LL.views.entryForm.save() : $LL.views.entryForm.create()} + /> + </div> +</form> diff --git a/old-apps/projects/src/app/pages/views/entry-form/sections/category.svelte b/old-apps/projects/src/app/pages/views/entry-form/sections/category.svelte new file mode 100644 index 0000000..f7af382 --- /dev/null +++ b/old-apps/projects/src/app/pages/views/entry-form/sections/category.svelte @@ -0,0 +1,76 @@ +<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"; + import LL from "$app/lib/i18n/i18n-svelte" + + let categoriesError = ""; + let loading = false; + + let DropdownExports; + + function reset() { + DropdownExports.reset(); + categoriesError = ""; + console.log($LL.views.entryForm.category._logReset()); + } + + 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 = $LL.views.entryForm.category.errisRequired(); + 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="{$LL.views.entryForm.category.category()}" + maxlength="50" + createable={true} + placeholder="{$LL.views.entryForm.category.placeholder()}" + id="category-dropdown" + loading={loading} + name="category-dropdown" + on_create_async={on_create} + noResultsText="{$LL.views.entryForm.category.noResults()}" + errorText="{categoriesError}" + bind:this={DropdownExports} +/> + diff --git a/old-apps/projects/src/app/pages/views/entry-form/sections/date-time.svelte b/old-apps/projects/src/app/pages/views/entry-form/sections/date-time.svelte new file mode 100644 index 0000000..b91f1a4 --- /dev/null +++ b/old-apps/projects/src/app/pages/views/entry-form/sections/date-time.svelte @@ -0,0 +1,167 @@ +<script lang="ts"> + import LL from "$app/lib/i18n/i18n-svelte"; + 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 = $LL.views.entryForm.dateTime.errDateIsRequired(); + isValid = false; + if (!focusIsSet) { + document.getElementById("date")?.focus(); + focusIsSet = true; + } + } else { + dateError = ""; + } + + if (!fromTimeValue) { + fromTimeError = $LL.views.entryForm.dateTime.errFromIsRequired(); + isValid = false; + if (!focusIsSet) { + document.getElementById("from")?.focus(); + focusIsSet = true; + } + } else if (toTimeValue && fromTimeValue > toTimeValue) { + fromTimeError = $LL.views.entryForm.dateTime.errFromAfterTo(); + isValid = false; + if (!focusIsSet) { + document.getElementById("from")?.focus(); + focusIsSet = true; + } + } else if (fromTimeValue === toTimeValue) { + fromTimeError = $LL.views.entryForm.dateTime.errFromEqTo(); + + isValid = false; + if (!focusIsSet) { + document.getElementById("from")?.focus(); + focusIsSet = true; + } + } else { + fromTimeError = ""; + } + + if (!toTimeValue) { + toTimeError = $LL.views.entryForm.dateTime.errToIsRequired(); + isValid = false; + if (!focusIsSet) { + document.getElementById("to")?.focus(); + focusIsSet = true; + } + } else if (fromTimeValue && toTimeValue < fromTimeValue) { + toTimeError = $LL.views.entryForm.dateTime.errToBeforeFrom(); + 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(); + } + console.log($LL.views.entryForm.dateTime._logReset()); + }, + set_times(value) { + fromTimeValue = value.from.toPlainTime().toString().substring(0, 5); + toTimeValue = value.to.toPlainTime().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">{$LL.views.entryForm.dateTime.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">{$LL.views.entryForm.dateTime.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">{$LL.views.entryForm.dateTime.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/old-apps/projects/src/app/pages/views/entry-form/sections/labels.svelte b/old-apps/projects/src/app/pages/views/entry-form/sections/labels.svelte new file mode 100644 index 0000000..a6f324b --- /dev/null +++ b/old-apps/projects/src/app/pages/views/entry-form/sections/labels.svelte @@ -0,0 +1,66 @@ +<script> + import LL from "$app/lib/i18n/i18n-svelte"; + 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($LL.views.entryForm.labels._logReset()); + } + + 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="{$LL.views.entryForm.labels.labels()}" + maxlength="50" + createable={true} + placeholder="{$LL.views.entryForm.labels.placeholder()}" + multiple="{true}" + id="labels-search" + name="labels-search" + on_create_async={on_create} + noResultsText="{$LL.views.entryForm.labels.placeholder()}" + errorText="{labelsError}" + bind:this={DropdownExports} + {loading} +/> diff --git a/old-apps/projects/src/app/pages/views/profile-modal.svelte b/old-apps/projects/src/app/pages/views/profile-modal.svelte new file mode 100644 index 0000000..7560175 --- /dev/null +++ b/old-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 {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() { + alert("Not implemented"); + return; + if (understands && confirm("Are you absolutely sure that you want to delete your account?")) { + } + } + + 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/old-apps/projects/src/app/pages/views/settings-categories-tile.svelte b/old-apps/projects/src/app/pages/views/settings-categories-tile.svelte new file mode 100644 index 0000000..8d2480f --- /dev/null +++ b/old-apps/projects/src/app/pages/views/settings-categories-tile.svelte @@ -0,0 +1,126 @@ +<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"; + import LL from "$app/lib/i18n/i18n-svelte"; + + 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($LL.views.settingsCategoriesTile.deleteAllConfirm()) + ) { + 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">{$LL.views.settingsCategoriesTile.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">{$LL.views.settingsCategoriesTile.active()} ({active_categories.length})</a></li> + <li><a class="s-tabs__link" + href="#0">{$LL.views.settingsCategoriesTile.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"> + {$LL.views.settingsCategoriesTile.name()} + </TCell> + <TCell type="th" + thScope="col"> + {$LL.views.settingsCategoriesTile.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="{$LL.views.settingsCategoriesTile.editEntry()}"/> + <Button icon="{IconNames.trash}" + variant="reset" + icon_width="1.2rem" + icon_height="1.2rem" + on:click={handle_delete_category_click} + title="{$LL.views.settingsCategoriesTile.deleteEntry()}"/> + + </TCell> + </TRow> + {/each} + {:else} + <TRow> + <TCell type="th" + thScope="3"> + {$LL.views.settingsCategoriesTile.noCategories()} + </TCell> + </TRow> + {/if} + </TBody> + </Table> + </div> +</Tile> diff --git a/old-apps/projects/src/app/pages/views/settings-labels-tile.svelte b/old-apps/projects/src/app/pages/views/settings-labels-tile.svelte new file mode 100644 index 0000000..3d5a567 --- /dev/null +++ b/old-apps/projects/src/app/pages/views/settings-labels-tile.svelte @@ -0,0 +1,111 @@ +<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"; + import LL from "$app/lib/i18n/i18n-svelte"; + + let isLoadingLabels = true; + + $: active_labels = $labels.filter(c => !c.archived); + $: archived_labels = $labels.filter(c => c.archived); + + async function load_labels() { + isLoadingLabels = true; + await reload_labels(); + isLoadingLabels = 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($LL.views.settingsLabelsTile.deleteAllConfirm()) + ) { + await delete_label_async({id: row.dataset.id}); + row.classList.add("d-none"); + } + } + + onMount(() => { + load_labels(); + }); +</script> + +<Tile class="col-6@md col-12 {isLoadingLabels ? 'c-disabled loading' : ''}"> + <h2 class="margin-bottom-xxs">{$LL.views.settingsLabelsTile.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">{$LL.views.settingsLabelsTile.active()} ({active_labels.length})</a></li> + <li><a class="s-tabs__link" + href="#0">{$LL.views.settingsLabelsTile.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"> + {$LL.views.settingsLabelsTile.name()} + </TCell> + <TCell type="th" + thScope="row"> + {$LL.views.settingsLabelsTile.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="{$LL.views.settingsLabelsTile.editEntry()}"/> + <Button icon="{IconNames.trash}" + variant="reset" + icon_width="1.2rem" + icon_height="1.2rem" + on:click={handle_delete_label_click} + title="{$LL.views.settingsLabelsTile.deleteEntry()}"/> + </TCell> + </TRow> + {/each} + {:else} + <TRow> + <TCell type="th" + thScope="row" + colspan="3"> + {$LL.views.settingsLabelsTile.noLabels()} + </TCell> + </TRow> + {/if} + </TBody> + </Table> + </div> +</Tile> |
