Compare commits

..

210 Commits

Author SHA1 Message Date
e0c00b306e fix(deps): update dependency pinia to v2.0.32 2023-02-21 08:04:09 +00:00
10eaacc552 chore(deps): update dependency vue-tsc to v1.1.5 2023-02-20 20:04:05 +00:00
4b1465955a chore(deps): update typescript-eslint monorepo to v5.53.0 2023-02-20 19:16:38 +00:00
1711318212 chore(deps): update dependency esbuild to v0.17.10 2023-02-20 18:04:33 +00:00
b042547aaa chore(deps): update dependency netlify-cli to v12.13.2 2023-02-20 12:29:07 +00:00
ed0db956eb chore(deps): update dependency happy-dom to v8.6.0 2023-02-20 12:04:07 +00:00
a66f8a6484 chore(deps): update dependency rollup to v3.17.2 2023-02-20 11:34:43 +00:00
47e895149e chore(deps): update dependency vue-tsc to v1.1.4 2023-02-20 11:34:01 +00:00
8e00014feb fix(deps): update dependency pinia to v2.0.31 2023-02-20 11:33:30 +00:00
6146340034 fix(deps): update dependency codemirror to v5.65.12 2023-02-20 11:04:16 +00:00
c7b761b0eb chore(deps): update dependency caniuse-lite to v1.0.30001457 2023-02-20 09:32:51 +00:00
a1e84b3460 chore(deps): update dependency @vue/test-utils to v2.3.0 2023-02-20 09:32:12 +00:00
038debaa22 chore(deps): update dependency vite to v4.1.3 2023-02-20 09:04:17 +00:00
88faf04251 chore(deps): update dependency esbuild to v0.17.9 2023-02-19 18:04:07 +00:00
04be2b9745 chore(deps): update dependency rollup to v3.17.1 2023-02-18 20:04:05 +00:00
815e8cce0e chore(deps): update dependency sass to v1.58.3 2023-02-18 13:04:11 +00:00
d12f9247ff chore(deps): update dependency vue-tsc to v1.1.3 2023-02-18 12:38:10 +00:00
85e7a17934 chore(deps): update pnpm to v7.27.1 2023-02-18 12:37:43 +00:00
59c5d43348 chore(deps): update dependency rollup to v3.17.0 2023-02-18 12:37:10 +00:00
c011f9aa52 fix(deps): update dependency @vueuse/core to v9.13.0 2023-02-18 12:36:29 +00:00
b9f5319a4f chore(deps): update histoire to v0.15.8 2023-02-18 12:04:22 +00:00
f120ba4169 chore(deps): update dependency @types/node to v18.14.0 2023-02-17 21:04:08 +00:00
b2b70f4a9d chore(deps): update dependency @cypress/vite-dev-server to v5.0.3 2023-02-17 15:04:01 +00:00
9facffe3e9 fix(deps): update dependency blurhash to v2.0.5 2023-02-17 14:38:30 +00:00
c31aff1d88 chore(deps): update histoire to v0.15.7 2023-02-17 14:23:27 +00:00
60dea80462 chore(deps): update dependency rollup to v3.16.0 2023-02-17 14:20:21 +00:00
cd10ccfbc0 fix(deps): update sentry-javascript monorepo to v7.38.0 2023-02-17 14:04:40 +00:00
8647402038 chore(deps): update dependency vite to v4.1.2 2023-02-17 11:04:08 +00:00
990fd46302 chore(deps): update node.js to v18.14.1 2023-02-17 10:17:25 +00:00
cf0aafd9e6 fix(deps): update dependency ufo to v1.1.0 2023-02-17 09:15:10 +00:00
70d2535e93 chore(deps): update dependency sass to v1.58.2 2023-02-17 02:04:07 +00:00
Frederick [Bot]
0c6f1a4083 [skip ci] Updated translations via Crowdin 2023-02-17 00:06:13 +00:00
29eb42932a chore(deps): update dependency vue-tsc to v1.1.2 2023-02-16 17:04:00 +00:00
736e9051d8 chore(deps): update histoire to v0.15.4 2023-02-16 10:04:01 +00:00
4a4c401558 chore(deps): update dependency cypress to v12.6.0 (#3115)
Reviewed-on: vikunja/frontend#3115
Reviewed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-02-15 22:45:51 +00:00
9198abe24d chore(deps): pin node.js to 18.14.0 2023-02-15 22:02:55 +00:00
Dominik Pschenitschni
97c8970dd6 feat: use renovate js-app as preset (#3087)
Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: vikunja/frontend#3087
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
2023-02-15 21:58:35 +00:00
5303b6bc97 chore(deps): update dependency vue-tsc to v1.1.0 2023-02-15 20:04:24 +00:00
24a0a8f5eb chore(deps): update histoire to v0.15.3 2023-02-15 19:03:46 +00:00
Dominik Pschenitschni
d07ad495e2 fix(postcss-preset-env): client side polyfills (#3051)
Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: vikunja/frontend#3051
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
2023-02-15 15:40:00 +00:00
8465afe421 chore(deps): update histoire to v0.15.1 2023-02-15 15:03:47 +00:00
d40729cbe7
fix: button styles
Partially reverts eaeddda4e4
2023-02-15 11:28:25 +01:00
fa0e46a399
chore: remove sponsor 2023-02-15 11:12:00 +01:00
b78481f9f6 fix(deps): update dependency @kyvg/vue3-notification to v2.9.0 (#3113)
Reviewed-on: vikunja/frontend#3113
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-02-14 15:16:03 +00:00
cbc9cf6f7f fix(deps): update dependency vue-flatpickr-component to v11.0.2 (#3112)
Reviewed-on: vikunja/frontend#3112
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-02-14 10:28:55 +00:00
62fd9a656e chore(deps): update dependency sass to v1.58.1 2023-02-14 01:04:11 +00:00
85269b4524 chore(deps): update dependency start-server-and-test to v1.15.4 (#3109)
Reviewed-on: vikunja/frontend#3109
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-02-13 20:15:41 +00:00
536d709961 fix(deps): update dependency axios to v1.3.3 2023-02-13 19:04:37 +00:00
59d6d7e786 chore(deps): update typescript-eslint monorepo to v5.52.0 2023-02-13 18:04:07 +00:00
ae86d0d42a fix(deps): update dependency dompurify to v3 (#3107)
Reviewed-on: vikunja/frontend#3107
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-02-13 17:39:29 +00:00
9a20b7a853 fix(deps): update sentry-javascript monorepo to v7.37.2 2023-02-13 16:04:08 +00:00
5687b66ea5 chore(deps): update dependency vitest to v0.28.5 2023-02-13 13:04:04 +00:00
1da411e1f6 chore(deps): update dependency vite-plugin-inject-preload to v1.3.0 2023-02-13 09:48:27 +00:00
e8a6d3f31b chore(deps): update dependency caniuse-lite to v1.0.30001451 2023-02-13 09:47:50 +00:00
a25a795276 chore(deps): update dependency netlify-cli to v12.12.0 2023-02-13 09:45:48 +00:00
57f6abd99f chore(deps): update dependency esbuild to v0.17.8 2023-02-13 07:04:03 +00:00
84d205f90b chore(deps): update dependency vite-plugin-pwa to v0.14.4 2023-02-11 10:04:05 +00:00
de91e7c9ae chore(deps): update histoire to v0.14.2 2023-02-11 09:04:37 +00:00
2cf9c35acb chore(deps): update dependency eslint to v8.34.0 2023-02-11 08:04:56 +00:00
db525db6eb fix(deps): histoire renovate group 2023-02-11 08:04:20 +00:00
88525ae7c8 chore(deps): include histoire main package in histoire renovate group 2023-02-11 08:03:36 +00:00
957bfdc8f1 chore(deps): update dependency histoire to v0.14.2 2023-02-11 00:05:13 +00:00
c52ae83b75 fix(deps): update sentry-javascript monorepo to v7.37.1 2023-02-10 16:03:55 +00:00
df40c4e475
chore(deps): update dependency histoire to v0.14.0 2023-02-10 14:29:39 +01:00
3f41e9a3a6 chore(deps): update dependency @histoire/plugin-vue to v0.14.0 2023-02-10 13:04:04 +00:00
1da510b5dd
chore(deps): update dependency @histoire/plugin-screenshot to v0.14.0 2023-02-10 13:35:01 +01:00
536db3fd46 chore(deps): update dependency @histoire/plugin-vue to v0.14.0 2023-02-10 12:26:56 +00:00
cefa5250c5
chore(deps): create a group for all histoire dependencies 2023-02-10 13:10:46 +01:00
f697640636
chore: remove minimist dependency (not used anywhere) 2023-02-10 12:57:48 +01:00
09b7595b68 chore(deps): update dependency rollup to v3.15.0 2023-02-10 07:08:20 +00:00
6b7f73f724 chore(deps): update dependency esbuild to v0.17.7 2023-02-09 23:04:14 +00:00
Dominik Pschenitschni
d6b55c7570 feat: fix calculation of token invalidation (#3077)
Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: vikunja/frontend#3077
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
2023-02-09 21:45:18 +00:00
Yurii Vlasov
3f4b08b8be Added ipv6 control script 2023-02-09 21:43:32 +00:00
791c61cabb
fix(docker): default api url 2023-02-09 22:30:36 +01:00
e3dd4ef78a feat: persistent menuActive state with Local Storage (#3011)
Reviewed-on: vikunja/frontend#3011
Reviewed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
2023-02-09 21:14:49 +00:00
830d0887b9 fix(deps): update sentry-javascript monorepo to v7.37.0 2023-02-09 17:04:06 +00:00
e8db2c2b45
feat: header improvements 2023-02-09 15:19:33 +01:00
706a13242e fix(deps): update dependency @intlify/unplugin-vue-i18n to v0.8.2 2023-02-08 23:34:16 +00:00
13fab10584 chore(deps): update dependency histoire to v0.13.2 2023-02-08 23:05:01 +00:00
4b0c8aa66b chore(deps): update dependency @histoire/plugin-vue to v0.13.2 2023-02-08 22:04:14 +00:00
bfaf9401f4 chore(deps): update dependency @histoire/plugin-screenshot to v0.13.2 2023-02-08 21:07:12 +00:00
13607124a6
chore(deps): update dependency histoire to v0.13.1 2023-02-08 17:34:13 +01:00
9fc3d0a965 chore(deps): update dependency vite-plugin-pwa to v0.14.3 2023-02-08 16:27:37 +00:00
4d6286451e chore(deps): update dependency @histoire/plugin-vue to v0.13.1 2023-02-08 16:04:15 +00:00
0479d17e69 chore(deps): update dependency @histoire/plugin-screenshot to v0.13.1 2023-02-08 15:04:13 +00:00
5ca272959d chore(deps): update pnpm to v7.27.0 2023-02-08 14:03:48 +00:00
c502f9b840
feat: refactor to composable
- using useMediaQuery and useLocalStorage
- remove watcher in contentAuth
2023-02-08 12:56:32 +01:00
a3a313a21f fix(deps): update font awesome to v6.3.0 2023-02-07 20:04:47 +00:00
c58d1ffd2e chore(deps): update dependency vite-plugin-pwa to v0.14.2 2023-02-07 17:04:16 +00:00
99dc5cf34f
Refactor to only used local storage value when on desktop viewport widths 2023-02-07 14:58:45 +01:00
3604cb3ec7
Solve for resize() 2023-02-07 14:58:45 +01:00
aa01a92278
Persist menuActive state in Local Storage 2023-02-07 14:58:44 +01:00
Dominik Pschenitschni
7b96397e3b feat: use klona instead of lodash.clonedeep (#3073)
Resolves: vikunja/frontend#3032
Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: vikunja/frontend#3073
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
2023-02-07 13:04:03 +00:00
b45a4e1aaf chore(deps): update dependency @types/node to v18.13.0 2023-02-07 09:04:26 +00:00
Frederick [Bot]
d3365d6add [skip ci] Updated translations via Crowdin 2023-02-07 00:10:26 +00:00
49cb2b9e6f chore(deps): update dependency @cypress/vue to v5.0.4 2023-02-06 22:04:02 +00:00
d4ce10e79a chore(deps): update dependency esbuild to v0.17.6 2023-02-06 20:04:01 +00:00
345c5e3588 chore(deps): update typescript-eslint monorepo to v5.51.0 2023-02-06 18:04:17 +00:00
7ff84bcd29 chore(deps): update dependency happy-dom to v8.2.6 2023-02-06 10:56:28 +00:00
d1633ef622 chore(deps): update dependency @histoire/plugin-vue to v0.13.0 2023-02-06 10:04:27 +00:00
7e92bc63ac chore(deps): update caniuse-and-related 2023-02-06 09:41:54 +00:00
be076b65cf chore(deps): update dependency histoire to v0.13.0 2023-02-06 01:05:40 +00:00
Frederick [Bot]
65b90cbee0 [skip ci] Updated translations via Crowdin 2023-02-06 00:09:40 +00:00
74aac1b245 chore(deps): update dependency @histoire/plugin-screenshot to v0.13.0 2023-02-05 23:38:03 +00:00
ade791ed43 chore(deps): update dependency @types/node to v18.11.19 2023-02-05 12:04:08 +00:00
55b008c67c chore(deps): update dependency rollup to v3.14.0 2023-02-05 06:03:59 +00:00
Frederick [Bot]
1f088cca18 [skip ci] Updated translations via Crowdin 2023-02-05 00:10:26 +00:00
6fad1e4969 fix(deps): update dependency axios to v1.3.2 2023-02-03 19:04:04 +00:00
eaeddda4e4
feat: improve naming and styles 2023-02-03 17:25:38 +01:00
7cbf0acac5
fix: always show update popup on top 2023-02-03 17:04:51 +01:00
3db5ea45d7
feat: move update from navigation to app 2023-02-03 17:04:51 +01:00
RoboMagus
dcd5c3fd6a
Disable listening on IPv6 ports when IPv6 is not supported (#102) 2023-02-03 15:55:36 +01:00
61fff44764 chore(deps): update dependency rollup to v3.13.0 2023-02-03 13:03:57 +00:00
ecdae4e03e chore(deps): update dependency vitest to v0.28.4 2023-02-03 11:04:06 +00:00
b26ea45fe0
chore: update funding links 2023-02-03 11:47:46 +01:00
7cb0cd293d
chore: update funding links 2023-02-03 11:46:37 +01:00
6572f75e5d fix: Use Build Time Base Path (#2964)
Reviewed-on: vikunja/frontend#2964
Reviewed-by: konrad <k@knt.li>
2023-02-03 08:57:27 +00:00
af55992057
feat(config): Support Setting Base Path in .env
* This uses loadEnv to load an environment file at configuration
  time.
  * Documentation:
    * https://vitejs.dev/config/#environment-variables
  * More on environment files:
    * https://vitejs.dev/guide/env-and-mode.html
  * `VIKUNJA_FRONTEND_BASE` is the variable in the environment
     file that will be used to set Vite’s base option.
* This adds a commented example to .env.local.example

Signed-off-by: Jef Oliver <jef@eljef.me>
2023-02-03 09:21:08 +01:00
e92559dc00
fix(base): Use Build Time Base Path
* If a base path is provided at build time, use it.
  * Base path can be set with `VIKUNJA_FRONTEND_BASE` at
    build time
    * `VIKUNJA_FRONTEND_BASE` sets `import.meta.env.BASE_URL` after Vite resolves it.
    * Usages of `import.meta.env.BASE_URL` are statically replaced
      at build time.
    * If base path is not provided, `import.meta.env.BASE_URL`
      defaults to '/'.
    * Documentation:
      https://vitejs.dev/guide/env-and-mode.html

* Fixes:
  * Manifest not loading because of incorrect path.
  * Service Worker not loading because path is incorrect in
    manifest.
  * Service Worker crashing because import of workbox is from
    wrong path.
  * Service Worker not loading a task because path is incorrect
    in event listener.
  * Incorrect URLs being set on window because base path is
    incorrect.
    * ex: `/login` vs `/base/login`

Signed-off-by: Jef Oliver <jef@eljef.me>
2023-02-03 09:21:06 +01:00
3dbf02fd7a chore(deps): update dependency @vue/test-utils to v2.2.10 2023-02-03 00:05:11 +00:00
81a4f2d977 chore: typo 2023-02-02 19:11:08 +00:00
2972d0d400 chore(deps): update dependency cypress to v12.5.1 2023-02-02 18:03:56 +00:00
c11ebc44c4 chore(deps): update dependency vite to v4.1.1 2023-02-02 15:04:03 +00:00
144f90c5f7 fix(deps): update sentry-javascript monorepo to v7.36.0 2023-02-02 14:14:47 +00:00
913879604a chore(deps): update dependency @vitejs/plugin-legacy to v4.0.1 2023-02-02 14:03:59 +00:00
1589ed5739 chore(deps): update dependency @vitejs/plugin-legacy to v4 2023-02-02 12:04:02 +00:00
a991c537ac chore(deps): update dependency postcss-preset-env to v8 (#3000)
Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#3000
Reviewed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-02-02 11:49:30 +00:00
69b57aa23a chore(deps): update dependency vite to v4.1.0 2023-02-02 11:04:00 +00:00
1a1939963a fix(deps): update dependency vue to v3.2.47 2023-02-02 07:03:57 +00:00
3d62c9789c fix(deps): update dependency axios to v1.3.1 2023-02-02 06:56:51 +00:00
c18df8687c chore(deps): update dependency @vue/test-utils to v2.2.9 2023-02-02 00:05:20 +00:00
d83ba0c158 fix(deps): update dependency pinia to v2.0.30 (#3042)
Reviewed-on: vikunja/frontend#3042
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-02-01 14:26:35 +00:00
cea31d1da7
fix(docker): cross compilation with buildx 2023-02-01 15:08:12 +01:00
12509a7e0f fix(deps): update sentry-javascript monorepo to v7.35.0 (#3041)
Reviewed-on: vikunja/frontend#3041
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-02-01 13:12:20 +00:00
dd43057a08 chore(deps): update dependency rollup to v3.12.1 2023-02-01 10:03:47 +00:00
19d3cf01cd chore(deps): update pnpm to v7.26.3 2023-02-01 09:36:45 +00:00
80012bf035 chore(deps): update dependency cypress to v12.5.0 2023-02-01 09:11:17 +00:00
899d9e1cb7 chore(deps): update dependency sass to v1.58.0 2023-02-01 02:04:06 +00:00
56830ddadc fix(deps): update dependency axios to v1.3.0 (#3036)
Reviewed-on: vikunja/frontend#3036
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-01-31 17:16:19 +00:00
1749d6ba0a
fix(list): make sure favorite lists are not duplicated in the menu when renaming them
Resolves vikunja/frontend#3031
2023-01-31 17:12:11 +01:00
b29008d304 chore(deps): update typescript-eslint monorepo to v5.50.0 2023-01-31 10:03:47 +00:00
8ae3054b1a chore(deps): update dependency typescript to v4.9.5 2023-01-30 22:03:50 +00:00
f9dad79b23 chore(deps): update dependency caniuse-lite to v1.0.30001449 2023-01-30 07:21:12 +00:00
30f5cb0656 chore(deps): update dependency happy-dom to v8.2.0 2023-01-30 07:20:45 +00:00
3f58c983da chore(deps): update dependency netlify-cli to v12.10.0 2023-01-30 01:04:11 +00:00
8fa8b03aa6
fix(tests): only look in src for tests 2023-01-29 20:24:44 +01:00
e4499f44b7 Docker refactoring (#3018)
Co-authored-by: Yurii Vlasov <yv@itsvit.org>
Reviewed-on: vikunja/frontend#3018
Reviewed-by: konrad <k@knt.li>
Co-authored-by: Yurii Vlasov <yuriy@vlasov.pro>
Co-committed-by: Yurii Vlasov <yuriy@vlasov.pro>
2023-01-29 14:47:22 +00:00
b799233bca
fix(quick add magic): correctly parse "next {weekday}" on the beginning of the text
Resolves vikunja/frontend#3022
2023-01-29 15:32:01 +01:00
be0ae4bc29 chore(deps): update dependency eslint to v8.33.0 2023-01-29 13:49:19 +00:00
60d99f3bba chore(deps): update pnpm to v7.26.2 2023-01-29 13:48:52 +00:00
fa666d2817 fix(deps): update dependency @vueuse/core to v9.12.0 2023-01-29 04:04:10 +00:00
9312aa14fa fix(deps): update dependency axios to v1.2.6 2023-01-28 17:03:57 +00:00
68e4f776b9 chore(deps): update dependency esbuild to v0.17.5 2023-01-28 07:32:56 +00:00
2d137d564e chore(deps): update dependency rollup to v3.12.0 2023-01-28 06:03:58 +00:00
Frederick [Bot]
fc8824d942 [skip ci] Updated translations via Crowdin 2023-01-28 00:27:10 +00:00
6d4ca57601 chore(deps): update dependency cypress to v12.4.1 2023-01-27 16:04:04 +00:00
d2bf4e38b1 chore(deps): update dependency vitest to v0.28.3 (#3019)
Reviewed-on: vikunja/frontend#3019
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-01-27 13:13:52 +00:00
a5f6857a40 chore(deps): update dependency @vue/test-utils to v2.2.8 2023-01-27 07:48:21 +00:00
ed3d79fa4c chore(deps): update pnpm to v7.26.1 2023-01-27 05:03:37 +00:00
Frederick [Bot]
81c5c54aed [skip ci] Updated translations via Crowdin 2023-01-27 00:28:14 +00:00
793e06c6ac fix(deps): update sentry-javascript monorepo to v7.34.0 2023-01-26 22:04:00 +00:00
Nikola Sivkov v2
7eb07e92f8
Add Ipv6 support to nginx (#100)
(cherry picked from commit 0e68bcfd5a518b5cbd0bafce1fc48d31b25e1fa1)
2023-01-26 22:00:49 +01:00
2a15878b81 fix(deps): update dependency axios to v1.2.5 2023-01-26 16:03:45 +00:00
ebd2b1e8c0 chore(deps): update dependency @vitejs/plugin-legacy to v3.0.2 (#3012)
Reviewed-on: vikunja/frontend#3012
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-01-26 15:20:58 +00:00
d11fcfa072 chore(deps): update dependency rollup to v3.11.0 (#3013)
Reviewed-on: vikunja/frontend#3013
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-01-26 14:19:03 +00:00
Frederick [Bot]
8e6e976867 [skip ci] Updated translations via Crowdin 2023-01-26 00:28:07 +00:00
9adf1aba89
chore: simplify getting the error text from an exception 2023-01-25 18:44:02 +01:00
e67088fdb7
chore: simplify error handling for login and OpenId Auth 2023-01-25 18:41:30 +01:00
da241d21f3
fix(quick actions): hide edges of last entry on hover 2023-01-25 16:26:05 +01:00
97133010af
fix(quick actions): don't throw an error message when selecting the last items with the arrow keys 2023-01-25 16:23:46 +01:00
4576da0dd3
fix: make sure global error handler handles unrejected promises correctly
Resolves vikunja/frontend#2992
2023-01-25 15:05:54 +01:00
fd4a68daf0 chore(deps): update dependency vitest to v0.28.2 (#3008)
Reviewed-on: vikunja/frontend#3008
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-01-25 12:30:19 +00:00
6f02d43801 fix(deps): update dependency @infectoone/vue-ganttastic to v2.1.4 (#3009)
Reviewed-on: vikunja/frontend#3009
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-01-25 12:29:41 +00:00
2be784766f feat: small content auth improvements (#2998)
Reviewed-on: vikunja/frontend#2998
2023-01-24 22:09:25 +00:00
Dominik Pschenitschni
13a39be3de feat: unindent settings page (#2996)
Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: vikunja/frontend#2996
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
2023-01-24 21:54:48 +00:00
d2e07efc7d chore(deps): update dependency cypress to v12.4.0 (#3006)
Reviewed-on: vikunja/frontend#3006
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-01-24 21:16:24 +00:00
a44299e786 chore(deps): update pnpm to v7.26.0 (#3002)
Reviewed-on: vikunja/frontend#3002
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-01-24 20:50:13 +00:00
221f73c347 fix(deps): update dependency axios to v1.2.4 (#3005)
Reviewed-on: vikunja/frontend#3005
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-01-24 18:21:26 +00:00
9b170d0d81 fix(deps): update sentry-javascript monorepo to v7.33.0 (#3004)
Reviewed-on: vikunja/frontend#3004
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-01-24 17:49:43 +00:00
16e61a8492
fix(ci): disable auto_tag for release docker images 2023-01-24 18:24:36 +01:00
a95f1090d7
fix(ci): save .tags file to generate release tags 2023-01-24 17:46:28 +01:00
c6026107fa
chore: 0.20.3 release preperations 2023-01-24 17:17:03 +01:00
e07e6bf677 fix(deps): update dependency @fortawesome/vue-fontawesome to v3.0.3 (#3003)
Reviewed-on: vikunja/frontend#3003
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-01-24 15:56:09 +00:00
c6ed925424
chore: move class name to top 2023-01-23 22:26:26 +01:00
7ed1a37de5
feat: use v-show for navigation buttons 2023-01-23 22:26:14 +01:00
1a2e9af88f chore(deps): update dependency start-server-and-test to v1.15.3 2023-01-23 21:24:26 +00:00
f5e90067f6 chore(deps): update typescript-eslint monorepo to v5.49.0 (#2994)
Reviewed-on: vikunja/frontend#2994
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-01-23 21:07:36 +00:00
188ae57dc0 chore(deps): update dependency @types/codemirror to v5.60.7 (#2993)
Reviewed-on: vikunja/frontend#2993
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-01-23 17:17:39 +00:00
3e4bbd58a3 chore(deps): update dependency vitest to v0.28.1 (#2990)
Reviewed-on: vikunja/frontend#2990
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-01-23 17:01:15 +00:00
bb8ee15a2d fix(deps): update sentry-javascript monorepo to v7.32.1 (#2991)
Reviewed-on: vikunja/frontend#2991
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-01-23 17:00:23 +00:00
4c46ae5b2f chore(deps): update dependency netlify-cli to v12.9.1 (#2988)
Reviewed-on: vikunja/frontend#2988
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-01-23 10:04:50 +00:00
ff27030e1c chore(deps): update dependency happy-dom to v8.1.5 (#2987)
Reviewed-on: vikunja/frontend#2987
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-01-23 10:04:03 +00:00
639e5e3d23 chore(deps): update dependency caniuse-lite to v1.0.30001447 (#2986)
Reviewed-on: vikunja/frontend#2986
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-01-23 10:03:19 +00:00
Frederick [Bot]
e49e9352e5 [skip ci] Updated translations via Crowdin 2023-01-23 00:29:07 +00:00
705afa0272 chore(deps): update dependency esbuild to v0.17.4 (#2985)
Reviewed-on: vikunja/frontend#2985
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-01-22 13:14:39 +00:00
83864a6bac chore(deps): update dependency vitest to v0.27.3 (#2984)
Reviewed-on: vikunja/frontend#2984
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-01-22 12:03:32 +00:00
664a39b70d chore(deps): update dependency vite-plugin-inject-preload to v1.2.0 (#2983)
Reviewed-on: vikunja/frontend#2983
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-01-22 11:59:51 +00:00
Dominik Pschenitschni
9922fcba65 fix: close button hover for sidebar (#2981)
Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: vikunja/frontend#2981
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
2023-01-21 11:02:17 +00:00
Dominik Pschenitschni
489014944a feat: fix broken font preloading (#2980)
Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: vikunja/frontend#2980
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
2023-01-21 11:01:32 +00:00
Frederick [Bot]
bb44beb4ba [skip ci] Updated translations via Crowdin 2023-01-21 00:29:03 +00:00
d996f24028 chore(deps): update dependency rollup to v3.10.1 2023-01-20 16:03:52 +00:00
59cac0eb38 fix(deps): update dependency @vueuse/core to v9.11.1 2023-01-20 14:03:51 +00:00
Frederick [Bot]
7adc5ceb9f [skip ci] Updated translations via Crowdin 2023-01-20 00:31:29 +00:00
a3d9cb5324 chore(deps): update pnpm to v7.25.1 (#2977)
Reviewed-on: vikunja/frontend#2977
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-01-19 11:14:20 +00:00
Frederick [Bot]
6c192b6f59 [skip ci] Updated translations via Crowdin 2023-01-19 00:30:50 +00:00
8ff1b3006b fix(task): don't show the list color on the task when only viewing the list (#2975)
Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#2975
2023-01-18 21:12:59 +00:00
0414352b02 chore(deps): update dependency esbuild to v0.17.3 (#2976)
Reviewed-on: vikunja/frontend#2976
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-01-18 20:47:14 +00:00
Dominik Pschenitschni
2a2c27af92 chore: use es6 imports for deploy-preview-netlify (#2970)
Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: vikunja/frontend#2970
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
2023-01-18 15:54:37 +00:00
Dominik Pschenitschni
e1b35ff023 feat: add .env.local.example (#2972)
Co-authored-by: Dominik Pschenitschni <mail@celement.de>
Reviewed-on: vikunja/frontend#2972
Co-authored-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-committed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
2023-01-18 00:06:18 +00:00
95b2bcf5fb fix(deps): update dependency axios to v1.2.3 (#2974)
Reviewed-on: vikunja/frontend#2974
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-01-17 22:34:18 +00:00
215 changed files with 7044 additions and 5413 deletions

View File

@ -149,8 +149,10 @@ steps:
# Override the default api url used for preview
- sed -i 's|http://localhost:3456|https://try.vikunja.io|g' dist-preview/index.html
- apk add --no-cache perl-utils
- shasum -a 384 -c ./scripts/deploy-preview-netlify.js.sha384
- node ./scripts/deploy-preview-netlify.js
# create via:
# `shasum -a 384 ./scripts/deploy-preview-netlify.mjs > ./scripts/deploy-preview-netlify.mjs.sha384`
- shasum -a 384 -c ./scripts/deploy-preview-netlify.mjs.sha384
- node ./scripts/deploy-preview-netlify.mjs
depends_on:
- build-prod
when:
@ -391,6 +393,7 @@ steps:
environment:
DOCKER_AUTOTAG_VERSION: ${DRONE_TAG}
DOCKER_AUTOTAG_EXTRA_TAGS: latest
DOCKER_AUTOTAG_OUTPUT_FILE: .tags
depends_on: [ fetch-tags ]
when:
ref:
@ -406,7 +409,6 @@ steps:
password:
from_secret: docker_password
repo: vikunja/frontend
auto_tag: true
build_args:
- USE_RELEASE=true
- RELEASE_VERSION=${DRONE_TAG##v}
@ -519,6 +521,6 @@ steps:
from_secret: crowdin_key
---
kind: signature
hmac: 0a65a032008f2d1911866dcb0d9536b26c1d6b105943ed5328ac13abc99c423f
hmac: 971875b90c7bb1649d1b00d022d0b594ba9b68f927bf8f0dbe840190816d676b
...

13
.env.local.example Normal file
View File

@ -0,0 +1,13 @@
# (1) Duplicate this file and remove the '.example' suffix.
# Naming this file '.env.local' is a Vite convention to prevent accidentally
# submitting to git.
# For more info see: https://vitejs.dev/guide/env-and-mode.html#env-files
# (2) Comment in and adjust the values as needed.
# VITE_IS_ONLINE=true
# VITE_WORKBOX_DEBUG=false
# SENTRY_AUTH_TOKEN=YOUR_TOKEN
# SENTRY_ORG=vikunja
# SENTRY_PROJECT=frontend-oss
# VIKUNJA_FRONTEND_BASE=/custom-subpath

3
.github/FUNDING.yml vendored
View File

@ -1,2 +1,3 @@
github: kolaente
custom: https://www.buymeacoffee.com/kolaente
open_collective: vikunja
custom: ["https://vikunja.cloud", "https://www.buymeacoffee.com/kolaente"]

2
.nvmrc
View File

@ -1 +1 @@
v18
18.14.1

View File

@ -9,6 +9,173 @@ All releases can be found on https://code.vikunja.io/frontend/releases.
The releases aim at the api versions which is why there are missing versions.
## [0.20.3] - 2023-01-24
### Bug Fixes
* *(BaseButton)* Prop type
* *(ci)* Make sure the i18n sync cron job actually runs
* *(ci)* Sign drone config
* *(ci)* Sign drone config
* *(ci)* Tagging logic for release docker images
* *(ci)* Sign drone config
* *(cypress)* Use ts for updateUserSettings
* *(cypress)* Use env for API_URL (#2925)
* *(drone)* Use correct property value (#2920)
* *(drone)* Pnpm cache folder path (#2932)
* *(faker)* Remove mock types (#2921)
* *(i18n)* Incorrect translation string
* *(migration)* Actually pass migration oauth code from query param
* *(quick add magic)* Make sure assignees which don't exist are not removed from task title
* *(task)* Update task description when switching between related tasks
* *(task)* Don't show the list color on the task when only viewing the list (#2975)
* *(useOnline)* Only log if actually faking state (#2924)
* Close button hover for sidebar (#2981) ([9922fcb](9922fcba65c8dc2c46c4f085813c2fbc0d0a7df6))
### Dependencies
* *(deps)* Update dependency vite to v4.0.2 (#2861)
* *(deps)* Update dependency netlify-cli to v12.4.0 (#2862)
* *(deps)* Update typescript-eslint monorepo to v5.47.0 (#2864)
* *(deps)* Update dependency esbuild to v0.16.10 (#2865)
* *(deps)* Update dependency sass to v1.57.1 (#2866)
* *(deps)* Update dependency vue-tsc to v1.0.16 (#2867)
* *(deps)* Update dependency codemirror to v5.65.11
* *(deps)* Update dependency @vueuse/core to v9.8.0
* *(deps)* Update dependency vitest to v0.26.1
* *(deps)* Update dependency @vueuse/core to v9.8.1 (#2870)
* *(deps)* Update dependency @vueuse/core to v9.8.2
* *(deps)* Update sentry-javascript monorepo to v7.28.0
* *(deps)* Update dependency cypress to v12.2.0 (#2873)
* *(deps)* Update dependency vitest to v0.26.2 (#2874)
* *(deps)* Update dependency vite to v4.0.3 (#2876)
* *(deps)* Update pnpm to v7.19.0 (#2875)
* *(deps)* Update dependency rollup to v3.8.0 (#2877)
* *(deps)* Update sentry-javascript monorepo to v7.28.1 (#2878)
* *(deps)* Update dependency @vueuse/core to v9.9.0 (#2881)
* *(deps)* Update dependency rollup to v3.8.1 (#2879)
* *(deps)* Update dependency vite-svg-loader to v4 (#2882)
* *(deps)* Update dependency vue-tsc to v1.0.17 (#2883)
* *(deps)* Update dependency caniuse-lite to v1.0.30001441 (#2884)
* *(deps)* Update dependency netlify-cli to v12.5.0 (#2886)
* *(deps)* Update pnpm to v7.20.0 (#2887)
* *(deps)* Update dependency vue-tsc to v1.0.18 (#2888)
* *(deps)* Update dependency happy-dom to v8.1.1 (#2885)
* *(deps)* Update dependency @types/node to v18.11.18 (#2889)
* *(deps)* Update typescript-eslint monorepo to v5.47.1 (#2890)
* *(deps)* Update dependency esbuild to v0.16.11
* *(deps)* Update dependency esbuild to v0.16.12 (#2893)
* *(deps)* Update dependency rollup to v3.9.0 (#2894)
* *(deps)* Update dependency rollup-plugin-visualizer to v5.9.0 (#2896)
* *(deps)* Update dependency marked to v4.2.5 (#2880)
* *(deps)* Update pnpm to v7.21.0 (#2895)
* *(deps)* Update dependency eslint to v8.31.0
* *(deps)* Update dependency vue-tsc to v1.0.19
* *(deps)* Update dependency @types/codemirror to v5.60.6
* *(deps)* Update dependency rollup to v3.9.1
* *(deps)* Update dependency vitest to v0.26.3
* *(deps)* Update dependency vite-plugin-pwa to v0.14.1 (#2909)
* *(deps)* Update dependency esbuild to v0.16.13 (#2907)
* *(deps)* Update typescript-eslint monorepo to v5.48.0 (#2906)
* *(deps)* Update dependency vue-tsc to v1.0.20
* *(deps)* Update dependency cypress to v12.3.0
* *(deps)* Update dependency @vueuse/core to v9.10.0 (#2911)
* *(deps)* Update pnpm to v7.22.0 (#2910)
* *(deps)* Update dependency @vue/test-utils to v2.2.7 (#2914)
* *(deps)* Update dependency vite to v4.0.4 (#2908)
* *(deps)* Update sentry-javascript monorepo to v7.29.0 (#2915)
* *(deps)* Update dependency esbuild to v0.16.14
* *(deps)* Update dependency axios to v1
* *(deps)* Update dependency vue-tsc to v1.0.21
* *(deps)* Update dependency vue-tsc to v1.0.22
* *(deps)* Update dependency dompurify to v2.4.2
* *(deps)* Update dependency dompurify to v2.4.3 (#2931)
* *(deps)* Update dependency postcss to v8.4.21 (#2933)
* *(deps)* Update dependency esbuild to v0.16.15 (#2934)
* *(deps)* Update dependency vue-tsc to v1.0.24
* *(deps)* Update pnpm to v7.23.0 (#2940)
* *(deps)* Update dependency happy-dom to v8.1.3 (#2939)
* *(deps)* Update dependency esbuild to v0.16.16 (#2937)
* *(deps)* Update dependency caniuse-lite to v1.0.30001442 (#2938)
* *(deps)* Update dependency vitest to v0.27.0 (#2941)
* *(deps)* Update typescript-eslint monorepo to v5.48.1 (#2942)
* *(deps)* Update pnpm to v7.24.2 (#2944)
* *(deps)* Update sentry-javascript monorepo to v7.30.0 (#2945)
* *(deps)* Update pnpm to v7.24.3 (#2946)
* *(deps)* Update dependency vitest to v0.27.1 (#2947)
* *(deps)* Update dependency esbuild to v0.16.17 (#2948)
* *(deps)* Update dependency rollup to v3.10.0 (#2949)
* *(deps)* Update dependency eslint-plugin-vue to v9.9.0 (#2950)
* *(deps)* Update pnpm to v7.25.0 (#2951)
* *(deps)* Update dependency marked to v4.2.12 (#2952)
* *(deps)* Update dependency esbuild to v0.17.0 (#2953)
* *(deps)* Update dependency eslint to v8.32.0 (#2954)
* *(deps)* Update dependency vue-advanced-cropper to v2.8.8 (#2955)
* *(deps)* Update dependency pinia to v2.0.29 (#2956)
* *(deps)* Update dependency @kyvg/vue3-notification to v2.8.0 (#2957)
* *(deps)* Update dependency caniuse-lite to v1.0.30001445 (#2958)
* *(deps)* Update dependency happy-dom to v8.1.4 (#2959)
* *(deps)* Update dependency netlify-cli to v12.7.2 (#2960)
* *(deps)* Update sentry-javascript monorepo to v7.31.0
* *(deps)* Update dependency esbuild to v0.17.1 (#2963)
* *(deps)* Update typescript-eslint monorepo to v5.48.2 (#2962)
* *(deps)* Update dependency esbuild to v0.17.2 (#2965)
* *(deps)* Update dependency vitest to v0.27.2 (#2966)
* *(deps)* Update dependency @vueuse/core to v9.11.0 (#2967)
* *(deps)* Update sentry-javascript monorepo to v7.31.1 (#2973)
* *(deps)* Update dependency axios to v1.2.3 (#2974)
* *(deps)* Update dependency esbuild to v0.17.3 (#2976)
* *(deps)* Update pnpm to v7.25.1 (#2977)
* *(deps)* Update dependency @vueuse/core to v9.11.1
* *(deps)* Update dependency rollup to v3.10.1
* *(deps)* Update dependency vite-plugin-inject-preload to v1.2.0 (#2983)
* *(deps)* Update dependency vitest to v0.27.3 (#2984)
* *(deps)* Update dependency esbuild to v0.17.4 (#2985)
* *(deps)* Update dependency caniuse-lite to v1.0.30001447 (#2986)
* *(deps)* Update dependency happy-dom to v8.1.5 (#2987)
* *(deps)* Update dependency netlify-cli to v12.9.1 (#2988)
* *(deps)* Update sentry-javascript monorepo to v7.32.1 (#2991)
* *(deps)* Update dependency vitest to v0.28.1 (#2990)
* *(deps)* Update dependency @types/codemirror to v5.60.7 (#2993)
* *(deps)* Update typescript-eslint monorepo to v5.49.0 (#2994)
* *(deps)* Update dependency start-server-and-test to v1.15.3
* *(deps)* Update dependency @fortawesome/vue-fontawesome to v3.0.3 (#3003)
### Features
* *(cypress)* Remove getSettled
* *(cypress)* Use cy.session
* *(i18n)* Add Norwegian translation
* *(netlify)* Abstract createSlug helper function (#2923)
* *(postcss)* Mock plugin types (#2930)
* Enable ts for rollup-plugin-visualizer (#2897) ([09d1352](09d13520b060e47be18640865befde44f59332e3))
* Remove date-fns formatISO (#2899) ([1f25386](1f25386f54f376357722e1e589d3a8bd8288a033))
* Add-task usability improvements (#2767) ([4be53b0](4be53b098ca909194aefb464a93b6dae99f4b9ab))
* Remove formatISO from list-view-gantt.spec (#2922) ([a29131e](a29131e7d4be2c83c3e9046549924d1f7692c95e))
* Add histoire ([7be8e89](7be8e892e2480f17cb5de6a69d35287906151c0f))
* Add XButton story ([ccc85b9](ccc85b9a828488dc849758f1e89f3ba3f75967d1))
* Add card story ([35cfb2f](35cfb2f3ca42ac83a9b943fc59818c978ee95fcc))
* Add histoire (#2724) ([a4424e0](a4424e089cdfadb4ab3b753e6fdca818bbe82dc4))
* Add describe project better in package.json (#2971) ([14466bf](14466bf9b7b8a3fc455c0d601205abbaf8cba4f5))
* Add .env.local.example (#2972) ([e1b35ff](e1b35ff023679a7cb8448a06e9edeb8eccc2f727))
* Fix broken font preloading (#2980) ([4890149](489014944a1544846875910d7d5e17e3d71b7e2d))
### Miscellaneous Tasks
* *(config)* Remove unused URL_PREFIX const (#2926)
* *(package)* Use pnpm commands (#2919)
* *(tests)* Fix macos cypress and align with create vite (#2898)
* Improve migrate title (#2968) ([56fd25e](56fd25e888cae8343f64a4c14ac5a3a760bdc7be))
* Add has content="false" to gantt charts (#2969) ([903e9a9](903e9a9904c18ced59962fc03b4c36e5ac8cd688))
* Use es6 imports for deploy-preview-netlify (#2970) ([2a2c27a](2a2c27af9226f441ec80d9d4f560b55cd357126c))
### Other
* *(other)* [skip ci] Updated translations via Crowdin
* *(other)* Redirect to oidc provider if configured correctly (#2805)
## [0.20.2] - 2022-12-18
### Bug Fixes

View File

@ -1,49 +1,70 @@
# Stage 1: Build application
FROM --platform=$BUILDPLATFORM node:18-alpine AS compile-image
# syntax=docker/dockerfile:1
# ┬─┐┬ ┐o┬ ┬─┐
# │─││ │││ │ │
# ┘─┘┘─┘┘┘─┘┘─┘
FROM --platform=$BUILDPLATFORM node:18-alpine AS builder
WORKDIR /build
ARG USE_RELEASE=false
ARG RELEASE_VERSION=main
ENV PNPM_CACHE_FOLDER .cache/pnpm/
ADD . ./
RUN \
if [ $USE_RELEASE = true ]; then \
wget https://dl.vikunja.io/frontend/vikunja-frontend-$RELEASE_VERSION.zip -O frontend-release.zip && \
unzip frontend-release.zip -d dist/ && \
exit 0; \
fi && \
# https://pnpm.io/installation#using-corepack
corepack enable && \
# we don't use corepack prepare here by intend since
# we have renovate to keep our dependencies up to date
# Build the frontend
pnpm install && \
apk add --no-cache git && \
echo '{"VERSION": "'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'"}' > src/version.json && \
pnpm run build
COPY package.json ./
COPY pnpm-lock.yaml ./
# Stage 2: copy
FROM nginx:alpine
RUN if [ "$USE_RELEASE" != true ]; then \
# https://pnpm.io/installation#using-corepack
corepack enable && \
pnpm install; \
fi
COPY nginx.conf /etc/nginx/nginx.conf
COPY scripts/run.sh /run.sh
COPY . ./
# copy compiled files from stage 1
COPY --from=compile-image /build/dist /usr/share/nginx/html
RUN if [ "$USE_RELEASE" != true ]; then \
apk add --no-cache --virtual .build-deps git jq && \
git describe --tags --always --abbrev=10 | sed 's/-/+/; s/^v//; s/-g/-/' | \
xargs -0 -I{} jq -Mcnr --arg version {} '{VERSION:$version}' | \
tee src/version.json && \
apk del .build-deps; \
fi
# Unprivileged user
ENV PUID 1000
ENV PGID 1000
RUN if [ "$USE_RELEASE" = true ]; then \
wget "https://dl.vikunja.io/frontend/vikunja-frontend-${RELEASE_VERSION}.zip" -O frontend-release.zip && \
unzip frontend-release.zip -d dist/; \
else \
# we don't use corepack prepare here by intend since
# we have renovate to keep our dependencies up to date
# Build the frontend
pnpm run build; \
fi
# ┌┐┐┌─┐o┌┐┐┐ │
# ││││ ┬││││┌┼┘
# ┘└┘┘─┘┘┘└┘┘ └
FROM nginx:stable-alpine AS runner
WORKDIR /usr/share/nginx/html
LABEL maintainer="maintainers@vikunja.io"
RUN apk add --no-cache \
# for sh file
bash \
# installs usermod and groupmod
shadow
ENV VIKUNJA_HTTP_PORT 80
ENV VIKUNJA_HTTP2_PORT 81
ENV VIKUNJA_LOG_FORMAT main
ENV VIKUNJA_API_URL /api/v1
ENV VIKUNJA_SENTRY_ENABLED false
ENV VIKUNJA_SENTRY_DSN https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480
CMD "/run.sh"
COPY docker/injector.sh /docker-entrypoint.d/50-injector.sh
COPY docker/ipv6-disable.sh /docker-entrypoint.d/60-ipv6-disable.sh
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY docker/templates/. /etc/nginx/templates/
# copy compiled files from stage 1
COPY --from=builder /build/dist ./
# manage permissions
RUN chmod 0755 /docker-entrypoint.d/*.sh /etc/nginx/templates && \
chmod -R 0644 /etc/nginx/nginx.conf && \
chown -R nginx:nginx ./ /etc/nginx/conf.d /etc/nginx/templates && \
rm -f /docker-entrypoint.d/10-listen-on-ipv6-by-default.sh
# unprivileged user
USER nginx

View File

@ -4,7 +4,7 @@
[![Build Status](https://drone.kolaente.de/api/badges/vikunja/frontend/status.svg)](https://drone.kolaente.de/vikunja/frontend)
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](LICENSE)
[![Download](https://img.shields.io/badge/download-v0.20.2-brightgreen.svg)](https://dl.vikunja.io)
[![Download](https://img.shields.io/badge/download-v0.20.3-brightgreen.svg)](https://dl.vikunja.io)
[![Translation](https://badges.crowdin.net/vikunja/localized.svg)](https://crowdin.com/project/vikunja)
This is the web frontend for Vikunja, written in Vue.js.
@ -18,6 +18,14 @@ If you find any security-related issues you don't want to disclose publicly, ple
## Docker
There is a [docker image available](https://hub.docker.com/r/vikunja/api) with support for http/2 and aggressive caching enabled.
In order to build it from sources run the command below. (Docker >= v19.03)
```shell
export DOCKER_BUILDKIT=1
docker build -t vikunja/frontend .
```
Refer to Refer [to multi-platform documentation](https://docs.docker.com/build/building/multi-platform/) in order to build for the different platform.
## Project setup
@ -43,6 +51,3 @@ pnpm run build
pnpm run lint
```
## Sponsors
[![Relm](https://vikunja.io/images/sponsors/relm.png)](https://relm.us)

View File

@ -0,0 +1,57 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {ListFactory} from '../../factories/list'
import {prepareLists} from './prepareLists'
describe('List History', () => {
createFakeUserAndLogin()
prepareLists()
it('should show a list history on the home page', () => {
cy.intercept(Cypress.env('API_URL') + '/namespaces*').as('loadNamespaces')
cy.intercept(Cypress.env('API_URL') + '/lists/*').as('loadList')
const lists = ListFactory.create(6)
cy.visit('/')
cy.wait('@loadNamespaces')
cy.get('body')
.should('not.contain', 'Last viewed')
cy.visit(`/lists/${lists[0].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
cy.visit(`/lists/${lists[1].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
cy.visit(`/lists/${lists[2].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
cy.visit(`/lists/${lists[3].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
cy.visit(`/lists/${lists[4].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
cy.visit(`/lists/${lists[5].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
// cy.visit('/')
// cy.wait('@loadNamespaces')
// Not using cy.visit here to work around the redirect issue fixed in #1337
cy.get('nav.menu.top-menu a')
.contains('Overview')
.click()
cy.get('body')
.should('contain', 'Last viewed')
cy.get('[data-cy="listCardGrid"]')
.should('not.contain', lists[0].title)
.should('contain', lists[1].title)
.should('contain', lists[2].title)
.should('contain', lists[3].title)
.should('contain', lists[4].title)
.should('contain', lists[5].title)
})
})

View File

@ -3,15 +3,15 @@ import {formatISO, format} from 'date-fns'
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {TaskFactory} from '../../factories/task'
import {prepareProjects} from './prepareProjects'
import {prepareLists} from './prepareLists'
describe('Project View Gantt', () => {
describe('List View Gantt', () => {
createFakeUserAndLogin()
prepareProjects()
prepareLists()
it('Hides tasks with no dates', () => {
const tasks = TaskFactory.create(1)
cy.visit('/projects/1/gantt')
cy.visit('/lists/1/gantt')
cy.get('.g-gantt-rows-container')
.should('not.contain', tasks[0].title)
@ -25,7 +25,7 @@ describe('Project View Gantt', () => {
nextMonth.setDate(1)
nextMonth.setMonth(9)
cy.visit('/projects/1/gantt')
cy.visit('/lists/1/gantt')
cy.get('.g-timeunits-container')
.should('contain', format(now, 'MMMM'))
@ -38,7 +38,7 @@ describe('Project View Gantt', () => {
start_date: now.toISOString(),
end_date: new Date(new Date(now).setDate(now.getDate() + 4)).toISOString(),
})
cy.visit('/projects/1/gantt')
cy.visit('/lists/1/gantt')
cy.get('.g-gantt-rows-container')
.should('not.be.empty')
@ -50,7 +50,7 @@ describe('Project View Gantt', () => {
start_date: null,
end_date: null,
})
cy.visit('/projects/1/gantt')
cy.visit('/lists/1/gantt')
cy.get('.gantt-options .fancycheckbox')
.contains('Show tasks which don\'t have dates set')
@ -69,7 +69,7 @@ describe('Project View Gantt', () => {
start_date: now.toISOString(),
end_date: new Date(new Date(now).setDate(now.getDate() + 4)).toISOString(),
})
cy.visit('/projects/1/gantt')
cy.visit('/lists/1/gantt')
cy.get('.g-gantt-rows-container .g-gantt-row .g-gantt-row-bars-container div .g-gantt-bar')
.first()
@ -83,9 +83,9 @@ describe('Project View Gantt', () => {
const now = Date.UTC(2022, 10, 9)
cy.clock(now, ['Date'])
cy.visit('/projects/1/gantt')
cy.visit('/lists/1/gantt')
cy.get('.project-gantt .gantt-options .field .control input.input.form-control')
cy.get('.list-gantt .gantt-options .field .control input.input.form-control')
.click()
cy.get('.flatpickr-calendar .flatpickr-innerContainer .dayContainer .flatpickr-day')
.first()
@ -99,13 +99,13 @@ describe('Project View Gantt', () => {
})
it('Should change the date range based on date query parameters', () => {
cy.visit('/projects/1/gantt?dateFrom=2022-09-25&dateTo=2022-11-05')
cy.visit('/lists/1/gantt?dateFrom=2022-09-25&dateTo=2022-11-05')
cy.get('.g-timeunits-container')
.should('contain', 'September 2022')
.should('contain', 'October 2022')
.should('contain', 'November 2022')
cy.get('.project-gantt .gantt-options .field .control input.input.form-control')
cy.get('.list-gantt .gantt-options .field .control input.input.form-control')
.should('have.value', '25 Sep 2022 to 5 Nov 2022')
})
@ -115,7 +115,7 @@ describe('Project View Gantt', () => {
start_date: formatISO(now),
end_date: formatISO(now.setDate(now.getDate() + 4)),
})
cy.visit('/projects/1/gantt')
cy.visit('/lists/1/gantt')
cy.get('.gantt-container .g-gantt-chart .g-gantt-row-bars-container .g-gantt-bar')
.dblclick()

View File

@ -1,13 +1,13 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {BucketFactory} from '../../factories/bucket'
import {ProjectFactory} from '../../factories/project'
import {ListFactory} from '../../factories/list'
import {TaskFactory} from '../../factories/task'
import {prepareProjects} from './prepareProjects'
import {prepareLists} from './prepareLists'
describe('Project View Kanban', () => {
describe('List View Kanban', () => {
createFakeUserAndLogin()
prepareProjects()
prepareLists()
let buckets
beforeEach(() => {
@ -16,10 +16,10 @@ describe('Project View Kanban', () => {
it('Shows all buckets with their tasks', () => {
const data = TaskFactory.create(10, {
project_id: 1,
list_id: 1,
bucket_id: 1,
})
cy.visit('/projects/1/kanban')
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .title')
.contains(buckets[0].title)
@ -34,10 +34,10 @@ describe('Project View Kanban', () => {
it('Can add a new task to a bucket', () => {
TaskFactory.create(2, {
project_id: 1,
list_id: 1,
bucket_id: 1,
})
cy.visit('/projects/1/kanban')
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket')
.contains(buckets[0].title)
@ -55,7 +55,7 @@ describe('Project View Kanban', () => {
})
it('Can create a new bucket', () => {
cy.visit('/projects/1/kanban')
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket.new-bucket .button')
.click()
@ -69,7 +69,7 @@ describe('Project View Kanban', () => {
})
it('Can set a bucket limit', () => {
cy.visit('/projects/1/kanban')
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
.first()
@ -90,7 +90,7 @@ describe('Project View Kanban', () => {
})
it('Can rename a bucket', () => {
cy.visit('/projects/1/kanban')
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .bucket-header .title')
.first()
@ -101,7 +101,7 @@ describe('Project View Kanban', () => {
})
it('Can delete a bucket', () => {
cy.visit('/projects/1/kanban')
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
.first()
@ -125,10 +125,10 @@ describe('Project View Kanban', () => {
it('Can drag tasks around', () => {
const tasks = TaskFactory.create(2, {
project_id: 1,
list_id: 1,
bucket_id: 1,
})
cy.visit('/projects/1/kanban')
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
@ -144,10 +144,10 @@ describe('Project View Kanban', () => {
it('Should navigate to the task when the task card is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
project_id: 1,
list_id: 1,
bucket_id: 1,
})
cy.visit('/projects/1/kanban')
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
@ -158,18 +158,18 @@ describe('Project View Kanban', () => {
.should('contain', `/tasks/${tasks[0].id}`, { timeout: 1000 })
})
it('Should remove a task from the kanban board when moving it to another project', () => {
const projects = ProjectFactory.create(2)
it('Should remove a task from the kanban board when moving it to another list', () => {
const lists = ListFactory.create(2)
BucketFactory.create(2, {
project_id: '{increment}',
list_id: '{increment}',
})
const tasks = TaskFactory.create(5, {
id: '{increment}',
project_id: 1,
list_id: 1,
bucket_id: 1,
})
const task = tasks[0]
cy.visit('/projects/1/kanban')
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .tasks .task')
.contains(task.title)
@ -180,7 +180,7 @@ describe('Project View Kanban', () => {
.contains('Move')
.click()
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
.type(`${projects[1].title}{enter}`)
.type(`${lists[1].title}{enter}`)
// The requests happen with a 200ms timeout. Because of that, the results are not yet there when cypress
// presses enter and we can't simulate pressing on enter to select the item.
cy.get('.task-view .content.details .field .multiselect.control .search-results')
@ -197,26 +197,26 @@ describe('Project View Kanban', () => {
it('Shows a button to filter the kanban board', () => {
const data = TaskFactory.create(10, {
project_id: 1,
list_id: 1,
bucket_id: 1,
})
cy.visit('/projects/1/kanban')
cy.visit('/lists/1/kanban')
cy.get('.project-kanban .filter-container .base-button')
cy.get('.list-kanban .filter-container .base-button')
.should('exist')
})
it('Should remove a task from the board when deleting it', () => {
const projects = ProjectFactory.create(1)
const lists = ListFactory.create(1)
const buckets = BucketFactory.create(2, {
project_id: projects[0].id,
list_id: lists[0].id,
})
const tasks = TaskFactory.create(5, {
project_id: 1,
list_id: 1,
bucket_id: buckets[0].id,
})
const task = tasks[0]
cy.visit('/projects/1/kanban')
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .tasks .task')
.contains(task.title)

View File

@ -1,32 +1,32 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {UserProjectFactory} from '../../factories/users_project'
import {UserListFactory} from '../../factories/users_list'
import {TaskFactory} from '../../factories/task'
import {UserFactory} from '../../factories/user'
import {ProjectFactory} from '../../factories/project'
import {prepareProjects} from './prepareProjects'
import {ListFactory} from '../../factories/list'
import {prepareLists} from './prepareLists'
describe('Project View Project', () => {
describe('List View List', () => {
createFakeUserAndLogin()
prepareProjects()
prepareLists()
it('Should be an empty project', () => {
cy.visit('/projects/1')
it('Should be an empty list', () => {
cy.visit('/lists/1')
cy.url()
.should('contain', '/projects/1/list')
cy.get('.project-title h1')
.should('contain', 'First Project')
cy.get('.project-title .dropdown')
.should('contain', '/lists/1/list')
cy.get('.list-title')
.should('contain', 'First List')
cy.get('.list-title-dropdown')
.should('exist')
cy.get('p')
.contains('This project is currently empty.')
.contains('This list is currently empty.')
.should('exist')
})
it('Should create a new task', () => {
const newTaskTitle = 'New task'
cy.visit('/projects/1')
cy.visit('/lists/1')
cy.get('.task-add textarea')
.type(newTaskTitle+'{enter}')
cy.get('.tasks')
@ -36,9 +36,9 @@ describe('Project View Project', () => {
it('Should navigate to the task when the title is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
project_id: 1,
list_id: 1,
})
cy.visit('/projects/1/list')
cy.visit('/lists/1/list')
cy.get('.tasks .task .tasktext')
.contains(tasks[0].title)
@ -49,33 +49,33 @@ describe('Project View Project', () => {
.should('contain', `/tasks/${tasks[0].id}`)
})
it('Should not see any elements for a project which is shared read only', () => {
it('Should not see any elements for a list which is shared read only', () => {
UserFactory.create(2)
UserProjectFactory.create(1, {
project_id: 2,
UserListFactory.create(1, {
list_id: 2,
user_id: 1,
right: 0,
})
const projects = ProjectFactory.create(2, {
const lists = ListFactory.create(2, {
owner_id: '{increment}',
namespace_id: '{increment}',
})
cy.visit(`/projects/${projects[1].id}/`)
cy.visit(`/lists/${lists[1].id}/`)
cy.get('.project-title .icon')
cy.get('.list-title-wrapper .icon')
.should('not.exist')
cy.get('input.input[placeholder="Add a new task..."')
.should('not.exist')
})
it('Should only show the color of a project in the navigation and not in the project view', () => {
const projects = ProjectFactory.create(1, {
it('Should only show the color of a list in the navigation and not in the list view', () => {
const lists = ListFactory.create(1, {
hex_color: '00db60',
})
TaskFactory.create(10, {
project_id: projects[0].id,
list_id: lists[0].id,
})
cy.visit(`/projects/${projects[0].id}/`)
cy.visit(`/lists/${lists[0].id}/`)
cy.get('.menu-list li .list-menu-link .color-bubble')
.should('have.css', 'background-color', 'rgb(0, 219, 96)')
@ -87,9 +87,9 @@ describe('Project View Project', () => {
const tasks = TaskFactory.create(100, {
id: '{increment}',
title: i => `task${i}`,
project_id: 1,
list_id: 1,
})
cy.visit('/projects/1/list')
cy.visit('/lists/1/list')
cy.get('.tasks')
.should('contain', tasks[1].title)

View File

@ -2,37 +2,37 @@ import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {TaskFactory} from '../../factories/task'
describe('Project View Table', () => {
describe('List View Table', () => {
createFakeUserAndLogin()
it('Should show a table with tasks', () => {
const tasks = TaskFactory.create(1)
cy.visit('/projects/1/table')
cy.visit('/lists/1/table')
cy.get('.project-table table.table')
cy.get('.list-table table.table')
.should('exist')
cy.get('.project-table table.table')
cy.get('.list-table table.table')
.should('contain', tasks[0].title)
})
it('Should have working column switches', () => {
TaskFactory.create(1)
cy.visit('/projects/1/table')
cy.visit('/lists/1/table')
cy.get('.project-table .filter-container .items .button')
cy.get('.list-table .filter-container .items .button')
.contains('Columns')
.click()
cy.get('.project-table .filter-container .card.columns-filter .card-content .fancycheckbox .check')
cy.get('.list-table .filter-container .card.columns-filter .card-content .fancycheckbox .check')
.contains('Priority')
.click()
cy.get('.project-table .filter-container .card.columns-filter .card-content .fancycheckbox .check')
cy.get('.list-table .filter-container .card.columns-filter .card-content .fancycheckbox .check')
.contains('Done')
.click()
cy.get('.project-table table.table th')
cy.get('.list-table table.table th')
.contains('Priority')
.should('exist')
cy.get('.project-table table.table th')
cy.get('.list-table table.table th')
.contains('Done')
.should('not.exist')
})
@ -40,11 +40,11 @@ describe('Project View Table', () => {
it('Should navigate to the task when the title is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
project_id: 1,
list_id: 1,
})
cy.visit('/projects/1/table')
cy.visit('/lists/1/table')
cy.get('.project-table table.table')
cy.get('.list-table table.table')
.contains(tasks[0].title)
.click()

View File

@ -1,58 +1,58 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {TaskFactory} from '../../factories/task'
import {prepareProjects} from './prepareProjects'
import {prepareLists} from './prepareLists'
describe('Projects', () => {
describe('Lists', () => {
createFakeUserAndLogin()
let projects
prepareProjects((newProjects) => (projects = newProjects))
let lists
prepareLists((newLists) => (lists = newLists))
it('Should create a new project', () => {
it('Should create a new list', () => {
cy.visit('/')
cy.get('.namespace-title .dropdown-trigger')
.click()
cy.get('.namespace-title .dropdown .dropdown-item')
.contains('New project')
.contains('New list')
.click()
cy.url()
.should('contain', '/projects/new/1')
.should('contain', '/lists/new/1')
cy.get('.card-header-title')
.contains('New project')
.contains('New list')
cy.get('input.input')
.type('New Project')
.type('New List')
cy.get('.button')
.contains('Create')
.click()
cy.get('.global-notification', { timeout: 1000 }) // Waiting until the request to create the new project is done
cy.get('.global-notification', { timeout: 1000 }) // Waiting until the request to create the new list is done
.should('contain', 'Success')
cy.url()
.should('contain', '/projects/')
cy.get('.project-title h1')
.should('contain', 'New Project')
.should('contain', '/lists/')
cy.get('.list-title')
.should('contain', 'New List')
})
it('Should redirect to a specific project view after visited', () => {
cy.visit('/projects/1/kanban')
it('Should redirect to a specific list view after visited', () => {
cy.visit('/lists/1/kanban')
cy.url()
.should('contain', '/projects/1/kanban')
cy.visit('/projects/1')
.should('contain', '/lists/1/kanban')
cy.visit('/lists/1')
cy.url()
.should('contain', '/projects/1/kanban')
.should('contain', '/lists/1/kanban')
})
it('Should rename the project in all places', () => {
it('Should rename the list in all places', () => {
TaskFactory.create(5, {
id: '{increment}',
project_id: 1,
list_id: 1,
})
const newProjectName = 'New project name'
const newListName = 'New list name'
cy.visit('/projects/1')
cy.get('.project-title h1')
.should('contain', 'First Project')
cy.visit('/lists/1')
cy.get('.list-title')
.should('contain', 'First List')
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
.click()
@ -60,27 +60,27 @@ describe('Projects', () => {
.contains('Edit')
.click()
cy.get('#title')
.type(`{selectall}${newProjectName}`)
.type(`{selectall}${newListName}`)
cy.get('footer.card-footer .button')
.contains('Save')
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.project-title h1')
.should('contain', newProjectName)
.should('not.contain', projects[0].title)
cy.get('.list-title')
.should('contain', newListName)
.should('not.contain', lists[0].title)
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child')
.should('contain', newProjectName)
.should('not.contain', projects[0].title)
.should('contain', newListName)
.should('not.contain', lists[0].title)
cy.visit('/')
cy.get('.card-content')
.should('contain', newProjectName)
.should('not.contain', projects[0].title)
.should('contain', newListName)
.should('not.contain', lists[0].title)
})
it('Should remove a project', () => {
cy.visit(`/projects/${projects[0].id}`)
it('Should remove a list', () => {
cy.visit(`/lists/${lists[0].id}`)
cy.get('.namespace-container .menu.namespaces-lists .menu-list li:first-child .dropdown .menu-list-dropdown-trigger')
.click()
@ -96,27 +96,27 @@ describe('Projects', () => {
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.namespace-container .menu.namespaces-lists .menu-list')
.should('not.contain', projects[0].title)
.should('not.contain', lists[0].title)
cy.location('pathname')
.should('equal', '/')
})
it('Should archive a project', () => {
cy.visit(`/projects/${projects[0].id}`)
it('Should archive a list', () => {
cy.visit(`/lists/${lists[0].id}`)
cy.get('.project-title .dropdown')
cy.get('.list-title-dropdown')
.click()
cy.get('.project-title .dropdown .dropdown-menu .dropdown-item')
cy.get('.list-title-dropdown .dropdown-menu .dropdown-item')
.contains('Archive')
.click()
cy.get('.modal-content')
.should('contain.text', 'Archive this project')
.should('contain.text', 'Archive this list')
cy.get('.modal-content [data-cy=modalPrimary]')
.click()
cy.get('.namespace-container .menu.namespaces-lists .menu-list')
.should('not.contain', projects[0].title)
.should('not.contain', lists[0].title)
cy.get('main.app-content')
.should('contain.text', 'This project is archived. It is not possible to create new or edit tasks for it.')
.should('contain.text', 'This list is archived. It is not possible to create new or edit tasks for it.')
})
})

View File

@ -1,6 +1,6 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {ProjectFactory} from '../../factories/project'
import {ListFactory} from '../../factories/list'
import {NamespaceFactory} from '../../factories/namespace'
describe('Namepaces', () => {
@ -10,7 +10,7 @@ describe('Namepaces', () => {
beforeEach(() => {
namespaces = NamespaceFactory.create(1)
ProjectFactory.create(1)
ListFactory.create(1)
})
it('Should be all there', () => {
@ -99,17 +99,17 @@ describe('Namepaces', () => {
.should('not.contain', newNamespaces[0].title)
})
it('Should not show archived projects & namespaces if the filter is not checked', () => {
it('Should not show archived lists & namespaces if the filter is not checked', () => {
const n = NamespaceFactory.create(1, {
id: 2,
is_archived: true,
}, false)
ProjectFactory.create(1, {
ListFactory.create(1, {
id: 2,
namespace_id: n[0].id,
}, false)
ProjectFactory.create(1, {
ListFactory.create(1, {
id: 3,
is_archived: true,
}, false)

View File

@ -0,0 +1,19 @@
import {ListFactory} from '../../factories/list'
import {NamespaceFactory} from '../../factories/namespace'
import {TaskFactory} from '../../factories/task'
export function createLists() {
NamespaceFactory.create(1)
const lists = ListFactory.create(1, {
title: 'First List'
})
TaskFactory.truncate()
return lists
}
export function prepareLists(setLists = (...args: any[]) => {}) {
beforeEach(() => {
const lists = createLists()
setLists(lists)
})
}

View File

@ -1,18 +1,18 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {TaskFactory} from '../../factories/task'
import {ProjectFactory} from '../../factories/project'
import {ListFactory} from '../../factories/list'
import {NamespaceFactory} from '../../factories/namespace'
import {UserProjectFactory} from '../../factories/users_project'
import {UserListFactory} from '../../factories/users_list'
describe('Editor', () => {
createFakeUserAndLogin()
beforeEach(() => {
NamespaceFactory.create(1)
ProjectFactory.create(1)
ListFactory.create(1)
TaskFactory.truncate()
UserProjectFactory.truncate()
UserListFactory.truncate()
})
it('Has a preview with checkable checkboxes', () => {

View File

@ -1,19 +0,0 @@
import {ProjectFactory} from '../../factories/project'
import {NamespaceFactory} from '../../factories/namespace'
import {TaskFactory} from '../../factories/task'
export function createProjects() {
NamespaceFactory.create(1)
const projects = ProjectFactory.create(1, {
title: 'First Project'
})
TaskFactory.truncate()
return projects
}
export function prepareProjects(setProjects = () => {}) {
beforeEach(() => {
const projects = createProjects()
setProjects(projects)
})
}

View File

@ -1,57 +0,0 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {ProjectFactory} from '../../factories/project'
import {prepareProjects} from './prepareProjects'
describe('Project History', () => {
createFakeUserAndLogin()
prepareProjects()
it('should show a project history on the home page', () => {
cy.intercept(Cypress.env('API_URL') + '/namespaces*').as('loadNamespaces')
cy.intercept(Cypress.env('API_URL') + '/projects/*').as('loadProject')
const projects = ProjectFactory.create(6)
cy.visit('/')
cy.wait('@loadNamespaces')
cy.get('body')
.should('not.contain', 'Last viewed')
cy.visit(`/projects/${projects[0].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadProject')
cy.visit(`/projects/${projects[1].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadProject')
cy.visit(`/projects/${projects[2].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadProject')
cy.visit(`/projects/${projects[3].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadProject')
cy.visit(`/projects/${projects[4].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadProject')
cy.visit(`/projects/${projects[5].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadProject')
// cy.visit('/')
// cy.wait('@loadNamespaces')
// Not using cy.visit here to work around the redirect issue fixed in #1337
cy.get('nav.menu.top-menu a')
.contains('Overview')
.click()
cy.get('body')
.should('contain', 'Last viewed')
cy.get('[data-cy="projectCardGrid"]')
.should('not.contain', projects[0].title)
.should('contain', projects[1].title)
.should('contain', projects[2].title)
.should('contain', projects[3].title)
.should('contain', projects[4].title)
.should('contain', projects[5].title)
})
})

View File

@ -1,22 +1,22 @@
import {LinkShareFactory} from '../../factories/link_sharing'
import {ProjectFactory} from '../../factories/project'
import {ListFactory} from '../../factories/list'
import {TaskFactory} from '../../factories/task'
describe('Link shares', () => {
it('Can view a link share', () => {
const projects = ProjectFactory.create(1)
const lists = ListFactory.create(1)
const tasks = TaskFactory.create(10, {
project_id: projects[0].id
list_id: lists[0].id
})
const linkShares = LinkShareFactory.create(1, {
project_id: projects[0].id,
list_id: lists[0].id,
right: 0,
})
cy.visit(`/share/${linkShares[0].hash}/auth`)
cy.get('h1.title')
.should('contain', projects[0].title)
.should('contain', lists[0].title)
cy.get('input.input[placeholder="Add a new task..."')
.should('not.exist')
cy.get('.tasks')

View File

@ -1,6 +1,6 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {ProjectFactory} from '../../factories/project'
import {ListFactory} from '../../factories/list'
import {seed} from '../../support/seed'
import {TaskFactory} from '../../factories/task'
import {NamespaceFactory} from '../../factories/namespace'
@ -9,9 +9,9 @@ import {updateUserSettings} from '../../support/updateUserSettings'
function seedTasks(numberOfTasks = 50, startDueDate = new Date()) {
NamespaceFactory.create(1)
const project = ProjectFactory.create()[0]
const list = ListFactory.create()[0]
BucketFactory.create(1, {
project_id: project.id,
list_id: list.id,
})
const tasks = []
let dueDate = startDueDate
@ -20,7 +20,7 @@ function seedTasks(numberOfTasks = 50, startDueDate = new Date()) {
dueDate = new Date(new Date(dueDate).setDate(dueDate.getDate() + 2))
tasks.push({
id: i + 1,
project_id: project.id,
list_id: list.id,
done: false,
created_by_id: 1,
title: 'Test Task ' + i,
@ -31,7 +31,7 @@ function seedTasks(numberOfTasks = 50, startDueDate = new Date()) {
})
}
seed(TaskFactory.table, tasks)
return {tasks, project}
return {tasks, list}
}
describe('Home Page Task Overview', () => {
@ -73,7 +73,7 @@ describe('Home Page Task Overview', () => {
due_date: new Date().toISOString(),
}, false)
cy.visit(`/projects/${tasks[0].project_id}/list`)
cy.visit(`/lists/${tasks[0].list_id}/list`)
cy.get('.tasks .task')
.first()
.should('contain.text', newTaskTitle)
@ -90,7 +90,7 @@ describe('Home Page Task Overview', () => {
cy.visit('/')
cy.visit(`/projects/${tasks[0].project_id}/list`)
cy.visit(`/lists/${tasks[0].list_id}/list`)
cy.get('.task-add textarea')
.type(newTaskTitle+'{enter}')
cy.visit('/')
@ -113,10 +113,10 @@ describe('Home Page Task Overview', () => {
.should('contain.text', newTaskTitle)
})
it('Should show a task without a due date added via default project at the bottom', () => {
const {project} = seedTasks(40)
it('Should show a task without a due date added via default list at the bottom', () => {
const {list} = seedTasks(40)
updateUserSettings({
default_project_id: project.id,
default_list_id: list.id,
overdue_tasks_reminders_time: '9:00',
})
@ -131,23 +131,23 @@ describe('Home Page Task Overview', () => {
.should('contain.text', newTaskTitle)
})
it('Should show the cta buttons for new project when there are no tasks', () => {
it('Should show the cta buttons for new list when there are no tasks', () => {
TaskFactory.truncate()
cy.visit('/')
cy.get('.home.app-content .content')
.should('contain.text', 'You can create a new project for your new tasks:')
.should('contain.text', 'Or import your projects and tasks from other services into Vikunja:')
.should('contain.text', 'You can create a new list for your new tasks:')
.should('contain.text', 'Or import your lists and tasks from other services into Vikunja:')
})
it('Should not show the cta buttons for new project when there are tasks', () => {
it('Should not show the cta buttons for new list when there are tasks', () => {
seedTasks()
cy.visit('/')
cy.get('.home.app-content .content')
.should('not.contain.text', 'You can create a new project for your new tasks:')
.should('not.contain.text', 'Or import your projects and tasks from other services into Vikunja:')
.should('not.contain.text', 'You can create a new list for your new tasks:')
.should('not.contain.text', 'Or import your lists and tasks from other services into Vikunja:')
})
})

View File

@ -1,11 +1,11 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {TaskFactory} from '../../factories/task'
import {ProjectFactory} from '../../factories/project'
import {ListFactory} from '../../factories/list'
import {TaskCommentFactory} from '../../factories/task_comment'
import {UserFactory} from '../../factories/user'
import {NamespaceFactory} from '../../factories/namespace'
import {UserProjectFactory} from '../../factories/users_project'
import {UserListFactory} from '../../factories/users_list'
import {TaskAssigneeFactory} from '../../factories/task_assignee'
import {LabelFactory} from '../../factories/labels'
import {LabelTaskFactory} from '../../factories/label_task'
@ -48,22 +48,22 @@ describe('Task', () => {
createFakeUserAndLogin()
let namespaces
let projects
let lists
let buckets
beforeEach(() => {
// UserFactory.create(1)
namespaces = NamespaceFactory.create(1)
projects = ProjectFactory.create(1)
lists = ListFactory.create(1)
buckets = BucketFactory.create(1, {
project_id: projects[0].id,
list_id: lists[0].id,
})
TaskFactory.truncate()
UserProjectFactory.truncate()
UserListFactory.truncate()
})
it('Should be created new', () => {
cy.visit('/projects/1/list')
cy.visit('/lists/1/list')
cy.get('.input[placeholder="Add a new task…"')
.type('New Task')
cy.get('.button')
@ -74,11 +74,11 @@ describe('Task', () => {
.should('contain', 'New Task')
})
it('Inserts new tasks at the top of the project', () => {
it('Inserts new tasks at the top of the list', () => {
TaskFactory.create(1)
cy.visit('/projects/1/list')
cy.get('.project-is-empty-notice')
cy.visit('/lists/1/list')
cy.get('.list-is-empty-notice')
.should('not.exist')
cy.get('.input[placeholder="Add a new task…"')
.type('New Task')
@ -95,7 +95,7 @@ describe('Task', () => {
it('Marks a task as done', () => {
TaskFactory.create(1)
cy.visit('/projects/1/list')
cy.visit('/lists/1/list')
cy.get('.tasks .task .fancycheckbox label.check')
.first()
.click()
@ -106,7 +106,7 @@ describe('Task', () => {
it('Can add a task to favorites', () => {
TaskFactory.create(1)
cy.visit('/projects/1/list')
cy.visit('/lists/1/list')
cy.get('.tasks .task .favorite')
.first()
.click()
@ -134,7 +134,7 @@ describe('Task', () => {
.should('contain', '#1')
cy.get('.task-view h6.subtitle')
.should('contain', namespaces[0].title)
.should('contain', projects[0].title)
.should('contain', lists[0].title)
cy.get('.task-view .details.content.description')
.should('contain', tasks[0].description)
cy.get('.task-view .action-buttons p.created')
@ -179,21 +179,21 @@ describe('Task', () => {
.should('contain', 'Mark as undone')
})
it('Shows a task identifier since the project has one', () => {
const projects = ProjectFactory.create(1, {
it('Shows a task identifier since the list has one', () => {
const lists = ListFactory.create(1, {
id: 1,
identifier: 'TEST',
})
const tasks = TaskFactory.create(1, {
id: 1,
project_id: projects[0].id,
list_id: lists[0].id,
index: 1,
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view h1.title.task-id')
.should('contain', `${projects[0].identifier}-${tasks[0].index}`)
.should('contain', `${lists[0].identifier}-${tasks[0].index}`)
})
it('Can edit the description', () => {
@ -236,14 +236,14 @@ describe('Task', () => {
.should('contain', 'Success')
})
it('Can move a task to another project', () => {
const projects = ProjectFactory.create(2)
it('Can move a task to another list', () => {
const lists = ListFactory.create(2)
BucketFactory.create(2, {
project_id: '{increment}'
list_id: '{increment}'
})
const tasks = TaskFactory.create(1, {
id: 1,
project_id: projects[0].id,
list_id: lists[0].id,
})
cy.visit(`/tasks/${tasks[0].id}`)
@ -251,7 +251,7 @@ describe('Task', () => {
.contains('Move')
.click()
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
.type(`${projects[1].title}{enter}`)
.type(`${lists[1].title}{enter}`)
// The requests happen with a 200ms timeout. Because of that, the results are not yet there when cypress
// presses enter and we can't simulate pressing on enter to select the item.
cy.get('.task-view .content.details .field .multiselect.control .search-results')
@ -261,7 +261,7 @@ describe('Task', () => {
cy.get('.task-view h6.subtitle')
.should('contain', namespaces[0].title)
.should('contain', projects[1].title)
.should('contain', lists[1].title)
cy.get('.global-notification')
.should('contain', 'Success')
})
@ -269,7 +269,7 @@ describe('Task', () => {
it('Can delete a task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
project_id: 1,
list_id: 1,
})
cy.visit(`/tasks/${tasks[0].id}`)
@ -286,17 +286,17 @@ describe('Task', () => {
cy.get('.global-notification')
.should('contain', 'Success')
cy.url()
.should('contain', `/projects/${tasks[0].project_id}/`)
.should('contain', `/lists/${tasks[0].list_id}/`)
})
it('Can add an assignee to a task', () => {
const users = UserFactory.create(5)
const tasks = TaskFactory.create(1, {
id: 1,
project_id: 1,
list_id: 1,
})
UserProjectFactory.create(5, {
project_id: 1,
UserListFactory.create(5, {
list_id: 1,
user_id: '{increment}',
})
@ -321,10 +321,10 @@ describe('Task', () => {
const users = UserFactory.create(2)
const tasks = TaskFactory.create(1, {
id: 1,
project_id: 1,
list_id: 1,
})
UserProjectFactory.create(5, {
project_id: 1,
UserListFactory.create(5, {
list_id: 1,
user_id: '{increment}',
})
TaskAssigneeFactory.create(1, {
@ -347,7 +347,7 @@ describe('Task', () => {
it('Can add a new label to a task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
project_id: 1,
list_id: 1,
})
LabelFactory.truncate()
const newLabelText = 'some new label'
@ -375,7 +375,7 @@ describe('Task', () => {
it('Can add an existing label to a task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
project_id: 1,
list_id: 1,
})
const labels = LabelFactory.create(1)
LabelTaskFactory.truncate()
@ -388,13 +388,13 @@ describe('Task', () => {
it('Can add a label to a task and it shows up on the kanban board afterwards', () => {
const tasks = TaskFactory.create(1, {
id: 1,
project_id: projects[0].id,
list_id: lists[0].id,
bucket_id: buckets[0].id,
})
const labels = LabelFactory.create(1)
LabelTaskFactory.truncate()
cy.visit(`/projects/${projects[0].id}/kanban`)
cy.visit(`/lists/${lists[0].id}/kanban`)
cy.get('.bucket .task')
.contains(tasks[0].title)
@ -412,7 +412,7 @@ describe('Task', () => {
it('Can remove a label from a task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
project_id: 1,
list_id: 1,
})
const labels = LabelFactory.create(1)
LabelTaskFactory.create(1, {
@ -527,13 +527,13 @@ describe('Task', () => {
TaskAttachmentFactory.truncate()
const tasks = TaskFactory.create(1, {
id: 1,
project_id: projects[0].id,
list_id: lists[0].id,
bucket_id: buckets[0].id,
})
const labels = LabelFactory.create(1)
LabelTaskFactory.truncate()
cy.visit(`/projects/${projects[0].id}/kanban`)
cy.visit(`/lists/${lists[0].id}/kanban`)
cy.get('.bucket .task')
.contains(tasks[0].title)

View File

@ -1,10 +1,10 @@
import {createFakeUserAndLogin} from '../../support/authenticateUser'
import {createProjects} from '../project/prepareProjects'
import {createLists} from '../list/prepareLists'
function logout() {
cy.get('.navbar .user .username')
cy.get('.navbar .username-dropdown-trigger')
.click()
cy.get('.navbar .user .dropdown-menu .dropdown-item')
cy.get('.navbar .dropdown-item')
.contains('Logout')
.click()
}
@ -26,21 +26,21 @@ describe('Log out', () => {
})
})
it.skip('Should clear the project history after logging the user out', () => {
const projects = createProjects()
cy.visit(`/projects/${projects[0].id}`)
it.skip('Should clear the list history after logging the user out', () => {
const lists = createLists()
cy.visit(`/lists/${lists[0].id}`)
.then(() => {
expect(localStorage.getItem('projectHistory')).to.not.eq(null)
expect(localStorage.getItem('listHistory')).to.not.eq(null)
})
logout()
cy.wait(1000) // This makes re-loading of the project and associated entities (and the resulting error) visible
cy.wait(1000) // This makes re-loading of the list and associated entities (and the resulting error) visible
cy.url()
.should('contain', '/login')
.then(() => {
expect(localStorage.getItem('projectHistory')).to.eq(null)
expect(localStorage.getItem('listHistory')).to.eq(null)
})
})
})

View File

@ -37,7 +37,7 @@ describe('User Settings', () => {
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.navbar .user .username')
cy.get('.navbar .username-dropdown-trigger .username')
.should('contain', 'Lorem Ipsum')
})
})

View File

@ -10,7 +10,7 @@ export class BucketFactory extends Factory {
return {
id: '{increment}',
title: faker.lorem.words(3),
project_id: 1,
list_id: 1,
created_by_id: 1,
created: now.toISOString(),
updated: now.toISOString(),

View File

@ -10,7 +10,7 @@ export class LinkShareFactory extends Factory {
return {
id: '{increment}',
hash: faker.random.word(32),
project_id: 1,
list_id: 1,
right: 0,
sharing_type: 0,
shared_by_id: 1,

View File

@ -1,8 +1,8 @@
import {Factory} from '../support/factory'
import {faker} from '@faker-js/faker'
export class ProjectFactory extends Factory {
static table = 'projects'
export class ListFactory extends Factory {
static table = 'lists'
static factory() {
const now = new Date()

View File

@ -11,7 +11,7 @@ export class TaskFactory extends Factory {
id: '{increment}',
title: faker.lorem.words(3),
done: false,
project_id: 1,
list_id: 1,
created_by_id: 1,
index: '{increment}',
position: '{increment}',

View File

@ -1,14 +1,14 @@
import {Factory} from '../support/factory'
export class UserProjectFactory extends Factory {
static table = 'users_projects'
export class UserListFactory extends Factory {
static table = 'users_lists'
static factory() {
const now = new Date()
return {
id: '{increment}',
project_id: 1,
list_id: 1,
user_id: 1,
right: 0,
created: now.toISOString(),

15
docker/injector.sh Normal file
View File

@ -0,0 +1,15 @@
#!/usr/bin/env sh
set -e
echo "info: API URL is $VIKUNJA_API_URL"
echo "info: Sentry enabled: $VIKUNJA_SENTRY_ENABLED"
# Escape the variable to prevent sed from complaining
VIKUNJA_API_URL="$(echo "$VIKUNJA_API_URL" | sed -r 's/([:;])/\\\1/g')"
VIKUNJA_SENTRY_DSN="$(echo "$VIKUNJA_SENTRY_DSN" | sed -r 's/([:;])/\\\1/g')"
sed -ri "s:^(\s*window.API_URL\s*=)\s*.+:\1 '${VIKUNJA_API_URL}':g" /usr/share/nginx/html/index.html
sed -ri "s:^(\s*window.SENTRY_ENABLED\s*=)\s*.+:\1 ${VIKUNJA_SENTRY_ENABLED}:g" /usr/share/nginx/html/index.html
sed -ri "s:^(\s*window.SENTRY_DSN\s*=)\s*.+:\1 '${VIKUNJA_SENTRY_DSN}':g" /usr/share/nginx/html/index.html
date -uIseconds | xargs echo 'info: started at'

19
docker/ipv6-disable.sh Normal file
View File

@ -0,0 +1,19 @@
#!/usr/bin/env sh
set -e
if [ ! -f "/proc/net/if_inet6" ]; then
echo "info: IPv6 is not available! Removing IPv6 listen configuration"
find /etc/nginx/conf.d -name '*.conf' -type f | \
while IFS= read -r CONFIG; do
sed -r '/^\s*listen\s*\[::\]:.+$/d' "$CONFIG" > "$CONFIG.temp"
if ! diff -U 5 "$CONFIG" "$CONFIG.temp" > "$CONFIG.diff"; then
echo "info: Removing IPv6 lines from $CONFIG" | \
cat - "$CONFIG.diff"
echo "# IPv6 is disabled because /proc/net/if_inet6 was not found" | \
cat - "$CONFIG.temp" > "$CONFIG"
else
echo "info: Skipping $CONFIG because it does not have IPv6 listen"
fi
rm -f "$CONFIG.temp" "$CONFIG.diff"
done
fi

112
docker/nginx.conf Normal file
View File

@ -0,0 +1,112 @@
# Generated by nginxconfig.io
# https://www.digitalocean.com/community/tools/nginx?domains.0.server.domain=localhost&domains.0.server.documentRoot=%2Fusr%2Fshare%2Fnginx%2Fhtml&domains.0.server.cdnSubdomain=true&domains.0.https.https=false&domains.0.php.php=false&domains.0.routing.index=index.html&domains.0.routing.fallbackHtml=true&domains.0.routing.fallbackPhp=false&global.performance.assetsExpiration=1d&global.performance.mediaExpiration=1d&global.performance.svgExpiration=1d&global.performance.fontsExpiration=1d&global.logging.accessLog=%2Fdev%2Fstdout&global.logging.errorLog=%2Fdev%2Fstderr%20warn&global.logging.logNotFound=true&global.nginx.user=nginx&global.nginx.pid=%2Fvar%2Frun%2Fnginx.pid&global.nginx.clientMaxBodySize=50&global.docker.dockerfile=true&global.tools.modularizedStructure=false&global.tools.symlinkVhost=false
# and then edited manually ;)
pid /tmp/nginx.pid;
worker_processes auto;
worker_rlimit_nofile 65535;
events {
multi_accept on;
worker_connections 1024;
}
http {
charset utf-8;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
server_tokens off;
types_hash_max_size 2048;
types_hash_bucket_size 64;
# rootless
client_body_temp_path /tmp/client_temp;
proxy_temp_path /tmp/proxy_temp_path;
fastcgi_temp_path /tmp/fastcgi_temp;
uwsgi_temp_path /tmp/uwsgi_temp;
scgi_temp_path /tmp/scgi_temp;
# MIME
include mime.types;
default_type application/octet-stream;
types {
application/manifest+json webmanifest;
}
# Logging
log_format json escape=json
'{'
'"bytes_sent": "$bytes_sent",'
'"http_user_agent": "$http_user_agent",'
'"nginx_version": "$nginx_version",'
'"query_string": "$query_string",'
'"realip_remote_addr": "$realip_remote_addr",'
'"remote_addr": "$remote_addr",'
'"remote_user": "$remote_user",'
'"request_length": "$request_length",'
'"request_method": "$request_method",'
'"request_time": "$request_time",'
'"server_addr": "$server_addr",'
'"server_port": "$server_port",'
'"server_protocol": "$server_protocol",'
'"status": "$status",'
'"time_local": "$time_local",'
'"uri": "$uri"'
'}';
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /dev/stdout main;
error_log /dev/stderr warn;
keepalive_timeout 65;
# compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_min_length 256;
gzip_types
text/plain
text/css
application/json
application/x-javascript
application/javascript
text/xml
application/xml
application/xml+rss
text/javascript
application/vnd.ms-fontobject
application/x-font-ttf
font/opentype
image/svg+xml
image/x-icon
audio/wav;
map_hash_max_size 128;
map_hash_bucket_size 128;
map $sent_http_content_type $expires {
default off;
text/css max;
application/javascript max;
text/javascript max;
application/vnd.ms-fontobject max;
application/x-font-ttf max;
font/opentype max;
font/woff2 max;
image/svg+xml max;
image/x-icon max;
audio/wav max;
~images/ max;
~font/ max;
}
include /etc/nginx/conf.d/*.conf;
}

View File

@ -0,0 +1,71 @@
server {
listen ${VIKUNJA_HTTP_PORT};
listen [::]:${VIKUNJA_HTTP_PORT};
## Needed when behind HAProxy with SSL termination + HTTP/2 support
listen ${VIKUNJA_HTTP2_PORT} default_server http2 proxy_protocol;
listen [::]:${VIKUNJA_HTTP2_PORT} default_server http2 proxy_protocol;
server_name _;
expires $expires;
root /usr/share/nginx/html;
access_log /dev/stdout ${VIKUNJA_LOG_FORMAT};
# security headers
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: ws: wss: data: blob: 'unsafe-inline'; frame-ancestors 'self';" always;
add_header Permissions-Policy "interest-cohort=()" always;
# . files
location ~ /\.(?!well-known) {
deny all;
}
# assume that everything else is handled by the application router, by injecting the index.html.
location / {
autoindex off;
expires off;
add_header Cache-Control "public, max-age=0, s-maxage=0, must-revalidate" always;
try_files $uri /index.html =404;
}
# favicon.ico
location = /favicon.ico {
log_not_found off;
access_log off;
}
# robots.txt
location = /robots.txt {
log_not_found off;
access_log off;
expires -1; # no-cache
}
location = /ready {
return 200 "";
access_log off;
expires -1; # no-cache
}
# all assets contain hash in filename, cache forever
location ^~ /assets/ {
add_header Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable";
try_files $uri =404;
}
# all workbox scripts are compiled with hash in filename, cache forever3
location ^~ /workbox- {
add_header Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable";
try_files $uri =404;
}
# assets, media
location ~* .(txt|webmanifest|css|js|mjs|map|svg|jpg|jpeg|png|ico|ttf|woff|woff2|wav)$ {
try_files $uri $uri/ =404;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html { }
}

View File

@ -30,21 +30,21 @@ A basic service can look like this:
```javascript
import AbstractService from './abstractService'
import ProjectModel from '../models/project'
import ListModel from '../models/list'
export default class ProjectService extends AbstractService {
export default class ListService extends AbstractService {
constructor() {
super({
getAll: '/projects',
get: '/projects/{id}',
create: '/namespaces/{namespaceID}/projects',
update: '/projects/{id}',
delete: '/projects/{id}',
getAll: '/lists',
get: '/lists/{id}',
create: '/namespaces/{namespaceID}/lists',
update: '/lists/{id}',
delete: '/lists/{id}',
})
}
modelFactory(data) {
return new ProjectModel(data)
return new ListModel(data)
}
}
```
@ -132,7 +132,7 @@ import AbstractModel from './abstractModel'
import TaskModel from './task'
import UserModel from './user'
export default class ProjectModel extends AbstractModel {
export default class ListModel extends AbstractModel {
constructor(data) {
// The constructor of AbstractModel handles all the default parsing.

View File

@ -9,9 +9,7 @@
<link rel="icon" href="/favicon.ico">
<link rel="apple-touch-icon" href="/images/icons/apple-touch-icon-180x180.png"/>
<link rel="preload" crossorigin="anonymous" href="/src/assets/fonts/OpenSans[wght].woff2" as="font">
<link rel="preload" crossorigin="anonymous" href="/src/assets/fonts/OpenSans-Italic[wght].woff2" as="font">
<link rel="preload" crossorigin="anonymous" href="/src/assets/fonts/Quicksand[wght].woff2" as="font">
<!--__vite-plugin-inject-preload__-->
</head>
<body>
<noscript>

View File

@ -1,115 +0,0 @@
user nginx;
worker_processes 1;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
types {
application/manifest+json webmanifest;
}
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
#tcp_nopush on;
keepalive_timeout 65;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_min_length 256;
gzip_types
text/plain
text/css
application/json
application/x-javascript
application/javascript
text/xml
application/xml
application/xml+rss
text/javascript
application/vnd.ms-fontobject
application/x-font-ttf
font/opentype
image/svg+xml
image/x-icon
audio/wav;
map_hash_max_size 128;
map_hash_bucket_size 128;
# Expires map
map $sent_http_content_type $expires {
default off;
text/css max;
application/javascript max;
text/javascript max;
application/vnd.ms-fontobject max;
application/x-font-ttf max;
font/opentype max;
font/woff2 max;
image/svg+xml max;
image/x-icon max;
audio/wav max;
~images/ max;
~font/ max;
}
server {
listen 80;
listen 81 default_server http2 proxy_protocol; ## Needed when behind HAProxy with SSL termination + HTTP/2 support
server_name _;
expires $expires;
root /usr/share/nginx/html;
# all assets contain hash in filename, cache forever
location ^~ /assets/ {
add_header Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable";
try_files $uri =404;
}
# all workbox scripts are compiled with hash in filename, cache forever3
location ^~ /workbox- {
add_header Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable";
try_files $uri =404;
}
# assume that everything else is handled by the application router, by injecting the index.html.
location / {
autoindex off;
expires off;
add_header Cache-Control "public, max-age=0, s-maxage=0, must-revalidate" always;
try_files $uri /index.html =404;
}
location ~* .(txt|webmanifest|css|js|mjs|map|svg|jpg|jpeg|png|ico|ttf|woff|woff2|wav)$ {
try_files $uri $uri/ =404;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
}
}
}

View File

@ -13,7 +13,7 @@
},
"homepage": "https://vikunja.io/",
"funding": "https://opencollective.com/vikunja",
"packageManager": "pnpm@7.25.0",
"packageManager": "pnpm@7.27.1",
"keywords": [
"todo",
"productivity",
@ -34,7 +34,7 @@
"test:e2e-record": "start-server-and-test preview http://127.0.0.1:4173 'cypress run --e2e --browser chrome --record'",
"test:e2e-dev-dev": "start-server-and-test preview:dev http://127.0.0.1:4173 'cypress open --e2e'",
"test:e2e-dev": "start-server-and-test preview http://127.0.0.1:4173 'cypress open --e2e'",
"test:unit": "vitest",
"test:unit": "vitest --dir ./src",
"typecheck": "vue-tsc --noEmit && vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
"browserslist:update": "pnpm dlx browserslist@latest --update-db",
"fonts:update": "pnpm fonts:download && pnpm fonts:subset",
@ -45,28 +45,28 @@
"story:preview": "histoire preview"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "6.2.1",
"@fortawesome/free-regular-svg-icons": "6.2.1",
"@fortawesome/free-solid-svg-icons": "6.2.1",
"@fortawesome/vue-fontawesome": "3.0.2",
"@fortawesome/fontawesome-svg-core": "6.3.0",
"@fortawesome/free-regular-svg-icons": "6.3.0",
"@fortawesome/free-solid-svg-icons": "6.3.0",
"@fortawesome/vue-fontawesome": "3.0.3",
"@github/hotkey": "2.0.1",
"@infectoone/vue-ganttastic": "2.1.3",
"@intlify/unplugin-vue-i18n": "0.8.1",
"@kyvg/vue3-notification": "2.8.0",
"@sentry/tracing": "7.31.1",
"@sentry/vue": "7.31.1",
"@infectoone/vue-ganttastic": "2.1.4",
"@intlify/unplugin-vue-i18n": "0.8.2",
"@kyvg/vue3-notification": "2.9.0",
"@sentry/tracing": "7.38.0",
"@sentry/vue": "7.38.0",
"@types/is-touch-device": "1.0.0",
"@types/lodash.clonedeep": "4.5.7",
"@types/sortablejs": "1.15.0",
"@vueuse/core": "9.11.0",
"axios": "1.2.2",
"blurhash": "2.0.4",
"@vueuse/core": "9.13.0",
"axios": "1.3.3",
"blurhash": "2.0.5",
"bulma-css-variables": "0.9.33",
"camel-case": "4.1.2",
"codemirror": "5.65.11",
"codemirror": "5.65.12",
"date-fns": "2.29.3",
"dayjs": "1.11.7",
"dompurify": "2.4.3",
"dompurify": "3.0.0",
"easymde": "2.18.0",
"fast-deep-equal": "3.1.3",
"flatpickr": "4.6.13",
@ -75,18 +75,17 @@
"focus-within": "3.0.2",
"highlight.js": "11.7.0",
"is-touch-device": "1.0.1",
"lodash.clonedeep": "4.5.0",
"klona": "2.0.6",
"lodash.debounce": "4.0.8",
"marked": "4.2.12",
"minimist": "1.2.7",
"pinia": "2.0.29",
"pinia": "2.0.32",
"register-service-worker": "1.7.2",
"snake-case": "3.0.4",
"sortablejs": "1.15.0",
"ufo": "1.0.1",
"vue": "3.2.45",
"ufo": "1.1.0",
"vue": "3.2.47",
"vue-advanced-cropper": "2.8.8",
"vue-flatpickr-component": "11.0.1",
"vue-flatpickr-component": "11.0.2",
"vue-i18n": "9.2.2",
"vue-router": "4.1.6",
"workbox-precaching": "6.5.4",
@ -94,52 +93,53 @@
},
"devDependencies": {
"@4tw/cypress-drag-drop": "2.2.3",
"@cypress/vite-dev-server": "5.0.2",
"@cypress/vue": "5.0.3",
"@cypress/vite-dev-server": "5.0.3",
"@cypress/vue": "5.0.4",
"@faker-js/faker": "7.6.0",
"@histoire/plugin-screenshot": "0.12.4",
"@histoire/plugin-vue": "0.12.4",
"@histoire/plugin-screenshot": "0.15.8",
"@histoire/plugin-vue": "0.15.8",
"@rushstack/eslint-patch": "1.2.0",
"@types/codemirror": "5.60.6",
"@types/codemirror": "5.60.7",
"@types/dompurify": "2.4.0",
"@types/flexsearch": "0.7.3",
"@types/focus-within": "1.0.1",
"@types/lodash.debounce": "4.0.7",
"@types/marked": "4.0.8",
"@types/node": "18.11.18",
"@types/node": "18.14.0",
"@types/postcss-preset-env": "7.7.0",
"@typescript-eslint/eslint-plugin": "5.48.2",
"@typescript-eslint/parser": "5.48.2",
"@vitejs/plugin-legacy": "3.0.1",
"@typescript-eslint/eslint-plugin": "5.53.0",
"@typescript-eslint/parser": "5.53.0",
"@vitejs/plugin-legacy": "4.0.1",
"@vitejs/plugin-vue": "4.0.0",
"@vue/eslint-config-typescript": "11.0.2",
"@vue/test-utils": "2.2.7",
"@vue/test-utils": "2.3.0",
"@vue/tsconfig": "0.1.3",
"autoprefixer": "10.4.13",
"browserslist": "4.21.4",
"caniuse-lite": "1.0.30001445",
"browserslist": "4.21.5",
"caniuse-lite": "1.0.30001457",
"csstype": "3.1.1",
"cypress": "12.3.0",
"esbuild": "0.17.2",
"eslint": "8.32.0",
"cypress": "12.6.0",
"esbuild": "0.17.10",
"eslint": "8.34.0",
"eslint-plugin-vue": "9.9.0",
"happy-dom": "8.1.4",
"histoire": "0.12.4",
"netlify-cli": "12.7.2",
"happy-dom": "8.6.0",
"histoire": "0.15.8",
"netlify-cli": "12.13.2",
"postcss": "8.4.21",
"postcss-easing-gradients": "3.0.1",
"postcss-easings": "3.0.1",
"postcss-preset-env": "7.8.3",
"rollup": "3.10.0",
"postcss-preset-env": "8.0.1",
"rollup": "3.17.2",
"rollup-plugin-visualizer": "5.9.0",
"sass": "1.57.1",
"start-server-and-test": "1.15.2",
"typescript": "4.9.4",
"vite": "4.0.4",
"vite-plugin-pwa": "0.14.1",
"sass": "1.58.3",
"start-server-and-test": "1.15.4",
"typescript": "4.9.5",
"vite": "4.1.3",
"vite-plugin-inject-preload": "1.3.0",
"vite-plugin-pwa": "0.14.4",
"vite-svg-loader": "4.0.0",
"vitest": "0.27.2",
"vue-tsc": "1.0.24",
"vitest": "0.28.5",
"vue-tsc": "1.1.5",
"wait-on": "7.0.1",
"workbox-cli": "6.5.4"
}

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"labels": ["dependencies"],
"extends": [
"config:base"
"config:js-app"
],
"packageRules": [
{
@ -20,6 +20,13 @@
"@vueuse/"
]
},
{
"groupName": "histoire",
"matchPackagePrefixes": [
"@histoire/",
"histoire"
]
},
{
"matchDepTypes": ["devDependencies"],
"automerge": true,

View File

@ -1 +0,0 @@
24df869e7a9282c76c9e1883071a39c0b11a53a57da68b37f2b918df25b1ae0f1b403e38a29c9cb694575bb9a7b52b6e ./scripts/deploy-preview-netlify.js

View File

@ -1,4 +1,4 @@
const { exec } = require('child_process')
import { exec } from 'node:child_process'
function createSlug(string) {
return String(string)

View File

@ -0,0 +1 @@
57af69409e66bc87f4f2fc5822dd8d3c2eb47c601f81af1ac4a56f3e2d80837b1a2de06f4ff57695ec379b7c15b881e3 ./scripts/deploy-preview-netlify.mjs

View File

@ -1,28 +0,0 @@
#!/bin/bash
# This shell script sets the api url based on an environment variable and starts nginx in foreground.
VIKUNJA_API_URL="${VIKUNJA_API_URL:-"/api/v1"}"
VIKUNJA_SENTRY_ENABLED="${VIKUNJA_SENTRY_ENABLED:-"false"}"
VIKUNJA_SENTRY_DSN="${VIKUNJA_SENTRY_DSN:-"https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480"}"
VIKUNJA_HTTP_PORT="${VIKUNJA_HTTP_PORT:-80}"
VIKUNJA_HTTPS_PORT="${VIKUNJA_HTTPS_PORT:-443}"
echo "Using $VIKUNJA_API_URL as default api url"
# Escape the variable to prevent sed from complaining
VIKUNJA_API_URL=$(echo $VIKUNJA_API_URL |sed 's/\//\\\//g')
sed -i "s/http\:\/\/localhost\:3456//g" /usr/share/nginx/html/index.html # replacing in two steps to make sure api urls from releases are properly replaced as well
sed -i "s/'\/api\/v1/'$VIKUNJA_API_URL/g" /usr/share/nginx/html/index.html
sed -i "s/\.SENTRY_ENABLED = false/\.SENTRY_ENABLED = $VIKUNJA_SENTRY_ENABLED/g" /usr/share/nginx/html/index.html
sed -i "s|\.SENTRY_DSN = '.*'|\.SENTRY_DSN = '$VIKUNJA_SENTRY_DSN'|g" /usr/share/nginx/html/index.html
sed -i "s/listen 80/listen $VIKUNJA_HTTP_PORT/g" /etc/nginx/nginx.conf
sed -i "s/listen 443/listen $VIKUNJA_HTTPS_PORT/g" /etc/nginx/nginx.conf
# Set the uid and gid of the nginx run user
usermod --non-unique --uid ${PUID} nginx
groupmod --non-unique --gid ${PGID} nginx
nginx -g "daemon off;"

View File

@ -8,9 +8,13 @@
<no-auth-wrapper v-else>
<router-view/>
</no-auth-wrapper>
<Notification/>
<keyboard-shortcuts v-if="keyboardShortcutsActive"/>
<Teleport to="body">
<UpdateNotification/>
<Notification/>
</Teleport>
</ready>
</template>
@ -19,23 +23,26 @@ import {computed, watch} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import {useI18n} from 'vue-i18n'
import isTouchDevice from 'is-touch-device'
import {success} from '@/message'
import Notification from '@/components/misc/notification.vue'
import KeyboardShortcuts from './components/misc/keyboard-shortcuts/index.vue'
import UpdateNotification from '@/components/home/UpdateNotification.vue'
import KeyboardShortcuts from '@/components/misc/keyboard-shortcuts/index.vue'
import TheNavigation from '@/components/home/TheNavigation.vue'
import ContentAuth from './components/home/contentAuth.vue'
import ContentLinkShare from './components/home/contentLinkShare.vue'
import ContentAuth from '@/components/home/contentAuth.vue'
import ContentLinkShare from '@/components/home/contentLinkShare.vue'
import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue'
import Ready from '@/components/misc/ready.vue'
import {setLanguage} from './i18n'
import {setLanguage} from '@/i18n'
import AccountDeleteService from '@/services/accountDelete'
import {success} from '@/message'
import {useAuthStore} from '@/stores/auth'
import {useBaseStore} from '@/stores/base'
import {useColorScheme} from '@/composables/useColorScheme'
import {useBodyClass} from '@/composables/useBodyClass'
import {useAuthStore} from './stores/auth'
const baseStore = useBaseStore()
const authStore = useAuthStore()

View File

@ -2,101 +2,94 @@
<header
:class="{'has-background': background, 'menu-active': menuActive}"
aria-label="main navigation"
class="navbar main-theme is-fixed-top d-print-none"
class="navbar d-print-none"
>
<router-link :to="{name: 'home'}" class="logo-link">
<Logo width="164" height="48"/>
</router-link>
<MenuButton class="menu-button"/>
<div class="project-title" ref="projectTitle" v-show="currentProject.id">
<template v-if="currentProject.id">
<h1
:style="{ 'opacity': currentProject.title === '' ? '0': '1' }"
class="title">
{{ currentProject.title === '' ? $t('misc.loading') : getProjectTitle(currentProject) }}
</h1>
<BaseButton :to="{name: 'project.info', params: {projectId: currentProject.id}}" class="info-button">
<icon icon="circle-info"/>
</BaseButton>
<div
v-if="currentList.id"
class="list-title-wrapper"
>
<h1 class="list-title">{{ currentList.title === '' ? $t('misc.loading') : getListTitle(currentList) }}</h1>
<BaseButton :to="{name: 'list.info', params: {listId: currentList.id}}" class="list-title-button">
<icon icon="circle-info"/>
</BaseButton>
<project-settings-dropdown v-if="canWriteCurrentProject && currentProject.id !== -1" :project="currentProject"/>
</template>
<list-settings-dropdown
v-if="canWriteCurrentList && currentList.id !== -1"
class="list-title-dropdown"
:list="currentList"
>
<template #trigger="{toggleOpen}">
<BaseButton class="list-title-button" @click="toggleOpen">
<icon icon="ellipsis-h" class="icon"/>
</BaseButton>
</template>
</list-settings-dropdown>
</div>
<div class="navbar-end">
<update/>
<BaseButton
@click="openQuickActions"
class="trigger-button pr-0"
class="trigger-button"
v-shortcut="'Control+k'"
:title="$t('keyboardShortcuts.quickSearch')"
>
<icon icon="search"/>
</BaseButton>
<notifications/>
<div class="user">
<dropdown class="is-right" ref="usernameDropdown">
<template #trigger="{toggleOpen}">
<x-button
class="username-dropdown-trigger"
@click="toggleOpen()"
variant="secondary"
:shadow="false"
>
<img :src="authStore.avatarUrl" alt="" class="avatar" width="40" height="40"/>
<span class="username">{{ authStore.userDisplayName }}</span>
<span class="icon is-small">
<icon icon="chevron-down"/>
</span>
</x-button>
</template>
<Notifications />
<dropdown>
<template #trigger="{toggleOpen, open}">
<BaseButton
class="username-dropdown-trigger"
@click="toggleOpen"
variant="secondary"
:shadow="false"
>
<img :src="authStore.avatarUrl" alt="" class="avatar" width="40" height="40"/>
<span class="username">{{ authStore.userDisplayName }}</span>
<span class="icon is-small" :style="{
transform: open ? 'rotate(180deg)' : 'rotate(0)',
}">
<icon icon="chevron-down"/>
</span>
</BaseButton>
</template>
<dropdown-item
:to="{name: 'user.settings'}"
>
{{ $t('user.settings.title') }}
</dropdown-item>
<dropdown-item
v-if="imprintUrl"
:href="imprintUrl"
>
{{ $t('navigation.imprint') }}
</dropdown-item>
<dropdown-item
v-if="privacyPolicyUrl"
:href="privacyPolicyUrl"
>
{{ $t('navigation.privacy') }}
</dropdown-item>
<dropdown-item
@click="baseStore.setKeyboardShortcutsActive(true)"
>
{{ $t('keyboardShortcuts.title') }}
</dropdown-item>
<dropdown-item
:to="{name: 'about'}"
>
{{ $t('about.title') }}
</dropdown-item>
<dropdown-item
@click="authStore.logout()"
>
{{ $t('user.auth.logout') }}
</dropdown-item>
</dropdown>
</div>
<dropdown-item :to="{name: 'user.settings'}">
{{ $t('user.settings.title') }}
</dropdown-item>
<dropdown-item v-if="imprintUrl" :href="imprintUrl">
{{ $t('navigation.imprint') }}
</dropdown-item>
<dropdown-item v-if="privacyPolicyUrl" :href="privacyPolicyUrl">
{{ $t('navigation.privacy') }}
</dropdown-item>
<dropdown-item @click="baseStore.setKeyboardShortcutsActive(true)">
{{ $t('keyboardShortcuts.title') }}
</dropdown-item>
<dropdown-item :to="{name: 'about'}">
{{ $t('about.title') }}
</dropdown-item>
<dropdown-item @click="authStore.logout()">
{{ $t('user.auth.logout') }}
</dropdown-item>
</dropdown>
</div>
</header>
</template>
<script setup lang="ts">
import {ref, computed, onMounted, nextTick} from 'vue'
import {computed} from 'vue'
import {RIGHTS as Rights} from '@/constants/rights'
import Update from '@/components/home/update.vue'
import ProjectSettingsDropdown from '@/components/project/project-settings-dropdown.vue'
import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue'
import Dropdown from '@/components/misc/dropdown.vue'
import DropdownItem from '@/components/misc/dropdown-item.vue'
import Notifications from '@/components/notifications/notifications.vue'
@ -104,16 +97,16 @@ import Logo from '@/components/home/Logo.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import MenuButton from '@/components/home/MenuButton.vue'
import {getProjectTitle} from '@/helpers/getProjectTitle'
import {getListTitle} from '@/helpers/getListTitle'
import {useBaseStore} from '@/stores/base'
import {useConfigStore} from '@/stores/config'
import {useAuthStore} from '@/stores/auth'
const baseStore = useBaseStore()
const currentProject = computed(() => baseStore.currentProject)
const currentList = computed(() => baseStore.currentList)
const background = computed(() => baseStore.background)
const canWriteCurrentProject = computed(() => baseStore.currentProject.maxRight > Rights.READ)
const canWriteCurrentList = computed(() => baseStore.currentList.maxRight > Rights.READ)
const menuActive = computed(() => baseStore.menuActive)
const authStore = useAuthStore()
@ -122,182 +115,152 @@ const configStore = useConfigStore()
const imprintUrl = computed(() => configStore.legal.imprintUrl)
const privacyPolicyUrl = computed(() => configStore.legal.privacyPolicyUrl)
const usernameDropdown = ref()
const projectTitle = ref()
onMounted(async () => {
await nextTick()
if (typeof usernameDropdown.value === 'undefined' || typeof projectTitle.value === 'undefined') {
return
}
const usernameWidth = usernameDropdown.value.$el.clientWidth
projectTitle.value.style.setProperty('--nav-username-width', `${usernameWidth}px`)
})
function openQuickActions() {
baseStore.setQuickActionsActive(true)
}
</script>
<style lang="scss" scoped>
$vikunja-nav-logo-full-width: 164px;
$user-dropdown-width-mobile: 5rem;
$hamburger-menu-icon-spacing: 1rem;
$hamburger-menu-icon-width: 28px;
.navbar {
--navbar-button-min-width: 40px;
--navbar-gap-width: 1rem;
--navbar-icon-size: 1.25rem;
position: fixed;
top: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-between;
gap: var(--navbar-gap-width);
background: var(--site-background);
@media screen and (max-width: $tablet) {
padding-right: .5rem;
}
@media screen and (min-width: $tablet) {
padding-left: 2rem;
padding-right: 1rem;
align-items: stretch;
}
&.menu-active {
@media screen and (max-width: $tablet) {
z-index: 0;
}
}
// FIXME: notifications should provide a slot for the icon instead, so that we can style it as we want
:deep() {
.trigger-button {
color: var(--grey-400);
font-size: var(--navbar-icon-size);
}
}
}
.logo-link {
display: none;
padding: 0.5rem 0.75rem;
@media screen and (min-width: $tablet) {
align-self: stretch;
display: flex;
align-items: center;
padding-left: 2rem;
margin-right: 1.5rem;
margin-right: .5rem;
}
}
.menu-button {
align-self: stretch;
margin-right: auto;
align-self: stretch;
flex: 0 0 auto;
@media screen and (max-width: $tablet) {
margin-left: $hamburger-menu-icon-spacing;
margin-left: 1rem;
}
}
.navbar.main-theme {
background: var(--site-background);
justify-content: space-between;
align-items: center;
@media screen and (max-width: $desktop) {
display: flex;
justify-content: space-between;
}
.title {
margin: 0;
font-size: 1.75rem;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.navbar-end {
margin-left: 0;
align-items: center;
display: flex;
}
@media screen and (max-width: $tablet) {
&.menu-active {
z-index: 0;
}
.user {
width: $user-dropdown-width-mobile;
.username-dropdown-trigger {
line-height: 1;
padding: 0 0.25rem;
height: 1rem;
.icon {
width: .5rem;
}
}
.username {
display: none;
}
}
}
}
.navbar {
// FIXME: notifications should provide a slot for the icon instead, so that we can style it as we want
:deep() {
.trigger-button {
cursor: pointer;
color: var(--grey-400);
padding: .5rem;
font-size: 1.25rem;
position: relative;
}
> * > .trigger-button {
width: $navbar-icon-width;
}
}
.user {
display: flex;
align-items: center;
span {
font-family: $vikunja-font;
}
.avatar {
border-radius: 100%;
vertical-align: middle;
height: 40px;
margin-right: .5rem;
}
.username-dropdown-trigger {
background: none;
&:focus:not(:active), &:active {
outline: none !important;
box-shadow: none !important;
}
}
}
}
.project-title {
.list-title-wrapper {
margin-inline: auto;
display: flex;
align-items: center;
justify-content: center;
$edit-icon-width: 1rem;
// this makes the truncated text of the list title work
// inside the flexbox parent
min-width: 0;
@media screen and (min-width: $tablet) {
// We need a fixed width for overflowing ellipsis to work
--nav-username-width: 0;
width: calc(100vw - #{$user-dropdown-width-mobile} - #{2 * $hamburger-menu-icon-spacing} - #{$hamburger-menu-icon-width} - #{$edit-icon-width} - #{2 * $navbar-icon-width} - #{$vikunja-nav-logo-full-width} - var(--nav-username-width));
padding-inline: var(--navbar-gap-width);
}
}
.list-title {
font-size: 1rem;
// We need the following for overflowing ellipsis to work
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
@media screen and (min-width: $tablet) {
font-size: 1.75rem;
}
}
.list-title-dropdown {
align-self: stretch;
.list-title-button {
flex-grow: 1;
}
}
.list-title-button {
align-self: stretch;
min-width: var(--navbar-button-min-width);
display: flex;
place-items: center;
justify-content: center;
font-size: var(--navbar-icon-size);
color: var(--grey-400);
}
.navbar-end {
margin-left: auto;
flex: 0 0 auto;
display: flex;
align-items: stretch;
> * {
min-width: var(--navbar-button-min-width);
}
}
.username-dropdown-trigger {
padding-left: 1rem;
display: inline-flex;
align-items: center;
text-transform: uppercase;
font-size: .85rem;
font-weight: 700;
}
.username {
font-family: $vikunja-font;
@media screen and (max-width: $tablet) {
// We need a fixed width for overflowing ellipsis to work
width: calc(100vw - #{$user-dropdown-width-mobile} - #{2 * $hamburger-menu-icon-spacing} - #{$hamburger-menu-icon-width} - #{$edit-icon-width} - #{2 * $navbar-icon-width});
}
h1 {
margin: 0;
}
:deep(.dropdown-trigger) {
color: var(--grey-400);
margin-left: .5rem;
height: 1rem;
width: 1rem;
cursor: pointer;
display: none;
}
}
.info-button {
text-align: center;
height: 1.25rem;
line-height: 1.25rem;
width: 2rem;
margin-top: .25rem;
padding: 0 .5rem;
color: var(--grey-400);
margin-left: .5rem;
.avatar {
border-radius: 100%;
vertical-align: middle;
height: 40px;
margin-right: .5rem;
}
</style>

View File

@ -1,7 +1,11 @@
<template>
<div class="update-notification" v-if="updateAvailable">
<p>{{ $t('update.available') }}</p>
<x-button @click="refreshApp()" :shadow="false" class="has-no-text-wrap">
<p class="update-notification__message">{{ $t('update.available') }}</p>
<x-button
@click="refreshApp()"
:shadow="false"
:wrap="false"
>
{{ $t('update.do') }}
</x-button>
</div>
@ -16,15 +20,13 @@ const refreshing = ref(false)
document.addEventListener('swUpdated', showRefreshUI, {once: true})
if (navigator && navigator.serviceWorker) {
navigator.serviceWorker.addEventListener(
'controllerchange', () => {
if (refreshing.value) return
refreshing.value = true
window.location.reload()
},
)
}
navigator?.serviceWorker?.addEventListener(
'controllerchange', () => {
if (refreshing.value) return
refreshing.value = true
window.location.reload()
},
)
function showRefreshUI(e: Event) {
console.log('recieved refresh event', e)
@ -33,6 +35,7 @@ function showRefreshUI(e: Event) {
}
function refreshApp() {
updateAvailable.value = false
if (!registration.value || !registration.value.waiting) {
return
}
@ -43,39 +46,30 @@ function refreshApp() {
<style lang="scss" scoped>
.update-notification {
position: fixed;
// FIXME: We should prevent usage of z-index or
// at least define it centrally
// the highest z-index of a modal is .hint-modal with 4500
z-index: 5000;
bottom: 1rem;
inset-inline: 1rem;
max-width: max-content;
margin-inline: auto;
display: flex;
align-items: center;
background: $warning;
justify-content: space-between;
gap: 1rem;
padding: .5rem;
background: $warning;
border-radius: $radius;
font-size: .9rem;
color: var(--grey-900);
justify-content: space-between;
position: fixed;
bottom: 1rem;
width: 450px;
left: calc(50vw - 225px);
@media screen and (max-width: $tablet) {
position: fixed;
left: 1rem;
right: 1rem;
bottom: 1rem;
width: auto;
}
p {
text-align: center;
width: 100%;
}
> * + * {
margin-left: .5rem;
}
}
.dark .update-notification {
color: var(--grey-200);
.update-notification__message {
width: 100%;
text-align: center;
}
</style>

View File

@ -1,16 +1,16 @@
<template>
<div class="content-auth">
<BaseButton
v-if="menuActive"
v-show="menuActive"
@click="baseStore.setMenuActive(false)"
class="menu-hide-button d-print-none"
>
<icon icon="times"/>
</BaseButton>
<div
class="app-container"
:class="{'has-background': background || blurHash}"
:style="{'background-image': blurHash && `url(${blurHash})`}"
class="app-container"
>
<div
:class="{'is-visible': background}"
@ -18,14 +18,14 @@
:style="{'background-image': background && `url(${background})`}"></div>
<navigation class="d-print-none"/>
<main
class="app-content"
:class="[
{ 'is-menu-enabled': menuActive },
$route.name,
]"
class="app-content"
>
<BaseButton
v-if="menuActive"
v-show="menuActive"
@click="baseStore.setMenuActive(false)"
class="mobile-overlay d-print-none"
/>
@ -33,7 +33,7 @@
<quick-actions/>
<router-view :route="routeWithModal" v-slot="{ Component }">
<keep-alive :include="['project.list', 'project.gantt', 'project.table', 'project.kanban']">
<keep-alive :include="['list.list', 'list.gantt', 'list.table', 'list.kanban']">
<component :is="Component"/>
</keep-alive>
</router-view>
@ -86,11 +86,8 @@ function showKeyboardShortcuts() {
const route = useRoute()
// hide menu on mobile
watch(() => route.fullPath, () => window.innerWidth < 769 && baseStore.setMenuActive(false))
// FIXME: this is really error prone
// Reset the current project highlight in menu if the current route is not project related.
// Reset the current list highlight in menu if the current route is not list related.
watch(() => route.name as string, (routeName) => {
if (
routeName &&
@ -109,7 +106,7 @@ watch(() => route.name as string, (routeName) => {
routeName.startsWith('user.settings')
)
) {
baseStore.handleSetCurrentProject({project: null})
baseStore.handleSetCurrentList({list: null})
}
})
@ -143,7 +140,6 @@ labelStore.loadAllLabels()
&:hover,
&:focus {
height: 1rem;
color: var(--grey-600);
}
}
@ -225,9 +221,4 @@ labelStore.loadAllLabels()
position: relative;
z-index: 1;
}
.is-touch .content-auth,
.content-auth.z-unset {
z-index: unset;
}
</style>

View File

@ -9,9 +9,9 @@
<Logo class="logo" v-if="logoVisible"/>
<h1
:class="{'m-0': !logoVisible}"
:style="{ 'opacity': currentProject.title === '' ? '0': '1' }"
:style="{ 'opacity': currentList.title === '' ? '0': '1' }"
class="title">
{{ currentProject.title === '' ? $t('misc.loading') : currentProject.title }}
{{ currentList.title === '' ? $t('misc.loading') : currentList.title }}
</h1>
<div class="box has-text-left view">
<router-view/>
@ -31,7 +31,7 @@ import Logo from '@/components/home/Logo.vue'
import PoweredByLink from './PoweredByLink.vue'
const baseStore = useBaseStore()
const currentProject = computed(() => baseStore.currentProject)
const currentList = computed(() => baseStore.currentList)
const background = computed(() => baseStore.background)
const logoVisible = computed(() => baseStore.logoVisible)
</script>

View File

@ -52,37 +52,37 @@
<template v-for="(n, nk) in namespaces" :key="n.id">
<div class="namespace-title" :class="{'has-menu': n.id > 0}">
<BaseButton
@click="toggleProjects(n.id)"
@click="toggleLists(n.id)"
class="menu-label"
v-tooltip="namespaceTitles[nk]"
>
<ColorBubble
v-if="n.hexColor !== ''"
:color="n.hexColor"
class="mr-1"
v-if="n.hexColor !== ''"
:color="n.hexColor"
class="mr-1"
/>
<span class="name">{{ namespaceTitles[nk] }}</span>
<div
class="icon menu-item-icon is-small toggle-lists-icon pl-2"
:class="{'active': typeof projectsVisible[n.id] !== 'undefined' ? projectsVisible[n.id] : true}"
class="icon menu-item-icon is-small toggle-lists-icon pl-2"
:class="{'active': typeof listsVisible[n.id] !== 'undefined' ? listsVisible[n.id] : true}"
>
<icon icon="chevron-down"/>
</div>
<span class="count" :class="{'ml-2 mr-0': n.id > 0}">
({{ namespaceProjectsCount[nk] }})
({{ namespaceListsCount[nk] }})
</span>
</BaseButton>
<namespace-settings-dropdown class="menu-list-dropdown" :namespace="n" v-if="n.id > 0"/>
</div>
<!--
NOTE: a v-model / computed setter is not possible, since the updateActiveProjects function
triggered by the change needs to have access to the current namespace
-->
<draggable
v-if="projectsVisible[n.id] ?? true"
<!--
NOTE: a v-model / computed setter is not possible, since the updateActiveLists function
triggered by the change needs to have access to the current namespace
-->
<draggable
v-if="listsVisible[n.id] ?? true"
v-bind="dragOptions"
:modelValue="activeProjects[nk]"
@update:modelValue="(projects) => updateActiveProjects(n, projects)"
:modelValue="activeLists[nk]"
@update:modelValue="(lists) => updateActiveLists(n, lists)"
group="namespace-lists"
@start="() => drag = true"
@end="saveListPosition"
@ -100,45 +100,45 @@
{ 'dragging-disabled': n.id < 0 }
]
}"
>
<template #item="{element: l}">
<li
>
<template #item="{element: l}">
<li
class="list-menu loader-container is-loading-small"
:class="{'is-loading': projectUpdating[l.id]}"
>
<BaseButton
:to="{ name: 'project.index', params: { projectId: l.id} }"
class="list-menu-link"
:class="{'router-link-exact-active': currentProject.id === l.id}"
:class="{'is-loading': listUpdating[l.id]}"
>
<BaseButton
:to="{ name: 'list.index', params: { listId: l.id} }"
class="list-menu-link"
:class="{'router-link-exact-active': currentList.id === l.id}"
>
<span class="icon menu-item-icon handle">
<icon icon="grip-lines"/>
</span>
<ColorBubble
<ColorBubble
v-if="l.hexColor !== ''"
:color="l.hexColor"
class="mr-1"
/>
<span class="list-menu-title">{{ getProjectTitle(l) }}</span>
</BaseButton>
<BaseButton
/>
<span class="list-menu-title">{{ getListTitle(l) }}</span>
</BaseButton>
<BaseButton
class="favorite"
:class="{'is-favorite': l.isFavorite}"
@click="projectStore.toggleProjectFavorite(l)"
>
<icon :icon="l.isFavorite ? 'star' : ['far', 'star']"/>
</BaseButton>
<list-settings-dropdown class="menu-list-dropdown" :list="l" v-if="l.id > 0">
<template #trigger="{toggleOpen}">
<BaseButton class="menu-list-dropdown-trigger" @click="toggleOpen">
<icon icon="ellipsis-h" class="icon"/>
</BaseButton>
</template>
</list-settings-dropdown>
<span class="list-setting-spacer" v-else></span>
</li>
</template>
</draggable>
@click="listStore.toggleListFavorite(l)"
>
<icon :icon="l.isFavorite ? 'star' : ['far', 'star']"/>
</BaseButton>
<list-settings-dropdown class="menu-list-dropdown" :list="l" v-if="l.id > 0">
<template #trigger="{toggleOpen}">
<BaseButton class="menu-list-dropdown-trigger" @click="toggleOpen">
<icon icon="ellipsis-h" class="icon"/>
</BaseButton>
</template>
</list-settings-dropdown>
<span class="list-setting-spacer" v-else></span>
</li>
</template>
</draggable>
</template>
</nav>
<PoweredByLink/>
@ -146,26 +146,25 @@
</template>
<script setup lang="ts">
import {ref, computed, onMounted, onBeforeMount} from 'vue'
import {ref, computed, onBeforeMount} from 'vue'
import draggable from 'zhyswan-vuedraggable'
import type {SortableEvent} from 'sortablejs'
import BaseButton from '@/components/base/BaseButton.vue'
import ProjectSettingsDropdown from '@/components/project/project-settings-dropdown.vue'
import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue'
import NamespaceSettingsDropdown from '@/components/namespace/namespace-settings-dropdown.vue'
import PoweredByLink from '@/components/home/PoweredByLink.vue'
import Logo from '@/components/home/Logo.vue'
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
import {getNamespaceTitle} from '@/helpers/getNamespaceTitle'
import {getProjectTitle} from '@/helpers/getProjectTitle'
import {useEventListener} from '@vueuse/core'
import type {IProject} from '@/modelTypes/IProject'
import {getListTitle} from '@/helpers/getListTitle'
import type {IList} from '@/modelTypes/IList'
import type {INamespace} from '@/modelTypes/INamespace'
import ColorBubble from '@/components/misc/colorBubble.vue'
import {useBaseStore} from '@/stores/base'
import {useProjectStore} from '@/stores/projects'
import {useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces'
const drag = ref(false)
@ -176,7 +175,7 @@ const dragOptions = {
const baseStore = useBaseStore()
const namespaceStore = useNamespaceStore()
const currentProject = computed(() => baseStore.currentProject)
const currentList = computed(() => baseStore.currentList)
const menuActive = computed(() => baseStore.menuActive)
const loading = computed(() => namespaceStore.isLoading)
@ -184,9 +183,9 @@ const loading = computed(() => namespaceStore.isLoading)
const namespaces = computed(() => {
return namespaceStore.namespaces.filter(n => !n.isArchived)
})
const activeProjects = computed(() => {
return namespaces.value.map(({projects}) => {
return projects?.filter(item => {
const activeLists = computed(() => {
return namespaces.value.map(({lists}) => {
return lists?.filter(item => {
return typeof item !== 'undefined' && !item.isArchived
})
})
@ -196,54 +195,45 @@ const namespaceTitles = computed(() => {
return namespaces.value.map((namespace) => getNamespaceTitle(namespace))
})
const namespaceProjectsCount = computed(() => {
return namespaces.value.map((_, index) => activeProjects.value[index]?.length ?? 0)
const namespaceListsCount = computed(() => {
return namespaces.value.map((_, index) => activeLists.value[index]?.length ?? 0)
})
const listStore = useListStore()
useEventListener('resize', resize)
onMounted(() => resize())
const projectStore = useProjectStore()
function resize() {
// Hide the menu by default on mobile
baseStore.setMenuActive(window.innerWidth >= 770)
function toggleLists(namespaceId: INamespace['id']) {
listsVisible.value[namespaceId] = !listsVisible.value[namespaceId]
}
function toggleProjects(namespaceId: INamespace['id']) {
projectsVisible.value[namespaceId] = !projectsVisible.value[namespaceId]
}
const projectsVisible = ref<{ [id: INamespace['id']]: boolean }>({})
const listsVisible = ref<{ [id: INamespace['id']]: boolean }>({})
// FIXME: async action will be unfinished when component mounts
onBeforeMount(async () => {
const namespaces = await namespaceStore.loadNamespaces()
namespaces.forEach(n => {
if (typeof projectsVisible.value[n.id] === 'undefined') {
projectsVisible.value[n.id] = true
if (typeof listsVisible.value[n.id] === 'undefined') {
listsVisible.value[n.id] = true
}
})
})
function updateActiveProjects(namespace: INamespace, activeProjects: IProject[]) {
function updateActiveLists(namespace: INamespace, activeLists: IList[]) {
// This is a bit hacky: since we do have to filter out the archived items from the list
// for vue draggable updating it is not as simple as replacing it.
// To work around this, we merge the active projects with the archived ones. Doing so breaks the order
// because now all archived projects are sorted after the active ones. This is fine because they are sorted
// To work around this, we merge the active lists with the archived ones. Doing so breaks the order
// because now all archived lists are sorted after the active ones. This is fine because they are sorted
// later when showing them anyway, and it makes the merging happening here a lot easier.
const projects = [
...activeProjects,
...namespace.projects.filter(l => l.isArchived),
const lists = [
...activeLists,
...namespace.lists.filter(l => l.isArchived),
]
namespaceStore.setNamespaceById({
...namespace,
projects,
lists,
})
}
const projectUpdating = ref<{ [id: INamespace['id']]: boolean }>({})
const listUpdating = ref<{ [id: INamespace['id']]: boolean }>({})
async function saveListPosition(e: SortableEvent) {
if (!e.newIndex && e.newIndex !== 0) return
@ -251,31 +241,31 @@ async function saveListPosition(e: SortableEvent) {
const namespaceId = parseInt(e.to.dataset.namespaceId as string)
const newNamespaceIndex = parseInt(e.to.dataset.namespaceIndex as string)
const projectsActive = activeProjects.value[newNamespaceIndex]
// If the project was dragged to the last position, Safari will report e.newIndex as the size of the projectsActive
// array instead of using the position. Because the index is wrong in that case, dragging the project will fail.
const listsActive = activeLists.value[newNamespaceIndex]
// If the list was dragged to the last position, Safari will report e.newIndex as the size of the listsActive
// array instead of using the position. Because the index is wrong in that case, dragging the list will fail.
// To work around that we're explicitly checking that case here and decrease the index.
const newIndex = e.newIndex === projectsActive.length ? e.newIndex - 1 : e.newIndex
const newIndex = e.newIndex === listsActive.length ? e.newIndex - 1 : e.newIndex
const project = projectsActive[newIndex]
const projectBefore = projectsActive[newIndex - 1] ?? null
const projectAfter = projectsActive[newIndex + 1] ?? null
projectUpdating.value[project.id] = true
const list = listsActive[newIndex]
const listBefore = listsActive[newIndex - 1] ?? null
const listAfter = listsActive[newIndex + 1] ?? null
listUpdating.value[list.id] = true
const position = calculateItemPosition(
projectBefore !== null ? projectBefore.position : null,
projectAfter !== null ? projectAfter.position : null,
listBefore !== null ? listBefore.position : null,
listAfter !== null ? listAfter.position : null,
)
try {
// create a copy of the project in order to not violate pinia manipulation
await projectStore.updateProject({
...project,
// create a copy of the list in order to not violate pinia manipulation
await listStore.updateList({
...list,
position,
namespaceId,
})
} finally {
projectUpdating.value[project.id] = false
listUpdating.value[list.id] = false
}
}
</script>
@ -432,7 +422,7 @@ $vikunja-nav-selected-width: 0.4rem;
.top-menu {
margin-top: math.div($navbar-padding, 2);
.menu-list {
li {
font-weight: 600;

View File

@ -0,0 +1,63 @@
<template>
<multiselect
v-model="selectedLists"
:search-results="foundLists"
:loading="listService.loading"
:multiple="true"
:placeholder="$t('list.search')"
label="title"
@search="findLists"
/>
</template>
<script setup lang="ts">
import {computed, ref, shallowReactive, watchEffect, type PropType} from 'vue'
import Multiselect from '@/components/input/multiselect.vue'
import type {IList} from '@/modelTypes/IList'
import ListService from '@/services/list'
import {includesById} from '@/helpers/utils'
const props = defineProps({
modelValue: {
type: Array as PropType<IList[]>,
default: () => [],
},
})
const emit = defineEmits<{
(e: 'update:modelValue', value: IList[]): void
}>()
const lists = ref<IList[]>([])
watchEffect(() => {
lists.value = props.modelValue
})
const selectedLists = computed({
get() {
return lists.value
},
set: (value) => {
lists.value = value
emit('update:modelValue', value)
},
})
const listService = shallowReactive(new ListService())
const foundLists = ref<IList[]>([])
async function findLists(query: string) {
if (query === '') {
foundLists.value = []
return
}
const response = await listService.getAll({}, {s: query}) as IList[]
// Filter selected items from the results
foundLists.value = response.filter(({id}) => !includesById(lists.value, id))
}
</script>

View File

@ -1,63 +0,0 @@
<template>
<multiselect
v-model="selectedProjects"
:search-results="foundProjects"
:loading="projectService.loading"
:multiple="true"
:placeholder="$t('project.search')"
label="title"
@search="findProjects"
/>
</template>
<script setup lang="ts">
import {computed, ref, shallowReactive, watchEffect, type PropType} from 'vue'
import Multiselect from '@/components/input/multiselect.vue'
import type {IProject} from '@/modelTypes/IProject'
import ProjectService from '@/services/project'
import {includesById} from '@/helpers/utils'
const props = defineProps({
modelValue: {
type: Array as PropType<IProject[]>,
default: () => [],
},
})
const emit = defineEmits<{
(e: 'update:modelValue', value: IProject[]): void
}>()
const projects = ref<IProject[]>([])
watchEffect(() => {
projects.value = props.modelValue
})
const selectedProjects = computed({
get() {
return projects.value
},
set: (value) => {
projects.value = value
emit('update:modelValue', value)
},
})
const projectService = shallowReactive(new ProjectService())
const foundProjects = ref<IProject[]>([])
async function findProjects(query: string) {
if (query === '') {
foundProjects.value = []
return
}
const response = await projectService.getAll({}, {s: query}) as IProject[]
// Filter selected items from the results
foundProjects.value = response.filter(({id}) => !includesById(projects.value, id))
}
</script>

View File

@ -8,17 +8,20 @@
'has-no-shadow': !shadow || variant === 'tertiary',
}
]"
:style="{
'--button-white-space': wrap ? 'break-spaces' : 'nowrap',
}"
>
<template v-if="icon">
<icon
v-if="showIconOnly"
:icon="icon"
:style="{'color': iconColor !== '' ? iconColor : false}"
:style="{'color': iconColor !== '' ? iconColor : undefined}"
/>
<span class="icon is-small" v-else>
<icon
:icon="icon"
:style="{'color': iconColor !== '' ? iconColor : false}"
:style="{'color': iconColor !== '' ? iconColor : undefined}"
/>
</span>
</template>
@ -50,6 +53,7 @@ export interface ButtonProps extends BaseButtonProps {
iconColor?: string
loading?: boolean
shadow?: boolean
wrap?: boolean
}
const {
@ -58,6 +62,7 @@ const {
iconColor = '',
loading = false,
shadow = true,
wrap = true,
} = defineProps<ButtonProps>()
const variantClass = computed(() => BUTTON_TYPES_MAP[variant])
@ -77,7 +82,7 @@ const showIconOnly = computed(() => icon !== '' && typeof slots.default === 'und
min-height: $button-height;
box-shadow: var(--shadow-sm);
display: inline-flex;
white-space: break-spaces;
white-space: var(--button-white-space);
&:hover {
box-shadow: var(--shadow-md);
@ -99,7 +104,6 @@ const showIconOnly = computed(() => icon !== '' && typeof slots.default === 'und
&.is-primary.is-outlined:hover {
color: var(--white);
}
}
.is-small {

View File

@ -286,11 +286,11 @@ function handleCheckboxClick(e: Event) {
console.debug('no index found')
return
}
const projectPrefix = text.value.substring(index, index + 1)
const listPrefix = text.value.substring(index, index + 1)
console.debug({index, projectPrefix, checked, text: text.value})
console.debug({index, listPrefix, checked, text: text.value})
text.value = replaceAt(text.value, index, `${projectPrefix} ${checked ? '[x]' : '[ ]'} `)
text.value = replaceAt(text.value, index, `${listPrefix} ${checked ? '[x]' : '[ ]'} `)
bubble()
renderPreview()
}

View File

@ -0,0 +1,200 @@
<template>
<div
:class="{ 'is-loading': listService.loading, 'is-archived': currentList.isArchived}"
class="loader-container"
>
<div class="switch-view-container">
<div class="switch-view">
<BaseButton
v-shortcut="'g l'"
:title="$t('keyboardShortcuts.list.switchToListView')"
class="switch-view-button"
:class="{'is-active': viewName === 'list'}"
:to="{ name: 'list.list', params: { listId } }"
>
{{ $t('list.list.title') }}
</BaseButton>
<BaseButton
v-shortcut="'g g'"
:title="$t('keyboardShortcuts.list.switchToGanttView')"
class="switch-view-button"
:class="{'is-active': viewName === 'gantt'}"
:to="{ name: 'list.gantt', params: { listId } }"
>
{{ $t('list.gantt.title') }}
</BaseButton>
<BaseButton
v-shortcut="'g t'"
:title="$t('keyboardShortcuts.list.switchToTableView')"
class="switch-view-button"
:class="{'is-active': viewName === 'table'}"
:to="{ name: 'list.table', params: { listId } }"
>
{{ $t('list.table.title') }}
</BaseButton>
<BaseButton
v-shortcut="'g k'"
:title="$t('keyboardShortcuts.list.switchToKanbanView')"
class="switch-view-button"
:class="{'is-active': viewName === 'kanban'}"
:to="{ name: 'list.kanban', params: { listId } }"
>
{{ $t('list.kanban.title') }}
</BaseButton>
</div>
<slot name="header" />
</div>
<CustomTransition name="fade">
<Message variant="warning" v-if="currentList.isArchived" class="mb-4">
{{ $t('list.archived') }}
</Message>
</CustomTransition>
<slot v-if="loadedListId"/>
</div>
</template>
<script setup lang="ts">
import {ref, computed, watch} from 'vue'
import {useRoute} from 'vue-router'
import BaseButton from '@/components/base/BaseButton.vue'
import Message from '@/components/misc/message.vue'
import CustomTransition from '@/components/misc/CustomTransition.vue'
import ListModel from '@/models/list'
import ListService from '@/services/list'
import {getListTitle} from '@/helpers/getListTitle'
import {saveListToHistory} from '@/modules/listHistory'
import {useTitle} from '@/composables/useTitle'
import {useBaseStore} from '@/stores/base'
import {useListStore} from '@/stores/lists'
const props = defineProps({
listId: {
type: Number,
required: true,
},
viewName: {
type: String,
required: true,
},
})
const route = useRoute()
const baseStore = useBaseStore()
const listStore = useListStore()
const listService = ref(new ListService())
const loadedListId = ref(0)
const currentList = computed(() => {
return typeof baseStore.currentList === 'undefined' ? {
id: 0,
title: '',
isArchived: false,
maxRight: null,
} : baseStore.currentList
})
useTitle(() => currentList.value.id ? getListTitle(currentList.value) : '')
// watchEffect would be called every time the prop would get a value assigned, even if that value was the same as before.
// This resulted in loading and setting the list multiple times, even when navigating away from it.
// This caused wired bugs where the list background would be set on the home page but only right after setting a new
// list background and then navigating to home. It also highlighted the list in the menu and didn't allow changing any
// of it, most likely due to the rights not being properly populated.
watch(
() => props.listId,
// loadList
async (listIdToLoad: number) => {
const listData = {id: listIdToLoad}
saveListToHistory(listData)
// Don't load the list if we either already loaded it or aren't dealing with a list at all currently and
// the currently loaded list has the right set.
if (
(
listIdToLoad === loadedListId.value ||
typeof listIdToLoad === 'undefined' ||
listIdToLoad === currentList.value.id
)
&& typeof currentList.value !== 'undefined' && currentList.value.maxRight !== null
) {
loadedListId.value = props.listId
return
}
console.debug(`Loading list, props.viewName = ${props.viewName}, $route.params =`, route.params, `, loadedListId = ${loadedListId.value}, currentList = `, currentList.value)
// Set the current list to the one we're about to load so that the title is already shown at the top
loadedListId.value = 0
const listFromStore = listStore.getListById(listData.id)
if (listFromStore !== null) {
baseStore.setBackground(null)
baseStore.setBlurHash(null)
baseStore.handleSetCurrentList({list: listFromStore})
}
// We create an extra list object instead of creating it in list.value because that would trigger a ui update which would result in bad ux.
const list = new ListModel(listData)
try {
const loadedList = await listService.value.get(list)
baseStore.handleSetCurrentList({list: loadedList})
} finally {
loadedListId.value = props.listId
}
},
{immediate: true},
)
</script>
<style lang="scss" scoped>
.switch-view-container {
@media screen and (max-width: $tablet) {
display: flex;
justify-content: center;
flex-direction: column;
}
}
.switch-view {
background: var(--white);
display: inline-flex;
border-radius: $radius;
font-size: .75rem;
box-shadow: var(--shadow-sm);
height: $switch-view-height;
margin: 0 auto 1rem;
padding: .5rem;
}
.switch-view-button {
padding: .25rem .5rem;
display: block;
border-radius: $radius;
transition: all 100ms;
&:not(:last-child) {
margin-right: .5rem;
}
&:hover {
color: var(--switch-view-color);
background: var(--primary);
}
&.is-active {
color: var(--switch-view-color);
background: var(--primary);
font-weight: bold;
box-shadow: var(--shadow-xs);
}
}
// FIXME: this should be in notification and set via a prop
.is-archived .notification.is-warning {
margin-bottom: 1rem;
}
</style>

View File

@ -8,24 +8,24 @@
</slot>
</template>
<template v-if="isSavedFilter(project)">
<template v-if="isSavedFilter(list)">
<dropdown-item
:to="{ name: 'filter.settings.edit', params: { projectId: project.id } }"
:to="{ name: 'filter.settings.edit', params: { listId: list.id } }"
icon="pen"
>
{{ $t('menu.edit') }}
</dropdown-item>
<dropdown-item
:to="{ name: 'filter.settings.delete', params: { projectId: project.id } }"
:to="{ name: 'filter.settings.delete', params: { listId: list.id } }"
icon="trash-alt"
>
{{ $t('misc.delete') }}
</dropdown-item>
</template>
<template v-else-if="project.isArchived">
<template v-else-if="list.isArchived">
<dropdown-item
:to="{ name: 'project.settings.archive', params: { projectId: project.id } }"
:to="{ name: 'list.settings.archive', params: { listId: list.id } }"
icon="archive"
>
{{ $t('menu.unarchive') }}
@ -33,32 +33,32 @@
</template>
<template v-else>
<dropdown-item
:to="{ name: 'project.settings.edit', params: { projectId: project.id } }"
:to="{ name: 'list.settings.edit', params: { listId: list.id } }"
icon="pen"
>
{{ $t('menu.edit') }}
</dropdown-item>
<dropdown-item
v-if="backgroundsEnabled"
:to="{ name: 'project.settings.background', params: { projectId: project.id } }"
:to="{ name: 'list.settings.background', params: { listId: list.id } }"
icon="image"
>
{{ $t('menu.setBackground') }}
</dropdown-item>
<dropdown-item
:to="{ name: 'project.settings.share', params: { projectId: project.id } }"
:to="{ name: 'list.settings.share', params: { listId: list.id } }"
icon="share-alt"
>
{{ $t('menu.share') }}
</dropdown-item>
<dropdown-item
:to="{ name: 'project.settings.duplicate', params: { projectId: project.id } }"
:to="{ name: 'list.settings.duplicate', params: { listId: list.id } }"
icon="paste"
>
{{ $t('menu.duplicate') }}
</dropdown-item>
<dropdown-item
:to="{ name: 'project.settings.archive', params: { projectId: project.id } }"
:to="{ name: 'list.settings.archive', params: { listId: list.id } }"
icon="archive"
>
{{ $t('menu.archive') }}
@ -66,14 +66,14 @@
<Subscription
class="has-no-shadow"
:is-button="false"
entity="project"
:entity-id="project.id"
:model-value="project.subscription"
entity="list"
:entity-id="list.id"
:model-value="list.subscription"
@update:model-value="setSubscriptionInStore"
type="dropdown"
/>
<dropdown-item
:to="{ name: 'project.settings.delete', params: { projectId: project.id } }"
:to="{ name: 'list.settings.delete', params: { listId: list.id } }"
icon="trash-alt"
class="has-text-danger"
>
@ -90,26 +90,26 @@ import BaseButton from '@/components/base/BaseButton.vue'
import Dropdown from '@/components/misc/dropdown.vue'
import DropdownItem from '@/components/misc/dropdown-item.vue'
import Subscription from '@/components/misc/subscription.vue'
import type {IProject} from '@/modelTypes/IProject'
import type {IList} from '@/modelTypes/IList'
import type {ISubscription} from '@/modelTypes/ISubscription'
import {isSavedFilter} from '@/services/savedFilter'
import {useConfigStore} from '@/stores/config'
import {useProjectStore} from '@/stores/projects'
import {useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces'
const props = defineProps({
project: {
type: Object as PropType<IProject>,
list: {
type: Object as PropType<IList>,
required: true,
},
})
const projectStore = useProjectStore()
const listStore = useListStore()
const namespaceStore = useNamespaceStore()
const subscription = ref<ISubscription | null>(null)
watchEffect(() => {
subscription.value = props.project.subscription ?? null
subscription.value = props.list.subscription ?? null
})
const configStore = useConfigStore()
@ -117,11 +117,11 @@ const backgroundsEnabled = computed(() => configStore.enabledBackgroundProviders
function setSubscriptionInStore(sub: ISubscription) {
subscription.value = sub
const updatedProject = {
...props.project,
const updatedList = {
...props.list,
subscription: sub,
}
projectStore.setProject(updatedProject)
namespaceStore.setProjectInNamespaceById(updatedProject)
listStore.setList(updatedList)
namespaceStore.setListInNamespaceById(updatedList)
}
</script>

View File

@ -1,39 +1,39 @@
<template>
<div
class="project-card"
class="list-card"
:class="{
'has-light-text': background !== null,
'has-background': blurHashUrl !== '' || background !== null
}"
:style="{
'border-left': project.hexColor ? `0.25rem solid ${project.hexColor}` : undefined,
'border-left': list.hexColor ? `0.25rem solid ${list.hexColor}` : undefined,
'background-image': blurHashUrl !== '' ? `url(${blurHashUrl})` : undefined,
}"
>
<div
class="project-background background-fade-in"
class="list-background background-fade-in"
:class="{'is-visible': background}"
:style="{'background-image': background !== null ? `url(${background})` : undefined}"
/>
<span v-if="project.isArchived" class="is-archived" >{{ $t('namespace.archived') }}</span>
<span v-if="list.isArchived" class="is-archived" >{{ $t('namespace.archived') }}</span>
<div class="project-title" aria-hidden="true">{{ project.title }}</div>
<div class="list-title" aria-hidden="true">{{ list.title }}</div>
<BaseButton
class="project-button"
:aria-label="project.title"
:title="project.description"
class="list-button"
:aria-label="list.title"
:title="list.description"
:to="{
name: 'project.index',
params: { projectId: project.id}
name: 'list.index',
params: { listId: list.id}
}"
/>
<BaseButton
v-if="!project.isArchived"
v-if="!list.isArchived"
class="favorite"
:class="{'is-favorite': project.isFavorite}"
@click.prevent.stop="projectStore.toggleProjectFavorite(project)"
:class="{'is-favorite': list.isFavorite}"
@click.prevent.stop="listStore.toggleListFavorite(list)"
>
<icon :icon="project.isFavorite ? 'star' : ['far', 'star']" />
<icon :icon="list.isFavorite ? 'star' : ['far', 'star']" />
</BaseButton>
</div>
</template>
@ -41,30 +41,30 @@
<script lang="ts" setup>
import {toRef, type PropType} from 'vue'
import type {IProject} from '@/modelTypes/IProject'
import type {IList} from '@/modelTypes/IList'
import BaseButton from '@/components/base/BaseButton.vue'
import {useProjectBackground} from './useProjectBackground'
import {useProjectStore} from '@/stores/projects'
import {useListBackground} from './useListBackground'
import {useListStore} from '@/stores/lists'
const props = defineProps({
project: {
type: Object as PropType<IProject>,
list: {
type: Object as PropType<IList>,
required: true,
},
})
const {background, blurHashUrl} = useProjectBackground(toRef(props, 'project'))
const {background, blurHashUrl} = useListBackground(toRef(props, 'list'))
const projectStore = useProjectStore()
const listStore = useListStore()
</script>
<style lang="scss" scoped>
.project-card {
--project-card-padding: 1rem;
.list-card {
--list-card-padding: 1rem;
background: var(--white);
padding: var(--project-card-padding);
padding: var(--list-card-padding);
border-radius: $radius;
box-shadow: var(--shadow-sm);
transition: box-shadow $transition;
@ -91,14 +91,14 @@ const projectStore = useProjectStore()
}
.has-background,
.project-background {
.list-background {
background-size: cover;
background-repeat: no-repeat;
background-position: center;
}
.project-background,
.project-button {
.list-background,
.list-button {
position: absolute;
top: 0;
right: 0;
@ -111,7 +111,7 @@ const projectStore = useProjectStore()
float: left;
}
.project-title {
.list-title {
align-self: flex-end;
font-family: $vikunja-font;
font-weight: 400;
@ -120,7 +120,7 @@ const projectStore = useProjectStore()
color: var(--text);
width: 100%;
margin-bottom: 0;
max-height: calc(100% - (var(--project-card-padding) + 1rem)); // padding & height of the "is archived" badge
max-height: calc(100% - (var(--list-card-padding) + 1rem)); // padding & height of the "is archived" badge
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
@ -130,11 +130,11 @@ const projectStore = useProjectStore()
-webkit-box-orient: vertical;
}
.has-light-text .project-title {
.has-light-text .list-title {
color: var(--grey-100);
}
.has-background .project-title {
.has-background .list-title {
text-shadow:
0 0 10px var(--black),
1px 1px 5px var(--grey-700),
@ -144,8 +144,8 @@ const projectStore = useProjectStore()
.favorite {
position: absolute;
top: var(--project-card-padding);
right: var(--project-card-padding);
top: var(--list-card-padding);
right: var(--list-card-padding);
transition: opacity $transition, color $transition;
opacity: 0;
@ -160,7 +160,7 @@ const projectStore = useProjectStore()
}
}
.project-card:hover .favorite {
.list-card:hover .favorite {
opacity: 1;
}

View File

@ -0,0 +1,77 @@
<template>
<ul class="list-grid">
<li
v-for="(item, index) in filteredLists"
:key="`list_${item.id}_${index}`"
class="list-grid-item"
>
<ListCard :list="item" />
</li>
</ul>
</template>
<script lang="ts" setup>
import {computed, type PropType} from 'vue'
import type {IList} from '@/modelTypes/IList'
import ListCard from './ListCard.vue'
const props = defineProps({
lists: {
type: Array as PropType<IList[]>,
default: () => [],
},
showArchived: {
default: false,
type: Boolean,
},
itemLimit: {
type: Boolean,
default: false,
},
})
const filteredLists = computed(() => {
return props.showArchived
? props.lists
: props.lists.filter(l => !l.isArchived)
})
</script>
<style lang="scss" scoped>
$list-height: 150px;
$list-spacing: 1rem;
.list-grid {
margin: 0; // reset li
list-style-type: none;
display: grid;
grid-template-columns: repeat(var(--list-columns), 1fr);
grid-auto-rows: $list-height;
gap: $list-spacing;
@media screen and (min-width: $mobile) {
--list-rows: 4;
--list-columns: 1;
}
@media screen and (min-width: $mobile) and (max-width: $tablet) {
--list-columns: 2;
}
@media screen and (min-width: $tablet) and (max-width: $widescreen) {
--list-columns: 3;
--list-rows: 3;
}
@media screen and (min-width: $widescreen) {
--list-columns: 5;
--list-rows: 2;
}
}
.list-grid-item {
display: grid;
margin-top: 0; // remove padding coming form .content li + li
}
</style>

View File

@ -32,7 +32,7 @@
<script setup lang="ts">
import {computed, ref, watch} from 'vue'
import Filters from '@/components/project/partials/filters.vue'
import Filters from '@/components/list/partials/filters.vue'
import {getDefaultParams} from '@/composables/useTaskList'

View File

@ -20,7 +20,7 @@
{{ $t('filters.attributes.showDoneTasks') }}
</fancycheckbox>
<fancycheckbox
v-if="!['project.kanban', 'project.table'].includes($route.name as string)"
v-if="!['list.kanban', 'list.table'].includes($route.name as string)"
v-model="sortAlphabetically"
@update:model-value="change()"
>
@ -154,14 +154,14 @@
</div>
<template
v-if="['filters.create', 'project.edit', 'filter.settings.edit'].includes($route.name as string)">
v-if="['filters.create', 'list.edit', 'filter.settings.edit'].includes($route.name as string)">
<div class="field">
<label class="label">{{ $t('project.lists') }}</label>
<label class="label">{{ $t('list.lists') }}</label>
<div class="control">
<SelectProject
v-model="entities.projects"
@select="changeMultiselectFilter('projects', 'project_id')"
@remove="changeMultiselectFilter('projects', 'project_id')"
<SelectList
v-model="entities.lists"
@select="changeMultiselectFilter('lists', 'list_id')"
@remove="changeMultiselectFilter('lists', 'list_id')"
/>
</div>
</div>
@ -190,7 +190,7 @@ import {camelCase} from 'camel-case'
import type {ILabel} from '@/modelTypes/ILabel'
import type {IUser} from '@/modelTypes/IUser'
import type {INamespace} from '@/modelTypes/INamespace'
import type {IProject} from '@/modelTypes/IProject'
import type {IList} from '@/modelTypes/IList'
import {useLabelStore} from '@/stores/labels'
@ -200,7 +200,7 @@ import PercentDoneSelect from '@/components/tasks/partials/percentDoneSelect.vue
import EditLabels from '@/components/tasks/partials/editLabels.vue'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import SelectUser from '@/components/input/SelectUser.vue'
import SelectProject from '@/components/input/SelectProject.vue'
import SelectList from '@/components/input/SelectList.vue'
import SelectNamespace from '@/components/input/SelectNamespace.vue'
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
@ -208,13 +208,13 @@ import {dateIsValid, formatISO} from '@/helpers/time/formatDate'
import {objectToSnakeCase} from '@/helpers/case'
import UserService from '@/services/user'
import ProjectService from '@/services/project'
import ListService from '@/services/list'
import NamespaceService from '@/services/namespace'
// FIXME: do not use this here for now. instead create new version from DEFAULT_PARAMS
import {getDefaultParams} from '@/composables/useTaskList'
// FIXME: merge with DEFAULT_PARAMS in taskProject.js
// FIXME: merge with DEFAULT_PARAMS in taskList.js
const DEFAULT_PARAMS = {
sort_by: [],
order_by: [],
@ -239,7 +239,7 @@ const DEFAULT_FILTERS = {
reminders: '',
assignees: '',
labels: '',
project_id: '',
list_id: '',
namespace: '',
} as const
@ -264,23 +264,23 @@ const filters = ref({...DEFAULT_FILTERS})
const services = {
users: shallowReactive(new UserService()),
projects: shallowReactive(new ProjectService()),
lists: shallowReactive(new ListService()),
namespace: shallowReactive(new NamespaceService()),
}
interface Entities {
users: IUser[]
labels: ILabel[]
projects: IProject[]
lists: IList[]
namespace: INamespace[]
}
type EntityType = 'users' | 'labels' | 'projects' | 'namespace'
type EntityType = 'users' | 'labels' | 'lists' | 'namespace'
const entities: Entities = reactive({
users: [],
labels: [],
projects: [],
lists: [],
namespace: [],
})
@ -327,7 +327,7 @@ function prepareFilters() {
prepareSingleValue('percent_done', 'percentDone', 'usePercentDone', true)
prepareDate('reminders')
prepareRelatedObjectFilter('users', 'assignees')
prepareRelatedObjectFilter('projects', 'project_id')
prepareRelatedObjectFilter('lists', 'list_id')
prepareRelatedObjectFilter('namespace')
prepareSingleValue('labels')

View File

@ -1,30 +1,30 @@
import {ref, watch, type Ref} from 'vue'
import ProjectService from '@/services/project'
import type {IProject} from '@/modelTypes/IProject'
import ListService from '@/services/list'
import type {IList} from '@/modelTypes/IList'
import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash'
export function useProjectBackground(project: Ref<IProject>) {
export function useListBackground(list: Ref<IList>) {
const background = ref<string | null>(null)
const backgroundLoading = ref(false)
const blurHashUrl = ref('')
watch(
() => [project.value.id, project.value.backgroundBlurHash] as [IProject['id'], IProject['backgroundBlurHash']],
async ([projectId, blurHash], oldValue) => {
() => [list.value.id, list.value.backgroundBlurHash] as [IList['id'], IList['backgroundBlurHash']],
async ([listId, blurHash], oldValue) => {
if (
project.value === null ||
!project.value.backgroundInformation ||
list.value === null ||
!list.value.backgroundInformation ||
backgroundLoading.value
) {
return
}
const [oldProjectId, oldBlurHash] = oldValue || []
const [oldListId, oldBlurHash] = oldValue || []
if (
oldValue !== undefined &&
projectId === oldProjectId && blurHash === oldBlurHash
oldValue !== undefined &&
listId === oldListId && blurHash === oldBlurHash
) {
// project hasn't changed
// list hasn't changed
return
}
@ -35,8 +35,8 @@ export function useProjectBackground(project: Ref<IProject>) {
blurHashUrl.value = blurHash ? window.URL.createObjectURL(blurHash) : ''
})
const projectService = new ProjectService()
const backgroundPromise = projectService.background(project.value).then((result) => {
const listService = new ListService()
const backgroundPromise = listService.background(list.value).then((result) => {
background.value = result
})
await Promise.all([blurHashPromise, backgroundPromise])
@ -44,7 +44,7 @@ export function useProjectBackground(project: Ref<IProject>) {
backgroundLoading.value = false
}
},
{immediate: true},
{ immediate: true },
)
return {
@ -52,4 +52,4 @@ export function useProjectBackground(project: Ref<IProject>) {
blurHashUrl,
backgroundLoading,
}
}
}

View File

@ -1,6 +1,6 @@
<template>
<div class="dropdown" ref="dropdown">
<slot name="trigger" :close="close" :toggleOpen="toggleOpen">
<slot name="trigger" :close="close" :toggleOpen="toggleOpen" :open="open">
<BaseButton class="dropdown-trigger is-flex" @click="toggleOpen">
<icon :icon="triggerIcon" class="icon"/>
</BaseButton>
@ -56,7 +56,6 @@ onClickOutside(dropdown, (e: Event) => {
.dropdown {
display: inline-flex;
position: relative;
vertical-align: top;
}
.dropdown-menu {

View File

@ -61,8 +61,8 @@ export const KEYBOARD_SHORTCUTS : ShortcutGroup[] = [
],
},
{
title: 'project.kanban.title',
available: (route) => route.name === 'project.kanban',
title: 'list.kanban.title',
available: (route) => route.name === 'list.kanban',
shortcuts: [
{
title: 'keyboardShortcuts.task.done',
@ -71,26 +71,26 @@ export const KEYBOARD_SHORTCUTS : ShortcutGroup[] = [
],
},
{
title: 'keyboardShortcuts.project.title',
available: (route) => (route.name as string)?.startsWith('project.'),
title: 'keyboardShortcuts.list.title',
available: (route) => (route.name as string)?.startsWith('list.'),
shortcuts: [
{
title: 'keyboardShortcuts.project.switchToProjectView',
title: 'keyboardShortcuts.list.switchToListView',
keys: ['g', 'l'],
combination: 'then',
},
{
title: 'keyboardShortcuts.project.switchToGanttView',
title: 'keyboardShortcuts.list.switchToGanttView',
keys: ['g', 'g'],
combination: 'then',
},
{
title: 'keyboardShortcuts.project.switchToTableView',
title: 'keyboardShortcuts.list.switchToTableView',
keys: ['g', 't'],
combination: 'then',
},
{
title: 'keyboardShortcuts.project.switchToKanbanView',
title: 'keyboardShortcuts.list.switchToKanbanView',
keys: ['g', 'k'],
combination: 'then',
},

View File

@ -73,14 +73,14 @@ const {t} = useI18n({useScope: 'global'})
const tooltipText = computed(() => {
if (disabled.value) {
if (props.entity === 'project' && subscriptionEntity.value === 'namespace') {
return t('task.subscription.subscribedProjectThroughParentNamespace')
if (props.entity === 'list' && subscriptionEntity.value === 'namespace') {
return t('task.subscription.subscribedListThroughParentNamespace')
}
if (props.entity === 'task' && subscriptionEntity.value === 'namespace') {
return t('task.subscription.subscribedTaskThroughParentNamespace')
}
if (props.entity === 'task' && subscriptionEntity.value === 'project') {
return t('task.subscription.subscribedTaskThroughParentProject')
if (props.entity === 'task' && subscriptionEntity.value === 'list') {
return t('task.subscription.subscribedTaskThroughParentList')
}
return ''
@ -91,10 +91,10 @@ const tooltipText = computed(() => {
return props.modelValue !== null ?
t('task.subscription.subscribedNamespace') :
t('task.subscription.notSubscribedNamespace')
case 'project':
case 'list':
return props.modelValue !== null ?
t('task.subscription.subscribedProject') :
t('task.subscription.notSubscribedProject')
t('task.subscription.subscribedList') :
t('task.subscription.notSubscribedList')
case 'task':
return props.modelValue !== null ?
t('task.subscription.subscribedTask') :
@ -133,8 +133,8 @@ async function subscribe() {
case 'namespace':
message = t('task.subscription.subscribeSuccessNamespace')
break
case 'project':
message = t('task.subscription.subscribeSuccessProject')
case 'list':
message = t('task.subscription.subscribeSuccessList')
break
case 'task':
message = t('task.subscription.subscribeSuccessTask')
@ -156,8 +156,8 @@ async function unsubscribe() {
case 'namespace':
message = t('task.subscription.unsubscribeSuccessNamespace')
break
case 'project':
message = t('task.subscription.unsubscribeSuccessProject')
case 'list':
message = t('task.subscription.unsubscribeSuccessList')
break
case 'task':
message = t('task.subscription.unsubscribeSuccessTask')

View File

@ -30,10 +30,10 @@
{{ $t('menu.share') }}
</dropdown-item>
<dropdown-item
:to="{ name: 'project.create', params: { namespaceId: namespace.id } }"
:to="{ name: 'list.create', params: { namespaceId: namespace.id } }"
icon="plus"
>
{{ $t('menu.newProject') }}
{{ $t('menu.newList') }}
</dropdown-item>
<dropdown-item
:to="{ name: 'namespace.settings.archive', params: { id: namespace.id } }"

View File

@ -1,11 +1,11 @@
<template>
<div class="notifications">
<div class="is-flex is-justify-content-center">
<BaseButton @click.stop="showNotifications = !showNotifications" class="trigger-button">
<slot name="trigger" toggleOpen="() => showNotifications = !showNotifications" :has-unread-notifications="unreadNotifications > 0">
<BaseButton class="trigger-button" @click.stop="showNotifications = !showNotifications">
<span class="unread-indicator" v-if="unreadNotifications > 0"></span>
<icon icon="bell"/>
</BaseButton>
</div>
</slot>
<CustomTransition name="fade">
<div class="notifications-list" v-if="showNotifications" ref="popup">
@ -117,9 +117,9 @@ function to(n, index) {
case names.TASK_DELETED:
// Nothing
break
case names.PROJECT_CREATED:
case names.LIST_CREATED:
to.name = 'task.index'
to.params.projectId = n.notification.project.id
to.params.listId = n.notification.list.id
break
case names.TEAM_MEMBER_ADDED:
to.name = 'teams.edit'
@ -141,7 +141,11 @@ function to(n, index) {
<style lang="scss" scoped>
.notifications {
width: $navbar-icon-width;
display: flex;
.trigger-button {
width: 100%;
}
.unread-indicator {
position: absolute;
@ -156,9 +160,9 @@ function to(n, index) {
}
.notifications-list {
position: fixed;
position: absolute;
right: 1rem;
margin-top: 1rem;
top: calc(100% + 1rem);
max-height: 400px;
overflow-y: auto;

View File

@ -1,199 +0,0 @@
<template>
<div
:class="{ 'is-loading': projectService.loading, 'is-archived': currentProject.isArchived}"
class="loader-container"
>
<div class="switch-view-container">
<div class="switch-view">
<BaseButton
v-shortcut="'g l'"
:title="$t('keyboardShortcuts.project.switchToProjectView')"
class="switch-view-button"
:class="{'is-active': viewName === 'project'}"
:to="{ name: 'project.list', params: { projectId } }"
>
{{ $t('project.list.title') }}
</BaseButton>
<BaseButton
v-shortcut="'g g'"
:title="$t('keyboardShortcuts.project.switchToGanttView')"
class="switch-view-button"
:class="{'is-active': viewName === 'gantt'}"
:to="{ name: 'project.gantt', params: { projectId } }"
>
{{ $t('project.gantt.title') }}
</BaseButton>
<BaseButton
v-shortcut="'g t'"
:title="$t('keyboardShortcuts.project.switchToTableView')"
class="switch-view-button"
:class="{'is-active': viewName === 'table'}"
:to="{ name: 'project.table', params: { projectId } }"
>
{{ $t('project.table.title') }}
</BaseButton>
<BaseButton
v-shortcut="'g k'"
:title="$t('keyboardShortcuts.project.switchToKanbanView')"
class="switch-view-button"
:class="{'is-active': viewName === 'kanban'}"
:to="{ name: 'project.kanban', params: { projectId } }"
>
{{ $t('project.kanban.title') }}
</BaseButton>
</div>
<slot name="header" />
</div>
<transition name="fade">
<Message variant="warning" v-if="currentProject.isArchived" class="mb-4">
{{ $t('project.archived') }}
</Message>
</transition>
<slot v-if="loadedProjectId"/>
</div>
</template>
<script setup lang="ts">
import {ref, computed, watch} from 'vue'
import {useRoute} from 'vue-router'
import BaseButton from '@/components/base/BaseButton.vue'
import Message from '@/components/misc/message.vue'
import ProjectModel from '@/models/project'
import ProjectService from '@/services/project'
import {getProjectTitle} from '@/helpers/getProjectTitle'
import {saveProjectToHistory} from '@/modules/projectHistory'
import {useTitle} from '@/composables/useTitle'
import {useBaseStore} from '@/stores/base'
import {useProjectStore} from '@/stores/projects'
const props = defineProps({
projectId: {
type: Number,
required: true,
},
viewName: {
type: String,
required: true,
},
})
const route = useRoute()
const baseStore = useBaseStore()
const projectStore = useProjectStore()
const projectService = ref(new ProjectService())
const loadedProjectId = ref(0)
const currentProject = computed(() => {
return typeof baseStore.currentProject === 'undefined' ? {
id: 0,
title: '',
isArchived: false,
maxRight: null,
} : baseStore.currentProject
})
useTitle(() => currentProject.value.id ? getProjectTitle(currentProject.value) : '')
// watchEffect would be called every time the prop would get a value assigned, even if that value was the same as before.
// This resulted in loading and setting the project multiple times, even when navigating away from it.
// This caused wired bugs where the project background would be set on the home page but only right after setting a new
// project background and then navigating to home. It also highlighted the project in the menu and didn't allow changing any
// of it, most likely due to the rights not being properly populated.
watch(
() => props.projectId,
// loadProject
async (projectIdToLoad: number) => {
const projectData = {id: projectIdToLoad}
saveProjectToHistory(projectData)
// Don't load the project if we either already loaded it or aren't dealing with a project at all currently and
// the currently loaded project has the right set.
if (
(
projectIdToLoad === loadedProjectId.value ||
typeof projectIdToLoad === 'undefined' ||
projectIdToLoad === currentProject.value.id
)
&& typeof currentProject.value !== 'undefined' && currentProject.value.maxRight !== null
) {
loadedProjectId.value = props.projectId
return
}
console.debug(`Loading project, props.viewName = ${props.viewName}, $route.params =`, route.params, `, loadedProjectId = ${loadedProjectId.value}, currentProject = `, currentProject.value)
// Set the current project to the one we're about to load so that the title is already shown at the top
loadedProjectId.value = 0
const projectFromStore = projectStore.getProjectById(projectData.id)
if (projectFromStore !== null) {
baseStore.setBackground(null)
baseStore.setBlurHash(null)
baseStore.handleSetCurrentProject({project: projectFromStore})
}
// We create an extra project object instead of creating it in project.value because that would trigger a ui update which would result in bad ux.
const project = new ProjectModel(projectData)
try {
const loadedProject = await projectService.value.get(project)
baseStore.handleSetCurrentProject({project: loadedProject})
} finally {
loadedProjectId.value = props.projectId
}
},
{immediate: true},
)
</script>
<style lang="scss" scoped>
.switch-view-container {
@media screen and (max-width: $tablet) {
display: flex;
justify-content: center;
flex-direction: column;
}
}
.switch-view {
background: var(--white);
display: inline-flex;
border-radius: $radius;
font-size: .75rem;
box-shadow: var(--shadow-sm);
height: $switch-view-height;
margin: 0 auto 1rem;
padding: .5rem;
}
.switch-view-button {
padding: .25rem .5rem;
display: block;
border-radius: $radius;
transition: all 100ms;
&:not(:last-child) {
margin-right: .5rem;
}
&:hover {
color: var(--switch-view-color);
background: var(--primary);
}
&.is-active {
color: var(--switch-view-color);
background: var(--primary);
font-weight: bold;
box-shadow: var(--shadow-xs);
}
}
// FIXME: this should be in notification and set via a prop
.is-archived .notification.is-warning {
margin-bottom: 1rem;
}
</style>

View File

@ -1,77 +0,0 @@
<template>
<ul class="project-grid">
<li
v-for="(item, index) in filteredProjects"
:key="`project_${item.id}_${index}`"
class="project-grid-item"
>
<ProjectCard :project="item" />
</li>
</ul>
</template>
<script lang="ts" setup>
import {computed, type PropType} from 'vue'
import type {IProject} from '@/modelTypes/IProject'
import ProjectCard from './ProjectCard.vue'
const props = defineProps({
projects: {
type: Array as PropType<IProject[]>,
default: () => [],
},
showArchived: {
default: false,
type: Boolean,
},
itemLimit: {
type: Boolean,
default: false,
},
})
const filteredProjects = computed(() => {
return props.showArchived
? props.projects
: props.projects.filter(l => !l.isArchived)
})
</script>
<style lang="scss" scoped>
$project-height: 150px;
$project-spacing: 1rem;
.project-grid {
margin: 0; // reset li
project-style-type: none;
display: grid;
grid-template-columns: repeat(var(--project-columns), 1fr);
grid-auto-rows: $project-height;
gap: $project-spacing;
@media screen and (min-width: $mobile) {
--project-rows: 4;
--project-columns: 1;
}
@media screen and (min-width: $mobile) and (max-width: $tablet) {
--project-columns: 2;
}
@media screen and (min-width: $tablet) and (max-width: $widescreen) {
--project-columns: 3;
--project-rows: 3;
}
@media screen and (min-width: $widescreen) {
--project-columns: 5;
--project-rows: 2;
}
}
.project-grid-item {
display: grid;
margin-top: 0; // remove padding coming form .content li + li
}
</style>

View File

@ -63,18 +63,18 @@ import TeamService from '@/services/team'
import NamespaceModel from '@/models/namespace'
import TeamModel from '@/models/team'
import ProjectModel from '@/models/project'
import ListModel from '@/models/list'
import BaseButton from '@/components/base/BaseButton.vue'
import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue'
import {useBaseStore} from '@/stores/base'
import {useProjectStore} from '@/stores/projects'
import {useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces'
import {useLabelStore} from '@/stores/labels'
import {useTaskStore} from '@/stores/tasks'
import {getHistory} from '@/modules/projectHistory'
import {getHistory} from '@/modules/listHistory'
import {parseTaskText, PrefixMode, PREFIXES} from '@/modules/parseTaskText'
import {getQuickAddMagicMode} from '@/helpers/quickAddMagicMode'
import {success} from '@/message'
@ -82,13 +82,13 @@ import {success} from '@/message'
import type {ITeam} from '@/modelTypes/ITeam'
import type {ITask} from '@/modelTypes/ITask'
import type {INamespace} from '@/modelTypes/INamespace'
import type {IProject} from '@/modelTypes/IProject'
import type {IList} from '@/modelTypes/IList'
const {t} = useI18n({useScope: 'global'})
const router = useRouter()
const baseStore = useBaseStore()
const projectStore = useProjectStore()
const listStore = useListStore()
const namespaceStore = useNamespaceStore()
const labelStore = useLabelStore()
const taskStore = useTaskStore()
@ -98,13 +98,13 @@ type DoAction<Type = any> = { type: ACTION_TYPE } & Type
enum ACTION_TYPE {
CMD = 'cmd',
TASK = 'task',
PROJECT = 'project',
LIST = 'list',
TEAM = 'team',
}
enum COMMAND_TYPE {
NEW_TASK = 'newTask',
NEW_PROJECT = 'newProject',
NEW_LIST = 'newList',
NEW_NAMESPACE = 'newNamespace',
NEW_TEAM = 'newTeam',
}
@ -112,7 +112,7 @@ enum COMMAND_TYPE {
enum SEARCH_MODE {
ALL = 'all',
TASKS = 'tasks',
PROJECTS = 'projects',
LISTS = 'lists',
TEAMS = 'teams',
}
@ -137,26 +137,26 @@ function closeQuickActions() {
baseStore.setQuickActionsActive(false)
}
const foundProjects = computed(() => {
const { project } = parsedQuery.value
const foundLists = computed(() => {
const { list } = parsedQuery.value
if (
searchMode.value === SEARCH_MODE.ALL ||
searchMode.value === SEARCH_MODE.PROJECTS ||
project === null
searchMode.value === SEARCH_MODE.LISTS ||
list === null
) {
return []
}
const ncache: { [id: ProjectModel['id']]: INamespace } = {}
const ncache: { [id: ListModel['id']]: INamespace } = {}
const history = getHistory()
const allProjects = [
const allLists = [
...new Set([
...history.map((l) => projectStore.getProjectById(l.id)),
...projectStore.searchProject(project),
...history.map((l) => listStore.getListById(l.id)),
...listStore.searchList(list),
]),
]
return allProjects.filter((l) => {
return allLists.filter((l) => {
if (typeof l === 'undefined' || l === null) {
return false
}
@ -191,9 +191,9 @@ const results = computed<Result[]>(() => {
items: foundTasks.value,
},
{
type: ACTION_TYPE.PROJECT,
title: t('quickActions.projects'),
items: foundProjects.value,
type: ACTION_TYPE.LIST,
title: t('quickActions.lists'),
items: foundLists.value,
},
{
type: ACTION_TYPE.TEAM,
@ -206,7 +206,7 @@ const results = computed<Result[]>(() => {
const loading = computed(() =>
taskService.loading ||
namespaceStore.isLoading ||
projectStore.isLoading ||
listStore.isLoading ||
teamService.loading,
)
@ -224,11 +224,11 @@ const commands = computed<{ [key in COMMAND_TYPE]: Command }>(() => ({
placeholder: t('quickActions.newTask'),
action: newTask,
},
newProject: {
type: COMMAND_TYPE.NEW_PROJECT,
title: t('quickActions.cmds.newProject'),
placeholder: t('quickActions.newProject'),
action: newProject,
newList: {
type: COMMAND_TYPE.NEW_LIST,
title: t('quickActions.cmds.newList'),
placeholder: t('quickActions.newList'),
action: newList,
},
newNamespace: {
type: COMMAND_TYPE.NEW_NAMESPACE,
@ -246,24 +246,24 @@ const commands = computed<{ [key in COMMAND_TYPE]: Command }>(() => ({
const placeholder = computed(() => selectedCmd.value?.placeholder || t('quickActions.placeholder'))
const currentProject = computed(() => Object.keys(baseStore.currentProject).length === 0
const currentList = computed(() => Object.keys(baseStore.currentList).length === 0
? null
: baseStore.currentProject,
: baseStore.currentList,
)
const hintText = computed(() => {
let namespace
if (selectedCmd.value !== null && currentProject.value !== null) {
if (selectedCmd.value !== null && currentList.value !== null) {
switch (selectedCmd.value.type) {
case COMMAND_TYPE.NEW_TASK:
return t('quickActions.createTask', {
title: currentProject.value.title,
title: currentList.value.title,
})
case COMMAND_TYPE.NEW_PROJECT:
case COMMAND_TYPE.NEW_LIST:
namespace = namespaceStore.getNamespaceById(
currentProject.value.namespaceId,
currentList.value.namespaceId,
)
return t('quickActions.createProject', {
return t('quickActions.createList', {
title: namespace?.title,
})
}
@ -275,8 +275,8 @@ const hintText = computed(() => {
const availableCmds = computed(() => {
const cmds = []
if (currentProject.value !== null) {
cmds.push(commands.value.newTask, commands.value.newProject)
if (currentList.value !== null) {
cmds.push(commands.value.newTask, commands.value.newList)
}
cmds.push(commands.value.newNamespace, commands.value.newTeam)
return cmds
@ -288,21 +288,21 @@ const searchMode = computed(() => {
if (query.value === '') {
return SEARCH_MODE.ALL
}
const { text, project, labels, assignees } = parsedQuery.value
const { text, list, labels, assignees } = parsedQuery.value
if (assignees.length === 0 && text !== '') {
return SEARCH_MODE.TASKS
}
if (
assignees.length === 0 &&
project !== null &&
list !== null &&
text === '' &&
labels.length === 0
) {
return SEARCH_MODE.PROJECTS
return SEARCH_MODE.LISTS
}
if (
assignees.length > 0 &&
project === null &&
list === null &&
text === '' &&
labels.length === 0
) {
@ -356,7 +356,7 @@ function searchTasks() {
taskSearchTimeout.value = null
}
const { text, project: projectName, labels } = parsedQuery.value
const { text, list: listName, labels } = parsedQuery.value
const filters: Filter[] = []
@ -373,10 +373,10 @@ function searchTasks() {
})
}
if (projectName !== null) {
const project = projectStore.findProjectByExactname(projectName)
if (project !== null) {
addFilter('projectId', project.id, 'equals')
if (listName !== null) {
const list = listStore.findListByExactname(listName)
if (list !== null) {
addFilter('listId', list.id, 'equals')
}
}
@ -396,9 +396,9 @@ function searchTasks() {
const r = await taskService.getAll({}, params) as DoAction<ITask>[]
foundTasks.value = r.map((t) => {
t.type = ACTION_TYPE.TASK
const project = projectStore.getProjectById(t.projectId)
if (project !== null) {
t.title = `${t.title} (${project.title})`
const list = listStore.getListById(t.listId)
if (list !== null) {
t.title = `${t.title} (${list.title})`
}
return t
})
@ -444,11 +444,11 @@ const searchInput = ref<HTMLElement | null>(null)
async function doAction(type: ACTION_TYPE, item: DoAction) {
switch (type) {
case ACTION_TYPE.PROJECT:
case ACTION_TYPE.LIST:
closeQuickActions()
await router.push({
name: 'project.index',
params: { projectId: (item as DoAction<IProject>).id },
name: 'list.index',
params: { listId: (item as DoAction<IList>).id },
})
break
case ACTION_TYPE.TASK:
@ -482,29 +482,29 @@ async function doCmd() {
}
async function newTask() {
if (currentProject.value === null) {
if (currentList.value === null) {
return
}
const task = await taskStore.createNewTask({
title: query.value,
projectId: currentProject.value.id,
listId: currentList.value.id,
})
success({ message: t('task.createSuccess') })
await router.push({ name: 'task.detail', params: { id: task.id } })
}
async function newProject() {
if (currentProject.value === null) {
async function newList() {
if (currentList.value === null) {
return
}
const newProject = await projectStore.createProject(new ProjectModel({
const newList = await listStore.createList(new ListModel({
title: query.value,
namespaceId: currentProject.value.namespaceId,
namespaceId: currentList.value.namespaceId,
}))
success({ message: t('project.create.createdSuccess')})
success({ message: t('list.create.createdSuccess')})
await router.push({
name: 'project.index',
params: { projectId: newProject.id },
name: 'list.index',
params: { listId: newList.id },
})
}
@ -546,7 +546,7 @@ function select(parentIndex: number, index: number) {
}
let elems = resultRefs.value[parentIndex][index]
if (results.value[parentIndex].items.length === index) {
elems = resultRefs.value[parentIndex + 1][0]
elems = resultRefs.value[parentIndex + 1] ? resultRefs.value[parentIndex + 1][0] : undefined
}
if (
typeof elems === 'undefined'
@ -576,6 +576,8 @@ function reset() {
<style lang="scss" scoped>
.quick-actions {
overflow: hidden;
// FIXME: changed position should be an option of the modal
:deep(.modal-content) {
top: 3rem;

View File

@ -1,39 +1,39 @@
<template>
<div>
<p class="has-text-weight-bold">
{{ $t('project.share.links.title') }}
{{ $t('list.share.links.title') }}
<span
class="is-size-7 has-text-grey is-italic ml-3"
v-tooltip="$t('project.share.links.explanation')">
{{ $t('project.share.links.what') }}
v-tooltip="$t('list.share.links.explanation')">
{{ $t('list.share.links.what') }}
</span>
</p>
<div class="sharables-project">
<div class="sharables-list">
<x-button
v-if="!(linkShares.length === 0 || showNewForm)"
@click="showNewForm = true"
icon="plus"
class="mb-4">
{{ $t('project.share.links.create') }}
{{ $t('list.share.links.create') }}
</x-button>
<div class="p-4" v-if="linkShares.length === 0 || showNewForm">
<div class="field">
<label class="label" for="linkShareRight">
{{ $t('project.share.right.title') }}
{{ $t('list.share.right.title') }}
</label>
<div class="control">
<div class="select">
<select v-model="selectedRight" id="linkShareRight">
<option :value="RIGHTS.READ">
{{ $t('project.share.right.read') }}
{{ $t('list.share.right.read') }}
</option>
<option :value="RIGHTS.READ_WRITE">
{{ $t('project.share.right.readWrite') }}
{{ $t('list.share.right.readWrite') }}
</option>
<option :value="RIGHTS.ADMIN">
{{ $t('project.share.right.admin') }}
{{ $t('list.share.right.admin') }}
</option>
</select>
</div>
@ -41,21 +41,21 @@
</div>
<div class="field">
<label class="label" for="linkShareName">
{{ $t('project.share.links.name') }}
{{ $t('list.share.links.name') }}
</label>
<div class="control">
<input
id="linkShareName"
class="input"
:placeholder="$t('project.share.links.namePlaceholder')"
v-tooltip="$t('project.share.links.nameExplanation')"
:placeholder="$t('list.share.links.namePlaceholder')"
v-tooltip="$t('list.share.links.nameExplanation')"
v-model="name"
/>
</div>
</div>
<div class="field">
<label class="label" for="linkSharePassword">
{{ $t('project.share.links.password') }}
{{ $t('list.share.links.password') }}
</label>
<div class="control">
<input
@ -63,25 +63,25 @@
type="password"
class="input"
:placeholder="$t('user.auth.passwordPlaceholder')"
v-tooltip="$t('project.share.links.passwordExplanation')"
v-tooltip="$t('list.share.links.passwordExplanation')"
v-model="password"
/>
</div>
</div>
<x-button @click="add(projectId)" icon="plus">
{{ $t('project.share.share') }}
<x-button @click="add(listId)" icon="plus">
{{ $t('list.share.share') }}
</x-button>
</div>
<table
class="table has-actions is-striped is-hoverable is-fullwidth"
class="table has-actions is-striped is-hoverable is-fullwidth link-share-list"
v-if="linkShares.length > 0"
>
<thead>
<tr>
<th></th>
<th>{{ $t('project.share.links.view') }}</th>
<th>{{ $t('project.share.attributes.delete') }}</th>
<th>{{ $t('list.share.links.view') }}</th>
<th>{{ $t('list.share.attributes.delete') }}</th>
</tr>
</thead>
<tbody>
@ -92,7 +92,7 @@
</p>
<p class="mb-2">
<i18n-t keypath="project.share.links.sharedBy" scope="global">
<i18n-t keypath="list.share.links.sharedBy" scope="global">
<strong>{{ getDisplayName(s.sharedBy) }}</strong>
</i18n-t>
</p>
@ -102,19 +102,19 @@
<span class="icon is-small">
<icon icon="lock"/>
</span>&nbsp;
{{ $t('project.share.right.admin') }}
{{ $t('list.share.right.admin') }}
</template>
<template v-else-if="s.right === RIGHTS.READ_WRITE">
<span class="icon is-small">
<icon icon="pen"/>
</span>&nbsp;
{{ $t('project.share.right.readWrite') }}
{{ $t('list.share.right.readWrite') }}
</template>
<template v-else>
<span class="icon is-small">
<icon icon="users"/>
</span>&nbsp;
{{ $t('project.share.right.read') }}
{{ $t('list.share.right.read') }}
</template>
</p>
@ -172,14 +172,14 @@
<modal
:enabled="showDeleteModal"
@close="showDeleteModal = false"
@submit="remove(projectId)"
@submit="remove(listId)"
>
<template #header>
<span>{{ $t('project.share.links.remove') }}</span>
<span>{{ $t('list.share.links.remove') }}</span>
</template>
<template #text>
<p>{{ $t('project.share.links.removeText') }}</p>
<p>{{ $t('list.share.links.removeText') }}</p>
</template>
</modal>
</div>
@ -193,19 +193,19 @@ import {RIGHTS} from '@/constants/rights'
import LinkShareModel from '@/models/linkShare'
import type {ILinkShare} from '@/modelTypes/ILinkShare'
import type {IProject} from '@/modelTypes/IProject'
import type {IList} from '@/modelTypes/IList'
import LinkShareService from '@/services/linkShare'
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
import {success} from '@/message'
import {getDisplayName} from '@/models/user'
import type {ProjectView} from '@/types/ProjectView'
import {PROJECT_VIEWS} from '@/types/ProjectView'
import type {ListView} from '@/types/ListView'
import {LIST_VIEWS} from '@/types/ListView'
import {useConfigStore} from '@/stores/config'
const props = defineProps({
projectId: {
listId: {
default: 0,
required: true,
},
@ -222,20 +222,20 @@ const showDeleteModal = ref(false)
const linkIdToDelete = ref(0)
const showNewForm = ref(false)
type SelectedViewMapper = Record<IProject['id'], ProjectView>
type SelectedViewMapper = Record<IList['id'], ListView>
const selectedView = ref<SelectedViewMapper>({})
const availableViews = computed<Record<ProjectView, string>>(() => ({
list: t('project.list.title'),
gantt: t('project.gantt.title'),
table: t('project.table.title'),
kanban: t('project.kanban.title'),
const availableViews = computed<Record<ListView, string>>(() => ({
list: t('list.list.title'),
gantt: t('list.gantt.title'),
table: t('list.table.title'),
kanban: t('list.kanban.title'),
}))
const copy = useCopyToClipboard()
watch(
() => props.projectId,
() => props.listId,
load,
{immediate: true},
)
@ -243,23 +243,23 @@ watch(
const configStore = useConfigStore()
const frontendUrl = computed(() => configStore.frontendUrl)
async function load(projectId: IProject['id']) {
// If projectId == 0 the project on the calling component wasn't already loaded, so we just bail out here
if (projectId === 0) {
async function load(listId: IList['id']) {
// If listId == 0 the list on the calling component wasn't already loaded, so we just bail out here
if (listId === 0) {
return
}
const links = await linkShareService.getAll({projectId})
const links = await linkShareService.getAll({listId})
links.forEach((l: ILinkShare) => {
selectedView.value[l.id] = 'project'
selectedView.value[l.id] = 'list'
})
linkShares.value = links
}
async function add(projectId: IProject['id']) {
async function add(listId: IList['id']) {
const newLinkShare = new LinkShareModel({
right: selectedRight.value,
projectId,
listId,
name: name.value,
password: password.value,
})
@ -268,31 +268,31 @@ async function add(projectId: IProject['id']) {
name.value = ''
password.value = ''
showNewForm.value = false
success({message: t('project.share.links.createSuccess')})
await load(projectId)
success({message: t('list.share.links.createSuccess')})
await load(listId)
}
async function remove(projectId: IProject['id']) {
async function remove(listId: IList['id']) {
try {
await linkShareService.delete(new LinkShareModel({
id: linkIdToDelete.value,
projectId,
listId,
}))
success({message: t('project.share.links.deleteSuccess')})
await load(projectId)
success({message: t('list.share.links.deleteSuccess')})
await load(listId)
} finally {
showDeleteModal.value = false
}
}
function getShareLink(hash: string, view: ProjectView = PROJECT_VIEWS.LIST) {
function getShareLink(hash: string, view: ListView = LIST_VIEWS.LIST) {
return frontendUrl.value + 'share/' + hash + '/auth?view=' + view
}
</script>
<style lang="scss" scoped>
// FIXME: I think this is not needed
.sharables-project:not(.card-content) {
.sharables-list:not(.card-content) {
overflow-y: auto
}
</style>

View File

@ -1,7 +1,7 @@
<template>
<div>
<p class="has-text-weight-bold">
{{ $t('project.share.userTeam.shared', {type: shareTypeNames}) }}
{{ $t('list.share.userTeam.shared', {type: shareTypeNames}) }}
</p>
<div v-if="userIsAdmin">
<div class="field has-addons">
@ -19,7 +19,7 @@
/>
</p>
<p class="control">
<x-button @click="add()">{{ $t('project.share.share') }}</x-button>
<x-button @click="add()">{{ $t('list.share.share') }}</x-button>
</p>
</div>
</div>
@ -31,7 +31,7 @@
<td>{{ getDisplayName(s) }}</td>
<td>
<template v-if="s.id === userInfo.id">
<b class="is-success">{{ $t('project.share.userTeam.you') }}</b>
<b class="is-success">{{ $t('list.share.userTeam.you') }}</b>
</template>
</td>
</template>
@ -52,19 +52,19 @@
<span class="icon is-small">
<icon icon="lock"/>
</span>
{{ $t('project.share.right.admin') }}
{{ $t('list.share.right.admin') }}
</template>
<template v-else-if="s.right === RIGHTS.READ_WRITE">
<span class="icon is-small">
<icon icon="pen"/>
</span>
{{ $t('project.share.right.readWrite') }}
{{ $t('list.share.right.readWrite') }}
</template>
<template v-else>
<span class="icon is-small">
<icon icon="users"/>
</span>
{{ $t('project.share.right.read') }}
{{ $t('list.share.right.read') }}
</template>
</td>
<td class="actions" v-if="userIsAdmin">
@ -78,19 +78,19 @@
:selected="s.right === RIGHTS.READ"
:value="RIGHTS.READ"
>
{{ $t('project.share.right.read') }}
{{ $t('list.share.right.read') }}
</option>
<option
:selected="s.right === RIGHTS.READ_WRITE"
:value="RIGHTS.READ_WRITE"
>
{{ $t('project.share.right.readWrite') }}
{{ $t('list.share.right.readWrite') }}
</option>
<option
:selected="s.right === RIGHTS.ADMIN"
:value="RIGHTS.ADMIN"
>
{{ $t('project.share.right.admin') }}
{{ $t('list.share.right.admin') }}
</option>
</select>
</div>
@ -110,7 +110,7 @@
</table>
<nothing v-else>
{{ $t('project.share.userTeam.notShared', {type: shareTypeNames}) }}
{{ $t('list.share.userTeam.notShared', {type: shareTypeNames}) }}
</nothing>
<modal
@ -120,11 +120,11 @@
>
<template #header>
<span>{{
$t('project.share.userTeam.removeHeader', {type: shareTypeName, sharable: sharableName})
$t('list.share.userTeam.removeHeader', {type: shareTypeName, sharable: sharableName})
}}</span>
</template>
<template #text>
<p>{{ $t('project.share.userTeam.removeText', {type: shareTypeName, sharable: sharableName}) }}</p>
<p>{{ $t('list.share.userTeam.removeText', {type: shareTypeName, sharable: sharableName}) }}</p>
</template>
</modal>
</div>
@ -143,9 +143,9 @@ import UserNamespaceService from '@/services/userNamespace'
import UserNamespaceModel from '@/models/userNamespace'
import type {IUserNamespace} from '@/modelTypes/IUserNamespace'
import UserProjectService from '@/services/userProject'
import UserProjectModel from '@/models/userProject'
import type {IUserProject} from '@/modelTypes/IUserProject'
import UserListService from '@/services/userList'
import UserListModel from '@/models/userList'
import type {IUserList} from '@/modelTypes/IUserList'
import UserService from '@/services/user'
import UserModel, { getDisplayName } from '@/models/user'
@ -155,9 +155,9 @@ import TeamNamespaceService from '@/services/teamNamespace'
import TeamNamespaceModel from '@/models/teamNamespace'
import type { ITeamNamespace } from '@/modelTypes/ITeamNamespace'
import TeamProjectService from '@/services/teamProject'
import TeamProjectModel from '@/models/teamProject'
import type { ITeamProject } from '@/modelTypes/ITeamProject'
import TeamListService from '@/services/teamList'
import TeamListModel from '@/models/teamList'
import type { ITeamList } from '@/modelTypes/ITeamList'
import TeamService from '@/services/team'
import TeamModel from '@/models/team'
@ -172,7 +172,7 @@ import {useAuthStore} from '@/stores/auth'
const props = defineProps({
type: {
type: String as PropType<'project' | 'namespace'>,
type: String as PropType<'list' | 'namespace'>,
default: '',
},
shareType: {
@ -191,9 +191,9 @@ const props = defineProps({
const {t} = useI18n({useScope: 'global'})
// This user service is either a userNamespaceService or a userProjectService, depending on the type we are using
let stuffService: UserNamespaceService | UserProjectService | TeamProjectService | TeamNamespaceService
let stuffModel: IUserNamespace | IUserProject | ITeamProject | ITeamNamespace
// This user service is either a userNamespaceService or a userListService, depending on the type we are using
let stuffService: UserNamespaceService | UserListService | TeamListService | TeamNamespaceService
let stuffModel: IUserNamespace | IUserList | ITeamList | ITeamNamespace
let searchService: UserService | TeamService
let sharable: Ref<IUser | ITeam>
@ -201,7 +201,7 @@ const searchLabel = ref('')
const selectedRight = ref({})
// This holds either teams or users who this namepace or project is shared with
// This holds either teams or users who this namepace or list is shared with
const sharables = ref([])
const showDeleteModal = ref(false)
@ -212,11 +212,11 @@ const userInfo = computed(() => authStore.info)
function createShareTypeNameComputed(count: number) {
return computed(() => {
if (props.shareType === 'user') {
return t('project.share.userTeam.typeUser', count)
return t('list.share.userTeam.typeUser', count)
}
if (props.shareType === 'team') {
return t('project.share.userTeam.typeTeam', count)
return t('list.share.userTeam.typeTeam', count)
}
return ''
@ -227,8 +227,8 @@ const shareTypeNames = createShareTypeNameComputed(2)
const shareTypeName = createShareTypeNameComputed(1)
const sharableName = computed(() => {
if (props.type === 'project') {
return t('project.list.title')
if (props.type === 'list') {
return t('list.list.title')
}
if (props.shareType === 'namespace') {
@ -244,9 +244,9 @@ if (props.shareType === 'user') {
sharable = ref(new UserModel())
searchLabel.value = 'username'
if (props.type === 'project') {
stuffService = shallowReactive(new UserProjectService())
stuffModel = reactive(new UserProjectModel({projectId: props.id}))
if (props.type === 'list') {
stuffService = shallowReactive(new UserListService())
stuffModel = reactive(new UserListModel({listId: props.id}))
} else if (props.type === 'namespace') {
stuffService = shallowReactive(new UserNamespaceService())
stuffModel = reactive(new UserNamespaceModel({
@ -261,9 +261,9 @@ if (props.shareType === 'user') {
sharable = ref(new TeamModel())
searchLabel.value = 'name'
if (props.type === 'project') {
stuffService = shallowReactive(new TeamProjectService())
stuffModel = reactive(new TeamProjectModel({projectId: props.id}))
if (props.type === 'list') {
stuffService = shallowReactive(new TeamListService())
stuffModel = reactive(new TeamListModel({listId: props.id}))
} else if (props.type === 'namespace') {
stuffService = shallowReactive(new TeamNamespaceService())
stuffModel = reactive(new TeamNamespaceModel({
@ -303,7 +303,7 @@ async function deleteSharable() {
}
}
success({
message: t('project.share.userTeam.removeSuccess', {
message: t('list.share.userTeam.removeSuccess', {
type: shareTypeName.value,
sharable: sharableName.value,
}),
@ -326,7 +326,7 @@ async function add(admin) {
}
await stuffService.create(stuffModel)
success({message: t('project.share.userTeam.addedSuccess', {type: shareTypeName.value})})
success({message: t('list.share.userTeam.addedSuccess', {type: shareTypeName.value})})
await load()
}
@ -358,7 +358,7 @@ async function toggleType(sharable) {
sharables.value[i].right = r.right
}
}
success({message: t('project.share.userTeam.updatedSuccess', {type: shareTypeName.value})})
success({message: t('list.share.userTeam.updatedSuccess', {type: shareTypeName.value})})
}
const found = ref([])

View File

@ -50,7 +50,7 @@ import {parseKebabDate} from '@/helpers/time/parseKebabDate'
import type {ITask, ITaskPartialWithId} from '@/modelTypes/ITask'
import type {DateISO} from '@/types/DateISO'
import type {GanttFilters} from '@/views/project/helpers/useGanttFilters'
import type {GanttFilters} from '@/views/list/helpers/useGanttFilters'
import {
extendDayjs,

View File

@ -5,7 +5,7 @@
<textarea
class="add-task-textarea input"
:class="{'textarea-empty': newTaskTitle === ''}"
:placeholder="$t('project.list.addPlaceholder')"
:placeholder="$t('list.list.addPlaceholder')"
rows="1"
v-focus
v-model="newTaskTitle"
@ -24,10 +24,10 @@
@click="addTask()"
icon="plus"
:loading="loading"
:aria-label="$t('project.list.add')"
:aria-label="$t('list.list.add')"
>
<span class="button-text">
{{ $t('project.list.add') }}
{{ $t('list.list.add') }}
</span>
</x-button>
</p>
@ -107,7 +107,7 @@ const loading = computed(() => taskStore.isLoading)
async function addTask() {
if (newTaskTitle.value === '') {
errorMessage.value = t('project.create.addTitleRequired')
errorMessage.value = t('list.create.addTitleRequired')
return
}
errorMessage.value = ''
@ -128,20 +128,20 @@ async function addTask() {
const allLabels = tasksToCreate.map(({title}) => getLabelsFromPrefix(title) ?? [])
await taskStore.ensureLabelsExist(allLabels.flat())
const newTasks = tasksToCreate.map(async ({title, project}) => {
const newTasks = tasksToCreate.map(async ({title, list}) => {
if (title === '') {
return
}
// If the task has a project specified, make sure to use it
let projectId = null
if (project !== null) {
projectId = await taskStore.findProjectId({project, projectId: 0})
// If the task has a list specified, make sure to use it
let listId = null
if (list !== null) {
listId = await taskStore.findListId({list, listId: 0})
}
const task = await taskStore.createNewTask({
title,
projectId: projectId || authStore.settings.defaultProjectId,
listId: listId || authStore.settings.defaultListId,
position: props.defaultPosition,
})
createdTasks[title] = task
@ -176,7 +176,7 @@ async function addTask() {
}))
createdTask.relatedTasks[RELATION_KIND.PARENTTASK] = [createdParentTask]
// we're only emitting here so that the relation shows up in the project
// we're only emitting here so that the relation shows up in the task list
emit('taskAdded', createdTask)
return rel
@ -184,8 +184,8 @@ async function addTask() {
await Promise.all(relations)
} catch (e: any) {
newTaskTitle.value = taskTitleBackup
if (e?.message === 'NO_PROJECT') {
errorMessage.value = t('project.create.addProjectRequired')
if (e?.message === 'NO_LIST') {
errorMessage.value = t('list.create.addListRequired')
return
}
throw e

View File

@ -1,6 +1,6 @@
<template>
<Multiselect
:loading="projectUserService.loading"
:loading="listUserService.loading"
:placeholder="$t('task.assignee.placeholder')"
:multiple="true"
@search="findUser"
@ -30,7 +30,7 @@ import Multiselect from '@/components/input/multiselect.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import {includesById} from '@/helpers/utils'
import ProjectUserService from '@/services/projectUsers'
import ListUserService from '@/services/listUsers'
import {success} from '@/message'
import {useTaskStore} from '@/stores/tasks'
@ -42,7 +42,7 @@ const props = defineProps({
type: Number,
required: true,
},
projectId: {
listId: {
type: Number,
required: true,
},
@ -59,7 +59,7 @@ const emit = defineEmits(['update:modelValue'])
const taskStore = useTaskStore()
const {t} = useI18n({useScope: 'global'})
const projectUserService = shallowReactive(new ProjectUserService())
const listUserService = shallowReactive(new ListUserService())
const foundUsers = ref<IUser[]>([])
const assignees = ref<IUser[]>([])
let isAdding = false
@ -94,7 +94,7 @@ async function addAssignee(user: IUser) {
async function removeAssignee(user: IUser) {
await taskStore.removeAssignee({user: user, taskId: props.taskId})
// Remove the assignee from the project
// Remove the assignee from the list
for (const a in assignees.value) {
if (assignees.value[a].id === user.id) {
assignees.value.splice(a, 1)
@ -109,7 +109,7 @@ async function findUser(query: string) {
return
}
const response = await projectUserService.getAll({projectId: props.projectId}, {s: query}) as IUser[]
const response = await listUserService.getAll({listId: props.listId}, {s: query}) as IUser[]
// Filter the results to not include users who are already assigned
foundUsers.value = response

View File

@ -1,18 +1,18 @@
<template>
<Multiselect
class="control is-expanded"
:placeholder="$t('project.search')"
:search-results="foundProjects"
:placeholder="$t('list.search')"
:search-results="foundLists"
label="title"
:select-placeholder="$t('project.searchSelect')"
:model-value="project"
@update:model-value="Object.assign(project, $event)"
:select-placeholder="$t('list.searchSelect')"
:model-value="list"
@update:model-value="Object.assign(list, $event)"
@select="select"
@search="findProjects"
@search="findLists"
>
<template #searchResult="{option}">
<span class="project-namespace-title search-result">{{ namespace((option as IProject).namespaceId) }} ></span>
{{ (option as IProject).title }}
<span class="list-namespace-title search-result">{{ namespace((option as IList).namespaceId) }} ></span>
{{ (option as IList).title }}
</template>
</Multiselect>
</template>
@ -22,19 +22,19 @@ import {reactive, ref, watch} from 'vue'
import type {PropType} from 'vue'
import {useI18n} from 'vue-i18n'
import type {IProject} from '@/modelTypes/IProject'
import type {IList} from '@/modelTypes/IList'
import type {INamespace} from '@/modelTypes/INamespace'
import {useProjectStore} from '@/stores/projects'
import {useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces'
import ProjectModel from '@/models/project'
import ListModel from '@/models/list'
import Multiselect from '@/components/input/multiselect.vue'
const props = defineProps({
modelValue: {
type: Object as PropType<IProject>,
type: Object as PropType<IList>,
required: false,
},
})
@ -42,45 +42,45 @@ const emit = defineEmits(['update:modelValue'])
const {t} = useI18n({useScope: 'global'})
const project: IProject = reactive(new ProjectModel())
const list: IList = reactive(new ListModel())
watch(
() => props.modelValue,
(newProject) => Object.assign(project, newProject),
(newList) => Object.assign(list, newList),
{
immediate: true,
deep: true,
},
)
const projectStore = useProjectStore()
const listStore = useListStore()
const namespaceStore = useNamespaceStore()
const foundProjects = ref<IProject[]>([])
function findProjects(query: string) {
const foundLists = ref<IList[]>([])
function findLists(query: string) {
if (query === '') {
select(null)
}
foundProjects.value = projectStore.searchProject(query)
foundLists.value = listStore.searchList(query)
}
function select(l: IProject | null) {
function select(l: IList | null) {
if (l === null) {
return
}
Object.assign(project, l)
emit('update:modelValue', project)
Object.assign(list, l)
emit('update:modelValue', list)
}
function namespace(namespaceId: INamespace['id']) {
const namespace = namespaceStore.getNamespaceById(namespaceId)
return namespace !== null
? namespace.title
: t('project.shared')
: t('list.shared')
}
</script>
<style lang="scss" scoped>
.project-namespace-title {
.list-namespace-title {
color: var(--grey-500);
}
</style>

View File

@ -37,14 +37,14 @@
{{ $t('task.quickAddMagic.multiple') }}
</p>
<h3>{{ $t('project.list.title') }}</h3>
<h3>{{ $t('list.list.title') }}</h3>
<p>
{{ $t('task.quickAddMagic.project1', {prefix: prefixes.project}) }}
{{ $t('task.quickAddMagic.project2') }}
{{ $t('task.quickAddMagic.list1', {prefix: prefixes.list}) }}
{{ $t('task.quickAddMagic.list2') }}
</p>
<p>
{{ $t('task.quickAddMagic.project3') }}
{{ $t('task.quickAddMagic.project4', {prefix: prefixes.project}) }}
{{ $t('task.quickAddMagic.list3') }}
{{ $t('task.quickAddMagic.list4', {prefix: prefixes.list}) }}
</p>
<h3>{{ $t('task.quickAddMagic.dateAndTime') }}</h3>

View File

@ -43,8 +43,8 @@
:class="{'is-strikethrough': task.done}"
>
<span
class="different-project"
v-if="task.projectId !== projectId"
class="different-list"
v-if="task.listId !== listId"
>
<span
v-if="task.differentNamespace !== null"
@ -52,9 +52,9 @@
{{ task.differentNamespace }} >
</span>
<span
v-if="task.differentProject !== null"
v-tooltip="$t('task.relation.differentProject')">
{{ task.differentProject }} >
v-if="task.differentList !== null"
v-tooltip="$t('task.relation.differentList')">
{{ task.differentList }} >
</span>
</span>
{{ task.title }}
@ -98,8 +98,8 @@
:class="{ 'is-strikethrough': t.done}"
>
<span
class="different-project"
v-if="t.projectId !== projectId"
class="different-list"
v-if="t.listId !== listId"
>
<span
v-if="t.differentNamespace !== null"
@ -107,9 +107,9 @@
{{ t.differentNamespace }} >
</span>
<span
v-if="t.differentProject !== null"
v-tooltip="$t('task.relation.differentProject')">
{{ t.differentProject }} >
v-if="t.differentList !== null"
v-tooltip="$t('task.relation.differentList')">
{{ t.differentList }} >
</span>
</span>
{{ t.title }}
@ -186,7 +186,7 @@ const props = defineProps({
type: Boolean,
default: false,
},
projectId: {
listId: {
type: Number,
default: 0,
},
@ -230,17 +230,17 @@ async function findTasks(newQuery: string) {
foundTasks.value = await taskService.getAll({}, {s: newQuery})
}
const getProjectAndNamespaceById = (projectId: number) => namespaceStore.getProjectAndNamespaceById(projectId, true)
const getListAndNamespaceById = (listId: number) => namespaceStore.getListAndNamespaceById(listId, true)
const namespace = computed(() => getProjectAndNamespaceById(props.projectId)?.namespace)
const namespace = computed(() => getListAndNamespaceById(props.listId)?.namespace)
function mapRelatedTasks(tasks: ITask[]) {
return tasks.map(task => {
// by doing this here once we can save a lot of duplicate calls in the template
const {
project,
list,
namespace: taskNamespace,
} = getProjectAndNamespaceById(task.projectId) || {project: null, namespace: null}
} = getListAndNamespaceById(task.listId) || {list: null, namespace: null}
return {
...task,
@ -248,10 +248,10 @@ function mapRelatedTasks(tasks: ITask[]) {
(taskNamespace !== null &&
taskNamespace.id !== namespace.value.id &&
taskNamespace?.title) || null,
differentProject:
(project !== null &&
task.projectId !== props.projectId &&
project?.title) || null,
differentList:
(list !== null &&
task.listId !== props.listId &&
list?.title) || null,
}
})
}
@ -343,7 +343,7 @@ async function removeTaskRelation() {
}
async function createAndRelateTask(title: string) {
const newTask = await taskService.create(new TaskModel({title, projectId: props.projectId}))
const newTask = await taskService.create(new TaskModel({title, listId: props.listId}))
newTaskRelation.task = newTask
await addTaskRelation()
}
@ -351,7 +351,7 @@ async function createAndRelateTask(title: string) {
async function toggleTaskDone(task: ITask) {
await taskStore.update(task)
// Find the task in the project and update it so that it is correctly strike through
// Find the task in the list and update it so that it is correctly strike through
Object.entries(relatedTasks.value).some(([kind, tasks]) => {
return (tasks as ITask[]).some((t, key) => {
const found = t.id === task.id
@ -379,7 +379,7 @@ async function toggleTaskDone(task: ITask) {
}
}
.different-project {
.different-list {
color: var(--grey-500);
width: auto;
}

View File

@ -7,24 +7,24 @@
/>
<ColorBubble
v-if="showProjectColor && projectColor !== '' && currentProject.id !== task.projectId"
:color="projectColor"
v-if="showListColor && listColor !== '' && currentList.id !== task.listId"
:color="listColor"
class="mr-1"
/>
<router-link
:to="taskDetailRoute"
:class="{ 'done': task.done, 'show-list': showProject && project !== null}"
:class="{ 'done': task.done, 'show-list': showList && taskList !== null}"
class="tasktext"
>
<span>
<router-link
v-if="showProject && project !== null"
:to="{ name: 'project.list', params: { projectId: task.projectId } }"
class="task-project"
v-if="showList && taskList !== null"
:to="{ name: 'list.list', params: { listId: task.listId } }"
class="task-list"
:class="{'mr-2': task.hexColor !== ''}"
v-tooltip="$t('task.detail.belongsToProject', {project: project.title})">
{{ project.title }}
v-tooltip="$t('task.detail.belongsToList', {list: taskList.title})">
{{ taskList.title }}
</router-link>
<ColorBubble
@ -81,13 +81,13 @@
<priority-label :priority="task.priority" :done="task.done"/>
<span>
<span class="project-task-icon" v-if="task.attachments.length > 0">
<span class="list-task-icon" v-if="task.attachments.length > 0">
<icon icon="paperclip"/>
</span>
<span class="project-task-icon" v-if="task.description">
<span class="list-task-icon" v-if="task.description">
<icon icon="align-left"/>
</span>
<span class="project-task-icon" v-if="task.repeatAfter.amount > 0">
<span class="list-task-icon" v-if="task.repeatAfter.amount > 0">
<icon icon="history"/>
</span>
</span>
@ -104,12 +104,12 @@
</progress>
<router-link
v-if="!showProject && currentProject.id !== task.projectId && project !== null"
:to="{ name: 'project.list', params: { projectId: task.projectId } }"
class="task-project"
v-tooltip="$t('task.detail.belongsToProject', {project: project.title})"
v-if="!showList && currentList.id !== task.listId && taskList !== null"
:to="{ name: 'list.list', params: { listId: task.listId } }"
class="task-list"
v-tooltip="$t('task.detail.belongsToList', {list: taskList.title})"
>
{{ project.title }}
{{ taskList.title }}
</router-link>
<BaseButton
@ -148,7 +148,7 @@ import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import {formatDateSince, formatISO, formatDateLong} from '@/helpers/time/formatDate'
import {success} from '@/message'
import {useProjectStore} from '@/stores/projects'
import {useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces'
import {useBaseStore} from '@/stores/base'
import {useTaskStore} from '@/stores/tasks'
@ -162,7 +162,7 @@ const props = defineProps({
type: Boolean,
default: false,
},
showProject: {
showList: {
type: Boolean,
default: false,
},
@ -170,7 +170,7 @@ const props = defineProps({
type: Boolean,
default: false,
},
showProjectColor: {
showListColor: {
type: Boolean,
default: true,
},
@ -207,18 +207,18 @@ onBeforeUnmount(() => {
})
const baseStore = useBaseStore()
const projectStore = useProjectStore()
const listStore = useListStore()
const taskStore = useTaskStore()
const namespaceStore = useNamespaceStore()
const project = computed(() => projectStore.getProjectById(task.value.projectId))
const projectColor = computed(() => project.value !== null ? project.value.hexColor : '')
const taskList = computed(() => listStore.getListById(task.value.listId))
const listColor = computed(() => taskList.value !== null ? taskList.value.hexColor : '')
const currentProject = computed(() => {
return typeof baseStore.currentProject === 'undefined' ? {
const currentList = computed(() => {
return typeof baseStore.currentList === 'undefined' ? {
id: 0,
title: '',
} : baseStore.currentProject
} : baseStore.currentList
})
const taskDetailRoute = computed(() => ({
@ -307,7 +307,7 @@ function hideDeferDueDatePopup(e) {
}
}
.task-project {
.task-list {
width: auto;
color: var(--grey-400);
font-size: .9rem;
@ -322,7 +322,7 @@ function hideDeferDueDatePopup(e) {
width: 27px;
}
.project-task-icon {
.list-task-icon {
margin-left: 6px;
&:not(:first-of-type) {

View File

@ -0,0 +1,47 @@
import {ref, watch, readonly} from 'vue'
import {useLocalStorage, useMediaQuery} from '@vueuse/core'
const BULMA_MOBILE_BREAKPOINT = 768
export function useMenuActive() {
const isMobile = useMediaQuery(`(max-width: ${BULMA_MOBILE_BREAKPOINT}px)`)
const desktopPreference = useLocalStorage(
'menuActiveDesktopPreference',
true,
// If we have two tabs open we want to be able to have the menu open in one window
// and closed in the other. The last changed value will be the new preference
{listenToStorageChanges: false},
)
const menuActive = ref(false)
// set to prefered value
watch(isMobile, (current) => {
menuActive.value = current
// On mobile we don't show the menu in an expanded state
// because that would hide the main content
? false
: desktopPreference.value
}, {immediate: true})
watch(menuActive, (current) => {
if (!isMobile.value) {
desktopPreference.value = current
}
})
function setMenuActive(newMenuActive: boolean) {
menuActive.value = newMenuActive
}
function toggleMenu() {
menuActive.value = menuActive.value = !menuActive.value
}
return {
menuActive: readonly(menuActive),
setMenuActive,
toggleMenu,
}
}

View File

@ -3,7 +3,7 @@ import {useRouter} from 'vue-router'
import {useEventListener} from '@vueuse/core'
import {useAuthStore} from '@/stores/auth'
import {MILLISECONDS_A_HOUR, SECONDS_A_HOUR} from '@/constants/date'
import {MILLISECONDS_A_SECOND, SECONDS_A_HOUR} from '@/constants/date'
const SECONDS_TOKEN_VALID = 60 * SECONDS_A_HOUR
@ -24,11 +24,14 @@ export function useRenewTokenOnFocus() {
return
}
const expiresIn = (userInfo.value !== null ? userInfo.value.exp : 0) - new Date().valueOf() / MILLISECONDS_A_HOUR
const nowInSeconds = new Date().getTime() / MILLISECONDS_A_SECOND
const expiresIn = userInfo.value !== null
? userInfo.value.exp - nowInSeconds
: 0
// If the token expiry is negative, it is already expired and we have no choice but to redirect
// the user to the login page
if (expiresIn < 0) {
if (expiresIn <= 0) {
await authStore.checkAuth()
await router.push({name: 'user.login'})
return

View File

@ -42,9 +42,9 @@ const SORT_BY_DEFAULT = {
}
/**
* This mixin provides a base set of methods and properties to get tasks.
* This mixin provides a base set of methods and properties to get tasks on a list.
*/
export function useTaskList(projectId, sortByDefault = SORT_BY_DEFAULT) {
export function useTaskList(listId, sortByDefault = SORT_BY_DEFAULT) {
const params = ref({...getDefaultParams()})
const search = ref('')
@ -64,7 +64,7 @@ export function useTaskList(projectId, sortByDefault = SORT_BY_DEFAULT) {
loadParams = formatSortOrder(sortBy.value, loadParams)
return [
{projectId: projectId.value},
{listId: listId.value},
loadParams,
page.value || 1,
]

View File

@ -0,0 +1,14 @@
/**
* Get full BASE_URL
* - including path
* - will always end with a trailing slash
*/
export function getFullBaseUrl() {
// (1) The injected BASE_URL is declared from the `resolvedBase` that might miss a trailing slash...
// see: https://github.com/vitejs/vite/blob/b35fe883fdc699ac1450882562872095abe9959b/packages/vite/src/node/config.ts#LL614C25-L614C25
const rawBase = import.meta.env.BASE_URL
// (2) so we readd a slash like done here
// https://github.com/vitejs/vite/blob/b35fe883fdc699ac1450882562872095abe9959b/packages/vite/src/node/config.ts#L643
// See this comment: https://github.com/vitejs/vite/pull/10723#issuecomment-1303627478
return rawBase.endsWith('/') ? rawBase : rawBase + '/'
}

View File

@ -0,0 +1,9 @@
import {i18n} from '@/i18n'
import type {IList} from '@/modelTypes/IList'
export function getListTitle(l: IList) {
if (l.id === -1) {
return i18n.global.t('list.pseudo.favorites.title')
}
return l.title
}

View File

@ -3,7 +3,7 @@ import type {INamespace} from '@/modelTypes/INamespace'
export const getNamespaceTitle = (n: INamespace) => {
if (n.id === -1) {
return i18n.global.t('namespace.pseudo.sharedProjects.title')
return i18n.global.t('namespace.pseudo.sharedLists.title')
}
if (n.id === -2) {
return i18n.global.t('namespace.pseudo.favorites.title')

View File

@ -1,9 +0,0 @@
import {i18n} from '@/i18n'
import type {IProject} from '@/modelTypes/IProject'
export function getProjectTitle(l: IProject) {
if (l.id === -1) {
return i18n.global.t('project.pseudo.favorites.title')
}
return l.title
}

View File

@ -1,4 +1,4 @@
import {getProjectFromPrefix} from '@/modules/parseTaskText'
import {getListFromPrefix} from '@/modules/parseTaskText'
export interface TaskWithParent {
title: string,
@ -26,7 +26,7 @@ export function parseSubtasksViaIndention(taskTitles: string): TaskWithParent[]
list: null,
}
task.list = getProjectFromPrefix(task.title)
task.list = getListFromPrefix(task.title)
if (index === 0) {
return task
@ -49,7 +49,7 @@ export function parseSubtasksViaIndention(taskTitles: string): TaskWithParent[]
task.parent = task.parent.replace(spaceRegex, '')
if (task.list === null) {
// This allows to specify a list once for the parent task and inherit it to all subtasks
task.list = getProjectFromPrefix(task.parent)
task.list = getListFromPrefix(task.parent)
}
}

View File

@ -1,5 +1,5 @@
import type {IBucket} from '@/modelTypes/IBucket'
import type {IProject} from '@/modelTypes/IProject'
import type {IList} from '@/modelTypes/IList'
const key = 'collapsedBuckets'
@ -13,22 +13,22 @@ function getAllState() {
}
export const saveCollapsedBucketState = (
projectId: IProject['id'],
listId: IList['id'],
collapsedBuckets: CollapsedBuckets,
) => {
const state = getAllState()
state[projectId] = collapsedBuckets
for (const bucketId in state[projectId]) {
if (!state[projectId][bucketId]) {
delete state[projectId][bucketId]
state[listId] = collapsedBuckets
for (const bucketId in state[listId]) {
if (!state[listId][bucketId]) {
delete state[listId][bucketId]
}
}
localStorage.setItem(key, JSON.stringify(state))
}
export function getCollapsedBucketState(projectId : IProject['id']) {
export function getCollapsedBucketState(listId : IList['id']) {
const state = getAllState()
return typeof state[projectId] !== 'undefined'
? state[projectId]
return typeof state[listId] !== 'undefined'
? state[listId]
: {}
}

View File

@ -0,0 +1,53 @@
import type { IList } from '@/modelTypes/IList'
type ListView = Record<IList['id'], string>
const DEFAULT_LIST_VIEW = 'list.list' as const
/**
* Save the current list view to local storage
*/
export function saveListView(listId: IList['id'], routeName: string) {
if (routeName.includes('settings.')) {
return
}
if (!listId) {
return
}
// We use local storage and not the store here to make it persistent across reloads.
const savedListView = localStorage.getItem('listView')
let savedListViewJson: ListView | false = false
if (savedListView !== null) {
savedListViewJson = JSON.parse(savedListView) as ListView
}
let listView: ListView = {}
if (savedListViewJson) {
listView = savedListViewJson
}
listView[listId] = routeName
localStorage.setItem('listView', JSON.stringify(listView))
}
export const getListView = (listId: IList['id']) => {
// Remove old stored settings
const savedListView = localStorage.getItem('listView')
if (savedListView !== null && savedListView.startsWith('list.')) {
localStorage.removeItem('listView')
}
if (!savedListView) {
return DEFAULT_LIST_VIEW
}
const savedListViewJson: ListView = JSON.parse(savedListView)
if (!savedListViewJson[listId]) {
return DEFAULT_LIST_VIEW
}
return savedListViewJson[listId]
}

View File

@ -1,63 +0,0 @@
import type { IProject } from '@/modelTypes/IProject'
type ProjectView = Record<IProject['id'], string>
const DEFAULT_PROJECT_VIEW = 'project.list' as const
const PROJECT_VIEW_SETTINGS_KEY = 'projectView'
/**
* Save the current project view to local storage
*/
export function saveProjectView(projectId: IProject['id'], routeName: string) {
if (routeName.includes('settings.')) {
return
}
if (!projectId) {
return
}
// We use local storage and not the store here to make it persistent across reloads.
const savedProjectView = localStorage.getItem(PROJECT_VIEW_SETTINGS_KEY)
let savedProjectViewJson: ProjectView | false = false
if (savedProjectView !== null) {
savedProjectViewJson = JSON.parse(savedProjectView) as ProjectView
}
let projectView: ProjectView = {}
if (savedProjectViewJson) {
projectView = savedProjectViewJson
}
projectView[projectId] = routeName
localStorage.setItem(PROJECT_VIEW_SETTINGS_KEY, JSON.stringify(projectView))
}
export const getProjectView = (projectId: IProject['id']) => {
// Migrate old setting over
// TODO: remove when 1.0 release
const oldListViewSettings = localStorage.getItem('listView')
if (oldListViewSettings !== null) {
localStorage.setItem(PROJECT_VIEW_SETTINGS_KEY, oldListViewSettings)
localStorage.removeItem('listView')
}
// Remove old stored settings
// TODO: remove when 1.0 release
const savedProjectView = localStorage.getItem(PROJECT_VIEW_SETTINGS_KEY)
if (savedProjectView !== null && savedProjectView.startsWith('project.')) {
localStorage.removeItem(PROJECT_VIEW_SETTINGS_KEY)
}
if (!savedProjectView) {
return DEFAULT_PROJECT_VIEW
}
const savedProjectViewJson: ProjectView = JSON.parse(savedProjectView)
if (!savedProjectViewJson[projectId]) {
return DEFAULT_PROJECT_VIEW
}
return savedProjectViewJson[projectId]
}

View File

@ -233,7 +233,7 @@ export const getDateFromTextIn = (text: string, now: Date = new Date()) => {
}
const getDateFromWeekday = (text: string): dateFoundResult => {
const matcher = / (next )?(monday|mon|tuesday|tue|wednesday|wed|thursday|thu|friday|fri|saturday|sat|sunday|sun)($| )/g
const matcher = /(^| )(next )?(monday|mon|tuesday|tue|wednesday|wed|thursday|thu|friday|fri|saturday|sat|sunday|sun)($| )/g
const results: string[] | null = matcher.exec(text.toLowerCase()) // The i modifier does not seem to work.
if (results === null) {
return {
@ -246,7 +246,7 @@ const getDateFromWeekday = (text: string): dateFoundResult => {
const currentDay: number = date.getDay()
let day = 0
switch (results[2]) {
switch (results[3]) {
case 'mon':
case 'monday':
day = 1

View File

@ -417,7 +417,7 @@
}
},
"migrate": {
"title": "Migrate from other services to Vikunja",
"title": "Import from other services",
"titleService": "Import your data from {name} into Vikunja",
"import": "Import your data into Vikunja",
"description": "Click on the logo of one of the third-party services below to get started.",
@ -911,7 +911,7 @@
}
},
"update": {
"available": "There is an update for Vikunja available!",
"available": "There is an update available!",
"do": "Update Now"
},
"menu": {

View File

@ -417,7 +417,7 @@
}
},
"migrate": {
"title": "Migrace z jiných služeb do Vikunja",
"title": "Import from other services",
"titleService": "Importujte svá data z {name} do Vikunja",
"import": "Importujte svá data do Vikunja",
"description": "Chcete-li začít, klikněte na logo jedné ze služeb třetích stran.",
@ -911,7 +911,7 @@
}
},
"update": {
"available": "K dispozici je aktualizace pro Vikunja!",
"available": "There is an update available!",
"do": "Aktualizovat nyní"
},
"menu": {

1054
src/i18n/lang/da-DK.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -417,7 +417,7 @@
}
},
"migrate": {
"title": "Von einem anderen Dienst zu Vikunja migrieren",
"title": "Aus anderen Diensten importieren",
"titleService": "Importiere deine Daten von {name} in Vikunja",
"import": "Importiere deine Daten in Vikunja",
"description": "Klicke auf das Logo eines der unten aufgeführten Drittanbieterdienste, um loszulegen.",
@ -911,7 +911,7 @@
}
},
"update": {
"available": "Es ist ein Aktualisierung für Vikunja verfügbar!",
"available": "Es ist ein Update verfügbar!",
"do": "Jetzt aktualisieren"
},
"menu": {

View File

@ -417,7 +417,7 @@
}
},
"migrate": {
"title": "Vomene andere Dienst zu Vikunja migriere",
"title": "Aus anderen Diensten importieren",
"titleService": "Dini Date vo {name} in Vikunja importiere",
"import": "Dini Date in Vikunja importiere",
"description": "Klick ufs Logo une vo eine vo de Drittabüüter um aahzfange.",
@ -911,7 +911,7 @@
}
},
"update": {
"available": "Es het es Update für Vikiunja!",
"available": "Es ist ein Update verfügbar!",
"do": "Jetzt aktualisierä"
},
"menu": {

View File

@ -5,10 +5,10 @@
"welcomeDay": "Hi {username}!",
"welcomeEvening": "Good Evening {username}!",
"lastViewed": "Last viewed",
"project": {
"newText": "You can create a new project for your new tasks:",
"new": "New project",
"importText": "Or import your projects and tasks from other services into Vikunja:",
"list": {
"newText": "You can create a new list for your new tasks:",
"new": "New list",
"importText": "Or import your lists and tasks from other services into Vikunja:",
"import": "Import your data into Vikunja"
}
},
@ -85,7 +85,7 @@
"weekStartSunday": "Sunday",
"weekStartMonday": "Monday",
"language": "Language",
"defaultProject": "Default Project",
"defaultList": "Default List",
"timezone": "Time Zone",
"overdueTasksRemindersTime": "Overdue tasks reminder email time"
},
@ -143,7 +143,7 @@
},
"deletion": {
"title": "Delete your Vikunja Account",
"text1": "The deletion of your account is permanent and cannot be undone. We will delete all your namespaces, projects, tasks and everything associated with it.",
"text1": "The deletion of your account is permanent and cannot be undone. We will delete all your namespaces, lists, tasks and everything associated with it.",
"text2": "To proceed, please enter your password. You will receive an email with further instructions.",
"confirm": "Delete my account",
"requestSuccess": "The request was successful. You'll receive an email with further instructions.",
@ -157,40 +157,40 @@
},
"export": {
"title": "Export your Vikunja data",
"description": "You can request a copy of all your Vikunja data. This include Namespaces, Projects, Tasks and everything associated to them. You can import this data in any Vikunja instance through the migration function.",
"description": "You can request a copy of all your Vikunja data. This include Namespaces, Lists, Tasks and everything associated to them. You can import this data in any Vikunja instance through the migration function.",
"descriptionPasswordRequired": "Please enter your password to proceed:",
"request": "Request a copy of my Vikunja Data",
"success": "You've successfully requested your Vikunja Data! We will send you an email once it's ready to download.",
"downloadTitle": "Download your exported Vikunja data"
}
},
"project": {
"archived": "This project is archived. It is not possible to create new or edit tasks for it.",
"title": "Project Title",
"list": {
"archived": "This list is archived. It is not possible to create new or edit tasks for it.",
"title": "List Title",
"color": "Color",
"projects": "Projects",
"project": "Project",
"search": "Type to search for a project…",
"searchSelect": "Click or press enter to select this project",
"shared": "Shared Projects",
"noDescriptionAvailable": "No project description is available.",
"lists": "Lists",
"list": "List",
"search": "Type to search for a list…",
"searchSelect": "Click or press enter to select this list",
"shared": "Shared Lists",
"noDescriptionAvailable": "No list description is available.",
"create": {
"header": "New project",
"titlePlaceholder": "The project's title goes here…",
"header": "New list",
"titlePlaceholder": "The list's title goes here…",
"addTitleRequired": "Please specify a title.",
"createdSuccess": "The project was successfully created.",
"addProjectRequired": "Please specify a project or set a default project in the settings."
"createdSuccess": "The list was successfully created.",
"addListRequired": "Please specify a list or set a default list in the settings."
},
"archive": {
"title": "Archive \"{project}\"",
"archive": "Archive this project",
"unarchive": "Un-Archive this project",
"title": "Archive \"{list}\"",
"archive": "Archive this list",
"unarchive": "Un-Archive this list",
"unarchiveText": "You will be able to create new tasks or edit it.",
"archiveText": "You won't be able to edit this project or create new tasks until you un-archive it.",
"success": "The project was successfully archived."
"archiveText": "You won't be able to edit this list or create new tasks until you un-archive it.",
"success": "The list was successfully archived."
},
"background": {
"title": "Set project background",
"title": "Set list background",
"remove": "Remove Background",
"upload": "Choose a background from your pc",
"searchPlaceholder": "Search for a background…",
@ -200,40 +200,40 @@
"removeSuccess": "The background has been removed successfully!"
},
"delete": {
"title": "Delete \"{project}\"",
"header": "Delete this project",
"text1": "Are you sure you want to delete this project and all of its contents?",
"title": "Delete \"{list}\"",
"header": "Delete this list",
"text1": "Are you sure you want to delete this list and all of its contents?",
"text2": "This includes all tasks and CANNOT BE UNDONE!",
"success": "The project was successfully deleted.",
"success": "The list was successfully deleted.",
"tasksToDelete": "This will irrevocably remove approx. {count} tasks.",
"noTasksToDelete": "This project does not contain any tasks, it should be safe to delete."
"noTasksToDelete": "This list does not contain any tasks, it should be safe to delete."
},
"duplicate": {
"title": "Duplicate this project",
"title": "Duplicate this list",
"label": "Duplicate",
"text": "Select a namespace which should hold the duplicated project:",
"success": "The project was successfully duplicated."
"text": "Select a namespace which should hold the duplicated list:",
"success": "The list was successfully duplicated."
},
"edit": {
"header": "Edit This Project",
"title": "Edit \"{project}\"",
"titlePlaceholder": "The project title goes here…",
"identifierTooltip": "The project identifier can be used to uniquely identify a task across projects. You can set it to empty to disable it.",
"identifier": "Project Identifier",
"identifierPlaceholder": "The project identifier goes here…",
"header": "Edit This List",
"title": "Edit \"{list}\"",
"titlePlaceholder": "The list title goes here…",
"identifierTooltip": "The list identifier can be used to uniquely identify a task across lists. You can set it to empty to disable it.",
"identifier": "List Identifier",
"identifierPlaceholder": "The list identifier goes here…",
"description": "Description",
"descriptionPlaceholder": "The projects description goes here…",
"descriptionPlaceholder": "The lists description goes here…",
"color": "Color",
"success": "The project was successfully updated."
"success": "The list was successfully updated."
},
"share": {
"header": "Share this project",
"title": "Share \"{project}\"",
"header": "Share this list",
"title": "Share \"{list}\"",
"share": "Share",
"links": {
"title": "Share Links",
"what": "What is a share link?",
"explanation": "Share Links allow you to easily share a project with other users who don't have an account on Vikunja.",
"explanation": "Share Links allow you to easily share a list with other users who don't have an account on Vikunja.",
"create": "Create a new link share",
"name": "Name (optional)",
"namePlaceholder": "e.g. Lorem Ipsum",
@ -242,7 +242,7 @@
"passwordExplanation": "When authenticating, the user will be required to enter this password.",
"noName": "No name set",
"remove": "Remove a link share",
"removeText": "Are you sure you want to remove this link share? It will no longer be possible to access this project with this link share. This cannot be undone!",
"removeText": "Are you sure you want to remove this link share? It will no longer be possible to access this list with this link share. This cannot be undone!",
"createSuccess": "The link share was successfully created.",
"deleteSuccess": "The link share was successfully deleted",
"view": "View",
@ -275,7 +275,7 @@
"title": "List",
"add": "Add",
"addPlaceholder": "Add a new task…",
"empty": "This project is currently empty.",
"empty": "This list is currently empty.",
"newTaskCta": "Create a new task.",
"editTask": "Edit Task"
},
@ -323,36 +323,36 @@
}
},
"namespace": {
"title": "Namespaces & Projects",
"title": "Namespaces & Lists",
"namespace": "Namespace",
"showArchived": "Show Archived",
"noneAvailable": "You don't have any namespaces right now.",
"unarchive": "Un-Archive",
"archived": "Archived",
"noProjects": "This namespace does not contain any projects.",
"createProject": "Create a new project in this namespace.",
"noLists": "This namespace does not contain any lists.",
"createList": "Create a new list in this namespace.",
"namespaces": "Namespaces",
"search": "Type to search for a namespace…",
"create": {
"title": "New namespace",
"titleRequired": "Please specify a title.",
"explanation": "A namespace is a collection of projects you can share and use to organize your projects with. In fact, every project belongs to a namespace.",
"explanation": "A namespace is a collection of lists you can share and use to organize your lists with. In fact, every list belongs to a namespace.",
"tooltip": "What's a namespace?",
"success": "The namespace was successfully created."
},
"archive": {
"titleArchive": "Archive \"{namespace}\"",
"titleUnarchive": "Un-Archive \"{namespace}\"",
"archiveText": "You won't be able to edit this namespace or create new projects until you un-archive it. This will also archive all projects in this namespace.",
"unarchiveText": "You will be able to create new projects or edit it.",
"archiveText": "You won't be able to edit this namespace or create new lists until you un-archive it. This will also archive all lists in this namespace.",
"unarchiveText": "You will be able to create new lists or edit it.",
"success": "The namespace was successfully archived.",
"unarchiveSuccess": "The namespace was successfully un-archived.",
"description": "If a namespace is archived, you cannot create new projects or edit it."
"description": "If a namespace is archived, you cannot create new lists or edit it."
},
"delete": {
"title": "Delete \"{namespace}\"",
"text1": "Are you sure you want to delete this namespace and all of its contents?",
"text2": "This includes all projects and tasks and CANNOT BE UNDONE!",
"text2": "This includes all lists and tasks and CANNOT BE UNDONE!",
"success": "The namespace was successfully deleted."
},
"edit": {
@ -372,8 +372,8 @@
"isArchived": "This namespace is archived"
},
"pseudo": {
"sharedProjects": {
"title": "Shared Projects"
"sharedLists": {
"title": "Shared Lists"
},
"favorites": {
"title": "Favorites"
@ -404,7 +404,7 @@
},
"create": {
"title": "New Saved Filter",
"description": "A saved filter is a virtual project which is computed from a set of filters each time it is accessed. Once created, it will appear in a special namespace.",
"description": "A saved filter is a virtual list which is computed from a set of filters each time it is accessed. Once created, it will appear in a special namespace.",
"action": "Create new saved filter"
},
"delete": {
@ -435,7 +435,7 @@
"label": {
"title": "Labels",
"manage": "Manage labels",
"description": "Click on a label to edit it. You can edit all labels you created, you can use all labels which are associated with a task to whose project you have access.",
"description": "Click on a label to edit it. You can edit all labels you created, you can use all labels which are associated with a task to whose list you have access.",
"newCTA": "You currently do not have any labels.",
"search": "Type to search for a label…",
"create": {
@ -460,7 +460,7 @@
},
"sharing": {
"authenticating": "Authenticating…",
"passwordRequired": "This shared project requires a password. Please enter it below:",
"passwordRequired": "This shared list requires a password. Please enter it below:",
"error": "An error occured.",
"invalidPassword": "The password is invalid."
},
@ -529,7 +529,7 @@
"code": "Code",
"quote": "Quote",
"unorderedList": "Unordered List",
"orderedList ": "Ordered List",
"orderedList": "Ordered List",
"cleanBlock": "Clean Block",
"link": "Link",
"image": "Image",
@ -621,7 +621,7 @@
"chooseDueDate": "Click here to set a due date",
"chooseStartDate": "Click here to set a start date",
"chooseEndDate": "Click here to set an end date",
"move": "Move task to a different project",
"move": "Move task to a different list",
"done": "Mark task done!",
"undone": "Mark as undone",
"created": "Created {0} by {1}",
@ -629,7 +629,7 @@
"doneAt": "Done {0}",
"updateSuccess": "The task was saved successfully.",
"deleteSuccess": "The task has been deleted successfully.",
"belongsToProject": "This task belongs to project '{project}'",
"belongsToList": "This task belongs to list '{list}'",
"due": "Due {at}",
"closePopup": "Close popup",
"delete": {
@ -649,7 +649,7 @@
"percentDone": "Set Progress",
"attachments": "Add Attachments",
"relatedTasks": "Add Relation",
"moveProject": "Move",
"moveList": "Move",
"color": "Set Color",
"delete": "Delete",
"favorite": "Add to Favorites",
@ -676,21 +676,21 @@
"updated": "Updated"
},
"subscription": {
"subscribedProjectThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this project through its namespace.",
"subscribedListThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this list through its namespace.",
"subscribedTaskThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this task through its namespace.",
"subscribedTaskThroughParentProject": "You can't unsubscribe here because you are subscribed to this task through its project.",
"subscribedTaskThroughParentList": "You can't unsubscribe here because you are subscribed to this task through its list.",
"subscribedNamespace": "You are currently subscribed to this namespace and will receive notifications for changes.",
"notSubscribedNamespace": "You are not subscribed to this namespace and won't receive notifications for changes.",
"subscribedProject": "You are currently subscribed to this project and will receive notifications for changes.",
"notSubscribedProject": "You are not subscribed to this project and won't receive notifications for changes.",
"subscribedList": "You are currently subscribed to this list and will receive notifications for changes.",
"notSubscribedList": "You are not subscribed to this list and won't receive notifications for changes.",
"subscribedTask": "You are currently subscribed to this task and will receive notifications for changes.",
"notSubscribedTask": "You are not subscribed to this task and won't receive notifications for changes.",
"subscribe": "Subscribe",
"unsubscribe": "Unsubscribe",
"subscribeSuccessNamespace": "You are now subscribed to this namespace",
"unsubscribeSuccessNamespace": "You are now unsubscribed to this namespace",
"subscribeSuccessProject": "You are now subscribed to this project",
"unsubscribeSuccessProject": "You are now unsubscribed to this project",
"subscribeSuccessList": "You are now subscribed to this list",
"unsubscribeSuccessList": "You are now unsubscribed to this list",
"subscribeSuccessTask": "You are now subscribed to this task",
"unsubscribeSuccessTask": "You are now unsubscribed to this task"
},
@ -764,7 +764,7 @@
"new": "New Task Relation",
"searchPlaceholder": "Type search for a new task to add as related…",
"createPlaceholder": "Add this as new related task",
"differentProject": "This task belongs to a different project.",
"differentList": "This task belongs to a different list.",
"differentNamespace": "This task belongs to a different namespace.",
"noneYet": "No task relations yet.",
"delete": "Delete Task Relation",
@ -814,10 +814,10 @@
"priority1": "To set a task's priority, add a number 1-5, prefixed with a {prefix}.",
"priority2": "The higher the number, the higher the priority.",
"assignees": "To directly assign the task to a user, add their username prefixed with {prefix} to the task.",
"project1": "To set a project for the task to appear in, enter its name prefixed with {prefix}.",
"project2": "This will return an error if the project does not exist.",
"project3": "To use spaces, simply add a \" or ' around the project name.",
"project4": "For example: {prefix}\"Project with spaces\".",
"list1": "To set a list for the task to appear in, enter its name prefixed with {prefix}.",
"list2": "This will return an error if the list does not exist.",
"list3": "To use spaces, simply add a \" or ' around the list name.",
"list4": "For example: {prefix}\"List with spaces\".",
"dateAndTime": "Date and time",
"date": "Any date will be used as the due date of the new task. You can use dates in any of these formats:",
"dateWeekday": "any weekday, will use the next date with that date",
@ -850,19 +850,19 @@
"delete": {
"header": "Delete the team",
"text1": "Are you sure you want to delete this team and all of its members?",
"text2": "All team members will lose access to projects and namespaces shared with this team. This CANNOT BE UNDONE!",
"text2": "All team members will lose access to lists and namespaces shared with this team. This CANNOT BE UNDONE!",
"success": "The team was successfully deleted."
},
"deleteUser": {
"header": "Remove a user from the team",
"text1": "Are you sure you want to remove this user from the team?",
"text2": "They will lose access to all projects and namespaces this team has access to. This CANNOT BE UNDONE!",
"text2": "They will lose access to all lists and namespaces this team has access to. This CANNOT BE UNDONE!",
"success": "The user was successfully deleted from the team."
},
"leave": {
"title": "Leave team",
"text1": "Are you sure you want to leave this team?",
"text2": "You will lose access to all projects and namespaces this team has access to. If you change your mind you'll need a team admin to add you again.",
"text2": "You will lose access to all lists and namespaces this team has access to. If you change your mind you'll need a team admin to add you again.",
"success": "You have successfully left the team."
}
},
@ -894,13 +894,13 @@
"attachment": "Add an attachment to this task",
"related": "Modify related tasks of this task",
"color": "Change the color of this task",
"move": "Move this task to another project",
"move": "Move this task to another list",
"reminder": "Manage reminders of this task",
"description": "Toggle editing of the task description"
},
"project": {
"title": "Project Views",
"switchToListView": "Switch to project view",
"list": {
"title": "List Views",
"switchToListView": "Switch to list view",
"switchToGanttView": "Switch to gantt view",
"switchToKanbanView": "Switch to kanban view",
"switchToTableView": "Switch to table view"
@ -909,13 +909,13 @@
"title": "Navigation",
"overview": "Navigate to overview",
"upcoming": "Navigate to upcoming tasks",
"namespaces": "Navigate to namespaces & projects",
"namespaces": "Navigate to namespaces & lists",
"labels": "Navigate to labels",
"teams": "Navigate to teams"
}
},
"update": {
"available": "There is an update for Vikunja available!",
"available": "There is an update available!",
"do": "Update Now"
},
"menu": {
@ -926,7 +926,7 @@
"unarchive": "Un-Archive",
"setBackground": "Set background",
"share": "Share",
"newProject": "New project"
"newList": "New list"
},
"apiConfig": {
"url": "Vikunja URL",
@ -945,24 +945,24 @@
"notification": {
"title": "Notifications",
"none": "You don't have any notifications. Have a nice day!",
"explainer": "Notifications will appear here when actions on namespaces, projects or tasks you subscribed to happen."
"explainer": "Notifications will appear here when actions on namespaces, lists or tasks you subscribed to happen."
},
"quickActions": {
"commands": "Commands",
"placeholder": "Type a command or search…",
"hint": "You can use {project} to limit the search to a project. Combine {project} or {label} (labels) with a search query to search for a task with these labels or on that project. Use {assignee} to only search for teams.",
"hint": "You can use {list} to limit the search to a list. Combine {list} or {label} (labels) with a search query to search for a task with these labels or on that list. Use {assignee} to only search for teams.",
"tasks": "Tasks",
"projects": "Projects",
"lists": "Lists",
"teams": "Teams",
"newProject": "Enter the title of the new project…",
"newList": "Enter the title of the new list…",
"newTask": "Enter the title of the new task…",
"newNamespace": "Enter the title of the new namespace…",
"newTeam": "Enter the name of the new team…",
"createTask": "Create a task in the current project ({title})",
"createProject": "Create a project in the current namespace ({title})",
"createTask": "Create a task in the current list ({title})",
"createList": "Create a list in the current namespace ({title})",
"cmds": {
"newTask": "New task",
"newProject": "New project",
"newList": "New list",
"newNamespace": "New namespace",
"newTeam": "New team"
}
@ -994,15 +994,15 @@
"1018": "The user avatar type setting is invalid.",
"2001": "ID cannot be empty or 0.",
"2002": "Some of the request data was invalid.",
"3001": "The project does not exist.",
"3004": "You need to have read permissions on that project to perform that action.",
"3005": "The project title cannot be empty.",
"3006": "The project share does not exist.",
"3007": "A project with this identifier already exists.",
"3008": "The project is archived and can therefore only be accessed read only. This is also true for all tasks associated with this project.",
"4001": "The project task text cannot be empty.",
"4002": "The project task does not exist.",
"4003": "All bulk editing tasks must belong to the same project.",
"3001": "The list does not exist.",
"3004": "You need to have read permissions on that list to perform that action.",
"3005": "The list title cannot be empty.",
"3006": "The list share does not exist.",
"3007": "A list with this identifier already exists.",
"3008": "The list is archived and can therefore only be accessed read only. This is also true for all tasks associated with this list.",
"4001": "The list task text cannot be empty.",
"4002": "The list task does not exist.",
"4003": "All bulk editing tasks must belong to the same list.",
"4004": "Need at least one task when bulk editing tasks.",
"4005": "You do not have the right to see the task.",
"4006": "You can't set a parent task as the task itself.",
@ -1028,21 +1028,21 @@
"5012": "The namespace is archived and can therefore only be accessed read only.",
"6001": "The team name cannot be empty.",
"6002": "The team does not exist.",
"6004": "The team already has access to that namespace or project.",
"6004": "The team already has access to that namespace or list.",
"6005": "The user is already a member of that team.",
"6006": "Cannot delete the last team member.",
"6007": "The team does not have access to the project to perform that action.",
"7002": "The user already has access to that project.",
"7003": "You do not have access to that project.",
"6007": "The team does not have access to the list to perform that action.",
"7002": "The user already has access to that list.",
"7003": "You do not have access to that list.",
"8001": "This label already exists on that task.",
"8002": "The label does not exist.",
"8003": "You do not have access to this label.",
"9001": "The right is invalid.",
"10001": "The bucket does not exist.",
"10002": "The bucket does not belong to that project.",
"10003": "You cannot remove the last bucket on a project.",
"10002": "The bucket does not belong to that list.",
"10003": "You cannot remove the last bucket on a list.",
"10004": "You cannot add the task to this bucket as it already exceeded the limit of tasks it can hold.",
"10005": "There can be only one done bucket per project.",
"10005": "There can be only one done bucket per list.",
"11001": "The saved filter does not exist.",
"11002": "Saved filters are not available for link shares.",
"12001": "The subscription entity type is invalid.",

View File

@ -417,7 +417,7 @@
}
},
"migrate": {
"title": "Migrate from other services to Vikunja",
"title": "Import from other services",
"titleService": "Import your data from {name} into Vikunja",
"import": "Import your data into Vikunja",
"description": "Click on the logo of one of the third-party services below to get started.",
@ -911,7 +911,7 @@
}
},
"update": {
"available": "¡Hay una actualización de Vikunja disponible!",
"available": "There is an update available!",
"do": "Actualizar Ahora"
},
"menu": {

View File

@ -417,7 +417,7 @@
}
},
"migrate": {
"title": "Migrer dautres services vers Vikunja",
"title": "Import from other services",
"titleService": "Importe tes données depuis {name} dans Vikunja",
"import": "Importer tes données dans Vikunja",
"description": "Clique sur le logo dun des services tiers ci-dessous pour commencer.",
@ -911,7 +911,7 @@
}
},
"update": {
"available": "Il y a une mise à jour pour Vikunja disponible !",
"available": "There is an update available!",
"do": "Mettre à jour maintenant"
},
"menu": {

View File

@ -417,7 +417,7 @@
}
},
"migrate": {
"title": "Migra da altri servizi a Vikunja",
"title": "Importa da altri servizi",
"titleService": "Importa i tuoi dati da {name} in Vikunja",
"import": "Importa i tuoi dati in Vikunja",
"description": "Clicca sul logo di uno dei servizi esterni qui sotto per iniziare.",
@ -911,7 +911,7 @@
}
},
"update": {
"available": "È disponibile un aggiornamento per Vikunja!",
"available": "È disponibile un aggiornamento!",
"do": "Aggiorna Adesso"
},
"menu": {

View File

@ -417,7 +417,7 @@
}
},
"migrate": {
"title": "Migreer van andere diensten naar Vikunja",
"title": "Import from other services",
"titleService": "Importeer je gegevens van {name} naar Vikunja",
"import": "Importeer je data in Vikunja",
"description": "Click on the logo of one of the third-party services below to get started.",
@ -911,7 +911,7 @@
}
},
"update": {
"available": "Er is een update voor Vikunja beschikbaar!",
"available": "There is an update available!",
"do": "Nu bijwerken"
},
"menu": {

View File

@ -1,6 +1,6 @@
{
"home": {
"welcomeNight": "God Morgen {username}!",
"welcomeNight": "God Natt {username}!",
"welcomeMorning": "God Morgen {username}!",
"welcomeDay": "Hei {username}!",
"welcomeEvening": "God Morgen {username}!",
@ -91,7 +91,7 @@
},
"totp": {
"title": "To-faktor-autentisering",
"enroll": "Delta",
"enroll": "Registrere",
"finishSetupPart1": "For å fullføre oppsettet, bruk denne appen (Google Authenticator eller lignende):",
"finishSetupPart2": "Etter det, skriv inn en kode fra appen under.",
"scanQR": "Alternativt kan du skanne denne QR-koden:",
@ -417,7 +417,7 @@
}
},
"migrate": {
"title": "Migrere fra andre tjenester til Vikunja",
"title": "Importer fra andre tjenester",
"titleService": "Importer dine data fra {name} til Vikunja",
"import": "Importer dine data til Vikunja",
"description": "Klikk på logoen til en av tjenester fra tredjeparter nedenfor for å starte.",
@ -911,7 +911,7 @@
}
},
"update": {
"available": "Det er en oppdatering for Vikunja tilgjengelig!",
"available": "Det er en oppdatering tilgjengelig!",
"do": "Oppdater Nå"
},
"menu": {

View File

@ -417,7 +417,7 @@
}
},
"migrate": {
"title": "Migruj z innych usług do Vikunja",
"title": "Import from other services",
"titleService": "Zaimportuj swoje dane z {name} do Vikunja",
"import": "Zaimportuj swoje dane do Vikunja",
"description": "Aby rozpocząć, kliknij logo jednej z usług zewnętrznych.",
@ -911,7 +911,7 @@
}
},
"update": {
"available": "Dostępna jest aktualizacja Vikunji!",
"available": "There is an update available!",
"do": "Aktualizuj teraz"
},
"menu": {

Some files were not shown because too many files have changed in this diff Show More