summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorivarlovlie <git@ivarlovlie.no>2022-06-01 22:10:32 +0200
committerivarlovlie <git@ivarlovlie.no>2022-06-01 22:10:32 +0200
commita640703f2da8815dc26ad1600a6f206be1624379 (patch)
treedbda195fb5783d16487e557e06471cf848b75427
downloadgreatoffice-a640703f2da8815dc26ad1600a6f206be1624379.tar.xz
greatoffice-a640703f2da8815dc26ad1600a6f206be1624379.zip
feat: Initial after clean slate
-rw-r--r--.gitignore479
-rw-r--r--COPYING674
-rw-r--r--README.md56
-rw-r--r--apps/accounts/.version1
-rw-r--r--apps/accounts/.version-dev1
-rw-r--r--apps/accounts/CHANGELOG.md53
-rwxr-xr-xapps/accounts/build_and_push.sh76
-rw-r--r--apps/accounts/cliff.toml62
-rw-r--r--apps/accounts/src/_assets/pre.css128
-rw-r--r--apps/accounts/src/_assets/pwa/android-chrome-192x192.pngbin0 -> 3291 bytes
-rw-r--r--apps/accounts/src/_assets/pwa/android-chrome-512x512.pngbin0 -> 9687 bytes
-rw-r--r--apps/accounts/src/_assets/pwa/apple-touch-icon.pngbin0 -> 2769 bytes
-rw-r--r--apps/accounts/src/_assets/pwa/browserconfig.xml9
-rw-r--r--apps/accounts/src/_assets/pwa/favicon-16x16.pngbin0 -> 636 bytes
-rw-r--r--apps/accounts/src/_assets/pwa/favicon-32x32.pngbin0 -> 907 bytes
-rw-r--r--apps/accounts/src/_assets/pwa/favicon.icobin0 -> 15086 bytes
-rw-r--r--apps/accounts/src/_assets/pwa/favicon.svg4
-rw-r--r--apps/accounts/src/_assets/pwa/manifest.json28
-rw-r--r--apps/accounts/src/_assets/pwa/mstile-144x144.pngbin0 -> 3109 bytes
-rw-r--r--apps/accounts/src/_assets/pwa/mstile-150x150.pngbin0 -> 3238 bytes
-rw-r--r--apps/accounts/src/_assets/pwa/mstile-310x150.pngbin0 -> 3501 bytes
-rw-r--r--apps/accounts/src/_assets/pwa/mstile-310x310.pngbin0 -> 6823 bytes
-rw-r--r--apps/accounts/src/_assets/pwa/mstile-70x70.pngbin0 -> 2238 bytes
-rw-r--r--apps/accounts/src/_assets/pwa/safari-pinned-tab.svg50
-rw-r--r--apps/accounts/src/app/index.d.ts48
-rw-r--r--apps/accounts/src/app/index.scss21
-rw-r--r--apps/accounts/src/app/index.svelte61
-rw-r--r--apps/accounts/src/app/index.ts14
-rw-r--r--apps/accounts/src/app/pages/_layout.svelte142
-rw-r--r--apps/accounts/src/app/pages/forgot.svelte99
-rw-r--r--apps/accounts/src/app/pages/login.svelte145
-rw-r--r--apps/accounts/src/app/pages/not-found.svelte23
-rw-r--r--apps/accounts/src/app/pages/reset-password.svelte135
-rw-r--r--apps/accounts/src/app/pages/sign-up.svelte128
-rw-r--r--apps/accounts/src/index.html63
-rw-r--r--apps/accounts/src/package.json22
-rw-r--r--apps/accounts/src/pnpm-lock.yaml769
-rw-r--r--apps/accounts/src/tsconfig.json30
-rw-r--r--apps/accounts/src/vite.config.ts30
-rw-r--r--apps/frontpage/.gitignore8
-rw-r--r--apps/frontpage/.version1
-rw-r--r--apps/frontpage/.version-dev1
-rw-r--r--apps/frontpage/CHANGELOG.md9
-rw-r--r--apps/frontpage/README.md3
-rwxr-xr-xapps/frontpage/build_and_push.sh73
-rw-r--r--apps/frontpage/cliff.toml62
-rw-r--r--apps/frontpage/index.html10
-rw-r--r--apps/frontpage/package.json25
-rw-r--r--apps/frontpage/playwright.config.ts10
-rw-r--r--apps/frontpage/pnpm-lock.yaml925
-rw-r--r--apps/frontpage/src/app.d.ts10
-rw-r--r--apps/frontpage/src/app.html12
-rw-r--r--apps/frontpage/src/routes/index.svelte1
-rw-r--r--apps/frontpage/static/favicon.pngbin0 -> 1571 bytes
-rw-r--r--apps/frontpage/svelte.config.js20
-rw-r--r--apps/frontpage/tests/test.ts6
-rw-r--r--apps/frontpage/tsconfig.json13
-rw-r--r--apps/projects-web/.version1
-rw-r--r--apps/projects-web/.version-dev1
-rw-r--r--apps/projects-web/CHANGELOG.md67
-rwxr-xr-xapps/projects-web/build_and_push.sh76
-rw-r--r--apps/projects-web/cliff.toml62
-rw-r--r--apps/projects-web/src/_assets/pre.css128
-rw-r--r--apps/projects-web/src/_assets/pwa/android-chrome-192x192.pngbin0 -> 3291 bytes
-rw-r--r--apps/projects-web/src/_assets/pwa/android-chrome-512x512.pngbin0 -> 9687 bytes
-rw-r--r--apps/projects-web/src/_assets/pwa/apple-touch-icon.pngbin0 -> 2769 bytes
-rw-r--r--apps/projects-web/src/_assets/pwa/browserconfig.xml9
-rw-r--r--apps/projects-web/src/_assets/pwa/favicon-16x16.pngbin0 -> 636 bytes
-rw-r--r--apps/projects-web/src/_assets/pwa/favicon-32x32.pngbin0 -> 907 bytes
-rw-r--r--apps/projects-web/src/_assets/pwa/favicon.icobin0 -> 15086 bytes
-rw-r--r--apps/projects-web/src/_assets/pwa/favicon.svg4
-rw-r--r--apps/projects-web/src/_assets/pwa/manifest.json28
-rw-r--r--apps/projects-web/src/_assets/pwa/mstile-144x144.pngbin0 -> 3109 bytes
-rw-r--r--apps/projects-web/src/_assets/pwa/mstile-150x150.pngbin0 -> 3238 bytes
-rw-r--r--apps/projects-web/src/_assets/pwa/mstile-310x150.pngbin0 -> 3501 bytes
-rw-r--r--apps/projects-web/src/_assets/pwa/mstile-310x310.pngbin0 -> 6823 bytes
-rw-r--r--apps/projects-web/src/_assets/pwa/mstile-70x70.pngbin0 -> 2238 bytes
-rw-r--r--apps/projects-web/src/_assets/pwa/safari-pinned-tab.svg50
-rw-r--r--apps/projects-web/src/app/index.d.ts48
-rw-r--r--apps/projects-web/src/app/index.html63
-rw-r--r--apps/projects-web/src/app/index.scss38
-rw-r--r--apps/projects-web/src/app/index.svelte56
-rw-r--r--apps/projects-web/src/app/index.ts16
-rw-r--r--apps/projects-web/src/app/lib/services/user-service.ts21
-rw-r--r--apps/projects-web/src/app/lib/stores/categories.ts44
-rw-r--r--apps/projects-web/src/app/lib/stores/entries.ts74
-rw-r--r--apps/projects-web/src/app/lib/stores/labels.ts44
-rw-r--r--apps/projects-web/src/app/pages/_layout.svelte79
-rw-r--r--apps/projects-web/src/app/pages/data.svelte392
-rw-r--r--apps/projects-web/src/app/pages/home.svelte167
-rw-r--r--apps/projects-web/src/app/pages/not-found.svelte24
-rw-r--r--apps/projects-web/src/app/pages/settings.svelte12
-rw-r--r--apps/projects-web/src/app/pages/ui-workbench.svelte48
-rw-r--r--apps/projects-web/src/app/pages/views/category-form/index.svelte144
-rw-r--r--apps/projects-web/src/app/pages/views/data-table-paginator.svelte107
-rw-r--r--apps/projects-web/src/app/pages/views/entry-form/index.svelte196
-rw-r--r--apps/projects-web/src/app/pages/views/entry-form/sections/category.svelte75
-rw-r--r--apps/projects-web/src/app/pages/views/entry-form/sections/date-time.svelte165
-rw-r--r--apps/projects-web/src/app/pages/views/entry-form/sections/labels.svelte65
-rw-r--r--apps/projects-web/src/app/pages/views/profile-modal.svelte156
-rw-r--r--apps/projects-web/src/app/pages/views/settings-categories-tile.svelte127
-rw-r--r--apps/projects-web/src/app/pages/views/settings-labels-tile.svelte112
-rw-r--r--apps/projects-web/src/index.html63
-rw-r--r--apps/projects-web/src/package.json22
-rw-r--r--apps/projects-web/src/pnpm-lock.yaml769
-rw-r--r--apps/projects-web/src/tsconfig.json27
-rw-r--r--apps/projects-web/src/vite.config.ts31
-rw-r--r--apps/web-shared/package.json12
-rw-r--r--apps/web-shared/pnpm-lock.yaml769
-rw-r--r--apps/web-shared/src/components/alert.svelte66
-rw-r--r--apps/web-shared/src/components/button.svelte116
-rw-r--r--apps/web-shared/src/components/chip.svelte50
-rw-r--r--apps/web-shared/src/components/details.svelte35
-rw-r--r--apps/web-shared/src/components/dropdown.svelte374
-rw-r--r--apps/web-shared/src/components/form/index.ts5
-rw-r--r--apps/web-shared/src/components/form/textarea.svelte48
-rw-r--r--apps/web-shared/src/components/icon.svelte87
-rw-r--r--apps/web-shared/src/components/menu/index.ts9
-rw-r--r--apps/web-shared/src/components/menu/item.svelte8
-rw-r--r--apps/web-shared/src/components/menu/menu.svelte54
-rw-r--r--apps/web-shared/src/components/menu/separator.svelte2
-rw-r--r--apps/web-shared/src/components/modal.svelte66
-rw-r--r--apps/web-shared/src/components/pre-header.svelte37
-rw-r--r--apps/web-shared/src/components/stopwatch.svelte161
-rw-r--r--apps/web-shared/src/components/table/index.ts15
-rw-r--r--apps/web-shared/src/components/table/paginator.svelte101
-rw-r--r--apps/web-shared/src/components/table/table.svelte3
-rw-r--r--apps/web-shared/src/components/table/tbody.svelte3
-rw-r--r--apps/web-shared/src/components/table/tcell.svelte23
-rw-r--r--apps/web-shared/src/components/table/thead.svelte10
-rw-r--r--apps/web-shared/src/components/table/trow.svelte6
-rw-r--r--apps/web-shared/src/components/tile.svelte4
-rw-r--r--apps/web-shared/src/lib/api/internal-fetch.ts170
-rw-r--r--apps/web-shared/src/lib/api/root.ts6
-rw-r--r--apps/web-shared/src/lib/api/time-entry.ts86
-rw-r--r--apps/web-shared/src/lib/api/user.ts47
-rw-r--r--apps/web-shared/src/lib/colors.ts47
-rw-r--r--apps/web-shared/src/lib/configuration.ts60
-rw-r--r--apps/web-shared/src/lib/helpers.ts489
-rw-r--r--apps/web-shared/src/lib/models/CreateAccountPayload.ts4
-rw-r--r--apps/web-shared/src/lib/models/ErrorResult.ts4
-rw-r--r--apps/web-shared/src/lib/models/IInternalFetchRequest.ts6
-rw-r--r--apps/web-shared/src/lib/models/IInternalFetchResponse.ts6
-rw-r--r--apps/web-shared/src/lib/models/ISession.ts7
-rw-r--r--apps/web-shared/src/lib/models/IValidationResult.ts31
-rw-r--r--apps/web-shared/src/lib/models/LoginPayload.ts4
-rw-r--r--apps/web-shared/src/lib/models/TimeCategoryDto.ts9
-rw-r--r--apps/web-shared/src/lib/models/TimeEntryDto.ts13
-rw-r--r--apps/web-shared/src/lib/models/TimeEntryQuery.ts27
-rw-r--r--apps/web-shared/src/lib/models/TimeLabelDto.ts8
-rw-r--r--apps/web-shared/src/lib/models/TimeQueryDto.ts29
-rw-r--r--apps/web-shared/src/lib/models/UnwrappedEntryDateTime.ts9
-rw-r--r--apps/web-shared/src/lib/models/UpdateProfilePayload.ts4
-rw-r--r--apps/web-shared/src/lib/persistent-store.ts102
-rw-r--r--apps/web-shared/src/lib/session.ts62
-rw-r--r--apps/web-shared/src/styles/_base.scss48
-rw-r--r--apps/web-shared/src/styles/base/_accessibility.scss17
-rw-r--r--apps/web-shared/src/styles/base/_breakpoints.scss15
-rw-r--r--apps/web-shared/src/styles/base/_buttons.scss24
-rw-r--r--apps/web-shared/src/styles/base/_colors.scss6
-rw-r--r--apps/web-shared/src/styles/base/_forms.scss22
-rw-r--r--apps/web-shared/src/styles/base/_grid-layout.scss261
-rw-r--r--apps/web-shared/src/styles/base/_icons.scss62
-rw-r--r--apps/web-shared/src/styles/base/_mixins.scss151
-rw-r--r--apps/web-shared/src/styles/base/_reset.scss83
-rw-r--r--apps/web-shared/src/styles/base/_shared-styles.scss34
-rw-r--r--apps/web-shared/src/styles/base/_spacing.scss20
-rw-r--r--apps/web-shared/src/styles/base/_typography.scss185
-rw-r--r--apps/web-shared/src/styles/base/_util.scss1738
-rw-r--r--apps/web-shared/src/styles/base/_visibility.scss23
-rw-r--r--apps/web-shared/src/styles/base/_z-index.scss6
-rw-r--r--apps/web-shared/src/styles/components/alert.scss69
-rw-r--r--apps/web-shared/src/styles/components/autocomplete.scss76
-rw-r--r--apps/web-shared/src/styles/components/btn-states.scss51
-rw-r--r--apps/web-shared/src/styles/components/chip.scss117
-rw-r--r--apps/web-shared/src/styles/components/circle-loader.scss315
-rw-r--r--apps/web-shared/src/styles/components/custom-checkbox.scss131
-rw-r--r--apps/web-shared/src/styles/components/custom-select.scss158
-rw-r--r--apps/web-shared/src/styles/components/details.scss57
-rw-r--r--apps/web-shared/src/styles/components/dropdown.scss98
-rw-r--r--apps/web-shared/src/styles/components/form-validator.scss18
-rw-r--r--apps/web-shared/src/styles/components/interactive-table.scss156
-rw-r--r--apps/web-shared/src/styles/components/list.scss195
-rw-r--r--apps/web-shared/src/styles/components/menu-bar.scss139
-rw-r--r--apps/web-shared/src/styles/components/menu.scss81
-rw-r--r--apps/web-shared/src/styles/components/modal.scss105
-rw-r--r--apps/web-shared/src/styles/components/pagination.scss77
-rw-r--r--apps/web-shared/src/styles/components/popover.scss38
-rw-r--r--apps/web-shared/src/styles/components/pre-header.scss46
-rw-r--r--apps/web-shared/src/styles/components/radios-checkboxes.scss134
-rw-r--r--apps/web-shared/src/styles/components/select-autocomplete.scss173
-rw-r--r--apps/web-shared/src/styles/components/tabbed-navigation.scss133
-rw-r--r--apps/web-shared/src/styles/components/table.scss147
-rw-r--r--apps/web-shared/src/styles/components/user-menu.scss81
-rw-r--r--apps/web-shared/src/styles/custom-style/_buttons.scss111
-rw-r--r--apps/web-shared/src/styles/custom-style/_colors.scss119
-rw-r--r--apps/web-shared/src/styles/custom-style/_forms.scss56
-rw-r--r--apps/web-shared/src/styles/custom-style/_icons.scss19
-rw-r--r--apps/web-shared/src/styles/custom-style/_shared-styles.scss59
-rw-r--r--apps/web-shared/src/styles/custom-style/_spacing.scss49
-rw-r--r--apps/web-shared/src/styles/custom-style/_typography.scss92
-rw-r--r--apps/web-shared/src/styles/custom-style/_util.scss41
-rw-r--r--apps/web-shared/tsconfig.json24
-rwxr-xr-xcloc.sh2
-rw-r--r--server/.dockerignore10
-rw-r--r--server/.version1
-rw-r--r--server/.version-dev1
-rw-r--r--server/CHANGELOG.md68
-rw-r--r--server/Dockerfile16
-rwxr-xr-xserver/build_and_push.sh96
-rw-r--r--server/cliff.toml62
-rw-r--r--server/src/Data/AppDbContext.cs58
-rw-r--r--server/src/Data/Database/ApiAccessToken.cs31
-rw-r--r--server/src/Data/Database/Base.cs14
-rw-r--r--server/src/Data/Database/BaseWithOwner.cs24
-rw-r--r--server/src/Data/Database/ForgotPasswordRequest.cs23
-rw-r--r--server/src/Data/Database/GithubUserMapping.cs9
-rw-r--r--server/src/Data/Database/Tenant.cs10
-rw-r--r--server/src/Data/Database/TimeCategory.cs31
-rw-r--r--server/src/Data/Database/TimeEntry.cs45
-rw-r--r--server/src/Data/Database/TimeLabel.cs31
-rw-r--r--server/src/Data/Database/User.cs29
-rw-r--r--server/src/Data/Dtos/TimeQueryDto.cs34
-rw-r--r--server/src/Data/Dtos/UserArchiveDto.cs131
-rw-r--r--server/src/Data/Enums/TimeEntryQueryDuration.cs37
-rw-r--r--server/src/Data/Exceptions/ForgotPasswordRequestNotFoundException.cs21
-rw-r--r--server/src/Data/Exceptions/UserNotFoundException.cs19
-rw-r--r--server/src/Data/Models/ApiSpecDocument.cs9
-rw-r--r--server/src/Data/Models/AppPath.cs23
-rw-r--r--server/src/Data/Models/LoggedInUserModel.cs8
-rw-r--r--server/src/Data/Results/ErrorResult.cs12
-rw-r--r--server/src/Data/Static/AppClaims.cs8
-rw-r--r--server/src/Data/Static/AppConstants.cs11
-rw-r--r--server/src/Data/Static/AppEnvironmentVariables.cs27
-rw-r--r--server/src/Data/Static/AppHeaders.cs6
-rw-r--r--server/src/Data/Static/AppPaths.cs17
-rw-r--r--server/src/Data/Static/JsonSettings.cs11
-rw-r--r--server/src/Endpoints/Internal/Account/CreateAccountPayload.cs17
-rw-r--r--server/src/Endpoints/Internal/Account/CreateAccountRoute.cs44
-rw-r--r--server/src/Endpoints/Internal/Account/CreateGithubSessionRoute.cs17
-rw-r--r--server/src/Endpoints/Internal/Account/CreateInitialAccountRoute.cs34
-rw-r--r--server/src/Endpoints/Internal/Account/DeleteAccountRoute.cs49
-rw-r--r--server/src/Endpoints/Internal/Account/GetArchiveRoute.cs62
-rw-r--r--server/src/Endpoints/Internal/Account/GetRoute.cs30
-rw-r--r--server/src/Endpoints/Internal/Account/LoginPayload.cs22
-rw-r--r--server/src/Endpoints/Internal/Account/LoginRoute.cs37
-rw-r--r--server/src/Endpoints/Internal/Account/LogoutRoute.cs22
-rw-r--r--server/src/Endpoints/Internal/Account/UpdateAccountPayload.cs17
-rw-r--r--server/src/Endpoints/Internal/Account/UpdateAccountRoute.cs51
-rw-r--r--server/src/Endpoints/Internal/BaseRoute.cs16
-rw-r--r--server/src/Endpoints/Internal/PasswordResetRequests/CreateResetRequestRoute.cs59
-rw-r--r--server/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestPayload.cs14
-rw-r--r--server/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestRoute.cs34
-rw-r--r--server/src/Endpoints/Internal/PasswordResetRequests/IsResetRequestValidRoute.cs29
-rw-r--r--server/src/Endpoints/Internal/Root/GetApplicationVersionRoute.cs21
-rw-r--r--server/src/Endpoints/Internal/Root/LogRoute.cs16
-rw-r--r--server/src/Endpoints/Internal/RouteBaseAsync.cs73
-rw-r--r--server/src/Endpoints/Internal/RouteBaseSync.cs53
-rw-r--r--server/src/Endpoints/V1/ApiSpecV1.cs18
-rw-r--r--server/src/Endpoints/V1/ApiTokens/CreateTokenRoute.cs52
-rw-r--r--server/src/Endpoints/V1/ApiTokens/DeleteTokenRoute.cs33
-rw-r--r--server/src/Endpoints/V1/ApiTokens/GetTokensRoute.cs22
-rw-r--r--server/src/Endpoints/V1/BaseRoute.cs39
-rw-r--r--server/src/Endpoints/V1/Categories/CreateCategoryRoute.cs43
-rw-r--r--server/src/Endpoints/V1/Categories/DeleteCategoryRoute.cs38
-rw-r--r--server/src/Endpoints/V1/Categories/GetCategoriesRoute.cs35
-rw-r--r--server/src/Endpoints/V1/Categories/UpdateCategoryRoute.cs39
-rw-r--r--server/src/Endpoints/V1/Entries/CreateEntryRoute.cs65
-rw-r--r--server/src/Endpoints/V1/Entries/DeleteEntryRoute.cs35
-rw-r--r--server/src/Endpoints/V1/Entries/EntryQueryPayload.cs60
-rw-r--r--server/src/Endpoints/V1/Entries/EntryQueryResponse.cs37
-rw-r--r--server/src/Endpoints/V1/Entries/EntryQueryRoute.cs186
-rw-r--r--server/src/Endpoints/V1/Entries/GetEntryRoute.cs34
-rw-r--r--server/src/Endpoints/V1/Entries/UpdateEntryRoute.cs66
-rw-r--r--server/src/Endpoints/V1/Labels/CreateLabelRoute.cs46
-rw-r--r--server/src/Endpoints/V1/Labels/DeleteLabelRoute.cs35
-rw-r--r--server/src/Endpoints/V1/Labels/GetLabelRoute.cs34
-rw-r--r--server/src/Endpoints/V1/Labels/UpdateLabelRoute.cs38
-rw-r--r--server/src/Endpoints/V1/RouteBaseAsync.cs73
-rw-r--r--server/src/Endpoints/V1/RouteBaseSync.cs53
-rw-r--r--server/src/IOL.GreatOffice.Api.csproj48
-rw-r--r--server/src/Jobs/JobRegister.cs18
-rw-r--r--server/src/Jobs/TokenCleanupJob.cs21
-rw-r--r--server/src/Migrations/20210517202115_InitialMigration.Designer.cs238
-rw-r--r--server/src/Migrations/20210517202115_InitialMigration.cs162
-rw-r--r--server/src/Migrations/20210522165932_RenameNoteToDescription.Designer.cs229
-rw-r--r--server/src/Migrations/20210522165932_RenameNoteToDescription.cs34
-rw-r--r--server/src/Migrations/20211002113037_V6Migration.Designer.cs233
-rw-r--r--server/src/Migrations/20211002113037_V6Migration.cs130
-rw-r--r--server/src/Migrations/20220225143559_GithubUserMappings.Designer.cs270
-rw-r--r--server/src/Migrations/20220225143559_GithubUserMappings.cs43
-rw-r--r--server/src/Migrations/20220319135910_RenameCreated.Designer.cs270
-rw-r--r--server/src/Migrations/20220319135910_RenameCreated.cs65
-rw-r--r--server/src/Migrations/20220319144958_ModifiedAt.Designer.cs290
-rw-r--r--server/src/Migrations/20220319144958_ModifiedAt.cs66
-rw-r--r--server/src/Migrations/20220319203018_UserBase.Designer.cs322
-rw-r--r--server/src/Migrations/20220319203018_UserBase.cs140
-rw-r--r--server/src/Migrations/20220320115601_Update1.Designer.cs342
-rw-r--r--server/src/Migrations/20220320115601_Update1.cs139
-rw-r--r--server/src/Migrations/20220320132220_UpdatedForgotPasswordRequests.Designer.cs344
-rw-r--r--server/src/Migrations/20220320132220_UpdatedForgotPasswordRequests.cs57
-rw-r--r--server/src/Migrations/20220529190359_ApiAccessTokens.Designer.cs401
-rw-r--r--server/src/Migrations/20220529190359_ApiAccessTokens.cs48
-rw-r--r--server/src/Migrations/20220530174741_Tenants.Designer.cs710
-rw-r--r--server/src/Migrations/20220530174741_Tenants.cs481
-rw-r--r--server/src/Migrations/20220530175322_RemoveUnusedNavs.Designer.cs686
-rw-r--r--server/src/Migrations/20220530175322_RemoveUnusedNavs.cs78
-rw-r--r--server/src/Migrations/AppDbContextModelSnapshot.cs684
-rw-r--r--server/src/Program.cs261
-rw-r--r--server/src/Properties/launchSettings.json14
-rw-r--r--server/src/Services/ForgotPasswordService.cs115
-rw-r--r--server/src/Services/MailService.cs52
-rw-r--r--server/src/Services/UserService.cs50
-rw-r--r--server/src/Utilities/BasicAuthenticationAttribute.cs39
-rw-r--r--server/src/Utilities/BasicAuthenticationHandler.cs79
-rw-r--r--server/src/Utilities/ConfigurationExtensions.cs37
-rw-r--r--server/src/Utilities/GithubAuthenticationHelpers.cs84
-rw-r--r--server/src/Utilities/QuartzJsonSerializer.cs16
-rw-r--r--server/src/Utilities/SwaggerDefaultValues.cs58
-rw-r--r--server/src/Utilities/SwaggerGenOptionsExtensions.cs43
-rw-r--r--server/src/appsettings.json22
-rw-r--r--server/src/wwwroot/version.txt1
-rw-r--r--sql/quartz-create.sql156
-rw-r--r--sql/quartz-drop.sql23
-rw-r--r--tests/IOL.GreatOffice.IntegrationTests/ApplicationTests/LoginPageTests.cs23
-rw-r--r--tests/IOL.GreatOffice.IntegrationTests/Helpers/Element.cs6
-rw-r--r--tests/IOL.GreatOffice.IntegrationTests/Helpers/WebServerFixture.cs48
-rw-r--r--tests/IOL.GreatOffice.IntegrationTests/IOL.GreatOffice.IntegrationTests.csproj25
328 files changed, 27739 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e301ba6
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,479 @@
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+##
+## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
+
+# User-specific files
+*.rsuser
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# User-specific files (MonoDevelop/Xamarin Studio)
+*.userprefs
+
+# Mono auto generated files
+mono_crash.*
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
+[Bb]in/
+[Oo]bj/
+[Ll]og/
+[Ll]ogs/
+
+# Visual Studio 2015/2017 cache/options directory
+.vs/
+# Uncomment if you have tasks that create the project's static files in wwwroot
+#wwwroot/
+
+# Visual Studio 2017 auto generated files
+Generated\ Files/
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+# NUnit
+*.VisualState.xml
+TestResult.xml
+nunit-*.xml
+
+# Build Results of an ATL Project
+[Dd]ebugPS/
+[Rr]eleasePS/
+dlldata.c
+
+# Benchmark Results
+BenchmarkDotNet.Artifacts/
+
+# .NET Core
+project.lock.json
+project.fragment.lock.json
+artifacts/
+
+# StyleCop
+StyleCopReport.xml
+
+# Files built by Visual Studio
+*_i.c
+*_p.c
+*_h.h
+*.ilk
+*.meta
+*.obj
+*.iobj
+*.pch
+*.pdb
+*.ipdb
+*.pgc
+*.pgd
+*.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*_wpftmp.csproj
+*.log
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+
+# Chutzpah Test files
+_Chutzpah*
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opendb
+*.opensdf
+*.sdf
+*.cachefile
+*.VC.db
+*.VC.VC.opendb
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+*.sap
+
+# Visual Studio Trace Files
+*.e2e
+
+# TFS 2012 Local Workspace
+$tf/
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*/
+*.[Rr]e[Ss]harper
+*.DotSettings.user
+
+# TeamCity is a build add-in
+_TeamCity*
+
+# DotCover is a Code Coverage Tool
+*.dotCover
+
+# AxoCover is a Code Coverage Tool
+.axoCover/*
+!.axoCover/settings.json
+
+# Coverlet is a free, cross platform Code Coverage Tool
+coverage*[.json, .xml, .info]
+
+# Visual Studio code coverage results
+*.coverage
+*.coveragexml
+
+# NCrunch
+_NCrunch_*
+.*crunch*.local.xml
+nCrunchTemp_*
+
+# MightyMoose
+*.mm.*
+AutoTest.Net/
+
+# Web workbench (sass)
+.sass-cache/
+
+# Installshield output folder
+[Ee]xpress/
+
+# DocProject is a documentation generator add-in
+DocProject/buildhelp/
+DocProject/Help/*.HxT
+DocProject/Help/*.HxC
+DocProject/Help/*.hhc
+DocProject/Help/*.hhk
+DocProject/Help/*.hhp
+DocProject/Help/Html2
+DocProject/Help/html
+
+# Click-Once directory
+publish/
+
+# Publish Web Output
+*.[Pp]ublish.xml
+*.azurePubxml
+# Note: Comment the next line if you want to checkin your web deploy settings,
+# but database connection strings (with potential passwords) will be unencrypted
+*.pubxml
+*.publishproj
+
+# Microsoft Azure Web App publish settings. Comment the next line if you want to
+# checkin your Azure Web App publish settings, but sensitive information contained
+# in these scripts will be unencrypted
+PublishScripts/
+
+# NuGet Packages
+*.nupkg
+# NuGet Symbol Packages
+*.snupkg
+# The packages folder can be ignored because of Package Restore
+**/[Pp]ackages/*
+# except build/, which is used as an MSBuild target.
+!**/[Pp]ackages/build/
+# Uncomment if necessary however generally it will be regenerated when needed
+#!**/[Pp]ackages/repositories.config
+# NuGet v3's project.json files produces more ignorable files
+*.nuget.props
+*.nuget.targets
+
+# Microsoft Azure Build Output
+csx/
+*.build.csdef
+
+# Microsoft Azure Emulator
+ecf/
+rcf/
+
+# Windows Store app package directories and files
+AppPackages/
+BundleArtifacts/
+Package.StoreAssociation.xml
+_pkginfo.txt
+*.appx
+*.appxbundle
+*.appxupload
+
+# Visual Studio cache files
+# files ending in .cache can be ignored
+*.[Cc]ache
+# but keep track of directories ending in .cache
+!?*.[Cc]ache/
+
+# Others
+ClientBin/
+~$*
+*~
+*.dbmdl
+*.dbproj.schemaview
+*.jfm
+*.pfx
+*.publishsettings
+orleans.codegen.cs
+
+# Including strong name files can present a security risk
+# (https://github.com/github/gitignore/pull/2483#issue-259490424)
+#*.snk
+
+# Since there are multiple workflows, uncomment next line to ignore bower_components
+# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
+#bower_components/
+
+# RIA/Silverlight projects
+Generated_Code/
+
+# Backup & report files from converting an old project file
+# to a newer Visual Studio version. Backup files are not needed,
+# because we have git ;-)
+_UpgradeReport_Files/
+Backup*/
+UpgradeLog*.XML
+UpgradeLog*.htm
+ServiceFabricBackup/
+*.rptproj.bak
+
+# SQL Server files
+*.mdf
+*.ldf
+*.ndf
+
+# Business Intelligence projects
+*.rdl.data
+*.bim.layout
+*.bim_*.settings
+*.rptproj.rsuser
+*- [Bb]ackup.rdl
+*- [Bb]ackup ([0-9]).rdl
+*- [Bb]ackup ([0-9][0-9]).rdl
+
+# Microsoft Fakes
+FakesAssemblies/
+
+# GhostDoc plugin setting file
+*.GhostDoc.xml
+
+# Node.js Tools for Visual Studio
+.ntvs_analysis.dat
+node_modules/
+
+# Visual Studio 6 build log
+*.plg
+
+# Visual Studio 6 workspace options file
+*.opt
+
+# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
+*.vbw
+
+# Visual Studio LightSwitch build output
+**/*.HTMLClient/GeneratedArtifacts
+**/*.DesktopClient/GeneratedArtifacts
+**/*.DesktopClient/ModelManifest.xml
+**/*.Server/GeneratedArtifacts
+**/*.Server/ModelManifest.xml
+_Pvt_Extensions
+
+# Paket dependency manager
+.paket/paket.exe
+paket-files/
+
+# FAKE - F# Make
+.fake/
+
+# CodeRush personal settings
+.cr/personal
+
+# Python Tools for Visual Studio (PTVS)
+__pycache__/
+*.pyc
+
+# Cake - Uncomment if you are using it
+# tools/**
+# !tools/packages.config
+
+# Tabs Studio
+*.tss
+
+# Telerik's JustMock configuration file
+*.jmconfig
+
+# BizTalk build output
+*.btp.cs
+*.btm.cs
+*.odx.cs
+*.xsd.cs
+
+# OpenCover UI analysis results
+OpenCover/
+
+# Azure Stream Analytics local run output
+ASALocalRun/
+
+# MSBuild Binary and Structured Log
+*.binlog
+
+# NVidia Nsight GPU debugger configuration file
+*.nvuser
+
+# MFractors (Xamarin productivity tool) working folder
+.mfractor/
+
+# Local History for Visual Studio
+.localhistory/
+
+# BeatPulse healthcheck temp database
+healthchecksdb
+
+# Backup folder for Package Reference Convert tool in Visual Studio 2017
+MigrationBackup/
+
+# Ionide (cross platform F# VS Code tools) working folder
+.ionide/
+
+##
+## Visual studio for Mac
+##
+
+
+# globs
+Makefile.in
+*.userprefs
+*.usertasks
+config.make
+config.status
+aclocal.m4
+install-sh
+autom4te.cache/
+*.tar.gz
+tarballs/
+test-results/
+
+# Mac bundle stuff
+*.dmg
+*.app
+
+# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore
+# Windows thumbnail cache files
+Thumbs.db
+ehthumbs.db
+ehthumbs_vista.db
+
+# Dump file
+*.stackdump
+
+# Folder config file
+[Dd]esktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Windows Installer files
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# Windows shortcuts
+*.lnk
+
+# JetBrains Rider
+.idea/
+*.sln.iml
+
+##
+## Visual Studio Code
+##
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+.build
+build
+web_modules
+server-secrets.*
+src/server/wwwroot
+src/server/AppData
+/.svelte-kit
+/package
+**/dist
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+.svelte
+bin
+obj
+AppData
diff --git a/COPYING b/COPYING
new file mode 100644
index 0000000..f288702
--- /dev/null
+++ b/COPYING
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ <program> Copyright (C) <year> <name of author>
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+<https://www.gnu.org/licenses/>.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+<https://www.gnu.org/licenses/why-not-lgpl.html>.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..05e6ce1
--- /dev/null
+++ b/README.md
@@ -0,0 +1,56 @@
+# Great Office
+
+[Changelog](CHANGELOG.md)
+
+## server
+
+Contains an ASP.NET Core Web API project using the [ApiEndpoints](https://github.com/ardalis/ApiEndpoints) paradigm.
+
+It handles all data operation and administration for the platform.
+
+To run it you need .NET 6 and a PostgreSQL instance.
+
+### Environment
+
+The server is configured through environments variables,
+in development [user-secret](https://docs.microsoft.com/en-us/aspnet/core/security/app-secrets) is a nifty tool.
+
+All environment variables the server needs to function properly is specified
+in [src/server/Data/Static/AppEnvironmentVariables.cs](./server/src/Data/Static/AppEnvironmentVariables.cs),
+at a bare minimum these variables needs to be set:
+
+```
+DB_HOST=<host>
+DB_USER=<user>
+DB_PASSWORD=<password>
+DB_NAME=<schema>
+```
+
+### Building/Developing
+
+To run the server in development mode use `dotnet run` (`dotnet watch` for hot-reloading).
+
+To build the server locally use `dotnet build` or `dotnet build -c Release` for production builds.
+
+## tests
+
+Contains integration tests for the web-app, written in .NET and xunit with Playwright for browser mocking.
+
+It automatically starts the server and expects the server to host the web-app at /index.html.
+
+Use `dotnet run` to run the tests.
+
+## apps/web-app
+
+A svelte project built with vite.
+
+Run the dev task in package.json to open a dev server at localhost:3xxx (usually :3000). Append `--host` as a parameter to this task if you need to expose the app on your local
+network.
+
+Contains the public sites (in `apps/web-app/_public`) which include login, signup and password resetting etc. and the meat of the application as a nested application (meaning it is
+not loaded unless you are logged in)
+in `apps/web-app/app`.
+
+## apps/web-shared
+
+A source lib containing models, shared styles and shared components for all of great office's js clients.
diff --git a/apps/accounts/.version b/apps/accounts/.version
new file mode 100644
index 0000000..722aa6d
--- /dev/null
+++ b/apps/accounts/.version
@@ -0,0 +1 @@
+v1-accounts
diff --git a/apps/accounts/.version-dev b/apps/accounts/.version-dev
new file mode 100644
index 0000000..6eeb9c2
--- /dev/null
+++ b/apps/accounts/.version-dev
@@ -0,0 +1 @@
+v12-accounts-dev
diff --git a/apps/accounts/CHANGELOG.md b/apps/accounts/CHANGELOG.md
new file mode 100644
index 0000000..4d4ed65
--- /dev/null
+++ b/apps/accounts/CHANGELOG.md
@@ -0,0 +1,53 @@
+# Changelog
+
+## [unreleased]
+
+### Miscellaneous Tasks
+
+- Bump version
+- Update CHANGELOG.md for v4-projects-dev
+- Bump version
+- Update CHANGELOG.md for v3-projects-dev
+- Bump version
+- Bump version
+- Update CHANGELOG.md for v12-accounts-dev
+
+### Refactor
+
+- Name path basers based on the app it bases
+
+## [unreleased]
+
+### Miscellaneous Tasks
+
+- Bump version
+- Update CHANGELOG.md for v11-accounts-dev
+
+## [unreleased]
+
+### Miscellaneous Tasks
+
+- Bump version
+- Update CHANGELOG.md for v14-web-app-dev
+- Bump version
+- Update CHANGELOG.md for v9-web-app-dev
+
+## [unreleased]
+
+### Bug Fixes
+
+- .
+
+### Miscellaneous Tasks
+
+- Bump version
+- Update CHANGELOG.md for v8-web-app-dev
+
+## [unreleased]
+
+### Miscellaneous Tasks
+
+- Bump version
+- Bump version
+- Update CHANGELOG.md for v7-web-app-dev
+
diff --git a/apps/accounts/build_and_push.sh b/apps/accounts/build_and_push.sh
new file mode 100755
index 0000000..bd349ff
--- /dev/null
+++ b/apps/accounts/build_and_push.sh
@@ -0,0 +1,76 @@
+#!/usr/bin/env bash
+
+set -Eueo pipefail
+
+APP_NAME="accounts";
+CURRENT_DEV_VERSION=$(cat .version-dev)
+CURRENT_DEV_VERSION_INT=${CURRENT_DEV_VERSION//[!0-9]/}
+CURRENT_VERSION=$(cat .version)
+CURRENT_VERSION_INT=${CURRENT_VERSION//[!0-9]/}
+if [ ${1-prod} == "dev" ]; then
+ NEW_VERSION="v$((CURRENT_DEV_VERSION_INT+1))-$APP_NAME-dev"
+ OLD_VERSION=$CURRENT_DEV_VERSION
+else
+ NEW_VERSION="v$((CURRENT_VERSION_INT+1))-$APP_NAME"
+ OLD_VERSION=$CURRENT_VERSION
+fi
+# Check for uncommited changes and optionally commit them
+if [ "$(git status --untracked-files=no --porcelain)" ]; then
+ echo "Unclean git tree! press CTRL+C to exit or press ENTER to commit and push to the default branch"
+ read -n 1
+
+ read -p "Enter commit message: " COMMIT_MESSAGE
+ git add ../../
+ git commit --quiet -m "$COMMIT_MESSAGE"
+fi
+
+if [ ${1-prod} == "dev" ]; then
+ echo $NEW_VERSION >| .version-dev
+ git add .version-dev
+else
+ echo $NEW_VERSION >| .version
+ git add .version
+fi
+
+echo "Starting build of $APP_NAME@$NEW_VERSION at $(date -u)..."
+echo
+
+git commit --quiet -m "chore(release): Bump version";
+
+read -p "Do you want to tag this build? (y/n) " -n 1 -r
+echo
+if [[ $REPLY =~ ^[Yy]$ ]]
+then
+ read -p "Enter tag message (can be empty): " TAG_MESSAGE
+ commit_msg="chore(release): Update CHANGELOG.md for $NEW_VERSION"
+ git cliff -r ../../ $OLD_VERSION..HEAD --with-commit "$commit_msg" --prepend CHANGELOG.md
+ git add CHANGELOG.md
+ git commit --quiet -m "$commit_msg";
+ git tag -am "$TAG_MESSAGE" $NEW_VERSION
+fi
+
+read -p "Do you want to push the latest commits and tags to origin? (y/n) " -n 1 -r
+echo
+if [[ $REPLY =~ ^[Yy]$ ]]
+then
+ echo "Pushing latest changes to remotes..."
+ echo
+ git push --quiet --follow-tags
+fi
+
+pushd src
+pnpm run build
+
+cd build
+echo "$NEW_VERSION" >version.txt
+
+
+if [ ${1-prod} == "dev" ]; then
+ scp -r * contabo-fast-1:services/public/a.dev.greatoffice.life/www
+else
+ echo "Pushing to production in 10 sec, press CTRL+C to cancel"
+ sleep 10
+ scp -r * contabo-fast-1:services/public/a.greatoffice.life/www
+fi
+
+popd
diff --git a/apps/accounts/cliff.toml b/apps/accounts/cliff.toml
new file mode 100644
index 0000000..955a72b
--- /dev/null
+++ b/apps/accounts/cliff.toml
@@ -0,0 +1,62 @@
+# configuration file for git-cliff (0.1.0)
+
+[changelog]
+# changelog header
+header = """
+# Changelog\n
+"""
+# template for the changelog body
+# https://tera.netlify.app/docs/#introduction
+body = """
+{% if version %}\
+ ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
+{% else %}\
+ ## [unreleased]
+{% endif %}\
+{% for group, commits in commits | group_by(attribute="group") %}
+ ### {{ group | upper_first }}
+ {% for commit in commits %}
+ - {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}\
+ {% endfor %}
+{% endfor %}\n
+"""
+# remove the leading and trailing whitespace from the template
+trim = true
+# changelog footer
+footer = """
+<!-- generated by git-cliff -->
+"""
+
+[git]
+# parse the commits based on https://www.conventionalcommits.org
+conventional_commits = true
+# filter out the commits that are not conventional
+filter_unconventional = true
+# regex for preprocessing the commit messages
+commit_preprocessors = [
+ { pattern = "([ \\n])(([a-f0-9]{7})[a-f0-9]*)", replace = "${1}commit # [${3}](https://git.ivarlovlie.no/time-tracker/commit/${2})" },
+ { pattern = "https://git.ivarlovlie.no/time-tracker/commit/([a-f0-9]{7})[a-f0-9]*", replace = "commit # [${1}](${0})" },
+]
+# regex for parsing and grouping commits
+commit_parsers = [
+ { message = "^feat", group = "Features" },
+ { message = "^fix", group = "Bug Fixes" },
+ { message = "^doc", group = "Documentation" },
+ { message = "^perf", group = "Performance" },
+ { message = "^refactor", group = "Refactor" },
+ { message = "^style", group = "Styling" },
+ { message = "^test", group = "Testing" },
+ { message = "^chore", group = "Miscellaneous Tasks" },
+]
+# filter out the commits that are not matched by commit parsers
+filter_commits = true
+# glob pattern for matching git tags
+tag_pattern = "v.*"
+# regex for skipping tags
+skip_tags = "v0.1.0-beta.1"
+# regex for ignoring tags
+ignore_tags = ""
+# sort the tags chronologically
+date_order = true
+# sort the commits inside sections by oldest/newest order
+sort_commits = "newest"
diff --git a/apps/accounts/src/_assets/pre.css b/apps/accounts/src/_assets/pre.css
new file mode 100644
index 0000000..9c9446e
--- /dev/null
+++ b/apps/accounts/src/_assets/pre.css
@@ -0,0 +1,128 @@
+:root {
+ --loader-primary: hsl(250, 84%, 54%);
+ --loader-accent: hsl(342, 89%, 48%);
+ --loader-contrast: hsl(180, 1%, 84%);
+ --loader-easing: cubic-bezier(0.645, 0.045, 0.355, 1);
+}
+
+[data-theme="dark"] :root {
+ --loader-primary: hsl(250, 93%, 65%);
+ --loader-accent: hsl(342, 92%, 54%);
+ --loader-contrast: hsl(208, 12%, 24%);
+ --loader-easing: cubic-bezier(0.645, 0.045, 0.355, 1);
+}
+
+[data-theme="dark"] {
+ background-color: hsl(203, 24%, 13%);
+}
+
+.fill-loader {
+ position: relative;
+ overflow: hidden;
+ display: inline-block;
+ margin: 3rem;
+}
+
+.fill-loader__fill {
+ position: absolute;
+}
+
+@supports (-webkit-animation-name: this) or (animation-name: this) {
+ .fill-loader__label {
+ position: absolute;
+ clip: rect(1px, 1px, 1px, 1px);
+ -webkit-clip-path: inset(50%);
+ clip-path: inset(50%);
+ }
+}
+
+@supports (-webkit-animation-name: this) or (animation-name: this) {
+ .fill-loader--v4 {
+ width: 90%;
+ max-width: 300px;
+ }
+
+ .fill-loader--v4 .fill-loader__base {
+ height: 4px;
+ background-color: var(--loader-contrast);
+ }
+
+ .fill-loader--v4 .fill-loader__fill {
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 100%;
+ background-color: var(--loader-primary);
+ -webkit-animation: fill-loader-4 1.6s infinite var(--loader-easing);
+ animation: fill-loader-4 1.6s infinite var(--loader-easing);
+ will-change: left, right;
+ }
+}
+
+@-webkit-keyframes fill-loader-4 {
+ 0% {
+ left: 0;
+ right: 100%;
+ background-color: var(--loader-primary);
+ }
+
+ 10%,
+ 60% {
+ left: 0;
+ }
+
+ 40%,
+ 90% {
+ right: 0;
+ }
+
+ 50% {
+ left: 100%;
+ background-color: var(--loader-primary);
+ }
+
+ 51% {
+ left: 0;
+ right: 100%;
+ background-color: var(--loader-accent);
+ }
+
+ 100% {
+ left: 100%;
+ background-color: var(--loader-accent);
+ }
+}
+
+@keyframes fill-loader-4 {
+ 0% {
+ left: 0;
+ right: 100%;
+ background-color: var(--loader-primary);
+ }
+
+ 10%,
+ 60% {
+ left: 0;
+ }
+
+ 40%,
+ 90% {
+ right: 0;
+ }
+
+ 50% {
+ left: 100%;
+ background-color: var(--loader-primary);
+ }
+
+ 51% {
+ left: 0;
+ right: 100%;
+ background-color: var(--loader-accent);
+ }
+
+ 100% {
+ left: 100%;
+ background-color: var(--loader-accent);
+ }
+}
diff --git a/apps/accounts/src/_assets/pwa/android-chrome-192x192.png b/apps/accounts/src/_assets/pwa/android-chrome-192x192.png
new file mode 100644
index 0000000..5c098bc
--- /dev/null
+++ b/apps/accounts/src/_assets/pwa/android-chrome-192x192.png
Binary files differ
diff --git a/apps/accounts/src/_assets/pwa/android-chrome-512x512.png b/apps/accounts/src/_assets/pwa/android-chrome-512x512.png
new file mode 100644
index 0000000..973a1c3
--- /dev/null
+++ b/apps/accounts/src/_assets/pwa/android-chrome-512x512.png
Binary files differ
diff --git a/apps/accounts/src/_assets/pwa/apple-touch-icon.png b/apps/accounts/src/_assets/pwa/apple-touch-icon.png
new file mode 100644
index 0000000..b4d9773
--- /dev/null
+++ b/apps/accounts/src/_assets/pwa/apple-touch-icon.png
Binary files differ
diff --git a/apps/accounts/src/_assets/pwa/browserconfig.xml b/apps/accounts/src/_assets/pwa/browserconfig.xml
new file mode 100644
index 0000000..b3930d0
--- /dev/null
+++ b/apps/accounts/src/_assets/pwa/browserconfig.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<browserconfig>
+ <msapplication>
+ <tile>
+ <square150x150logo src="/mstile-150x150.png"/>
+ <TileColor>#da532c</TileColor>
+ </tile>
+ </msapplication>
+</browserconfig>
diff --git a/apps/accounts/src/_assets/pwa/favicon-16x16.png b/apps/accounts/src/_assets/pwa/favicon-16x16.png
new file mode 100644
index 0000000..5dde9f9
--- /dev/null
+++ b/apps/accounts/src/_assets/pwa/favicon-16x16.png
Binary files differ
diff --git a/apps/accounts/src/_assets/pwa/favicon-32x32.png b/apps/accounts/src/_assets/pwa/favicon-32x32.png
new file mode 100644
index 0000000..9cef4c4
--- /dev/null
+++ b/apps/accounts/src/_assets/pwa/favicon-32x32.png
Binary files differ
diff --git a/apps/accounts/src/_assets/pwa/favicon.ico b/apps/accounts/src/_assets/pwa/favicon.ico
new file mode 100644
index 0000000..89c7542
--- /dev/null
+++ b/apps/accounts/src/_assets/pwa/favicon.ico
Binary files differ
diff --git a/apps/accounts/src/_assets/pwa/favicon.svg b/apps/accounts/src/_assets/pwa/favicon.svg
new file mode 100644
index 0000000..964dbb8
--- /dev/null
+++ b/apps/accounts/src/_assets/pwa/favicon.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-stopwatch" viewBox="0 0 16 16">
+ <path d="M8.5 5.6a.5.5 0 1 0-1 0v2.9h-3a.5.5 0 0 0 0 1H8a.5.5 0 0 0 .5-.5V5.6z"/>
+ <path d="M6.5 1A.5.5 0 0 1 7 .5h2a.5.5 0 0 1 0 1v.57c1.36.196 2.594.78 3.584 1.64a.715.715 0 0 1 .012-.013l.354-.354-.354-.353a.5.5 0 0 1 .707-.708l1.414 1.415a.5.5 0 1 1-.707.707l-.353-.354-.354.354a.512.512 0 0 1-.013.012A7 7 0 1 1 7 2.071V1.5a.5.5 0 0 1-.5-.5zM8 3a6 6 0 1 0 .001 12A6 6 0 0 0 8 3z"/>
+</svg> \ No newline at end of file
diff --git a/apps/accounts/src/_assets/pwa/manifest.json b/apps/accounts/src/_assets/pwa/manifest.json
new file mode 100644
index 0000000..4c550fe
--- /dev/null
+++ b/apps/accounts/src/_assets/pwa/manifest.json
@@ -0,0 +1,28 @@
+{
+ "manifest_version": 2,
+ "version": "0.1",
+ "name": "Time Tracker",
+ "short_name": "Time Tracker",
+ "display": "standalone",
+ "background_color": "#fff",
+ "theme_color": "#4D3DF7",
+ "start_url": ".",
+ "orientation": "portrait",
+ "icons": [
+ {
+ "src": "/favicon.svg",
+ "purpose": "maskable any",
+ "sizes": "any"
+ },
+ {
+ "src": "/pwa/android-chrome-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "/pwa/android-chrome-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ }
+ ]
+}
diff --git a/apps/accounts/src/_assets/pwa/mstile-144x144.png b/apps/accounts/src/_assets/pwa/mstile-144x144.png
new file mode 100644
index 0000000..84d94cb
--- /dev/null
+++ b/apps/accounts/src/_assets/pwa/mstile-144x144.png
Binary files differ
diff --git a/apps/accounts/src/_assets/pwa/mstile-150x150.png b/apps/accounts/src/_assets/pwa/mstile-150x150.png
new file mode 100644
index 0000000..b1398ae
--- /dev/null
+++ b/apps/accounts/src/_assets/pwa/mstile-150x150.png
Binary files differ
diff --git a/apps/accounts/src/_assets/pwa/mstile-310x150.png b/apps/accounts/src/_assets/pwa/mstile-310x150.png
new file mode 100644
index 0000000..76b16a0
--- /dev/null
+++ b/apps/accounts/src/_assets/pwa/mstile-310x150.png
Binary files differ
diff --git a/apps/accounts/src/_assets/pwa/mstile-310x310.png b/apps/accounts/src/_assets/pwa/mstile-310x310.png
new file mode 100644
index 0000000..d8e4097
--- /dev/null
+++ b/apps/accounts/src/_assets/pwa/mstile-310x310.png
Binary files differ
diff --git a/apps/accounts/src/_assets/pwa/mstile-70x70.png b/apps/accounts/src/_assets/pwa/mstile-70x70.png
new file mode 100644
index 0000000..0df1e8c
--- /dev/null
+++ b/apps/accounts/src/_assets/pwa/mstile-70x70.png
Binary files differ
diff --git a/apps/accounts/src/_assets/pwa/safari-pinned-tab.svg b/apps/accounts/src/_assets/pwa/safari-pinned-tab.svg
new file mode 100644
index 0000000..ba2220c
--- /dev/null
+++ b/apps/accounts/src/_assets/pwa/safari-pinned-tab.svg
@@ -0,0 +1,50 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000"
+ preserveAspectRatio="xMidYMid meet">
+<metadata>
+Created by potrace 1.14, written by Peter Selinger 2001-2017
+</metadata>
+<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M3195 6780 c-116 -3 -211 -10 -226 -17 -39 -17 -105 -98 -116 -142
+-19 -72 -2 -146 45 -202 26 -31 96 -69 131 -72 25 -2 31 -6 32 -27 1 -27 1
+-198 0 -216 -1 -6 -47 -19 -103 -28 -160 -28 -451 -107 -533 -146 -11 -5 -51
+-21 -90 -36 -60 -23 -246 -112 -325 -155 -431 -236 -834 -619 -1101 -1045
+-207 -328 -364 -733 -423 -1089 -51 -307 -61 -583 -31 -875 26 -261 119 -615
+225 -861 185 -430 432 -773 800 -1108 75 -69 387 -301 405 -301 1 0 33 -18 70
+-40 209 -128 602 -288 796 -325 12 -2 29 -7 39 -10 72 -23 273 -56 435 -73
+144 -14 601 -5 658 13 7 2 37 7 67 10 273 33 616 141 904 283 725 357 1275
+982 1542 1754 55 159 113 395 129 523 4 28 8 57 10 65 2 8 7 47 10 85 3 39 8
+93 10 120 6 66 6 327 0 390 -2 28 -7 82 -10 120 -3 39 -11 99 -16 135 -6 36
+-13 79 -16 95 -15 98 -61 279 -103 405 -121 372 -298 694 -542 993 -27 32 -48
+61 -48 65 0 4 35 41 78 84 l77 76 90 -90 c53 -54 108 -99 134 -110 62 -28 130
+-25 191 8 95 52 135 151 103 257 -13 46 -44 79 -362 397 -322 323 -351 349
+-398 363 -148 44 -287 -61 -285 -215 1 -62 35 -118 126 -208 47 -47 86 -87 86
+-90 0 -6 -91 -101 -132 -138 l-25 -23 -46 38 c-264 223 -584 405 -924 528 -92
+34 -320 100 -376 109 -15 3 -35 7 -45 10 -9 3 -34 7 -54 10 -86 13 -113 18
+-117 22 -2 2 -4 56 -4 121 l0 118 29 9 c66 19 114 47 139 80 72 95 65 215 -18
+296 -58 56 -83 60 -402 63 -159 1 -380 0 -490 -3z m560 -1104 c224 -24 547
+-99 670 -156 11 -5 56 -24 100 -41 90 -37 282 -134 306 -155 8 -8 19 -14 23
+-14 13 0 192 -124 286 -199 97 -77 297 -270 364 -351 237 -288 405 -598 509
+-941 30 -98 44 -157 72 -299 3 -14 8 -47 11 -75 3 -27 7 -56 10 -63 22 -69 21
+-519 -1 -642 -1 -8 -6 -40 -10 -70 -4 -30 -9 -64 -11 -75 -2 -11 -7 -33 -10
+-50 -3 -16 -14 -66 -26 -110 -11 -44 -21 -84 -22 -90 -18 -79 -93 -275 -154
+-408 -39 -83 -158 -296 -171 -307 -3 -3 -26 -34 -50 -70 -116 -169 -312 -384
+-466 -508 -38 -32 -78 -65 -89 -74 -25 -22 -229 -160 -281 -189 -177 -99 -405
+-197 -570 -244 -126 -36 -305 -74 -375 -81 -19 -2 -48 -5 -65 -8 -121 -22
+-509 -22 -618 0 -12 2 -42 6 -67 10 -369 45 -795 215 -1125 448 -192 135 -399
+326 -517 476 -23 30 -48 61 -55 67 -57 60 -227 336 -291 473 -64 135 -150 365
+-167 444 -2 12 -6 30 -9 41 -28 120 -36 156 -41 193 -3 24 -7 53 -10 65 -32
+148 -38 552 -10 707 2 14 7 45 10 70 33 274 160 643 313 910 60 106 201 312
+232 340 3 3 23 28 45 55 22 28 85 96 140 151 347 352 768 590 1252 709 56 14
+118 27 137 30 20 2 61 9 93 14 32 6 92 13 133 17 41 3 76 7 77 8 6 5 368 -2
+428 -8z"/>
+<path d="M3423 4754 c-45 -17 -95 -61 -121 -109 -15 -27 -17 -93 -19 -650 -1
+-341 -2 -641 -3 -666 l0 -47 -679 0 -679 -1 -49 -24 c-59 -30 -76 -49 -104
+-112 -54 -122 23 -270 154 -293 23 -5 400 -8 837 -7 l795 1 42 22 c52 27 98
+82 112 136 8 29 10 273 9 826 -3 854 1 796 -59 867 -53 63 -153 87 -236 57z"/>
+</g>
+</svg>
diff --git a/apps/accounts/src/app/index.d.ts b/apps/accounts/src/app/index.d.ts
new file mode 100644
index 0000000..c044583
--- /dev/null
+++ b/apps/accounts/src/app/index.d.ts
@@ -0,0 +1,48 @@
+/* Use this file to declare any custom file extensions for importing */
+/* Use this folder to also add/extend a package d.ts file, if needed. */
+
+/* CSS MODULES */
+declare module "*.module.css" {
+ const classes: { [key: string]: string };
+ export default classes;
+}
+declare module "*.module.scss" {
+ const classes: { [key: string]: string };
+ export default classes;
+}
+
+/* CSS */
+declare module "*.css";
+declare module "*.scss";
+
+/* IMAGES */
+declare module "*.svg" {
+ const ref: string;
+ export default ref;
+}
+declare module "*.bmp" {
+ const ref: string;
+ export default ref;
+}
+declare module "*.gif" {
+ const ref: string;
+ export default ref;
+}
+declare module "*.jpg" {
+ const ref: string;
+ export default ref;
+}
+declare module "*.jpeg" {
+ const ref: string;
+ export default ref;
+}
+declare module "*.png" {
+ const ref: string;
+ export default ref;
+}
+
+/* CUSTOM: ADD YOUR OWN HERE */
+declare module "*.svelte" {
+ const value: any;
+ export default value;
+}
diff --git a/apps/accounts/src/app/index.scss b/apps/accounts/src/app/index.scss
new file mode 100644
index 0000000..56ac1c0
--- /dev/null
+++ b/apps/accounts/src/app/index.scss
@@ -0,0 +1,21 @@
+@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/btn-states';
+@use '../../web-shared/src/styles/components/alert';
+@use '../../web-shared/src/styles/components/details';
diff --git a/apps/accounts/src/app/index.svelte b/apps/accounts/src/app/index.svelte
new file mode 100644
index 0000000..40fe6ae
--- /dev/null
+++ b/apps/accounts/src/app/index.svelte
@@ -0,0 +1,61 @@
+<svelte:options immutable={true}/>
+<svelte:window bind:online={online}/>
+
+<script>
+ import {projects_base} from "$shared/lib/configuration";
+ import Router from "svelte-spa-router";
+ import {wrap} from "svelte-spa-router/wrap";
+ import {is_active} from "$shared/lib/session";
+ import NotFound from "$app/pages/not-found.svelte";
+ import SignUp from "$app/pages/sign-up.svelte";
+ import Login from "$app/pages/login.svelte";
+ import Forgot from "$app/pages/forgot.svelte";
+ import Reset from "$app/pages/reset-password.svelte";
+ import PreHeader from "$shared/components/pre-header.svelte";
+
+ let online = true;
+
+ async function user_is_logged_in() {
+ if (await is_active()) {
+ location.replace(projects_base("#/home"));
+ }
+ return true;
+ }
+
+ const routes = {
+ "/login": wrap({
+ component: Login,
+ conditions: [user_is_logged_in],
+ }),
+ "/": wrap({
+ component: Login,
+ conditions: [user_is_logged_in],
+ }),
+ "/signup": wrap({
+ component: SignUp,
+ conditions: [user_is_logged_in],
+ }),
+ "/reset-password": wrap({
+ component: Reset,
+ conditions: [user_is_logged_in],
+ }),
+ "/forgot": wrap({
+ component: Forgot,
+ conditions: [user_is_logged_in],
+ }),
+ "*": NotFound,
+ };
+</script>
+
+<PreHeader show="{!online}">You seem to be offline, please check your internet connection.</PreHeader>
+
+<Router
+ {routes}
+ restoreScrollState={true}
+ on:routeLoading={() => {
+ document.getElementById("loader").style.display = "inline-block";
+ }}
+ on:routeLoaded={() => {
+ document.getElementById("loader").style.display = "none";
+ }}
+/>
diff --git a/apps/accounts/src/app/index.ts b/apps/accounts/src/app/index.ts
new file mode 100644
index 0000000..0bfb30d
--- /dev/null
+++ b/apps/accounts/src/app/index.ts
@@ -0,0 +1,14 @@
+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 Production; Suppressing logs", "background-color:yellow;color:black;font-size:18px;");
+ console.log = noop;
+}
+
+// @ts-ignore
+export default new App({
+ target: document.getElementById("root"),
+});
diff --git a/apps/accounts/src/app/pages/_layout.svelte b/apps/accounts/src/app/pages/_layout.svelte
new file mode 100644
index 0000000..8c2e4a8
--- /dev/null
+++ b/apps/accounts/src/app/pages/_layout.svelte
@@ -0,0 +1,142 @@
+<script>
+ import Details from "$shared/components/details.svelte";
+ import Button from "$shared/components/button.svelte";
+ import {switch_theme} from "$shared/lib/helpers";
+</script>
+
+<style>
+ #decoration {
+ position: absolute;
+ top: 0;
+ left: 0;
+ pointer-events: none;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ }
+
+ #decoration svg {
+ position: absolute;
+ top: 0;
+ left: 50%;
+ -webkit-transform: translateX(-50%);
+ transform: translateX(-50%);
+ width: 134%;
+ min-width: 1280px;
+ max-width: 1920px;
+ height: auto;
+ }
+</style>
+
+<main class="container-fluid padding-x-xs padding-x-xxl@xs padding-y-md padding-y-lg@md max-width-sm">
+ <slot/>
+
+ <Details summary="About">
+ <p>Time Tracker is a tool to keep track of time spent.</p>
+ <p>Use demo@demo.demo 123456 to demo the app.</p>
+ <a href="https://git.ivarlovlie.no/time-tracker">Source</a>
+ <a href="https://git.ivarlovlie.no/time-tracker/tree/LICENSE">License</a>
+ <a href="/assets/third-party-licenses.txt">License notices</a>
+ </Details>
+
+ <Details summary="Pricing"/>
+
+ <Details summary="Privacy policy">
+ <h3>Information we collect</h3>
+ <p>We collect information you the user provide, explicitly this means:</p>
+ <ul>
+ <li>Username</li>
+ <li>Password</li>
+ <li>Entries generated by you</li>
+ <li>Labels generated by you</li>
+ <li>Categories generated by you</li>
+ <li>Your IP address when making requests to our API (using the service)</li>
+ </ul>
+
+ <h3>How we use your information</h3>
+ <p>We use your information to provide the time-tracker service.</p>
+
+ <h3>How we share your information</h3>
+ <p>
+ We do not share your information with anyone nor any entity. All information is handled by us the provider and you the user
+ exclusively.
+ </p>
+
+ <h3>Right to delete</h3>
+ <p>
+ You can at any time delete any data related to your personal information by navigating to your profile page inside of the
+ service.
+ </p>
+
+ <h3>Right to inspect</h3>
+ <p>You can at any time download all of your generated data by navigating to your profile page inside of the service.</p>
+
+ <h3>Contact</h3>
+ <p>Please direct any inquires about your personal data to time-tracker@ivarlovlie.no.</p>
+ </Details>
+
+ <Details summary="Terms of service"/>
+
+ <Button on:click={() => switch_theme()}
+ text="Switch theme"
+ variant="secondary"/>
+
+ <figure id="decoration"
+ aria-hidden="true">
+ <svg class="color-contrast-higher opacity-10%"
+ viewBox="0 0 1920 450"
+ fill="none">
+ <g stroke="currentColor"
+ stroke-width="2">
+ <rect x="1286"
+ y="64"
+ width="128"
+ height="128"/>
+ <circle cx="1350"
+ cy="128"
+ r="64"/>
+ <path d="M1286 64L1414 192"/>
+ <circle cx="1478"
+ cy="128"
+ r="64"/>
+ <rect x="1414"
+ y="192"
+ width="128"
+ height="128"/>
+ <circle cx="1478"
+ cy="256"
+ r="64"/>
+ <path d="M1414 192L1542 320"/>
+ <circle cx="1606"
+ cy="256"
+ r="64"/>
+ <rect x="1542"
+ y="320"
+ width="128"
+ height="128"/>
+ <circle cx="1606"
+ cy="384"
+ r="64"/>
+ <path d="M1542 320L1670 448"/>
+ <rect x="1690"
+ y="192"
+ width="128"
+ height="128"/>
+ <circle cx="1754"
+ cy="256"
+ r="64"/>
+ <path d="M1690 192L1818 320"/>
+ <rect x="1542"
+ y="64"
+ width="128"
+ height="128"/>
+ <circle cx="1606"
+ cy="128"
+ r="64"/>
+ <path d="M1542 64L1670 192"/>
+ <circle cx="1478"
+ r="64"/>
+ </g>
+ </svg>
+ </figure>
+</main>
diff --git a/apps/accounts/src/app/pages/forgot.svelte b/apps/accounts/src/app/pages/forgot.svelte
new file mode 100644
index 0000000..f22d664
--- /dev/null
+++ b/apps/accounts/src/app/pages/forgot.svelte
@@ -0,0 +1,99 @@
+<script>
+ import {onMount} from "svelte";
+ import {link} from "svelte-spa-router";
+ import {create_forgot_password_request} from "$shared/lib/api/user";
+ import {is_email} from "$shared/lib/helpers";
+ import Alert from "$shared/components/alert.svelte";
+ import Button from "$shared/components/button.svelte";
+ import Layout from "./_layout.svelte";
+
+ let isLoading = false;
+ let username;
+
+ const alert = {
+ title: "",
+ type: "",
+ message: "",
+ isVisible: false,
+ show(type, obj) {
+ alert.title = obj.title;
+ alert.message = obj.text;
+ alert.type = type;
+ alert.isVisible = true;
+ isLoading = false;
+ },
+ hide() {
+ alert.isVisible = false;
+ alert.title = "";
+ alert.message = "";
+ alert.type = "";
+ isLoading = false;
+ },
+ };
+
+ function is_valid() {
+ return is_email(username);
+ }
+
+ async function submit_form() {
+ if (isLoading) {
+ return;
+ }
+ if (is_valid()) {
+ isLoading = true;
+ const response = await create_forgot_password_request(username);
+ if (response.ok) {
+ alert.show("success", {
+ title: "Request is sent",
+ text: "If we find an account associated with this email address, you will receive an email with a reset link very soon.",
+ });
+ } else {
+ console.error(response.data);
+ alert.show("error", {
+ title: response.data?.title ?? "An error occured",
+ text: response.data?.text ?? "Please try again soon",
+ });
+ }
+ }
+ }
+
+ onMount(() => {
+ document.addEventListener("DOMContentLoaded", () => {
+ document.getElementById("email-address").focus();
+ });
+ });
+</script>
+
+<Layout>
+ <form on:submit|preventDefault={submit_form}
+ class="margin-bottom-md max-width-xxs">
+ <fieldset>
+ <legend class="form-legend">
+ <span class="margin-bottom-xs">Send reset link</span> <br/>
+ <span class="text-sm">... or <a href="/login"
+ use:link>log in</a></span>
+ </legend>
+ <div class="margin-bottom-xs">
+ <p>Provide your email address, and we'll send you a link to set your new password.</p>
+ </div>
+ <div class="margin-bottom-xxs max-width-xxs">
+ <Alert visible={alert.isVisible}
+ title={alert.title}
+ message={alert.message}
+ type={alert.type}/>
+ </div>
+ <div class="margin-bottom-xs">
+ <input type="email"
+ id="email-address"
+ placeholder="Email address"
+ class="form-control width-100%"
+ bind:value={username}/>
+ </div>
+ <div class="flex justify-end">
+ <Button text="Send reset link"
+ type="primary"
+ loading={isLoading}/>
+ </div>
+ </fieldset>
+ </form>
+</Layout>
diff --git a/apps/accounts/src/app/pages/login.svelte b/apps/accounts/src/app/pages/login.svelte
new file mode 100644
index 0000000..3324056
--- /dev/null
+++ b/apps/accounts/src/app/pages/login.svelte
@@ -0,0 +1,145 @@
+<script>
+ import {onMount} from "svelte";
+ import {link, querystring} from "svelte-spa-router";
+ import {api_base, projects_base, IconNames} from "$shared/lib/configuration";
+ import Button from "$shared/components/button.svelte";
+ import Alert from "$shared/components/alert.svelte";
+ import {login} from "$shared/lib/api/user";
+ import {is_email} from "$shared/lib/helpers";
+ import Layout from "./_layout.svelte";
+
+ const loginForm = {
+ loading: false,
+ values: {
+ username: "",
+ password: "",
+ },
+ alert: {
+ title: "",
+ type: "",
+ message: "",
+ isVisible: false,
+ show(type, obj) {
+ loginForm.alert.title = obj.title;
+ loginForm.alert.message = obj.text;
+ loginForm.alert.type = type;
+ loginForm.alert.isVisible = true;
+ loginForm.loading = false;
+ },
+ hide() {
+ loginForm.alert.isVisible = false;
+ loginForm.alert.title = "";
+ loginForm.alert.message = "";
+ loginForm.alert.type = "";
+ },
+ },
+ is_valid() {
+ return is_email(loginForm.values.username) && loginForm.values.password.length > 0;
+ },
+ async submit_form() {
+ if (loginForm.loading) {
+ return;
+ }
+ if (loginForm.is_valid()) {
+ loginForm.alert.hide();
+ loginForm.loading = true;
+ try {
+ const response = await login(loginForm.values);
+ if (response.ok) {
+ location.replace(projects_base("#/home"));
+ } else {
+ if (response.data.title || response.data.text) {
+ loginForm.alert.show("error", {
+ title: response.data.title ?? "",
+ text: response.data.text ?? "",
+ });
+ } else {
+ loginForm.alert.show("error", {
+ title: "An unknown error occured",
+ text: "Try again soon",
+ });
+ }
+ }
+ } catch (e) {
+ console.error(e);
+ loginForm.alert.show("error", {
+ title: "An error occured",
+ text: "Could not connect to server, please check your internet connection",
+ });
+ }
+ } else {
+ loginForm.alert.show("error", {
+ title: "Invalid form",
+ });
+ }
+ },
+ };
+
+ onMount(() => {
+ if ($querystring === "deleted") {
+ loginForm.alert.show("info", {
+ title: "Account deleted",
+ text: "Your account and all its data was successfully deleted.",
+ });
+ }
+ if ($querystring === "expired") {
+ loginForm.alert.show("info", {
+ title: "Session expired",
+ text: "Your session has expired, feel free to log in again.",
+ });
+ }
+ });
+</script>
+
+<Layout>
+ <form on:submit|preventDefault={loginForm.submit_form}
+ class="margin-bottom-md max-width-xxs">
+ <fieldset>
+ <legend class="form-legend">
+ <span class="margin-bottom-xs">Log into your account</span>
+ <br/>
+ <span class="text-sm">... or <a href="/signup"
+ use:link>create a new one</a></span>
+ </legend>
+ <div class="margin-bottom-xxs max-width-xxs">
+ <Alert visible={loginForm.alert.isVisible}
+ title={loginForm.alert.title}
+ message={loginForm.alert.message}
+ type={loginForm.alert.type}/>
+ </div>
+ <div class="margin-bottom-xxs">
+ <input type="email"
+ placeholder="Email address"
+ class="form-control width-100%"
+ id="username"
+ bind:value={loginForm.values.username}/>
+ </div>
+ <div class="margin-bottom-xxs">
+ <input type="password"
+ placeholder="Password"
+ id="password"
+ class="form-control width-100%"
+ bind:value={loginForm.values.password}/>
+ <div class="flex justify-end">
+ <a tabindex="-1"
+ class="text-sm"
+ href="/forgot"
+ use:link>Reset password</a>
+ </div>
+ </div>
+ <div class="flex justify-between">
+ <Button text="Login with Github"
+ variant="secondary"
+ icon="{IconNames.github}"
+ icon_right_aligned="true"
+ href={api_base("_/account/create-github-session")}
+ loading={loginForm.loading}
+ />
+ <Button text="Login"
+ type="submit"
+ variant="primary"
+ loading={loginForm.loading}/>
+ </div>
+ </fieldset>
+ </form>
+</Layout>
diff --git a/apps/accounts/src/app/pages/not-found.svelte b/apps/accounts/src/app/pages/not-found.svelte
new file mode 100644
index 0000000..34568ba
--- /dev/null
+++ b/apps/accounts/src/app/pages/not-found.svelte
@@ -0,0 +1,23 @@
+<script>
+ import {link} from "svelte-spa-router";
+</script>
+<style>
+ header {
+ font-size: 12rem;
+ }
+
+ main {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ text-align: center;
+ }
+</style>
+
+<main>
+ <header>404</header>
+ <p>Page not found!</p>
+ <a use:link
+ href="/">Go to front</a>
+</main>
diff --git a/apps/accounts/src/app/pages/reset-password.svelte b/apps/accounts/src/app/pages/reset-password.svelte
new file mode 100644
index 0000000..56c4f62
--- /dev/null
+++ b/apps/accounts/src/app/pages/reset-password.svelte
@@ -0,0 +1,135 @@
+<script>
+ import {querystring, link} from "svelte-spa-router";
+ import {check_forgot_password_request, fulfill_forgot_password_request} from "$shared/lib/api/user";
+ import Alert from "$shared/components/alert.svelte";
+ import Button from "$shared/components/button.svelte";
+ import Layout from "./_layout.svelte";
+
+ const requestId = new URLSearchParams($querystring).get("id");
+ let isLoading = false;
+ let newPassword;
+ let newPasswordError;
+ let alert = {
+ title: "",
+ type: "",
+ message: "",
+ isVisible: false,
+ show(type, obj) {
+ alert.title = obj.title;
+ alert.message = obj.text;
+ alert.type = type;
+ alert.isVisible = true;
+ isLoading = false;
+ },
+ hide() {
+ alert.isVisible = false;
+ alert.title = "";
+ alert.message = "";
+ alert.type = "";
+ isLoading = false;
+ },
+ };
+
+ function is_valid() {
+ let isValid = true;
+ if (!newPassword.length > 5) {
+ newPasswordError = "The new password must be at least 5 characters";
+ isValid = false;
+ }
+ return isValid;
+ }
+
+ async function submit() {
+ if (isLoading) {
+ return;
+ }
+ if (is_valid()) {
+ isLoading = true;
+ const response = await fulfill_forgot_password_request(requestId, newPassword);
+ if (response.ok) {
+ alert.show("success", {
+ title: "Your new password is set",
+ text: "<a href='/#/login'>Click here to log in</a>",
+ });
+ } else {
+ console.error(response.data);
+ alert.show("error", {
+ title: response.data?.title ?? "An error occured",
+ text: response.data?.text ?? "Please try again soon",
+ });
+ }
+ }
+ }
+
+ async function is_valid_password_reset_request() {
+ const response = await check_forgot_password_request(requestId);
+ if (response.ok) {
+ return response.data === true;
+ }
+ return false;
+ }
+</script>
+
+<Layout>
+ <form on:submit|preventDefault={submit}
+ class="margin-bottom-md max-width-xxs {isLoading ? 'c-disabled loading' : ''}">
+ {#if requestId}
+ {#await is_valid_password_reset_request()}
+ <p>Checking your request...</p>
+ <a href="/login"
+ use:link>cancel</a>
+ {:then isActive}
+ {#if isActive === true}
+ <fieldset>
+ <legend class="form-legend">
+ <span class="margin-bottom-xs">Set your new password</span> <br/>
+ <span class="text-sm">
+ ... or
+ <a href="/login"
+ use:link> log in </a>
+ </span>
+ </legend>
+ <div class="margin-bottom-xxs max-width-xxs">
+ <Alert visible={alert.isVisible}
+ title={alert.title}
+ message={alert.message}
+ type={alert.type}/>
+ </div>
+ <div class="margin-bottom-xs">
+ <input
+ type="password"
+ id="new-password"
+ placeholder="New password"
+ class="form-control width-100%"
+ bind:value={newPassword}
+ />
+ {#if newPasswordError}
+ <small class="color-danger">{newPasswordError}</small>
+ {/if}
+ </div>
+ <div class="flex justify-end">
+ <Button text="Set new password"
+ type="primary"
+ loading={isLoading}
+ on:click={submit}/>
+ </div>
+ </fieldset>
+ {:else}
+ <Alert title="This request is expired"
+ message="Please submit the forgot password form again"
+ type="warning"/>
+ <div class="flex justify-between width-100% margin-y-sm">
+ <a href="/forgot"
+ use:link>Go to forgot form</a>
+ <a href="/login"
+ use:link>Go to login form</a>
+ </div>
+ {/if}
+ {:catch _}
+ <Alert title="An error occured"
+ message="Please try again soon"
+ type="error"/>
+ {/await}
+ {/if}
+ </form>
+</Layout>
diff --git a/apps/accounts/src/app/pages/sign-up.svelte b/apps/accounts/src/app/pages/sign-up.svelte
new file mode 100644
index 0000000..80780e0
--- /dev/null
+++ b/apps/accounts/src/app/pages/sign-up.svelte
@@ -0,0 +1,128 @@
+<script>
+ import {create_account} from "$shared/lib/api/user";
+ import {is_email} from "$shared/lib/helpers";
+ import Alert from "$shared/components/alert.svelte";
+ import Button from "$shared/components/button.svelte";
+ import {link} from "svelte-spa-router";
+ import Layout from "./_layout.svelte";
+
+ const signupForm = {
+ loading: false,
+ values: {
+ username: "",
+ password: "",
+ },
+ alert: {
+ title: "",
+ type: "",
+ message: "",
+ isVisible: false,
+ show(type, obj) {
+ signupForm.alert.title = obj.title;
+ signupForm.alert.message = obj.text;
+ signupForm.alert.type = type;
+ signupForm.alert.isVisible = true;
+ signupForm.loading = false;
+ },
+ hide() {
+ signupForm.alert.isVisible = false;
+ signupForm.alert.title = "";
+ signupForm.alert.message = "";
+ signupForm.alert.type = "";
+ },
+ },
+ is_valid() {
+ return (
+ is_email(signupForm.values.username) &&
+ signupForm.values.password.length > 0
+ );
+ },
+ async submit_form() {
+ if (signupForm.loading) {
+ return;
+ }
+ if (signupForm.is_valid()) {
+ signupForm.alert.hide();
+ signupForm.loading = true;
+ try {
+ const response = await create_account(signupForm.values);
+ if (response.ok) {
+ location.reload();
+ } else {
+ if (response.data.title || response.data.text) {
+ signupForm.alert.show("error", {
+ title: response.data.title ?? "",
+ text: response.data.text ?? "",
+ });
+ } else {
+ signupForm.alert.show("error", {
+ title: "An unknown error occured",
+ text: "Try again soon",
+ });
+ }
+ }
+ } catch (e) {
+ console.error(e);
+ signupForm.alert.show("error", {
+ title: "An error occured",
+ text: "Could not connect to server, please check your internet connection",
+ });
+ }
+ } else {
+ signupForm.alert.show("error", {
+ title: "Invalid form",
+ });
+ }
+ },
+ };
+</script>
+
+<Layout>
+ <form
+ on:submit|preventDefault={signupForm.submit_form}
+ class="margin-bottom-md max-width-xxs"
+ >
+ <fieldset>
+ <legend class="form-legend">
+ <span class="margin-bottom-xs">Create your account</span> <br/>
+ <span class="text-sm"
+ >... or <a href="/login"
+ use:link>log in</a></span
+ >
+ </legend>
+ <div class="margin-bottom-xxs max-width-xxs">
+ <Alert
+ visible={signupForm.alert.isVisible}
+ title={signupForm.alert.title}
+ message={signupForm.alert.message}
+ type={signupForm.alert.type}
+ />
+ </div>
+ <div class="margin-bottom-xxs">
+ <input
+ type="email"
+ placeholder="Email address"
+ class="form-control width-100%"
+ id="email-address"
+ bind:value={signupForm.values.username}
+ />
+ </div>
+ <div class="margin-bottom-xxs">
+ <input
+ type="password"
+ placeholder="Password"
+ class="form-control width-100%"
+ bind:value={signupForm.values.password}
+ />
+ </div>
+ <div class="flex justify-end">
+ <Button
+ class="margin-bottom-xs"
+ text="Submit"
+ type="primary"
+ loading={signupForm.loading}
+ />
+ </div>
+ </fieldset>
+ </form>
+</Layout>
diff --git a/apps/accounts/src/index.html b/apps/accounts/src/index.html
new file mode 100644
index 0000000..985b62b
--- /dev/null
+++ b/apps/accounts/src/index.html
@@ -0,0 +1,63 @@
+<!doctype html>
+<html lang="en">
+
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport"
+ content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
+ <link rel="apple-touch-icon"
+ sizes="180x180"
+ href="./_assets/pwa/apple-touch-icon.png">
+ <link rel="icon"
+ type="image/png"
+ sizes="32x32"
+ href="./_assets/pwa/favicon-32x32.png">
+ <link rel="icon"
+ type="image/png"
+ sizes="16x16"
+ href="./_assets/pwa/favicon-16x16.png">
+ <link rel="manifest"
+ href="./_assets/pwa/manifest.json">
+ <link rel="mask-icon"
+ href="./_assets/pwa/safari-pinned-tab.svg"
+ color="#5bbad5">
+ <meta name="msapplication-TileColor"
+ content="#da532c">
+ <link rel="icon"
+ href="./_assets/pwa/favicon.svg">
+ <script>
+ const currentTheme = localStorage.getItem("theme");
+ if (currentTheme === "light") {
+ document.querySelector("html").dataset.theme = "light";
+ } else {
+ document.querySelector("html").dataset.theme = "dark";
+ }
+ </script>
+ <link rel="stylesheet"
+ href="./_assets/pre.css">
+ <title>Time Tracker</title>
+</head>
+
+<body>
+
+<noscript>
+ This page is built with javascript. Allow it and try again.
+</noscript>
+
+<div class="fill-loader fill-loader--v4"
+ id="loader"
+ role="alert">
+ <p class="fill-loader__label">Loading Time Tracker...</p>
+ <div aria-hidden="true">
+ <div class="fill-loader__base"></div>
+ <div class="fill-loader__fill"></div>
+ </div>
+</div>
+
+<div id="root"></div>
+
+<script type="module"
+ src="./app/index.ts"></script>
+</body>
+
+</html>
diff --git a/apps/accounts/src/package.json b/apps/accounts/src/package.json
new file mode 100644
index 0000000..8ff516d
--- /dev/null
+++ b/apps/accounts/src/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "time-tracker-public",
+ "version": "0.0.1",
+ "private": "true",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build"
+ },
+ "devDependencies": {
+ "@sveltejs/vite-plugin-svelte": "1.0.0-next.43",
+ "sass": "^1.51.0",
+ "svelte": "^3.48.0",
+ "svelte-preprocess": "^4.10.6",
+ "svelte-spa-router": "^3.2.0",
+ "typescript": "4.6.4",
+ "vite": "^2.9.8"
+ },
+ "dependencies": {
+ "@js-temporal/polyfill": "^0.4.1",
+ "fuzzysort": "^1.9.0"
+ }
+}
diff --git a/apps/accounts/src/pnpm-lock.yaml b/apps/accounts/src/pnpm-lock.yaml
new file mode 100644
index 0000000..3b56115
--- /dev/null
+++ b/apps/accounts/src/pnpm-lock.yaml
@@ -0,0 +1,769 @@
+lockfileVersion: 5.4
+
+specifiers:
+ '@js-temporal/polyfill': ^0.4.1
+ '@sveltejs/vite-plugin-svelte': 1.0.0-next.43
+ fuzzysort: ^1.9.0
+ sass: ^1.51.0
+ svelte: ^3.48.0
+ svelte-preprocess: ^4.10.6
+ svelte-spa-router: ^3.2.0
+ typescript: 4.6.4
+ vite: ^2.9.8
+
+dependencies:
+ '@js-temporal/polyfill': 0.4.1
+ fuzzysort: 1.9.0
+
+devDependencies:
+ '@sveltejs/vite-plugin-svelte': 1.0.0-next.43_svelte@3.48.0+vite@2.9.8
+ sass: 1.51.0
+ svelte: 3.48.0
+ svelte-preprocess: 4.10.6_24ezlekk4ocevlsjgs2qnqmjum
+ svelte-spa-router: 3.2.0
+ typescript: 4.6.4
+ vite: 2.9.8_sass@1.51.0
+
+packages:
+
+ /@js-temporal/polyfill/0.4.1:
+ resolution: {integrity: sha512-q45ecIocpa2TLem2jNOsCrDwP/sgKZdSkt+C1Rx07OkdKsdbvVfHcD1iDiK9scxBZrBQ38uJ8VQISXBS70ql1w==}
+ engines: {node: '>=12'}
+ dependencies:
+ jsbi: 4.3.0
+ tslib: 2.4.0
+ dev: false
+
+ /@rollup/pluginutils/4.2.1:
+ resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==}
+ engines: {node: '>= 8.0.0'}
+ dependencies:
+ estree-walker: 2.0.2
+ picomatch: 2.3.1
+ dev: true
+
+ /@sveltejs/vite-plugin-svelte/1.0.0-next.43_svelte@3.48.0+vite@2.9.8:
+ resolution: {integrity: sha512-MzeczqGrnDmbAldw/LfXV/dhpLC2bdUzuMhcx0C2j79V2uNzQERHDinxXnG2AVTCTjSpbQxzQwMMmYflnI7W1g==}
+ engines: {node: ^14.13.1 || >= 16}
+ peerDependencies:
+ diff-match-patch: ^1.0.5
+ svelte: ^3.44.0
+ vite: ^2.9.0
+ peerDependenciesMeta:
+ diff-match-patch:
+ optional: true
+ dependencies:
+ '@rollup/pluginutils': 4.2.1
+ debug: 4.3.4
+ deepmerge: 4.2.2
+ kleur: 4.1.4
+ magic-string: 0.26.1
+ svelte: 3.48.0
+ svelte-hmr: 0.14.11_svelte@3.48.0
+ vite: 2.9.8_sass@1.51.0
+ transitivePeerDependencies:
+ - supports-color
+ dev: true
+
+ /@types/node/17.0.31:
+ resolution: {integrity: sha512-AR0x5HbXGqkEx9CadRH3EBYx/VkiUgZIhP4wvPn/+5KIsgpNoyFaRlVe0Zlx9gRtg8fA06a9tskE2MSN7TcG4Q==}
+ dev: true
+
+ /@types/pug/2.0.6:
+ resolution: {integrity: sha512-SnHmG9wN1UVmagJOnyo/qkk0Z7gejYxOYYmaAwr5u2yFYfsupN3sg10kyzN8Hep/2zbHxCnsumxOoRIRMBwKCg==}
+ dev: true
+
+ /@types/sass/1.43.1:
+ resolution: {integrity: sha512-BPdoIt1lfJ6B7rw35ncdwBZrAssjcwzI5LByIrYs+tpXlj/CAkuVdRsgZDdP4lq5EjyWzwxZCqAoFyHKFwp32g==}
+ dependencies:
+ '@types/node': 17.0.31
+ dev: true
+
+ /anymatch/3.1.2:
+ resolution: {integrity: sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==}
+ engines: {node: '>= 8'}
+ dependencies:
+ normalize-path: 3.0.0
+ picomatch: 2.3.1
+ dev: true
+
+ /balanced-match/1.0.2:
+ resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+ dev: true
+
+ /binary-extensions/2.2.0:
+ resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==}
+ engines: {node: '>=8'}
+ dev: true
+
+ /brace-expansion/1.1.11:
+ resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==}
+ dependencies:
+ balanced-match: 1.0.2
+ concat-map: 0.0.1
+ dev: true
+
+ /braces/3.0.2:
+ resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==}
+ engines: {node: '>=8'}
+ dependencies:
+ fill-range: 7.0.1
+ dev: true
+
+ /buffer-crc32/0.2.13:
+ resolution: {integrity: sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=}
+ dev: true
+
+ /chokidar/3.5.3:
+ resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==}
+ engines: {node: '>= 8.10.0'}
+ dependencies:
+ anymatch: 3.1.2
+ braces: 3.0.2
+ glob-parent: 5.1.2
+ is-binary-path: 2.1.0
+ is-glob: 4.0.3
+ normalize-path: 3.0.0
+ readdirp: 3.6.0
+ optionalDependencies:
+ fsevents: 2.3.2
+ dev: true
+
+ /concat-map/0.0.1:
+ resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=}
+ dev: true
+
+ /debug/4.3.4:
+ resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+ dependencies:
+ ms: 2.1.2
+ dev: true
+
+ /deepmerge/4.2.2:
+ resolution: {integrity: sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /detect-indent/6.1.0:
+ resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==}
+ engines: {node: '>=8'}
+ dev: true
+
+ /es6-promise/3.3.1:
+ resolution: {integrity: sha1-oIzd6EzNvzTQJ6FFG8kdS80ophM=}
+ dev: true
+
+ /esbuild-android-64/0.14.38:
+ resolution: {integrity: sha512-aRFxR3scRKkbmNuGAK+Gee3+yFxkTJO/cx83Dkyzo4CnQl/2zVSurtG6+G86EQIZ+w+VYngVyK7P3HyTBKu3nw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [android]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-android-arm64/0.14.38:
+ resolution: {integrity: sha512-L2NgQRWuHFI89IIZIlpAcINy9FvBk6xFVZ7xGdOwIm8VyhX1vNCEqUJO3DPSSy945Gzdg98cxtNt8Grv1CsyhA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [android]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-darwin-64/0.14.38:
+ resolution: {integrity: sha512-5JJvgXkX87Pd1Og0u/NJuO7TSqAikAcQQ74gyJ87bqWRVeouky84ICoV4sN6VV53aTW+NE87qLdGY4QA2S7KNA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [darwin]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-darwin-arm64/0.14.38:
+ resolution: {integrity: sha512-eqF+OejMI3mC5Dlo9Kdq/Ilbki9sQBw3QlHW3wjLmsLh+quNfHmGMp3Ly1eWm981iGBMdbtSS9+LRvR2T8B3eQ==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [darwin]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-freebsd-64/0.14.38:
+ resolution: {integrity: sha512-epnPbhZUt93xV5cgeY36ZxPXDsQeO55DppzsIgWM8vgiG/Rz+qYDLmh5ts3e+Ln1wA9dQ+nZmVHw+RjaW3I5Ig==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [freebsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-freebsd-arm64/0.14.38:
+ resolution: {integrity: sha512-/9icXUYJWherhk+y5fjPI5yNUdFPtXHQlwP7/K/zg8t8lQdHVj20SqU9/udQmeUo5pDFHMYzcEFfJqgOVeKNNQ==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [freebsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-32/0.14.38:
+ resolution: {integrity: sha512-QfgfeNHRFvr2XeHFzP8kOZVnal3QvST3A0cgq32ZrHjSMFTdgXhMhmWdKzRXP/PKcfv3e2OW9tT9PpcjNvaq6g==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-64/0.14.38:
+ resolution: {integrity: sha512-uuZHNmqcs+Bj1qiW9k/HZU3FtIHmYiuxZ/6Aa+/KHb/pFKr7R3aVqvxlAudYI9Fw3St0VCPfv7QBpUITSmBR1Q==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-arm/0.14.38:
+ resolution: {integrity: sha512-FiFvQe8J3VKTDXG01JbvoVRXQ0x6UZwyrU4IaLBZeq39Bsbatd94Fuc3F1RGqPF5RbIWW7RvkVQjn79ejzysnA==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-arm64/0.14.38:
+ resolution: {integrity: sha512-HlMGZTEsBrXrivr64eZ/EO0NQM8H8DuSENRok9d+Jtvq8hOLzrxfsAT9U94K3KOGk2XgCmkaI2KD8hX7F97lvA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-mips64le/0.14.38:
+ resolution: {integrity: sha512-qd1dLf2v7QBiI5wwfil9j0HG/5YMFBAmMVmdeokbNAMbcg49p25t6IlJFXAeLzogv1AvgaXRXvgFNhScYEUXGQ==}
+ engines: {node: '>=12'}
+ cpu: [mips64el]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-ppc64le/0.14.38:
+ resolution: {integrity: sha512-mnbEm7o69gTl60jSuK+nn+pRsRHGtDPfzhrqEUXyCl7CTOCLtWN2bhK8bgsdp6J/2NyS/wHBjs1x8aBWwP2X9Q==}
+ engines: {node: '>=12'}
+ cpu: [ppc64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-riscv64/0.14.38:
+ resolution: {integrity: sha512-+p6YKYbuV72uikChRk14FSyNJZ4WfYkffj6Af0/Tw63/6TJX6TnIKE+6D3xtEc7DeDth1fjUOEqm+ApKFXbbVQ==}
+ engines: {node: '>=12'}
+ cpu: [riscv64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-s390x/0.14.38:
+ resolution: {integrity: sha512-0zUsiDkGJiMHxBQ7JDU8jbaanUY975CdOW1YDrurjrM0vWHfjv9tLQsW9GSyEb/heSK1L5gaweRjzfUVBFoybQ==}
+ engines: {node: '>=12'}
+ cpu: [s390x]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-netbsd-64/0.14.38:
+ resolution: {integrity: sha512-cljBAApVwkpnJZfnRVThpRBGzCi+a+V9Ofb1fVkKhtrPLDYlHLrSYGtmnoTVWDQdU516qYI8+wOgcGZ4XIZh0Q==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [netbsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-openbsd-64/0.14.38:
+ resolution: {integrity: sha512-CDswYr2PWPGEPpLDUO50mL3WO/07EMjnZDNKpmaxUPsrW+kVM3LoAqr/CE8UbzugpEiflYqJsGPLirThRB18IQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [openbsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-sunos-64/0.14.38:
+ resolution: {integrity: sha512-2mfIoYW58gKcC3bck0j7lD3RZkqYA7MmujFYmSn9l6TiIcAMpuEvqksO+ntBgbLep/eyjpgdplF7b+4T9VJGOA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [sunos]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-windows-32/0.14.38:
+ resolution: {integrity: sha512-L2BmEeFZATAvU+FJzJiRLFUP+d9RHN+QXpgaOrs2klshoAm1AE6Us4X6fS9k33Uy5SzScn2TpcgecbqJza1Hjw==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [win32]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-windows-64/0.14.38:
+ resolution: {integrity: sha512-Khy4wVmebnzue8aeSXLC+6clo/hRYeNIm0DyikoEqX+3w3rcvrhzpoix0S+MF9vzh6JFskkIGD7Zx47ODJNyCw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [win32]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-windows-arm64/0.14.38:
+ resolution: {integrity: sha512-k3FGCNmHBkqdJXuJszdWciAH77PukEyDsdIryEHn9cKLQFxzhT39dSumeTuggaQcXY57UlmLGIkklWZo2qzHpw==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [win32]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild/0.14.38:
+ resolution: {integrity: sha512-12fzJ0fsm7gVZX1YQ1InkOE5f9Tl7cgf6JPYXRJtPIoE0zkWAbHdPHVPPaLi9tYAcEBqheGzqLn/3RdTOyBfcA==}
+ engines: {node: '>=12'}
+ hasBin: true
+ requiresBuild: true
+ optionalDependencies:
+ esbuild-android-64: 0.14.38
+ esbuild-android-arm64: 0.14.38
+ esbuild-darwin-64: 0.14.38
+ esbuild-darwin-arm64: 0.14.38
+ esbuild-freebsd-64: 0.14.38
+ esbuild-freebsd-arm64: 0.14.38
+ esbuild-linux-32: 0.14.38
+ esbuild-linux-64: 0.14.38
+ esbuild-linux-arm: 0.14.38
+ esbuild-linux-arm64: 0.14.38
+ esbuild-linux-mips64le: 0.14.38
+ esbuild-linux-ppc64le: 0.14.38
+ esbuild-linux-riscv64: 0.14.38
+ esbuild-linux-s390x: 0.14.38
+ esbuild-netbsd-64: 0.14.38
+ esbuild-openbsd-64: 0.14.38
+ esbuild-sunos-64: 0.14.38
+ esbuild-windows-32: 0.14.38
+ esbuild-windows-64: 0.14.38
+ esbuild-windows-arm64: 0.14.38
+ dev: true
+
+ /estree-walker/2.0.2:
+ resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
+ dev: true
+
+ /fill-range/7.0.1:
+ resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==}
+ engines: {node: '>=8'}
+ dependencies:
+ to-regex-range: 5.0.1
+ dev: true
+
+ /fs.realpath/1.0.0:
+ resolution: {integrity: sha1-FQStJSMVjKpA20onh8sBQRmU6k8=}
+ dev: true
+
+ /fsevents/2.3.2:
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /function-bind/1.1.1:
+ resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==}
+ dev: true
+
+ /fuzzysort/1.9.0:
+ resolution: {integrity: sha512-MOxCT0qLTwLqmEwc7UtU045RKef7mc8Qz8eR4r2bLNEq9dy/c3ZKMEFp6IEst69otkQdFZ4FfgH2dmZD+ddX1g==}
+ dev: false
+
+ /glob-parent/5.1.2:
+ resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
+ engines: {node: '>= 6'}
+ dependencies:
+ is-glob: 4.0.3
+ dev: true
+
+ /glob/7.2.0:
+ resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==}
+ dependencies:
+ fs.realpath: 1.0.0
+ inflight: 1.0.6
+ inherits: 2.0.4
+ minimatch: 3.1.2
+ once: 1.4.0
+ path-is-absolute: 1.0.1
+ dev: true
+
+ /graceful-fs/4.2.10:
+ resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==}
+ dev: true
+
+ /has/1.0.3:
+ resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==}
+ engines: {node: '>= 0.4.0'}
+ dependencies:
+ function-bind: 1.1.1
+ dev: true
+
+ /immutable/4.0.0:
+ resolution: {integrity: sha512-zIE9hX70qew5qTUjSS7wi1iwj/l7+m54KWU247nhM3v806UdGj1yDndXj+IOYxxtW9zyLI+xqFNZjTuDaLUqFw==}
+ dev: true
+
+ /inflight/1.0.6:
+ resolution: {integrity: sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=}
+ dependencies:
+ once: 1.4.0
+ wrappy: 1.0.2
+ dev: true
+
+ /inherits/2.0.4:
+ resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+ dev: true
+
+ /is-binary-path/2.1.0:
+ resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
+ engines: {node: '>=8'}
+ dependencies:
+ binary-extensions: 2.2.0
+ dev: true
+
+ /is-core-module/2.9.0:
+ resolution: {integrity: sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==}
+ dependencies:
+ has: 1.0.3
+ dev: true
+
+ /is-extglob/2.1.1:
+ resolution: {integrity: sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /is-glob/4.0.3:
+ resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
+ engines: {node: '>=0.10.0'}
+ dependencies:
+ is-extglob: 2.1.1
+ dev: true
+
+ /is-number/7.0.0:
+ resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
+ engines: {node: '>=0.12.0'}
+ dev: true
+
+ /jsbi/4.3.0:
+ resolution: {integrity: sha512-SnZNcinB4RIcnEyZqFPdGPVgrg2AcnykiBy0sHVJQKHYeaLUvi3Exj+iaPpLnFVkDPZIV4U0yvgC9/R4uEAZ9g==}
+ dev: false
+
+ /kleur/4.1.4:
+ resolution: {integrity: sha512-8QADVssbrFjivHWQU7KkMgptGTl6WAcSdlbBPY4uNF+mWr6DGcKrvY2w4FQJoXch7+fKMjj0dRrL75vk3k23OA==}
+ engines: {node: '>=6'}
+ dev: true
+
+ /magic-string/0.25.9:
+ resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==}
+ dependencies:
+ sourcemap-codec: 1.4.8
+ dev: true
+
+ /magic-string/0.26.1:
+ resolution: {integrity: sha512-ndThHmvgtieXe8J/VGPjG+Apu7v7ItcD5mhEIvOscWjPF/ccOiLxHaSuCAS2G+3x4GKsAbT8u7zdyamupui8Tg==}
+ engines: {node: '>=12'}
+ dependencies:
+ sourcemap-codec: 1.4.8
+ dev: true
+
+ /min-indent/1.0.1:
+ resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
+ engines: {node: '>=4'}
+ dev: true
+
+ /minimatch/3.1.2:
+ resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
+ dependencies:
+ brace-expansion: 1.1.11
+ dev: true
+
+ /minimist/1.2.6:
+ resolution: {integrity: sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==}
+ dev: true
+
+ /mkdirp/0.5.6:
+ resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
+ hasBin: true
+ dependencies:
+ minimist: 1.2.6
+ dev: true
+
+ /ms/2.1.2:
+ resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
+ dev: true
+
+ /nanoid/3.3.4:
+ resolution: {integrity: sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+ dev: true
+
+ /normalize-path/3.0.0:
+ resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /once/1.4.0:
+ resolution: {integrity: sha1-WDsap3WWHUsROsF9nFC6753Xa9E=}
+ dependencies:
+ wrappy: 1.0.2
+ dev: true
+
+ /path-is-absolute/1.0.1:
+ resolution: {integrity: sha1-F0uSaHNVNP+8es5r9TpanhtcX18=}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /path-parse/1.0.7:
+ resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
+ dev: true
+
+ /picocolors/1.0.0:
+ resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==}
+ dev: true
+
+ /picomatch/2.3.1:
+ resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
+ engines: {node: '>=8.6'}
+ dev: true
+
+ /postcss/8.4.13:
+ resolution: {integrity: sha512-jtL6eTBrza5MPzy8oJLFuUscHDXTV5KcLlqAWHl5q5WYRfnNRGSmOZmOZ1T6Gy7A99mOZfqungmZMpMmCVJ8ZA==}
+ engines: {node: ^10 || ^12 || >=14}
+ dependencies:
+ nanoid: 3.3.4
+ picocolors: 1.0.0
+ source-map-js: 1.0.2
+ dev: true
+
+ /readdirp/3.6.0:
+ resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
+ engines: {node: '>=8.10.0'}
+ dependencies:
+ picomatch: 2.3.1
+ dev: true
+
+ /regexparam/2.0.0:
+ resolution: {integrity: sha512-gJKwd2MVPWHAIFLsaYDZfyKzHNS4o7E/v8YmNf44vmeV2e4YfVoDToTOKTvE7ab68cRJ++kLuEXJBaEeJVt5ow==}
+ engines: {node: '>=8'}
+ dev: true
+
+ /resolve/1.22.0:
+ resolution: {integrity: sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==}
+ hasBin: true
+ dependencies:
+ is-core-module: 2.9.0
+ path-parse: 1.0.7
+ supports-preserve-symlinks-flag: 1.0.0
+ dev: true
+
+ /rimraf/2.7.1:
+ resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==}
+ hasBin: true
+ dependencies:
+ glob: 7.2.0
+ dev: true
+
+ /rollup/2.72.1:
+ resolution: {integrity: sha512-NTc5UGy/NWFGpSqF1lFY8z9Adri6uhyMLI6LvPAXdBKoPRFhIIiBUpt+Qg2awixqO3xvzSijjhnb4+QEZwJmxA==}
+ engines: {node: '>=10.0.0'}
+ hasBin: true
+ optionalDependencies:
+ fsevents: 2.3.2
+ dev: true
+
+ /sander/0.5.1:
+ resolution: {integrity: sha1-dB4kXiMfB8r7b98PEzrfohalAq0=}
+ dependencies:
+ es6-promise: 3.3.1
+ graceful-fs: 4.2.10
+ mkdirp: 0.5.6
+ rimraf: 2.7.1
+ dev: true
+
+ /sass/1.51.0:
+ resolution: {integrity: sha512-haGdpTgywJTvHC2b91GSq+clTKGbtkkZmVAb82jZQN/wTy6qs8DdFm2lhEQbEwrY0QDRgSQ3xDurqM977C3noA==}
+ engines: {node: '>=12.0.0'}
+ hasBin: true
+ dependencies:
+ chokidar: 3.5.3
+ immutable: 4.0.0
+ source-map-js: 1.0.2
+ dev: true
+
+ /sorcery/0.10.0:
+ resolution: {integrity: sha1-iukK19fLBfxZ8asMY3hF1cFaUrc=}
+ hasBin: true
+ dependencies:
+ buffer-crc32: 0.2.13
+ minimist: 1.2.6
+ sander: 0.5.1
+ sourcemap-codec: 1.4.8
+ dev: true
+
+ /source-map-js/1.0.2:
+ resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /sourcemap-codec/1.4.8:
+ resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==}
+ dev: true
+
+ /strip-indent/3.0.0:
+ resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
+ engines: {node: '>=8'}
+ dependencies:
+ min-indent: 1.0.1
+ dev: true
+
+ /supports-preserve-symlinks-flag/1.0.0:
+ resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
+ engines: {node: '>= 0.4'}
+ dev: true
+
+ /svelte-hmr/0.14.11_svelte@3.48.0:
+ resolution: {integrity: sha512-R9CVfX6DXxW1Kn45Jtmx+yUe+sPhrbYSUp7TkzbW0jI5fVPn6lsNG9NEs5dFg5qRhFNAoVdRw5qQDLALNKhwbQ==}
+ engines: {node: ^12.20 || ^14.13.1 || >= 16}
+ peerDependencies:
+ svelte: '>=3.19.0'
+ dependencies:
+ svelte: 3.48.0
+ dev: true
+
+ /svelte-preprocess/4.10.6_24ezlekk4ocevlsjgs2qnqmjum:
+ resolution: {integrity: sha512-I2SV1w/AveMvgIQlUF/ZOO3PYVnhxfcpNyGt8pxpUVhPfyfL/CZBkkw/KPfuFix5FJ9TnnNYMhACK3DtSaYVVQ==}
+ engines: {node: '>= 9.11.2'}
+ requiresBuild: true
+ peerDependencies:
+ '@babel/core': ^7.10.2
+ coffeescript: ^2.5.1
+ less: ^3.11.3 || ^4.0.0
+ node-sass: '*'
+ postcss: ^7 || ^8
+ postcss-load-config: ^2.1.0 || ^3.0.0
+ pug: ^3.0.0
+ sass: ^1.26.8
+ stylus: ^0.55.0
+ sugarss: ^2.0.0
+ svelte: ^3.23.0
+ typescript: ^3.9.5 || ^4.0.0
+ peerDependenciesMeta:
+ '@babel/core':
+ optional: true
+ coffeescript:
+ optional: true
+ less:
+ optional: true
+ node-sass:
+ optional: true
+ postcss:
+ optional: true
+ postcss-load-config:
+ optional: true
+ pug:
+ optional: true
+ sass:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ typescript:
+ optional: true
+ dependencies:
+ '@types/pug': 2.0.6
+ '@types/sass': 1.43.1
+ detect-indent: 6.1.0
+ magic-string: 0.25.9
+ sass: 1.51.0
+ sorcery: 0.10.0
+ strip-indent: 3.0.0
+ svelte: 3.48.0
+ typescript: 4.6.4
+ dev: true
+
+ /svelte-spa-router/3.2.0:
+ resolution: {integrity: sha512-igemo5Vs82TGBBw+DjWt6qKameXYzNs6aDXcTxou5XbEvOjiRcAM6MLkdVRCatn6u8r42dE99bt/br7T4qe/AQ==}
+ dependencies:
+ regexparam: 2.0.0
+ dev: true
+
+ /svelte/3.48.0:
+ resolution: {integrity: sha512-fN2YRm/bGumvjUpu6yI3BpvZnpIm9I6A7HR4oUNYd7ggYyIwSA/BX7DJ+UXXffLp6XNcUijyLvttbPVCYa/3xQ==}
+ engines: {node: '>= 8'}
+ dev: true
+
+ /to-regex-range/5.0.1:
+ resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
+ engines: {node: '>=8.0'}
+ dependencies:
+ is-number: 7.0.0
+ dev: true
+
+ /tslib/2.4.0:
+ resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==}
+ dev: false
+
+ /typescript/4.6.4:
+ resolution: {integrity: sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==}
+ engines: {node: '>=4.2.0'}
+ hasBin: true
+ dev: true
+
+ /vite/2.9.8_sass@1.51.0:
+ resolution: {integrity: sha512-zsBGwn5UT3YS0NLSJ7hnR54+vUKfgzMUh/Z9CxF1YKEBVIe213+63jrFLmZphgGI5zXwQCSmqIdbPuE8NJywPw==}
+ engines: {node: '>=12.2.0'}
+ hasBin: true
+ peerDependencies:
+ less: '*'
+ sass: '*'
+ stylus: '*'
+ peerDependenciesMeta:
+ less:
+ optional: true
+ sass:
+ optional: true
+ stylus:
+ optional: true
+ dependencies:
+ esbuild: 0.14.38
+ postcss: 8.4.13
+ resolve: 1.22.0
+ rollup: 2.72.1
+ sass: 1.51.0
+ optionalDependencies:
+ fsevents: 2.3.2
+ dev: true
+
+ /wrappy/1.0.2:
+ resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=}
+ dev: true
diff --git a/apps/accounts/src/tsconfig.json b/apps/accounts/src/tsconfig.json
new file mode 100644
index 0000000..e00d638
--- /dev/null
+++ b/apps/accounts/src/tsconfig.json
@@ -0,0 +1,30 @@
+{
+ "include": [
+ "./**/*.d.ts",
+ "./**/*.ts",
+ "./**/*.js",
+ "./**/*.svelte"
+ ],
+ "exclude": [
+ "./node_modules"
+ ],
+ "compilerOptions": {
+ "target": "esnext",
+ "useDefineForClassFields": true,
+ "module": "esnext",
+ "moduleResolution": "node",
+ "allowJs": true,
+ "checkJs": false,
+ "paths": {
+ "$app/*": [
+ "./_public/*"
+ ],
+ "$app/*": [
+ "./app/*"
+ ],
+ "$shared/*": [
+ "../../web-shared/src/*"
+ ]
+ }
+ }
+}
diff --git a/apps/accounts/src/vite.config.ts b/apps/accounts/src/vite.config.ts
new file mode 100644
index 0000000..907422e
--- /dev/null
+++ b/apps/accounts/src/vite.config.ts
@@ -0,0 +1,30 @@
+import {defineConfig} from "vite";
+import {svelte} from "@sveltejs/vite-plugin-svelte";
+import sveltePreprocess from "svelte-preprocess";
+// @ts-ignore
+import path from "path";
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ resolve: {
+ alias: {
+ "$shared": path.resolve(__dirname, "../../web-shared/src"),
+ "$app": path.resolve(__dirname, "./app"),
+ }
+ },
+ build: {
+ outDir: "build",
+ emptyOutDir: true,
+ rollupOptions: {
+ input: {
+ main: path.resolve(__dirname, "index.html"),
+ }
+ }
+ },
+
+ plugins: [
+ svelte({
+ preprocess: sveltePreprocess()
+ })
+ ],
+});
diff --git a/apps/frontpage/.gitignore b/apps/frontpage/.gitignore
new file mode 100644
index 0000000..f4401a3
--- /dev/null
+++ b/apps/frontpage/.gitignore
@@ -0,0 +1,8 @@
+.DS_Store
+node_modules
+/build
+/.svelte-kit
+/package
+.env
+.env.*
+!.env.example
diff --git a/apps/frontpage/.version b/apps/frontpage/.version
new file mode 100644
index 0000000..0b20bfd
--- /dev/null
+++ b/apps/frontpage/.version
@@ -0,0 +1 @@
+v8-frontpage
diff --git a/apps/frontpage/.version-dev b/apps/frontpage/.version-dev
new file mode 100644
index 0000000..ee11e9e
--- /dev/null
+++ b/apps/frontpage/.version-dev
@@ -0,0 +1 @@
+v1-frontpage-dev
diff --git a/apps/frontpage/CHANGELOG.md b/apps/frontpage/CHANGELOG.md
new file mode 100644
index 0000000..864930a
--- /dev/null
+++ b/apps/frontpage/CHANGELOG.md
@@ -0,0 +1,9 @@
+# Changelog
+
+## [unreleased]
+
+### Miscellaneous Tasks
+
+- Bump version
+- Update CHANGELOG.md for v8-frontpage
+
diff --git a/apps/frontpage/README.md b/apps/frontpage/README.md
new file mode 100644
index 0000000..42ee3ec
--- /dev/null
+++ b/apps/frontpage/README.md
@@ -0,0 +1,3 @@
+# Frontpage
+
+This is a sveltekit app that makes up the frontpage on greatoffice.life
diff --git a/apps/frontpage/build_and_push.sh b/apps/frontpage/build_and_push.sh
new file mode 100755
index 0000000..3e048f5
--- /dev/null
+++ b/apps/frontpage/build_and_push.sh
@@ -0,0 +1,73 @@
+#!/usr/bin/env bash
+
+set -Eueo pipefail
+
+APP_NAME="frontpage";
+CURRENT_DEV_VERSION=$(cat .version-dev)
+CURRENT_DEV_VERSION_INT=${CURRENT_DEV_VERSION//[!0-9]/}
+CURRENT_VERSION=$(cat .version)
+CURRENT_VERSION_INT=${CURRENT_VERSION//[!0-9]/}
+if [ ${1-prod} == "dev" ]; then
+ NEW_VERSION="v$((CURRENT_DEV_VERSION_INT+1))-$APP_NAME-dev"
+ OLD_VERSION=$CURRENT_DEV_VERSION
+else
+ NEW_VERSION="v$((CURRENT_VERSION_INT+1))-$APP_NAME"
+ OLD_VERSION=$CURRENT_VERSION
+fi
+# Check for uncommited changes and optionally commit them
+if [ "$(git status --untracked-files=no --porcelain)" ]; then
+ echo "Unclean git tree! press CTRL+C to exit or press ENTER to commit and push to the default branch"
+ read -n 1
+
+ read -p "Enter commit message: " COMMIT_MESSAGE
+ git add ../../
+ git commit --quiet -m "$COMMIT_MESSAGE"
+fi
+
+if [ ${1-prod} == "dev" ]; then
+ echo $NEW_VERSION >| .version-dev
+ git add .version-dev
+else
+ echo $NEW_VERSION >| .version
+ git add .version
+fi
+
+echo "Starting build of $APP_NAME@$NEW_VERSION at $(date -u)..."
+echo
+
+git commit --quiet -m "chore(release): Bump version";
+
+read -p "Do you want to tag this build? (y/n) " -n 1 -r
+echo
+if [[ $REPLY =~ ^[Yy]$ ]]
+then
+ read -p "Enter tag message (can be empty): " TAG_MESSAGE
+ commit_msg="chore(release): Update CHANGELOG.md for $NEW_VERSION"
+ git cliff -r ../../ $OLD_VERSION..HEAD --with-commit "$commit_msg" --prepend CHANGELOG.md
+ git add CHANGELOG.md
+ git commit --quiet -m "$commit_msg";
+ git tag -am "$TAG_MESSAGE" $NEW_VERSION
+fi
+
+read -p "Do you want to push the latest commits and tags to origin? (y/n) " -n 1 -r
+echo
+if [[ $REPLY =~ ^[Yy]$ ]]
+then
+ echo "Pushing latest changes to remotes..."
+ echo
+ git push --quiet --follow-tags
+fi
+
+pnpm run build
+
+cd build
+echo "$NEW_VERSION" >version.txt
+
+
+if [ ${1-prod} == "dev" ]; then
+ scp -r * contabo-fast-1:services/public/dev.greatoffice.life/www
+else
+ echo "Pushing to production in 10 sec, press CTRL+C to cancel"
+ sleep 10
+ scp -r * contabo-fast-1:services/public/greatoffice.life/www
+fi
diff --git a/apps/frontpage/cliff.toml b/apps/frontpage/cliff.toml
new file mode 100644
index 0000000..955a72b
--- /dev/null
+++ b/apps/frontpage/cliff.toml
@@ -0,0 +1,62 @@
+# configuration file for git-cliff (0.1.0)
+
+[changelog]
+# changelog header
+header = """
+# Changelog\n
+"""
+# template for the changelog body
+# https://tera.netlify.app/docs/#introduction
+body = """
+{% if version %}\
+ ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
+{% else %}\
+ ## [unreleased]
+{% endif %}\
+{% for group, commits in commits | group_by(attribute="group") %}
+ ### {{ group | upper_first }}
+ {% for commit in commits %}
+ - {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}\
+ {% endfor %}
+{% endfor %}\n
+"""
+# remove the leading and trailing whitespace from the template
+trim = true
+# changelog footer
+footer = """
+<!-- generated by git-cliff -->
+"""
+
+[git]
+# parse the commits based on https://www.conventionalcommits.org
+conventional_commits = true
+# filter out the commits that are not conventional
+filter_unconventional = true
+# regex for preprocessing the commit messages
+commit_preprocessors = [
+ { pattern = "([ \\n])(([a-f0-9]{7})[a-f0-9]*)", replace = "${1}commit # [${3}](https://git.ivarlovlie.no/time-tracker/commit/${2})" },
+ { pattern = "https://git.ivarlovlie.no/time-tracker/commit/([a-f0-9]{7})[a-f0-9]*", replace = "commit # [${1}](${0})" },
+]
+# regex for parsing and grouping commits
+commit_parsers = [
+ { message = "^feat", group = "Features" },
+ { message = "^fix", group = "Bug Fixes" },
+ { message = "^doc", group = "Documentation" },
+ { message = "^perf", group = "Performance" },
+ { message = "^refactor", group = "Refactor" },
+ { message = "^style", group = "Styling" },
+ { message = "^test", group = "Testing" },
+ { message = "^chore", group = "Miscellaneous Tasks" },
+]
+# filter out the commits that are not matched by commit parsers
+filter_commits = true
+# glob pattern for matching git tags
+tag_pattern = "v.*"
+# regex for skipping tags
+skip_tags = "v0.1.0-beta.1"
+# regex for ignoring tags
+ignore_tags = ""
+# sort the tags chronologically
+date_order = true
+# sort the commits inside sections by oldest/newest order
+sort_commits = "newest"
diff --git a/apps/frontpage/index.html b/apps/frontpage/index.html
new file mode 100644
index 0000000..e149a39
--- /dev/null
+++ b/apps/frontpage/index.html
@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <title>Title</title>
+</head>
+<body>
+
+</body>
+</html>
diff --git a/apps/frontpage/package.json b/apps/frontpage/package.json
new file mode 100644
index 0000000..11f90ae
--- /dev/null
+++ b/apps/frontpage/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "frontpage",
+ "version": "0.0.1",
+ "scripts": {
+ "dev": "svelte-kit dev",
+ "build": "svelte-kit build",
+ "package": "svelte-kit package",
+ "preview": "svelte-kit preview",
+ "prepare": "svelte-kit sync",
+ "test": "playwright test",
+ "check": "svelte-check --tsconfig ./tsconfig.json",
+ "check:watch": "svelte-check --tsconfig ./tsconfig.json --watch"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.21.0",
+ "@sveltejs/adapter-static": "1.0.0-next.34",
+ "@sveltejs/kit": "next",
+ "svelte": "^3.44.0",
+ "svelte-check": "^2.2.6",
+ "svelte-preprocess": "^4.10.1",
+ "tslib": "^2.3.1",
+ "typescript": "~4.6.2"
+ },
+ "type": "module"
+}
diff --git a/apps/frontpage/playwright.config.ts b/apps/frontpage/playwright.config.ts
new file mode 100644
index 0000000..05dea1f
--- /dev/null
+++ b/apps/frontpage/playwright.config.ts
@@ -0,0 +1,10 @@
+import type {PlaywrightTestConfig} from "@playwright/test";
+
+const config: PlaywrightTestConfig = {
+ webServer: {
+ command: "pnpm run build && pnpm run preview",
+ port: 3000
+ }
+};
+
+export default config;
diff --git a/apps/frontpage/pnpm-lock.yaml b/apps/frontpage/pnpm-lock.yaml
new file mode 100644
index 0000000..9486720
--- /dev/null
+++ b/apps/frontpage/pnpm-lock.yaml
@@ -0,0 +1,925 @@
+lockfileVersion: 5.4
+
+specifiers:
+ '@playwright/test': ^1.21.0
+ '@sveltejs/adapter-static': 1.0.0-next.34
+ '@sveltejs/kit': next
+ svelte: ^3.44.0
+ svelte-check: ^2.2.6
+ svelte-preprocess: ^4.10.1
+ tslib: ^2.3.1
+ typescript: ~4.6.2
+
+devDependencies:
+ '@playwright/test': 1.22.2
+ '@sveltejs/adapter-static': 1.0.0-next.34
+ '@sveltejs/kit': 1.0.0-next.347_svelte@3.48.0
+ svelte: 3.48.0
+ svelte-check: 2.7.1_svelte@3.48.0
+ svelte-preprocess: 4.10.6_wwvk7nlptlrqo2czohjtk6eiqm
+ tslib: 2.4.0
+ typescript: 4.6.4
+
+packages:
+
+ /@jridgewell/resolve-uri/3.0.7:
+ resolution: {integrity: sha512-8cXDaBBHOr2pQ7j77Y6Vp5VDT2sIqWyWQ56TjEq4ih/a4iST3dItRe8Q9fp0rrIl9DoKhWQtUQz/YpOxLkXbNA==}
+ engines: {node: '>=6.0.0'}
+ dev: true
+
+ /@jridgewell/sourcemap-codec/1.4.13:
+ resolution: {integrity: sha512-GryiOJmNcWbovBxTfZSF71V/mXbgcV3MewDe3kIMCLyIh5e7SKAeUZs+rMnJ8jkMolZ/4/VsdBmMrw3l+VdZ3w==}
+ dev: true
+
+ /@jridgewell/trace-mapping/0.3.13:
+ resolution: {integrity: sha512-o1xbKhp9qnIAoHJSWd6KlCZfqslL4valSF81H8ImioOAxluWYWOpWkpyktY2vnt4tbrX9XYaxovq6cgowaJp2w==}
+ dependencies:
+ '@jridgewell/resolve-uri': 3.0.7
+ '@jridgewell/sourcemap-codec': 1.4.13
+ dev: true
+
+ /@nodelib/fs.scandir/2.1.5:
+ resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
+ engines: {node: '>= 8'}
+ dependencies:
+ '@nodelib/fs.stat': 2.0.5
+ run-parallel: 1.2.0
+ dev: true
+
+ /@nodelib/fs.stat/2.0.5:
+ resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
+ engines: {node: '>= 8'}
+ dev: true
+
+ /@nodelib/fs.walk/1.2.8:
+ resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
+ engines: {node: '>= 8'}
+ dependencies:
+ '@nodelib/fs.scandir': 2.1.5
+ fastq: 1.13.0
+ dev: true
+
+ /@playwright/test/1.22.2:
+ resolution: {integrity: sha512-cCl96BEBGPtptFz7C2FOSN3PrTnJ3rPpENe+gYCMx4GNNDlN4tmo2D89y13feGKTMMAIVrXfSQ/UmaQKLy1XLA==}
+ engines: {node: '>=14'}
+ hasBin: true
+ dependencies:
+ '@types/node': 17.0.38
+ playwright-core: 1.22.2
+ dev: true
+
+ /@rollup/pluginutils/4.2.1:
+ resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==}
+ engines: {node: '>= 8.0.0'}
+ dependencies:
+ estree-walker: 2.0.2
+ picomatch: 2.3.1
+ dev: true
+
+ /@sveltejs/adapter-static/1.0.0-next.34:
+ resolution: {integrity: sha512-XjuMhemme5z0L/B2nTZpA6k+RJjF+b6L96ts6gIQ6ixiCzJQSbBqJhrrBYBCaeLAKvdUMoGEmX8m862JhKjRFg==}
+ dependencies:
+ tiny-glob: 0.2.9
+ dev: true
+
+ /@sveltejs/kit/1.0.0-next.347_svelte@3.48.0:
+ resolution: {integrity: sha512-kxan2F8g9nM/4QzLINsPiZdLZLx6X2Tjg+Ft8KR2QPhHKCEQ3jlosnGTzmznt572PTg89UhiUhQWKK4IDk2nSA==}
+ engines: {node: '>=16.7'}
+ hasBin: true
+ peerDependencies:
+ svelte: ^3.44.0
+ dependencies:
+ '@sveltejs/vite-plugin-svelte': 1.0.0-next.46_svelte@3.48.0+vite@2.9.9
+ chokidar: 3.5.3
+ sade: 1.8.1
+ svelte: 3.48.0
+ vite: 2.9.9
+ transitivePeerDependencies:
+ - diff-match-patch
+ - less
+ - sass
+ - stylus
+ - supports-color
+ dev: true
+
+ /@sveltejs/vite-plugin-svelte/1.0.0-next.46_svelte@3.48.0+vite@2.9.9:
+ resolution: {integrity: sha512-dumtaI5XusnDgXoQ3vxQAdoCaTWf8zKVezJdiTGjuaS/GSsmLIvtHUvMt0NlwEikPQ/hL53eIzMliRQ/j35w9w==}
+ engines: {node: ^14.13.1 || >= 16}
+ peerDependencies:
+ diff-match-patch: ^1.0.5
+ svelte: ^3.44.0
+ vite: ^2.9.0
+ peerDependenciesMeta:
+ diff-match-patch:
+ optional: true
+ dependencies:
+ '@rollup/pluginutils': 4.2.1
+ debug: 4.3.4
+ deepmerge: 4.2.2
+ kleur: 4.1.4
+ magic-string: 0.26.2
+ svelte: 3.48.0
+ svelte-hmr: 0.14.12_svelte@3.48.0
+ vite: 2.9.9
+ transitivePeerDependencies:
+ - supports-color
+ dev: true
+
+ /@types/node/17.0.38:
+ resolution: {integrity: sha512-5jY9RhV7c0Z4Jy09G+NIDTsCZ5G0L5n+Z+p+Y7t5VJHM30bgwzSjVtlcBxqAj+6L/swIlvtOSzr8rBk/aNyV2g==}
+ dev: true
+
+ /@types/pug/2.0.6:
+ resolution: {integrity: sha512-SnHmG9wN1UVmagJOnyo/qkk0Z7gejYxOYYmaAwr5u2yFYfsupN3sg10kyzN8Hep/2zbHxCnsumxOoRIRMBwKCg==}
+ dev: true
+
+ /@types/sass/1.43.1:
+ resolution: {integrity: sha512-BPdoIt1lfJ6B7rw35ncdwBZrAssjcwzI5LByIrYs+tpXlj/CAkuVdRsgZDdP4lq5EjyWzwxZCqAoFyHKFwp32g==}
+ dependencies:
+ '@types/node': 17.0.38
+ dev: true
+
+ /anymatch/3.1.2:
+ resolution: {integrity: sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==}
+ engines: {node: '>= 8'}
+ dependencies:
+ normalize-path: 3.0.0
+ picomatch: 2.3.1
+ dev: true
+
+ /balanced-match/1.0.2:
+ resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+ dev: true
+
+ /binary-extensions/2.2.0:
+ resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==}
+ engines: {node: '>=8'}
+ dev: true
+
+ /brace-expansion/1.1.11:
+ resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==}
+ dependencies:
+ balanced-match: 1.0.2
+ concat-map: 0.0.1
+ dev: true
+
+ /braces/3.0.2:
+ resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==}
+ engines: {node: '>=8'}
+ dependencies:
+ fill-range: 7.0.1
+ dev: true
+
+ /buffer-crc32/0.2.13:
+ resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
+ dev: true
+
+ /callsites/3.1.0:
+ resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
+ engines: {node: '>=6'}
+ dev: true
+
+ /chokidar/3.5.3:
+ resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==}
+ engines: {node: '>= 8.10.0'}
+ dependencies:
+ anymatch: 3.1.2
+ braces: 3.0.2
+ glob-parent: 5.1.2
+ is-binary-path: 2.1.0
+ is-glob: 4.0.3
+ normalize-path: 3.0.0
+ readdirp: 3.6.0
+ optionalDependencies:
+ fsevents: 2.3.2
+ dev: true
+
+ /concat-map/0.0.1:
+ resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=}
+ dev: true
+
+ /debug/4.3.4:
+ resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+ dependencies:
+ ms: 2.1.2
+ dev: true
+
+ /deepmerge/4.2.2:
+ resolution: {integrity: sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /detect-indent/6.1.0:
+ resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==}
+ engines: {node: '>=8'}
+ dev: true
+
+ /es6-promise/3.3.1:
+ resolution: {integrity: sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==}
+ dev: true
+
+ /esbuild-android-64/0.14.42:
+ resolution: {integrity: sha512-P4Y36VUtRhK/zivqGVMqhptSrFILAGlYp0Z8r9UQqHJ3iWztRCNWnlBzD9HRx0DbueXikzOiwyOri+ojAFfW6A==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [android]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-android-arm64/0.14.42:
+ resolution: {integrity: sha512-0cOqCubq+RWScPqvtQdjXG3Czb3AWI2CaKw3HeXry2eoA2rrPr85HF7IpdU26UWdBXgPYtlTN1LUiuXbboROhg==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [android]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-darwin-64/0.14.42:
+ resolution: {integrity: sha512-ipiBdCA3ZjYgRfRLdQwP82rTiv/YVMtW36hTvAN5ZKAIfxBOyPXY7Cejp3bMXWgzKD8B6O+zoMzh01GZsCuEIA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [darwin]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-darwin-arm64/0.14.42:
+ resolution: {integrity: sha512-bU2tHRqTPOaoH/4m0zYHbFWpiYDmaA0gt90/3BMEFaM0PqVK/a6MA2V/ypV5PO0v8QxN6gH5hBPY4YJ2lopXgA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [darwin]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-freebsd-64/0.14.42:
+ resolution: {integrity: sha512-75h1+22Ivy07+QvxHyhVqOdekupiTZVLN1PMwCDonAqyXd8TVNJfIRFrdL8QmSJrOJJ5h8H1I9ETyl2L8LQDaw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [freebsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-freebsd-arm64/0.14.42:
+ resolution: {integrity: sha512-W6Jebeu5TTDQMJUJVarEzRU9LlKpNkPBbjqSu+GUPTHDCly5zZEQq9uHkmHHl7OKm+mQ2zFySN83nmfCeZCyNA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [freebsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-32/0.14.42:
+ resolution: {integrity: sha512-Ooy/Bj+mJ1z4jlWcK5Dl6SlPlCgQB9zg1UrTCeY8XagvuWZ4qGPyYEWGkT94HUsRi2hKsXvcs6ThTOjBaJSMfg==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-64/0.14.42:
+ resolution: {integrity: sha512-2L0HbzQfbTuemUWfVqNIjOfaTRt9zsvjnme6lnr7/MO9toz/MJ5tZhjqrG6uDWDxhsaHI2/nsDgrv8uEEN2eoA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-arm/0.14.42:
+ resolution: {integrity: sha512-STq69yzCMhdRaWnh29UYrLSr/qaWMm/KqwaRF1pMEK7kDiagaXhSL1zQGXbYv94GuGY/zAwzK98+6idCMUOOCg==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-arm64/0.14.42:
+ resolution: {integrity: sha512-c3Ug3e9JpVr8jAcfbhirtpBauLxzYPpycjWulD71CF6ZSY26tvzmXMJYooQ2YKqDY4e/fPu5K8bm7MiXMnyxuA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-mips64le/0.14.42:
+ resolution: {integrity: sha512-QuvpHGbYlkyXWf2cGm51LBCHx6eUakjaSrRpUqhPwjh/uvNUYvLmz2LgPTTPwCqaKt0iwL+OGVL0tXA5aDbAbg==}
+ engines: {node: '>=12'}
+ cpu: [mips64el]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-ppc64le/0.14.42:
+ resolution: {integrity: sha512-8ohIVIWDbDT+i7lCx44YCyIRrOW1MYlks9fxTo0ME2LS/fxxdoJBwHWzaDYhjvf8kNpA+MInZvyOEAGoVDrMHg==}
+ engines: {node: '>=12'}
+ cpu: [ppc64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-riscv64/0.14.42:
+ resolution: {integrity: sha512-DzDqK3TuoXktPyG1Lwx7vhaF49Onv3eR61KwQyxYo4y5UKTpL3NmuarHSIaSVlTFDDpcIajCDwz5/uwKLLgKiQ==}
+ engines: {node: '>=12'}
+ cpu: [riscv64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-s390x/0.14.42:
+ resolution: {integrity: sha512-YFRhPCxl8nb//Wn6SiS5pmtplBi4z9yC2gLrYoYI/tvwuB1jldir9r7JwAGy1Ck4D7sE7wBN9GFtUUX/DLdcEQ==}
+ engines: {node: '>=12'}
+ cpu: [s390x]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-netbsd-64/0.14.42:
+ resolution: {integrity: sha512-QYSD2k+oT9dqB/4eEM9c+7KyNYsIPgzYOSrmfNGDIyJrbT1d+CFVKvnKahDKNJLfOYj8N4MgyFaU9/Ytc6w5Vw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [netbsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-openbsd-64/0.14.42:
+ resolution: {integrity: sha512-M2meNVIKWsm2HMY7+TU9AxM7ZVwI9havdsw6m/6EzdXysyCFFSoaTQ/Jg03izjCsK17FsVRHqRe26Llj6x0MNA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [openbsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-sunos-64/0.14.42:
+ resolution: {integrity: sha512-uXV8TAZEw36DkgW8Ak3MpSJs1ofBb3Smkc/6pZ29sCAN1KzCAQzsje4sUwugf+FVicrHvlamCOlFZIXgct+iqQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [sunos]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-windows-32/0.14.42:
+ resolution: {integrity: sha512-4iw/8qWmRICWi9ZOnJJf9sYt6wmtp3hsN4TdI5NqgjfOkBVMxNdM9Vt3626G1Rda9ya2Q0hjQRD9W1o+m6Lz6g==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [win32]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-windows-64/0.14.42:
+ resolution: {integrity: sha512-j3cdK+Y3+a5H0wHKmLGTJcq0+/2mMBHPWkItR3vytp/aUGD/ua/t2BLdfBIzbNN9nLCRL9sywCRpOpFMx3CxzA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [win32]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-windows-arm64/0.14.42:
+ resolution: {integrity: sha512-+lRAARnF+hf8J0mN27ujO+VbhPbDqJ8rCcJKye4y7YZLV6C4n3pTRThAb388k/zqF5uM0lS5O201u0OqoWSicw==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [win32]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild/0.14.42:
+ resolution: {integrity: sha512-V0uPZotCEHokJdNqyozH6qsaQXqmZEOiZWrXnds/zaH/0SyrIayRXWRB98CENO73MIZ9T3HBIOsmds5twWtmgw==}
+ engines: {node: '>=12'}
+ hasBin: true
+ requiresBuild: true
+ optionalDependencies:
+ esbuild-android-64: 0.14.42
+ esbuild-android-arm64: 0.14.42
+ esbuild-darwin-64: 0.14.42
+ esbuild-darwin-arm64: 0.14.42
+ esbuild-freebsd-64: 0.14.42
+ esbuild-freebsd-arm64: 0.14.42
+ esbuild-linux-32: 0.14.42
+ esbuild-linux-64: 0.14.42
+ esbuild-linux-arm: 0.14.42
+ esbuild-linux-arm64: 0.14.42
+ esbuild-linux-mips64le: 0.14.42
+ esbuild-linux-ppc64le: 0.14.42
+ esbuild-linux-riscv64: 0.14.42
+ esbuild-linux-s390x: 0.14.42
+ esbuild-netbsd-64: 0.14.42
+ esbuild-openbsd-64: 0.14.42
+ esbuild-sunos-64: 0.14.42
+ esbuild-windows-32: 0.14.42
+ esbuild-windows-64: 0.14.42
+ esbuild-windows-arm64: 0.14.42
+ dev: true
+
+ /estree-walker/2.0.2:
+ resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
+ dev: true
+
+ /fast-glob/3.2.11:
+ resolution: {integrity: sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==}
+ engines: {node: '>=8.6.0'}
+ dependencies:
+ '@nodelib/fs.stat': 2.0.5
+ '@nodelib/fs.walk': 1.2.8
+ glob-parent: 5.1.2
+ merge2: 1.4.1
+ micromatch: 4.0.5
+ dev: true
+
+ /fastq/1.13.0:
+ resolution: {integrity: sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==}
+ dependencies:
+ reusify: 1.0.4
+ dev: true
+
+ /fill-range/7.0.1:
+ resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==}
+ engines: {node: '>=8'}
+ dependencies:
+ to-regex-range: 5.0.1
+ dev: true
+
+ /fs.realpath/1.0.0:
+ resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
+ dev: true
+
+ /fsevents/2.3.2:
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /function-bind/1.1.1:
+ resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==}
+ dev: true
+
+ /glob-parent/5.1.2:
+ resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
+ engines: {node: '>= 6'}
+ dependencies:
+ is-glob: 4.0.3
+ dev: true
+
+ /glob/7.2.3:
+ resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
+ dependencies:
+ fs.realpath: 1.0.0
+ inflight: 1.0.6
+ inherits: 2.0.4
+ minimatch: 3.1.2
+ once: 1.4.0
+ path-is-absolute: 1.0.1
+ dev: true
+
+ /globalyzer/0.1.0:
+ resolution: {integrity: sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==}
+ dev: true
+
+ /globrex/0.1.2:
+ resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==}
+ dev: true
+
+ /graceful-fs/4.2.10:
+ resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==}
+ dev: true
+
+ /has/1.0.3:
+ resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==}
+ engines: {node: '>= 0.4.0'}
+ dependencies:
+ function-bind: 1.1.1
+ dev: true
+
+ /import-fresh/3.3.0:
+ resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
+ engines: {node: '>=6'}
+ dependencies:
+ parent-module: 1.0.1
+ resolve-from: 4.0.0
+ dev: true
+
+ /inflight/1.0.6:
+ resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
+ dependencies:
+ once: 1.4.0
+ wrappy: 1.0.2
+ dev: true
+
+ /inherits/2.0.4:
+ resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+ dev: true
+
+ /is-binary-path/2.1.0:
+ resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
+ engines: {node: '>=8'}
+ dependencies:
+ binary-extensions: 2.2.0
+ dev: true
+
+ /is-core-module/2.9.0:
+ resolution: {integrity: sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==}
+ dependencies:
+ has: 1.0.3
+ dev: true
+
+ /is-extglob/2.1.1:
+ resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /is-glob/4.0.3:
+ resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
+ engines: {node: '>=0.10.0'}
+ dependencies:
+ is-extglob: 2.1.1
+ dev: true
+
+ /is-number/7.0.0:
+ resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
+ engines: {node: '>=0.12.0'}
+ dev: true
+
+ /kleur/4.1.4:
+ resolution: {integrity: sha512-8QADVssbrFjivHWQU7KkMgptGTl6WAcSdlbBPY4uNF+mWr6DGcKrvY2w4FQJoXch7+fKMjj0dRrL75vk3k23OA==}
+ engines: {node: '>=6'}
+ dev: true
+
+ /magic-string/0.25.9:
+ resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==}
+ dependencies:
+ sourcemap-codec: 1.4.8
+ dev: true
+
+ /magic-string/0.26.2:
+ resolution: {integrity: sha512-NzzlXpclt5zAbmo6h6jNc8zl2gNRGHvmsZW4IvZhTC4W7k4OlLP+S5YLussa/r3ixNT66KOQfNORlXHSOy/X4A==}
+ engines: {node: '>=12'}
+ dependencies:
+ sourcemap-codec: 1.4.8
+ dev: true
+
+ /merge2/1.4.1:
+ resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
+ engines: {node: '>= 8'}
+ dev: true
+
+ /micromatch/4.0.5:
+ resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==}
+ engines: {node: '>=8.6'}
+ dependencies:
+ braces: 3.0.2
+ picomatch: 2.3.1
+ dev: true
+
+ /min-indent/1.0.1:
+ resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
+ engines: {node: '>=4'}
+ dev: true
+
+ /minimatch/3.1.2:
+ resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
+ dependencies:
+ brace-expansion: 1.1.11
+ dev: true
+
+ /minimist/1.2.6:
+ resolution: {integrity: sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==}
+ dev: true
+
+ /mkdirp/0.5.6:
+ resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
+ hasBin: true
+ dependencies:
+ minimist: 1.2.6
+ dev: true
+
+ /mri/1.2.0:
+ resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
+ engines: {node: '>=4'}
+ dev: true
+
+ /ms/2.1.2:
+ resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
+ dev: true
+
+ /nanoid/3.3.4:
+ resolution: {integrity: sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+ dev: true
+
+ /normalize-path/3.0.0:
+ resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /once/1.4.0:
+ resolution: {integrity: sha1-WDsap3WWHUsROsF9nFC6753Xa9E=}
+ dependencies:
+ wrappy: 1.0.2
+ dev: true
+
+ /parent-module/1.0.1:
+ resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
+ engines: {node: '>=6'}
+ dependencies:
+ callsites: 3.1.0
+ dev: true
+
+ /path-is-absolute/1.0.1:
+ resolution: {integrity: sha1-F0uSaHNVNP+8es5r9TpanhtcX18=}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /path-parse/1.0.7:
+ resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
+ dev: true
+
+ /picocolors/1.0.0:
+ resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==}
+ dev: true
+
+ /picomatch/2.3.1:
+ resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
+ engines: {node: '>=8.6'}
+ dev: true
+
+ /playwright-core/1.22.2:
+ resolution: {integrity: sha512-w/hc/Ld0RM4pmsNeE6aL/fPNWw8BWit2tg+TfqJ3+p59c6s3B6C8mXvXrIPmfQEobkcFDc+4KirNzOQ+uBSP1Q==}
+ engines: {node: '>=14'}
+ hasBin: true
+ dev: true
+
+ /postcss/8.4.14:
+ resolution: {integrity: sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig==}
+ engines: {node: ^10 || ^12 || >=14}
+ dependencies:
+ nanoid: 3.3.4
+ picocolors: 1.0.0
+ source-map-js: 1.0.2
+ dev: true
+
+ /queue-microtask/1.2.3:
+ resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+ dev: true
+
+ /readdirp/3.6.0:
+ resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
+ engines: {node: '>=8.10.0'}
+ dependencies:
+ picomatch: 2.3.1
+ dev: true
+
+ /resolve-from/4.0.0:
+ resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
+ engines: {node: '>=4'}
+ dev: true
+
+ /resolve/1.22.0:
+ resolution: {integrity: sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==}
+ hasBin: true
+ dependencies:
+ is-core-module: 2.9.0
+ path-parse: 1.0.7
+ supports-preserve-symlinks-flag: 1.0.0
+ dev: true
+
+ /reusify/1.0.4:
+ resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==}
+ engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
+ dev: true
+
+ /rimraf/2.7.1:
+ resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==}
+ hasBin: true
+ dependencies:
+ glob: 7.2.3
+ dev: true
+
+ /rollup/2.75.4:
+ resolution: {integrity: sha512-JgZiJMJkKImMZJ8ZY1zU80Z2bA/TvrL/7D9qcBCrfl2bP+HUaIw0QHUroB4E3gBpFl6CRFM1YxGbuYGtdAswbQ==}
+ engines: {node: '>=10.0.0'}
+ hasBin: true
+ optionalDependencies:
+ fsevents: 2.3.2
+ dev: true
+
+ /run-parallel/1.2.0:
+ resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
+ dependencies:
+ queue-microtask: 1.2.3
+ dev: true
+
+ /sade/1.8.1:
+ resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
+ engines: {node: '>=6'}
+ dependencies:
+ mri: 1.2.0
+ dev: true
+
+ /sander/0.5.1:
+ resolution: {integrity: sha1-dB4kXiMfB8r7b98PEzrfohalAq0=}
+ dependencies:
+ es6-promise: 3.3.1
+ graceful-fs: 4.2.10
+ mkdirp: 0.5.6
+ rimraf: 2.7.1
+ dev: true
+
+ /sorcery/0.10.0:
+ resolution: {integrity: sha1-iukK19fLBfxZ8asMY3hF1cFaUrc=}
+ hasBin: true
+ dependencies:
+ buffer-crc32: 0.2.13
+ minimist: 1.2.6
+ sander: 0.5.1
+ sourcemap-codec: 1.4.8
+ dev: true
+
+ /source-map-js/1.0.2:
+ resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /sourcemap-codec/1.4.8:
+ resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==}
+ dev: true
+
+ /strip-indent/3.0.0:
+ resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
+ engines: {node: '>=8'}
+ dependencies:
+ min-indent: 1.0.1
+ dev: true
+
+ /supports-preserve-symlinks-flag/1.0.0:
+ resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
+ engines: {node: '>= 0.4'}
+ dev: true
+
+ /svelte-check/2.7.1_svelte@3.48.0:
+ resolution: {integrity: sha512-vHVu2+SQ6ibt77iTQaq2oiOjBgGL48qqcg0ZdEOsP5pPOjgeyR9QbnaEdzdBs9nsVYBc/42haKtzb2uFqS8GVw==}
+ hasBin: true
+ peerDependencies:
+ svelte: ^3.24.0
+ dependencies:
+ '@jridgewell/trace-mapping': 0.3.13
+ chokidar: 3.5.3
+ fast-glob: 3.2.11
+ import-fresh: 3.3.0
+ picocolors: 1.0.0
+ sade: 1.8.1
+ svelte: 3.48.0
+ svelte-preprocess: 4.10.6_wwvk7nlptlrqo2czohjtk6eiqm
+ typescript: 4.6.4
+ transitivePeerDependencies:
+ - '@babel/core'
+ - coffeescript
+ - less
+ - node-sass
+ - postcss
+ - postcss-load-config
+ - pug
+ - sass
+ - stylus
+ - sugarss
+ dev: true
+
+ /svelte-hmr/0.14.12_svelte@3.48.0:
+ resolution: {integrity: sha512-4QSW/VvXuqVcFZ+RhxiR8/newmwOCTlbYIezvkeN6302YFRE8cXy0naamHcjz8Y9Ce3ITTZtrHrIL0AGfyo61w==}
+ engines: {node: ^12.20 || ^14.13.1 || >= 16}
+ peerDependencies:
+ svelte: '>=3.19.0'
+ dependencies:
+ svelte: 3.48.0
+ dev: true
+
+ /svelte-preprocess/4.10.6_wwvk7nlptlrqo2czohjtk6eiqm:
+ resolution: {integrity: sha512-I2SV1w/AveMvgIQlUF/ZOO3PYVnhxfcpNyGt8pxpUVhPfyfL/CZBkkw/KPfuFix5FJ9TnnNYMhACK3DtSaYVVQ==}
+ engines: {node: '>= 9.11.2'}
+ requiresBuild: true
+ peerDependencies:
+ '@babel/core': ^7.10.2
+ coffeescript: ^2.5.1
+ less: ^3.11.3 || ^4.0.0
+ node-sass: '*'
+ postcss: ^7 || ^8
+ postcss-load-config: ^2.1.0 || ^3.0.0
+ pug: ^3.0.0
+ sass: ^1.26.8
+ stylus: ^0.55.0
+ sugarss: ^2.0.0
+ svelte: ^3.23.0
+ typescript: ^3.9.5 || ^4.0.0
+ peerDependenciesMeta:
+ '@babel/core':
+ optional: true
+ coffeescript:
+ optional: true
+ less:
+ optional: true
+ node-sass:
+ optional: true
+ postcss:
+ optional: true
+ postcss-load-config:
+ optional: true
+ pug:
+ optional: true
+ sass:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ typescript:
+ optional: true
+ dependencies:
+ '@types/pug': 2.0.6
+ '@types/sass': 1.43.1
+ detect-indent: 6.1.0
+ magic-string: 0.25.9
+ sorcery: 0.10.0
+ strip-indent: 3.0.0
+ svelte: 3.48.0
+ typescript: 4.6.4
+ dev: true
+
+ /svelte/3.48.0:
+ resolution: {integrity: sha512-fN2YRm/bGumvjUpu6yI3BpvZnpIm9I6A7HR4oUNYd7ggYyIwSA/BX7DJ+UXXffLp6XNcUijyLvttbPVCYa/3xQ==}
+ engines: {node: '>= 8'}
+ dev: true
+
+ /tiny-glob/0.2.9:
+ resolution: {integrity: sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==}
+ dependencies:
+ globalyzer: 0.1.0
+ globrex: 0.1.2
+ dev: true
+
+ /to-regex-range/5.0.1:
+ resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
+ engines: {node: '>=8.0'}
+ dependencies:
+ is-number: 7.0.0
+ dev: true
+
+ /tslib/2.4.0:
+ resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==}
+ dev: true
+
+ /typescript/4.6.4:
+ resolution: {integrity: sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==}
+ engines: {node: '>=4.2.0'}
+ hasBin: true
+ dev: true
+
+ /vite/2.9.9:
+ resolution: {integrity: sha512-ffaam+NgHfbEmfw/Vuh6BHKKlI/XIAhxE5QSS7gFLIngxg171mg1P3a4LSRME0z2ZU1ScxoKzphkipcYwSD5Ew==}
+ engines: {node: '>=12.2.0'}
+ hasBin: true
+ peerDependencies:
+ less: '*'
+ sass: '*'
+ stylus: '*'
+ peerDependenciesMeta:
+ less:
+ optional: true
+ sass:
+ optional: true
+ stylus:
+ optional: true
+ dependencies:
+ esbuild: 0.14.42
+ postcss: 8.4.14
+ resolve: 1.22.0
+ rollup: 2.75.4
+ optionalDependencies:
+ fsevents: 2.3.2
+ dev: true
+
+ /wrappy/1.0.2:
+ resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=}
+ dev: true
diff --git a/apps/frontpage/src/app.d.ts b/apps/frontpage/src/app.d.ts
new file mode 100644
index 0000000..121720c
--- /dev/null
+++ b/apps/frontpage/src/app.d.ts
@@ -0,0 +1,10 @@
+/// <reference types="@sveltejs/kit" />
+
+// See https://kit.svelte.dev/docs/types#app
+// for information about these interfaces
+declare namespace App {
+ // interface Locals {}
+ // interface Platform {}
+ // interface Session {}
+ // interface Stuff {}
+}
diff --git a/apps/frontpage/src/app.html b/apps/frontpage/src/app.html
new file mode 100644
index 0000000..3dff376
--- /dev/null
+++ b/apps/frontpage/src/app.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <link rel="icon" href="%sveltekit.assets%/favicon.png" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ %sveltekit.head%
+ </head>
+ <body>
+ <main>%sveltekit.body%</main>
+ </body>
+</html>
diff --git a/apps/frontpage/src/routes/index.svelte b/apps/frontpage/src/routes/index.svelte
new file mode 100644
index 0000000..8038f19
--- /dev/null
+++ b/apps/frontpage/src/routes/index.svelte
@@ -0,0 +1 @@
+<h1>Welcome to Greatoffice</h1>
diff --git a/apps/frontpage/static/favicon.png b/apps/frontpage/static/favicon.png
new file mode 100644
index 0000000..825b9e6
--- /dev/null
+++ b/apps/frontpage/static/favicon.png
Binary files differ
diff --git a/apps/frontpage/svelte.config.js b/apps/frontpage/svelte.config.js
new file mode 100644
index 0000000..20f856b
--- /dev/null
+++ b/apps/frontpage/svelte.config.js
@@ -0,0 +1,20 @@
+import adapter from "@sveltejs/adapter-static";
+import preprocess from "svelte-preprocess";
+
+/** @type {import("@sveltejs/kit").Config} */
+const config = {
+ // Consult https://github.com/sveltejs/svelte-preprocess
+ // for more information about preprocessors
+ preprocess: preprocess(),
+
+ kit: {
+ adapter: adapter({
+ fallback: "index.html",
+ prerender: {
+ default: false
+ }
+ })
+ }
+};
+
+export default config;
diff --git a/apps/frontpage/tests/test.ts b/apps/frontpage/tests/test.ts
new file mode 100644
index 0000000..af64ad3
--- /dev/null
+++ b/apps/frontpage/tests/test.ts
@@ -0,0 +1,6 @@
+import { expect, test } from '@playwright/test';
+
+test('index page has expected h1', async ({ page }) => {
+ await page.goto('/');
+ expect(await page.textContent('h1')).toBe("Welcome to Greatoffice");
+});
diff --git a/apps/frontpage/tsconfig.json b/apps/frontpage/tsconfig.json
new file mode 100644
index 0000000..0f47472
--- /dev/null
+++ b/apps/frontpage/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true
+ }
+}
diff --git a/apps/projects-web/.version b/apps/projects-web/.version
new file mode 100644
index 0000000..2eb62bb
--- /dev/null
+++ b/apps/projects-web/.version
@@ -0,0 +1 @@
+v1-projects
diff --git a/apps/projects-web/.version-dev b/apps/projects-web/.version-dev
new file mode 100644
index 0000000..1c69081
--- /dev/null
+++ b/apps/projects-web/.version-dev
@@ -0,0 +1 @@
+v4-projects-dev
diff --git a/apps/projects-web/CHANGELOG.md b/apps/projects-web/CHANGELOG.md
new file mode 100644
index 0000000..0aa4f12
--- /dev/null
+++ b/apps/projects-web/CHANGELOG.md
@@ -0,0 +1,67 @@
+# Changelog
+
+## [unreleased]
+
+### Miscellaneous Tasks
+
+- Bump version
+- Update CHANGELOG.md for v4-projects-dev
+
+### Refactor
+
+- Name path basers based on the app it bases
+
+## [unreleased]
+
+### Miscellaneous Tasks
+
+- Bump version
+- Update CHANGELOG.md for v3-projects-dev
+
+## [unreleased]
+
+### Bug Fixes
+
+- .
+- .
+- Incorrect paths
+
+### Miscellaneous Tasks
+
+- Bump version
+- Update CHANGELOG.md for v8-web-app-dev
+- Bump version
+- Update CHANGELOG.md for v7-web-app-dev
+- Bump version
+- Bump version
+- Bump version
+- Bump version
+- Bump version
+- Bump version
+- Update CHANGELOG.md for v14-web-app-dev
+
+## [unreleased]
+
+### Miscellaneous Tasks
+
+- Bump version
+- Update CHANGELOG.md for v13-web-app-dev
+
+## [unreleased]
+
+### Bug Fixes
+
+- Inncorrect paths
+
+### Miscellaneous Tasks
+
+- Bump version
+- Update CHANGELOG.md for v20-web-app
+
+## [unreleased]
+
+### Miscellaneous Tasks
+
+- Bump version
+- Update CHANGELOG.md for v19-web-app
+
diff --git a/apps/projects-web/build_and_push.sh b/apps/projects-web/build_and_push.sh
new file mode 100755
index 0000000..abc8ea9
--- /dev/null
+++ b/apps/projects-web/build_and_push.sh
@@ -0,0 +1,76 @@
+#!/usr/bin/env bash
+
+set -Eueo pipefail
+
+APP_NAME="projects"
+CURRENT_DEV_VERSION=$(cat .version-dev)
+CURRENT_DEV_VERSION_INT=${CURRENT_DEV_VERSION//[!0-9]/}
+CURRENT_VERSION=$(cat .version)
+CURRENT_VERSION_INT=${CURRENT_VERSION//[!0-9]/}
+if [ ${1-prod} == "dev" ]; then
+ NEW_VERSION="v$((CURRENT_DEV_VERSION_INT+1))-$APP_NAME-dev"
+ OLD_VERSION=$CURRENT_DEV_VERSION
+else
+ NEW_VERSION="v$((CURRENT_VERSION_INT+1))-$APP_NAME"
+ OLD_VERSION=$CURRENT_VERSION
+fi
+# Check for uncommited changes and optionally commit them
+if [ "$(git status --untracked-files=no --porcelain)" ]; then
+ echo "Unclean git tree! press CTRL+C to exit or press ENTER to commit and push to the default branch"
+ read -n 1
+
+ read -p "Enter commit message: " COMMIT_MESSAGE
+ git add ../..
+ git commit --quiet -m "$COMMIT_MESSAGE"
+fi
+
+if [ ${1-prod} == "dev" ]; then
+ echo $NEW_VERSION >| .version-dev
+ git add .version-dev
+else
+ echo $NEW_VERSION >| .version
+ git add .version
+fi
+
+echo "Starting build of $APP_NAME@$NEW_VERSION at $(date -u)..."
+echo
+
+git commit --quiet -m "chore(release): Bump version";
+
+read -p "Do you want to tag this build? (y/n) " -n 1 -r
+echo
+if [[ $REPLY =~ ^[Yy]$ ]]
+then
+ read -p "Enter tag message (can be empty): " TAG_MESSAGE
+ commit_msg="chore(release): Update CHANGELOG.md for $NEW_VERSION"
+ git cliff -r ../../ $OLD_VERSION..HEAD --with-commit "$commit_msg" --prepend CHANGELOG.md
+ git add CHANGELOG.md
+ git commit --quiet -m "$commit_msg";
+ git tag -am "$TAG_MESSAGE" $NEW_VERSION
+fi
+
+read -p "Do you want to push the latest commits and tags to origin? (y/n) " -n 1 -r
+echo
+if [[ $REPLY =~ ^[Yy]$ ]]
+then
+ echo "Pushing latest changes to remotes..."
+ echo
+ git push --quiet --follow-tags
+fi
+
+pushd src
+pnpm run build
+
+cd build
+echo "$NEW_VERSION" >version.txt
+
+
+if [ ${1-prod} == "dev" ]; then
+ scp -r * contabo-fast-1:services/public/projects.dev.greatoffice.life/www
+else
+ echo "Pushing to production in 10 sec, press CTRL+C to cancel"
+ sleep 10
+ scp -r * contabo-fast-1:services/public/projects.greatoffice.life/www
+fi
+
+popd
diff --git a/apps/projects-web/cliff.toml b/apps/projects-web/cliff.toml
new file mode 100644
index 0000000..955a72b
--- /dev/null
+++ b/apps/projects-web/cliff.toml
@@ -0,0 +1,62 @@
+# configuration file for git-cliff (0.1.0)
+
+[changelog]
+# changelog header
+header = """
+# Changelog\n
+"""
+# template for the changelog body
+# https://tera.netlify.app/docs/#introduction
+body = """
+{% if version %}\
+ ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
+{% else %}\
+ ## [unreleased]
+{% endif %}\
+{% for group, commits in commits | group_by(attribute="group") %}
+ ### {{ group | upper_first }}
+ {% for commit in commits %}
+ - {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}\
+ {% endfor %}
+{% endfor %}\n
+"""
+# remove the leading and trailing whitespace from the template
+trim = true
+# changelog footer
+footer = """
+<!-- generated by git-cliff -->
+"""
+
+[git]
+# parse the commits based on https://www.conventionalcommits.org
+conventional_commits = true
+# filter out the commits that are not conventional
+filter_unconventional = true
+# regex for preprocessing the commit messages
+commit_preprocessors = [
+ { pattern = "([ \\n])(([a-f0-9]{7})[a-f0-9]*)", replace = "${1}commit # [${3}](https://git.ivarlovlie.no/time-tracker/commit/${2})" },
+ { pattern = "https://git.ivarlovlie.no/time-tracker/commit/([a-f0-9]{7})[a-f0-9]*", replace = "commit # [${1}](${0})" },
+]
+# regex for parsing and grouping commits
+commit_parsers = [
+ { message = "^feat", group = "Features" },
+ { message = "^fix", group = "Bug Fixes" },
+ { message = "^doc", group = "Documentation" },
+ { message = "^perf", group = "Performance" },
+ { message = "^refactor", group = "Refactor" },
+ { message = "^style", group = "Styling" },
+ { message = "^test", group = "Testing" },
+ { message = "^chore", group = "Miscellaneous Tasks" },
+]
+# filter out the commits that are not matched by commit parsers
+filter_commits = true
+# glob pattern for matching git tags
+tag_pattern = "v.*"
+# regex for skipping tags
+skip_tags = "v0.1.0-beta.1"
+# regex for ignoring tags
+ignore_tags = ""
+# sort the tags chronologically
+date_order = true
+# sort the commits inside sections by oldest/newest order
+sort_commits = "newest"
diff --git a/apps/projects-web/src/_assets/pre.css b/apps/projects-web/src/_assets/pre.css
new file mode 100644
index 0000000..9c9446e
--- /dev/null
+++ b/apps/projects-web/src/_assets/pre.css
@@ -0,0 +1,128 @@
+:root {
+ --loader-primary: hsl(250, 84%, 54%);
+ --loader-accent: hsl(342, 89%, 48%);
+ --loader-contrast: hsl(180, 1%, 84%);
+ --loader-easing: cubic-bezier(0.645, 0.045, 0.355, 1);
+}
+
+[data-theme="dark"] :root {
+ --loader-primary: hsl(250, 93%, 65%);
+ --loader-accent: hsl(342, 92%, 54%);
+ --loader-contrast: hsl(208, 12%, 24%);
+ --loader-easing: cubic-bezier(0.645, 0.045, 0.355, 1);
+}
+
+[data-theme="dark"] {
+ background-color: hsl(203, 24%, 13%);
+}
+
+.fill-loader {
+ position: relative;
+ overflow: hidden;
+ display: inline-block;
+ margin: 3rem;
+}
+
+.fill-loader__fill {
+ position: absolute;
+}
+
+@supports (-webkit-animation-name: this) or (animation-name: this) {
+ .fill-loader__label {
+ position: absolute;
+ clip: rect(1px, 1px, 1px, 1px);
+ -webkit-clip-path: inset(50%);
+ clip-path: inset(50%);
+ }
+}
+
+@supports (-webkit-animation-name: this) or (animation-name: this) {
+ .fill-loader--v4 {
+ width: 90%;
+ max-width: 300px;
+ }
+
+ .fill-loader--v4 .fill-loader__base {
+ height: 4px;
+ background-color: var(--loader-contrast);
+ }
+
+ .fill-loader--v4 .fill-loader__fill {
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 100%;
+ background-color: var(--loader-primary);
+ -webkit-animation: fill-loader-4 1.6s infinite var(--loader-easing);
+ animation: fill-loader-4 1.6s infinite var(--loader-easing);
+ will-change: left, right;
+ }
+}
+
+@-webkit-keyframes fill-loader-4 {
+ 0% {
+ left: 0;
+ right: 100%;
+ background-color: var(--loader-primary);
+ }
+
+ 10%,
+ 60% {
+ left: 0;
+ }
+
+ 40%,
+ 90% {
+ right: 0;
+ }
+
+ 50% {
+ left: 100%;
+ background-color: var(--loader-primary);
+ }
+
+ 51% {
+ left: 0;
+ right: 100%;
+ background-color: var(--loader-accent);
+ }
+
+ 100% {
+ left: 100%;
+ background-color: var(--loader-accent);
+ }
+}
+
+@keyframes fill-loader-4 {
+ 0% {
+ left: 0;
+ right: 100%;
+ background-color: var(--loader-primary);
+ }
+
+ 10%,
+ 60% {
+ left: 0;
+ }
+
+ 40%,
+ 90% {
+ right: 0;
+ }
+
+ 50% {
+ left: 100%;
+ background-color: var(--loader-primary);
+ }
+
+ 51% {
+ left: 0;
+ right: 100%;
+ background-color: var(--loader-accent);
+ }
+
+ 100% {
+ left: 100%;
+ background-color: var(--loader-accent);
+ }
+}
diff --git a/apps/projects-web/src/_assets/pwa/android-chrome-192x192.png b/apps/projects-web/src/_assets/pwa/android-chrome-192x192.png
new file mode 100644
index 0000000..5c098bc
--- /dev/null
+++ b/apps/projects-web/src/_assets/pwa/android-chrome-192x192.png
Binary files differ
diff --git a/apps/projects-web/src/_assets/pwa/android-chrome-512x512.png b/apps/projects-web/src/_assets/pwa/android-chrome-512x512.png
new file mode 100644
index 0000000..973a1c3
--- /dev/null
+++ b/apps/projects-web/src/_assets/pwa/android-chrome-512x512.png
Binary files differ
diff --git a/apps/projects-web/src/_assets/pwa/apple-touch-icon.png b/apps/projects-web/src/_assets/pwa/apple-touch-icon.png
new file mode 100644
index 0000000..b4d9773
--- /dev/null
+++ b/apps/projects-web/src/_assets/pwa/apple-touch-icon.png
Binary files differ
diff --git a/apps/projects-web/src/_assets/pwa/browserconfig.xml b/apps/projects-web/src/_assets/pwa/browserconfig.xml
new file mode 100644
index 0000000..b3930d0
--- /dev/null
+++ b/apps/projects-web/src/_assets/pwa/browserconfig.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<browserconfig>
+ <msapplication>
+ <tile>
+ <square150x150logo src="/mstile-150x150.png"/>
+ <TileColor>#da532c</TileColor>
+ </tile>
+ </msapplication>
+</browserconfig>
diff --git a/apps/projects-web/src/_assets/pwa/favicon-16x16.png b/apps/projects-web/src/_assets/pwa/favicon-16x16.png
new file mode 100644
index 0000000..5dde9f9
--- /dev/null
+++ b/apps/projects-web/src/_assets/pwa/favicon-16x16.png
Binary files differ
diff --git a/apps/projects-web/src/_assets/pwa/favicon-32x32.png b/apps/projects-web/src/_assets/pwa/favicon-32x32.png
new file mode 100644
index 0000000..9cef4c4
--- /dev/null
+++ b/apps/projects-web/src/_assets/pwa/favicon-32x32.png
Binary files differ
diff --git a/apps/projects-web/src/_assets/pwa/favicon.ico b/apps/projects-web/src/_assets/pwa/favicon.ico
new file mode 100644
index 0000000..89c7542
--- /dev/null
+++ b/apps/projects-web/src/_assets/pwa/favicon.ico
Binary files differ
diff --git a/apps/projects-web/src/_assets/pwa/favicon.svg b/apps/projects-web/src/_assets/pwa/favicon.svg
new file mode 100644
index 0000000..964dbb8
--- /dev/null
+++ b/apps/projects-web/src/_assets/pwa/favicon.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-stopwatch" viewBox="0 0 16 16">
+ <path d="M8.5 5.6a.5.5 0 1 0-1 0v2.9h-3a.5.5 0 0 0 0 1H8a.5.5 0 0 0 .5-.5V5.6z"/>
+ <path d="M6.5 1A.5.5 0 0 1 7 .5h2a.5.5 0 0 1 0 1v.57c1.36.196 2.594.78 3.584 1.64a.715.715 0 0 1 .012-.013l.354-.354-.354-.353a.5.5 0 0 1 .707-.708l1.414 1.415a.5.5 0 1 1-.707.707l-.353-.354-.354.354a.512.512 0 0 1-.013.012A7 7 0 1 1 7 2.071V1.5a.5.5 0 0 1-.5-.5zM8 3a6 6 0 1 0 .001 12A6 6 0 0 0 8 3z"/>
+</svg> \ No newline at end of file
diff --git a/apps/projects-web/src/_assets/pwa/manifest.json b/apps/projects-web/src/_assets/pwa/manifest.json
new file mode 100644
index 0000000..4c550fe
--- /dev/null
+++ b/apps/projects-web/src/_assets/pwa/manifest.json
@@ -0,0 +1,28 @@
+{
+ "manifest_version": 2,
+ "version": "0.1",
+ "name": "Time Tracker",
+ "short_name": "Time Tracker",
+ "display": "standalone",
+ "background_color": "#fff",
+ "theme_color": "#4D3DF7",
+ "start_url": ".",
+ "orientation": "portrait",
+ "icons": [
+ {
+ "src": "/favicon.svg",
+ "purpose": "maskable any",
+ "sizes": "any"
+ },
+ {
+ "src": "/pwa/android-chrome-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "/pwa/android-chrome-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ }
+ ]
+}
diff --git a/apps/projects-web/src/_assets/pwa/mstile-144x144.png b/apps/projects-web/src/_assets/pwa/mstile-144x144.png
new file mode 100644
index 0000000..84d94cb
--- /dev/null
+++ b/apps/projects-web/src/_assets/pwa/mstile-144x144.png
Binary files differ
diff --git a/apps/projects-web/src/_assets/pwa/mstile-150x150.png b/apps/projects-web/src/_assets/pwa/mstile-150x150.png
new file mode 100644
index 0000000..b1398ae
--- /dev/null
+++ b/apps/projects-web/src/_assets/pwa/mstile-150x150.png
Binary files differ
diff --git a/apps/projects-web/src/_assets/pwa/mstile-310x150.png b/apps/projects-web/src/_assets/pwa/mstile-310x150.png
new file mode 100644
index 0000000..76b16a0
--- /dev/null
+++ b/apps/projects-web/src/_assets/pwa/mstile-310x150.png
Binary files differ
diff --git a/apps/projects-web/src/_assets/pwa/mstile-310x310.png b/apps/projects-web/src/_assets/pwa/mstile-310x310.png
new file mode 100644
index 0000000..d8e4097
--- /dev/null
+++ b/apps/projects-web/src/_assets/pwa/mstile-310x310.png
Binary files differ
diff --git a/apps/projects-web/src/_assets/pwa/mstile-70x70.png b/apps/projects-web/src/_assets/pwa/mstile-70x70.png
new file mode 100644
index 0000000..0df1e8c
--- /dev/null
+++ b/apps/projects-web/src/_assets/pwa/mstile-70x70.png
Binary files differ
diff --git a/apps/projects-web/src/_assets/pwa/safari-pinned-tab.svg b/apps/projects-web/src/_assets/pwa/safari-pinned-tab.svg
new file mode 100644
index 0000000..ba2220c
--- /dev/null
+++ b/apps/projects-web/src/_assets/pwa/safari-pinned-tab.svg
@@ -0,0 +1,50 @@
+<?xml version="1.0" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
+ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
+ width="700.000000pt" height="700.000000pt" viewBox="0 0 700.000000 700.000000"
+ preserveAspectRatio="xMidYMid meet">
+<metadata>
+Created by potrace 1.14, written by Peter Selinger 2001-2017
+</metadata>
+<g transform="translate(0.000000,700.000000) scale(0.100000,-0.100000)"
+fill="#000000" stroke="none">
+<path d="M3195 6780 c-116 -3 -211 -10 -226 -17 -39 -17 -105 -98 -116 -142
+-19 -72 -2 -146 45 -202 26 -31 96 -69 131 -72 25 -2 31 -6 32 -27 1 -27 1
+-198 0 -216 -1 -6 -47 -19 -103 -28 -160 -28 -451 -107 -533 -146 -11 -5 -51
+-21 -90 -36 -60 -23 -246 -112 -325 -155 -431 -236 -834 -619 -1101 -1045
+-207 -328 -364 -733 -423 -1089 -51 -307 -61 -583 -31 -875 26 -261 119 -615
+225 -861 185 -430 432 -773 800 -1108 75 -69 387 -301 405 -301 1 0 33 -18 70
+-40 209 -128 602 -288 796 -325 12 -2 29 -7 39 -10 72 -23 273 -56 435 -73
+144 -14 601 -5 658 13 7 2 37 7 67 10 273 33 616 141 904 283 725 357 1275
+982 1542 1754 55 159 113 395 129 523 4 28 8 57 10 65 2 8 7 47 10 85 3 39 8
+93 10 120 6 66 6 327 0 390 -2 28 -7 82 -10 120 -3 39 -11 99 -16 135 -6 36
+-13 79 -16 95 -15 98 -61 279 -103 405 -121 372 -298 694 -542 993 -27 32 -48
+61 -48 65 0 4 35 41 78 84 l77 76 90 -90 c53 -54 108 -99 134 -110 62 -28 130
+-25 191 8 95 52 135 151 103 257 -13 46 -44 79 -362 397 -322 323 -351 349
+-398 363 -148 44 -287 -61 -285 -215 1 -62 35 -118 126 -208 47 -47 86 -87 86
+-90 0 -6 -91 -101 -132 -138 l-25 -23 -46 38 c-264 223 -584 405 -924 528 -92
+34 -320 100 -376 109 -15 3 -35 7 -45 10 -9 3 -34 7 -54 10 -86 13 -113 18
+-117 22 -2 2 -4 56 -4 121 l0 118 29 9 c66 19 114 47 139 80 72 95 65 215 -18
+296 -58 56 -83 60 -402 63 -159 1 -380 0 -490 -3z m560 -1104 c224 -24 547
+-99 670 -156 11 -5 56 -24 100 -41 90 -37 282 -134 306 -155 8 -8 19 -14 23
+-14 13 0 192 -124 286 -199 97 -77 297 -270 364 -351 237 -288 405 -598 509
+-941 30 -98 44 -157 72 -299 3 -14 8 -47 11 -75 3 -27 7 -56 10 -63 22 -69 21
+-519 -1 -642 -1 -8 -6 -40 -10 -70 -4 -30 -9 -64 -11 -75 -2 -11 -7 -33 -10
+-50 -3 -16 -14 -66 -26 -110 -11 -44 -21 -84 -22 -90 -18 -79 -93 -275 -154
+-408 -39 -83 -158 -296 -171 -307 -3 -3 -26 -34 -50 -70 -116 -169 -312 -384
+-466 -508 -38 -32 -78 -65 -89 -74 -25 -22 -229 -160 -281 -189 -177 -99 -405
+-197 -570 -244 -126 -36 -305 -74 -375 -81 -19 -2 -48 -5 -65 -8 -121 -22
+-509 -22 -618 0 -12 2 -42 6 -67 10 -369 45 -795 215 -1125 448 -192 135 -399
+326 -517 476 -23 30 -48 61 -55 67 -57 60 -227 336 -291 473 -64 135 -150 365
+-167 444 -2 12 -6 30 -9 41 -28 120 -36 156 -41 193 -3 24 -7 53 -10 65 -32
+148 -38 552 -10 707 2 14 7 45 10 70 33 274 160 643 313 910 60 106 201 312
+232 340 3 3 23 28 45 55 22 28 85 96 140 151 347 352 768 590 1252 709 56 14
+118 27 137 30 20 2 61 9 93 14 32 6 92 13 133 17 41 3 76 7 77 8 6 5 368 -2
+428 -8z"/>
+<path d="M3423 4754 c-45 -17 -95 -61 -121 -109 -15 -27 -17 -93 -19 -650 -1
+-341 -2 -641 -3 -666 l0 -47 -679 0 -679 -1 -49 -24 c-59 -30 -76 -49 -104
+-112 -54 -122 23 -270 154 -293 23 -5 400 -8 837 -7 l795 1 42 22 c52 27 98
+82 112 136 8 29 10 273 9 826 -3 854 1 796 -59 867 -53 63 -153 87 -236 57z"/>
+</g>
+</svg>
diff --git a/apps/projects-web/src/app/index.d.ts b/apps/projects-web/src/app/index.d.ts
new file mode 100644
index 0000000..c044583
--- /dev/null
+++ b/apps/projects-web/src/app/index.d.ts
@@ -0,0 +1,48 @@
+/* Use this file to declare any custom file extensions for importing */
+/* Use this folder to also add/extend a package d.ts file, if needed. */
+
+/* CSS MODULES */
+declare module "*.module.css" {
+ const classes: { [key: string]: string };
+ export default classes;
+}
+declare module "*.module.scss" {
+ const classes: { [key: string]: string };
+ export default classes;
+}
+
+/* CSS */
+declare module "*.css";
+declare module "*.scss";
+
+/* IMAGES */
+declare module "*.svg" {
+ const ref: string;
+ export default ref;
+}
+declare module "*.bmp" {
+ const ref: string;
+ export default ref;
+}
+declare module "*.gif" {
+ const ref: string;
+ export default ref;
+}
+declare module "*.jpg" {
+ const ref: string;
+ export default ref;
+}
+declare module "*.jpeg" {
+ const ref: string;
+ export default ref;
+}
+declare module "*.png" {
+ const ref: string;
+ export default ref;
+}
+
+/* CUSTOM: ADD YOUR OWN HERE */
+declare module "*.svelte" {
+ const value: any;
+ export default value;
+}
diff --git a/apps/projects-web/src/app/index.html b/apps/projects-web/src/app/index.html
new file mode 100644
index 0000000..7e0b0e1
--- /dev/null
+++ b/apps/projects-web/src/app/index.html
@@ -0,0 +1,63 @@
+<!doctype html>
+<html lang="en">
+
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport"
+ content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
+ <link rel="apple-touch-icon"
+ sizes="180x180"
+ href="../_assets/pwa/apple-touch-icon.png">
+ <link rel="icon"
+ type="image/png"
+ sizes="32x32"
+ href="../_assets/pwa/favicon-32x32.png">
+ <link rel="icon"
+ type="image/png"
+ sizes="16x16"
+ href="../_assets/pwa/favicon-16x16.png">
+ <link rel="manifest"
+ href="../_assets/pwa/manifest.json">
+ <link rel="mask-icon"
+ href="../_assets/pwa/safari-pinned-tab.svg"
+ color="#5bbad5">
+ <meta name="msapplication-TileColor"
+ content="#da532c">
+ <link rel="icon"
+ href="../_assets/pwa/favicon.svg">
+ <script>
+ const currentTheme = localStorage.getItem("theme");
+ if (currentTheme === "light") {
+ document.querySelector("html").dataset.theme = "light";
+ } else {
+ document.querySelector("html").dataset.theme = "dark";
+ }
+ </script>
+ <link rel="stylesheet"
+ href="../_assets/pre.css">
+ <title>Time Tracker</title>
+</head>
+
+<body>
+
+<noscript>
+ This page is built with javascript. Allow it and try again.
+</noscript>
+
+<div class="fill-loader fill-loader--v4"
+ id="loader"
+ role="alert">
+ <p class="fill-loader__label">Loading Time Tracker...</p>
+ <div aria-hidden="true">
+ <div class="fill-loader__base"></div>
+ <div class="fill-loader__fill"></div>
+ </div>
+</div>
+
+<div id="root"></div>
+
+<script type="module"
+ src="./index.ts"></script>
+</body>
+
+</html>
diff --git a/apps/projects-web/src/app/index.scss b/apps/projects-web/src/app/index.scss
new file mode 100644
index 0000000..4794787
--- /dev/null
+++ b/apps/projects-web/src/app/index.scss
@@ -0,0 +1,38 @@
+@use '../../web-shared/src/styles/base'as * with ($breakpoints: ('xs': "768px",
+ 'sm': "768px",
+ 'md': "1200px",
+ 'lg': "1200px",
+ 'xl': "1600px",
+ ),
+ $grid-columns: 12);
+
+@use '../../web-shared/src/styles/custom-style/colors';
+@use '../../web-shared/src/styles/custom-style/spacing';
+@use '../../web-shared/src/styles/custom-style/shared-styles';
+@use '../../web-shared/src/styles/custom-style/typography';
+@use '../../web-shared/src/styles/custom-style/icons';
+@use '../../web-shared/src/styles/custom-style/buttons';
+@use '../../web-shared/src/styles/custom-style/forms';
+@use '../../web-shared/src/styles/custom-style/util';
+
+@use '../../web-shared/src/styles/components/radios-checkboxes';
+@use '../../web-shared/src/styles/components/circle-loader';
+@use '../../web-shared/src/styles/components/list';
+@use '../../web-shared/src/styles/components/form-validator';
+@use '../../web-shared/src/styles/components/btn-states';
+@use '../../web-shared/src/styles/components/alert';
+@use '../../web-shared/src/styles/components/details';
+@use '../../web-shared/src/styles/components/tabbed-navigation';
+@use '../../web-shared/src/styles/components/dropdown';
+@use '../../web-shared/src/styles/components/modal';
+@use '../../web-shared/src/styles/components/chip';
+@use '../../web-shared/src/styles/components/autocomplete';
+@use '../../web-shared/src/styles/components/select-autocomplete';
+@use '../../web-shared/src/styles/components/interactive-table';
+@use '../../web-shared/src/styles/components/pagination';
+@use '../../web-shared/src/styles/components/custom-select';
+@use '../../web-shared/src/styles/components/pre-header';
+@use '../../web-shared/src/styles/components/table';
+@use '../../web-shared/src/styles/components/custom-checkbox';
+@use '../../web-shared/src/styles/components/menu';
+@use '../../web-shared/src/styles/components/user-menu';
diff --git a/apps/projects-web/src/app/index.svelte b/apps/projects-web/src/app/index.svelte
new file mode 100644
index 0000000..9dd2bf8
--- /dev/null
+++ b/apps/projects-web/src/app/index.svelte
@@ -0,0 +1,56 @@
+<svelte:options immutable={true}/>
+<svelte:window bind:online={online}/>
+
+<script>
+ import {logout_user} from "$app/lib/services/user-service";
+ import Router from "svelte-spa-router";
+ import {wrap} from "svelte-spa-router/wrap";
+ import {is_active} from "$shared/lib/session";
+ import UiWorkbench from "$app/pages/ui-workbench.svelte";
+ import NotFound from "$app/pages/not-found.svelte";
+ import Home from "$app/pages/home.svelte";
+ import Settings from "$app/pages/settings.svelte";
+ import Data from "$app/pages/data.svelte";
+ import PreHeader from "$shared/components/pre-header.svelte";
+
+ let online = true;
+
+ async function user_is_logged_in() {
+ if (!await is_active()) {
+ await logout_user("expired");
+ }
+ return true;
+ }
+
+ const routes = {
+ "/home": wrap({
+ component: Home,
+ conditions: [user_is_logged_in],
+ }),
+ "/": wrap({
+ component: Home,
+ conditions: [user_is_logged_in],
+ }),
+ "/settings": wrap({
+ component: Settings,
+ conditions: [user_is_logged_in],
+ }),
+ "/data": wrap({
+ component: Data,
+ conditions: [user_is_logged_in],
+ }),
+ "/ui-workbench": UiWorkbench,
+ "*": NotFound,
+ };
+</script>
+<PreHeader show="{!online}">You seem to be offline, please check your internet connection.</PreHeader>
+<Router
+ {routes}
+ restoreScrollState={true}
+ on:routeLoading={() => {
+ document.getElementById("loader").style.display = "inline-block";
+ }}
+ on:routeLoaded={() => {
+ document.getElementById("loader").style.display = "none";
+ }}
+/>
diff --git a/apps/projects-web/src/app/index.ts b/apps/projects-web/src/app/index.ts
new file mode 100644
index 0000000..febb583
--- /dev/null
+++ b/apps/projects-web/src/app/index.ts
@@ -0,0 +1,16 @@
+// @ts-ignore
+import App from "./index.svelte";
+import "./index.scss";
+import {is_debug, is_development} from "$shared/lib/configuration";
+import {noop} from "$shared/lib/helpers";
+
+if (is_development() || is_debug()) {
+ console.log("%c Debug", "background-color:yellow;color:black;font-size:18px;");
+} else {
+ console.log("%c Production; Suppressing logs", "background-color:yellow;color:black;font-size:18px;");
+ console.log = noop;
+}
+
+export default new App({
+ target: document.getElementById("root"),
+});
diff --git a/apps/projects-web/src/app/lib/services/user-service.ts b/apps/projects-web/src/app/lib/services/user-service.ts
new file mode 100644
index 0000000..7bffa49
--- /dev/null
+++ b/apps/projects-web/src/app/lib/services/user-service.ts
@@ -0,0 +1,21 @@
+import {delete_account, logout} from "$shared/lib/api/user";
+import {accounts_base} from "$shared/lib/configuration";
+import {clear_session_data} from "$shared/lib/session";
+import {clear_categories} from "$app/lib/stores/categories";
+import {clear_entries} from "$app/lib/stores/entries";
+import {clear_labels} from "$app/lib/stores/labels";
+
+export async function logout_user(reason: string = "") {
+ await logout();
+ clear_session_data();
+ clear_categories();
+ clear_labels();
+ clear_entries();
+ location.replace(accounts_base("#/login" + (reason ? "?" + reason : "")));
+}
+
+export async function delete_user() {
+ await delete_account();
+ clear_session_data();
+ location.replace(accounts_base("#/login?deleted"));
+}
diff --git a/apps/projects-web/src/app/lib/stores/categories.ts b/apps/projects-web/src/app/lib/stores/categories.ts
new file mode 100644
index 0000000..2a63c42
--- /dev/null
+++ b/apps/projects-web/src/app/lib/stores/categories.ts
@@ -0,0 +1,44 @@
+import {writable, get} from "svelte/store";
+import {create_time_category, delete_time_category, get_time_categories} from "$shared/lib/api/time-entry";
+import type {TimeCategoryDto} from "$shared/lib/models/TimeCategoryDto";
+import type {IInternalFetchResponse} from "$shared/lib/models/IInternalFetchResponse";
+
+const categories = writable<Array<TimeCategoryDto>>([]);
+
+export async function reload_categories() {
+ const get_categories_response = await get_time_categories();
+ if (!get_categories_response.ok) {
+ clear_categories();
+ return;
+ }
+ categories.set(get_categories_response.data ?? []);
+}
+
+export function clear_categories() {
+ categories.set([]);
+}
+
+export async function create_category_async(request: TimeCategoryDto): Promise<IInternalFetchResponse> {
+ const create_entry_response = await create_time_category(request);
+ if (create_entry_response.ok) {
+ const stored_entries = get(categories);
+ stored_entries.push(create_entry_response.data);
+ categories.set(stored_entries);
+ }
+ return create_entry_response;
+}
+
+export async function edit_category_async(entry: TimeCategoryDto) {
+ if (!entry.id) return;
+}
+
+export async function delete_category_async(entry: TimeCategoryDto) {
+ if (!entry.id) return;
+ const http_request = await delete_time_category(entry.id);
+ if (http_request.ok) {
+ const stored_entries = get(categories);
+ categories.set(stored_entries.filter(e => e.id !== entry.id));
+ }
+}
+
+export default categories;
diff --git a/apps/projects-web/src/app/lib/stores/entries.ts b/apps/projects-web/src/app/lib/stores/entries.ts
new file mode 100644
index 0000000..e933568
--- /dev/null
+++ b/apps/projects-web/src/app/lib/stores/entries.ts
@@ -0,0 +1,74 @@
+import {Temporal} from "@js-temporal/polyfill";
+import {writable, get} from "svelte/store";
+import {get_time_entries, create_time_entry, delete_time_entry, update_time_entry} from "$shared/lib/api/time-entry";
+import type {TimeEntryDto} from "$shared/lib/models/TimeEntryDto";
+import type {IInternalFetchResponse} from "$shared/lib/models/IInternalFetchResponse";
+import type {TimeEntryQuery} from "$shared/lib/models/TimeEntryQuery";
+
+const entries = writable<Array<TimeEntryDto>>([]);
+
+export function get_time_entry(id: string): TimeEntryDto {
+ return get(entries).find(c => c.id === id);
+}
+
+export async function reload_entries(query: TimeEntryQuery): Promise<void> {
+ const get_entries_response = await get_time_entries(query);
+ if (!get_entries_response.ok) {
+ clear_entries();
+ return;
+ }
+ entries.set(get_default_sorted(get_entries_response.data?.results ?? []));
+}
+
+export function clear_entries() {
+ entries.set([]);
+}
+
+function get_default_sorted(unsorted: Array<TimeEntryDto>): Array<TimeEntryDto> {
+ if (unsorted.length < 1) return unsorted;
+ const byStart = unsorted.sort((a, b) => {
+ return Temporal.Instant.compare(Temporal.Instant.from(b.start), Temporal.Instant.from(a.start));
+ });
+
+ return byStart.sort((a, b) => {
+ return Temporal.Instant.compare(Temporal.Instant.from(b.stop), Temporal.Instant.from(a.stop));
+ });
+}
+
+export async function create_entry_async(request: TimeEntryDto): Promise<IInternalFetchResponse> {
+ const create_entry_response = await create_time_entry(request);
+ if (create_entry_response.ok) {
+ const stored_entries = get(entries) ?? [];
+ stored_entries.push(create_entry_response.data);
+ entries.set(get_default_sorted(stored_entries));
+ }
+ return create_entry_response;
+}
+
+export async function edit_entry_async(request: TimeEntryDto): Promise<IInternalFetchResponse> {
+ if (!request.id) return;
+ const edit_entry_response = await update_time_entry(request);
+ if (edit_entry_response.ok) {
+ const stored_entries = get(entries) ?? [];
+ const index = stored_entries.findIndex(c => c.id === request.id);
+ if (index === -1) {
+ stored_entries.push(edit_entry_response.data);
+ } else {
+ stored_entries[index] = edit_entry_response.data;
+ }
+ entries.set(get_default_sorted(stored_entries));
+ }
+ return edit_entry_response;
+}
+
+export async function delete_entry_async(entry_id: string): Promise<void> {
+ if (!entry_id) throw new Error("No id was supplied when deleting query");
+ const delete_entry_response = await delete_time_entry(entry_id);
+ if (delete_entry_response.ok) {
+ const stored_entries = get(entries) ?? [];
+ entries.set(get_default_sorted(stored_entries.filter((e) => e.id !== entry_id) ?? []));
+ }
+}
+
+
+export default entries;
diff --git a/apps/projects-web/src/app/lib/stores/labels.ts b/apps/projects-web/src/app/lib/stores/labels.ts
new file mode 100644
index 0000000..d5ffaa9
--- /dev/null
+++ b/apps/projects-web/src/app/lib/stores/labels.ts
@@ -0,0 +1,44 @@
+import {writable, get} from "svelte/store";
+import {create_time_label, delete_time_label, get_time_labels} from "$shared/lib/api/time-entry";
+import type {IInternalFetchResponse} from "$shared/lib/models/IInternalFetchResponse";
+import type {TimeLabelDto} from "$shared/lib/models/TimeLabelDto";
+
+const labels = writable<Array<TimeLabelDto>>([]);
+
+export async function reload_labels() {
+ const get_labels_response = await get_time_labels();
+ if (!get_labels_response.ok) {
+ clear_labels();
+ return;
+ }
+ labels.set(get_labels_response.data ?? []);
+}
+
+export function clear_labels() {
+ labels.set([]);
+}
+
+export async function create_label_async(request: TimeLabelDto): Promise<IInternalFetchResponse> {
+ const create_label_response = await create_time_label(request);
+ if (create_label_response.ok) {
+ const stored_entries = get(labels) ?? [];
+ stored_entries.push(create_label_response.data);
+ labels.set(stored_entries);
+ }
+ return create_label_response;
+}
+
+export async function edit_label_async(entry: TimeLabelDto) {
+ if (!entry.id) throw new Error("Label id is required");
+}
+
+export async function delete_label_async(entry: TimeLabelDto) {
+ if (!entry.id) return;
+ const http_request = await delete_time_label(entry.id);
+ if (http_request.ok) {
+ const stored_entries = get(labels) ?? [];
+ labels.set(stored_entries.filter(e => e.id !== entry.id));
+ }
+}
+
+export default labels;
diff --git a/apps/projects-web/src/app/pages/_layout.svelte b/apps/projects-web/src/app/pages/_layout.svelte
new file mode 100644
index 0000000..24a9370
--- /dev/null
+++ b/apps/projects-web/src/app/pages/_layout.svelte
@@ -0,0 +1,79 @@
+<script>
+ import {onMount} from "svelte";
+ import {location, link} from "svelte-spa-router";
+ import {logout_user} from "$app/lib/services/user-service";
+ import {random_string, switch_theme} from "$shared/lib/helpers";
+ import {get_session_data} from "$shared/lib/session";
+ import ProfileModal from "$app/pages/views/profile-modal.svelte";
+ import {Menu, MenuItem, MenuItemSeparator} from "$shared/components/menu";
+ import Button from "$shared/components/button.svelte";
+ import {IconNames} from "$shared/lib/configuration";
+
+ let ProfileModalFunctions = {};
+ let showUserMenu = false;
+ let userMenuTriggerNode;
+ const userMenuId = "__menu_" + random_string(3);
+ const username = get_session_data().profile.username;
+
+ onMount(() => {
+ userMenuTriggerNode = document.getElementById("open-user-menu");
+ });
+</script>
+
+<ProfileModal bind:functions={ProfileModalFunctions}/>
+
+<nav class="container max-width-xl@md width-fit-content@md width-100% max-width-none margin-y-xs@md margin-bottom-xs block@md position-relative@md position-absolute bottom-unset@md bottom-0">
+ <div class="tabs-nav-v2 justify-between">
+ <div class="tab-v2">
+ <div class="tab-v2">
+ <a href="/home"
+ use:link
+ class="tabs-nav-v2__item {$location === '/home' ? 'tabs-nav-v2__item--selected' : ''}">Home</a>
+ </div>
+ <div class="tab-v2">
+ <a href="/data"
+ use:link
+ class="tabs-nav-v2__item {$location === '/data' ? 'tabs-nav-v2__item--selected' : ''}">Data</a>
+ </div>
+ <div class="tab-v2">
+ <a href="/settings"
+ use:link
+ class="tabs-nav-v2__item {$location === '/settings' ? 'tabs-nav-v2__item--selected' : ''}">Settings</a>
+ </div>
+ </div>
+ <div class="tab-v2 padding-x-sm">
+ <Button class="user-menu-control"
+ variant="reset"
+ id="open-user-menu"
+ on:click={() => showUserMenu = !showUserMenu}
+ text={username}
+ icon={IconNames.chevronDown}
+ icon_width="2rem"
+ icon_height="2rem"
+ icon_right_aligned="true"
+ title="Toggle user menu"
+ aria-controls="{userMenuId}"
+ />
+ <Menu bind:show="{showUserMenu}"
+ trigger={userMenuTriggerNode}
+ id="{userMenuId}">
+ <div slot="options">
+ <MenuItem on:click={() => ProfileModalFunctions.open()}>
+ <span title="Administrate your profile">Profile</span>
+ </MenuItem>
+ <MenuItem on:click={() => switch_theme()}>
+ <span title="Change between a dark and light theme">Switch theme</span>
+ </MenuItem>
+ <MenuItemSeparator/>
+ <MenuItem danger="true" on:click={() => logout_user()}>
+ <span title="Log out of your profile">Log out</span>
+ </MenuItem>
+ </div>
+ </Menu>
+ </div>
+ </div>
+</nav>
+
+<main class="container max-width-xl">
+ <slot/>
+</main>
diff --git a/apps/projects-web/src/app/pages/data.svelte b/apps/projects-web/src/app/pages/data.svelte
new file mode 100644
index 0000000..070b98b
--- /dev/null
+++ b/apps/projects-web/src/app/pages/data.svelte
@@ -0,0 +1,392 @@
+<script>
+ import {IconNames} from "$shared/lib/configuration";
+ import {onMount} from "svelte";
+ import {Temporal} from "@js-temporal/polyfill";
+ import Layout from "./_layout.svelte";
+ import Modal from "$shared/components/modal.svelte";
+ import Tile from "$shared/components/tile.svelte";
+ import Icon from "$shared/components/icon.svelte";
+ import EntryForm from "$app/pages/views/entry-form/index.svelte";
+ import {Table, THead, TBody, TCell, TRow, TablePaginator} from "$shared/components/table";
+ import {TimeEntryQueryDuration} from "$shared/lib/models/TimeEntryQuery";
+ import {delete_time_entry, get_time_entries, get_time_entry} from "$shared/lib/api/time-entry";
+ import {seconds_to_hour_minute_string, is_guid, move_focus, unwrap_date_time_from_entry} from "$shared/lib/helpers";
+ import Button from "$shared/components/button.svelte";
+
+ let pageCount = 1;
+ let page = 1;
+
+ const defaultQuery = {
+ duration: TimeEntryQueryDuration.THIS_YEAR,
+ categories: [],
+ labels: [],
+ page: page,
+ pageSize: 50,
+ };
+
+ let isLoading;
+ let categories = [];
+ let labels = [];
+ let entries = [];
+ let durationSummary = false;
+ let EditEntryModal;
+ let EditEntryForm;
+ let currentTimespanFilter = TimeEntryQueryDuration.THIS_YEAR;
+ let currentSpecificDateFilter = Temporal.Now.plainDateTimeISO().subtract({days: 1}).toString().substring(0, 10);
+ let currentDateRangeFilter = {};
+ let currentCategoryFilter = "all";
+ let currentLabelFilter = "all";
+ let showDateFilterOptions = false;
+ let secondsLogged = 0;
+
+ function set_duration_summary_string() {
+ if (entries.length > 0) {
+ durationSummary = `Showing ${entries.length} ${entries.length === 1 ? "entry" : "entries"}, totalling in ${seconds_to_hour_minute_string(secondsLogged)}`;
+ } else {
+ durationSummary = "";
+ }
+ }
+
+ async function load_entries(query = defaultQuery) {
+ isLoading = true;
+ const response = await get_time_entries(query);
+ if (response.status === 200) {
+ const responseEntries = [];
+ secondsLogged = 0;
+ for (const entry of response.data.results) {
+ const date_time = unwrap_date_time_from_entry(entry);
+ const seconds = (date_time.duration.hours * 60 * 60) + (date_time.duration.minutes * 60);
+ responseEntries.push({
+ id: entry.id,
+ date: date_time.start_date,
+ start: date_time.start_time,
+ stop: date_time.stop_time,
+ durationString: date_time.duration.hours + "h" + date_time.duration.minutes + "m",
+ seconds: seconds,
+ category: entry.category,
+ labels: entry.labels,
+ description: entry.description,
+ });
+ secondsLogged += seconds;
+ }
+ entries = responseEntries;
+ page = response.data.page;
+ pageCount = response.data.totalPageCount;
+ } else {
+ entries = [];
+ page = 0;
+ pageCount = 0;
+ }
+ isLoading = false;
+ set_duration_summary_string();
+ }
+
+ function load_entries_with_filter(page = 1) {
+ let query = defaultQuery;
+ query.duration = currentTimespanFilter;
+ query.labels = [];
+ query.categories = [];
+ query.page = page;
+
+ if (currentTimespanFilter === TimeEntryQueryDuration.SPECIFIC_DATE) {
+ query.specificDate = currentSpecificDateFilter;
+ } else {
+ delete query.specificDate;
+ }
+
+ if (currentTimespanFilter === TimeEntryQueryDuration.DATE_RANGE) {
+ query.dateRange = currentDateRangeFilter;
+ } else {
+ delete query.dateRange;
+ }
+
+ if ((currentCategoryFilter !== "all" && currentCategoryFilter?.length > 0) ?? false) {
+ for (const chosenCategoryId of currentCategoryFilter) {
+ if (chosenCategoryId === "all") {
+ continue;
+ }
+ query.categories.push({
+ id: chosenCategoryId,
+ });
+ }
+ }
+
+ if ((currentLabelFilter !== "all" && currentLabelFilter?.length > 0) ?? false) {
+ for (const chosenLabelId of currentLabelFilter) {
+ if (chosenLabelId === "all") {
+ continue;
+ }
+ query.labels.push({
+ id: chosenLabelId,
+ });
+ }
+ }
+
+ load_entries(query);
+ }
+
+ async function handle_delete_entry_button_click(e, entryId) {
+ if (confirm("Are you sure you want to delete this entry?")) {
+ const response = await delete_time_entry(entryId);
+ if (response.ok) {
+ const indexOfEntry = entries.findIndex((c) => c.id === entryId);
+ if (indexOfEntry !== -1) {
+ secondsLogged -= entries[indexOfEntry].seconds;
+ entries.splice(indexOfEntry, 1);
+ entries = entries;
+ set_duration_summary_string();
+ }
+ }
+ }
+ }
+
+ function handle_edit_entry_form_updated() {
+ load_entries_with_filter(page);
+ EditEntryModal.close();
+ }
+
+ async function handle_edit_entry_button_click(event, entryId) {
+ const response = await get_time_entry(entryId);
+ if (response.status === 200) {
+ if (is_guid(response.data.id)) {
+ EditEntryForm.set_values(response.data);
+ EditEntryModal.open();
+ move_focus(document.querySelector("input[id='date']"));
+ }
+ }
+ }
+
+ function close_date_filter_box(event) {
+ if (!event.target.closest(".date_filter_box_el")) {
+ showDateFilterOptions = false;
+ window.removeEventListener("click", close_date_filter_box);
+ }
+ }
+
+ function toggle_date_filter_box(event) {
+ const box = document.getElementById("date_filter_box");
+ const rect = event.target.getBoundingClientRect();
+ box.style.top = rect.y + "px";
+ box.style.left = rect.x - 50 + "px";
+ showDateFilterOptions = true;
+ window.addEventListener("click", close_date_filter_box);
+ }
+
+ onMount(() => {
+ isLoading = true;
+ Promise.all([load_entries()]).then(() => {
+ isLoading = false;
+ });
+ });
+</script>
+
+<Modal title="Edit entry"
+ bind:functions={EditEntryModal}
+ on:closed={() => EditEntryForm.reset()}>
+ <EntryForm bind:functions={EditEntryForm}
+ on:updated={handle_edit_entry_form_updated}/>
+</Modal>
+
+<div id="date_filter_box"
+ style="margin-top:25px"
+ class="padding-xs z-index-overlay bg shadow-sm position-absolute date_filter_box_el border {showDateFilterOptions ? '' : 'hide'}">
+ <div class="flex items-baseline margin-bottom-xxxxs">
+ <label class="text-sm color-contrast-medium margin-right-xs"
+ for="durationSelect">Timespan:</label>
+ <div class="select inline-block js-select">
+ <select name="durationSelect"
+ bind:value={currentTimespanFilter}
+ id="durationSelect">
+ <option value={TimeEntryQueryDuration.TODAY}
+ selected> Today
+ </option>
+ <option value={TimeEntryQueryDuration.THIS_WEEK}>This week</option>
+ <option value={TimeEntryQueryDuration.THIS_MONTH}>This month</option>
+ <option value={TimeEntryQueryDuration.THIS_YEAR}>This year</option>
+ <option value={TimeEntryQueryDuration.SPECIFIC_DATE}>Spesific date</option>
+ <option value={TimeEntryQueryDuration.DATE_RANGE}>Date range</option>
+ </select>
+
+ <svg class="icon icon--xxxs margin-left-xxs"
+ viewBox="0 0 8 8">
+ <path d="M7.934,1.251A.5.5,0,0,0,7.5,1H.5a.5.5,0,0,0-.432.752l3.5,6a.5.5,0,0,0,.864,0l3.5-6A.5.5,0,0,0,7.934,1.251Z"/>
+ </svg>
+ </div>
+ </div>
+
+ {#if currentTimespanFilter === TimeEntryQueryDuration.SPECIFIC_DATE}
+ <div class="flex items-baseline margin-bottom-xxxxs justify-between">
+ <span class="text-sm color-contrast-medium margin-right-xs">Date:</span>
+ <span class="text-sm">
+ <input type="date"
+ class="border-none padding-0 color-inherit bg-transparent"
+ bind:value={currentSpecificDateFilter}/>
+ </span>
+ </div>
+ {/if}
+
+ {#if currentTimespanFilter === TimeEntryQueryDuration.DATE_RANGE}
+ <div class="flex items-baseline margin-bottom-xxxxs justify-between">
+ <span class="text-sm color-contrast-medium margin-right-xs">From:</span>
+ <span class="text-sm">
+ <input type="date"
+ class="border-none padding-0 color-inherit bg-transparent"
+ on:change={(e) => (currentDateRangeFilter.from = e.target.value)}/>
+ </span>
+ </div>
+
+ <div class="flex items-baseline margin-bottom-xxxxs justify-between">
+ <span class="text-sm color-contrast-medium margin-right-xs">To:</span>
+ <span class="text-sm">
+ <input type="date"
+ class="border-none padding-0 color-inherit bg-transparent"
+ on:change={(e) => (currentDateRangeFilter.to = e.target.value)}/>
+ </span>
+ </div>
+ {/if}
+
+ <div class="flex items-baseline justify-end">
+ <Button variant="subtle"
+ on:click={() => load_entries_with_filter(page)}
+ class="text-sm"
+ text="Save"/>
+ </div>
+</div>
+
+<Layout>
+ <Tile class="{isLoading ? 'c-disabled loading' : ''}">
+ <nav class="s-tabs text-sm">
+ <ul class="s-tabs__list">
+ <li><span class="s-tabs__link s-tabs__link--current">All (21)</span></li>
+ <li><span class="s-tabs__link">Published (19)</span></li>
+ <li><span class="s-tabs__link">Draft (2)</span></li>
+ </ul>
+ </nav>
+ <div class="max-width-100% overflow-auto"
+ style="max-height: 82.5vh">
+ <Table class="text-sm width-100% int-table--sticky-header">
+ <THead>
+ <TCell type="th"
+ style="width: 30px;">
+ <div class="custom-checkbox int-table__checkbox">
+ <input class="custom-checkbox__input"
+ type="checkbox"
+ aria-label="Select all rows"/>
+ <div class="custom-checkbox__control"
+ aria-hidden="true"></div>
+ </div>
+ </TCell>
+
+ <TCell type="th"
+ style="width: 100px">
+ <div class="flex items-center justify-between">
+ <span>Date</span>
+ <div class="date_filter_box_el cursor-pointer"
+ on:click={toggle_date_filter_box}>
+ <Icon name="{IconNames.funnel}"/>
+ </div>
+ </div>
+ </TCell>
+
+ <TCell type="th"
+ style="width: 100px">
+ <div class="flex items-center">
+ <span>Duration</span>
+ </div>
+ </TCell>
+
+ <TCell type="th"
+ style="width: 100px;">
+ <div class="flex items-center">
+ <span>Category</span>
+ </div>
+ </TCell>
+
+ <TCell type="th"
+ style="width: 300px;">
+ <div class="flex items-center">
+ <span>Description</span>
+ </div>
+ </TCell>
+ <TCell type="th"
+ style="width: 50px"></TCell>
+ </THead>
+ <TBody>
+ {#if entries.length > 0}
+ {#each entries as entry}
+ <TRow class="text-nowrap"
+ data-id={entry.id}>
+ <TCell type="th"
+ thScope="row">
+ <div class="custom-checkbox int-table__checkbox">
+ <input class="custom-checkbox__input"
+ type="checkbox"
+ aria-label="Select this row"/>
+ <div class="custom-checkbox__control"
+ aria-hidden="true"></div>
+ </div>
+ </TCell>
+ <TCell>
+ <pre>{entry.date.toLocaleString()}</pre>
+ </TCell>
+ <TCell>
+ <pre class="flex justify-between">
+ <div class="flex justify-between">
+ <span>{entry.start.toLocaleString(undefined, {timeStyle: "short"})}</span>
+ <span> - </span>
+ <span>{entry.stop.toLocaleString(undefined, {timeStyle: "short"})}</span>
+ </div>
+ </pre>
+ </TCell>
+ <TCell>
+ <span data-id={entry.category.id}>{entry.category.name}</span>
+ </TCell>
+ <TCell class="text-truncate max-width-xxxxs"
+ title="{entry.description}">
+ {entry.description ?? ""}
+ </TCell>
+ <TCell class="flex flex-row justify-end items-center">
+ <Button icon="{IconNames.pencilSquare}"
+ variant="reset"
+ icon_width="1.2rem"
+ icon_height="1.2rem"
+ on:click={(e) => handle_edit_entry_button_click(e, entry.id)}
+ title="Edit entry"/>
+ <Button icon="{IconNames.trash}"
+ variant="reset"
+ icon_width="1.2rem"
+ icon_height="1.2rem"
+ on:click={(e) => handle_delete_entry_button_click(e, entry.id)}
+ title="Delete entry"/>
+ </TCell>
+ </TRow>
+ {/each}
+ {:else}
+ <TRow class="text-nowrap">
+ <TCell type="th"
+ thScope="row"
+ colspan="7">
+ {isLoading ? "Loading..." : "No entries"}
+ </TCell>
+ </TRow>
+ {/if}
+ </TBody>
+ </Table>
+ </div>
+ <div class="flex items-center justify-between">
+ <p class="text-sm">
+ {#if durationSummary}
+ <small class={isLoading ? "c-disabled loading" : ""}>{durationSummary}</small>
+ {:else}
+ <small class={isLoading ? "c-disabled loading" : ""}>No entries</small>
+ {/if}
+ </p>
+
+ <nav class="grid padding-y-sm {isLoading ? 'c-disabled loading' : ''}">
+ <TablePaginator {page}
+ on:value_change={(e) => load_entries_with_filter(e.detail.newValue)}
+ {pageCount}/>
+ </nav>
+ </div>
+ </Tile>
+</Layout>
diff --git a/apps/projects-web/src/app/pages/home.svelte b/apps/projects-web/src/app/pages/home.svelte
new file mode 100644
index 0000000..c3e7af4
--- /dev/null
+++ b/apps/projects-web/src/app/pages/home.svelte
@@ -0,0 +1,167 @@
+<script lang="ts">
+ import {IconNames} from "$shared/lib/configuration";
+ import {TimeEntryDto} from "$shared/lib/models/TimeEntryDto";
+ import {Temporal} from "@js-temporal/polyfill";
+ import {onMount} from "svelte";
+ import Tile from "$shared/components/tile.svelte";
+ import Button from "$shared/components/button.svelte";
+ import Stopwatch from "$shared/components/stopwatch.svelte";
+ import {Table, THead, TBody, TCell, TRow} from "$shared/components/table";
+ import Layout from "./_layout.svelte";
+ import EntryFrom from "$app/pages/views/entry-form/index.svelte";
+ import {seconds_to_hour_minute_string, unwrap_date_time_from_entry} from "$shared/lib/helpers";
+ import {TimeEntryQueryDuration} from "$shared/lib/models/TimeEntryQuery";
+ import entries, {delete_entry_async, get_time_entry, reload_entries} from "$app/lib/stores/entries";
+
+ let currentTime = "";
+ let isLoading = false;
+ let EditEntryForm: any;
+ let timeEntries = [] as Array<TimeEntryDto>;
+ let timeLoggedTodayString = "0h0m";
+
+ function set_current_time() {
+ currentTime = Temporal.Now.plainTimeISO().toLocaleString(undefined, {
+ timeStyle: "short",
+ });
+ }
+
+ async function on_edit_entry_button_click(event, entryId: string) {
+ const response = get_time_entry(entryId);
+ EditEntryForm.set_values(response);
+ }
+
+ async function on_delete_entry_button_click(event, entryId: string) {
+ if (confirm("Are you sure you want to delete this entry?")) {
+ await delete_entry_async(entryId);
+ }
+ }
+
+ async function load_todays_entries() {
+ await reload_entries({
+ duration: TimeEntryQueryDuration.TODAY,
+ page: 1,
+ pageSize: 100,
+ });
+ }
+
+ function on_create_from_stopwatch(event) {
+ EditEntryForm.set_time({to: event.detail.to, from: event.detail.from});
+ if (event.detail.description) {
+ EditEntryForm.set_description(event.detail.description);
+ }
+ }
+
+ onMount(async () => {
+ set_current_time();
+ setInterval(() => {
+ set_current_time();
+ }, 1e4);
+ await load_todays_entries();
+ entries.subscribe((val) => {
+ const newEntries = [];
+ let loggedSecondsToday = 0;
+ for (const entry of val) {
+ const date_time = unwrap_date_time_from_entry(entry);
+ newEntries.push({
+ id: entry.id,
+ start: date_time.start_time,
+ stop: date_time.stop_time,
+ category: entry.category,
+ });
+ loggedSecondsToday += (date_time.duration.hours * 60 * 60) + (date_time.duration.minutes * 60);
+ }
+ timeLoggedTodayString = seconds_to_hour_minute_string(loggedSecondsToday);
+ timeEntries = newEntries;
+ });
+ });
+</script>
+
+<Layout>
+ <div class="grid gap-md margin-top-xs flex-row@md items-start flex-column-reverse">
+ <Tile class="col">
+ <h3 class="text-md padding-bottom-xxxs">New entry</h3>
+ <EntryFrom bind:functions={EditEntryForm}/>
+ </Tile>
+ <div class="col grid gap-sm">
+ <Tile class="col-6@md col-12">
+ <p class="text-xxl">{timeLoggedTodayString}</p>
+ <p class="text-xs margin-bottom-xxs">Logged time today</p>
+ <pre class="text-xxl">{currentTime}</pre>
+ <p class="text-xs">Current time</p>
+ </Tile>
+ <Tile class="col-6@md col-12">
+ <Stopwatch on:create={on_create_from_stopwatch}>
+ <h3 slot="header"
+ class="text-md">Stopwatch</h3>
+ </Stopwatch>
+ </Tile>
+ <Tile class="col-12">
+ <h3 class="text-md padding-bottom-xxxs">Today's entries</h3>
+ <div class="max-width-100% overflow-auto">
+ <Table class="width-100% text-sm">
+ <THead>
+ <TCell type="th"
+ class="text-left">
+ <span>Category</span>
+ </TCell>
+ <TCell type="th"
+ class="text-left">
+ <span>Timespan</span>
+ </TCell>
+ <TCell type="th"
+ class="text-right">
+ <Button icon="{IconNames.refresh}"
+ variant="reset"
+ icon_width="1.2rem"
+ icon_height="1.2rem"
+ title="Refresh today's entries"
+ on:click={load_todays_entries}/>
+ </TCell>
+ </THead>
+ <TBody>
+ {#if timeEntries.length > 0}
+ {#each timeEntries as entry}
+ <TRow class="text-nowrap text-left"
+ data-id={entry.id}>
+ <TCell>
+ <span data-id={entry.category?.id}>
+ {entry.category?.name}
+ </span>
+ </TCell>
+ <TCell>
+ {entry.start.toLocaleString(undefined, {timeStyle: "short"})}
+ <span>-</span>
+ {entry.stop.toLocaleString(undefined, {timeStyle: "short"})}
+ </TCell>
+ <TCell class="flex flex-row justify-end items-center">
+ <Button icon="{IconNames.pencilSquare}"
+ variant="reset"
+ icon_width="1.2rem"
+ icon_height="1.2rem"
+ on:click={(e) => on_edit_entry_button_click(e, entry.id)}
+ title="Edit entry"/>
+ <Button icon="{IconNames.trash}"
+ variant="reset"
+ icon_width="1.2rem"
+ icon_height="1.2rem"
+ on:click={(e) => on_delete_entry_button_click(e, entry.id)}
+ title="Delete entry"/>
+ </TCell>
+ </TRow>
+ {/each}
+ {:else}
+ <TRow class="text-nowrap">
+ <TCell type="th"
+ thScope="row"
+ colspan="7">
+ {isLoading ? "Loading..." : "No entries today"}
+ </TCell>
+ </TRow>
+ {/if}
+ </TBody>
+ </Table>
+ </div>
+ </Tile>
+ </div>
+ </div>
+</Layout>
diff --git a/apps/projects-web/src/app/pages/not-found.svelte b/apps/projects-web/src/app/pages/not-found.svelte
new file mode 100644
index 0000000..46d0d1d
--- /dev/null
+++ b/apps/projects-web/src/app/pages/not-found.svelte
@@ -0,0 +1,24 @@
+<script>
+ import {link} from "svelte-spa-router";
+</script>
+
+<style>
+ header {
+ font-size: 12rem;
+ }
+
+ main {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ text-align: center;
+ }
+</style>
+
+<main>
+ <header>404</header>
+ <p>Page not found!</p>
+ <a use:link
+ href="/">Go to front</a>
+</main>
diff --git a/apps/projects-web/src/app/pages/settings.svelte b/apps/projects-web/src/app/pages/settings.svelte
new file mode 100644
index 0000000..ca9fd47
--- /dev/null
+++ b/apps/projects-web/src/app/pages/settings.svelte
@@ -0,0 +1,12 @@
+<script>
+ import Layout from "./_layout.svelte";
+ import CategoriesTile from "$app/pages/views/settings-categories-tile.svelte";
+ import LabelsTile from "$app/pages/views/settings-labels-tile.svelte";
+</script>
+
+<Layout>
+ <section class="grid gap-md">
+ <CategoriesTile/>
+ <LabelsTile/>
+ </section>
+</Layout>
diff --git a/apps/projects-web/src/app/pages/ui-workbench.svelte b/apps/projects-web/src/app/pages/ui-workbench.svelte
new file mode 100644
index 0000000..5e92c9d
--- /dev/null
+++ b/apps/projects-web/src/app/pages/ui-workbench.svelte
@@ -0,0 +1,48 @@
+<script>
+ import Dropdown from "$shared/components/dropdown.svelte";
+ import {generate_random_hex_color} from "$shared/lib/colors";
+ import {uuid_v4} from "$shared/lib/helpers";
+
+ let entries = [];
+
+ let dropdown;
+
+ for (let i = 1; i < 20; i++) {
+ entries.push({
+ id: uuid_v4(),
+ name: "Option " + i,
+ selected: false,
+ color: generate_random_hex_color(true)
+ });
+ }
+
+ function on_create({detail}) {
+ const copy = entries;
+ const entry = {id: uuid_v4(), name: detail.name};
+ copy.push(entry);
+ entries = copy;
+ console.log("Created", entry);
+ dropdown.select_entry(entry.id);
+ }
+
+ function on_select({detail}) {
+ console.log(detail);
+ }
+</script>
+
+<main class="grid gap-y-lg padding-md">
+ <div class="row">
+ <label for="dropdown">Choose an entry</label>
+ <Dropdown id="dropdown"
+ name="dropdown"
+ placeholder="Search or create"
+ maxlength="50"
+ creatable="true"
+ multiple="false"
+ {entries}
+ bind:this={dropdown}
+ on:create={on_create}
+ on:select={on_select}
+ />
+ </div>
+</main>
diff --git a/apps/projects-web/src/app/pages/views/category-form/index.svelte b/apps/projects-web/src/app/pages/views/category-form/index.svelte
new file mode 100644
index 0000000..e8c0f94
--- /dev/null
+++ b/apps/projects-web/src/app/pages/views/category-form/index.svelte
@@ -0,0 +1,144 @@
+<script lang="ts">
+ import Alert from "$shared/components/alert.svelte";
+ import Dropdown from "$shared/components/dropdown.svelte";
+ import labels, {reload_labels, create_label_async} from "$app/lib/stores/labels";
+ import {generate_random_hex_color} from "$shared/lib/colors";
+ import {get} from "svelte/store";
+
+ let LabelsDropdown;
+
+ const dough = {
+ error: "",
+ fields: {
+ name: {
+ value: "",
+ error: "",
+ validate() {
+ return false;
+ }
+ },
+ color: {
+ value: "",
+ error: "",
+ validate() {
+ return true;
+ }
+ },
+ labels: {
+ loading: false,
+ value: [],
+ error: "",
+ validate() {
+ return true;
+ },
+ async create({name}) {
+ dough.fields.labels.loading = true;
+ const response = await create_label_async({
+ name: name,
+ color: generate_random_hex_color(),
+ });
+ dough.fields.labels.loading = false;
+ if (response.ok) {
+ // Small pause to allow loading state to update everywhere.
+ setTimeout(() => LabelsDropdown.select_entry(response.data.id), 50);
+ }
+ }
+ },
+ archived: {
+ value: false,
+ error: "",
+ validate() {
+ return true;
+ }
+ }
+ },
+ bake() {
+ // labels.filter((c) => Object.hasOwn(c, "selected") && c.selected === true);
+ return {
+ labels: dough.fields.labels.value,
+ name: dough.fields.name.value,
+ color: dough.fields.color.value,
+ };
+ },
+ submit(event) {
+ const bread = dough.bake();
+ console.log(bread);
+ console.log("Submitted");
+ }
+ };
+
+ const functions = {
+ set(values) {
+ functions.set_archived(values.archived);
+ functions.set_labels(values.labels);
+ functions.set_color(values.color);
+ functions.set_name(values.name);
+ },
+ is_valid() {
+ let isValid = true;
+ if (!dough.fields.labels.validate()) isValid = false;
+ if (!dough.fields.color.validate()) isValid = false;
+ if (!dough.fields.name.validate()) isValid = false;
+ if (!dough.fields.archived.validate()) isValid = false;
+ return isValid;
+ },
+ set_archived(value) {
+ dough.fields.archived.value = value;
+ },
+ set_labels(value) {
+ dough.fields.labels.value = value;
+ },
+ set_color(value) {
+ dough.fields.color.value = value;
+ },
+ set_name(value) {
+ dough.fields.name.value = value;
+ },
+ };
+</script>
+
+<form on:submit|preventDefault={dough.submit}>
+ <div class="margin-y-sm">
+ <Alert visible={dough.error !== ""}
+ message={dough.error}
+ type="error"/>
+ </div>
+ <div class="grid gap-x-xs margin-bottom-sm">
+ <div class="col-10">
+ <label for="name"
+ class="form-label margin-bottom-xxs">Name</label>
+ <input type="text"
+ class="form-control width-100%"
+ id="name"
+ bind:value={dough.fields.name.value}/>
+ {#if dough.fields.name.error}
+ <small class="color-error">{dough.fields.name.error}</small>
+ {/if}
+ </div>
+ <div class="col-2">
+ <label for="color"
+ class="form-label margin-bottom-xxs">Color</label>
+ <input type="color"
+ class="form-control width-100%"
+ id="color"
+ style="height: 41px"
+ bind:value={dough.fields.color.value}/>
+ {#if dough.fields.color.error}
+ <small class="color-error">{dough.fields.color.error}</small>
+ {/if}
+ </div>
+ </div>
+ <div class="margin-bottom-sm">
+ <label for="labels"
+ class="form-label margin-bottom-xxs">Default labels</label>
+ <Dropdown id="labels"
+ createable={true}
+ placeholder="Search or create"
+ entries={$labels}
+ multiple={true}
+ on_create_async={(name) => dough.fields.labels.create({name})}/>
+ {#if dough.fields.labels.error}
+ <small class="color-error">{dough.fields.labels.error}</small>
+ {/if}
+ </div>
+</form>
diff --git a/apps/projects-web/src/app/pages/views/data-table-paginator.svelte b/apps/projects-web/src/app/pages/views/data-table-paginator.svelte
new file mode 100644
index 0000000..7696ca2
--- /dev/null
+++ b/apps/projects-web/src/app/pages/views/data-table-paginator.svelte
@@ -0,0 +1,107 @@
+<script>
+ import {createEventDispatcher, onMount} from "svelte";
+ import {restrict_input_to_numbers} from "$shared/lib/helpers";
+
+ const dispatch = createEventDispatcher();
+ export let page = 1;
+ export let pageCount = 1;
+ let prevCount = page;
+ let canIncrement = false;
+ let canDecrement = false;
+ $: canIncrement = page < pageCount;
+ $: canDecrement = page > 1;
+
+ onMount(() => {
+ restrict_input_to_numbers(document.querySelector("#curr-page"));
+ });
+
+ function increment() {
+ if (canIncrement) {
+ page++;
+ }
+ }
+
+ function decrement() {
+ if (canDecrement) {
+ page--;
+ }
+ }
+
+ $: if (page) {
+ handle_change();
+ }
+
+ function handle_change() {
+ if (page === prevCount) {
+ return;
+ }
+ prevCount = page;
+ if (page > pageCount) {
+ page = pageCount;
+ }
+ dispatch("value_change", {
+ newValue: page,
+ });
+ }
+</script>
+
+<nav class="pagination"
+ aria-label="Pagination">
+ <ul
+ class="pagination__list flex flex-wrap gap-xxxs justify-center justify-end@md"
+ >
+ <li>
+ <button
+ on:click={decrement}
+ class="reset pagination__item {canDecrement ? '' : 'c-disabled'}"
+ >
+ <svg class="icon icon--xs flip-x"
+ viewBox="0 0 16 16"
+ ><title>Go to previous page</title>
+ <polyline
+ points="6 2 12 8 6 14"
+ fill="none"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ />
+ </svg>
+ </button>
+ </li>
+
+ <li>
+ <span class="pagination__jumper flex items-center">
+ <input
+ aria-label="Page number"
+ class="form-control"
+ id="curr-page"
+ type="text"
+ on:change={handle_change}
+ value={page}
+ />
+ <em>of {pageCount}</em>
+ </span>
+ </li>
+
+ <li>
+ <button
+ on:click={increment}
+ class="reset pagination__item {canIncrement ? '' : 'c-disabled'}"
+ >
+ <svg class="icon icon--xs"
+ viewBox="0 0 16 16"
+ ><title>Go to next page</title>
+ <polyline
+ points="6 2 12 8 6 14"
+ fill="none"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ />
+ </svg>
+ </button>
+ </li>
+ </ul>
+</nav>
diff --git a/apps/projects-web/src/app/pages/views/entry-form/index.svelte b/apps/projects-web/src/app/pages/views/entry-form/index.svelte
new file mode 100644
index 0000000..cb974ed
--- /dev/null
+++ b/apps/projects-web/src/app/pages/views/entry-form/index.svelte
@@ -0,0 +1,196 @@
+<script lang="ts">
+ import {TimeEntryDto} from "$shared/lib/models/TimeEntryDto";
+ import {Temporal} from "@js-temporal/polyfill";
+ import {createEventDispatcher, onMount, onDestroy} from "svelte";
+ import DateTimePart from "./sections/date-time.svelte";
+ import LabelsPart from "./sections/labels.svelte";
+ import CategoryPart from "./sections/category.svelte";
+ import Button from "$shared/components/button.svelte";
+ import {Textarea} from "$shared/components/form";
+ import Alert from "$shared/components/alert.svelte";
+ import {is_guid} from "$shared/lib/helpers";
+ import {create_entry_async, edit_entry_async} from "$app/lib/stores/entries";
+
+ const dispatch = createEventDispatcher();
+
+ let formError = "";
+ let formIsLoading = false;
+ let description = "";
+ let descriptionError = "";
+ let dateTimePart;
+ let labelsPart;
+ let categoryPart;
+ let entryId;
+
+ onMount(() => {
+ formIsLoading = true;
+
+ Promise.all([categoryPart.load_categories(), labelsPart.load_labels()]).then(() => {
+ formIsLoading = false;
+ });
+
+ window.addEventListener("keydown", handle_window_keydown);
+ });
+
+ onDestroy(() => {
+ window.removeEventListener("keydown", handle_window_keydown);
+ });
+
+ function handle_window_keydown(event) {
+ if (event.ctrlKey && event.code === "Enter") {
+ submit_form();
+ }
+ }
+
+ function validate_form() {
+ return dateTimePart.is_valid() && categoryPart.is_valid() && description_is_valid();
+ }
+
+ function description_is_valid() {
+ if (!description) {
+ descriptionError = "Description is required";
+ } else {
+ descriptionError = "";
+ }
+
+ return description;
+ }
+
+ function get_payload() {
+ const response = {} as TimeEntryDto;
+ const values = get_values();
+ if (!is_guid(values.id)) {
+ delete values.id;
+ } else {
+ response.id = values.id;
+ }
+
+ const currentTimeZone = Temporal.Now.zonedDateTimeISO().offset;
+ response.start = values.date + "T" + values.fromTimeValue + currentTimeZone.toString();
+ response.stop = values.date + "T" + values.toTimeValue + currentTimeZone.toString();
+
+ response.category = {
+ id: values.category.id,
+ };
+
+ const selectedLabels = values.labels;
+ if (selectedLabels?.length > 0 ?? false) {
+ response.labels = selectedLabels;
+ }
+
+ const descriptionContent = description?.trim();
+ if (descriptionContent?.length > 0 ?? false) {
+ response.description = descriptionContent;
+ }
+
+ return response;
+ }
+
+ async function submit_form() {
+ formError = "";
+ if (validate_form()) {
+ const payload = get_payload() as TimeEntryDto;
+ formIsLoading = true;
+ if (is_guid(payload.id)) {
+ const response = await edit_entry_async(payload);
+ if (response.ok) {
+ functions.reset();
+ dispatch("updated", response.data);
+ } else {
+ formError = "An error occured while updating the entry, try again soon";
+ formIsLoading = false;
+ }
+ } else {
+ const response = await create_entry_async(payload);
+ if (response.ok) {
+ functions.reset();
+ dispatch("created");
+ } else {
+ formError = "An error occured while creating the entry, try again soon";
+ formIsLoading = false;
+ }
+ }
+ }
+ }
+
+ function get_values() {
+ return {
+ id: entryId,
+ toTimeValue: dateTimePart.get_to_time_value(),
+ fromTimeValue: dateTimePart.get_from_time_value(),
+ date: dateTimePart.get_date(),
+ category: categoryPart.get_selected(),
+ labels: labelsPart.get_selected(),
+ description: description,
+ };
+ }
+
+ export const functions = {
+ set_values(values) {
+ entryId = values.id;
+ dateTimePart.set_values(values);
+ labelsPart.select_labels(values?.labels.map((c) => c.id) ?? []);
+ categoryPart.select_category(values?.category?.id);
+ description = values.description;
+ },
+ set_time(value: {to: Temporal.PlainTime, from: Temporal.PlainTime}) {
+ dateTimePart.set_times(value);
+ },
+ set_description(value: string) {
+ if (description) description = description + "\n\n" + value;
+ else description = value;
+ },
+ reset() {
+ formIsLoading = false;
+ entryId = "";
+ labelsPart.reset();
+ categoryPart.reset();
+ dateTimePart.reset(true);
+ description = "";
+ formError = "";
+ },
+ };
+</script>
+
+<form on:submit|preventDefault={submit_form}
+ on:reset={() => functions.reset()}>
+ <div class="margin-y-sm">
+ <Alert visible={formError !== ""}
+ message={formError}
+ type="error"/>
+ </div>
+
+ <div class="margin-bottom-sm">
+ <DateTimePart bind:functions={dateTimePart}/>
+ </div>
+
+ <div class="margin-bottom-sm">
+ <CategoryPart bind:functions={categoryPart}/>
+ </div>
+
+ <div class="margin-bottom-sm">
+ <LabelsPart bind:functions={labelsPart}/>
+ </div>
+
+ <div class="margin-bottom-sm">
+ <Textarea class="width-100%"
+ id="description"
+ label="Description"
+ errorText="{descriptionError}"
+ bind:value={description}></Textarea>
+ </div>
+
+ <div class="flex flex-row justify-end gap-x-xs">
+ {#if entryId}
+ <Button text="Reset"
+ on:click={() => functions.reset()}
+ variant="subtle"
+ />
+ {/if}
+ <Button loading={formIsLoading}
+ type="submit"
+ variant="primary"
+ text={entryId ? "Save" : "Create"}
+ />
+ </div>
+</form>
diff --git a/apps/projects-web/src/app/pages/views/entry-form/sections/category.svelte b/apps/projects-web/src/app/pages/views/entry-form/sections/category.svelte
new file mode 100644
index 0000000..f98c045
--- /dev/null
+++ b/apps/projects-web/src/app/pages/views/entry-form/sections/category.svelte
@@ -0,0 +1,75 @@
+<script>
+ import {generate_random_hex_color} from "$shared/lib/colors";
+ import Dropdown from "$shared/components/dropdown.svelte";
+ import {is_guid, move_focus} from "$shared/lib/helpers";
+ import categories, {reload_categories, create_category_async} from "$app/lib/stores/categories";
+
+ let categoriesError = "";
+ let loading = false;
+
+ let DropdownExports;
+
+ function reset() {
+ DropdownExports.reset();
+ categoriesError = "";
+ console.log("Reset category-part");
+ }
+
+ async function on_create({name}) {
+ loading = true;
+ const response = await create_category_async({
+ name: name,
+ color: generate_random_hex_color(),
+ });
+ loading = false;
+ if (response.ok) {
+ // Small pause to allow loading state to update everywhere.
+ setTimeout(() => select_category(response.data.id), 50);
+ }
+ }
+
+ function get_selected() {
+ return $categories.find((c) => c.selected === true);
+ }
+
+ function select_category(id) {
+ DropdownExports.select(id);
+ }
+
+ function is_valid() {
+ let isValid = true;
+ const category = get_selected();
+ if (!is_guid(category?.id)) {
+ categoriesError = "Category is required";
+ isValid = false;
+ move_focus(document.getElementById("category-dropdown"));
+ } else {
+ categoriesError = "";
+ }
+ return isValid;
+ }
+
+ export const functions = {
+ get_selected,
+ reset,
+ is_valid,
+ select_category,
+ load_categories: reload_categories,
+ };
+</script>
+
+<Dropdown
+ entries={$categories}
+ label="Category"
+ maxlength="50"
+ createable={true}
+ placeholder="Search or create"
+ id="category-dropdown"
+ loading={loading}
+ name="category-dropdown"
+ on_create_async={on_create}
+ noResultsText="No categories available (Create a new one by searching for it and pressing enter)"
+ errorText="{categoriesError}"
+ bind:this={DropdownExports}
+/>
+
diff --git a/apps/projects-web/src/app/pages/views/entry-form/sections/date-time.svelte b/apps/projects-web/src/app/pages/views/entry-form/sections/date-time.svelte
new file mode 100644
index 0000000..c91e014
--- /dev/null
+++ b/apps/projects-web/src/app/pages/views/entry-form/sections/date-time.svelte
@@ -0,0 +1,165 @@
+<script lang="ts">
+ import {Temporal} from "@js-temporal/polyfill";
+
+ // TIME
+ let fromTimeValue = "";
+ let fromTimeError = "";
+ let toTimeValue = "";
+ let toTimeError = "";
+
+ function handle_from_time_changed(e) {
+ fromTimeValue = e.target.value;
+ if (fromTimeValue) {
+ fromTimeError = "";
+ }
+ }
+
+ function handle_to_time_changed(e) {
+ toTimeValue = e.target.value;
+ if (toTimeValue) {
+ toTimeError = "";
+ }
+ }
+
+ // DATE
+ let date = Temporal.Now.plainDateTimeISO().toString().substring(0, 10);
+ let dateError = "";
+
+ function is_valid() {
+ let isValid = true;
+ let focusIsSet = false;
+ if (!date) {
+ dateError = "Date is required";
+ isValid = false;
+ if (!focusIsSet) {
+ document.getElementById("date")?.focus();
+ focusIsSet = true;
+ }
+ } else {
+ dateError = "";
+ }
+
+ if (!fromTimeValue) {
+ fromTimeError = "From is required";
+ isValid = false;
+ if (!focusIsSet) {
+ document.getElementById("from")?.focus();
+ focusIsSet = true;
+ }
+ } else if (toTimeValue && fromTimeValue > toTimeValue) {
+ fromTimeError = "From can not be after To";
+ isValid = false;
+ if (!focusIsSet) {
+ document.getElementById("from")?.focus();
+ focusIsSet = true;
+ }
+ } else if (fromTimeValue === toTimeValue) {
+ fromTimeError = "From and To can not be equal";
+ isValid = false;
+ if (!focusIsSet) {
+ document.getElementById("from")?.focus();
+ focusIsSet = true;
+ }
+ } else {
+ fromTimeError = "";
+ }
+
+ if (!toTimeValue) {
+ toTimeError = "To is required";
+ isValid = false;
+ if (!focusIsSet) {
+ document.getElementById("to")?.focus();
+ focusIsSet = true;
+ }
+ } else if (fromTimeValue && toTimeValue < fromTimeValue) {
+ toTimeError = "To can not be before From";
+ isValid = false;
+ if (!focusIsSet) {
+ document.getElementById("to")?.focus();
+ focusIsSet = true;
+ }
+ } else {
+ toTimeError = "";
+ }
+
+ return isValid;
+ }
+
+ export const functions = {
+ get_from_time_value() {
+ return fromTimeValue;
+ },
+ get_to_time_value() {
+ return toTimeValue;
+ },
+ get_date() {
+ return date;
+ },
+ is_valid,
+ reset(focusDate = false) {
+ fromTimeValue = "";
+ toTimeValue = "";
+ if (focusDate) {
+ document.getElementById("date")?.focus();
+ }
+ },
+ set_times(value) {
+ console.log(value);
+ fromTimeValue = value.from.toString().substring(0, 5);
+ toTimeValue = value.to.toString().substring(0, 5);
+ },
+ set_date(new_date: Temporal.PlainDate) {
+ date = new_date.toString();
+ },
+ set_values(values) {
+ const currentTimeZone = Temporal.Now.timeZone().id;
+ const startDate = Temporal.Instant.from(values.start);
+ const stopDate = Temporal.Instant.from(values.stop);
+ fromTimeValue = startDate.toZonedDateTimeISO(currentTimeZone).toPlainTime().toString().substring(0, 5);
+ toTimeValue = stopDate.toZonedDateTimeISO(currentTimeZone).toPlainTime().toString().substring(0, 5);
+ date = startDate.toZonedDateTimeISO(currentTimeZone).toPlainDate().toString();
+ }
+ };
+</script>
+
+<div class="grid gap-xs">
+ <div class="col-4">
+ <label for="date"
+ class="form-label margin-bottom-xxs">Date</label>
+ <input type="date"
+ id="date"
+ class="form-control width-100%"
+ bind:value={date}>
+ {#if dateError}
+ <small class="color-error">{dateError}</small>
+ {/if}
+ </div>
+ <div class="col-4">
+ <label for="from"
+ class="form-label margin-bottom-xxs">From</label>
+ <input id="from"
+ class="form-control width-100%"
+ pattern="[0-9][0-9]:[0-9][0-9]"
+ type="time"
+ bind:value={fromTimeValue}
+ on:input={handle_from_time_changed}
+ />
+ {#if fromTimeError}
+ <small class="color-error">{fromTimeError}</small>
+ {/if}
+ </div>
+ <div class="col-4">
+ <label for="to"
+ class="form-label margin-bottom-xxs">To</label>
+ <input id="to"
+ class="form-control width-100%"
+ pattern="[0-9][0-9]:[0-9][0-9]"
+ type="time"
+ bind:value={toTimeValue}
+ on:input={handle_to_time_changed}
+ />
+ {#if toTimeError}
+ <small class="color-error">{toTimeError}</small>
+ {/if}
+ </div>
+</div>
diff --git a/apps/projects-web/src/app/pages/views/entry-form/sections/labels.svelte b/apps/projects-web/src/app/pages/views/entry-form/sections/labels.svelte
new file mode 100644
index 0000000..06c703d
--- /dev/null
+++ b/apps/projects-web/src/app/pages/views/entry-form/sections/labels.svelte
@@ -0,0 +1,65 @@
+<script>
+ import {generate_random_hex_color} from "$shared/lib/colors";
+ import labels, {reload_labels, create_label_async} from "$app/lib/stores/labels";
+ import Dropdown from "$shared/components/dropdown.svelte";
+
+ let labelsError = "";
+ let loading = false;
+ let DropdownExports;
+
+ function reset() {
+ DropdownExports.reset();
+ console.log("Reset labels-part");
+ }
+
+ function get_selected() {
+ return $labels.filter((c) => Object.hasOwn(c, "selected") && c.selected === true);
+ }
+
+ function select_label(id) {
+ DropdownExports.select(id);
+ }
+
+ function select_labels(ids) {
+ for (const id of ids) {
+ DropdownExports.select(id);
+ }
+ }
+
+ async function on_create({name}) {
+ loading = true;
+ const response = await create_label_async({
+ name: name,
+ color: generate_random_hex_color(),
+ });
+ loading = false;
+ if (response.ok) {
+ // Small pause to allow loading state to update everywhere.
+ setTimeout(() => select_label(response.data.id), 50);
+ }
+ }
+
+ export const functions = {
+ get_selected,
+ reset,
+ load_labels: reload_labels,
+ select_labels,
+ select_label,
+ };
+</script>
+
+<Dropdown
+ entries={$labels}
+ label="Labels"
+ maxlength="50"
+ createable={true}
+ placeholder="Search or create"
+ multiple="{true}"
+ id="labels-search"
+ name="labels-search"
+ on_create_async={on_create}
+ noResultsText="No labels available (Create a new one by searching for it and pressing enter)"
+ errorText="{labelsError}"
+ bind:this={DropdownExports}
+ {loading}
+/>
diff --git a/apps/projects-web/src/app/pages/views/profile-modal.svelte b/apps/projects-web/src/app/pages/views/profile-modal.svelte
new file mode 100644
index 0000000..839b59d
--- /dev/null
+++ b/apps/projects-web/src/app/pages/views/profile-modal.svelte
@@ -0,0 +1,156 @@
+<script>
+ import {update_profile} from "$shared/lib/api/user";
+ import Modal from "$shared/components/modal.svelte";
+ import Alert from "$shared/components/alert.svelte";
+ import Button from "$shared/components/button.svelte";
+ import {is_email} from "$shared/lib/helpers";
+ import {api_base} from "$shared/lib/configuration";
+ import {delete_user} from "$app/lib/services/user-service";
+ import {get_session_data} from "$shared/lib/session";
+
+ const archiveLink = api_base("_/api/account/archive");
+
+ let modal;
+ let understands = false;
+
+ let formIsLoading = false;
+ let formError;
+
+ let username = get_session_data()?.profile.username;
+ let usernameFieldMessage;
+ let usernameFieldMessageClass = "color-error";
+
+ let password;
+ let passwordFieldMessage;
+ let passwordFieldMessageClass = "color-error";
+
+ async function submit_form(e) {
+ e.preventDefault();
+ if (!username && !password) {
+ console.error("Not submitting becuase both values is empty");
+ return;
+ }
+
+ usernameFieldMessage = "";
+ passwordFieldMessage = "";
+
+ if (username && !is_email(username)) {
+ usernameFieldMessage = "Username has to be a valid email";
+ return;
+ }
+
+ if (password && password?.length < 6) {
+ passwordFieldMessage = "The new password must contain at least 6 characters";
+ return;
+ }
+
+ formIsLoading = true;
+
+ const response = await update_profile({
+ username,
+ password,
+ });
+
+ formIsLoading = false;
+
+ if (response.ok) {
+ if (password) {
+ passwordFieldMessage = "Successfully updated";
+ passwordFieldMessageClass = "color-success";
+ password = "";
+ }
+ if (username) {
+ usernameFieldMessage = "Successfully updated";
+ usernameFieldMessageClass = "color-success";
+ password = "";
+ }
+ } else {
+ formError = response.data.title ?? "An unknown error occured";
+ }
+ }
+
+ async function handle_delete_account_button_click() {
+ if (understands && confirm("Are you absolutely sure that you want to delete your account?")) {
+ await delete_user();
+ }
+ }
+
+ export const functions = {
+ open() {
+ modal.open();
+ },
+ close() {
+ // modal.close();
+ },
+ };
+</script>
+
+<Modal title="Profile"
+ bind:functions={modal}>
+ <section class="margin-bottom-md">
+ <p class="text-md margin-bottom-sm">Update your information</p>
+ <form on:submit={submit_form}
+ autocomplete="new-password">
+ {#if formError}
+ <small class="color-danger">{formError}</small>
+ {/if}
+ <div class="margin-bottom-sm">
+ <label for="email"
+ class="form-label margin-bottom-xxs">New username</label>
+ <input type="email"
+ class="form-control width-100%"
+ id="email"
+ placeholder={username}
+ bind:value={username}/>
+ {#if usernameFieldMessage}
+ <small class={usernameFieldMessageClass}>{usernameFieldMessage}</small>
+ {/if}
+ </div>
+ <div class="margin-bottom-sm">
+ <label for="password"
+ class="form-label margin-bottom-xxs">New password</label>
+ <input type="password"
+ class="form-control width-100%"
+ id="password"
+ bind:value={password}/>
+ {#if passwordFieldMessage}
+ <small class={passwordFieldMessageClass}>{passwordFieldMessage}</small>
+ {/if}
+ </div>
+ <div class="flex justify-end">
+ <Button text="Save"
+ on:click={submit_form}
+ variant="primary"
+ loading={formIsLoading}/>
+ </div>
+ </form>
+ </section>
+ <section class="margin-bottom-md">
+ <p class="text-md margin-bottom-sm">Download your data</p>
+ <a class="btn btn--subtle"
+ href={archiveLink}
+ download>Click here to download your data</a>
+ </section>
+ <section>
+ <p class="text-md margin-bottom-sm">Delete account</p>
+ <div class="margin-bottom-sm">
+ <Alert
+ message="Deleting your account and data means that all of your data (entries, categories, etc.) will be unrecoverable forever.<br>You should probably download your data before continuing."
+ type="info"
+ />
+ </div>
+ <div class="form-check margin-bottom-sm">
+ <input type="checkbox"
+ class="checkbox"
+ id="the-consequences"
+ bind:checked={understands}/>
+ <label for="the-consequences">I understand the consequences of deleting my account and data.</label>
+ </div>
+ <div class="flex justify-end">
+ <Button text="Delete everything"
+ variant="accent"
+ disabled={!understands}
+ on:click={handle_delete_account_button_click}/>
+ </div>
+ </section>
+</Modal>
diff --git a/apps/projects-web/src/app/pages/views/settings-categories-tile.svelte b/apps/projects-web/src/app/pages/views/settings-categories-tile.svelte
new file mode 100644
index 0000000..890609a
--- /dev/null
+++ b/apps/projects-web/src/app/pages/views/settings-categories-tile.svelte
@@ -0,0 +1,127 @@
+<script>
+ import {IconNames} from "$shared/lib/configuration";
+ import {onMount} from "svelte";
+ import {
+ delete_time_category,
+ get_time_categories,
+ } from "$shared/lib/api/time-entry";
+ import Button from "$shared/components/button.svelte";
+ import Tile from "$shared/components/tile.svelte";
+ import {Table, THead, TBody, TCell, TRow} from "$shared/components/table";
+
+ let is_loading = true;
+ let categories = [];
+
+ $: active_categories = categories.filter(c => !c.archived);
+ $: archived_categories = categories.filter(c => c.archived);
+
+ async function load_categories() {
+ is_loading = true;
+ const response = await get_time_categories();
+ if (response.status === 200) {
+ categories = response.data;
+ } else if (response.status === 204) {
+ categories = [];
+ console.log("Empty response when getting time categories");
+ } else {
+ categories = [];
+ console.error("Error when getting time categories");
+ }
+ is_loading = false;
+ }
+
+ async function handle_edit_category_click(event) {
+ }
+
+ async function handle_delete_category_click(event) {
+ const row = event.target.closest("tr");
+ if (
+ row &&
+ row.dataset.id &&
+ confirm(
+ "Are you sure you want to delete this category?\nThis will delete all relating entries!"
+ )
+ ) {
+ const response = await delete_time_category(row.dataset.id);
+ if (response.ok) {
+ // svelte errors if we remove the row.
+ row.classList.add("d-none");
+ }
+ }
+ }
+
+ onMount(() => {
+ load_categories();
+ });
+</script>
+
+<Tile class="col-6@md col-12 {is_loading ? 'c-disabled loading' : ''}">
+ <h2 class="margin-bottom-xxs">Categories</h2>
+ {#if active_categories.length > 0 && archived_categories.length > 0}
+ <nav class="s-tabs text-sm">
+ <ul class="s-tabs__list">
+ <li><a class="s-tabs__link s-tabs__link--current"
+ href="#0">Active ({active_categories.length})</a></li>
+ <li><a class="s-tabs__link"
+ href="#0">Archived ({archived_categories.length})</a></li>
+ </ul>
+ </nav>
+ {/if}
+ <div class="max-width-100% overflow-auto">
+ <Table class="text-sm width-100%">
+ <THead class="text-left">
+ <TCell type="th"
+ thScope="col">
+ Name
+ </TCell>
+ <TCell type="th"
+ thScope="col">
+ Color
+ </TCell>
+ <TCell type="th"
+ thScope="col"
+ style="width:50px"></TCell>
+ </THead>
+ <TBody class="text-left">
+ {#if categories.length > 0}
+ {#each categories as category}
+ <TRow class="text-nowrap"
+ data-id={category.id}>
+ <TCell>
+ {category.name}
+ </TCell>
+ <TCell>
+ <span style="border-left: 3px solid {category.color}; background-color:{category.color}25;">
+ {category.color}
+ </span>
+ </TCell>
+ <TCell>
+ <Button icon="{IconNames.pencilSquare}"
+ variant="reset"
+ icon_width="1.2rem"
+ class="hide"
+ icon_height="1.2rem"
+ on:click={handle_edit_category_click}
+ title="Edit entry"/>
+ <Button icon="{IconNames.trash}"
+ variant="reset"
+ icon_width="1.2rem"
+ icon_height="1.2rem"
+ on:click={handle_delete_category_click}
+ title="Delete entry"/>
+
+ </TCell>
+ </TRow>
+ {/each}
+ {:else}
+ <TRow>
+ <TCell type="th"
+ thScope="3">
+ No categories
+ </TCell>
+ </TRow>
+ {/if}
+ </TBody>
+ </Table>
+ </div>
+</Tile>
diff --git a/apps/projects-web/src/app/pages/views/settings-labels-tile.svelte b/apps/projects-web/src/app/pages/views/settings-labels-tile.svelte
new file mode 100644
index 0000000..f59e233
--- /dev/null
+++ b/apps/projects-web/src/app/pages/views/settings-labels-tile.svelte
@@ -0,0 +1,112 @@
+<script>
+ import {IconNames} from "$shared/lib/configuration";
+ import {onMount} from "svelte";
+ import labels, {reload_labels, delete_label_async} from "$app/lib/stores/labels";
+ import Button from "$shared/components/button.svelte";
+ import Tile from "$shared/components/tile.svelte";
+ import {Table, THead, TBody, TCell, TRow} from "$shared/components/table";
+
+ let is_loading = true;
+
+ $: active_labels = $labels.filter(c => !c.archived);
+ $: archived_labels = $labels.filter(c => c.archived);
+
+ async function load_labels() {
+ is_loading = true;
+ await reload_labels();
+ is_loading = false;
+ }
+
+ async function handle_edit_label_click(event) {
+ }
+
+ async function handle_delete_label_click(event) {
+ const row = event.target.closest("tr");
+ if (
+ row &&
+ row.dataset.id &&
+ confirm(
+ "Are you sure you want to delete this label?\nIt will be removed from all related entries!"
+ )
+ ) {
+ await delete_label_async({id: row.dataset.id});
+ row.classList.add("d-none");
+ }
+ }
+
+ onMount(() => {
+ load_labels();
+ });
+</script>
+
+<Tile class="col-6@md col-12 {is_loading ? 'c-disabled loading' : ''}">
+ <h2 class="margin-bottom-xxs">Labels</h2>
+ {#if active_labels.length > 0 && archived_labels.length > 0}
+ <nav class="s-tabs text-sm">
+ <ul class="s-tabs__list">
+ <li><a class="s-tabs__link s-tabs__link--current"
+ href="#0">Active ({active_labels.length})</a></li>
+ <li><a class="s-tabs__link"
+ href="#0">Archived ({archived_labels.length})</a></li>
+ </ul>
+ </nav>
+ {/if}
+ <div class="max-width-100% overflow-auto">
+ <Table class="text-sm width-100%">
+ <THead class="text-left">
+ <TCell type="th"
+ thScope="row">
+ Name
+ </TCell>
+ <TCell type="th"
+ thScope="row">
+ Color
+ </TCell>
+ <TCell type="th"
+ thScope="row"
+ style="width: 50px;">
+ </TCell>
+ </THead>
+ <TBody class="text-left">
+ {#if $labels.length > 0}
+ {#each $labels as label}
+ <TRow class="text-nowrap"
+ dataId={label.id}>
+ <TCell>
+ {label.name}
+ </TCell>
+ <TCell>
+ <span style="border-left: 3px solid {label.color}; background-color:{label.color}25;">
+ {label.color}
+ </span>
+ </TCell>
+ <TCell>
+ <Button icon="{IconNames.pencilSquare}"
+ variant="reset"
+ icon_width="1.2rem"
+ class="hide"
+ icon_height="1.2rem"
+ on:click={handle_edit_label_click}
+ title="Edit entry"/>
+ <Button icon="{IconNames.trash}"
+ variant="reset"
+ icon_width="1.2rem"
+ icon_height="1.2rem"
+ on:click={handle_delete_label_click}
+ title="Delete entry"/>
+ </TCell>
+ </TRow>
+ {/each}
+ {:else}
+ <TRow>
+ <TCell type="th"
+ thScope="row"
+ colspan="3">
+ No labels
+ </TCell>
+ </TRow>
+ {/if}
+ </TBody>
+ </Table>
+ </div>
+</Tile>
diff --git a/apps/projects-web/src/index.html b/apps/projects-web/src/index.html
new file mode 100644
index 0000000..985b62b
--- /dev/null
+++ b/apps/projects-web/src/index.html
@@ -0,0 +1,63 @@
+<!doctype html>
+<html lang="en">
+
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport"
+ content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
+ <link rel="apple-touch-icon"
+ sizes="180x180"
+ href="./_assets/pwa/apple-touch-icon.png">
+ <link rel="icon"
+ type="image/png"
+ sizes="32x32"
+ href="./_assets/pwa/favicon-32x32.png">
+ <link rel="icon"
+ type="image/png"
+ sizes="16x16"
+ href="./_assets/pwa/favicon-16x16.png">
+ <link rel="manifest"
+ href="./_assets/pwa/manifest.json">
+ <link rel="mask-icon"
+ href="./_assets/pwa/safari-pinned-tab.svg"
+ color="#5bbad5">
+ <meta name="msapplication-TileColor"
+ content="#da532c">
+ <link rel="icon"
+ href="./_assets/pwa/favicon.svg">
+ <script>
+ const currentTheme = localStorage.getItem("theme");
+ if (currentTheme === "light") {
+ document.querySelector("html").dataset.theme = "light";
+ } else {
+ document.querySelector("html").dataset.theme = "dark";
+ }
+ </script>
+ <link rel="stylesheet"
+ href="./_assets/pre.css">
+ <title>Time Tracker</title>
+</head>
+
+<body>
+
+<noscript>
+ This page is built with javascript. Allow it and try again.
+</noscript>
+
+<div class="fill-loader fill-loader--v4"
+ id="loader"
+ role="alert">
+ <p class="fill-loader__label">Loading Time Tracker...</p>
+ <div aria-hidden="true">
+ <div class="fill-loader__base"></div>
+ <div class="fill-loader__fill"></div>
+ </div>
+</div>
+
+<div id="root"></div>
+
+<script type="module"
+ src="./app/index.ts"></script>
+</body>
+
+</html>
diff --git a/apps/projects-web/src/package.json b/apps/projects-web/src/package.json
new file mode 100644
index 0000000..8ff516d
--- /dev/null
+++ b/apps/projects-web/src/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "time-tracker-public",
+ "version": "0.0.1",
+ "private": "true",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build"
+ },
+ "devDependencies": {
+ "@sveltejs/vite-plugin-svelte": "1.0.0-next.43",
+ "sass": "^1.51.0",
+ "svelte": "^3.48.0",
+ "svelte-preprocess": "^4.10.6",
+ "svelte-spa-router": "^3.2.0",
+ "typescript": "4.6.4",
+ "vite": "^2.9.8"
+ },
+ "dependencies": {
+ "@js-temporal/polyfill": "^0.4.1",
+ "fuzzysort": "^1.9.0"
+ }
+}
diff --git a/apps/projects-web/src/pnpm-lock.yaml b/apps/projects-web/src/pnpm-lock.yaml
new file mode 100644
index 0000000..3b56115
--- /dev/null
+++ b/apps/projects-web/src/pnpm-lock.yaml
@@ -0,0 +1,769 @@
+lockfileVersion: 5.4
+
+specifiers:
+ '@js-temporal/polyfill': ^0.4.1
+ '@sveltejs/vite-plugin-svelte': 1.0.0-next.43
+ fuzzysort: ^1.9.0
+ sass: ^1.51.0
+ svelte: ^3.48.0
+ svelte-preprocess: ^4.10.6
+ svelte-spa-router: ^3.2.0
+ typescript: 4.6.4
+ vite: ^2.9.8
+
+dependencies:
+ '@js-temporal/polyfill': 0.4.1
+ fuzzysort: 1.9.0
+
+devDependencies:
+ '@sveltejs/vite-plugin-svelte': 1.0.0-next.43_svelte@3.48.0+vite@2.9.8
+ sass: 1.51.0
+ svelte: 3.48.0
+ svelte-preprocess: 4.10.6_24ezlekk4ocevlsjgs2qnqmjum
+ svelte-spa-router: 3.2.0
+ typescript: 4.6.4
+ vite: 2.9.8_sass@1.51.0
+
+packages:
+
+ /@js-temporal/polyfill/0.4.1:
+ resolution: {integrity: sha512-q45ecIocpa2TLem2jNOsCrDwP/sgKZdSkt+C1Rx07OkdKsdbvVfHcD1iDiK9scxBZrBQ38uJ8VQISXBS70ql1w==}
+ engines: {node: '>=12'}
+ dependencies:
+ jsbi: 4.3.0
+ tslib: 2.4.0
+ dev: false
+
+ /@rollup/pluginutils/4.2.1:
+ resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==}
+ engines: {node: '>= 8.0.0'}
+ dependencies:
+ estree-walker: 2.0.2
+ picomatch: 2.3.1
+ dev: true
+
+ /@sveltejs/vite-plugin-svelte/1.0.0-next.43_svelte@3.48.0+vite@2.9.8:
+ resolution: {integrity: sha512-MzeczqGrnDmbAldw/LfXV/dhpLC2bdUzuMhcx0C2j79V2uNzQERHDinxXnG2AVTCTjSpbQxzQwMMmYflnI7W1g==}
+ engines: {node: ^14.13.1 || >= 16}
+ peerDependencies:
+ diff-match-patch: ^1.0.5
+ svelte: ^3.44.0
+ vite: ^2.9.0
+ peerDependenciesMeta:
+ diff-match-patch:
+ optional: true
+ dependencies:
+ '@rollup/pluginutils': 4.2.1
+ debug: 4.3.4
+ deepmerge: 4.2.2
+ kleur: 4.1.4
+ magic-string: 0.26.1
+ svelte: 3.48.0
+ svelte-hmr: 0.14.11_svelte@3.48.0
+ vite: 2.9.8_sass@1.51.0
+ transitivePeerDependencies:
+ - supports-color
+ dev: true
+
+ /@types/node/17.0.31:
+ resolution: {integrity: sha512-AR0x5HbXGqkEx9CadRH3EBYx/VkiUgZIhP4wvPn/+5KIsgpNoyFaRlVe0Zlx9gRtg8fA06a9tskE2MSN7TcG4Q==}
+ dev: true
+
+ /@types/pug/2.0.6:
+ resolution: {integrity: sha512-SnHmG9wN1UVmagJOnyo/qkk0Z7gejYxOYYmaAwr5u2yFYfsupN3sg10kyzN8Hep/2zbHxCnsumxOoRIRMBwKCg==}
+ dev: true
+
+ /@types/sass/1.43.1:
+ resolution: {integrity: sha512-BPdoIt1lfJ6B7rw35ncdwBZrAssjcwzI5LByIrYs+tpXlj/CAkuVdRsgZDdP4lq5EjyWzwxZCqAoFyHKFwp32g==}
+ dependencies:
+ '@types/node': 17.0.31
+ dev: true
+
+ /anymatch/3.1.2:
+ resolution: {integrity: sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==}
+ engines: {node: '>= 8'}
+ dependencies:
+ normalize-path: 3.0.0
+ picomatch: 2.3.1
+ dev: true
+
+ /balanced-match/1.0.2:
+ resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+ dev: true
+
+ /binary-extensions/2.2.0:
+ resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==}
+ engines: {node: '>=8'}
+ dev: true
+
+ /brace-expansion/1.1.11:
+ resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==}
+ dependencies:
+ balanced-match: 1.0.2
+ concat-map: 0.0.1
+ dev: true
+
+ /braces/3.0.2:
+ resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==}
+ engines: {node: '>=8'}
+ dependencies:
+ fill-range: 7.0.1
+ dev: true
+
+ /buffer-crc32/0.2.13:
+ resolution: {integrity: sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=}
+ dev: true
+
+ /chokidar/3.5.3:
+ resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==}
+ engines: {node: '>= 8.10.0'}
+ dependencies:
+ anymatch: 3.1.2
+ braces: 3.0.2
+ glob-parent: 5.1.2
+ is-binary-path: 2.1.0
+ is-glob: 4.0.3
+ normalize-path: 3.0.0
+ readdirp: 3.6.0
+ optionalDependencies:
+ fsevents: 2.3.2
+ dev: true
+
+ /concat-map/0.0.1:
+ resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=}
+ dev: true
+
+ /debug/4.3.4:
+ resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+ dependencies:
+ ms: 2.1.2
+ dev: true
+
+ /deepmerge/4.2.2:
+ resolution: {integrity: sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /detect-indent/6.1.0:
+ resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==}
+ engines: {node: '>=8'}
+ dev: true
+
+ /es6-promise/3.3.1:
+ resolution: {integrity: sha1-oIzd6EzNvzTQJ6FFG8kdS80ophM=}
+ dev: true
+
+ /esbuild-android-64/0.14.38:
+ resolution: {integrity: sha512-aRFxR3scRKkbmNuGAK+Gee3+yFxkTJO/cx83Dkyzo4CnQl/2zVSurtG6+G86EQIZ+w+VYngVyK7P3HyTBKu3nw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [android]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-android-arm64/0.14.38:
+ resolution: {integrity: sha512-L2NgQRWuHFI89IIZIlpAcINy9FvBk6xFVZ7xGdOwIm8VyhX1vNCEqUJO3DPSSy945Gzdg98cxtNt8Grv1CsyhA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [android]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-darwin-64/0.14.38:
+ resolution: {integrity: sha512-5JJvgXkX87Pd1Og0u/NJuO7TSqAikAcQQ74gyJ87bqWRVeouky84ICoV4sN6VV53aTW+NE87qLdGY4QA2S7KNA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [darwin]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-darwin-arm64/0.14.38:
+ resolution: {integrity: sha512-eqF+OejMI3mC5Dlo9Kdq/Ilbki9sQBw3QlHW3wjLmsLh+quNfHmGMp3Ly1eWm981iGBMdbtSS9+LRvR2T8B3eQ==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [darwin]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-freebsd-64/0.14.38:
+ resolution: {integrity: sha512-epnPbhZUt93xV5cgeY36ZxPXDsQeO55DppzsIgWM8vgiG/Rz+qYDLmh5ts3e+Ln1wA9dQ+nZmVHw+RjaW3I5Ig==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [freebsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-freebsd-arm64/0.14.38:
+ resolution: {integrity: sha512-/9icXUYJWherhk+y5fjPI5yNUdFPtXHQlwP7/K/zg8t8lQdHVj20SqU9/udQmeUo5pDFHMYzcEFfJqgOVeKNNQ==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [freebsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-32/0.14.38:
+ resolution: {integrity: sha512-QfgfeNHRFvr2XeHFzP8kOZVnal3QvST3A0cgq32ZrHjSMFTdgXhMhmWdKzRXP/PKcfv3e2OW9tT9PpcjNvaq6g==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-64/0.14.38:
+ resolution: {integrity: sha512-uuZHNmqcs+Bj1qiW9k/HZU3FtIHmYiuxZ/6Aa+/KHb/pFKr7R3aVqvxlAudYI9Fw3St0VCPfv7QBpUITSmBR1Q==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-arm/0.14.38:
+ resolution: {integrity: sha512-FiFvQe8J3VKTDXG01JbvoVRXQ0x6UZwyrU4IaLBZeq39Bsbatd94Fuc3F1RGqPF5RbIWW7RvkVQjn79ejzysnA==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-arm64/0.14.38:
+ resolution: {integrity: sha512-HlMGZTEsBrXrivr64eZ/EO0NQM8H8DuSENRok9d+Jtvq8hOLzrxfsAT9U94K3KOGk2XgCmkaI2KD8hX7F97lvA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-mips64le/0.14.38:
+ resolution: {integrity: sha512-qd1dLf2v7QBiI5wwfil9j0HG/5YMFBAmMVmdeokbNAMbcg49p25t6IlJFXAeLzogv1AvgaXRXvgFNhScYEUXGQ==}
+ engines: {node: '>=12'}
+ cpu: [mips64el]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-ppc64le/0.14.38:
+ resolution: {integrity: sha512-mnbEm7o69gTl60jSuK+nn+pRsRHGtDPfzhrqEUXyCl7CTOCLtWN2bhK8bgsdp6J/2NyS/wHBjs1x8aBWwP2X9Q==}
+ engines: {node: '>=12'}
+ cpu: [ppc64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-riscv64/0.14.38:
+ resolution: {integrity: sha512-+p6YKYbuV72uikChRk14FSyNJZ4WfYkffj6Af0/Tw63/6TJX6TnIKE+6D3xtEc7DeDth1fjUOEqm+ApKFXbbVQ==}
+ engines: {node: '>=12'}
+ cpu: [riscv64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-s390x/0.14.38:
+ resolution: {integrity: sha512-0zUsiDkGJiMHxBQ7JDU8jbaanUY975CdOW1YDrurjrM0vWHfjv9tLQsW9GSyEb/heSK1L5gaweRjzfUVBFoybQ==}
+ engines: {node: '>=12'}
+ cpu: [s390x]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-netbsd-64/0.14.38:
+ resolution: {integrity: sha512-cljBAApVwkpnJZfnRVThpRBGzCi+a+V9Ofb1fVkKhtrPLDYlHLrSYGtmnoTVWDQdU516qYI8+wOgcGZ4XIZh0Q==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [netbsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-openbsd-64/0.14.38:
+ resolution: {integrity: sha512-CDswYr2PWPGEPpLDUO50mL3WO/07EMjnZDNKpmaxUPsrW+kVM3LoAqr/CE8UbzugpEiflYqJsGPLirThRB18IQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [openbsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-sunos-64/0.14.38:
+ resolution: {integrity: sha512-2mfIoYW58gKcC3bck0j7lD3RZkqYA7MmujFYmSn9l6TiIcAMpuEvqksO+ntBgbLep/eyjpgdplF7b+4T9VJGOA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [sunos]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-windows-32/0.14.38:
+ resolution: {integrity: sha512-L2BmEeFZATAvU+FJzJiRLFUP+d9RHN+QXpgaOrs2klshoAm1AE6Us4X6fS9k33Uy5SzScn2TpcgecbqJza1Hjw==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [win32]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-windows-64/0.14.38:
+ resolution: {integrity: sha512-Khy4wVmebnzue8aeSXLC+6clo/hRYeNIm0DyikoEqX+3w3rcvrhzpoix0S+MF9vzh6JFskkIGD7Zx47ODJNyCw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [win32]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-windows-arm64/0.14.38:
+ resolution: {integrity: sha512-k3FGCNmHBkqdJXuJszdWciAH77PukEyDsdIryEHn9cKLQFxzhT39dSumeTuggaQcXY57UlmLGIkklWZo2qzHpw==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [win32]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild/0.14.38:
+ resolution: {integrity: sha512-12fzJ0fsm7gVZX1YQ1InkOE5f9Tl7cgf6JPYXRJtPIoE0zkWAbHdPHVPPaLi9tYAcEBqheGzqLn/3RdTOyBfcA==}
+ engines: {node: '>=12'}
+ hasBin: true
+ requiresBuild: true
+ optionalDependencies:
+ esbuild-android-64: 0.14.38
+ esbuild-android-arm64: 0.14.38
+ esbuild-darwin-64: 0.14.38
+ esbuild-darwin-arm64: 0.14.38
+ esbuild-freebsd-64: 0.14.38
+ esbuild-freebsd-arm64: 0.14.38
+ esbuild-linux-32: 0.14.38
+ esbuild-linux-64: 0.14.38
+ esbuild-linux-arm: 0.14.38
+ esbuild-linux-arm64: 0.14.38
+ esbuild-linux-mips64le: 0.14.38
+ esbuild-linux-ppc64le: 0.14.38
+ esbuild-linux-riscv64: 0.14.38
+ esbuild-linux-s390x: 0.14.38
+ esbuild-netbsd-64: 0.14.38
+ esbuild-openbsd-64: 0.14.38
+ esbuild-sunos-64: 0.14.38
+ esbuild-windows-32: 0.14.38
+ esbuild-windows-64: 0.14.38
+ esbuild-windows-arm64: 0.14.38
+ dev: true
+
+ /estree-walker/2.0.2:
+ resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
+ dev: true
+
+ /fill-range/7.0.1:
+ resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==}
+ engines: {node: '>=8'}
+ dependencies:
+ to-regex-range: 5.0.1
+ dev: true
+
+ /fs.realpath/1.0.0:
+ resolution: {integrity: sha1-FQStJSMVjKpA20onh8sBQRmU6k8=}
+ dev: true
+
+ /fsevents/2.3.2:
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /function-bind/1.1.1:
+ resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==}
+ dev: true
+
+ /fuzzysort/1.9.0:
+ resolution: {integrity: sha512-MOxCT0qLTwLqmEwc7UtU045RKef7mc8Qz8eR4r2bLNEq9dy/c3ZKMEFp6IEst69otkQdFZ4FfgH2dmZD+ddX1g==}
+ dev: false
+
+ /glob-parent/5.1.2:
+ resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
+ engines: {node: '>= 6'}
+ dependencies:
+ is-glob: 4.0.3
+ dev: true
+
+ /glob/7.2.0:
+ resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==}
+ dependencies:
+ fs.realpath: 1.0.0
+ inflight: 1.0.6
+ inherits: 2.0.4
+ minimatch: 3.1.2
+ once: 1.4.0
+ path-is-absolute: 1.0.1
+ dev: true
+
+ /graceful-fs/4.2.10:
+ resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==}
+ dev: true
+
+ /has/1.0.3:
+ resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==}
+ engines: {node: '>= 0.4.0'}
+ dependencies:
+ function-bind: 1.1.1
+ dev: true
+
+ /immutable/4.0.0:
+ resolution: {integrity: sha512-zIE9hX70qew5qTUjSS7wi1iwj/l7+m54KWU247nhM3v806UdGj1yDndXj+IOYxxtW9zyLI+xqFNZjTuDaLUqFw==}
+ dev: true
+
+ /inflight/1.0.6:
+ resolution: {integrity: sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=}
+ dependencies:
+ once: 1.4.0
+ wrappy: 1.0.2
+ dev: true
+
+ /inherits/2.0.4:
+ resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+ dev: true
+
+ /is-binary-path/2.1.0:
+ resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
+ engines: {node: '>=8'}
+ dependencies:
+ binary-extensions: 2.2.0
+ dev: true
+
+ /is-core-module/2.9.0:
+ resolution: {integrity: sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==}
+ dependencies:
+ has: 1.0.3
+ dev: true
+
+ /is-extglob/2.1.1:
+ resolution: {integrity: sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /is-glob/4.0.3:
+ resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
+ engines: {node: '>=0.10.0'}
+ dependencies:
+ is-extglob: 2.1.1
+ dev: true
+
+ /is-number/7.0.0:
+ resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
+ engines: {node: '>=0.12.0'}
+ dev: true
+
+ /jsbi/4.3.0:
+ resolution: {integrity: sha512-SnZNcinB4RIcnEyZqFPdGPVgrg2AcnykiBy0sHVJQKHYeaLUvi3Exj+iaPpLnFVkDPZIV4U0yvgC9/R4uEAZ9g==}
+ dev: false
+
+ /kleur/4.1.4:
+ resolution: {integrity: sha512-8QADVssbrFjivHWQU7KkMgptGTl6WAcSdlbBPY4uNF+mWr6DGcKrvY2w4FQJoXch7+fKMjj0dRrL75vk3k23OA==}
+ engines: {node: '>=6'}
+ dev: true
+
+ /magic-string/0.25.9:
+ resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==}
+ dependencies:
+ sourcemap-codec: 1.4.8
+ dev: true
+
+ /magic-string/0.26.1:
+ resolution: {integrity: sha512-ndThHmvgtieXe8J/VGPjG+Apu7v7ItcD5mhEIvOscWjPF/ccOiLxHaSuCAS2G+3x4GKsAbT8u7zdyamupui8Tg==}
+ engines: {node: '>=12'}
+ dependencies:
+ sourcemap-codec: 1.4.8
+ dev: true
+
+ /min-indent/1.0.1:
+ resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
+ engines: {node: '>=4'}
+ dev: true
+
+ /minimatch/3.1.2:
+ resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
+ dependencies:
+ brace-expansion: 1.1.11
+ dev: true
+
+ /minimist/1.2.6:
+ resolution: {integrity: sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==}
+ dev: true
+
+ /mkdirp/0.5.6:
+ resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
+ hasBin: true
+ dependencies:
+ minimist: 1.2.6
+ dev: true
+
+ /ms/2.1.2:
+ resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
+ dev: true
+
+ /nanoid/3.3.4:
+ resolution: {integrity: sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+ dev: true
+
+ /normalize-path/3.0.0:
+ resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /once/1.4.0:
+ resolution: {integrity: sha1-WDsap3WWHUsROsF9nFC6753Xa9E=}
+ dependencies:
+ wrappy: 1.0.2
+ dev: true
+
+ /path-is-absolute/1.0.1:
+ resolution: {integrity: sha1-F0uSaHNVNP+8es5r9TpanhtcX18=}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /path-parse/1.0.7:
+ resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
+ dev: true
+
+ /picocolors/1.0.0:
+ resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==}
+ dev: true
+
+ /picomatch/2.3.1:
+ resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
+ engines: {node: '>=8.6'}
+ dev: true
+
+ /postcss/8.4.13:
+ resolution: {integrity: sha512-jtL6eTBrza5MPzy8oJLFuUscHDXTV5KcLlqAWHl5q5WYRfnNRGSmOZmOZ1T6Gy7A99mOZfqungmZMpMmCVJ8ZA==}
+ engines: {node: ^10 || ^12 || >=14}
+ dependencies:
+ nanoid: 3.3.4
+ picocolors: 1.0.0
+ source-map-js: 1.0.2
+ dev: true
+
+ /readdirp/3.6.0:
+ resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
+ engines: {node: '>=8.10.0'}
+ dependencies:
+ picomatch: 2.3.1
+ dev: true
+
+ /regexparam/2.0.0:
+ resolution: {integrity: sha512-gJKwd2MVPWHAIFLsaYDZfyKzHNS4o7E/v8YmNf44vmeV2e4YfVoDToTOKTvE7ab68cRJ++kLuEXJBaEeJVt5ow==}
+ engines: {node: '>=8'}
+ dev: true
+
+ /resolve/1.22.0:
+ resolution: {integrity: sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==}
+ hasBin: true
+ dependencies:
+ is-core-module: 2.9.0
+ path-parse: 1.0.7
+ supports-preserve-symlinks-flag: 1.0.0
+ dev: true
+
+ /rimraf/2.7.1:
+ resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==}
+ hasBin: true
+ dependencies:
+ glob: 7.2.0
+ dev: true
+
+ /rollup/2.72.1:
+ resolution: {integrity: sha512-NTc5UGy/NWFGpSqF1lFY8z9Adri6uhyMLI6LvPAXdBKoPRFhIIiBUpt+Qg2awixqO3xvzSijjhnb4+QEZwJmxA==}
+ engines: {node: '>=10.0.0'}
+ hasBin: true
+ optionalDependencies:
+ fsevents: 2.3.2
+ dev: true
+
+ /sander/0.5.1:
+ resolution: {integrity: sha1-dB4kXiMfB8r7b98PEzrfohalAq0=}
+ dependencies:
+ es6-promise: 3.3.1
+ graceful-fs: 4.2.10
+ mkdirp: 0.5.6
+ rimraf: 2.7.1
+ dev: true
+
+ /sass/1.51.0:
+ resolution: {integrity: sha512-haGdpTgywJTvHC2b91GSq+clTKGbtkkZmVAb82jZQN/wTy6qs8DdFm2lhEQbEwrY0QDRgSQ3xDurqM977C3noA==}
+ engines: {node: '>=12.0.0'}
+ hasBin: true
+ dependencies:
+ chokidar: 3.5.3
+ immutable: 4.0.0
+ source-map-js: 1.0.2
+ dev: true
+
+ /sorcery/0.10.0:
+ resolution: {integrity: sha1-iukK19fLBfxZ8asMY3hF1cFaUrc=}
+ hasBin: true
+ dependencies:
+ buffer-crc32: 0.2.13
+ minimist: 1.2.6
+ sander: 0.5.1
+ sourcemap-codec: 1.4.8
+ dev: true
+
+ /source-map-js/1.0.2:
+ resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /sourcemap-codec/1.4.8:
+ resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==}
+ dev: true
+
+ /strip-indent/3.0.0:
+ resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
+ engines: {node: '>=8'}
+ dependencies:
+ min-indent: 1.0.1
+ dev: true
+
+ /supports-preserve-symlinks-flag/1.0.0:
+ resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
+ engines: {node: '>= 0.4'}
+ dev: true
+
+ /svelte-hmr/0.14.11_svelte@3.48.0:
+ resolution: {integrity: sha512-R9CVfX6DXxW1Kn45Jtmx+yUe+sPhrbYSUp7TkzbW0jI5fVPn6lsNG9NEs5dFg5qRhFNAoVdRw5qQDLALNKhwbQ==}
+ engines: {node: ^12.20 || ^14.13.1 || >= 16}
+ peerDependencies:
+ svelte: '>=3.19.0'
+ dependencies:
+ svelte: 3.48.0
+ dev: true
+
+ /svelte-preprocess/4.10.6_24ezlekk4ocevlsjgs2qnqmjum:
+ resolution: {integrity: sha512-I2SV1w/AveMvgIQlUF/ZOO3PYVnhxfcpNyGt8pxpUVhPfyfL/CZBkkw/KPfuFix5FJ9TnnNYMhACK3DtSaYVVQ==}
+ engines: {node: '>= 9.11.2'}
+ requiresBuild: true
+ peerDependencies:
+ '@babel/core': ^7.10.2
+ coffeescript: ^2.5.1
+ less: ^3.11.3 || ^4.0.0
+ node-sass: '*'
+ postcss: ^7 || ^8
+ postcss-load-config: ^2.1.0 || ^3.0.0
+ pug: ^3.0.0
+ sass: ^1.26.8
+ stylus: ^0.55.0
+ sugarss: ^2.0.0
+ svelte: ^3.23.0
+ typescript: ^3.9.5 || ^4.0.0
+ peerDependenciesMeta:
+ '@babel/core':
+ optional: true
+ coffeescript:
+ optional: true
+ less:
+ optional: true
+ node-sass:
+ optional: true
+ postcss:
+ optional: true
+ postcss-load-config:
+ optional: true
+ pug:
+ optional: true
+ sass:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ typescript:
+ optional: true
+ dependencies:
+ '@types/pug': 2.0.6
+ '@types/sass': 1.43.1
+ detect-indent: 6.1.0
+ magic-string: 0.25.9
+ sass: 1.51.0
+ sorcery: 0.10.0
+ strip-indent: 3.0.0
+ svelte: 3.48.0
+ typescript: 4.6.4
+ dev: true
+
+ /svelte-spa-router/3.2.0:
+ resolution: {integrity: sha512-igemo5Vs82TGBBw+DjWt6qKameXYzNs6aDXcTxou5XbEvOjiRcAM6MLkdVRCatn6u8r42dE99bt/br7T4qe/AQ==}
+ dependencies:
+ regexparam: 2.0.0
+ dev: true
+
+ /svelte/3.48.0:
+ resolution: {integrity: sha512-fN2YRm/bGumvjUpu6yI3BpvZnpIm9I6A7HR4oUNYd7ggYyIwSA/BX7DJ+UXXffLp6XNcUijyLvttbPVCYa/3xQ==}
+ engines: {node: '>= 8'}
+ dev: true
+
+ /to-regex-range/5.0.1:
+ resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
+ engines: {node: '>=8.0'}
+ dependencies:
+ is-number: 7.0.0
+ dev: true
+
+ /tslib/2.4.0:
+ resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==}
+ dev: false
+
+ /typescript/4.6.4:
+ resolution: {integrity: sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==}
+ engines: {node: '>=4.2.0'}
+ hasBin: true
+ dev: true
+
+ /vite/2.9.8_sass@1.51.0:
+ resolution: {integrity: sha512-zsBGwn5UT3YS0NLSJ7hnR54+vUKfgzMUh/Z9CxF1YKEBVIe213+63jrFLmZphgGI5zXwQCSmqIdbPuE8NJywPw==}
+ engines: {node: '>=12.2.0'}
+ hasBin: true
+ peerDependencies:
+ less: '*'
+ sass: '*'
+ stylus: '*'
+ peerDependenciesMeta:
+ less:
+ optional: true
+ sass:
+ optional: true
+ stylus:
+ optional: true
+ dependencies:
+ esbuild: 0.14.38
+ postcss: 8.4.13
+ resolve: 1.22.0
+ rollup: 2.72.1
+ sass: 1.51.0
+ optionalDependencies:
+ fsevents: 2.3.2
+ dev: true
+
+ /wrappy/1.0.2:
+ resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=}
+ dev: true
diff --git a/apps/projects-web/src/tsconfig.json b/apps/projects-web/src/tsconfig.json
new file mode 100644
index 0000000..c60fce6
--- /dev/null
+++ b/apps/projects-web/src/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "include": [
+ "./**/*.d.ts",
+ "./**/*.ts",
+ "./**/*.js",
+ "./**/*.svelte"
+ ],
+ "exclude": [
+ "./node_modules"
+ ],
+ "compilerOptions": {
+ "target": "esnext",
+ "useDefineForClassFields": true,
+ "module": "esnext",
+ "moduleResolution": "node",
+ "allowJs": true,
+ "checkJs": false,
+ "paths": {
+ "$app/*": [
+ "./app/*"
+ ],
+ "$shared/*": [
+ "../../web-shared/src/*"
+ ]
+ }
+ }
+}
diff --git a/apps/projects-web/src/vite.config.ts b/apps/projects-web/src/vite.config.ts
new file mode 100644
index 0000000..ac44266
--- /dev/null
+++ b/apps/projects-web/src/vite.config.ts
@@ -0,0 +1,31 @@
+import { defineConfig } from "vite";
+import { svelte } from "@sveltejs/vite-plugin-svelte";
+import sveltePreprocess from "svelte-preprocess";
+// @ts-ignore
+import path from "path";
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ resolve: {
+ alias: {
+ "$shared": path.resolve(__dirname, "../../web-shared/src"),
+ "$app": path.resolve(__dirname, "./app"),
+ "$public": path.resolve(__dirname, "./_public"),
+ }
+ },
+ build: {
+ outDir: "build",
+ emptyOutDir: true,
+ rollupOptions: {
+ input: {
+ main: path.resolve(__dirname, "index.html"),
+ }
+ }
+ },
+
+ plugins: [
+ svelte({
+ preprocess: sveltePreprocess()
+ })
+ ],
+});
diff --git a/apps/web-shared/package.json b/apps/web-shared/package.json
new file mode 100644
index 0000000..b7b83d5
--- /dev/null
+++ b/apps/web-shared/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "time-tracker-shared",
+ "version": "0.0.1",
+ "private": "true",
+ "devDependencies": {
+ "svelte": "^3.48.0",
+ "svelte-spa-router": "^3.2.0",
+ "typescript": "4.6.4",
+ "@js-temporal/polyfill": "^0.4.1",
+ "fuzzysort": "^1.9.0"
+ }
+}
diff --git a/apps/web-shared/pnpm-lock.yaml b/apps/web-shared/pnpm-lock.yaml
new file mode 100644
index 0000000..3b56115
--- /dev/null
+++ b/apps/web-shared/pnpm-lock.yaml
@@ -0,0 +1,769 @@
+lockfileVersion: 5.4
+
+specifiers:
+ '@js-temporal/polyfill': ^0.4.1
+ '@sveltejs/vite-plugin-svelte': 1.0.0-next.43
+ fuzzysort: ^1.9.0
+ sass: ^1.51.0
+ svelte: ^3.48.0
+ svelte-preprocess: ^4.10.6
+ svelte-spa-router: ^3.2.0
+ typescript: 4.6.4
+ vite: ^2.9.8
+
+dependencies:
+ '@js-temporal/polyfill': 0.4.1
+ fuzzysort: 1.9.0
+
+devDependencies:
+ '@sveltejs/vite-plugin-svelte': 1.0.0-next.43_svelte@3.48.0+vite@2.9.8
+ sass: 1.51.0
+ svelte: 3.48.0
+ svelte-preprocess: 4.10.6_24ezlekk4ocevlsjgs2qnqmjum
+ svelte-spa-router: 3.2.0
+ typescript: 4.6.4
+ vite: 2.9.8_sass@1.51.0
+
+packages:
+
+ /@js-temporal/polyfill/0.4.1:
+ resolution: {integrity: sha512-q45ecIocpa2TLem2jNOsCrDwP/sgKZdSkt+C1Rx07OkdKsdbvVfHcD1iDiK9scxBZrBQ38uJ8VQISXBS70ql1w==}
+ engines: {node: '>=12'}
+ dependencies:
+ jsbi: 4.3.0
+ tslib: 2.4.0
+ dev: false
+
+ /@rollup/pluginutils/4.2.1:
+ resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==}
+ engines: {node: '>= 8.0.0'}
+ dependencies:
+ estree-walker: 2.0.2
+ picomatch: 2.3.1
+ dev: true
+
+ /@sveltejs/vite-plugin-svelte/1.0.0-next.43_svelte@3.48.0+vite@2.9.8:
+ resolution: {integrity: sha512-MzeczqGrnDmbAldw/LfXV/dhpLC2bdUzuMhcx0C2j79V2uNzQERHDinxXnG2AVTCTjSpbQxzQwMMmYflnI7W1g==}
+ engines: {node: ^14.13.1 || >= 16}
+ peerDependencies:
+ diff-match-patch: ^1.0.5
+ svelte: ^3.44.0
+ vite: ^2.9.0
+ peerDependenciesMeta:
+ diff-match-patch:
+ optional: true
+ dependencies:
+ '@rollup/pluginutils': 4.2.1
+ debug: 4.3.4
+ deepmerge: 4.2.2
+ kleur: 4.1.4
+ magic-string: 0.26.1
+ svelte: 3.48.0
+ svelte-hmr: 0.14.11_svelte@3.48.0
+ vite: 2.9.8_sass@1.51.0
+ transitivePeerDependencies:
+ - supports-color
+ dev: true
+
+ /@types/node/17.0.31:
+ resolution: {integrity: sha512-AR0x5HbXGqkEx9CadRH3EBYx/VkiUgZIhP4wvPn/+5KIsgpNoyFaRlVe0Zlx9gRtg8fA06a9tskE2MSN7TcG4Q==}
+ dev: true
+
+ /@types/pug/2.0.6:
+ resolution: {integrity: sha512-SnHmG9wN1UVmagJOnyo/qkk0Z7gejYxOYYmaAwr5u2yFYfsupN3sg10kyzN8Hep/2zbHxCnsumxOoRIRMBwKCg==}
+ dev: true
+
+ /@types/sass/1.43.1:
+ resolution: {integrity: sha512-BPdoIt1lfJ6B7rw35ncdwBZrAssjcwzI5LByIrYs+tpXlj/CAkuVdRsgZDdP4lq5EjyWzwxZCqAoFyHKFwp32g==}
+ dependencies:
+ '@types/node': 17.0.31
+ dev: true
+
+ /anymatch/3.1.2:
+ resolution: {integrity: sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==}
+ engines: {node: '>= 8'}
+ dependencies:
+ normalize-path: 3.0.0
+ picomatch: 2.3.1
+ dev: true
+
+ /balanced-match/1.0.2:
+ resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
+ dev: true
+
+ /binary-extensions/2.2.0:
+ resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==}
+ engines: {node: '>=8'}
+ dev: true
+
+ /brace-expansion/1.1.11:
+ resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==}
+ dependencies:
+ balanced-match: 1.0.2
+ concat-map: 0.0.1
+ dev: true
+
+ /braces/3.0.2:
+ resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==}
+ engines: {node: '>=8'}
+ dependencies:
+ fill-range: 7.0.1
+ dev: true
+
+ /buffer-crc32/0.2.13:
+ resolution: {integrity: sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=}
+ dev: true
+
+ /chokidar/3.5.3:
+ resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==}
+ engines: {node: '>= 8.10.0'}
+ dependencies:
+ anymatch: 3.1.2
+ braces: 3.0.2
+ glob-parent: 5.1.2
+ is-binary-path: 2.1.0
+ is-glob: 4.0.3
+ normalize-path: 3.0.0
+ readdirp: 3.6.0
+ optionalDependencies:
+ fsevents: 2.3.2
+ dev: true
+
+ /concat-map/0.0.1:
+ resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=}
+ dev: true
+
+ /debug/4.3.4:
+ resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+ dependencies:
+ ms: 2.1.2
+ dev: true
+
+ /deepmerge/4.2.2:
+ resolution: {integrity: sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /detect-indent/6.1.0:
+ resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==}
+ engines: {node: '>=8'}
+ dev: true
+
+ /es6-promise/3.3.1:
+ resolution: {integrity: sha1-oIzd6EzNvzTQJ6FFG8kdS80ophM=}
+ dev: true
+
+ /esbuild-android-64/0.14.38:
+ resolution: {integrity: sha512-aRFxR3scRKkbmNuGAK+Gee3+yFxkTJO/cx83Dkyzo4CnQl/2zVSurtG6+G86EQIZ+w+VYngVyK7P3HyTBKu3nw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [android]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-android-arm64/0.14.38:
+ resolution: {integrity: sha512-L2NgQRWuHFI89IIZIlpAcINy9FvBk6xFVZ7xGdOwIm8VyhX1vNCEqUJO3DPSSy945Gzdg98cxtNt8Grv1CsyhA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [android]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-darwin-64/0.14.38:
+ resolution: {integrity: sha512-5JJvgXkX87Pd1Og0u/NJuO7TSqAikAcQQ74gyJ87bqWRVeouky84ICoV4sN6VV53aTW+NE87qLdGY4QA2S7KNA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [darwin]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-darwin-arm64/0.14.38:
+ resolution: {integrity: sha512-eqF+OejMI3mC5Dlo9Kdq/Ilbki9sQBw3QlHW3wjLmsLh+quNfHmGMp3Ly1eWm981iGBMdbtSS9+LRvR2T8B3eQ==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [darwin]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-freebsd-64/0.14.38:
+ resolution: {integrity: sha512-epnPbhZUt93xV5cgeY36ZxPXDsQeO55DppzsIgWM8vgiG/Rz+qYDLmh5ts3e+Ln1wA9dQ+nZmVHw+RjaW3I5Ig==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [freebsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-freebsd-arm64/0.14.38:
+ resolution: {integrity: sha512-/9icXUYJWherhk+y5fjPI5yNUdFPtXHQlwP7/K/zg8t8lQdHVj20SqU9/udQmeUo5pDFHMYzcEFfJqgOVeKNNQ==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [freebsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-32/0.14.38:
+ resolution: {integrity: sha512-QfgfeNHRFvr2XeHFzP8kOZVnal3QvST3A0cgq32ZrHjSMFTdgXhMhmWdKzRXP/PKcfv3e2OW9tT9PpcjNvaq6g==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-64/0.14.38:
+ resolution: {integrity: sha512-uuZHNmqcs+Bj1qiW9k/HZU3FtIHmYiuxZ/6Aa+/KHb/pFKr7R3aVqvxlAudYI9Fw3St0VCPfv7QBpUITSmBR1Q==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-arm/0.14.38:
+ resolution: {integrity: sha512-FiFvQe8J3VKTDXG01JbvoVRXQ0x6UZwyrU4IaLBZeq39Bsbatd94Fuc3F1RGqPF5RbIWW7RvkVQjn79ejzysnA==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-arm64/0.14.38:
+ resolution: {integrity: sha512-HlMGZTEsBrXrivr64eZ/EO0NQM8H8DuSENRok9d+Jtvq8hOLzrxfsAT9U94K3KOGk2XgCmkaI2KD8hX7F97lvA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-mips64le/0.14.38:
+ resolution: {integrity: sha512-qd1dLf2v7QBiI5wwfil9j0HG/5YMFBAmMVmdeokbNAMbcg49p25t6IlJFXAeLzogv1AvgaXRXvgFNhScYEUXGQ==}
+ engines: {node: '>=12'}
+ cpu: [mips64el]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-ppc64le/0.14.38:
+ resolution: {integrity: sha512-mnbEm7o69gTl60jSuK+nn+pRsRHGtDPfzhrqEUXyCl7CTOCLtWN2bhK8bgsdp6J/2NyS/wHBjs1x8aBWwP2X9Q==}
+ engines: {node: '>=12'}
+ cpu: [ppc64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-riscv64/0.14.38:
+ resolution: {integrity: sha512-+p6YKYbuV72uikChRk14FSyNJZ4WfYkffj6Af0/Tw63/6TJX6TnIKE+6D3xtEc7DeDth1fjUOEqm+ApKFXbbVQ==}
+ engines: {node: '>=12'}
+ cpu: [riscv64]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-linux-s390x/0.14.38:
+ resolution: {integrity: sha512-0zUsiDkGJiMHxBQ7JDU8jbaanUY975CdOW1YDrurjrM0vWHfjv9tLQsW9GSyEb/heSK1L5gaweRjzfUVBFoybQ==}
+ engines: {node: '>=12'}
+ cpu: [s390x]
+ os: [linux]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-netbsd-64/0.14.38:
+ resolution: {integrity: sha512-cljBAApVwkpnJZfnRVThpRBGzCi+a+V9Ofb1fVkKhtrPLDYlHLrSYGtmnoTVWDQdU516qYI8+wOgcGZ4XIZh0Q==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [netbsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-openbsd-64/0.14.38:
+ resolution: {integrity: sha512-CDswYr2PWPGEPpLDUO50mL3WO/07EMjnZDNKpmaxUPsrW+kVM3LoAqr/CE8UbzugpEiflYqJsGPLirThRB18IQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [openbsd]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-sunos-64/0.14.38:
+ resolution: {integrity: sha512-2mfIoYW58gKcC3bck0j7lD3RZkqYA7MmujFYmSn9l6TiIcAMpuEvqksO+ntBgbLep/eyjpgdplF7b+4T9VJGOA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [sunos]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-windows-32/0.14.38:
+ resolution: {integrity: sha512-L2BmEeFZATAvU+FJzJiRLFUP+d9RHN+QXpgaOrs2klshoAm1AE6Us4X6fS9k33Uy5SzScn2TpcgecbqJza1Hjw==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [win32]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-windows-64/0.14.38:
+ resolution: {integrity: sha512-Khy4wVmebnzue8aeSXLC+6clo/hRYeNIm0DyikoEqX+3w3rcvrhzpoix0S+MF9vzh6JFskkIGD7Zx47ODJNyCw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [win32]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild-windows-arm64/0.14.38:
+ resolution: {integrity: sha512-k3FGCNmHBkqdJXuJszdWciAH77PukEyDsdIryEHn9cKLQFxzhT39dSumeTuggaQcXY57UlmLGIkklWZo2qzHpw==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [win32]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /esbuild/0.14.38:
+ resolution: {integrity: sha512-12fzJ0fsm7gVZX1YQ1InkOE5f9Tl7cgf6JPYXRJtPIoE0zkWAbHdPHVPPaLi9tYAcEBqheGzqLn/3RdTOyBfcA==}
+ engines: {node: '>=12'}
+ hasBin: true
+ requiresBuild: true
+ optionalDependencies:
+ esbuild-android-64: 0.14.38
+ esbuild-android-arm64: 0.14.38
+ esbuild-darwin-64: 0.14.38
+ esbuild-darwin-arm64: 0.14.38
+ esbuild-freebsd-64: 0.14.38
+ esbuild-freebsd-arm64: 0.14.38
+ esbuild-linux-32: 0.14.38
+ esbuild-linux-64: 0.14.38
+ esbuild-linux-arm: 0.14.38
+ esbuild-linux-arm64: 0.14.38
+ esbuild-linux-mips64le: 0.14.38
+ esbuild-linux-ppc64le: 0.14.38
+ esbuild-linux-riscv64: 0.14.38
+ esbuild-linux-s390x: 0.14.38
+ esbuild-netbsd-64: 0.14.38
+ esbuild-openbsd-64: 0.14.38
+ esbuild-sunos-64: 0.14.38
+ esbuild-windows-32: 0.14.38
+ esbuild-windows-64: 0.14.38
+ esbuild-windows-arm64: 0.14.38
+ dev: true
+
+ /estree-walker/2.0.2:
+ resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
+ dev: true
+
+ /fill-range/7.0.1:
+ resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==}
+ engines: {node: '>=8'}
+ dependencies:
+ to-regex-range: 5.0.1
+ dev: true
+
+ /fs.realpath/1.0.0:
+ resolution: {integrity: sha1-FQStJSMVjKpA20onh8sBQRmU6k8=}
+ dev: true
+
+ /fsevents/2.3.2:
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+ requiresBuild: true
+ dev: true
+ optional: true
+
+ /function-bind/1.1.1:
+ resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==}
+ dev: true
+
+ /fuzzysort/1.9.0:
+ resolution: {integrity: sha512-MOxCT0qLTwLqmEwc7UtU045RKef7mc8Qz8eR4r2bLNEq9dy/c3ZKMEFp6IEst69otkQdFZ4FfgH2dmZD+ddX1g==}
+ dev: false
+
+ /glob-parent/5.1.2:
+ resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
+ engines: {node: '>= 6'}
+ dependencies:
+ is-glob: 4.0.3
+ dev: true
+
+ /glob/7.2.0:
+ resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==}
+ dependencies:
+ fs.realpath: 1.0.0
+ inflight: 1.0.6
+ inherits: 2.0.4
+ minimatch: 3.1.2
+ once: 1.4.0
+ path-is-absolute: 1.0.1
+ dev: true
+
+ /graceful-fs/4.2.10:
+ resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==}
+ dev: true
+
+ /has/1.0.3:
+ resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==}
+ engines: {node: '>= 0.4.0'}
+ dependencies:
+ function-bind: 1.1.1
+ dev: true
+
+ /immutable/4.0.0:
+ resolution: {integrity: sha512-zIE9hX70qew5qTUjSS7wi1iwj/l7+m54KWU247nhM3v806UdGj1yDndXj+IOYxxtW9zyLI+xqFNZjTuDaLUqFw==}
+ dev: true
+
+ /inflight/1.0.6:
+ resolution: {integrity: sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=}
+ dependencies:
+ once: 1.4.0
+ wrappy: 1.0.2
+ dev: true
+
+ /inherits/2.0.4:
+ resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+ dev: true
+
+ /is-binary-path/2.1.0:
+ resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
+ engines: {node: '>=8'}
+ dependencies:
+ binary-extensions: 2.2.0
+ dev: true
+
+ /is-core-module/2.9.0:
+ resolution: {integrity: sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A==}
+ dependencies:
+ has: 1.0.3
+ dev: true
+
+ /is-extglob/2.1.1:
+ resolution: {integrity: sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /is-glob/4.0.3:
+ resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
+ engines: {node: '>=0.10.0'}
+ dependencies:
+ is-extglob: 2.1.1
+ dev: true
+
+ /is-number/7.0.0:
+ resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
+ engines: {node: '>=0.12.0'}
+ dev: true
+
+ /jsbi/4.3.0:
+ resolution: {integrity: sha512-SnZNcinB4RIcnEyZqFPdGPVgrg2AcnykiBy0sHVJQKHYeaLUvi3Exj+iaPpLnFVkDPZIV4U0yvgC9/R4uEAZ9g==}
+ dev: false
+
+ /kleur/4.1.4:
+ resolution: {integrity: sha512-8QADVssbrFjivHWQU7KkMgptGTl6WAcSdlbBPY4uNF+mWr6DGcKrvY2w4FQJoXch7+fKMjj0dRrL75vk3k23OA==}
+ engines: {node: '>=6'}
+ dev: true
+
+ /magic-string/0.25.9:
+ resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==}
+ dependencies:
+ sourcemap-codec: 1.4.8
+ dev: true
+
+ /magic-string/0.26.1:
+ resolution: {integrity: sha512-ndThHmvgtieXe8J/VGPjG+Apu7v7ItcD5mhEIvOscWjPF/ccOiLxHaSuCAS2G+3x4GKsAbT8u7zdyamupui8Tg==}
+ engines: {node: '>=12'}
+ dependencies:
+ sourcemap-codec: 1.4.8
+ dev: true
+
+ /min-indent/1.0.1:
+ resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
+ engines: {node: '>=4'}
+ dev: true
+
+ /minimatch/3.1.2:
+ resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
+ dependencies:
+ brace-expansion: 1.1.11
+ dev: true
+
+ /minimist/1.2.6:
+ resolution: {integrity: sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==}
+ dev: true
+
+ /mkdirp/0.5.6:
+ resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
+ hasBin: true
+ dependencies:
+ minimist: 1.2.6
+ dev: true
+
+ /ms/2.1.2:
+ resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
+ dev: true
+
+ /nanoid/3.3.4:
+ resolution: {integrity: sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+ dev: true
+
+ /normalize-path/3.0.0:
+ resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /once/1.4.0:
+ resolution: {integrity: sha1-WDsap3WWHUsROsF9nFC6753Xa9E=}
+ dependencies:
+ wrappy: 1.0.2
+ dev: true
+
+ /path-is-absolute/1.0.1:
+ resolution: {integrity: sha1-F0uSaHNVNP+8es5r9TpanhtcX18=}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /path-parse/1.0.7:
+ resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
+ dev: true
+
+ /picocolors/1.0.0:
+ resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==}
+ dev: true
+
+ /picomatch/2.3.1:
+ resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
+ engines: {node: '>=8.6'}
+ dev: true
+
+ /postcss/8.4.13:
+ resolution: {integrity: sha512-jtL6eTBrza5MPzy8oJLFuUscHDXTV5KcLlqAWHl5q5WYRfnNRGSmOZmOZ1T6Gy7A99mOZfqungmZMpMmCVJ8ZA==}
+ engines: {node: ^10 || ^12 || >=14}
+ dependencies:
+ nanoid: 3.3.4
+ picocolors: 1.0.0
+ source-map-js: 1.0.2
+ dev: true
+
+ /readdirp/3.6.0:
+ resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
+ engines: {node: '>=8.10.0'}
+ dependencies:
+ picomatch: 2.3.1
+ dev: true
+
+ /regexparam/2.0.0:
+ resolution: {integrity: sha512-gJKwd2MVPWHAIFLsaYDZfyKzHNS4o7E/v8YmNf44vmeV2e4YfVoDToTOKTvE7ab68cRJ++kLuEXJBaEeJVt5ow==}
+ engines: {node: '>=8'}
+ dev: true
+
+ /resolve/1.22.0:
+ resolution: {integrity: sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==}
+ hasBin: true
+ dependencies:
+ is-core-module: 2.9.0
+ path-parse: 1.0.7
+ supports-preserve-symlinks-flag: 1.0.0
+ dev: true
+
+ /rimraf/2.7.1:
+ resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==}
+ hasBin: true
+ dependencies:
+ glob: 7.2.0
+ dev: true
+
+ /rollup/2.72.1:
+ resolution: {integrity: sha512-NTc5UGy/NWFGpSqF1lFY8z9Adri6uhyMLI6LvPAXdBKoPRFhIIiBUpt+Qg2awixqO3xvzSijjhnb4+QEZwJmxA==}
+ engines: {node: '>=10.0.0'}
+ hasBin: true
+ optionalDependencies:
+ fsevents: 2.3.2
+ dev: true
+
+ /sander/0.5.1:
+ resolution: {integrity: sha1-dB4kXiMfB8r7b98PEzrfohalAq0=}
+ dependencies:
+ es6-promise: 3.3.1
+ graceful-fs: 4.2.10
+ mkdirp: 0.5.6
+ rimraf: 2.7.1
+ dev: true
+
+ /sass/1.51.0:
+ resolution: {integrity: sha512-haGdpTgywJTvHC2b91GSq+clTKGbtkkZmVAb82jZQN/wTy6qs8DdFm2lhEQbEwrY0QDRgSQ3xDurqM977C3noA==}
+ engines: {node: '>=12.0.0'}
+ hasBin: true
+ dependencies:
+ chokidar: 3.5.3
+ immutable: 4.0.0
+ source-map-js: 1.0.2
+ dev: true
+
+ /sorcery/0.10.0:
+ resolution: {integrity: sha1-iukK19fLBfxZ8asMY3hF1cFaUrc=}
+ hasBin: true
+ dependencies:
+ buffer-crc32: 0.2.13
+ minimist: 1.2.6
+ sander: 0.5.1
+ sourcemap-codec: 1.4.8
+ dev: true
+
+ /source-map-js/1.0.2:
+ resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
+ engines: {node: '>=0.10.0'}
+ dev: true
+
+ /sourcemap-codec/1.4.8:
+ resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==}
+ dev: true
+
+ /strip-indent/3.0.0:
+ resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
+ engines: {node: '>=8'}
+ dependencies:
+ min-indent: 1.0.1
+ dev: true
+
+ /supports-preserve-symlinks-flag/1.0.0:
+ resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
+ engines: {node: '>= 0.4'}
+ dev: true
+
+ /svelte-hmr/0.14.11_svelte@3.48.0:
+ resolution: {integrity: sha512-R9CVfX6DXxW1Kn45Jtmx+yUe+sPhrbYSUp7TkzbW0jI5fVPn6lsNG9NEs5dFg5qRhFNAoVdRw5qQDLALNKhwbQ==}
+ engines: {node: ^12.20 || ^14.13.1 || >= 16}
+ peerDependencies:
+ svelte: '>=3.19.0'
+ dependencies:
+ svelte: 3.48.0
+ dev: true
+
+ /svelte-preprocess/4.10.6_24ezlekk4ocevlsjgs2qnqmjum:
+ resolution: {integrity: sha512-I2SV1w/AveMvgIQlUF/ZOO3PYVnhxfcpNyGt8pxpUVhPfyfL/CZBkkw/KPfuFix5FJ9TnnNYMhACK3DtSaYVVQ==}
+ engines: {node: '>= 9.11.2'}
+ requiresBuild: true
+ peerDependencies:
+ '@babel/core': ^7.10.2
+ coffeescript: ^2.5.1
+ less: ^3.11.3 || ^4.0.0
+ node-sass: '*'
+ postcss: ^7 || ^8
+ postcss-load-config: ^2.1.0 || ^3.0.0
+ pug: ^3.0.0
+ sass: ^1.26.8
+ stylus: ^0.55.0
+ sugarss: ^2.0.0
+ svelte: ^3.23.0
+ typescript: ^3.9.5 || ^4.0.0
+ peerDependenciesMeta:
+ '@babel/core':
+ optional: true
+ coffeescript:
+ optional: true
+ less:
+ optional: true
+ node-sass:
+ optional: true
+ postcss:
+ optional: true
+ postcss-load-config:
+ optional: true
+ pug:
+ optional: true
+ sass:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ typescript:
+ optional: true
+ dependencies:
+ '@types/pug': 2.0.6
+ '@types/sass': 1.43.1
+ detect-indent: 6.1.0
+ magic-string: 0.25.9
+ sass: 1.51.0
+ sorcery: 0.10.0
+ strip-indent: 3.0.0
+ svelte: 3.48.0
+ typescript: 4.6.4
+ dev: true
+
+ /svelte-spa-router/3.2.0:
+ resolution: {integrity: sha512-igemo5Vs82TGBBw+DjWt6qKameXYzNs6aDXcTxou5XbEvOjiRcAM6MLkdVRCatn6u8r42dE99bt/br7T4qe/AQ==}
+ dependencies:
+ regexparam: 2.0.0
+ dev: true
+
+ /svelte/3.48.0:
+ resolution: {integrity: sha512-fN2YRm/bGumvjUpu6yI3BpvZnpIm9I6A7HR4oUNYd7ggYyIwSA/BX7DJ+UXXffLp6XNcUijyLvttbPVCYa/3xQ==}
+ engines: {node: '>= 8'}
+ dev: true
+
+ /to-regex-range/5.0.1:
+ resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
+ engines: {node: '>=8.0'}
+ dependencies:
+ is-number: 7.0.0
+ dev: true
+
+ /tslib/2.4.0:
+ resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==}
+ dev: false
+
+ /typescript/4.6.4:
+ resolution: {integrity: sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==}
+ engines: {node: '>=4.2.0'}
+ hasBin: true
+ dev: true
+
+ /vite/2.9.8_sass@1.51.0:
+ resolution: {integrity: sha512-zsBGwn5UT3YS0NLSJ7hnR54+vUKfgzMUh/Z9CxF1YKEBVIe213+63jrFLmZphgGI5zXwQCSmqIdbPuE8NJywPw==}
+ engines: {node: '>=12.2.0'}
+ hasBin: true
+ peerDependencies:
+ less: '*'
+ sass: '*'
+ stylus: '*'
+ peerDependenciesMeta:
+ less:
+ optional: true
+ sass:
+ optional: true
+ stylus:
+ optional: true
+ dependencies:
+ esbuild: 0.14.38
+ postcss: 8.4.13
+ resolve: 1.22.0
+ rollup: 2.72.1
+ sass: 1.51.0
+ optionalDependencies:
+ fsevents: 2.3.2
+ dev: true
+
+ /wrappy/1.0.2:
+ resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=}
+ dev: true
diff --git a/apps/web-shared/src/components/alert.svelte b/apps/web-shared/src/components/alert.svelte
new file mode 100644
index 0000000..4771f78
--- /dev/null
+++ b/apps/web-shared/src/components/alert.svelte
@@ -0,0 +1,66 @@
+<script>
+ import {afterUpdate} from "svelte";
+
+ export let title = "";
+ export let message = "";
+ export let type = "info";
+ export let visible = true;
+ export let closeable = false;
+
+ afterUpdate(() => {
+ if (type === "default") {
+ type = "primary";
+ }
+ });
+</script>
+
+<div class="alert alert--{type} padding-sm radius-md"
+ class:alert--is-visible={visible}
+ role="alert">
+ <div class="flex justify-between">
+ <div class="flex flex-row items-center">
+ <svg class="icon icon--sm alert__icon margin-right-xxs"
+ viewBox="0 0 24 24"
+ aria-hidden="true">
+ <path d="M12,0C5.383,0,0,5.383,0,12s5.383,12,12,12s12-5.383,12-12S18.617,0,12,0z M14.658,18.284 c-0.661,0.26-2.952,1.354-4.272,0.191c-0.394-0.346-0.59-0.785-0.59-1.318c0-0.998,0.328-1.868,0.919-3.957 c0.104-0.395,0.231-0.907,0.231-1.313c0-0.701-0.266-0.887-0.987-0.887c-0.352,0-0.742,0.125-1.095,0.257l0.195-0.799 c0.787-0.32,1.775-0.71,2.621-0.71c1.269,0,2.203,0.633,2.203,1.837c0,0.347-0.06,0.955-0.186,1.375l-0.73,2.582 c-0.151,0.522-0.424,1.673-0.001,2.014c0.416,0.337,1.401,0.158,1.887-0.071L14.658,18.284z M13.452,8c-0.828,0-1.5-0.672-1.5-1.5 s0.672-1.5,1.5-1.5s1.5,0.672,1.5,1.5S14.28,8,13.452,8z"></path>
+ </svg>
+ {#if title}
+ <p class="text-sm">
+ <strong class="error-title">{title}</strong>
+ </p>
+ {:else if message}
+ <div class="text-component text-sm break-word">
+ {@html message}
+ </div>
+ {/if}
+ </div>
+ {#if closeable}
+ <button class="reset alert__close-btn"
+ on:click={() => visible = false}>
+ <svg class="icon"
+ viewBox="0 0 20 20"
+ fill="none"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2">
+ <title>Close alert</title>
+ <line x1="3"
+ y1="3"
+ x2="17"
+ y2="17"/>
+ <line x1="17"
+ y1="3"
+ x2="3"
+ y2="17"/>
+ </svg>
+ </button>
+ {/if}
+ </div>
+
+ {#if message && title}
+ <div class="text-component text-sm break-word padding-top-xs">
+ {@html message}
+ </div>
+ {/if}
+</div>
diff --git a/apps/web-shared/src/components/button.svelte b/apps/web-shared/src/components/button.svelte
new file mode 100644
index 0000000..5eaf19f
--- /dev/null
+++ b/apps/web-shared/src/components/button.svelte
@@ -0,0 +1,116 @@
+<script lang="ts">
+ import Icon from "$shared/components/icon.svelte";
+
+ export let text = "";
+ export let title = "";
+ export let href = "";
+ export let variant: "primary"|"secondary"|"subtle" = "primary";
+ export let type: "button"|"submit"|"reset" = "button";
+ export let disabled = false;
+ export let loading = false;
+ export let icon = "";
+ export let icon_right_aligned = false;
+ export let icon_width = false;
+ export let icon_height = false;
+ export let id;
+ export let tabindex;
+ export let style;
+
+ $: shared_props = {
+ type: type,
+ id: id || null,
+ title: title || null,
+ disabled: disabled || null,
+ tabindex: tabindex || null,
+ style: style || null,
+ "aria-controls": ($$restProps["aria-controls"] ?? "") || null,
+ class: [variant === "reset" ? "reset" : `btn btn--${variant} btn--preserve-width ${loading ? "btn--state-b" : ""}`, $$restProps.class ?? ""].filter(Boolean).join(" "),
+ };
+</script>
+
+<template>
+ {#if href && !disabled}
+ <a {href}
+ {...shared_props}
+ on:click>
+ <span class="btn__content-a">
+ {#if icon !== ""}
+ {#if icon_right_aligned}
+ {text}
+ <Icon class="{text ? 'margin-left-xxxs': ''}"
+ width={icon_width}
+ height={icon_height}
+ name={icon}/>
+ {:else}
+ <Icon class="{text ? 'margin-left-xxxs': ''}"
+ width={icon_width}
+ height={icon_height}
+ name={icon}/>
+ {text}
+ {/if}
+ {:else}
+ {text}
+ {/if}
+ </span>
+ {#if variant !== "reset" && loading}
+ <span class="btn__content-b">
+ <svg class="icon icon--is-spinning"
+ aria-hidden="true"
+ viewBox="0 0 16 16">
+ <title>Loading</title>
+ <g stroke-width="1"
+ fill="currentColor"
+ stroke="currentColor">
+ <path d="M.5,8a7.5,7.5,0,1,1,1.91,5"
+ fill="none"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"/>
+ </g>
+ </svg>
+ </span>
+ {/if}
+ </a>
+ {:else}
+ <button {...shared_props}
+ on:click>
+ <span class="btn__content-a">
+ {#if icon !== ""}
+ {#if icon_right_aligned}
+ {text}
+ <Icon class="{text ? 'margin-left-xxxs': ''}"
+ width={icon_width}
+ height={icon_height}
+ name={icon}/>
+ {:else}
+ <Icon class="{text ? 'margin-left-xxxs': ''}"
+ width={icon_width}
+ height={icon_height}
+ name={icon}/>
+ {text}
+ {/if}
+ {:else}
+ {text}
+ {/if}
+ </span>
+ {#if variant !== "reset" && loading}
+ <span class="btn__content-b">
+ <svg class="icon icon--is-spinning"
+ aria-hidden="true"
+ viewBox="0 0 16 16">
+ <title>Loading</title>
+ <g stroke-width="1"
+ fill="currentColor"
+ stroke="currentColor">
+ <path d="M.5,8a7.5,7.5,0,1,1,1.91,5"
+ fill="none"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"/>
+ </g>
+ </svg>
+ </span>
+ {/if}
+ </button>
+ {/if}
+</template>
diff --git a/apps/web-shared/src/components/chip.svelte b/apps/web-shared/src/components/chip.svelte
new file mode 100644
index 0000000..7fbb445
--- /dev/null
+++ b/apps/web-shared/src/components/chip.svelte
@@ -0,0 +1,50 @@
+<script>
+ import {IconNames} from "$shared/lib/configuration";
+ import {createEventDispatcher} from "svelte";
+ import Button from "./button.svelte";
+
+ const dispatch = createEventDispatcher();
+ export let removable = false;
+ export let clickable = false;
+ export let text = "";
+ export let id = "";
+ export let color = "";
+ export let tabindex = "";
+
+ function handle_remove() {
+ if (removable) {
+ dispatch("remove", {
+ id
+ });
+ }
+ }
+
+ function handle_click() {
+ if (clickable) {
+ dispatch("clicked", {
+ id
+ });
+ }
+ }
+</script>
+
+<div class="chip break-word text-sm justify-between justify-start@md {clickable ? 'chip--interactive' : ''}"
+ on:click={handle_click}
+ id={id}
+ style={color !== "" ? `background-color: ${color}15; border: 1px solid ${color}; color: ${color}` : ""}
+ {tabindex}
+>
+ <span class="chip__label">{text}</span>
+
+ {#if removable}
+ <Button class="chip__btn"
+ variant="reset"
+ style="{color !== '' ? `background-color: ${color}45;` : ''}"
+ {tabindex}
+ icon="{IconNames.x}"
+ icon_width="initial"
+ icon_height="initial"
+ on:click={handle_remove}
+ />
+ {/if}
+</div>
diff --git a/apps/web-shared/src/components/details.svelte b/apps/web-shared/src/components/details.svelte
new file mode 100644
index 0000000..6ccacb0
--- /dev/null
+++ b/apps/web-shared/src/components/details.svelte
@@ -0,0 +1,35 @@
+<script>
+ import {random_string} from "$shared/lib/helpers";
+
+ let open = false;
+ export let summary;
+ const id = "details-" + random_string(4);
+
+ function on_toggle(event) {
+ open = event.target.open;
+ }
+</script>
+
+<details class="details margin-bottom-sm"
+ on:toggle={on_toggle}
+ id={id}>
+ <summary class="details__summary"
+ aria-controls={id}
+ aria-expanded={open}>
+ <span class="flex items-center">
+ <svg
+ class="icon icon--xxs margin-right-xxxs"
+ aria-hidden="true"
+ viewBox="0 0 12 12">
+ <path
+ d="M2.783.088A.5.5,0,0,0,2,.5v11a.5.5,0,0,0,.268.442A.49.49,0,0,0,2.5,12a.5.5,0,0,0,.283-.088l8-5.5a.5.5,0,0,0,0-.824Z"/>
+ </svg>
+ <span>{summary}</span>
+ </span>
+ </summary>
+ <div
+ class="details__content text-component margin-top-xs"
+ aria-hidden={!open}>
+ <slot/>
+ </div>
+</details>
diff --git a/apps/web-shared/src/components/dropdown.svelte b/apps/web-shared/src/components/dropdown.svelte
new file mode 100644
index 0000000..b5068a7
--- /dev/null
+++ b/apps/web-shared/src/components/dropdown.svelte
@@ -0,0 +1,374 @@
+<script lang="ts">
+ // @ts-ignore
+ import {go, highlight} from "fuzzysort";
+ import {element_has_focus, random_string} from "$shared/lib/helpers";
+ import Button from "$shared/components/button.svelte";
+ import Chip from "$shared/components/chip.svelte";
+
+ export let name;
+ export let id;
+ export let maxlength;
+ export let placeholder = "Search";
+ export let entries = [];
+ export let createable = false;
+ export let loading = false;
+ export let multiple = false;
+ export let noResultsText;
+ export let errorText;
+ export let label;
+ export let on_create_async = ({name: string}) => {
+ };
+
+ export const reset = () => methods.reset();
+ export const select = (id: string) => methods.select_entry(id);
+ export const deselect = (id: string) => methods.deselect_entry(id);
+
+ const INTERNAL_ID = "__dropdown-" + random_string(5);
+
+ let entriesUlNode;
+ let searchInputNode;
+ let searchResults = [];
+ let searchValue = "";
+ let showCreationHint = false;
+ let showDropdown = false;
+ let lastKeydownCode = "";
+ let mouseIsOverDropdown = false;
+ let mouseIsOverComponent = false;
+
+ $: hasSelection = entries.some((c) => c.selected === true);
+ $: if (searchValue.trim()) {
+ showCreationHint = createable && entries.every((c) => search.normalise_value(c.name) !== search.normalise_value(searchValue));
+ } else {
+ showCreationHint = false;
+ entries = methods.get_sorted_array(entries);
+ }
+
+ const search = {
+ normalise_value(value: string): string {
+ if (!value) {
+ return "";
+ }
+ return value.toString().trim().toLowerCase();
+ },
+ do() {
+ const query = search.normalise_value(searchValue);
+ if (!query.trim()) {
+ searchResults = [];
+ return;
+ }
+
+ const options = {
+ limit: 10,
+ allowTypo: true,
+ threshold: -10000,
+ key: "name",
+ };
+ searchResults = go(query, entries, options);
+ showDropdown = true;
+ },
+ on_input_focusout() {
+ if (lastKeydownCode !== "Tab" && (mouseIsOverDropdown || lastKeydownCode === "ArrowDown")) {
+ return;
+ }
+ const selected = entries.find((c) => c.selected === true);
+ if (selected && !multiple) {
+ searchValue = selected.name;
+ }
+ showDropdown = false;
+ }
+ };
+
+ const methods = {
+ reset(focus_input = false) {
+ searchValue = "";
+ const copy = entries;
+ for (const entry of copy) {
+ entry.selected = false;
+ }
+ entries = methods.get_sorted_array(copy);
+ if (focus_input) {
+ searchInputNode?.focus();
+ showDropdown = true;
+ } else {
+ showDropdown = false;
+ }
+ },
+ async create_entry(name) {
+ if (!name || !createable || loading) {
+ console.log("Not sending creation event due to failed preconditions", {name, createable, loading});
+ return;
+ }
+ try {
+ await on_create_async({name});
+ searchValue = "";
+ loading = false;
+ } catch (e) {
+ console.error(e);
+ }
+ },
+ select_entry(entry_id) {
+ if (!entry_id || loading) {
+ console.log("Not selecting entry due to failed preconditions", {
+ entry_id,
+ loading,
+ });
+ return;
+ }
+
+ const copy = entries;
+ let selected;
+ for (const entry of entries) {
+ if (entry.id === entry_id) {
+ entry.selected = true;
+ selected = entry;
+ if (multiple) {
+ searchValue = "";
+ } else {
+ searchValue = entry.name;
+ }
+ } else if (!multiple) {
+ entry.selected = false;
+ }
+ }
+ entries = methods.get_sorted_array(copy);
+ searchInputNode?.focus();
+ searchResults = [];
+ },
+ deselect_entry(entry_id) {
+ if (!entry_id || loading) {
+ console.log("Not deselecting entry due to failed preconditions", {
+ entry_id,
+ loading,
+ });
+ return;
+ }
+ console.log("Deselecting entry", entry_id);
+
+ const copy = entries;
+ let deselected;
+
+ for (const entry of copy) {
+ if (entry.id === entry_id) {
+ entry.selected = false;
+ deselected = entry;
+ }
+ }
+
+ entries = methods.get_sorted_array(copy);
+ searchInputNode?.focus();
+ },
+ get_sorted_array(entries: Array<DropdownEntry>): Array<DropdownEntry> {
+ if (!entries) {
+ return;
+ }
+ if (entries.length < 1) {
+ return [];
+ }
+ if (searchValue) {
+ return entries;
+ }
+ return (entries as any).sort((a, b) => {
+ search.normalise_value(a.name).localeCompare(search.normalise_value(b.name));
+ });
+ },
+ };
+
+ const windowEvents = {
+ on_mousemove(event) {
+ mouseIsOverDropdown = (event.target?.closest("#" + INTERNAL_ID + " .autocomplete__results") != null ?? false);
+ mouseIsOverComponent = (event.target?.closest("#" + INTERNAL_ID) != null ?? false);
+ },
+ on_click(event) {
+ if (showDropdown && !mouseIsOverDropdown && !mouseIsOverComponent && event.target.id !== id && event.target?.htmlFor !== id) {
+ showDropdown = false;
+ }
+ },
+ on_keydown(event) {
+ lastKeydownCode = event.code;
+ const enterPressed = event.code === "Enter";
+ const backspacePressed = event.code === "Backspace";
+ const arrowUpPressed = event.code === "ArrowUp";
+ const spacePressed = event.code === "Space";
+ const arrowDownPressed = event.code === "ArrowDown";
+ const searchInputHasFocus = element_has_focus(searchInputNode);
+ const focusedEntry = entriesUlNode?.querySelector("li:focus");
+
+ if (showDropdown && (enterPressed || arrowDownPressed)) {
+ event.preventDefault();
+ event.stopPropagation();
+ }
+
+ if (searchInputHasFocus && backspacePressed && !searchValue && entries.length > 0) {
+ if (entries.filter(c => c.selected === true).at(-1)?.id ?? false) {
+ methods.deselect_entry(entries.filter(c => c.selected === true).at(-1)?.id ?? "");
+ }
+ return;
+ }
+
+ if (searchInputHasFocus) {
+ if (enterPressed && showCreationHint) {
+ methods.create_entry(searchValue.trim());
+ return;
+ }
+
+ if (arrowDownPressed) {
+ const firstEntry = entriesUlNode.querySelector("li:first-of-type");
+ if (firstEntry) {
+ firstEntry.focus();
+ }
+ return;
+ }
+ }
+
+ if (focusedEntry && (arrowUpPressed || arrowDownPressed)) {
+ if (arrowDownPressed && focusedEntry.nextElementSibling) {
+ focusedEntry.nextElementSibling.focus();
+ } else if (arrowUpPressed && focusedEntry.previousElementSibling) {
+ focusedEntry.previousElementSibling.focus();
+ }
+ return;
+ }
+
+ if (focusedEntry && (spacePressed || enterPressed)) {
+ methods.select_entry(focusedEntry.dataset.id);
+ return;
+ }
+
+ if (lastKeydownCode === "Tab" && !searchInputHasFocus) {
+ showDropdown = false;
+ }
+ },
+ on_touchend(event) {
+ windowEvents.on_mousemove(event);
+ }
+ };
+
+ interface DropdownEntry {
+ name: string,
+ id: string,
+ }
+</script>
+
+<svelte:window
+ on:keydown={windowEvents.on_keydown}
+ on:mousemove={windowEvents.on_mousemove}
+ on:touchend={windowEvents.on_touchend}
+ on:click={windowEvents.on_click}
+/>
+
+{#if label}
+ <label for="{id}"
+ class="form-label margin-bottom-xxs">{label}</label>
+{/if}
+
+<div class="autocomplete position-relative select-auto"
+ class:cursor-wait={loading}
+ class:autocomplete--results-visible={showDropdown}
+ class:select-auto--selection-done={searchValue}
+ id={INTERNAL_ID}
+>
+ <!-- input -->
+ <div class="select-auto__input-wrapper form-control"
+ class:multiple={multiple === true}
+ class:has-selection={hasSelection}>
+ {#if multiple === true && hasSelection}
+ {#each entries.filter((c) => c.selected === true) as entry}
+ <Chip id={entry.id}
+ removable={true}
+ tabindex="-1"
+ on:remove={() => methods.deselect_entry(entry.id)}
+ text={entry.name}/>
+ {/each}
+ {/if}
+ <input
+ class="reset width-100%"
+ style="outline:none;"
+ type="text"
+ {name}
+ {id}
+ {maxlength}
+ {placeholder}
+ bind:value={searchValue}
+ bind:this={searchInputNode}
+ on:input={() => search.do()}
+ on:click={() => (showDropdown = true)}
+ on:focus={() => (showDropdown = true)}
+ on:blur={search.on_input_focusout}
+ autocomplete="off"
+ />
+ <div class="select-auto__input-icon-wrapper">
+ <!-- arrow icon -->
+ <svg class="icon"
+ viewBox="0 0 16 16">
+ <title>Open selection</title>
+ <polyline points="1 5 8 12 15 5"
+ fill="none"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"/>
+ </svg>
+
+ <!-- close X icon -->
+ <button class="reset select-auto__input-btn"
+ type="button"
+ on:click={() => reset(true)}>
+ <svg class="icon"
+ viewBox="0 0 16 16">
+ <title>Reset selection</title>
+ <path
+ d="M8,0a8,8,0,1,0,8,8A8,8,0,0,0,8,0Zm3.707,10.293a1,1,0,1,1-1.414,1.414L8,9.414,5.707,11.707a1,1,0,0,1-1.414-1.414L6.586,8,4.293,5.707A1,1,0,0,1,5.707,4.293L8,6.586l2.293-2.293a1,1,0,1,1,1.414,1.414L9.414,8Z"
+ />
+ </svg>
+ </button>
+ </div>
+ </div>
+
+ {#if errorText}
+ <small class="color-error">{errorText}</small>
+ {/if}
+
+ <!-- dropdown -->
+ <div class="autocomplete__results select-auto__results">
+ <ul bind:this={entriesUlNode}
+ on:keydown={(event) => event.code.startsWith("Arrow") && event.preventDefault()}
+ tabindex="-1"
+ class="autocomplete__list">
+ {#if searchResults.length > 0}
+ {#each searchResults.filter((c) => !c.selected) as result}
+ <li class="select-auto__option padding-y-xs padding-x-sm"
+ data-id={result.obj.id}
+ on:click={(e) => methods.select_entry(e.target.dataset.id)}
+ tabindex="-1">
+ {@html highlight(result, (open = '<span class="font-semibold">'), (close = "</span>"))}
+ </li>
+ {/each}
+ {:else if entries.length > 0}
+ {#each entries.filter((c) => !c.selected) as entry}
+ <li class="select-auto__option padding-y-xs padding-x-sm"
+ data-id={entry.id}
+ on:click={(e) => methods.select_entry(e.target.dataset.id)}
+ tabindex="-1">
+ {entry.name}
+ </li>
+ {/each}
+ {:else}
+ <li class="select-auto__option text-center padding-y-xs padding-x-sm pointer-events-none"
+ tabindex="-1">
+ {noResultsText}
+ </li>
+ {/if}
+ </ul>
+ {#if showCreationHint}
+ <div class="width-100% border-top border-bg-lighter padding-xxxs">
+ <Button variant="reset"
+ type="button"
+ class="width-100%"
+ text="Press enter or click to create {searchValue.trim()}"
+ title="Press enter or click here to create {searchValue.trim()}"
+ loading={loading}
+ on:click={() => methods.create_entry(searchValue.trim())}/>
+ </div>
+ {/if}
+ </div>
+</div>
diff --git a/apps/web-shared/src/components/form/index.ts b/apps/web-shared/src/components/form/index.ts
new file mode 100644
index 0000000..08769bd
--- /dev/null
+++ b/apps/web-shared/src/components/form/index.ts
@@ -0,0 +1,5 @@
+import Textarea from "./textarea.svelte";
+
+export {
+ Textarea
+};
diff --git a/apps/web-shared/src/components/form/textarea.svelte b/apps/web-shared/src/components/form/textarea.svelte
new file mode 100644
index 0000000..b313d2e
--- /dev/null
+++ b/apps/web-shared/src/components/form/textarea.svelte
@@ -0,0 +1,48 @@
+<script lang="ts">
+ export let id;
+ export let disabled = false;
+ export let loading = false;
+ export let rows = 2;
+ export let cols = 0;
+ export let name;
+ export let placeholder;
+ export let value;
+ export let label;
+ export let errorText;
+
+ $: shared_props = {
+ rows: rows || null,
+ cols: cols || null,
+ name: name || null,
+ id: id || null,
+ disabled: disabled || null,
+ class: [`form-control ${loading ? "c-disabled loading" : ""}`, $$restProps.class ?? ""].filter(Boolean).join(" "),
+ };
+
+ let textarea;
+ let scrollHeight = 0;
+
+ $:if (textarea) {
+ scrollHeight = textarea.scrollHeight;
+ }
+
+ function on_input(event) {
+ event.target.style.height = "auto";
+ event.target.style.height = (this.scrollHeight) + "px";
+ }
+</script>
+
+{#if label}
+ <label for="{id}"
+ class="form-label margin-bottom-xxs">{label}</label>
+{/if}
+<textarea {...shared_props}
+ {placeholder}
+ style="overflow-y:hidden;min-height:calc(1.5em + .75rem + 2px);{scrollHeight ? 'height:{scrollHeight}px' : ''};"
+ bind:value={value}
+ bind:this={textarea}
+ on:input={on_input}
+></textarea>
+{#if errorText}
+ <small class="color-error">{errorText}</small>
+{/if}
diff --git a/apps/web-shared/src/components/icon.svelte b/apps/web-shared/src/components/icon.svelte
new file mode 100644
index 0000000..144b45d
--- /dev/null
+++ b/apps/web-shared/src/components/icon.svelte
@@ -0,0 +1,87 @@
+<script>
+ import {IconNames} from "$shared/lib/configuration";
+
+ const icons = [
+ {
+ box: 16,
+ name: IconNames.verticalDots,
+ svg: `<path d="M9.5 13a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"/>`,
+ },
+ {
+ box: 16,
+ name: IconNames.clock,
+ svg: `<path d="M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71V3.5z"/><path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z"/>`,
+ },
+ {
+ box: 21,
+ name: IconNames.trash,
+ svg: `<g fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="translate(3 2)"><path d="m2.5 2.5h10v12c0 1.1045695-.8954305 2-2 2h-6c-1.1045695 0-2-.8954305-2-2zm5-2c1.0543618 0 1.91816512.81587779 1.99451426 1.85073766l.00548574.14926234h-4c0-1.1045695.8954305-2 2-2z"/><path d="m.5 2.5h14"/><path d="m5.5 5.5v8"/><path d="m9.5 5.5v8"/></g>`,
+ },
+ {
+ box: 21,
+ name: IconNames.pencilSquare,
+ svg: `
+ <g fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="translate(3 3)"><path d="m14 1c.8284271.82842712.8284271 2.17157288 0 3l-9.5 9.5-4 1 1-3.9436508 9.5038371-9.55252193c.7829896-.78700064 2.0312313-.82943964 2.864366-.12506788z"/><path d="m6.5 14.5h8"/><path d="m12.5 3.5 1 1"/></g>
+ `,
+ },
+ {
+ box: 16,
+ name: IconNames.x,
+ svg: `<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>`,
+ },
+ {
+ box: 16,
+ name: IconNames.funnel,
+ svg: `<path d="M1.5 1.5A.5.5 0 0 1 2 1h12a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.128.334L10 8.692V13.5a.5.5 0 0 1-.342.474l-3 1A.5.5 0 0 1 6 14.5V8.692L1.628 3.834A.5.5 0 0 1 1.5 3.5v-2zm1 .5v1.308l4.372 4.858A.5.5 0 0 1 7 8.5v5.306l2-.666V8.5a.5.5 0 0 1 .128-.334L13.5 3.308V2h-11z"/>`,
+ },
+ {
+ box: 16,
+ name: IconNames.funnelFilled,
+ svg: `<path d="M1.5 1.5A.5.5 0 0 1 2 1h12a.5.5 0 0 1 .5.5v2a.5.5 0 0 1-.128.334L10 8.692V13.5a.5.5 0 0 1-.342.474l-3 1A.5.5 0 0 1 6 14.5V8.692L1.628 3.834A.5.5 0 0 1 1.5 3.5v-2z"/>`,
+ },
+ {
+ box: 16,
+ name: IconNames.github,
+ svg: `
+ <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"/>
+ `
+ },
+ {
+ box: 21,
+ name: IconNames.refresh,
+ svg: `<g fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="translate(2 2)"><path d="m4.5 1.5c-2.4138473 1.37729434-4 4.02194088-4 7 0 4.418278 3.581722 8 8 8s8-3.581722 8-8-3.581722-8-8-8"/><path d="m4.5 5.5v-4h-4"/></g> `
+ },
+ {
+ box: 21,
+ name: IconNames.resetHard,
+ svg: `<g fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="matrix(0 1 1 0 2.5 2.5)"><path d="m13 11 3 3v-6c0-3.36502327-2.0776-6.24479706-5.0200433-7.42656457-.9209869-.36989409-1.92670197-.57343543-2.9799567-.57343543-4.418278 0-8 3.581722-8 8s3.581722 8 8 8c1.48966767 0 3.4724708-.3698516 5.0913668-1.5380762" transform="matrix(-1 0 0 -1 16 16)"/><path d="m5 5 6 6"/><path d="m11 5-6 6"/></g>`
+ },
+ {
+ box: 21,
+ name: IconNames.arrowUp,
+ svg: `<g fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="translate(6 3)"><path d="m8.5 4.5-4-4-4.029 4"/><path d="m4.5.5v13"/></g>`
+ },
+ {
+ box: 21,
+ name: IconNames.arrowDown,
+ svg: `<g fill="none" fill-rule="evenodd" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="translate(6 4)"><path d="m.5 9.499 4 4.001 4-4.001"/><path d="m4.5.5v13" transform="matrix(-1 0 0 -1 9 14)"/></g>`
+ },
+ {
+ box: 21,
+ name: IconNames.chevronDown,
+ svg: `<path d="m8.5.5-4 4-4-4" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" transform="translate(6 8)"/>`
+ }
+ ];
+
+ export let name;
+ export let fill = false;
+ export let width = "1rem";
+ export let height = "1rem";
+ const displayIcon = icons.find((e) => e.name === name);
+</script>
+
+<svg class="icon {$$restProps.class ?? ''}"
+ style="width: {width}; height:{height}; fill: currentColor;"
+ viewBox="0 0 {displayIcon.box} {displayIcon.box}">
+ {@html displayIcon.svg}
+</svg>
diff --git a/apps/web-shared/src/components/menu/index.ts b/apps/web-shared/src/components/menu/index.ts
new file mode 100644
index 0000000..8eb7938
--- /dev/null
+++ b/apps/web-shared/src/components/menu/index.ts
@@ -0,0 +1,9 @@
+import Menu from "./menu.svelte";
+import MenuItem from "./item.svelte";
+import MenuItemSeparator from "./separator.svelte";
+
+export {
+ Menu,
+ MenuItem,
+ MenuItemSeparator
+};
diff --git a/apps/web-shared/src/components/menu/item.svelte b/apps/web-shared/src/components/menu/item.svelte
new file mode 100644
index 0000000..aeb0f99
--- /dev/null
+++ b/apps/web-shared/src/components/menu/item.svelte
@@ -0,0 +1,8 @@
+<script lang="ts">
+ export let danger = false;
+</script>
+<li role="menuitem" on:click>
+ <span class="menu__content {danger ? 'bg-error-lighter@hover color-white@hover' : ''}">
+ <slot/>
+ </span>
+</li>
diff --git a/apps/web-shared/src/components/menu/menu.svelte b/apps/web-shared/src/components/menu/menu.svelte
new file mode 100644
index 0000000..33b1160
--- /dev/null
+++ b/apps/web-shared/src/components/menu/menu.svelte
@@ -0,0 +1,54 @@
+<script lang="ts">
+ import {random_string} from "$shared/lib/helpers";
+
+ export let id = "__menu_" + random_string(3);
+ export let trigger: HTMLElement;
+ export let show = false;
+
+ let windowInnerWidth = 0;
+ let windowInnerHeight = 0;
+ let menu: HTMLMenuElement;
+
+ $: if (show && menu && trigger) {
+ const
+ selectedTriggerPosition = trigger.getBoundingClientRect(),
+ menuOnTop = (windowInnerHeight - selectedTriggerPosition.bottom) < selectedTriggerPosition.top,
+ left = selectedTriggerPosition.left,
+ right = (windowInnerWidth - selectedTriggerPosition.right),
+ isRight = (windowInnerWidth < selectedTriggerPosition.left + menu.offsetWidth),
+ vertical = menuOnTop
+ ? "bottom: " + (windowInnerHeight - selectedTriggerPosition.top) + "px;"
+ : "top: " + selectedTriggerPosition.bottom + "px;";
+
+ let horizontal = isRight ? "right: " + right + "px;" : "left: " + left + "px;";
+
+ // check right position is correct -> otherwise set left to 0
+ if (isRight && (right + menu.offsetWidth) > windowInnerWidth) horizontal = ("left: " + (windowInnerWidth - menu.offsetWidth) / 2 + "px;");
+ const maxHeight = menuOnTop ? selectedTriggerPosition.top - 20 : windowInnerHeight - selectedTriggerPosition.bottom - 20;
+ menu.setAttribute("style", horizontal + vertical + "max-height:" + Math.floor(maxHeight) + "px;");
+ }
+
+ function on_window_click(event) {
+ if (!event.target.closest("#" + id) && !event.target.closest("[aria-controls='" + id + "']")) show = false;
+ }
+
+ function on_window_touchend(event) {
+ if (!event.target.closest("#" + id) && !event.target.closest("[aria-controls='" + id + "']")) show = false;
+ }
+</script>
+
+<svelte:window
+ on:click={on_window_click}
+ on:touchend={on_window_touchend}
+ bind:innerWidth={windowInnerWidth}
+ bind:innerHeight={windowInnerHeight}
+/>
+
+<menu class="menu"
+ id="{id}"
+ bind:this={menu}
+ class:menu--is-visible={show}
+ aria-expanded="{show}"
+ aria-haspopup="true">
+ <slot name="options"/>
+</menu>
diff --git a/apps/web-shared/src/components/menu/separator.svelte b/apps/web-shared/src/components/menu/separator.svelte
new file mode 100644
index 0000000..798dce0
--- /dev/null
+++ b/apps/web-shared/src/components/menu/separator.svelte
@@ -0,0 +1,2 @@
+<li class="menu__separator"
+ role="separator"></li>
diff --git a/apps/web-shared/src/components/modal.svelte b/apps/web-shared/src/components/modal.svelte
new file mode 100644
index 0000000..f3b633c
--- /dev/null
+++ b/apps/web-shared/src/components/modal.svelte
@@ -0,0 +1,66 @@
+<script>
+ import {random_string} from "$shared/lib/helpers";
+
+ export let title = "";
+ let isVisible = false;
+ const modal_id = "modal_" + random_string(5);
+
+ function handle_keyup(e) {
+ if (e.key === "Escape") {
+ isVisible = false;
+ }
+ }
+
+ export const functions = {
+ open() {
+ isVisible = true;
+ window.addEventListener("keyup", handle_keyup);
+ },
+ close() {
+ isVisible = false;
+ window.removeEventListener("keyup", handle_keyup);
+ },
+ };
+</script>
+
+<div class="modal modal--animate-scale flex flex-center padding-md bg-dark bg-opacity-40% {isVisible ? 'modal--is-visible' : ''}"
+ id={modal_id}
+>
+ <div class="modal__content width-100% max-width-xs max-height-100% overflow-auto radius-md shadow-md bg"
+ role="alertdialog"
+ >
+ <header class="padding-y-sm padding-x-md flex items-center justify-between"
+ >
+ <h4 class="text-truncate">{title}</h4>
+
+ <button class="reset modal__close-btn modal__close-btn--inner"
+ on:click={functions.close}
+ >
+ <svg class="icon"
+ viewBox="0 0 20 20">
+ <title>Close modal window</title>
+ <g fill="none"
+ stroke="currentColor"
+ stroke-miterlimit="10"
+ stroke-width="2"
+ 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>
+ </button>
+ </header>
+
+ <div class="padding-bottom-md padding-x-md">
+ <slot/>
+ </div>
+ </div>
+</div>
diff --git a/apps/web-shared/src/components/pre-header.svelte b/apps/web-shared/src/components/pre-header.svelte
new file mode 100644
index 0000000..87a19b1
--- /dev/null
+++ b/apps/web-shared/src/components/pre-header.svelte
@@ -0,0 +1,37 @@
+<script>
+ export let closable = true;
+ export let show = false;
+</script>
+
+<div class="pre-header padding-y-xs" style="{show ? '' : 'display:none'}">
+ <div class="container max-width-lg position-relative">
+ <div class="text-component text-sm padding-right-lg">
+ <p>
+ <slot/>
+ </p>
+ </div>
+ {#if closable}
+ <button class="reset pre-header__close-btn"
+ on:click={(event) => event.target.closest(".pre-header")?.remove()}>
+ <svg class="icon"
+ viewBox="0 0 20 20">
+ <title>Close header banner</title>
+ <g fill="none"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2">
+ <line x1="4"
+ y1="4"
+ x2="16"
+ y2="16"/>
+ <line x1="16"
+ y1="4"
+ x2="4"
+ y2="16"/>
+ </g>
+ </svg>
+ </button>
+ {/if}
+ </div>
+</div>
diff --git a/apps/web-shared/src/components/stopwatch.svelte b/apps/web-shared/src/components/stopwatch.svelte
new file mode 100644
index 0000000..8287e31
--- /dev/null
+++ b/apps/web-shared/src/components/stopwatch.svelte
@@ -0,0 +1,161 @@
+<script lang="ts">
+ import {writable_persistent} from "$shared/lib/persistent-store";
+ import Button from "$shared/components/button.svelte";
+ import {Textarea} from "$shared/components/form";
+ import {StorageKeys} from "$shared/lib/configuration";
+ import {Temporal} from "@js-temporal/polyfill";
+ import {createEventDispatcher, onMount} from "svelte";
+
+ const state = writable_persistent({
+ initialState: {
+ hours: 0,
+ minutes: 0,
+ seconds: 0,
+ startTime: null as Temporal.PlainTime,
+ isRunning: false,
+ intervalId: 0,
+ note: "",
+ },
+ name: StorageKeys.stopwatch,
+ });
+
+ let timeString;
+
+ $: if ($state.hours || $state.minutes || $state.seconds) {
+ timeString = $state.hours.toLocaleString(undefined, {minimumIntegerDigits: 2})
+ + ":" + $state.minutes.toLocaleString(undefined, {minimumIntegerDigits: 2})
+ + ":" + $state.seconds.toLocaleString(undefined, {minimumIntegerDigits: 2});
+ } else {
+ timeString = "--:--:--";
+ }
+
+ onMount(() => {
+ if ($state.isRunning) {
+ clearInterval($state.intervalId);
+ $state.intervalId = setInterval(step, 1000);
+ }
+ });
+
+ const dispatch = createEventDispatcher();
+
+ function step() {
+ $state.seconds = $state.seconds + 1;
+
+ if ($state.seconds == 60) {
+ $state.minutes = $state.minutes + 1;
+ $state.seconds = 0;
+ }
+
+ if ($state.minutes == 60) {
+ $state.hours = $state.hours + 1;
+ $state.minutes = 0;
+ $state.seconds = 0;
+ }
+
+ if (!$state.startTime) $state.startTime = Temporal.Now.plainTimeISO();
+ }
+
+ function reset() {
+ clearInterval($state.intervalId);
+ $state.isRunning = false;
+ $state.hours = 0;
+ $state.minutes = 0;
+ $state.seconds = 0;
+ $state.startTime = null;
+ $state.intervalId = 0;
+ $state.note = "";
+ }
+
+ let roundUpToNearest = 30;
+ let roundDownToNearest = 30;
+
+ function on_round_up() {
+ const newTime = Temporal.PlainTime
+ .from({hour: $state.hours, minute: $state.minutes, second: $state.seconds})
+ .round({
+ roundingIncrement: roundUpToNearest,
+ smallestUnit: "minute",
+ roundingMode: "ceil"
+ });
+ $state.hours = newTime.hour;
+ $state.minutes = newTime.minute;
+ $state.seconds = newTime.second;
+ }
+
+ function on_round_down() {
+ const newTime = Temporal.PlainTime
+ .from({hour: $state.hours, minute: $state.minutes, second: $state.seconds,})
+ .round({
+ roundingIncrement: roundDownToNearest,
+ smallestUnit: "minute",
+ roundingMode: "trunc"
+ });
+ $state.hours = newTime.hour;
+ $state.minutes = newTime.minute;
+ $state.seconds = newTime.second;
+ }
+
+ function on_start_stop() {
+ if ($state.isRunning) {
+ clearInterval($state.intervalId);
+ $state.isRunning = false;
+ return;
+ }
+ step();
+ $state.intervalId = setInterval(step, 1000);
+ $state.isRunning = true;
+ }
+
+ function on_create_entry() {
+ if (!$state.startTime) return;
+ const plainStartTime = Temporal.PlainTime.from($state.startTime);
+ dispatch("create", {
+ from: plainStartTime,
+ to: plainStartTime.add({hours: $state.hours, minutes: $state.minutes, seconds: $state.seconds}),
+ description: $state.note
+ });
+ reset();
+ }
+</script>
+
+<div class="grid">
+ <div class="col-6">
+ <slot name="header"></slot>
+ <pre class="text-xxl padding-y-sm">{timeString}</pre>
+ </div>
+ <div class="col-6 flex align-bottom flex-column text-xs">
+ <Button title="{$state.isRunning ? 'Stop' : 'Start'}"
+ text="{$state.isRunning ? 'Stop' : 'Start'}"
+ variant="link"
+ on:click={on_start_stop}/>
+
+ {#if $state.startTime}
+ <Button title="Reset"
+ text="Reset"
+ variant="link"
+ class="bg-error-lighter@hover color-white@hover"
+ on:click={reset}/>
+ {#if !$state.isRunning}
+ <Button title="Round up"
+ text="Round up"
+ variant="link"
+ on:click={on_round_up}/>
+ <Button title="Round down"
+ text="Round down"
+ variant="link"
+ on:click={on_round_down}/>
+ {#if $state.minutes > 0 || $state.hours > 0}
+ <Button title="Create entry"
+ text="Create entry"
+ variant="link"
+ on:click={on_create_entry}/>
+ {/if}
+ {/if}
+ {/if}
+ </div>
+</div>
+<Textarea class="width-100% margin-top-xs"
+ placeholder="What's your focus?"
+ rows="1"
+ bind:value={$state.note}
+/>
diff --git a/apps/web-shared/src/components/table/index.ts b/apps/web-shared/src/components/table/index.ts
new file mode 100644
index 0000000..8390c0e
--- /dev/null
+++ b/apps/web-shared/src/components/table/index.ts
@@ -0,0 +1,15 @@
+import TablePaginator from "./paginator.svelte";
+import Table from "./table.svelte";
+import THead from "./thead.svelte";
+import TBody from "./tbody.svelte";
+import TCell from "./tcell.svelte";
+import TRow from "./trow.svelte";
+
+export {
+ TablePaginator,
+ Table,
+ THead,
+ TBody,
+ TCell,
+ TRow
+};
diff --git a/apps/web-shared/src/components/table/paginator.svelte b/apps/web-shared/src/components/table/paginator.svelte
new file mode 100644
index 0000000..53c6392
--- /dev/null
+++ b/apps/web-shared/src/components/table/paginator.svelte
@@ -0,0 +1,101 @@
+<script>
+ import {createEventDispatcher, onMount} from "svelte";
+ import {restrict_input_to_numbers} from "$shared/lib/helpers";
+
+ const dispatch = createEventDispatcher();
+ export let page = 1;
+ export let pageCount = 1;
+ let prevCount = page;
+ let canIncrement = false;
+ let canDecrement = false;
+ $: canIncrement = page < pageCount;
+ $: canDecrement = page > 1;
+
+ onMount(() => {
+ restrict_input_to_numbers(document.querySelector("#curr-page"));
+ });
+
+ function increment() {
+ if (canIncrement) {
+ page++;
+ }
+ }
+
+ function decrement() {
+ if (canDecrement) {
+ page--;
+ }
+ }
+
+ $: if (page) {
+ handle_change();
+ }
+
+ function handle_change() {
+ if (page === prevCount) {
+ return;
+ }
+ prevCount = page;
+ if (page > pageCount) {
+ page = pageCount;
+ }
+ dispatch("value_change", {
+ newValue: page,
+ });
+ }
+</script>
+
+<nav class="pagination"
+ aria-label="Pagination">
+ <ul class="pagination__list flex flex-wrap gap-xxxs justify-center justify-end@md">
+ <li>
+ <button on:click={decrement}
+ class="reset pagination__item {canDecrement ? '' : 'c-disabled'}">
+ <svg class="icon icon--xs flip-x"
+ viewBox="0 0 16 16"
+ ><title>Go to previous page</title>
+ <polyline
+ points="6 2 12 8 6 14"
+ fill="none"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ />
+ </svg>
+ </button>
+ </li>
+
+ <li>
+ <span class="pagination__jumper flex items-center">
+ <input
+ aria-label="Page number"
+ class="form-control"
+ id="curr-page"
+ type="text"
+ on:change={handle_change}
+ value={page}
+ />
+ <em>of {pageCount}</em>
+ </span>
+ </li>
+
+ <li>
+ <button on:click={increment}
+ class="reset pagination__item {canIncrement ? '' : 'c-disabled'}">
+ <svg class="icon icon--xs"
+ viewBox="0 0 16 16"
+ ><title>Go to next page</title>
+ <polyline
+ points="6 2 12 8 6 14"
+ fill="none"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ />
+ </svg>
+ </button>
+ </li>
+ </ul>
+</nav>
diff --git a/apps/web-shared/src/components/table/table.svelte b/apps/web-shared/src/components/table/table.svelte
new file mode 100644
index 0000000..4acbf37
--- /dev/null
+++ b/apps/web-shared/src/components/table/table.svelte
@@ -0,0 +1,3 @@
+<table class="int-table {$$restProps.class ?? ''}">
+ <slot/>
+</table>
diff --git a/apps/web-shared/src/components/table/tbody.svelte b/apps/web-shared/src/components/table/tbody.svelte
new file mode 100644
index 0000000..f0617fa
--- /dev/null
+++ b/apps/web-shared/src/components/table/tbody.svelte
@@ -0,0 +1,3 @@
+<tbody class="int-table__body {$$restProps.class ?? ''}">
+<slot/>
+</tbody>
diff --git a/apps/web-shared/src/components/table/tcell.svelte b/apps/web-shared/src/components/table/tcell.svelte
new file mode 100644
index 0000000..76f500f
--- /dev/null
+++ b/apps/web-shared/src/components/table/tcell.svelte
@@ -0,0 +1,23 @@
+<script lang="ts">
+ export let thScope: "row"|"col"|"rowgroup"|"colgroup"|"";
+ export let colspan = "";
+ export let type: "th"|"td" = "td";
+ export let style;
+
+ $: shared_props = {
+ colspan: colspan || null,
+ style: style || null,
+ class: [type === "th" ? "int-table__cell--th" : "", "int-table__cell", $$restProps.class ?? ""].filter(Boolean).join(" "),
+ };
+</script>
+{#if type === "th"}
+ <th {thScope}
+ {...shared_props}>
+ <slot/>
+ </th>
+{/if}
+{#if type === "td"}
+ <td {...shared_props}>
+ <slot/>
+ </td>
+{/if}
diff --git a/apps/web-shared/src/components/table/thead.svelte b/apps/web-shared/src/components/table/thead.svelte
new file mode 100644
index 0000000..aa20bf0
--- /dev/null
+++ b/apps/web-shared/src/components/table/thead.svelte
@@ -0,0 +1,10 @@
+<script lang="ts">
+ import TRow from "./trow.svelte";
+</script>
+
+
+<thead class="int-table__header {$$restProps.class ?? ''}">
+<TRow>
+ <slot/>
+</TRow>
+</thead>
diff --git a/apps/web-shared/src/components/table/trow.svelte b/apps/web-shared/src/components/table/trow.svelte
new file mode 100644
index 0000000..35b34bb
--- /dev/null
+++ b/apps/web-shared/src/components/table/trow.svelte
@@ -0,0 +1,6 @@
+<script>
+ export let dataId;
+</script>
+<tr class="int-table__row {$$restProps.class ?? ''}" data-id={dataId}>
+ <slot/>
+</tr>
diff --git a/apps/web-shared/src/components/tile.svelte b/apps/web-shared/src/components/tile.svelte
new file mode 100644
index 0000000..b8e9cdf
--- /dev/null
+++ b/apps/web-shared/src/components/tile.svelte
@@ -0,0 +1,4 @@
+<section class="bg-light radius-sm padding-sm inner-glow shadow-xs {$$restProps.class??''}"
+ style="height: fit-content;">
+ <slot/>
+</section>
diff --git a/apps/web-shared/src/lib/api/internal-fetch.ts b/apps/web-shared/src/lib/api/internal-fetch.ts
new file mode 100644
index 0000000..8659ccb
--- /dev/null
+++ b/apps/web-shared/src/lib/api/internal-fetch.ts
@@ -0,0 +1,170 @@
+import {Temporal} from "@js-temporal/polyfill";
+import {clear_session_data} from "$shared/lib/session";
+import {resolve_references} from "$shared/lib/helpers";
+import {replace} from "svelte-spa-router";
+import type {IInternalFetchResponse} from "$shared/lib/models/IInternalFetchResponse";
+import type {IInternalFetchRequest} from "$shared/lib/models/IInternalFetchRequest";
+
+export async function http_post(url: string, body?: object|string, timeout = -1, skip_401_check = false, abort_signal: AbortSignal = undefined): Promise<IInternalFetchResponse> {
+ const init = {
+ method: "post",
+ } as RequestInit;
+
+ if (abort_signal) {
+ init.signal = abort_signal;
+ }
+
+ if (body) {
+ init.headers = {
+ "Content-Type": "application/json;charset=UTF-8",
+ };
+ init.body = JSON.stringify(body);
+ }
+
+ const response = await internal_fetch({url, init, timeout});
+ const result = {} as IInternalFetchResponse;
+
+ if (!skip_401_check && await is_401(response)) return result;
+
+ result.ok = response.ok;
+ result.status = response.status;
+ result.http_response = response;
+
+ if (response.status !== 204) {
+ try {
+ const ct = response.headers.get("Content-Type")?.toString() ?? "";
+ if (ct.startsWith("application/json")) {
+ const data = await response.json();
+ result.data = resolve_references(data);
+ } else if (ct.startsWith("text/plain")) {
+ const text = await response.text();
+ result.data = text as string;
+ }
+ } catch {
+ // Ignored
+ }
+ }
+
+ return result;
+}
+
+export async function http_get(url: string, timeout = -1, skip_401_check = false, abort_signal: AbortSignal = undefined): Promise<IInternalFetchResponse> {
+ const init = {
+ method: "get",
+ } as RequestInit;
+
+ if (abort_signal) {
+ init.signal = abort_signal;
+ }
+
+ const response = await internal_fetch({url, init, timeout});
+ const result = {} as IInternalFetchResponse;
+
+ if (!skip_401_check && await is_401(response)) return result;
+
+ result.ok = response.ok;
+ result.status = response.status;
+ result.http_response = response;
+
+ if (response.status !== 204) {
+ try {
+ const ct = response.headers.get("Content-Type")?.toString() ?? "";
+ if (ct.startsWith("application/json")) {
+ const data = await response.json();
+ result.data = resolve_references(data);
+ } else if (ct.startsWith("text/plain")) {
+ const text = await response.text();
+ result.data = text as string;
+ }
+ } catch {
+ // Ignored
+ }
+ }
+
+ return result;
+}
+
+export async function http_delete(url: string, body?: object|string, timeout = -1, skip_401_check = false, abort_signal: AbortSignal = undefined): Promise<IInternalFetchResponse> {
+ const init = {
+ method: "delete",
+ } as RequestInit;
+
+ if (abort_signal) {
+ init.signal = abort_signal;
+ }
+
+ if (body) {
+ init.headers = {
+ "Content-Type": "application/json;charset=UTF-8",
+ };
+ init.body = JSON.stringify(body);
+ }
+
+ const response = await internal_fetch({url, init, timeout});
+ const result = {} as IInternalFetchResponse;
+
+ if (!skip_401_check && await is_401(response)) return result;
+
+ result.ok = response.ok;
+ result.status = response.status;
+ result.http_response = response;
+
+ if (response.status !== 204) {
+ try {
+ const ct = response.headers.get("Content-Type")?.toString() ?? "";
+ if (ct.startsWith("application/json")) {
+ const data = await response.json();
+ result.data = resolve_references(data);
+ } else if (ct.startsWith("text/plain")) {
+ const text = await response.text();
+ result.data = text as string;
+ }
+ } catch (error) {
+ // ignored
+ }
+ }
+
+ return result;
+}
+
+async function internal_fetch(request: IInternalFetchRequest): Promise<Response> {
+ if (!request.init) request.init = {};
+ request.init.credentials = "include";
+ request.init.headers = {
+ "X-TimeZone": Temporal.Now.timeZone().id,
+ ...request.init.headers
+ };
+
+ const fetch_request = new Request(request.url, request.init);
+ let response: any;
+
+ try {
+ if (request.timeout > 500) {
+ response = await Promise.race([
+ fetch(fetch_request),
+ new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), request.timeout))
+ ]);
+ } else {
+ response = await fetch(fetch_request);
+ }
+ } catch (error) {
+ if (error.message === "Timeout") {
+ console.error("Request timed out");
+ } else if (error.message === "Network request failed") {
+ console.error("No internet connection");
+ } else {
+ throw error; // rethrow other unexpected errors
+ }
+ }
+
+ return response;
+}
+
+async function is_401(response: Response): Promise<boolean> {
+ if (response.status === 401) {
+ clear_session_data();
+ await replace("/login");
+ return true;
+ }
+ return false;
+}
diff --git a/apps/web-shared/src/lib/api/root.ts b/apps/web-shared/src/lib/api/root.ts
new file mode 100644
index 0000000..d65efc4
--- /dev/null
+++ b/apps/web-shared/src/lib/api/root.ts
@@ -0,0 +1,6 @@
+import {http_post} from "$shared/lib/api/internal-fetch";
+import {api_base} from "$shared/lib/configuration";
+
+export function server_log(message: string): void {
+ http_post(api_base("_/api/log"), message);
+}
diff --git a/apps/web-shared/src/lib/api/time-entry.ts b/apps/web-shared/src/lib/api/time-entry.ts
new file mode 100644
index 0000000..e81329d
--- /dev/null
+++ b/apps/web-shared/src/lib/api/time-entry.ts
@@ -0,0 +1,86 @@
+import {api_base} from "$shared/lib/configuration";
+import {is_guid} from "$shared/lib/helpers";
+import {http_delete, http_get, http_post} from "./internal-fetch";
+import type {TimeCategoryDto} from "$shared/lib/models/TimeCategoryDto";
+import type {TimeLabelDto} from "$shared/lib/models/TimeLabelDto";
+import type {TimeEntryDto} from "$shared/lib/models/TimeEntryDto";
+import type {TimeEntryQuery} from "$shared/lib/models/TimeEntryQuery";
+import type {IInternalFetchResponse} from "$shared/lib/models/IInternalFetchResponse";
+
+
+// ENTRIES
+
+export async function create_time_entry(payload: TimeEntryDto): Promise<IInternalFetchResponse> {
+ return http_post(api_base("v1/entries/create"), payload);
+}
+
+export async function get_time_entry(entryId: string): Promise<IInternalFetchResponse> {
+ if (is_guid(entryId)) {
+ return http_get(api_base("v1/entries/" + entryId));
+ }
+ throw new Error("entryId is not a valid guid.");
+}
+
+export async function get_time_entries(entryQuery: TimeEntryQuery): Promise<IInternalFetchResponse> {
+ return http_post(api_base("v1/entries/query"), entryQuery);
+}
+
+export async function delete_time_entry(id: string): Promise<IInternalFetchResponse> {
+ if (!is_guid(id)) throw new Error("id is not a valid guid");
+ return http_delete(api_base("v1/entries/" + id + "/delete"));
+}
+
+export async function update_time_entry(entryDto: TimeEntryDto): Promise<IInternalFetchResponse> {
+ if (!is_guid(entryDto.id ?? "")) throw new Error("id is not a valid guid");
+ if (!entryDto.category) throw new Error("category is empty");
+ if (!entryDto.stop) throw new Error("stop is empty");
+ if (!entryDto.start) throw new Error("start is empty");
+ return http_post(api_base("v1/entries/update"), entryDto);
+}
+
+// LABELS
+
+
+export async function create_time_label(labelDto: TimeLabelDto): Promise<IInternalFetchResponse> {
+ return http_post(api_base("v1/labels/create"), labelDto);
+}
+
+export async function get_time_labels(): Promise<IInternalFetchResponse> {
+ return http_get(api_base("v1/labels"));
+}
+
+export async function delete_time_label(id: string): Promise<IInternalFetchResponse> {
+ if (!is_guid(id)) throw new Error("id is not a valid guid");
+ return http_delete(api_base("v1/labels/" + id + "/delete"));
+}
+
+export async function update_time_label(labelDto: TimeLabelDto): Promise<IInternalFetchResponse> {
+ if (!is_guid(labelDto.id ?? "")) throw new Error("id is not a valid guid");
+ if (!labelDto.name) throw new Error("name is empty");
+ if (!labelDto.color) throw new Error("color is empty");
+ return http_post(api_base("v1/labels/update"), labelDto);
+}
+
+
+// CATEGORIES
+export async function create_time_category(category: TimeCategoryDto): Promise<IInternalFetchResponse> {
+ if (!category.name) throw new Error("name is empty");
+ if (!category.color) throw new Error("color is empty");
+ return http_post(api_base("v1/categories/create"), category);
+}
+
+export async function get_time_categories(): Promise<IInternalFetchResponse> {
+ return http_get(api_base("v1/categories"));
+}
+
+export async function delete_time_category(id: string): Promise<IInternalFetchResponse> {
+ if (!is_guid(id)) throw new Error("id is not a valid guid");
+ return http_delete(api_base("v1/categories/" + id + "/delete"));
+}
+
+export async function update_time_category(category: TimeCategoryDto): Promise<IInternalFetchResponse> {
+ if (!is_guid(category.id ?? "")) throw new Error("id is not a valid guid");
+ if (!category.name) throw new Error("name is empty");
+ if (!category.color) throw new Error("color is empty");
+ return http_post(api_base("v1/categories/update"), category);
+}
diff --git a/apps/web-shared/src/lib/api/user.ts b/apps/web-shared/src/lib/api/user.ts
new file mode 100644
index 0000000..a3a149e
--- /dev/null
+++ b/apps/web-shared/src/lib/api/user.ts
@@ -0,0 +1,47 @@
+import {api_base} from "$shared/lib/configuration";
+import {http_delete, http_get, http_post} from "./internal-fetch";
+import type {LoginPayload} from "$shared/lib/models/LoginPayload";
+import type {UpdateProfilePayload} from "$shared/lib/models/UpdateProfilePayload";
+import type {CreateAccountPayload} from "$shared/lib/models/CreateAccountPayload";
+import type {IInternalFetchResponse} from "$shared/lib/models/IInternalFetchResponse";
+
+export async function login(payload: LoginPayload): Promise<IInternalFetchResponse> {
+ return http_post(api_base("_/account/login"), payload);
+}
+
+export async function logout(): Promise<IInternalFetchResponse> {
+ return http_get(api_base("_/account/logout"));
+}
+
+export async function create_forgot_password_request(username: string): Promise<IInternalFetchResponse> {
+ if (!username) throw new Error("Username is empty");
+ return http_get(api_base("_/forgot-password-requests/create?username=" + username));
+}
+
+export async function check_forgot_password_request(public_id: string): Promise<IInternalFetchResponse> {
+ if (!public_id) throw new Error("Id is empty");
+ return http_get(api_base("_/forgot-password-requests/is-valid?id=" + public_id));
+}
+
+export async function fulfill_forgot_password_request(public_id: string, newPassword: string): Promise<IInternalFetchResponse> {
+ if (!public_id) throw new Error("Id is empty");
+ return http_post(api_base("_/forgot-password-requests/fulfill"), {id: public_id, newPassword});
+}
+
+export async function delete_account(): Promise<IInternalFetchResponse> {
+ return http_delete(api_base("_/account/delete"));
+}
+
+export async function update_profile(payload: UpdateProfilePayload): Promise<IInternalFetchResponse> {
+ if (!payload.password && !payload.username) throw new Error("Password and Username is empty");
+ return http_post(api_base("_/account/update"), payload);
+}
+
+export async function create_account(payload: CreateAccountPayload): Promise<IInternalFetchResponse> {
+ if (!payload.password && !payload.username) throw new Error("Password and Username is empty");
+ return http_post(api_base("_/account/create"), payload);
+}
+
+export async function get_profile_for_active_check(): Promise<IInternalFetchResponse> {
+ return http_get(api_base("_/account"), 0, true);
+}
diff --git a/apps/web-shared/src/lib/colors.ts b/apps/web-shared/src/lib/colors.ts
new file mode 100644
index 0000000..c2da03d
--- /dev/null
+++ b/apps/web-shared/src/lib/colors.ts
@@ -0,0 +1,47 @@
+export function generate_random_hex_color(skip_contrast_check = false) {
+ let hex = __generate_random_hex_color();
+ if (skip_contrast_check) return hex;
+ while ((__calculate_contrast_ratio("#ffffff", hex) < 4.5) || (__calculate_contrast_ratio("#000000", hex) < 4.5)) {
+ hex = __generate_random_hex_color();
+ }
+
+ return hex;
+}
+
+// Largely copied from chroma js api
+function __generate_random_hex_color(): string {
+ let code = "#";
+ for (let i = 0; i < 6; i++) {
+ code += "0123456789abcdef".charAt(Math.floor(Math.random() * 16));
+ }
+ return code;
+}
+
+function __calculate_contrast_ratio(hex1: string, hex2: string): number {
+ const rgb1 = __hex_to_rgb(hex1);
+ const rgb2 = __hex_to_rgb(hex2);
+ const l1 = __get_luminance(rgb1[0], rgb1[1], rgb1[2]);
+ const l2 = __get_luminance(rgb2[0], rgb2[1], rgb2[2]);
+ const result = l1 > l2 ? (l1 + 0.05) / (l2 + 0.05) : (l2 + 0.05) / (l1 + 0.05);
+ return result;
+}
+
+function __hex_to_rgb(hex: string): number[] {
+ if (!hex.match(/^#([A-Fa-f0-9]{6})$/)) return [];
+ if (hex[0] === "#") hex = hex.substring(1, hex.length);
+ return [parseInt(hex.substring(0, 2), 16), parseInt(hex.substring(2, 4), 16), parseInt(hex.substring(4, 6), 16)];
+}
+
+function __get_luminance(r, g, b) {
+ // relative luminance
+ // see http://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
+ r = __luminance_x(r);
+ g = __luminance_x(g);
+ b = __luminance_x(b);
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+}
+
+function __luminance_x(x) {
+ x /= 255;
+ return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
+}
diff --git a/apps/web-shared/src/lib/configuration.ts b/apps/web-shared/src/lib/configuration.ts
new file mode 100644
index 0000000..f597bb4
--- /dev/null
+++ b/apps/web-shared/src/lib/configuration.ts
@@ -0,0 +1,60 @@
+export const API_ADDRESS = "https://api.dev.greatoffice.life";
+export const PROJECTS_ADDRESS = "https://projects.dev.greatoffice.life";
+export const ACCOUNTS_ADDRESS = "https://a.dev.greatoffice.life";
+export const FRONTPAGE_ADDRESS = "https://greatoffice.life";
+export const DEV_ACCOUNTS_ADDRESS = "http://localhost:3001";
+export const DEV_FRONTPAGE_ADDRESS = "http://localhost:3002";
+export const DEV_API_ADDRESS = "http://localhost:5000";
+export const DEV_PROJECTS_ADDRESS = "http://localhost:3000";
+export const SECONDS_BETWEEN_SESSION_CHECK = 600;
+
+export function projects_base(path: string): string {
+ return (is_development() ? DEV_PROJECTS_ADDRESS : PROJECTS_ADDRESS) + (path ? "/" + path : "/");
+}
+
+export function frontpage_base(path: string): string {
+ return (is_development() ? DEV_FRONTPAGE_ADDRESS : FRONTPAGE_ADDRESS) + (path ? "/" + path : "/");
+}
+
+export function accounts_base(path: string): string {
+ return (is_development() ? DEV_ACCOUNTS_ADDRESS : ACCOUNTS_ADDRESS) + (path ? "/" + path : "/");
+}
+
+export function api_base(path: string): string {
+ return (is_development() ? DEV_API_ADDRESS : API_ADDRESS) + (path ? "/" + path : "/");
+}
+
+export function is_development(): boolean {
+ // @ts-ignore
+ return import.meta.env.DEV;
+}
+
+export function is_debug(): boolean {
+ return localStorage.getItem(StorageKeys.debug) !== "true";
+}
+
+export const IconNames = {
+ github: "github",
+ verticalDots: "verticalDots",
+ clock: "clock",
+ trash: "trash",
+ pencilSquare: "pencilSquare",
+ x: "x",
+ funnel: "funnel",
+ funnelFilled: "funnelFilled",
+ refresh: "refresh",
+ resetHard: "resetHard",
+ arrowUp: "arrowUp",
+ arrowDown: "arrowDown",
+ chevronDown: "chevronDown"
+};
+
+export const StorageKeys = {
+ session: "sessionData",
+ theme: "theme",
+ debug: "debug",
+ categories: "categories",
+ labels: "labels",
+ entries: "entries",
+ stopwatch: "stopwatchState"
+};
diff --git a/apps/web-shared/src/lib/helpers.ts b/apps/web-shared/src/lib/helpers.ts
new file mode 100644
index 0000000..650bccf
--- /dev/null
+++ b/apps/web-shared/src/lib/helpers.ts
@@ -0,0 +1,489 @@
+import {StorageKeys} from "$shared/lib/configuration";
+import {TimeEntryDto} from "$shared/lib/models/TimeEntryDto";
+import {UnwrappedEntryDateTime} from "$shared/lib/models/UnwrappedEntryDateTime";
+import {Temporal} from "@js-temporal/polyfill";
+
+export const EMAIL_REGEX = new RegExp(/^([a-z0-9]+(?:([._\-])[a-z0-9]+)*@(?:[a-z0-9]+(?:(-)[a-z0-9]+)?\.)+[a-z0-9](?:[a-z0-9]*[a-z0-9])?)$/i);
+export const URL_REGEX = new RegExp(/^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?[a-z0-9]+([\-.][a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?$/gm);
+export const GUID_REGEX = new RegExp(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
+export const NORWEGIAN_PHONE_NUMBER_REGEX = new RegExp(/(0047|\+47|47)?\d{8,12}/);
+
+export function is_email(value: string): boolean {
+ return EMAIL_REGEX.test(String(value).toLowerCase());
+}
+
+export function is_url(value: string): boolean {
+ return URL_REGEX.test(String(value).toLowerCase());
+}
+
+export function is_norwegian_phone_number(value: string): boolean {
+ if (value.length < 8 || value.length > 12) {
+ return false;
+ }
+ return NORWEGIAN_PHONE_NUMBER_REGEX.test(String(value));
+}
+
+export function switch_theme() {
+ const html = document.querySelector("html");
+ if (html) {
+ if (html.dataset.theme === "dark") {
+ html.dataset.theme = "light";
+ } else {
+ html.dataset.theme = "dark";
+ }
+ localStorage.setItem(StorageKeys.theme, html.dataset.theme);
+ }
+}
+
+export function unwrap_date_time_from_entry(entry: TimeEntryDto): UnwrappedEntryDateTime {
+ if (!entry) throw new Error("entry was undefined");
+ const currentTimeZone = Temporal.Now.timeZone().id;
+ const startInstant = Temporal.Instant.from(entry.start).toZonedDateTimeISO(currentTimeZone);
+ const stopInstant = Temporal.Instant.from(entry.stop).toZonedDateTimeISO(currentTimeZone);
+
+ return {
+ start_date: startInstant.toPlainDate(),
+ stop_date: stopInstant.toPlainDate(),
+ start_time: startInstant.toPlainTime(),
+ stop_time: stopInstant.toPlainTime(),
+ duration: Temporal.Duration.from({
+ hours: stopInstant.hour,
+ minutes: stopInstant.minute
+ }).subtract(Temporal.Duration.from({
+ hours: startInstant.hour,
+ minutes: startInstant.minute
+ }))
+ };
+}
+
+
+export function is_guid(value: string): boolean {
+ if (!value) {
+ return false;
+ }
+ if (value[0] === "{") {
+ value = value.substring(1, value.length - 1);
+ }
+ return GUID_REGEX.test(value);
+}
+
+export function is_empty_object(obj: object): boolean {
+ return obj !== void 0 && Object.keys(obj).length > 0;
+}
+
+export function merge_obj_arr<T>(a: Array<T>, b: Array<T>, props: Array<string>): Array<T> {
+ let start = 0;
+ let merge = [];
+
+ while (start < a.length) {
+
+ if (a[start] === b[start]) {
+ //pushing the merged objects into array
+ merge.push({...a[start], ...b[start]});
+ }
+ //incrementing start value
+ start = start + 1;
+ }
+ return merge;
+}
+
+export function set_favicon(url: string) {
+ // Find the current favicon element
+ const favicon = document.querySelector("link[rel=\"icon\"]") as HTMLLinkElement;
+ if (favicon) {
+ // Update the new link
+ favicon.href = url;
+ } else {
+ // Create new `link`
+ const link = document.createElement("link");
+ link.rel = "icon";
+ link.href = url;
+
+ // Append to the `head` element
+ document.head.appendChild(link);
+ }
+}
+
+export function set_emoji_favicon(emoji: string) {
+ // Create a canvas element
+ const canvas = document.createElement("canvas");
+ canvas.height = 64;
+ canvas.width = 64;
+
+ // Get the canvas context
+ const context = canvas.getContext("2d") as CanvasRenderingContext2D;
+ context.font = "64px serif";
+ context.fillText(emoji, 0, 64);
+
+ // Get the custom URL
+ const url = canvas.toDataURL();
+
+ // Update the favicon
+ set_favicon(url);
+}
+
+
+// https://stackoverflow.com/a/48400665/11961742
+export function seconds_to_hour_minute_string(seconds: number) {
+ const hours = Math.floor(seconds / (60 * 60));
+ seconds -= hours * (60 * 60);
+ const minutes = Math.floor(seconds / 60);
+ return hours + "h" + minutes + "m";
+}
+
+export function get_query_string(params: any = {}): string {
+ const map = Object.keys(params).reduce((arr: Array<string>, key: string) => {
+ if (params[key] !== undefined) {
+ return arr.concat(`${key}=${encodeURIComponent(params[key])}`);
+ }
+ return arr;
+ }, [] as any);
+
+ if (map.length) {
+ return `?${map.join("&")}`;
+ }
+
+ return "";
+}
+
+export function make_url(url: string, params: object): string {
+ return `${url}${get_query_string(params)}`;
+}
+
+export function load_script(url: string) {
+ unload_script(url, () => {
+ return new Promise(function (resolve, reject) {
+ const script = document.createElement("script");
+ script.src = url;
+
+ script.addEventListener("load", function () {
+ // The script is loaded completely
+ resolve(true);
+ });
+
+ document.body.appendChild(script);
+ });
+ });
+}
+
+export function unload_script(src: string, callback?: Function): void {
+ document.querySelectorAll("script[src='" + src + "']").forEach(el => el.remove());
+ if (typeof callback === "function") {
+ callback();
+ }
+}
+
+export function noop() {
+}
+
+export async function run_async(functionToRun: Function): Promise<any> {
+ return new Promise((greatSuccess, graveFailure) => {
+ try {
+ greatSuccess(functionToRun());
+ } catch (exception) {
+ graveFailure(exception);
+ }
+ });
+}
+
+// https://stackoverflow.com/a/45215694/11961742
+export function get_selected_options(domElement: HTMLSelectElement): Array<string> {
+ const ret = [];
+
+ // fast but not universally supported
+ if (domElement.selectedOptions !== undefined) {
+ for (let i = 0; i < domElement.selectedOptions.length; i++) {
+ ret.push(domElement.selectedOptions[i].value);
+ }
+
+ // compatible, but can be painfully slow
+ } else {
+ for (let i = 0; i < domElement.options.length; i++) {
+ if (domElement.options[i].selected) {
+ ret.push(domElement.options[i].value);
+ }
+ }
+ }
+ return ret;
+}
+
+export function uuid_v4(): string {
+ // @ts-ignore
+ return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c => (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16));
+}
+
+export function random_string(length: number): string {
+ if (!length) {
+ throw new Error("length is undefined");
+ }
+ let result = "";
+ const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+ const charactersLength = characters.length;
+ for (let i = 0; i < length; i++) {
+ result += characters.charAt(Math.floor(Math.random() * charactersLength));
+ }
+ return result;
+}
+
+interface CreateElementOptions {
+ name: string,
+ properties?: object,
+ children?: Array<HTMLElement|Function|Node>
+}
+
+export function create_element_from_object(elementOptions: CreateElementOptions): HTMLElement {
+ return create_element(elementOptions.name, elementOptions.properties, elementOptions.children);
+}
+
+export function create_element(name: string, properties?: object, children?: Array<HTMLElement|any>): HTMLElement {
+ if (!name || name.length < 1) {
+ throw new Error("name is required");
+ }
+ const node = document.createElement(name);
+ if (properties) {
+ for (const [key, value] of Object.entries(properties)) {
+ // @ts-ignore
+ node[key] = value;
+ }
+ }
+
+ if (children && children.length > 0) {
+ let actualChildren = children;
+ if (typeof children === "function") {
+ // @ts-ignore
+ actualChildren = children();
+ }
+ for (const child of actualChildren) {
+ node.appendChild(child as Node);
+ }
+ }
+ return node;
+}
+
+export function get_element_position(element: HTMLElement|any) {
+ if (!element) return {x: 0, y: 0};
+ let x = 0;
+ let y = 0;
+ while (true) {
+ x += element.offsetLeft;
+ y += element.offsetTop;
+ if (element.offsetParent === null) {
+ break;
+ }
+ element = element.offsetParent;
+ }
+ return {x, y};
+}
+
+export function restrict_input_to_numbers(element: HTMLElement, specials: Array<string> = [], mergeSpecialsWithDefaults: boolean = false): void {
+ if (element) {
+ element.addEventListener("keydown", (e) => {
+ const defaultSpecials = ["Backspace", "ArrowLeft", "ArrowRight", "Tab",];
+ let keys = specials.length > 0 ? specials : defaultSpecials;
+ if (mergeSpecialsWithDefaults && specials) {
+ keys = [...specials, ...defaultSpecials];
+ }
+ if (keys.indexOf(e.key) !== -1) {
+ return;
+ }
+ if (isNaN(parseInt(e.key))) {
+ e.preventDefault();
+ }
+ });
+ }
+}
+
+export function element_has_focus(element: HTMLElement): boolean {
+ return element === document.activeElement;
+}
+
+export function move_focus(element: HTMLElement): void {
+ if (!element) {
+ element = document.getElementsByTagName("body")[0];
+ }
+ element.focus();
+ // @ts-ignore
+ if (!element_has_focus(element)) {
+ element.setAttribute("tabindex", "-1");
+ element.focus();
+ }
+}
+
+export function get_url_parameter(name: string): string {
+ // @ts-ignore
+ return new RegExp("[?&]" + name + "=([^&#]*)")?.exec(window.location.href)[1];
+}
+
+export function update_url_parameter(param: string, newVal: string): void {
+ let newAdditionalURL = "";
+ let tempArray = location.href.split("?");
+ const baseURL = tempArray[0];
+ const additionalURL = tempArray[1];
+ let temp = "";
+ if (additionalURL) {
+ tempArray = additionalURL.split("&");
+ for (let i = 0; i < tempArray.length; i++) {
+ if (tempArray[i].split("=")[0] !== param) {
+ newAdditionalURL += temp + tempArray[i];
+ temp = "&";
+ }
+ }
+ }
+ const rows_txt = temp + "" + param + "=" + newVal;
+ const newUrl = baseURL + "?" + newAdditionalURL + rows_txt;
+ window.history.replaceState("", "", newUrl);
+}
+
+
+export function get_style_string(rules: CSSRuleList) {
+ let styleString = "";
+ for (const [key, value] of Object.entries(rules)) {
+ styleString += key + ":" + value + ";";
+ }
+ return styleString;
+}
+
+export function get_local_time_zone_date(date: Date): Date {
+ const timeOffsetInMS = new Date().getTimezoneOffset() * 60000;
+ date.setTime(date.getTime() - timeOffsetInMS);
+ return date;
+}
+
+export function parse_iso_local(s: string) {
+ const b = s.split(/\D/);
+ //@ts-ignore
+ return new Date(b[0], b[1] - 1, b[2], b[3], b[4], b[5]);
+}
+
+export function resolve_references(json: any) {
+ if (!json) return;
+ if (typeof json === "string") {
+ json = JSON.parse(json ?? "{}");
+ }
+ const byid = {}, refs = [];
+ json = function recurse(obj, prop, parent) {
+ if (typeof obj !== "object" || !obj) {
+ return obj;
+ }
+ if (Object.prototype.toString.call(obj) === "[object Array]") {
+ for (let i = 0; i < obj.length; i++) {
+ if (typeof obj[i] !== "object" || !obj[i]) {
+ continue;
+ } else if ("$ref" in obj[i]) {
+ // @ts-ignore
+ obj[i] = recurse(obj[i], i, obj);
+ } else {
+ obj[i] = recurse(obj[i], prop, obj);
+ }
+ }
+ return obj;
+ }
+ if ("$ref" in obj) {
+ let ref = obj.$ref;
+ if (ref in byid) {
+ // @ts-ignore
+ return byid[ref];
+ }
+ refs.push([parent, prop, ref]);
+ return;
+ } else if ("$id" in obj) {
+ let id = obj.$id;
+ delete obj.$id;
+ if ("$values" in obj) {
+ obj = obj.$values.map(recurse);
+ } else {
+ for (let prop2 in obj) {
+ // @ts-ignore
+ obj[prop2] = recurse(obj[prop2], prop2, obj);
+ }
+ }
+ // @ts-ignore
+ byid[id] = obj;
+ }
+ return obj;
+ }(json);
+ for (let i = 0; i < refs.length; i++) {
+ let ref = refs[i];
+ // @ts-ignore
+ ref[0][ref[1]] = byid[ref[2]];
+ }
+ return json;
+}
+
+export function to_readable_date_string(date: Date, locale = "nb-NO"): string {
+ date.setMinutes(date.getMinutes() - date.getTimezoneOffset());
+ return date.toLocaleString(locale);
+}
+
+export function get_random_int(min: number, max: number): number {
+ min = Math.ceil(min);
+ max = Math.floor(max);
+ return Math.floor(Math.random() * (max - min + 1)) + min;
+}
+
+export function to_readable_bytes(bytes: number): string {
+ const s = ["bytes", "kB", "MB", "GB", "TB", "PB"];
+ const e = Math.floor(Math.log(bytes) / Math.log(1024));
+ return (bytes / Math.pow(1024, e)).toFixed(2) + " " + s[e];
+}
+
+export function can_use_dom(): boolean {
+ return !!(typeof window !== "undefined" && window.document && window.document.createElement);
+}
+
+export function session_storage_remove_regex(regex: RegExp): void {
+ let n = sessionStorage.length;
+ while (n--) {
+ const key = sessionStorage.key(n);
+ if (key && regex.test(key)) {
+ sessionStorage.removeItem(key);
+ }
+ }
+}
+
+export function local_storage_remove_regex(regex: RegExp): void {
+ let n = localStorage.length;
+ while (n--) {
+ const key = localStorage.key(n);
+ if (key && regex.test(key)) {
+ localStorage.removeItem(key);
+ }
+ }
+}
+
+export function session_storage_set_json(key: string, value: object): void {
+ sessionStorage.setItem(key, JSON.stringify(value));
+}
+
+export function session_storage_get_json(key: string): object {
+ return JSON.parse(sessionStorage.getItem(key) ?? "{}");
+}
+
+export function local_storage_set_json(key: string, value: object): void {
+ localStorage.setItem(key, JSON.stringify(value));
+}
+
+export function local_storage_get_json(key: string): object {
+ return JSON.parse(localStorage.getItem(key) ?? "{}");
+}
+
+export function get_hash_code(value: string): number|undefined {
+ let hash = 0;
+ if (value.length === 0) {
+ return;
+ }
+ for (let i = 0; i < value.length; i++) {
+ const char = value.charCodeAt(i);
+ hash = (hash << 5) - hash + char;
+ hash |= 0;
+ }
+ return hash;
+}
+
+export function $(selector: string): HTMLElement|null {
+ return document.querySelector(selector);
+}
+
+export function $$(selector: string): NodeListOf<Element> {
+ return document.querySelectorAll(selector);
+}
diff --git a/apps/web-shared/src/lib/models/CreateAccountPayload.ts b/apps/web-shared/src/lib/models/CreateAccountPayload.ts
new file mode 100644
index 0000000..d116308
--- /dev/null
+++ b/apps/web-shared/src/lib/models/CreateAccountPayload.ts
@@ -0,0 +1,4 @@
+export interface CreateAccountPayload {
+ username: string,
+ password: string
+}
diff --git a/apps/web-shared/src/lib/models/ErrorResult.ts b/apps/web-shared/src/lib/models/ErrorResult.ts
new file mode 100644
index 0000000..7c70017
--- /dev/null
+++ b/apps/web-shared/src/lib/models/ErrorResult.ts
@@ -0,0 +1,4 @@
+export interface ErrorResult {
+ title: string,
+ text: string
+}
diff --git a/apps/web-shared/src/lib/models/IInternalFetchRequest.ts b/apps/web-shared/src/lib/models/IInternalFetchRequest.ts
new file mode 100644
index 0000000..68505e2
--- /dev/null
+++ b/apps/web-shared/src/lib/models/IInternalFetchRequest.ts
@@ -0,0 +1,6 @@
+export interface IInternalFetchRequest {
+ url: string,
+ init?: RequestInit,
+ timeout?: number
+ retry_count?: number
+}
diff --git a/apps/web-shared/src/lib/models/IInternalFetchResponse.ts b/apps/web-shared/src/lib/models/IInternalFetchResponse.ts
new file mode 100644
index 0000000..6c91b35
--- /dev/null
+++ b/apps/web-shared/src/lib/models/IInternalFetchResponse.ts
@@ -0,0 +1,6 @@
+export interface IInternalFetchResponse {
+ ok: boolean,
+ status: number,
+ data: any,
+ http_response: Response
+}
diff --git a/apps/web-shared/src/lib/models/ISession.ts b/apps/web-shared/src/lib/models/ISession.ts
new file mode 100644
index 0000000..f7ed46b
--- /dev/null
+++ b/apps/web-shared/src/lib/models/ISession.ts
@@ -0,0 +1,7 @@
+export interface ISession {
+ profile: {
+ username: string,
+ id: string,
+ },
+ lastChecked: number,
+} \ No newline at end of file
diff --git a/apps/web-shared/src/lib/models/IValidationResult.ts b/apps/web-shared/src/lib/models/IValidationResult.ts
new file mode 100644
index 0000000..9a21b13
--- /dev/null
+++ b/apps/web-shared/src/lib/models/IValidationResult.ts
@@ -0,0 +1,31 @@
+export interface IValidationResult {
+ errors: Array<IValidationError>,
+ has_errors: Function,
+ add_error: Function,
+ remove_error: Function,
+}
+
+export interface IValidationError {
+ _id?: string,
+ title: string,
+ text?: string
+}
+
+export default class ValidationResult implements IValidationResult {
+ errors: IValidationError[]
+ has_errors(): boolean {
+ return this.errors?.length > 0;
+ }
+ add_error(prop: string, error: IValidationError): void {
+ if (!this.errors) this.errors = [];
+ error._id = prop;
+ this.errors.push(error);
+ }
+ remove_error(property: string): void {
+ const new_errors = [];
+ for (const error of this.errors) {
+ if (error._id != property) new_errors.push(error)
+ }
+ this.errors = new_errors;
+ }
+}
diff --git a/apps/web-shared/src/lib/models/LoginPayload.ts b/apps/web-shared/src/lib/models/LoginPayload.ts
new file mode 100644
index 0000000..ccd9bed
--- /dev/null
+++ b/apps/web-shared/src/lib/models/LoginPayload.ts
@@ -0,0 +1,4 @@
+export interface LoginPayload {
+ username: string,
+ password: string
+}
diff --git a/apps/web-shared/src/lib/models/TimeCategoryDto.ts b/apps/web-shared/src/lib/models/TimeCategoryDto.ts
new file mode 100644
index 0000000..875e8cb
--- /dev/null
+++ b/apps/web-shared/src/lib/models/TimeCategoryDto.ts
@@ -0,0 +1,9 @@
+import { Temporal } from "@js-temporal/polyfill";
+
+export interface TimeCategoryDto {
+ selected?: boolean;
+ id?: string,
+ modified_at?: Temporal.PlainDate,
+ name?: string,
+ color?: string
+}
diff --git a/apps/web-shared/src/lib/models/TimeEntryDto.ts b/apps/web-shared/src/lib/models/TimeEntryDto.ts
new file mode 100644
index 0000000..71fe7a3
--- /dev/null
+++ b/apps/web-shared/src/lib/models/TimeEntryDto.ts
@@ -0,0 +1,13 @@
+import type { TimeLabelDto } from "./TimeLabelDto";
+import type { TimeCategoryDto } from "./TimeCategoryDto";
+import { Temporal } from "@js-temporal/polyfill";
+
+export interface TimeEntryDto {
+ id: string,
+ modified_at?: Temporal.PlainDate,
+ start: string,
+ stop: string,
+ description: string,
+ labels?: Array<TimeLabelDto>,
+ category: TimeCategoryDto,
+}
diff --git a/apps/web-shared/src/lib/models/TimeEntryQuery.ts b/apps/web-shared/src/lib/models/TimeEntryQuery.ts
new file mode 100644
index 0000000..6681c79
--- /dev/null
+++ b/apps/web-shared/src/lib/models/TimeEntryQuery.ts
@@ -0,0 +1,27 @@
+import type { TimeCategoryDto } from "./TimeCategoryDto";
+import type { TimeLabelDto } from "./TimeLabelDto";
+import type { Temporal } from "@js-temporal/polyfill";
+
+export interface TimeEntryQuery {
+ duration: TimeEntryQueryDuration,
+ categories?: Array<TimeCategoryDto>,
+ labels?: Array<TimeLabelDto>,
+ dateRange?: TimeEntryQueryDateRange,
+ specificDate?: Temporal.PlainDateTime
+ page: number,
+ pageSize: number
+}
+
+export interface TimeEntryQueryDateRange {
+ from: Temporal.PlainDateTime,
+ to: Temporal.PlainDateTime
+}
+
+export enum TimeEntryQueryDuration {
+ TODAY = 0,
+ THIS_WEEK = 1,
+ THIS_MONTH = 2,
+ THIS_YEAR = 3,
+ SPECIFIC_DATE = 4,
+ DATE_RANGE = 5,
+}
diff --git a/apps/web-shared/src/lib/models/TimeLabelDto.ts b/apps/web-shared/src/lib/models/TimeLabelDto.ts
new file mode 100644
index 0000000..2b42d07
--- /dev/null
+++ b/apps/web-shared/src/lib/models/TimeLabelDto.ts
@@ -0,0 +1,8 @@
+import { Temporal } from "@js-temporal/polyfill";
+
+export interface TimeLabelDto {
+ id?: string,
+ modified_at?: Temporal.PlainDate,
+ name?: string,
+ color?: string
+}
diff --git a/apps/web-shared/src/lib/models/TimeQueryDto.ts b/apps/web-shared/src/lib/models/TimeQueryDto.ts
new file mode 100644
index 0000000..607c51e
--- /dev/null
+++ b/apps/web-shared/src/lib/models/TimeQueryDto.ts
@@ -0,0 +1,29 @@
+import type { TimeEntryDto } from "./TimeEntryDto";
+import ValidationResult, { IValidationResult } from "./IValidationResult";
+
+export interface ITimeQueryDto {
+ results: Array<TimeEntryDto>,
+ page: number,
+ pageSize: number,
+ totalRecords: number,
+ totalPageCount: number,
+ is_valid: Function
+}
+
+export class TimeQueryDto implements ITimeQueryDto {
+ results: TimeEntryDto[];
+ page: number;
+ pageSize: number;
+ totalRecords: number;
+ totalPageCount: number;
+
+ is_valid(): IValidationResult {
+ const result = new ValidationResult();
+ if (this.page < 0) {
+ result.add_error("page", {
+ title: "Page cannot be less than zero",
+ })
+ }
+ return result;
+ }
+}
diff --git a/apps/web-shared/src/lib/models/UnwrappedEntryDateTime.ts b/apps/web-shared/src/lib/models/UnwrappedEntryDateTime.ts
new file mode 100644
index 0000000..e6022d8
--- /dev/null
+++ b/apps/web-shared/src/lib/models/UnwrappedEntryDateTime.ts
@@ -0,0 +1,9 @@
+import {Temporal} from "@js-temporal/polyfill";
+
+export interface UnwrappedEntryDateTime {
+ start_date: Temporal.PlainDate,
+ stop_date: Temporal.PlainDate,
+ start_time: Temporal.PlainTime,
+ stop_time: Temporal.PlainTime,
+ duration: Temporal.Duration,
+}
diff --git a/apps/web-shared/src/lib/models/UpdateProfilePayload.ts b/apps/web-shared/src/lib/models/UpdateProfilePayload.ts
new file mode 100644
index 0000000..d2983ff
--- /dev/null
+++ b/apps/web-shared/src/lib/models/UpdateProfilePayload.ts
@@ -0,0 +1,4 @@
+export interface UpdateProfilePayload {
+ username?: string,
+ password?: string,
+}
diff --git a/apps/web-shared/src/lib/persistent-store.ts b/apps/web-shared/src/lib/persistent-store.ts
new file mode 100644
index 0000000..922f3ab
--- /dev/null
+++ b/apps/web-shared/src/lib/persistent-store.ts
@@ -0,0 +1,102 @@
+import { writable as _writable, readable as _readable, } from "svelte/store";
+import type { Writable, Readable, StartStopNotifier } from "svelte/store";
+
+enum StoreType {
+ SESSION = 0,
+ LOCAL = 1
+}
+
+interface StoreOptions {
+ store?: StoreType;
+}
+
+const default_store_options = {
+ store: StoreType.SESSION
+} as StoreOptions;
+
+interface WritableStore<T> {
+ name: string,
+ initialState: T,
+ options?: StoreOptions
+}
+
+interface ReadableStore<T> {
+ name: string,
+ initialState: T,
+ callback: StartStopNotifier<any>,
+ options?: StoreOptions
+}
+
+function get_store(type: StoreType): Storage {
+ switch (type) {
+ case StoreType.SESSION:
+ return window.sessionStorage;
+ case StoreType.LOCAL:
+ return window.localStorage;
+ }
+}
+
+function prepared_store_value(value: any): string {
+ try {
+ return JSON.stringify(value);
+ } catch (e) {
+ console.error(e);
+ return "__INVALID__";
+ }
+}
+
+function get_store_value<T>(options: WritableStore<T> | ReadableStore<T>): any {
+ try {
+ const storage = get_store(options.options.store);
+ const value = storage.getItem(options.name);
+ if (!value) return false;
+ return JSON.parse(value);
+ } catch (e) {
+ console.error(e);
+ return { __INVALID__: true };
+ }
+}
+
+function hydrate<T>(store: Writable<T>, options: WritableStore<T> | ReadableStore<T>): void {
+ const value = get_store_value<T>(options);
+ if (value && store.set) store.set(value);
+}
+
+function subscribe<T>(store: Writable<T> | Readable<T>, options: WritableStore<T> | ReadableStore<T>): void {
+ const storage = get_store(options.options.store);
+ if (!store.subscribe) return;
+ store.subscribe((state: any) => {
+ storage.setItem(options.name, prepared_store_value(state));
+ });
+}
+
+function writable_persistent<T>(options: WritableStore<T>): Writable<T> {
+ if (options.options === undefined) options.options = default_store_options;
+ console.log("Creating writable store with options: ", options);
+ const store = _writable<T>(options.initialState);
+ hydrate(store, options);
+ subscribe(store, options);
+ return store;
+}
+
+function readable_persistent<T>(options: ReadableStore<T>): Readable<T> {
+ if (options.options === undefined) options.options = default_store_options;
+ console.log("Creating readable store with options: ", options);
+ const store = _readable<T>(options.initialState, options.callback);
+ // hydrate(store, options);
+ subscribe(store, options);
+ return store;
+}
+
+export {
+ writable_persistent,
+ readable_persistent,
+ StoreType
+};
+
+export type {
+ WritableStore,
+ ReadableStore,
+ StoreOptions
+};
+
diff --git a/apps/web-shared/src/lib/session.ts b/apps/web-shared/src/lib/session.ts
new file mode 100644
index 0000000..4f40a17
--- /dev/null
+++ b/apps/web-shared/src/lib/session.ts
@@ -0,0 +1,62 @@
+import {Temporal} from "@js-temporal/polyfill";
+import {get_profile_for_active_check} from "./api/user";
+import {is_guid, session_storage_get_json, session_storage_set_json} from "./helpers";
+import {SECONDS_BETWEEN_SESSION_CHECK, StorageKeys} from "./configuration";
+import type {ISession} from "$shared/lib/models/ISession";
+
+export async function is_active(forceRefresh: boolean = false): Promise<boolean> {
+ const nowEpoch = Temporal.Now.instant().epochSeconds;
+ const data = session_storage_get_json(StorageKeys.session) as ISession;
+ const expiryEpoch = data?.lastChecked + SECONDS_BETWEEN_SESSION_CHECK;
+ const lastCheckIsStaleOrNone = !is_guid(data?.profile?.id) || (expiryEpoch < nowEpoch);
+ if (forceRefresh || lastCheckIsStaleOrNone) {
+ return await call_api();
+ } else {
+ const sessionIsValid = data.profile && is_guid(data.profile.id);
+ if (!sessionIsValid) {
+ clear_session_data();
+ console.log("Session data is not valid");
+ }
+ return sessionIsValid;
+ }
+}
+
+async function call_api(): Promise<boolean> {
+ console.log("Getting profile data while checking session state");
+ try {
+ const response = await get_profile_for_active_check();
+ if (response.ok) {
+ const userData = await response.data;
+ if (is_guid(userData.id) && userData.username) {
+ const session = {
+ profile: userData,
+ lastChecked: Temporal.Now.instant().epochSeconds
+ } as ISession;
+ session_storage_set_json(StorageKeys.session, session);
+ console.log("Successfully got profile data while checking session state");
+ return true;
+ } else {
+ console.error("Api returned invalid data while getting profile data");
+ clear_session_data();
+ return false;
+ }
+ } else {
+ console.error("Api returned unsuccessfully while getting profile data");
+ clear_session_data();
+ return false;
+ }
+ } catch (e) {
+ console.error(e);
+ clear_session_data();
+ return false;
+ }
+}
+
+export function clear_session_data() {
+ session_storage_set_json(StorageKeys.session, {});
+ console.log("Cleared session data.");
+}
+
+export function get_session_data(): ISession {
+ return session_storage_get_json(StorageKeys.session) as ISession;
+}
diff --git a/apps/web-shared/src/styles/_base.scss b/apps/web-shared/src/styles/_base.scss
new file mode 100644
index 0000000..414b440
--- /dev/null
+++ b/apps/web-shared/src/styles/_base.scss
@@ -0,0 +1,48 @@
+// --------------------------------
+
+// Basic Style - Essential CSS rules and utility classes
+
+// --------------------------------
+
+@forward 'base/breakpoints';
+@forward 'base/mixins';
+@forward 'base/grid-layout';
+
+@use 'base/reset';
+@use 'base/colors';
+@use 'base/spacing';
+@use 'base/shared-styles';
+@use 'base/typography';
+@use 'base/icons';
+@use 'base/buttons';
+@use 'base/forms';
+@use 'base/z-index';
+@use 'base/visibility';
+@use 'base/accessibility';
+@use 'base/util';
+
+pre {
+ font-family: monospace !important;
+}
+
+*:focus-visible {
+ outline: 1px auto;
+}
+
+.c-disabled {
+ cursor: not-allowed !important;
+ filter: opacity(.45);
+ pointer-events: none !important;
+
+ &.loading {
+ cursor: wait !important;
+ }
+}
+
+button.btn--state-b {
+ cursor: wait;
+}
+
+button.reset {
+ cursor: pointer !important;
+}
diff --git a/apps/web-shared/src/styles/base/_accessibility.scss b/apps/web-shared/src/styles/base/_accessibility.scss
new file mode 100644
index 0000000..9f71937
--- /dev/null
+++ b/apps/web-shared/src/styles/base/_accessibility.scss
@@ -0,0 +1,17 @@
+.sr-only { // content made available only to screen readers
+ position: absolute;
+ clip: rect(1px, 1px, 1px, 1px);
+ clip-path: inset(50%);
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ padding: 0;
+ border: 0;
+ white-space: nowrap;
+}
+
+.sr-only-focusable { // focusable, visually hidden element
+ &:not(:focus):not(:focus-within){
+ @extend .sr-only
+ }
+} \ No newline at end of file
diff --git a/apps/web-shared/src/styles/base/_breakpoints.scss b/apps/web-shared/src/styles/base/_breakpoints.scss
new file mode 100644
index 0000000..a7f1eda
--- /dev/null
+++ b/apps/web-shared/src/styles/base/_breakpoints.scss
@@ -0,0 +1,15 @@
+$breakpoints: (
+ xs: 32rem, // ~512px
+ sm: 48rem, // ~768px
+ md: 64rem, // ~1024px
+ lg: 80rem, // ~1280px
+ xl: 90rem // ~1440px
+) !default;
+
+@mixin breakpoint($breakpoint, $logic: false) {
+ @if( $logic ) {
+ @media #{$logic} and (min-width: map-get($map: $breakpoints, $key: $breakpoint)) { @content; }
+ } @else {
+ @media (min-width: map-get($map: $breakpoints, $key: $breakpoint)) { @content; }
+ }
+}
diff --git a/apps/web-shared/src/styles/base/_buttons.scss b/apps/web-shared/src/styles/base/_buttons.scss
new file mode 100644
index 0000000..2a7ff34
--- /dev/null
+++ b/apps/web-shared/src/styles/base/_buttons.scss
@@ -0,0 +1,24 @@
+// don't modify this file -> edit 📁 custom-style/_buttons.scss to create your custom buttons
+
+.btn { // basic button style
+ position: relative;
+ display: inline-flex;
+ justify-content: center;
+ align-items: center;
+ white-space: nowrap;
+ text-decoration: none;
+ font-size: var(--btn-font-size, 1em);
+ padding-top: var(--btn-padding-y, 0.5em);
+ padding-bottom: var(--btn-padding-y, 0.5em);
+ padding-left: var(--btn-padding-x, 0.75em);
+ padding-right: var(--btn-padding-x, 0.75em);
+ border-radius: var(--btn-radius, 0.25em);
+}
+
+// default size variations
+.btn--sm { font-size: var(--btn-font-size-sm, 0.8em); }
+.btn--md { font-size: var(--btn-font-size-md, 1.2em); }
+.btn--lg { font-size: var(--btn-font-size-lg, 1.4em); }
+
+// button with (only) icon
+.btn--icon { padding: var(--btn-padding-y, 0.5em); } \ No newline at end of file
diff --git a/apps/web-shared/src/styles/base/_colors.scss b/apps/web-shared/src/styles/base/_colors.scss
new file mode 100644
index 0000000..f061d9a
--- /dev/null
+++ b/apps/web-shared/src/styles/base/_colors.scss
@@ -0,0 +1,6 @@
+// don't modify this file -> edit 📁 custom-style/_colors.scss to create your color palette
+
+[data-theme] {
+ background-color: var(--color-bg, hsl(0, 0%, 100%));
+ color: var(--color-contrast-high, hsl(210, 7%, 21%));
+} \ No newline at end of file
diff --git a/apps/web-shared/src/styles/base/_forms.scss b/apps/web-shared/src/styles/base/_forms.scss
new file mode 100644
index 0000000..faffddd
--- /dev/null
+++ b/apps/web-shared/src/styles/base/_forms.scss
@@ -0,0 +1,22 @@
+// don't modify this file -> edit 📁 custom-style/_forms.scss to create your custom form elements
+
+.form-control {
+ font-size: var(--form-control-font-size, 1em);
+ padding-top: var(--form-control-padding-y, 0.5em);
+ padding-bottom: var(--form-control-padding-y, 0.5em);
+ padding-left: var(--form-control-padding-x, 0.75em);
+ padding-right: var(--form-control-padding-x, 0.75em);
+ border-radius: var(--form-control-radius, 0.25em);
+}
+
+.form-legend {
+ color: var(--color-contrast-higher, hsl(204, 28%, 7%));
+ line-height: var(--heading-line-height, 1.2);
+ font-size: var(--text-md, 1.125rem);
+ margin-bottom: var(--space-sm);
+}
+
+.form-label {
+ display: inline-block;
+ font-size: var(--text-sm, 0.75rem);
+}
diff --git a/apps/web-shared/src/styles/base/_grid-layout.scss b/apps/web-shared/src/styles/base/_grid-layout.scss
new file mode 100644
index 0000000..bd8b6c9
--- /dev/null
+++ b/apps/web-shared/src/styles/base/_grid-layout.scss
@@ -0,0 +1,261 @@
+@use 'mixins' as *;
+@use 'breakpoints' as *;
+
+// --------------------------------
+
+// Container - center content on x-axis
+
+// --------------------------------
+
+.container {
+ width: calc(100% - 2*var(--component-padding));
+ margin-left: auto;
+ margin-right: auto;
+}
+
+// --------------------------------
+
+// Grid System
+
+// --------------------------------
+
+$grid-columns: 12 !default;
+
+.grid, .flex, .inline-flex {
+ --gap: 0px;
+ --gap-x: var(--gap);
+ --gap-y: var(--gap);
+ gap: var(--gap-y) var(--gap-x);
+
+ > * {
+ --sub-gap: 0px;
+ --sub-gap-x: var(--sub-gap);
+ --sub-gap-y: var(--sub-gap);
+ }
+}
+
+.grid {
+ --grid-columns: 12;
+ display: flex;
+ flex-wrap: wrap;
+
+ > * {
+ flex-basis: 100%;
+ max-width: 100%;
+ min-width: 0;
+ }
+}
+
+/* #region (Safari < 14.1 fallback) */
+@media not all and (min-resolution:.001dpcm) {
+ @supports (not(translate: none)) {
+ .grid, .flex[class*="gap-"], .inline-flex[class*="gap-"] {
+ gap: 0; // reset
+ margin-bottom: calc(-1 * var(--gap-y));
+ margin-left: calc(-1 * var(--gap-x));
+
+ > * {
+ margin-bottom: var(--sub-gap-y);
+ }
+ }
+
+ .grid {
+ --offset: var(--gap-x);
+ --gap-modifier: 0;
+ --offset-modifier: 1;
+
+ > * {
+ margin-left: var(--offset);
+ }
+ }
+
+ .flex[class*="gap-"], .inline-flex[class*="gap-"] {
+ > * {
+ margin-left: var(--sub-gap-x);
+ }
+ }
+ }
+}
+/* #endregion */
+
+.gap-xxxxs { --gap-x: var(--space-xxxxs); --gap-y: var(--space-xxxxs); > * { --sub-gap-x: var(--space-xxxxs); --sub-gap-y: var(--space-xxxxs); }}
+.gap-xxxs { --gap-x: var(--space-xxxs); --gap-y: var(--space-xxxs); > * { --sub-gap-x: var(--space-xxxs); --sub-gap-y: var(--space-xxxs); }}
+.gap-xxs { --gap-x: var(--space-xxs); --gap-y: var(--space-xxs); > * { --sub-gap-x: var(--space-xxs); --sub-gap-y: var(--space-xxs); }}
+.gap-xs { --gap-x: var(--space-xs); --gap-y: var(--space-xs); > * { --sub-gap-x: var(--space-xs); --sub-gap-y: var(--space-xs); }}
+.gap-sm { --gap-x: var(--space-sm); --gap-y: var(--space-sm); > * { --sub-gap-x: var(--space-sm); --sub-gap-y: var(--space-sm); }}
+.gap-md { --gap-x: var(--space-md); --gap-y: var(--space-md); > * { --sub-gap-x: var(--space-md); --sub-gap-y: var(--space-md); }}
+.gap-lg { --gap-x: var(--space-lg); --gap-y: var(--space-lg); > * { --sub-gap-x: var(--space-lg); --sub-gap-y: var(--space-lg); }}
+.gap-xl { --gap-x: var(--space-xl); --gap-y: var(--space-xl); > * { --sub-gap-x: var(--space-xl); --sub-gap-y: var(--space-xl); }}
+.gap-xxl { --gap-x: var(--space-xxl); --gap-y: var(--space-xxl); > * { --sub-gap-x: var(--space-xxl); --sub-gap-y: var(--space-xxl); }}
+.gap-xxxl { --gap-x: var(--space-xxxl); --gap-y: var(--space-xxxl); > * { --sub-gap-x: var(--space-xxxl); --sub-gap-y: var(--space-xxxl); }}
+.gap-xxxxl { --gap-x: var(--space-xxxxl); --gap-y: var(--space-xxxxl); > * { --sub-gap-x: var(--space-xxxxl); --sub-gap-y: var(--space-xxxxl); }}
+.gap-0 { --gap-x: 0; --gap-y: 0; > * { --sub-gap-x: 0; --sub-gap-y: 0; }}
+
+.gap-x-xxxxs { --gap-x: var(--space-xxxxs); > * { --sub-gap-x: var(--space-xxxxs); }}
+.gap-x-xxxs { --gap-x: var(--space-xxxs); > * { --sub-gap-x: var(--space-xxxs); }}
+.gap-x-xxs { --gap-x: var(--space-xxs); > * { --sub-gap-x: var(--space-xxs); }}
+.gap-x-xs { --gap-x: var(--space-xs); > * { --sub-gap-x: var(--space-xs); }}
+.gap-x-sm { --gap-x: var(--space-sm); > * { --sub-gap-x: var(--space-sm); }}
+.gap-x-md { --gap-x: var(--space-md); > * { --sub-gap-x: var(--space-md); }}
+.gap-x-lg { --gap-x: var(--space-lg); > * { --sub-gap-x: var(--space-lg); }}
+.gap-x-xl { --gap-x: var(--space-xl); > * { --sub-gap-x: var(--space-xl); }}
+.gap-x-xxl { --gap-x: var(--space-xxl); > * { --sub-gap-x: var(--space-xxl); }}
+.gap-x-xxxl { --gap-x: var(--space-xxxl); > * { --sub-gap-x: var(--space-xxxl); }}
+.gap-x-xxxxl { --gap-x: var(--space-xxxxl); > * { --sub-gap-x: var(--space-xxxxl); }}
+.gap-x-0 { --gap-x: 0; > * { --sub-gap-x: 0; }}
+
+.gap-y-xxxxs { --gap-y: var(--space-xxxxs); > * { --sub-gap-y: var(--space-xxxxs); }}
+.gap-y-xxxs { --gap-y: var(--space-xxxs); > * { --sub-gap-y: var(--space-xxxs); }}
+.gap-y-xxs { --gap-y: var(--space-xxs); > * { --sub-gap-y: var(--space-xxs); }}
+.gap-y-xs { --gap-y: var(--space-xs); > * { --sub-gap-y: var(--space-xs); }}
+.gap-y-sm { --gap-y: var(--space-sm); > * { --sub-gap-y: var(--space-sm); }}
+.gap-y-md { --gap-y: var(--space-md); > * { --sub-gap-y: var(--space-md); }}
+.gap-y-lg { --gap-y: var(--space-lg); > * { --sub-gap-y: var(--space-lg); }}
+.gap-y-xl { --gap-y: var(--space-xl); > * { --sub-gap-y: var(--space-xl); }}
+.gap-y-xxl { --gap-y: var(--space-xxl); > * { --sub-gap-y: var(--space-xxl); }}
+.gap-y-xxxl { --gap-y: var(--space-xxxl); > * { --sub-gap-y: var(--space-xxxl); }}
+.gap-y-xxxxl { --gap-y: var(--space-xxxxl); > * { --sub-gap-y: var(--space-xxxxl); }}
+.gap-y-0 { --gap-y: 0; > * { --sub-gap-y: 0; }}
+
+$grid-col-class-list: ''; // list of col-{span} classes
+
+@for $i from 1 through $grid-columns {
+ $grid-col-class-list: $grid-col-class-list + ".col-#{$i}";
+ @if($i < $grid-columns) {
+ $grid-col-class-list: $grid-col-class-list + ', ';
+ }
+ .grid-col-#{$i} { --grid-columns: #{$i}; } // set number of grid columns
+ .col-#{$i} { --span: #{$i}; } // set grid item span
+}
+
+#{$grid-col-class-list} {
+ flex-basis: calc(((100% - (var(--grid-columns) - var(--gap-modifier, 1)) * var(--sub-gap-x)) * var(--span) / var(--grid-columns)) + (var(--span) - 1) * var(--sub-gap-x));
+ max-width: calc(((100% - (var(--grid-columns) - var(--gap-modifier, 1)) * var(--sub-gap-x)) * var(--span) / var(--grid-columns)) + (var(--span) - 1) * var(--sub-gap-x));
+}
+
+.col { // auto-expanding column
+ flex-grow: 1;
+ flex-basis: 0;
+ max-width: 100%;
+}
+
+.col-content { // column width depends on its content
+ flex-grow: 0;
+ flex-basis: initial;
+ max-width: initial;
+}
+
+// offset
+$grid-offset-class-list: ''; // list of offset-{span} classes
+
+@for $i from 1 through $grid-columns - 1 {
+ $grid-offset-class-list: $grid-offset-class-list + ".offset-#{$i}";
+ @if($i < $grid-columns) {
+ $grid-offset-class-list: $grid-offset-class-list + ', ';
+ }
+ .offset-#{$i} { --offset: #{$i}; }
+}
+
+#{$grid-offset-class-list} {
+ margin-left: calc(((100% - (var(--grid-columns) - var(--gap-modifier, 1)) * var(--sub-gap-x)) * var(--offset) / var(--grid-columns)) + (var(--offset) + var(--offset-modifier, 0)) * var(--sub-gap-x));
+}
+
+// responsive variations
+@each $breakpoint, $value in $breakpoints {
+ @include breakpoint(#{$breakpoint}) {
+ .gap-xxxxs\@#{$breakpoint} { --gap-x: var(--space-xxxxs); --gap-y: var(--space-xxxxs); > * { --sub-gap-x: var(--space-xxxxs); --sub-gap-y: var(--space-xxxxs); }}
+ .gap-xxxs\@#{$breakpoint} { --gap-x: var(--space-xxxs); --gap-y: var(--space-xxxs); > * { --sub-gap-x: var(--space-xxxs); --sub-gap-y: var(--space-xxxs); }}
+ .gap-xxs\@#{$breakpoint} { --gap-x: var(--space-xxs); --gap-y: var(--space-xxs); > * { --sub-gap-x: var(--space-xxs); --sub-gap-y: var(--space-xxs); }}
+ .gap-xs\@#{$breakpoint} { --gap-x: var(--space-xs); --gap-y: var(--space-xs); > * { --sub-gap-x: var(--space-xs); --sub-gap-y: var(--space-xs); }}
+ .gap-sm\@#{$breakpoint} { --gap-x: var(--space-sm); --gap-y: var(--space-sm); > * { --sub-gap-x: var(--space-sm); --sub-gap-y: var(--space-sm); }}
+ .gap-md\@#{$breakpoint} { --gap-x: var(--space-md); --gap-y: var(--space-md); > * { --sub-gap-x: var(--space-md); --sub-gap-y: var(--space-md); }}
+ .gap-lg\@#{$breakpoint} { --gap-x: var(--space-lg); --gap-y: var(--space-lg); > * { --sub-gap-x: var(--space-lg); --sub-gap-y: var(--space-lg); }}
+ .gap-xl\@#{$breakpoint} { --gap-x: var(--space-xl); --gap-y: var(--space-xl); > * { --sub-gap-x: var(--space-xl); --sub-gap-y: var(--space-xl); }}
+ .gap-xxl\@#{$breakpoint} { --gap-x: var(--space-xxl); --gap-y: var(--space-xxl); > * { --sub-gap-x: var(--space-xxl); --sub-gap-y: var(--space-xxl); }}
+ .gap-xxxl\@#{$breakpoint} { --gap-x: var(--space-xxxl); --gap-y: var(--space-xxxl); > * { --sub-gap-x: var(--space-xxxl); --sub-gap-y: var(--space-xxxl); }}
+ .gap-xxxxl\@#{$breakpoint} { --gap-x: var(--space-xxxxl); --gap-y: var(--space-xxxxl); > * { --sub-gap-x: var(--space-xxxxl); --sub-gap-y: var(--space-xxxxl); }}
+ .gap-0\@#{$breakpoint} { --gap-x: 0; --gap-y: 0; > * { --sub-gap-x: 0; --sub-gap-y: 0; }}
+
+ .gap-x-xxxxs\@#{$breakpoint} { --gap-x: var(--space-xxxxs); > * { --sub-gap-x: var(--space-xxxxs); }}
+ .gap-x-xxxs\@#{$breakpoint} { --gap-x: var(--space-xxxs); > * { --sub-gap-x: var(--space-xxxs); }}
+ .gap-x-xxs\@#{$breakpoint} { --gap-x: var(--space-xxs); > * { --sub-gap-x: var(--space-xxs); }}
+ .gap-x-xs\@#{$breakpoint} { --gap-x: var(--space-xs); > * { --sub-gap-x: var(--space-xs); }}
+ .gap-x-sm\@#{$breakpoint} { --gap-x: var(--space-sm); > * { --sub-gap-x: var(--space-sm); }}
+ .gap-x-md\@#{$breakpoint} { --gap-x: var(--space-md); > * { --sub-gap-x: var(--space-md); }}
+ .gap-x-lg\@#{$breakpoint} { --gap-x: var(--space-lg); > * { --sub-gap-x: var(--space-lg); }}
+ .gap-x-xl\@#{$breakpoint} { --gap-x: var(--space-xl); > * { --sub-gap-x: var(--space-xl); }}
+ .gap-x-xxl\@#{$breakpoint} { --gap-x: var(--space-xxl); > * { --sub-gap-x: var(--space-xxl); }}
+ .gap-x-xxxl\@#{$breakpoint} { --gap-x: var(--space-xxxl); > * { --sub-gap-x: var(--space-xxxl); }}
+ .gap-x-xxxxl\@#{$breakpoint} { --gap-x: var(--space-xxxxl); > * { --sub-gap-x: var(--space-xxxxl); }}
+ .gap-x-0\@#{$breakpoint} { --gap-x: 0; > * { --sub-gap-x: 0; }}
+
+ .gap-y-xxxxs\@#{$breakpoint} { --gap-y: var(--space-xxxxs); > * { --sub-gap-y: var(--space-xxxxs); }}
+ .gap-y-xxxs\@#{$breakpoint} { --gap-y: var(--space-xxxs); > * { --sub-gap-y: var(--space-xxxs); }}
+ .gap-y-xxs\@#{$breakpoint} { --gap-y: var(--space-xxs); > * { --sub-gap-y: var(--space-xxs); }}
+ .gap-y-xs\@#{$breakpoint} { --gap-y: var(--space-xs); > * { --sub-gap-y: var(--space-xs); }}
+ .gap-y-sm\@#{$breakpoint} { --gap-y: var(--space-sm); > * { --sub-gap-y: var(--space-sm); }}
+ .gap-y-md\@#{$breakpoint} { --gap-y: var(--space-md); > * { --sub-gap-y: var(--space-md); }}
+ .gap-y-lg\@#{$breakpoint} { --gap-y: var(--space-lg); > * { --sub-gap-y: var(--space-lg); }}
+ .gap-y-xl\@#{$breakpoint} { --gap-y: var(--space-xl); > * { --sub-gap-y: var(--space-xl); }}
+ .gap-y-xxl\@#{$breakpoint} { --gap-y: var(--space-xxl); > * { --sub-gap-y: var(--space-xxl); }}
+ .gap-y-xxxl\@#{$breakpoint} { --gap-y: var(--space-xxxl); > * { --sub-gap-y: var(--space-xxxl); }}
+ .gap-y-xxxxl\@#{$breakpoint} { --gap-y: var(--space-xxxxl); > * { --sub-gap-y: var(--space-xxxxl); }}
+ .gap-y-0\@#{$breakpoint} { --gap-y: 0; > * { --sub-gap-y: 0; }}
+
+ $grid-col-class-list: ''; // list of col-{span} classes
+
+ @for $i from 1 through $grid-columns {
+ $grid-col-class-list: $grid-col-class-list + ".col-#{$i}\\@#{$breakpoint}";
+ @if($i < $grid-columns) {
+ $grid-col-class-list: $grid-col-class-list + ', ';
+ }
+ .grid-col-#{$i}\@#{$breakpoint} { --grid-columns: #{$i}; } // set number of grid columns
+ .col-#{$i}\@#{$breakpoint} { --span: #{$i}; } // set grid item span
+ }
+
+ #{$grid-col-class-list} {
+ flex-basis: calc(((100% - (var(--grid-columns) - var(--gap-modifier, 1)) * var(--sub-gap-x)) * var(--span) / var(--grid-columns)) + (var(--span) - 1) * var(--sub-gap-x));
+ max-width: calc(((100% - (var(--grid-columns) - var(--gap-modifier, 1)) * var(--sub-gap-x)) * var(--span) / var(--grid-columns)) + (var(--span) - 1) * var(--sub-gap-x));
+ }
+
+ .col\@#{$breakpoint} { // auto-expanding column
+ flex-grow: 1;
+ flex-basis: 0;
+ max-width: 100%;
+ }
+
+ .col-content\@#{$breakpoint} { // column width depends on its content
+ flex-grow: 0;
+ flex-basis: initial;
+ max-width: initial;
+ }
+
+ // offset
+ $grid-offset-class-list: ''; // list of offset-{span} classes
+
+ @for $i from 1 through $grid-columns - 1 {
+ $grid-offset-class-list: $grid-offset-class-list + ".offset-#{$i}\\@#{$breakpoint}";
+ @if($i < $grid-columns) {
+ $grid-offset-class-list: $grid-offset-class-list + ', ';
+ }
+ .offset-#{$i}\@#{$breakpoint} { --offset: #{$i}; }
+ }
+
+ #{$grid-offset-class-list} {
+ margin-left: calc(((100% - (var(--grid-columns) - var(--gap-modifier, 1)) * var(--sub-gap-x)) * var(--offset) / var(--grid-columns)) + (var(--offset) + var(--offset-modifier, 0)) * var(--sub-gap-x));
+ }
+
+ .offset-0\@#{$breakpoint} {
+ margin-left: 0;
+ }
+
+ @media not all and (min-resolution:.001dpcm) {
+ @supports (not(translate: none)) {
+ .offset-0\@#{$breakpoint} {
+ margin-left: var(--gap-x);
+ }
+ }
+ }
+ }
+}
+
diff --git a/apps/web-shared/src/styles/base/_icons.scss b/apps/web-shared/src/styles/base/_icons.scss
new file mode 100644
index 0000000..1674a7c
--- /dev/null
+++ b/apps/web-shared/src/styles/base/_icons.scss
@@ -0,0 +1,62 @@
+// don't modify this file -> edit 📁 custom-style/_icons.scss to set your custom icons style
+
+:root {
+ // default icon sizes
+ --icon-xxxs: 8px;
+ --icon-xxs: 12px;
+ --icon-xs: 16px;
+ --icon-sm: 24px;
+ --icon-md: 32px;
+ --icon-lg: 48px;
+ --icon-xl: 64px;
+ --icon-xxl: 96px;
+ --icon-xxxl: 128px;
+}
+
+.icon {
+ --size: 1em;
+ height: var(--size);
+ width: var(--size);
+ display: inline-block;
+ color: inherit;
+ fill: currentColor;
+ line-height: 1;
+ flex-shrink: 0;
+ max-width: initial;
+}
+
+// icon size
+.icon--xxxs { --size: var(--icon-xxxs); }
+.icon--xxs { --size: var(--icon-xxs); }
+.icon--xs { --size: var(--icon-xs); }
+.icon--sm { --size: var(--icon-sm); }
+.icon--md { --size: var(--icon-md); }
+.icon--lg { --size: var(--icon-lg); }
+.icon--xl { --size: var(--icon-xl); }
+.icon--xxl { --size: var(--icon-xxl); }
+.icon--xxxl { --size: var(--icon-xxxl); }
+
+.icon--is-spinning { // rotate the icon infinitely
+ animation: icon-spin 1s infinite linear;
+}
+
+@keyframes icon-spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+// --------------------------------
+
+// SVG <symbol>
+
+// --------------------------------
+
+// enable icon color corrections
+.icon use {
+ color: inherit;
+ fill: currentColor;
+} \ No newline at end of file
diff --git a/apps/web-shared/src/styles/base/_mixins.scss b/apps/web-shared/src/styles/base/_mixins.scss
new file mode 100644
index 0000000..8fe82f6
--- /dev/null
+++ b/apps/web-shared/src/styles/base/_mixins.scss
@@ -0,0 +1,151 @@
+@use 'sass:math';
+
+// --------------------------------
+
+// Typography
+
+// --------------------------------
+
+// edit font rendering -> tip: use for light text on dark backgrounds
+@mixin fontSmooth {
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+// crop top space on text elements - caused by line height
+@mixin lhCrop($line-height, $capital-letter: 1) {
+ &::before {
+ content: '';
+ display: block;
+ height: 0;
+ width: 0;
+ margin-top: calc((#{$capital-letter} - #{$line-height}) * 0.5em);
+ }
+}
+
+// edit text unit on a component level
+@mixin textUnit($text-unit) {
+ --text-unit: #{$text-unit};
+ font-size: var(--text-unit);
+}
+
+// --------------------------------
+
+// Spacing
+
+// --------------------------------
+
+// edit space unit on a component level
+@mixin spaceUnit($space-unit) {
+ --space-unit: #{$space-unit};
+}
+
+// --------------------------------
+
+// Reset
+
+// --------------------------------
+
+// reset user agent style
+@mixin reset {
+ background-color: transparent;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ color: inherit;
+ line-height: inherit;
+ appearance: none;
+}
+
+// --------------------------------
+
+// Colors
+
+// --------------------------------
+
+// define HSL color variable
+@mixin defineColorHSL($color, $hue, $saturation, $lightness) {
+ #{$color}: unquote("hsl(#{$hue}, #{$saturation}, #{$lightness})");#{$color}-h: #{$hue};#{$color}-s: #{$saturation};#{$color}-l: #{$lightness};
+}
+
+// return color with different opacity value
+@function alpha($color, $opacity) {
+ $color: str-replace($color, 'var(');
+ $color: str-replace($color, ')');
+ $color-h: var(#{$color+'-h'});
+ $color-s: var(#{$color+'-s'});
+ $color-l: var(#{$color+'-l'});
+ @return hsla($color-h, $color-s, $color-l, $opacity);
+}
+
+// return color with different lightness value
+@function lightness($color, $lightnessMultiplier) {
+ $color: str-replace($color, 'var(');
+ $color: str-replace($color, ')');
+ $color-h: var(#{$color+'-h'});
+ $color-s: var(#{$color+'-s'});
+ $color-l: var(#{$color+'-l'});
+ @return hsl($color-h, $color-s, calc(#{$color-l} * #{$lightnessMultiplier}));
+}
+
+// modify color HSLA values
+@function adjustHSLA($color, $hueMultiplier: 1, $saturationMultiplier: 1, $lightnessMultiplier: 1, $opacity: 1) {
+ $color: str-replace($color, 'var(');
+ $color: str-replace($color, ')');
+ $color-h: var(#{$color+'-h'});
+ $color-s: var(#{$color+'-s'});
+ $color-l: var(#{$color+'-l'});
+ @return hsla(calc(#{$color-h} * #{$hueMultiplier}), calc(#{$color-s} * #{$saturationMultiplier}), calc(#{$color-l} * #{$lightnessMultiplier}), $opacity);
+}
+
+// replace substring with another string
+// credits: https://css-tricks.com/snippets/sass/str-replace-function/
+@function str-replace($string, $search, $replace: '') {
+ $index: str-index($string, $search);
+ @if $index {
+ @return str-slice($string, 1, $index - 1) + $replace + str-replace(str-slice($string, $index + str-length($search)), $search, $replace);
+ }
+ @return $string;
+}
+
+// --------------------------------
+
+// Accessibility
+
+// --------------------------------
+
+// hide - content made available only to screen readers
+@mixin srHide {
+ position: absolute;
+ clip: rect(1px, 1px, 1px, 1px);
+ clip-path: inset(50%);
+}
+
+// show
+@mixin srShow {
+ position: static;
+ clip: auto;
+ clip-path: none;
+}
+
+// --------------------------------
+
+// CSS Triangle
+
+// --------------------------------
+
+@mixin triangle ($direction: up, $width: 12px, $color: red) {
+ width: 0;
+ height: 0;
+ border: $width solid transparent;
+
+ @if( $direction == left ) {
+ border-right-color: $color;
+ } @else if( $direction == right ) {
+ border-left-color: $color;
+ } @else if( $direction == down ) {
+ border-top-color: $color;
+ } @else {
+ border-bottom-color: $color;
+ }
+}
diff --git a/apps/web-shared/src/styles/base/_reset.scss b/apps/web-shared/src/styles/base/_reset.scss
new file mode 100644
index 0000000..5ba4534
--- /dev/null
+++ b/apps/web-shared/src/styles/base/_reset.scss
@@ -0,0 +1,83 @@
+*, *::after, *::before {
+ box-sizing: inherit;
+}
+
+* {
+ font: inherit;
+}
+
+html, body, div, span, applet, object, iframe,
+h1, h2, h3, h4, h5, h6, p, blockquote, pre,
+a, abbr, acronym, address, big, cite, code,
+del, dfn, em, img, ins, kbd, q, s, samp,
+small, strike, strong, sub, sup, tt, var,
+b, u, i, center,
+dl, dt, dd, ol, ul, li,
+fieldset, form, label, legend,
+table, caption, tbody, tfoot, thead, tr, th, td,
+article, aside, canvas, details, embed,
+figure, figcaption, footer, header, hgroup,
+menu, nav, output, ruby, section, summary,
+time, mark, audio, video, hr {
+ margin: 0;
+ padding: 0;
+ border: 0;
+}
+
+html {
+ box-sizing: border-box;
+}
+
+body {
+ background-color: var(--color-bg, white);
+}
+
+article, aside, details, figcaption, figure,
+footer, header, hgroup, menu, nav, section, main, form legend {
+ display: block;
+}
+
+ol, ul, menu {
+ list-style: none;
+}
+
+blockquote, q {
+ quotes: none;
+}
+
+button, input, textarea, select {
+ margin: 0;
+}
+
+.btn, .form-control, .link, .reset { // reset style of buttons + form controls
+ background-color: transparent;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ color: inherit;
+ line-height: inherit;
+ appearance: none;
+}
+
+select.form-control::-ms-expand {
+ display: none; // hide Select default icon on IE
+}
+
+textarea {
+ resize: vertical;
+ overflow: auto;
+ vertical-align: top;
+}
+
+input::-ms-clear {
+ display: none; // hide X icon in IE and Edge
+}
+
+table {
+ border-collapse: collapse;
+ border-spacing: 0;
+}
+
+img, video, svg {
+ max-width: 100%;
+} \ No newline at end of file
diff --git a/apps/web-shared/src/styles/base/_shared-styles.scss b/apps/web-shared/src/styles/base/_shared-styles.scss
new file mode 100644
index 0000000..dae02fe
--- /dev/null
+++ b/apps/web-shared/src/styles/base/_shared-styles.scss
@@ -0,0 +1,34 @@
+// don't modify this file -> edit 📁 custom-style/_shared-style.scss to set your custom shared styles
+
+:root {
+ // radius
+ --radius-sm: calc(var(--radius, 0.25em)/2);
+ --radius-md: var(--radius, 0.25em);
+ --radius-lg: calc(var(--radius, 0.25em)*2);
+
+ // box shadow
+ --shadow-xs: 0 0.1px 0.3px rgba(0, 0, 0, 0.06),
+ 0 1px 2px rgba(0, 0, 0, 0.12);
+ --shadow-sm: 0 0.3px 0.4px rgba(0, 0, 0, 0.025),
+ 0 0.9px 1.5px rgba(0, 0, 0, 0.05),
+ 0 3.5px 6px rgba(0, 0, 0, 0.1);
+ --shadow-md: 0 0.9px 1.5px rgba(0, 0, 0, 0.03),
+ 0 3.1px 5.5px rgba(0, 0, 0, 0.08),
+ 0 14px 25px rgba(0, 0, 0, 0.12);
+ --shadow-lg: 0 1.2px 1.9px -1px rgba(0, 0, 0, 0.014),
+ 0 3.3px 5.3px -1px rgba(0, 0, 0, 0.038),
+ 0 8.5px 12.7px -1px rgba(0, 0, 0, 0.085),
+ 0 30px 42px -1px rgba(0, 0, 0, 0.15);
+ --shadow-xl: 0 1.5px 2.1px -6px rgba(0, 0, 0, 0.012),
+ 0 3.6px 5.2px -6px rgba(0, 0, 0, 0.035),
+ 0 7.3px 10.6px -6px rgba(0, 0, 0, 0.07),
+ 0 16.2px 21.9px -6px rgba(0, 0, 0, 0.117),
+ 0 46px 60px -6px rgba(0, 0, 0, 0.2);
+
+ // timing functions
+ // credits: https://github.com/ai/easings.net
+ --ease-in-out: cubic-bezier(0.645, 0.045, 0.355, 1);
+ --ease-in: cubic-bezier(0.55, 0.055, 0.675, 0.19);
+ --ease-out: cubic-bezier(0.215, 0.61, 0.355, 1);
+ --ease-out-back: cubic-bezier(0.34, 1.56, 0.64, 1);
+} \ No newline at end of file
diff --git a/apps/web-shared/src/styles/base/_spacing.scss b/apps/web-shared/src/styles/base/_spacing.scss
new file mode 100644
index 0000000..24e6645
--- /dev/null
+++ b/apps/web-shared/src/styles/base/_spacing.scss
@@ -0,0 +1,20 @@
+// don't modify this file -> edit 📁 custom-style/_spacing.scss to set your custom spacing scale
+
+:root {
+ --space-unit: 1rem;
+}
+
+:root, * {
+ --space-xxxxs: calc(0.125 * var(--space-unit));
+ --space-xxxs: calc(0.25 * var(--space-unit));
+ --space-xxs: calc(0.375 * var(--space-unit));
+ --space-xs: calc(0.5 * var(--space-unit));
+ --space-sm: calc(0.75 * var(--space-unit));
+ --space-md: calc(1.25 * var(--space-unit));
+ --space-lg: calc(2 * var(--space-unit));
+ --space-xl: calc(3.25 * var(--space-unit));
+ --space-xxl: calc(5.25 * var(--space-unit));
+ --space-xxxl: calc(8.5 * var(--space-unit));
+ --space-xxxxl: calc(13.75 * var(--space-unit));
+ --component-padding: var(--space-md);
+}
diff --git a/apps/web-shared/src/styles/base/_typography.scss b/apps/web-shared/src/styles/base/_typography.scss
new file mode 100644
index 0000000..85b974a
--- /dev/null
+++ b/apps/web-shared/src/styles/base/_typography.scss
@@ -0,0 +1,185 @@
+// don't modify this file -> edit 📁 custom-style/_typography.scss to set your custom typography
+
+@use 'breakpoints' as *;
+
+:root {
+ --heading-line-height: 1.2;
+ --body-line-height: 1.4;
+}
+
+body {
+ font-size: var(--text-base-size, 1rem);
+ font-family: var(--font-primary, sans-serif);
+ color: var(--color-contrast-high, hsl(210, 7%, 21%));
+ font-weight: var(--body-font-weight, normal);
+}
+
+h1, h2, h3, h4 {
+ color: var(--color-contrast-higher, hsl(204, 28%, 7%));
+ line-height: var(--heading-line-height, 1.2);
+ font-weight: var(--heading-font-weight, 700);
+}
+
+h1 {
+ font-size: var(--text-xxl, 2rem);
+}
+
+h2 {
+ font-size: var(--text-xl, 1.75rem);
+}
+
+h3 {
+ font-size: var(--text-lg, 1.375rem);
+}
+
+h4 {
+ font-size: var(--text-md, 1.125rem);
+}
+
+small {
+ font-size: var(--text-sm, 0.75rem);
+}
+
+// --------------------------------
+
+// Inline Text
+
+// --------------------------------
+
+a, .link {
+ color: var(--color-primary, hsl(250, 84%, 54%));
+ text-decoration: underline;
+}
+
+strong {
+ font-weight: bold;
+}
+
+s {
+ text-decoration: line-through;
+}
+
+u {
+ text-decoration: underline;
+}
+
+// --------------------------------
+
+// Text Component - Class used to stylize text blocks
+
+// --------------------------------
+
+.text-component {
+ h1, h2, h3, h4 {
+ line-height: calc(var(--heading-line-height) * var(--line-height-multiplier, 1));
+ margin-bottom: calc(var(--space-unit) * 0.3125 * var(--text-space-y-multiplier, 1));
+ }
+
+ h2, h3, h4 {
+ margin-top: calc(var(--space-unit) * 0.9375 * var(--text-space-y-multiplier, 1));
+ }
+
+ p, blockquote, ul li, ol li {
+ line-height: calc(var(--body-line-height) * var(--line-height-multiplier, 1));
+ }
+
+ ul, ol, p, blockquote, .text-component__block {
+ margin-bottom: calc(var(--space-unit) * 0.9375 * var(--text-space-y-multiplier, 1));
+ }
+
+ ul, ol {
+ list-style-position: inside;
+
+ ul, ol {
+ padding-left: 1em;
+ margin-bottom: 0;
+ }
+ }
+
+ ul {
+ list-style-type: disc;
+ }
+
+ ol {
+ list-style-type: decimal;
+ }
+
+ img {
+ display: block;
+ margin: 0 auto;
+ }
+
+ figcaption {
+ text-align: center;
+ margin-top: calc(var(--space-unit) * 0.5);
+ }
+
+ em {
+ font-style: italic;
+ }
+
+ hr {
+ margin-top: calc(var(--space-unit) * 1.875 * var(--text-space-y-multiplier, 1));
+ margin-bottom: calc(var(--space-unit) * 1.875 * var(--text-space-y-multiplier, 1));
+ margin-left: auto;
+ margin-right: auto;
+ }
+
+ > *:first-child {
+ margin-top: 0;
+ }
+
+ > *:last-child {
+ margin-bottom: 0;
+ }
+}
+
+// text block container
+.text-component__block--full-width {
+ width: 100vw;
+ margin-left: calc(50% - 50vw);
+}
+
+@include breakpoint(sm) {
+ .text-component__block--left,
+ .text-component__block--right {
+ width: 45%;
+
+ img {
+ width: 100%;
+ }
+ }
+
+ .text-component__block--left {
+ float: left;
+ margin-right: calc(var(--space-unit) * 0.9375 * var(--text-space-y-multiplier, 1));
+ }
+
+ .text-component__block--right {
+ float: right;
+ margin-left: calc(var(--space-unit) * 0.9375 * var(--text-space-y-multiplier, 1));
+ }
+}
+
+// outset content
+@include breakpoint(xl) {
+ .text-component__block--outset {
+ width: calc(100% + 10.5 * var(--space-unit));
+
+ img {
+ width: 100%;
+ }
+ }
+
+ .text-component__block--outset:not(.text-component__block--right) {
+ margin-left: calc(-5.25 * var(--space-unit));
+ }
+
+ .text-component__block--left, .text-component__block--right {
+ width: 50%;
+ }
+
+ .text-component__block--right.text-component__block--outset {
+ margin-right: calc(-5.25 * var(--space-unit));
+ }
+} \ No newline at end of file
diff --git a/apps/web-shared/src/styles/base/_util.scss b/apps/web-shared/src/styles/base/_util.scss
new file mode 100644
index 0000000..d688e1c
--- /dev/null
+++ b/apps/web-shared/src/styles/base/_util.scss
@@ -0,0 +1,1738 @@
+@use 'mixins' as *;
+@use 'breakpoints' as *;
+
+// --------------------------------
+
+// Flexbox
+
+// --------------------------------
+
+.flex { display: flex; }
+.inline-flex { display: inline-flex; }
+.flex-wrap { flex-wrap: wrap; }
+.flex-nowrap { flex-wrap: nowrap; }
+.flex-column { flex-direction: column; }
+.flex-column-reverse { flex-direction: column-reverse; }
+.flex-row { flex-direction: row; }
+.flex-row-reverse { flex-direction: row-reverse; }
+.flex-center { justify-content: center; align-items: center; }
+
+
+// flex items
+.flex-grow { flex-grow: 1; }
+.flex-grow-0 { flex-grow: 0; }
+.flex-shrink { flex-shrink: 1; }
+.flex-shrink-0 { flex-shrink: 0; }
+.flex-basis-0 { flex-basis: 0; }
+
+// --------------------------------
+
+// Justify Content
+
+// --------------------------------
+
+.justify-start { justify-content: flex-start; }
+.justify-end { justify-content: flex-end; }
+.justify-center { justify-content: center; }
+.justify-between { justify-content: space-between; }
+
+// --------------------------------
+
+// Align Items
+
+// --------------------------------
+
+.items-center { align-items: center; }
+.items-start { align-items: flex-start; }
+.items-end { align-items: flex-end; }
+.items-baseline { align-items: baseline; }
+
+// --------------------------------
+
+// Order
+
+// --------------------------------
+
+.order-1 { order: 1; }
+.order-2 { order: 2; }
+.order-3 { order: 3; }
+
+// --------------------------------
+
+// Aspect Ratio
+
+// --------------------------------
+
+[class^="aspect-ratio"], [class*=" aspect-ratio"] {
+ --aspect-ratio: calc(16/9);
+ position: relative;
+ height: 0;
+ padding-bottom: calc(100%/(var(--aspect-ratio)));
+
+ > * {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+
+ &:not(iframe) {
+ object-fit: cover;
+ }
+ }
+}
+
+.aspect-ratio-16\:9 { --aspect-ratio: calc(16/9); }
+.aspect-ratio-3\:2 { --aspect-ratio: calc(3/2); }
+.aspect-ratio-4\:3 { --aspect-ratio: calc(4/3); }
+.aspect-ratio-5\:4 { --aspect-ratio: calc(5/4); }
+.aspect-ratio-1\:1 { --aspect-ratio: calc(1/1); }
+.aspect-ratio-4\:5 { --aspect-ratio: calc(4/5); }
+.aspect-ratio-3\:4 { --aspect-ratio: calc(3/4); }
+.aspect-ratio-2\:3 { --aspect-ratio: calc(2/3); }
+.aspect-ratio-9\:16 { --aspect-ratio: calc(9/16); }
+
+// --------------------------------
+
+// Display
+
+// --------------------------------
+
+.block { display: block; }
+.inline-block { display: inline-block; }
+.inline { display: inline; }
+.contents { display: contents; }
+.hide { display: none; }
+
+// --------------------------------
+
+// Space unit
+
+// --------------------------------
+
+.space-unit-rem { --space-unit: 1rem; }
+.space-unit-em { --space-unit: 1em; }
+.space-unit-px { --space-unit: 16px; }
+
+// --------------------------------
+
+// Margin
+
+// --------------------------------
+
+.margin-xxxxs { margin: var(--space-xxxxs); }
+.margin-xxxs { margin: var(--space-xxxs); }
+.margin-xxs { margin: var(--space-xxs); }
+.margin-xs { margin: var(--space-xs); }
+.margin-sm { margin: var(--space-sm); }
+.margin-md { margin: var(--space-md); }
+.margin-lg { margin: var(--space-lg); }
+.margin-xl { margin: var(--space-xl); }
+.margin-xxl { margin: var(--space-xxl); }
+.margin-xxxl { margin: var(--space-xxxl); }
+.margin-xxxxl { margin: var(--space-xxxxl); }
+.margin-auto { margin: auto; }
+.margin-0 { margin: 0; }
+
+.margin-top-xxxxs { margin-top: var(--space-xxxxs); }
+.margin-top-xxxs { margin-top: var(--space-xxxs); }
+.margin-top-xxs { margin-top: var(--space-xxs); }
+.margin-top-xs { margin-top: var(--space-xs); }
+.margin-top-sm { margin-top: var(--space-sm); }
+.margin-top-md { margin-top: var(--space-md); }
+.margin-top-lg { margin-top: var(--space-lg); }
+.margin-top-xl { margin-top: var(--space-xl); }
+.margin-top-xxl { margin-top: var(--space-xxl); }
+.margin-top-xxxl { margin-top: var(--space-xxxl); }
+.margin-top-xxxxl { margin-top: var(--space-xxxxl); }
+.margin-top-auto { margin-top: auto; }
+.margin-top-0 { margin-top: 0; }
+
+.margin-bottom-xxxxs { margin-bottom: var(--space-xxxxs); }
+.margin-bottom-xxxs { margin-bottom: var(--space-xxxs); }
+.margin-bottom-xxs { margin-bottom: var(--space-xxs); }
+.margin-bottom-xs { margin-bottom: var(--space-xs); }
+.margin-bottom-sm { margin-bottom: var(--space-sm); }
+.margin-bottom-md { margin-bottom: var(--space-md); }
+.margin-bottom-lg { margin-bottom: var(--space-lg); }
+.margin-bottom-xl { margin-bottom: var(--space-xl); }
+.margin-bottom-xxl { margin-bottom: var(--space-xxl); }
+.margin-bottom-xxxl { margin-bottom: var(--space-xxxl); }
+.margin-bottom-xxxxl { margin-bottom: var(--space-xxxxl); }
+.margin-bottom-auto { margin-bottom: auto; }
+.margin-bottom-0 { margin-bottom: 0; }
+
+.margin-right-xxxxs { margin-right: var(--space-xxxxs); }
+.margin-right-xxxs { margin-right: var(--space-xxxs); }
+.margin-right-xxs { margin-right: var(--space-xxs); }
+.margin-right-xs { margin-right: var(--space-xs); }
+.margin-right-sm { margin-right: var(--space-sm); }
+.margin-right-md { margin-right: var(--space-md); }
+.margin-right-lg { margin-right: var(--space-lg); }
+.margin-right-xl { margin-right: var(--space-xl); }
+.margin-right-xxl { margin-right: var(--space-xxl); }
+.margin-right-xxxl { margin-right: var(--space-xxxl); }
+.margin-right-xxxxl { margin-right: var(--space-xxxxl); }
+.margin-right-auto { margin-right: auto; }
+.margin-right-0 { margin-right: 0; }
+
+.margin-left-xxxxs { margin-left: var(--space-xxxxs); }
+.margin-left-xxxs { margin-left: var(--space-xxxs); }
+.margin-left-xxs { margin-left: var(--space-xxs); }
+.margin-left-xs { margin-left: var(--space-xs); }
+.margin-left-sm { margin-left: var(--space-sm); }
+.margin-left-md { margin-left: var(--space-md); }
+.margin-left-lg { margin-left: var(--space-lg); }
+.margin-left-xl { margin-left: var(--space-xl); }
+.margin-left-xxl { margin-left: var(--space-xxl); }
+.margin-left-xxxl { margin-left: var(--space-xxxl); }
+.margin-left-xxxxl { margin-left: var(--space-xxxxl); }
+.margin-left-auto { margin-left: auto; }
+.margin-left-0 { margin-left: 0; }
+
+.margin-x-xxxxs { margin-left: var(--space-xxxxs); margin-right: var(--space-xxxxs); }
+.margin-x-xxxs { margin-left: var(--space-xxxs); margin-right: var(--space-xxxs); }
+.margin-x-xxs { margin-left: var(--space-xxs); margin-right: var(--space-xxs); }
+.margin-x-xs { margin-left: var(--space-xs); margin-right: var(--space-xs); }
+.margin-x-sm { margin-left: var(--space-sm); margin-right: var(--space-sm); }
+.margin-x-md { margin-left: var(--space-md); margin-right: var(--space-md); }
+.margin-x-lg { margin-left: var(--space-lg); margin-right: var(--space-lg); }
+.margin-x-xl { margin-left: var(--space-xl); margin-right: var(--space-xl); }
+.margin-x-xxl { margin-left: var(--space-xxl); margin-right: var(--space-xxl); }
+.margin-x-xxxl { margin-left: var(--space-xxxl); margin-right: var(--space-xxxl); }
+.margin-x-xxxxl { margin-left: var(--space-xxxxl); margin-right: var(--space-xxxxl); }
+.margin-x-auto { margin-left: auto; margin-right: auto; }
+.margin-x-0 { margin-left: 0; margin-right: 0; }
+
+.margin-y-xxxxs { margin-top: var(--space-xxxxs); margin-bottom: var(--space-xxxxs); }
+.margin-y-xxxs { margin-top: var(--space-xxxs); margin-bottom: var(--space-xxxs); }
+.margin-y-xxs { margin-top: var(--space-xxs); margin-bottom: var(--space-xxs); }
+.margin-y-xs { margin-top: var(--space-xs); margin-bottom: var(--space-xs); }
+.margin-y-sm { margin-top: var(--space-sm); margin-bottom: var(--space-sm); }
+.margin-y-md { margin-top: var(--space-md); margin-bottom: var(--space-md); }
+.margin-y-lg { margin-top: var(--space-lg); margin-bottom: var(--space-lg); }
+.margin-y-xl { margin-top: var(--space-xl); margin-bottom: var(--space-xl); }
+.margin-y-xxl { margin-top: var(--space-xxl); margin-bottom: var(--space-xxl); }
+.margin-y-xxxl { margin-top: var(--space-xxxl); margin-bottom: var(--space-xxxl); }
+.margin-y-xxxxl { margin-top: var(--space-xxxxl); margin-bottom: var(--space-xxxxl); }
+.margin-y-auto { margin-top: auto; margin-bottom: auto; }
+.margin-y-0 { margin-top: 0; margin-bottom: 0; }
+
+// --------------------------------
+
+// Padding
+
+// --------------------------------
+
+.padding-xxxxs { padding: var(--space-xxxxs); }
+.padding-xxxs { padding: var(--space-xxxs); }
+.padding-xxs { padding: var(--space-xxs); }
+.padding-xs { padding: var(--space-xs); }
+.padding-sm { padding: var(--space-sm); }
+.padding-md { padding: var(--space-md); }
+.padding-lg { padding: var(--space-lg); }
+.padding-xl { padding: var(--space-xl); }
+.padding-xxl { padding: var(--space-xxl); }
+.padding-xxxl { padding: var(--space-xxxl); }
+.padding-xxxxl { padding: var(--space-xxxxl); }
+.padding-0 { padding: 0; }
+.padding-component { padding: var(--component-padding); }
+
+.padding-top-xxxxs { padding-top: var(--space-xxxxs); }
+.padding-top-xxxs { padding-top: var(--space-xxxs); }
+.padding-top-xxs { padding-top: var(--space-xxs); }
+.padding-top-xs { padding-top: var(--space-xs); }
+.padding-top-sm { padding-top: var(--space-sm); }
+.padding-top-md { padding-top: var(--space-md); }
+.padding-top-lg { padding-top: var(--space-lg); }
+.padding-top-xl { padding-top: var(--space-xl); }
+.padding-top-xxl { padding-top: var(--space-xxl); }
+.padding-top-xxxl { padding-top: var(--space-xxxl); }
+.padding-top-xxxxl { padding-top: var(--space-xxxxl); }
+.padding-top-0 { padding-top: 0; }
+.padding-top-component { padding-top: var(--component-padding); }
+
+.padding-bottom-xxxxs { padding-bottom: var(--space-xxxxs); }
+.padding-bottom-xxxs { padding-bottom: var(--space-xxxs); }
+.padding-bottom-xxs { padding-bottom: var(--space-xxs); }
+.padding-bottom-xs { padding-bottom: var(--space-xs); }
+.padding-bottom-sm { padding-bottom: var(--space-sm); }
+.padding-bottom-md { padding-bottom: var(--space-md); }
+.padding-bottom-lg { padding-bottom: var(--space-lg); }
+.padding-bottom-xl { padding-bottom: var(--space-xl); }
+.padding-bottom-xxl { padding-bottom: var(--space-xxl); }
+.padding-bottom-xxxl { padding-bottom: var(--space-xxxl); }
+.padding-bottom-xxxxl { padding-bottom: var(--space-xxxxl); }
+.padding-bottom-0 { padding-bottom: 0; }
+.padding-bottom-component { padding-bottom: var(--component-padding); }
+
+.padding-right-xxxxs { padding-right: var(--space-xxxxs); }
+.padding-right-xxxs { padding-right: var(--space-xxxs); }
+.padding-right-xxs { padding-right: var(--space-xxs); }
+.padding-right-xs { padding-right: var(--space-xs); }
+.padding-right-sm { padding-right: var(--space-sm); }
+.padding-right-md { padding-right: var(--space-md); }
+.padding-right-lg { padding-right: var(--space-lg); }
+.padding-right-xl { padding-right: var(--space-xl); }
+.padding-right-xxl { padding-right: var(--space-xxl); }
+.padding-right-xxxl { padding-right: var(--space-xxxl); }
+.padding-right-xxxxl { padding-right: var(--space-xxxxl); }
+.padding-right-0 { padding-right: 0; }
+.padding-right-component { padding-right: var(--component-padding); }
+
+.padding-left-xxxxs { padding-left: var(--space-xxxxs); }
+.padding-left-xxxs { padding-left: var(--space-xxxs); }
+.padding-left-xxs { padding-left: var(--space-xxs); }
+.padding-left-xs { padding-left: var(--space-xs); }
+.padding-left-sm { padding-left: var(--space-sm); }
+.padding-left-md { padding-left: var(--space-md); }
+.padding-left-lg { padding-left: var(--space-lg); }
+.padding-left-xl { padding-left: var(--space-xl); }
+.padding-left-xxl { padding-left: var(--space-xxl); }
+.padding-left-xxxl { padding-left: var(--space-xxxl); }
+.padding-left-xxxxl { padding-left: var(--space-xxxxl); }
+.padding-left-0 { padding-left: 0; }
+.padding-left-component { padding-left: var(--component-padding); }
+
+.padding-x-xxxxs { padding-left: var(--space-xxxxs); padding-right: var(--space-xxxxs); }
+.padding-x-xxxs { padding-left: var(--space-xxxs); padding-right: var(--space-xxxs); }
+.padding-x-xxs { padding-left: var(--space-xxs); padding-right: var(--space-xxs); }
+.padding-x-xs { padding-left: var(--space-xs); padding-right: var(--space-xs); }
+.padding-x-sm { padding-left: var(--space-sm); padding-right: var(--space-sm); }
+.padding-x-md { padding-left: var(--space-md); padding-right: var(--space-md); }
+.padding-x-lg { padding-left: var(--space-lg); padding-right: var(--space-lg); }
+.padding-x-xl { padding-left: var(--space-xl); padding-right: var(--space-xl); }
+.padding-x-xxl { padding-left: var(--space-xxl); padding-right: var(--space-xxl); }
+.padding-x-xxxl { padding-left: var(--space-xxxl); padding-right: var(--space-xxxl); }
+.padding-x-xxxxl { padding-left: var(--space-xxxxl); padding-right: var(--space-xxxxl); }
+.padding-x-0 { padding-left: 0; padding-right: 0; }
+.padding-x-component { padding-left: var(--component-padding); padding-right: var(--component-padding); }
+
+.padding-y-xxxxs { padding-top: var(--space-xxxxs); padding-bottom: var(--space-xxxxs); }
+.padding-y-xxxs { padding-top: var(--space-xxxs); padding-bottom: var(--space-xxxs); }
+.padding-y-xxs { padding-top: var(--space-xxs); padding-bottom: var(--space-xxs); }
+.padding-y-xs { padding-top: var(--space-xs); padding-bottom: var(--space-xs); }
+.padding-y-sm { padding-top: var(--space-sm); padding-bottom: var(--space-sm); }
+.padding-y-md { padding-top: var(--space-md); padding-bottom: var(--space-md); }
+.padding-y-lg { padding-top: var(--space-lg); padding-bottom: var(--space-lg); }
+.padding-y-xl { padding-top: var(--space-xl); padding-bottom: var(--space-xl); }
+.padding-y-xxl { padding-top: var(--space-xxl); padding-bottom: var(--space-xxl); }
+.padding-y-xxxl { padding-top: var(--space-xxxl); padding-bottom: var(--space-xxxl); }
+.padding-y-xxxxl { padding-top: var(--space-xxxxl); padding-bottom: var(--space-xxxxl); }
+.padding-y-0 { padding-top: 0; padding-bottom: 0; }
+.padding-y-component { padding-top: var(--component-padding); padding-bottom: var(--component-padding); }
+
+// --------------------------------
+
+// Vertical Align
+
+// --------------------------------
+
+.align-baseline { vertical-align: baseline; }
+.align-top { vertical-align: top; }
+.align-middle { vertical-align: middle; }
+.align-bottom { vertical-align: bottom; }
+
+// --------------------------------
+
+// Typography
+
+// --------------------------------
+
+.truncate, .text-truncate { // truncate text if it exceeds its parent
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.text-replace { // replace text with bg img
+ overflow: hidden;
+ color: transparent;
+ text-indent: 100%;
+ white-space: nowrap;
+}
+
+.break-word {
+ overflow-wrap: break-word;
+ min-width: 0;
+}
+
+// --------------------------------
+
+// Font Size
+
+// --------------------------------
+
+.text-unit-rem, .text-unit-em, .text-unit-px {
+ font-size: var(--text-unit);
+}
+
+.text-unit-rem { --text-unit: 1rem; }
+.text-unit-em { --text-unit: 1em; }
+.text-unit-px { --text-unit: 16px; }
+
+.text-xs { font-size: var(--text-xs, 0.6875rem); }
+.text-sm { font-size: var(--text-sm, 0.75rem); }
+.text-base { font-size: var(--text-unit, 1rem); }
+.text-md { font-size: var(--text-md, 1.125rem); }
+.text-lg { font-size: var(--text-lg, 1.375rem); }
+.text-xl { font-size: var(--text-xl, 1.75rem); }
+.text-xxl { font-size: var(--text-xxl, 2rem); }
+.text-xxxl { font-size: var(--text-xxxl, 2.5rem); }
+.text-xxxxl { font-size: var(--text-xxxxl, 3rem); }
+
+// --------------------------------
+
+// Text Transform
+
+// --------------------------------
+
+.text-uppercase { text-transform: uppercase; }
+.text-capitalize { text-transform: capitalize; }
+
+// --------------------------------
+
+// Letter Spacing
+
+// --------------------------------
+
+.letter-spacing-xs { letter-spacing: -0.1em; }
+.letter-spacing-sm { letter-spacing: -0.05em; }
+.letter-spacing-md { letter-spacing: 0.05em; }
+.letter-spacing-lg { letter-spacing: 0.1em; }
+.letter-spacing-xl { letter-spacing: 0.2em; }
+
+// --------------------------------
+
+// Font Weight
+
+// --------------------------------
+
+.font-light { font-weight: 300; }
+.font-normal { font-weight: 400; }
+.font-medium { font-weight: 500; }
+.font-semibold { font-weight: 600; }
+.font-bold, .text-bold { font-weight: 700; }
+
+// --------------------------------
+
+// Font Style
+
+// --------------------------------
+
+.font-italic { font-style: italic; }
+
+// --------------------------------
+
+// Font Smooth
+
+// --------------------------------
+
+.font-smooth {
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+// --------------------------------
+
+// Font Family
+
+// --------------------------------
+
+.font-primary { font-family: var(--font-primary); }
+
+// --------------------------------
+
+// Text Align
+
+// --------------------------------
+
+.text-center { text-align: center; }
+.text-left { text-align: left; }
+.text-right { text-align: right; }
+.text-justify { text-align: justify; }
+
+// --------------------------------
+
+// Text Decoration
+
+// --------------------------------
+
+.text-line-through { text-decoration: line-through; }
+.text-underline { text-decoration: underline; }
+.text-decoration-none { text-decoration: none; }
+
+// --------------------------------
+
+// Text Shadow
+
+// --------------------------------
+
+.text-shadow-xs { text-shadow: 0 1px 1px rgba(#000, 0.15); }
+.text-shadow-sm { text-shadow: 0 1px 2px rgba(#000, 0.25); }
+.text-shadow-md { text-shadow: 0 1px 2px rgba(#000, 0.1), 0 2px 4px rgba(#000, 0.2); }
+.text-shadow-lg { text-shadow: 0 1px 4px rgba(#000, 0.1), 0 2px 8px rgba(#000, 0.15), 0 4px 16px rgba(#000, 0.2); }
+.text-shadow-xl { text-shadow: 0 1px 4px rgba(#000, 0.1), 0 2px 8px rgba(#000, 0.15), 0 4px 16px rgba(#000, 0.2), 0 6px 24px rgba(#000, 0.25); }
+.text-shadow-none { text-shadow: none; }
+
+// --------------------------------
+
+// .text-component vertical spacing
+
+// --------------------------------
+
+.text-space-y-xxs { --text-space-y-multiplier: 0.25 !important; }
+.text-space-y-xs { --text-space-y-multiplier: 0.5 !important; }
+.text-space-y-sm { --text-space-y-multiplier: 0.75 !important; }
+.text-space-y-md { --text-space-y-multiplier: 1.25 !important; }
+.text-space-y-lg { --text-space-y-multiplier: 1.5 !important; }
+.text-space-y-xl { --text-space-y-multiplier: 1.75 !important; }
+.text-space-y-xxl { --text-space-y-multiplier: 2 !important; }
+
+// --------------------------------
+
+// Line Height
+
+// --------------------------------
+
+.line-height-xs {
+ --heading-line-height: 1;
+ --body-line-height: 1.1;
+
+ &:not(.text-component) {
+ line-height: 1.1;
+ }
+}
+
+.line-height-sm {
+ --heading-line-height: 1.1;
+ --body-line-height: 1.2;
+
+ &:not(.text-component) {
+ line-height: 1.2;
+ }
+}
+
+.line-height-md {
+ --heading-line-height: 1.15;
+ --body-line-height: 1.4;
+
+ &:not(.text-component) {
+ line-height: 1.4;
+ }
+}
+
+.line-height-lg {
+ --heading-line-height: 1.22;
+ --body-line-height: 1.58;
+
+ &:not(.text-component) {
+ line-height: 1.58;
+ }
+}
+
+.line-height-xl {
+ --heading-line-height: 1.3;
+ --body-line-height: 1.72;
+
+ &:not(.text-component) {
+ line-height: 1.72;
+ }
+}
+
+.line-height-body { line-height: var(--body-line-height); }
+.line-height-heading { line-height: var(--heading-line-height); }
+.line-height-normal { line-height: normal !important; }
+.line-height-1 { line-height: 1 !important; }
+
+// --------------------------------
+
+// White Space
+
+// --------------------------------
+
+.ws-nowrap, .text-nowrap { white-space: nowrap; }
+
+// --------------------------------
+
+// Cursor
+
+// --------------------------------
+
+.cursor-pointer { cursor: pointer; }
+.cursor-default { cursor: default; }
+
+// --------------------------------
+
+// Pointer Events
+
+// --------------------------------
+
+.pointer-events-auto { pointer-events: auto; }
+.pointer-events-none { pointer-events: none; }
+
+// --------------------------------
+
+// User Select
+
+// --------------------------------
+
+.user-select-none { user-select: none; }
+.user-select-all { user-select: all; }
+
+// --------------------------------
+
+// Color
+
+// --------------------------------
+
+[class^="color-"], [class*=" color-"] { --color-o: 1; }
+
+.color-inherit { color: inherit; }
+
+.color-bg-darker { color: alpha(var(--color-bg-darker), var(--color-o, 1)); }
+.color-bg-dark { color: alpha(var(--color-bg-dark), var(--color-o, 1)); }
+.color-bg { color: alpha(var(--color-bg), var(--color-o, 1)); }
+.color-bg-light { color: alpha(var(--color-bg-light), var(--color-o, 1)); }
+.color-bg-lighter { color: alpha(var(--color-bg-lighter), var(--color-o, 1)); }
+
+.color-contrast-lower { color: alpha(var(--color-contrast-lower), var(--color-o, 1)); }
+.color-contrast-low { color: alpha(var(--color-contrast-low), var(--color-o, 1)); }
+.color-contrast-medium { color: alpha(var(--color-contrast-medium), var(--color-o, 1)); }
+.color-contrast-high { color: alpha(var(--color-contrast-high), var(--color-o, 1)); }
+.color-contrast-higher { color: alpha(var(--color-contrast-higher), var(--color-o, 1)); }
+
+.color-primary-darker { color: alpha(var(--color-primary-darker), var(--color-o, 1)); }
+.color-primary-dark { color: alpha(var(--color-primary-dark), var(--color-o, 1)); }
+.color-primary { color: alpha(var(--color-primary), var(--color-o, 1)); }
+.color-primary-light { color: alpha(var(--color-primary-light), var(--color-o, 1)); }
+.color-primary-lighter { color: alpha(var(--color-primary-lighter), var(--color-o, 1)); }
+
+.color-accent-darker { color: alpha(var(--color-accent-darker), var(--color-o, 1)); }
+.color-accent-dark { color: alpha(var(--color-accent-dark), var(--color-o, 1)); }
+.color-accent { color: alpha(var(--color-accent), var(--color-o, 1)); }
+.color-accent-light { color: alpha(var(--color-accent-light), var(--color-o, 1)); }
+.color-accent-lighter { color: alpha(var(--color-accent-lighter), var(--color-o, 1)); }
+
+.color-success-darker { color: alpha(var(--color-success-darker), var(--color-o, 1)); }
+.color-success-dark { color: alpha(var(--color-success-dark), var(--color-o, 1)); }
+.color-success { color: alpha(var(--color-success), var(--color-o, 1)); }
+.color-success-light { color: alpha(var(--color-success-light), var(--color-o, 1)); }
+.color-success-lighter { color: alpha(var(--color-success-lighter), var(--color-o, 1)); }
+
+.color-warning-darker { color: alpha(var(--color-warning-darker), var(--color-o, 1)); }
+.color-warning-dark { color: alpha(var(--color-warning-dark), var(--color-o, 1)); }
+.color-warning { color: alpha(var(--color-warning), var(--color-o, 1)); }
+.color-warning-light { color: alpha(var(--color-warning-light), var(--color-o, 1)); }
+.color-warning-lighter { color: alpha(var(--color-warning-lighter), var(--color-o, 1)); }
+
+.color-error-darker { color: alpha(var(--color-error-darker), var(--color-o, 1)); }
+.color-error-dark { color: alpha(var(--color-error-dark), var(--color-o, 1)); }
+.color-error { color: alpha(var(--color-error), var(--color-o, 1)); }
+.color-error-light { color: alpha(var(--color-error-light), var(--color-o, 1)); }
+.color-error-lighter { color: alpha(var(--color-error-lighter), var(--color-o, 1)); }
+
+.color-white { color: alpha(var(--color-white), var(--color-o, 1)); }
+.color-black { color: alpha(var(--color-black), var(--color-o, 1)); }
+
+.color-opacity-0 { --color-o: 0; }
+.color-opacity-10\% { --color-o: 0.1; }
+.color-opacity-20\% { --color-o: 0.2; }
+.color-opacity-30\% { --color-o: 0.3; }
+.color-opacity-40\% { --color-o: 0.4; }
+.color-opacity-50\% { --color-o: 0.5; }
+.color-opacity-60\% { --color-o: 0.6; }
+.color-opacity-70\% { --color-o: 0.7; }
+.color-opacity-80\% { --color-o: 0.8; }
+.color-opacity-90\% { --color-o: 0.9; }
+
+// --------------------------------
+
+// Gradients
+
+// --------------------------------
+
+[class^="color-gradient"], [class*=" color-gradient"] {
+ color: transparent !important;
+ background-clip: text;
+}
+
+// --------------------------------
+
+// Width
+
+// --------------------------------
+
+.width-xxxxs { width: var(--size-xxxxs, 0.25rem); }
+.width-xxxs { width: var(--size-xxxs, 0.5rem); }
+.width-xxs { width: var(--size-xxs, 0.75rem); }
+.width-xs { width: var(--size-xs, 1rem); }
+.width-sm { width: var(--size-sm, 1.5rem); }
+.width-md { width: var(--size-md, 2rem); }
+.width-lg { width: var(--size-lg, 3rem); }
+.width-xl { width: var(--size-xl, 4rem); }
+.width-xxl { width: var(--size-xxl, 6rem); }
+.width-xxxl { width: var(--size-xxxl, 8rem); }
+.width-xxxxl { width: var(--size-xxxxl, 16rem); }
+.width-0 { width: 0; }
+.width-10\% { width: 10%; }
+.width-20\% { width: 20%; }
+.width-25\% { width: 25%; }
+.width-30\% { width: 30%; }
+.width-33\% { width: calc(100% / 3); }
+.width-40\% { width: 40%; }
+.width-50\% { width: 50%; }
+.width-60\% { width: 60%; }
+.width-66\% { width: calc(100% / 1.5); }
+.width-70\% { width: 70%; }
+.width-75\% { width: 75%; }
+.width-80\% { width: 80%; }
+.width-90\% { width: 90%; }
+.width-100\% { width: 100%; }
+.width-100vw { width: 100vw; }
+.width-auto { width: auto; }
+.width-fit-content { width: fit-content; }
+.width-max-content { width: max-content; }
+
+// --------------------------------
+
+// Height
+
+// --------------------------------
+
+.height-xxxxs { height: var(--size-xxxxs, 0.25rem); }
+.height-xxxs { height: var(--size-xxxs, 0.5rem); }
+.height-xxs { height: var(--size-xxs, 0.75rem); }
+.height-xs { height: var(--size-xs, 1rem); }
+.height-sm { height: var(--size-sm, 1.5rem); }
+.height-md { height: var(--size-md, 2rem); }
+.height-lg { height: var(--size-lg, 3rem); }
+.height-xl { height: var(--size-xl, 4rem); }
+.height-xxl { height: var(--size-xxl, 6rem); }
+.height-xxxl { height: var(--size-xxxl, 8rem); }
+.height-xxxxl { height: var(--size-xxxxl, 16rem); }
+.height-0 { height: 0; }
+.height-10\% { height: 10%; }
+.height-20\% { height: 20%; }
+.height-25\% { height: 25%; }
+.height-30\% { height: 30%; }
+.height-33\% { height: calc(100% / 3); }
+.height-40\% { height: 40%; }
+.height-50\% { height: 50%; }
+.height-60\% { height: 60%; }
+.height-66\% { height: calc(100% / 1.5); }
+.height-70\% { height: 70%; }
+.height-75\% { height: 75%; }
+.height-80\% { height: 80%; }
+.height-90\% { height: 90%; }
+.height-100\% { height: 100%; }
+.height-100vh { height: 100vh; }
+.height-auto { height: auto; }
+.height-fit-content { height: fit-content; }
+.height-max-content { height: max-content; }
+
+// --------------------------------
+
+// Min-Width
+
+// --------------------------------
+
+.min-width-0 { min-width: 0; }
+.min-width-25\% { min-width: 25%; }
+.min-width-33\% { min-width: calc(100% / 3); }
+.min-width-50\% { min-width: 50%; }
+.min-width-66\% { min-width: calc(100% / 1.5); }
+.min-width-75\% { min-width: 75%; }
+.min-width-100\% { min-width: 100%; }
+.min-width-100vw { min-width: 100vw; }
+.min-width-fit-content { min-width: fit-content; }
+.min-width-max-content { min-width: max-content; }
+
+// --------------------------------
+
+// Min-Height
+
+// --------------------------------
+
+.min-height-100\% { min-height: 100%; }
+.min-height-100vh { min-height: 100vh; }
+.min-height-fit-content { min-height: fit-content; }
+.min-height-max-content { min-height: max-content; }
+
+// --------------------------------
+
+// Max-Width
+
+// --------------------------------
+
+:root {
+ --max-width-xxxxs: 20rem; // ~320px
+ --max-width-xxxs: 26rem; // ~416px
+ --max-width-xxs: 32rem; // ~512px
+ --max-width-xs: 38rem; // ~608px
+ --max-width-sm: 48rem; // ~768px
+ --max-width-md: 64rem; // ~1024px
+ --max-width-lg: 80rem; // ~1280px
+ --max-width-xl: 90rem; // ~1440px
+ --max-width-xxl: 100rem; // ~1600px
+ --max-width-xxxl: 120rem; // ~1920px
+ --max-width-xxxxl: 150rem; // ~2400px
+}
+
+.max-width-xxxxs { max-width: var(--max-width-xxxxs); }
+.max-width-xxxs { max-width: var(--max-width-xxxs); }
+.max-width-xxs { max-width: var(--max-width-xxs); }
+.max-width-xs { max-width: var(--max-width-xs); }
+.max-width-sm { max-width: var(--max-width-sm); }
+.max-width-md { max-width: var(--max-width-md); }
+.max-width-lg { max-width: var(--max-width-lg); }
+.max-width-xl { max-width: var(--max-width-xl); }
+.max-width-xxl { max-width: var(--max-width-xxl); }
+.max-width-xxxl { max-width: var(--max-width-xxxl); }
+.max-width-xxxxl { max-width: var(--max-width-xxxxl); }
+.max-width-100\% { max-width: 100%; }
+.max-width-none { max-width: none; }
+.max-width-fit-content { max-width: fit-content; }
+.max-width-max-content { max-width: max-content; }
+
+// alt approach - max-width is equal to current breakpoint
+$breakpointsNr: length($breakpoints);
+@each $breakpoint, $value in $breakpoints {
+ $i: index($breakpoints, $breakpoint $value);
+ @if $i == 1 {
+ [class^="max-width-adaptive"], [class*=" max-width-adaptive"] {
+ max-width: map-get($map: $breakpoints, $key: #{$breakpoint});
+ }
+ } @else {
+ $classList : '';
+ @each $subBreakpoint, $subValue in $breakpoints {
+ $j: index($breakpoints, $subBreakpoint $subValue);
+ @if $j == $i {
+ $classList: '.max-width-adaptive-#{$subBreakpoint}';
+ } @else if $j > $i {
+ $classList: $classList+', .max-width-adaptive-#{$subBreakpoint}';
+ }
+ }
+ @if $i < $breakpointsNr {
+ $classList: $classList+', .max-width-adaptive';
+ }
+ @include breakpoint(#{$breakpoint}) {
+ #{$classList} {
+ max-width: map-get($map: $breakpoints, $key: #{$breakpoint});
+ }
+ }
+ }
+}
+
+// --------------------------------
+
+// Max-Height
+
+// --------------------------------
+
+.max-height-100\% { max-height: 100%; }
+.max-height-100vh { max-height: 100vh; }
+
+// --------------------------------
+
+// Box-Shadow
+
+// --------------------------------
+
+.shadow-xs { box-shadow: var(--shadow-xs); }
+.shadow-sm { box-shadow: var(--shadow-sm); }
+.shadow-md { box-shadow: var(--shadow-md); }
+.shadow-lg { box-shadow: var(--shadow-lg); }
+.shadow-xl { box-shadow: var(--shadow-xl); }
+.shadow-none { box-shadow: none; }
+
+:root {
+ --inner-glow: inset 0 0 0.5px 1px hsla(0, 0%, 100%, 0.075);
+ --inner-glow-top: inset 0 1px 0.5px hsla(0, 0%, 100%, 0.075);
+}
+
+.inner-glow, .inner-glow-top {
+ position: relative;
+
+ &::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ border-radius: inherit;
+ pointer-events: none;
+ }
+}
+
+.inner-glow::after { box-shadow: var(--inner-glow); }
+.inner-glow-top::after { box-shadow: var(--inner-glow-top); }
+
+// --------------------------------
+
+// Position
+
+// --------------------------------
+
+.position-relative { position: relative; }
+.position-absolute { position: absolute; }
+.position-fixed { position: fixed; }
+.position-sticky { position: sticky; }
+
+.inset-0 { top: 0; right: 0; bottom: 0; left: 0; }
+
+.top-0 { top: 0; }
+.top-50\% { top: 50%; }
+.top-xxxxs { top: var(--space-xxxxs); }
+.top-xxxs { top: var(--space-xxxs); }
+.top-xxs { top: var(--space-xxs); }
+.top-xs { top: var(--space-xs); }
+.top-sm { top: var(--space-sm); }
+.top-md { top: var(--space-md); }
+.top-lg { top: var(--space-lg); }
+.top-xl { top: var(--space-xl); }
+.top-xxl { top: var(--space-xxl); }
+.top-xxxl { top: var(--space-xxxl); }
+.top-xxxxl { top: var(--space-xxxxl); }
+
+.bottom-0 { bottom: 0; }
+.bottom-unset { bottom: unset; }
+.bottom-50\% { bottom: 50%; }
+.bottom-xxxxs { bottom: var(--space-xxxxs); }
+.bottom-xxxs { bottom: var(--space-xxxs); }
+.bottom-xxs { bottom: var(--space-xxs); }
+.bottom-xs { bottom: var(--space-xs); }
+.bottom-sm { bottom: var(--space-sm); }
+.bottom-md { bottom: var(--space-md); }
+.bottom-lg { bottom: var(--space-lg); }
+.bottom-xl { bottom: var(--space-xl); }
+.bottom-xxl { bottom: var(--space-xxl); }
+.bottom-xxxl { bottom: var(--space-xxxl); }
+.bottom-xxxxl { bottom: var(--space-xxxxl); }
+
+.right-0 { right: 0; }
+.right-50\% { right: 50%; }
+.right-xxxxs { right: var(--space-xxxxs); }
+.right-xxxs { right: var(--space-xxxs); }
+.right-xxs { right: var(--space-xxs); }
+.right-xs { right: var(--space-xs); }
+.right-sm { right: var(--space-sm); }
+.right-md { right: var(--space-md); }
+.right-lg { right: var(--space-lg); }
+.right-xl { right: var(--space-xl); }
+.right-xxl { right: var(--space-xxl); }
+.right-xxxl { right: var(--space-xxxl); }
+.right-xxxxl { right: var(--space-xxxxl); }
+
+.left-0 { left: 0; }
+.left-50\% { left: 50%; }
+.left-xxxxs { left: var(--space-xxxxs); }
+.left-xxxs { left: var(--space-xxxs); }
+.left-xxs { left: var(--space-xxs); }
+.left-xs { left: var(--space-xs); }
+.left-sm { left: var(--space-sm); }
+.left-md { left: var(--space-md); }
+.left-lg { left: var(--space-lg); }
+.left-xl { left: var(--space-xl); }
+.left-xxl { left: var(--space-xxl); }
+.left-xxxl { left: var(--space-xxxl); }
+.left-xxxxl { left: var(--space-xxxxl); }
+
+// --------------------------------
+
+// Z-Index
+
+// --------------------------------
+
+.z-index-header { z-index: var(--z-index-header); }
+.z-index-popover { z-index: var(--z-index-popover); }
+.z-index-fixed-element { z-index: var(--z-index-fixed-element); }
+.z-index-overlay { z-index: var(--z-index-overlay); }
+
+.z-index-1 { z-index: 1; }
+.z-index-2 { z-index: 2; }
+.z-index-3 { z-index: 3; }
+
+// --------------------------------
+
+// Overflow
+
+// --------------------------------
+
+.overflow-hidden { overflow: hidden; }
+.overflow-auto { overflow: auto; }
+.momentum-scrolling { -webkit-overflow-scrolling: touch; }
+
+// overscroll-behavior
+.overscroll-contain { overscroll-behavior: contain; }
+
+// --------------------------------
+
+// Scroll Behavior
+
+// --------------------------------
+
+.scroll-smooth { scroll-behavior: smooth; }
+
+.scroll-padding-xxxxs { scroll-padding: var(--space-xxxxs); }
+.scroll-padding-xxxs { scroll-padding: var(--space-xxxs); }
+.scroll-padding-xxs { scroll-padding: var(--space-xxs); }
+.scroll-padding-xs { scroll-padding: var(--space-xs); }
+.scroll-padding-sm { scroll-padding: var(--space-sm); }
+.scroll-padding-md { scroll-padding: var(--space-md); }
+.scroll-padding-lg { scroll-padding: var(--space-lg); }
+.scroll-padding-xl { scroll-padding: var(--space-xl); }
+.scroll-padding-xxl { scroll-padding: var(--space-xxl); }
+.scroll-padding-xxxl { scroll-padding: var(--space-xxxl); }
+.scroll-padding-xxxxl { scroll-padding: var(--space-xxxxl); }
+
+
+// --------------------------------
+
+// Opacity
+
+// --------------------------------
+
+.opacity-0 { opacity: 0; }
+.opacity-10\% { opacity: 0.1; }
+.opacity-20\% { opacity: 0.2; }
+.opacity-30\% { opacity: 0.3; }
+.opacity-40\% { opacity: 0.4; }
+.opacity-50\% { opacity: 0.5; }
+.opacity-60\% { opacity: 0.6; }
+.opacity-70\% { opacity: 0.7; }
+.opacity-80\% { opacity: 0.8; }
+.opacity-90\% { opacity: 0.9; }
+
+// --------------------------------
+
+// Float
+
+// --------------------------------
+
+.float-left { float: left; }
+.float-right { float: right; }
+
+.clearfix::after {
+ content: "";
+ display: table;
+ clear: both;
+}
+
+// --------------------------------
+
+// Border
+
+// --------------------------------
+
+[class^="border-"], [class*=" border-"] {
+ --border-o: 1;
+}
+
+.border { border: var(--border-width, 1px) var(--border-style, solid) alpha(var(--color-contrast-lower), var(--border-o, 1)); }
+.border-top { border-top: var(--border-width, 1px) var(--border-style, solid) alpha(var(--color-contrast-lower), var(--border-o, 1)); }
+.border-bottom { border-bottom: var(--border-width, 1px) var(--border-style, solid) alpha(var(--color-contrast-lower), var(--border-o, 1)); }
+.border-left { border-left: var(--border-width, 1px) var(--border-style, solid) alpha(var(--color-contrast-lower), var(--border-o, 1)); }
+.border-right { border-right: var(--border-width, 1px) var(--border-style, solid) alpha(var(--color-contrast-lower), var(--border-o, 1)); }
+
+.border-2 { --border-width: 2px; }
+.border-3 { --border-width: 3px; }
+.border-4 { --border-width: 4px; }
+.border-dotted { --border-style: dotted; }
+.border-dashed { --border-style: dashed; }
+
+.border-bg-darker { border-color: alpha(var(--color-bg-darker), var(--border-o, 1)); }
+.border-bg-dark { border-color: alpha(var(--color-bg-dark), var(--border-o, 1)); }
+.border-bg { border-color: alpha(var(--color-bg), var(--border-o, 1)); }
+.border-bg-light { border-color: alpha(var(--color-bg-light), var(--border-o, 1)); }
+.border-bg-lighter { border-color: alpha(var(--color-bg-lighter), var(--border-o, 1)); }
+
+.border-contrast-lower { border-color: alpha(var(--color-contrast-lower), var(--border-o, 1)); }
+.border-contrast-low { border-color: alpha(var(--color-contrast-low), var(--border-o, 1)); }
+.border-contrast-medium { border-color: alpha(var(--color-contrast-medium), var(--border-o, 1)); }
+.border-contrast-high { border-color: alpha(var(--color-contrast-high), var(--border-o, 1)); }
+.border-contrast-higher { border-color: alpha(var(--color-contrast-higher), var(--border-o, 1)); }
+
+.border-primary-darker { border-color: alpha(var(--color-primary-darker), var(--border-o, 1)); }
+.border-primary-dark { border-color: alpha(var(--color-primary-dark), var(--border-o, 1)); }
+.border-primary { border-color: alpha(var(--color-primary), var(--border-o, 1)); }
+.border-primary-light { border-color: alpha(var(--color-primary-light), var(--border-o, 1)); }
+.border-primary-lighter { border-color: alpha(var(--color-primary-lighter), var(--border-o, 1)); }
+
+.border-accent-darker { border-color: alpha(var(--color-accent-darker), var(--border-o, 1)); }
+.border-accent-dark { border-color: alpha(var(--color-accent-dark), var(--border-o, 1)); }
+.border-accent { border-color: alpha(var(--color-accent), var(--border-o, 1)); }
+.border-accent-light { border-color: alpha(var(--color-accent-light), var(--border-o, 1)); }
+.border-accent-lighter { border-color: alpha(var(--color-accent-lighter), var(--border-o, 1)); }
+
+.border-success-darker { border-color: alpha(var(--color-success-darker), var(--border-o, 1)); }
+.border-success-dark { border-color: alpha(var(--color-success-dark), var(--border-o, 1)); }
+.border-success { border-color: alpha(var(--color-success), var(--border-o, 1)); }
+.border-success-light { border-color: alpha(var(--color-success-light), var(--border-o, 1)); }
+.border-success-lighter { border-color: alpha(var(--color-success-lighter), var(--border-o, 1)); }
+
+.border-warning-darker { border-color: alpha(var(--color-warning-darker), var(--border-o, 1)); }
+.border-warning-dark { border-color: alpha(var(--color-warning-dark), var(--border-o, 1)); }
+.border-warning { border-color: alpha(var(--color-warning), var(--border-o, 1)); }
+.border-warning-light { border-color: alpha(var(--color-warning-light), var(--border-o, 1)); }
+.border-warning-lighter { border-color: alpha(var(--color-warning-lighter), var(--border-o, 1)); }
+
+.border-error-darker { border-color: alpha(var(--color-error-darker), var(--border-o, 1)); }
+.border-error-dark { border-color: alpha(var(--color-error-dark), var(--border-o, 1)); }
+.border-error { border-color: alpha(var(--color-error), var(--border-o, 1)); }
+.border-error-light { border-color: alpha(var(--color-error-light), var(--border-o, 1)); }
+.border-error-lighter { border-color: alpha(var(--color-error-lighter), var(--border-o, 1)); }
+
+.border-white { border-color: alpha(var(--color-white), var(--border-o, 1)); }
+.border-black { border-color: alpha(var(--color-black), var(--border-o, 1)); }
+
+.border-opacity-0 { --border-o: 0; }
+.border-opacity-10\% { --border-o: 0.1; }
+.border-opacity-20\% { --border-o: 0.2; }
+.border-opacity-30\% { --border-o: 0.3; }
+.border-opacity-40\% { --border-o: 0.4; }
+.border-opacity-50\% { --border-o: 0.5; }
+.border-opacity-60\% { --border-o: 0.6; }
+.border-opacity-70\% { --border-o: 0.7; }
+.border-opacity-80\% { --border-o: 0.8; }
+.border-opacity-90\% { --border-o: 0.9; }
+
+// --------------------------------
+
+// Border Radius
+
+// --------------------------------
+
+.radius-sm { border-radius: var(--radius-sm); }
+.radius-md { border-radius: var(--radius-md); }
+.radius-lg { border-radius: var(--radius-lg); }
+.radius-50\% { border-radius: 50%; }
+.radius-full { border-radius: 50em; }
+.radius-0 { border-radius: 0; }
+.radius-inherit { border-radius: inherit; }
+.radius-top-left-0 { border-top-left-radius: 0; }
+.radius-top-right-0 { border-top-right-radius: 0; }
+.radius-bottom-right-0 { border-bottom-right-radius: 0; }
+.radius-bottom-left-0 { border-bottom-left-radius: 0; }
+
+// --------------------------------
+
+// Background
+
+// --------------------------------
+
+.bg, [class^="bg-"], [class*=" bg-"] { --bg-o: 1; }
+
+.bg-transparent { background-color: transparent; }
+.bg-inherit { background-color: inherit; }
+
+.bg-darker { background-color: alpha(var(--color-bg-darker), var(--bg-o)); }
+.bg-dark { background-color: alpha(var(--color-bg-dark), var(--bg-o)); }
+.bg { background-color: alpha(var(--color-bg), var(--bg-o)); }
+.bg-light { background-color: alpha(var(--color-bg-light), var(--bg-o)); }
+.bg-lighter { background-color: alpha(var(--color-bg-lighter), var(--bg-o)); }
+
+.bg-contrast-lower { background-color: alpha(var(--color-contrast-lower), var(--bg-o, 1)); }
+.bg-contrast-low { background-color: alpha(var(--color-contrast-low), var(--bg-o, 1)); }
+.bg-contrast-medium { background-color: alpha(var(--color-contrast-medium), var(--bg-o, 1)); }
+.bg-contrast-high { background-color: alpha(var(--color-contrast-high), var(--bg-o, 1)); }
+.bg-contrast-higher { background-color: alpha(var(--color-contrast-higher), var(--bg-o, 1)); }
+
+.bg-primary-darker { background-color: alpha(var(--color-primary-darker), var(--bg-o, 1)); }
+.bg-primary-dark { background-color: alpha(var(--color-primary-dark), var(--bg-o, 1)); }
+.bg-primary { background-color: alpha(var(--color-primary), var(--bg-o, 1)); }
+.bg-primary-light { background-color: alpha(var(--color-primary-light), var(--bg-o, 1)); }
+.bg-primary-lighter { background-color: alpha(var(--color-primary-lighter), var(--bg-o, 1)); }
+
+.bg-accent-darker { background-color: alpha(var(--color-accent-darker), var(--bg-o, 1)); }
+.bg-accent-dark { background-color: alpha(var(--color-accent-dark), var(--bg-o, 1)); }
+.bg-accent { background-color: alpha(var(--color-accent), var(--bg-o, 1)); }
+.bg-accent-light { background-color: alpha(var(--color-accent-light), var(--bg-o, 1)); }
+.bg-accent-lighter { background-color: alpha(var(--color-accent-lighter), var(--bg-o, 1)); }
+
+.bg-success-darker { background-color: alpha(var(--color-success-darker), var(--bg-o, 1)); }
+.bg-success-dark { background-color: alpha(var(--color-success-dark), var(--bg-o, 1)); }
+.bg-success { background-color: alpha(var(--color-success), var(--bg-o, 1)); }
+.bg-success-light { background-color: alpha(var(--color-success-light), var(--bg-o, 1)); }
+.bg-success-lighter { background-color: alpha(var(--color-success-lighter), var(--bg-o, 1)); }
+
+.bg-warning-darker { background-color: alpha(var(--color-warning-darker), var(--bg-o, 1)); }
+.bg-warning-dark { background-color: alpha(var(--color-warning-dark), var(--bg-o, 1)); }
+.bg-warning { background-color: alpha(var(--color-warning), var(--bg-o, 1)); }
+.bg-warning-light { background-color: alpha(var(--color-warning-light), var(--bg-o, 1)); }
+.bg-warning-lighter { background-color: alpha(var(--color-warning-lighter), var(--bg-o, 1)); }
+
+.bg-error-darker { background-color: alpha(var(--color-error-darker), var(--bg-o, 1)); }
+.bg-error-dark { background-color: alpha(var(--color-error-dark), var(--bg-o, 1)); }
+.bg-error { background-color: alpha(var(--color-error), var(--bg-o, 1)); }
+.bg-error-light { background-color: alpha(var(--color-error-light), var(--bg-o, 1)); }
+.bg-error-lighter { background-color: alpha(var(--color-error-lighter), var(--bg-o, 1)); }
+
+.bg-white { background-color: alpha(var(--color-white), var(--bg-o, 1)); }
+.bg-black { background-color: alpha(var(--color-black), var(--bg-o, 1)); }
+
+.bg-opacity-0 { --bg-o: 0; }
+.bg-opacity-10\% { --bg-o: 0.1; }
+.bg-opacity-20\% { --bg-o: 0.2; }
+.bg-opacity-30\% { --bg-o: 0.3; }
+.bg-opacity-40\% { --bg-o: 0.4; }
+.bg-opacity-50\% { --bg-o: 0.5; }
+.bg-opacity-60\% { --bg-o: 0.6; }
+.bg-opacity-70\% { --bg-o: 0.7; }
+.bg-opacity-80\% { --bg-o: 0.8; }
+.bg-opacity-90\% { --bg-o: 0.9; }
+
+.bg-center { background-position: center; }
+.bg-top { background-position: center top; }
+.bg-right { background-position: right center; }
+.bg-bottom { background-position: center bottom; }
+.bg-left { background-position: left center; }
+.bg-top-left { background-position: left top; }
+.bg-top-right { background-position: right top; }
+.bg-bottom-left { background-position: left bottom; }
+.bg-bottom-right { background-position: right bottom; }
+
+.bg-cover { background-size: cover; }
+.bg-no-repeat { background-repeat: no-repeat; }
+
+// --------------------------------
+
+// Backdrop Filter
+
+// --------------------------------
+
+.backdrop-blur-10 { backdrop-filter: blur(10px); }
+.backdrop-blur-20 { backdrop-filter: blur(20px); }
+
+// --------------------------------
+
+// Mix-Blend Mode
+
+// --------------------------------
+
+.isolate { isolation: isolate; }
+.blend-multiply { mix-blend-mode: multiply; }
+.blend-overlay { mix-blend-mode: overlay; }
+.blend-difference { mix-blend-mode: difference; }
+
+// --------------------------------
+
+// Object-Fit
+
+// --------------------------------
+
+.object-contain { object-fit: contain; }
+.object-cover { object-fit: cover; }
+
+// --------------------------------
+
+// Perspective
+
+// --------------------------------
+
+.perspective-xs { perspective: 250px; }
+.perspective-sm { perspective: 500px; }
+.perspective-md { perspective: 1000px; }
+.perspective-lg { perspective: 1500px; }
+.perspective-xl { perspective: 3000px; }
+
+// --------------------------------
+
+// Transform
+
+// --------------------------------
+
+[class^="flip"], [class*=" flip"],
+[class^="-rotate"], [class*=" -rotate"],
+[class^="rotate"], [class*=" rotate"],
+[class^="-translate"], [class*=" -translate"],
+[class^="translate"], [class*=" translate"],
+[class^="-scale"], [class*=" -scale"],
+[class^="scale"], [class*=" scale"],
+[class^="-skew"], [class*=" -skew"]
+[class^="skew"], [class*=" skew"] {
+ --translate: 0;
+ --rotate: 0;
+ --skew: 0;
+ --scale: 1;
+
+ transform: translate3d(var(--translate-x, var(--translate)), var(--translate-y, var(--translate)), var(--translate-z, 0)) rotateX(var(--rotate-x, 0)) rotateY(var(--rotate-y, 0)) rotateZ(var(--rotate-z, var(--rotate))) skewX(var(--skew-x, var(--skew))) skewY(var(--skew-y, 0)) scaleX(var(--scale-x, var(--scale))) scaleY(var(--scale-y, var(--scale)));
+}
+
+.flip { --scale: -1; }
+.flip-x { --scale-x: -1; }
+.flip-y { --scale-y: -1; }
+
+.rotate-90 { --rotate: 90deg; }
+.rotate-180 { --rotate: 180deg; }
+.rotate-270 { --rotate: 270deg; }
+
+.-translate-50\% { --translate: -50%; }
+.-translate-x-50\% { --translate-x: -50%; }
+.-translate-y-50\% { --translate-y: -50%; }
+
+.translate-50\% { --translate: 50%; }
+.translate-x-50\% { --translate-x: 50%; }
+.translate-y-50\% { --translate-y: 50%; }
+
+// --------------------------------
+
+// Transform Origin
+
+// --------------------------------
+
+.origin-center { transform-origin: center; }
+.origin-top { transform-origin: center top; }
+.origin-right { transform-origin: right center; }
+.origin-bottom { transform-origin: center bottom; }
+.origin-left { transform-origin: left center; }
+.origin-top-left { transform-origin: left top; }
+.origin-top-right { transform-origin: right top; }
+.origin-bottom-left { transform-origin: left bottom; }
+.origin-bottom-right { transform-origin: right bottom; }
+
+// --------------------------------
+
+// SVG
+
+// --------------------------------
+
+.fill-current { fill: currentColor; }
+
+.stroke-current { stroke: currentColor; }
+
+.stroke-1 { stroke-width: 1px; }
+.stroke-2 { stroke-width: 2px; }
+.stroke-3 { stroke-width: 3px; }
+.stroke-4 { stroke-width: 4px; }
+
+// --------------------------------
+
+// Visibility
+
+// --------------------------------
+
+.visible { visibility: visible; }
+.invisible { visibility: hidden; }
+
+// --------------------------------
+
+// Responsive Variations
+
+// --------------------------------
+
+@each $breakpoint, $value in $breakpoints {
+ @include breakpoint(#{$breakpoint}) {
+ // flexbox
+ .flex\@#{$breakpoint} { display: flex; }
+ .inline-flex\@#{$breakpoint} { display: inline-flex; }
+ .flex-wrap\@#{$breakpoint} { flex-wrap: wrap; }
+ .flex-nowrap\@#{$breakpoint} { flex-wrap:nowrap; }
+ .flex-column\@#{$breakpoint} { flex-direction: column; }
+ .flex-column-reverse\@#{$breakpoint} { flex-direction: column-reverse; }
+ .flex-row\@#{$breakpoint} { flex-direction: row; }
+ .flex-row-reverse\@#{$breakpoint} { flex-direction: row-reverse; }
+ .flex-center\@#{$breakpoint} { justify-content: center; align-items: center; }
+
+ .flex-grow\@#{$breakpoint} { flex-grow: 1; }
+ .flex-grow-0\@#{$breakpoint} { flex-grow: 0; }
+ .flex-shrink\@#{$breakpoint} { flex-shrink: 1; }
+ .flex-shrink-0\@#{$breakpoint} { flex-shrink: 0; }
+ .flex-basis-0\@#{$breakpoint} { flex-basis: 0; }
+
+ // justify-content
+ .justify-start\@#{$breakpoint} { justify-content: flex-start; }
+ .justify-end\@#{$breakpoint} { justify-content: flex-end; }
+ .justify-center\@#{$breakpoint} { justify-content: center; }
+ .justify-between\@#{$breakpoint} { justify-content: space-between; }
+
+ // align-items
+ .items-center\@#{$breakpoint} { align-items: center; }
+ .items-start\@#{$breakpoint} { align-items: flex-start; }
+ .items-end\@#{$breakpoint} { align-items: flex-end; }
+ .items-baseline\@#{$breakpoint} { align-items: baseline; }
+
+ // order
+ .order-1\@#{$breakpoint} { order: 1; }
+ .order-2\@#{$breakpoint} { order: 2; }
+ .order-3\@#{$breakpoint} { order: 3; }
+
+ // display
+ .block\@#{$breakpoint} { display: block; }
+ .inline-block\@#{$breakpoint} { display: inline-block; }
+ .inline\@#{$breakpoint} { display: inline; }
+ .contents\@#{$breakpoint} { display: contents; }
+ .hide\@#{$breakpoint} { display: none !important; }
+
+ // margin
+ .margin-xxxxs\@#{$breakpoint} { margin: var(--space-xxxxs); }
+ .margin-xxxs\@#{$breakpoint} { margin: var(--space-xxxs); }
+ .margin-xxs\@#{$breakpoint} { margin: var(--space-xxs); }
+ .margin-xs\@#{$breakpoint} { margin: var(--space-xs); }
+ .margin-sm\@#{$breakpoint} { margin: var(--space-sm); }
+ .margin-md\@#{$breakpoint} { margin: var(--space-md); }
+ .margin-lg\@#{$breakpoint} { margin: var(--space-lg); }
+ .margin-xl\@#{$breakpoint} { margin: var(--space-xl); }
+ .margin-xxl\@#{$breakpoint} { margin: var(--space-xxl); }
+ .margin-xxxl\@#{$breakpoint} { margin: var(--space-xxxl); }
+ .margin-xxxxl\@#{$breakpoint} { margin: var(--space-xxxxl); }
+ .margin-auto\@#{$breakpoint} { margin: auto; }
+ .margin-0\@#{$breakpoint} { margin: 0; }
+
+ .margin-top-xxxxs\@#{$breakpoint} { margin-top: var(--space-xxxxs); }
+ .margin-top-xxxs\@#{$breakpoint} { margin-top: var(--space-xxxs); }
+ .margin-top-xxs\@#{$breakpoint} { margin-top: var(--space-xxs); }
+ .margin-top-xs\@#{$breakpoint} { margin-top: var(--space-xs); }
+ .margin-top-sm\@#{$breakpoint} { margin-top: var(--space-sm); }
+ .margin-top-md\@#{$breakpoint} { margin-top: var(--space-md); }
+ .margin-top-lg\@#{$breakpoint} { margin-top: var(--space-lg); }
+ .margin-top-xl\@#{$breakpoint} { margin-top: var(--space-xl); }
+ .margin-top-xxl\@#{$breakpoint} { margin-top: var(--space-xxl); }
+ .margin-top-xxxl\@#{$breakpoint} { margin-top: var(--space-xxxl); }
+ .margin-top-xxxxl\@#{$breakpoint} { margin-top: var(--space-xxxxl); }
+ .margin-top-auto\@#{$breakpoint} { margin-top: auto; }
+ .margin-top-0\@#{$breakpoint} { margin-top: 0; }
+
+ .margin-bottom-xxxxs\@#{$breakpoint} { margin-bottom: var(--space-xxxxs); }
+ .margin-bottom-xxxs\@#{$breakpoint} { margin-bottom: var(--space-xxxs); }
+ .margin-bottom-xxs\@#{$breakpoint} { margin-bottom: var(--space-xxs); }
+ .margin-bottom-xs\@#{$breakpoint} { margin-bottom: var(--space-xs); }
+ .margin-bottom-sm\@#{$breakpoint} { margin-bottom: var(--space-sm); }
+ .margin-bottom-md\@#{$breakpoint} { margin-bottom: var(--space-md); }
+ .margin-bottom-lg\@#{$breakpoint} { margin-bottom: var(--space-lg); }
+ .margin-bottom-xl\@#{$breakpoint} { margin-bottom: var(--space-xl); }
+ .margin-bottom-xxl\@#{$breakpoint} { margin-bottom: var(--space-xxl); }
+ .margin-bottom-xxxl\@#{$breakpoint} { margin-bottom: var(--space-xxxl); }
+ .margin-bottom-xxxxl\@#{$breakpoint} { margin-bottom: var(--space-xxxxl); }
+ .margin-bottom-auto\@#{$breakpoint} { margin-bottom: auto; }
+ .margin-bottom-0\@#{$breakpoint} { margin-bottom: 0; }
+
+ .margin-right-xxxxs\@#{$breakpoint} { margin-right: var(--space-xxxxs); }
+ .margin-right-xxxs\@#{$breakpoint} { margin-right: var(--space-xxxs); }
+ .margin-right-xxs\@#{$breakpoint} { margin-right: var(--space-xxs); }
+ .margin-right-xs\@#{$breakpoint} { margin-right: var(--space-xs); }
+ .margin-right-sm\@#{$breakpoint} { margin-right: var(--space-sm); }
+ .margin-right-md\@#{$breakpoint} { margin-right: var(--space-md); }
+ .margin-right-lg\@#{$breakpoint} { margin-right: var(--space-lg); }
+ .margin-right-xl\@#{$breakpoint} { margin-right: var(--space-xl); }
+ .margin-right-xxl\@#{$breakpoint} { margin-right: var(--space-xxl); }
+ .margin-right-xxxl\@#{$breakpoint} { margin-right: var(--space-xxxl); }
+ .margin-right-xxxxl\@#{$breakpoint} { margin-right: var(--space-xxxxl); }
+ .margin-right-auto\@#{$breakpoint} { margin-right: auto; }
+ .margin-right-0\@#{$breakpoint} { margin-right: 0; }
+
+ .margin-left-xxxxs\@#{$breakpoint} { margin-left: var(--space-xxxxs); }
+ .margin-left-xxxs\@#{$breakpoint} { margin-left: var(--space-xxxs); }
+ .margin-left-xxs\@#{$breakpoint} { margin-left: var(--space-xxs); }
+ .margin-left-xs\@#{$breakpoint} { margin-left: var(--space-xs); }
+ .margin-left-sm\@#{$breakpoint} { margin-left: var(--space-sm); }
+ .margin-left-md\@#{$breakpoint} { margin-left: var(--space-md); }
+ .margin-left-lg\@#{$breakpoint} { margin-left: var(--space-lg); }
+ .margin-left-xl\@#{$breakpoint} { margin-left: var(--space-xl); }
+ .margin-left-xxl\@#{$breakpoint} { margin-left: var(--space-xxl); }
+ .margin-left-xxxl\@#{$breakpoint} { margin-left: var(--space-xxxl); }
+ .margin-left-xxxxl\@#{$breakpoint} { margin-left: var(--space-xxxxl); }
+ .margin-left-auto\@#{$breakpoint} { margin-left: auto; }
+ .margin-left-0\@#{$breakpoint} { margin-left: 0; }
+
+ .margin-x-xxxxs\@#{$breakpoint} { margin-left: var(--space-xxxxs); margin-right: var(--space-xxxxs); }
+ .margin-x-xxxs\@#{$breakpoint} { margin-left: var(--space-xxxs); margin-right: var(--space-xxxs); }
+ .margin-x-xxs\@#{$breakpoint} { margin-left: var(--space-xxs); margin-right: var(--space-xxs); }
+ .margin-x-xs\@#{$breakpoint} { margin-left: var(--space-xs); margin-right: var(--space-xs); }
+ .margin-x-sm\@#{$breakpoint} { margin-left: var(--space-sm); margin-right: var(--space-sm); }
+ .margin-x-md\@#{$breakpoint} { margin-left: var(--space-md); margin-right: var(--space-md); }
+ .margin-x-lg\@#{$breakpoint} { margin-left: var(--space-lg); margin-right: var(--space-lg); }
+ .margin-x-xl\@#{$breakpoint} { margin-left: var(--space-xl); margin-right: var(--space-xl); }
+ .margin-x-xxl\@#{$breakpoint} { margin-left: var(--space-xxl); margin-right: var(--space-xxl); }
+ .margin-x-xxxl\@#{$breakpoint} { margin-left: var(--space-xxxl); margin-right: var(--space-xxxl); }
+ .margin-x-xxxxl\@#{$breakpoint} { margin-left: var(--space-xxxxl); margin-right: var(--space-xxxxl); }
+ .margin-x-auto\@#{$breakpoint} { margin-left: auto; margin-right: auto; }
+ .margin-x-0\@#{$breakpoint} { margin-left: 0; margin-right: 0; }
+
+ .margin-y-xxxxs\@#{$breakpoint} { margin-top: var(--space-xxxxs); margin-bottom: var(--space-xxxxs); }
+ .margin-y-xxxs\@#{$breakpoint} { margin-top: var(--space-xxxs); margin-bottom: var(--space-xxxs); }
+ .margin-y-xxs\@#{$breakpoint} { margin-top: var(--space-xxs); margin-bottom: var(--space-xxs); }
+ .margin-y-xs\@#{$breakpoint} { margin-top: var(--space-xs); margin-bottom: var(--space-xs); }
+ .margin-y-sm\@#{$breakpoint} { margin-top: var(--space-sm); margin-bottom: var(--space-sm); }
+ .margin-y-md\@#{$breakpoint} { margin-top: var(--space-md); margin-bottom: var(--space-md); }
+ .margin-y-lg\@#{$breakpoint} { margin-top: var(--space-lg); margin-bottom: var(--space-lg); }
+ .margin-y-xl\@#{$breakpoint} { margin-top: var(--space-xl); margin-bottom: var(--space-xl); }
+ .margin-y-xxl\@#{$breakpoint} { margin-top: var(--space-xxl); margin-bottom: var(--space-xxl); }
+ .margin-y-xxxl\@#{$breakpoint} { margin-top: var(--space-xxxl); margin-bottom: var(--space-xxxl); }
+ .margin-y-xxxxl\@#{$breakpoint} { margin-top: var(--space-xxxxl); margin-bottom: var(--space-xxxxl); }
+ .margin-y-auto\@#{$breakpoint} { margin-top: auto; margin-bottom: auto; }
+ .margin-y-0\@#{$breakpoint} { margin-top: 0; margin-bottom: 0; }
+
+ // padding
+ .padding-xxxxs\@#{$breakpoint} { padding: var(--space-xxxxs); }
+ .padding-xxxs\@#{$breakpoint} { padding: var(--space-xxxs); }
+ .padding-xxs\@#{$breakpoint} { padding: var(--space-xxs); }
+ .padding-xs\@#{$breakpoint} { padding: var(--space-xs); }
+ .padding-sm\@#{$breakpoint} { padding: var(--space-sm); }
+ .padding-md\@#{$breakpoint} { padding: var(--space-md); }
+ .padding-lg\@#{$breakpoint} { padding: var(--space-lg); }
+ .padding-xl\@#{$breakpoint} { padding: var(--space-xl); }
+ .padding-xxl\@#{$breakpoint} { padding: var(--space-xxl); }
+ .padding-xxxl\@#{$breakpoint} { padding: var(--space-xxxl); }
+ .padding-xxxxl\@#{$breakpoint} { padding: var(--space-xxxxl); }
+ .padding-0\@#{$breakpoint} { padding: 0; }
+ .padding-component\@#{$breakpoint} { padding: var(--component-padding); }
+
+ .padding-top-xxxxs\@#{$breakpoint} { padding-top: var(--space-xxxxs); }
+ .padding-top-xxxs\@#{$breakpoint} { padding-top: var(--space-xxxs); }
+ .padding-top-xxs\@#{$breakpoint} { padding-top: var(--space-xxs); }
+ .padding-top-xs\@#{$breakpoint} { padding-top: var(--space-xs); }
+ .padding-top-sm\@#{$breakpoint} { padding-top: var(--space-sm); }
+ .padding-top-md\@#{$breakpoint} { padding-top: var(--space-md); }
+ .padding-top-lg\@#{$breakpoint} { padding-top: var(--space-lg); }
+ .padding-top-xl\@#{$breakpoint} { padding-top: var(--space-xl); }
+ .padding-top-xxl\@#{$breakpoint} { padding-top: var(--space-xxl); }
+ .padding-top-xxxl\@#{$breakpoint} { padding-top: var(--space-xxxl); }
+ .padding-top-xxxxl\@#{$breakpoint} { padding-top: var(--space-xxxxl); }
+ .padding-top-0\@#{$breakpoint} { padding-top: 0; }
+ .padding-top-component\@#{$breakpoint} { padding-top: var(--component-padding); }
+
+ .padding-bottom-xxxxs\@#{$breakpoint} { padding-bottom: var(--space-xxxxs); }
+ .padding-bottom-xxxs\@#{$breakpoint} { padding-bottom: var(--space-xxxs); }
+ .padding-bottom-xxs\@#{$breakpoint} { padding-bottom: var(--space-xxs); }
+ .padding-bottom-xs\@#{$breakpoint} { padding-bottom: var(--space-xs); }
+ .padding-bottom-sm\@#{$breakpoint} { padding-bottom: var(--space-sm); }
+ .padding-bottom-md\@#{$breakpoint} { padding-bottom: var(--space-md); }
+ .padding-bottom-lg\@#{$breakpoint} { padding-bottom: var(--space-lg); }
+ .padding-bottom-xl\@#{$breakpoint} { padding-bottom: var(--space-xl); }
+ .padding-bottom-xxl\@#{$breakpoint} { padding-bottom: var(--space-xxl); }
+ .padding-bottom-xxxl\@#{$breakpoint} { padding-bottom: var(--space-xxxl); }
+ .padding-bottom-xxxxl\@#{$breakpoint} { padding-bottom: var(--space-xxxxl); }
+ .padding-bottom-0\@#{$breakpoint} { padding-bottom: 0; }
+ .padding-bottom-component\@#{$breakpoint} { padding-bottom: var(--component-padding); }
+
+ .padding-right-xxxxs\@#{$breakpoint} { padding-right: var(--space-xxxxs); }
+ .padding-right-xxxs\@#{$breakpoint} { padding-right: var(--space-xxxs); }
+ .padding-right-xxs\@#{$breakpoint} { padding-right: var(--space-xxs); }
+ .padding-right-xs\@#{$breakpoint} { padding-right: var(--space-xs); }
+ .padding-right-sm\@#{$breakpoint} { padding-right: var(--space-sm); }
+ .padding-right-md\@#{$breakpoint} { padding-right: var(--space-md); }
+ .padding-right-lg\@#{$breakpoint} { padding-right: var(--space-lg); }
+ .padding-right-xl\@#{$breakpoint} { padding-right: var(--space-xl); }
+ .padding-right-xxl\@#{$breakpoint} { padding-right: var(--space-xxl); }
+ .padding-right-xxxl\@#{$breakpoint} { padding-right: var(--space-xxxl); }
+ .padding-right-xxxxl\@#{$breakpoint} { padding-right: var(--space-xxxxl); }
+ .padding-right-0\@#{$breakpoint} { padding-right: 0; }
+ .padding-right-component\@#{$breakpoint} { padding-right: var(--component-padding); }
+
+ .padding-left-xxxxs\@#{$breakpoint} { padding-left: var(--space-xxxxs); }
+ .padding-left-xxxs\@#{$breakpoint} { padding-left: var(--space-xxxs); }
+ .padding-left-xxs\@#{$breakpoint} { padding-left: var(--space-xxs); }
+ .padding-left-xs\@#{$breakpoint} { padding-left: var(--space-xs); }
+ .padding-left-sm\@#{$breakpoint} { padding-left: var(--space-sm); }
+ .padding-left-md\@#{$breakpoint} { padding-left: var(--space-md); }
+ .padding-left-lg\@#{$breakpoint} { padding-left: var(--space-lg); }
+ .padding-left-xl\@#{$breakpoint} { padding-left: var(--space-xl); }
+ .padding-left-xxl\@#{$breakpoint} { padding-left: var(--space-xxl); }
+ .padding-left-xxxl\@#{$breakpoint} { padding-left: var(--space-xxxl); }
+ .padding-left-xxxxl\@#{$breakpoint} { padding-left: var(--space-xxxxl); }
+ .padding-left-0\@#{$breakpoint} { padding-left: 0; }
+ .padding-left-component\@#{$breakpoint} { padding-left: var(--component-padding); }
+
+ .padding-x-xxxxs\@#{$breakpoint} { padding-left: var(--space-xxxxs); padding-right: var(--space-xxxxs); }
+ .padding-x-xxxs\@#{$breakpoint} { padding-left: var(--space-xxxs); padding-right: var(--space-xxxs); }
+ .padding-x-xxs\@#{$breakpoint} { padding-left: var(--space-xxs); padding-right: var(--space-xxs); }
+ .padding-x-xs\@#{$breakpoint} { padding-left: var(--space-xs); padding-right: var(--space-xs); }
+ .padding-x-sm\@#{$breakpoint} { padding-left: var(--space-sm); padding-right: var(--space-sm); }
+ .padding-x-md\@#{$breakpoint} { padding-left: var(--space-md); padding-right: var(--space-md); }
+ .padding-x-lg\@#{$breakpoint} { padding-left: var(--space-lg); padding-right: var(--space-lg); }
+ .padding-x-xl\@#{$breakpoint} { padding-left: var(--space-xl); padding-right: var(--space-xl); }
+ .padding-x-xxl\@#{$breakpoint} { padding-left: var(--space-xxl); padding-right: var(--space-xxl); }
+ .padding-x-xxxl\@#{$breakpoint} { padding-left: var(--space-xxxl); padding-right: var(--space-xxxl); }
+ .padding-x-xxxxl\@#{$breakpoint} { padding-left: var(--space-xxxxl); padding-right: var(--space-xxxxl); }
+ .padding-x-0\@#{$breakpoint} { padding-left: 0; padding-right: 0; }
+ .padding-x-component\@#{$breakpoint} { padding-left: var(--component-padding); padding-right: var(--component-padding); }
+
+ .padding-y-xxxxs\@#{$breakpoint} { padding-top: var(--space-xxxxs); padding-bottom: var(--space-xxxxs); }
+ .padding-y-xxxs\@#{$breakpoint} { padding-top: var(--space-xxxs); padding-bottom: var(--space-xxxs); }
+ .padding-y-xxs\@#{$breakpoint} { padding-top: var(--space-xxs); padding-bottom: var(--space-xxs); }
+ .padding-y-xs\@#{$breakpoint} { padding-top: var(--space-xs); padding-bottom: var(--space-xs); }
+ .padding-y-sm\@#{$breakpoint} { padding-top: var(--space-sm); padding-bottom: var(--space-sm); }
+ .padding-y-md\@#{$breakpoint} { padding-top: var(--space-md); padding-bottom: var(--space-md); }
+ .padding-y-lg\@#{$breakpoint} { padding-top: var(--space-lg); padding-bottom: var(--space-lg); }
+ .padding-y-xl\@#{$breakpoint} { padding-top: var(--space-xl); padding-bottom: var(--space-xl); }
+ .padding-y-xxl\@#{$breakpoint} { padding-top: var(--space-xxl); padding-bottom: var(--space-xxl); }
+ .padding-y-xxxl\@#{$breakpoint} { padding-top: var(--space-xxxl); padding-bottom: var(--space-xxxl); }
+ .padding-y-xxxxl\@#{$breakpoint} { padding-top: var(--space-xxxxl); padding-bottom: var(--space-xxxxl); }
+ .padding-y-0\@#{$breakpoint} { padding-top: 0; padding-bottom: 0; }
+ .padding-y-component\@#{$breakpoint} { padding-top: var(--component-padding); padding-bottom: var(--component-padding); }
+
+ // text-align
+ .text-center\@#{$breakpoint} { text-align: center; }
+ .text-left\@#{$breakpoint} { text-align: left; }
+ .text-right\@#{$breakpoint} { text-align: right; }
+ .text-justify\@#{$breakpoint} { text-align: justify; }
+
+ // font-size
+ .text-xs\@#{$breakpoint} { font-size: var(--text-xs, 0.6875rem); }
+ .text-sm\@#{$breakpoint} { font-size: var(--text-sm, 0.75rem); }
+ .text-base\@#{$breakpoint} { font-size: var(--text-unit, 1rem); }
+ .text-md\@#{$breakpoint} { font-size: var(--text-md, 1.125rem); }
+ .text-lg\@#{$breakpoint} { font-size: var(--text-lg, 1.375rem); }
+ .text-xl\@#{$breakpoint} { font-size: var(--text-xl, 1.75rem); }
+ .text-xxl\@#{$breakpoint} { font-size: var(--text-xxl, 2rem); }
+ .text-xxxl\@#{$breakpoint} { font-size: var(--text-xxxl, 2.5rem); }
+ .text-xxxxl\@#{$breakpoint} { font-size: var(--text-xxxxl, 3rem); }
+
+ // width
+ .width-xxxxs\@#{$breakpoint} { width: var(--size-xxxxs, 0.25rem); }
+ .width-xxxs\@#{$breakpoint} { width: var(--size-xxxs, 0.5rem); }
+ .width-xxs\@#{$breakpoint} { width: var(--size-xxs, 0.75rem); }
+ .width-xs\@#{$breakpoint} { width: var(--size-xs, 1rem); }
+ .width-sm\@#{$breakpoint} { width: var(--size-sm, 1.5rem); }
+ .width-md\@#{$breakpoint} { width: var(--size-md, 2rem); }
+ .width-lg\@#{$breakpoint} { width: var(--size-lg, 3rem); }
+ .width-xl\@#{$breakpoint} { width: var(--size-xl, 4rem); }
+ .width-xxl\@#{$breakpoint} { width: var(--size-xxl, 6rem); }
+ .width-xxxl\@#{$breakpoint} { width: var(--size-xxxl, 8rem); }
+ .width-xxxxl\@#{$breakpoint} { width: var(--size-xxxxl, 16rem); }
+ .width-0\@#{$breakpoint} { width: 0; }
+ .width-10\%\@#{$breakpoint} { width: 10%; }
+ .width-20\%\@#{$breakpoint} { width: 20%; }
+ .width-25\%\@#{$breakpoint} { width: 25%; }
+ .width-30\%\@#{$breakpoint} { width: 30%; }
+ .width-33\%\@#{$breakpoint} { width: calc(100% / 3); }
+ .width-40\%\@#{$breakpoint} { width: 40%; }
+ .width-50\%\@#{$breakpoint} { width: 50%; }
+ .width-60\%\@#{$breakpoint} { width: 60%; }
+ .width-66\%\@#{$breakpoint} { width: calc(100% / 1.5); }
+ .width-70\%\@#{$breakpoint} { width: 70%; }
+ .width-75\%\@#{$breakpoint} { width: 75%; }
+ .width-80\%\@#{$breakpoint} { width: 80%; }
+ .width-90\%\@#{$breakpoint} { width: 90%; }
+ .width-100\%\@#{$breakpoint} { width: 100%; }
+ .width-100vw\@#{$breakpoint} { width: 100vw; }
+ .width-auto\@#{$breakpoint} { width: auto; }
+
+ // height
+ .height-xxxxs\@#{$breakpoint} { height: var(--size-xxxxs, 0.25rem); }
+ .height-xxxs\@#{$breakpoint} { height: var(--size-xxxs, 0.5rem); }
+ .height-xxs\@#{$breakpoint} { height: var(--size-xxs, 0.75rem); }
+ .height-xs\@#{$breakpoint} { height: var(--size-xs, 1rem); }
+ .height-sm\@#{$breakpoint} { height: var(--size-sm, 1.5rem); }
+ .height-md\@#{$breakpoint} { height: var(--size-md, 2rem); }
+ .height-lg\@#{$breakpoint} { height: var(--size-lg, 3rem); }
+ .height-xl\@#{$breakpoint} { height: var(--size-xl, 4rem); }
+ .height-xxl\@#{$breakpoint} { height: var(--size-xxl, 6rem); }
+ .height-xxxl\@#{$breakpoint} { height: var(--size-xxxl, 8rem); }
+ .height-xxxxl\@#{$breakpoint} { height: var(--size-xxxxl, 16rem); }
+ .height-0\@#{$breakpoint} { height: 0; }
+ .height-10\%\@#{$breakpoint} { height: 10%; }
+ .height-20\%\@#{$breakpoint} { height: 20%; }
+ .height-25\%\@#{$breakpoint} { height: 25%; }
+ .height-30\%\@#{$breakpoint} { height: 30%; }
+ .height-33\%\@#{$breakpoint} { height: calc(100% / 3); }
+ .height-40\%\@#{$breakpoint} { height: 40%; }
+ .height-50\%\@#{$breakpoint} { height: 50%; }
+ .height-60\%\@#{$breakpoint} { height: 60%; }
+ .height-66\%\@#{$breakpoint} { height: calc(100% / 1.5); }
+ .height-70\%\@#{$breakpoint} { height: 70%; }
+ .height-75\%\@#{$breakpoint} { height: 75%; }
+ .height-80\%\@#{$breakpoint} { height: 80%; }
+ .height-90\%\@#{$breakpoint} { height: 90%; }
+ .height-100\%\@#{$breakpoint} { height: 100%; }
+ .height-100vh\@#{$breakpoint} { height: 100vh; }
+ .height-auto\@#{$breakpoint} { height: auto; }
+
+ // max-width
+ .max-width-xxxxs\@#{$breakpoint} { max-width: var(--max-width-xxxxs); }
+ .max-width-xxxs\@#{$breakpoint} { max-width: var(--max-width-xxxs); }
+ .max-width-xxs\@#{$breakpoint} { max-width: var(--max-width-xxs); }
+ .max-width-xs\@#{$breakpoint} { max-width: var(--max-width-xs); }
+ .max-width-sm\@#{$breakpoint} { max-width: var(--max-width-sm); }
+ .max-width-md\@#{$breakpoint} { max-width: var(--max-width-md); }
+ .max-width-lg\@#{$breakpoint} { max-width: var(--max-width-lg); }
+ .max-width-xl\@#{$breakpoint} { max-width: var(--max-width-xl); }
+ .max-width-xxl\@#{$breakpoint} { max-width: var(--max-width-xxl); }
+ .max-width-xxxl\@#{$breakpoint} { max-width: var(--max-width-xxxl); }
+ .max-width-xxxxl\@#{$breakpoint} { max-width: var(--max-width-xxxxl); }
+ .max-width-100\%\@#{$breakpoint} { max-width: 100%; }
+ .max-width-none\@#{$breakpoint} { max-width: none; }
+
+ // position
+ .position-relative\@#{$breakpoint} { position: relative; }
+ .position-absolute\@#{$breakpoint} { position: absolute; }
+ .position-fixed\@#{$breakpoint} { position: fixed; }
+ .position-sticky\@#{$breakpoint} { position: sticky; }
+ .position-static\@#{$breakpoint} { position: static; }
+
+ .inset-0\@#{$breakpoint} { top: 0; right: 0; bottom: 0; left: 0; }
+
+ .top-0\@#{$breakpoint} { top: 0; }
+ .top-50\%\@#{$breakpoint} { top: 50%; }
+ .top-xxxxs\@#{$breakpoint} { top: var(--space-xxxxs); }
+ .top-xxxs\@#{$breakpoint} { top: var(--space-xxxs); }
+ .top-xxs\@#{$breakpoint} { top: var(--space-xxs); }
+ .top-xs\@#{$breakpoint} { top: var(--space-xs); }
+ .top-sm\@#{$breakpoint} { top: var(--space-sm); }
+ .top-md\@#{$breakpoint} { top: var(--space-md); }
+ .top-lg\@#{$breakpoint} { top: var(--space-lg); }
+ .top-xl\@#{$breakpoint} { top: var(--space-xl); }
+ .top-xxl\@#{$breakpoint} { top: var(--space-xxl); }
+ .top-xxxl\@#{$breakpoint} { top: var(--space-xxxl); }
+ .top-xxxxl\@#{$breakpoint} { top: var(--space-xxxxl); }
+
+ .bottom-0\@#{$breakpoint} { bottom: 0; }
+ .bottom-unset\@#{$breakpoint} { bottom: unset; }
+ .bottom-50\%\@#{$breakpoint} { bottom: 50%; }
+ .bottom-xxxxs\@#{$breakpoint} { bottom: var(--space-xxxxs); }
+ .bottom-xxxs\@#{$breakpoint} { bottom: var(--space-xxxs); }
+ .bottom-xxs\@#{$breakpoint} { bottom: var(--space-xxs); }
+ .bottom-xs\@#{$breakpoint} { bottom: var(--space-xs); }
+ .bottom-sm\@#{$breakpoint} { bottom: var(--space-sm); }
+ .bottom-md\@#{$breakpoint} { bottom: var(--space-md); }
+ .bottom-lg\@#{$breakpoint} { bottom: var(--space-lg); }
+ .bottom-xl\@#{$breakpoint} { bottom: var(--space-xl); }
+ .bottom-xxl\@#{$breakpoint} { bottom: var(--space-xxl); }
+ .bottom-xxxl\@#{$breakpoint} { bottom: var(--space-xxxl); }
+ .bottom-xxxxl\@#{$breakpoint} { bottom: var(--space-xxxxl); }
+
+ .right-0\@#{$breakpoint} { right: 0; }
+ .right-50\%\@#{$breakpoint} { right: 50%; }
+ .right-xxxxs\@#{$breakpoint} { right: var(--space-xxxxs); }
+ .right-xxxs\@#{$breakpoint} { right: var(--space-xxxs); }
+ .right-xxs\@#{$breakpoint} { right: var(--space-xxs); }
+ .right-xs\@#{$breakpoint} { right: var(--space-xs); }
+ .right-sm\@#{$breakpoint} { right: var(--space-sm); }
+ .right-md\@#{$breakpoint} { right: var(--space-md); }
+ .right-lg\@#{$breakpoint} { right: var(--space-lg); }
+ .right-xl\@#{$breakpoint} { right: var(--space-xl); }
+ .right-xxl\@#{$breakpoint} { right: var(--space-xxl); }
+ .right-xxxl\@#{$breakpoint} { right: var(--space-xxxl); }
+ .right-xxxxl\@#{$breakpoint} { right: var(--space-xxxxl); }
+
+ .left-0\@#{$breakpoint} { left: 0; }
+ .left-50\%\@#{$breakpoint} { left: 50%; }
+ .left-xxxxs\@#{$breakpoint} { left: var(--space-xxxxs); }
+ .left-xxxs\@#{$breakpoint} { left: var(--space-xxxs); }
+ .left-xxs\@#{$breakpoint} { left: var(--space-xxs); }
+ .left-xs\@#{$breakpoint} { left: var(--space-xs); }
+ .left-sm\@#{$breakpoint} { left: var(--space-sm); }
+ .left-md\@#{$breakpoint} { left: var(--space-md); }
+ .left-lg\@#{$breakpoint} { left: var(--space-lg); }
+ .left-xl\@#{$breakpoint} { left: var(--space-xl); }
+ .left-xxl\@#{$breakpoint} { left: var(--space-xxl); }
+ .left-xxxl\@#{$breakpoint} { left: var(--space-xxxl); }
+ .left-xxxxl\@#{$breakpoint} { left: var(--space-xxxxl); }
+
+ // overflow
+ .overflow-hidden\@#{$breakpoint} { overflow: hidden; }
+ .overflow-auto\@#{$breakpoint} { overflow: auto; }
+ .momentum-scrolling\@#{$breakpoint} { -webkit-overflow-scrolling: touch; }
+ .overscroll-contain\@#{$breakpoint} { overscroll-behavior: contain; }
+
+ // visibility
+ .visible\@#{$breakpoint} { visibility: visible; }
+ .invisible\@#{$breakpoint} { visibility: hidden; }
+ }
+
+ @include breakpoint(#{$breakpoint}, "not all") {
+ .display\@#{$breakpoint} { display: none !important; }
+ }
+}
diff --git a/apps/web-shared/src/styles/base/_visibility.scss b/apps/web-shared/src/styles/base/_visibility.scss
new file mode 100644
index 0000000..ab6a516
--- /dev/null
+++ b/apps/web-shared/src/styles/base/_visibility.scss
@@ -0,0 +1,23 @@
+:root {
+ --display: block;
+}
+
+.is-visible {
+ display: var(--display) !important;
+}
+
+.is-hidden {
+ display: none !important;
+}
+
+html:not(.js) {
+ .no-js\:is-hidden {
+ display: none !important;
+ }
+}
+
+@media print {
+ .print\:is-hidden {
+ display: none !important;
+ }
+} \ No newline at end of file
diff --git a/apps/web-shared/src/styles/base/_z-index.scss b/apps/web-shared/src/styles/base/_z-index.scss
new file mode 100644
index 0000000..5af9ff3
--- /dev/null
+++ b/apps/web-shared/src/styles/base/_z-index.scss
@@ -0,0 +1,6 @@
+:root {
+ --z-index-header: 3; // e.g., main header
+ --z-index-popover: 5; // e.g., tooltips and dropdown
+ --z-index-fixed-element: 10; // e.g., 'back to top' button
+ --z-index-overlay: 15; // e.g., modals and dialogs
+} \ No newline at end of file
diff --git a/apps/web-shared/src/styles/components/alert.scss b/apps/web-shared/src/styles/components/alert.scss
new file mode 100644
index 0000000..9d9008d
--- /dev/null
+++ b/apps/web-shared/src/styles/components/alert.scss
@@ -0,0 +1,69 @@
+@use '../base' as *;
+
+/* --------------------------------
+
+File#: _1_alert
+Title: Alert
+Descr: Feedback message
+Usage: codyhouse.co/license
+
+-------------------------------- */
+
+.alert {
+ background-color: alpha(var(--color-primary), 0.2);
+ color: var(--color-contrast-higher);
+
+ // hide element
+ position: absolute;
+ clip: rect(1px, 1px, 1px, 1px);
+ clip-path: inset(50%);
+}
+
+.alert__icon {
+ color: var(--color-primary);
+}
+
+.alert__close-btn {
+ display: inline-block;
+
+ .icon {
+ display: block;
+ }
+
+ &:hover {
+ opacity: 0.7;
+ }
+}
+
+// themes
+.alert--success {
+ background-color: alpha(var(--color-success), 0.2);
+
+ .alert__icon {
+ color: var(--color-success);
+ }
+}
+
+.alert--error {
+ background-color: alpha(var(--color-error), 0.2);
+
+ .alert__icon {
+ color: var(--color-error);
+ }
+}
+
+.alert--warning {
+ background-color: alpha(var(--color-warning), 0.2);
+
+ .alert__icon {
+ color: var(--color-warning);
+ }
+}
+
+// toggle visibility
+.alert--is-visible {
+ position: static;
+ clip: auto;
+ clip-path: none;
+}
+
diff --git a/apps/web-shared/src/styles/components/autocomplete.scss b/apps/web-shared/src/styles/components/autocomplete.scss
new file mode 100644
index 0000000..cde3632
--- /dev/null
+++ b/apps/web-shared/src/styles/components/autocomplete.scss
@@ -0,0 +1,76 @@
+@use '../base' as *;
+@use 'circle-loader.scss' as *;
+
+/* --------------------------------
+
+File#: _2_autocomplete
+Title: Autocomplete
+Descr: Autocomplete plugin for input elements
+Usage: codyhouse.co/license
+
+-------------------------------- */
+
+:root {
+ --autocomplete-dropdown-vertical-gap: 4px; // gap between input and results list
+ --autocomplete-dropdown-max-height: 150px;
+ --autocomplete-dropdown-scrollbar-width: 6px; // custom scrollbar width - webkit browsers
+}
+
+// results dropdown
+.autocomplete__results {
+ position: absolute;
+ z-index: var(--z-index-popover, 5);
+ width: 100%;
+ left: 0;
+ top: calc(100% + var(--autocomplete-dropdown-vertical-gap));
+ background-color: var(--color-bg-light);
+ box-shadow: var(--inner-glow), var(--shadow-md);
+ border-radius: var(--radius-md);
+ opacity: 0;
+ visibility: hidden;
+ overflow: hidden;
+
+ .autocomplete--results-visible & {
+ opacity: 1;
+ visibility: visible;
+ }
+}
+
+.autocomplete__list {
+ max-height: var(--autocomplete-dropdown-max-height);
+ overflow: auto;
+ -webkit-overflow-scrolling: touch;
+
+ // custom scrollbar
+ &::-webkit-scrollbar { // scrollbar width
+ width: var(--autocomplete-dropdown-scrollbar-width);
+ }
+
+ &::-webkit-scrollbar-track { // progress bar
+ background-color: alpha(var(--color-contrast-higher), 0.08);
+ border-radius: 0;
+ }
+
+ &::-webkit-scrollbar-thumb { // handle
+ background-color: alpha(var(--color-contrast-higher), 0.12);
+ border-radius: 0;
+
+ &:hover {
+ background-color: alpha(var(--color-contrast-higher), 0.2);
+ }
+ }
+}
+
+// single result item
+.autocomplete__item {
+ cursor: pointer;
+
+ &:hover {
+ background-color: alpha(var(--color-contrast-higher), 0.075);
+ }
+
+ &:focus {
+ outline: none;
+ background-color: alpha(var(--color-primary), 0.15);
+ }
+}
diff --git a/apps/web-shared/src/styles/components/btn-states.scss b/apps/web-shared/src/styles/components/btn-states.scss
new file mode 100644
index 0000000..a2fc6c5
--- /dev/null
+++ b/apps/web-shared/src/styles/components/btn-states.scss
@@ -0,0 +1,51 @@
+@use '../base' as *;
+
+/* --------------------------------
+
+File#: _1_btn-states
+Title: Buttons states
+Descr: Multi-state button elements
+Usage: codyhouse.co/license
+
+-------------------------------- */
+
+.btn__content-a {
+ display: inline-flex;
+}
+
+.btn__content-b {
+ display: none;
+}
+
+.btn__content-a, .btn__content-b {
+ align-items: center;
+}
+
+.btn--state-b {
+ .btn__content-a {
+ display: none;
+ }
+
+ .btn__content-b {
+ display: inline-block; // fallback
+ display: inline-flex;
+ }
+}
+
+/* preserve button width when switching from state A to state B */
+.btn--preserve-width {
+ .btn__content-b {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ justify-content: center;
+ }
+
+ &.btn--state-b .btn__content-a {
+ display: inline-block; // fallback
+ display: inline-flex;
+ visibility: hidden;
+ }
+}
diff --git a/apps/web-shared/src/styles/components/chip.scss b/apps/web-shared/src/styles/components/chip.scss
new file mode 100644
index 0000000..1bb93db
--- /dev/null
+++ b/apps/web-shared/src/styles/components/chip.scss
@@ -0,0 +1,117 @@
+@use '../base' as *;
+
+/* --------------------------------
+
+File#: _1_chips
+Title: Chips
+Descr: A list of compact pieces of information
+Usage: codyhouse.co/license
+
+-------------------------------- */
+
+.chip {
+ /* reset - in case the class is applied to a <button> or an <a> */
+ border: 0;
+ color: inherit;
+ line-height: 1;
+ appearance: none;
+
+ display: inline-flex;
+ align-items: center;
+ border-radius: var(--radius-sm);
+
+ background-color: alpha(var(--color-contrast-higher), 0.1);
+ padding: var(--space-xxxs) 0;
+}
+
+.chip--outline {
+ background-color: transparent;
+ box-shadow: inset 0 0 0 1px alpha(var(--color-contrast-higher), 0.25);
+}
+
+.chip--error {
+ background-color: alpha(var(--color-error), 0.2);
+ color: var(--color-contrast-higher);
+}
+
+.chip--success {
+ background-color: alpha(var(--color-success), 0.2);
+ color: var(--color-contrast-higher);
+}
+
+.chip--warning {
+ background-color: alpha(var(--color-warning), 0.2);
+ color: var(--color-contrast-higher);
+}
+
+.chip--interactive {
+ cursor: pointer;
+
+ &:hover {
+ background-color: alpha(var(--color-contrast-higher), 0.2);
+ }
+
+ &:focus {
+ outline: none;
+ box-shadow: 0 0 0 3px alpha(var(--color-contrast-higher), 0.3);
+ }
+
+ &:focus:not(:focus-visible) {
+ box-shadow: none;
+ }
+}
+
+.chip__label {
+ padding: 0 var(--space-xxs);
+}
+
+.chip__img {
+ display: block;
+ width: 1.5em;
+ height: 1.5em;
+ border-radius: 50%;
+ object-fit: cover;
+}
+
+.chip__icon-wrapper {
+ display: flex;
+ width: 1.5em;
+ height: 1.5em;
+ border-radius: 50%;
+ background-color: alpha(var(--color-contrast-higher), 0.95);
+ color: var(--color-bg); /* icon color */
+
+ .icon {
+ display: block;
+ margin: auto;
+ }
+}
+
+.chip__btn {
+ @include reset;
+ display: flex;
+ width: 1em;
+ height: 1em;
+ background-color: alpha(var(--color-contrast-higher), 0.2);
+ border-radius: 50%;
+ cursor: pointer;
+ margin-right: 7px;
+
+ .icon {
+ display: block;
+ margin: 0 auto;
+ }
+
+ &:hover {
+ background-color: alpha(var(--color-contrast-higher), 0.3);
+ }
+
+ &:focus {
+ outline: none;
+ box-shadow: 0 0 0 2px alpha(var(--color-contrast-higher), 0.5);
+ }
+
+ &:focus:not(:focus-visible) {
+ box-shadow: none;
+ }
+}
diff --git a/apps/web-shared/src/styles/components/circle-loader.scss b/apps/web-shared/src/styles/components/circle-loader.scss
new file mode 100644
index 0000000..5116d39
--- /dev/null
+++ b/apps/web-shared/src/styles/components/circle-loader.scss
@@ -0,0 +1,315 @@
+@use '../base' as *;
+
+/* --------------------------------
+
+File#: _1_circle-loader
+Title: Circle Loader
+Descr: A collection of animated circle loaders
+Usage: codyhouse.co/license
+
+-------------------------------- */
+
+:root {
+ // v1
+ --circle-loader-v1-size: 48px;
+ --circle-loader-v1-stroke-width: 4px;
+
+ // v2
+ --circle-loader-v2-size: 64px;
+ --circle-loader-v2-stroke-width: 2;
+
+ // v3
+ --circle-loader-v3-size: 64px;
+
+ // v4
+ --circle-loader-v4-size: 48px;
+
+ // v5
+ --circle-loader-v5-size: 64px;
+
+ // v6
+ --circle-loader-v6-size: 48px;
+}
+
+.circle-loader {
+ position: relative;
+ display: inline-block;
+}
+
+@supports (animation-name: this) {
+ .circle-loader__label {
+ @include srHide; // show label only to screen readers if animations are supported
+ }
+}
+
+// loader v1 - rotation
+@supports (animation-name: this) {
+ .circle-loader--v1 {
+ transform: rotate(45deg);
+ will-change: transform;
+ animation: circle-loader-1 0.75s infinite var(--ease-in-out);
+
+ .circle-loader__circle {
+ width: var(--circle-loader-v1-size); // loader width
+ height: var(--circle-loader-v1-size); // loader height
+ border-width: var(--circle-loader-v1-stroke-width); // loader stroke width
+ border-style: solid;
+ border-color: alpha(var(--color-primary), 0.2); // loader base color
+ border-radius: 50%;
+
+ &::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ border-width: inherit;
+ border-style: inherit;
+ border-color: transparent;
+ border-top-color: var(--color-primary); // loader fill color
+ border-radius: inherit;
+ }
+ }
+ }
+}
+
+@keyframes circle-loader-1 {
+ 0% {
+ transform: rotate(45deg);
+ }
+
+ 100% {
+ transform: rotate(405deg);
+ }
+}
+
+// loader v2 - filling
+@supports (animation-name: this) {
+ .circle-loader--v2 {
+ will-change: transform;
+ animation: circle-loader-spinning-main 1.4s infinite linear;
+
+ .circle-loader__svg {
+ display: block;
+ width: var(--circle-loader-v2-size);
+ height: var(--circle-loader-v2-size);
+ color: var(--color-primary); // loader color
+
+ > * {
+ stroke-width: var(--circle-loader-v2-stroke-width); // loader stroke width
+ }
+ }
+
+ .circle-loader__base {
+ opacity: 0.2;
+ }
+
+ .circle-loader__fill {
+ stroke-linecap: round; // optional - remove if you prefer butt caps
+ stroke-dashoffset: 0;
+ stroke-dasharray: 90 120;
+ transform-origin: 50% 50%;
+ transform: rotate(45deg);
+ animation: circle-loader-dash 1.4s infinite;
+ }
+ }
+}
+
+@keyframes circle-loader-dash {
+ 0%, 20% {
+ stroke-dashoffset: 0;
+ transform: rotate(0);
+ }
+
+ 50%, 70% {
+ stroke-dashoffset: 80;
+ transform: rotate(270deg);
+ }
+
+ 100% {
+ stroke-dashoffset: 0;
+ transform: rotate(360deg);
+ }
+}
+
+@keyframes circle-loader-spinning-main {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+// loader v3 - drop
+@supports (animation-name: this) {
+ .circle-loader--v3 {
+ width: var(--circle-loader-v3-size); // loader width
+ height: var(--circle-loader-v3-size); // loader height
+
+ .circle-loader__circle {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ border-radius: 50%;
+ background-color: var(--color-primary); // loader color
+ transform: scale(0);
+ opacity: 0.8;
+ will-change: transform, opacity;
+ animation: circle-loader-3 1.2s infinite;
+ }
+
+ .circle-loader__circle--2nd {
+ animation-delay: 0.6s; // this should be half the duration of animation
+ }
+ }
+}
+
+@keyframes circle-loader-3 {
+ to {
+ transform: scale(1);
+ opacity: 0;
+ }
+}
+
+// loader v4 - eclipse
+@supports (animation-name: this) {
+ .circle-loader--v4 {
+ width: var(--circle-loader-v4-size); // loader width
+ height: var(--circle-loader-v4-size); // loader height
+ border-radius: 50%;
+ overflow: hidden;
+
+ .circle-loader__mask,
+ .circle-loader__circle {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ border-radius: inherit;
+ }
+
+ .circle-loader__mask {
+ clip-path: circle(calc(0.5 * var(--circle-loader-v4-size))); // fix iOS issue - it needs to be = half size of loader
+ }
+
+ .circle-loader__circle--1st {
+ background-color: var(--color-contrast-low); // loader base color
+ }
+
+ .circle-loader__circle--2nd {
+ background-color: var(--color-primary); // loader fill color
+ will-change: transform;
+ transform-origin: 50% 100%;
+ animation: circle-loader-4 1.2s infinite cubic-bezier(.23, .9, .75, .1);
+ transform: translateX(-100%);
+ }
+ }
+}
+
+@keyframes circle-loader-4 {
+ to {
+ transform: translateX(100%);
+ }
+}
+
+// loader v5 - bounce
+@supports (animation-name: this) {
+ .circle-loader--v5 {
+ font-size: var(--circle-loader-v5-size); // loader size - if you edit this value all elements scale accordingly
+ width: 1em;
+ height: 1em;
+
+ .circle-loader__label {
+ font-size: 1rem;
+ }
+
+ .circle-loader__ball {
+ position: absolute;
+ top: 0;
+ left: calc(50% - 0.140625em);
+ width: 0.28125em;
+ height: 0.28125em;
+ background-color: var(--color-primary);
+ border-radius: 50%;
+ animation: circle-loader-5-ball 0.8s infinite;
+ }
+
+ .circle-loader__shadow {
+ position: absolute;
+ bottom: 0;
+ left: calc(50% - 0.15625em);
+ width: 0.3125em;
+ height: 0.3125em;
+ background-color: var(--color-contrast-lower);
+ border-radius: 50%;
+ transform: scaleY(0.4) scaleX(1.2);
+ animation: circle-loader-5-shadow 0.8s infinite;
+ }
+ }
+}
+
+@keyframes circle-loader-5-ball {
+ 0% {
+ transform: translateY(0);
+ animation-timing-function: cubic-bezier(.61, .12, .85, .4);
+ }
+
+ 50% {
+ transform: translateY(0.5625em);
+ animation-timing-function: cubic-bezier(.12, .59, .46, .95);
+ }
+
+ 100% {
+ transform: translateY(0);
+ }
+}
+
+@keyframes circle-loader-5-shadow {
+ 0% {
+ transform: scaleY(0.4) scaleX(1.2);
+ background-color: var(--color-contrast-lower);
+ animation-timing-function: cubic-bezier(.61, .12, .85, .4);
+ }
+
+ 50% {
+ transform: scaleY(0.2) scaleX(0.6);
+ background-color: var(--color-contrast-low);
+ animation-timing-function: cubic-bezier(.12, .59, .46, .95);
+ }
+
+ 100% {
+ transform: scaleY(0.4) scaleX(1.2);
+ background-color: var(--color-contrast-lower);
+ }
+}
+
+// loader v6 - worm
+@supports (animation-name: this) {
+ .circle-loader--v6 {
+ .circle-loader__svg {
+ display: block;
+ width: var(--circle-loader-v6-size);
+ height: var(--circle-loader-v6-size);
+ color: var(--color-primary); // loader color
+ }
+
+ .circle-loader__fill {
+ stroke-width: 8px; // loader stroke width
+ stroke-dashoffset: 35;
+ stroke-dasharray: 36 36;
+ animation: circle-loader-6 1.5s infinite;
+ }
+ }
+}
+
+@keyframes circle-loader-6 {
+ 0%, 100% {
+ stroke-dashoffset: 35;
+ }
+
+ 50% {
+ stroke-dashoffset: -35;
+ }
+}
diff --git a/apps/web-shared/src/styles/components/custom-checkbox.scss b/apps/web-shared/src/styles/components/custom-checkbox.scss
new file mode 100644
index 0000000..5722ee0
--- /dev/null
+++ b/apps/web-shared/src/styles/components/custom-checkbox.scss
@@ -0,0 +1,131 @@
+@use '../base' as *;
+
+/* --------------------------------
+
+File#: _1_custom-checkbox
+Title: Custom Checkbox
+Descr: Replace the native checkbox button with a custom element (e.g., an icon)
+Usage: codyhouse.co/license
+
+-------------------------------- */
+
+:root {
+ --custom-checkbox-size: 20px;
+ --custom-checkbox-radius: 4px;
+ --custom-checkbox-border-width: 1px;
+ --custom-checkbox-marker-size: 18px;
+}
+
+.custom-checkbox {
+ position: relative;
+ z-index: 1;
+ display: inline-block;
+ font-size: var(--custom-checkbox-size);
+}
+
+.custom-checkbox__input {
+ position: relative;
+ /* hide native input */
+ margin: 0;
+ padding: 0;
+ opacity: 0;
+ height: 1em;
+ width: 1em;
+ display: block;
+ z-index: 1;
+}
+
+.custom-checkbox__control {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ top: 0;
+ left: 0;
+ z-index: -1;
+ pointer-events: none;
+ color: alpha(var(--color-contrast-low), 0.65); /* unchecked color */
+
+ &::before, &::after {
+ content: '';
+ position: absolute;
+ }
+
+ &::before { /* focus circle */
+ width: 160%;
+ height: 160%;
+ background-color: currentColor;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%) scale(0);
+ opacity: 0;
+ border-radius: 50%;
+ will-change: transform;
+ }
+
+ &::after { /* custom checkbox */
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+
+ /* custom checkbox style */
+ background-color: var(--color-bg);
+ border-radius: var(--custom-checkbox-radius);
+ box-shadow: inset 0 0 0 var(--custom-checkbox-border-width) currentColor, var(--shadow-xs); /* border */
+ }
+}
+
+.custom-checkbox__input:checked ~ .custom-checkbox__control,
+.custom-checkbox__input:indeterminate ~ .custom-checkbox__control {
+ &::after {
+ background-color: currentColor;
+ background-repeat: no-repeat;
+ background-position: center;
+ background-size: var(--custom-checkbox-marker-size);
+ box-shadow: none;
+ }
+}
+
+.custom-checkbox__input:checked ~ .custom-checkbox__control {
+ color: var(--color-primary); /* checked color */
+
+ &::after {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpolyline points='2.5 8 6.5 12 13.5 3' fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5'/%3E%3C/svg%3E");
+ }
+}
+
+.custom-checkbox__input:indeterminate ~ .custom-checkbox__control {
+ &::after {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cline x1='2' y1='8' x2='14' y2='8' fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'/%3E%3C/svg%3E");
+ }
+}
+
+.custom-checkbox__input:active ~ .custom-checkbox__control {
+ transform: scale(0.9);
+}
+
+.custom-checkbox__input:checked:active ~ .custom-checkbox__control,
+.custom-checkbox__input:indeterminate:active ~ .custom-checkbox__control {
+ transform: scale(1);
+}
+
+.custom-checkbox__input:focus ~ .custom-checkbox__control::before {
+ opacity: 0.2;
+ transform: translate(-50%, -50%) scale(1);
+}
+
+/* --icon */
+.custom-checkbox--icon {
+ --custom-checkbox-size: 32px;
+
+ .custom-checkbox__control::after {
+ display: none;
+ }
+
+ .icon {
+ display: block;
+ color: inherit;
+ position: relative;
+ z-index: 1;
+ }
+}
diff --git a/apps/web-shared/src/styles/components/custom-select.scss b/apps/web-shared/src/styles/components/custom-select.scss
new file mode 100644
index 0000000..9cd3b5e
--- /dev/null
+++ b/apps/web-shared/src/styles/components/custom-select.scss
@@ -0,0 +1,158 @@
+@use '../base' as *;
+
+/* --------------------------------
+
+File#: _1_custom-select
+Title: Custom Select
+Descr: Custom Select Control
+Usage: codyhouse.co/license
+
+-------------------------------- */
+
+:root {
+ // --default variation only 👇
+ --select-icon-size: 16px;
+ --select-icon-right-margin: var(--space-sm); // icon margin right
+ --select-text-icon-gap: var(--space-xxxs); // gap between text and icon
+}
+
+.select {
+ position: relative;
+}
+
+.select__input {
+ width: 100%;
+ height: 100%;
+ padding-right: calc(var(--select-icon-size) + var(--select-icon-right-margin) + var(--select-text-icon-gap)) !important;
+}
+
+.select__icon {
+ width: var(--select-icon-size);
+ height: var(--select-icon-size);
+ pointer-events: none;
+ position: absolute;
+ right: var(--select-icon-right-margin);
+ top: 50%;
+ transform: translateY(-50%);
+}
+
+// --custom-dropdown
+:root {
+ --select-dropdown-gap: 4px; // distance between select control and custom dropdown
+}
+
+.select__button { // created in JS - custom select control
+ width: 100%;
+}
+
+.select__button[aria-expanded="true"] {
+ // custom select control if dropdown = visible
+}
+
+.select__dropdown { // created in JS - custom select dropdown
+ position: absolute;
+ left: 0;
+ top: 100%;
+ min-width: 200px;
+ max-height: 1px; // updated in JS
+ background-color: var(--color-bg-light);
+ box-shadow: var(--inner-glow), var(--shadow-md);
+ padding: var(--space-xxxs) 0;
+ border-radius: var(--radius-md);
+ z-index: var(--z-index-popover, 5);
+ margin-top: var(--select-dropdown-gap);
+ margin-bottom: var(--select-dropdown-gap);
+ overflow: auto;
+
+ // use rem units
+ @include spaceUnit(1rem);
+ @include textUnit(1rem);
+
+ visibility: hidden;
+ opacity: 0;
+}
+
+.select__dropdown--right { // change dropdown position based on the available space
+ right: 0;
+ left: auto;
+}
+
+.select__dropdown--up {
+ bottom: 100%;
+ top: auto;
+}
+
+.select__button[aria-expanded="true"] + .select__dropdown {
+ visibility: visible;
+ opacity: 1;
+}
+
+// custom <optgroup> list - include all <option>s if no <optgroup> available
+.select__list {
+ list-style: none !important;
+}
+
+.select__list:not(:first-of-type) {
+ padding-top: var(--space-xxs);
+}
+
+.select__list:not(:last-of-type) {
+ border-bottom: 1px solid alpha(var(--color-contrast-higher), 0.1);
+ padding-bottom: var(--space-xxs);
+}
+
+.select__item { // single item inside .select__list
+ display: flex;
+ align-items: center;
+ padding: var(--space-xxs) var(--space-sm);
+ color: var(--color-contrast-high);
+ width: 100%;
+ text-align: left;
+ // truncate text
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.select__item--optgroup { // custom <optgroup> label
+ font-size: var(--text-sm);
+ color: var(--color-contrast-medium);
+}
+
+.select__item--option { // custom <option> label
+ cursor: pointer;
+
+ &:hover {
+ background-color: alpha(var(--color-contrast-higher), 0.075);
+ }
+
+ &:focus {
+ outline: none;
+ background-color: alpha(var(--color-primary), 0.15);
+ }
+
+ &[aria-selected=true] { // selected option
+ background-color: var(--color-primary);
+ color: var(--color-white);
+ position: relative;
+ @include fontSmooth;
+
+ &::after { // check icon next to the selected language
+ content: '';
+ display: block;
+ height: 1em;
+ width: 1em;
+ margin-left: auto;
+ background-color: currentColor;
+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpolyline stroke-width='2' stroke='%23ffffff' fill='none' stroke-linecap='round' stroke-linejoin='round' points='1,9 5,13 15,3 '/%3E%3C/svg%3E");
+ }
+
+ &:focus {
+ box-shadow: inset 0 0 0 2px var(--color-primary-dark);
+ }
+ }
+}
+
+html:not(.js ) .select .icon { // hide icon if JS = disabled
+ display: none;
+}
diff --git a/apps/web-shared/src/styles/components/details.scss b/apps/web-shared/src/styles/components/details.scss
new file mode 100644
index 0000000..b4c122d
--- /dev/null
+++ b/apps/web-shared/src/styles/components/details.scss
@@ -0,0 +1,57 @@
+@use '../base' as *;
+
+/* --------------------------------
+
+File#: _1_details
+Title: Details
+Descr: A button that toggles the visibility of additional information
+Usage: codyhouse.co/license
+
+-------------------------------- */
+
+.details {}
+
+.details__summary {
+ display: inline-block;
+ cursor: pointer;
+ user-select: none;
+
+ &:hover {
+ color: var(--color-primary);
+ }
+
+ &:focus {
+ outline: 2px solid alpha(var(--color-primary), 0.2);
+ outline-offset: 4px;
+ }
+
+ .icon {
+ flex-shrink: 0;
+ }
+}
+
+// if JS = enabled
+.js {
+ .details__summary {
+ list-style: none; // remove summary default icon
+ }
+
+ .details__summary::-webkit-details-marker {
+ display: none; // remove default icon in webkit browsers
+ }
+
+ .details__summary[aria-expanded="true"] .icon {
+ transform: rotate(90deg); // rotate icon when content is visible
+ }
+
+ .details__content[aria-hidden="true"] {
+ display: none;
+ }
+}
+
+// if JS = disabled
+html:not(.js) .details__summary {
+ .icon {
+ display: none;
+ }
+}
diff --git a/apps/web-shared/src/styles/components/dropdown.scss b/apps/web-shared/src/styles/components/dropdown.scss
new file mode 100644
index 0000000..c5ded33
--- /dev/null
+++ b/apps/web-shared/src/styles/components/dropdown.scss
@@ -0,0 +1,98 @@
+@use '../base' as *;
+
+/* --------------------------------
+
+File#: _2_dropdown
+Title: Dropdown
+Descr: A hoverable link that toggles the visibility of a dropdown list
+Usage: codyhouse.co/license
+
+-------------------------------- */
+
+:root {
+ --dropdown-item-padding: var(--space-xxs) var(--space-sm);
+}
+
+.dropdown {
+ position: relative;
+}
+
+.dropdown__menu {
+ border-radius: var(--radius-md);
+ background-color: var(--color-bg-light);
+ box-shadow: var(--inner-glow), var(--shadow-sm);
+ z-index: var(--z-index-popover, 5);
+ position: absolute;
+ left: 0;
+ top: 100%;
+ opacity: 0;
+ visibility: hidden;
+}
+
+.dropdown__wrapper {
+ max-height: 24px;
+}
+
+@media (pointer: fine) { // user has pointing device (e.g., mouse)
+ .dropdown__wrapper,
+ .open-dropdown {
+ &:hover .dropdown__menu,
+ &:focus .dropdown__menu {
+ opacity: 1;
+ visibility: visible;
+ }
+ }
+
+ .dropdown__sub-wrapper:hover > .dropdown__menu {
+ left: 100%;
+ }
+}
+
+@media not all and (pointer: fine) {
+ .dropdown__trigger-icon {
+ display: none;
+ }
+}
+
+.dropdown__item {
+ display: block;
+ text-decoration: none;
+ color: var(--color-contrast-high);
+ padding: var(--dropdown-item-padding);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ &:hover, &.dropdown__item--hover {
+ background-color: alpha(var(--color-contrast-higher), 0.075);
+ }
+}
+
+.dropdown__separator { // h line divider
+ height: 1px;
+ background-color: var(--color-contrast-lower);
+ margin: var(--dropdown-item-padding);
+}
+
+.dropdown__sub-wrapper {
+ position: relative;
+
+ > .dropdown__item { // item w/ right arrow
+ position: relative;
+ padding-right: calc(var(--space-sm) + 12px);
+
+ .icon { // right arrow
+ position: absolute;
+ display: block;
+ width: 12px;
+ height: 12px;
+ right: var(--space-xxs);
+ top: calc(50% - 6px);
+ }
+ }
+
+ > .dropdown__menu { // sub menu
+ top: calc(var(--space-xxs) * -1);
+ box-shadow: var(--inner-glow), var(--shadow-md);
+ }
+}
diff --git a/apps/web-shared/src/styles/components/form-validator.scss b/apps/web-shared/src/styles/components/form-validator.scss
new file mode 100644
index 0000000..cc9f9a3
--- /dev/null
+++ b/apps/web-shared/src/styles/components/form-validator.scss
@@ -0,0 +1,18 @@
+@use '../base' as *;
+
+/* --------------------------------
+
+File#: _1_form-validator
+Title: Form Validator
+Descr: A plugin to validate form fields
+Usage: codyhouse.co/license
+
+-------------------------------- */
+
+.form-validate__error-msg {
+ display: none; // hide error message by default
+
+ .form-validate__input-wrapper--error & {
+ display: block; // show error message
+ }
+}
diff --git a/apps/web-shared/src/styles/components/interactive-table.scss b/apps/web-shared/src/styles/components/interactive-table.scss
new file mode 100644
index 0000000..f239c62
--- /dev/null
+++ b/apps/web-shared/src/styles/components/interactive-table.scss
@@ -0,0 +1,156 @@
+@use '../base' as *;
+@use 'menu.scss' as *;
+@use 'menu-bar.scss' as *;
+
+/* --------------------------------
+
+File#: _3_interactive-table
+Title: Interactive Table
+Descr: Table with the option of sorting data and selecting rows to perform specific actions
+Usage: codyhouse.co/license
+
+-------------------------------- */
+
+.int-table {
+ overflow: hidden;
+ border-bottom: 2px solid var(--color-contrast-lower);
+}
+
+.int-table__inner {
+ position: relative;
+ overflow: auto;
+
+ &::-webkit-scrollbar { // custom scrollbar style
+ height: 8px;
+ width: 8px;
+ }
+
+ &::-webkit-scrollbar-track { // progress bar
+ background-color: var(--color-contrast-lower);
+ }
+
+ &::-webkit-scrollbar-thumb { // handle
+ background-color: alpha(var(--color-contrast-higher), 0.9);
+ border-radius: 50em;
+ }
+
+ &::-webkit-scrollbar-thumb:hover {
+ background-color: var(--color-contrast-higher);
+ }
+}
+
+.int-table__table {
+ width: 100%;
+}
+
+.int-table__header {
+ .int-table__cell {
+ background-color: var(--color-bg);
+ box-shadow: 0 2px 0 var(--color-contrast-lower);
+ }
+}
+
+.int-table__body {
+ .int-table__row {
+ border-bottom: 1px solid var(--color-contrast-lower);
+
+ &:last-child {
+ border-bottom: none;
+ }
+ }
+
+ .int-table__row--checked {
+ background-color: alpha(var(--color-primary), 0.1);
+ border-color: alpha(var(--color-primary), 0.25);
+ }
+}
+
+.int-table__cell { // standard cell
+ padding: var(--space-xxxs);
+}
+
+.int-table__cell--th { // header cell
+ font-weight: 600;
+}
+
+.int-table__cell--sort { // header cell with sorting option
+ user-select: none;
+
+ &:hover, &:focus-within {
+ background-color: alpha(var(--color-contrast-higher), 0.075);
+ }
+
+ &:hover {
+ cursor: pointer;
+ }
+}
+
+.int-table__cell--focus {
+ background-color: alpha(var(--color-primary), 0.15);
+}
+
+.int-table__sort-icon { // sorting icon indicator
+ .arrow-up, .arrow-down {
+ fill: alpha(var(--color-contrast-higher), 0.3);
+ }
+}
+
+.int-table__cell--asc .int-table__sort-icon .arrow-up,
+.int-table__cell--desc .int-table__sort-icon .arrow-down {
+ fill: var(--color-contrast-higher);
+}
+
+.int-table__checkbox {
+ --custom-checkbox-size: 18px;
+ --custom-checkbox-marker-size: 16px;
+ display: block;
+ width: var(--custom-checkbox-size);
+ height: var(--custom-checkbox-size);
+}
+
+.int-table__menu-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 2em;
+ cursor: pointer;
+
+ .icon {
+ display: block;
+ width: 16px;
+ height: 16px;
+ }
+}
+
+// --sticky-header
+.int-table--sticky-header {
+ position: relative;
+ z-index: 1;
+
+ .int-table__inner {
+ max-height: 605px;
+ }
+
+ .int-table__header {
+ .int-table__cell {
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ }
+ }
+}
+
+// actions
+.int-table-actions {
+ .menu-bar {
+ --menu-bar-button-size: 38px; // size of the menu buttons
+ --menu-bar-icon-size: 16px; // size of the icons inside the buttons
+ --menu-bar-horizontal-gap: var(--space-xxxxs); // horizontal gap between buttons
+ --menu-bar-vertical-gap: 4px; // vertical gap between buttons and labels (tooltips)
+ --menu-bar-label-size: var(--text-xs); // label font size
+ }
+
+ .menu-bar__icon {
+ color: alpha(var(--color-contrast-higher), 0.5);
+ }
+}
diff --git a/apps/web-shared/src/styles/components/list.scss b/apps/web-shared/src/styles/components/list.scss
new file mode 100644
index 0000000..df600a3
--- /dev/null
+++ b/apps/web-shared/src/styles/components/list.scss
@@ -0,0 +1,195 @@
+@use '../base' as *;
+
+/* --------------------------------
+
+File#: _1_list
+Title: List
+Descr: Custom list component
+Usage: codyhouse.co/license
+
+-------------------------------- */
+
+:root {
+ --list-space-y: 0.375em; // vertical gaps
+ --list-offset: 1em; // sublist horizontal offset
+ --list-line-height-multiplier: 1; // line-height multiplier
+}
+
+.list, .text-component .list {
+ padding-left: 0;
+ list-style: none;
+
+ ul, ol {
+ list-style: none;
+ margin: 0; // reset
+ margin-top: calc((var(--list-space-y) / 2) * var(--text-space-y-multiplier, 1));
+ padding-top: calc((var(--list-space-y) / 2) * var(--text-space-y-multiplier, 1));
+ padding-left: var(--list-offset);
+ }
+
+ li {
+ padding-bottom: calc((var(--list-space-y) / 2) * var(--text-space-y-multiplier, 1));
+ margin-bottom: calc((var(--list-space-y) / 2) * var(--text-space-y-multiplier, 1));
+ line-height: calc(var(--body-line-height) * var(--list-line-height-multiplier));
+ }
+
+ > li:last-child, ul > li:last-child, ol > li:last-child {
+ margin-bottom: 0;
+ }
+
+ &:not(.list--border) > li:last-child, ul > li:last-child, ol > li:last-child {
+ padding-bottom: 0;
+ }
+}
+
+/* #region (ul + ol) */
+.list--ul, .text-component .list--ul,
+.list--ol, .text-component .list--ol {
+ --list-offset: calc(var(--list-bullet-size) + var(--list-bullet-margin-right));
+
+ ul, ol {
+ padding-left: 0;
+ }
+
+ li {
+ @supports (--css: variables) {
+ padding-left: var(--list-offset) !important;
+ }
+ }
+
+ li::before {
+ display: inline-flex;
+ justify-content: center;
+ align-items: center;
+ vertical-align: middle;
+ position: relative;
+ top: -0.1em;
+
+ @supports (--css: variables) {
+ width: var(--list-bullet-size) !important;
+ height: var(--list-bullet-size) !important;
+ margin-left: calc(var(--list-bullet-size) * -1) !important;
+ left: calc(var(--list-bullet-margin-right) * -1) !important;
+ }
+ }
+}
+
+// unordered list
+.list--ul, .text-component .list--ul {
+ --list-bullet-size: 7px; // dot width and height
+ --list-bullet-margin-right: 12px; // gap between bullet and content
+
+ > li {
+ padding-left: 19px; // IE fallback
+ }
+
+ > li::before { // bullet
+ content: '';
+ border-radius: 50%;
+ color: var(--color-contrast-lower); // bullet color
+ background-color: currentColor;
+
+ // IE fallback
+ width: 7px;
+ height: 7px;
+ margin-left: -7px;
+ left: -12px;
+ // end - IE fallback
+ }
+
+ ul li::before {
+ background-color: transparent;
+ box-shadow: inset 0 0 0 2px currentColor;
+ }
+}
+
+// ordered list
+.list--ol, .text-component .list--ol {
+ --list-bullet-size: 26px; // ⚠️ use px or rem units - circle width and height
+ --list-bullet-margin-right: 6px; // ⚠️ use px or rem units - gap between circle and content
+ --list-bullet-font-size: 14px; // ⚠️ use px or rem units - bullet font size
+ counter-reset: list-items;
+
+ > li {
+ counter-increment: list-items;
+ padding-left: 32px; // IE fallback
+ }
+
+ ol {
+ counter-reset: list-items;
+ }
+
+ > li::before {
+ content: counter(list-items);
+ font-size: var(--list-bullet-font-size, 14px);
+ background-color: var(--color-contrast-lower);
+ color: var(--color-contrast-high);
+ line-height: 1;
+ border-radius: 50%;
+
+ // IE fallback
+ width: 26px;
+ height: 26px;
+ margin-left: -26px;
+ left: -6px;
+ // end - IE fallback
+ }
+
+ ol > li::before {
+ background-color: transparent;
+ box-shadow: inset 0 0 0 2px var(--color-contrast-lower);
+ }
+}
+/* #endregion */
+
+/* #region (border) */
+.list--border, .text-component .list--border { // show border divider among list items
+ li:not(:last-child) {
+ border-bottom: 1px solid var(--color-contrast-lower);
+ }
+
+ ul, ol {
+ border-top: 1px solid var(--color-contrast-lower);
+ }
+}
+/* #endregion */
+
+/* #region (icons) */
+.list--icons, .text-component .list--icons { // use icons as bullet points
+ --list-bullet-size: 24px;
+ --list-bullet-margin-right: 8px; // gap between icon and text
+ --list-offset: calc(var(--list-bullet-size) + var(--list-bullet-margin-right));
+
+ ul, ol {
+ padding-left: 32px; // IE fallback
+
+ @supports (--css: variables) {
+ padding-left: var(--list-offset);
+ }
+ }
+}
+
+.list__icon {
+ position: relative;
+
+ // IE fallback
+ width: 24px;
+ height: 24px;
+ margin-right: 8px;
+
+ &:not(.top-0) {
+ top: calc((1em * var(--body-line-height) - 24px) / 2);
+ }
+ // end - IE fallback
+
+ @supports (--css: variables) {
+ width: var(--list-bullet-size);
+ height: var(--list-bullet-size);
+ margin-right: var(--list-bullet-margin-right);
+
+ &:not(.top-0) {
+ top: calc((1em * var(--body-line-height) * var(--list-line-height-multiplier) - var(--list-bullet-size)) / 2);
+ }
+ }
+}
+/* #endregion */
diff --git a/apps/web-shared/src/styles/components/menu-bar.scss b/apps/web-shared/src/styles/components/menu-bar.scss
new file mode 100644
index 0000000..3f70fbe
--- /dev/null
+++ b/apps/web-shared/src/styles/components/menu-bar.scss
@@ -0,0 +1,139 @@
+@use '../base' as *;
+@use 'menu.scss' as *;
+
+/* --------------------------------
+
+File#: _2_menu-bar
+Title: Menu Bar
+Descr: Application menu with a list of common actions that users can perform
+Usage: codyhouse.co/license
+
+-------------------------------- */
+
+:root {
+ --menu-bar-button-size: 2.5em; // size of the menu buttons
+ --menu-bar-icon-size: 1em; // size of the icons inside the buttons
+ --menu-bar-horizontal-gap: var(--space-xxs); // horizontal gap between buttons
+ --menu-bar-vertical-gap: 4px; // vertical gap between buttons and labels (tooltips)
+ --menu-bar-label-size: var(--text-xs); // label font size
+}
+
+.menu-bar {
+ list-style: none;
+ display: inline-flex;
+ align-items: center;
+}
+
+.menu-bar__item { // menu button
+ position: relative;
+ display: inline-block; // flex fallback
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: var(--menu-bar-button-size);
+ width: var(--menu-bar-button-size);
+ border-radius: 50%;
+ cursor: pointer;
+
+ &:not(:last-child) {
+ margin-right: var(--menu-bar-horizontal-gap);
+ }
+
+ &:hover,
+ &.menu-control--active {
+ background-color: alpha(var(--color-contrast-higher), 0.1);
+
+ > .menu-bar__icon {
+ color: var(--color-contrast-higher);
+ }
+
+ > .menu-bar__label { // show label
+ clip: auto;
+ clip-path: none;
+ height: auto;
+ width: auto;
+ }
+ }
+
+ &:focus {
+ outline: none;
+ background-color: alpha(var(--color-primary), 0.1);
+ }
+
+ &:active {
+ background-color: var(--color-contrast-low);
+ }
+
+ &:focus:active {
+ background-color: alpha(var(--color-primary), 0.2);
+ }
+}
+
+.menu-bar__item--trigger { // button used to show hidden actions - visibile only if menu = collapsed
+ display: none;
+}
+
+.menu-bar__icon {
+ display: block;
+ color: var(--color-contrast-high);
+ font-size: var(--menu-bar-icon-size); // set icon size
+}
+
+.menu-bar__label { // label visible on :hover
+ // hide
+ position: absolute;
+ z-index: var(--z-index-popover, 5);
+ clip: rect(1px, 1px, 1px, 1px);
+ clip-path: inset(50%);
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ white-space: nowrap;
+ // style
+ top: 100%;
+ left: 50%;
+ transform: translateX(-50%) translateY(var(--menu-bar-vertical-gap));
+ padding: var(--space-xxs) var(--space-xs);
+ color: var(--color-bg);
+ background-color: alpha(var(--color-contrast-higher), 0.95);
+ border-radius: var(--radius-md);
+ font-size: var(--menu-bar-label-size);
+ @include fontSmooth;
+ pointer-events: none;
+ user-select: none;
+}
+
+.menu-bar--collapsed { // mobile layout style
+ .menu-bar__item--hide { // hide buttons
+ display: none;
+ }
+
+ .menu-bar__item--trigger { // show submenu trigger
+ display: inline-block; // flex fallback
+ display: flex;
+ }
+}
+
+// detect when the menu needs to switch from the mobile layout to an expanded one - used in JS
+.js {
+ .menu-bar {
+ opacity: 0; // hide menu bar while it is initialized in JS
+
+ &::before {
+ display: none;
+ content: 'collapsed';
+ }
+ }
+
+ .menu-bar--loaded {
+ opacity: 1;
+ }
+
+ @each $breakpoint, $value in $breakpoints {
+ .menu-bar--expanded\@#{$breakpoint}::before {
+ @include breakpoint(#{$breakpoint}) {
+ content: 'expanded';
+ }
+ }
+ }
+}
diff --git a/apps/web-shared/src/styles/components/menu.scss b/apps/web-shared/src/styles/components/menu.scss
new file mode 100644
index 0000000..8e211a5
--- /dev/null
+++ b/apps/web-shared/src/styles/components/menu.scss
@@ -0,0 +1,81 @@
+@use '../base' as *;
+
+/* --------------------------------
+
+File#: _1_menu
+Title: Menu
+Descr: Application menu that provides access to a set of functionalities
+Usage: codyhouse.co/license
+
+-------------------------------- */
+
+.menu {
+ --menu-vertical-gap: 5px; // vertical gap between the Menu element and its control
+ --menu-item-padding: var(--space-xxxs) var(--space-xs);
+ list-style: none;
+ position: fixed; // top/left position set in JS
+ background-color: var(--color-bg-light);
+ //padding: var(--space-xxs) 0;
+ border-radius: var(--radius-md);
+ z-index: var(--z-index-popover, 5);
+ user-select: none;
+ margin-top: var(--menu-vertical-gap);
+ margin-bottom: var(--menu-vertical-gap);
+ overflow: auto;
+
+
+ // use rem units
+ @include spaceUnit(1rem);
+ @include textUnit(1rem);
+
+ visibility: hidden;
+ opacity: 0;
+}
+
+.menu--is-visible {
+ visibility: visible;
+ opacity: 1;
+}
+
+.menu--overlay {
+ z-index: var(--z-index-overlay, 15);
+}
+
+.menu__content {
+ display: block; // fallback
+ display: flex;
+ align-items: center;
+ text-decoration: none; // reset link style
+ padding: var(--menu-item-padding);
+ color: var(--color-contrast-high);
+ cursor: pointer;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ &:hover {
+ background-color: alpha(var(--color-contrast-higher), 0.075);
+ }
+
+ &:focus {
+ outline: none;
+ background-color: alpha(var(--color-primary), 0.15);
+ }
+}
+
+.menu__label {
+ padding: var(--menu-item-padding);
+ font-size: var(--text-sm);
+ color: var(--color-contrast-medium);
+}
+
+.menu__separator {
+ height: 1px;
+ background-color: var(--color-contrast-lower);
+ margin: var(--menu-item-padding);
+}
+
+.menu__icon {
+ color: alpha(var(--color-contrast-higher), 0.5);
+ margin-right: var(--space-xxs);
+}
diff --git a/apps/web-shared/src/styles/components/modal.scss b/apps/web-shared/src/styles/components/modal.scss
new file mode 100644
index 0000000..1beec76
--- /dev/null
+++ b/apps/web-shared/src/styles/components/modal.scss
@@ -0,0 +1,105 @@
+@use '../base' as *;
+
+/* --------------------------------
+
+File#: _1_modal-window
+Title: Modal Window
+Descr: A modal dialog used to display critical information
+Usage: codyhouse.co/license
+
+-------------------------------- */
+
+.modal {
+ position: fixed;
+ z-index: var(--z-index-overlay, 15);
+ width: 100%;
+ height: 100%;
+ left: 0;
+ top: 0;
+ opacity: 0;
+ visibility: hidden;
+
+ &:not(.modal--is-visible) {
+ pointer-events: none;
+ background-color: transparent;
+ }
+}
+
+.modal--is-visible {
+ opacity: 1;
+ visibility: visible;
+}
+
+// close button
+.modal__close-btn {
+ display: flex;
+ flex-shrink: 0;
+ border-radius: 50%;
+ cursor: pointer;
+
+ .icon {
+ display: block;
+ margin: auto;
+ }
+}
+
+.modal__close-btn--outer { // close button - outside the modal__content
+ width: 48px;
+ height: 48px;
+ position: fixed;
+ top: var(--space-sm);
+ right: var(--space-sm);
+ z-index: var(--z-index-fixed-element, 10);
+ background-color: alpha(var(--color-black), 0.9);
+
+ .icon {
+ color: var(--color-white); // icon color
+ }
+
+ &:hover {
+ background-color: alpha(var(--color-black), 1);
+
+ .icon {
+ transform: scale(1.1);
+ }
+ }
+}
+
+.modal__close-btn--inner { // close button - inside the modal__content
+ width: 2em;
+ height: 2em;
+ background-color: var(--color-bg-light);
+ box-shadow: var(--inner-glow), var(--shadow-sm);
+
+ .icon {
+ color: inherit; // icon color
+ }
+
+ &:hover {
+ background-color: var(--color-bg-lighter);
+ box-shadow: var(--inner-glow), var(--shadow-md);
+ }
+}
+
+// load content - optional
+.modal--is-loading {
+ .modal__content {
+ visibility: hidden;
+ }
+
+ .modal__loader {
+ display: flex;
+ }
+}
+
+.modal__loader { // loader icon
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ justify-content: center;
+ align-items: center;
+ display: none;
+ pointer-events: none;
+}
diff --git a/apps/web-shared/src/styles/components/pagination.scss b/apps/web-shared/src/styles/components/pagination.scss
new file mode 100644
index 0000000..0a09210
--- /dev/null
+++ b/apps/web-shared/src/styles/components/pagination.scss
@@ -0,0 +1,77 @@
+@use '../base' as *;
+
+/* --------------------------------
+
+File#: _1_pagination
+Title: Pagination
+Descr: Component used to navigate through pages of related content
+Usage: codyhouse.co/license
+
+-------------------------------- */
+
+.pagination {}
+
+.pagination__list > li {
+ display: inline-block; // flex fallback
+}
+
+// --split - push first + last item to sides
+.pagination--split {
+ .pagination__list {
+ width: 100%;
+
+ > *:first-child {
+ margin-right: auto;
+ }
+
+ > *:last-child {
+ margin-left: auto;
+ }
+ }
+}
+
+.pagination__item {
+ display: inline-block; // flex fallback
+ display: inline-flex;
+ height: 100%;
+ align-items: center;
+ padding: var(--space-xs) calc(1.355 * var(--space-xs));
+
+ white-space: nowrap;
+ line-height: 1;
+ border-radius: var(--radius-md);
+
+ text-decoration: none;
+ color: var(--color-contrast-high);
+ @include fontSmooth;
+
+ will-change: transform;
+
+ &:hover:not(.pagination__item--selected):not(.pagination__item--ellipsis) {
+ background-color: alpha(var(--color-contrast-higher), 0.1);
+ }
+}
+
+.pagination__item--selected {
+ background-color: var(--color-contrast-higher);
+ color: var(--color-bg);
+ box-shadow: var(--shadow-sm);
+}
+
+.pagination__item--disabled {
+ opacity: 0.5;
+ pointer-events: none;
+}
+
+// --jumper
+.pagination__jumper {
+ .form-control {
+ width: 3em;
+ margin-right: var(--space-xs);
+ }
+
+ em {
+ flex-shrink: 0;
+ white-space: nowrap;
+ }
+}
diff --git a/apps/web-shared/src/styles/components/popover.scss b/apps/web-shared/src/styles/components/popover.scss
new file mode 100644
index 0000000..7f423a0
--- /dev/null
+++ b/apps/web-shared/src/styles/components/popover.scss
@@ -0,0 +1,38 @@
+@use '../base'as *;
+
+/* --------------------------------
+
+File#: _1_popover
+Title: Popover
+Descr: A pop-up box controlled by a trigger element
+Usage: codyhouse.co/license
+
+-------------------------------- */
+:root {
+ --popover-width: 250px;
+ --popover-control-gap: 4px; // ⚠️ use px units - vertical gap between the popover and its control
+ --popover-viewport-gap: 20px; // ⚠️ use px units - vertical gap between the popover and the viewport - visible if popover height > viewport height
+ --popover-transition-duration: 0.2s;
+}
+
+.popover {
+ position: fixed; // top/left position set in JS
+ width: var(--popover-width);
+ z-index: var(--z-index-popover, 5);
+ margin-top: var(--popover-control-gap);
+ margin-bottom: var(--popover-control-gap);
+ overflow: auto;
+ -webkit-overflow-scrolling: touch;
+
+ visibility: hidden;
+ opacity: 0;
+}
+
+.popover--is-visible {
+ visibility: visible;
+ opacity: 1;
+}
+
+.popover-control--active {
+ // class added to the trigger when popover is visible
+}
diff --git a/apps/web-shared/src/styles/components/pre-header.scss b/apps/web-shared/src/styles/components/pre-header.scss
new file mode 100644
index 0000000..1e803e7
--- /dev/null
+++ b/apps/web-shared/src/styles/components/pre-header.scss
@@ -0,0 +1,46 @@
+@use '../base' as *;
+
+/* --------------------------------
+
+File#: _1_pre-header
+Title: Pre-header
+Descr: Pre-header (top) banner
+Usage: codyhouse.co/license
+
+-------------------------------- */
+
+.pre-header {
+ display: block;
+ background-color: var(--color-contrast-higher);
+ color: var(--color-bg);
+ @include fontSmooth;
+}
+
+.pre-header--is-hidden {
+ display: none;
+}
+
+.pre-header__close-btn {
+ position: absolute;
+ right: 0;
+ top: calc(50% - 0.5em);
+ will-change: transform;
+
+ &:hover {
+ transform: scale(1.1);
+ }
+
+ .icon {
+ display: block;
+ }
+}
+
+// --link
+a.pre-header {
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ background-color: var(--color-contrast-high);
+ }
+}
diff --git a/apps/web-shared/src/styles/components/radios-checkboxes.scss b/apps/web-shared/src/styles/components/radios-checkboxes.scss
new file mode 100644
index 0000000..c4009f9
--- /dev/null
+++ b/apps/web-shared/src/styles/components/radios-checkboxes.scss
@@ -0,0 +1,134 @@
+@use '../base' as *;
+
+/* --------------------------------
+
+File#: _1_radios-checkboxes
+Title: Radios and Checkboxes
+Descr: Custom radio and checkbox buttons
+Usage: codyhouse.co/license
+
+-------------------------------- */
+
+:root {
+ // radios and checkboxes
+ --checkbox-radio-size: 18px;
+ --checkbox-radio-gap: var(--space-xxs); // gap between button and label
+ --checkbox-radio-border-width: 1px;
+ --checkbox-radio-line-height: var(--body-line-height);
+
+ // radio buttons
+ --radio-marker-size: 8px;
+
+ // checkboxes
+ --checkbox-marker-size: 12px;
+ --checkbox-radius: 4px;
+}
+
+// hide native buttons
+.radio,
+.checkbox {
+ position: absolute;
+ padding: 0;
+ margin: 0;
+ margin-top: calc((1em * var(--checkbox-radio-line-height) - var(--checkbox-radio-size)) / 2);
+ opacity: 0;
+ height: var(--checkbox-radio-size);
+ width: var(--checkbox-radio-size);
+ pointer-events: none;
+}
+
+// label
+.radio + label,
+.checkbox + label {
+ display: inline-block;
+ line-height: var(--checkbox-radio-line-height);
+ user-select: none;
+ cursor: pointer;
+ padding-left: calc(var(--checkbox-radio-size) + var(--checkbox-radio-gap));
+}
+
+// custom inputs - basic style
+.radio + label::before,
+.checkbox + label::before {
+ content: '';
+ box-sizing: border-box;
+ display: inline-block;
+ position: relative;
+ vertical-align: middle;
+ top: -0.1em;
+ margin-left: calc(-1 * (var(--checkbox-radio-size) + var(--checkbox-radio-gap)));
+ flex-shrink: 0;
+ width: var(--checkbox-radio-size);
+ height: var(--checkbox-radio-size);
+ background-color: var(--color-bg);
+ border-width: var(--checkbox-radio-border-width);
+ border-color: alpha(var(--color-contrast-low), 0.65);
+ border-style: solid;
+ box-shadow: var(--shadow-xs);
+ background-repeat: no-repeat;
+ background-position: center;
+ margin-right: var(--checkbox-radio-gap);
+}
+
+// :hover
+.radio:not(:checked):not(:focus) + label:hover::before,
+.checkbox:not(:checked):not(:focus) + label:hover::before {
+ border-color: alpha(var(--color-contrast-low), 1);
+}
+
+// radio only style
+.radio + label::before {
+ border-radius: 50%;
+}
+
+// checkbox only style
+.checkbox + label::before {
+ border-radius: var(--checkbox-radius);
+}
+
+// :checked
+.radio:checked + label::before,
+.checkbox:checked + label::before {
+ background-color: var(--color-primary);
+ box-shadow: var(--shadow-xs);
+ border-color: var(--color-primary);
+}
+
+// radio button icon
+.radio:checked + label::before {
+ background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cg class='nc-icon-wrapper' fill='%23ffffff'%3E%3Ccircle cx='8' cy='8' r='8' fill='%23ffffff'%3E%3C/circle%3E%3C/g%3E%3C/svg%3E");
+ background-size: var(--radio-marker-size);
+}
+
+// checkbox button icon
+.checkbox:checked + label::before {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpolyline points='1 6.5 4 9.5 11 2.5' fill='none' stroke='%23FFFFFF' stroke-linecap='round' stroke-linejoin='round' stroke-width='2'/%3E%3C/svg%3E");
+ background-size: var(--checkbox-marker-size);
+}
+
+// :focus
+.radio:checked:active + label::before,
+.checkbox:checked:active + label::before,
+.radio:focus + label::before,
+.checkbox:focus + label::before {
+ border-color: var(--color-primary);
+ box-shadow: 0 0 0 3px alpha(var(--color-primary), 0.2);
+}
+
+// --radio--bg, --checkbox--bg -> variation with background color
+.radio--bg + label, .checkbox--bg + label {
+ padding: var(--space-xxxxs) var(--space-xxxs);
+ padding-left: calc(var(--checkbox-radio-size) + var(--checkbox-radio-gap) + var(--space-xxxs));
+ border-radius: var(--radius-md);
+}
+
+.radio--bg + label:hover, .checkbox--bg + label:hover {
+ background-color: alpha(var(--color-contrast-higher), 0.075);
+}
+
+.radio--bg:active + label,
+.checkbox--bg:active + label,
+.radio--bg:focus + label,
+.checkbox--bg:focus + label {
+ background-color: alpha(var(--color-primary), 0.1);
+}
diff --git a/apps/web-shared/src/styles/components/select-autocomplete.scss b/apps/web-shared/src/styles/components/select-autocomplete.scss
new file mode 100644
index 0000000..78a0fb0
--- /dev/null
+++ b/apps/web-shared/src/styles/components/select-autocomplete.scss
@@ -0,0 +1,173 @@
+@use '../base' as *;
+@use 'autocomplete.scss' as *;
+
+/* --------------------------------
+
+File#: _3_select-autocomplete
+Title: Select Autocomplete
+Descr: Selection dropdown with autocomplete
+Usage: codyhouse.co/license
+
+-------------------------------- */
+
+.select-auto {
+ &.autocomplete {
+ --autocomplete-dropdown-vertical-gap: 4px; // gap between input and results list
+ --autocomplete-dropdown-max-height: 250px;
+ --autocomplete-dropdown-scrollbar-width: 6px; // custom scrollbar - webkit browsers
+ }
+}
+
+// input
+.select-auto__input-wrapper {
+ --input-btn-size: 1.25em; // btn/icon size
+ --input-btn-icon-size: 16px; // btn/icon size
+ --input-btn-text-gap: var(--space-xxs); // gap between button/icon and text
+
+ position: relative;
+ background: var(--color-bg-dark);
+ line-height: 1.2;
+ box-shadow: inset 0 0 0 1px var(--color-contrast-lower);
+
+ &.multiple {
+ display: flex;
+ flex-direction: row;
+ flex-flow: wrap;
+
+ .chip {
+ white-space: nowrap;
+ margin-right: 1px;
+ }
+
+ input[type="text"] {
+ width: auto;
+ }
+
+ @media (max-width: 756px) {
+ flex-flow: column !important;
+
+ &.has-selection {
+ input[type="text"] {
+ margin-top: 5px;
+ }
+
+ .chip {
+ justify-content: space-between;
+
+ .chip__btn {
+ margin-right: 0 !important;;
+ }
+ }
+ }
+ }
+ }
+
+ &::placeholder {
+ opacity: 1;
+ color: var(--color-contrast-low);
+ }
+
+ &:focus-within {
+ background: var(--color-bg);
+ box-shadow: inset 0 0 0 1px alpha(var(--color-contrast-lower), 0), 0px 0px 0px 1px var(--color-primary);
+ outline: none;
+ }
+
+ .form-control {
+ width: 100%;
+ height: 100%;
+ padding-right: calc(var(--form-control-padding-x) + var(--input-btn-size) + var(--input-btn-text-gap));
+ }
+}
+
+
+.select-auto__input-icon-wrapper {
+ width: var(--input-btn-size);
+ height: var(--input-btn-size);
+
+ position: absolute;
+ bottom: calc(var(--input-btn-size) / 3);
+ right: var(--form-control-padding-x);
+ display: flex;
+ pointer-events: none;
+
+ .icon {
+ display: block;
+ margin: auto;
+ width: var(--input-btn-icon-size, 16px);
+ height: var(--input-btn-icon-size, 16px);
+ }
+}
+
+.select-auto__input-btn {
+ display: none;
+ justify-content: center;
+ align-items: center;
+ width: inherit;
+ height: inherit;
+ pointer-events: auto;
+ cursor: pointer;
+ color: var(--color-contrast-medium); // icon color
+
+ &:hover {
+ color: var(--color-contrast-high);
+ }
+}
+
+.select-auto--selection-done {
+ .select-auto__input-icon-wrapper > .icon {
+ display: none;
+ }
+
+ .select-auto__input-btn {
+ display: flex;
+ }
+}
+
+// dropdown
+.select-auto__results {
+ // reset spacing and typography
+ @include spaceUnit(1rem);
+ @include textUnit(1rem);
+}
+
+// single result item
+.select-auto__option {
+ position: relative;
+ cursor: pointer;
+
+ &:hover {
+ background-color: alpha(var(--color-contrast-higher), 0.05);
+ }
+
+ &:focus {
+ outline: none;
+ background-color: alpha(var(--color-primary), 0.12);
+ }
+
+ &.select-auto__option--selected {
+ background-color: var(--color-primary);
+ color: var(--color-white);
+ padding-right: calc(1em + var(--space-sm));
+ @include fontSmooth;
+
+ &:focus {
+ background-color: var(--color-primary-dark);
+ }
+
+ &::after {
+ content: '';
+ position: absolute;
+ right: var(--space-sm);
+ top: calc(50% - 0.5em);
+ height: 1em;
+ width: 1em;
+ background-color: currentColor;
+ mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpolyline stroke-width='2' stroke='%23ffffff' fill='none' stroke-linecap='round' stroke-linejoin='round' points='1,9 5,13 15,3 '/%3E%3C/svg%3E");
+ }
+ }
+}
+
+.select-auto__group-title, .select-auto__no-results-msg {
+ outline: none;
+}
diff --git a/apps/web-shared/src/styles/components/tabbed-navigation.scss b/apps/web-shared/src/styles/components/tabbed-navigation.scss
new file mode 100644
index 0000000..4090fca
--- /dev/null
+++ b/apps/web-shared/src/styles/components/tabbed-navigation.scss
@@ -0,0 +1,133 @@
+@use '../base' as *;
+
+/* --------------------------------
+
+File#: _1_tabbed-navigation-v2
+Title: Tabbed Navigation v2
+Descr: Tabbed (secondary) navigation
+Usage: codyhouse.co/license
+
+-------------------------------- */
+
+.tabs-nav-v2 {
+ display: flex;
+ flex-wrap: wrap;
+
+ .tab-v2 {
+ display: inline-block; // flexbox fallback
+ display: inline-flex;
+ align-items: center;
+ }
+}
+
+.tabs-nav-v2__item {
+ display: inline-block;
+ padding: var(--space-xxs) var(--space-sm);
+ color: inherit;
+ white-space: nowrap;
+ text-decoration: none;
+}
+
+.tabs-nav-v2__item--selected,
+.tabs-nav-v2__item[aria-selected="true"] {
+ color: var(--color-bg);
+ background-color: var(--color-contrast-higher);
+}
+
+@include breakpoint(md) {
+ .tabs-nav-v2 {
+ .tab-v2 {
+ margin: 0;
+ }
+ }
+
+ .tabs-nav-v2__item {
+ background-color: transparent;
+ margin: var(--space-xxs) var(--space-sm);
+ padding: var(--space-xxxs) var(--space-xxs) !important;
+ border-radius: var(--radius-md);
+
+ &:hover {
+ background-color: alpha(var(--color-primary), 0.035);
+ color: var(--color-primary);
+ }
+ }
+
+ .tabs-nav-v2__item--selected,
+ .tabs-nav-v2__item[aria-selected="true"] {
+
+ background-color: alpha(var(--color-primary), 0.075);
+ color: var(--color-primary);
+ position: relative;
+
+ &::after {
+ content: '';
+ position: absolute;
+ bottom: calc(var(--tabs-nav-border-width) * -1);
+ left: 0;
+ width: 100%;
+ height: var(--tabs-nav-border-width);
+ background-color: var(--color-bg);
+ }
+
+ &:hover {
+ background-color: alpha(var(--color-primary), 0.075);
+ }
+ }
+}
+
+:root {
+ --s-tabs-border-bottom-width: 1px;
+ --s-tabs-selected-item-border-bottom-width: 1px;
+}
+
+.s-tabs {
+ position: relative;
+
+ &::after { /* gradient - truncate text */
+ content: '';
+ position: absolute;
+ right: -1px;
+ top: 0;
+ height: calc(100% - var(--s-tabs-border-bottom-width));
+ width: 2em;
+ pointer-events: none;
+ z-index: 1;
+ }
+}
+
+.s-tabs__list {
+ display: flex;
+ overflow: auto;
+ -webkit-overflow-scrolling: auto;
+
+ &::after { /* border bottom */
+ content: '';
+ position: absolute;
+ width: 100%;
+ height: var(--s-tabs-border-bottom-width);
+ left: 0;
+ bottom: 0;
+ background-color: var(--color-contrast-lower);
+ }
+}
+
+.s-tabs__link {
+ color: var(--color-contrast-medium);
+ text-decoration: none;
+ display: inline-block;
+ padding: var(--space-xs) var(--space-sm);
+ white-space: nowrap;
+ border-bottom: var(--s-tabs-selected-item-border-bottom-width) solid transparent;
+ z-index: 1;
+
+ &:hover:not(.s-tabs__link--current) {
+ color: var(--color-contrast-high);
+ }
+}
+
+.s-tabs__link--current {
+ position: relative;
+ color: var(--color-primary);
+ border-bottom-color: var(--color-primary);
+}
diff --git a/apps/web-shared/src/styles/components/table.scss b/apps/web-shared/src/styles/components/table.scss
new file mode 100644
index 0000000..af8207f
--- /dev/null
+++ b/apps/web-shared/src/styles/components/table.scss
@@ -0,0 +1,147 @@
+@use '../base'as *;
+
+/* --------------------------------
+
+File#: _1_table
+Title: Table
+Descr: Data tables used to organize and display information in rows and columns
+Usage: codyhouse.co/license
+
+-------------------------------- */
+
+// >>> style affecting all (block + expanded) versions 👇
+.table {
+ position: relative;
+ z-index: 1;
+}
+
+// <<< end style affecting all versions
+
+// >>> block version only (mobile) 👇
+.table:not(.table--expanded) {
+ border-collapse: separate;
+ border-spacing: 0 var(--space-md); // row gap
+ margin-top: calc(-2 * var(--space-md)); // set spacing variable = row gap ☝️
+
+ .table__header {
+ // hide table header - but keep it accessible to SR
+ @include srHide;
+ }
+
+ .table__row {
+ .table__cell:first-child {
+ border-radius: var(--radius-md) var(--radius-md) 0 0;
+ }
+
+ .table__cell:last-child {
+ border-radius: 0 0 var(--radius-md) var(--radius-md);
+
+ &::after {
+ // hide border bottom
+ display: none;
+ }
+ }
+ }
+
+ .table__cell {
+ position: relative;
+ display: flex;
+ justify-content: space-between;
+ width: 100%;
+ text-align: right;
+ padding: var(--space-md);
+ background-color: var(--color-bg-light);
+
+ &::after {
+ // border bottom
+ content: '';
+ position: absolute;
+ bottom: 0;
+ left: var(--space-md);
+ width: calc(100% - (2 * var(--space-md)));
+ height: 1px;
+ background-color: var(--color-contrast-lower);
+ }
+ }
+
+ .table__label {
+ // inline labels -> visible when table header is hidden
+ font-weight: bold;
+ text-align: left;
+ color: var(--color-contrast-higher);
+ margin-right: var(--space-md);
+ }
+}
+
+// <<< end block version
+
+// >>> expanded version only (desktop) 👇 -> show standard rows and cols
+.table--expanded {
+ border-bottom: 1px solid var(--color-contrast-lower); // table border bottom
+
+ .table__header {
+ .table__cell {
+ // header cell style
+ position: relative;
+ z-index: 10;
+ background-color: var(--color-bg);
+ border-bottom: 1px solid var(--color-contrast-lower); // header border bottom
+ font-weight: bold;
+ color: var(--color-contrast-higher);
+ }
+ }
+
+ .table__body {
+ .table__row {
+ &:nth-child(odd) {
+ background-color: alpha(var(--color-bg-dark), 0.85);
+ }
+ }
+ }
+
+ .table__cell {
+ padding: var(--space-xxxs);
+ }
+
+ .table__label {
+ // hide inline labels
+ display: none;
+ }
+
+ // --header-sticky
+ .table__header--sticky {
+ .table__cell {
+ // header cell style
+ position: sticky;
+ top: 0;
+ }
+ }
+}
+
+// <<< end expanded version
+
+.js {
+ .table {
+ opacity: 0; // hide table while it is initialized in JS
+ }
+
+ .table--loaded {
+ opacity: 1;
+ }
+}
+
+// detect when the table needs to switch from the mobile layout to an expanded one - used in JS
+[class*="table--expanded"]::before {
+ display: none;
+}
+
+@each $breakpoint,
+$value in $breakpoints {
+ .table--expanded\@#{$breakpoint}::before {
+ content: 'collapsed';
+
+ @include breakpoint(#{$breakpoint}) {
+ content: 'expanded';
+ }
+ }
+}
diff --git a/apps/web-shared/src/styles/components/user-menu.scss b/apps/web-shared/src/styles/components/user-menu.scss
new file mode 100644
index 0000000..1b5c1d5
--- /dev/null
+++ b/apps/web-shared/src/styles/components/user-menu.scss
@@ -0,0 +1,81 @@
+@use '../base' as *;
+@use 'menu.scss' as *;
+
+/* --------------------------------
+
+File#: _2_user-menu
+Title: User Menu
+Descr: A menu controlled by the user profile image
+Usage: codyhouse.co/license
+
+-------------------------------- */
+
+.user-menu-control {
+ --profile-figure-size: 40px;
+
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ text-align: left;
+
+ &:hover {
+ .user-menu-control__img-wrapper {
+ opacity: 0.8;
+ }
+
+ .user-menu__meta-title {
+ color: var(--color-primary);
+ }
+ }
+
+ &:focus, &.menu-control--active {
+ outline: none;
+
+ .user-menu-control__img-wrapper::after {
+ opacity: 1;
+ transform: scale(1);
+ }
+ }
+}
+
+.user-menu-control__img-wrapper {
+ width: var(--profile-figure-size);
+ height: var(--profile-figure-size);
+ position: relative;
+ transition: opacity 0.2s;
+
+ &::after {
+ content: '';
+ position: absolute;
+ z-index: -1;
+ left: -4px;
+ top: -4px;
+ width: 100%;
+ height: 100%;
+ border-radius: inherit;
+ width: calc(var(--profile-figure-size) + 8px);
+ height: calc(var(--profile-figure-size) + 8px);
+ border: 2px solid var(--color-primary);
+ pointer-events: none;
+
+ opacity: 0;
+ transform: scale(0.8);
+
+ transition: all 0.2s;
+ }
+}
+
+.user-menu-control__img {
+ display: block;
+ width: 100%;
+ object-fit: cover;
+ border-radius: inherit;
+}
+
+.user-menu__meta {
+ max-width: 100px;
+}
+
+.user-menu__meta-title {
+ transition: color 0.2s;
+}
diff --git a/apps/web-shared/src/styles/custom-style/_buttons.scss b/apps/web-shared/src/styles/custom-style/_buttons.scss
new file mode 100644
index 0000000..e396f8d
--- /dev/null
+++ b/apps/web-shared/src/styles/custom-style/_buttons.scss
@@ -0,0 +1,111 @@
+@use '../base' as *;
+
+// --------------------------------
+
+// (START) Global editor code https://codyhouse.co/ds/globals/buttons
+
+// --------------------------------
+
+:root {
+ --btn-font-size: 1em;
+ --btn-padding-x: var(--space-xxs);
+ --btn-padding-y: var(--space-xxxs);
+ --btn-radius: var(--radius-sm);
+}
+
+.btn {
+ background: var(--color-bg-dark);
+ color: var(--color-contrast-higher);
+ cursor: pointer;
+ text-decoration: none;
+ line-height: 1.2;
+ @include fontSmooth;
+ will-change: transform;
+
+ &:focus {
+ box-shadow: 0px 0px 0px 2px alpha(var(--color-contrast-higher), 0.15);
+ outline: none;
+ }
+}
+
+.btn--link {
+ @include reset;
+ color: inherit;
+ cursor: pointer;
+ text-decoration: none;
+
+ &:hover {
+ color: var(--color-primary);
+ }
+}
+
+// themes
+.btn--primary {
+ background: var(--color-primary);
+ color: var(--color-white);
+ box-shadow: inset 0px 1px 0px alpha(var(--color-white), 0.15), var(--shadow-xs);
+
+ &:hover {
+ background: var(--color-primary-light);
+ box-shadow: inset 0px 1px 0px alpha(var(--color-white), 0.15), var(--shadow-sm);
+ }
+
+ &:focus {
+ box-shadow: inset 0px 1px 0px alpha(var(--color-white), 0.15), 0px 0px 0px 2px alpha(var(--color-primary), 0.2);
+ }
+}
+
+.btn--subtle {
+ background: var(--color-bg-light);
+ color: var(--color-contrast-higher);
+ box-shadow: inset 0px 0px 0px 1px var(--color-contrast-lower), var(--shadow-xs);
+
+ &:hover {
+ background: var(--color-bg-lighter);
+ box-shadow: inset 0px 0px 0px 1px var(--color-contrast-lower), var(--shadow-sm);
+ }
+
+ &:focus {
+ box-shadow: inset 0px 0px 0px 1px var(--color-contrast-lower), 0px 0px 0px 2px alpha(var(--color-contrast-higher), 0.05);
+ }
+}
+
+.btn--accent {
+ background: var(--color-accent);
+ color: var(--color-white);
+ box-shadow: inset 0px 1px 0px alpha(var(--color-white), 0.15), var(--shadow-xs);
+
+ &:hover {
+ background: var(--color-accent-light);
+ box-shadow: inset 0px 1px 0px alpha(var(--color-white), 0.15), var(--shadow-sm);
+ }
+
+ &:focus {
+ box-shadow: inset 0px 1px 0px alpha(var(--color-white), 0.15), 0px 0px 0px 2px alpha(var(--color-accent), 0.2);
+ }
+}
+
+// feedback
+.btn--disabled, .btn[disabled], .btn[readonly] {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+// size
+.btn--sm {
+ font-size: 0.8em;
+}
+
+.btn--md {
+ font-size: 1.2em;
+}
+
+.btn--lg {
+ font-size: 1.4em;
+}
+
+// --------------------------------
+
+// (END) Global editor code
+
+// --------------------------------
diff --git a/apps/web-shared/src/styles/custom-style/_colors.scss b/apps/web-shared/src/styles/custom-style/_colors.scss
new file mode 100644
index 0000000..76d3fa6
--- /dev/null
+++ b/apps/web-shared/src/styles/custom-style/_colors.scss
@@ -0,0 +1,119 @@
+@use '../base' as *;
+
+// --------------------------------
+
+// (START) Global editor code https://codyhouse.co/ds/globals/colors
+
+// --------------------------------
+
+:root, [data-theme="default"] {
+ // main
+ @include defineColorHSL(--color-primary-darker, 250, 84%, 38%);
+ @include defineColorHSL(--color-primary-dark, 250, 84%, 46%);
+ @include defineColorHSL(--color-primary, 250, 84%, 54%);
+ @include defineColorHSL(--color-primary-light, 250, 84%, 60%);
+ @include defineColorHSL(--color-primary-lighter, 250, 84%, 67%);
+
+ @include defineColorHSL(--color-accent-darker, 342, 89%, 38%);
+ @include defineColorHSL(--color-accent-dark, 342, 89%, 43%);
+ @include defineColorHSL(--color-accent, 342, 89%, 48%);
+ @include defineColorHSL(--color-accent-light, 342, 89%, 56%);
+ @include defineColorHSL(--color-accent-lighter, 342, 89%, 62%);
+
+ @include defineColorHSL(--color-black, 204, 28%, 7%);
+ @include defineColorHSL(--color-white, 0, 0%, 100%);
+
+ // feedback
+ @include defineColorHSL(--color-warning-darker, 46, 100%, 47%);
+ @include defineColorHSL(--color-warning-dark, 46, 100%, 50%);
+ @include defineColorHSL(--color-warning, 46, 100%, 61%);
+ @include defineColorHSL(--color-warning-light, 46, 100%, 71%);
+ @include defineColorHSL(--color-warning-lighter, 46, 100%, 80%);
+
+ @include defineColorHSL(--color-success-darker, 122, 50%, 47%);
+ @include defineColorHSL(--color-success-dark, 122, 50%, 52%);
+ @include defineColorHSL(--color-success, 122, 50%, 60%);
+ @include defineColorHSL(--color-success-light, 122, 50%, 69%);
+ @include defineColorHSL(--color-success-lighter, 122, 50%, 76%);
+
+ @include defineColorHSL(--color-error-darker, 342, 89%, 38%);
+ @include defineColorHSL(--color-error-dark, 342, 89%, 43%);
+ @include defineColorHSL(--color-error, 342, 89%, 48%);
+ @include defineColorHSL(--color-error-light, 342, 89%, 56%);
+ @include defineColorHSL(--color-error-lighter, 342, 89%, 62%);
+
+ // background
+ @include defineColorHSL(--color-bg-darker, 210, 4%, 89%);
+ @include defineColorHSL(--color-bg-dark, 180, 3%, 94%);
+ @include defineColorHSL(--color-bg, 210, 17%, 98%);
+ @include defineColorHSL(--color-bg-light, 180, 3%, 100%);
+ @include defineColorHSL(--color-bg-lighter, 210, 4%, 100%);
+
+ // color contrasts
+ @include defineColorHSL(--color-contrast-lower, 180, 1%, 84%);
+ @include defineColorHSL(--color-contrast-low, 210, 2%, 64%);
+ @include defineColorHSL(--color-contrast-medium, 204, 2%, 46%);
+ @include defineColorHSL(--color-contrast-high, 210, 7%, 21%);
+ @include defineColorHSL(--color-contrast-higher, 204, 28%, 7%);
+}
+
+[data-theme="dark"] {
+ // main
+ @include defineColorHSL(--color-primary-darker, 250, 93%, 57%);
+ @include defineColorHSL(--color-primary-dark, 250, 93%, 61%);
+ @include defineColorHSL(--color-primary, 250, 93%, 65%);
+ @include defineColorHSL(--color-primary-light, 250, 93%, 69%);
+ @include defineColorHSL(--color-primary-lighter, 250, 93%, 72%);
+
+ @include defineColorHSL(--color-accent-darker, 342, 92%, 41%);
+ @include defineColorHSL(--color-accent-dark, 342, 92%, 47%);
+ @include defineColorHSL(--color-accent, 342, 92%, 54%);
+ @include defineColorHSL(--color-accent-light, 342, 92%, 60%);
+ @include defineColorHSL(--color-accent-lighter, 342, 92%, 65%);
+
+ @include defineColorHSL(--color-black, 204, 28%, 7%);
+ @include defineColorHSL(--color-white, 0, 0%, 100%);
+
+ // feedback
+ @include defineColorHSL(--color-warning-darker, 46, 100%, 47%);
+ @include defineColorHSL(--color-warning-dark, 46, 100%, 50%);
+ @include defineColorHSL(--color-warning, 46, 100%, 61%);
+ @include defineColorHSL(--color-warning-light, 46, 100%, 71%);
+ @include defineColorHSL(--color-warning-lighter, 46, 100%, 80%);
+
+ @include defineColorHSL(--color-success-darker, 122, 50%, 47%);
+ @include defineColorHSL(--color-success-dark, 122, 50%, 52%);
+ @include defineColorHSL(--color-success, 122, 50%, 60%);
+ @include defineColorHSL(--color-success-light, 122, 50%, 69%);
+ @include defineColorHSL(--color-success-lighter, 122, 50%, 76%);
+
+ @include defineColorHSL(--color-error-darker, 342, 92%, 41%);
+ @include defineColorHSL(--color-error-dark, 342, 92%, 47%);
+ @include defineColorHSL(--color-error, 342, 92%, 54%);
+ @include defineColorHSL(--color-error-light, 342, 92%, 60%);
+ @include defineColorHSL(--color-error-lighter, 342, 92%, 65%);
+
+ // background
+ @include defineColorHSL(--color-bg-darker, 204, 15%, 6%);
+ @include defineColorHSL(--color-bg-dark, 203, 18%, 9%);
+ @include defineColorHSL(--color-bg, 203, 24%, 13%);
+ @include defineColorHSL(--color-bg-light, 203, 18%, 17%);
+ @include defineColorHSL(--color-bg-lighter, 204, 15%, 20%);
+
+ // color contrasts
+ @include defineColorHSL(--color-contrast-lower, 208, 12%, 24%);
+ @include defineColorHSL(--color-contrast-low, 208, 6%, 40%);
+ @include defineColorHSL(--color-contrast-medium, 213, 5%, 56%);
+ @include defineColorHSL(--color-contrast-high, 223, 8%, 82%);
+ @include defineColorHSL(--color-contrast-higher, 240, 100%, 99%);
+
+ // font rendering
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+// --------------------------------
+
+// (END) Global editor code
+
+// --------------------------------
diff --git a/apps/web-shared/src/styles/custom-style/_forms.scss b/apps/web-shared/src/styles/custom-style/_forms.scss
new file mode 100644
index 0000000..0048941
--- /dev/null
+++ b/apps/web-shared/src/styles/custom-style/_forms.scss
@@ -0,0 +1,56 @@
+@use '../base' as *;
+
+// --------------------------------
+
+// (START) Global editor code https://codyhouse.co/ds/globals/forms
+
+// --------------------------------
+
+:root {
+ --form-control-font-size: 1em;
+ --form-control-padding-x: var(--space-xs);
+ --form-control-padding-y: var(--space-xxs);
+ --form-control-radius: var(--radius-sm);
+}
+
+.form-control {
+ background: var(--color-bg-dark);
+ line-height: 1.2;
+ box-shadow: inset 0 0 0 1px var(--color-contrast-lower);
+
+ &::placeholder {
+ opacity: 1;
+ color: var(--color-contrast-low);
+ }
+
+ &:focus {
+ background: var(--color-bg);
+ box-shadow: inset 0 0 0 1px alpha(var(--color-contrast-lower), 0), 0px 0px 0px 1px var(--color-primary);
+ outline: none;
+ }
+}
+
+.form-control--disabled, .form-control[disabled], .form-control[readonly] {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.form-control[aria-invalid="true"], .form-control.form-control--error {
+ box-shadow: inset 0 0 0 1px alpha(var(--color-contrast-lower), 0), 0px 0px 0px 2px var(--color-error);
+
+ &:focus {
+ box-shadow: inset 0 0 0 1px alpha(var(--color-contrast-lower), 0), 0px 0px 0px 2px var(--color-error), var(--shadow-sm);
+ }
+}
+
+.form-legend {
+}
+
+.form-label {
+}
+
+// --------------------------------
+
+// (END) Global editor code
+
+// --------------------------------
diff --git a/apps/web-shared/src/styles/custom-style/_icons.scss b/apps/web-shared/src/styles/custom-style/_icons.scss
new file mode 100644
index 0000000..a9fcb46
--- /dev/null
+++ b/apps/web-shared/src/styles/custom-style/_icons.scss
@@ -0,0 +1,19 @@
+@use '../base' as *;
+
+:root {
+ // size - 👇 uncomment to modify default icon sizes
+ // --icon-xxxs: 8px;
+ // --icon-xxs: 12px;
+ // --icon-xs: 16px;
+ // --icon-sm: 24px;
+ // --icon-md: 32px;
+ // --icon-lg: 48px;
+ // --icon-xl: 64px;
+ // --icon-xxl: 96px;
+ // --icon-xxxl: 128px;
+}
+
+.icon {
+ // 👇 include the font-family declaration here if you are using an icon font
+ // font-family: 'fontName';
+} \ No newline at end of file
diff --git a/apps/web-shared/src/styles/custom-style/_shared-styles.scss b/apps/web-shared/src/styles/custom-style/_shared-styles.scss
new file mode 100644
index 0000000..e9a32b8
--- /dev/null
+++ b/apps/web-shared/src/styles/custom-style/_shared-styles.scss
@@ -0,0 +1,59 @@
+@use '../base' as *;
+
+:root {
+ --radius: 0.25em; // border radius base size
+ --radius-sm: calc(var(--radius) / 2);
+ --radius-md: var(--radius);
+ --radius-lg: calc(var(--radius) * 2);
+ --shadow-xs: 0;
+ --shadow-sm: 0;
+ --shadow-md: 0;
+ --shadow-lg: 0;
+ --shadow-xl: 0;
+}
+
+// --------------------------------
+
+// (START) Global editor code https://codyhouse.co/ds/globals/shared-styles
+
+// --------------------------------
+
+.hover\:reduce-opacity {
+ opacity: 1;
+
+ &:hover {
+ opacity: 0.8;
+ }
+}
+
+.hover\:scale {
+
+ &:hover {
+ transform: scale(1.1);
+ }
+}
+
+.hover\:elevate {
+ box-shadow: var(--shadow-sm);
+
+ &:hover {
+ box-shadow: var(--shadow-md);
+ }
+}
+
+// text styles
+.link-subtle {
+ color: inherit;
+ cursor: pointer;
+ text-decoration: none;
+
+ &:hover {
+ color: var(--color-primary);
+ }
+}
+
+// --------------------------------
+
+// (END) Global editor code
+
+// --------------------------------
diff --git a/apps/web-shared/src/styles/custom-style/_spacing.scss b/apps/web-shared/src/styles/custom-style/_spacing.scss
new file mode 100644
index 0000000..56cd451
--- /dev/null
+++ b/apps/web-shared/src/styles/custom-style/_spacing.scss
@@ -0,0 +1,49 @@
+@use '../base' as *;
+
+// --------------------------------
+
+// (START) Global editor code https://codyhouse.co/ds/globals/spacing
+
+// --------------------------------
+
+// 👇 uncomment to modify default spacing scale
+// :root {
+// --space-unit: 1rem;
+// }
+
+ :root, * {
+ --space-xxxxs: calc(0.125 * var(--space-unit));
+ --space-xxxs: calc(0.25 * var(--space-unit));
+ --space-xxs: calc(0.375 * var(--space-unit));
+ --space-xs: calc(0.5 * var(--space-unit));
+ --space-sm: calc(0.75 * var(--space-unit));
+ --space-md: calc(1.25 * var(--space-unit));
+ --space-lg: calc(2 * var(--space-unit));
+ --space-xl: calc(3.25 * var(--space-unit));
+ --space-xxl: calc(5.25 * var(--space-unit));
+ --space-xxxl: calc(8.5 * var(--space-unit));
+ --space-xxxxl: calc(13.75 * var(--space-unit));
+ --component-padding: var(--space-sm);
+ }
+
+@include breakpoint(md) {
+ :root, * {
+ --space-xxxxs: calc(0.1875 * var(--space-unit));
+ --space-xxxs: calc(0.375 * var(--space-unit));
+ --space-xxs: calc(0.5625 * var(--space-unit));
+ --space-xs: calc(0.75 * var(--space-unit));
+ --space-sm: calc(1.125 * var(--space-unit));
+ --space-md: calc(2 * var(--space-unit));
+ --space-lg: calc(3.125 * var(--space-unit));
+ --space-xl: calc(5.125 * var(--space-unit));
+ --space-xxl: calc(8.25 * var(--space-unit));
+ --space-xxxl: calc(13.25 * var(--space-unit));
+ --space-xxxxl: calc(21.5 * var(--space-unit));
+ }
+}
+
+// --------------------------------
+
+// (END) Global editor code
+
+// --------------------------------
diff --git a/apps/web-shared/src/styles/custom-style/_typography.scss b/apps/web-shared/src/styles/custom-style/_typography.scss
new file mode 100644
index 0000000..d0bb431
--- /dev/null
+++ b/apps/web-shared/src/styles/custom-style/_typography.scss
@@ -0,0 +1,92 @@
+@use '../base' as *;
+
+// --------------------------------
+
+// (START) Global editor code https://codyhouse.co/ds/globals/typography
+
+// --------------------------------
+
+:root {
+ // font family
+ //--font-primary: Inter, system-ui, sans-serif;
+ font-family: -apple-system, BlinkMacSystemFont, avenir next, avenir, segoe ui, helvetica neue, helvetica, Cantarell, Ubuntu, roboto, noto, arial, sans-serif;
+
+ // font size
+ --text-base-size: 1.25rem; // body font-size
+ --text-scale-ratio: 1.1; // multiplier used to generate the type scale values 👇
+
+ // line-height
+ --body-line-height: 1.2;
+ --heading-line-height: 1.34;
+
+ // capital letters - used in combo with the lhCrop mixin
+ --font-primary-capital-letter: 1;
+
+ // unit - don't modify unless you want to change the typography unit (e.g., from Rem to Em units)
+ --text-unit: var(--text-base-size); // if Em units → --text-unit: var(--text-base-size);
+}
+
+:root, * {
+ --text-xs: calc((var(--text-unit) / var(--text-scale-ratio)) / var(--text-scale-ratio));
+ --text-sm: calc(var(--text-xs) * var(--text-scale-ratio));
+ --text-md: calc(var(--text-sm) * var(--text-scale-ratio) * var(--text-scale-ratio));
+ --text-lg: calc(var(--text-md) * var(--text-scale-ratio));
+ --text-xl: calc(var(--text-lg) * var(--text-scale-ratio));
+ --text-xxl: calc(var(--text-xl) * var(--text-scale-ratio));
+ --text-xxxl: calc(var(--text-xxl) * var(--text-scale-ratio));
+ --text-xxxxl: calc(var(--text-xxxl) * var(--text-scale-ratio));
+}
+
+body {
+ font-family: var(--font-primary);
+}
+
+h1, h2, h3, h4 {
+ font-family: var(--font-primary);
+ --heading-font-weight: 600;
+}
+
+// font family
+.font-primary { font-family: var(--font-primary); }
+
+// --------------------------------
+
+// (END) Global editor code
+
+// --------------------------------
+
+// link style
+a, .link {}
+
+mark {
+ background-color: alpha(var(--color-accent), 0.2);
+ color: inherit;
+}
+
+.text-component {
+ --text-unit: 1em;
+ --space-unit: 1em;
+ --line-height-multiplier: 1;
+ --text-space-y-multiplier: 1;
+
+ blockquote {
+ padding-left: 1em;
+ border-left: 4px solid var(--color-contrast-lower);
+ font-style: italic;
+ }
+
+ hr {
+ background: var(--color-contrast-lower);
+ height: 1px;
+ }
+
+ figcaption {
+ font-size: var(--text-sm);
+ color: var(--color-contrast-low);
+ }
+}
+
+.article { // e.g., blog posts
+ --body-line-height: 1.58; // set body line-height
+ --text-space-y-multiplier: 1.2; // control vertical spacing
+}
diff --git a/apps/web-shared/src/styles/custom-style/_util.scss b/apps/web-shared/src/styles/custom-style/_util.scss
new file mode 100644
index 0000000..5677630
--- /dev/null
+++ b/apps/web-shared/src/styles/custom-style/_util.scss
@@ -0,0 +1,41 @@
+@use '../base' as *;
+
+// --------------------------------
+
+// How to create custom utility classes 👇
+
+// --------------------------------
+
+.border-none {
+ border: none !important;
+}
+
+@each $breakpoint, $value in $breakpoints {
+ @include breakpoint(#{$breakpoint}) {
+ .border-none\@#{$breakpoint} {
+ border: none !important;
+ }
+ }
+}
+
+.left-unset {
+ left: unset !important;
+}
+
+.cursor-wait {
+ cursor: wait !important;
+}
+
+.bg-error-lighter\@hover {
+ &:hover,
+ &:active {
+ background-color: var(--color-error-lighter) !important;
+ }
+}
+
+.color-white\@hover {
+ &:hover,
+ &:active {
+ color: var(--color-white) !important;
+ }
+}
diff --git a/apps/web-shared/tsconfig.json b/apps/web-shared/tsconfig.json
new file mode 100644
index 0000000..7716e44
--- /dev/null
+++ b/apps/web-shared/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "include": [
+ "./src/**/*.d.ts",
+ "./src/**/*.ts",
+ "./src/**/*.js",
+ "./src/**/*.svelte"
+ ],
+ "exclude": [
+ "./node_modules"
+ ],
+ "compilerOptions": {
+ "target": "esnext",
+ "useDefineForClassFields": true,
+ "module": "esnext",
+ "moduleResolution": "node",
+ "allowJs": true,
+ "checkJs": false,
+ "paths": {
+ "$shared/*": [
+ "./src/*"
+ ]
+ }
+ }
+}
diff --git a/cloc.sh b/cloc.sh
new file mode 100755
index 0000000..9384d7a
--- /dev/null
+++ b/cloc.sh
@@ -0,0 +1,2 @@
+#!/bin/bash
+cloc --exclude-dir=bin,obj,build,node_modules,bootstrap,wwwroot --exclude-ext=json,xml .
diff --git a/server/.dockerignore b/server/.dockerignore
new file mode 100644
index 0000000..2b24da3
--- /dev/null
+++ b/server/.dockerignore
@@ -0,0 +1,10 @@
+*/**/bin*
+*/**/obj*
+server-secrets.*
+.git
+*/**/node_modules/*
+src/web-app
+src/web-shared
+src/tests
+build_and_push.sh
+cloc.sh
diff --git a/server/.version b/server/.version
new file mode 100644
index 0000000..3f430af
--- /dev/null
+++ b/server/.version
@@ -0,0 +1 @@
+v18
diff --git a/server/.version-dev b/server/.version-dev
new file mode 100644
index 0000000..863c2f5
--- /dev/null
+++ b/server/.version-dev
@@ -0,0 +1 @@
+v35-server-dev
diff --git a/server/CHANGELOG.md b/server/CHANGELOG.md
new file mode 100644
index 0000000..47e1f6c
--- /dev/null
+++ b/server/CHANGELOG.md
@@ -0,0 +1,68 @@
+# Changelog
+
+## [unreleased]
+
+### Bug Fixes
+
+- BUildscript
+- .
+- .
+- Incorrect paths
+- Inncorrect paths
+
+### Features
+
+- Add more configuration for seperated apps and evironments
+- Print env when starting server
+- Seperate projects app and profile app
+
+### Miscellaneous Tasks
+
+- Bump version
+- Update CHANGELOG.md for v8-frontpage
+- Bump version
+- Bump version
+- Bump version
+- Bump version
+- Bump version
+- Bump version
+- Bump version
+- Update CHANGELOG.md for v9-web-app-dev
+- Bump version
+- Update CHANGELOG.md for v14-web-app-dev
+- Bump version
+- Update CHANGELOG.md for v8-web-app-dev
+- Bump version
+- Update CHANGELOG.md for v7-web-app-dev
+- Bump version
+- Bump version
+- Bump version
+- Bump version
+- Bump version
+- Bump version
+- Update CHANGELOG.md for v13-web-app-dev
+- Bump version
+- Bump version
+- Update CHANGELOG.md for v20-web-app
+- Bump version
+- Update CHANGELOG.md for v19-web-app
+- Bump version
+- Bump version
+- Bump version
+- Bump version
+- Bump version
+- Bump version
+- Bump version
+- Update CHANGELOG.md for v35-server-dev
+
+### Refactor
+
+- Remove CANONICAL_NAME from app and expected environment variables
+
+## [unreleased]
+
+### Miscellaneous Tasks
+
+- Bump version
+- Update CHANGELOG.md for v34-server-dev
+
diff --git a/server/Dockerfile b/server/Dockerfile
new file mode 100644
index 0000000..adc4be3
--- /dev/null
+++ b/server/Dockerfile
@@ -0,0 +1,16 @@
+FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build-env
+WORKDIR /source
+
+# Copy csproj and restore as distinct layers
+COPY src/*.csproj ./
+RUN dotnet restore
+
+# Copy everything else and build
+COPY src/ ./
+RUN dotnet publish -c Release -o out
+
+# Build runtime image
+FROM mcr.microsoft.com/dotnet/aspnet:6.0
+WORKDIR /app
+COPY --from=build-env /source/out .
+ENTRYPOINT ["dotnet", "IOL.GreatOffice.Api.dll"]
diff --git a/server/build_and_push.sh b/server/build_and_push.sh
new file mode 100755
index 0000000..5bcc0d0
--- /dev/null
+++ b/server/build_and_push.sh
@@ -0,0 +1,96 @@
+#!/usr/bin/env bash
+
+set -Eueo pipefail
+
+CURRENT_DEV_VERSION=$(cat .version-dev)
+CURRENT_DEV_VERSION_INT=${CURRENT_DEV_VERSION//[!0-9]/}
+CURRENT_VERSION=$(cat .version)
+CURRENT_VERSION_INT=${CURRENT_VERSION//[!0-9]/}
+if [ ${1-prod} == "dev" ]; then
+ NEW_VERSION="v$((CURRENT_DEV_VERSION_INT+1))-server-dev"
+ OLD_VERSION=$CURRENT_DEV_VERSION
+else
+ NEW_VERSION="v$((CURRENT_VERSION_INT+1))-server"
+ OLD_VERSION=$CURRENT_VERSION
+fi
+IMAGE_NAME="greatoffice-api"
+HUB_NAME="dr.ivar.systems/greatoffice-api"
+
+# Check for uncommited changes and optionally commit them
+if [ "$(git status --untracked-files=no --porcelain)" ]; then
+ echo "Unclean git tree! press CTRL+C to exit or press ENTER to commit and push to the default branch"
+ read -n 1
+
+ read -p "Enter commit message: " COMMIT_MESSAGE
+ git add ..
+ git commit --quiet -m "$COMMIT_MESSAGE"
+fi
+
+if [ ${1-prod} == "dev" ]; then
+ echo $NEW_VERSION >| .version-dev
+ git add .version-dev
+else
+ echo $NEW_VERSION >| .version
+ git add .version
+fi
+
+echo "Starting build of $HUB_NAME:$NEW_VERSION at $(date -u)..."
+echo
+
+# Put version.txt inside of server
+pushd src/wwwroot
+echo "$NEW_VERSION" >version.txt
+git add version.txt
+popd
+
+git commit --quiet -m "chore(release): Bump version";
+
+
+read -p "Do you want to tag this build? (y/n) " -n 1 -r
+echo
+if [[ $REPLY =~ ^[Yy]$ ]]
+then
+ read -p "Enter tag message (can be empty): " TAG_MESSAGE
+ commit_msg="chore(release): Update CHANGELOG.md for $NEW_VERSION"
+ git cliff -r ../ $OLD_VERSION..HEAD --with-commit "$commit_msg" --prepend CHANGELOG.md
+ git add CHANGELOG.md
+ git commit --quiet -m "$commit_msg";
+ git tag -am "$TAG_MESSAGE" $NEW_VERSION
+fi
+
+read -p "Do you want to push the latest commits and tags to origin? (y/n) " -n 1 -r
+echo
+if [[ $REPLY =~ ^[Yy]$ ]]
+then
+ echo "Pushing latest changes to remotes..."
+ echo
+ git push --quiet --follow-tags
+fi
+
+
+# Build docker image
+echo "Building docker image"
+echo
+docker build -t $IMAGE_NAME:$NEW_VERSION .
+
+docker tag $IMAGE_NAME:$NEW_VERSION $HUB_NAME:$NEW_VERSION
+
+if [ ${1-prod} == "dev" ]; then
+ docker tag $IMAGE_NAME:$NEW_VERSION $HUB_NAME:latest-dev
+fi
+if [ ${1-prod} == "prod" ]; then
+ docker tag $IMAGE_NAME:$NEW_VERSION $HUB_NAME:latest
+fi
+
+# Optionally push images to docker registry
+echo "Press CTRL+C to exit or press ENTER to push docker image to registry"
+read -n 1
+docker push $HUB_NAME:$NEW_VERSION
+
+if [ ${1-prod} == "dev" ]; then
+ docker push $HUB_NAME:latest-dev
+fi
+
+if [ ${1-prod} == "prod" ]; then
+ docker push $HUB_NAME:latest
+fi
diff --git a/server/cliff.toml b/server/cliff.toml
new file mode 100644
index 0000000..955a72b
--- /dev/null
+++ b/server/cliff.toml
@@ -0,0 +1,62 @@
+# configuration file for git-cliff (0.1.0)
+
+[changelog]
+# changelog header
+header = """
+# Changelog\n
+"""
+# template for the changelog body
+# https://tera.netlify.app/docs/#introduction
+body = """
+{% if version %}\
+ ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
+{% else %}\
+ ## [unreleased]
+{% endif %}\
+{% for group, commits in commits | group_by(attribute="group") %}
+ ### {{ group | upper_first }}
+ {% for commit in commits %}
+ - {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first }}\
+ {% endfor %}
+{% endfor %}\n
+"""
+# remove the leading and trailing whitespace from the template
+trim = true
+# changelog footer
+footer = """
+<!-- generated by git-cliff -->
+"""
+
+[git]
+# parse the commits based on https://www.conventionalcommits.org
+conventional_commits = true
+# filter out the commits that are not conventional
+filter_unconventional = true
+# regex for preprocessing the commit messages
+commit_preprocessors = [
+ { pattern = "([ \\n])(([a-f0-9]{7})[a-f0-9]*)", replace = "${1}commit # [${3}](https://git.ivarlovlie.no/time-tracker/commit/${2})" },
+ { pattern = "https://git.ivarlovlie.no/time-tracker/commit/([a-f0-9]{7})[a-f0-9]*", replace = "commit # [${1}](${0})" },
+]
+# regex for parsing and grouping commits
+commit_parsers = [
+ { message = "^feat", group = "Features" },
+ { message = "^fix", group = "Bug Fixes" },
+ { message = "^doc", group = "Documentation" },
+ { message = "^perf", group = "Performance" },
+ { message = "^refactor", group = "Refactor" },
+ { message = "^style", group = "Styling" },
+ { message = "^test", group = "Testing" },
+ { message = "^chore", group = "Miscellaneous Tasks" },
+]
+# filter out the commits that are not matched by commit parsers
+filter_commits = true
+# glob pattern for matching git tags
+tag_pattern = "v.*"
+# regex for skipping tags
+skip_tags = "v0.1.0-beta.1"
+# regex for ignoring tags
+ignore_tags = ""
+# sort the tags chronologically
+date_order = true
+# sort the commits inside sections by oldest/newest order
+sort_commits = "newest"
diff --git a/server/src/Data/AppDbContext.cs b/server/src/Data/AppDbContext.cs
new file mode 100644
index 0000000..3f949dd
--- /dev/null
+++ b/server/src/Data/AppDbContext.cs
@@ -0,0 +1,58 @@
+namespace IOL.GreatOffice.Api.Data;
+
+public class AppDbContext : DbContext
+{
+ public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
+ public DbSet<User> Users { get; set; }
+ public DbSet<ForgotPasswordRequest> ForgotPasswordRequests { get; set; }
+ public DbSet<TimeLabel> TimeLabels { get; set; }
+ public DbSet<TimeEntry> TimeEntries { get; set; }
+ public DbSet<TimeCategory> TimeCategories { get; set; }
+ public DbSet<GithubUserMapping> GithubUserMappings { get; set; }
+ public DbSet<ApiAccessToken> AccessTokens { get; set; }
+ public DbSet<Tenant> Tenants { get; set; }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder) {
+ modelBuilder.Entity<User>(e => {
+ e.ToTable("users");
+ });
+
+ modelBuilder.Entity<ForgotPasswordRequest>(e => {
+ e.HasOne(c => c.User);
+ e.ToTable("forgot_password_requests");
+ });
+
+ modelBuilder.Entity<TimeCategory>(e => {
+ e.HasOne(c => c.User);
+ e.ToTable("time_categories");
+ });
+
+ modelBuilder.Entity<TimeLabel>(e => {
+ e.HasOne(c => c.User);
+ e.ToTable("time_labels");
+ });
+
+ modelBuilder.Entity<TimeEntry>(e => {
+ e.HasOne(c => c.User);
+ e.HasOne(c => c.Category);
+ e.HasMany(c => c.Labels);
+ e.ToTable("time_entries");
+ });
+
+ modelBuilder.Entity<GithubUserMapping>(e => {
+ e.HasOne(c => c.User);
+ e.HasKey(c => c.GithubId);
+ e.ToTable("github_user_mappings");
+ });
+
+ modelBuilder.Entity<ApiAccessToken>(e => {
+ e.ToTable("api_access_tokens");
+ });
+
+ modelBuilder.Entity<Tenant>(e => {
+ e.ToTable("tenants");
+ });
+
+ base.OnModelCreating(modelBuilder);
+ }
+}
diff --git a/server/src/Data/Database/ApiAccessToken.cs b/server/src/Data/Database/ApiAccessToken.cs
new file mode 100644
index 0000000..3eff5f3
--- /dev/null
+++ b/server/src/Data/Database/ApiAccessToken.cs
@@ -0,0 +1,31 @@
+namespace IOL.GreatOffice.Api.Data.Database;
+
+public class ApiAccessToken : Base
+{
+ public User User { get; set; }
+ public DateTime ExpiryDate { get; set; }
+ public bool AllowRead { get; set; }
+ public bool AllowCreate { get; set; }
+ public bool AllowUpdate { get; set; }
+ public bool AllowDelete { get; set; }
+ public bool HasExpired => ExpiryDate < DateTime.UtcNow;
+ public ApiAccessTokenDto AsDto => new(this);
+
+ public class ApiAccessTokenDto
+ {
+ public ApiAccessTokenDto(ApiAccessToken source) {
+ ExpiryDate = source.ExpiryDate;
+ AllowRead = source.AllowRead;
+ AllowCreate = source.AllowCreate;
+ AllowUpdate = source.AllowUpdate;
+ AllowDelete = source.AllowDelete;
+ }
+
+ public DateTime ExpiryDate { get; set; }
+ public bool AllowRead { get; set; }
+ public bool AllowCreate { get; set; }
+ public bool AllowUpdate { get; set; }
+ public bool AllowDelete { get; set; }
+ public bool HasExpired => ExpiryDate < DateTime.UtcNow;
+ }
+}
diff --git a/server/src/Data/Database/Base.cs b/server/src/Data/Database/Base.cs
new file mode 100644
index 0000000..2439668
--- /dev/null
+++ b/server/src/Data/Database/Base.cs
@@ -0,0 +1,14 @@
+namespace IOL.GreatOffice.Api.Data.Database;
+
+public class Base
+{
+ protected Base() {
+ Id = Guid.NewGuid();
+ CreatedAt = DateTime.UtcNow;
+ }
+
+ public Guid Id { get; init; }
+ public DateTime CreatedAt { get; init; }
+ public DateTime? ModifiedAt { get; private set; }
+ public void Modified() => ModifiedAt = DateTime.UtcNow;
+}
diff --git a/server/src/Data/Database/BaseWithOwner.cs b/server/src/Data/Database/BaseWithOwner.cs
new file mode 100644
index 0000000..eb4438d
--- /dev/null
+++ b/server/src/Data/Database/BaseWithOwner.cs
@@ -0,0 +1,24 @@
+namespace IOL.GreatOffice.Api.Data.Database;
+
+/// <summary>
+/// Base class for all entities.
+/// </summary>
+public class BaseWithOwner : Base
+{
+ protected BaseWithOwner() { }
+
+ protected BaseWithOwner(Guid userId) {
+ UserId = userId;
+ }
+
+ public Guid UserId { get; init; }
+ public User User { get; init; }
+ public Guid TenantId { get; set; }
+ public Tenant Tenant { get; init; }
+ public Guid ModifiedById { get; init; }
+ public User ModifiedBy { get; set; }
+ public Guid CreatedById { get; init; }
+ public User CreatedBy { get; set; }
+ public Guid DeletedById { get; init; }
+ public User DeletedBy { get; set; }
+}
diff --git a/server/src/Data/Database/ForgotPasswordRequest.cs b/server/src/Data/Database/ForgotPasswordRequest.cs
new file mode 100644
index 0000000..164f09d
--- /dev/null
+++ b/server/src/Data/Database/ForgotPasswordRequest.cs
@@ -0,0 +1,23 @@
+namespace IOL.GreatOffice.Api.Data.Database;
+
+public class ForgotPasswordRequest
+{
+ public ForgotPasswordRequest() { }
+
+ public ForgotPasswordRequest(User user) {
+ CreatedAt = DateTime.UtcNow;
+ Id = Guid.NewGuid();
+ User = user;
+ }
+
+ public Guid Id { get; set; }
+ public Guid UserId { get; set; }
+ public User User { get; set; }
+ public DateTime CreatedAt { get; set; }
+
+ [NotMapped]
+ public DateTime ExpirationDate => CreatedAt.AddMinutes(15);
+
+ [NotMapped]
+ public bool IsExpired => DateTime.Compare(ExpirationDate, DateTime.UtcNow) < 0;
+}
diff --git a/server/src/Data/Database/GithubUserMapping.cs b/server/src/Data/Database/GithubUserMapping.cs
new file mode 100644
index 0000000..dbdb2b7
--- /dev/null
+++ b/server/src/Data/Database/GithubUserMapping.cs
@@ -0,0 +1,9 @@
+namespace IOL.GreatOffice.Api.Data.Database;
+
+public class GithubUserMapping
+{
+ public User User { get; set; }
+ public string GithubId { get; set; }
+ public string Email { get; set; }
+ public string RefreshToken { get; set; }
+}
diff --git a/server/src/Data/Database/Tenant.cs b/server/src/Data/Database/Tenant.cs
new file mode 100644
index 0000000..3028d13
--- /dev/null
+++ b/server/src/Data/Database/Tenant.cs
@@ -0,0 +1,10 @@
+namespace IOL.GreatOffice.Api.Data.Database;
+
+public class Tenant : BaseWithOwner
+{
+ public string Name { get; set; }
+ public string Description { get; set; }
+ public string ContactEmail { get; set; }
+ public Guid MasterUserId { get; set; }
+ public string MasterUserPassword { get; set; }
+}
diff --git a/server/src/Data/Database/TimeCategory.cs b/server/src/Data/Database/TimeCategory.cs
new file mode 100644
index 0000000..69c6957
--- /dev/null
+++ b/server/src/Data/Database/TimeCategory.cs
@@ -0,0 +1,31 @@
+namespace IOL.GreatOffice.Api.Data.Database;
+
+public class TimeCategory : BaseWithOwner
+{
+ public TimeCategory() { }
+ public TimeCategory(Guid userId) : base(userId) { }
+ public string Name { get; set; }
+ public string Color { get; set; }
+ public TimeCategoryDto AsDto => new(this);
+
+ public class TimeCategoryDto
+ {
+ public TimeCategoryDto() { }
+
+ public TimeCategoryDto(TimeCategory sourceEntry = default) {
+ if (sourceEntry == default) {
+ return;
+ }
+
+ Id = sourceEntry.Id;
+ ModifiedAt = sourceEntry.ModifiedAt;
+ Name = sourceEntry.Name;
+ Color = sourceEntry.Color;
+ }
+
+ public Guid Id { get; set; }
+ public DateTime? ModifiedAt { get; set; }
+ public string Name { get; set; }
+ public string Color { get; set; }
+ }
+}
diff --git a/server/src/Data/Database/TimeEntry.cs b/server/src/Data/Database/TimeEntry.cs
new file mode 100644
index 0000000..46c62e1
--- /dev/null
+++ b/server/src/Data/Database/TimeEntry.cs
@@ -0,0 +1,45 @@
+namespace IOL.GreatOffice.Api.Data.Database;
+
+public class TimeEntry : BaseWithOwner
+{
+ public TimeEntry() { }
+ public TimeEntry(Guid userId) : base(userId) { }
+ public DateTime Start { get; set; }
+ public DateTime Stop { get; set; }
+ public string Description { get; set; }
+ public ICollection<TimeLabel> Labels { get; set; }
+ public TimeCategory Category { get; set; }
+ public TimeEntryDto AsDto => new(this);
+
+ public class TimeEntryDto
+ {
+ public TimeEntryDto() { }
+
+ public TimeEntryDto(TimeEntry sourceEntry = default) {
+ if (sourceEntry == default) {
+ return;
+ }
+
+ Id = sourceEntry.Id;
+ ModifiedAt = sourceEntry.ModifiedAt;
+ Stop = sourceEntry.Stop;
+ Start = sourceEntry.Start;
+ Description = sourceEntry.Description;
+ if (sourceEntry.Labels != default) {
+ Labels = sourceEntry.Labels
+ .Select(t => t.AsDto)
+ .ToList();
+ }
+
+ Category = sourceEntry.Category.AsDto;
+ }
+
+ public Guid? Id { get; set; }
+ public DateTime? ModifiedAt { get; set; }
+ public DateTime Start { get; set; }
+ public DateTime Stop { get; set; }
+ public string Description { get; set; }
+ public List<TimeLabel.TimeLabelDto> Labels { get; set; }
+ public TimeCategory.TimeCategoryDto Category { get; set; }
+ }
+}
diff --git a/server/src/Data/Database/TimeLabel.cs b/server/src/Data/Database/TimeLabel.cs
new file mode 100644
index 0000000..55e20b0
--- /dev/null
+++ b/server/src/Data/Database/TimeLabel.cs
@@ -0,0 +1,31 @@
+namespace IOL.GreatOffice.Api.Data.Database;
+
+public class TimeLabel : BaseWithOwner
+{
+ public TimeLabel() { }
+ public TimeLabel(Guid userId) : base(userId) { }
+ public string Name { get; set; }
+ public string Color { get; set; }
+
+ [NotMapped]
+ public TimeLabelDto AsDto => new(this);
+
+ public class TimeLabelDto
+ {
+ public TimeLabelDto() { }
+
+ public TimeLabelDto(TimeLabel sourceEntry) {
+ Id = sourceEntry.Id;
+ CreatedAt = sourceEntry.CreatedAt;
+ ModifiedAt = sourceEntry.ModifiedAt;
+ Name = sourceEntry.Name;
+ Color = sourceEntry.Color;
+ }
+
+ public Guid Id { get; set; }
+ public DateTime CreatedAt { get; set; }
+ public DateTime? ModifiedAt { get; set; }
+ public string Name { get; set; }
+ public string Color { get; set; }
+ }
+}
diff --git a/server/src/Data/Database/User.cs b/server/src/Data/Database/User.cs
new file mode 100644
index 0000000..c5063f6
--- /dev/null
+++ b/server/src/Data/Database/User.cs
@@ -0,0 +1,29 @@
+namespace IOL.GreatOffice.Api.Data.Database;
+
+public class User : Base
+{
+ public User() { }
+
+ public User(string username) {
+ Username = username;
+ }
+
+ public string Username { get; set; }
+ public string Password { get; set; }
+
+
+ public void HashAndSetPassword(string password) {
+ Password = PasswordHelper.HashPassword(password);
+ }
+
+ public bool VerifyPassword(string password) {
+ return PasswordHelper.Verify(password, Password);
+ }
+
+ public IEnumerable<Claim> DefaultClaims() {
+ return new Claim[] {
+ new(AppClaims.USER_ID, Id.ToString()),
+ new(AppClaims.NAME, Username),
+ };
+ }
+}
diff --git a/server/src/Data/Dtos/TimeQueryDto.cs b/server/src/Data/Dtos/TimeQueryDto.cs
new file mode 100644
index 0000000..f734cb1
--- /dev/null
+++ b/server/src/Data/Dtos/TimeQueryDto.cs
@@ -0,0 +1,34 @@
+
+namespace IOL.GreatOffice.Api.Data.Dtos;
+
+public class TimeQueryDto
+{
+ public TimeQueryDto() {
+ Results = new List<TimeEntry.TimeEntryDto>();
+ }
+
+ /// <summary>
+ /// List of entries.
+ /// </summary>
+ public List<TimeEntry.TimeEntryDto> Results { get; set; }
+
+ /// <summary>
+ /// Curren page.
+ /// </summary>
+ public int Page { get; set; }
+
+ /// <summary>
+ /// Maximum count of entries in a page.
+ /// </summary>
+ public int PageSize { get; set; }
+
+ /// <summary>
+ /// Total count of entries.
+ /// </summary>
+ public int TotalSize { get; set; }
+
+ /// <summary>
+ /// Total count of pages.
+ /// </summary>
+ public int TotalPageCount { get; set; }
+}
diff --git a/server/src/Data/Dtos/UserArchiveDto.cs b/server/src/Data/Dtos/UserArchiveDto.cs
new file mode 100644
index 0000000..63b1470
--- /dev/null
+++ b/server/src/Data/Dtos/UserArchiveDto.cs
@@ -0,0 +1,131 @@
+
+namespace IOL.GreatOffice.Api.Data.Dtos;
+
+/// <summary>
+/// Represents a user archive as it is provided to users.
+/// </summary>
+public class UserArchiveDto
+{
+ /// <inheritdoc cref="UserArchiveDto"/>
+ public UserArchiveDto(User user) {
+ Meta = new MetaDto {
+ GeneratedAt = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ")
+ };
+ User = new UserDto(user);
+ Entries = new List<EntryDto>();
+ }
+
+ /// <summary>
+ /// Metadata for the user archive.
+ /// </summary>
+ public MetaDto Meta { get; }
+
+ /// <summary>
+ /// Relevant user data for the archive.
+ /// </summary>
+ public UserDto User { get; }
+
+ /// <summary>
+ /// List of entries that the user has created.
+ /// </summary>
+ public List<EntryDto> Entries { get; }
+
+ public void CountEntries() {
+ Meta.EntryCount = Entries.Count;
+ }
+
+ /// <summary>
+ /// Represents a time entry in the data archive.
+ /// </summary>
+ public class EntryDto
+ {
+ public string CreatedAt { get; init; }
+
+ [JsonIgnore]
+ public DateTime StartDateTime { get; init; }
+
+ /// <summary>
+ /// ISO 8601 string of the UTC date the time entry started.
+ /// </summary>
+ public string Start => StartDateTime.ToString("yyyy-MM-ddTHH:mm:ssZ");
+
+ [JsonIgnore]
+ public DateTime StopDateTime { get; init; }
+
+ /// <summary>
+ /// ISO 8601 string of the UTC date the time entry stopped.
+ /// </summary>
+ public string Stop => StopDateTime.ToString("yyyy-MM-ddTHH:mm:ssZ");
+
+ /// <summary>
+ /// Total amount of minutes elapsed from start to stop on this time entry.
+ /// </summary>
+ public double Minutes => StopDateTime.Subtract(StartDateTime).TotalMinutes;
+
+ public string Description { get; init; }
+
+ /// <summary>
+ /// Archive spesific category for this time entry.
+ /// </summary>
+ public CategoryDto Category { get; init; }
+
+ /// <summary>
+ /// Archive spesific list of labels for this time entry.
+ /// </summary>
+ public List<LabelDto> Labels { get; init; }
+ }
+
+ /// <summary>
+ /// Time entry category as it is written to the user archive.
+ /// </summary>
+ public class CategoryDto
+ {
+ public string Name { get; init; }
+ public string Color { get; init; }
+ }
+
+ /// <summary>
+ /// Time entry label as it is written to the user archive.
+ /// </summary>
+ public class LabelDto
+ {
+ public string Name { get; init; }
+ public string Color { get; init; }
+ }
+
+
+ /// <summary>
+ /// Represents the user who this archive's data is based on.
+ /// </summary>
+ public class UserDto
+ {
+ /// <inheritdoc cref="UserDto"/>
+ public UserDto(User user) {
+ Username = user.Username;
+ CreatedAt = user.CreatedAt;
+ }
+
+ /// <summary>
+ /// UTC date this user was created.
+ /// </summary>
+ public DateTime CreatedAt { get; }
+
+ public string Username { get; }
+ }
+
+ /// <summary>
+ /// Represents the meta object which contains metdata for this archive.
+ /// </summary>
+ public class MetaDto
+ {
+ /// <summary>
+ /// ISO 8601 UTC date string for when this archive was created.
+ /// </summary>
+ public string GeneratedAt { get; init; }
+
+ /// <summary>
+ /// Amount of entries in the archive.
+ /// </summary>
+ public int EntryCount { get; set; }
+ }
+}
diff --git a/server/src/Data/Enums/TimeEntryQueryDuration.cs b/server/src/Data/Enums/TimeEntryQueryDuration.cs
new file mode 100644
index 0000000..af70ca6
--- /dev/null
+++ b/server/src/Data/Enums/TimeEntryQueryDuration.cs
@@ -0,0 +1,37 @@
+namespace IOL.GreatOffice.Api.Data.Enums;
+
+/// <summary>
+/// Specify a duration filter for time entry queries.
+/// </summary>
+public enum TimeEntryQueryDuration
+{
+ /// <summary>
+ /// Only query entries created today.
+ /// </summary>
+ TODAY = 0,
+
+ /// <summary>
+ /// Only query entries created this week.
+ /// </summary>
+ THIS_WEEK = 1,
+
+ /// <summary>
+ /// Only query entries created this month.
+ /// </summary>
+ THIS_MONTH = 2,
+
+ /// <summary>
+ /// Only query entries created this year.
+ /// </summary>
+ THIS_YEAR = 3,
+
+ /// <summary>
+ /// Only query entries created at a spesific date.
+ /// </summary>
+ SPECIFIC_DATE = 4,
+
+ /// <summary>
+ /// Only query entries created between two dates.
+ /// </summary>
+ DATE_RANGE = 5,
+}
diff --git a/server/src/Data/Exceptions/ForgotPasswordRequestNotFoundException.cs b/server/src/Data/Exceptions/ForgotPasswordRequestNotFoundException.cs
new file mode 100644
index 0000000..02474b4
--- /dev/null
+++ b/server/src/Data/Exceptions/ForgotPasswordRequestNotFoundException.cs
@@ -0,0 +1,21 @@
+namespace IOL.GreatOffice.Api.Data.Exceptions;
+
+[Serializable]
+public class ForgotPasswordRequestNotFoundException : Exception
+{
+ //
+ // For guidelines regarding the creation of new exception types, see
+ // http://msdn.microsoft.com/library/default.asp?url=/library/en-us/cpgenref/html/cpconerrorraisinghandlingguidelines.asp
+ // and
+ // http://msdn.microsoft.com/library/default.asp?url=/library/en-us/dncscol/html/csharp07192001.asp
+ //
+
+ public ForgotPasswordRequestNotFoundException() { }
+ public ForgotPasswordRequestNotFoundException(string message) : base(message) { }
+ public ForgotPasswordRequestNotFoundException(string message, Exception inner) : base(message, inner) { }
+
+ protected ForgotPasswordRequestNotFoundException(
+ SerializationInfo info,
+ StreamingContext context
+ ) : base(info, context) { }
+}
diff --git a/server/src/Data/Exceptions/UserNotFoundException.cs b/server/src/Data/Exceptions/UserNotFoundException.cs
new file mode 100644
index 0000000..06b57a9
--- /dev/null
+++ b/server/src/Data/Exceptions/UserNotFoundException.cs
@@ -0,0 +1,19 @@
+namespace IOL.GreatOffice.Api.Data.Exceptions;
+
+[Serializable]
+public class UserNotFoundException : Exception
+{
+ // For guidelines regarding the creation of new exception types, see
+ // http://msdn.microsoft.com/library/default.asp?url=/library/en-us/cpgenref/html/cpconerrorraisinghandlingguidelines.asp
+ // and
+ // http://msdn.microsoft.com/library/default.asp?url=/library/en-us/dncscol/html/csharp07192001.asp
+
+ public UserNotFoundException() { }
+ public UserNotFoundException(string message) : base(message) { }
+ public UserNotFoundException(string message, Exception inner) : base(message, inner) { }
+
+ protected UserNotFoundException(
+ SerializationInfo info,
+ StreamingContext context
+ ) : base(info, context) { }
+}
diff --git a/server/src/Data/Models/ApiSpecDocument.cs b/server/src/Data/Models/ApiSpecDocument.cs
new file mode 100644
index 0000000..1c7d936
--- /dev/null
+++ b/server/src/Data/Models/ApiSpecDocument.cs
@@ -0,0 +1,9 @@
+namespace IOL.GreatOffice.Api.Data.Models;
+
+public class ApiSpecDocument
+{
+ public string VersionName { get; set; }
+ public string SwaggerPath { get; set; }
+ public ApiVersion Version { get; set; }
+ public OpenApiInfo OpenApiInfo { get; set; }
+}
diff --git a/server/src/Data/Models/AppPath.cs b/server/src/Data/Models/AppPath.cs
new file mode 100644
index 0000000..e47e48c
--- /dev/null
+++ b/server/src/Data/Models/AppPath.cs
@@ -0,0 +1,23 @@
+namespace IOL.GreatOffice.Api.Data.Models;
+
+public sealed record AppPath
+{
+ public string HostPath { get; init; }
+ public string WebPath { get; init; }
+
+ public string GetHostPathForFilename(string filename, string fallback = "") {
+ if (filename.IsNullOrWhiteSpace()) {
+ return fallback;
+ }
+
+ return Path.Combine(HostPath, filename);
+ }
+
+ public string GetWebPathForFilename(string filename, string fallback = "") {
+ if (filename.IsNullOrWhiteSpace()) {
+ return fallback;
+ }
+
+ return Path.Combine(WebPath, filename);
+ }
+}
diff --git a/server/src/Data/Models/LoggedInUserModel.cs b/server/src/Data/Models/LoggedInUserModel.cs
new file mode 100644
index 0000000..4a5bef9
--- /dev/null
+++ b/server/src/Data/Models/LoggedInUserModel.cs
@@ -0,0 +1,8 @@
+namespace IOL.GreatOffice.Api.Data.Models;
+
+public class LoggedInUserModel
+{
+ public LoggedInUserModel() { }
+ public Guid Id { get; set; }
+ public string Username { get; set; }
+}
diff --git a/server/src/Data/Results/ErrorResult.cs b/server/src/Data/Results/ErrorResult.cs
new file mode 100644
index 0000000..fd2fd6a
--- /dev/null
+++ b/server/src/Data/Results/ErrorResult.cs
@@ -0,0 +1,12 @@
+namespace IOL.GreatOffice.Api.Data.Results;
+
+public class ErrorResult
+{
+ public ErrorResult(string title = default, string text = default) {
+ Title = title;
+ Text = text;
+ }
+
+ public string Title { get; set; }
+ public string Text { get; set; }
+}
diff --git a/server/src/Data/Static/AppClaims.cs b/server/src/Data/Static/AppClaims.cs
new file mode 100644
index 0000000..8b6d3a8
--- /dev/null
+++ b/server/src/Data/Static/AppClaims.cs
@@ -0,0 +1,8 @@
+namespace IOL.GreatOffice.Api.Data.Static;
+
+public static class AppClaims
+{
+ public const string USER_ID = "user_id";
+ public const string NAME = "name";
+ public const string GITHUB_ACCESS_TOKEN = "";
+}
diff --git a/server/src/Data/Static/AppConstants.cs b/server/src/Data/Static/AppConstants.cs
new file mode 100644
index 0000000..61e5cd5
--- /dev/null
+++ b/server/src/Data/Static/AppConstants.cs
@@ -0,0 +1,11 @@
+namespace IOL.GreatOffice.Api.Data.Static;
+
+public static class AppConstants
+{
+ public const string API_NAME = "Great Office API";
+ public const string BASIC_AUTH_SCHEME = "BasicAuthenticationScheme";
+ public const string TOKEN_ALLOW_READ = "TOKEN_ALLOW_READ";
+ public const string TOKEN_ALLOW_CREATE = "TOKEN_ALLOW_CREATE";
+ public const string TOKEN_ALLOW_UPDATE = "TOKEN_ALLOW_UPDATE";
+ public const string TOKEN_ALLOW_DELETE = "TOKEN_ALLOW_DELETE";
+}
diff --git a/server/src/Data/Static/AppEnvironmentVariables.cs b/server/src/Data/Static/AppEnvironmentVariables.cs
new file mode 100644
index 0000000..a734146
--- /dev/null
+++ b/server/src/Data/Static/AppEnvironmentVariables.cs
@@ -0,0 +1,27 @@
+namespace IOL.GreatOffice.Api.Data.Static;
+
+public static class AppEnvironmentVariables
+{
+ public const string DB_HOST = "DB_HOST";
+ public const string DB_PORT = "DB_PORT";
+ public const string DB_USER = "DB_USER";
+ public const string DB_PASSWORD = "DB_PASSWORD";
+ public const string DB_NAME = "DB_NAME";
+ public const string QUARTZ_DB_HOST = "QUARTZ_DB_HOST";
+ public const string QUARTZ_DB_PORT = "QUARTZ_DB_PORT";
+ public const string QUARTZ_DB_USER = "QUARTZ_DB_USER";
+ public const string QUARTZ_DB_PASSWORD = "QUARTZ_DB_PASSWORD";
+ public const string QUARTZ_DB_NAME = "QUARTZ_DB_NAME";
+ public const string SEQ_API_KEY = "SEQ_API_KEY";
+ public const string SEQ_API_URL = "SEQ_API_URL";
+ public const string SMTP_HOST = "SMTP_HOST";
+ public const string SMTP_PORT = "SMTP_PORT";
+ public const string SMTP_USER = "SMTP_USER";
+ public const string SMTP_PASSWORD = "SMTP_PASSWORD";
+ public const string EMAIL_FROM_ADDRESS = "EMAIL_FROM_ADDRESS";
+ public const string EMAIL_FROM_DISPLAY_NAME = "EMAIL_FROM_DISPLAY_NAME";
+ public const string ACCOUNTS_URL = "ACCOUNTS_URL";
+ public const string GITHUB_CLIENT_ID = "GH_CLIENT_ID";
+ public const string GITHUB_CLIENT_SECRET = "GH_CLIENT_SECRET";
+ public const string APP_AES_KEY = "APP_AES_KEY";
+}
diff --git a/server/src/Data/Static/AppHeaders.cs b/server/src/Data/Static/AppHeaders.cs
new file mode 100644
index 0000000..41a3085
--- /dev/null
+++ b/server/src/Data/Static/AppHeaders.cs
@@ -0,0 +1,6 @@
+namespace IOL.GreatOffice.Api.Data.Static;
+
+public static class AppHeaders
+{
+ public const string BROWSER_TIME_ZONE = "X-TimeZone";
+}
diff --git a/server/src/Data/Static/AppPaths.cs b/server/src/Data/Static/AppPaths.cs
new file mode 100644
index 0000000..a24f5af
--- /dev/null
+++ b/server/src/Data/Static/AppPaths.cs
@@ -0,0 +1,17 @@
+
+namespace IOL.GreatOffice.Api.Data.Static;
+
+public static class AppPaths
+{
+ public static AppPath AppData => new() {
+ HostPath = Path.Combine(Directory.GetCurrentDirectory(), "AppData")
+ };
+
+ public static AppPath DataProtectionKeys => new() {
+ HostPath = Path.Combine(Directory.GetCurrentDirectory(), "AppData", "dp-keys")
+ };
+
+ public static AppPath Frontend => new() {
+ HostPath = Path.Combine(Directory.GetCurrentDirectory(), "Frontend")
+ };
+}
diff --git a/server/src/Data/Static/JsonSettings.cs b/server/src/Data/Static/JsonSettings.cs
new file mode 100644
index 0000000..a163c11
--- /dev/null
+++ b/server/src/Data/Static/JsonSettings.cs
@@ -0,0 +1,11 @@
+namespace IOL.GreatOffice.Api.Data.Static;
+
+public static class JsonSettings
+{
+ public static Action<JsonOptions> Default { get; } = options => {
+ options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
+ options.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
+ options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowReadingFromString;
+ options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
+ };
+}
diff --git a/server/src/Endpoints/Internal/Account/CreateAccountPayload.cs b/server/src/Endpoints/Internal/Account/CreateAccountPayload.cs
new file mode 100644
index 0000000..dc73e68
--- /dev/null
+++ b/server/src/Endpoints/Internal/Account/CreateAccountPayload.cs
@@ -0,0 +1,17 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Account;
+
+/// <summary>
+/// Payload for creating new user accounts.
+/// </summary>
+public class CreateAccountPayload
+{
+ /// <summary>
+ /// Username for the new account.
+ /// </summary>
+ public string Username { get; set; }
+
+ /// <summary>
+ /// Password for the new account.
+ /// </summary>
+ public string Password { get; set; }
+}
diff --git a/server/src/Endpoints/Internal/Account/CreateAccountRoute.cs b/server/src/Endpoints/Internal/Account/CreateAccountRoute.cs
new file mode 100644
index 0000000..954fbf5
--- /dev/null
+++ b/server/src/Endpoints/Internal/Account/CreateAccountRoute.cs
@@ -0,0 +1,44 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Account;
+
+/// <inheritdoc />
+public class CreateAccountRoute : RouteBaseAsync.WithRequest<CreateAccountPayload>.WithActionResult
+{
+ private readonly AppDbContext _context;
+ private readonly UserService _userService;
+
+ /// <inheritdoc />
+ public CreateAccountRoute(UserService userService, AppDbContext context) {
+ _userService = userService;
+ _context = context;
+ }
+
+ /// <summary>
+ /// Create a new user account.
+ /// </summary>
+ /// <param name="request"></param>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ [AllowAnonymous]
+ [HttpPost("~/_/account/create")]
+ public override async Task<ActionResult> HandleAsync(CreateAccountPayload request, CancellationToken cancellationToken = default) {
+ if (request.Username.IsValidEmailAddress() == false) {
+ return BadRequest(new ErrorResult("Invalid form", request.Username + " does not look like a valid email"));
+ }
+
+ if (request.Password.Length < 6) {
+ return BadRequest(new ErrorResult("Invalid form", "The password requires 6 or more characters."));
+ }
+
+ var username = request.Username.Trim();
+ if (_context.Users.Any(c => c.Username == username)) {
+ return BadRequest(new ErrorResult("Username is not available", "There is already a user registered with email: " + username));
+ }
+
+ var user = new User(username);
+ user.HashAndSetPassword(request.Password);
+ _context.Users.Add(user);
+ await _context.SaveChangesAsync(cancellationToken);
+ await _userService.LogInUser(HttpContext, user);
+ return Ok();
+ }
+}
diff --git a/server/src/Endpoints/Internal/Account/CreateGithubSessionRoute.cs b/server/src/Endpoints/Internal/Account/CreateGithubSessionRoute.cs
new file mode 100644
index 0000000..0cd1aa5
--- /dev/null
+++ b/server/src/Endpoints/Internal/Account/CreateGithubSessionRoute.cs
@@ -0,0 +1,17 @@
+using AspNet.Security.OAuth.GitHub;
+
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Account;
+
+public class CreateGithubSessionRoute : RouteBaseSync.WithRequest<string>.WithActionResult
+{
+ public CreateGithubSessionRoute(IConfiguration configuration) { }
+
+ [AllowAnonymous]
+ [HttpGet("~/_/account/create-github-session")]
+ public override ActionResult Handle(string next) {
+ return Challenge(new AuthenticationProperties {
+ RedirectUri = next
+ },
+ GitHubAuthenticationDefaults.AuthenticationScheme);
+ }
+}
diff --git a/server/src/Endpoints/Internal/Account/CreateInitialAccountRoute.cs b/server/src/Endpoints/Internal/Account/CreateInitialAccountRoute.cs
new file mode 100644
index 0000000..13fbdf4
--- /dev/null
+++ b/server/src/Endpoints/Internal/Account/CreateInitialAccountRoute.cs
@@ -0,0 +1,34 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Account;
+
+/// <inheritdoc />
+public class CreateInitialAccountRoute : RouteBaseAsync.WithoutRequest.WithActionResult
+{
+ private readonly AppDbContext _context;
+ private readonly UserService _userService;
+
+ /// <inheritdoc />
+ public CreateInitialAccountRoute(AppDbContext context, UserService userService) {
+ _context = context;
+ _userService = userService;
+ }
+
+ /// <summary>
+ /// Create an initial user account.
+ /// </summary>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ [AllowAnonymous]
+ [HttpGet("~/_/account/create-initial")]
+ public override async Task<ActionResult> HandleAsync(CancellationToken cancellationToken = default) {
+ if (_context.Users.Any()) {
+ return NotFound();
+ }
+
+ var user = new User("admin@ivarlovlie.no");
+ user.HashAndSetPassword("ivar123");
+ _context.Users.Add(user);
+ await _context.SaveChangesAsync(cancellationToken);
+ await _userService.LogInUser(HttpContext, user);
+ return Redirect("/");
+ }
+}
diff --git a/server/src/Endpoints/Internal/Account/DeleteAccountRoute.cs b/server/src/Endpoints/Internal/Account/DeleteAccountRoute.cs
new file mode 100644
index 0000000..2149e15
--- /dev/null
+++ b/server/src/Endpoints/Internal/Account/DeleteAccountRoute.cs
@@ -0,0 +1,49 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Account;
+
+public class DeleteAccountRoute : RouteBaseAsync.WithoutRequest.WithActionResult
+{
+ private readonly AppDbContext _context;
+ private readonly UserService _userService;
+
+ /// <inheritdoc />
+ public DeleteAccountRoute(AppDbContext context, UserService userService) {
+ _context = context;
+ _userService = userService;
+ }
+
+ /// <summary>
+ /// Delete the logged on user's account.
+ /// </summary>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ [HttpDelete("~/_/account/delete")]
+ public override async Task<ActionResult> HandleAsync(CancellationToken cancellationToken = default) {
+ var user = _context.Users.SingleOrDefault(c => c.Id == LoggedInUser.Id);
+ if (user == default) {
+ await _userService.LogOutUser(HttpContext);
+ return Unauthorized();
+ }
+
+ if (user.Username == "demo@demo.demo") {
+ await _userService.LogOutUser(HttpContext);
+ return Ok();
+ }
+
+ var githubMappings = _context.TimeCategories.Where(c => c.UserId == user.Id);
+ var passwordResets = _context.ForgotPasswordRequests.Where(c => c.UserId == user.Id);
+ var entries = _context.TimeEntries.Where(c => c.UserId == user.Id);
+ var labels = _context.TimeLabels.Where(c => c.UserId == user.Id);
+ var categories = _context.TimeCategories.Where(c => c.UserId == user.Id);
+
+ _context.TimeCategories.RemoveRange(githubMappings);
+ _context.ForgotPasswordRequests.RemoveRange(passwordResets);
+ _context.TimeEntries.RemoveRange(entries);
+ _context.TimeLabels.RemoveRange(labels);
+ _context.TimeCategories.RemoveRange(categories);
+ _context.Users.Remove(user);
+
+ await _context.SaveChangesAsync(cancellationToken);
+ await _userService.LogOutUser(HttpContext);
+ return Ok();
+ }
+}
diff --git a/server/src/Endpoints/Internal/Account/GetArchiveRoute.cs b/server/src/Endpoints/Internal/Account/GetArchiveRoute.cs
new file mode 100644
index 0000000..44f5249
--- /dev/null
+++ b/server/src/Endpoints/Internal/Account/GetArchiveRoute.cs
@@ -0,0 +1,62 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Account;
+
+public class GetAccountArchiveRoute : RouteBaseAsync.WithoutRequest.WithActionResult<UserArchiveDto>
+{
+ private readonly AppDbContext _context;
+
+ /// <inheritdoc />
+ public GetAccountArchiveRoute(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Get a data archive with the currently logged on user's data.
+ /// </summary>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ [HttpGet("~/_/account/archive")]
+ public override async Task<ActionResult<UserArchiveDto>> HandleAsync(CancellationToken cancellationToken = default) {
+ var user = _context.Users.SingleOrDefault(c => c.Id == LoggedInUser.Id);
+ if (user == default) {
+ await HttpContext.SignOutAsync();
+ return Unauthorized();
+ }
+
+ var entries = _context.TimeEntries
+ .AsNoTracking()
+ .Include(c => c.Labels)
+ .Include(c => c.Category)
+ .Where(c => c.User.Id == user.Id)
+ .ToList();
+
+ var jsonOptions = new JsonSerializerOptions {
+ WriteIndented = true
+ };
+
+ var dto = new UserArchiveDto(user);
+ dto.Entries.AddRange(entries.Select(entry => new UserArchiveDto.EntryDto {
+ CreatedAt = entry.CreatedAt.ToString("yyyy-MM-ddTHH:mm:ssZ"),
+ StartDateTime = entry.Start,
+ StopDateTime = entry.Stop,
+ Description = entry.Description,
+ Labels = entry.Labels
+ .Select(c => new UserArchiveDto.LabelDto {
+ Name = c.Name,
+ Color = c.Color
+ })
+ .ToList(),
+ Category = new UserArchiveDto.CategoryDto {
+ Name = entry.Category.Name,
+ Color = entry.Category.Color
+ },
+ }));
+
+ dto.CountEntries();
+
+ var entriesSerialized = JsonSerializer.SerializeToUtf8Bytes(dto, jsonOptions);
+
+ return File(entriesSerialized,
+ "application/json",
+ user.Username + "-time-tracker-archive-" + DateTime.UtcNow.ToString("yyyyMMddTHHmmss") + ".json");
+ }
+}
diff --git a/server/src/Endpoints/Internal/Account/GetRoute.cs b/server/src/Endpoints/Internal/Account/GetRoute.cs
new file mode 100644
index 0000000..34a3c97
--- /dev/null
+++ b/server/src/Endpoints/Internal/Account/GetRoute.cs
@@ -0,0 +1,30 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Account;
+
+public class GetAccountRoute : RouteBaseAsync.WithoutRequest.WithActionResult<LoggedInUserModel>
+{
+ private readonly AppDbContext _context;
+
+ /// <inheritdoc />
+ public GetAccountRoute(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Get the logged on user's session data.
+ /// </summary>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ [HttpGet("~/_/account")]
+ public override async Task<ActionResult<LoggedInUserModel>> HandleAsync(CancellationToken cancellationToken = default) {
+ var user = _context.Users.SingleOrDefault(c => c.Id == LoggedInUser.Id);
+ if (user != default) {
+ return Ok(new LoggedInUserModel {
+ Id = LoggedInUser.Id,
+ Username = LoggedInUser.Username
+ });
+ }
+
+ await HttpContext.SignOutAsync();
+ return Unauthorized();
+ }
+}
diff --git a/server/src/Endpoints/Internal/Account/LoginPayload.cs b/server/src/Endpoints/Internal/Account/LoginPayload.cs
new file mode 100644
index 0000000..807662c
--- /dev/null
+++ b/server/src/Endpoints/Internal/Account/LoginPayload.cs
@@ -0,0 +1,22 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Account;
+
+/// <summary>
+/// Payload for logging in a user.
+/// </summary>
+public class LoginPayload
+{
+ /// <summary>
+ /// Username of the user's account.
+ /// </summary>
+ public string Username { get; set; }
+
+ /// <summary>
+ /// Password of the user's account.
+ /// </summary>
+ public string Password { get; set; }
+
+ /// <summary>
+ /// Specify that the created session should be long lived and continually refreshed.
+ /// </summary>
+ public bool Persist { get; set; }
+}
diff --git a/server/src/Endpoints/Internal/Account/LoginRoute.cs b/server/src/Endpoints/Internal/Account/LoginRoute.cs
new file mode 100644
index 0000000..5b41c61
--- /dev/null
+++ b/server/src/Endpoints/Internal/Account/LoginRoute.cs
@@ -0,0 +1,37 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Account;
+
+public class LoginRoute : RouteBaseAsync
+ .WithRequest<LoginPayload>
+ .WithActionResult
+{
+ private readonly AppDbContext _context;
+ private readonly UserService _userService;
+
+ /// <inheritdoc />
+ public LoginRoute(AppDbContext context, UserService userService) {
+ _context = context;
+ _userService = userService;
+ }
+
+ /// <summary>
+ /// Login a user.
+ /// </summary>
+ /// <param name="request"></param>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ [AllowAnonymous]
+ [HttpPost("~/_/account/login")]
+ public override async Task<ActionResult> HandleAsync(LoginPayload request, CancellationToken cancellationToken = default) {
+ if (!ModelState.IsValid) {
+ return BadRequest(ModelState);
+ }
+
+ var user = _context.Users.SingleOrDefault(u => u.Username == request.Username);
+ if (user == default || !user.VerifyPassword(request.Password)) {
+ return BadRequest(new ErrorResult("Invalid username or password"));
+ }
+
+ await _userService.LogInUser(HttpContext, user, request.Persist);
+ return Ok();
+ }
+}
diff --git a/server/src/Endpoints/Internal/Account/LogoutRoute.cs b/server/src/Endpoints/Internal/Account/LogoutRoute.cs
new file mode 100644
index 0000000..4a06f4a
--- /dev/null
+++ b/server/src/Endpoints/Internal/Account/LogoutRoute.cs
@@ -0,0 +1,22 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Account;
+
+public class LogoutRoute : RouteBaseAsync.WithoutRequest.WithActionResult
+{
+ private readonly UserService _userService;
+
+ public LogoutRoute(UserService userService) {
+ _userService = userService;
+ }
+
+ /// <summary>
+ /// Logout a user.
+ /// </summary>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ [AllowAnonymous]
+ [HttpGet("~/_/account/logout")]
+ public override async Task<ActionResult> HandleAsync(CancellationToken cancellationToken = default) {
+ await _userService.LogOutUser(HttpContext);
+ return Ok();
+ }
+}
diff --git a/server/src/Endpoints/Internal/Account/UpdateAccountPayload.cs b/server/src/Endpoints/Internal/Account/UpdateAccountPayload.cs
new file mode 100644
index 0000000..88a3237
--- /dev/null
+++ b/server/src/Endpoints/Internal/Account/UpdateAccountPayload.cs
@@ -0,0 +1,17 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Account;
+
+/// <summary>
+/// Payload for updating an account.
+/// </summary>
+public class UpdatePayload
+{
+ /// <summary>
+ /// Username to set on the logged on user's account.
+ /// </summary>
+ public string Username { get; set; }
+
+ /// <summary>
+ /// Password to set on the logged on user's account.
+ /// </summary>
+ public string Password { get; set; }
+}
diff --git a/server/src/Endpoints/Internal/Account/UpdateAccountRoute.cs b/server/src/Endpoints/Internal/Account/UpdateAccountRoute.cs
new file mode 100644
index 0000000..a997dcb
--- /dev/null
+++ b/server/src/Endpoints/Internal/Account/UpdateAccountRoute.cs
@@ -0,0 +1,51 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Account;
+
+public class UpdateAccountRoute : RouteBaseAsync.WithRequest<UpdatePayload>.WithActionResult
+{
+ private readonly AppDbContext _context;
+
+ /// <inheritdoc />
+ public UpdateAccountRoute(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Update the logged on user's data.
+ /// </summary>
+ /// <param name="request"></param>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ [HttpPost("~/_/account/update")]
+ public override async Task<ActionResult> HandleAsync(UpdatePayload request, CancellationToken cancellationToken = default) {
+ var user = _context.Users.SingleOrDefault(c => c.Id == LoggedInUser.Id);
+ if (user == default) {
+ await HttpContext.SignOutAsync();
+ return Unauthorized();
+ }
+
+ if (request.Password.IsNullOrWhiteSpace() && request.Username.IsNullOrWhiteSpace()) {
+ return BadRequest(new ErrorResult("Invalid request", "No data was submitted"));
+ }
+
+ if (request.Password.HasValue() && request.Password.Length < 6) {
+ return BadRequest(new ErrorResult("Invalid request",
+ "The new password must contain at least 6 characters"));
+ }
+
+ if (request.Password.HasValue()) {
+ user.HashAndSetPassword(request.Password);
+ }
+
+ if (request.Username.HasValue() && !request.Username.IsValidEmailAddress()) {
+ return BadRequest(new ErrorResult("Invalid request",
+ "The new username does not look like a valid email address"));
+ }
+
+ if (request.Username.HasValue()) {
+ user.Username = request.Username.Trim();
+ }
+
+ await _context.SaveChangesAsync(cancellationToken);
+ return Ok();
+ }
+}
diff --git a/server/src/Endpoints/Internal/BaseRoute.cs b/server/src/Endpoints/Internal/BaseRoute.cs
new file mode 100644
index 0000000..3e2c6af
--- /dev/null
+++ b/server/src/Endpoints/Internal/BaseRoute.cs
@@ -0,0 +1,16 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal;
+
+[Authorize]
+[ApiController]
+[ApiExplorerSettings(IgnoreApi = true)]
+[ApiVersionNeutral]
+public class BaseRoute : ControllerBase
+{
+ /// <summary>
+ /// User data for the currently logged on user.
+ /// </summary>
+ protected LoggedInUserModel LoggedInUser => new() {
+ Username = User.FindFirstValue(AppClaims.NAME),
+ Id = User.FindFirstValue(AppClaims.USER_ID).AsGuid(),
+ };
+}
diff --git a/server/src/Endpoints/Internal/PasswordResetRequests/CreateResetRequestRoute.cs b/server/src/Endpoints/Internal/PasswordResetRequests/CreateResetRequestRoute.cs
new file mode 100644
index 0000000..3e086f6
--- /dev/null
+++ b/server/src/Endpoints/Internal/PasswordResetRequests/CreateResetRequestRoute.cs
@@ -0,0 +1,59 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.PasswordResetRequests;
+
+/// <inheritdoc />
+public class CreateResetRequestRoute : RouteBaseAsync.WithRequest<string>.WithActionResult
+{
+ private readonly ILogger<CreateResetRequestRoute> _logger;
+ private readonly ForgotPasswordService _forgotPasswordService;
+ private readonly AppDbContext _context;
+
+ /// <inheritdoc />
+ public CreateResetRequestRoute(ILogger<CreateResetRequestRoute> logger, ForgotPasswordService forgotPasswordService, AppDbContext context) {
+ _logger = logger;
+ _forgotPasswordService = forgotPasswordService;
+ _context = context;
+ }
+
+ /// <summary>
+ /// Create a new password reset request.
+ /// </summary>
+ /// <param name="username"></param>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ [AllowAnonymous]
+ [HttpGet("~/_/forgot-password-requests/create")]
+ public override async Task<ActionResult> HandleAsync(string username, CancellationToken cancellationToken = default) {
+ if (!username.IsValidEmailAddress()) {
+ _logger.LogInformation("Username is invalid, not doing request for password change");
+ return BadRequest(new ErrorResult("Invalid email address", username + " looks like an invalid email address"));
+ }
+
+ Request.Headers.TryGetValue(AppHeaders.BROWSER_TIME_ZONE, out var timeZoneHeader);
+ var tz = TimeZoneInfo.FindSystemTimeZoneById(timeZoneHeader.ToString().HasValue() ? timeZoneHeader.ToString() : "UTC");
+ var offset = tz.BaseUtcOffset.Hours;
+
+ // this is fine as long as the client is not connecting from Australia: Lord Howe Island
+ // according to https://en.wikipedia.org/wiki/Daylight_saving_time_by_country
+ if (tz.IsDaylightSavingTime(DateTime.UtcNow)) {
+ offset++;
+ }
+
+ _logger.LogInformation("Request time zone (" + tz.Id + ") offset is: " + offset + " hours");
+ var requestDateTime = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz);
+ _logger.LogInformation("Creating forgot password request with date time: " + requestDateTime.ToString("u"));
+
+ try {
+ var user = _context.Users.SingleOrDefault(c => c.Username.Equals(username));
+ if (user != default) {
+ await _forgotPasswordService.AddRequestAsync(user, tz, cancellationToken);
+ return Ok();
+ }
+
+ _logger.LogInformation("User was not found, not doing request for password change");
+ return Ok();
+ } catch (Exception e) {
+ _logger.LogError(e, "ForgotAction failed badly");
+ return Ok();
+ }
+ }
+}
diff --git a/server/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestPayload.cs b/server/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestPayload.cs
new file mode 100644
index 0000000..f0fb59f
--- /dev/null
+++ b/server/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestPayload.cs
@@ -0,0 +1,14 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.PasswordResetRequests;
+
+public class FulfillResetRequestPayload
+{
+ /// <summary>
+ /// Id of the password reset request to fulfill
+ /// </summary>
+ public Guid Id { get; set; }
+
+ /// <summary>
+ /// New password to set on the relevant account
+ /// </summary>
+ public string NewPassword { get; set; }
+}
diff --git a/server/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestRoute.cs b/server/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestRoute.cs
new file mode 100644
index 0000000..e33a4fb
--- /dev/null
+++ b/server/src/Endpoints/Internal/PasswordResetRequests/FulfillResetRequestRoute.cs
@@ -0,0 +1,34 @@
+
+namespace IOL.GreatOffice.Api.Endpoints.Internal.PasswordResetRequests;
+
+/// <inheritdoc />
+public class FulfillResetRequestRoute : RouteBaseAsync.WithRequest<FulfillResetRequestPayload>.WithActionResult
+{
+ private readonly ForgotPasswordService _forgotPasswordService;
+
+ /// <inheritdoc />
+ public FulfillResetRequestRoute(ForgotPasswordService forgotPasswordService) {
+ _forgotPasswordService = forgotPasswordService;
+ }
+
+ /// <summary>
+ /// Fulfill a password reset request.
+ /// </summary>
+ /// <param name="request"></param>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ [AllowAnonymous]
+ [HttpPost("~/_/forgot-password-requests/fulfill")]
+ public override async Task<ActionResult> HandleAsync(FulfillResetRequestPayload request, CancellationToken cancellationToken = default) {
+ try {
+ var fulfilled = await _forgotPasswordService.FullFillRequestAsync(request.Id, request.NewPassword, cancellationToken);
+ return Ok(fulfilled);
+ } catch (Exception e) {
+ if (e is ForgotPasswordRequestNotFoundException or UserNotFoundException) {
+ return NotFound();
+ }
+
+ throw;
+ }
+ }
+}
diff --git a/server/src/Endpoints/Internal/PasswordResetRequests/IsResetRequestValidRoute.cs b/server/src/Endpoints/Internal/PasswordResetRequests/IsResetRequestValidRoute.cs
new file mode 100644
index 0000000..9984094
--- /dev/null
+++ b/server/src/Endpoints/Internal/PasswordResetRequests/IsResetRequestValidRoute.cs
@@ -0,0 +1,29 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.PasswordResetRequests;
+
+/// <inheritdoc />
+public class IsResetRequestValidRoute : RouteBaseAsync.WithRequest<Guid>.WithActionResult
+{
+ private readonly ForgotPasswordService _forgotPasswordService;
+
+ /// <inheritdoc />
+ public IsResetRequestValidRoute(ForgotPasswordService forgotPasswordService) {
+ _forgotPasswordService = forgotPasswordService;
+ }
+
+ /// <summary>
+ /// Check if a given password reset request is still valid.
+ /// </summary>
+ /// <param name="id"></param>
+ /// <param name="cancellationToken"></param>
+ /// <returns></returns>
+ [AllowAnonymous]
+ [HttpGet("~/_/forgot-password-requests/is-valid")]
+ public override async Task<ActionResult> HandleAsync(Guid id, CancellationToken cancellationToken = default) {
+ var request = await _forgotPasswordService.GetRequestAsync(id, cancellationToken);
+ if (request == default) {
+ return NotFound();
+ }
+
+ return Ok(request.IsExpired == false);
+ }
+}
diff --git a/server/src/Endpoints/Internal/Root/GetApplicationVersionRoute.cs b/server/src/Endpoints/Internal/Root/GetApplicationVersionRoute.cs
new file mode 100644
index 0000000..5fb8213
--- /dev/null
+++ b/server/src/Endpoints/Internal/Root/GetApplicationVersionRoute.cs
@@ -0,0 +1,21 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Root;
+
+public class GetApplicationVersionRoute : RouteBaseSync.WithoutRequest.WithActionResult<string>
+{
+ private readonly IWebHostEnvironment _environment;
+
+ /// <inheritdoc />
+ public GetApplicationVersionRoute(IWebHostEnvironment environment) {
+ _environment = environment;
+ }
+
+ /// <summary>
+ /// Get the running api version number.
+ /// </summary>
+ /// <returns></returns>
+ [HttpGet("~/_/version")]
+ public override ActionResult<string> Handle() {
+ var versionFilePath = Path.Combine(_environment.WebRootPath, "version.txt");
+ return Ok(System.IO.File.ReadAllText(versionFilePath));
+ }
+}
diff --git a/server/src/Endpoints/Internal/Root/LogRoute.cs b/server/src/Endpoints/Internal/Root/LogRoute.cs
new file mode 100644
index 0000000..48b497a
--- /dev/null
+++ b/server/src/Endpoints/Internal/Root/LogRoute.cs
@@ -0,0 +1,16 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal.Root;
+
+public class LogRoute : RouteBaseSync.WithRequest<string>.WithoutResult
+{
+ private readonly ILogger<LogRoute> _logger;
+
+ public LogRoute(ILogger<LogRoute> logger) {
+ _logger = logger;
+ }
+
+ [AllowAnonymous]
+ [HttpPost("~/_/log")]
+ public override void Handle([FromBody] string request) {
+ _logger.LogInformation(request);
+ }
+}
diff --git a/server/src/Endpoints/Internal/RouteBaseAsync.cs b/server/src/Endpoints/Internal/RouteBaseAsync.cs
new file mode 100644
index 0000000..1bb0af0
--- /dev/null
+++ b/server/src/Endpoints/Internal/RouteBaseAsync.cs
@@ -0,0 +1,73 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal;
+
+/// <summary>
+/// A base class for an endpoint that accepts parameters.
+/// </summary>
+public static class RouteBaseAsync
+{
+ public static class WithRequest<TRequest>
+ {
+ public abstract class WithResult<TResponse> : BaseRoute
+ {
+ public abstract Task<TResponse> HandleAsync(
+ TRequest request,
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithoutResult : BaseRoute
+ {
+ public abstract Task HandleAsync(
+ TRequest request,
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithActionResult<TResponse> : BaseRoute
+ {
+ public abstract Task<ActionResult<TResponse>> HandleAsync(
+ TRequest request,
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithActionResult : BaseRoute
+ {
+ public abstract Task<ActionResult> HandleAsync(
+ TRequest request,
+ CancellationToken cancellationToken = default
+ );
+ }
+ }
+
+ public static class WithoutRequest
+ {
+ public abstract class WithResult<TResponse> : BaseRoute
+ {
+ public abstract Task<TResponse> HandleAsync(
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithoutResult : BaseRoute
+ {
+ public abstract Task HandleAsync(
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithActionResult<TResponse> : BaseRoute
+ {
+ public abstract Task<ActionResult<TResponse>> HandleAsync(
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithActionResult : BaseRoute
+ {
+ public abstract Task<ActionResult> HandleAsync(
+ CancellationToken cancellationToken = default
+ );
+ }
+ }
+}
diff --git a/server/src/Endpoints/Internal/RouteBaseSync.cs b/server/src/Endpoints/Internal/RouteBaseSync.cs
new file mode 100644
index 0000000..173999d
--- /dev/null
+++ b/server/src/Endpoints/Internal/RouteBaseSync.cs
@@ -0,0 +1,53 @@
+namespace IOL.GreatOffice.Api.Endpoints.Internal;
+
+/// <summary>
+/// A base class for an endpoint that accepts parameters.
+/// </summary>
+public static class RouteBaseSync
+{
+ public static class WithRequest<TRequest>
+ {
+ public abstract class WithResult<TResponse> : BaseRoute
+ {
+ public abstract TResponse Handle(TRequest request);
+ }
+
+ public abstract class WithoutResult : BaseRoute
+ {
+ public abstract void Handle(TRequest request);
+ }
+
+ public abstract class WithActionResult<TResponse> : BaseRoute
+ {
+ public abstract ActionResult<TResponse> Handle(TRequest request);
+ }
+
+ public abstract class WithActionResult : BaseRoute
+ {
+ public abstract ActionResult Handle(TRequest request);
+ }
+ }
+
+ public static class WithoutRequest
+ {
+ public abstract class WithResult<TResponse> : BaseRoute
+ {
+ public abstract TResponse Handle();
+ }
+
+ public abstract class WithoutResult : BaseRoute
+ {
+ public abstract void Handle();
+ }
+
+ public abstract class WithActionResult<TResponse> : BaseRoute
+ {
+ public abstract ActionResult<TResponse> Handle();
+ }
+
+ public abstract class WithActionResult : BaseRoute
+ {
+ public abstract ActionResult Handle();
+ }
+ }
+}
diff --git a/server/src/Endpoints/V1/ApiSpecV1.cs b/server/src/Endpoints/V1/ApiSpecV1.cs
new file mode 100644
index 0000000..e4f9cc9
--- /dev/null
+++ b/server/src/Endpoints/V1/ApiSpecV1.cs
@@ -0,0 +1,18 @@
+namespace IOL.GreatOffice.Api.Endpoints.V1;
+
+public static class ApiSpecV1
+{
+ private const int MAJOR = 1;
+ private const int MINOR = 0;
+ public const string VERSION_STRING = "1.0";
+
+ public static ApiSpecDocument Document => new() {
+ Version = new ApiVersion(MAJOR, MINOR),
+ VersionName = VERSION_STRING,
+ SwaggerPath = $"/swagger/{VERSION_STRING}/swagger.json",
+ OpenApiInfo = new OpenApiInfo {
+ Title = AppConstants.API_NAME,
+ Version = VERSION_STRING
+ }
+ };
+}
diff --git a/server/src/Endpoints/V1/ApiTokens/CreateTokenRoute.cs b/server/src/Endpoints/V1/ApiTokens/CreateTokenRoute.cs
new file mode 100644
index 0000000..e8abbf8
--- /dev/null
+++ b/server/src/Endpoints/V1/ApiTokens/CreateTokenRoute.cs
@@ -0,0 +1,52 @@
+using System.Text;
+
+namespace IOL.GreatOffice.Api.Endpoints.V1.ApiTokens;
+
+public class CreateTokenRoute : RouteBaseSync.WithRequest<ApiAccessToken.ApiAccessTokenDto>.WithActionResult
+{
+ private readonly AppDbContext _context;
+ private readonly IConfiguration _configuration;
+ private readonly ILogger<CreateTokenRoute> _logger;
+
+ public CreateTokenRoute(AppDbContext context, IConfiguration configuration, ILogger<CreateTokenRoute> logger) {
+ _context = context;
+ _configuration = configuration;
+ _logger = logger;
+ }
+
+ /// <summary>
+ /// Create a new api token with the provided claims.
+ /// </summary>
+ /// <param name="request">The claims to set on the api token</param>
+ /// <returns></returns>
+ [ApiVersion(ApiSpecV1.VERSION_STRING)]
+ [HttpPost("~/v{version:apiVersion}/api-tokens/create")]
+ [ProducesResponseType(200, Type = typeof(string))]
+ [ProducesResponseType(404, Type = typeof(ErrorResult))]
+ public override ActionResult Handle(ApiAccessToken.ApiAccessTokenDto request) {
+ var user = _context.Users.SingleOrDefault(c => c.Id == LoggedInUser.Id);
+ if (user == default) {
+ return NotFound(new ErrorResult("User does not exist"));
+ }
+
+ var token_entropy = _configuration.GetValue<string>("TOKEN_ENTROPY");
+ if (token_entropy.IsNullOrWhiteSpace()) {
+ _logger.LogWarning("No token entropy is available in env:TOKEN_ENTROPY, Basic auth is disabled");
+ return NotFound();
+ }
+
+ var access_token = new ApiAccessToken() {
+ Id = Guid.NewGuid(),
+ User = user,
+ ExpiryDate = request.ExpiryDate.ToUniversalTime(),
+ AllowCreate = request.AllowCreate,
+ AllowRead = request.AllowRead,
+ AllowDelete = request.AllowDelete,
+ AllowUpdate = request.AllowUpdate
+ };
+
+ _context.AccessTokens.Add(access_token);
+ _context.SaveChanges();
+ return Ok(Convert.ToBase64String(Encoding.UTF8.GetBytes(access_token.Id.ToString().EncryptWithAes(token_entropy))));
+ }
+}
diff --git a/server/src/Endpoints/V1/ApiTokens/DeleteTokenRoute.cs b/server/src/Endpoints/V1/ApiTokens/DeleteTokenRoute.cs
new file mode 100644
index 0000000..a90b4c0
--- /dev/null
+++ b/server/src/Endpoints/V1/ApiTokens/DeleteTokenRoute.cs
@@ -0,0 +1,33 @@
+namespace IOL.GreatOffice.Api.Endpoints.V1.ApiTokens;
+
+public class DeleteTokenRoute : RouteBaseSync.WithRequest<Guid>.WithActionResult
+{
+ private readonly AppDbContext _context;
+ private readonly ILogger<DeleteTokenRoute> _logger;
+
+ public DeleteTokenRoute(AppDbContext context, ILogger<DeleteTokenRoute> logger) {
+ _context = context;
+ _logger = logger;
+ }
+
+ /// <summary>
+ /// Delete an api token, rendering it unusable
+ /// </summary>
+ /// <param name="id">Id of the token to delete</param>
+ /// <returns>Nothing</returns>
+ [ApiVersion(ApiSpecV1.VERSION_STRING)]
+ [HttpDelete("~/v{version:apiVersion}/api-tokens/delete")]
+ [ProducesResponseType(200)]
+ [ProducesResponseType(404)]
+ public override ActionResult Handle(Guid id) {
+ var token = _context.AccessTokens.SingleOrDefault(c => c.Id == id);
+ if (token == default) {
+ _logger.LogWarning("A deletion request of an already deleted (maybe) api token was received.");
+ return NotFound();
+ }
+
+ _context.AccessTokens.Remove(token);
+ _context.SaveChanges();
+ return Ok();
+ }
+}
diff --git a/server/src/Endpoints/V1/ApiTokens/GetTokensRoute.cs b/server/src/Endpoints/V1/ApiTokens/GetTokensRoute.cs
new file mode 100644
index 0000000..59fd077
--- /dev/null
+++ b/server/src/Endpoints/V1/ApiTokens/GetTokensRoute.cs
@@ -0,0 +1,22 @@
+namespace IOL.GreatOffice.Api.Endpoints.V1.ApiTokens;
+
+public class GetTokensRoute : RouteBaseSync.WithoutRequest.WithResult<ActionResult<List<ApiAccessToken.ApiAccessTokenDto>>>
+{
+ private readonly AppDbContext _context;
+
+ public GetTokensRoute(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Get all tokens, both active and inactive.
+ /// </summary>
+ /// <returns>A list of tokens</returns>
+ [ApiVersion(ApiSpecV1.VERSION_STRING)]
+ [HttpGet("~/v{version:apiVersion}/api-tokens")]
+ [ProducesResponseType(200, Type = typeof(List<ApiAccessToken.ApiAccessTokenDto>))]
+ [ProducesResponseType(204)]
+ public override ActionResult<List<ApiAccessToken.ApiAccessTokenDto>> Handle() {
+ return Ok(_context.AccessTokens.Where(c => c.User.Id == LoggedInUser.Id).Select(c => c.AsDto));
+ }
+}
diff --git a/server/src/Endpoints/V1/BaseRoute.cs b/server/src/Endpoints/V1/BaseRoute.cs
new file mode 100644
index 0000000..e7d72ac
--- /dev/null
+++ b/server/src/Endpoints/V1/BaseRoute.cs
@@ -0,0 +1,39 @@
+using System.Net.Http.Headers;
+
+namespace IOL.GreatOffice.Api.Endpoints.V1;
+
+/// <inheritdoc />
+[ApiVersion(ApiSpecV1.VERSION_STRING)]
+[Authorize(AuthenticationSchemes = AuthSchemes)]
+[ApiController]
+public class BaseRoute : ControllerBase
+{
+ private const string AuthSchemes = CookieAuthenticationDefaults.AuthenticationScheme + "," + AppConstants.BASIC_AUTH_SCHEME;
+
+ /// <summary>
+ /// User data for the currently logged on user.
+ /// </summary>
+ protected LoggedInUserModel LoggedInUser => new() {
+ Username = User.FindFirstValue(AppClaims.NAME),
+ Id = User.FindFirstValue(AppClaims.USER_ID).AsGuid(),
+ };
+
+ protected bool IsApiCall() {
+ if (!Request.Headers.ContainsKey("Authorization")) return false;
+ try {
+ var authHeader = AuthenticationHeaderValue.Parse(Request.Headers["Authorization"]);
+ if (authHeader.Parameter == null) return false;
+ } catch {
+ return false;
+ }
+
+ return true;
+ }
+
+ protected bool HasApiPermission(string permission_key) {
+ var permission_claim = User.Claims.SingleOrDefault(c => c.Type == permission_key);
+ return permission_claim is {
+ Value: "True"
+ };
+ }
+}
diff --git a/server/src/Endpoints/V1/Categories/CreateCategoryRoute.cs b/server/src/Endpoints/V1/Categories/CreateCategoryRoute.cs
new file mode 100644
index 0000000..fac2b5e
--- /dev/null
+++ b/server/src/Endpoints/V1/Categories/CreateCategoryRoute.cs
@@ -0,0 +1,43 @@
+namespace IOL.GreatOffice.Api.Endpoints.V1.Categories;
+
+public class CreateCategoryRoute : RouteBaseSync.WithRequest<TimeCategory.TimeCategoryDto>.WithActionResult<TimeCategory.TimeCategoryDto>
+{
+ private readonly AppDbContext _context;
+
+ public CreateCategoryRoute(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Create a new time entry category.
+ /// </summary>
+ /// <param name="categoryTimeCategoryDto"></param>
+ /// <returns></returns>
+ [ApiVersion(ApiSpecV1.VERSION_STRING)]
+ [BasicAuthentication(AppConstants.TOKEN_ALLOW_CREATE)]
+ [HttpPost("~/v{version:apiVersion}/categories/create")]
+ [ProducesResponseType(200, Type = typeof(TimeCategory.TimeCategoryDto))]
+ public override ActionResult<TimeCategory.TimeCategoryDto> Handle(TimeCategory.TimeCategoryDto categoryTimeCategoryDto) {
+ var duplicate = _context.TimeCategories
+ .Where(c => c.UserId == LoggedInUser.Id)
+ .Any(c => c.Name.Trim() == categoryTimeCategoryDto.Name.Trim());
+ if (duplicate) {
+ var category = _context.TimeCategories
+ .Where(c => c.UserId == LoggedInUser.Id)
+ .SingleOrDefault(c => c.Name.Trim() == categoryTimeCategoryDto.Name.Trim());
+ if (category != default) {
+ return Ok(category.AsDto);
+ }
+ }
+
+ var newCategory = new TimeCategory(LoggedInUser.Id) {
+ Name = categoryTimeCategoryDto.Name.Trim(),
+ Color = categoryTimeCategoryDto.Color
+ };
+
+ _context.TimeCategories.Add(newCategory);
+ _context.SaveChanges();
+ categoryTimeCategoryDto.Id = newCategory.Id;
+ return Ok(categoryTimeCategoryDto);
+ }
+}
diff --git a/server/src/Endpoints/V1/Categories/DeleteCategoryRoute.cs b/server/src/Endpoints/V1/Categories/DeleteCategoryRoute.cs
new file mode 100644
index 0000000..3d438a0
--- /dev/null
+++ b/server/src/Endpoints/V1/Categories/DeleteCategoryRoute.cs
@@ -0,0 +1,38 @@
+namespace IOL.GreatOffice.Api.Endpoints.V1.Categories;
+
+public class DeleteCategoryRoute : RouteBaseSync.WithRequest<Guid>.WithActionResult
+{
+ private readonly AppDbContext _context;
+
+ public DeleteCategoryRoute(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Delete a time entry category.
+ /// </summary>
+ /// <param name="id"></param>
+ /// <returns></returns>
+ [ApiVersion(ApiSpecV1.VERSION_STRING)]
+ [BasicAuthentication(AppConstants.TOKEN_ALLOW_DELETE)]
+ [HttpDelete("~/v{version:apiVersion}/categories/{id:guid}/delete")]
+ [ProducesResponseType(200)]
+ [ProducesResponseType(404)]
+ public override ActionResult Handle(Guid id) {
+ var category = _context.TimeCategories
+ .Where(c => c.UserId == LoggedInUser.Id)
+ .SingleOrDefault(c => c.Id == id);
+
+ if (category == default) {
+ return NotFound();
+ }
+
+ var entries = _context.TimeEntries
+ .Include(c => c.Category)
+ .Where(c => c.Category.Id == category.Id);
+ _context.TimeEntries.RemoveRange(entries);
+ _context.TimeCategories.Remove(category);
+ _context.SaveChanges();
+ return Ok();
+ }
+}
diff --git a/server/src/Endpoints/V1/Categories/GetCategoriesRoute.cs b/server/src/Endpoints/V1/Categories/GetCategoriesRoute.cs
new file mode 100644
index 0000000..a40a832
--- /dev/null
+++ b/server/src/Endpoints/V1/Categories/GetCategoriesRoute.cs
@@ -0,0 +1,35 @@
+namespace IOL.GreatOffice.Api.Endpoints.V1.Categories;
+
+/// <inheritdoc />
+public class GetCategoriesRoute : RouteBaseSync.WithoutRequest.WithActionResult<List<TimeCategory.TimeCategoryDto>>
+{
+ private readonly AppDbContext _context;
+
+ /// <inheritdoc />
+ public GetCategoriesRoute(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Get a minimal list of time entry categories.
+ /// </summary>
+ /// <returns></returns>
+ [ApiVersion(ApiSpecV1.VERSION_STRING)]
+ [ProducesResponseType(200, Type = typeof(List<TimeCategory.TimeCategoryDto>))]
+ [ProducesResponseType(204)]
+ [BasicAuthentication(AppConstants.TOKEN_ALLOW_READ)]
+ [HttpGet("~/v{version:apiVersion}/categories")]
+ public override ActionResult<List<TimeCategory.TimeCategoryDto>> Handle() {
+ var categories = _context.TimeCategories
+ .Where(c => c.UserId == LoggedInUser.Id)
+ .OrderByDescending(c => c.CreatedAt)
+ .Select(c => c.AsDto)
+ .ToList();
+
+ if (categories.Count == 0) {
+ return NoContent();
+ }
+
+ return Ok(categories);
+ }
+}
diff --git a/server/src/Endpoints/V1/Categories/UpdateCategoryRoute.cs b/server/src/Endpoints/V1/Categories/UpdateCategoryRoute.cs
new file mode 100644
index 0000000..ca7dfdf
--- /dev/null
+++ b/server/src/Endpoints/V1/Categories/UpdateCategoryRoute.cs
@@ -0,0 +1,39 @@
+namespace IOL.GreatOffice.Api.Endpoints.V1.Categories;
+
+public class UpdateCategoryRoute : RouteBaseSync.WithRequest<TimeCategory.TimeCategoryDto>.WithActionResult
+{
+ private readonly AppDbContext _context;
+
+ public UpdateCategoryRoute(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Update a time entry category.
+ /// </summary>
+ /// <param name="categoryTimeCategoryDto"></param>
+ /// <returns></returns>
+ [ApiVersion(ApiSpecV1.VERSION_STRING)]
+ [BasicAuthentication(AppConstants.TOKEN_ALLOW_UPDATE)]
+ [HttpPost("~/v{version:apiVersion}/categories/update")]
+ [ProducesResponseType(200)]
+ [ProducesResponseType(404)]
+ [ProducesResponseType(403)]
+ public override ActionResult Handle(TimeCategory.TimeCategoryDto categoryTimeCategoryDto) {
+ var category = _context.TimeCategories
+ .Where(c => c.UserId == LoggedInUser.Id)
+ .SingleOrDefault(c => c.Id == categoryTimeCategoryDto.Id);
+ if (category == default) {
+ return NotFound();
+ }
+
+ if (LoggedInUser.Id != category.UserId) {
+ return Forbid();
+ }
+
+ category.Name = categoryTimeCategoryDto.Name;
+ category.Color = categoryTimeCategoryDto.Color;
+ _context.SaveChanges();
+ return Ok();
+ }
+}
diff --git a/server/src/Endpoints/V1/Entries/CreateEntryRoute.cs b/server/src/Endpoints/V1/Entries/CreateEntryRoute.cs
new file mode 100644
index 0000000..362e430
--- /dev/null
+++ b/server/src/Endpoints/V1/Entries/CreateEntryRoute.cs
@@ -0,0 +1,65 @@
+namespace IOL.GreatOffice.Api.Endpoints.V1.Entries;
+
+public class CreateEntryRoute : RouteBaseSync.WithRequest<TimeEntry.TimeEntryDto>.WithActionResult<TimeEntry.TimeEntryDto>
+{
+ private readonly AppDbContext _context;
+
+ public CreateEntryRoute(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Create a time entry.
+ /// </summary>
+ /// <param name="timeEntryTimeEntryDto"></param>
+ /// <returns></returns>
+ [ApiVersion(ApiSpecV1.VERSION_STRING)]
+ [BasicAuthentication(AppConstants.TOKEN_ALLOW_CREATE)]
+ [ProducesResponseType(200)]
+ [ProducesResponseType(400, Type = typeof(ErrorResult))]
+ [ProducesResponseType(404, Type = typeof(ErrorResult))]
+ [HttpPost("~/v{version:apiVersion}/entries/create")]
+ public override ActionResult<TimeEntry.TimeEntryDto> Handle(TimeEntry.TimeEntryDto timeEntryTimeEntryDto) {
+ if (timeEntryTimeEntryDto.Stop == default) {
+ return BadRequest(new ErrorResult("Invalid form", "A stop date is required"));
+ }
+
+ if (timeEntryTimeEntryDto.Start == default) {
+ return BadRequest(new ErrorResult("Invalid form", "A start date is required"));
+ }
+
+ if (timeEntryTimeEntryDto.Category == default) {
+ return BadRequest(new ErrorResult("Invalid form", "A category is required"));
+ }
+
+ var category = _context.TimeCategories
+ .Where(c => c.UserId == LoggedInUser.Id)
+ .SingleOrDefault(c => c.Id == timeEntryTimeEntryDto.Category.Id);
+ if (category == default) {
+ return NotFound(new ErrorResult("Not found", $"Could not find category {timeEntryTimeEntryDto.Category.Name}"));
+ }
+
+ var entry = new TimeEntry(LoggedInUser.Id) {
+ Category = category,
+ Start = timeEntryTimeEntryDto.Start.ToUniversalTime(),
+ Stop = timeEntryTimeEntryDto.Stop.ToUniversalTime(),
+ Description = timeEntryTimeEntryDto.Description,
+ };
+
+ if (timeEntryTimeEntryDto.Labels?.Count > 0) {
+ var labels = _context.TimeLabels
+ .Where(c => c.UserId == LoggedInUser.Id)
+ .Where(c => timeEntryTimeEntryDto.Labels.Select(p => p.Id).Contains(c.Id))
+ .ToList();
+ if (labels.Count != timeEntryTimeEntryDto.Labels.Count) {
+ return NotFound(new ErrorResult("Not found", "Could not find all of the specified labels"));
+ }
+
+ entry.Labels = labels;
+ }
+
+ _context.TimeEntries.Add(entry);
+ _context.SaveChanges();
+ return Ok(entry.AsDto);
+ }
+}
diff --git a/server/src/Endpoints/V1/Entries/DeleteEntryRoute.cs b/server/src/Endpoints/V1/Entries/DeleteEntryRoute.cs
new file mode 100644
index 0000000..0850af0
--- /dev/null
+++ b/server/src/Endpoints/V1/Entries/DeleteEntryRoute.cs
@@ -0,0 +1,35 @@
+namespace IOL.GreatOffice.Api.Endpoints.V1.Entries;
+
+/// <inheritdoc />
+public class DeleteEntryRoute : RouteBaseSync.WithRequest<Guid>.WithActionResult
+{
+ private readonly AppDbContext _context;
+
+ /// <inheritdoc />
+ public DeleteEntryRoute(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Delete a time entry.
+ /// </summary>
+ /// <param name="id"></param>
+ /// <returns></returns>
+ [ApiVersion(ApiSpecV1.VERSION_STRING)]
+ [BasicAuthentication(AppConstants.TOKEN_ALLOW_DELETE)]
+ [HttpDelete("~/v{version:apiVersion}/entries/{id:guid}/delete")]
+ [ProducesResponseType(404)]
+ [ProducesResponseType(200)]
+ public override ActionResult Handle(Guid id) {
+ var entry = _context.TimeEntries
+ .Where(c => c.UserId == LoggedInUser.Id)
+ .SingleOrDefault(c => c.Id == id);
+ if (entry == default) {
+ return NotFound();
+ }
+
+ _context.TimeEntries.Remove(entry);
+ _context.SaveChanges();
+ return Ok();
+ }
+}
diff --git a/server/src/Endpoints/V1/Entries/EntryQueryPayload.cs b/server/src/Endpoints/V1/Entries/EntryQueryPayload.cs
new file mode 100644
index 0000000..763ac8b
--- /dev/null
+++ b/server/src/Endpoints/V1/Entries/EntryQueryPayload.cs
@@ -0,0 +1,60 @@
+namespace IOL.GreatOffice.Api.Endpoints.V1.Entries;
+
+/// <summary>
+/// Query model for querying time entries.
+/// </summary>
+public class EntryQueryPayload
+{
+ /// <summary>
+ /// Duration to filter with.
+ /// </summary>
+ public TimeEntryQueryDuration Duration { get; set; }
+
+ /// <summary>
+ /// List of categories to filter with.
+ /// </summary>
+ public List<TimeCategory.TimeCategoryDto> Categories { get; set; }
+
+ /// <summary>
+ /// List of labels to filter with.
+ /// </summary>
+ public List<TimeLabel.TimeLabelDto> Labels { get; set; }
+
+ /// <summary>
+ /// Date range to filter with, only respected if Duration is set to TimeEntryQueryDuration.DATE_RANGE.
+ /// </summary>
+ /// <see cref="TimeEntryQueryDuration"/>
+ public QueryDateRange DateRange { get; set; }
+
+ /// <summary>
+ /// Spesific date to filter with, only respected if Duration is set to TimeEntryQueryDuration.SPECIFIC_DATE.
+ /// </summary>
+ /// <see cref="TimeEntryQueryDuration"/>
+ public DateTime SpecificDate { get; set; }
+
+ /// <summary>
+ /// Optional page number to show, goes well with PageSize.
+ /// </summary>
+ public int Page { get; set; }
+
+ /// <summary>
+ /// Optional page size to show, goes well with Page.
+ /// </summary>
+ public int PageSize { get; set; }
+
+ /// <summary>
+ /// Represents a date range.
+ /// </summary>
+ public class QueryDateRange
+ {
+ /// <summary>
+ /// Range start
+ /// </summary>
+ public DateTime From { get; set; }
+
+ /// <summary>
+ /// Range end
+ /// </summary>
+ public DateTime To { get; set; }
+ }
+}
diff --git a/server/src/Endpoints/V1/Entries/EntryQueryResponse.cs b/server/src/Endpoints/V1/Entries/EntryQueryResponse.cs
new file mode 100644
index 0000000..b1b07a3
--- /dev/null
+++ b/server/src/Endpoints/V1/Entries/EntryQueryResponse.cs
@@ -0,0 +1,37 @@
+namespace IOL.GreatOffice.Api.Endpoints.V1.Entries;
+
+/// <summary>
+/// Response given for a successful query.
+/// </summary>
+public class EntryQueryResponse
+{
+ /// <inheritdoc cref="EntryQueryResponse"/>
+ public EntryQueryResponse() {
+ Results = new List<TimeEntry.TimeEntryDto>();
+ }
+
+ /// <summary>
+ /// List of entries.
+ /// </summary>
+ public List<TimeEntry.TimeEntryDto> Results { get; set; }
+
+ /// <summary>
+ /// Current page.
+ /// </summary>
+ public int Page { get; set; }
+
+ /// <summary>
+ /// Current page size (amount of entries).
+ /// </summary>
+ public int PageSize { get; set; }
+
+ /// <summary>
+ /// Total amount of entries in query.
+ /// </summary>
+ public int TotalSize { get; set; }
+
+ /// <summary>
+ /// Total amount of page(s) in query.
+ /// </summary>
+ public int TotalPageCount { get; set; }
+}
diff --git a/server/src/Endpoints/V1/Entries/EntryQueryRoute.cs b/server/src/Endpoints/V1/Entries/EntryQueryRoute.cs
new file mode 100644
index 0000000..c037b72
--- /dev/null
+++ b/server/src/Endpoints/V1/Entries/EntryQueryRoute.cs
@@ -0,0 +1,186 @@
+namespace IOL.GreatOffice.Api.Endpoints.V1.Entries;
+
+public class EntryQueryRoute : RouteBaseSync.WithRequest<EntryQueryPayload>.WithActionResult<EntryQueryResponse>
+{
+ private readonly ILogger<EntryQueryRoute> _logger;
+ private readonly AppDbContext _context;
+
+ public EntryQueryRoute(ILogger<EntryQueryRoute> logger, AppDbContext context) {
+ _logger = logger;
+ _context = context;
+ }
+
+ /// <summary>
+ /// Get a list of entries based on a given query.
+ /// </summary>
+ /// <param name="entryQuery"></param>
+ /// <returns></returns>
+ /// <exception cref="ArgumentOutOfRangeException"></exception>
+ [ApiVersion(ApiSpecV1.VERSION_STRING)]
+ [BasicAuthentication(AppConstants.TOKEN_ALLOW_READ)]
+ [HttpPost("~/v{version:apiVersion}/entries/query")]
+ [ProducesResponseType(204)]
+ [ProducesResponseType(400, Type = typeof(ErrorResult))]
+ [ProducesResponseType(200, Type = typeof(EntryQueryResponse))]
+ public override ActionResult<EntryQueryResponse> Handle(EntryQueryPayload entryQuery) {
+ var result = new TimeQueryDto();
+
+ Request.Headers.TryGetValue(AppHeaders.BROWSER_TIME_ZONE, out var timeZoneHeader);
+ var tz = TimeZoneInfo.FindSystemTimeZoneById(timeZoneHeader.ToString().HasValue() ? timeZoneHeader.ToString() : "UTC");
+ var offsetInHours = tz.BaseUtcOffset.Hours;
+
+ // this is fine as long as the client is not connecting from Australia: Lord Howe Island
+ // according to https://en.wikipedia.org/wiki/Daylight_saving_time_by_country
+ if (tz.IsDaylightSavingTime(DateTime.UtcNow)) {
+ offsetInHours++;
+ }
+
+ _logger.LogInformation("Request time zone (" + tz.Id + ") offset is: " + offsetInHours + " hours");
+ var requestDateTime = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, tz);
+ _logger.LogInformation("Querying data with date time: " + requestDateTime.ToString("u"));
+
+ var skipCount = 0;
+ if (entryQuery.Page > 1) {
+ skipCount = entryQuery.PageSize * entryQuery.Page;
+ }
+
+ result.Page = entryQuery.Page;
+ result.PageSize = entryQuery.PageSize;
+
+ var baseQuery = _context.TimeEntries
+ .AsNoTracking()
+ .Include(c => c.Category)
+ .Include(c => c.Labels)
+ .Where(c => c.UserId == LoggedInUser.Id)
+ .ConditionalWhere(entryQuery.Categories?.Any() ?? false, c => entryQuery.Categories.Any(p => p.Id == c.Category.Id))
+ .ConditionalWhere(entryQuery.Labels?.Any() ?? false, c => c.Labels.Any(l => entryQuery.Labels.Any(p => p.Id == l.Id)))
+ .OrderByDescending(c => c.Start);
+
+ switch (entryQuery.Duration) {
+ case TimeEntryQueryDuration.TODAY:
+ var baseTodaysEntries = baseQuery
+ .Where(c => DateTime.Compare(c.Start.AddHours(offsetInHours).Date, DateTime.UtcNow.Date) == 0);
+ var baseTodaysEntriesCount = baseTodaysEntries.Count();
+
+ if (baseTodaysEntriesCount == 0) {
+ return NoContent();
+ }
+
+ result.TotalSize = baseTodaysEntriesCount;
+ result.TotalPageCount = Convert.ToInt32(Math.Round((double)baseTodaysEntriesCount / entryQuery.PageSize));
+
+ var pagedTodaysEntries = baseTodaysEntries.Skip(skipCount).Take(entryQuery.PageSize);
+
+ result.Results.AddRange(pagedTodaysEntries.Select(c => c.AsDto));
+ break;
+ case TimeEntryQueryDuration.THIS_WEEK:
+ var lastMonday = DateTime.UtcNow.StartOfWeek(DayOfWeek.Monday);
+
+ var baseEntriesThisWeek = baseQuery
+ .Where(c => c.Start.AddHours(offsetInHours).Date >= lastMonday.Date && c.Start.AddHours(offsetInHours).Date <= DateTime.UtcNow.Date);
+
+ var baseEntriesThisWeekCount = baseEntriesThisWeek.Count();
+
+ if (baseEntriesThisWeekCount == 0) {
+ return NoContent();
+ }
+
+ result.TotalSize = baseEntriesThisWeekCount;
+ result.TotalPageCount = Convert.ToInt32(Math.Round((double)baseEntriesThisWeekCount / entryQuery.PageSize));
+
+ var pagedEntriesThisWeek = baseEntriesThisWeek.Skip(skipCount).Take(entryQuery.PageSize);
+
+ result.Results.AddRange(pagedEntriesThisWeek.Select(c => c.AsDto));
+ break;
+ case TimeEntryQueryDuration.THIS_MONTH:
+ var baseEntriesThisMonth = baseQuery
+ .Where(c => c.Start.AddHours(offsetInHours).Month == DateTime.UtcNow.Month
+ && c.Start.AddHours(offsetInHours).Year == DateTime.UtcNow.Year);
+ var baseEntriesThisMonthCount = baseEntriesThisMonth.Count();
+ if (baseEntriesThisMonthCount == 0) {
+ return NoContent();
+ }
+
+ result.TotalSize = baseEntriesThisMonthCount;
+ result.TotalPageCount = Convert.ToInt32(Math.Round((double)baseEntriesThisMonthCount / entryQuery.PageSize));
+
+ var pagedEntriesThisMonth = baseEntriesThisMonth.Skip(skipCount).Take(entryQuery.PageSize);
+
+ result.Results.AddRange(pagedEntriesThisMonth.Select(c => c.AsDto));
+ break;
+ case TimeEntryQueryDuration.THIS_YEAR:
+ var baseEntriesThisYear = baseQuery
+ .Where(c => c.Start.AddHours(offsetInHours).Year == DateTime.UtcNow.Year);
+
+ var baseEntriesThisYearCount = baseEntriesThisYear.Count();
+ if (baseEntriesThisYearCount == 0) {
+ return NoContent();
+ }
+
+ result.TotalSize = baseEntriesThisYearCount;
+ result.TotalPageCount = Convert.ToInt32(Math.Round((double)baseEntriesThisYearCount / entryQuery.PageSize));
+
+ var pagedEntriesThisYear = baseEntriesThisYear.Skip(skipCount).Take(entryQuery.PageSize);
+
+ result.Results.AddRange(pagedEntriesThisYear.Select(c => c.AsDto));
+ break;
+ case TimeEntryQueryDuration.SPECIFIC_DATE:
+ var date = DateTime.SpecifyKind(entryQuery.SpecificDate, DateTimeKind.Utc);
+ var baseEntriesOnThisDate = baseQuery.Where(c => c.Start.AddHours(offsetInHours).Date == date.Date);
+ var baseEntriesOnThisDateCount = baseEntriesOnThisDate.Count();
+
+ if (baseEntriesOnThisDateCount == 0) {
+ return NoContent();
+ }
+
+ result.TotalSize = baseEntriesOnThisDateCount;
+ result.TotalPageCount = Convert.ToInt32(Math.Round((double)baseEntriesOnThisDateCount / entryQuery.PageSize));
+
+ var pagedEntriesOnThisDate = baseEntriesOnThisDate.Skip(skipCount).Take(entryQuery.PageSize);
+
+ result.Results.AddRange(pagedEntriesOnThisDate.Select(c => c.AsDto));
+ break;
+ case TimeEntryQueryDuration.DATE_RANGE:
+ if (entryQuery.DateRange.From == default) {
+ return BadRequest(new ErrorResult("Invalid query", "From date cannot be empty"));
+ }
+
+ var fromDate = DateTime.SpecifyKind(entryQuery.DateRange.From, DateTimeKind.Utc);
+
+ if (entryQuery.DateRange.To == default) {
+ return BadRequest(new ErrorResult("Invalid query", "To date cannot be empty"));
+ }
+
+ var toDate = DateTime.SpecifyKind(entryQuery.DateRange.To, DateTimeKind.Utc);
+
+ if (DateTime.Compare(fromDate, toDate) > 0) {
+ return BadRequest(new ErrorResult("Invalid query", "To date cannot be less than From date"));
+ }
+
+ var baseDateRangeEntries = baseQuery
+ .Where(c => c.Start.AddHours(offsetInHours).Date > fromDate && c.Start.AddHours(offsetInHours).Date <= toDate);
+
+ var baseDateRangeEntriesCount = baseDateRangeEntries.Count();
+ if (baseDateRangeEntriesCount == 0) {
+ return NoContent();
+ }
+
+ result.TotalSize = baseDateRangeEntriesCount;
+ result.TotalPageCount = Convert.ToInt32(Math.Round((double)baseDateRangeEntriesCount / entryQuery.PageSize));
+
+ var pagedDateRangeEntries = baseDateRangeEntries.Skip(skipCount).Take(entryQuery.PageSize);
+
+ result.Results.AddRange(pagedDateRangeEntries.Select(c => c.AsDto));
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(entryQuery), "Unknown duration for query");
+ }
+
+ if (result.Results.Any() && result.Page == 0) {
+ result.Page = 1;
+ result.TotalPageCount = 1;
+ }
+
+ return Ok(result);
+ }
+}
diff --git a/server/src/Endpoints/V1/Entries/GetEntryRoute.cs b/server/src/Endpoints/V1/Entries/GetEntryRoute.cs
new file mode 100644
index 0000000..87038db
--- /dev/null
+++ b/server/src/Endpoints/V1/Entries/GetEntryRoute.cs
@@ -0,0 +1,34 @@
+namespace IOL.GreatOffice.Api.Endpoints.V1.Entries;
+
+public class GetEntryRoute : RouteBaseSync.WithRequest<Guid>.WithActionResult<TimeEntry.TimeEntryDto>
+{
+ private readonly AppDbContext _context;
+
+ public GetEntryRoute(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Get a spesific time entry.
+ /// </summary>
+ /// <param name="id"></param>
+ /// <returns></returns>
+ [ApiVersion(ApiSpecV1.VERSION_STRING)]
+ [BasicAuthentication(AppConstants.TOKEN_ALLOW_READ)]
+ [HttpGet("~/v{version:apiVersion}/entries/{id:guid}")]
+ [ProducesResponseType(404)]
+ [ProducesResponseType(200, Type = typeof(TimeEntry.TimeEntryDto))]
+ public override ActionResult<TimeEntry.TimeEntryDto> Handle(Guid id) {
+ var entry = _context.TimeEntries
+ .Where(c => c.UserId == LoggedInUser.Id)
+ .Include(c => c.Category)
+ .Include(c => c.Labels)
+ .SingleOrDefault(c => c.Id == id);
+
+ if (entry == default) {
+ return NotFound();
+ }
+
+ return Ok(entry);
+ }
+}
diff --git a/server/src/Endpoints/V1/Entries/UpdateEntryRoute.cs b/server/src/Endpoints/V1/Entries/UpdateEntryRoute.cs
new file mode 100644
index 0000000..ac233e0
--- /dev/null
+++ b/server/src/Endpoints/V1/Entries/UpdateEntryRoute.cs
@@ -0,0 +1,66 @@
+namespace IOL.GreatOffice.Api.Endpoints.V1.Entries;
+
+public class UpdateEntryRoute : RouteBaseSync.WithRequest<TimeEntry.TimeEntryDto>.WithActionResult<TimeEntry.TimeEntryDto>
+{
+ private readonly AppDbContext _context;
+
+ public UpdateEntryRoute(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Update a time entry.
+ /// </summary>
+ /// <param name="timeEntryTimeEntryDto"></param>
+ /// <returns></returns>
+ [ApiVersion(ApiSpecV1.VERSION_STRING)]
+ [BasicAuthentication(AppConstants.TOKEN_ALLOW_UPDATE)]
+ [HttpPost("~/v{version:apiVersion}/entries/update")]
+ [ProducesResponseType(404, Type = typeof(ErrorResult))]
+ [ProducesResponseType(200, Type = typeof(TimeEntry.TimeEntryDto))]
+ public override ActionResult<TimeEntry.TimeEntryDto> Handle(TimeEntry.TimeEntryDto timeEntryTimeEntryDto) {
+ var entry = _context.TimeEntries
+ .Where(c => c.UserId == LoggedInUser.Id)
+ .Include(c => c.Labels)
+ .SingleOrDefault(c => c.Id == timeEntryTimeEntryDto.Id);
+
+ if (entry == default) {
+ return NotFound();
+ }
+
+ var category = _context.TimeCategories
+ .Where(c => c.UserId == LoggedInUser.Id)
+ .SingleOrDefault(c => c.Id == timeEntryTimeEntryDto.Category.Id);
+ if (category == default) {
+ return NotFound(new ErrorResult("Not found", $"Could not find category {timeEntryTimeEntryDto.Category.Name}"));
+ }
+
+ entry.Start = timeEntryTimeEntryDto.Start.ToUniversalTime();
+ entry.Stop = timeEntryTimeEntryDto.Stop.ToUniversalTime();
+ entry.Description = timeEntryTimeEntryDto.Description;
+ entry.Category = category;
+
+ if (timeEntryTimeEntryDto.Labels?.Count > 0) {
+ var labels = new List<TimeLabel>();
+
+ foreach (var labelDto in timeEntryTimeEntryDto.Labels) {
+ var label = _context.TimeLabels
+ .Where(c => c.UserId == LoggedInUser.Id)
+ .SingleOrDefault(c => c.Id == labelDto.Id);
+
+ if (label == default) {
+ continue;
+ }
+
+ labels.Add(label);
+ }
+
+ entry.Labels = labels;
+ } else {
+ entry.Labels = default;
+ }
+
+ _context.SaveChanges();
+ return Ok(entry.AsDto);
+ }
+}
diff --git a/server/src/Endpoints/V1/Labels/CreateLabelRoute.cs b/server/src/Endpoints/V1/Labels/CreateLabelRoute.cs
new file mode 100644
index 0000000..31ef7d0
--- /dev/null
+++ b/server/src/Endpoints/V1/Labels/CreateLabelRoute.cs
@@ -0,0 +1,46 @@
+
+namespace IOL.GreatOffice.Api.Endpoints.V1.Labels;
+
+/// <inheritdoc />
+public class CreateLabelRoute : RouteBaseSync.WithRequest<TimeLabel.TimeLabelDto>.WithActionResult<TimeLabel.TimeLabelDto>
+{
+ private readonly AppDbContext _context;
+
+ /// <inheritdoc />
+ public CreateLabelRoute(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Create a time entry label.
+ /// </summary>
+ /// <param name="labelTimeLabelDto"></param>
+ /// <returns></returns>
+ [ApiVersion(ApiSpecV1.VERSION_STRING)]
+ [BasicAuthentication(AppConstants.TOKEN_ALLOW_CREATE)]
+ [HttpPost("~/v{version:apiVersion}/labels/create")]
+ public override ActionResult<TimeLabel.TimeLabelDto> Handle(TimeLabel.TimeLabelDto labelTimeLabelDto) {
+ var duplicate = _context.TimeLabels
+ .Where(c => c.UserId == LoggedInUser.Id)
+ .Any(c => c.Name.Trim() == labelTimeLabelDto.Name.Trim());
+ if (duplicate) {
+ var label = _context.TimeLabels
+ .Where(c => c.UserId == LoggedInUser.Id)
+ .SingleOrDefault(c => c.Name.Trim() == labelTimeLabelDto.Name.Trim());
+
+ if (label != default) {
+ return Ok(label.AsDto);
+ }
+ }
+
+ var newLabel = new TimeLabel(LoggedInUser.Id) {
+ Name = labelTimeLabelDto.Name.Trim(),
+ Color = labelTimeLabelDto.Color
+ };
+
+ _context.TimeLabels.Add(newLabel);
+ _context.SaveChanges();
+ labelTimeLabelDto.Id = newLabel.Id;
+ return Ok(labelTimeLabelDto);
+ }
+}
diff --git a/server/src/Endpoints/V1/Labels/DeleteLabelRoute.cs b/server/src/Endpoints/V1/Labels/DeleteLabelRoute.cs
new file mode 100644
index 0000000..d845a6f
--- /dev/null
+++ b/server/src/Endpoints/V1/Labels/DeleteLabelRoute.cs
@@ -0,0 +1,35 @@
+
+namespace IOL.GreatOffice.Api.Endpoints.V1.Labels;
+
+/// <inheritdoc />
+public class DeleteLabelEndpoint : RouteBaseSync.WithRequest<Guid>.WithActionResult
+{
+ private readonly AppDbContext _context;
+
+ /// <inheritdoc />
+ public DeleteLabelEndpoint(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Delete a time entry label.
+ /// </summary>
+ /// <param name="id"></param>
+ /// <returns></returns>
+ [ApiVersion(ApiSpecV1.VERSION_STRING)]
+ [BasicAuthentication(AppConstants.TOKEN_ALLOW_DELETE)]
+ [HttpDelete("~/v{version:apiVersion}/labels/{id:guid}/delete")]
+ public override ActionResult Handle(Guid id) {
+ var label = _context.TimeLabels
+ .Where(c => c.UserId == LoggedInUser.Id)
+ .SingleOrDefault(c => c.Id == id);
+
+ if (label == default) {
+ return NotFound();
+ }
+
+ _context.TimeLabels.Remove(label);
+ _context.SaveChanges();
+ return Ok();
+ }
+}
diff --git a/server/src/Endpoints/V1/Labels/GetLabelRoute.cs b/server/src/Endpoints/V1/Labels/GetLabelRoute.cs
new file mode 100644
index 0000000..c9ccef3
--- /dev/null
+++ b/server/src/Endpoints/V1/Labels/GetLabelRoute.cs
@@ -0,0 +1,34 @@
+
+namespace IOL.GreatOffice.Api.Endpoints.V1.Labels;
+
+/// <inheritdoc />
+public class GetEndpoint : RouteBaseSync.WithoutRequest.WithActionResult<List<TimeLabel.TimeLabelDto>>
+{
+ private readonly AppDbContext _context;
+
+ /// <inheritdoc />
+ public GetEndpoint(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Get a minimal list of time entry labels.
+ /// </summary>
+ /// <returns></returns>
+ [ApiVersion(ApiSpecV1.VERSION_STRING)]
+ [BasicAuthentication(AppConstants.TOKEN_ALLOW_READ)]
+ [HttpGet("~/v{version:apiVersion}/labels")]
+ public override ActionResult<List<TimeLabel.TimeLabelDto>> Handle() {
+ var labels = _context.TimeLabels
+ .Where(c => c.UserId == LoggedInUser.Id)
+ .OrderByDescending(c => c.CreatedAt)
+ .Select(c => c.AsDto)
+ .ToList();
+
+ if (labels.Count == 0) {
+ return NoContent();
+ }
+
+ return Ok(labels);
+ }
+}
diff --git a/server/src/Endpoints/V1/Labels/UpdateLabelRoute.cs b/server/src/Endpoints/V1/Labels/UpdateLabelRoute.cs
new file mode 100644
index 0000000..0868671
--- /dev/null
+++ b/server/src/Endpoints/V1/Labels/UpdateLabelRoute.cs
@@ -0,0 +1,38 @@
+namespace IOL.GreatOffice.Api.Endpoints.V1.Labels;
+
+/// <inheritdoc />
+public class UpdateLabelEndpoint : RouteBaseSync.WithRequest<TimeLabel.TimeLabelDto>.WithActionResult
+{
+ private readonly AppDbContext _context;
+
+ /// <inheritdoc />
+ public UpdateLabelEndpoint(AppDbContext context) {
+ _context = context;
+ }
+
+ /// <summary>
+ /// Update a time entry label.
+ /// </summary>
+ /// <param name="labelTimeLabelDto"></param>
+ /// <returns></returns>
+ [ApiVersion(ApiSpecV1.VERSION_STRING)]
+ [BasicAuthentication(AppConstants.TOKEN_ALLOW_UPDATE)]
+ [HttpPost("~/v{version:apiVersion}/labels/update")]
+ public override ActionResult Handle(TimeLabel.TimeLabelDto labelTimeLabelDto) {
+ var label = _context.TimeLabels
+ .Where(c => c.UserId == LoggedInUser.Id)
+ .SingleOrDefault(c => c.Id == labelTimeLabelDto.Id);
+ if (label == default) {
+ return NotFound();
+ }
+
+ if (LoggedInUser.Id != label.User.Id) {
+ return Forbid();
+ }
+
+ label.Name = labelTimeLabelDto.Name;
+ label.Color = labelTimeLabelDto.Color;
+ _context.SaveChanges();
+ return Ok();
+ }
+}
diff --git a/server/src/Endpoints/V1/RouteBaseAsync.cs b/server/src/Endpoints/V1/RouteBaseAsync.cs
new file mode 100644
index 0000000..1d179f7
--- /dev/null
+++ b/server/src/Endpoints/V1/RouteBaseAsync.cs
@@ -0,0 +1,73 @@
+namespace IOL.GreatOffice.Api.Endpoints.V1;
+
+/// <summary>
+/// A base class for an endpoint that accepts parameters.
+/// </summary>
+public static class RouteBaseAsync
+{
+ public static class WithRequest<TRequest>
+ {
+ public abstract class WithResult<TResponse> : BaseRoute
+ {
+ public abstract Task<TResponse> HandleAsync(
+ TRequest request,
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithoutResult : BaseRoute
+ {
+ public abstract Task HandleAsync(
+ TRequest request,
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithActionResult<TResponse> : BaseRoute
+ {
+ public abstract Task<ActionResult<TResponse>> HandleAsync(
+ TRequest request,
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithActionResult : BaseRoute
+ {
+ public abstract Task<ActionResult> HandleAsync(
+ TRequest request,
+ CancellationToken cancellationToken = default
+ );
+ }
+ }
+
+ public static class WithoutRequest
+ {
+ public abstract class WithResult<TResponse> : BaseRoute
+ {
+ public abstract Task<TResponse> HandleAsync(
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithoutResult : BaseRoute
+ {
+ public abstract Task HandleAsync(
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithActionResult<TResponse> : BaseRoute
+ {
+ public abstract Task<ActionResult<TResponse>> HandleAsync(
+ CancellationToken cancellationToken = default
+ );
+ }
+
+ public abstract class WithActionResult : BaseRoute
+ {
+ public abstract Task<ActionResult> HandleAsync(
+ CancellationToken cancellationToken = default
+ );
+ }
+ }
+}
diff --git a/server/src/Endpoints/V1/RouteBaseSync.cs b/server/src/Endpoints/V1/RouteBaseSync.cs
new file mode 100644
index 0000000..cb27c14
--- /dev/null
+++ b/server/src/Endpoints/V1/RouteBaseSync.cs
@@ -0,0 +1,53 @@
+namespace IOL.GreatOffice.Api.Endpoints.V1;
+
+/// <summary>
+/// A base class for an endpoint that accepts parameters.
+/// </summary>
+public static class RouteBaseSync
+{
+ public static class WithRequest<TRequest>
+ {
+ public abstract class WithResult<TResponse> : BaseRoute
+ {
+ public abstract TResponse Handle(TRequest request);
+ }
+
+ public abstract class WithoutResult : BaseRoute
+ {
+ public abstract void Handle(TRequest request);
+ }
+
+ public abstract class WithActionResult<TResponse> : BaseRoute
+ {
+ public abstract ActionResult<TResponse> Handle(TRequest request);
+ }
+
+ public abstract class WithActionResult : BaseRoute
+ {
+ public abstract ActionResult Handle(TRequest request);
+ }
+ }
+
+ public static class WithoutRequest
+ {
+ public abstract class WithResult<TResponse> : BaseRoute
+ {
+ public abstract TResponse Handle();
+ }
+
+ public abstract class WithoutResult : BaseRoute
+ {
+ public abstract void Handle();
+ }
+
+ public abstract class WithActionResult<TResponse> : BaseRoute
+ {
+ public abstract ActionResult<TResponse> Handle();
+ }
+
+ public abstract class WithActionResult : BaseRoute
+ {
+ public abstract ActionResult Handle();
+ }
+ }
+}
diff --git a/server/src/IOL.GreatOffice.Api.csproj b/server/src/IOL.GreatOffice.Api.csproj
new file mode 100644
index 0000000..0bd2c48
--- /dev/null
+++ b/server/src/IOL.GreatOffice.Api.csproj
@@ -0,0 +1,48 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+ <PropertyGroup>
+ <TargetFramework>net6.0</TargetFramework>
+ <RuntimeIdentifier>linux-x64</RuntimeIdentifier>
+ <UserSecretsId>ed5ff3e5-46e2-4d7e-8272-7081f5abfee4</UserSecretsId>
+ <GenerateDocumentationFile>true</GenerateDocumentationFile>
+ <ImplicitUsings>true</ImplicitUsings>
+ <Nullable>disable</Nullable>
+ <NoWarn>CS1591</NoWarn>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="AspNet.Security.OAuth.GitHub" Version="6.0.6" />
+ <PackageReference Include="Duende.IdentityServer" Version="6.1.0" />
+ <PackageReference Include="Duende.IdentityServer.EntityFramework.Storage" Version="6.1.0" />
+ <PackageReference Include="EFCore.NamingConventions" Version="6.0.0" />
+ <PackageReference Include="IOL.Helpers" Version="3.0.0" />
+ <PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning" Version="5.0.0" />
+ <PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer" Version="5.0.0" />
+ <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.5">
+ <PrivateAssets>all</PrivateAssets>
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ </PackageReference>
+ <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="6.0.4" />
+ <PackageReference Include="Quartz.Extensions.Hosting" Version="3.4.0" />
+ <PackageReference Include="Serilog.AspNetCore" Version="5.0.0" />
+ <PackageReference Include="Serilog.Expressions" Version="3.4.0" />
+ <PackageReference Include="Serilog.Sinks.Seq" Version="5.1.1" />
+ <PackageReference Include="Swashbuckle.AspNetCore" Version="6.3.1" />
+ <PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="6.3.1" />
+ <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="6.3.1" />
+ </ItemGroup>
+
+ <ItemGroup Condition="'$(Configuration)' == 'Release'">
+ <Content Remove="AppData" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <Content Include="..\..\README.md">
+ <Link>README.md</Link>
+ </Content>
+ </ItemGroup>
+
+ <ItemGroup>
+ <Folder Include="wwwroot" />
+ </ItemGroup>
+</Project>
diff --git a/server/src/Jobs/JobRegister.cs b/server/src/Jobs/JobRegister.cs
new file mode 100644
index 0000000..72c2cc7
--- /dev/null
+++ b/server/src/Jobs/JobRegister.cs
@@ -0,0 +1,18 @@
+using Quartz;
+
+namespace IOL.GreatOffice.Api.Jobs;
+
+public static class JobRegister
+{
+ public static readonly JobKey TokenCleanupKey = new("TokenCleanupJob");
+
+ public static IServiceCollectionQuartzConfigurator RegisterJobs(this IServiceCollectionQuartzConfigurator configurator) {
+ configurator.AddJob<TokenCleanupJob>(TokenCleanupKey);
+ configurator.AddTrigger(options => {
+ options.ForJob(TokenCleanupKey)
+ .WithIdentity(TokenCleanupKey.Name + "-trigger")
+ .WithCronSchedule(CronScheduleBuilder.DailyAtHourAndMinute(1, 0));
+ });
+ return configurator;
+ }
+}
diff --git a/server/src/Jobs/TokenCleanupJob.cs b/server/src/Jobs/TokenCleanupJob.cs
new file mode 100644
index 0000000..22b60d1
--- /dev/null
+++ b/server/src/Jobs/TokenCleanupJob.cs
@@ -0,0 +1,21 @@
+using Quartz;
+
+namespace IOL.GreatOffice.Api.Jobs;
+
+public class TokenCleanupJob : IJob
+{
+ private readonly ILogger<TokenCleanupJob> _logger;
+ private readonly AppDbContext _context;
+
+ public TokenCleanupJob(ILogger<TokenCleanupJob> logger, AppDbContext context) {
+ _logger = logger;
+ _context = context;
+ }
+
+ public Task Execute(IJobExecutionContext context) {
+ var staleTokens = _context.AccessTokens.Where(c => c.HasExpired);
+ _logger.LogInformation("Removing {0} stale tokens", staleTokens.Count());
+ _context.AccessTokens.RemoveRange();
+ return Task.CompletedTask;
+ }
+}
diff --git a/server/src/Migrations/20210517202115_InitialMigration.Designer.cs b/server/src/Migrations/20210517202115_InitialMigration.Designer.cs
new file mode 100644
index 0000000..b6a01ff
--- /dev/null
+++ b/server/src/Migrations/20210517202115_InitialMigration.Designer.cs
@@ -0,0 +1,238 @@
+// <auto-generated />
+using System;
+using IOL.GreatOffice.Api.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20210517202115_InitialMigration")]
+ partial class InitialMigration
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("Relational:MaxIdentifierLength", 63)
+ .HasAnnotation("ProductVersion", "5.0.6")
+ .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_forgot_password_requests");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_forgot_password_requests_user_id");
+
+ b.ToTable("forgot_password_requests");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_categories");
+
+ b.ToTable("time_categories");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("CategoryId")
+ .HasColumnType("uuid")
+ .HasColumnName("category_id");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Description")
+ .HasColumnType("text")
+ .HasColumnName("note");
+
+ b.Property<DateTime>("Start")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start");
+
+ b.Property<DateTime>("Stop")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("stop");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_entries");
+
+ b.HasIndex("CategoryId")
+ .HasDatabaseName("ix_time_entries_category_id");
+
+ b.ToTable("time_entries");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_labels");
+
+ b.ToTable("time_labels");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Password")
+ .HasColumnType("text")
+ .HasColumnName("password");
+
+ b.Property<string>("Username")
+ .HasColumnType("text")
+ .HasColumnName("username");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.ToTable("users");
+
+ b.HasData(
+ new
+ {
+ Id = new Guid("784938f0-cc0e-46ec-afa6-fc60b47b28db"),
+ Created = new DateTime(2021, 5, 17, 20, 21, 14, 827, DateTimeKind.Utc).AddTicks(4868),
+ Password = "AAAAAAEAACcQAAAAEJdtrX3pEeIbcgY+BDAr56gvfbc420ag1TllA0cK6Q6Gw3+gGDIQtYIZnisW3dmqaQ==",
+ Username = "admin@ivarlovlie.no"
+ });
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.Property<Guid>("EntriesId")
+ .HasColumnType("uuid")
+ .HasColumnName("entries_id");
+
+ b.Property<Guid>("LabelsId")
+ .HasColumnType("uuid")
+ .HasColumnName("labels_id");
+
+ b.HasKey("EntriesId", "LabelsId")
+ .HasName("pk_time_entry_time_label");
+
+ b.HasIndex("LabelsId")
+ .HasDatabaseName("ix_time_entry_time_label_labels_id");
+
+ b.ToTable("time_entry_time_label");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_forgot_password_requests_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeCategory", "Category")
+ .WithMany()
+ .HasForeignKey("CategoryId")
+ .HasConstraintName("fk_time_entries_time_categories_category_id");
+
+ b.Navigation("Category");
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeEntry", null)
+ .WithMany()
+ .HasForeignKey("EntriesId")
+ .HasConstraintName("fk_time_entry_time_label_time_entries_entries_id")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeLabel", null)
+ .WithMany()
+ .HasForeignKey("LabelsId")
+ .HasConstraintName("fk_time_entry_time_label_time_labels_labels_id")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/server/src/Migrations/20210517202115_InitialMigration.cs b/server/src/Migrations/20210517202115_InitialMigration.cs
new file mode 100644
index 0000000..8bfaf61
--- /dev/null
+++ b/server/src/Migrations/20210517202115_InitialMigration.cs
@@ -0,0 +1,162 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ public partial class InitialMigration : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "time_categories",
+ columns: table => new
+ {
+ id = table.Column<Guid>(type: "uuid", nullable: false),
+ name = table.Column<string>(type: "text", nullable: true),
+ color = table.Column<string>(type: "text", nullable: true),
+ user_id = table.Column<Guid>(type: "uuid", nullable: false),
+ created = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_time_categories", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "time_labels",
+ columns: table => new
+ {
+ id = table.Column<Guid>(type: "uuid", nullable: false),
+ name = table.Column<string>(type: "text", nullable: true),
+ color = table.Column<string>(type: "text", nullable: true),
+ user_id = table.Column<Guid>(type: "uuid", nullable: false),
+ created = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_time_labels", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "users",
+ columns: table => new
+ {
+ id = table.Column<Guid>(type: "uuid", nullable: false),
+ username = table.Column<string>(type: "text", nullable: true),
+ password = table.Column<string>(type: "text", nullable: true),
+ created = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_users", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "time_entries",
+ columns: table => new
+ {
+ id = table.Column<Guid>(type: "uuid", nullable: false),
+ start = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
+ stop = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
+ note = table.Column<string>(type: "text", nullable: true),
+ user_id = table.Column<Guid>(type: "uuid", nullable: false),
+ category_id = table.Column<Guid>(type: "uuid", nullable: true),
+ created = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_time_entries", x => x.id);
+ table.ForeignKey(
+ name: "fk_time_entries_time_categories_category_id",
+ column: x => x.category_id,
+ principalTable: "time_categories",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Restrict);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "forgot_password_requests",
+ columns: table => new
+ {
+ id = table.Column<Guid>(type: "uuid", nullable: false),
+ user_id = table.Column<Guid>(type: "uuid", nullable: true),
+ created = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_forgot_password_requests", x => x.id);
+ table.ForeignKey(
+ name: "fk_forgot_password_requests_users_user_id",
+ column: x => x.user_id,
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Restrict);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "time_entry_time_label",
+ columns: table => new
+ {
+ entries_id = table.Column<Guid>(type: "uuid", nullable: false),
+ labels_id = table.Column<Guid>(type: "uuid", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_time_entry_time_label", x => new { x.entries_id, x.labels_id });
+ table.ForeignKey(
+ name: "fk_time_entry_time_label_time_entries_entries_id",
+ column: x => x.entries_id,
+ principalTable: "time_entries",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "fk_time_entry_time_label_time_labels_labels_id",
+ column: x => x.labels_id,
+ principalTable: "time_labels",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.InsertData(
+ table: "users",
+ columns: new[] { "id", "created", "password", "username" },
+ values: new object[] { new Guid("784938f0-cc0e-46ec-afa6-fc60b47b28db"), new DateTime(2021, 5, 17, 20, 21, 14, 827, DateTimeKind.Utc).AddTicks(4868), "AAAAAAEAACcQAAAAEJdtrX3pEeIbcgY+BDAr56gvfbc420ag1TllA0cK6Q6Gw3+gGDIQtYIZnisW3dmqaQ==", "admin@ivarlovlie.no" });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_forgot_password_requests_user_id",
+ table: "forgot_password_requests",
+ column: "user_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_entries_category_id",
+ table: "time_entries",
+ column: "category_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_entry_time_label_labels_id",
+ table: "time_entry_time_label",
+ column: "labels_id");
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "forgot_password_requests");
+
+ migrationBuilder.DropTable(
+ name: "time_entry_time_label");
+
+ migrationBuilder.DropTable(
+ name: "users");
+
+ migrationBuilder.DropTable(
+ name: "time_entries");
+
+ migrationBuilder.DropTable(
+ name: "time_labels");
+
+ migrationBuilder.DropTable(
+ name: "time_categories");
+ }
+ }
+}
diff --git a/server/src/Migrations/20210522165932_RenameNoteToDescription.Designer.cs b/server/src/Migrations/20210522165932_RenameNoteToDescription.Designer.cs
new file mode 100644
index 0000000..368e6b3
--- /dev/null
+++ b/server/src/Migrations/20210522165932_RenameNoteToDescription.Designer.cs
@@ -0,0 +1,229 @@
+// <auto-generated />
+using System;
+using IOL.GreatOffice.Api.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20210522165932_RenameNoteToDescription")]
+ partial class RenameNoteToDescription
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("Relational:MaxIdentifierLength", 63)
+ .HasAnnotation("ProductVersion", "5.0.6")
+ .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn);
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("created");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_forgot_password_requests");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_forgot_password_requests_user_id");
+
+ b.ToTable("forgot_password_requests");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_categories");
+
+ b.ToTable("time_categories");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("CategoryId")
+ .HasColumnType("uuid")
+ .HasColumnName("category_id");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property<DateTime>("Start")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("start");
+
+ b.Property<DateTime>("Stop")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("stop");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_entries");
+
+ b.HasIndex("CategoryId")
+ .HasDatabaseName("ix_time_entries_category_id");
+
+ b.ToTable("time_entries");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_labels");
+
+ b.ToTable("time_labels");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp without time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Password")
+ .HasColumnType("text")
+ .HasColumnName("password");
+
+ b.Property<string>("Username")
+ .HasColumnType("text")
+ .HasColumnName("username");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.ToTable("users");
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.Property<Guid>("EntriesId")
+ .HasColumnType("uuid")
+ .HasColumnName("entries_id");
+
+ b.Property<Guid>("LabelsId")
+ .HasColumnType("uuid")
+ .HasColumnName("labels_id");
+
+ b.HasKey("EntriesId", "LabelsId")
+ .HasName("pk_time_entry_time_label");
+
+ b.HasIndex("LabelsId")
+ .HasDatabaseName("ix_time_entry_time_label_labels_id");
+
+ b.ToTable("time_entry_time_label");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_forgot_password_requests_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeCategory", "Category")
+ .WithMany()
+ .HasForeignKey("CategoryId")
+ .HasConstraintName("fk_time_entries_time_categories_category_id");
+
+ b.Navigation("Category");
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeEntry", null)
+ .WithMany()
+ .HasForeignKey("EntriesId")
+ .HasConstraintName("fk_time_entry_time_label_time_entries_entries_id")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeLabel", null)
+ .WithMany()
+ .HasForeignKey("LabelsId")
+ .HasConstraintName("fk_time_entry_time_label_time_labels_labels_id")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/server/src/Migrations/20210522165932_RenameNoteToDescription.cs b/server/src/Migrations/20210522165932_RenameNoteToDescription.cs
new file mode 100644
index 0000000..e5bae54
--- /dev/null
+++ b/server/src/Migrations/20210522165932_RenameNoteToDescription.cs
@@ -0,0 +1,34 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ public partial class RenameNoteToDescription : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DeleteData(
+ table: "users",
+ keyColumn: "id",
+ keyValue: new Guid("784938f0-cc0e-46ec-afa6-fc60b47b28db"));
+
+ migrationBuilder.RenameColumn(
+ name: "note",
+ table: "time_entries",
+ newName: "description");
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.RenameColumn(
+ name: "description",
+ table: "time_entries",
+ newName: "note");
+
+ migrationBuilder.InsertData(
+ table: "users",
+ columns: new[] { "id", "created", "password", "username" },
+ values: new object[] { new Guid("784938f0-cc0e-46ec-afa6-fc60b47b28db"), new DateTime(2021, 5, 17, 20, 21, 14, 827, DateTimeKind.Utc).AddTicks(4868), "AAAAAAEAACcQAAAAEJdtrX3pEeIbcgY+BDAr56gvfbc420ag1TllA0cK6Q6Gw3+gGDIQtYIZnisW3dmqaQ==", "admin@ivarlovlie.no" });
+ }
+ }
+}
diff --git a/server/src/Migrations/20211002113037_V6Migration.Designer.cs b/server/src/Migrations/20211002113037_V6Migration.Designer.cs
new file mode 100644
index 0000000..59e6112
--- /dev/null
+++ b/server/src/Migrations/20211002113037_V6Migration.Designer.cs
@@ -0,0 +1,233 @@
+// <auto-generated />
+
+
+#nullable disable
+
+using System;
+using IOL.GreatOffice.Api.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+namespace IOL.GreatOffice.Api.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20211002113037_V6Migration")]
+ partial class V6Migration
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "6.0.0-rc.1.21452.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_forgot_password_requests");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_forgot_password_requests_user_id");
+
+ b.ToTable("forgot_password_requests", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_categories");
+
+ b.ToTable("time_categories", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("CategoryId")
+ .HasColumnType("uuid")
+ .HasColumnName("category_id");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property<DateTime>("Start")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start");
+
+ b.Property<DateTime>("Stop")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("stop");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_entries");
+
+ b.HasIndex("CategoryId")
+ .HasDatabaseName("ix_time_entries_category_id");
+
+ b.ToTable("time_entries", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_labels");
+
+ b.ToTable("time_labels", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Password")
+ .HasColumnType("text")
+ .HasColumnName("password");
+
+ b.Property<string>("Username")
+ .HasColumnType("text")
+ .HasColumnName("username");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.Property<Guid>("EntriesId")
+ .HasColumnType("uuid")
+ .HasColumnName("entries_id");
+
+ b.Property<Guid>("LabelsId")
+ .HasColumnType("uuid")
+ .HasColumnName("labels_id");
+
+ b.HasKey("EntriesId", "LabelsId")
+ .HasName("pk_time_entry_time_label");
+
+ b.HasIndex("LabelsId")
+ .HasDatabaseName("ix_time_entry_time_label_labels_id");
+
+ b.ToTable("time_entry_time_label", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_forgot_password_requests_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeCategory", "Category")
+ .WithMany()
+ .HasForeignKey("CategoryId")
+ .HasConstraintName("fk_time_entries_time_categories_category_id");
+
+ b.Navigation("Category");
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeEntry", null)
+ .WithMany()
+ .HasForeignKey("EntriesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_entries_entries_id");
+
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeLabel", null)
+ .WithMany()
+ .HasForeignKey("LabelsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_labels_labels_id");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/server/src/Migrations/20211002113037_V6Migration.cs b/server/src/Migrations/20211002113037_V6Migration.cs
new file mode 100644
index 0000000..c7ac971
--- /dev/null
+++ b/server/src/Migrations/20211002113037_V6Migration.cs
@@ -0,0 +1,130 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ public partial class V6Migration : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.Sql("SET TimeZone='UTC'");
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "created",
+ table: "users",
+ type: "timestamp with time zone",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "timestamp without time zone");
+
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "created",
+ table: "time_labels",
+ type: "timestamp with time zone",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "timestamp without time zone");
+
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "stop",
+ table: "time_entries",
+ type: "timestamp with time zone",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "timestamp without time zone");
+
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "start",
+ table: "time_entries",
+ type: "timestamp with time zone",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "timestamp without time zone");
+
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "created",
+ table: "time_entries",
+ type: "timestamp with time zone",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "timestamp without time zone");
+
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "created",
+ table: "time_categories",
+ type: "timestamp with time zone",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "timestamp without time zone");
+
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "created",
+ table: "forgot_password_requests",
+ type: "timestamp with time zone",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "timestamp without time zone");
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.Sql("SET TimeZone='UTC'");
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "created",
+ table: "users",
+ type: "timestamp without time zone",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "timestamp with time zone");
+
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "created",
+ table: "time_labels",
+ type: "timestamp without time zone",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "timestamp with time zone");
+
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "stop",
+ table: "time_entries",
+ type: "timestamp without time zone",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "timestamp with time zone");
+
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "start",
+ table: "time_entries",
+ type: "timestamp without time zone",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "timestamp with time zone");
+
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "created",
+ table: "time_entries",
+ type: "timestamp without time zone",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "timestamp with time zone");
+
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "created",
+ table: "time_categories",
+ type: "timestamp without time zone",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "timestamp with time zone");
+
+ migrationBuilder.AlterColumn<DateTime>(
+ name: "created",
+ table: "forgot_password_requests",
+ type: "timestamp without time zone",
+ nullable: false,
+ oldClrType: typeof(DateTime),
+ oldType: "timestamp with time zone");
+ }
+ }
+}
diff --git a/server/src/Migrations/20220225143559_GithubUserMappings.Designer.cs b/server/src/Migrations/20220225143559_GithubUserMappings.Designer.cs
new file mode 100644
index 0000000..2b95f9d
--- /dev/null
+++ b/server/src/Migrations/20220225143559_GithubUserMappings.Designer.cs
@@ -0,0 +1,270 @@
+// <auto-generated />
+
+
+#nullable disable
+
+using System;
+using IOL.GreatOffice.Api.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+namespace IOL.GreatOffice.Api.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20220225143559_GithubUserMappings")]
+ partial class GithubUserMappings
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "6.0.2")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_forgot_password_requests");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_forgot_password_requests_user_id");
+
+ b.ToTable("forgot_password_requests", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b =>
+ {
+ b.Property<string>("GithubId")
+ .HasColumnType("text")
+ .HasColumnName("github_id");
+
+ b.Property<string>("Email")
+ .HasColumnType("text")
+ .HasColumnName("email");
+
+ b.Property<string>("RefreshToken")
+ .HasColumnType("text")
+ .HasColumnName("refresh_token");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("GithubId")
+ .HasName("pk_github_user_mappings");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_github_user_mappings_user_id");
+
+ b.ToTable("github_user_mappings", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_categories");
+
+ b.ToTable("time_categories", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("CategoryId")
+ .HasColumnType("uuid")
+ .HasColumnName("category_id");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property<DateTime>("Start")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start");
+
+ b.Property<DateTime>("Stop")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("stop");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_entries");
+
+ b.HasIndex("CategoryId")
+ .HasDatabaseName("ix_time_entries_category_id");
+
+ b.ToTable("time_entries", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_labels");
+
+ b.ToTable("time_labels", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("Created")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created");
+
+ b.Property<string>("Password")
+ .HasColumnType("text")
+ .HasColumnName("password");
+
+ b.Property<string>("Username")
+ .HasColumnType("text")
+ .HasColumnName("username");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.Property<Guid>("EntriesId")
+ .HasColumnType("uuid")
+ .HasColumnName("entries_id");
+
+ b.Property<Guid>("LabelsId")
+ .HasColumnType("uuid")
+ .HasColumnName("labels_id");
+
+ b.HasKey("EntriesId", "LabelsId")
+ .HasName("pk_time_entry_time_label");
+
+ b.HasIndex("LabelsId")
+ .HasDatabaseName("ix_time_entry_time_label_labels_id");
+
+ b.ToTable("time_entry_time_label", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_forgot_password_requests_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_github_user_mappings_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeCategory", "Category")
+ .WithMany()
+ .HasForeignKey("CategoryId")
+ .HasConstraintName("fk_time_entries_time_categories_category_id");
+
+ b.Navigation("Category");
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeEntry", null)
+ .WithMany()
+ .HasForeignKey("EntriesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_entries_entries_id");
+
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeLabel", null)
+ .WithMany()
+ .HasForeignKey("LabelsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_labels_labels_id");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/server/src/Migrations/20220225143559_GithubUserMappings.cs b/server/src/Migrations/20220225143559_GithubUserMappings.cs
new file mode 100644
index 0000000..fc30c7a
--- /dev/null
+++ b/server/src/Migrations/20220225143559_GithubUserMappings.cs
@@ -0,0 +1,43 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ public partial class GithubUserMappings : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "github_user_mappings",
+ columns: table => new
+ {
+ github_id = table.Column<string>(type: "text", nullable: false),
+ user_id = table.Column<Guid>(type: "uuid", nullable: true),
+ email = table.Column<string>(type: "text", nullable: true),
+ refresh_token = table.Column<string>(type: "text", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_github_user_mappings", x => x.github_id);
+ table.ForeignKey(
+ name: "fk_github_user_mappings_users_user_id",
+ column: x => x.user_id,
+ principalTable: "users",
+ principalColumn: "id");
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_github_user_mappings_user_id",
+ table: "github_user_mappings",
+ column: "user_id");
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "github_user_mappings");
+ }
+ }
+}
diff --git a/server/src/Migrations/20220319135910_RenameCreated.Designer.cs b/server/src/Migrations/20220319135910_RenameCreated.Designer.cs
new file mode 100644
index 0000000..3d57f1a
--- /dev/null
+++ b/server/src/Migrations/20220319135910_RenameCreated.Designer.cs
@@ -0,0 +1,270 @@
+// <auto-generated />
+
+
+#nullable disable
+
+using System;
+using IOL.GreatOffice.Api.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+namespace IOL.GreatOffice.Api.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20220319135910_RenameCreated")]
+ partial class RenameCreated
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "6.0.3")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_forgot_password_requests");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_forgot_password_requests_user_id");
+
+ b.ToTable("forgot_password_requests", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b =>
+ {
+ b.Property<string>("GithubId")
+ .HasColumnType("text")
+ .HasColumnName("github_id");
+
+ b.Property<string>("Email")
+ .HasColumnType("text")
+ .HasColumnName("email");
+
+ b.Property<string>("RefreshToken")
+ .HasColumnType("text")
+ .HasColumnName("refresh_token");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("GithubId")
+ .HasName("pk_github_user_mappings");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_github_user_mappings_user_id");
+
+ b.ToTable("github_user_mappings", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_categories");
+
+ b.ToTable("time_categories", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("CategoryId")
+ .HasColumnType("uuid")
+ .HasColumnName("category_id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<string>("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property<DateTime>("Start")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start");
+
+ b.Property<DateTime>("Stop")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("stop");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_entries");
+
+ b.HasIndex("CategoryId")
+ .HasDatabaseName("ix_time_entries_category_id");
+
+ b.ToTable("time_entries", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_labels");
+
+ b.ToTable("time_labels", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<string>("Password")
+ .HasColumnType("text")
+ .HasColumnName("password");
+
+ b.Property<string>("Username")
+ .HasColumnType("text")
+ .HasColumnName("username");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.Property<Guid>("EntriesId")
+ .HasColumnType("uuid")
+ .HasColumnName("entries_id");
+
+ b.Property<Guid>("LabelsId")
+ .HasColumnType("uuid")
+ .HasColumnName("labels_id");
+
+ b.HasKey("EntriesId", "LabelsId")
+ .HasName("pk_time_entry_time_label");
+
+ b.HasIndex("LabelsId")
+ .HasDatabaseName("ix_time_entry_time_label_labels_id");
+
+ b.ToTable("time_entry_time_label", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_forgot_password_requests_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_github_user_mappings_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeCategory", "Category")
+ .WithMany()
+ .HasForeignKey("CategoryId")
+ .HasConstraintName("fk_time_entries_time_categories_category_id");
+
+ b.Navigation("Category");
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeEntry", null)
+ .WithMany()
+ .HasForeignKey("EntriesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_entries_entries_id");
+
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeLabel", null)
+ .WithMany()
+ .HasForeignKey("LabelsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_labels_labels_id");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/server/src/Migrations/20220319135910_RenameCreated.cs b/server/src/Migrations/20220319135910_RenameCreated.cs
new file mode 100644
index 0000000..6571e50
--- /dev/null
+++ b/server/src/Migrations/20220319135910_RenameCreated.cs
@@ -0,0 +1,65 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ public partial class RenameCreated : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.RenameColumn(
+ name: "created",
+ table: "users",
+ newName: "created_at");
+
+ migrationBuilder.RenameColumn(
+ name: "created",
+ table: "time_labels",
+ newName: "created_at");
+
+ migrationBuilder.RenameColumn(
+ name: "created",
+ table: "time_entries",
+ newName: "created_at");
+
+ migrationBuilder.RenameColumn(
+ name: "created",
+ table: "time_categories",
+ newName: "created_at");
+
+ migrationBuilder.RenameColumn(
+ name: "created",
+ table: "forgot_password_requests",
+ newName: "created_at");
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.RenameColumn(
+ name: "created_at",
+ table: "users",
+ newName: "created");
+
+ migrationBuilder.RenameColumn(
+ name: "created_at",
+ table: "time_labels",
+ newName: "created");
+
+ migrationBuilder.RenameColumn(
+ name: "created_at",
+ table: "time_entries",
+ newName: "created");
+
+ migrationBuilder.RenameColumn(
+ name: "created_at",
+ table: "time_categories",
+ newName: "created");
+
+ migrationBuilder.RenameColumn(
+ name: "created_at",
+ table: "forgot_password_requests",
+ newName: "created");
+ }
+ }
+}
diff --git a/server/src/Migrations/20220319144958_ModifiedAt.Designer.cs b/server/src/Migrations/20220319144958_ModifiedAt.Designer.cs
new file mode 100644
index 0000000..f75400e
--- /dev/null
+++ b/server/src/Migrations/20220319144958_ModifiedAt.Designer.cs
@@ -0,0 +1,290 @@
+// <auto-generated />
+
+
+#nullable disable
+
+using System;
+using IOL.GreatOffice.Api.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+namespace IOL.GreatOffice.Api.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20220319144958_ModifiedAt")]
+ partial class ModifiedAt
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "6.0.3")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_forgot_password_requests");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_forgot_password_requests_user_id");
+
+ b.ToTable("forgot_password_requests", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b =>
+ {
+ b.Property<string>("GithubId")
+ .HasColumnType("text")
+ .HasColumnName("github_id");
+
+ b.Property<string>("Email")
+ .HasColumnType("text")
+ .HasColumnName("email");
+
+ b.Property<string>("RefreshToken")
+ .HasColumnType("text")
+ .HasColumnName("refresh_token");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("GithubId")
+ .HasName("pk_github_user_mappings");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_github_user_mappings_user_id");
+
+ b.ToTable("github_user_mappings", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_categories");
+
+ b.ToTable("time_categories", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("CategoryId")
+ .HasColumnType("uuid")
+ .HasColumnName("category_id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<string>("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<DateTime>("Start")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start");
+
+ b.Property<DateTime>("Stop")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("stop");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_entries");
+
+ b.HasIndex("CategoryId")
+ .HasDatabaseName("ix_time_entries_category_id");
+
+ b.ToTable("time_entries", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_labels");
+
+ b.ToTable("time_labels", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Password")
+ .HasColumnType("text")
+ .HasColumnName("password");
+
+ b.Property<string>("Username")
+ .HasColumnType("text")
+ .HasColumnName("username");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.Property<Guid>("EntriesId")
+ .HasColumnType("uuid")
+ .HasColumnName("entries_id");
+
+ b.Property<Guid>("LabelsId")
+ .HasColumnType("uuid")
+ .HasColumnName("labels_id");
+
+ b.HasKey("EntriesId", "LabelsId")
+ .HasName("pk_time_entry_time_label");
+
+ b.HasIndex("LabelsId")
+ .HasDatabaseName("ix_time_entry_time_label_labels_id");
+
+ b.ToTable("time_entry_time_label", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_forgot_password_requests_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_github_user_mappings_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeCategory", "Category")
+ .WithMany()
+ .HasForeignKey("CategoryId")
+ .HasConstraintName("fk_time_entries_time_categories_category_id");
+
+ b.Navigation("Category");
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeEntry", null)
+ .WithMany()
+ .HasForeignKey("EntriesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_entries_entries_id");
+
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeLabel", null)
+ .WithMany()
+ .HasForeignKey("LabelsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_labels_labels_id");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/server/src/Migrations/20220319144958_ModifiedAt.cs b/server/src/Migrations/20220319144958_ModifiedAt.cs
new file mode 100644
index 0000000..028473d
--- /dev/null
+++ b/server/src/Migrations/20220319144958_ModifiedAt.cs
@@ -0,0 +1,66 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ public partial class ModifiedAt : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn<DateTime>(
+ name: "modified_at",
+ table: "users",
+ type: "timestamp with time zone",
+ nullable: true);
+
+ migrationBuilder.AddColumn<DateTime>(
+ name: "modified_at",
+ table: "time_labels",
+ type: "timestamp with time zone",
+ nullable: true);
+
+ migrationBuilder.AddColumn<DateTime>(
+ name: "modified_at",
+ table: "time_entries",
+ type: "timestamp with time zone",
+ nullable: true);
+
+ migrationBuilder.AddColumn<DateTime>(
+ name: "modified_at",
+ table: "time_categories",
+ type: "timestamp with time zone",
+ nullable: true);
+
+ migrationBuilder.AddColumn<DateTime>(
+ name: "modified_at",
+ table: "forgot_password_requests",
+ type: "timestamp with time zone",
+ nullable: true);
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "modified_at",
+ table: "users");
+
+ migrationBuilder.DropColumn(
+ name: "modified_at",
+ table: "time_labels");
+
+ migrationBuilder.DropColumn(
+ name: "modified_at",
+ table: "time_entries");
+
+ migrationBuilder.DropColumn(
+ name: "modified_at",
+ table: "time_categories");
+
+ migrationBuilder.DropColumn(
+ name: "modified_at",
+ table: "forgot_password_requests");
+ }
+ }
+}
diff --git a/server/src/Migrations/20220319203018_UserBase.Designer.cs b/server/src/Migrations/20220319203018_UserBase.Designer.cs
new file mode 100644
index 0000000..6c7a76f
--- /dev/null
+++ b/server/src/Migrations/20220319203018_UserBase.Designer.cs
@@ -0,0 +1,322 @@
+// <auto-generated />
+
+
+#nullable disable
+
+using System;
+using IOL.GreatOffice.Api.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+namespace IOL.GreatOffice.Api.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20220319203018_UserBase")]
+ partial class UserBase
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "6.0.3")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_forgot_password_requests");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_forgot_password_requests_user_id");
+
+ b.ToTable("forgot_password_requests", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b =>
+ {
+ b.Property<string>("GithubId")
+ .HasColumnType("text")
+ .HasColumnName("github_id");
+
+ b.Property<string>("Email")
+ .HasColumnType("text")
+ .HasColumnName("email");
+
+ b.Property<string>("RefreshToken")
+ .HasColumnType("text")
+ .HasColumnName("refresh_token");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("GithubId")
+ .HasName("pk_github_user_mappings");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_github_user_mappings_user_id");
+
+ b.ToTable("github_user_mappings", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_categories");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_categories_user_id");
+
+ b.ToTable("time_categories", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("CategoryId")
+ .HasColumnType("uuid")
+ .HasColumnName("category_id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<string>("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<DateTime>("Start")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start");
+
+ b.Property<DateTime>("Stop")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("stop");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_entries");
+
+ b.HasIndex("CategoryId")
+ .HasDatabaseName("ix_time_entries_category_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_entries_user_id");
+
+ b.ToTable("time_entries", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_labels");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_labels_user_id");
+
+ b.ToTable("time_labels", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Username")
+ .HasColumnType("text")
+ .HasColumnName("username");
+
+ b.Property<string>("password")
+ .HasColumnType("text")
+ .HasColumnName("password");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.Property<Guid>("EntriesId")
+ .HasColumnType("uuid")
+ .HasColumnName("entries_id");
+
+ b.Property<Guid>("LabelsId")
+ .HasColumnType("uuid")
+ .HasColumnName("labels_id");
+
+ b.HasKey("EntriesId", "LabelsId")
+ .HasName("pk_time_entry_time_label");
+
+ b.HasIndex("LabelsId")
+ .HasDatabaseName("ix_time_entry_time_label_labels_id");
+
+ b.ToTable("time_entry_time_label", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_forgot_password_requests_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_github_user_mappings_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_time_categories_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeCategory", "Category")
+ .WithMany()
+ .HasForeignKey("CategoryId")
+ .HasConstraintName("fk_time_entries_time_categories_category_id");
+
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_time_entries_users_user_id");
+
+ b.Navigation("Category");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_time_labels_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeEntry", null)
+ .WithMany()
+ .HasForeignKey("EntriesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_entries_entries_id");
+
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeLabel", null)
+ .WithMany()
+ .HasForeignKey("LabelsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_labels_labels_id");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/server/src/Migrations/20220319203018_UserBase.cs b/server/src/Migrations/20220319203018_UserBase.cs
new file mode 100644
index 0000000..14d3f4b
--- /dev/null
+++ b/server/src/Migrations/20220319203018_UserBase.cs
@@ -0,0 +1,140 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ public partial class UserBase : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "modified_at",
+ table: "forgot_password_requests");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "user_id",
+ table: "time_labels",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "user_id",
+ table: "time_entries",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "user_id",
+ table: "time_categories",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_labels_user_id",
+ table: "time_labels",
+ column: "user_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_entries_user_id",
+ table: "time_entries",
+ column: "user_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_categories_user_id",
+ table: "time_categories",
+ column: "user_id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_categories_users_user_id",
+ table: "time_categories",
+ column: "user_id",
+ principalTable: "users",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_entries_users_user_id",
+ table: "time_entries",
+ column: "user_id",
+ principalTable: "users",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_labels_users_user_id",
+ table: "time_labels",
+ column: "user_id",
+ principalTable: "users",
+ principalColumn: "id");
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_categories_users_user_id",
+ table: "time_categories");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_entries_users_user_id",
+ table: "time_entries");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_labels_users_user_id",
+ table: "time_labels");
+
+ migrationBuilder.DropIndex(
+ name: "ix_time_labels_user_id",
+ table: "time_labels");
+
+ migrationBuilder.DropIndex(
+ name: "ix_time_entries_user_id",
+ table: "time_entries");
+
+ migrationBuilder.DropIndex(
+ name: "ix_time_categories_user_id",
+ table: "time_categories");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "user_id",
+ table: "time_labels",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
+ oldClrType: typeof(Guid),
+ oldType: "uuid",
+ oldNullable: true);
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "user_id",
+ table: "time_entries",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
+ oldClrType: typeof(Guid),
+ oldType: "uuid",
+ oldNullable: true);
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "user_id",
+ table: "time_categories",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
+ oldClrType: typeof(Guid),
+ oldType: "uuid",
+ oldNullable: true);
+
+ migrationBuilder.AddColumn<DateTime>(
+ name: "modified_at",
+ table: "forgot_password_requests",
+ type: "timestamp with time zone",
+ nullable: true);
+ }
+ }
+}
diff --git a/server/src/Migrations/20220320115601_Update1.Designer.cs b/server/src/Migrations/20220320115601_Update1.Designer.cs
new file mode 100644
index 0000000..c7463fb
--- /dev/null
+++ b/server/src/Migrations/20220320115601_Update1.Designer.cs
@@ -0,0 +1,342 @@
+// <auto-generated />
+
+
+#nullable disable
+
+using System;
+using IOL.GreatOffice.Api.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+namespace IOL.GreatOffice.Api.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20220320115601_Update1")]
+ partial class Update1
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "6.0.3")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_forgot_password_requests");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_forgot_password_requests_user_id");
+
+ b.ToTable("forgot_password_requests", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b =>
+ {
+ b.Property<string>("GithubId")
+ .HasColumnType("text")
+ .HasColumnName("github_id");
+
+ b.Property<string>("Email")
+ .HasColumnType("text")
+ .HasColumnName("email");
+
+ b.Property<string>("RefreshToken")
+ .HasColumnType("text")
+ .HasColumnName("refresh_token");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("GithubId")
+ .HasName("pk_github_user_mappings");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_github_user_mappings_user_id");
+
+ b.ToTable("github_user_mappings", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_categories");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_categories_user_id");
+
+ b.ToTable("time_categories", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("CategoryId")
+ .HasColumnType("uuid")
+ .HasColumnName("category_id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<string>("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<DateTime>("Start")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start");
+
+ b.Property<DateTime>("Stop")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("stop");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_entries");
+
+ b.HasIndex("CategoryId")
+ .HasDatabaseName("ix_time_entries_category_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_entries_user_id");
+
+ b.ToTable("time_entries", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_labels");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_labels_user_id");
+
+ b.ToTable("time_labels", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Username")
+ .HasColumnType("text")
+ .HasColumnName("username");
+
+ b.Property<string>("password")
+ .HasColumnType("text")
+ .HasColumnName("password");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.Property<Guid>("EntriesId")
+ .HasColumnType("uuid")
+ .HasColumnName("entries_id");
+
+ b.Property<Guid>("LabelsId")
+ .HasColumnType("uuid")
+ .HasColumnName("labels_id");
+
+ b.HasKey("EntriesId", "LabelsId")
+ .HasName("pk_time_entry_time_label");
+
+ b.HasIndex("LabelsId")
+ .HasDatabaseName("ix_time_entry_time_label_labels_id");
+
+ b.ToTable("time_entry_time_label", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_forgot_password_requests_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_github_user_mappings_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany("Categories")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeCategory", "Category")
+ .WithMany("Entries")
+ .HasForeignKey("CategoryId")
+ .HasConstraintName("fk_time_entries_time_categories_category_id");
+
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany("Entries")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_users_user_id");
+
+ b.Navigation("Category");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany("Labels")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeEntry", null)
+ .WithMany()
+ .HasForeignKey("EntriesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_entries_entries_id");
+
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeLabel", null)
+ .WithMany()
+ .HasForeignKey("LabelsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_labels_labels_id");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b =>
+ {
+ b.Navigation("Entries");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.User", b =>
+ {
+ b.Navigation("Categories");
+
+ b.Navigation("Entries");
+
+ b.Navigation("Labels");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/server/src/Migrations/20220320115601_Update1.cs b/server/src/Migrations/20220320115601_Update1.cs
new file mode 100644
index 0000000..8b06fb7
--- /dev/null
+++ b/server/src/Migrations/20220320115601_Update1.cs
@@ -0,0 +1,139 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ public partial class Update1 : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_categories_users_user_id",
+ table: "time_categories");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_entries_users_user_id",
+ table: "time_entries");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_labels_users_user_id",
+ table: "time_labels");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "user_id",
+ table: "time_labels",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
+ oldClrType: typeof(Guid),
+ oldType: "uuid",
+ oldNullable: true);
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "user_id",
+ table: "time_entries",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
+ oldClrType: typeof(Guid),
+ oldType: "uuid",
+ oldNullable: true);
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "user_id",
+ table: "time_categories",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
+ oldClrType: typeof(Guid),
+ oldType: "uuid",
+ oldNullable: true);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_categories_users_user_id",
+ table: "time_categories",
+ column: "user_id",
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_entries_users_user_id",
+ table: "time_entries",
+ column: "user_id",
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_labels_users_user_id",
+ table: "time_labels",
+ column: "user_id",
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_categories_users_user_id",
+ table: "time_categories");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_entries_users_user_id",
+ table: "time_entries");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_labels_users_user_id",
+ table: "time_labels");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "user_id",
+ table: "time_labels",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "user_id",
+ table: "time_entries",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "user_id",
+ table: "time_categories",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_categories_users_user_id",
+ table: "time_categories",
+ column: "user_id",
+ principalTable: "users",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_entries_users_user_id",
+ table: "time_entries",
+ column: "user_id",
+ principalTable: "users",
+ principalColumn: "id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_labels_users_user_id",
+ table: "time_labels",
+ column: "user_id",
+ principalTable: "users",
+ principalColumn: "id");
+ }
+ }
+}
diff --git a/server/src/Migrations/20220320132220_UpdatedForgotPasswordRequests.Designer.cs b/server/src/Migrations/20220320132220_UpdatedForgotPasswordRequests.Designer.cs
new file mode 100644
index 0000000..3a18463
--- /dev/null
+++ b/server/src/Migrations/20220320132220_UpdatedForgotPasswordRequests.Designer.cs
@@ -0,0 +1,344 @@
+// <auto-generated />
+
+
+#nullable disable
+
+using System;
+using IOL.GreatOffice.Api.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+namespace IOL.GreatOffice.Api.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20220320132220_UpdatedForgotPasswordRequests")]
+ partial class UpdatedForgotPasswordRequests
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "6.0.3")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_forgot_password_requests");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_forgot_password_requests_user_id");
+
+ b.ToTable("forgot_password_requests", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b =>
+ {
+ b.Property<string>("GithubId")
+ .HasColumnType("text")
+ .HasColumnName("github_id");
+
+ b.Property<string>("Email")
+ .HasColumnType("text")
+ .HasColumnName("email");
+
+ b.Property<string>("RefreshToken")
+ .HasColumnType("text")
+ .HasColumnName("refresh_token");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("GithubId")
+ .HasName("pk_github_user_mappings");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_github_user_mappings_user_id");
+
+ b.ToTable("github_user_mappings", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_categories");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_categories_user_id");
+
+ b.ToTable("time_categories", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("CategoryId")
+ .HasColumnType("uuid")
+ .HasColumnName("category_id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<string>("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<DateTime>("Start")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start");
+
+ b.Property<DateTime>("Stop")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("stop");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_entries");
+
+ b.HasIndex("CategoryId")
+ .HasDatabaseName("ix_time_entries_category_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_entries_user_id");
+
+ b.ToTable("time_entries", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_labels");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_labels_user_id");
+
+ b.ToTable("time_labels", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Password")
+ .HasColumnType("text")
+ .HasColumnName("password");
+
+ b.Property<string>("Username")
+ .HasColumnType("text")
+ .HasColumnName("username");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.Property<Guid>("EntriesId")
+ .HasColumnType("uuid")
+ .HasColumnName("entries_id");
+
+ b.Property<Guid>("LabelsId")
+ .HasColumnType("uuid")
+ .HasColumnName("labels_id");
+
+ b.HasKey("EntriesId", "LabelsId")
+ .HasName("pk_time_entry_time_label");
+
+ b.HasIndex("LabelsId")
+ .HasDatabaseName("ix_time_entry_time_label_labels_id");
+
+ b.ToTable("time_entry_time_label", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_forgot_password_requests_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.GithubUserMapping", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_github_user_mappings_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany("Categories")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeEntry", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeCategory", "Category")
+ .WithMany("Entries")
+ .HasForeignKey("CategoryId")
+ .HasConstraintName("fk_time_entries_time_categories_category_id");
+
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany("Entries")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_users_user_id");
+
+ b.Navigation("Category");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.User", "User")
+ .WithMany("Labels")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeEntry", null)
+ .WithMany()
+ .HasForeignKey("EntriesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_entries_entries_id");
+
+ b.HasOne("IOL.GreatOffice.Data.Database.TimeLabel", null)
+ .WithMany()
+ .HasForeignKey("LabelsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_labels_labels_id");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.TimeCategory", b =>
+ {
+ b.Navigation("Entries");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Data.Database.User", b =>
+ {
+ b.Navigation("Categories");
+
+ b.Navigation("Entries");
+
+ b.Navigation("Labels");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/server/src/Migrations/20220320132220_UpdatedForgotPasswordRequests.cs b/server/src/Migrations/20220320132220_UpdatedForgotPasswordRequests.cs
new file mode 100644
index 0000000..df7a195
--- /dev/null
+++ b/server/src/Migrations/20220320132220_UpdatedForgotPasswordRequests.cs
@@ -0,0 +1,57 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ public partial class UpdatedForgotPasswordRequests : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropForeignKey(
+ name: "fk_forgot_password_requests_users_user_id",
+ table: "forgot_password_requests");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "user_id",
+ table: "forgot_password_requests",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
+ oldClrType: typeof(Guid),
+ oldType: "uuid",
+ oldNullable: true);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_forgot_password_requests_users_user_id",
+ table: "forgot_password_requests",
+ column: "user_id",
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropForeignKey(
+ name: "fk_forgot_password_requests_users_user_id",
+ table: "forgot_password_requests");
+
+ migrationBuilder.AlterColumn<Guid>(
+ name: "user_id",
+ table: "forgot_password_requests",
+ type: "uuid",
+ nullable: true,
+ oldClrType: typeof(Guid),
+ oldType: "uuid");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_forgot_password_requests_users_user_id",
+ table: "forgot_password_requests",
+ column: "user_id",
+ principalTable: "users",
+ principalColumn: "id");
+ }
+ }
+}
diff --git a/server/src/Migrations/20220529190359_ApiAccessTokens.Designer.cs b/server/src/Migrations/20220529190359_ApiAccessTokens.Designer.cs
new file mode 100644
index 0000000..74f9b40
--- /dev/null
+++ b/server/src/Migrations/20220529190359_ApiAccessTokens.Designer.cs
@@ -0,0 +1,401 @@
+// <auto-generated />
+using System;
+using IOL.GreatOffice.Api.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20220529190359_ApiAccessTokens")]
+ partial class ApiAccessTokens
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "6.0.5")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ApiAccessToken", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<bool>("AllowCreate")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_create");
+
+ b.Property<bool>("AllowDelete")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_delete");
+
+ b.Property<bool>("AllowRead")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_read");
+
+ b.Property<bool>("AllowUpdate")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_update");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime>("ExpiryDate")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expiry_date");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_api_access_tokens");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_api_access_tokens_user_id");
+
+ b.ToTable("api_access_tokens", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_forgot_password_requests");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_forgot_password_requests_user_id");
+
+ b.ToTable("forgot_password_requests", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.GithubUserMapping", b =>
+ {
+ b.Property<string>("GithubId")
+ .HasColumnType("text")
+ .HasColumnName("github_id");
+
+ b.Property<string>("Email")
+ .HasColumnType("text")
+ .HasColumnName("email");
+
+ b.Property<string>("RefreshToken")
+ .HasColumnType("text")
+ .HasColumnName("refresh_token");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("GithubId")
+ .HasName("pk_github_user_mappings");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_github_user_mappings_user_id");
+
+ b.ToTable("github_user_mappings", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeCategory", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_categories");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_categories_user_id");
+
+ b.ToTable("time_categories", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("CategoryId")
+ .HasColumnType("uuid")
+ .HasColumnName("category_id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<string>("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<DateTime>("Start")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start");
+
+ b.Property<DateTime>("Stop")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("stop");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_entries");
+
+ b.HasIndex("CategoryId")
+ .HasDatabaseName("ix_time_entries_category_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_entries_user_id");
+
+ b.ToTable("time_entries", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeLabel", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_labels");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_labels_user_id");
+
+ b.ToTable("time_labels", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Password")
+ .HasColumnType("text")
+ .HasColumnName("password");
+
+ b.Property<string>("Username")
+ .HasColumnType("text")
+ .HasColumnName("username");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.Property<Guid>("EntriesId")
+ .HasColumnType("uuid")
+ .HasColumnName("entries_id");
+
+ b.Property<Guid>("LabelsId")
+ .HasColumnType("uuid")
+ .HasColumnName("labels_id");
+
+ b.HasKey("EntriesId", "LabelsId")
+ .HasName("pk_time_entry_time_label");
+
+ b.HasIndex("LabelsId")
+ .HasDatabaseName("ix_time_entry_time_label_labels_id");
+
+ b.ToTable("time_entry_time_label", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ApiAccessToken", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_api_access_tokens_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_forgot_password_requests_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.GithubUserMapping", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_github_user_mappings_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeCategory", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany("Categories")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeCategory", "Category")
+ .WithMany("Entries")
+ .HasForeignKey("CategoryId")
+ .HasConstraintName("fk_time_entries_time_categories_category_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany("Entries")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_users_user_id");
+
+ b.Navigation("Category");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany("Labels")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeEntry", null)
+ .WithMany()
+ .HasForeignKey("EntriesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_entries_entries_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeLabel", null)
+ .WithMany()
+ .HasForeignKey("LabelsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_labels_labels_id");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeCategory", b =>
+ {
+ b.Navigation("Entries");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.User", b =>
+ {
+ b.Navigation("Categories");
+
+ b.Navigation("Entries");
+
+ b.Navigation("Labels");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/server/src/Migrations/20220529190359_ApiAccessTokens.cs b/server/src/Migrations/20220529190359_ApiAccessTokens.cs
new file mode 100644
index 0000000..dc44bee
--- /dev/null
+++ b/server/src/Migrations/20220529190359_ApiAccessTokens.cs
@@ -0,0 +1,48 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ public partial class ApiAccessTokens : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "api_access_tokens",
+ columns: table => new
+ {
+ id = table.Column<Guid>(type: "uuid", nullable: false),
+ user_id = table.Column<Guid>(type: "uuid", nullable: true),
+ expiry_date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
+ allow_read = table.Column<bool>(type: "boolean", nullable: false),
+ allow_create = table.Column<bool>(type: "boolean", nullable: false),
+ allow_update = table.Column<bool>(type: "boolean", nullable: false),
+ allow_delete = table.Column<bool>(type: "boolean", nullable: false),
+ created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
+ modified_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_api_access_tokens", x => x.id);
+ table.ForeignKey(
+ name: "fk_api_access_tokens_users_user_id",
+ column: x => x.user_id,
+ principalTable: "users",
+ principalColumn: "id");
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_api_access_tokens_user_id",
+ table: "api_access_tokens",
+ column: "user_id");
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "api_access_tokens");
+ }
+ }
+}
diff --git a/server/src/Migrations/20220530174741_Tenants.Designer.cs b/server/src/Migrations/20220530174741_Tenants.Designer.cs
new file mode 100644
index 0000000..678c52d
--- /dev/null
+++ b/server/src/Migrations/20220530174741_Tenants.Designer.cs
@@ -0,0 +1,710 @@
+// <auto-generated />
+using System;
+using IOL.GreatOffice.Api.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20220530174741_Tenants")]
+ partial class Tenants
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "6.0.5")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ApiAccessToken", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<bool>("AllowCreate")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_create");
+
+ b.Property<bool>("AllowDelete")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_delete");
+
+ b.Property<bool>("AllowRead")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_read");
+
+ b.Property<bool>("AllowUpdate")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_update");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime>("ExpiryDate")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expiry_date");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_api_access_tokens");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_api_access_tokens_user_id");
+
+ b.ToTable("api_access_tokens", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_forgot_password_requests");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_forgot_password_requests_user_id");
+
+ b.ToTable("forgot_password_requests", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.GithubUserMapping", b =>
+ {
+ b.Property<string>("GithubId")
+ .HasColumnType("text")
+ .HasColumnName("github_id");
+
+ b.Property<string>("Email")
+ .HasColumnType("text")
+ .HasColumnName("email");
+
+ b.Property<string>("RefreshToken")
+ .HasColumnType("text")
+ .HasColumnName("refresh_token");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("GithubId")
+ .HasName("pk_github_user_mappings");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_github_user_mappings_user_id");
+
+ b.ToTable("github_user_mappings", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.Tenant", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("ContactEmail")
+ .HasColumnType("text")
+ .HasColumnName("contact_email");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("CreatedById")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_id");
+
+ b.Property<Guid>("DeletedById")
+ .HasColumnType("uuid")
+ .HasColumnName("deleted_by_id");
+
+ b.Property<string>("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property<Guid>("MasterUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("master_user_id");
+
+ b.Property<string>("MasterUserPassword")
+ .HasColumnType("text")
+ .HasColumnName("master_user_password");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid>("ModifiedById")
+ .HasColumnType("uuid")
+ .HasColumnName("modified_by_id");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property<Guid?>("TenantId1")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id1");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_tenants");
+
+ b.HasIndex("CreatedById")
+ .HasDatabaseName("ix_tenants_created_by_id");
+
+ b.HasIndex("DeletedById")
+ .HasDatabaseName("ix_tenants_deleted_by_id");
+
+ b.HasIndex("ModifiedById")
+ .HasDatabaseName("ix_tenants_modified_by_id");
+
+ b.HasIndex("TenantId1")
+ .HasDatabaseName("ix_tenants_tenant_id1");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_tenants_user_id");
+
+ b.ToTable("tenants", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeCategory", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("CreatedById")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_id");
+
+ b.Property<Guid>("DeletedById")
+ .HasColumnType("uuid")
+ .HasColumnName("deleted_by_id");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid>("ModifiedById")
+ .HasColumnType("uuid")
+ .HasColumnName("modified_by_id");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_categories");
+
+ b.HasIndex("CreatedById")
+ .HasDatabaseName("ix_time_categories_created_by_id");
+
+ b.HasIndex("DeletedById")
+ .HasDatabaseName("ix_time_categories_deleted_by_id");
+
+ b.HasIndex("ModifiedById")
+ .HasDatabaseName("ix_time_categories_modified_by_id");
+
+ b.HasIndex("TenantId")
+ .HasDatabaseName("ix_time_categories_tenant_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_categories_user_id");
+
+ b.ToTable("time_categories", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("CategoryId")
+ .HasColumnType("uuid")
+ .HasColumnName("category_id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("CreatedById")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_id");
+
+ b.Property<Guid>("DeletedById")
+ .HasColumnType("uuid")
+ .HasColumnName("deleted_by_id");
+
+ b.Property<string>("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid>("ModifiedById")
+ .HasColumnType("uuid")
+ .HasColumnName("modified_by_id");
+
+ b.Property<DateTime>("Start")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start");
+
+ b.Property<DateTime>("Stop")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("stop");
+
+ b.Property<Guid>("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_entries");
+
+ b.HasIndex("CategoryId")
+ .HasDatabaseName("ix_time_entries_category_id");
+
+ b.HasIndex("CreatedById")
+ .HasDatabaseName("ix_time_entries_created_by_id");
+
+ b.HasIndex("DeletedById")
+ .HasDatabaseName("ix_time_entries_deleted_by_id");
+
+ b.HasIndex("ModifiedById")
+ .HasDatabaseName("ix_time_entries_modified_by_id");
+
+ b.HasIndex("TenantId")
+ .HasDatabaseName("ix_time_entries_tenant_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_entries_user_id");
+
+ b.ToTable("time_entries", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeLabel", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("CreatedById")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_id");
+
+ b.Property<Guid>("DeletedById")
+ .HasColumnType("uuid")
+ .HasColumnName("deleted_by_id");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid>("ModifiedById")
+ .HasColumnType("uuid")
+ .HasColumnName("modified_by_id");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_labels");
+
+ b.HasIndex("CreatedById")
+ .HasDatabaseName("ix_time_labels_created_by_id");
+
+ b.HasIndex("DeletedById")
+ .HasDatabaseName("ix_time_labels_deleted_by_id");
+
+ b.HasIndex("ModifiedById")
+ .HasDatabaseName("ix_time_labels_modified_by_id");
+
+ b.HasIndex("TenantId")
+ .HasDatabaseName("ix_time_labels_tenant_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_labels_user_id");
+
+ b.ToTable("time_labels", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Password")
+ .HasColumnType("text")
+ .HasColumnName("password");
+
+ b.Property<string>("Username")
+ .HasColumnType("text")
+ .HasColumnName("username");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.Property<Guid>("EntriesId")
+ .HasColumnType("uuid")
+ .HasColumnName("entries_id");
+
+ b.Property<Guid>("LabelsId")
+ .HasColumnType("uuid")
+ .HasColumnName("labels_id");
+
+ b.HasKey("EntriesId", "LabelsId")
+ .HasName("pk_time_entry_time_label");
+
+ b.HasIndex("LabelsId")
+ .HasDatabaseName("ix_time_entry_time_label_labels_id");
+
+ b.ToTable("time_entry_time_label", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ApiAccessToken", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_api_access_tokens_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_forgot_password_requests_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.GithubUserMapping", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_github_user_mappings_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.Tenant", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy")
+ .WithMany()
+ .HasForeignKey("CreatedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenants_users_created_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy")
+ .WithMany()
+ .HasForeignKey("DeletedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenants_users_deleted_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy")
+ .WithMany()
+ .HasForeignKey("ModifiedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenants_users_modified_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant")
+ .WithMany()
+ .HasForeignKey("TenantId1")
+ .HasConstraintName("fk_tenants_tenants_tenant_id1");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenants_users_user_id");
+
+ b.Navigation("CreatedBy");
+
+ b.Navigation("DeletedBy");
+
+ b.Navigation("ModifiedBy");
+
+ b.Navigation("Tenant");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeCategory", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy")
+ .WithMany()
+ .HasForeignKey("CreatedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_users_created_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy")
+ .WithMany()
+ .HasForeignKey("DeletedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_users_deleted_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy")
+ .WithMany()
+ .HasForeignKey("ModifiedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_users_modified_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant")
+ .WithMany()
+ .HasForeignKey("TenantId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_tenants_tenant_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_users_user_id");
+
+ b.Navigation("CreatedBy");
+
+ b.Navigation("DeletedBy");
+
+ b.Navigation("ModifiedBy");
+
+ b.Navigation("Tenant");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeCategory", "Category")
+ .WithMany("Entries")
+ .HasForeignKey("CategoryId")
+ .HasConstraintName("fk_time_entries_time_categories_category_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy")
+ .WithMany()
+ .HasForeignKey("CreatedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_users_created_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy")
+ .WithMany()
+ .HasForeignKey("DeletedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_users_deleted_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy")
+ .WithMany()
+ .HasForeignKey("ModifiedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_users_modified_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant")
+ .WithMany()
+ .HasForeignKey("TenantId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_tenants_tenant_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_users_user_id");
+
+ b.Navigation("Category");
+
+ b.Navigation("CreatedBy");
+
+ b.Navigation("DeletedBy");
+
+ b.Navigation("ModifiedBy");
+
+ b.Navigation("Tenant");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy")
+ .WithMany()
+ .HasForeignKey("CreatedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_users_created_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy")
+ .WithMany()
+ .HasForeignKey("DeletedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_users_deleted_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy")
+ .WithMany()
+ .HasForeignKey("ModifiedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_users_modified_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant")
+ .WithMany()
+ .HasForeignKey("TenantId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_tenants_tenant_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_users_user_id");
+
+ b.Navigation("CreatedBy");
+
+ b.Navigation("DeletedBy");
+
+ b.Navigation("ModifiedBy");
+
+ b.Navigation("Tenant");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("TimeEntryTimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeEntry", null)
+ .WithMany()
+ .HasForeignKey("EntriesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_entries_entries_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeLabel", null)
+ .WithMany()
+ .HasForeignKey("LabelsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entry_time_label_time_labels_labels_id");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeCategory", b =>
+ {
+ b.Navigation("Entries");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/server/src/Migrations/20220530174741_Tenants.cs b/server/src/Migrations/20220530174741_Tenants.cs
new file mode 100644
index 0000000..ea02ddd
--- /dev/null
+++ b/server/src/Migrations/20220530174741_Tenants.cs
@@ -0,0 +1,481 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ public partial class Tenants : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn<Guid>(
+ name: "created_by_id",
+ table: "time_labels",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
+
+ migrationBuilder.AddColumn<Guid>(
+ name: "deleted_by_id",
+ table: "time_labels",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
+
+ migrationBuilder.AddColumn<Guid>(
+ name: "modified_by_id",
+ table: "time_labels",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
+
+ migrationBuilder.AddColumn<Guid>(
+ name: "tenant_id",
+ table: "time_labels",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
+
+ migrationBuilder.AddColumn<Guid>(
+ name: "created_by_id",
+ table: "time_entries",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
+
+ migrationBuilder.AddColumn<Guid>(
+ name: "deleted_by_id",
+ table: "time_entries",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
+
+ migrationBuilder.AddColumn<Guid>(
+ name: "modified_by_id",
+ table: "time_entries",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
+
+ migrationBuilder.AddColumn<Guid>(
+ name: "tenant_id",
+ table: "time_entries",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
+
+ migrationBuilder.AddColumn<Guid>(
+ name: "created_by_id",
+ table: "time_categories",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
+
+ migrationBuilder.AddColumn<Guid>(
+ name: "deleted_by_id",
+ table: "time_categories",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
+
+ migrationBuilder.AddColumn<Guid>(
+ name: "modified_by_id",
+ table: "time_categories",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
+
+ migrationBuilder.AddColumn<Guid>(
+ name: "tenant_id",
+ table: "time_categories",
+ type: "uuid",
+ nullable: false,
+ defaultValue: new Guid("00000000-0000-0000-0000-000000000000"));
+
+ migrationBuilder.CreateTable(
+ name: "tenants",
+ columns: table => new
+ {
+ id = table.Column<Guid>(type: "uuid", nullable: false),
+ name = table.Column<string>(type: "text", nullable: true),
+ description = table.Column<string>(type: "text", nullable: true),
+ contact_email = table.Column<string>(type: "text", nullable: true),
+ master_user_id = table.Column<Guid>(type: "uuid", nullable: false),
+ master_user_password = table.Column<string>(type: "text", nullable: true),
+ created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
+ modified_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
+ user_id = table.Column<Guid>(type: "uuid", nullable: false),
+ tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
+ tenant_id1 = table.Column<Guid>(type: "uuid", nullable: true),
+ modified_by_id = table.Column<Guid>(type: "uuid", nullable: false),
+ created_by_id = table.Column<Guid>(type: "uuid", nullable: false),
+ deleted_by_id = table.Column<Guid>(type: "uuid", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_tenants", x => x.id);
+ table.ForeignKey(
+ name: "fk_tenants_tenants_tenant_id1",
+ column: x => x.tenant_id1,
+ principalTable: "tenants",
+ principalColumn: "id");
+ table.ForeignKey(
+ name: "fk_tenants_users_created_by_id",
+ column: x => x.created_by_id,
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "fk_tenants_users_deleted_by_id",
+ column: x => x.deleted_by_id,
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "fk_tenants_users_modified_by_id",
+ column: x => x.modified_by_id,
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "fk_tenants_users_user_id",
+ column: x => x.user_id,
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_labels_created_by_id",
+ table: "time_labels",
+ column: "created_by_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_labels_deleted_by_id",
+ table: "time_labels",
+ column: "deleted_by_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_labels_modified_by_id",
+ table: "time_labels",
+ column: "modified_by_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_labels_tenant_id",
+ table: "time_labels",
+ column: "tenant_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_entries_created_by_id",
+ table: "time_entries",
+ column: "created_by_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_entries_deleted_by_id",
+ table: "time_entries",
+ column: "deleted_by_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_entries_modified_by_id",
+ table: "time_entries",
+ column: "modified_by_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_entries_tenant_id",
+ table: "time_entries",
+ column: "tenant_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_categories_created_by_id",
+ table: "time_categories",
+ column: "created_by_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_categories_deleted_by_id",
+ table: "time_categories",
+ column: "deleted_by_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_categories_modified_by_id",
+ table: "time_categories",
+ column: "modified_by_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_categories_tenant_id",
+ table: "time_categories",
+ column: "tenant_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_tenants_created_by_id",
+ table: "tenants",
+ column: "created_by_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_tenants_deleted_by_id",
+ table: "tenants",
+ column: "deleted_by_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_tenants_modified_by_id",
+ table: "tenants",
+ column: "modified_by_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_tenants_tenant_id1",
+ table: "tenants",
+ column: "tenant_id1");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_tenants_user_id",
+ table: "tenants",
+ column: "user_id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_categories_tenants_tenant_id",
+ table: "time_categories",
+ column: "tenant_id",
+ principalTable: "tenants",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_categories_users_created_by_id",
+ table: "time_categories",
+ column: "created_by_id",
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_categories_users_deleted_by_id",
+ table: "time_categories",
+ column: "deleted_by_id",
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_categories_users_modified_by_id",
+ table: "time_categories",
+ column: "modified_by_id",
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_entries_tenants_tenant_id",
+ table: "time_entries",
+ column: "tenant_id",
+ principalTable: "tenants",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_entries_users_created_by_id",
+ table: "time_entries",
+ column: "created_by_id",
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_entries_users_deleted_by_id",
+ table: "time_entries",
+ column: "deleted_by_id",
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_entries_users_modified_by_id",
+ table: "time_entries",
+ column: "modified_by_id",
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_labels_tenants_tenant_id",
+ table: "time_labels",
+ column: "tenant_id",
+ principalTable: "tenants",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_labels_users_created_by_id",
+ table: "time_labels",
+ column: "created_by_id",
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_labels_users_deleted_by_id",
+ table: "time_labels",
+ column: "deleted_by_id",
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_labels_users_modified_by_id",
+ table: "time_labels",
+ column: "modified_by_id",
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_categories_tenants_tenant_id",
+ table: "time_categories");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_categories_users_created_by_id",
+ table: "time_categories");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_categories_users_deleted_by_id",
+ table: "time_categories");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_categories_users_modified_by_id",
+ table: "time_categories");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_entries_tenants_tenant_id",
+ table: "time_entries");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_entries_users_created_by_id",
+ table: "time_entries");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_entries_users_deleted_by_id",
+ table: "time_entries");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_entries_users_modified_by_id",
+ table: "time_entries");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_labels_tenants_tenant_id",
+ table: "time_labels");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_labels_users_created_by_id",
+ table: "time_labels");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_labels_users_deleted_by_id",
+ table: "time_labels");
+
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_labels_users_modified_by_id",
+ table: "time_labels");
+
+ migrationBuilder.DropTable(
+ name: "tenants");
+
+ migrationBuilder.DropIndex(
+ name: "ix_time_labels_created_by_id",
+ table: "time_labels");
+
+ migrationBuilder.DropIndex(
+ name: "ix_time_labels_deleted_by_id",
+ table: "time_labels");
+
+ migrationBuilder.DropIndex(
+ name: "ix_time_labels_modified_by_id",
+ table: "time_labels");
+
+ migrationBuilder.DropIndex(
+ name: "ix_time_labels_tenant_id",
+ table: "time_labels");
+
+ migrationBuilder.DropIndex(
+ name: "ix_time_entries_created_by_id",
+ table: "time_entries");
+
+ migrationBuilder.DropIndex(
+ name: "ix_time_entries_deleted_by_id",
+ table: "time_entries");
+
+ migrationBuilder.DropIndex(
+ name: "ix_time_entries_modified_by_id",
+ table: "time_entries");
+
+ migrationBuilder.DropIndex(
+ name: "ix_time_entries_tenant_id",
+ table: "time_entries");
+
+ migrationBuilder.DropIndex(
+ name: "ix_time_categories_created_by_id",
+ table: "time_categories");
+
+ migrationBuilder.DropIndex(
+ name: "ix_time_categories_deleted_by_id",
+ table: "time_categories");
+
+ migrationBuilder.DropIndex(
+ name: "ix_time_categories_modified_by_id",
+ table: "time_categories");
+
+ migrationBuilder.DropIndex(
+ name: "ix_time_categories_tenant_id",
+ table: "time_categories");
+
+ migrationBuilder.DropColumn(
+ name: "created_by_id",
+ table: "time_labels");
+
+ migrationBuilder.DropColumn(
+ name: "deleted_by_id",
+ table: "time_labels");
+
+ migrationBuilder.DropColumn(
+ name: "modified_by_id",
+ table: "time_labels");
+
+ migrationBuilder.DropColumn(
+ name: "tenant_id",
+ table: "time_labels");
+
+ migrationBuilder.DropColumn(
+ name: "created_by_id",
+ table: "time_entries");
+
+ migrationBuilder.DropColumn(
+ name: "deleted_by_id",
+ table: "time_entries");
+
+ migrationBuilder.DropColumn(
+ name: "modified_by_id",
+ table: "time_entries");
+
+ migrationBuilder.DropColumn(
+ name: "tenant_id",
+ table: "time_entries");
+
+ migrationBuilder.DropColumn(
+ name: "created_by_id",
+ table: "time_categories");
+
+ migrationBuilder.DropColumn(
+ name: "deleted_by_id",
+ table: "time_categories");
+
+ migrationBuilder.DropColumn(
+ name: "modified_by_id",
+ table: "time_categories");
+
+ migrationBuilder.DropColumn(
+ name: "tenant_id",
+ table: "time_categories");
+ }
+ }
+}
diff --git a/server/src/Migrations/20220530175322_RemoveUnusedNavs.Designer.cs b/server/src/Migrations/20220530175322_RemoveUnusedNavs.Designer.cs
new file mode 100644
index 0000000..8fd6b40
--- /dev/null
+++ b/server/src/Migrations/20220530175322_RemoveUnusedNavs.Designer.cs
@@ -0,0 +1,686 @@
+// <auto-generated />
+using System;
+using IOL.GreatOffice.Api.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20220530175322_RemoveUnusedNavs")]
+ partial class RemoveUnusedNavs
+ {
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "6.0.5")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ApiAccessToken", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<bool>("AllowCreate")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_create");
+
+ b.Property<bool>("AllowDelete")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_delete");
+
+ b.Property<bool>("AllowRead")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_read");
+
+ b.Property<bool>("AllowUpdate")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_update");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime>("ExpiryDate")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expiry_date");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_api_access_tokens");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_api_access_tokens_user_id");
+
+ b.ToTable("api_access_tokens", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_forgot_password_requests");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_forgot_password_requests_user_id");
+
+ b.ToTable("forgot_password_requests", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.GithubUserMapping", b =>
+ {
+ b.Property<string>("GithubId")
+ .HasColumnType("text")
+ .HasColumnName("github_id");
+
+ b.Property<string>("Email")
+ .HasColumnType("text")
+ .HasColumnName("email");
+
+ b.Property<string>("RefreshToken")
+ .HasColumnType("text")
+ .HasColumnName("refresh_token");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("GithubId")
+ .HasName("pk_github_user_mappings");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_github_user_mappings_user_id");
+
+ b.ToTable("github_user_mappings", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.Tenant", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("ContactEmail")
+ .HasColumnType("text")
+ .HasColumnName("contact_email");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("CreatedById")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_id");
+
+ b.Property<Guid>("DeletedById")
+ .HasColumnType("uuid")
+ .HasColumnName("deleted_by_id");
+
+ b.Property<string>("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property<Guid>("MasterUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("master_user_id");
+
+ b.Property<string>("MasterUserPassword")
+ .HasColumnType("text")
+ .HasColumnName("master_user_password");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid>("ModifiedById")
+ .HasColumnType("uuid")
+ .HasColumnName("modified_by_id");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property<Guid?>("TenantId1")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id1");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_tenants");
+
+ b.HasIndex("CreatedById")
+ .HasDatabaseName("ix_tenants_created_by_id");
+
+ b.HasIndex("DeletedById")
+ .HasDatabaseName("ix_tenants_deleted_by_id");
+
+ b.HasIndex("ModifiedById")
+ .HasDatabaseName("ix_tenants_modified_by_id");
+
+ b.HasIndex("TenantId1")
+ .HasDatabaseName("ix_tenants_tenant_id1");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_tenants_user_id");
+
+ b.ToTable("tenants", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeCategory", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("CreatedById")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_id");
+
+ b.Property<Guid>("DeletedById")
+ .HasColumnType("uuid")
+ .HasColumnName("deleted_by_id");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid>("ModifiedById")
+ .HasColumnType("uuid")
+ .HasColumnName("modified_by_id");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_categories");
+
+ b.HasIndex("CreatedById")
+ .HasDatabaseName("ix_time_categories_created_by_id");
+
+ b.HasIndex("DeletedById")
+ .HasDatabaseName("ix_time_categories_deleted_by_id");
+
+ b.HasIndex("ModifiedById")
+ .HasDatabaseName("ix_time_categories_modified_by_id");
+
+ b.HasIndex("TenantId")
+ .HasDatabaseName("ix_time_categories_tenant_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_categories_user_id");
+
+ b.ToTable("time_categories", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("CategoryId")
+ .HasColumnType("uuid")
+ .HasColumnName("category_id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("CreatedById")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_id");
+
+ b.Property<Guid>("DeletedById")
+ .HasColumnType("uuid")
+ .HasColumnName("deleted_by_id");
+
+ b.Property<string>("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid>("ModifiedById")
+ .HasColumnType("uuid")
+ .HasColumnName("modified_by_id");
+
+ b.Property<DateTime>("Start")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start");
+
+ b.Property<DateTime>("Stop")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("stop");
+
+ b.Property<Guid>("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_entries");
+
+ b.HasIndex("CategoryId")
+ .HasDatabaseName("ix_time_entries_category_id");
+
+ b.HasIndex("CreatedById")
+ .HasDatabaseName("ix_time_entries_created_by_id");
+
+ b.HasIndex("DeletedById")
+ .HasDatabaseName("ix_time_entries_deleted_by_id");
+
+ b.HasIndex("ModifiedById")
+ .HasDatabaseName("ix_time_entries_modified_by_id");
+
+ b.HasIndex("TenantId")
+ .HasDatabaseName("ix_time_entries_tenant_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_entries_user_id");
+
+ b.ToTable("time_entries", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeLabel", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("CreatedById")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_id");
+
+ b.Property<Guid>("DeletedById")
+ .HasColumnType("uuid")
+ .HasColumnName("deleted_by_id");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid>("ModifiedById")
+ .HasColumnType("uuid")
+ .HasColumnName("modified_by_id");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property<Guid?>("TimeEntryId")
+ .HasColumnType("uuid")
+ .HasColumnName("time_entry_id");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_labels");
+
+ b.HasIndex("CreatedById")
+ .HasDatabaseName("ix_time_labels_created_by_id");
+
+ b.HasIndex("DeletedById")
+ .HasDatabaseName("ix_time_labels_deleted_by_id");
+
+ b.HasIndex("ModifiedById")
+ .HasDatabaseName("ix_time_labels_modified_by_id");
+
+ b.HasIndex("TenantId")
+ .HasDatabaseName("ix_time_labels_tenant_id");
+
+ b.HasIndex("TimeEntryId")
+ .HasDatabaseName("ix_time_labels_time_entry_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_labels_user_id");
+
+ b.ToTable("time_labels", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Password")
+ .HasColumnType("text")
+ .HasColumnName("password");
+
+ b.Property<string>("Username")
+ .HasColumnType("text")
+ .HasColumnName("username");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ApiAccessToken", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_api_access_tokens_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_forgot_password_requests_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.GithubUserMapping", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_github_user_mappings_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.Tenant", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy")
+ .WithMany()
+ .HasForeignKey("CreatedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenants_users_created_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy")
+ .WithMany()
+ .HasForeignKey("DeletedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenants_users_deleted_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy")
+ .WithMany()
+ .HasForeignKey("ModifiedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenants_users_modified_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant")
+ .WithMany()
+ .HasForeignKey("TenantId1")
+ .HasConstraintName("fk_tenants_tenants_tenant_id1");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenants_users_user_id");
+
+ b.Navigation("CreatedBy");
+
+ b.Navigation("DeletedBy");
+
+ b.Navigation("ModifiedBy");
+
+ b.Navigation("Tenant");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeCategory", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy")
+ .WithMany()
+ .HasForeignKey("CreatedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_users_created_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy")
+ .WithMany()
+ .HasForeignKey("DeletedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_users_deleted_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy")
+ .WithMany()
+ .HasForeignKey("ModifiedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_users_modified_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant")
+ .WithMany()
+ .HasForeignKey("TenantId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_tenants_tenant_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_users_user_id");
+
+ b.Navigation("CreatedBy");
+
+ b.Navigation("DeletedBy");
+
+ b.Navigation("ModifiedBy");
+
+ b.Navigation("Tenant");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeCategory", "Category")
+ .WithMany()
+ .HasForeignKey("CategoryId")
+ .HasConstraintName("fk_time_entries_time_categories_category_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy")
+ .WithMany()
+ .HasForeignKey("CreatedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_users_created_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy")
+ .WithMany()
+ .HasForeignKey("DeletedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_users_deleted_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy")
+ .WithMany()
+ .HasForeignKey("ModifiedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_users_modified_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant")
+ .WithMany()
+ .HasForeignKey("TenantId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_tenants_tenant_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_users_user_id");
+
+ b.Navigation("Category");
+
+ b.Navigation("CreatedBy");
+
+ b.Navigation("DeletedBy");
+
+ b.Navigation("ModifiedBy");
+
+ b.Navigation("Tenant");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy")
+ .WithMany()
+ .HasForeignKey("CreatedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_users_created_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy")
+ .WithMany()
+ .HasForeignKey("DeletedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_users_deleted_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy")
+ .WithMany()
+ .HasForeignKey("ModifiedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_users_modified_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant")
+ .WithMany()
+ .HasForeignKey("TenantId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_tenants_tenant_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeEntry", null)
+ .WithMany("Labels")
+ .HasForeignKey("TimeEntryId")
+ .HasConstraintName("fk_time_labels_time_entries_time_entry_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_users_user_id");
+
+ b.Navigation("CreatedBy");
+
+ b.Navigation("DeletedBy");
+
+ b.Navigation("ModifiedBy");
+
+ b.Navigation("Tenant");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b =>
+ {
+ b.Navigation("Labels");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/server/src/Migrations/20220530175322_RemoveUnusedNavs.cs b/server/src/Migrations/20220530175322_RemoveUnusedNavs.cs
new file mode 100644
index 0000000..36b3cf1
--- /dev/null
+++ b/server/src/Migrations/20220530175322_RemoveUnusedNavs.cs
@@ -0,0 +1,78 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ public partial class RemoveUnusedNavs : Migration
+ {
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "time_entry_time_label");
+
+ migrationBuilder.AddColumn<Guid>(
+ name: "time_entry_id",
+ table: "time_labels",
+ type: "uuid",
+ nullable: true);
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_labels_time_entry_id",
+ table: "time_labels",
+ column: "time_entry_id");
+
+ migrationBuilder.AddForeignKey(
+ name: "fk_time_labels_time_entries_time_entry_id",
+ table: "time_labels",
+ column: "time_entry_id",
+ principalTable: "time_entries",
+ principalColumn: "id");
+ }
+
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropForeignKey(
+ name: "fk_time_labels_time_entries_time_entry_id",
+ table: "time_labels");
+
+ migrationBuilder.DropIndex(
+ name: "ix_time_labels_time_entry_id",
+ table: "time_labels");
+
+ migrationBuilder.DropColumn(
+ name: "time_entry_id",
+ table: "time_labels");
+
+ migrationBuilder.CreateTable(
+ name: "time_entry_time_label",
+ columns: table => new
+ {
+ entries_id = table.Column<Guid>(type: "uuid", nullable: false),
+ labels_id = table.Column<Guid>(type: "uuid", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_time_entry_time_label", x => new { x.entries_id, x.labels_id });
+ table.ForeignKey(
+ name: "fk_time_entry_time_label_time_entries_entries_id",
+ column: x => x.entries_id,
+ principalTable: "time_entries",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "fk_time_entry_time_label_time_labels_labels_id",
+ column: x => x.labels_id,
+ principalTable: "time_labels",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_time_entry_time_label_labels_id",
+ table: "time_entry_time_label",
+ column: "labels_id");
+ }
+ }
+}
diff --git a/server/src/Migrations/AppDbContextModelSnapshot.cs b/server/src/Migrations/AppDbContextModelSnapshot.cs
new file mode 100644
index 0000000..e040cfb
--- /dev/null
+++ b/server/src/Migrations/AppDbContextModelSnapshot.cs
@@ -0,0 +1,684 @@
+// <auto-generated />
+using System;
+using IOL.GreatOffice.Api.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace IOL.GreatOffice.Api.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ partial class AppDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "6.0.5")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ApiAccessToken", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<bool>("AllowCreate")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_create");
+
+ b.Property<bool>("AllowDelete")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_delete");
+
+ b.Property<bool>("AllowRead")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_read");
+
+ b.Property<bool>("AllowUpdate")
+ .HasColumnType("boolean")
+ .HasColumnName("allow_update");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime>("ExpiryDate")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expiry_date");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_api_access_tokens");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_api_access_tokens_user_id");
+
+ b.ToTable("api_access_tokens", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_forgot_password_requests");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_forgot_password_requests_user_id");
+
+ b.ToTable("forgot_password_requests", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.GithubUserMapping", b =>
+ {
+ b.Property<string>("GithubId")
+ .HasColumnType("text")
+ .HasColumnName("github_id");
+
+ b.Property<string>("Email")
+ .HasColumnType("text")
+ .HasColumnName("email");
+
+ b.Property<string>("RefreshToken")
+ .HasColumnType("text")
+ .HasColumnName("refresh_token");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("GithubId")
+ .HasName("pk_github_user_mappings");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_github_user_mappings_user_id");
+
+ b.ToTable("github_user_mappings", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.Tenant", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("ContactEmail")
+ .HasColumnType("text")
+ .HasColumnName("contact_email");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("CreatedById")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_id");
+
+ b.Property<Guid>("DeletedById")
+ .HasColumnType("uuid")
+ .HasColumnName("deleted_by_id");
+
+ b.Property<string>("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property<Guid>("MasterUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("master_user_id");
+
+ b.Property<string>("MasterUserPassword")
+ .HasColumnType("text")
+ .HasColumnName("master_user_password");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid>("ModifiedById")
+ .HasColumnType("uuid")
+ .HasColumnName("modified_by_id");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property<Guid?>("TenantId1")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id1");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_tenants");
+
+ b.HasIndex("CreatedById")
+ .HasDatabaseName("ix_tenants_created_by_id");
+
+ b.HasIndex("DeletedById")
+ .HasDatabaseName("ix_tenants_deleted_by_id");
+
+ b.HasIndex("ModifiedById")
+ .HasDatabaseName("ix_tenants_modified_by_id");
+
+ b.HasIndex("TenantId1")
+ .HasDatabaseName("ix_tenants_tenant_id1");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_tenants_user_id");
+
+ b.ToTable("tenants", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeCategory", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("CreatedById")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_id");
+
+ b.Property<Guid>("DeletedById")
+ .HasColumnType("uuid")
+ .HasColumnName("deleted_by_id");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid>("ModifiedById")
+ .HasColumnType("uuid")
+ .HasColumnName("modified_by_id");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_categories");
+
+ b.HasIndex("CreatedById")
+ .HasDatabaseName("ix_time_categories_created_by_id");
+
+ b.HasIndex("DeletedById")
+ .HasDatabaseName("ix_time_categories_deleted_by_id");
+
+ b.HasIndex("ModifiedById")
+ .HasDatabaseName("ix_time_categories_modified_by_id");
+
+ b.HasIndex("TenantId")
+ .HasDatabaseName("ix_time_categories_tenant_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_categories_user_id");
+
+ b.ToTable("time_categories", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<Guid?>("CategoryId")
+ .HasColumnType("uuid")
+ .HasColumnName("category_id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("CreatedById")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_id");
+
+ b.Property<Guid>("DeletedById")
+ .HasColumnType("uuid")
+ .HasColumnName("deleted_by_id");
+
+ b.Property<string>("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid>("ModifiedById")
+ .HasColumnType("uuid")
+ .HasColumnName("modified_by_id");
+
+ b.Property<DateTime>("Start")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("start");
+
+ b.Property<DateTime>("Stop")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("stop");
+
+ b.Property<Guid>("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_entries");
+
+ b.HasIndex("CategoryId")
+ .HasDatabaseName("ix_time_entries_category_id");
+
+ b.HasIndex("CreatedById")
+ .HasDatabaseName("ix_time_entries_created_by_id");
+
+ b.HasIndex("DeletedById")
+ .HasDatabaseName("ix_time_entries_deleted_by_id");
+
+ b.HasIndex("ModifiedById")
+ .HasDatabaseName("ix_time_entries_modified_by_id");
+
+ b.HasIndex("TenantId")
+ .HasDatabaseName("ix_time_entries_tenant_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_entries_user_id");
+
+ b.ToTable("time_entries", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeLabel", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("Color")
+ .HasColumnType("text")
+ .HasColumnName("color");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<Guid>("CreatedById")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_id");
+
+ b.Property<Guid>("DeletedById")
+ .HasColumnType("uuid")
+ .HasColumnName("deleted_by_id");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<Guid>("ModifiedById")
+ .HasColumnType("uuid")
+ .HasColumnName("modified_by_id");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<Guid>("TenantId")
+ .HasColumnType("uuid")
+ .HasColumnName("tenant_id");
+
+ b.Property<Guid?>("TimeEntryId")
+ .HasColumnType("uuid")
+ .HasColumnName("time_entry_id");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_time_labels");
+
+ b.HasIndex("CreatedById")
+ .HasDatabaseName("ix_time_labels_created_by_id");
+
+ b.HasIndex("DeletedById")
+ .HasDatabaseName("ix_time_labels_deleted_by_id");
+
+ b.HasIndex("ModifiedById")
+ .HasDatabaseName("ix_time_labels_modified_by_id");
+
+ b.HasIndex("TenantId")
+ .HasDatabaseName("ix_time_labels_tenant_id");
+
+ b.HasIndex("TimeEntryId")
+ .HasDatabaseName("ix_time_labels_time_entry_id");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_time_labels_user_id");
+
+ b.ToTable("time_labels", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<DateTime?>("ModifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("modified_at");
+
+ b.Property<string>("Password")
+ .HasColumnType("text")
+ .HasColumnName("password");
+
+ b.Property<string>("Username")
+ .HasColumnType("text")
+ .HasColumnName("username");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ApiAccessToken", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_api_access_tokens_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.ForgotPasswordRequest", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_forgot_password_requests_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.GithubUserMapping", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .HasConstraintName("fk_github_user_mappings_users_user_id");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.Tenant", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy")
+ .WithMany()
+ .HasForeignKey("CreatedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenants_users_created_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy")
+ .WithMany()
+ .HasForeignKey("DeletedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenants_users_deleted_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy")
+ .WithMany()
+ .HasForeignKey("ModifiedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenants_users_modified_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant")
+ .WithMany()
+ .HasForeignKey("TenantId1")
+ .HasConstraintName("fk_tenants_tenants_tenant_id1");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_tenants_users_user_id");
+
+ b.Navigation("CreatedBy");
+
+ b.Navigation("DeletedBy");
+
+ b.Navigation("ModifiedBy");
+
+ b.Navigation("Tenant");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeCategory", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy")
+ .WithMany()
+ .HasForeignKey("CreatedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_users_created_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy")
+ .WithMany()
+ .HasForeignKey("DeletedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_users_deleted_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy")
+ .WithMany()
+ .HasForeignKey("ModifiedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_users_modified_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant")
+ .WithMany()
+ .HasForeignKey("TenantId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_tenants_tenant_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_categories_users_user_id");
+
+ b.Navigation("CreatedBy");
+
+ b.Navigation("DeletedBy");
+
+ b.Navigation("ModifiedBy");
+
+ b.Navigation("Tenant");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeCategory", "Category")
+ .WithMany()
+ .HasForeignKey("CategoryId")
+ .HasConstraintName("fk_time_entries_time_categories_category_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy")
+ .WithMany()
+ .HasForeignKey("CreatedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_users_created_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy")
+ .WithMany()
+ .HasForeignKey("DeletedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_users_deleted_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy")
+ .WithMany()
+ .HasForeignKey("ModifiedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_users_modified_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant")
+ .WithMany()
+ .HasForeignKey("TenantId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_tenants_tenant_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_entries_users_user_id");
+
+ b.Navigation("Category");
+
+ b.Navigation("CreatedBy");
+
+ b.Navigation("DeletedBy");
+
+ b.Navigation("ModifiedBy");
+
+ b.Navigation("Tenant");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeLabel", b =>
+ {
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "CreatedBy")
+ .WithMany()
+ .HasForeignKey("CreatedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_users_created_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "DeletedBy")
+ .WithMany()
+ .HasForeignKey("DeletedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_users_deleted_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "ModifiedBy")
+ .WithMany()
+ .HasForeignKey("ModifiedById")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_users_modified_by_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.Tenant", "Tenant")
+ .WithMany()
+ .HasForeignKey("TenantId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_tenants_tenant_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.TimeEntry", null)
+ .WithMany("Labels")
+ .HasForeignKey("TimeEntryId")
+ .HasConstraintName("fk_time_labels_time_entries_time_entry_id");
+
+ b.HasOne("IOL.GreatOffice.Api.Data.Database.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_time_labels_users_user_id");
+
+ b.Navigation("CreatedBy");
+
+ b.Navigation("DeletedBy");
+
+ b.Navigation("ModifiedBy");
+
+ b.Navigation("Tenant");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("IOL.GreatOffice.Api.Data.Database.TimeEntry", b =>
+ {
+ b.Navigation("Labels");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/server/src/Program.cs b/server/src/Program.cs
new file mode 100644
index 0000000..b449117
--- /dev/null
+++ b/server/src/Program.cs
@@ -0,0 +1,261 @@
+global using System;
+global using System.Linq;
+global using System.IO;
+global using System.Net.Mail;
+global using System.Net;
+global using System.Threading;
+global using System.Threading.Tasks;
+global using System.Collections.Generic;
+global using System.Runtime.Serialization;
+global using System.ComponentModel.DataAnnotations.Schema;
+global using System.Security.Claims;
+global using System.Text.Json;
+global using System.Text.Json.Serialization;
+global using IOL.GreatOffice.Api.Data.Database;
+global using IOL.GreatOffice.Api.Data.Exceptions;
+global using IOL.GreatOffice.Api.Data.Dtos;
+global using IOL.GreatOffice.Api.Data.Enums;
+global using IOL.GreatOffice.Api.Data.Models;
+global using IOL.GreatOffice.Api.Data.Results;
+global using IOL.Helpers;
+global using Microsoft.OpenApi.Models;
+global using Microsoft.AspNetCore.Authentication.Cookies;
+global using Microsoft.AspNetCore.Builder;
+global using Microsoft.AspNetCore.DataProtection;
+global using Microsoft.AspNetCore.Hosting;
+global using Microsoft.AspNetCore.Authorization;
+global using Microsoft.AspNetCore.Http;
+global using Microsoft.AspNetCore.Authentication;
+global using Microsoft.AspNetCore.Mvc;
+global using Microsoft.EntityFrameworkCore;
+global using Microsoft.Extensions.Configuration;
+global using Microsoft.Extensions.DependencyInjection;
+global using Microsoft.Extensions.Hosting;
+global using Microsoft.Extensions.Logging;
+global using Serilog;
+global using IOL.GreatOffice.Api.Data;
+global using IOL.GreatOffice.Api.Data.Static;
+global using IOL.GreatOffice.Api.Services;
+global using IOL.GreatOffice.Api.Utilities;
+using System.Diagnostics;
+using System.Reflection;
+using IOL.GreatOffice.Api.Endpoints.V1;
+using IOL.GreatOffice.Api.Jobs;
+using Microsoft.AspNetCore.HttpOverrides;
+using Microsoft.AspNetCore.Mvc.Versioning;
+using Quartz;
+
+namespace IOL.GreatOffice.Api;
+
+public static class Program
+{
+ public static WebApplicationBuilder CreateAppBuilder(string[] args) {
+ var builder = WebApplication.CreateBuilder(args);
+
+ var seqUrl = builder.Configuration.GetValue<string>(AppEnvironmentVariables.SEQ_API_URL);
+ var seqKey = builder.Configuration.GetValue<string>(AppEnvironmentVariables.SEQ_API_KEY);
+ var logger = new LoggerConfiguration()
+ .Enrich.FromLogContext()
+ .ReadFrom.Configuration(builder.Configuration)
+ .WriteTo.Console();
+
+ if (!builder.Environment.IsDevelopment() && seqUrl.HasValue() && seqKey.HasValue()) {
+ logger.WriteTo.Seq(seqUrl, apiKey: seqKey);
+ }
+
+ Log.Logger = logger.CreateLogger();
+ Log.Information("Starting web host"
+ + JsonSerializer.Serialize(new {
+ DateTime.UtcNow,
+ PID = Environment.ProcessId,
+ DB_HOST = builder.Configuration.GetValue<string>(AppEnvironmentVariables.DB_HOST),
+ DB_PORT = builder.Configuration.GetValue<string>(AppEnvironmentVariables.DB_PORT),
+ DB_USER = builder.Configuration.GetValue<string>(AppEnvironmentVariables.DB_USER),
+ DB_NAME = builder.Configuration.GetValue<string>(AppEnvironmentVariables.DB_NAME),
+ DB_PASS = builder.Configuration.GetValue<string>(AppEnvironmentVariables.DB_PASSWORD).Obfuscate() ?? "!!!Empty!!!",
+ QUARTZ_DB_HOST = builder.Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_HOST),
+ QUARTZ_DB_PORT = builder.Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_PORT),
+ QUARTZ_DB_USER = builder.Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_USER),
+ QUARTZ_DB_NAME = builder.Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_NAME),
+ QUARTZ_DB_PASS = builder.Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_PASSWORD).Obfuscate()
+ ?? "!!!Empty!!!",
+ },
+ new JsonSerializerOptions() {
+ WriteIndented = true
+ }));
+
+ builder.Host.UseSerilog(Log.Logger);
+ builder.WebHost.ConfigureKestrel(kestrel => {
+ kestrel.AddServerHeader = false;
+ });
+
+ if (builder.Environment.IsDevelopment()) {
+ builder.Services.AddCors();
+ }
+
+ if (builder.Environment.IsProduction()) {
+ builder.Services.Configure<ForwardedHeadersOptions>(options => {
+ options.ForwardedHeaders = ForwardedHeaders.XForwardedProto;
+ });
+ }
+
+ builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(AppPaths.DataProtectionKeys.HostPath));
+
+ builder.Services.Configure(JsonSettings.Default);
+ builder.Services.AddQuartz(options => {
+ options.UsePersistentStore(o => {
+ o.UsePostgres(builder.Configuration.GetQuartzDatabaseConnectionString());
+ o.UseSerializer<QuartzJsonSerializer>();
+ });
+ options.UseMicrosoftDependencyInjectionJobFactory();
+ options.RegisterJobs();
+ });
+ builder.Services.AddQuartzHostedService(options => {
+ options.WaitForJobsToComplete = true;
+ });
+
+ builder.Services.AddAuthentication(options => {
+ options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
+ options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
+ })
+ .AddCookie(options => {
+ options.Cookie.Name = "go_session";
+ options.Cookie.SameSite = SameSiteMode.Strict;
+ options.Cookie.HttpOnly = true;
+ options.Cookie.SecurePolicy = builder.Environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always;
+ options.Cookie.IsEssential = true;
+ options.SlidingExpiration = true;
+ options.Events.OnRedirectToAccessDenied =
+ options.Events.OnRedirectToLogin = c => {
+ c.Response.StatusCode = StatusCodes.Status401Unauthorized;
+ return Task.FromResult<object>(null);
+ };
+ })
+ .AddGitHub(options => {
+ options.ClientSecret = builder.Configuration.GetValue<string>(AppEnvironmentVariables.GITHUB_CLIENT_SECRET);
+ options.ClientId = builder.Configuration.GetValue<string>(AppEnvironmentVariables.GITHUB_CLIENT_ID);
+ options.SaveTokens = true;
+ options.CorrelationCookie.Name = "gh_correlation";
+ options.CorrelationCookie.SameSite = SameSiteMode.Lax;
+ options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.Always;
+ options.CorrelationCookie.HttpOnly = true;
+ options.Events.OnCreatingTicket = context => GithubAuthenticationHelpers.HandleGithubTicketCreation(context, builder.Configuration);
+ })
+ .AddScheme<AuthenticationSchemeOptions, BasicAuthenticationHandler>(AppConstants.BASIC_AUTH_SCHEME, default);
+
+
+ builder.Services.AddDbContext<AppDbContext>(options => {
+ options.UseNpgsql(builder.Configuration.GetAppDatabaseConnectionString(),
+ npgsqlDbContextOptionsBuilder => {
+ npgsqlDbContextOptionsBuilder.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
+ npgsqlDbContextOptionsBuilder.EnableRetryOnFailure(5, TimeSpan.FromSeconds(10), default);
+ })
+ .UseSnakeCaseNamingConvention();
+ if (builder.Environment.IsDevelopment()) {
+ options.EnableSensitiveDataLogging();
+ }
+ });
+
+ builder.Services.AddApiVersioning(options => {
+ options.ApiVersionReader = new UrlSegmentApiVersionReader();
+ options.ReportApiVersions = true;
+ options.AssumeDefaultVersionWhenUnspecified = false;
+ });
+ builder.Services.AddVersionedApiExplorer(options => {
+ options.SubstituteApiVersionInUrl = true;
+ });
+ builder.Services.AddSwaggerGen(options => {
+ options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, Assembly.GetExecutingAssembly().GetName().Name + ".xml"));
+ options.UseApiEndpoints();
+ options.OperationFilter<SwaggerDefaultValues>();
+ options.SwaggerDoc(ApiSpecV1.Document.VersionName, ApiSpecV1.Document.OpenApiInfo);
+ options.AddSecurityDefinition("Basic",
+ new OpenApiSecurityScheme {
+ Name = "Authorization",
+ Type = SecuritySchemeType.ApiKey,
+ Scheme = "Basic",
+ BearerFormat = "Basic",
+ In = ParameterLocation.Header,
+ Description =
+ "Enter your token in the text input below.\r\n\r\nExample: \"Basic 12345abcdef\"",
+ });
+
+ options.AddSecurityRequirement(new OpenApiSecurityRequirement {
+ {
+ new OpenApiSecurityScheme {
+ Reference = new OpenApiReference {
+ Type = ReferenceType.SecurityScheme,
+ Id = "Basic"
+ }
+ },
+ Array.Empty<string>()
+ }
+ });
+ });
+
+ builder.Services.AddScoped<MailService>();
+ builder.Services.AddScoped<ForgotPasswordService>();
+ builder.Services.AddScoped<UserService>();
+ builder.Services.AddLogging();
+ builder.Services.AddHttpClient();
+ builder.Services
+ .AddControllers()
+ .AddJsonOptions(JsonSettings.Default);
+
+
+ return builder;
+ }
+
+ public static WebApplication CreateWebApplication(WebApplicationBuilder builder) {
+ var app = builder.Build();
+
+ if (app.Environment.IsDevelopment()) {
+ app.UseDeveloperExceptionPage();
+ app.UseCors(cors => {
+ cors.AllowAnyMethod();
+ cors.AllowAnyHeader();
+ cors.WithOrigins("http://localhost:3000", "http://localhost:3002", "http://localhost:3001");
+ cors.AllowCredentials();
+ });
+ }
+
+ if (app.Environment.IsProduction()) {
+ app.UseForwardedHeaders();
+ }
+
+ app.UseDefaultFiles()
+ .UseStaticFiles()
+ .UseRouting()
+ .UseSerilogRequestLogging()
+ .UseStatusCodePages()
+ .UseAuthentication()
+ .UseAuthorization()
+ .UseSwagger()
+ .UseSwaggerUI(options => {
+ options.SwaggerEndpoint(ApiSpecV1.Document.SwaggerPath, ApiSpecV1.Document.VersionName);
+ options.DocumentTitle = AppConstants.API_NAME;
+ })
+ .UseEndpoints(endpoints => {
+ endpoints.MapControllers();
+ });
+ return app;
+ }
+
+ public static int Main(string[] args) {
+ try {
+ CreateWebApplication(CreateAppBuilder(args)).Run();
+ return 0;
+ } catch (Exception ex) {
+ // This is subject to change in future .net versions, see https://github.com/dotnet/runtime/issues/60600.
+ if (ex.GetType().Name.Equals("StopTheHostException", StringComparison.Ordinal)) {
+ throw;
+ }
+
+ Log.Fatal(ex, "Unhandled exception");
+ return 1;
+ } finally {
+ Log.Information("Shut down complete, flusing logs...");
+ Log.CloseAndFlush();
+ }
+ }
+}
diff --git a/server/src/Properties/launchSettings.json b/server/src/Properties/launchSettings.json
new file mode 100644
index 0000000..6403d71
--- /dev/null
+++ b/server/src/Properties/launchSettings.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "IOL.GreatOffice": {
+ "commandName": "Project",
+ "dotnetRunMessages": "true",
+ "launchBrowser": false,
+ "applicationUrl": "https://0.0.0.0:5001;http://0.0.0.0:5000",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/server/src/Services/ForgotPasswordService.cs b/server/src/Services/ForgotPasswordService.cs
new file mode 100644
index 0000000..de38b29
--- /dev/null
+++ b/server/src/Services/ForgotPasswordService.cs
@@ -0,0 +1,115 @@
+namespace IOL.GreatOffice.Api.Services;
+
+public class ForgotPasswordService
+{
+ private readonly AppDbContext _context;
+ private readonly MailService _mailService;
+ private readonly IConfiguration _configuration;
+ private readonly ILogger<ForgotPasswordService> _logger;
+
+
+ public ForgotPasswordService(
+ AppDbContext context,
+ IConfiguration configuration,
+ ILogger<ForgotPasswordService> logger,
+ MailService mailService
+ ) {
+ _context = context;
+ _configuration = configuration;
+ _logger = logger;
+ _mailService = mailService;
+ }
+
+ public async Task<ForgotPasswordRequest> GetRequestAsync(Guid id, CancellationToken cancellationToken = default) {
+ var request = await _context.ForgotPasswordRequests
+ .Include(c => c.User)
+ .SingleOrDefaultAsync(c => c.Id == id, cancellationToken);
+ if (request == default) {
+ return default;
+ }
+
+ _logger.LogInformation($"Found forgot password request for user: {request.User.Username}, expires at {request.ExpirationDate} (in {request.ExpirationDate.Subtract(DateTime.UtcNow).Minutes} minutes).");
+ return request;
+ }
+
+ public async Task<bool> FullFillRequestAsync(Guid id, string newPassword, CancellationToken cancellationToken = default) {
+ var request = await GetRequestAsync(id, cancellationToken);
+ if (request == default) {
+ throw new ForgotPasswordRequestNotFoundException("Request with id: " + id + " was not found");
+ }
+
+ var user = _context.Users.SingleOrDefault(c => c.Id == request.User.Id);
+ if (user == default) {
+ throw new UserNotFoundException("User with id: " + request.User.Id + " was not found");
+ }
+
+ user.HashAndSetPassword(newPassword);
+ _context.Users.Update(user);
+ await _context.SaveChangesAsync(cancellationToken);
+ _logger.LogInformation($"Fullfilled forgot password request for user: {request.User.Username}");
+ await DeleteRequestsForUserAsync(user.Id, cancellationToken);
+ return true;
+ }
+
+
+ public async Task AddRequestAsync(User user, TimeZoneInfo requestTz, CancellationToken cancellationToken = default) {
+ await DeleteRequestsForUserAsync(user.Id, cancellationToken);
+ var request = new ForgotPasswordRequest(user);
+ _context.ForgotPasswordRequests.Add(request);
+ await _context.SaveChangesAsync(cancellationToken);
+ var accountsUrl = _configuration.GetValue<string>(AppEnvironmentVariables.ACCOUNTS_URL);
+ var emailFromAddress = _configuration.GetValue<string>(AppEnvironmentVariables.EMAIL_FROM_ADDRESS);
+ var emailFromDisplayName = _configuration.GetValue<string>(AppEnvironmentVariables.EMAIL_FROM_DISPLAY_NAME);
+ var zonedExpirationDate = TimeZoneInfo.ConvertTimeBySystemTimeZoneId(request.ExpirationDate, requestTz.Id);
+ var message = new MailMessage {
+ From = new MailAddress(emailFromAddress, emailFromDisplayName),
+ To = {
+ new MailAddress(user.Username)
+ },
+ Subject = "Time Tracker - Forgot password request",
+ Body = @$"
+Hi {user.Username}
+
+Go to the following link to set a new password.
+
+{accountsUrl}/#/reset-password?id={request.Id}
+
+The link expires at {zonedExpirationDate:yyyy-MM-dd hh:mm}.
+If you did not request a password reset, no action is required.
+"
+ };
+
+#pragma warning disable 4014
+ Task.Run(() => {
+#pragma warning restore 4014
+ _mailService.SendMail(message);
+ _logger.LogInformation($"Added forgot password request for user: {request.User.Username}, expires in {request.ExpirationDate.Subtract(DateTime.UtcNow)}.");
+ },
+ cancellationToken);
+ }
+
+ public async Task DeleteRequestsForUserAsync(Guid userId, CancellationToken cancellationToken = default) {
+ var requestsToRemove = _context.ForgotPasswordRequests.Where(c => c.UserId == userId).ToList();
+ if (!requestsToRemove.Any()) return;
+ _context.ForgotPasswordRequests.RemoveRange(requestsToRemove);
+ await _context.SaveChangesAsync(cancellationToken);
+ _logger.LogInformation($"Deleted {requestsToRemove.Count} forgot password requests for user: {userId}.");
+ }
+
+
+ public async Task DeleteStaleRequestsAsync(CancellationToken cancellationToken = default) {
+ var deleteCount = 0;
+ foreach (var request in _context.ForgotPasswordRequests) {
+ if (!request.IsExpired) {
+ continue;
+ }
+
+ _context.ForgotPasswordRequests.Remove(request);
+ deleteCount++;
+ _logger.LogInformation($"Marking forgot password request with id: {request.Id} for deletion, expiration date was {request.ExpirationDate}.");
+ }
+
+ await _context.SaveChangesAsync(cancellationToken);
+ _logger.LogInformation($"Deleted {deleteCount} stale forgot password requests.");
+ }
+}
diff --git a/server/src/Services/MailService.cs b/server/src/Services/MailService.cs
new file mode 100644
index 0000000..b271de4
--- /dev/null
+++ b/server/src/Services/MailService.cs
@@ -0,0 +1,52 @@
+namespace IOL.GreatOffice.Api.Services;
+
+public class MailService
+{
+ private readonly ILogger<MailService> _logger;
+ private static string _emailHost;
+ private static int _emailPort;
+ private static string _emailUser;
+ private static string _emailPassword;
+
+ /// <summary>
+ /// Provides methods to send email.
+ /// </summary>
+ /// <param name="configuration"></param>
+ /// <param name="logger"></param>
+ public MailService(IConfiguration configuration, ILogger<MailService> logger) {
+ _logger = logger;
+ _emailHost = configuration.GetValue<string>(AppEnvironmentVariables.SMTP_HOST);
+ _emailPort = configuration.GetValue<int>(AppEnvironmentVariables.SMTP_PORT);
+ _emailUser = configuration.GetValue<string>(AppEnvironmentVariables.SMTP_USER);
+ _emailPassword = configuration.GetValue<string>(AppEnvironmentVariables.SMTP_PASSWORD);
+ }
+
+ /// <summary>
+ /// Send an email.
+ /// </summary>
+ /// <param name="message"></param>
+ public void SendMail(MailMessage message) {
+ using var smtpClient = new SmtpClient {
+ Host = _emailHost,
+ EnableSsl = _emailPort == 587,
+ Port = _emailPort,
+ Credentials = new NetworkCredential {
+ UserName = _emailUser,
+ Password = _emailPassword,
+ }
+ };
+ var configurationString = JsonSerializer.Serialize(new {
+ Host = smtpClient.Host,
+ EnableSsl = smtpClient.EnableSsl,
+ Port = smtpClient.Port,
+ UserName = _emailUser.HasValue() ? "**REDACTED**" : "**MISSING**",
+ Password = _emailPassword.HasValue() ? "**REDACTED**" : "**MISSING**",
+ },
+ new JsonSerializerOptions {
+ WriteIndented = true
+ });
+ _logger.LogDebug("SmtpClient was instansiated with the following configuration\n" + configurationString);
+
+ smtpClient.Send(message);
+ }
+}
diff --git a/server/src/Services/UserService.cs b/server/src/Services/UserService.cs
new file mode 100644
index 0000000..9b531de
--- /dev/null
+++ b/server/src/Services/UserService.cs
@@ -0,0 +1,50 @@
+namespace IOL.GreatOffice.Api.Services;
+
+public class UserService
+{
+ private readonly ForgotPasswordService _forgotPasswordService;
+
+ /// <summary>
+ /// Provides methods to perform common operations on user data.
+ /// </summary>
+ /// <param name="forgotPasswordService"></param>
+ public UserService(ForgotPasswordService forgotPasswordService) {
+ _forgotPasswordService = forgotPasswordService;
+ }
+
+ /// <summary>
+ /// Log in a user.
+ /// </summary>
+ /// <param name="httpContext"></param>
+ /// <param name="user"></param>
+ /// <param name="persist"></param>
+ public async Task LogInUser(HttpContext httpContext, User user, bool persist = false) {
+ var claims = new List<Claim> {
+ new(AppClaims.USER_ID, user.Id.ToString()),
+ new(AppClaims.NAME, user.Username),
+ };
+
+ var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
+ var principal = new ClaimsPrincipal(identity);
+ var authenticationProperties = new AuthenticationProperties {
+ AllowRefresh = true,
+ IssuedUtc = DateTimeOffset.UtcNow,
+ };
+
+ if (persist) {
+ authenticationProperties.ExpiresUtc = DateTimeOffset.UtcNow.AddMonths(6);
+ authenticationProperties.IsPersistent = true;
+ }
+
+ await httpContext.SignInAsync(principal, authenticationProperties);
+ await _forgotPasswordService.DeleteRequestsForUserAsync(user.Id);
+ }
+
+ /// <summary>
+ /// Log out a user.
+ /// </summary>
+ /// <param name="httpContext"></param>
+ public async Task LogOutUser(HttpContext httpContext) {
+ await httpContext.SignOutAsync();
+ }
+}
diff --git a/server/src/Utilities/BasicAuthenticationAttribute.cs b/server/src/Utilities/BasicAuthenticationAttribute.cs
new file mode 100644
index 0000000..0bfd007
--- /dev/null
+++ b/server/src/Utilities/BasicAuthenticationAttribute.cs
@@ -0,0 +1,39 @@
+using System.Net.Http.Headers;
+using Microsoft.AspNetCore.Mvc.Filters;
+
+namespace IOL.GreatOffice.Api.Utilities;
+
+public class BasicAuthenticationAttribute : TypeFilterAttribute
+{
+ public BasicAuthenticationAttribute(string claimPermission) : base(typeof(BasicAuthenticationFilter)) {
+ Arguments = new object[] {
+ new Claim(claimPermission, "True")
+ };
+ }
+}
+
+public class BasicAuthenticationFilter : IAuthorizationFilter
+{
+ private readonly Claim _claim;
+
+ public BasicAuthenticationFilter(Claim claim) {
+ _claim = claim;
+ }
+
+ public void OnAuthorization(AuthorizationFilterContext context) {
+ if (!context.HttpContext.Request.Headers.ContainsKey("Authorization")) return;
+ try {
+ var authHeader = AuthenticationHeaderValue.Parse(context.HttpContext.Request.Headers["Authorization"]);
+ if (authHeader.Parameter is null) {
+ context.Result = new ForbidResult(AppConstants.BASIC_AUTH_SCHEME);
+ }
+
+ var hasClaim = context.HttpContext.User.Claims.Any(c => c.Type == _claim.Type && c.Value == _claim.Value);
+ if (!hasClaim) {
+ context.Result = new ForbidResult(AppConstants.BASIC_AUTH_SCHEME);
+ }
+ } catch {
+ // ignore
+ }
+ }
+}
diff --git a/server/src/Utilities/BasicAuthenticationHandler.cs b/server/src/Utilities/BasicAuthenticationHandler.cs
new file mode 100644
index 0000000..2b9d9ef
--- /dev/null
+++ b/server/src/Utilities/BasicAuthenticationHandler.cs
@@ -0,0 +1,79 @@
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Encodings.Web;
+using Microsoft.Extensions.Options;
+
+namespace IOL.GreatOffice.Api.Utilities;
+
+public class BasicAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
+{
+ private readonly AppDbContext _context;
+ private readonly IConfiguration _configuration;
+ private readonly ILogger<BasicAuthenticationHandler> _logger;
+
+ public BasicAuthenticationHandler(
+ IOptionsMonitor<AuthenticationSchemeOptions> options,
+ ILoggerFactory logger,
+ UrlEncoder encoder,
+ ISystemClock clock,
+ AppDbContext context,
+ IConfiguration configuration
+ ) :
+ base(options, logger, encoder, clock) {
+ _context = context;
+ _configuration = configuration;
+ _logger = logger.CreateLogger<BasicAuthenticationHandler>();
+ }
+
+ protected override Task<AuthenticateResult> HandleAuthenticateAsync() {
+ var endpoint = Context.GetEndpoint();
+ if (endpoint?.Metadata.GetMetadata<IAllowAnonymous>() != null)
+ return Task.FromResult(AuthenticateResult.NoResult());
+
+ if (!Request.Headers.ContainsKey("Authorization"))
+ return Task.FromResult(AuthenticateResult.Fail("Missing Authorization Header"));
+
+ try {
+ var token_entropy = _configuration.GetValue<string>("TOKEN_ENTROPY");
+ if (token_entropy.IsNullOrWhiteSpace()) {
+ _logger.LogWarning("No token entropy is available in env:TOKEN_ENTROPY, Basic auth is disabled");
+ return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header"));
+ }
+
+ var authHeader = AuthenticationHeaderValue.Parse(Request.Headers["Authorization"]);
+ if (authHeader.Parameter == null) return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header"));
+ var credentialBytes = Convert.FromBase64String(authHeader.Parameter);
+ var decrypted_string = Encoding.UTF8.GetString(credentialBytes).DecryptWithAes(token_entropy);
+ var token_is_guid = Guid.TryParse(decrypted_string, out var token_id);
+
+ if (!token_is_guid) {
+ return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header"));
+ }
+
+ var token = _context.AccessTokens.Include(c => c.User).SingleOrDefault(c => c.Id == token_id);
+ if (token == default) {
+ return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header: Not Found"));
+ }
+
+ if (token.HasExpired) {
+ return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header: Expired"));
+ }
+
+ var permissions = new List<Claim>() {
+ new(AppConstants.TOKEN_ALLOW_READ, token.AllowRead.ToString()),
+ new(AppConstants.TOKEN_ALLOW_UPDATE, token.AllowUpdate.ToString()),
+ new(AppConstants.TOKEN_ALLOW_CREATE, token.AllowCreate.ToString()),
+ new(AppConstants.TOKEN_ALLOW_DELETE, token.AllowDelete.ToString()),
+ };
+ var claims = token.User.DefaultClaims().Concat(permissions);
+ var identity = new ClaimsIdentity(claims, AppConstants.BASIC_AUTH_SCHEME);
+ var principal = new ClaimsPrincipal(identity);
+ var ticket = new AuthenticationTicket(principal, AppConstants.BASIC_AUTH_SCHEME);
+
+ return Task.FromResult(AuthenticateResult.Success(ticket));
+ } catch (Exception e) {
+ _logger.LogError(e, $"An exception occured when challenging {AppConstants.BASIC_AUTH_SCHEME}");
+ return Task.FromResult(AuthenticateResult.Fail("Invalid Authorization Header"));
+ }
+ }
+}
diff --git a/server/src/Utilities/ConfigurationExtensions.cs b/server/src/Utilities/ConfigurationExtensions.cs
new file mode 100644
index 0000000..772059a
--- /dev/null
+++ b/server/src/Utilities/ConfigurationExtensions.cs
@@ -0,0 +1,37 @@
+namespace IOL.GreatOffice.Api.Utilities;
+
+public static class ConfigurationExtensions
+{
+ public static string GetAppDatabaseConnectionString(this IConfiguration configuration) {
+ var host = configuration.GetValue<string>(AppEnvironmentVariables.DB_HOST);
+ var port = configuration.GetValue<string>(AppEnvironmentVariables.DB_PORT);
+ var database = configuration.GetValue<string>(AppEnvironmentVariables.DB_NAME);
+ var user = configuration.GetValue<string>(AppEnvironmentVariables.DB_USER);
+ var password = configuration.GetValue<string>(AppEnvironmentVariables.DB_PASSWORD);
+
+ if (configuration.GetValue<string>("ASPNETCORE_ENVIRONMENT") == "Development") {
+ return $"Server={host};Port={port};Database={database};User Id={user};Password={password};Include Error Detail=true";
+ }
+
+ return $"Server={host};Port={port};Database={database};User Id={user};Password={password}";
+ }
+
+ public static string GetQuartzDatabaseConnectionString(this IConfiguration Configuration) {
+ var host = Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_HOST);
+ var port = Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_PORT);
+ var database = Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_NAME);
+ var user = Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_USER);
+ var password = Configuration.GetValue<string>(AppEnvironmentVariables.QUARTZ_DB_PASSWORD);
+ return $"Server={host};Port={port};Database={database};User Id={user};Password={password}";
+ }
+
+ public static string GetVersion(this IConfiguration configuration) {
+ var versionFilePath = Path.Combine(AppPaths.AppData.HostPath, "version.txt");
+ if (File.Exists(versionFilePath)) {
+ var versionText = File.ReadAllText(versionFilePath);
+ return versionText + "-" + configuration.GetValue<string>("ASPNETCORE_ENVIRONMENT");
+ }
+
+ return "unknown-" + configuration.GetValue<string>("ASPNETCORE_ENVIRONMENT");
+ }
+}
diff --git a/server/src/Utilities/GithubAuthenticationHelpers.cs b/server/src/Utilities/GithubAuthenticationHelpers.cs
new file mode 100644
index 0000000..cf0cabb
--- /dev/null
+++ b/server/src/Utilities/GithubAuthenticationHelpers.cs
@@ -0,0 +1,84 @@
+using Microsoft.AspNetCore.Authentication.OAuth;
+using Npgsql;
+
+namespace IOL.GreatOffice.Api.Utilities;
+
+public static class GithubAuthenticationHelpers
+{
+ public static async Task HandleGithubTicketCreation(OAuthCreatingTicketContext context, IConfiguration configuration) {
+ var githubId = context.Identity?.FindFirst(p => p.Type == ClaimTypes.NameIdentifier)?.Value;
+ var githubUsername = context.Identity?.FindFirst(p => p.Type == ClaimTypes.Name)?.Value;
+ var githubEmail = context.Identity?.FindFirst(p => p.Type == ClaimTypes.Email)?.Value;
+
+ if (githubId.IsNullOrWhiteSpace() || githubUsername.IsNullOrWhiteSpace() || context.Identity == default) {
+ return;
+ }
+
+ var claims = context.Identity.Claims.ToList();
+ foreach (var claim in claims) {
+ context.Identity.RemoveClaim(claim);
+ }
+
+ var connstring = configuration.GetAppDatabaseConnectionString();
+ var connection = new NpgsqlConnection(connstring);
+
+ Log.Information($"Getting user mappings for github user: {githubId}");
+ var getMappedUserQuery = @$"SELECT u.id,u.username FROM github_user_mappings INNER JOIN users u on u.id = github_user_mappings.user_id WHERE github_id='{githubId}'";
+ await connection.OpenAsync();
+ await using var getMappedUserCommand = new NpgsqlCommand(getMappedUserQuery, connection);
+ await using var reader = await getMappedUserCommand.ExecuteReaderAsync();
+ var handled = false;
+ while (await reader.ReadAsync()) {
+ try {
+ var userId = reader.GetGuid(0);
+ var username = reader.GetString(1);
+ context.Identity.AddClaim(new Claim(AppClaims.USER_ID, userId.ToString()));
+ context.Identity.AddClaim(new Claim(AppClaims.NAME, username));
+ if (context.AccessToken != default) {
+ context.Identity.AddClaim(new Claim(AppClaims.GITHUB_ACCESS_TOKEN, context.AccessToken ?? ""));
+ }
+
+ Log.Information($"Found mapping for github id {githubId} mapped to user id {userId}");
+ handled = true;
+ } catch (Exception e) {
+ Log.Error(e, "An exception occured when handling github user mappings");
+ handled = false;
+ }
+ }
+
+ await connection.CloseAsync();
+
+ if (!handled) {
+ var userId = Guid.NewGuid();
+
+ var insertUserQuery = $@"INSERT INTO users VALUES ('{userId}', '{githubUsername}', '', '{DateTime.UtcNow}')";
+ await connection.OpenAsync();
+ await using var insertUserCommand = new NpgsqlCommand(insertUserQuery, connection);
+ await insertUserCommand.ExecuteNonQueryAsync();
+ await connection.CloseAsync();
+
+ var refreshTokenEncryptionKey = configuration.GetValue<string>(AppEnvironmentVariables.APP_AES_KEY);
+ string insertMappingQuery;
+
+ if (context.RefreshToken.HasValue() && refreshTokenEncryptionKey.HasValue()) {
+ var encryptedRefreshToken = context.RefreshToken.EncryptWithAes(refreshTokenEncryptionKey);
+ insertMappingQuery = $@"INSERT INTO github_user_mappings VALUES ('{githubId}', '{userId}', '{githubEmail}', '{encryptedRefreshToken}')";
+ } else {
+ insertMappingQuery = $@"INSERT INTO github_user_mappings VALUES ('{githubId}', '{userId}', '{githubEmail}', '')";
+ }
+
+ await connection.OpenAsync();
+ await using var insertMappingCommand = new NpgsqlCommand(insertMappingQuery, connection);
+ await insertMappingCommand.ExecuteNonQueryAsync();
+ await connection.CloseAsync();
+
+ context.Identity.AddClaim(new Claim(AppClaims.USER_ID, userId.ToString()));
+ context.Identity.AddClaim(new Claim(AppClaims.NAME, githubUsername));
+ if (context.AccessToken != default) {
+ context.Identity.AddClaim(new Claim(AppClaims.GITHUB_ACCESS_TOKEN, context.AccessToken ?? ""));
+ }
+
+ Log.Information($"Created mapping for github id {githubId} mapped to user id {userId}");
+ }
+ }
+}
diff --git a/server/src/Utilities/QuartzJsonSerializer.cs b/server/src/Utilities/QuartzJsonSerializer.cs
new file mode 100644
index 0000000..164a189
--- /dev/null
+++ b/server/src/Utilities/QuartzJsonSerializer.cs
@@ -0,0 +1,16 @@
+using Quartz.Spi;
+
+namespace IOL.GreatOffice.Api.Utilities;
+
+public class QuartzJsonSerializer : IObjectSerializer
+{
+ public void Initialize() { }
+
+ public byte[] Serialize<T>(T obj) where T : class {
+ return JsonSerializer.SerializeToUtf8Bytes(obj);
+ }
+
+ public T DeSerialize<T>(byte[] data) where T : class {
+ return JsonSerializer.Deserialize<T>(data);
+ }
+}
diff --git a/server/src/Utilities/SwaggerDefaultValues.cs b/server/src/Utilities/SwaggerDefaultValues.cs
new file mode 100644
index 0000000..4b5c764
--- /dev/null
+++ b/server/src/Utilities/SwaggerDefaultValues.cs
@@ -0,0 +1,58 @@
+using Microsoft.AspNetCore.Mvc.ApiExplorer;
+using Swashbuckle.AspNetCore.SwaggerGen;
+
+namespace IOL.GreatOffice.Api.Utilities;
+
+/// <summary>
+/// Represents the Swagger/Swashbuckle operation filter used to document the implicit API version parameter.
+/// </summary>
+/// <remarks>This <see cref="IOperationFilter"/> is only required due to bugs in the <see cref="SwaggerGenerator"/>.
+/// Once they are fixed and published, this class can be removed.</remarks>
+public class SwaggerDefaultValues : IOperationFilter
+{
+ /// <summary>
+ /// Applies the filter to the specified operation using the given context.
+ /// </summary>
+ /// <param name="operation">The operation to apply the filter to.</param>
+ /// <param name="context">The current operation filter context.</param>
+ public void Apply(OpenApiOperation operation, OperationFilterContext context) {
+ var apiDescription = context.ApiDescription;
+
+ operation.Deprecated |= apiDescription.IsDeprecated();
+
+ // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/1752#issue-663991077
+ foreach (var responseType in context.ApiDescription.SupportedResponseTypes) {
+ // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/blob/b7cf75e7905050305b115dd96640ddd6e74c7ac9/src/Swashbuckle.AspNetCore.SwaggerGen/SwaggerGenerator/SwaggerGenerator.cs#L383-L387
+ var responseKey = responseType.IsDefaultResponse ? "default" : responseType.StatusCode.ToString();
+ var response = operation.Responses[responseKey];
+
+ foreach (var contentType in response.Content.Keys) {
+ if (!responseType.ApiResponseFormats.Any(x => x.MediaType == contentType)) {
+ response.Content.Remove(contentType);
+ }
+ }
+ }
+
+ if (operation.Parameters == null) {
+ return;
+ }
+
+ // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/412
+ // REF: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/pull/413
+ foreach (var parameter in operation.Parameters) {
+ var description = apiDescription.ParameterDescriptions.First(p => p.Name == parameter.Name);
+
+ if (parameter.Description == null) {
+ parameter.Description = description.ModelMetadata.Description;
+ }
+
+ if (parameter.Schema.Default == null && description.DefaultValue != null) {
+ // REF: https://github.com/Microsoft/aspnet-api-versioning/issues/429#issuecomment-605402330
+ var json = JsonSerializer.Serialize(description.DefaultValue, description.ModelMetadata.ModelType);
+ parameter.Schema.Default = OpenApiAnyFactory.CreateFromJson(json);
+ }
+
+ parameter.Required |= description.IsRequired;
+ }
+ }
+}
diff --git a/server/src/Utilities/SwaggerGenOptionsExtensions.cs b/server/src/Utilities/SwaggerGenOptionsExtensions.cs
new file mode 100644
index 0000000..a2dcf7a
--- /dev/null
+++ b/server/src/Utilities/SwaggerGenOptionsExtensions.cs
@@ -0,0 +1,43 @@
+#nullable enable
+using Microsoft.AspNetCore.Mvc.ApiExplorer;
+using Microsoft.AspNetCore.Mvc.Controllers;
+using Swashbuckle.AspNetCore.SwaggerGen;
+using BaseRoute = IOL.GreatOffice.Api.Endpoints.V1.BaseRoute;
+
+namespace IOL.GreatOffice.Api.Utilities;
+
+public static class SwaggerGenOptionsExtensions
+{
+ /// <summary>
+ /// Updates Swagger document to support ApiEndpoints.<br/><br/>
+ /// For controllers inherited from <see cref="BaseRoute"/>:<br/>
+ /// - Replaces action Tag with <c>[namespace]</c><br/>
+ /// </summary>
+ public static void UseApiEndpoints(this SwaggerGenOptions options) {
+ options.TagActionsBy(EndpointNamespaceOrDefault);
+ }
+
+ private static IList<string?> EndpointNamespaceOrDefault(ApiDescription api) {
+ if (api.ActionDescriptor is not ControllerActionDescriptor actionDescriptor) {
+ throw new InvalidOperationException($"Unable to determine tag for endpoint: {api.ActionDescriptor.DisplayName}");
+ }
+
+ if (actionDescriptor.ControllerTypeInfo.GetBaseTypesAndThis().Any(t => t == typeof(BaseRoute))) {
+ return new[] {
+ actionDescriptor.ControllerTypeInfo.Namespace?.Split('.').Last()
+ };
+ }
+
+ return new[] {
+ actionDescriptor.ControllerName
+ };
+ }
+
+ public static IEnumerable<Type> GetBaseTypesAndThis(this Type type) {
+ Type? current = type;
+ while (current != null) {
+ yield return current;
+ current = current.BaseType;
+ }
+ }
+}
diff --git a/server/src/appsettings.json b/server/src/appsettings.json
new file mode 100644
index 0000000..8727fd7
--- /dev/null
+++ b/server/src/appsettings.json
@@ -0,0 +1,22 @@
+{
+ "AllowedHosts": "*",
+ "Serilog": {
+ "MinimumLevel": {
+ "Default": "Information",
+ "Override": {
+ "Microsoft": "Warning",
+ "Microsoft.Hosting.Lifetime": "Information",
+ "Microsoft.EntityFrameworkCore": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "Filter": [
+ {
+ "Name": "ByExcluding",
+ "Args": {
+ "expression": "@mt = 'An unhandled exception has occurred while executing the request.'"
+ }
+ }
+ ]
+ }
+}
diff --git a/server/src/wwwroot/version.txt b/server/src/wwwroot/version.txt
new file mode 100644
index 0000000..863c2f5
--- /dev/null
+++ b/server/src/wwwroot/version.txt
@@ -0,0 +1 @@
+v35-server-dev
diff --git a/sql/quartz-create.sql b/sql/quartz-create.sql
new file mode 100644
index 0000000..d0dc298
--- /dev/null
+++ b/sql/quartz-create.sql
@@ -0,0 +1,156 @@
+CREATE TABLE IF NOT EXISTS qrtz_job_details
+(
+ sched_name TEXT NOT NULL,
+ job_name TEXT NOT NULL,
+ job_group TEXT NOT NULL,
+ description TEXT NULL,
+ job_class_name TEXT NOT NULL,
+ is_durable BOOL NOT NULL,
+ is_nonconcurrent BOOL NOT NULL,
+ is_update_data BOOL NOT NULL,
+ requests_recovery BOOL NOT NULL,
+ job_data BYTEA NULL,
+ PRIMARY KEY (sched_name, job_name, job_group)
+);
+
+CREATE TABLE IF NOT EXISTS qrtz_triggers
+(
+ sched_name TEXT NOT NULL,
+ trigger_name TEXT NOT NULL,
+ trigger_group TEXT NOT NULL,
+ job_name TEXT NOT NULL,
+ job_group TEXT NOT NULL,
+ description TEXT NULL,
+ next_fire_time BIGINT NULL,
+ prev_fire_time BIGINT NULL,
+ priority INTEGER NULL,
+ trigger_state TEXT NOT NULL,
+ trigger_type TEXT NOT NULL,
+ start_time BIGINT NOT NULL,
+ end_time BIGINT NULL,
+ calendar_name TEXT NULL,
+ misfire_instr SMALLINT NULL,
+ job_data BYTEA NULL,
+ PRIMARY KEY (sched_name, trigger_name, trigger_group),
+ FOREIGN KEY (sched_name, job_name, job_group)
+ REFERENCES qrtz_job_details (sched_name, job_name, job_group)
+);
+
+CREATE TABLE IF NOT EXISTS qrtz_simple_triggers
+(
+ sched_name TEXT NOT NULL,
+ trigger_name TEXT NOT NULL,
+ trigger_group TEXT NOT NULL,
+ repeat_count BIGINT NOT NULL,
+ repeat_interval BIGINT NOT NULL,
+ times_triggered BIGINT NOT NULL,
+ PRIMARY KEY (sched_name, trigger_name, trigger_group),
+ FOREIGN KEY (sched_name, trigger_name, trigger_group)
+ REFERENCES qrtz_triggers (sched_name, trigger_name, trigger_group) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS QRTZ_SIMPROP_TRIGGERS
+(
+ sched_name TEXT NOT NULL,
+ trigger_name TEXT NOT NULL,
+ trigger_group TEXT NOT NULL,
+ str_prop_1 TEXT NULL,
+ str_prop_2 TEXT NULL,
+ str_prop_3 TEXT NULL,
+ int_prop_1 INTEGER NULL,
+ int_prop_2 INTEGER NULL,
+ long_prop_1 BIGINT NULL,
+ long_prop_2 BIGINT NULL,
+ dec_prop_1 NUMERIC NULL,
+ dec_prop_2 NUMERIC NULL,
+ bool_prop_1 BOOL NULL,
+ bool_prop_2 BOOL NULL,
+ time_zone_id TEXT NULL,
+ PRIMARY KEY (sched_name, trigger_name, trigger_group),
+ FOREIGN KEY (sched_name, trigger_name, trigger_group)
+ REFERENCES qrtz_triggers (sched_name, trigger_name, trigger_group) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS qrtz_cron_triggers
+(
+ sched_name TEXT NOT NULL,
+ trigger_name TEXT NOT NULL,
+ trigger_group TEXT NOT NULL,
+ cron_expression TEXT NOT NULL,
+ time_zone_id TEXT,
+ PRIMARY KEY (sched_name, trigger_name, trigger_group),
+ FOREIGN KEY (sched_name, trigger_name, trigger_group)
+ REFERENCES qrtz_triggers (sched_name, trigger_name, trigger_group) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS qrtz_blob_triggers
+(
+ sched_name TEXT NOT NULL,
+ trigger_name TEXT NOT NULL,
+ trigger_group TEXT NOT NULL,
+ blob_data BYTEA NULL,
+ PRIMARY KEY (sched_name, trigger_name, trigger_group),
+ FOREIGN KEY (sched_name, trigger_name, trigger_group)
+ REFERENCES qrtz_triggers (sched_name, trigger_name, trigger_group) ON DELETE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS qrtz_calendars
+(
+ sched_name TEXT NOT NULL,
+ calendar_name TEXT NOT NULL,
+ calendar BYTEA NOT NULL,
+ PRIMARY KEY (sched_name, calendar_name)
+);
+
+CREATE TABLE IF NOT EXISTS qrtz_paused_trigger_grps
+(
+ sched_name TEXT NOT NULL,
+ trigger_group TEXT NOT NULL,
+ PRIMARY KEY (sched_name, trigger_group)
+);
+
+CREATE TABLE IF NOT EXISTS qrtz_fired_triggers
+(
+ sched_name TEXT NOT NULL,
+ entry_id TEXT NOT NULL,
+ trigger_name TEXT NOT NULL,
+ trigger_group TEXT NOT NULL,
+ instance_name TEXT NOT NULL,
+ fired_time BIGINT NOT NULL,
+ sched_time BIGINT NOT NULL,
+ priority INTEGER NOT NULL,
+ state TEXT NOT NULL,
+ job_name TEXT NULL,
+ job_group TEXT NULL,
+ is_nonconcurrent BOOL NOT NULL,
+ requests_recovery BOOL NULL,
+ PRIMARY KEY (sched_name, entry_id)
+);
+
+CREATE TABLE IF NOT EXISTS qrtz_scheduler_state
+(
+ sched_name TEXT NOT NULL,
+ instance_name TEXT NOT NULL,
+ last_checkin_time BIGINT NOT NULL,
+ checkin_interval BIGINT NOT NULL,
+ PRIMARY KEY (sched_name, instance_name)
+);
+
+CREATE TABLE IF NOT EXISTS qrtz_locks
+(
+ sched_name TEXT NOT NULL,
+ lock_name TEXT NOT NULL,
+ PRIMARY KEY (sched_name, lock_name)
+);
+
+CREATE INDEX IF NOT EXISTS idx_qrtz_j_req_recovery on qrtz_job_details (requests_recovery);
+CREATE INDEX IF NOT EXISTS idx_qrtz_t_next_fire_time on qrtz_triggers (next_fire_time);
+CREATE INDEX IF NOT EXISTS idx_qrtz_t_state on qrtz_triggers (trigger_state);
+CREATE INDEX IF NOT EXISTS idx_qrtz_t_nft_st on qrtz_triggers (next_fire_time, trigger_state);
+CREATE INDEX IF NOT EXISTS idx_qrtz_ft_trig_name on qrtz_fired_triggers (trigger_name);
+CREATE INDEX IF NOT EXISTS idx_qrtz_ft_trig_group on qrtz_fired_triggers (trigger_group);
+CREATE INDEX IF NOT EXISTS idx_qrtz_ft_trig_nm_gp on qrtz_fired_triggers (sched_name, trigger_name, trigger_group);
+CREATE INDEX IF NOT EXISTS idx_qrtz_ft_trig_inst_name on qrtz_fired_triggers (instance_name);
+CREATE INDEX IF NOT EXISTS idx_qrtz_ft_job_name on qrtz_fired_triggers (job_name);
+CREATE INDEX IF NOT EXISTS idx_qrtz_ft_job_group on qrtz_fired_triggers (job_group);
+CREATE INDEX IF NOT EXISTS idx_qrtz_ft_job_req_recovery on qrtz_fired_triggers (requests_recovery);
diff --git a/sql/quartz-drop.sql b/sql/quartz-drop.sql
new file mode 100644
index 0000000..87b0797
--- /dev/null
+++ b/sql/quartz-drop.sql
@@ -0,0 +1,23 @@
+DROP TABLE IF EXISTS qrtz_fired_triggers;
+DROP TABLE IF EXISTS qrtz_paused_trigger_grps;
+DROP TABLE IF EXISTS qrtz_scheduler_state;
+DROP TABLE IF EXISTS qrtz_locks;
+DROP TABLE IF EXISTS qrtz_simprop_triggers;
+DROP TABLE IF EXISTS qrtz_simple_triggers;
+DROP TABLE IF EXISTS qrtz_cron_triggers;
+DROP TABLE IF EXISTS qrtz_blob_triggers;
+DROP TABLE IF EXISTS qrtz_triggers;
+DROP TABLE IF EXISTS qrtz_job_details;
+DROP TABLE IF EXISTS qrtz_calendars;
+
+DROP INDEX IF EXISTS idx_qrtz_j_req_recovery;
+DROP INDEX IF EXISTS idx_qrtz_t_next_fire_time;
+DROP INDEX IF EXISTS idx_qrtz_t_state;
+DROP INDEX IF EXISTS idx_qrtz_t_nft_st;
+DROP INDEX IF EXISTS idx_qrtz_ft_trig_name;
+DROP INDEX IF EXISTS idx_qrtz_ft_trig_group;
+DROP INDEX IF EXISTS idx_qrtz_ft_trig_nm_gp;
+DROP INDEX IF EXISTS idx_qrtz_ft_trig_inst_name;
+DROP INDEX IF EXISTS idx_qrtz_ft_job_name;
+DROP INDEX IF EXISTS idx_qrtz_ft_job_group;
+DROP INDEX IF EXISTS idx_qrtz_ft_job_req_recovery;
diff --git a/tests/IOL.GreatOffice.IntegrationTests/ApplicationTests/LoginPageTests.cs b/tests/IOL.GreatOffice.IntegrationTests/ApplicationTests/LoginPageTests.cs
new file mode 100644
index 0000000..10525fd
--- /dev/null
+++ b/tests/IOL.GreatOffice.IntegrationTests/ApplicationTests/LoginPageTests.cs
@@ -0,0 +1,23 @@
+using IOL.GreatOffice.IntegrationTests.Helpers;
+using Xunit;
+
+namespace IOL.GreatOffice.IntegrationTests.ApplicationTests;
+
+public class LoginPageTests : IClassFixture<WebServerFixture>
+{
+ private readonly WebServerFixture _fixture;
+
+ public LoginPageTests(WebServerFixture fixture) {
+ _fixture = fixture;
+ }
+
+ [Fact]
+ public async Task LoginPageTestsRenders() {
+ var page = await _fixture.Browser.NewPageAsync();
+ await page.GotoAsync(_fixture.BaseUrl);
+
+ var actual = await page.TextContentAsync(Element.ByName("Page Title"));
+
+ Assert.Equal("Welcome", actual);
+ }
+}
diff --git a/tests/IOL.GreatOffice.IntegrationTests/Helpers/Element.cs b/tests/IOL.GreatOffice.IntegrationTests/Helpers/Element.cs
new file mode 100644
index 0000000..da83cc3
--- /dev/null
+++ b/tests/IOL.GreatOffice.IntegrationTests/Helpers/Element.cs
@@ -0,0 +1,6 @@
+namespace IOL.GreatOffice.IntegrationTests.Helpers;
+
+public static class Element
+{
+ public static string ByName(string name) => $"[pw-name='{name}']";
+}
diff --git a/tests/IOL.GreatOffice.IntegrationTests/Helpers/WebServerFixture.cs b/tests/IOL.GreatOffice.IntegrationTests/Helpers/WebServerFixture.cs
new file mode 100644
index 0000000..080fa9f
--- /dev/null
+++ b/tests/IOL.GreatOffice.IntegrationTests/Helpers/WebServerFixture.cs
@@ -0,0 +1,48 @@
+using System.Net;
+using System.Net.Sockets;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Playwright;
+using Xunit;
+using Program = IOL.GreatOffice.Api.Program;
+
+namespace IOL.GreatOffice.IntegrationTests.Helpers;
+
+// ReSharper disable once ClassNeverInstantiated.Global
+public class WebServerFixture : IAsyncLifetime, IDisposable
+{
+ private readonly WebApplication Host;
+ private IPlaywright Playwright { get; set; }
+ public IBrowser Browser { get; private set; }
+ public string BaseUrl { get; } = $"https://localhost:{GetRandomUnusedPort()}";
+
+ public WebServerFixture() {
+ Host = Program.CreateWebApplication(Program.CreateAppBuilder(default));
+ }
+
+ public async Task InitializeAsync() {
+ Playwright = await Microsoft.Playwright.Playwright.CreateAsync();
+ Browser = await Playwright.Chromium.LaunchAsync();
+ await Host.StartAsync();
+ }
+
+ public async Task DisposeAsync() {
+ await Host.StopAsync();
+ await Host.DisposeAsync();
+ Playwright?.Dispose();
+ }
+
+ public void Dispose() {
+ Host.StopAsync();
+ Host.DisposeAsync();
+ Playwright?.Dispose();
+ GC.SuppressFinalize(this);
+ }
+
+ private static int GetRandomUnusedPort() {
+ var listener = new TcpListener(IPAddress.Any, 0);
+ listener.Start();
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
+ }
+}
diff --git a/tests/IOL.GreatOffice.IntegrationTests/IOL.GreatOffice.IntegrationTests.csproj b/tests/IOL.GreatOffice.IntegrationTests/IOL.GreatOffice.IntegrationTests.csproj
new file mode 100644
index 0000000..92ed6b2
--- /dev/null
+++ b/tests/IOL.GreatOffice.IntegrationTests/IOL.GreatOffice.IntegrationTests.csproj
@@ -0,0 +1,25 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <OutputType>Exe</OutputType>
+ <ImplicitUsings>true</ImplicitUsings>
+ <TargetFramework>net6.0</TargetFramework>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>disable</Nullable>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.2.0" />
+ <PackageReference Include="Microsoft.Playwright" Version="1.22.0" />
+ <PackageReference Include="xunit" Version="2.4.1" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\..\server\IOL.GreatOffice.Api.csproj" />
+ <ProjectReference Include="..\..\server\src\IOL.GreatOffice.Api.csproj" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <Folder Include="ServerTests" />
+ </ItemGroup>
+</Project>