Compare commits

...

11 Commits

Author SHA1 Message Date
WofWca 5553e3c24f redirect to first list 2023-03-25 11:57:38 +04:00
WofWca 0701e18067 buckets and some more 2023-03-24 16:50:46 +04:00
WofWca 0644dfbce6 adjust config 2023-03-24 16:50:45 +04:00
WofWca ebfcc0ad8e WIP 2023-03-24 16:50:45 +04:00
WofWca 57d748c899 WIP 2023-03-24 16:50:45 +04:00
renovate 2f009d0b27 chore(deps): update dependency @types/node to v18.15.7 2023-03-24 09:05:25 +00:00
renovate 70d7def7d7 chore(deps): update dependency sass to v1.60.0 2023-03-24 07:11:01 +00:00
renovate 0033407f96 chore(deps): update pnpm to v7.30.2 2023-03-24 02:04:25 +00:00
renovate b10a2329ca chore(deps): update dependency @types/node to v18.15.6 2023-03-23 22:05:08 +00:00
WofWca 6870db4a72 fix: list view: don't sort tasks after marking one "done" (#3285)
See https://community.vikunja.io/t/list-view-tasks-being-sorted-after-marking-one-done-throws-you-off/1257/2

Reviewed-on: vikunja/frontend#3285
Reviewed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-authored-by: WofWca <wofwca@protonmail.com>
Co-committed-by: WofWca <wofwca@protonmail.com>
2023-03-23 20:50:17 +00:00
WofWca 3643ffe0d0 fix: improve the "pop" sound a bit
Trim the (noisy) silence (especially at the start, because
that delay from making a change and playing a sound is a little
annoying), with fade-in and fade-out

Edited with Audacity

File(s) history:
7f5140bbb4
955bd73feccefe8e8ca54d946d6223b43a0836ed
2023-03-23 23:38:52 +04:00
49 changed files with 761 additions and 1931 deletions

Binary file not shown.

Binary file not shown.

View File

@ -13,7 +13,7 @@
},
"homepage": "https://vikunja.io/",
"funding": "https://opencollective.com/vikunja",
"packageManager": "pnpm@7.30.1",
"packageManager": "pnpm@7.30.2",
"keywords": [
"todo",
"productivity",
@ -105,7 +105,7 @@
"@types/focus-within": "1.0.1",
"@types/lodash.debounce": "4.0.7",
"@types/marked": "4.0.8",
"@types/node": "18.15.5",
"@types/node": "18.15.7",
"@types/postcss-preset-env": "7.7.0",
"@typescript-eslint/eslint-plugin": "5.56.0",
"@typescript-eslint/parser": "5.56.0",
@ -131,7 +131,7 @@
"postcss-preset-env": "8.0.1",
"rollup": "3.20.1",
"rollup-plugin-visualizer": "5.9.0",
"sass": "1.59.3",
"sass": "1.60.0",
"start-server-and-test": "2.0.0",
"typescript": "5.0.2",
"vite": "4.2.1",

View File

@ -31,7 +31,7 @@ specifiers:
'@types/lodash.clonedeep': 4.5.7
'@types/lodash.debounce': 4.0.7
'@types/marked': 4.0.8
'@types/node': 18.15.5
'@types/node': 18.15.7
'@types/postcss-preset-env': 7.7.0
'@types/sortablejs': 1.15.1
'@typescript-eslint/eslint-plugin': 5.56.0
@ -80,7 +80,7 @@ specifiers:
register-service-worker: 1.7.2
rollup: 3.20.1
rollup-plugin-visualizer: 5.9.0
sass: 1.59.3
sass: 1.60.0
snake-case: 3.0.4
sortablejs: 1.15.0
start-server-and-test: 2.0.0
@ -163,7 +163,7 @@ devDependencies:
'@types/focus-within': 1.0.1
'@types/lodash.debounce': 4.0.7
'@types/marked': 4.0.8
'@types/node': 18.15.5
'@types/node': 18.15.7
'@types/postcss-preset-env': 7.7.0
'@typescript-eslint/eslint-plugin': 5.56.0_2hcjazgfnbtq42tcc73br2vup4
'@typescript-eslint/parser': 5.56.0_j4766f7ecgqbon3u7zlxn5zszu
@ -171,7 +171,7 @@ devDependencies:
'@vitejs/plugin-vue': 4.1.0_vite@4.2.1+vue@3.2.47
'@vue/eslint-config-typescript': 11.0.2_lawzt7dmfqaotzbm75kfdoil5a
'@vue/test-utils': 2.3.2_vue@3.2.47
'@vue/tsconfig': 0.1.3_@types+node@18.15.5
'@vue/tsconfig': 0.1.3_@types+node@18.15.7
autoprefixer: 10.4.14_postcss@8.4.21
browserslist: 4.21.5
caniuse-lite: 1.0.30001468
@ -181,22 +181,22 @@ devDependencies:
eslint: 8.36.0
eslint-plugin-vue: 9.10.0_eslint@8.36.0
happy-dom: 8.9.0
histoire: 0.15.9_hic242k5noaieo4i4i2q6jbfp4
netlify-cli: 13.1.6_@types+node@18.15.5
histoire: 0.15.9_oehvzq4g4xshcqj4mejeoxz5oe
netlify-cli: 13.1.6_@types+node@18.15.7
postcss: 8.4.21
postcss-easing-gradients: 3.0.1
postcss-easings: 3.0.1_postcss@8.4.21
postcss-preset-env: 8.0.1_postcss@8.4.21
rollup: 3.20.1
rollup-plugin-visualizer: 5.9.0_rollup@3.20.1
sass: 1.59.3
sass: 1.60.0
start-server-and-test: 2.0.0
typescript: 5.0.2
vite: 4.2.1_cmfuwwiatpvig23qflkpysmzgu
vite: 4.2.1_ank4nqufaasuqxqalyydeajple
vite-plugin-inject-preload: 1.3.1_vite@4.2.1
vite-plugin-pwa: 0.14.6_h7yo2zok6ssdtcsppnnx4hllne
vite-svg-loader: 4.0.0
vitest: 0.29.7_qkiijodw3iekfhhsd7gvopg4tm
vitest: 0.29.7_zwl3dv34vescssgfug4i5hi5ka
vue-tsc: 1.2.0_typescript@5.0.2
wait-on: 7.0.1
workbox-cli: 6.5.4_acorn@7.4.1
@ -2256,7 +2256,7 @@ packages:
capture-website: 2.4.1
defu: 6.1.1
fs-extra: 10.1.0
histoire: 0.15.9_hic242k5noaieo4i4i2q6jbfp4
histoire: 0.15.9_oehvzq4g4xshcqj4mejeoxz5oe
pathe: 0.2.0
transitivePeerDependencies:
- bufferutil
@ -2276,7 +2276,7 @@ packages:
'@histoire/vendors': 0.15.8
change-case: 4.1.2
globby: 13.1.2
histoire: 0.15.9_hic242k5noaieo4i4i2q6jbfp4
histoire: 0.15.9_oehvzq4g4xshcqj4mejeoxz5oe
launch-editor: 2.6.0
pathe: 0.2.0
vue: 3.2.47
@ -2295,7 +2295,7 @@ packages:
chokidar: 3.5.3
pathe: 0.2.0
picocolors: 1.0.0
vite: 4.2.1_cmfuwwiatpvig23qflkpysmzgu
vite: 4.2.1_ank4nqufaasuqxqalyydeajple
dev: true
/@histoire/vendors/0.15.8:
@ -2458,7 +2458,7 @@ packages:
dependencies:
'@types/istanbul-lib-coverage': 2.0.4
'@types/istanbul-reports': 3.0.1
'@types/node': 18.15.5
'@types/node': 18.15.7
'@types/yargs': 16.0.4
chalk: 4.1.2
dev: true
@ -2586,7 +2586,7 @@ packages:
resolution: {integrity: sha512-4wMPu9iN3/HL97QblBsBay3E1etIciR84izI3U+4iALY+JHCrI+a2jO0qbAZ/nxKoegypYEaiiqWXylm+/zfrw==}
dev: true
/@netlify/build/29.7.0_@types+node@18.15.5:
/@netlify/build/29.7.0_@types+node@18.15.7:
resolution: {integrity: sha512-N3lPfpib0g1GnF+w5INswTpT1LHP1x/+1utGGNdCgpBhOstYbLRG72OPt2Iwi1MKroG7X1S7Q3jRRykFbuSXGQ==}
engines: {node: ^14.16.0 || >=16.0.0}
hasBin: true
@ -2640,7 +2640,7 @@ packages:
supports-color: 9.2.1
terminal-link: 3.0.0
tmp-promise: 3.0.3
ts-node: 10.8.1_345kigqis4fipgyy5xb3ndshmq
ts-node: 10.8.1_yhdydil56eh4tbjl5b4j24hzwq
typescript: 4.9.5
uuid: 8.3.2
yargs: 17.6.0
@ -3677,7 +3677,7 @@ packages:
dependencies:
'@types/http-cache-semantics': 4.0.1
'@types/keyv': 3.1.3
'@types/node': 18.15.5
'@types/node': 18.15.7
'@types/responselike': 1.0.0
dev: true
@ -3706,7 +3706,7 @@ packages:
/@types/decompress/4.2.4:
resolution: {integrity: sha512-/C8kTMRTNiNuWGl5nEyKbPiMv6HA+0RbEXzFhFBEzASM6+oa4tJro9b8nj7eRlOFfuLdzUU+DS/GPDlvvzMOhA==}
dependencies:
'@types/node': 18.15.5
'@types/node': 18.15.7
dev: true
/@types/dompurify/3.0.0:
@ -3720,7 +3720,7 @@ packages:
dependencies:
'@types/decompress': 4.2.4
'@types/got': 8.3.6
'@types/node': 18.15.5
'@types/node': 18.15.7
dev: true
/@types/estree/0.0.39:
@ -3755,20 +3755,20 @@ packages:
/@types/fs-extra/9.0.13:
resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==}
dependencies:
'@types/node': 18.15.5
'@types/node': 18.15.7
dev: true
/@types/glob/7.2.0:
resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==}
dependencies:
'@types/minimatch': 3.0.5
'@types/node': 18.15.5
'@types/node': 18.15.7
dev: true
/@types/got/8.3.6:
resolution: {integrity: sha512-nvLlj+831dhdm4LR2Ly+HTpdLyBaMynoOr6wpIxS19d/bPeHQxFU5XQ6Gp6ohBpxvCWZM1uHQIC2+ySRH1rGrQ==}
dependencies:
'@types/node': 18.15.5
'@types/node': 18.15.7
dev: true
/@types/har-format/1.2.10:
@ -3782,7 +3782,7 @@ packages:
/@types/http-proxy/1.17.8:
resolution: {integrity: sha512-5kPLG5BKpWYkw/LVOGWpiq3nEVqxiN32rTgI53Sk12/xHFQ2rG3ehI9IO+O3W2QoKeyB92dJkoka8SUm6BX1pA==}
dependencies:
'@types/node': 18.15.5
'@types/node': 18.15.7
dev: true
/@types/is-touch-device/1.0.0:
@ -3812,7 +3812,7 @@ packages:
/@types/keyv/3.1.3:
resolution: {integrity: sha512-FXCJgyyN3ivVgRoml4h94G/p3kY+u/B86La+QptcqJaWtBWtmc6TtkNfS40n9bIvyLteHh7zXOtgbobORKPbDg==}
dependencies:
'@types/node': 18.15.5
'@types/node': 18.15.7
dev: true
/@types/linkify-it/3.0.2:
@ -3859,7 +3859,7 @@ packages:
/@types/node-fetch/2.5.12:
resolution: {integrity: sha512-MKgC4dlq4kKNa/mYrwpKfzQMB5X3ee5U6fSprkKpToBqBmX4nFZL9cW5jl6sWn+xpRJ7ypWh2yyqqr8UUCstSw==}
dependencies:
'@types/node': 18.15.5
'@types/node': 18.15.7
form-data: 3.0.1
dev: true
@ -3867,8 +3867,8 @@ packages:
resolution: {integrity: sha512-zCoCEMA+IPpsRkyCFBqew5vGb7r8RSiB3uwdu/map7uwLAfu1MTazW26/pUDWoNnF88vJz4W3U56i5gtXNqxGg==}
dev: true
/@types/node/18.15.5:
resolution: {integrity: sha512-Ark2WDjjZO7GmvsyFFf81MXuGTA/d6oP38anyxWOL6EREyBKAxKoFHwBhaZxCfLRLpO8JgVXwqOwSwa7jRcjew==}
/@types/node/18.15.7:
resolution: {integrity: sha512-LFmUbFunqmBn26wJZgZPYZPrDR1RwGOu2v79Mgcka1ndO6V0/cwjivPTc4yoK6n9kmw4/ls1r8cLrvh2iMibFA==}
dev: true
/@types/normalize-package-data/2.4.1:
@ -3885,13 +3885,13 @@ packages:
/@types/resolve/1.17.1:
resolution: {integrity: sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==}
dependencies:
'@types/node': 18.15.5
'@types/node': 18.15.7
dev: true
/@types/responselike/1.0.0:
resolution: {integrity: sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==}
dependencies:
'@types/node': 18.15.5
'@types/node': 18.15.7
dev: true
/@types/retry/0.12.1:
@ -3941,7 +3941,7 @@ packages:
resolution: {integrity: sha512-8uALY5LTvSuHgloDVUvWP3pIauILm+8/0pDMokuDYIoNsOkSwd5AiHBTSEJjKTDcZr5z8UpgOWZkxBF4iJftoA==}
requiresBuild: true
dependencies:
'@types/node': 18.15.5
'@types/node': 18.15.7
dev: true
optional: true
@ -4172,7 +4172,7 @@ packages:
regenerator-runtime: 0.13.11
systemjs: 6.14.0
terser: 5.10.0_acorn@7.4.1
vite: 4.2.1_cmfuwwiatpvig23qflkpysmzgu
vite: 4.2.1_ank4nqufaasuqxqalyydeajple
transitivePeerDependencies:
- supports-color
dev: true
@ -4184,7 +4184,7 @@ packages:
vite: ^4.0.0
vue: ^3.2.25
dependencies:
vite: 4.2.1_cmfuwwiatpvig23qflkpysmzgu
vite: 4.2.1_ank4nqufaasuqxqalyydeajple
vue: 3.2.47
dev: true
@ -4428,7 +4428,7 @@ packages:
'@vue/server-renderer': 3.2.47_vue@3.2.47
dev: true
/@vue/tsconfig/0.1.3_@types+node@18.15.5:
/@vue/tsconfig/0.1.3_@types+node@18.15.7:
resolution: {integrity: sha512-kQVsh8yyWPvHpb8gIc9l/HIDiiVUy1amynLNpCy8p+FoCiZXCo6fQos5/097MmnNZc9AtseDsCrfkhqCrJ8Olg==}
peerDependencies:
'@types/node': '*'
@ -4436,7 +4436,7 @@ packages:
'@types/node':
optional: true
dependencies:
'@types/node': 18.15.5
'@types/node': 18.15.7
dev: true
/@vueuse/core/9.13.0_vue@3.2.47:
@ -8723,7 +8723,7 @@ packages:
engines: {node: '>=12.0.0'}
dev: false
/histoire/0.15.9_hic242k5noaieo4i4i2q6jbfp4:
/histoire/0.15.9_oehvzq4g4xshcqj4mejeoxz5oe:
resolution: {integrity: sha512-Mb9185Sq/SckVhtHpUdf3dqSrAdf9HDsOFbTcBIZ6W9TYCdsSLY5kLzPU2sHaHHM/myxh5DC+9SLBaTFeNJSug==}
hasBin: true
peerDependencies:
@ -8759,8 +8759,8 @@ packages:
sade: 1.8.1
shiki-es: 0.2.0
sirv: 2.0.2
vite: 4.2.1_cmfuwwiatpvig23qflkpysmzgu
vite-node: 0.28.4_cmfuwwiatpvig23qflkpysmzgu
vite: 4.2.1_ank4nqufaasuqxqalyydeajple
vite-node: 0.28.4_ank4nqufaasuqxqalyydeajple
transitivePeerDependencies:
- '@types/node'
- bufferutil
@ -9601,7 +9601,7 @@ packages:
resolution: {integrity: sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==}
engines: {node: '>= 10.13.0'}
dependencies:
'@types/node': 18.15.5
'@types/node': 18.15.7
merge-stream: 2.0.0
supports-color: 7.2.0
dev: true
@ -10777,14 +10777,14 @@ packages:
resolution: {integrity: sha512-9iN1ka/9zmX1ZvLV9ewJYEk9h7RyRRtqdK0woXcqohu8EWIerfPUjYJPg0ULy0UqP7cslmdGc8xKDJcojlKiaw==}
dev: true
/netlify-cli/13.1.6_@types+node@18.15.5:
/netlify-cli/13.1.6_@types+node@18.15.7:
resolution: {integrity: sha512-dn9NYfLLzBi/Bph9wccpbR/90Yh6xutHzdl9J06XfcYsgpQOhEE9qAHUppzzJnHXUJEi7PAMo0lkXExvVO4zhA==}
engines: {node: ^14.16.0 || >=16.0.0}
hasBin: true
requiresBuild: true
dependencies:
'@fastify/static': 6.6.1
'@netlify/build': 29.7.0_@types+node@18.15.5
'@netlify/build': 29.7.0_@types+node@18.15.7
'@netlify/config': 20.3.6
'@netlify/edge-bundler': 8.12.2
'@netlify/framework-info': 9.8.5
@ -13016,8 +13016,8 @@ packages:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
dev: true
/sass/1.59.3:
resolution: {integrity: sha512-QCq98N3hX1jfTCoUAsF3eyGuXLsY7BCnCEg9qAact94Yc21npG2/mVOqoDvE0fCbWDqiM4WlcJQla0gWG2YlxQ==}
/sass/1.60.0:
resolution: {integrity: sha512-updbwW6fNb5gGm8qMXzVO7V4sWf7LMXnMly/JEyfbfERbVH46Fn6q02BX7/eHTdKpE7d+oTkMMQpFWNUMfFbgQ==}
engines: {node: '>=12.0.0'}
hasBin: true
dependencies:
@ -14124,7 +14124,7 @@ packages:
resolution: {integrity: sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==}
dev: true
/ts-node/10.8.1_345kigqis4fipgyy5xb3ndshmq:
/ts-node/10.8.1_yhdydil56eh4tbjl5b4j24hzwq:
resolution: {integrity: sha512-Wwsnao4DQoJsN034wePSg5nZiw4YKXf56mPIAeD6wVmiv+RytNSWqc2f3fKvcUoV+Yn2+yocD71VOfQHbmVX4g==}
hasBin: true
peerDependencies:
@ -14143,7 +14143,7 @@ packages:
'@tsconfig/node12': 1.0.9
'@tsconfig/node14': 1.0.1
'@tsconfig/node16': 1.0.2
'@types/node': 18.15.5
'@types/node': 18.15.7
acorn: 8.8.2
acorn-walk: 8.2.0
arg: 4.1.3
@ -14588,7 +14588,7 @@ packages:
extsprintf: 1.3.0
dev: true
/vite-node/0.28.4_cmfuwwiatpvig23qflkpysmzgu:
/vite-node/0.28.4_ank4nqufaasuqxqalyydeajple:
resolution: {integrity: sha512-KM0Q0uSG/xHHKOJvVHc5xDBabgt0l70y7/lWTR7Q0pR5/MrYxadT+y32cJOE65FfjGmJgxpVEEY+69btJgcXOQ==}
engines: {node: '>=v14.16.0'}
hasBin: true
@ -14600,7 +14600,7 @@ packages:
picocolors: 1.0.0
source-map: 0.6.1
source-map-support: 0.5.21
vite: 4.2.1_cmfuwwiatpvig23qflkpysmzgu
vite: 4.2.1_ank4nqufaasuqxqalyydeajple
transitivePeerDependencies:
- '@types/node'
- less
@ -14611,7 +14611,7 @@ packages:
- terser
dev: true
/vite-node/0.29.7_cmfuwwiatpvig23qflkpysmzgu:
/vite-node/0.29.7_ank4nqufaasuqxqalyydeajple:
resolution: {integrity: sha512-PakCZLvz37yFfUPWBnLa1OYHPCGm5v4pmRrTcFN4V/N/T3I6tyP3z07S//9w+DdeL7vVd0VSeyMZuAh+449ZWw==}
engines: {node: '>=v14.16.0'}
hasBin: true
@ -14621,7 +14621,7 @@ packages:
mlly: 1.1.0
pathe: 1.1.0
picocolors: 1.0.0
vite: 4.2.1_cmfuwwiatpvig23qflkpysmzgu
vite: 4.2.1_ank4nqufaasuqxqalyydeajple
transitivePeerDependencies:
- '@types/node'
- less
@ -14639,7 +14639,7 @@ packages:
vite: ^3.0.0 || ^4.0.0
dependencies:
mime-types: 2.1.35
vite: 4.2.1_cmfuwwiatpvig23qflkpysmzgu
vite: 4.2.1_ank4nqufaasuqxqalyydeajple
dev: true
/vite-plugin-pwa/0.14.6_h7yo2zok6ssdtcsppnnx4hllne:
@ -14654,7 +14654,7 @@ packages:
fast-glob: 3.2.12
pretty-bytes: 6.0.0
rollup: 3.20.1
vite: 4.2.1_cmfuwwiatpvig23qflkpysmzgu
vite: 4.2.1_ank4nqufaasuqxqalyydeajple
workbox-build: 6.5.4_acorn@7.4.1
workbox-window: 6.5.4
transitivePeerDependencies:
@ -14668,7 +14668,7 @@ packages:
svgo: 3.0.2
dev: true
/vite/4.2.1_cmfuwwiatpvig23qflkpysmzgu:
/vite/4.2.1_ank4nqufaasuqxqalyydeajple:
resolution: {integrity: sha512-7MKhqdy0ISo4wnvwtqZkjke6XN4taqQ2TBaTccLIpOKv7Vp2h4Y+NpmWCnGDeSvvn45KxvWgGyb0MkHvY1vgbg==}
engines: {node: ^14.18.0 || >=16.0.0}
hasBin: true
@ -14693,18 +14693,18 @@ packages:
terser:
optional: true
dependencies:
'@types/node': 18.15.5
'@types/node': 18.15.7
esbuild: 0.17.12
postcss: 8.4.21
resolve: 1.22.1
rollup: 3.20.1
sass: 1.59.3
sass: 1.60.0
terser: 5.10.0_acorn@7.4.1
optionalDependencies:
fsevents: 2.3.2
dev: true
/vitest/0.29.7_qkiijodw3iekfhhsd7gvopg4tm:
/vitest/0.29.7_zwl3dv34vescssgfug4i5hi5ka:
resolution: {integrity: sha512-aWinOSOu4jwTuZHkb+cCyrqQ116Q9TXaJrNKTHudKBknIpR0VplzeaOUuDF9jeZcrbtQKZQt6yrtd+eakbaxHg==}
engines: {node: '>=v14.16.0'}
hasBin: true
@ -14734,7 +14734,7 @@ packages:
dependencies:
'@types/chai': 4.3.4
'@types/chai-subset': 1.3.3
'@types/node': 18.15.5
'@types/node': 18.15.7
'@vitest/expect': 0.29.7
'@vitest/runner': 0.29.7
'@vitest/spy': 0.29.7
@ -14754,8 +14754,8 @@ packages:
tinybench: 2.3.1
tinypool: 0.4.0
tinyspy: 1.0.2
vite: 4.2.1_cmfuwwiatpvig23qflkpysmzgu
vite-node: 0.29.7_cmfuwwiatpvig23qflkpysmzgu
vite: 4.2.1_ank4nqufaasuqxqalyydeajple
vite-node: 0.29.7_ank4nqufaasuqxqalyydeajple
why-is-node-running: 2.2.2
transitivePeerDependencies:
- less

View File

@ -35,7 +35,6 @@ import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue'
import Ready from '@/components/misc/ready.vue'
import {setLanguage} from '@/i18n'
import AccountDeleteService from '@/services/accountDelete'
import {success} from '@/message'
import {useAuthStore} from '@/stores/auth'
@ -57,41 +56,6 @@ const authLinkShare = computed(() => authStore.authLinkShare)
const {t} = useI18n({useScope: 'global'})
// setup account deletion verification
const accountDeletionConfirm = computed(() => route.query?.accountDeletionConfirm as (string | undefined))
watch(accountDeletionConfirm, async (accountDeletionConfirm) => {
if (accountDeletionConfirm === undefined) {
return
}
const accountDeletionService = new AccountDeleteService()
await accountDeletionService.confirm(accountDeletionConfirm)
success({message: t('user.deletion.confirmSuccess')})
authStore.refreshUserInfo()
}, { immediate: true })
// setup password reset redirect
const userPasswordReset = computed(() => route.query?.userPasswordReset as (string | undefined))
watch(userPasswordReset, (userPasswordReset) => {
if (userPasswordReset === undefined) {
return
}
localStorage.setItem('passwordResetToken', userPasswordReset)
router.push({name: 'user.password-reset.reset'})
}, { immediate: true })
// setup email verification redirect
const userEmailConfirm = computed(() => route.query?.userEmailConfirm as (string | undefined))
watch(userEmailConfirm, (userEmailConfirm) => {
if (userEmailConfirm === undefined) {
return
}
localStorage.setItem('emailConfirmToken', userEmailConfirm)
router.push({name: 'user.login'})
}, { immediate: true })
setLanguage()
useColorScheme()
</script>

Binary file not shown.

View File

@ -5,32 +5,13 @@
<Logo width="164" height="48" />
</router-link>
<MenuButton class="menu-button" />
<div v-if="currentProject.id" class="project-title-wrapper">
<h1 class="project-title">{{ currentProject.title === '' ? $t('misc.loading') : getProjectTitle(currentProject) }}
</h1>
<BaseButton :to="{ name: 'project.info', params: { projectId: currentProject.id } }" class="project-title-button">
<icon icon="circle-info" />
</BaseButton>
<project-settings-dropdown v-if="canWriteCurrentProject && currentProject.id !== -1"
class="project-title-dropdown" :project="currentProject">
<template #trigger="{ toggleOpen }">
<BaseButton class="project-title-button" @click="toggleOpen">
<icon icon="ellipsis-h" class="icon" />
</BaseButton>
</template>
</project-settings-dropdown>
</div>
<!-- <MenuButton class="menu-button" /> -->
<div class="navbar-end">
<BaseButton @click="openQuickActions" class="trigger-button" v-shortcut="'Control+k'"
:title="$t('keyboardShortcuts.quickSearch')">
<icon icon="search" />
</BaseButton>
<Notifications />
<dropdown>
<template #trigger="{ toggleOpen, open }">
<BaseButton class="username-dropdown-trigger" @click="toggleOpen" variant="secondary" :shadow="false">
@ -59,9 +40,6 @@
<dropdown-item :to="{ name: 'about' }">
{{ $t('about.title') }}
</dropdown-item>
<dropdown-item @click="authStore.logout()">
{{ $t('user.auth.logout') }}
</dropdown-item>
</dropdown>
</div>
</header>

View File

@ -16,6 +16,7 @@
:class="{'is-visible': background}"
class="app-container-background background-fade-in d-print-none"
:style="{'background-image': background && `url(${background})`}"></div>
<!-- Can't remove <navigation> because otherwise namespaces would not get loaded. -->
<navigation class="d-print-none"/>
<main
class="app-content"
@ -71,7 +72,6 @@ import {useBaseStore} from '@/stores/base'
import {useLabelStore} from '@/stores/labels'
import {useRouteWithModal} from '@/composables/useRouteWithModal'
import {useRenewTokenOnFocus} from '@/composables/useRenewTokenOnFocus'
const {routeWithModal, currentModal, closeModal} = useRouteWithModal()
@ -112,8 +112,6 @@ watch(() => route.name as string, (routeName) => {
// TODO: Reset the title if the page component does not set one itself
useRenewTokenOnFocus()
const labelStore = useLabelStore()
labelStore.loadAllLabels()
</script>

View File

@ -63,7 +63,7 @@ const route = useRoute()
const baseStore = useBaseStore()
const ready = computed(() => baseStore.ready)
const online = useOnline()
const online = true
const error = ref('')
const showLoading = computed(() => !ready.value && error.value === '')

View File

@ -80,9 +80,9 @@ const userInfo = computed(() => authStore.info)
let interval: ReturnType<typeof setInterval>
onMounted(() => {
loadNotifications()
// loadNotifications()
document.addEventListener('click', hidePopup)
interval = setInterval(loadNotifications, LOAD_NOTIFICATIONS_INTERVAL)
// interval = setInterval(loadNotifications, LOAD_NOTIFICATIONS_INTERVAL)
})
onUnmounted(() => {

View File

@ -45,7 +45,7 @@ export function useMenuActive() {
}
return {
menuActive: readonly(menuActive),
menuActive: false,
setMenuActive,
toggleMenu,
}

View File

@ -1,46 +0,0 @@
import {computed} from 'vue'
import {useRouter} from 'vue-router'
import {useEventListener} from '@vueuse/core'
import {useAuthStore} from '@/stores/auth'
import {MILLISECONDS_A_SECOND, SECONDS_A_HOUR} from '@/constants/date'
const SECONDS_TOKEN_VALID = 60 * SECONDS_A_HOUR
export function useRenewTokenOnFocus() {
const router = useRouter()
const authStore = useAuthStore()
const userInfo = computed(() => authStore.info)
const authenticated = computed(() => authStore.authenticated)
// Try renewing the token every time vikunja is loaded initially
// (When opening the browser the focus event is not fired)
authStore.renewToken()
// Check if the token is still valid if the window gets focus again to maybe renew it
useEventListener('focus', async () => {
if (!authenticated.value) {
return
}
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) {
await authStore.checkAuth()
await router.push({name: 'user.login'})
return
}
// Check if the token is valid for less than 60 hours and renew if thats the case
if (expiresIn < SECONDS_TOKEN_VALID) {
authStore.renewToken()
console.debug('renewed token')
}
})
}

View File

@ -60,28 +60,13 @@ const SORT_BY_DEFAULT: SortBy = {
/**
* This mixin provides a base set of methods and properties to get tasks.
*/
export function useTaskList(projectId, sortByDefault: SortBy = SORT_BY_DEFAULT) {
const params = ref({...getDefaultParams()})
const search = ref('')
export function useTaskList(projectId) {
const page = ref(1)
const sortBy = ref({ ...sortByDefault })
const getAllTasksParams = computed(() => {
let loadParams = {...params.value}
if (search.value !== '') {
loadParams.s = search.value
}
loadParams = formatSortOrder(sortBy.value, loadParams)
return [
{projectId: projectId.value},
loadParams,
// TODO_OFFLINE still need sorting by position.
{},
page.value || 1,
]
})
@ -103,10 +88,7 @@ export function useTaskList(projectId, sortByDefault: SortBy = SORT_BY_DEFAULT)
const route = useRoute()
watch(() => route.query, (query) => {
const { page: pageQueryValue, search: searchQuery } = query
if (searchQuery !== undefined) {
search.value = searchQuery as string
}
const { page: pageQueryValue } = query
if (pageQueryValue !== undefined) {
page.value = Number(pageQueryValue)
}
@ -129,8 +111,5 @@ export function useTaskList(projectId, sortByDefault: SortBy = SORT_BY_DEFAULT)
totalPages,
currentPage: page,
loadTasks,
searchTerm: search,
params,
sortByParam: sortBy,
}
}

View File

@ -5,6 +5,7 @@ const API_DEFAULT_PORT = '3456'
export const ERROR_NO_API_URL = 'noApiUrlProvided'
// TODO_OFFLINE remove?
export const checkAndSetApiUrl = (url: string): Promise<string> => {
if(url.startsWith('/')) {
url = window.location.host + url

182
src/localBackend/buckets.ts Normal file
View File

@ -0,0 +1,182 @@
import type { IBucket } from '@/modelTypes/IBucket'
import { getAllTasks, getTasksOfBucket } from './tasks'
import { defaultPositionIfZero } from './utils/calculateDefaultPosition'
// TODO_OFFLINE there is a lot of duplication between `localBackend` parts.
type IBucketWithoutTasks = Omit<IBucket, 'tasks'>
// Be carefult not to convert it to `const initialBuckets =` since we need to return a new
// array each time to avoid getting it mutated.
// The actual backend actually only creates one bucket by default.
function getInitialBuckets(): IBucketWithoutTasks[] {
return [
{
id: 1,
title: 'Backlog',
projectId: 1,
limit: 0,
isDoneBucket: false,
position: 65536,
createdBy: {
id: 1,
name: '',
username: 'demo',
created: '2021-05-30T10:45:25+02:00',
updated: '2023-03-16T11:55:59+01:00',
},
},
{
id: 2,
title: 'In progress',
projectId: 1,
limit: 0,
isDoneBucket: false,
position: 131072,
createdBy: {
id: 1,
name: '',
username: 'demo',
created: '2021-05-30T10:45:25+02:00',
updated: '2023-03-16T11:55:59+01:00',
},
},
{
id: 3,
title: 'Done',
projectId: 1,
limit: 0,
isDoneBucket: true,
position: 262144,
createdBy: {
id: 1,
name: '',
username: 'demo',
created: '2021-05-30T10:45:25+02:00',
updated: '2023-03-16T11:55:59+01:00',
},
},
]
}
function getAllBucketsWithoutTasks(): IBucketWithoutTasks[] {
const fromStorage = localStorage.getItem('buckets')
if (!fromStorage) {
// TODO_OFFLINE dynamic import.
// Currently we have a constant list. Each project must have at least one bucket
return getInitialBuckets()
}
// TODO_OFFLINE fill the `tasks`.
return JSON.parse(fromStorage)
}
// /**
// * Mutates `bucket` and returns it.
// */
// function fillBucketTasks(bucket: IBucketWithoutTasks): IBucket {
// (bucket as IBucket).tasks = getAllTas
// }
function getAllBuckets(): IBucket[] {
const bucketsWithoutTasks = getAllBucketsWithoutTasks()
const buckets = bucketsWithoutTasks.map(bucketWithoutTasks => {
const b = bucketWithoutTasks as IBucket
b.tasks = getTasksOfBucket(b.id)
// Tasks are always sorted by their `kanbanPosition`.
// https://kolaente.dev/vikunja/api/src/commit/6d8db0ce1e00e8c200a43b28ac98eb0fb825f4d4/pkg/models/kanban.go#L173-L178
.sort((a, b) => a.kanbanPosition - b.kanbanPosition)
return b
})
return buckets
}
export function getAllBucketsOfProject(projectId: number): IBucket[] {
// TODO_OFFLINE filter by position bruh.
// TODO_OFFLINE perf: maybe it's not worth getting tasks of each project then in `getAllBuckets`.
return getAllBuckets()
.filter(b => b.projectId === projectId)
// Buckets are always sorted.
// https://kolaente.dev/vikunja/api/src/commit/6d8db0ce1e00e8c200a43b28ac98eb0fb825f4d4/pkg/models/kanban.go#L139-L143
.sort((a, b) => a.position - b.position)
}
/** https://kolaente.dev/vikunja/api/src/commit/769db0dab2e50bc477dec6c7e18309effc80a1bd/pkg/models/kanban.go#L80-L87 */
export function getDefaultBucket(projectId: number): IBucket {
return getAllBucketsOfProject(projectId).sort((a, b) => a.position - b.position)[0]
}
/**
* https://kolaente.dev/vikunja/api/src/commit/066c26f83e1e066bdc9d80b4642db1df0d6a77eb/pkg/models/kanban.go#L252-L267
*/
export function createBucket(bucket: IBucket): IBucket {
const allBuckets = getAllBucketsWithoutTasks()
const maxPosition = allBuckets.reduce((currMax, b) => {
return b.position > currMax
? b.position
: currMax
}, -Infinity)
const newBucketFullData = {
...bucket,
id: Math.round(Math.random() * 1000000000000),
// position: defaultPositionIfZero(bucket.position),
position: maxPosition * 2,
}
const newBucketFullDataToStore = {
...newBucketFullData,
// It's not actually necessary FYI, it will just taske extra space in the storage.
tasks: undefined,
}
allBuckets.push(newBucketFullDataToStore)
localStorage.setItem('buckets', JSON.stringify(allBuckets))
return newBucketFullData
}
export function updateBucket(newBucketData: IBucket) {
const allBuckets = getAllBucketsWithoutTasks()
// TODO_OFFLINE looks like the real backend also filters by prjectId, but
// since in localBackend all bucket `id`s are unique even between projects,
// it's not necessary
const targetBucketInd = allBuckets.findIndex(b => b.id === newBucketData.id)
if (targetBucketInd < 0) {
console.warn('Tried to update a bucket, but it does not exist')
return
}
// TODO_OFFLINE remove tasks.
allBuckets.splice(targetBucketInd, 1, newBucketData)
localStorage.setItem('buckets', JSON.stringify(allBuckets))
return newBucketData
}
export function deleteBucket({ id }: { id: number }) {
const allBuckets = getAllBucketsWithoutTasks()
if (allBuckets.length <= 1) {
// Prevent removing the last bucket.
// https://kolaente.dev/vikunja/api/src/commit/6aadaaaffc1fff4a94e35e8fa3f6eab397cbc3ce/pkg/models/kanban.go#L325-L335
return
}
const targetBucketInd = allBuckets.findIndex(b => b.id === id)
if (targetBucketInd < 0) {
console.warn('Tried to delete a bucket, but it does not exist')
return
}
const projectId = allBuckets[targetBucketInd].projectId
allBuckets.splice(targetBucketInd, 1)
localStorage.setItem('buckets', JSON.stringify(allBuckets))
// Move all the tasks from this bucket to the default one.
// https://kolaente.dev/vikunja/api/src/commit/6aadaaaffc1fff4a94e35e8fa3f6eab397cbc3ce/pkg/models/kanban.go#L349-L353
const deletedBuckedId = id
const defaultBucketId = getDefaultBucket(projectId).id
const allTasks = getAllTasks()
allTasks.forEach(t => {
if (t.bucketId === deletedBuckedId) {
t.bucketId = defaultBucketId
}
})
localStorage.setItem('tasks', JSON.stringify(allTasks))
// TODO_OFFLINE idk what it's supposed to return
return true
}

105
src/localBackend/tasks.ts Normal file
View File

@ -0,0 +1,105 @@
import type { ITask } from '@/modelTypes/ITask'
import { getDefaultBucket } from './buckets'
import { defaultPositionIfZero } from './utils/calculateDefaultPosition'
// TODO_OFFLINE return types? ITask is not it, because of snake case and Date format.
// TODO_OFFLINE actually `project_id` is not always present on a task.
// https://kolaente.dev/vikunja/frontend/src/commit/0ff0d8c5b89bd6a8b628ddbe6074f61797b6b9c1/src/modelTypes/ITask.ts#L52
// So getTaskOfProject doesn't work right?? Should we make it return all tasks
// (since we only support just one project for now?)
// Actually `project_id` is present on a task when it is created:
// https://kolaente.dev/vikunja/frontend/src/commit/6aa02e29b19f9f57620bdf09919df34c363e1f3d/src/services/abstractService.ts#L404
// Here it substitutes `{projectId}` in the URL.
let _nextUniqueIntToReturn = 1
/**
* @returns A unique (in this browsing context) integer
*/
function getUniquieInt() {
return _nextUniqueIntToReturn++
}
/**
* Yes, we store the data in camelCase.
*/
export function getAllTasks(): ITask[] {
const fromStorage = localStorage.getItem('tasks')
if (!fromStorage) {
return []
}
const tasks: ITask[] = JSON.parse(fromStorage)
// TODO_OFFLINE don't just always sort them by position but look at
// `parameters.sort_by`.
return tasks.sort((a, b) => a.position - b.position)
}
// getAllTasksWithFilters(params)
// TODO_OFFLINE we only have one project currently, actually.
export function getTasksOfProject<PID extends number>(projectId: PID): Array<ITask & { projectId: PID }> {
const tasks: ITask[] = getAllTasks().filter(t => t.projectId === projectId)
return tasks
}
/**
* Unsorted
*/
export function getTasksOfBucket<BID extends number>(
bucketId: BID,
): Array<ITask & { bucketId: BID }> {
const tasks: ITask[] = getAllTasks().filter(t => t.bucketId === bucketId)
return tasks
}
/**
* https://kolaente.dev/vikunja/api/src/commit/066c26f83e1e066bdc9d80b4642db1df0d6a77eb/pkg/models/tasks.go#L913-L990
*/
export function createTask(newTask: ITask): ITask {
const allTasks = getAllTasks()
const newTaskFullData: ITask = {
...newTask,
id: Math.round(Math.random() * 1000000000000),
// TODO_OFFLINE created_by, indentifier, index
position: defaultPositionIfZero(newTask.position),
kanbanPosition: defaultPositionIfZero(newTask.kanbanPosition),
// https://kolaente.dev/vikunja/api/src/commit/769db0dab2e50bc477dec6c7e18309effc80a1bd/pkg/models/tasks.go#L939-L940
bucketId: newTask.bucketId || getDefaultBucket(newTask.projectId).id,
}
allTasks.unshift(newTaskFullData)
localStorage.setItem('tasks', JSON.stringify(allTasks))
return newTaskFullData
}
export function getTask(taskId: number) {
return getAllTasks().find(t => t.id === taskId)
}
export function updateTask(newTaskData: ITask) {
// TODO_OFFLINE a lot of stuff is not implemented. For example, marking a task "done"
// when it is moved to the "done" bucket.
// https://kolaente.dev/vikunja/api/src/commit/6aadaaaffc1fff4a94e35e8fa3f6eab397cbc3ce/pkg/models/tasks.go#L1008
const allTasks = getAllTasks()
const targetTaskInd = allTasks.findIndex(t => t.id === newTaskData.id)
if (targetTaskInd < 0) {
console.warn('Tried to update a task, but it does not exist')
return
}
allTasks.splice(targetTaskInd, 1, newTaskData)
localStorage.setItem('tasks', JSON.stringify(allTasks))
return newTaskData
}
export function deleteTask({ id }: { id: number }) {
const allTasks = getAllTasks()
const targetTaskInd = allTasks.findIndex(t => t.id === id)
if (targetTaskInd < 0) {
console.warn('Tried to delete a task, but it does not exist')
return
}
allTasks.splice(targetTaskInd, 1)
localStorage.setItem('tasks', JSON.stringify(allTasks))
// TODO_OFFLINE idk what it's supposed to return
return true
}

View File

@ -0,0 +1,12 @@
// https://kolaente.dev/vikunja/api/src/commit/066c26f83e1e066bdc9d80b4642db1df0d6a77eb/pkg/models/tasks.go#L874-L880
// TODO_OFFLINE this is wrong. For example, for buckets. it is possible to create several buckets
// with position: 65535, which would mess up positioning. Looks like we actually need to consider
// position. How about store `lastUsedGlobalId` in `localStorage` and simply increment it each
// time an entity is created?
export function defaultPositionIfZero(/* entityID: number, */ position: number): number {
if (position === 0) {
return /* entityID * */ 2**16
}
return position
}

View File

@ -15,9 +15,6 @@ import HomeComponent from '@/views/Home.vue'
import NotFoundComponent from '@/views/404.vue'
const About = () => import('@/views/About.vue')
// User Handling
import LoginComponent from '@/views/user/Login.vue'
import RegisterComponent from '@/views/user/Register.vue'
import OpenIdAuth from '@/views/user/OpenIdAuth.vue'
const DataExportDownload = () => import('@/views/user/DataExportDownload.vue')
// Tasks
import UpcomingTasksComponent from '@/views/tasks/ShowTasks.vue'
@ -41,12 +38,6 @@ const ProjectKanban = () => import('@/views/project/ProjectKanban.vue')
const ProjectInfo = () => import('@/views/project/ProjectInfo.vue')
// Project Settings
const ProjectSettingEdit = () => import('@/views/project/settings/edit.vue')
const ProjectSettingBackground = () => import('@/views/project/settings/background.vue')
const ProjectSettingDuplicate = () => import('@/views/project/settings/duplicate.vue')
const ProjectSettingShare = () => import('@/views/project/settings/share.vue')
const ProjectSettingDelete = () => import('@/views/project/settings/delete.vue')
const ProjectSettingArchive = () => import('@/views/project/settings/archive.vue')
// Namespace Settings
const NamespaceSettingEdit = () => import('@/views/namespaces/settings/edit.vue')
@ -55,21 +46,12 @@ const NamespaceSettingArchive = () => import('@/views/namespaces/settings/archiv
const NamespaceSettingDelete = () => import('@/views/namespaces/settings/delete.vue')
// Saved Filters
const FilterNew = () => import('@/views/filters/FilterNew.vue')
const FilterEdit = () => import('@/views/filters/FilterEdit.vue')
const FilterDelete = () => import('@/views/filters/FilterDelete.vue')
const PasswordResetComponent = () => import('@/views/user/PasswordReset.vue')
const GetPasswordResetComponent = () => import('@/views/user/RequestPasswordReset.vue')
const UserSettingsComponent = () => import('@/views/user/Settings.vue')
const UserSettingsAvatarComponent = () => import('@/views/user/settings/Avatar.vue')
const UserSettingsCaldavComponent = () => import('@/views/user/settings/Caldav.vue')
const UserSettingsDataExportComponent = () => import('@/views/user/settings/DataExport.vue')
const UserSettingsDeletionComponent = () => import('@/views/user/settings/Deletion.vue')
const UserSettingsEmailUpdateComponent = () => import('@/views/user/settings/EmailUpdate.vue')
const UserSettingsGeneralComponent = () => import('@/views/user/settings/General.vue')
const UserSettingsPasswordUpdateComponent = () => import('@/views/user/settings/PasswordUpdate.vue')
const UserSettingsTOTPComponent = () => import('@/views/user/settings/TOTP.vue')
// Project Handling
const NewProjectComponent = () => import('@/views/project/NewProject.vue')
@ -96,11 +78,17 @@ const router = createRouter({
// Otherwise just scroll to the top
return {left: 0, top: 0}
},
// TODO_OFFLINE remove the references of the removed routes.
routes: [
{
path: '/',
name: 'home',
component: HomeComponent,
// TODO_OFFLINE don't redirect when there's actually something useful on the home page
redirect: {
name: 'project.index',
params: { projectId: 1 },
},
},
{
path: '/:pathMatch(.*)*',
@ -113,38 +101,6 @@ const router = createRouter({
name: 'bad-not-found',
component: NotFoundComponent,
},
{
path: '/login',
name: 'user.login',
component: LoginComponent,
meta: {
title: 'user.auth.login',
},
},
{
path: '/get-password-reset',
name: 'user.password-reset.request',
component: GetPasswordResetComponent,
meta: {
title: 'user.auth.resetPassword',
},
},
{
path: '/password-reset',
name: 'user.password-reset.reset',
component: PasswordResetComponent,
meta: {
title: 'user.auth.resetPassword',
},
},
{
path: '/register',
name: 'user.register',
component: RegisterComponent,
meta: {
title: 'user.auth.createAccount',
},
},
{
path: '/user/settings',
name: 'user.settings',
@ -166,31 +122,11 @@ const router = createRouter({
name: 'user.settings.data-export',
component: UserSettingsDataExportComponent,
},
{
path: '/user/settings/deletion',
name: 'user.settings.deletion',
component: UserSettingsDeletionComponent,
},
{
path: '/user/settings/email-update',
name: 'user.settings.email-update',
component: UserSettingsEmailUpdateComponent,
},
{
path: '/user/settings/general',
name: 'user.settings.general',
component: UserSettingsGeneralComponent,
},
{
path: '/user/settings/password-update',
name: 'user.settings.password-update',
component: UserSettingsPasswordUpdateComponent,
},
{
path: '/user/settings/totp',
name: 'user.settings.totp',
component: UserSettingsTOTPComponent,
},
],
},
{
@ -289,73 +225,6 @@ const router = createRouter({
showAsModal: true,
},
},
{
path: '/projects/:projectId/settings/edit',
name: 'project.settings.edit',
component: ProjectSettingEdit,
props: route => ({ projectId: Number(route.params.projectId as string) }),
meta: {
showAsModal: true,
},
},
{
path: '/projects/:projectId/settings/background',
name: 'project.settings.background',
component: ProjectSettingBackground,
meta: {
showAsModal: true,
},
},
{
path: '/projects/:projectId/settings/duplicate',
name: 'project.settings.duplicate',
component: ProjectSettingDuplicate,
meta: {
showAsModal: true,
},
},
{
path: '/projects/:projectId/settings/share',
name: 'project.settings.share',
component: ProjectSettingShare,
meta: {
showAsModal: true,
},
},
{
path: '/projects/:projectId/settings/delete',
name: 'project.settings.delete',
component: ProjectSettingDelete,
meta: {
showAsModal: true,
},
},
{
path: '/projects/:projectId/settings/archive',
name: 'project.settings.archive',
component: ProjectSettingArchive,
meta: {
showAsModal: true,
},
},
{
path: '/projects/:projectId/settings/edit',
name: 'filter.settings.edit',
component: FilterEdit,
meta: {
showAsModal: true,
},
props: route => ({ projectId: Number(route.params.projectId as string) }),
},
{
path: '/projects/:projectId/settings/delete',
name: 'filter.settings.delete',
component: FilterDelete,
meta: {
showAsModal: true,
},
props: route => ({ projectId: Number(route.params.projectId as string) }),
},
{
path: '/projects/:projectId/info',
name: 'project.info',
@ -464,19 +333,6 @@ const router = createRouter({
code: route.query.code as string,
}),
},
{
path: '/filters/new',
name: 'filters.create',
component: FilterNew,
meta: {
showAsModal: true,
},
},
{
path: '/auth/openid/:provider',
name: 'openid.auth',
component: OpenIdAuth,
},
{
path: '/about',
name: 'about',
@ -485,6 +341,7 @@ const router = createRouter({
],
})
// TODO_OFFLINE Remove this function?
export async function getAuthForRoute(route: RouteLocation) {
const authStore = useAuthStore()
if (authStore.authUser || authStore.authLinkShare) {

View File

@ -4,7 +4,7 @@ import type {Method} from 'axios'
import {objectToSnakeCase} from '@/helpers/case'
import AbstractModel from '@/models/abstractModel'
import type {IAbstract} from '@/modelTypes/IAbstract'
import type {Right} from '@/constants/rights'
import {RIGHTS} from '@/constants/rights'
interface Paths {
create : string
@ -268,12 +268,34 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
* @param params Optional query parameters
*/
get(model : Model, params = {}) {
if (this._get) {
return this.get_Offline(...arguments);
}
if (this.paths.get === '') {
throw new Error('This model is not able to get data.')
}
return this.getM(this.paths.get, model, params)
}
async get_Offline(model : Model, params = {}) {
const cancel = this.setLoading()
model = this.beforeGet(model)
try {
// TODO_OFFLINE `_get` should throw if it can't properly handle all the arguments
// so that I can more easily find unsupported usages in the code.
const modelInitObj = await this._get(...arguments);
const result = this.modelGetFactory(modelInitObj)
// TODO_OFFLINE what does this do?
result.maxRight = RIGHTS.READ_WRITE
return result
} finally {
cancel()
}
}
abstract _get?: (model : Model, params) => PromiseLike<Model> | Model
/**
* This is a more abstract implementation which only does a get request.
@ -313,6 +335,12 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
* @param page The page to get
*/
async getAll(model : Model = new AbstractModel({}), params = {}, page = 1): Promise<Model[]> {
// TODO_OFFLINE this is a condition for debugging getAll_Offline will replace `getALl`
// when we're done defining `abstract _getAll`.
if (this._getAll) {
return this.getAll_Offline(...arguments)
}
if (this.paths.getAll === '') {
throw new Error('This model is not able to get data.')
}
@ -337,12 +365,45 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
cancel()
}
}
// TODO_OFFLINE warn if arguments were provided such that we can find the places
// where we're supposed to depend on them.
async getAll_Offline(model : Model = new AbstractModel({}), params = {}, page = 1): Promise<Model[]> {
// params.page = page
const cancel = this.setLoading()
// model = this.beforeGet(model)
// const finalUrl = this.getReplacedRoute(this.paths.getAll, model)
try {
// const response = await this.http.get(finalUrl, {params: prepareParams(params)})
// this.resultCount = Number(response.headers['x-pagination-result-count'])
// this.totalPages = Number(response.headers['x-pagination-total-pages'])
const modelInitObjects = await this._getAll(...arguments)
this.totalPages = 1 // TODO_OFFLINE?
this.resultCount = modelInitObjects.length
return modelInitObjects.map(entry => this.modelGetAllFactory(entry))
} finally {
cancel()
}
}
// TODO_OFFLINE what if service doesn't implements `getAll`? Need some composition I guess,
// instead of just throwing.
// Also a better name?
// abstract _getAll(): PromiseLike<Model[]> | Model[]
abstract _getAll?: () => PromiseLike<Model[]> | Model[]
/**
* Performs a put request to the url specified before
* @returns {Promise<any | never>}
*/
async create(model : Model) {
if (this._create) {
return this.create_Offline(...arguments)
}
if (this.paths.create === '') {
throw new Error('This model is not able to create data.')
}
@ -361,6 +422,30 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
cancel()
}
}
async create_Offline(model : Model) {
const cancel = this.setLoading()
try {
// As in http interceptors above.
// For interaction with a real backend we'd convert to snake case here,
// but locally we can store everything in the camel case format, which is used
// everywhere in the app, unlike snake case.
const toSend = this.beforeCreate(model)
const modelInitObj = await this._create(toSend)
const resultModel = this.modelCreateFactory(modelInitObj)
// This doesn't actually do anything currently, I think, since we're just copying
// whatever was passed to `localStorage`
if (typeof model.maxRight !== 'undefined') {
resultModel.maxRight = model.maxRight
}
return resultModel
} finally {
cancel()
}
}
abstract _create(model: Model)
/**
* An abstract implementation to send post requests.
@ -385,6 +470,10 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
* Performs a post request to the update url
*/
update(model : Model) {
if (this._update) {
return this.update_Offline(...arguments)
}
if (this.paths.update === '') {
throw new Error('This model is not able to update data.')
}
@ -392,11 +481,34 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
const finalUrl = this.getReplacedRoute(this.paths.update, model)
return this.post(finalUrl, model)
}
async update_Offline(model: Model) {
const cancel = this.setLoading()
try {
const toSend = this.beforeUpdate(model)
const modelInitObj = await this._update(toSend)
const newModel = this.modelUpdateFactory(modelInitObj)
if (typeof model.maxRight !== 'undefined') {
newModel.maxRight = model.maxRight
}
return newModel
} finally {
cancel()
}
}
abstract _update(model: unknown)
/**
* Performs a delete request to the update url
*/
async delete(model : Model) {
if (this._delete) {
return this.delete_Offline(...arguments)
}
if (this.paths.delete === '') {
throw new Error('This model is not able to delete data.')
}
@ -411,6 +523,24 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
cancel()
}
}
async delete_Offline(model: Model) {
if (this.paths.delete === '') {
throw new Error('This model is not able to delete data.')
}
const cancel = this.setLoading()
try {
const toSend = this.beforeDelete(model)
const data = await this._delete(toSend)
// TODO_OFFLINE what is it supposed to return??
return data
} finally {
cancel()
}
}
abstract _delete(model: unknown)
/**
* Uploads a file to a url.

View File

@ -1,15 +0,0 @@
import AbstractService from './abstractService'
export default class AccountDeleteService extends AbstractService {
request(password: string) {
return this.post('/user/deletion/request', {password})
}
confirm(token: string) {
return this.post('/user/deletion/confirm', {token})
}
cancel(password: string) {
return this.post('/user/deletion/cancel', {password})
}
}

View File

@ -1,30 +0,0 @@
import AbstractService from './abstractService'
import BackgroundImageModel from '../models/backgroundImage'
import ProjectModel from '@/models/project'
import type { IBackgroundImage } from '@/modelTypes/IBackgroundImage'
export default class BackgroundUnsplashService extends AbstractService<IBackgroundImage> {
constructor() {
super({
getAll: '/backgrounds/unsplash/search',
update: '/projects/{projectId}/backgrounds/unsplash',
})
}
modelFactory(data: Partial<IBackgroundImage>) {
return new BackgroundImageModel(data)
}
modelUpdateFactory(data) {
return new ProjectModel(data)
}
async thumb(model) {
const response = await this.http({
url: `/backgrounds/unsplash/images/${model.id}/thumb`,
method: 'GET',
responseType: 'blob',
})
return window.URL.createObjectURL(new Blob([response.data]))
}
}

View File

@ -2,6 +2,7 @@ import AbstractService from './abstractService'
import BucketModel from '../models/bucket'
import TaskService from '@/services/task'
import type { IBucket } from '@/modelTypes/IBucket'
import { createBucket, deleteBucket, getAllBucketsOfProject, updateBucket } from '@/localBackend/buckets'
export default class BucketService extends AbstractService<IBucket> {
constructor() {
@ -13,6 +14,11 @@ export default class BucketService extends AbstractService<IBucket> {
})
}
_getAll = ({ projectId }: { projectId: number }) => getAllBucketsOfProject(projectId)
_create = (bucket: IBucket) => createBucket(bucket)
_update = (bucket: IBucket) => updateBucket(bucket)
_delete = (bucket: IBucket) => deleteBucket(bucket)
modelFactory(data: Partial<IBucket>) {
return new BucketModel(data)
}

View File

@ -1,9 +0,0 @@
import AbstractService from './abstractService'
export default class EmailUpdateService extends AbstractService {
constructor() {
super({
update: '/user/settings/email',
})
}
}

View File

@ -3,17 +3,71 @@ import LabelModel from '@/models/label'
import type {ILabel} from '@/modelTypes/ILabel'
import {colorFromHex} from '@/helpers/color/colorFromHex'
function getAllLabels() {
return [
{
id: 1,
title: 'label',
description: '',
hexColor: 'e8e8e8',
createdBy: {
id: 1,
name: '',
username: 'demo',
created: '2021-05-30T10:45:25+02:00',
updated: '2023-03-06T14:10:00+01:00',
},
created: '2021-05-30T10:45:46+02:00',
updated: '2021-05-30T10:45:46+02:00',
},
{
id: 2,
title: 'abc',
description: '',
hexColor: 'e8e8e8',
createdBy: {
id: 1,
name: '',
username: 'demo',
created: '2021-05-30T10:45:25+02:00',
updated: '2023-03-06T14:10:00+01:00',
},
created: '2023-03-06T10:37:31+01:00',
updated: '2023-03-06T10:37:31+01:00',
},
{
id: 5,
title: 'aa',
description: '',
hexColor: 'e8e8e8',
createdBy: {
id: 1,
name: '',
username: 'demo',
created: '2021-05-30T10:45:25+02:00',
updated: '2023-03-06T14:10:00+01:00',
},
created: '2023-03-06T14:11:53+01:00',
updated: '2023-03-06T14:11:53+01:00',
},
]
}
export default class LabelService extends AbstractService<ILabel> {
constructor() {
super({
create: '/labels',
// create: '/labels',
getAll: '/labels',
get: '/labels/{id}',
update: '/labels/{id}',
delete: '/labels/{id}',
// get: '/labels/{id}',
// update: '/labels/{id}',
// delete: '/labels/{id}',
})
}
_getAll = () => {
return getAllLabels()
}
processModel(label) {
label.created = new Date(label.created).toISOString()
label.updated = new Date(label.updated).toISOString()

View File

@ -14,6 +14,55 @@ export default class NamespaceService extends AbstractService<INamespace> {
})
}
_getAll: () => INamespace[] = () => {
// TODO_OFFLINE
return [
{
id: 1,
title: 'demo',
description: 'demo\'s namespace.',
hexColor: '',
isArchived: false,
owner: {
id: 1,
name: '',
username: 'demo',
email: 'demo@vikunja.io',
created: '0001-01-01T00:00:00Z',
updated: '0001-01-01T00:00:00Z',
},
created: '2021-05-30T10:45:25+02:00',
updated: '2021-05-30T10:45:25+02:00',
// TODO_OFFLINE we also define the same project in the `projects` service
projects: [
{
id: 1,
title: 'Project',
description: '',
identifier: '',
hexColor: '',
namespaceId: 1,
owner: {
id: 1,
name: '',
username: 'demo',
email: 'demo@vikunja.io',
created: '2021-05-30T10:45:25+02:00',
updated: '2023-03-06T06:59:06+01:00',
},
isArchived: false,
background_information: null,
background_blur_hash: '',
is_favorite: false,
position: 65536,
created: '2021-05-30T10:45:30+02:00',
updated: '2023-03-06T09:24:43+01:00',
},
],
},
]
}
modelFactory(data) {
return new NamespaceModel(data)
}

View File

@ -1,38 +0,0 @@
import AbstractService from './abstractService'
import PasswordResetModel from '@/models/passwordReset'
import type {IPasswordReset} from '@/modelTypes/IPasswordReset'
export default class PasswordResetService extends AbstractService<IPasswordReset> {
constructor() {
super({})
this.paths = {
reset: '/user/password/reset',
requestReset: '/user/password/token',
}
}
modelFactory(data) {
return new PasswordResetModel(data)
}
async resetPassword(model) {
const cancel = this.setLoading()
try {
const response = await this.http.post(this.paths.reset, model)
return this.modelFactory(response.data)
} finally {
cancel()
}
}
async requestResetPassword(model) {
const cancel = this.setLoading()
try {
const response = await this.http.post(this.paths.requestReset, model)
return this.modelFactory(response.data)
} finally {
cancel()
}
}
}

View File

@ -1,10 +0,0 @@
import AbstractService from './abstractService'
import type {IPasswordUpdate} from '@/modelTypes/IPasswordUpdate'
export default class PasswordUpdateService extends AbstractService<IPasswordUpdate> {
constructor() {
super({
update: '/user/password',
})
}
}

View File

@ -4,17 +4,53 @@ import type {IProject} from '@/modelTypes/IProject'
import TaskService from './task'
import {colorFromHex} from '@/helpers/color/colorFromHex'
function getAllProjects() {
return [
{
id: 1,
title: 'Project',
description: '',
identifier: '',
hexColor: '',
namespaceId: 1,
owner: {
id: 1,
name: '',
username: 'demo',
created: '2021-05-30T10:45:25+02:00',
updated: '2023-03-06T12:49:35+01:00',
},
isArchived: false,
backgroundInformation: null,
backgroundBlurHash: '',
isFavorite: false,
position: 65536,
created: '2021-05-30T10:45:30+02:00',
updated: '2023-03-06T13:38:43+01:00',
},
]
}
export default class ProjectService extends AbstractService<IProject> {
constructor() {
super({
create: '/namespaces/{namespaceId}/projects',
// create: '/namespaces/{namespaceId}/projects',
get: '/projects/{id}',
getAll: '/projects',
update: '/projects/{id}',
delete: '/projects/{id}',
// update: '/projects/{id}',
// delete: '/projects/{id}',
})
}
_getAll: () => IProject[] = () => {
return getAllProjects()
}
_get = (model: IProject, params: Record<string, string>) => {
// TODO_OFFLINE throw if `id` is not the only query parameter, for easier debugging during this
// prototyping.
return getAllProjects().find(p => p.id === model.id)
}
modelFactory(data) {
return new ProjectModel(data)
}

View File

@ -6,6 +6,7 @@ import LabelService from './label'
import {colorFromHex} from '@/helpers/color/colorFromHex'
import {SECONDS_A_DAY, SECONDS_A_HOUR, SECONDS_A_WEEK, SECONDS_A_MONTH, SECONDS_A_YEAR} from '@/constants/date'
import { createTask, deleteTask, getAllTasks, getTask, updateTask } from '@/localBackend/tasks'
const parseDate = date => {
if (date) {
@ -26,6 +27,12 @@ export default class TaskService extends AbstractService<ITask> {
})
}
_getAll = () => getAllTasks()
_get = (model: ITask) => getTask(model.id)
_create = (taskData: ITask) => createTask(taskData)
_update = (model: ITask) => updateTask(model)
_delete = (model: ITask) => deleteTask(model)
modelFactory(data) {
return new TaskModel(data)
}

View File

@ -2,6 +2,7 @@ import AbstractService from '@/services/abstractService'
import TaskModel from '@/models/task'
import type {ITask} from '@/modelTypes/ITask'
import { getTasksOfProject } from '@/localBackend/tasks'
// FIXME: unite with other filter params types
export interface GetAllTasksParams {
@ -21,6 +22,12 @@ export default class TaskCollectionService extends AbstractService<ITask> {
})
}
// _getAll(): ITask[] {
_getAll: (model: { projectId: number }) => ITask[] = ({ projectId }) => {
// TODO_OFFLINE
return getTasksOfProject(projectId)
}
modelFactory(data) {
return new TaskModel(data)
}

View File

@ -1,38 +0,0 @@
import AbstractService from './abstractService'
import TotpModel from '@/models/totp'
import type {ITotp} from '@/modelTypes/ITotp'
export default class TotpService extends AbstractService<ITotp> {
urlPrefix = '/user/settings/totp'
constructor() {
super({})
this.paths.get = this.urlPrefix
}
modelFactory(data) {
return new TotpModel(data)
}
enroll() {
return this.post(`${this.urlPrefix}/enroll`, {})
}
enable(model) {
return this.post(`${this.urlPrefix}/enable`, model)
}
disable(model) {
return this.post(`${this.urlPrefix}/disable`, model)
}
async qrcode() {
const response = await this.http({
url: `${this.urlPrefix}/qrcode`,
method: 'GET',
responseType: 'blob',
})
return new Blob([response.data])
}
}

View File

@ -208,22 +208,8 @@ export const useAuthStore = defineStore('auth', () => {
return
}
const jwt = getToken()
let isAuthenticated = false
if (jwt) {
const base64 = jwt
.split('.')[1]
.replace('-', '+')
.replace('_', '/')
const info = new UserModel(JSON.parse(atob(base64)))
const ts = Math.round((new Date()).getTime() / MILLISECONDS_A_SECOND)
isAuthenticated = info.exp >= ts
setUser(info)
if (isAuthenticated) {
await refreshUserInfo()
}
}
const isAuthenticated = true
await refreshUserInfo()
setAuthenticated(isAuthenticated)
if (!isAuthenticated) {
@ -235,19 +221,35 @@ export const useAuthStore = defineStore('auth', () => {
}
async function refreshUserInfo() {
const jwt = getToken()
if (!jwt) {
return
}
const HTTP = AuthenticatedHTTPFactory()
try {
const response = await HTTP.get('user')
// TODO_OFFLINE
const newUser = new UserModel({
...response.data,
...(info.value?.type && {type: info.value?.type}),
...(info.value?.email && {email: info.value?.email}),
...(info.value?.exp && {exp: info.value?.exp}),
maxRight: null,
id: 1,
email: 'demo@vikunja.io',
username: 'demo',
name: '',
// TODO_OFFLINE how do we make it never expire?
exp: 1678108752,
type: 1,
created: '2021-05-30T08:45:25.000Z',
updated: '2023-03-03T10:50:28.000Z',
settings: {
maxRight: null,
name: '',
emailRemindersEnabled: false,
discoverableByName: false,
discoverableByEmail: false,
overdueTasksRemindersEnabled: false,
overdueTasksRemindersTime: '09:00',
// TODO_OFFLINE this is 0 by default.
defaultProjectId: 1,
weekStart: 1,
timezone: 'Europe/Moscow',
language: 'en',
},
isLocalUser: true,
deletionScheduledAt: '0001-01-01T00:00:00Z',
})
setUser(newUser)

View File

@ -80,10 +80,48 @@ export const useConfigStore = defineStore('config', () => {
Object.assign(state, config)
}
async function update(): Promise<boolean> {
const HTTP = HTTPFactory()
const {data: config} = await HTTP.get('info')
setConfig(objectToCamelCase(config))
const success = !!config
// TODO_OFFLINE it's just a stub currently
setConfig({
version: 'None whatsoever',
frontendUrl: 'https://try.vikunja.io/',
motd: '',
linkSharingEnabled: false,
maxFileSize: '20MB',
registrationEnabled: false,
availableMigrators: [
'vikunja-file',
// These require the internet.
// 'ticktick',
// 'todoist',
],
taskAttachmentsEnabled: false,
enabledBackgroundProviders: [
'upload',
// 'unsplash',
],
totpEnabled: false,
legal: {
imprintUrl: '',
privacyPolicyUrl: '',
},
// TODO_OFFLINE implement
caldavEnabled: false,
auth: {
local: {
enabled: true,
},
openidConnect: {
enabled: false,
redirectUrl: 'https://try.vikunja.io/auth/openid/',
providers: null,
},
},
emailRemindersEnabled: false,
userDeletionEnabled: false,
// TODO_OFFLINE implement comments
taskCommentsEnabled: false,
})
const success = true
return success
}

View File

@ -22,9 +22,7 @@
<x-button @click="setDefaultFilters">Reset</x-button>
</div>
</div>
<fancycheckbox class="is-block" v-model="filters.showTasksWithoutDates">
{{ $t('project.gantt.showTasksWithoutDates') }}
</fancycheckbox>
<div></div>
</div>
</card>
</template>

View File

@ -5,11 +5,6 @@
viewName="kanban"
>
<template #header>
<div class="filter-container" v-if="!isSavedFilter(project)">
<div class="items">
<filter-popup v-model="params" />
</div>
</div>
</template>
<template #default>

View File

@ -1,50 +1,6 @@
<template>
<ProjectWrapper class="project-list" :project-id="projectId" viewName="project">
<template #header>
<div
class="filter-container"
v-if="!isSavedFilter(project)"
>
<div class="items">
<div class="search">
<div :class="{ hidden: !showTaskSearch }" class="field has-addons">
<div class="control has-icons-left has-icons-right">
<input
@blur="hideSearchBar()"
@keyup.enter="searchTasks"
class="input"
:placeholder="$t('misc.search')"
type="text"
v-focus
v-model="searchTerm"
/>
<span class="icon is-left">
<icon icon="search"/>
</span>
</div>
<div class="control">
<x-button
:loading="loading"
@click="searchTasks"
:shadow="false"
>
{{ $t('misc.search') }}
</x-button>
</div>
</div>
<x-button
@click="showTaskSearch = !showTaskSearch"
icon="search"
variant="secondary"
v-if="!showTaskSearch"
/>
</div>
<filter-popup
v-model="params"
@update:modelValue="prepareFiltersAndLoadTasks()"
/>
</div>
</div>
</template>
<template #default>
@ -144,24 +100,6 @@ import {useTaskStore} from '@/stores/tasks'
import type {IProject} from '@/modelTypes/IProject'
function sortTasks(tasks: ITask[]) {
if (tasks === null || Array.isArray(tasks) && tasks.length === 0) {
return
}
return tasks.sort((a, b) => {
if (a.done < b.done)
return -1
if (a.done > b.done)
return 1
if (a.position < b.position)
return -1
if (a.position > b.position)
return 1
return 0
})
}
const props = defineProps({
projectId: {
type: Number as PropType<IProject['id']>,
@ -170,7 +108,6 @@ const props = defineProps({
})
const ctaVisible = ref(false)
const showTaskSearch = ref(false)
const drag = ref(false)
const DRAG_OPTIONS = {
@ -178,21 +115,20 @@ const DRAG_OPTIONS = {
ghostClass: 'task-ghost',
} as const
// TODO_OFFLINE load tasks from somewhere else
const {
tasks,
loading,
totalPages,
currentPage,
loadTasks,
searchTerm,
params,
sortByParam,
// searchTerm,
// params,
// sortByParam,
} = useTaskList(toRef(props, 'projectId'), {position: 'asc' })
const isAlphabeticalSorting = computed(() => {
return params.value.sort_by.find(sortBy => sortBy === ALPHABETICAL_SORT) !== undefined
})
const isAlphabeticalSorting = computed(() => false)
const firstNewPosition = computed(() => {
if (tasks.value.length === 0) {
@ -218,29 +154,6 @@ onMounted(async () => {
const route = useRoute()
const router = useRouter()
function searchTasks() {
// Only search if the search term changed
if (route.query as unknown as string === searchTerm.value) {
return
}
router.push({
name: 'project.list',
query: {search: searchTerm.value},
})
}
function hideSearchBar() {
// This is a workaround.
// When clicking on the search button, @blur from the input is fired. If we
// would then directly hide the whole search bar directly, no click event
// from the button gets fired. To prevent this, we wait 200ms until we hide
// everything so the button has a chance of firing the search event.
setTimeout(() => {
showTaskSearch.value = false
}, 200)
}
const addTaskRef = ref<typeof AddTask | null>(null)
function focusNewTaskInput() {
addTaskRef.value?.focusTaskInput()
@ -268,8 +181,6 @@ function updateTasks(updatedTask: ITask) {
break
}
}
// FIXME: Use computed
sortTasks(tasks.value)
}
async function saveTaskPosition(e) {
@ -287,15 +198,6 @@ async function saveTaskPosition(e) {
const updatedTask = await taskStore.update(newTask)
tasks.value[e.newIndex] = updatedTask
}
function prepareFiltersAndLoadTasks() {
if(isAlphabeticalSorting.value) {
sortByParam.value = {}
sortByParam.value[ALPHABETICAL_SORT] = 'asc'
}
loadTasks()
}
</script>
<style lang="scss" scoped>

View File

@ -1,63 +1,6 @@
<template>
<ProjectWrapper class="project-table" :project-id="projectId" viewName="table">
<template #header>
<div class="filter-container">
<div class="items">
<popup>
<template #trigger="{toggle}">
<x-button
@click.prevent.stop="toggle()"
icon="th"
variant="secondary"
>
{{ $t('project.table.columns') }}
</x-button>
</template>
<template #content="{isOpen}">
<card class="columns-filter" :class="{'is-open': isOpen}">
<fancycheckbox v-model="activeColumns.index">#</fancycheckbox>
<fancycheckbox v-model="activeColumns.done">
{{ $t('task.attributes.done') }}
</fancycheckbox>
<fancycheckbox v-model="activeColumns.title">
{{ $t('task.attributes.title') }}
</fancycheckbox>
<fancycheckbox v-model="activeColumns.priority">
{{ $t('task.attributes.priority') }}
</fancycheckbox>
<fancycheckbox v-model="activeColumns.labels">
{{ $t('task.attributes.labels') }}
</fancycheckbox>
<fancycheckbox v-model="activeColumns.assignees">
{{ $t('task.attributes.assignees') }}
</fancycheckbox>
<fancycheckbox v-model="activeColumns.dueDate">
{{ $t('task.attributes.dueDate') }}
</fancycheckbox>
<fancycheckbox v-model="activeColumns.startDate">
{{ $t('task.attributes.startDate') }}
</fancycheckbox>
<fancycheckbox v-model="activeColumns.endDate">
{{ $t('task.attributes.endDate') }}
</fancycheckbox>
<fancycheckbox v-model="activeColumns.percentDone">
{{ $t('task.attributes.percentDone') }}
</fancycheckbox>
<fancycheckbox v-model="activeColumns.created">
{{ $t('task.attributes.created') }}
</fancycheckbox>
<fancycheckbox v-model="activeColumns.updated">
{{ $t('task.attributes.updated') }}
</fancycheckbox>
<fancycheckbox v-model="activeColumns.createdBy">
{{ $t('task.attributes.createdBy') }}
</fancycheckbox>
</card>
</template>
</popup>
<filter-popup v-model="params"/>
</div>
</div>
</template>
<template #default>
@ -69,19 +12,15 @@
<tr>
<th v-if="activeColumns.index">
#
<Sort :order="sortBy.index" @click="sort('index')"/>
</th>
<th v-if="activeColumns.done">
{{ $t('task.attributes.done') }}
<Sort :order="sortBy.done" @click="sort('done')"/>
</th>
<th v-if="activeColumns.title">
{{ $t('task.attributes.title') }}
<Sort :order="sortBy.title" @click="sort('title')"/>
</th>
<th v-if="activeColumns.priority">
{{ $t('task.attributes.priority') }}
<Sort :order="sortBy.priority" @click="sort('priority')"/>
</th>
<th v-if="activeColumns.labels">
{{ $t('task.attributes.labels') }}
@ -91,27 +30,21 @@
</th>
<th v-if="activeColumns.dueDate">
{{ $t('task.attributes.dueDate') }}
<Sort :order="sortBy.due_date" @click="sort('due_date')"/>
</th>
<th v-if="activeColumns.startDate">
{{ $t('task.attributes.startDate') }}
<Sort :order="sortBy.start_date" @click="sort('start_date')"/>
</th>
<th v-if="activeColumns.endDate">
{{ $t('task.attributes.endDate') }}
<Sort :order="sortBy.end_date" @click="sort('end_date')"/>
</th>
<th v-if="activeColumns.percentDone">
{{ $t('task.attributes.percentDone') }}
<Sort :order="sortBy.percent_done" @click="sort('percent_done')"/>
</th>
<th v-if="activeColumns.created">
{{ $t('task.attributes.created') }}
<Sort :order="sortBy.created" @click="sort('created')"/>
</th>
<th v-if="activeColumns.updated">
{{ $t('task.attributes.updated') }}
<Sort :order="sortBy.updated" @click="sort('updated')"/>
</th>
<th v-if="activeColumns.createdBy">
{{ $t('task.attributes.createdBy') }}
@ -231,35 +164,22 @@ const SORT_BY_DEFAULT: SortBy = {
const activeColumns = useStorage('tableViewColumns', {...ACTIVE_COLUMNS_DEFAULT})
const sortBy = useStorage<SortBy>('tableViewSortBy', {...SORT_BY_DEFAULT})
const taskList = useTaskList(toRef(props, 'projectId'), sortBy.value)
const taskList = useTaskList(toRef(props, 'projectId'))
const {
loading,
params,
// params,
totalPages,
currentPage,
sortByParam,
// sortByParam,
} = taskList
const tasks: Ref<ITask[]> = taskList.tasks
Object.assign(params.value, {
filter_by: [],
filter_value: [],
filter_comparator: [],
})
// FIXME: by doing this we can have multiple sort orders
function sort(property: keyof SortBy) {
const order = sortBy.value[property]
if (typeof order === 'undefined' || order === 'none') {
sortBy.value[property] = 'desc'
} else if (order === 'desc') {
sortBy.value[property] = 'asc'
} else {
delete sortBy.value[property]
}
sortByParam.value = sortBy.value
}
// Object.assign(params.value, {
// filter_by: [],
// filter_value: [],
// filter_comparator: [],
// })
// TODO: re-enable opening task detail in modal
// const router = useRouter()

View File

@ -279,8 +279,6 @@
</div>
</div>
<!-- Comments -->
<comments :can-write="canWrite" :task-id="taskId"/>
</div>
<div class="column is-one-third action-buttons d-print-none" v-if="canWrite || isModal">
<template v-if="canWrite">
@ -295,29 +293,6 @@
>
{{ task.done ? $t('task.detail.undone') : $t('task.detail.done') }}
</x-button>
<task-subscription
entity="task"
:entity-id="task.id"
:model-value="task.subscription"
@update:model-value="sub => task.subscription = sub"
/>
<x-button
@click="setFieldActive('assignees')"
variant="secondary"
v-shortcut="'a'"
v-cy="'taskDetail.assign'"
>
<span class="icon is-small"><icon icon="users"/></span>
{{ $t('task.detail.actions.assign') }}
</x-button>
<x-button
@click="setFieldActive('labels')"
variant="secondary"
icon="tags"
v-shortcut="'l'"
>
{{ $t('task.detail.actions.label') }}
</x-button>
<x-button
@click="setFieldActive('priority')"
variant="secondary"
@ -347,21 +322,6 @@
>
{{ $t('task.detail.actions.endDate') }}
</x-button>
<x-button
@click="setFieldActive('reminders')"
variant="secondary"
:icon="['far', 'clock']"
v-shortcut="'Alt+r'"
>
{{ $t('task.detail.actions.reminders') }}
</x-button>
<x-button
@click="setFieldActive('repeatAfter')"
variant="secondary"
icon="history"
>
{{ $t('task.detail.actions.repeatAfter') }}
</x-button>
<x-button
@click="setFieldActive('percentDone')"
variant="secondary"
@ -369,30 +329,6 @@
>
{{ $t('task.detail.actions.percentDone') }}
</x-button>
<x-button
@click="setFieldActive('attachments')"
variant="secondary"
icon="paperclip"
v-shortcut="'f'"
>
{{ $t('task.detail.actions.attachments') }}
</x-button>
<x-button
@click="setFieldActive('relatedTasks')"
variant="secondary"
icon="sitemap"
v-shortcut="'r'"
>
{{ $t('task.detail.actions.relatedTasks') }}
</x-button>
<x-button
@click="setFieldActive('moveProject')"
variant="secondary"
icon="list"
v-shortcut="'m'"
>
{{ $t('task.detail.actions.moveProject') }}
</x-button>
<x-button
@click="setFieldActive('color')"
variant="secondary"

View File

@ -1,219 +0,0 @@
<template>
<div>
<message variant="success" text-align="center" class="mb-4" v-if="confirmedEmailSuccess">
{{ $t('user.auth.confirmEmailSuccess') }}
</message>
<message variant="danger" v-if="errorMessage" class="mb-4">
{{ errorMessage }}
</message>
<form @submit.prevent="submit" id="loginform" v-if="localAuthEnabled">
<div class="field">
<label class="label" for="username">{{ $t('user.auth.usernameEmail') }}</label>
<div class="control">
<input
class="input" id="username"
name="username"
:placeholder="$t('user.auth.usernamePlaceholder')"
ref="usernameRef"
required
type="text"
autocomplete="username"
v-focus
@keyup.enter="submit"
tabindex="1"
@focusout="validateUsernameField()"
/>
</div>
<p class="help is-danger" v-if="!usernameValid">
{{ $t('user.auth.usernameRequired') }}
</p>
</div>
<div class="field">
<div class="label-with-link">
<label class="label" for="password">{{ $t('user.auth.password') }}</label>
<router-link
:to="{ name: 'user.password-reset.request' }"
class="reset-password-link"
tabindex="6"
>
{{ $t('user.auth.forgotPassword') }}
</router-link>
</div>
<Password tabindex="2" @submit="submit" v-model="password" :validate-initially="validatePasswordInitially"/>
</div>
<div class="field" v-if="needsTotpPasscode">
<label class="label" for="totpPasscode">{{ $t('user.auth.totpTitle') }}</label>
<div class="control">
<input
autocomplete="one-time-code"
class="input"
id="totpPasscode"
:placeholder="$t('user.auth.totpPlaceholder')"
ref="totpPasscode"
required
type="text"
v-focus
@keyup.enter="submit"
tabindex="3"
inputmode="numeric"
/>
</div>
</div>
<div class="field">
<label class="label">
<input type="checkbox" v-model="rememberMe" class="mr-1"/>
{{ $t('user.auth.remember') }}
</label>
</div>
<x-button
@click="submit"
:loading="isLoading"
tabindex="4"
>
{{ $t('user.auth.login') }}
</x-button>
<p class="mt-2" v-if="registrationEnabled">
{{ $t('user.auth.noAccountYet') }}
<router-link
:to="{ name: 'user.register' }"
type="secondary"
tabindex="5"
>
{{ $t('user.auth.createAccount') }}
</router-link>
</p>
</form>
<div
v-if="hasOpenIdProviders"
class="mt-4">
<x-button
v-for="(p, k) in openidConnect.providers"
:key="k"
@click="redirectToProvider(p)"
variant="secondary"
class="is-fullwidth mt-2"
>
{{ $t('user.auth.loginWith', {provider: p.name}) }}
</x-button>
</div>
</div>
</template>
<script setup lang="ts">
import {computed, onBeforeMount, ref} from 'vue'
import {useI18n} from 'vue-i18n'
import {useDebounceFn} from '@vueuse/core'
import Message from '@/components/misc/message.vue'
import Password from '@/components/input/password.vue'
import {getErrorText} from '@/message'
import {redirectToProvider} from '@/helpers/redirectToProvider'
import {useRedirectToLastVisited} from '@/composables/useRedirectToLastVisited'
import {useAuthStore} from '@/stores/auth'
import {useConfigStore} from '@/stores/config'
import {useTitle} from '@/composables/useTitle'
const {t} = useI18n({useScope: 'global'})
useTitle(() => t('user.auth.login'))
const authStore = useAuthStore()
const configStore = useConfigStore()
const {redirectIfSaved} = useRedirectToLastVisited()
const registrationEnabled = computed(() => configStore.registrationEnabled)
const localAuthEnabled = computed(() => configStore.auth.local.enabled)
const openidConnect = computed(() => configStore.auth.openidConnect)
const hasOpenIdProviders = computed(() => openidConnect.value.enabled && openidConnect.value.providers?.length > 0)
const isLoading = computed(() => authStore.isLoading)
const confirmedEmailSuccess = ref(false)
const errorMessage = ref('')
const password = ref('')
const validatePasswordInitially = ref(false)
const rememberMe = ref(false)
const authenticated = computed(() => authStore.authenticated)
onBeforeMount(() => {
authStore.verifyEmail().then((confirmed) => {
confirmedEmailSuccess.value = confirmed
}).catch((e: Error) => {
errorMessage.value = e.message
})
// Check if the user is already logged in, if so, redirect them to the homepage
if (authenticated.value) {
redirectIfSaved()
}
})
const usernameValid = ref(true)
const usernameRef = ref<HTMLInputElement | null>(null)
const validateUsernameField = useDebounceFn(() => {
usernameValid.value = usernameRef.value?.value !== ''
}, 100)
const needsTotpPasscode = computed(() => authStore.needsTotpPasscode)
const totpPasscode = ref<HTMLInputElement | null>(null)
async function submit() {
errorMessage.value = ''
// Some browsers prevent Vue bindings from working with autofilled values.
// To work around this, we're manually getting the values here instead of relying on vue bindings.
// For more info, see https://kolaente.dev/vikunja/frontend/issues/78
const credentials = {
username: usernameRef.value?.value,
password: password.value,
longToken: rememberMe.value,
}
if (credentials.username === '' || credentials.password === '') {
// Trigger the validation error messages
validateUsernameField()
validatePasswordInitially.value = true
return
}
if (needsTotpPasscode.value) {
credentials.totpPasscode = totpPasscode.value?.value
}
try {
await authStore.login(credentials)
authStore.setNeedsTotpPasscode(false)
} catch (e) {
if (e.response?.data.code === 1017 && !credentials.totpPasscode) {
return
}
errorMessage.value = getErrorText(e)
}
}
</script>
<style lang="scss" scoped>
.button {
margin: 0 0.4rem 0 0;
}
.reset-password-link {
display: inline-block;
}
.label-with-link {
display: flex;
justify-content: space-between;
margin-bottom: .5rem;
.label {
margin-bottom: 0;
}
}
</style>

View File

@ -1,86 +0,0 @@
<template>
<div>
<message variant="danger" v-if="errorMessage">
{{ errorMessage }}
</message>
<message variant="danger" v-if="errorMessageFromQuery" class="mt-2">
{{ errorMessageFromQuery }}
</message>
<message v-if="loading">
{{ $t('user.auth.authenticating') }}
</message>
</div>
</template>
<script lang="ts">
export default { name: 'Auth' }
</script>
<script setup lang="ts">
import {ref, computed, onMounted} from 'vue'
import {useRoute} from 'vue-router'
import {useI18n} from 'vue-i18n'
import {getErrorText} from '@/message'
import Message from '@/components/misc/message.vue'
import {useRedirectToLastVisited} from '@/composables/useRedirectToLastVisited'
import {useAuthStore} from '@/stores/auth'
const {t} = useI18n({useScope: 'global'})
const route = useRoute()
const {redirectIfSaved} = useRedirectToLastVisited()
const authStore = useAuthStore()
const loading = computed(() => authStore.isLoading)
const errorMessage = ref('')
const errorMessageFromQuery = computed(() => route.query.error)
async function authenticateWithCode() {
// This component gets mounted twice: The first time when the actual auth request hits the frontend,
// the second time after that auth request succeeded and the outer component "content-no-auth" isn't used
// but instead the "content-auth" component is used. Because this component is just a route and thus
// gets mounted as part of a <router-view/> which both the content-auth and content-no-auth components have,
// this re-mounts the component, even if the user is already authenticated.
// To make sure we only try to authenticate the user once, we set this "authenticating" lock in localStorage
// which ensures only one auth request is done at a time. We don't simply check if the user is already
// authenticated to not prevent the whole authentication if some user is already logged in.
if (localStorage.getItem('authenticating')) {
return
}
localStorage.setItem('authenticating', 'true')
errorMessage.value = ''
if (typeof route.query.error !== 'undefined') {
localStorage.removeItem('authenticating')
errorMessage.value = typeof route.query.message !== 'undefined'
? route.query.message as string
: t('user.auth.openIdGeneralError')
return
}
const state = localStorage.getItem('state')
if (typeof route.query.state === 'undefined' || route.query.state !== state) {
localStorage.removeItem('authenticating')
errorMessage.value = t('user.auth.openIdStateError')
return
}
try {
await authStore.openIdAuth({
provider: route.params.provider,
code: route.query.code,
})
redirectIfSaved()
} catch(e) {
errorMessage.value = getErrorText(e)
} finally {
localStorage.removeItem('authenticating')
}
}
onMounted(() => authenticateWithCode())
</script>

View File

@ -1,72 +0,0 @@
<template>
<div>
<message v-if="errorMsg" class="mb-4">
{{ errorMsg }}
</message>
<div class="has-text-centered mb-4" v-if="successMessage">
<message variant="success">
{{ successMessage }}
</message>
<x-button :to="{ name: 'user.login' }" class="mt-4">
{{ $t('user.auth.login') }}
</x-button>
</div>
<form @submit.prevent="submit" id="form" v-if="!successMessage">
<div class="field">
<label class="label" for="password">{{ $t('user.auth.password') }}</label>
<Password @submit="submit" @update:modelValue="v => credentials.password = v"/>
</div>
<div class="field is-grouped">
<div class="control">
<x-button
:loading="passwordResetService.loading"
@click="submit"
>
{{ $t('user.auth.resetPassword') }}
</x-button>
</div>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import {ref, reactive} from 'vue'
import PasswordResetModel from '@/models/passwordReset'
import PasswordResetService from '@/services/passwordReset'
import Message from '@/components/misc/message.vue'
import Password from '@/components/input/password.vue'
const credentials = reactive({
password: '',
})
const passwordResetService = reactive(new PasswordResetService())
const errorMsg = ref('')
const successMessage = ref('')
async function submit() {
errorMsg.value = ''
if(credentials.password === '') {
return
}
const passwordReset = new PasswordResetModel({newPassword: credentials.password})
try {
const {message} = await passwordResetService.resetPassword(passwordReset)
successMessage.value = message
localStorage.removeItem('passwordResetToken')
} catch (e) {
errorMsg.value = e.response.data.message
}
}
</script>
<style scoped>
.button {
margin: 0 0.4rem 0 0;
}
</style>

View File

@ -1,137 +0,0 @@
<template>
<div>
<message variant="danger" v-if="errorMessage !== ''" class="mb-4">
{{ errorMessage }}
</message>
<form @submit.prevent="submit" id="registerform">
<div class="field">
<label class="label" for="username">{{ $t('user.auth.username') }}</label>
<div class="control">
<input
class="input"
id="username"
name="username"
:placeholder="$t('user.auth.usernamePlaceholder')"
required
type="text"
autocomplete="username"
v-focus
v-model="credentials.username"
@keyup.enter="submit"
@focusout="validateUsername"
/>
</div>
<p class="help is-danger" v-if="!usernameValid">
{{ $t('user.auth.usernameRequired') }}
</p>
</div>
<div class="field">
<label class="label" for="email">{{ $t('user.auth.email') }}</label>
<div class="control">
<input
class="input"
id="email"
name="email"
:placeholder="$t('user.auth.emailPlaceholder')"
required
type="email"
v-model="credentials.email"
@keyup.enter="submit"
@focusout="validateEmail"
/>
</div>
<p class="help is-danger" v-if="!emailValid">
{{ $t('user.auth.emailInvalid') }}
</p>
</div>
<div class="field">
<label class="label" for="password">{{ $t('user.auth.password') }}</label>
<password @submit="submit" @update:modelValue="v => credentials.password = v" :validate-initially="validatePasswordInitially"/>
</div>
<x-button
:loading="isLoading"
id="register-submit"
@click="submit"
class="mr-2"
:disabled="!everythingValid"
>
{{ $t('user.auth.createAccount') }}
</x-button>
<p class="mt-2">
{{ $t('user.auth.alreadyHaveAnAccount') }}
<router-link :to="{ name: 'user.login' }">
{{ $t('user.auth.login') }}
</router-link>
</p>
</form>
</div>
</template>
<script setup lang="ts">
import {useDebounceFn} from '@vueuse/core'
import {ref, reactive, toRaw, computed, onBeforeMount} from 'vue'
import router from '@/router'
import Message from '@/components/misc/message.vue'
import {isEmail} from '@/helpers/isEmail'
import Password from '@/components/input/password.vue'
import {useAuthStore} from '@/stores/auth'
const authStore = useAuthStore()
// FIXME: use the `beforeEnter` hook of vue-router
// Check if the user is already logged in, if so, redirect them to the homepage
onBeforeMount(() => {
if (authStore.authenticated) {
router.push({name: 'home'})
}
})
const credentials = reactive({
username: '',
email: '',
password: '',
})
const isLoading = computed(() => authStore.isLoading)
const errorMessage = ref('')
const validatePasswordInitially = ref(false)
const DEBOUNCE_TIME = 100
// debouncing to prevent error messages when clicking on the log in button
const emailValid = ref(true)
const validateEmail = useDebounceFn(() => {
emailValid.value = isEmail(credentials.email)
}, DEBOUNCE_TIME)
const usernameValid = ref(true)
const validateUsername = useDebounceFn(() => {
usernameValid.value = credentials.username !== ''
}, DEBOUNCE_TIME)
const everythingValid = computed(() => {
return credentials.username !== '' &&
credentials.email !== '' &&
credentials.password !== '' &&
emailValid.value &&
usernameValid.value
})
async function submit() {
errorMessage.value = ''
validatePasswordInitially.value = true
if (!everythingValid.value) {
return
}
try {
await authStore.register(toRaw(credentials))
} catch (e: any) {
errorMessage.value = e?.message
}
}
</script>

View File

@ -1,75 +0,0 @@
<template>
<div>
<message variant="danger" v-if="errorMsg" class="mb-4">
{{ errorMsg }}
</message>
<div class="has-text-centered mb-4" v-if="isSuccess">
<message variant="success">
{{ $t('user.auth.resetPasswordSuccess') }}
</message>
<x-button :to="{ name: 'user.login' }" class="mt-4">
{{ $t('user.auth.login') }}
</x-button>
</div>
<form @submit.prevent="submit" v-if="!isSuccess">
<div class="field">
<label class="label" for="email">{{ $t('user.auth.email') }}</label>
<div class="control">
<input
class="input"
id="email"
name="email"
:placeholder="$t('user.auth.emailPlaceholder')"
required
type="email"
v-focus
v-model="passwordReset.email"/>
</div>
</div>
<div class="field is-grouped">
<div class="control">
<x-button
@click="submit"
:loading="passwordResetService.loading"
>
{{ $t('user.auth.resetPasswordAction') }}
</x-button>
<x-button :to="{ name: 'user.login' }" variant="secondary">
{{ $t('user.auth.login') }}
</x-button>
</div>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import {ref, reactive} from 'vue'
import PasswordResetModel from '@/models/passwordReset'
import PasswordResetService from '@/services/passwordReset'
import Message from '@/components/misc/message.vue'
// Not sure if this instance needs a shalloRef at all
const passwordResetService = reactive(new PasswordResetService())
const passwordReset = ref(new PasswordResetModel())
const errorMsg = ref('')
const isSuccess = ref(false)
async function submit() {
errorMsg.value = ''
try {
await passwordResetService.requestResetPassword(passwordReset.value)
isSuccess.value = true
} catch (e) {
errorMsg.value = e.response.data.message
}
}
</script>
<style scoped>
.button {
margin: 0 0.4rem 0 0;
}
</style>

View File

@ -42,44 +42,8 @@ const navigationItems = computed(() => {
title: t('user.settings.general.title'),
routeName: 'user.settings.general',
},
{
title: t('user.settings.newPasswordTitle'),
routeName: 'user.settings.password-update',
condition: isLocalUser.value,
},
{
title: t('user.settings.updateEmailTitle'),
routeName: 'user.settings.email-update',
condition: isLocalUser.value,
},
{
title: t('user.settings.avatar.title'),
routeName: 'user.settings.avatar',
},
{
title: t('user.settings.totp.title'),
routeName: 'user.settings.totp',
condition: totpEnabled.value,
},
{
title: t('user.export.title'),
routeName: 'user.settings.data-export',
},
{
title: t('migrate.title'),
routeName: 'migrate.start',
condition: migratorsEnabled.value,
},
{
title: t('user.settings.caldav.title'),
routeName: 'user.settings.caldav',
condition: caldavEnabled.value,
},
{
title: t('user.deletion.title'),
routeName: 'user.settings.deletion',
condition: userDeletionEnabled.value,
},
// TODO_OFFLINE export/import
// TODO_OFFLINE caldav
]
return items.filter(({condition}) => condition !== false)

View File

@ -1,139 +0,0 @@
<template>
<card :title="$t('user.deletion.title')" v-if="userDeletionEnabled">
<template v-if="deletionScheduledAt !== null">
<form @submit.prevent="cancelDeletion()">
<p>
{{
$t('user.deletion.scheduled', {
date: formatDateShort(deletionScheduledAt),
dateSince: formatDateSince(deletionScheduledAt),
})
}}
</p>
<p>
{{ $t('user.deletion.scheduledCancelText') }}
</p>
<div class="field">
<label class="label" for="currentPasswordAccountDelete">
{{ $t('user.settings.currentPassword') }}
</label>
<div class="control">
<input
class="input"
:class="{'is-danger': errPasswordRequired}"
id="currentPasswordAccountDelete"
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
type="password"
v-model="password"
@keyup="() => errPasswordRequired = password === ''"
ref="passwordInput"
/>
</div>
<p class="help is-danger" v-if="errPasswordRequired">
{{ $t('user.deletion.passwordRequired') }}
</p>
</div>
</form>
<x-button
:loading="accountDeleteService.loading"
@click="cancelDeletion()"
class="is-fullwidth mt-4">
{{ $t('user.deletion.scheduledCancelConfirm') }}
</x-button>
</template>
<template v-else>
<form @submit.prevent="deleteAccount()">
<p>
{{ $t('user.deletion.text1') }}
</p>
<p>
{{ $t('user.deletion.text2') }}
</p>
<div class="field">
<label class="label" for="currentPasswordAccountDelete">
{{ $t('user.settings.currentPassword') }}
</label>
<div class="control">
<input
class="input"
:class="{'is-danger': errPasswordRequired}"
id="currentPasswordAccountDelete"
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
type="password"
v-model="password"
@keyup="() => errPasswordRequired = password === ''"
ref="passwordInput"
/>
</div>
<p class="help is-danger" v-if="errPasswordRequired">
{{ $t('user.deletion.passwordRequired') }}
</p>
</div>
</form>
<x-button
:loading="accountDeleteService.loading"
@click="deleteAccount()"
class="is-fullwidth mt-4 is-danger">
{{ $t('user.deletion.confirm') }}
</x-button>
</template>
</card>
</template>
<script lang="ts">
export default { name: 'user-settings-deletion' }
</script>
<script setup lang="ts">
import {ref, shallowReactive, computed} from 'vue'
import {useI18n} from 'vue-i18n'
import AccountDeleteService from '@/services/accountDelete'
import {parseDateOrNull} from '@/helpers/parseDateOrNull'
import {formatDateShort, formatDateSince} from '@/helpers/time/formatDate'
import {useTitle} from '@/composables/useTitle'
import {success} from '@/message'
import {useAuthStore} from '@/stores/auth'
import {useConfigStore} from '@/stores/config'
const {t} = useI18n({useScope: 'global'})
useTitle(() => `${t('user.deletion.title')} - ${t('user.settings.title')}`)
const accountDeleteService = shallowReactive(new AccountDeleteService())
const password = ref('')
const errPasswordRequired = ref(false)
const authStore = useAuthStore()
const configStore = useConfigStore()
const userDeletionEnabled = computed(() => configStore.userDeletionEnabled)
const deletionScheduledAt = computed(() => parseDateOrNull(authStore.info?.deletionScheduledAt))
const passwordInput = ref()
async function deleteAccount() {
if (password.value === '') {
errPasswordRequired.value = true
passwordInput.value.focus()
return
}
await accountDeleteService.request(password.value)
success({message: t('user.deletion.requestSuccess')})
password.value = ''
}
async function cancelDeletion() {
if (password.value === '') {
errPasswordRequired.value = true
passwordInput.value.focus()
return
}
await accountDeleteService.cancel(password.value)
success({message: t('user.deletion.scheduledCancelSuccess')})
authStore.refreshUserInfo()
password.value = ''
}
</script>

View File

@ -1,65 +0,0 @@
<template>
<card v-if="isLocalUser" :title="$t('user.settings.updateEmailTitle')">
<form @submit.prevent="updateEmail">
<div class="field">
<label class="label" for="newEmail">{{ $t('user.settings.updateEmailNew') }}</label>
<div class="control">
<input
@keyup.enter="updateEmail"
class="input"
id="newEmail"
:placeholder="$t('user.auth.emailPlaceholder')"
type="email"
v-model="emailUpdate.newEmail"/>
</div>
</div>
<div class="field">
<label class="label" for="currentPasswordEmail">{{ $t('user.settings.currentPassword') }}</label>
<div class="control">
<input
@keyup.enter="updateEmail"
class="input"
id="currentPasswordEmail"
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
type="password"
v-model="emailUpdate.password"/>
</div>
</div>
</form>
<x-button
:loading="emailUpdateService.loading"
@click="updateEmail"
class="is-fullwidth mt-4">
{{ $t('misc.save') }}
</x-button>
</card>
</template>
<script lang="ts">
export default { name: 'user-settings-update-email' }
</script>
<script setup lang="ts">
import {reactive, computed, shallowReactive} from 'vue'
import {useI18n} from 'vue-i18n'
import EmailUpdateService from '@/services/emailUpdate'
import EmailUpdateModel from '@/models/emailUpdate'
import {success} from '@/message'
import {useTitle} from '@/composables/useTitle'
import {useAuthStore} from '@/stores/auth'
const {t} = useI18n({useScope: 'global'})
useTitle(() => `${t('user.settings.updateEmailTitle')} - ${t('user.settings.title')}`)
const authStore = useAuthStore()
const isLocalUser = computed(() => authStore.info?.isLocalUser)
const emailUpdate = reactive(new EmailUpdateModel())
const emailUpdateService = shallowReactive(new EmailUpdateService())
async function updateEmail() {
await emailUpdateService.update(emailUpdate)
success({message: t('user.settings.updateEmailSuccess')})
}
</script>

View File

@ -1,79 +1,11 @@
<template>
<card :title="$t('user.settings.general.title')" class="general-settings" :loading="loading">
<div class="field">
<label class="label" :for="`newName${id}`">{{ $t('user.settings.general.name') }}</label>
<div class="control">
<input
@keyup.enter="updateSettings"
class="input"
:id="`newName${id}`"
:placeholder="$t('user.settings.general.newName')"
type="text"
v-model="settings.name"/>
</div>
</div>
<div class="field">
<label class="label">
{{ $t('user.settings.general.defaultProject') }}
</label>
<project-search v-model="defaultProject"/>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" v-model="settings.overdueTasksRemindersEnabled"/>
{{ $t('user.settings.general.overdueReminders') }}
</label>
</div>
<div class="field" v-if="settings.overdueTasksRemindersEnabled">
<label class="label" for="overdueTasksReminderTime">
{{ $t('user.settings.general.overdueTasksRemindersTime') }}
</label>
<div class="control">
<input
@keyup.enter="updateSettings"
class="input"
id="overdueTasksReminderTime"
type="time"
v-model="settings.overdueTasksRemindersTime"/>
</div>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" v-model="settings.emailRemindersEnabled"/>
{{ $t('user.settings.general.emailReminders') }}
</label>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" v-model="settings.discoverableByName"/>
{{ $t('user.settings.general.discoverableByName') }}
</label>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" v-model="settings.discoverableByEmail"/>
{{ $t('user.settings.general.discoverableByEmail') }}
</label>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" v-model="playSoundWhenDone"/>
{{ $t('user.settings.general.playSoundWhenDone') }}
</label>
</div>
<div class="field">
<label class="is-flex is-align-items-center">
<span>
{{ $t('user.settings.general.weekStart') }}
</span>
<div class="select ml-2">
<select v-model.number="settings.weekStart">
<option value="0">{{ $t('user.settings.general.weekStartSunday') }}</option>
<option value="1">{{ $t('user.settings.general.weekStartMonday') }}</option>
</select>
</div>
</label>
</div>
<div class="field">
<label class="is-flex is-align-items-center">
<span>
@ -91,6 +23,7 @@
</div>
</label>
</div>
<!-- TODO support week start, others that are possible -->
<div class="field">
<label class="is-flex is-align-items-center">
<span>
@ -120,20 +53,6 @@
</div>
</label>
</div>
<div class="field">
<label class="is-flex is-align-items-center">
<span>
{{ $t('user.settings.general.timezone') }}
</span>
<div class="select ml-2">
<select v-model="settings.timezone">
<option v-for="tz in availableTimezones" :key="tz">
{{ tz }}
</option>
</select>
</div>
</label>
</div>
<x-button
:loading="loading"

View File

@ -1,88 +0,0 @@
<template>
<card v-if="isLocalUser" :title="$t('user.settings.newPasswordTitle')" :loading="passwordUpdateService.loading">
<form @submit.prevent="updatePassword">
<div class="field">
<label class="label" for="newPassword">{{ $t('user.settings.newPassword') }}</label>
<div class="control">
<input
autocomplete="new-password"
@keyup.enter="updatePassword"
class="input"
id="newPassword"
:placeholder="$t('user.auth.passwordPlaceholder')"
type="password"
v-model="passwordUpdate.newPassword"/>
</div>
</div>
<div class="field">
<label class="label" for="newPasswordConfirm">{{ $t('user.settings.newPasswordConfirm') }}</label>
<div class="control">
<input
autocomplete="new-password"
@keyup.enter="updatePassword"
class="input"
id="newPasswordConfirm"
:placeholder="$t('user.auth.passwordPlaceholder')"
type="password"
v-model="passwordConfirm"/>
</div>
</div>
<div class="field">
<label class="label" for="currentPassword">{{ $t('user.settings.currentPassword') }}</label>
<div class="control">
<input
autocomplete="current-password"
@keyup.enter="updatePassword"
class="input"
id="currentPassword"
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
type="password"
v-model="passwordUpdate.oldPassword"/>
</div>
</div>
</form>
<x-button
:loading="passwordUpdateService.loading"
@click="updatePassword"
class="is-fullwidth mt-4">
{{ $t('misc.save') }}
</x-button>
</card>
</template>
<script lang="ts">
export default {name: 'user-settings-password-update'}
</script>
<script setup lang="ts">
import {ref, reactive, shallowReactive, computed} from 'vue'
import {useI18n} from 'vue-i18n'
import PasswordUpdateService from '@/services/passwordUpdateService'
import PasswordUpdateModel from '@/models/passwordUpdate'
import {useTitle} from '@/composables/useTitle'
import {success, error} from '@/message'
import {useAuthStore} from '@/stores/auth'
const passwordUpdateService = shallowReactive(new PasswordUpdateService())
const passwordUpdate = reactive(new PasswordUpdateModel())
const passwordConfirm = ref('')
const {t} = useI18n({useScope: 'global'})
useTitle(() => `${t('user.settings.newPasswordTitle')} - ${t('user.settings.title')}`)
const authStore = useAuthStore()
const isLocalUser = computed(() => authStore.info?.isLocalUser)
async function updatePassword() {
if (passwordConfirm.value !== passwordUpdate.newPassword) {
error({message: t('user.settings.passwordsDontMatch')})
return
}
await passwordUpdateService.update(passwordUpdate)
success({message: t('user.settings.passwordUpdateSuccess')})
}
</script>

View File

@ -1,142 +0,0 @@
<template>
<card :title="$t('user.settings.totp.title')" v-if="totpEnabled">
<x-button
:loading="totpService.loading"
@click="totpEnroll()"
v-if="!totpEnrolled && totp.secret === ''">
{{ $t('user.settings.totp.enroll') }}
</x-button>
<template v-else-if="totp.secret !== '' && !totp.enabled">
<p>
{{ $t('user.settings.totp.finishSetupPart1') }}
<strong>{{ totp.secret }}</strong><br/>
{{ $t('user.settings.totp.finishSetupPart2') }}
</p>
<p>
{{ $t('user.settings.totp.scanQR') }}<br/>
<img :src="totpQR" alt=""/>
</p>
<div class="field">
<label class="label" for="totpConfirmPasscode">{{ $t('user.settings.totp.passcode') }}</label>
<div class="control">
<input
autocomplete="one-time-code"
@keyup.enter="totpConfirm"
class="input"
id="totpConfirmPasscode"
:placeholder="$t('user.settings.totp.passcodePlaceholder')"
type="text"
inputmode="numeric"
v-model="totpConfirmPasscode"/>
</div>
</div>
<x-button @click="totpConfirm">{{ $t('misc.confirm') }}</x-button>
</template>
<template v-else-if="totp.secret !== '' && totp.enabled">
<p>
{{ $t('user.settings.totp.setupSuccess') }}
</p>
<p v-if="!totpDisableForm">
<x-button @click="totpDisableForm = true" class="is-danger">{{ $t('misc.disable') }}</x-button>
</p>
<div v-if="totpDisableForm">
<div class="field">
<label class="label" for="currentPassword">{{ $t('user.settings.totp.enterPassword') }}</label>
<div class="control">
<input
@keyup.enter="totpDisable"
class="input"
id="currentPassword"
:placeholder="$t('user.settings.currentPasswordPlaceholder')"
type="password"
v-focus
v-model="totpDisablePassword"/>
</div>
</div>
<x-button @click="totpDisable" class="is-danger">
{{ $t('user.settings.totp.disable') }}
</x-button>
<x-button @click="totpDisableForm = false" variant="tertiary" class="ml-2">
{{ $t('misc.cancel') }}
</x-button>
</div>
</template>
</card>
</template>
<script lang="ts">
export default { name: 'user-settings-totp' }
</script>
<script lang="ts" setup>
import {computed, ref, shallowReactive} from 'vue'
import {useI18n} from 'vue-i18n'
import TotpService from '@/services/totp'
import TotpModel from '@/models/totp'
import {success} from '@/message'
import {useTitle} from '@/composables/useTitle'
import {useConfigStore} from '@/stores/config'
import type {ITotp} from '@/modelTypes/ITotp'
const {t} = useI18n({useScope: 'global'})
useTitle(() => `${t('user.settings.totp.title')} - ${t('user.settings.title')}`)
const totpService = shallowReactive(new TotpService())
const totp = ref<ITotp>(new TotpModel())
const totpQR = ref('')
const totpEnrolled = ref(false)
const totpConfirmPasscode = ref('')
const totpDisableForm = ref(false)
const totpDisablePassword = ref('')
const configStore = useConfigStore()
const totpEnabled = computed(() => configStore.totpEnabled)
totpStatus()
async function totpStatus() {
if (!totpEnabled.value) {
return
}
try {
totp.value = await totpService.get()
totpSetQrCode()
} catch(e) {
// Error code 1016 means totp is not enabled, we don't need an error in that case.
if (e.response?.data?.code === 1016) {
totpEnrolled.value = false
return
}
throw e
}
}
async function totpSetQrCode() {
const qr = await totpService.qrcode()
totpQR.value = window.URL.createObjectURL(qr)
}
async function totpEnroll() {
totp.value = await totpService.enroll()
totpEnrolled.value = true
totpSetQrCode()
}
async function totpConfirm() {
await totpService.enable({passcode: totpConfirmPasscode.value})
totp.value.enabled = true
success({message: t('user.settings.totp.confirmSuccess')})
}
async function totpDisable() {
await totpService.disable({password: totpDisablePassword.value})
totpEnrolled.value = false
totp.value = new TotpModel()
success({message: t('user.settings.totp.disableSuccess')})
}
</script>