Compare commits
15 Commits
main
...
webxdc-pro
Author | SHA1 | Date | |
---|---|---|---|
e285fa9e84 | |||
4ee8ceb1c0 | |||
080fc489d7 | |||
e2e743708c | |||
d88126d366 | |||
ac8c9c7924 | |||
0e72842668 | |||
bcd1528faa | |||
1352a82c8c | |||
19f99af023 | |||
80ba017a74 | |||
90ef2b13a9 | |||
f4890f00d7 | |||
724daee7da | |||
6b47f1ba47 |
96
.github/workflows/deploy-and-release.yml
vendored
Normal file
96
.github/workflows/deploy-and-release.yml
vendored
Normal file
|
@ -0,0 +1,96 @@
|
||||||
|
# How the file was made: template used: GitHub's suggested "deploy Nuxt app to GitHub pages"
|
||||||
|
# Since it's a Vite project, better refer to https://vitejs.dev/guide/static-deploy.html#github-pages
|
||||||
|
name: Deploy and release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- webxdc*
|
||||||
|
# branches: ["webxdc-prototype"]
|
||||||
|
|
||||||
|
# Allows you to run this workflow manually from the Actions tab
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
|
||||||
|
permissions:
|
||||||
|
# Need `contents: write` to make a release.
|
||||||
|
contents: write
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
|
||||||
|
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
|
||||||
|
concurrency:
|
||||||
|
group: "pages"
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release-webxdc:
|
||||||
|
# Only make a release on tags
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# TODO refactor: steps are duplicated for both jobs. I think
|
||||||
|
# YAML can help here.
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
# https://pnpm.io/continuous-integration
|
||||||
|
- name: Set up pnpm
|
||||||
|
uses: pnpm/action-setup@v2
|
||||||
|
with:
|
||||||
|
version: 7
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: "16"
|
||||||
|
cache: "pnpm"
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: pnpm run build-webxdc
|
||||||
|
- name: Release
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
# prerelease: ${{ contains(github.event.ref, '-beta') }}
|
||||||
|
prerelease: true
|
||||||
|
fail_on_unmatched_files: true
|
||||||
|
files: dist/*.xdc
|
||||||
|
|
||||||
|
# Deploy to GitHub Pages.
|
||||||
|
deploy-gh-pages:
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
# https://pnpm.io/continuous-integration
|
||||||
|
- name: Set up pnpm
|
||||||
|
uses: pnpm/action-setup@v2
|
||||||
|
with:
|
||||||
|
version: 7
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: "16"
|
||||||
|
cache: "pnpm"
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install
|
||||||
|
|
||||||
|
- name: Setup Pages
|
||||||
|
uses: actions/configure-pages@v3
|
||||||
|
- name: Build
|
||||||
|
env:
|
||||||
|
REPO_OWNER_AND_NAME: ${{ github.repository }}
|
||||||
|
#VIKUNJA_FRONTEND_BASE: /${{ github.repository }}/
|
||||||
|
# Set VIKUNJA_FRONTEND_BASE to the repo name
|
||||||
|
run: VIKUNJA_FRONTEND_BASE=$(echo "$REPO_OWNER_AND_NAME" | cut -d / -f 2) pnpm run build
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-pages-artifact@v1
|
||||||
|
with:
|
||||||
|
path: ./dist
|
||||||
|
- name: Deploy to GitHub Pages
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v1
|
27
create-xdc.sh
Executable file
27
create-xdc.sh
Executable file
|
@ -0,0 +1,27 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
"-h" | "--help")
|
||||||
|
echo "usage: ${0##*/} [PACKAGE_NAME]"
|
||||||
|
exit
|
||||||
|
;;
|
||||||
|
"")
|
||||||
|
PACKAGE_NAME=${PWD##*/} # '##*/' removes everything before the last slash and the last slash
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
PACKAGE_NAME=${1%.xdc} # '%.xdc' removes the extension and allows PACKAGE_NAME to be given with or without extension
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
rm "$PACKAGE_NAME.xdc" 2> /dev/null
|
||||||
|
zip -9 --recurse-paths "$PACKAGE_NAME.xdc" --exclude LICENSE README.md webxdc.js webxdc.d.ts "./*.sh" "./*.xdc" -- *
|
||||||
|
|
||||||
|
echo "success, archive contents:"
|
||||||
|
unzip -l "$PACKAGE_NAME.xdc"
|
||||||
|
|
||||||
|
# check package size
|
||||||
|
MAXSIZE=655360
|
||||||
|
size=$(wc -c < "$PACKAGE_NAME.xdc")
|
||||||
|
if [ "$size" -ge $MAXSIZE ]; then
|
||||||
|
echo "WARNING: package size exceeded the limit ($size > $MAXSIZE)"
|
||||||
|
fi
|
7
generate-webxdc-manifest.sh
Executable file
7
generate-webxdc-manifest.sh
Executable file
|
@ -0,0 +1,7 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
echo -n 'name = "'
|
||||||
|
# Get the contents of the `<title>` element of `index.html`
|
||||||
|
grep -oP "(?<=<title>).*?(?=</title>)" index.html | tr -d '\n' \
|
||||||
|
&& echo '"' \
|
||||||
|
&& echo -n 'source_code_url = "https://github.com/WofWca/vikunja-frontend"'
|
|
@ -16,6 +16,7 @@
|
||||||
<strong>We're sorry but Vikunja doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
<strong>We're sorry but Vikunja doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
|
||||||
</noscript>
|
</noscript>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
<!--WEBXDC_SCRIPT_PLACEHOLDER-->
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
<script>
|
<script>
|
||||||
//
|
//
|
||||||
|
|
|
@ -27,6 +27,8 @@
|
||||||
"preview": "vite preview --port 4173",
|
"preview": "vite preview --port 4173",
|
||||||
"preview:dev": "vite preview --outDir dist-dev --mode development --port 4173",
|
"preview:dev": "vite preview --outDir dist-dev --mode development --port 4173",
|
||||||
"build": "vite build && workbox copyLibraries dist/",
|
"build": "vite build && workbox copyLibraries dist/",
|
||||||
|
"build-webxdc": "pnpm run build && pnpm run pack-webxdc",
|
||||||
|
"pack-webxdc": "./generate-webxdc-manifest.sh > dist/manifest.toml && cd dist && cp images/icons/icon-maskable.png icon.png && ../create-xdc.sh vikunja.xdc",
|
||||||
"build:modern-only": "BUILD_MODERN_ONLY=true vite build && workbox copyLibraries dist/",
|
"build:modern-only": "BUILD_MODERN_ONLY=true vite build && workbox copyLibraries dist/",
|
||||||
"build:dev": "vite build --mode development --outDir dist-dev/",
|
"build:dev": "vite build --mode development --outDir dist-dev/",
|
||||||
"lint": "eslint --ignore-pattern '*.test.*' ./src --ext .vue,.js,.ts",
|
"lint": "eslint --ignore-pattern '*.test.*' ./src --ext .vue,.js,.ts",
|
||||||
|
@ -89,6 +91,9 @@
|
||||||
"vue-i18n": "9.2.2",
|
"vue-i18n": "9.2.2",
|
||||||
"vue-router": "4.1.6",
|
"vue-router": "4.1.6",
|
||||||
"workbox-precaching": "6.5.4",
|
"workbox-precaching": "6.5.4",
|
||||||
|
"y-indexeddb": "^9.0.10",
|
||||||
|
"y-webrtc": "^10.2.5",
|
||||||
|
"yjs": "^13.5.51",
|
||||||
"zhyswan-vuedraggable": "4.1.3"
|
"zhyswan-vuedraggable": "4.1.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
102
pnpm-lock.yaml
102
pnpm-lock.yaml
|
@ -100,6 +100,9 @@ specifiers:
|
||||||
wait-on: 7.0.1
|
wait-on: 7.0.1
|
||||||
workbox-cli: 6.5.4
|
workbox-cli: 6.5.4
|
||||||
workbox-precaching: 6.5.4
|
workbox-precaching: 6.5.4
|
||||||
|
y-indexeddb: ^9.0.10
|
||||||
|
y-webrtc: ^10.2.5
|
||||||
|
yjs: ^13.5.51
|
||||||
zhyswan-vuedraggable: 4.1.3
|
zhyswan-vuedraggable: 4.1.3
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -147,6 +150,9 @@ dependencies:
|
||||||
vue-i18n: 9.2.2_vue@3.2.47
|
vue-i18n: 9.2.2_vue@3.2.47
|
||||||
vue-router: 4.1.6_vue@3.2.47
|
vue-router: 4.1.6_vue@3.2.47
|
||||||
workbox-precaching: 6.5.4
|
workbox-precaching: 6.5.4
|
||||||
|
y-indexeddb: 9.0.10_yjs@13.5.51
|
||||||
|
y-webrtc: 10.2.5
|
||||||
|
yjs: 13.5.51
|
||||||
zhyswan-vuedraggable: 4.1.3_vue@3.2.47
|
zhyswan-vuedraggable: 4.1.3_vue@3.2.47
|
||||||
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
@ -5174,7 +5180,6 @@ packages:
|
||||||
|
|
||||||
/base64-js/1.5.1:
|
/base64-js/1.5.1:
|
||||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/bcrypt-pbkdf/1.0.2:
|
/bcrypt-pbkdf/1.0.2:
|
||||||
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
|
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
|
||||||
|
@ -5417,7 +5422,6 @@ packages:
|
||||||
dependencies:
|
dependencies:
|
||||||
base64-js: 1.5.1
|
base64-js: 1.5.1
|
||||||
ieee754: 1.2.1
|
ieee754: 1.2.1
|
||||||
dev: true
|
|
||||||
|
|
||||||
/builtin-modules/3.2.0:
|
/builtin-modules/3.2.0:
|
||||||
resolution: {integrity: sha512-lGzLKcioL90C7wMczpkY0n/oART3MbBa8R9OFGE1rJxoVI86u4WAGfEk8Wjv10eKSyTHVGkSo3bvBylCEtk7LA==}
|
resolution: {integrity: sha512-lGzLKcioL90C7wMczpkY0n/oART3MbBa8R9OFGE1rJxoVI86u4WAGfEk8Wjv10eKSyTHVGkSo3bvBylCEtk7LA==}
|
||||||
|
@ -7188,6 +7192,10 @@ packages:
|
||||||
hasBin: true
|
hasBin: true
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/err-code/3.0.1:
|
||||||
|
resolution: {integrity: sha512-GiaH0KJUewYok+eeY05IIgjtAe4Yltygk9Wqp1V5yVWLdhf0hYZchRjNIT9bb0mSwRcIusT3cx7PJUf3zEIfUA==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/error-ex/1.3.2:
|
/error-ex/1.3.2:
|
||||||
resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==}
|
resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -8338,6 +8346,10 @@ packages:
|
||||||
node-source-walk: 5.0.0
|
node-source-walk: 5.0.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/get-browser-rtc/1.1.0:
|
||||||
|
resolution: {integrity: sha512-MghbMJ61EJrRsDe7w1Bvqt3ZsBuqhce5nrn/XAwgwOXhcsz53/ltdxOse1h/8eKXj5slzxdsz56g5rzOFSGwfQ==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/get-caller-file/2.0.5:
|
/get-caller-file/2.0.5:
|
||||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||||
engines: {node: 6.* || 8.* || >= 10.*}
|
engines: {node: 6.* || 8.* || >= 10.*}
|
||||||
|
@ -9067,7 +9079,6 @@ packages:
|
||||||
|
|
||||||
/ieee754/1.2.1:
|
/ieee754/1.2.1:
|
||||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/ignore/4.0.6:
|
/ignore/4.0.6:
|
||||||
resolution: {integrity: sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==}
|
resolution: {integrity: sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==}
|
||||||
|
@ -9130,7 +9141,6 @@ packages:
|
||||||
|
|
||||||
/inherits/2.0.4:
|
/inherits/2.0.4:
|
||||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/ini/1.3.7:
|
/ini/1.3.7:
|
||||||
resolution: {integrity: sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ==}
|
resolution: {integrity: sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ==}
|
||||||
|
@ -9676,6 +9686,10 @@ packages:
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/isomorphic.js/0.2.5:
|
||||||
|
resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/isstream/0.1.2:
|
/isstream/0.1.2:
|
||||||
resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==}
|
resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==}
|
||||||
dev: true
|
dev: true
|
||||||
|
@ -10068,6 +10082,14 @@ packages:
|
||||||
type-check: 0.4.0
|
type-check: 0.4.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/lib0/0.2.73:
|
||||||
|
resolution: {integrity: sha512-aJJIElCLWnHMcYZPtsM07QoSfHwpxCy4VUzBYGXFYEmh/h2QS5uZNbCCfL0CqnkOE30b7Tp9DVfjXag+3qzZjQ==}
|
||||||
|
engines: {node: '>=14'}
|
||||||
|
hasBin: true
|
||||||
|
dependencies:
|
||||||
|
isomorphic.js: 0.2.5
|
||||||
|
dev: false
|
||||||
|
|
||||||
/light-my-request/5.8.0:
|
/light-my-request/5.8.0:
|
||||||
resolution: {integrity: sha512-4BtD5C+VmyTpzlDPCZbsatZMJVgUIciSOwYhJDCbLffPZ35KoDkDj4zubLeHDEb35b4kkPeEv5imbh+RJxK/Pg==}
|
resolution: {integrity: sha512-4BtD5C+VmyTpzlDPCZbsatZMJVgUIciSOwYhJDCbLffPZ35KoDkDj4zubLeHDEb35b4kkPeEv5imbh+RJxK/Pg==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -12626,7 +12648,6 @@ packages:
|
||||||
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
|
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
|
||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer: 5.2.1
|
safe-buffer: 5.2.1
|
||||||
dev: true
|
|
||||||
|
|
||||||
/range-parser/1.2.1:
|
/range-parser/1.2.1:
|
||||||
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
|
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
|
||||||
|
@ -12714,7 +12735,6 @@ packages:
|
||||||
inherits: 2.0.4
|
inherits: 2.0.4
|
||||||
string_decoder: 1.3.0
|
string_decoder: 1.3.0
|
||||||
util-deprecate: 1.0.2
|
util-deprecate: 1.0.2
|
||||||
dev: true
|
|
||||||
|
|
||||||
/readable-stream/4.3.0:
|
/readable-stream/4.3.0:
|
||||||
resolution: {integrity: sha512-MuEnA0lbSi7JS8XM+WNJlWZkHAAdm7gETHdFK//Q/mChGyj2akEFtdLZh32jSdkWGbRwCW9pn6g3LWDdDeZnBQ==}
|
resolution: {integrity: sha512-MuEnA0lbSi7JS8XM+WNJlWZkHAAdm7gETHdFK//Q/mChGyj2akEFtdLZh32jSdkWGbRwCW9pn6g3LWDdDeZnBQ==}
|
||||||
|
@ -13097,7 +13117,6 @@ packages:
|
||||||
|
|
||||||
/safe-buffer/5.2.1:
|
/safe-buffer/5.2.1:
|
||||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/safe-json-stringify/1.2.0:
|
/safe-json-stringify/1.2.0:
|
||||||
resolution: {integrity: sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==}
|
resolution: {integrity: sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==}
|
||||||
|
@ -13300,6 +13319,20 @@ packages:
|
||||||
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
|
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/simple-peer/9.11.1:
|
||||||
|
resolution: {integrity: sha512-D1SaWpOW8afq1CZGWB8xTfrT3FekjQmPValrqncJMX7QFl8YwhrPTZvMCANLtgBwwdS+7zURyqxDDEmY558tTw==}
|
||||||
|
dependencies:
|
||||||
|
buffer: 6.0.3
|
||||||
|
debug: 4.3.4
|
||||||
|
err-code: 3.0.1
|
||||||
|
get-browser-rtc: 1.1.0
|
||||||
|
queue-microtask: 1.2.3
|
||||||
|
randombytes: 2.1.0
|
||||||
|
readable-stream: 3.6.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
dev: false
|
||||||
|
|
||||||
/simple-swizzle/0.2.2:
|
/simple-swizzle/0.2.2:
|
||||||
resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==}
|
resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -13712,7 +13745,6 @@ packages:
|
||||||
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer: 5.2.1
|
safe-buffer: 5.2.1
|
||||||
dev: true
|
|
||||||
|
|
||||||
/stringify-object/3.3.0:
|
/stringify-object/3.3.0:
|
||||||
resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==}
|
resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==}
|
||||||
|
@ -14647,7 +14679,6 @@ packages:
|
||||||
|
|
||||||
/util-deprecate/1.0.2:
|
/util-deprecate/1.0.2:
|
||||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/utils-merge/1.0.1:
|
/utils-merge/1.0.1:
|
||||||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
||||||
|
@ -15416,6 +15447,21 @@ packages:
|
||||||
signal-exit: 3.0.7
|
signal-exit: 3.0.7
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/ws/7.5.9:
|
||||||
|
resolution: {integrity: sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==}
|
||||||
|
engines: {node: '>=8.3.0'}
|
||||||
|
requiresBuild: true
|
||||||
|
peerDependencies:
|
||||||
|
bufferutil: ^4.0.1
|
||||||
|
utf-8-validate: ^5.0.2
|
||||||
|
peerDependenciesMeta:
|
||||||
|
bufferutil:
|
||||||
|
optional: true
|
||||||
|
utf-8-validate:
|
||||||
|
optional: true
|
||||||
|
dev: false
|
||||||
|
optional: true
|
||||||
|
|
||||||
/ws/8.11.0:
|
/ws/8.11.0:
|
||||||
resolution: {integrity: sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==}
|
resolution: {integrity: sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==}
|
||||||
engines: {node: '>=10.0.0'}
|
engines: {node: '>=10.0.0'}
|
||||||
|
@ -15466,6 +15512,37 @@ packages:
|
||||||
engines: {node: '>=0.4'}
|
engines: {node: '>=0.4'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/y-indexeddb/9.0.10_yjs@13.5.51:
|
||||||
|
resolution: {integrity: sha512-Tz4IzLZ20Pe8LTjXU125k2+7caERsX4ANsrjskHMm3yXUX4v9kBga/kK7ctT05P0uAj+glQComkAglY1qey7zg==}
|
||||||
|
peerDependencies:
|
||||||
|
yjs: ^13.0.0
|
||||||
|
dependencies:
|
||||||
|
lib0: 0.2.73
|
||||||
|
yjs: 13.5.51
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/y-protocols/1.0.5:
|
||||||
|
resolution: {integrity: sha512-Wil92b7cGk712lRHDqS4T90IczF6RkcvCwAD0A2OPg+adKmOe+nOiT/N2hvpQIWS3zfjmtL4CPaH5sIW1Hkm/A==}
|
||||||
|
dependencies:
|
||||||
|
lib0: 0.2.73
|
||||||
|
dev: false
|
||||||
|
|
||||||
|
/y-webrtc/10.2.5:
|
||||||
|
resolution: {integrity: sha512-ZyBNvTI5L28sQ2PQI0T/JvyWgvuTq05L21vGkIlcvNLNSJqAaLCBJRe3FHEqXoaogqWmRcEAKGfII4ErNXMnNw==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
hasBin: true
|
||||||
|
dependencies:
|
||||||
|
lib0: 0.2.73
|
||||||
|
simple-peer: 9.11.1
|
||||||
|
y-protocols: 1.0.5
|
||||||
|
optionalDependencies:
|
||||||
|
ws: 7.5.9
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- bufferutil
|
||||||
|
- supports-color
|
||||||
|
- utf-8-validate
|
||||||
|
dev: false
|
||||||
|
|
||||||
/y18n/5.0.8:
|
/y18n/5.0.8:
|
||||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
@ -15529,6 +15606,13 @@ packages:
|
||||||
fd-slicer: 1.1.0
|
fd-slicer: 1.1.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/yjs/13.5.51:
|
||||||
|
resolution: {integrity: sha512-F1Nb3z3TdandD80IAeQqgqy/2n9AhDLcXoBhZvCUX1dNVe0ef7fIwi6MjSYaGAYF2Ev8VcLcsGnmuGGOl7AWbw==}
|
||||||
|
engines: {node: '>=16.0.0', npm: '>=8.0.0'}
|
||||||
|
dependencies:
|
||||||
|
lib0: 0.2.73
|
||||||
|
dev: false
|
||||||
|
|
||||||
/yn/3.1.1:
|
/yn/3.1.1:
|
||||||
resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}
|
resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
36
src/App.vue
36
src/App.vue
|
@ -35,7 +35,6 @@ import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue'
|
||||||
import Ready from '@/components/misc/ready.vue'
|
import Ready from '@/components/misc/ready.vue'
|
||||||
|
|
||||||
import {setLanguage} from '@/i18n'
|
import {setLanguage} from '@/i18n'
|
||||||
import AccountDeleteService from '@/services/accountDelete'
|
|
||||||
import {success} from '@/message'
|
import {success} from '@/message'
|
||||||
|
|
||||||
import {useAuthStore} from '@/stores/auth'
|
import {useAuthStore} from '@/stores/auth'
|
||||||
|
@ -57,41 +56,6 @@ const authLinkShare = computed(() => authStore.authLinkShare)
|
||||||
|
|
||||||
const {t} = useI18n({useScope: 'global'})
|
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()
|
setLanguage()
|
||||||
useColorScheme()
|
useColorScheme()
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -5,32 +5,13 @@
|
||||||
<Logo width="164" height="48" />
|
<Logo width="164" height="48" />
|
||||||
</router-link>
|
</router-link>
|
||||||
|
|
||||||
<MenuButton class="menu-button" />
|
<!-- <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>
|
|
||||||
|
|
||||||
<div class="navbar-end">
|
<div class="navbar-end">
|
||||||
<BaseButton @click="openQuickActions" class="trigger-button" v-shortcut="'Control+k'"
|
<BaseButton @click="openQuickActions" class="trigger-button" v-shortcut="'Control+k'"
|
||||||
:title="$t('keyboardShortcuts.quickSearch')">
|
:title="$t('keyboardShortcuts.quickSearch')">
|
||||||
<icon icon="search" />
|
<icon icon="search" />
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
<Notifications />
|
|
||||||
<dropdown>
|
<dropdown>
|
||||||
<template #trigger="{ toggleOpen, open }">
|
<template #trigger="{ toggleOpen, open }">
|
||||||
<BaseButton class="username-dropdown-trigger" @click="toggleOpen" variant="secondary" :shadow="false">
|
<BaseButton class="username-dropdown-trigger" @click="toggleOpen" variant="secondary" :shadow="false">
|
||||||
|
@ -59,9 +40,6 @@
|
||||||
<dropdown-item :to="{ name: 'about' }">
|
<dropdown-item :to="{ name: 'about' }">
|
||||||
{{ $t('about.title') }}
|
{{ $t('about.title') }}
|
||||||
</dropdown-item>
|
</dropdown-item>
|
||||||
<dropdown-item @click="authStore.logout()">
|
|
||||||
{{ $t('user.auth.logout') }}
|
|
||||||
</dropdown-item>
|
|
||||||
</dropdown>
|
</dropdown>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
:class="{'is-visible': background}"
|
:class="{'is-visible': background}"
|
||||||
class="app-container-background background-fade-in d-print-none"
|
class="app-container-background background-fade-in d-print-none"
|
||||||
:style="{'background-image': background && `url(${background})`}"></div>
|
:style="{'background-image': background && `url(${background})`}"></div>
|
||||||
|
<!-- Can't remove <navigation> because otherwise namespaces would not get loaded. -->
|
||||||
<navigation class="d-print-none"/>
|
<navigation class="d-print-none"/>
|
||||||
<main
|
<main
|
||||||
class="app-content"
|
class="app-content"
|
||||||
|
@ -71,7 +72,6 @@ import {useBaseStore} from '@/stores/base'
|
||||||
import {useLabelStore} from '@/stores/labels'
|
import {useLabelStore} from '@/stores/labels'
|
||||||
|
|
||||||
import {useRouteWithModal} from '@/composables/useRouteWithModal'
|
import {useRouteWithModal} from '@/composables/useRouteWithModal'
|
||||||
import {useRenewTokenOnFocus} from '@/composables/useRenewTokenOnFocus'
|
|
||||||
|
|
||||||
const {routeWithModal, currentModal, closeModal} = useRouteWithModal()
|
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
|
// TODO: Reset the title if the page component does not set one itself
|
||||||
|
|
||||||
useRenewTokenOnFocus()
|
|
||||||
|
|
||||||
const labelStore = useLabelStore()
|
const labelStore = useLabelStore()
|
||||||
labelStore.loadAllLabels()
|
labelStore.loadAllLabels()
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -63,7 +63,7 @@ const route = useRoute()
|
||||||
const baseStore = useBaseStore()
|
const baseStore = useBaseStore()
|
||||||
|
|
||||||
const ready = computed(() => baseStore.ready)
|
const ready = computed(() => baseStore.ready)
|
||||||
const online = useOnline()
|
const online = true
|
||||||
|
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const showLoading = computed(() => !ready.value && error.value === '')
|
const showLoading = computed(() => !ready.value && error.value === '')
|
||||||
|
|
|
@ -80,9 +80,9 @@ const userInfo = computed(() => authStore.info)
|
||||||
let interval: ReturnType<typeof setInterval>
|
let interval: ReturnType<typeof setInterval>
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadNotifications()
|
// loadNotifications()
|
||||||
document.addEventListener('click', hidePopup)
|
document.addEventListener('click', hidePopup)
|
||||||
interval = setInterval(loadNotifications, LOAD_NOTIFICATIONS_INTERVAL)
|
// interval = setInterval(loadNotifications, LOAD_NOTIFICATIONS_INTERVAL)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
|
|
@ -45,7 +45,7 @@ export function useMenuActive() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
menuActive: readonly(menuActive),
|
menuActive: false,
|
||||||
setMenuActive,
|
setMenuActive,
|
||||||
toggleMenu,
|
toggleMenu,
|
||||||
}
|
}
|
||||||
|
|
|
@ -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')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
|
@ -60,28 +60,13 @@ const SORT_BY_DEFAULT: SortBy = {
|
||||||
/**
|
/**
|
||||||
* This mixin provides a base set of methods and properties to get tasks.
|
* This mixin provides a base set of methods and properties to get tasks.
|
||||||
*/
|
*/
|
||||||
export function useTaskList(projectId, sortByDefault: SortBy = SORT_BY_DEFAULT) {
|
export function useTaskList(projectId) {
|
||||||
const params = ref({...getDefaultParams()})
|
|
||||||
|
|
||||||
const search = ref('')
|
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
|
|
||||||
const sortBy = ref({ ...sortByDefault })
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const getAllTasksParams = computed(() => {
|
const getAllTasksParams = computed(() => {
|
||||||
let loadParams = {...params.value}
|
|
||||||
|
|
||||||
if (search.value !== '') {
|
|
||||||
loadParams.s = search.value
|
|
||||||
}
|
|
||||||
|
|
||||||
loadParams = formatSortOrder(sortBy.value, loadParams)
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{projectId: projectId.value},
|
{projectId: projectId.value},
|
||||||
loadParams,
|
// TODO_OFFLINE still need sorting by position.
|
||||||
|
{},
|
||||||
page.value || 1,
|
page.value || 1,
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
@ -103,10 +88,7 @@ export function useTaskList(projectId, sortByDefault: SortBy = SORT_BY_DEFAULT)
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
watch(() => route.query, (query) => {
|
watch(() => route.query, (query) => {
|
||||||
const { page: pageQueryValue, search: searchQuery } = query
|
const { page: pageQueryValue } = query
|
||||||
if (searchQuery !== undefined) {
|
|
||||||
search.value = searchQuery as string
|
|
||||||
}
|
|
||||||
if (pageQueryValue !== undefined) {
|
if (pageQueryValue !== undefined) {
|
||||||
page.value = Number(pageQueryValue)
|
page.value = Number(pageQueryValue)
|
||||||
}
|
}
|
||||||
|
@ -129,8 +111,5 @@ export function useTaskList(projectId, sortByDefault: SortBy = SORT_BY_DEFAULT)
|
||||||
totalPages,
|
totalPages,
|
||||||
currentPage: page,
|
currentPage: page,
|
||||||
loadTasks,
|
loadTasks,
|
||||||
searchTerm: search,
|
|
||||||
params,
|
|
||||||
sortByParam: sortBy,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -5,6 +5,7 @@ const API_DEFAULT_PORT = '3456'
|
||||||
export const ERROR_NO_API_URL = 'noApiUrlProvided'
|
export const ERROR_NO_API_URL = 'noApiUrlProvided'
|
||||||
|
|
||||||
|
|
||||||
|
// TODO_OFFLINE remove?
|
||||||
export const checkAndSetApiUrl = (url: string): Promise<string> => {
|
export const checkAndSetApiUrl = (url: string): Promise<string> => {
|
||||||
if (url.startsWith('/')) {
|
if (url.startsWith('/')) {
|
||||||
url = window.location.host + url
|
url = window.location.host + url
|
||||||
|
|
214
src/localBackend/buckets.ts
Normal file
214
src/localBackend/buckets.ts
Normal file
|
@ -0,0 +1,214 @@
|
||||||
|
import type { IBucket } from '@/modelTypes/IBucket'
|
||||||
|
import { ensureCreateSyncedYDoc } from './sync'
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAllBucketsWithoutTasks(): Promise<IBucketWithoutTasks[]> {
|
||||||
|
const fromYdoc = (await ensureCreateSyncedYDoc())
|
||||||
|
// Yes, a map with an only element. I'm too lazy to make it a Y.Array.
|
||||||
|
.getMap('bucketsMap')
|
||||||
|
.get('buckets')
|
||||||
|
|
||||||
|
// const fromStorage = localStorage.getItem('buckets')
|
||||||
|
if (!fromYdoc) {
|
||||||
|
// 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 fromYdoc
|
||||||
|
}
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * Mutates `bucket` and returns it.
|
||||||
|
// */
|
||||||
|
// function fillBucketTasks(bucket: IBucketWithoutTasks): IBucket {
|
||||||
|
// (bucket as IBucket).tasks = getAllTas
|
||||||
|
// }
|
||||||
|
|
||||||
|
async function getAllBuckets(): Promise<IBucket[]> {
|
||||||
|
const bucketsWithoutTasks = await getAllBucketsWithoutTasks()
|
||||||
|
const buckets = bucketsWithoutTasks.map(async bucketWithoutTasks => {
|
||||||
|
const b = bucketWithoutTasks as IBucket
|
||||||
|
b.tasks = (await 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 Promise.all(buckets)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAllBucketsOfProject(projectId: number): Promise<IBucket[]> {
|
||||||
|
// TODO_OFFLINE filter by position bruh.
|
||||||
|
// TODO_OFFLINE perf: maybe it's not worth getting tasks of each project then in `getAllBuckets`.
|
||||||
|
return (await 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 async function getDefaultBucket(projectId: number): Promise<IBucket> {
|
||||||
|
return (await 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 async function createBucket(bucket: IBucket): Promise<IBucket> {
|
||||||
|
const allBuckets = await 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);
|
||||||
|
(await ensureCreateSyncedYDoc())
|
||||||
|
.getMap('bucketsMap')
|
||||||
|
.set('buckets', allBuckets)
|
||||||
|
// localStorage.setItem('buckets', JSON.stringify(allBuckets))
|
||||||
|
return newBucketFullData
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateBucket(newBucketData: IBucket) {
|
||||||
|
const allBuckets = await 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.
|
||||||
|
newBucketData.tasks = undefined
|
||||||
|
allBuckets.splice(targetBucketInd, 1, newBucketData);
|
||||||
|
(await ensureCreateSyncedYDoc())
|
||||||
|
.getMap('bucketsMap')
|
||||||
|
.set('buckets', allBuckets)
|
||||||
|
// localStorage.setItem('buckets', JSON.stringify(allBuckets))
|
||||||
|
return newBucketData
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBucket({ id }: { id: number }) {
|
||||||
|
const allBuckets = await 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))
|
||||||
|
|
||||||
|
const ydoc = await ensureCreateSyncedYDoc()
|
||||||
|
ydoc
|
||||||
|
.getMap('bucketsMap')
|
||||||
|
.set('buckets', 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 = (await getDefaultBucket(projectId)).id
|
||||||
|
|
||||||
|
// const allTasks = getAllTasks()
|
||||||
|
// allTasks.forEach(t => {
|
||||||
|
// if (t.bucketId === deletedBuckedId) {
|
||||||
|
// t.bucketId = defaultBucketId
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
// localStorage.setItem('tasks', JSON.stringify(allTasks))
|
||||||
|
const tasksYarr = ydoc.getArray('tasks')
|
||||||
|
ydoc.transact(() => {
|
||||||
|
tasksYarr.forEach((t, i) => {
|
||||||
|
if (t.bucketId === deletedBuckedId) {
|
||||||
|
// TODO_OFFLINE not sure if it's ok to mutate the array while `forEach` ing it.
|
||||||
|
t.bucketId = defaultBucketId
|
||||||
|
tasksYarr.delete(i)
|
||||||
|
tasksYarr.insert(i, [t])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// TODO_OFFLINE idk what it's supposed to return
|
||||||
|
return true
|
||||||
|
}
|
122
src/localBackend/sync.ts
Normal file
122
src/localBackend/sync.ts
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
import * as Y from 'yjs'
|
||||||
|
import { IndexeddbPersistence as YjsIndexeddbPersistence } from 'y-indexeddb'
|
||||||
|
import type { Webxdc as WebxdcGeneric } from './webxdc'
|
||||||
|
|
||||||
|
type Webxdc = WebxdcGeneric<{
|
||||||
|
update: number[],
|
||||||
|
sender: typeof webxdc.selfAddr
|
||||||
|
}>
|
||||||
|
declare const webxdc: Webxdc;
|
||||||
|
// declare global {
|
||||||
|
// interface Window {
|
||||||
|
// webxdc: Webxdc;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns resolves when all the pending updates have been applied
|
||||||
|
*/
|
||||||
|
function initWebxdcSyncProvider(ydoc: Y.Doc): Promise<void> {
|
||||||
|
// TODO_OFFLINE I'm not sure at all if this is correct. Namely:
|
||||||
|
// I'm not sure if it is right to never apply updates that we've already applied
|
||||||
|
// in the previos sessions, as it looks like that we rely on `IndexeddbPersistence`
|
||||||
|
// to save all the applied updates. What happens if it for some reason doesn't manage
|
||||||
|
// to not save some update? In theore we'd have to download it from other peers then, right?
|
||||||
|
// But we don't do it here.
|
||||||
|
//
|
||||||
|
// Better check how existing providers work, like `y-websocket`. Looks like they use
|
||||||
|
// `y-protocols/sync`.
|
||||||
|
|
||||||
|
// const lastAppliedWebxdcUpdateSerialNum = localStorage.getItem('__lastAppliedWebxdcUpdate')
|
||||||
|
const lastAppliedWebxdcUpdateSerialNum = 0
|
||||||
|
|
||||||
|
const setListenerP = webxdc.setUpdateListener(
|
||||||
|
(update) => {
|
||||||
|
// if (update.payload.sender === webxdc.selfAddr) {
|
||||||
|
// return
|
||||||
|
// }
|
||||||
|
console.log('update', update.serial)
|
||||||
|
// TODO_OFFLINE optimize this. Batch updates? ydoc.transact?
|
||||||
|
Y.applyUpdate(ydoc, new Uint8Array(update.payload.update), 'webxdcUpdateHandler')
|
||||||
|
// localStorage.setItem('__lastAppliedWebxdcUpdate', update.serial.toString())
|
||||||
|
},
|
||||||
|
lastAppliedWebxdcUpdateSerialNum
|
||||||
|
? parseInt(lastAppliedWebxdcUpdateSerialNum)
|
||||||
|
: 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO_OFFLINE throttle
|
||||||
|
// TODO not sure if this reacts to the changes
|
||||||
|
ydoc.on('update', (update, origin) => {
|
||||||
|
if (origin === 'webxdcUpdateHandler') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// TODO_OFFLINE conversion to an ordinary array. Not good for performance.
|
||||||
|
// Look up how other Yjs connectors do this. Or
|
||||||
|
// https://docs.yjs.dev/api/document-updates#example-base64-encoding
|
||||||
|
const serializableArray = [...(update as Uint8Array)]
|
||||||
|
webxdc.sendUpdate({
|
||||||
|
payload: {
|
||||||
|
update: serializableArray,
|
||||||
|
sender: webxdc.selfAddr
|
||||||
|
},
|
||||||
|
},
|
||||||
|
''
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
return setListenerP
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maybeInitWebrtcSync(ydoc: Y.Doc) {
|
||||||
|
if (localStorage.getItem('sharingEnabled') !== 'true') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const sharingRoomName = localStorage.getItem('sharingRoomName')
|
||||||
|
if (sharingRoomName == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const sharingRoomPassword = localStorage.getItem('sharingRoomPassword')
|
||||||
|
if (sharingRoomPassword == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { WebrtcProvider } = await import('y-webrtc')
|
||||||
|
// To avoid collisions when people are lazy about coming up with a long enough name.
|
||||||
|
const fullRoomName = `vikunja-${sharingRoomName}`
|
||||||
|
const provider = new WebrtcProvider(fullRoomName, ydoc, { password: sharingRoomPassword })
|
||||||
|
console.log('WebRTC sharing enabled')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSyncedYDoc(): Promise<Y.Doc> {
|
||||||
|
const ydoc = new Y.Doc()
|
||||||
|
|
||||||
|
const indexeddbProvider = new YjsIndexeddbPersistence('p2p-tasks-db', ydoc)
|
||||||
|
// Did not check if we actually need to await
|
||||||
|
await indexeddbProvider.whenSynced
|
||||||
|
|
||||||
|
if (window.webxdc) {
|
||||||
|
// TODO_OFFLINE dynamic import
|
||||||
|
await initWebxdcSyncProvider(ydoc)
|
||||||
|
} else {
|
||||||
|
await maybeInitWebrtcSync(ydoc)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// const lastAppliedWebxdcUpdate = localStorage.getItem('__lastAppliedWebxdcUpdate')
|
||||||
|
// // function onWebxdcUpdate()
|
||||||
|
// const onWebxdcUpdate: Parameters<Webxdc['setUpdateListener']>[0] = (update) => {
|
||||||
|
// // update.
|
||||||
|
// }
|
||||||
|
|
||||||
|
return ydoc
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ydoc: Promise<Y.Doc> | undefined = undefined
|
||||||
|
export const ensureCreateSyncedYDoc = () => {
|
||||||
|
if (!_ydoc) {
|
||||||
|
// TODO_OFFLINE fix: if you call it twice in a row before it resolves,
|
||||||
|
// `createSyncedYDoc` will be exected twice. Don't await.
|
||||||
|
_ydoc = createSyncedYDoc()
|
||||||
|
}
|
||||||
|
return _ydoc
|
||||||
|
}
|
175
src/localBackend/tasks.ts
Normal file
175
src/localBackend/tasks.ts
Normal file
|
@ -0,0 +1,175 @@
|
||||||
|
import type { ITask } from '@/modelTypes/ITask'
|
||||||
|
import * as Y from 'yjs'
|
||||||
|
import { getDefaultBucket } from './buckets'
|
||||||
|
import { ensureCreateSyncedYDoc } from './sync'
|
||||||
|
import { defaultPositionIfZero } from './utils/calculateDefaultPosition'
|
||||||
|
|
||||||
|
// createSyncedYDoc().then(d => {
|
||||||
|
// window.ydoc = d
|
||||||
|
// console.log(d)
|
||||||
|
// })
|
||||||
|
|
||||||
|
// 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 async function getAllTasks(): Promise<ITask[]> {
|
||||||
|
// const fromStorage = localStorage.getItem('tasks')
|
||||||
|
// if (!fromStorage) {
|
||||||
|
// return []
|
||||||
|
// }
|
||||||
|
// const tasks: ITask[] = JSON.parse(fromStorage)
|
||||||
|
|
||||||
|
const fromYdoc = (await ensureCreateSyncedYDoc()).getArray('tasks')
|
||||||
|
|
||||||
|
const tasks = fromYdoc.toArray() as ITask[]
|
||||||
|
// 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 async function getTasksOfProject<PID extends number>(projectId: PID): Promise<(ITask & { projectId: PID })[]> {
|
||||||
|
const tasks: ITask[] = (await getAllTasks()).filter(t => t.projectId === projectId)
|
||||||
|
return tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsorted
|
||||||
|
*/
|
||||||
|
export async function getTasksOfBucket<BID extends number>(
|
||||||
|
bucketId: BID,
|
||||||
|
): Promise<Array<ITask & { bucketId: BID} >> {
|
||||||
|
const tasks: ITask[] = (await getAllTasks()).filter(t => t.bucketId === bucketId)
|
||||||
|
return tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://kolaente.dev/vikunja/api/src/commit/066c26f83e1e066bdc9d80b4642db1df0d6a77eb/pkg/models/tasks.go#L913-L990
|
||||||
|
*/
|
||||||
|
export async function createTask(newTask: ITask): Promise<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 > 0
|
||||||
|
? newTask.bucketId
|
||||||
|
: (await getDefaultBucket(newTask.projectId)).id,
|
||||||
|
};
|
||||||
|
|
||||||
|
(await ensureCreateSyncedYDoc())
|
||||||
|
.getArray('tasks')
|
||||||
|
.unshift([newTaskFullData])
|
||||||
|
// .unshift([new Y.Map(newTaskFullData)])
|
||||||
|
|
||||||
|
// (await allTasks).unshift(newTaskFullData)
|
||||||
|
// localStorage.setItem('tasks', JSON.stringify(allTasks))
|
||||||
|
return newTaskFullData
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTask(taskId: number) {
|
||||||
|
return (await getAllTasks()).find(t => t.id === taskId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async 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
|
||||||
|
|
||||||
|
|
||||||
|
// let targetTask
|
||||||
|
// const asd = (await ensureCreateSyncedYDoc()).getArray('tasks').forEach(_t => {
|
||||||
|
// // const t = _t as Y.Map<ITask>
|
||||||
|
// const t = _t
|
||||||
|
// if (t.get('id') === newTaskData.id) {
|
||||||
|
// targetTask = t
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
|
||||||
|
const tasksArr = (await ensureCreateSyncedYDoc()).getArray('tasks')
|
||||||
|
|
||||||
|
let targetTaskInd = -1
|
||||||
|
tasksArr.forEach((_t, i) => {
|
||||||
|
// const t = _t as Y.Map<ITask>
|
||||||
|
const t = _t
|
||||||
|
if (t.id === newTaskData.id) {
|
||||||
|
targetTaskInd = i;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// const allTasks = await 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
|
||||||
|
}
|
||||||
|
|
||||||
|
tasksArr.doc!.transact(() => {
|
||||||
|
// TODO_OFFLINE consider mutating the task instead.
|
||||||
|
tasksArr.delete(targetTaskInd)
|
||||||
|
tasksArr.insert(targetTaskInd, [newTaskData])
|
||||||
|
})
|
||||||
|
|
||||||
|
// allTasks.splice(targetTaskInd, 1, newTaskData)
|
||||||
|
// localStorage.setItem('tasks', JSON.stringify(allTasks))
|
||||||
|
return newTaskData
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTask({ id }: { id: number }) {
|
||||||
|
const tasksArr = (await ensureCreateSyncedYDoc()).getArray('tasks')
|
||||||
|
|
||||||
|
let targetTaskInd = -1
|
||||||
|
tasksArr.forEach((_t, i) => {
|
||||||
|
// const t = _t as Y.Map<ITask>
|
||||||
|
const t = _t
|
||||||
|
if (t.id === id) {
|
||||||
|
targetTaskInd = i;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (targetTaskInd < 0) {
|
||||||
|
console.warn('Tried to delete a task, but it does not exist')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// tasksArr.delete(targetTaskInd)
|
||||||
|
|
||||||
|
// const allTasks = await 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
|
||||||
|
}
|
12
src/localBackend/utils/calculateDefaultPosition.ts
Normal file
12
src/localBackend/utils/calculateDefaultPosition.ts
Normal 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
|
||||||
|
}
|
71
src/localBackend/webxdc.d.ts
vendored
Normal file
71
src/localBackend/webxdc.d.ts
vendored
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
//@ts-check
|
||||||
|
|
||||||
|
type SendingStatusUpdate<T> = {
|
||||||
|
/** the payload, deserialized json:
|
||||||
|
* any javascript primitive, array or object. */
|
||||||
|
payload: T;
|
||||||
|
/** optional, short, informational message that will be added to the chat,
|
||||||
|
* eg. "Alice voted" or "Bob scored 123 in MyGame";
|
||||||
|
* usually only one line of text is shown,
|
||||||
|
* use this option sparingly to not spam the chat. */
|
||||||
|
info?: string;
|
||||||
|
/** optional, if the Webxdc creates a document, you can set this to the name of the document;
|
||||||
|
* do not set if the Webxdc does not create a document */
|
||||||
|
document?: string;
|
||||||
|
/** optional, short text, shown beside the icon;
|
||||||
|
* it is recommended to use some aggregated value,
|
||||||
|
* eg. "8 votes", "Highscore: 123" */
|
||||||
|
summary?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReceivedStatusUpdate<T> = {
|
||||||
|
/** the payload, deserialized json */
|
||||||
|
payload: T;
|
||||||
|
/** the serial number of this update. Serials are larger than 0 and newer serials have higher numbers */
|
||||||
|
serial: number;
|
||||||
|
/** the maximum serial currently known */
|
||||||
|
max_serial: number;
|
||||||
|
/** optional, short, informational message. */
|
||||||
|
info?: string;
|
||||||
|
/** optional, if the Webxdc creates a document, this is the name of the document;
|
||||||
|
* not set if the Webxdc does not create a document */
|
||||||
|
document?: string;
|
||||||
|
/** optional, short text, shown beside the webxdc's icon. */
|
||||||
|
summary?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Webxdc<T> {
|
||||||
|
/** Returns the peer's own address.
|
||||||
|
* This is esp. useful if you want to differ between different peers - just send the address along with the payload,
|
||||||
|
* and, if needed, compare the payload addresses against selfAddr() later on. */
|
||||||
|
selfAddr: string;
|
||||||
|
/** Returns the peer's own name. This is name chosen by the user in their settings, if there is nothing set, that defaults to the peer's address. */
|
||||||
|
selfName: string;
|
||||||
|
/**
|
||||||
|
* set a listener for new status updates.
|
||||||
|
* The "serial" specifies the last serial that you know about (defaults to 0).
|
||||||
|
* Note that own status updates, that you send with {@link sendUpdate}, also trigger this method
|
||||||
|
* @returns promise that resolves when the listener has processed all the update messages known at the time when `setUpdateListener` was called.
|
||||||
|
* */
|
||||||
|
setUpdateListener(cb: (statusUpdate: ReceivedStatusUpdate<T>) => void, serial?: number): Promise<void>;
|
||||||
|
/**
|
||||||
|
* @deprecated See {@link setUpdateListener|`setUpdateListener()`}.
|
||||||
|
*/
|
||||||
|
getAllUpdates(): Promise<ReceivedStatusUpdate<T>[]>;
|
||||||
|
/**
|
||||||
|
* Webxdc are usually shared in a chat and run independently on each peer. To get a shared status, the peers use sendUpdate() to send updates to each other.
|
||||||
|
* @param update status update to send
|
||||||
|
* @param description short, human-readable description what this update is about. this is shown eg. as a fallback text in an email program.
|
||||||
|
*/
|
||||||
|
sendUpdate(update: SendingStatusUpdate<T>, description: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
////////// ANCHOR: global
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
webxdc: Webxdc<any>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
////////// ANCHOR_END: global
|
||||||
|
|
||||||
|
export { SendingStatusUpdate, ReceivedStatusUpdate, Webxdc };
|
|
@ -1,4 +1,4 @@
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router'
|
||||||
import type { RouteLocation } from 'vue-router'
|
import type { RouteLocation } from 'vue-router'
|
||||||
import {saveLastVisited} from '@/helpers/saveLastVisited'
|
import {saveLastVisited} from '@/helpers/saveLastVisited'
|
||||||
|
|
||||||
|
@ -15,9 +15,6 @@ import HomeComponent from '@/views/Home.vue'
|
||||||
import NotFoundComponent from '@/views/404.vue'
|
import NotFoundComponent from '@/views/404.vue'
|
||||||
const About = () => import('@/views/About.vue')
|
const About = () => import('@/views/About.vue')
|
||||||
// User Handling
|
// 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')
|
const DataExportDownload = () => import('@/views/user/DataExportDownload.vue')
|
||||||
// Tasks
|
// Tasks
|
||||||
import UpcomingTasksComponent from '@/views/tasks/ShowTasks.vue'
|
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')
|
const ProjectInfo = () => import('@/views/project/ProjectInfo.vue')
|
||||||
|
|
||||||
// Project Settings
|
// 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
|
// Namespace Settings
|
||||||
const NamespaceSettingEdit = () => import('@/views/namespaces/settings/edit.vue')
|
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')
|
const NamespaceSettingDelete = () => import('@/views/namespaces/settings/delete.vue')
|
||||||
|
|
||||||
// Saved Filters
|
// 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 UserSettingsComponent = () => import('@/views/user/Settings.vue')
|
||||||
const UserSettingsAvatarComponent = () => import('@/views/user/settings/Avatar.vue')
|
const UserSettingsAvatarComponent = () => import('@/views/user/settings/Avatar.vue')
|
||||||
const UserSettingsCaldavComponent = () => import('@/views/user/settings/Caldav.vue')
|
const UserSettingsCaldavComponent = () => import('@/views/user/settings/Caldav.vue')
|
||||||
const UserSettingsDataExportComponent = () => import('@/views/user/settings/DataExport.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 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
|
// Project Handling
|
||||||
const NewProjectComponent = () => import('@/views/project/NewProject.vue')
|
const NewProjectComponent = () => import('@/views/project/NewProject.vue')
|
||||||
|
@ -81,7 +63,10 @@ const EditTeamComponent = () => import('@/views/teams/EditTeam.vue')
|
||||||
const NewTeamComponent = () => import('@/views/teams/NewTeam.vue')
|
const NewTeamComponent = () => import('@/views/teams/NewTeam.vue')
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
// Using `createWebHashHistory` instead of `createWebHistory` so it can
|
||||||
|
// properly work on GitHub pages and such.
|
||||||
|
// TODO_OFFLINE make it configurable, make MR to the upstream repo.
|
||||||
|
history: createWebHashHistory(import.meta.env.BASE_URL),
|
||||||
scrollBehavior(to, from, savedPosition) {
|
scrollBehavior(to, from, savedPosition) {
|
||||||
// If the user is using their forward/backward keys to navigate, we want to restore the scroll view
|
// If the user is using their forward/backward keys to navigate, we want to restore the scroll view
|
||||||
if (savedPosition) {
|
if (savedPosition) {
|
||||||
|
@ -96,11 +81,17 @@ const router = createRouter({
|
||||||
// Otherwise just scroll to the top
|
// Otherwise just scroll to the top
|
||||||
return {left: 0, top: 0}
|
return {left: 0, top: 0}
|
||||||
},
|
},
|
||||||
|
// TODO_OFFLINE remove the references of the removed routes.
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
name: 'home',
|
name: 'home',
|
||||||
component: HomeComponent,
|
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(.*)*',
|
path: '/:pathMatch(.*)*',
|
||||||
|
@ -113,38 +104,6 @@ const router = createRouter({
|
||||||
name: 'bad-not-found',
|
name: 'bad-not-found',
|
||||||
component: NotFoundComponent,
|
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',
|
path: '/user/settings',
|
||||||
name: 'user.settings',
|
name: 'user.settings',
|
||||||
|
@ -166,31 +125,11 @@ const router = createRouter({
|
||||||
name: 'user.settings.data-export',
|
name: 'user.settings.data-export',
|
||||||
component: UserSettingsDataExportComponent,
|
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',
|
path: '/user/settings/general',
|
||||||
name: 'user.settings.general',
|
name: 'user.settings.general',
|
||||||
component: UserSettingsGeneralComponent,
|
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 +228,6 @@ const router = createRouter({
|
||||||
showAsModal: true,
|
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',
|
path: '/projects/:projectId/info',
|
||||||
name: 'project.info',
|
name: 'project.info',
|
||||||
|
@ -464,19 +336,6 @@ const router = createRouter({
|
||||||
code: route.query.code as string,
|
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',
|
path: '/about',
|
||||||
name: 'about',
|
name: 'about',
|
||||||
|
@ -485,6 +344,7 @@ const router = createRouter({
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// TODO_OFFLINE Remove this function?
|
||||||
export async function getAuthForRoute(route: RouteLocation) {
|
export async function getAuthForRoute(route: RouteLocation) {
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
if (authStore.authUser || authStore.authLinkShare) {
|
if (authStore.authUser || authStore.authLinkShare) {
|
||||||
|
|
|
@ -4,7 +4,7 @@ import type {Method} from 'axios'
|
||||||
import {objectToSnakeCase} from '@/helpers/case'
|
import {objectToSnakeCase} from '@/helpers/case'
|
||||||
import AbstractModel from '@/models/abstractModel'
|
import AbstractModel from '@/models/abstractModel'
|
||||||
import type {IAbstract} from '@/modelTypes/IAbstract'
|
import type {IAbstract} from '@/modelTypes/IAbstract'
|
||||||
import type {Right} from '@/constants/rights'
|
import {RIGHTS} from '@/constants/rights'
|
||||||
|
|
||||||
interface Paths {
|
interface Paths {
|
||||||
create : string
|
create : string
|
||||||
|
@ -268,12 +268,34 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
|
||||||
* @param params Optional query parameters
|
* @param params Optional query parameters
|
||||||
*/
|
*/
|
||||||
get(model : Model, params = {}) {
|
get(model : Model, params = {}) {
|
||||||
|
if (this._get) {
|
||||||
|
return this.get_Offline(...arguments);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.paths.get === '') {
|
if (this.paths.get === '') {
|
||||||
throw new Error('This model is not able to get data.')
|
throw new Error('This model is not able to get data.')
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.getM(this.paths.get, model, params)
|
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.
|
* 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
|
* @param page The page to get
|
||||||
*/
|
*/
|
||||||
async getAll(model : Model = new AbstractModel({}), params = {}, page = 1): Promise<Model[]> {
|
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 === '') {
|
if (this.paths.getAll === '') {
|
||||||
throw new Error('This model is not able to get data.')
|
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()
|
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
|
* Performs a put request to the url specified before
|
||||||
* @returns {Promise<any | never>}
|
* @returns {Promise<any | never>}
|
||||||
*/
|
*/
|
||||||
async create(model : Model) {
|
async create(model : Model) {
|
||||||
|
if (this._create) {
|
||||||
|
return this.create_Offline(...arguments)
|
||||||
|
}
|
||||||
|
|
||||||
if (this.paths.create === '') {
|
if (this.paths.create === '') {
|
||||||
throw new Error('This model is not able to create data.')
|
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()
|
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.
|
* 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
|
* Performs a post request to the update url
|
||||||
*/
|
*/
|
||||||
update(model : Model) {
|
update(model : Model) {
|
||||||
|
if (this._update) {
|
||||||
|
return this.update_Offline(...arguments)
|
||||||
|
}
|
||||||
|
|
||||||
if (this.paths.update === '') {
|
if (this.paths.update === '') {
|
||||||
throw new Error('This model is not able to update data.')
|
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)
|
const finalUrl = this.getReplacedRoute(this.paths.update, model)
|
||||||
return this.post(finalUrl, 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
|
* Performs a delete request to the update url
|
||||||
*/
|
*/
|
||||||
async delete(model : Model) {
|
async delete(model : Model) {
|
||||||
|
if (this._delete) {
|
||||||
|
return this.delete_Offline(...arguments)
|
||||||
|
}
|
||||||
|
|
||||||
if (this.paths.delete === '') {
|
if (this.paths.delete === '') {
|
||||||
throw new Error('This model is not able to delete data.')
|
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()
|
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.
|
* Uploads a file to a url.
|
||||||
|
|
|
@ -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})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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]))
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -2,6 +2,7 @@ import AbstractService from './abstractService'
|
||||||
import BucketModel from '../models/bucket'
|
import BucketModel from '../models/bucket'
|
||||||
import TaskService from '@/services/task'
|
import TaskService from '@/services/task'
|
||||||
import type { IBucket } from '@/modelTypes/IBucket'
|
import type { IBucket } from '@/modelTypes/IBucket'
|
||||||
|
import { createBucket, deleteBucket, getAllBucketsOfProject, updateBucket } from '@/localBackend/buckets'
|
||||||
|
|
||||||
export default class BucketService extends AbstractService<IBucket> {
|
export default class BucketService extends AbstractService<IBucket> {
|
||||||
constructor() {
|
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>) {
|
modelFactory(data: Partial<IBucket>) {
|
||||||
return new BucketModel(data)
|
return new BucketModel(data)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +0,0 @@
|
||||||
import AbstractService from './abstractService'
|
|
||||||
|
|
||||||
export default class EmailUpdateService extends AbstractService {
|
|
||||||
constructor() {
|
|
||||||
super({
|
|
||||||
update: '/user/settings/email',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -3,17 +3,71 @@ import LabelModel from '@/models/label'
|
||||||
import type {ILabel} from '@/modelTypes/ILabel'
|
import type {ILabel} from '@/modelTypes/ILabel'
|
||||||
import {colorFromHex} from '@/helpers/color/colorFromHex'
|
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> {
|
export default class LabelService extends AbstractService<ILabel> {
|
||||||
constructor() {
|
constructor() {
|
||||||
super({
|
super({
|
||||||
create: '/labels',
|
// create: '/labels',
|
||||||
getAll: '/labels',
|
getAll: '/labels',
|
||||||
get: '/labels/{id}',
|
// get: '/labels/{id}',
|
||||||
update: '/labels/{id}',
|
// update: '/labels/{id}',
|
||||||
delete: '/labels/{id}',
|
// delete: '/labels/{id}',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_getAll = () => {
|
||||||
|
return getAllLabels()
|
||||||
|
}
|
||||||
|
|
||||||
processModel(label) {
|
processModel(label) {
|
||||||
label.created = new Date(label.created).toISOString()
|
label.created = new Date(label.created).toISOString()
|
||||||
label.updated = new Date(label.updated).toISOString()
|
label.updated = new Date(label.updated).toISOString()
|
||||||
|
|
|
@ -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) {
|
modelFactory(data) {
|
||||||
return new NamespaceModel(data)
|
return new NamespaceModel(data)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -4,17 +4,53 @@ import type {IProject} from '@/modelTypes/IProject'
|
||||||
import TaskService from './task'
|
import TaskService from './task'
|
||||||
import {colorFromHex} from '@/helpers/color/colorFromHex'
|
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> {
|
export default class ProjectService extends AbstractService<IProject> {
|
||||||
constructor() {
|
constructor() {
|
||||||
super({
|
super({
|
||||||
create: '/namespaces/{namespaceId}/projects',
|
// create: '/namespaces/{namespaceId}/projects',
|
||||||
get: '/projects/{id}',
|
get: '/projects/{id}',
|
||||||
getAll: '/projects',
|
getAll: '/projects',
|
||||||
update: '/projects/{id}',
|
// update: '/projects/{id}',
|
||||||
delete: '/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) {
|
modelFactory(data) {
|
||||||
return new ProjectModel(data)
|
return new ProjectModel(data)
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import LabelService from './label'
|
||||||
|
|
||||||
import {colorFromHex} from '@/helpers/color/colorFromHex'
|
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 {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 => {
|
const parseDate = date => {
|
||||||
if (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) {
|
modelFactory(data) {
|
||||||
return new TaskModel(data)
|
return new TaskModel(data)
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ import AbstractService from '@/services/abstractService'
|
||||||
import TaskModel from '@/models/task'
|
import TaskModel from '@/models/task'
|
||||||
|
|
||||||
import type {ITask} from '@/modelTypes/ITask'
|
import type {ITask} from '@/modelTypes/ITask'
|
||||||
|
import { getTasksOfProject } from '@/localBackend/tasks'
|
||||||
|
|
||||||
// FIXME: unite with other filter params types
|
// FIXME: unite with other filter params types
|
||||||
export interface GetAllTasksParams {
|
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) {
|
modelFactory(data) {
|
||||||
return new TaskModel(data)
|
return new TaskModel(data)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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])
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -208,22 +208,8 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const jwt = getToken()
|
const isAuthenticated = true
|
||||||
let isAuthenticated = false
|
await refreshUserInfo()
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setAuthenticated(isAuthenticated)
|
setAuthenticated(isAuthenticated)
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
|
@ -235,19 +221,35 @@ export const useAuthStore = defineStore('auth', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshUserInfo() {
|
async function refreshUserInfo() {
|
||||||
const jwt = getToken()
|
|
||||||
if (!jwt) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const HTTP = AuthenticatedHTTPFactory()
|
|
||||||
try {
|
try {
|
||||||
const response = await HTTP.get('user')
|
// TODO_OFFLINE
|
||||||
const newUser = new UserModel({
|
const newUser = new UserModel({
|
||||||
...response.data,
|
maxRight: null,
|
||||||
...(info.value?.type && {type: info.value?.type}),
|
id: 1,
|
||||||
...(info.value?.email && {email: info.value?.email}),
|
email: 'demo@vikunja.io',
|
||||||
...(info.value?.exp && {exp: info.value?.exp}),
|
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)
|
setUser(newUser)
|
||||||
|
|
|
@ -80,10 +80,48 @@ export const useConfigStore = defineStore('config', () => {
|
||||||
Object.assign(state, config)
|
Object.assign(state, config)
|
||||||
}
|
}
|
||||||
async function update(): Promise<boolean> {
|
async function update(): Promise<boolean> {
|
||||||
const HTTP = HTTPFactory()
|
// TODO_OFFLINE it's just a stub currently
|
||||||
const {data: config} = await HTTP.get('info')
|
setConfig({
|
||||||
setConfig(objectToCamelCase(config))
|
version: 'None whatsoever',
|
||||||
const success = !!config
|
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
|
return success
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -22,9 +22,7 @@
|
||||||
<x-button @click="setDefaultFilters">Reset</x-button>
|
<x-button @click="setDefaultFilters">Reset</x-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<fancycheckbox class="is-block" v-model="filters.showTasksWithoutDates">
|
<div></div>
|
||||||
{{ $t('project.gantt.showTasksWithoutDates') }}
|
|
||||||
</fancycheckbox>
|
|
||||||
</div>
|
</div>
|
||||||
</card>
|
</card>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -5,11 +5,6 @@
|
||||||
viewName="kanban"
|
viewName="kanban"
|
||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="filter-container" v-if="!isSavedFilter(project)">
|
|
||||||
<div class="items">
|
|
||||||
<filter-popup v-model="params" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #default>
|
<template #default>
|
||||||
|
|
|
@ -1,50 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<ProjectWrapper class="project-list" :project-id="projectId" viewName="project">
|
<ProjectWrapper class="project-list" :project-id="projectId" viewName="project">
|
||||||
<template #header>
|
<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>
|
||||||
|
|
||||||
<template #default>
|
<template #default>
|
||||||
|
@ -152,7 +108,6 @@ const props = defineProps({
|
||||||
})
|
})
|
||||||
|
|
||||||
const ctaVisible = ref(false)
|
const ctaVisible = ref(false)
|
||||||
const showTaskSearch = ref(false)
|
|
||||||
|
|
||||||
const drag = ref(false)
|
const drag = ref(false)
|
||||||
const DRAG_OPTIONS = {
|
const DRAG_OPTIONS = {
|
||||||
|
@ -160,21 +115,20 @@ const DRAG_OPTIONS = {
|
||||||
ghostClass: 'task-ghost',
|
ghostClass: 'task-ghost',
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
// TODO_OFFLINE load tasks from somewhere else
|
||||||
const {
|
const {
|
||||||
tasks,
|
tasks,
|
||||||
loading,
|
loading,
|
||||||
totalPages,
|
totalPages,
|
||||||
currentPage,
|
currentPage,
|
||||||
loadTasks,
|
loadTasks,
|
||||||
searchTerm,
|
// searchTerm,
|
||||||
params,
|
// params,
|
||||||
sortByParam,
|
// sortByParam,
|
||||||
} = useTaskList(toRef(props, 'projectId'), {position: 'asc' })
|
} = useTaskList(toRef(props, 'projectId'), {position: 'asc' })
|
||||||
|
|
||||||
|
|
||||||
const isAlphabeticalSorting = computed(() => {
|
const isAlphabeticalSorting = computed(() => false)
|
||||||
return params.value.sort_by.find(sortBy => sortBy === ALPHABETICAL_SORT) !== undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
const firstNewPosition = computed(() => {
|
const firstNewPosition = computed(() => {
|
||||||
if (tasks.value.length === 0) {
|
if (tasks.value.length === 0) {
|
||||||
|
@ -200,29 +154,6 @@ onMounted(async () => {
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
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)
|
const addTaskRef = ref<typeof AddTask | null>(null)
|
||||||
function focusNewTaskInput() {
|
function focusNewTaskInput() {
|
||||||
addTaskRef.value?.focusTaskInput()
|
addTaskRef.value?.focusTaskInput()
|
||||||
|
@ -267,15 +198,6 @@ async function saveTaskPosition(e) {
|
||||||
const updatedTask = await taskStore.update(newTask)
|
const updatedTask = await taskStore.update(newTask)
|
||||||
tasks.value[e.newIndex] = updatedTask
|
tasks.value[e.newIndex] = updatedTask
|
||||||
}
|
}
|
||||||
|
|
||||||
function prepareFiltersAndLoadTasks() {
|
|
||||||
if(isAlphabeticalSorting.value) {
|
|
||||||
sortByParam.value = {}
|
|
||||||
sortByParam.value[ALPHABETICAL_SORT] = 'asc'
|
|
||||||
}
|
|
||||||
|
|
||||||
loadTasks()
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|
|
@ -1,63 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<ProjectWrapper class="project-table" :project-id="projectId" viewName="table">
|
<ProjectWrapper class="project-table" :project-id="projectId" viewName="table">
|
||||||
<template #header>
|
<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>
|
||||||
|
|
||||||
<template #default>
|
<template #default>
|
||||||
|
@ -69,19 +12,15 @@
|
||||||
<tr>
|
<tr>
|
||||||
<th v-if="activeColumns.index">
|
<th v-if="activeColumns.index">
|
||||||
#
|
#
|
||||||
<Sort :order="sortBy.index" @click="sort('index')"/>
|
|
||||||
</th>
|
</th>
|
||||||
<th v-if="activeColumns.done">
|
<th v-if="activeColumns.done">
|
||||||
{{ $t('task.attributes.done') }}
|
{{ $t('task.attributes.done') }}
|
||||||
<Sort :order="sortBy.done" @click="sort('done')"/>
|
|
||||||
</th>
|
</th>
|
||||||
<th v-if="activeColumns.title">
|
<th v-if="activeColumns.title">
|
||||||
{{ $t('task.attributes.title') }}
|
{{ $t('task.attributes.title') }}
|
||||||
<Sort :order="sortBy.title" @click="sort('title')"/>
|
|
||||||
</th>
|
</th>
|
||||||
<th v-if="activeColumns.priority">
|
<th v-if="activeColumns.priority">
|
||||||
{{ $t('task.attributes.priority') }}
|
{{ $t('task.attributes.priority') }}
|
||||||
<Sort :order="sortBy.priority" @click="sort('priority')"/>
|
|
||||||
</th>
|
</th>
|
||||||
<th v-if="activeColumns.labels">
|
<th v-if="activeColumns.labels">
|
||||||
{{ $t('task.attributes.labels') }}
|
{{ $t('task.attributes.labels') }}
|
||||||
|
@ -91,27 +30,21 @@
|
||||||
</th>
|
</th>
|
||||||
<th v-if="activeColumns.dueDate">
|
<th v-if="activeColumns.dueDate">
|
||||||
{{ $t('task.attributes.dueDate') }}
|
{{ $t('task.attributes.dueDate') }}
|
||||||
<Sort :order="sortBy.due_date" @click="sort('due_date')"/>
|
|
||||||
</th>
|
</th>
|
||||||
<th v-if="activeColumns.startDate">
|
<th v-if="activeColumns.startDate">
|
||||||
{{ $t('task.attributes.startDate') }}
|
{{ $t('task.attributes.startDate') }}
|
||||||
<Sort :order="sortBy.start_date" @click="sort('start_date')"/>
|
|
||||||
</th>
|
</th>
|
||||||
<th v-if="activeColumns.endDate">
|
<th v-if="activeColumns.endDate">
|
||||||
{{ $t('task.attributes.endDate') }}
|
{{ $t('task.attributes.endDate') }}
|
||||||
<Sort :order="sortBy.end_date" @click="sort('end_date')"/>
|
|
||||||
</th>
|
</th>
|
||||||
<th v-if="activeColumns.percentDone">
|
<th v-if="activeColumns.percentDone">
|
||||||
{{ $t('task.attributes.percentDone') }}
|
{{ $t('task.attributes.percentDone') }}
|
||||||
<Sort :order="sortBy.percent_done" @click="sort('percent_done')"/>
|
|
||||||
</th>
|
</th>
|
||||||
<th v-if="activeColumns.created">
|
<th v-if="activeColumns.created">
|
||||||
{{ $t('task.attributes.created') }}
|
{{ $t('task.attributes.created') }}
|
||||||
<Sort :order="sortBy.created" @click="sort('created')"/>
|
|
||||||
</th>
|
</th>
|
||||||
<th v-if="activeColumns.updated">
|
<th v-if="activeColumns.updated">
|
||||||
{{ $t('task.attributes.updated') }}
|
{{ $t('task.attributes.updated') }}
|
||||||
<Sort :order="sortBy.updated" @click="sort('updated')"/>
|
|
||||||
</th>
|
</th>
|
||||||
<th v-if="activeColumns.createdBy">
|
<th v-if="activeColumns.createdBy">
|
||||||
{{ $t('task.attributes.createdBy') }}
|
{{ $t('task.attributes.createdBy') }}
|
||||||
|
@ -231,35 +164,22 @@ const SORT_BY_DEFAULT: SortBy = {
|
||||||
const activeColumns = useStorage('tableViewColumns', {...ACTIVE_COLUMNS_DEFAULT})
|
const activeColumns = useStorage('tableViewColumns', {...ACTIVE_COLUMNS_DEFAULT})
|
||||||
const sortBy = useStorage<SortBy>('tableViewSortBy', {...SORT_BY_DEFAULT})
|
const sortBy = useStorage<SortBy>('tableViewSortBy', {...SORT_BY_DEFAULT})
|
||||||
|
|
||||||
const taskList = useTaskList(toRef(props, 'projectId'), sortBy.value)
|
const taskList = useTaskList(toRef(props, 'projectId'))
|
||||||
|
|
||||||
const {
|
const {
|
||||||
loading,
|
loading,
|
||||||
params,
|
// params,
|
||||||
totalPages,
|
totalPages,
|
||||||
currentPage,
|
currentPage,
|
||||||
sortByParam,
|
// sortByParam,
|
||||||
} = taskList
|
} = taskList
|
||||||
const tasks: Ref<ITask[]> = taskList.tasks
|
const tasks: Ref<ITask[]> = taskList.tasks
|
||||||
|
|
||||||
Object.assign(params.value, {
|
// Object.assign(params.value, {
|
||||||
filter_by: [],
|
// filter_by: [],
|
||||||
filter_value: [],
|
// filter_value: [],
|
||||||
filter_comparator: [],
|
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: re-enable opening task detail in modal
|
// TODO: re-enable opening task detail in modal
|
||||||
// const router = useRouter()
|
// const router = useRouter()
|
||||||
|
|
|
@ -279,8 +279,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Comments -->
|
|
||||||
<comments :can-write="canWrite" :task-id="taskId"/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-one-third action-buttons d-print-none" v-if="canWrite || isModal">
|
<div class="column is-one-third action-buttons d-print-none" v-if="canWrite || isModal">
|
||||||
<template v-if="canWrite">
|
<template v-if="canWrite">
|
||||||
|
@ -295,29 +293,6 @@
|
||||||
>
|
>
|
||||||
{{ task.done ? $t('task.detail.undone') : $t('task.detail.done') }}
|
{{ task.done ? $t('task.detail.undone') : $t('task.detail.done') }}
|
||||||
</x-button>
|
</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
|
<x-button
|
||||||
@click="setFieldActive('priority')"
|
@click="setFieldActive('priority')"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
@ -347,21 +322,6 @@
|
||||||
>
|
>
|
||||||
{{ $t('task.detail.actions.endDate') }}
|
{{ $t('task.detail.actions.endDate') }}
|
||||||
</x-button>
|
</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
|
<x-button
|
||||||
@click="setFieldActive('percentDone')"
|
@click="setFieldActive('percentDone')"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
@ -369,30 +329,6 @@
|
||||||
>
|
>
|
||||||
{{ $t('task.detail.actions.percentDone') }}
|
{{ $t('task.detail.actions.percentDone') }}
|
||||||
</x-button>
|
</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
|
<x-button
|
||||||
@click="setFieldActive('color')"
|
@click="setFieldActive('color')"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|
|
@ -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>
|
|
|
@ -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>
|
|
|
@ -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>
|
|
|
@ -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>
|
|
|
@ -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>
|
|
|
@ -42,44 +42,8 @@ const navigationItems = computed(() => {
|
||||||
title: t('user.settings.general.title'),
|
title: t('user.settings.general.title'),
|
||||||
routeName: 'user.settings.general',
|
routeName: 'user.settings.general',
|
||||||
},
|
},
|
||||||
{
|
// TODO_OFFLINE export/import
|
||||||
title: t('user.settings.newPasswordTitle'),
|
// TODO_OFFLINE caldav
|
||||||
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,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
return items.filter(({condition}) => condition !== false)
|
return items.filter(({condition}) => condition !== false)
|
||||||
|
|
|
@ -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>
|
|
|
@ -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>
|
|
|
@ -1,79 +1,45 @@
|
||||||
<template>
|
<template>
|
||||||
<card :title="$t('user.settings.general.title')" class="general-settings" :loading="loading">
|
<card :title="$t('user.settings.general.title')" class="general-settings" :loading="loading">
|
||||||
<div class="field">
|
<template v-if="isWebxdc">
|
||||||
<label class="label" :for="`newName${id}`">{{ $t('user.settings.general.name') }}</label>
|
<div class="field">
|
||||||
<div class="control">
|
<label class="checkbox">
|
||||||
<input
|
<input type="checkbox" v-model="sharingEnabled"/>
|
||||||
@keyup.enter="updateSettings"
|
Enable sharing
|
||||||
class="input"
|
</label>
|
||||||
:id="`newName${id}`"
|
<!-- TODO_OFFLINE a way to have multiple rooms, some private. -->
|
||||||
:placeholder="$t('user.settings.general.newName')"
|
<p>Everyone who knows the room name and the password will have read and write access to the data over network.</p>
|
||||||
type="text"
|
<p>You need to reload the page after changing the sharing settings.</p>
|
||||||
v-model="settings.name"/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<template v-if="sharingEnabled">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="label">
|
<label class="is-flex is-align-items-center">
|
||||||
{{ $t('user.settings.general.defaultProject') }}
|
Sharing: room name
|
||||||
</label>
|
<input
|
||||||
<project-search v-model="defaultProject"/>
|
class="input"
|
||||||
</div>
|
type="text"
|
||||||
<div class="field">
|
required
|
||||||
<label class="checkbox">
|
v-model="sharingRoomName"
|
||||||
<input type="checkbox" v-model="settings.overdueTasksRemindersEnabled"/>
|
/>
|
||||||
{{ $t('user.settings.general.overdueReminders') }}
|
</label>
|
||||||
</label>
|
</div>
|
||||||
</div>
|
<div class="field">
|
||||||
<div class="field" v-if="settings.overdueTasksRemindersEnabled">
|
<label class="is-flex is-align-items-center">
|
||||||
<label class="label" for="overdueTasksReminderTime">
|
Sharing: room password
|
||||||
{{ $t('user.settings.general.overdueTasksRemindersTime') }}
|
<input
|
||||||
</label>
|
class="input"
|
||||||
<div class="control">
|
type="password"
|
||||||
<input
|
v-model="sharingRoomPassword"
|
||||||
@keyup.enter="updateSettings"
|
/>
|
||||||
class="input"
|
</label>
|
||||||
id="overdueTasksReminderTime"
|
</div>
|
||||||
type="time"
|
</template>
|
||||||
v-model="settings.overdueTasksRemindersTime"/>
|
</template>
|
||||||
</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">
|
<div class="field">
|
||||||
<label class="checkbox">
|
<label class="checkbox">
|
||||||
<input type="checkbox" v-model="playSoundWhenDone"/>
|
<input type="checkbox" v-model="playSoundWhenDone"/>
|
||||||
{{ $t('user.settings.general.playSoundWhenDone') }}
|
{{ $t('user.settings.general.playSoundWhenDone') }}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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">
|
<div class="field">
|
||||||
<label class="is-flex is-align-items-center">
|
<label class="is-flex is-align-items-center">
|
||||||
<span>
|
<span>
|
||||||
|
@ -91,6 +57,7 @@
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- TODO support week start, others that are possible -->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="is-flex is-align-items-center">
|
<label class="is-flex is-align-items-center">
|
||||||
<span>
|
<span>
|
||||||
|
@ -120,20 +87,6 @@
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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
|
<x-button
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
|
@ -220,6 +173,11 @@ function getPlaySoundWhenDoneSetting() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const playSoundWhenDone = ref(getPlaySoundWhenDoneSetting())
|
const playSoundWhenDone = ref(getPlaySoundWhenDoneSetting())
|
||||||
|
// TODO_OFFLINE refactor: DRY
|
||||||
|
const isWebxdc = window.webxdc == undefined
|
||||||
|
const sharingEnabled = ref(localStorage.getItem('sharingEnabled') === 'true')
|
||||||
|
const sharingRoomName = ref(localStorage.getItem('sharingRoomName') ?? '')
|
||||||
|
const sharingRoomPassword = ref(localStorage.getItem('sharingRoomPassword') ?? '')
|
||||||
const quickAddMagicMode = ref(getQuickAddMagicMode())
|
const quickAddMagicMode = ref(getQuickAddMagicMode())
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
@ -259,6 +217,13 @@ watch(
|
||||||
|
|
||||||
async function updateSettings() {
|
async function updateSettings() {
|
||||||
localStorage.setItem(playSoundWhenDoneKey, playSoundWhenDone.value ? 'true' : 'false')
|
localStorage.setItem(playSoundWhenDoneKey, playSoundWhenDone.value ? 'true' : 'false')
|
||||||
|
localStorage.setItem('sharingEnabled', sharingEnabled.value ? 'true' : 'false')
|
||||||
|
if (sharingRoomName.value.length > 0) {
|
||||||
|
localStorage.setItem('sharingRoomName', sharingRoomName.value)
|
||||||
|
}
|
||||||
|
if (sharingRoomPassword.value.length > 0) {
|
||||||
|
localStorage.setItem('sharingRoomPassword', sharingRoomPassword.value)
|
||||||
|
}
|
||||||
setQuickAddMagicMode(quickAddMagicMode.value)
|
setQuickAddMagicMode(quickAddMagicMode.value)
|
||||||
|
|
||||||
await authStore.saveUserSettings({
|
await authStore.saveUserSettings({
|
||||||
|
|
|
@ -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>
|
|
|
@ -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>
|
|
|
@ -34,6 +34,22 @@ console.log(isModernBuild
|
||||||
: 'Building "legacy" build with "@vitejs/plugin-legacy"',
|
: 'Building "legacy" build with "@vitejs/plugin-legacy"',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Very hacky. Why not incldue it right there in `index.html`? Because otherwise
|
||||||
|
// would try to resolve `webxdc.js` and transform it.
|
||||||
|
// TODO_OFFLINE refactor: don't include it in non-webxdc build because it's always a 404.
|
||||||
|
const injectWebxdcIntoIndexHtmlPlugin = () => {
|
||||||
|
return {
|
||||||
|
name: 'inject-webxdc-into-index-html',
|
||||||
|
enforce: 'post',
|
||||||
|
transformIndexHtml(html) {
|
||||||
|
return html.replace(
|
||||||
|
'<!--WEBXDC_SCRIPT_PLACEHOLDER-->',
|
||||||
|
'<script type="module" src="/webxdc.js"></script>'
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param fontNames Array of the file names of the fonts without axis and hash suffixes
|
* @param fontNames Array of the file names of the fonts without axis and hash suffixes
|
||||||
*/
|
*/
|
||||||
|
@ -87,6 +103,7 @@ export default defineConfig(({mode}) => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
injectWebxdcIntoIndexHtmlPlugin(),
|
||||||
vue({
|
vue({
|
||||||
reactivityTransform: true,
|
reactivityTransform: true,
|
||||||
}),
|
}),
|
||||||
|
|
Reference in New Issue
Block a user