Compare commits

..

1 Commits

Author SHA1 Message Date
d92c3ae272
Added somewhat global config 2019-09-09 20:47:43 +02:00
143 changed files with 4858 additions and 12219 deletions

View File

@ -10,11 +10,12 @@ trigger:
steps:
- name: build
image: kolaente/yarn
image: node:11-alpine
pull: true
group: build-static
commands:
- yarn --frozen-lockfile
- apk add yarn
- yarn
- yarn run lint
- yarn run build
@ -30,11 +31,12 @@ trigger:
steps:
- name: build
image: kolaente/yarn
image: node:11-alpine
pull: true
group: build-static
commands:
- yarn --frozen-lockfile
- apk add yarn
- yarn
- yarn run lint
- "echo '{\"VIKUNJA_API_BASE_URL\": \"/api/v1/\"}' > /drone/src/public/config.json" # Override config
- yarn run build
@ -85,11 +87,12 @@ trigger:
steps:
- name: build
image: kolaente/yarn
image: node:11-alpine
pull: true
group: build-static
commands:
- yarn --frozen-lockfile
- apk add yarn
- yarn
- yarn run lint
- "echo '{\"VIKUNJA_API_BASE_URL\": \"/api/v1/\"}' > /drone/src/public/config.json" # Override config
- yarn run build

View File

@ -1,122 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
All releases can be found on https://code.vikunja.io/frontend/releases.
The releases aim at the api versions which is why there are missing versions.
## [0.9] - 2019-11-24
### Added
* Add minimal PWA (#34)
* Added caching to the docker image
* Added changing %Done on a task
* Added global api config (#31)
* Added handling if the user is offline (#35)
* Added labels for login and register inputs
* Added link sharing (#30)
* Added meta description tag
* Added support for HTTP/2 to the docker image
* Added the function to collapse all lists in a namespace in the sidebar menu
### Changed
* Correctly preload fonts
* Different edit icon
* Improved font handling
* Load the offline image quietly in the background
* Moved non-theme stuff in general.scss
* Removed rancher configuration
* Removed unused preload fonts tags
* Replace all spaces with tabs
* Show avatars of assigned users
* Sort tasks by done/undone first and then newest
* Task Detail View (#37)
* Update vue/cli-service
* Updated axios
* Updated dependencies
* Updated packages
* Updated packages to their latest versiosn
* Use the new listuser endpoint to search for users
### Fixed
* Fix edit label pane not closing when clicking on it
* Fixed gzip compression in docker
* Fixed label edit still opening when deleting a label
* Fixed menu not being visible on mobile
* Fixed namespace loading (#32)
* Fixed new task field not being reset after adding a new task
* Fixed redirect to login page (#33)
* Fixed scroll behaviour
* Fixed shared lists overflowing
* Fixed sharing with a user not working
* Fixed task update not working
* Fixed task update not working (again)
* Fixed team creating not working
* Handle task relations the right way (#36)
### Misc
* Moved markdown-based todo list to Vikunja [skip ci]
* Use yarn image instead of installing it every time
## [0.7] - 2019-04-30
### Added
* Design overhaul (#28)
* Gantt charts (#29)
* Pretty Scrollbars
* Task colors
### Fixed
* Fixed getting tasks (#27)
## [0.6] - 2019-03-08
### Added
* Labels (#25)
* Task priorites (#19)
* Task assingees (#21)
### Changed
* All requests are now using models and services, improving the development experience
* Team managing (#18)
## [0.5] - 2018-12-29
### Added
* User email verification when registering
* password reset
* Task overview
* Multiple reminders
* Repeating tasks
* Subtasks
* Task duration
* All new design
* Week and month view for tasks
### Changed
* Go to overview when clicking on the logo
* CSS improvements
* Don't show options to edit pseudonamespace
* Delay loading animation to not show it when the request finishes in < 100ms
* Use email instead of username when resetting a password
### Fixed
* Fixed trying to verify an email when there was none
* Fixed loading tasks when the user was not authenticated
## [0.1] - 2018-09-20

View File

@ -4,12 +4,10 @@
[![Build Status](https://drone.kolaente.de/api/badges/vikunja/frontend/status.svg)](https://drone.kolaente.de/vikunja/frontend)
[![License: LGPL v3](https://img.shields.io/badge/License-LGPL%20v3-blue.svg)](LICENSE)
[![Download](https://img.shields.io/badge/download-v0.9-brightgreen.svg)](https://storage.kolaente.de/minio/vikunja/)
[![Download](https://img.shields.io/badge/download-v0.8-brightgreen.svg)](https://storage.kolaente.de/minio/vikunja/)
This is the web frontend for Vikunja, written in Vue.js.
Take a look at [our roadmap](https://my.vikunja.cloud/share/UrdhKPqumxDXUbYpEGJLSIyNTwAnbBzVlwdDpRbv/auth) (hosted on Vikunja!) for a list of things we're currently working on!
## Docker
There is a [docker image available](https://hub.docker.com/r/vikunja/api) with support for http/2 and aggressive caching enabled.

View File

@ -34,7 +34,7 @@ http {
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_min_length 256;
gzip_types text/plain text/css application/json application/x-javascript application/javascript text/xml application/xml application/xml+rss text/javascript application/vnd.ms-fontobject application/x-font-ttf font/opentype image/svg+xml font/woff2 image/x-icon;
gzip_types text/plain text/css application/json application/x-javascript application/javascript text/xml application/xml application/xml+rss text/javascript application/vnd.ms-fontobject application/x-font-ttf font/opentype image/svg+xml font/woff2 text/html image/x-icon;
# Expires map
map $sent_http_content_type $expires {
@ -43,7 +43,6 @@ http {
text/css max;
application/javascript max;
~image/ max;
~font/ max;
}
server {

View File

@ -1,6 +1,7 @@
{
"name": "vikunja-frontend",
"version": "0.10.0",
"version": "0.8.0",
"license": "LGPL-3.0-or-later",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
@ -8,39 +9,33 @@
"lint": "vue-cli-service lint"
},
"dependencies": {
"bulma": "^0.8.0",
"bulma": "^0.7.1",
"copy-to-clipboard": "^3.2.0",
"lodash": "^4.17.15",
"moment": "^2.24.0",
"register-service-worker": "^1.6.2",
"v-tooltip": "^2.0.2",
"lodash": "^4.17.11",
"v-tooltip": "^2.0.0-rc.33",
"verte": "^0.0.12",
"vue": "^2.6.11",
"vue-drag-resize": "^1.3.2",
"vue-easymde": "^1.0.1"
"vue": "^2.5.17",
"vue-drag-resize": "^1.3.2"
},
"devDependencies": {
"@fortawesome/fontawesome-svg-core": "^1",
"@fortawesome/free-regular-svg-icons": "^5",
"@fortawesome/free-solid-svg-icons": "^5",
"@fortawesome/vue-fontawesome": "^0.1.9",
"@vue/cli": "^4.1.1",
"@vue/cli-plugin-babel": "^4.1.1",
"@vue/cli-plugin-eslint": "^4.1.1",
"@vue/cli-plugin-pwa": "^4.1.1",
"@vue/cli-service": "^4.1.1",
"@fortawesome/vue-fontawesome": "^0.1.1",
"@vue/cli-plugin-babel": "^3.0.1",
"@vue/cli-plugin-eslint": "^3.0.1",
"@vue/cli-service": "^3.9.2",
"axios": "^0.19.0",
"babel-eslint": "^10.0.3",
"core-js": "^3.5.0",
"eslint": "^6.7.2",
"eslint-plugin-vue": "^6.0.1",
"node-sass": "^4.13.0",
"sass-loader": "^8.0.0",
"vue-flatpickr-component": "^8.1.5",
"vue-multiselect": "^2.1.6",
"vue-notification": "^1.3.20",
"vue-router": "^3.1.3",
"vue-template-compiler": "^2.6.11"
"bulmaswatch": "^0.7.1",
"i": "^0.3.6",
"node-sass": "^4.9.3",
"npm": "^6.4.1",
"sass-loader": "^7.1.0",
"vue-flatpickr-component": "^8.1.2",
"vue-multiselect": "^2.1.0",
"vue-notification": "^1.3.13",
"vue-router": "^3.0.1",
"vue-template-compiler": "^2.5.17"
},
"eslintConfig": {
"root": true,
@ -65,6 +60,5 @@
"> 1%",
"last 2 versions",
"not ie <= 8"
],
"license": "LGPL-3.0-or-later"
]
}

View File

@ -3,7 +3,6 @@
font-family: 'Quicksand';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('quicksand-v7-latin-300.eot'); /* IE9 Compat Modes */
src: local('Quicksand Light'), local('Quicksand-Light'),
url('quicksand-v7-latin-300.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
@ -18,7 +17,6 @@
font-family: 'Quicksand';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('quicksand-v7-latin-regular.eot'); /* IE9 Compat Modes */
src: local('Quicksand Regular'), local('Quicksand-Regular'),
url('quicksand-v7-latin-regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
@ -33,7 +31,6 @@
font-family: 'Quicksand';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('quicksand-v7-latin-500.eot'); /* IE9 Compat Modes */
src: local('Quicksand Medium'), local('Quicksand-Medium'),
url('quicksand-v7-latin-500.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
@ -48,7 +45,6 @@
font-family: 'Quicksand';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('quicksand-v7-latin-700.eot'); /* IE9 Compat Modes */
src: local('Quicksand Bold'), local('Quicksand-Bold'),
url('quicksand-v7-latin-700.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
@ -63,7 +59,6 @@
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('open-sans-v15-latin-regular.eot'); /* IE9 Compat Modes */
src: local('Open Sans Regular'), local('OpenSans-Regular'),
url('open-sans-v15-latin-regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
@ -78,7 +73,6 @@
font-family: 'Open Sans';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url('open-sans-v15-latin-italic.eot'); /* IE9 Compat Modes */
src: local('Open Sans Italic'), local('OpenSans-Italic'),
url('open-sans-v15-latin-italic.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
@ -93,7 +87,6 @@
font-family: 'Open Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('open-sans-v15-latin-700.eot'); /* IE9 Compat Modes */
src: local('Open Sans Bold'), local('OpenSans-Bold'),
url('open-sans-v15-latin-700.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
@ -108,7 +101,6 @@
font-family: 'Open Sans';
font-style: italic;
font-weight: 700;
font-display: swap;
src: url('open-sans-v15-latin-700italic.eot'); /* IE9 Compat Modes */
src: local('Open Sans Bold Italic'), local('OpenSans-BoldItalic'),
url('open-sans-v15-latin-700italic.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 746 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -1,149 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="16.000000pt" height="16.000000pt" viewBox="0 0 16.000000 16.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,16.000000) scale(0.000320,-0.000320)"
fill="#000000" stroke="none">
<path d="M18 46618 c45 -75 122 -207 122 -211 0 -2 25 -45 55 -95 30 -50 55
-96 55 -102 0 -5 5 -10 10 -10 6 0 10 -4 10 -9 0 -5 73 -135 161 -288 89 -153
173 -298 187 -323 14 -25 32 -57 41 -72 88 -149 187 -324 189 -335 2 -7 8 -13
13 -13 5 0 9 -4 9 -10 0 -5 46 -89 103 -187 175 -302 490 -846 507 -876 8 -16
20 -36 25 -45 28 -46 290 -498 339 -585 13 -23 74 -129 136 -236 61 -107 123
-215 137 -240 14 -25 29 -50 33 -56 5 -5 23 -37 40 -70 18 -33 38 -67 44 -75
11 -16 21 -33 63 -109 14 -25 29 -50 33 -56 4 -5 21 -35 38 -65 55 -100 261
-455 269 -465 4 -5 14 -21 20 -35 15 -29 41 -75 103 -180 24 -41 52 -88 60
-105 9 -16 57 -100 107 -185 112 -193 362 -626 380 -660 8 -14 23 -38 33 -55
11 -16 23 -37 27 -45 4 -8 26 -46 48 -85 23 -38 53 -90 67 -115 46 -81 64
-113 178 -310 62 -107 121 -210 132 -227 37 -67 56 -99 85 -148 16 -27 32 -57
36 -65 4 -8 15 -27 25 -42 9 -15 53 -89 96 -165 44 -76 177 -307 296 -513 120
-206 268 -463 330 -570 131 -227 117 -203 200 -348 36 -62 73 -125 82 -140 10
-15 21 -34 25 -42 4 -8 20 -37 36 -65 17 -27 38 -65 48 -82 49 -85 64 -111 87
-153 13 -25 28 -49 32 -55 4 -5 78 -134 165 -285 87 -151 166 -288 176 -305
10 -16 26 -43 35 -59 9 -17 125 -217 257 -445 132 -229 253 -441 270 -471 17
-30 45 -79 64 -108 18 -29 33 -54 33 -57 0 -2 20 -37 44 -77 24 -40 123 -212
221 -383 97 -170 190 -330 205 -355 16 -25 39 -65 53 -90 13 -25 81 -144 152
-265 70 -121 137 -238 150 -260 12 -22 37 -65 55 -95 18 -30 43 -73 55 -95 12
-22 48 -85 80 -140 77 -132 163 -280 190 -330 13 -22 71 -123 130 -225 59
-102 116 -199 126 -217 10 -17 29 -50 43 -72 15 -22 26 -43 26 -45 0 -2 27
-50 60 -106 33 -56 60 -103 60 -105 0 -2 55 -98 90 -155 8 -14 182 -316 239
-414 13 -22 45 -79 72 -124 27 -46 49 -86 49 -89 0 -2 14 -24 30 -48 16 -24
30 -46 30 -49 0 -5 74 -135 100 -176 5 -8 24 -42 43 -75 50 -88 58 -101 262
-455 104 -179 199 -345 213 -370 14 -25 28 -49 32 -55 4 -5 17 -26 28 -45 10
-19 62 -109 114 -200 114 -197 133 -230 170 -295 16 -27 33 -57 38 -65 17 -28
96 -165 103 -180 4 -8 16 -28 26 -45 10 -16 77 -131 148 -255 72 -124 181
-313 243 -420 62 -107 121 -209 131 -227 35 -62 323 -560 392 -678 38 -66 83
-145 100 -175 16 -30 33 -59 37 -65 4 -5 17 -27 29 -47 34 -61 56 -100 90
-156 17 -29 31 -55 31 -57 0 -2 17 -32 39 -67 21 -35 134 -229 251 -433 117
-203 235 -407 261 -451 27 -45 49 -85 49 -88 0 -4 8 -19 19 -34 15 -21 200
-341 309 -533 10 -19 33 -58 51 -87 17 -29 31 -54 31 -56 0 -2 25 -44 55 -94
30 -50 55 -95 55 -98 0 -4 6 -15 14 -23 7 -9 27 -41 43 -71 17 -30 170 -297
342 -594 171 -296 311 -542 311 -547 0 -5 5 -9 10 -9 6 0 10 -4 10 -10 0 -5
22 -47 49 -92 27 -46 58 -99 68 -118 24 -43 81 -140 93 -160 5 -8 66 -114 135
-235 69 -121 130 -227 135 -235 12 -21 259 -447 283 -490 10 -19 28 -47 38
-62 11 -14 19 -29 19 -32 0 -3 37 -69 83 -148 99 -170 305 -526 337 -583 13
-22 31 -53 41 -70 11 -16 22 -37 26 -45 7 -14 82 -146 103 -180 14 -24 181
-311 205 -355 13 -22 46 -80 75 -130 29 -49 64 -110 78 -135 14 -25 51 -88 82
-140 31 -52 59 -102 63 -110 4 -8 18 -33 31 -55 205 -353 284 -489 309 -535
17 -30 45 -78 62 -106 18 -28 36 -60 39 -72 4 -12 12 -22 17 -22 5 0 9 -4 9
-10 0 -5 109 -197 241 -427 133 -230 250 -431 259 -448 51 -90 222 -385 280
-485 37 -63 78 -135 92 -160 14 -25 67 -117 118 -205 51 -88 101 -175 111
-193 34 -58 55 -95 149 -257 51 -88 101 -173 110 -190 9 -16 76 -131 147 -255
72 -124 140 -241 151 -260 61 -108 281 -489 355 -615 38 -66 77 -133 87 -150
35 -63 91 -161 100 -175 14 -23 99 -169 128 -220 54 -97 135 -235 142 -245 4
-5 20 -32 35 -60 26 -48 238 -416 276 -480 10 -16 26 -46 37 -65 30 -53 382
-661 403 -695 10 -16 22 -37 26 -45 4 -8 26 -48 50 -88 24 -41 43 -75 43 -77
0 -2 22 -40 50 -85 27 -45 50 -84 50 -86 0 -3 38 -69 83 -147 84 -142 302
-520 340 -587 10 -19 34 -60 52 -90 18 -30 44 -75 57 -100 14 -25 45 -79 70
-120 25 -41 56 -96 70 -121 14 -25 77 -133 138 -240 62 -107 122 -210 132
-229 25 -43 310 -535 337 -581 11 -19 26 -45 34 -59 17 -32 238 -414 266 -460
11 -19 24 -41 28 -49 3 -7 75 -133 160 -278 84 -146 153 -269 153 -274 0 -5 5
-9 10 -9 6 0 10 -4 10 -10 0 -5 82 -150 181 -322 182 -314 201 -346 240 -415
12 -21 80 -139 152 -263 71 -124 141 -245 155 -270 14 -25 28 -49 32 -55 6 -8
145 -248 220 -380 37 -66 209 -362 229 -395 11 -19 24 -42 28 -49 4 -8 67
-118 140 -243 73 -125 133 -230 133 -233 0 -2 15 -28 33 -57 19 -29 47 -78 64
-108 17 -30 53 -93 79 -139 53 -90 82 -141 157 -272 82 -142 115 -199 381
-659 142 -245 268 -463 281 -485 12 -22 71 -125 132 -230 60 -104 172 -298
248 -430 76 -132 146 -253 156 -270 11 -16 22 -36 26 -44 3 -8 30 -54 60 -103
29 -49 53 -91 53 -93 0 -3 18 -34 40 -70 22 -36 40 -67 40 -69 0 -2 37 -66 81
-142 45 -77 98 -168 119 -204 20 -36 47 -81 58 -100 12 -19 27 -47 33 -62 6
-16 15 -28 20 -28 5 0 9 -4 9 -9 0 -6 63 -118 140 -251 77 -133 140 -243 140
-245 0 -2 18 -33 41 -70 22 -37 49 -83 60 -101 10 -19 29 -51 40 -71 25 -45
109 -189 126 -218 7 -11 17 -29 22 -40 6 -11 22 -38 35 -60 14 -22 37 -62 52
-90 14 -27 35 -62 45 -77 11 -14 19 -29 19 -32 0 -3 18 -35 40 -71 22 -36 40
-67 40 -69 0 -2 19 -35 42 -72 23 -38 55 -94 72 -124 26 -47 139 -244 171
-298 6 -9 21 -36 34 -60 28 -48 37 -51 51 -19 6 12 19 36 29 52 10 17 27 46
38 65 11 19 104 181 208 360 103 179 199 345 213 370 14 25 42 74 64 109 21
34 38 65 38 67 0 2 18 33 40 69 22 36 40 67 40 69 0 3 177 310 199 346 16 26
136 234 140 244 2 5 25 44 52 88 27 44 49 81 49 84 0 2 18 34 40 70 22 36 40
67 40 69 0 2 20 36 43 77 35 58 169 289 297 513 9 17 50 86 90 155 40 69 86
150 103 180 16 30 35 62 41 70 6 8 16 24 22 35 35 64 72 129 167 293 59 100
116 199 127 220 11 20 30 53 41 72 43 72 1070 1850 1121 1940 14 25 65 113
113 195 48 83 96 166 107 185 10 19 28 50 38 68 11 18 73 124 137 235 64 111
175 303 246 427 71 124 173 299 225 390 52 91 116 202 143 248 27 45 49 85 49
89 0 4 6 14 14 22 7 9 28 43 46 76 26 47 251 436 378 655 11 19 29 51 40 70
11 19 101 176 201 348 99 172 181 317 181 323 0 5 5 9 10 9 6 0 10 5 10 11 0
6 8 23 18 37 11 15 32 52 49 82 16 30 130 228 253 440 122 212 234 405 248
430 13 25 39 70 57 100 39 65 69 117 130 225 25 44 50 87 55 95 12 19 78 134
220 380 61 107 129 224 150 260 161 277 222 382 246 425 15 28 47 83 71 123
24 41 43 78 43 83 0 5 4 9 8 9 4 0 13 12 19 28 7 15 23 45 36 67 66 110 277
478 277 483 0 3 6 13 14 21 7 9 27 41 43 71 17 30 45 80 63 110 34 57 375 649
394 685 6 11 16 27 22 35 6 8 26 42 44 75 18 33 41 74 51 90 10 17 24 41 32
55 54 97 72 128 88 152 11 14 19 28 19 30 0 3 79 141 175 308 96 167 175 305
175 308 0 3 6 13 14 21 7 9 26 39 41 66 33 60 276 483 338 587 24 40 46 80 50
88 4 8 13 24 20 35 14 23 95 163 125 215 11 19 52 91 92 160 40 69 80 139 90
155 9 17 103 179 207 360 105 182 200 346 211 365 103 181 463 802 489 845 7
11 15 27 19 35 4 8 29 51 55 95 64 110 828 1433 848 1470 9 17 24 41 33 55 9
14 29 48 45 77 15 28 52 93 82 145 30 51 62 107 71 123 17 30 231 398 400 690
51 88 103 179 115 202 12 23 26 48 32 55 6 7 24 38 40 68 17 30 61 107 98 170
37 63 84 144 103 180 19 36 41 72 48 81 8 8 14 18 14 21 0 4 27 51 59 106 32
55 72 124 89 154 16 29 71 125 122 213 51 88 104 180 118 205 13 25 28 50 32
55 4 6 17 26 28 45 11 19 45 80 77 135 31 55 66 116 77 135 11 19 88 152 171
295 401 694 620 1072 650 1125 11 19 87 152 170 295 83 143 158 273 166 288 9
16 21 36 26 45 6 9 31 52 55 96 25 43 54 94 66 115 11 20 95 164 186 321 91
157 173 299 182 315 9 17 26 46 37 65 12 19 66 114 121 210 56 96 108 186 117
200 8 14 24 40 34 59 24 45 383 664 412 713 5 9 17 29 26 45 15 28 120 210
241 419 36 61 68 117 72 125 4 8 12 23 19 34 35 57 245 420 262 453 11 20 35
61 53 90 17 29 32 54 32 56 0 3 28 51 62 108 33 57 70 119 80 138 10 19 23 42
28 50 5 8 32 53 59 100 27 47 149 258 271 470 122 212 234 405 248 430 30 53
62 108 80 135 6 11 15 27 19 35 4 8 85 150 181 315 96 165 187 323 202 350 31
56 116 202 130 225 5 8 25 42 43 75 19 33 92 159 162 280 149 257 157 271 202
350 19 33 38 67 43 75 9 14 228 392 275 475 12 22 55 96 95 165 40 69 80 139
90 155 24 42 202 350 221 383 9 15 27 47 41 72 14 25 75 131 136 236 61 106
121 210 134 232 99 172 271 470 279 482 5 8 23 40 40 70 18 30 81 141 142 245
60 105 121 210 135 235 14 25 71 124 127 220 56 96 143 247 194 335 51 88 96
167 102 175 14 24 180 311 204 355 23 43 340 590 356 615 5 8 50 87 101 175
171 301 517 898 582 1008 25 43 46 81 46 83 0 2 12 23 27 47 14 23 40 67 56
97 16 30 35 62 42 70 7 8 15 22 18 30 4 8 20 38 37 65 16 28 33 57 37 65 6 12
111 196 143 250 5 8 55 95 112 193 57 98 113 195 126 215 12 20 27 46 32 57 6
11 14 27 20 35 5 8 76 130 156 270 80 140 165 287 187 325 23 39 52 90 66 115
13 25 30 52 37 61 8 8 14 18 14 21 0 4 41 77 92 165 50 87 175 302 276 478
101 176 208 360 236 408 28 49 67 117 86 152 19 35 41 70 48 77 6 6 12 15 12
19 0 7 124 224 167 291 12 21 23 40 23 42 0 2 21 40 46 83 26 43 55 92 64 109
54 95 327 568 354 614 19 30 45 75 59 100 71 128 82 145 89 148 4 2 8 8 8 13
0 5 42 82 94 172 311 538 496 858 518 897 14 25 40 70 58 100 18 30 42 71 53
90 10 19 79 139 152 265 73 127 142 246 153 265 10 19 43 76 72 125 29 50 63
108 75 130 65 116 80 140 87 143 4 2 8 8 8 12 0 8 114 212 140 250 6 8 14 24
20 35 5 11 54 97 108 190 l100 170 -9611 3 c-5286 1 -9614 -1 -9618 -5 -5 -6
-419 -719 -619 -1068 -89 -155 -267 -463 -323 -560 -38 -66 -81 -140 -95 -165
-31 -56 -263 -457 -526 -910 -110 -190 -224 -388 -254 -440 -29 -52 -61 -109
-71 -125 -23 -39 -243 -420 -268 -465 -11 -19 -204 -352 -428 -740 -224 -388
-477 -826 -563 -975 -85 -148 -185 -322 -222 -385 -37 -63 -120 -207 -185
-320 -65 -113 -177 -306 -248 -430 -72 -124 -172 -297 -222 -385 -51 -88 -142
-245 -202 -350 -131 -226 -247 -427 -408 -705 -65 -113 -249 -432 -410 -710
-160 -278 -388 -673 -506 -877 -118 -205 -216 -373 -219 -373 -3 0 -52 82
-109 183 -58 100 -144 250 -192 332 -95 164 -402 696 -647 1120 -85 149 -228
396 -317 550 -212 365 -982 1700 -1008 1745 -10 19 -43 76 -72 125 -29 50 -64
110 -77 135 -14 25 -63 110 -110 190 -47 80 -96 165 -110 190 -14 25 -99 171
-188 325 -89 154 -174 300 -188 325 -13 25 -64 113 -112 195 -48 83 -140 242
-205 355 -65 113 -183 317 -263 454 -79 137 -152 264 -163 282 -50 89 -335
583 -354 614 -12 19 -34 58 -50 85 -15 28 -129 226 -253 440 -124 215 -235
408 -247 430 -12 22 -69 121 -127 220 -58 99 -226 389 -373 645 -148 256 -324
561 -392 678 -67 117 -134 232 -147 255 -13 23 -33 59 -46 80 l-22 37 -9615 0
-9615 0 20 -32z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

View File

@ -1,23 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Vikunja</title>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<meta name="description" content="Vikunja (/vɪˈkuːnjə/) - The to-do app to organize your life.">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<link rel="preload" crossorigin="anonymous" href="<%= BASE_URL %>fonts/fonts.css" as="style">
<link rel="preload" crossorigin="anonymous" href="<%= BASE_URL %>fonts/open-sans-v15-latin-700italic.woff2" as="font">
<link rel="preload" crossorigin="anonymous" href="<%= BASE_URL %>fonts/open-sans-v15-latin-italic.woff2" as="font">
<link rel="preload" crossorigin="anonymous" href="<%= BASE_URL %>fonts/quicksand-v7-latin-300.woff2" as="font">
<link rel="preload" crossorigin="anonymous" href="<%= BASE_URL %>fonts/quicksand-v7-latin-500.woff2" as="font">
<link rel="preload" crossorigin="anonymous" href="<%= BASE_URL %>fonts/quicksand-v7-latin-700.woff2" as="font">
<link rel="preload" crossorigin="anonymous" href="<%= BASE_URL %>fonts/open-sans-v15-latin-regular.woff2" as="font">
<link rel="preload" crossorigin="anonymous" href="<%= BASE_URL %>fonts/open-sans-v15-latin-700.woff2" as="font">
<link rel="preload" crossorigin="anonymous" href="<%= BASE_URL %>fonts/quicksand-v7-latin-regular.woff2" as="font">
<link href="<%= BASE_URL %>fonts/fonts.css" rel="stylesheet">
<title>Vikunja</title>
</head>
<body>
<noscript>

View File

@ -1,2 +0,0 @@
User-agent: *
Disallow:

View File

@ -1,209 +1,169 @@
<template>
<div>
<div v-if="isOnline">
<!-- This is a workaround to get the sw to "see" the to-be-cached version of the offline background image -->
<div class="offline" style="height: 0;width: 0;"></div>
<nav class="navbar main-theme is-fixed-top" role="navigation" aria-label="main navigation"
v-if="user.authenticated && user.infos.type === authTypes.USER">
<div class="navbar-brand">
<router-link :to="{name: 'home'}" class="navbar-item logo">
<img src="/images/logo-full.svg" alt="Vikunja"/>
</router-link>
</div>
<div class="navbar-end">
<div v-if="updateAvailable" class="update-notification">
<p>There is an update for Vikunja available!</p>
<a @click="refreshApp()" class="button is-primary noshadow">Update Now</a>
</div>
<div class="user">
<img :src="user.infos.getAvatarUrl()" class="avatar" alt=""/>
<div class="dropdown is-right is-active">
<div class="dropdown-trigger">
<button class="button noshadow" @click="userMenuActive = !userMenuActive">
<span class="username">{{user.infos.username}}</span>
<span class="icon is-small">
<div id="app">
<template v-if="appReady">
<nav class="navbar main-theme is-fixed-top" role="navigation" aria-label="main navigation" v-if="user.authenticated && user.infos.type === authTypes.USER">
<div class="navbar-brand">
<router-link :to="{name: 'home'}" class="navbar-item logo">
<img src="/images/logo-full.svg" alt="Vikunja"/>
</router-link>
</div>
<div class="navbar-end">
<div class="user">
<img :src="gravatar()" class="avatar" alt=""/>
<div class="dropdown is-right is-active">
<div class="dropdown-trigger">
<button class="button noshadow" @click="userMenuActive = !userMenuActive">
<span class="username">{{user.infos.username}}</span>
<span class="icon is-small">
<icon icon="chevron-down"/>
</span>
</button>
</div>
<transition name="fade">
<div class="dropdown-menu" v-if="userMenuActive">
<div class="dropdown-content">
<a @click="logout()" class="dropdown-item">
Logout
</a>
</div>
</div>
</transition>
</button>
</div>
</div>
</div>
</nav>
<div v-if="user.authenticated && user.infos.type === authTypes.USER">
<a @click="mobileMenuActive = true" class="mobilemenu-show-button" v-if="!mobileMenuActive">
<icon icon="bars"></icon>
</a>
<a @click="mobileMenuActive = false" class="mobilemenu-hide-button" v-if="mobileMenuActive">
<icon icon="times"></icon>
</a>
<div class="app-container">
<div class="namespace-container" :class="{'is-active': mobileMenuActive}">
<div class="menu top-menu">
<router-link :to="{name: 'home'}" class="logo">
<img src="/images/logo-full.svg" alt="Vikunja"/>
</router-link>
<ul class="menu-list">
<li>
<router-link :to="{ name: 'home'}">
<span class="icon">
<icon icon="calendar"/>
</span>
Overview
</router-link>
</li>
<li>
<router-link :to="{ name: 'showTasksInRange', params: {type: 'month'}}">
<span class="icon">
<icon :icon="['far', 'calendar-alt']"/>
</span>
Next Month
</router-link>
</li>
<li>
<router-link :to="{ name: 'showTasksInRange', params: {type: 'week'}}">
<span class="icon">
<icon icon="calendar-week"/>
</span>
Next Week
</router-link>
</li>
<li>
<router-link :to="{ name: 'listTeams'}">
<span class="icon">
<icon icon="users"/>
</span>
Teams
</router-link>
</li>
<li>
<router-link :to="{ name: 'newNamespace'}">
<span class="icon">
<icon icon="layer-group"/>
</span>
New Namespace
</router-link>
</li>
<li>
<router-link :to="{ name: 'listLabels'}">
<span class="icon">
<icon icon="tags"/>
</span>
Labels
</router-link>
</li>
</ul>
</div>
<aside class="menu namespaces-lists">
<div class="spinner" :class="{ 'is-loading': namespaceService.loading}"></div>
<template v-for="n in namespaces">
<div :key="n.id">
<router-link v-tooltip.right="'Settings'"
:to="{name: 'editNamespace', params: {id: n.id} }" class="nsettings"
v-if="n.id > 0">
<span class="icon">
<icon icon="cog"/>
</span>
</router-link>
<router-link v-tooltip="'Add a new list in the ' + n.name + ' namespace'"
:to="{ name: 'newList', params: { id: n.id} }" class="nsettings"
:key="n.id + 'newList'" v-if="n.id > 0">
<span class="icon">
<icon icon="plus"/>
</span>
</router-link>
<label class="menu-label" v-tooltip="n.name + ' (' + n.lists.length + ')'" :for="n.id + 'checker'">
{{n.name}} ({{n.lists.length}})
</label>
</div>
<input :key="n.id + 'checker'" type="checkbox" checked="checked" :id="n.id + 'checker'" class="checkinput"/>
<div class="more-container" :key="n.id + 'child'">
<ul class="menu-list can-be-hidden" >
<li v-for="l in n.lists" :key="l.id">
<router-link :to="{ name: 'showList', params: { id: l.id} }">{{l.title}}
</router-link>
</li>
</ul>
<label class="hidden-hint" :for="n.id + 'checker'">
Show hidden lists ({{n.lists.length}})...
</label>
</div>
</template>
</aside>
<a class="menu-bottom-link" target="_blank" href="https://vikunja.io">Powered by Vikunja</a>
</div>
<div class="app-content" :class="{'fullpage-overlay': fullpage}">
<a class="mobile-overlay" v-if="mobileMenuActive" @click="mobileMenuActive = false"></a>
<transition name="fade">
<router-view/>
<div class="dropdown-menu" v-if="userMenuActive">
<div class="dropdown-content">
<a @click="logout()" class="dropdown-item">
Logout
</a>
</div>
</div>
</transition>
</div>
</div>
</div>
<!-- FIXME: This will only be triggered when the root component is already loaded before doing link share auth. Will "fix" itself once we use vuex. -->
<div v-else-if="user.authenticated && user.infos.type === authTypes.LINK_SHARE">
<div class="container has-text-centered link-share-view">
<div class="column is-10 is-offset-1">
<img src="/images/logo-full.svg" alt="Vikunja" class="logo"/>
<div class="box has-text-left">
<div class="logout">
<a @click="logout()" class="button logout">
<span>Logout</span>
</nav>
<div v-if="user.authenticated && user.infos.type === authTypes.USER">
<a @click="mobileMenuActive = true" class="mobilemenu-show-button" v-if="!mobileMenuActive"><icon icon="bars"></icon></a>
<a @click="mobileMenuActive = false" class="mobilemenu-hide-button" v-if="mobileMenuActive"><icon icon="times"></icon></a>
<div class="app-container">
<div class="namespace-container" :class="{'is-active': mobileMenuActive}">
<div class="menu top-menu">
<ul class="menu-list">
<li>
<router-link :to="{ name: 'home'}">
<span class="icon">
<icon icon="calendar"/>
</span>
Overview
</router-link>
</li>
<li>
<router-link :to="{ name: 'showTasksInRange', params: {type: 'month'}}">
<span class="icon">
<icon :icon="['far', 'calendar-alt']"/>
</span>
Next Month
</router-link>
</li>
<li>
<router-link :to="{ name: 'showTasksInRange', params: {type: 'week'}}">
<span class="icon">
<icon icon="calendar-week"/>
</span>
Next Week
</router-link>
</li>
<li>
<router-link :to="{ name: 'listTeams'}">
<span class="icon">
<icon icon="users"/>
</span>
Teams
</router-link>
</li>
<li>
<router-link :to="{ name: 'newNamespace'}">
<span class="icon">
<icon icon="layer-group"/>
</span>
New Namespace
</router-link>
</li>
<li>
<router-link :to="{ name: 'listLabels'}">
<span class="icon">
<icon icon="tags"/>
</span>
Labels
</router-link>
</li>
</ul>
</div>
<aside class="menu namespaces-lists">
<div class="spinner" :class="{ 'is-loading': namespaceService.loading}"></div>
<template v-for="n in namespaces">
<div :key="n.id">
<router-link v-tooltip.right="'Settings'" :to="{name: 'editNamespace', params: {id: n.id} }" class="nsettings" v-if="n.id > 0">
<span class="icon">
<icon icon="cog"/>
</span>
</router-link>
<router-link v-tooltip="'Add a new list in the ' + n.name + ' namespace'" :to="{ name: 'newList', params: { id: n.id} }" class="nsettings" :key="n.id + 'newList'" v-if="n.id > 0">
<span class="icon">
<icon icon="plus"/>
</span>
</router-link>
<div class="menu-label">
{{n.name}}
</div>
</div>
<ul class="menu-list" :key="n.id + 'child'">
<li v-for="l in n.lists" :key="l.id">
<router-link :to="{ name: 'showList', params: { id: l.id} }">{{l.title}}</router-link>
</li>
</ul>
</template>
</aside>
</div>
<div class="app-content" :class="{'fullpage-overlay': fullpage}">
<a class="mobile-overlay" v-if="mobileMenuActive" @click="mobileMenuActive = false"></a>
<transition name="fade">
<router-view/>
</transition>
</div>
</div>
</div>
<!-- FIXME: This will only be triggered when the root component is already loaded before doing link share auth. Will "fix" itself once we use vuex. -->
<div v-else-if="user.authenticated && user.infos.type === authTypes.LINK_SHARE">
<div class="container has-text-centered link-share-view">
<div class="column is-10 is-offset-1">
<img src="/images/logo-full.svg" alt="Vikunja" class="logo"/>
<div class="box has-text-left">
<div class="logout">
<a @click="logout()" class="button logout">
<span>Logout</span>
<span class="icon is-small">
<icon icon="sign-out-alt"/>
</span>
</a>
</div>
<router-view/>
</a>
</div>
<router-view/>
</div>
</div>
</div>
<div v-else>
<div class="container">
<div class="column is-4 is-offset-4">
<img src="/images/logo-full.svg" alt="Vikunja"/>
<div class="message is-info" v-if="motd !== ''">
<div class="message-header">
<p>Info</p>
</div>
<div class="message-body">
{{ motd }}
</div>
</div>
</div>
<div v-else>
<div class="container has-text-centered">
<div class="column is-4 is-offset-4">
<img src="/images/logo-full.svg" alt="Vikunja"/>
<router-view/>
</div>
</div>
</div>
<notifications position="bottom left"/>
</div>
<div class="app offline" v-else>
<div class="offline-message">
<h1>You are offline.</h1>
<p>Please check your network connection and try again.</p>
</div>
</div>
<notifications position="bottom left" />
</template>
</div>
</template>
<script>
import auth from './auth'
import message from './message'
import router from './router'
import {HTTP} from './http-common'
import NamespaceService from './services/namespace'
import authTypes from './models/authTypes'
import swEvents from './ServiceWorker/events'
export default {
name: 'app',
@ -217,29 +177,32 @@
currentDate: new Date(),
userMenuActive: false,
authTypes: authTypes,
isOnline: true,
motd: '',
config: null,
}
},
beforeCreate() {
HTTP.get('info')
.then(r => {
this.config = r.data
// eslint-disable-next-line
console.log(this.config)
})
// Service Worker stuff
updateAvailable: false,
registration: null,
refreshing: false,
},
computed: {
appReady () {
return !!this.config
}
},
beforeMount() {
// Check if the user is offline, show a message then
this.isOnline = navigator.onLine
window.addEventListener('online', () => this.isOnline = navigator.onLine);
window.addEventListener('offline', () => this.isOnline = navigator.onLine);
// Password reset
if (this.$route.query.userPasswordReset !== undefined) {
if(this.$route.query.userPasswordReset !== undefined) {
localStorage.removeItem('passwordResetToken') // Delete an eventually preexisting old token
localStorage.setItem('passwordResetToken', this.$route.query.userPasswordReset)
router.push({name: 'passwordReset'})
}
// Email verification
if (this.$route.query.userEmailConfirm !== undefined) {
if(this.$route.query.userEmailConfirm !== undefined) {
localStorage.removeItem('emailConfirmToken') // Delete an eventually preexisting old token
localStorage.setItem('emailConfirmToken', this.$route.query.userEmailConfirm)
router.push({name: 'login'})
@ -249,25 +212,6 @@
if (auth.user.authenticated && auth.user.infos.type === authTypes.USER && (this.$route.params.name === 'home' || this.namespaces.length === 0)) {
this.loadNamespaces()
}
// Service worker communication
document.addEventListener(swEvents.SW_UPDATED, this.showRefreshUI, { once: true })
navigator.serviceWorker.addEventListener(
'controllerchange', () => {
if (this.refreshing) return;
this.refreshing = true;
window.location.reload();
}
);
// Schedule a token renew every 60 minutes
setTimeout(() => {
auth.renewToken()
}, 1000 * 60 * 60)
// Set the motd
this.setMotd()
},
watch: {
// call the method again if the route changes
@ -277,6 +221,9 @@
logout() {
auth.logout()
},
gravatar() {
return 'https://www.gravatar.com/avatar/' + this.user.infos.avatar + '?s=50'
},
loadNamespaces() {
this.namespaceService = new NamespaceService()
this.namespaceService.getAll()
@ -284,10 +231,10 @@
this.$set(this, 'namespaces', r)
})
.catch(e => {
this.error(e, this)
message.error(e, this)
})
},
loadNamespacesIfNeeded(e) {
loadNamespacesIfNeeded(e){
if (auth.user.authenticated && auth.user.infos.type === authTypes.USER && (e.name === 'home' || this.namespaces.length === 0)) {
this.loadNamespaces()
}
@ -296,34 +243,10 @@
this.fullpage = false;
this.loadNamespacesIfNeeded(e)
this.mobileMenuActive = false
this.userMenuActive = false
},
setFullPage() {
this.fullpage = true;
},
showRefreshUI (e) {
console.log('recieved refresh event', e)
this.registration = e.detail;
this.updateAvailable = true;
},
refreshApp () {
this.updateExists = false;
if (!this.registration || !this.registration.waiting) { return; }
// Notify the service worker to actually do the update
this.registration.waiting.postMessage('skipWaiting');
},
setMotd() {
let cancel = () => {};
// Since the config may not be initialized when we're calling this, we need to retry until it is ready.
if (typeof this.$config === 'undefined') {
cancel = setTimeout(() => {
this.setMotd()
}, 150)
} else {
cancel()
this.motd = this.$config.motd
}
},
},
}
</script>

View File

@ -1,3 +0,0 @@
{
"SW_UPDATED": "swUpdated"
}

View File

@ -1,118 +0,0 @@
/* eslint-disable no-console */
/* eslint-disable no-undef */
// Cache assets
workbox.routing.registerRoute(
// This regexp matches all files in precache-manifest
new RegExp('.+\\.(css|json|js|eot|svg|ttf|woff|woff2|png|html|txt)$'),
new workbox.strategies.StaleWhileRevalidate()
);
// Always send api reqeusts through the network
workbox.routing.registerRoute(
new RegExp('(\\/)?api\\/v1\\/.*$'),
new workbox.strategies.NetworkOnly()
);
// Cache everything else
workbox.routing.registerRoute(
new RegExp('.*'),
new workbox.strategies.StaleWhileRevalidate()
);
// This code listens for the user's confirmation to update the app.
self.addEventListener('message', (e) => {
if (!e.data) {
return;
}
switch (e.data) {
case 'skipWaiting':
self.skipWaiting();
break;
default:
// NOOP
break;
}
});
const getBearerToken = async () => {
// we can't get a client that sent the current request, therefore we need
// to ask any controlled page for auth token
const allClients = await self.clients.matchAll();
const client = allClients.filter(client => client.type === 'window')[0];
// if there is no page in scope, we can't get any token
// and we indicate it with null value
if(!client) {
return null;
}
// to communicate with a page we will use MessageChannels
// they expose pipe-like interface, where a receiver of
// a message uses one end of a port for messaging and
// we use the other end for listening
const channel = new MessageChannel();
client.postMessage({
'action': 'getBearerToken'
}, [channel.port1]);
// ports support only onmessage callback which
// is cumbersome to use, so we wrap it with Promise
return new Promise((resolve, reject) => {
channel.port2.onmessage = event => {
if (event.data.error) {
console.error('Port error', event.error);
reject(event.data.error);
}
resolve(event.data.authToken);
}
});
}
// Notification action
self.addEventListener('notificationclick', function(event) {
const taskID = event.notification.data.taskID
event.notification.close()
switch (event.action) {
case 'mark-as-done':
// FIXME: Ugly as hell, but no other way of doing this, since we can't use modules
// in service workersfor now.
fetch('/config.json')
.then(r => r.json())
.then(config => {
getBearerToken()
.then(token => {
fetch(`${config.VIKUNJA_API_BASE_URL}tasks/${taskID}`, {
method: 'post',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({id: taskID, done: true})
})
.then(r => r.json())
.then(r => {
console.debug('Task marked as done from notification', r)
})
.catch(e => {
console.debug('Error marking task as done from notification', e)
})
})
})
break
case 'show-task':
clients.openWindow(`/tasks/${taskID}`)
break
}
})
workbox.core.clientsClaim();
// The precaching code provided by Workbox.
self.__precacheManifest = [].concat(self.__precacheManifest || []);
workbox.precaching.precacheAndRoute(self.__precacheManifest, {});

View File

@ -1,6 +1,5 @@
import {HTTP} from '../http-common'
import router from '../router'
import UserModel from '../models/user'
// const API_URL = 'http://localhost:8082/api/v1/'
// const LOGIN_URL = 'http://localhost:8082/login'
@ -41,9 +40,9 @@ export default {
// Hide the loader
context.loading = false
if (e.response) {
context.errorMsg = e.response.data.message
context.error = e.response.data.message
if (e.response.status === 401) {
context.errorMsg = 'Wrong username or password.'
context.error = 'Wrong username or password.'
}
}
})
@ -62,9 +61,9 @@ export default {
// Hide the loader
context.loading = false
if (e.response) {
context.errorMsg = e.response.data.message
context.error = e.response.data.message
if (e.response.status === 401) {
context.errorMsg = 'Wrong username or password.'
context.error = 'Wrong username or password.'
}
}
})
@ -77,38 +76,24 @@ export default {
},
linkShareAuth(hash) {
return HTTP.post('/shares/' + hash + '/auth')
return HTTP.post('/shares/'+hash+'/auth')
.then(r => {
localStorage.setItem('token', r.data.token)
this.getUserInfos()
return Promise.resolve(r.data)
}).catch(e => {
}).catch(e => {
return Promise.reject(e)
})
},
renewToken() {
HTTP.post('user/token', null, {
headers: {
Authorization: 'Bearer ' + localStorage.getItem('token'),
}
})
.then(r => {
localStorage.setItem('token', r.data.token)
})
.catch(e => {
// eslint-disable-next-line
console.log('Error renewing token: ', e)
})
},
checkAuth() {
let jwt = localStorage.getItem('token')
this.getUserInfos()
this.user.authenticated = false
if (jwt) {
let infos = this.user.infos
let ts = Math.round((new Date()).getTime() / 1000)
if (this.user.infos.exp >= ts) {
if (infos.exp >= ts) {
this.user.authenticated = true
}
}
@ -117,8 +102,8 @@ export default {
getUserInfos() {
let jwt = localStorage.getItem('token')
if (jwt) {
this.user.infos = new UserModel(this.parseJwt(localStorage.getItem('token')))
return this.user.infos
this.user.infos = this.parseJwt(localStorage.getItem('token'))
return this.parseJwt(localStorage.getItem('token'))
} else {
return {}
}

View File

@ -2,7 +2,6 @@
<div class="content has-text-centered">
<h2>Hi {{user.infos.username}}!</h2>
<p>Click on a list or namespace on the left to get started.</p>
<router-link class="button is-primary is-right noshadow is-outlined" :to="{name: 'migrateStart'}">Import your data into Vikunja</router-link>
<TaskOverview :show-all="true"/>
</div>
</template>

View File

@ -1,95 +0,0 @@
<template>
<!-- TODO: Fix the icons -->
<vue-easymde v-model="text" :configs="config" @change="bubble"/>
</template>
<script>
import VueEasymde from 'vue-easymde'
export default {
name: 'easymde',
components: {
VueEasymde
},
props: {
value: {
type: String,
default: '',
},
},
data() {
return {
text: '',
config: {
autoDownloadFontAwesome: false,
spellChecker: false,
toolbar: [
'heading-1',
'heading-2',
'heading-3',
'heading-smaller',
'heading-bigger',
'|',
'bold',
'italic',
'strikethrough',
'code',
'quote',
'unordered-list',
'ordered-list',
'|',
'clean-block',
'link',
'image',
'table',
'horizontal-rule',
'|',
'preview',
'side-by-side',
'fullscreen',
'guide',
// {
// name: 'bold',
// title: 'Bold',
// iconElement: '<span>test</span>' // This relies on an extra thing added in node_modules/easymde/src/js/easymde.js:145
// },
]
},
}
},
watch: {
value(newVal) {
this.text = newVal
},
text() {
this.bubble()
}
},
methods: {
bubble() {
this.$emit('input', this.text)
this.$emit('change')
}
},
}
</script>
<style lang="scss">
@import '~easymde/dist/easymde.min.css';
.CodeMirror {
padding: 0;
}
.CodeMirror-scroll {
padding: .5em;
}
.editor-toolbar {
background: #ffffff;
}
pre.CodeMirror-line{
margin-bottom: 0 !important;
}
</style>

View File

@ -1,43 +0,0 @@
<template>
<div class="user">
<img :src="user.getAvatarUrl(avatarSize)" class="avatar" alt="" v-tooltip="user.username"/>
<span v-if="showUsername" class="username">{{ user.username }}</span>
</div>
</template>
<script>
export default {
name: 'user',
props: {
user: {
required: true,
type: Object,
},
showUsername: {
required: false,
type: Boolean,
default: true,
},
avatarSize: {
required: false,
type: Number,
default: 50,
}
},
}
</script>
<style lang="scss" scoped>
.user {
margin: .5em;
img {
-webkit-border-radius: 100%;
-moz-border-radius: 100%;
border-radius: 100%;
vertical-align: middle;
margin-right: .5em;
}
}
</style>

View File

@ -7,10 +7,11 @@
</p>
<div class="columns">
<div class="labels-list column">
<span
<a
v-for="l in labels" :key="l.id"
class="tag"
:class="{'disabled': user.infos.id !== l.created_by.id}"
@click="editLabel(l)"
:style="{'background': l.hex_color, 'color': l.textColor}"
>
<span
@ -18,15 +19,10 @@
v-tooltip.bottom="'You are not allowed to edit this label because you dont own it.'">
{{ l.title }}
</span>
<a
@click="editLabel(l)"
:style="{'color': l.textColor}"
v-else>
{{ l.title }}
</a>
<span v-else>{{ l.title }}</span>
<a class="delete is-small" @click="deleteLabel(l)" v-if="user.infos.id === l.created_by.id"></a>
</span>
</a>
</div>
<div class="column is-4" v-if="isLabelEdit">
<div class="card">
@ -34,9 +30,9 @@
<span class="card-header-title">
Edit Label
</span>
<a class="card-header-icon" @click="isLabelEdit = false">
<a class="card-header-icon" @click="isTaskEdit = false">
<span class="icon">
<icon icon="times"/>
<icon icon="angle-right"/>
</span>
</a>
</header>
@ -95,6 +91,7 @@
import LabelService from '../../services/label'
import LabelModel from '../../models/label'
import message from '../../message'
import auth from '../../auth'
export default {
@ -123,7 +120,7 @@
this.$set(this, 'labels', r)
})
.catch(e => {
this.error(e, this)
message.error(e, this)
})
},
deleteLabel(label) {
@ -135,10 +132,10 @@
this.labels.splice(l, 1)
}
}
this.success({message: 'The label was successfully deleted.'}, this)
message.success({message: 'The label was successfully deleted.'}, this)
})
.catch(e => {
this.error(e, this)
message.error(e, this)
})
},
editLabelSubmit() {
@ -149,10 +146,10 @@
this.$set(this.labels, l, r)
}
}
this.success({message: 'The label was successfully updated.'}, this)
message.success({message: 'The label was successfully updated.'}, this)
})
.catch(e => {
this.error(e, this)
message.error(e, this)
})
},
editLabel(label) {

View File

@ -60,6 +60,7 @@
<script>
import auth from '../../auth'
import router from '../../router'
import message from '../../message'
import manageSharing from '../sharing/userTeam'
import LinkSharing from '../sharing/linkSharing';
@ -113,7 +114,7 @@
this.manageUsersComponent = 'manageSharing'
})
.catch(e => {
this.error(e, this)
message.error(e, this)
})
},
submit() {
@ -128,20 +129,20 @@
}
}
}
this.success({message: 'The list was successfully updated.'}, this)
message.success({message: 'The list was successfully updated.'}, this)
})
.catch(e => {
this.error(e, this)
message.error(e, this)
})
},
deleteList() {
this.listService.delete(this.list)
.then(() => {
this.success({message: 'The list was successfully deleted.'}, this)
message.success({message: 'The list was successfully deleted.'}, this)
router.push({name: 'home'})
})
.catch(e => {
this.error(e, this)
message.error(e, this)
})
},
}

View File

@ -26,6 +26,7 @@
<script>
import auth from '../../auth'
import router from '../../router'
import message from '../../message'
import ListService from '../../services/list'
import ListModel from '../../models/list'
@ -54,11 +55,11 @@
this.listService.create(this.list)
.then(response => {
this.$parent.loadNamespaces()
this.success({message: 'The list was successfully created.'}, this)
message.success({message: 'The list was successfully created.'}, this)
router.push({name: 'showList', params: {id: response.id}})
})
.catch(e => {
this.error(e, this)
message.error(e, this)
})
},
back() {

View File

@ -4,7 +4,7 @@
<router-link :to="{ name: 'editList', params: { id: list.id } }" class="icon settings is-medium">
<icon icon="cog" size="2x"/>
</router-link>
<h1 :style="{ 'opacity': list.title === '' ? '0': '1' }">{{ list.title === '' ? 'Loading...': list.title}}</h1>
<h1>{{ list.title }}</h1>
<div class="switch-view">
<router-link :to="{ name: 'showList', params: { id: list.id } }" :class="{'is-active': $route.params.type !== 'gantt'}">List</router-link>
<router-link :to="{ name: 'showListWithType', params: { id: list.id, type: 'gantt' } }" :class="{'is-active': $route.params.type === 'gantt'}">Gantt</router-link>
@ -19,6 +19,7 @@
<script>
import auth from '../../auth'
import router from '../../router'
import message from '../../message'
import ShowListTask from '../tasks/ShowListTasks'
import Gantt from '../tasks/Gantt'
@ -57,7 +58,7 @@
},
watch: {
// call again the method if the route changes
'$route.path': 'loadList'
'$route': 'loadList'
},
methods: {
loadList() {
@ -68,7 +69,7 @@
this.$set(this, 'list', r)
})
.catch(e => {
this.error(e, this)
message.error(e, this)
})
},
}

View File

@ -1,18 +0,0 @@
<template>
<div class="content">
<h1>Import your data from other services to Vikunja</h1>
<p>Click on the logo of one of the third-party services below to get started.</p>
<div class="migration-services-overview">
<router-link :to="{name: 'migrateWunderlist'}">
<img src="/images/migration/wunderlist.png" alt="Wunderlist"/>
Wunderlist
</router-link>
</div>
</div>
</template>
<script>
export default {
name: 'migrate'
}
</script>

View File

@ -1,109 +0,0 @@
<template>
<div class="content">
<h1>Import your data from Wunderlist to Vikunja</h1>
<p>Vikunja will import all folders, lists, tasks, notes, reminders and files you have access to.</p>
<template v-if="isMigrating === false && message === '' && lastMigrationDate === 0">
<p>To authorize Vikunja to access your Wunderlist Account, click the button below.</p>
<a :href="authUrl" class="button is-primary" :class="{'is-loading': migrationService.loading}" :disabled="migrationService.loading">Get Started</a>
</template>
<div class="migration-in-progress-container" v-else-if="isMigrating === true && message === '' && lastMigrationDate === 0">
<div class="migration-in-progress">
<img src="/images/migration/wunderlist.png" alt="Wunderlist Logo"/>
<div class="progress-dots">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
<img src="/images/logo.svg" alt="Vikunja Logo">
</div>
<p>Importing in progress, hang tight...</p>
</div>
<div v-else-if="lastMigrationDate > 0">
<p>
It looks like you've already imported your stuff from wunderlist at {{ new Date(lastMigrationDate * 1000) }}.<br/>
Importing again is possible, but might create duplicates.
Are you sure?
</p>
<div class="buttons">
<button class="button is-primary" @click="migrate">I am sure, please start migrating now!</button>
<router-link :to="{name: 'home'}" class="button is-danger is-outlined">Cancel</router-link>
</div>
</div>
<div v-else>
<div class="message is-primary">
<div class="message-body">
{{ message }}
</div>
</div>
<router-link :to="{name: 'home'}" class="button is-primary">Refresh</router-link>
</div>
</div>
</template>
<script>
import WunderlistMigrationService from '../../services/migrator/wunderlist'
export default {
name: 'wunderlist',
data() {
return {
migrationService: WunderlistMigrationService,
authUrl: '',
isMigrating: false,
lastMigrationDate: 0,
message: '',
wunderlistCode: '',
}
},
created() {
this.migrationService = new WunderlistMigrationService()
this.getAuthUrl()
this.message = ''
if(typeof this.$route.query.code !== 'undefined') {
this.wunderlistCode = this.$route.query.code
this.migrationService.getStatus()
.then(r => {
if(r.time_unix > 0) {
this.lastMigrationDate = r.time_unix
return
}
this.migrate()
})
.catch(e => {
this.error(e, this)
})
}
},
methods: {
getAuthUrl() {
this.migrationService.getAuthUrl()
.then(r => {
this.authUrl = r.url
})
.catch(e => {
this.error(e, this)
})
},
migrate() {
this.isMigrating = true
this.lastMigrationDate = 0
this.migrationService.migrate({code: this.wunderlistCode})
.then(r => {
this.message = r.message
})
.catch(e => {
this.error(e, this)
})
.finally(() => {
this.isMigrating = false
})
},
},
}
</script>

View File

@ -58,6 +58,7 @@
<script>
import auth from '../../auth'
import router from '../../router'
import message from '../../message'
import manageSharing from '../sharing/userTeam'
import NamespaceService from '../../services/namespace'
@ -110,7 +111,7 @@
this.manageUsersComponent = 'manageSharing'
})
.catch(e => {
this.error(e, this)
message.error(e, this)
})
},
submit() {
@ -123,20 +124,20 @@
this.$set(this.$parent.namespaces, n, r)
}
}
this.success({message: 'The namespace was successfully updated.'}, this)
message.success({message: 'The namespace was successfully updated.'}, this)
})
.catch(e => {
this.error(e, this)
message.error(e, this)
})
},
deleteNamespace() {
this.namespaceService.delete(this.namespace)
.then(() => {
this.success({message: 'The namespace was successfully deleted.'}, this)
message.success({message: 'The namespace was successfully deleted.'}, this)
router.push({name: 'home'})
})
.catch(e => {
this.error(e, this)
message.error(e, this)
})
}
}

View File

@ -27,6 +27,7 @@
<script>
import auth from '../../auth'
import router from '../../router'
import message from '../../message'
import NamespaceModel from "../../models/namespace";
import NamespaceService from "../../services/namespace";
@ -54,11 +55,11 @@
this.namespaceService.create(this.namespace)
.then(() => {
this.$parent.loadNamespaces()
this.success({message: 'The namespace was successfully created.'}, this)
message.success({message: 'The namespace was successfully created.'}, this)
router.push({name: 'home'})
})
.catch(e => {
this.error(e, this)
message.error(e, this)
})
},
back() {

View File

@ -1,190 +1,195 @@
<template>
<div class="card is-fullwidth">
<header class="card-header">
<p class="card-header-title">
Share links
</p>
</header>
<div class="card-content content sharables-list">
<form @submit.prevent="add()" class="add-form">
<p>
Share with a link:
</p>
<div class="field has-addons">
<div class="control">
<div class="select">
<select v-model="selectedRight">
<option :value="rights.READ">Read only</option>
<option :value="rights.READ_WRITE">Read & write</option>
<option :value="rights.ADMIN">Admin</option>
</select>
</div>
</div>
<div class="control">
<button type="submit" class="button is-success">
Share
</button>
</div>
</div>
</form>
<table class="table is-striped is-hoverable is-fullwidth link-share-list">
<tbody>
<tr>
<th>Link</th>
<th>Shared by</th>
<th>Right</th>
<th>Delete</th>
</tr>
<template v-if="linkShares.length > 0">
<tr v-for="s in linkShares" :key="s.id">
<td>
<div class="field has-addons">
<div class="control">
<input class="input" type="text" :value="getShareLink(s.hash)" readonly/>
</div>
<div class="control">
<a class="button is-success noshadow" @click="copy(getShareLink(s.hash))">
<span class="icon">
<icon icon="paste"/>
</span>
</a>
</div>
</div>
</td>
<td>
{{ s.shared_by.username }}
</td>
<td class="type">
<template v-if="s.right === rights.ADMIN">
<span class="icon is-small">
<icon icon="lock"/>
</span>
Admin
</template>
<template v-else-if="s.right === rights.READ_WRITE">
<span class="icon is-small">
<icon icon="pen"/>
</span>
Write
</template>
<template v-else>
<span class="icon is-small">
<icon icon="users"/>
</span>
Read-only
</template>
</td>
<td class="actions">
<button @click="linkIDToDelete = s.id; showDeleteModal = true" class="button is-danger icon-only">
<span class="icon">
<icon icon="trash-alt"/>
</span>
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="card is-fullwidth">
<header class="card-header">
<p class="card-header-title">
Share links
</p>
</header>
<div class="card-content content sharables-list">
<form @submit.prevent="add()" class="add-form">
<div class="field is-grouped">
<div class="control">
<!-- TODO: maybe move this into a modal? -->
Add a new link share:
</div>
<div class="control">
<div class="select">
<select v-model="selectedRight" class="button buttonright">
<option :value="rights.READ">Read only</option>
<option :value="rights.READ_WRITE">Read & write</option>
<option :value="rights.ADMIN">Admin</option>
</select>
</div>
</div>
<div class="control">
<button type="submit" class="button is-success">
<span class="icon is-small">
<icon icon="plus"/>
</span>
Add
</button>
</div>
</div>
</form>
<table class="table is-striped is-hoverable is-fullwidth">
<tbody>
<tr>
<th>Link</th>
<th>Shared by</th>
<th>Right</th>
<th>Delete</th>
</tr>
<template v-if="linkShares.length > 0">
<tr v-for="s in linkShares" :key="s.id">
<td>
<div class="field has-addons">
<div class="control">
<input class="input" type="text" :value="getShareLink(s.hash)" readonly/>
</div>
<div class="control">
<a class="button is-success noshadow" @click="copy(getShareLink(s.hash))">
<span class="icon is-small">
<icon icon="paste"/>
</span>
</a>
</div>
</div>
</td>
<td>
{{ s.shared_by.username }}
</td>
<td class="type">
<template v-if="s.right === rights.ADMIN">
<span class="icon is-small">
<icon icon="lock"/>
</span>
Admin
</template>
<template v-else-if="s.right === rights.READ_WRITE">
<span class="icon is-small">
<icon icon="pen"/>
</span>
Write
</template>
<template v-else>
<span class="icon is-small">
<icon icon="users"/>
</span>
Read-only
</template>
</td>
<td class="actions">
<button @click="linkIDToDelete = s.id; showDeleteModal = true" class="button is-danger icon-only">
<span class="icon is-small">
<icon icon="trash-alt"/>
</span>
</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<modal
v-if="showDeleteModal"
@close="showDeleteModal = false"
@submit="remove()">
<span slot="header">Remove a link share</span>
<p slot="text">Are you sure you want to remove this link share?<br/>
It will no longer be possible to access this list with this link share.<br/>
<b>This CANNOT BE UNDONE!</b></p>
</modal>
</div>
<modal
v-if="showDeleteModal"
@close="showDeleteModal = false"
@submit="remove()">
<span slot="header">Remove a link share</span>
<p slot="text">Are you sure you want to remove this link share?<br/>
It will no longer be possible to access this list with this link share.<br/>
<b>This CANNOT BE UNDONE!</b></p>
</modal>
</div>
</template>
<script>
import rights from '../../models/rights'
import message from '../../message'
import rights from '../../models/rights'
import LinkShareService from '../../services/linkShare'
import LinkShareModel from '../../models/linkShare'
import LinkShareService from '../../services/linkShare'
import LinkShareModel from '../../models/linkShare'
import copy from 'copy-to-clipboard'
import copy from 'copy-to-clipboard'
export default {
name: 'linkSharing',
props: {
listID: {
default: 0,
required: true,
},
},
data() {
return {
linkShares: [],
linkShareService: LinkShareService,
newLinkShare: LinkShareModel,
rights: rights,
selectedRight: rights.READ,
showDeleteModal: false,
linkIDToDelete: 0,
}
},
beforeMount() {
this.linkShareService = new LinkShareService()
},
created() {
this.linkShareService = new LinkShareService()
this.load()
},
watch: {
listID: () => { // watch it
this.load()
}
},
methods: {
load() {
// If listID == 0 the list on the calling component wasn't already loaded, so we just bail out here
if (this.listID === 0) {
return
}
export default {
name: 'linkSharing',
props: {
listID: {
default: 0,
required: true,
},
},
data() {
return {
linkShares: [],
linkShareService: LinkShareService,
newLinkShare: LinkShareModel,
rights: rights,
selectedRight: rights.READ,
showDeleteModal: false,
linkIDToDelete: 0,
}
},
beforeMount() {
this.linkShareService = new LinkShareService()
},
created() {
this.linkShareService = new LinkShareService()
this.load()
},
watch: {
listID: () => { // watch it
this.load()
}
},
methods: {
load() {
// If listID == 0 the list on the calling component wasn't already loaded, so we just bail out here
if (this.listID === 0) {
return
}
this.linkShareService.getAll({listID: this.listID})
.then(r => {
this.linkShares = r
})
.catch(e => {
this.error(e, this)
})
},
add() {
let newLinkShare = new LinkShareModel({right: this.selectedRight, listID: this.listID})
this.linkShareService.create(newLinkShare)
.then(() => {
this.selectedRight = rights.READ
this.success({message: 'The link share was successfully created'}, this)
this.load()
})
.catch(e => {
this.error(e, this)
})
},
remove() {
let linkshare = new LinkShareModel({id: this.linkIDToDelete, listID: this.listID})
this.linkShareService.delete(linkshare)
.then(() => {
this.success({message: 'The link share was successfully deleted'}, this)
this.load()
})
.catch(e => {
this.error(e, this)
})
.finally(() => {
this.showDeleteModal = false
})
},
copy(text) {
copy(text)
},
getShareLink(hash) {
return this.$config.frontend_url + 'share/' + hash + '/auth'
},
},
}
this.linkShareService.getAll({listID: this.listID})
.then(r => {
this.linkShares = r
})
.catch(e => {
message.error(e, this)
})
},
add() {
let newLinkShare = new LinkShareModel({right: this.selectedRight, listID: this.listID})
this.linkShareService.create(newLinkShare)
.then(() => {
this.selectedRight = rights.READ
message.success({message: 'The link share was successfully created'}, this)
this.load()
})
.catch(e => {
message.error(e, this)
})
},
remove() {
let linkshare = new LinkShareModel({id: this.linkIDToDelete, listID: this.listID})
this.linkShareService.delete(linkshare)
.then(() => {
message.success({message: 'The link share was successfully deleted'}, this)
this.load()
})
.catch(e => {
message.error(e, this)
})
.finally(() => {
this.showDeleteModal = false
})
},
copy(text) {
copy(text)
},
getShareLink(hash) {
return this.$root.config.frontend_url + 'share/' + hash + '/auth'
},
},
}
</script>

View File

@ -1,39 +1,40 @@
<template>
<div class="message is-centered is-info" v-if="loading">
<div class="message-header">
<p class="has-text-centered">
Authenticating...
</p>
</div>
</div>
<div class="message is-centered is-info" v-if="loading">
<div class="message-header">
<p class="has-text-centered">
Authenticating...
</p>
</div>
</div>
</template>
<script>
import auth from '../../auth'
import router from '../../router'
import auth from '../../auth'
import router from '../../router'
import message from '../../message'
export default {
name: 'linkSharingAuth',
data() {
return {
hash: '',
loading: true,
}
},
created() {
this.auth()
},
methods: {
auth() {
auth.linkShareAuth(this.$route.params.share)
.then((r) => {
this.loading = false
router.push({name: 'showList', params: {id: r.list_id}})
})
.catch(e => {
this.error(e, this)
})
}
},
}
export default {
name: 'linkSharingAuth',
data() {
return {
hash: '',
loading: true,
}
},
created() {
this.auth()
},
methods: {
auth() {
auth.linkShareAuth(this.$route.params.share)
.then((r) => {
this.loading = false
router.push({name: 'showList', params: {id: r.list_id}})
})
.catch(e => {
message.error(e, this)
})
}
},
}
</script>

View File

@ -2,7 +2,7 @@
<div class="card is-fullwidth">
<header class="card-header">
<p class="card-header-title">
Shared with these {{shareType}}s
{{shareType}}s with access to this {{typeString}}
</p>
</header>
<div class="card-content content sharables-list">
@ -106,6 +106,7 @@
<script>
import auth from '../../auth'
import message from '../../message'
import multiselect from 'vue-multiselect'
import UserNamespaceService from '../../services/userNamespace'
@ -127,22 +128,10 @@
export default {
name: 'userTeamShare',
props: {
type: {
type: String,
default: '',
},
shareType: {
type: String,
default: '',
},
id: {
type: Number,
default: 0,
},
userIsAdmin: {
type: Boolean,
default: false,
},
type: '',
shareType: '',
id: 0,
userIsAdmin: false,
},
data() {
return {
@ -214,7 +203,7 @@
this.$set(this, 'sharables', r)
})
.catch(e => {
this.error(e, this)
message.error(e, this)
})
},
deleteSharable() {
@ -235,10 +224,10 @@
this.sharables.splice(i, 1)
}
}
this.success({message: 'The ' + this.shareType + ' was successfully deleted from the ' + this.typeString + '.'}, this)
message.success({message: 'The ' + this.shareType + ' was successfully deleted from the ' + this.typeString + '.'}, this)
})
.catch(e => {
this.error(e, this)
message.error(e, this)
})
},
add(admin) {
@ -258,11 +247,11 @@
this.stuffService.create(this.stuffModel)
.then(() => {
this.success({message: 'The ' + this.shareType + ' was successfully added.'}, this)
message.success({message: 'The ' + this.shareType + ' was successfully added.'}, this)
this.load()
})
.catch(e => {
this.error(e, this)
message.error(e, this)
})
},
toggleType() {
@ -291,10 +280,10 @@
this.$set(this.sharables[i], 'right', r.right)
}
}
this.success({message: 'The ' + this.shareType + ' right was successfully updated.'}, this)
message.success({message: 'The ' + this.shareType + ' right was successfully updated.'}, this)
})
.catch(e => {
this.error(e, this)
message.error(e, this)
})
},
find(query) {
@ -308,7 +297,7 @@
this.$set(this, 'found', response)
})
.catch(e => {
this.error(e, this)
message.error(e, this)
})
},
clearAll () {

View File

@ -1,38 +1,7 @@
<template>
<div class="loader-container" :class="{ 'is-loading': listService.loading || taskCollectionService.loading}">
<div class="search">
<div class="field has-addons" :class="{ 'hidden': !showTaskSearch }">
<div class="control has-icons-left has-icons-right">
<input
class="input"
type="text"
placeholder="Search"
v-focus
v-model="searchTerm"
@keyup.enter="searchTasks"
@blur="hideSearchBar()"/>
<span class="icon is-left">
<icon icon="search"/>
</span>
</div>
<div class="control">
<button
class="button noshadow is-primary"
@click="searchTasks"
:class="{'is-loading': taskCollectionService.loading}"
:disabled="searchTerm === ''">
Search
</button>
</div>
</div>
<button class="button" @click="showTaskSearch = !showTaskSearch" v-if="!showTaskSearch">
<span class="icon">
<icon icon="search"/>
</span>
</button>
</div>
<div class="loader-container" :class="{ 'is-loading': listService.loading}">
<form @submit.prevent="addTask()">
<div class="field is-grouped task-add">
<div class="field is-grouped">
<p class="control has-icons-left is-expanded" :class="{ 'is-loading': taskService.loading}">
<input v-focus class="input" :class="{ 'disabled': taskService.loading}" v-model="newTaskText" type="text" placeholder="Add a new task...">
<span class="icon is-small is-left">
@ -52,9 +21,9 @@
<div class="columns">
<div class="column">
<div class="tasks" v-if="tasks && tasks.length > 0" :class="{'short': isTaskEdit}">
<div class="task" v-for="l in tasks" :key="l.id">
<span>
<div class="tasks" v-if="this.list.tasks && this.list.tasks.length > 0" :class="{'short': isTaskEdit}">
<div class="task" v-for="l in list.tasks" :key="l.id">
<label :for="l.id">
<div class="fancycheckbox">
<input @change="markAsDone" type="checkbox" :id="l.id" :checked="l.done" style="display: none;">
<label :for="l.id" class="check">
@ -64,73 +33,64 @@
</svg>
</label>
</div>
<router-link :to="{ name: 'taskDetailView', params: { id: l.id } }" class="tasktext" :class="{ 'done': l.done}">
<!-- Show any parent tasks to make it clear this task is a sub task of something -->
<span class="parent-tasks" v-if="typeof l.related_tasks.parenttask !== 'undefined'">
<template v-for="(pt, i) in l.related_tasks.parenttask">
{{ pt.text }}<template v-if="(i + 1) < l.related_tasks.parenttask.length">,&nbsp;</template>
</template>
>
</span>
<span class="tasktext" :class="{ 'done': l.done}">
{{l.text}}
<span class="tag" v-for="label in l.labels" :style="{'background': label.hex_color, 'color': label.textColor}" :key="label.id">
<span>{{ label.title }}</span>
</span>
<img :src="a.getAvatarUrl(27)" :alt="a.username" v-for="(a, i) in l.assignees" class="avatar" :key="l.id + 'assignee' + a.id + i"/>
<i v-if="l.dueDate > 0" :class="{'overdue': l.dueDate <= new Date() && !l.done}" v-tooltip="formatDate(l.dueDate)"> - Due {{formatDateSince(l.dueDate)}}</i>
<priority-label :priority="l.priority"/>
</router-link>
</span>
<img :src="gravatar(a)" :alt="a.username" v-for="a in l.assignees" class="avatar" :key="l.id + 'assignee' + a.id"/>
<i v-if="l.dueDate > 0" :class="{'overdue': (l.dueDate <= new Date())}"> - Due on {{new Date(l.dueDate).toLocaleString()}}</i>
<span v-if="l.priority >= priorities.HIGH" class="high-priority" :class="{'not-so-high': l.priority === priorities.HIGH}">
<span class="icon">
<icon icon="exclamation"/>
</span>
<template v-if="l.priority === priorities.HIGH">High</template>
<template v-if="l.priority === priorities.URGENT">Urgent</template>
<template v-if="l.priority === priorities.DO_NOW">DO NOW</template>
<span class="icon" v-if="l.priority === priorities.DO_NOW">
<icon icon="exclamation"/>
</span>
</span>
</span>
</label>
<div @click="editTask(l.id)" class="icon settings">
<icon icon="pencil-alt"/>
<icon icon="cog"/>
</div>
</div>
</div>
</div>
<div class="column is-4" v-if="isTaskEdit">
<div class="card taskedit">
<header class="card-header">
<p class="card-header-title">
Edit Task
</p>
<a class="card-header-icon" @click="isTaskEdit = false">
<span class="icon">
<icon icon="angle-right"/>
</span>
</a>
</header>
<div class="card-content">
<div class="content">
<edit-task :task="taskEditTask"/>
</div>
</div>
<header class="card-header">
<p class="card-header-title">
Edit Task
</p>
<a class="card-header-icon" @click="isTaskEdit = false">
<span class="icon">
<icon icon="angle-right"/>
</span>
</a>
</header>
<div class="card-content">
<div class="content">
<edit-task :task="taskEditTask"/>
</div>
</div>
</div>
</div>
</div>
<nav class="pagination is-centered" role="navigation" aria-label="pagination" v-if="taskCollectionService.totalPages > 1">
<router-link class="pagination-previous" :to="{name: 'showList', query: { page: currentPage - 1 }}" tag="button" :disabled="currentPage === 1">Previous</router-link>
<router-link class="pagination-next" :to="{name: 'showList', query: { page: currentPage + 1 }}" tag="button" :disabled="currentPage === taskCollectionService.totalPages">Next page</router-link>
<ul class="pagination-list">
<template v-for="(p, i) in pages">
<li :key="'page'+i" v-if="p.isEllipsis"><span class="pagination-ellipsis">&hellip;</span></li>
<li :key="'page'+i" v-else>
<router-link :to="{name: 'showList', query: { page: p.number }}" :class="{'is-current': p.number === currentPage}" class="pagination-link" :aria-label="'Goto page ' + p.number">{{ p.number }}</router-link>
</li>
</template>
</ul>
</nav>
</div>
</template>
<script>
import message from '../../message'
import ListService from '../../services/list'
import TaskService from '../../services/task'
import ListModel from '../../models/list'
import EditTask from './edit-task'
import TaskModel from '../../models/task'
import PriorityLabel from './reusable/priorityLabel'
import TaskCollectionService from '../../services/taskCollection'
import EditTask from './edit-task'
import TaskModel from '../../models/task'
import priorities from '../../models/priorities'
export default {
data() {
@ -138,23 +98,16 @@
listID: this.$route.params.id,
listService: ListService,
taskService: TaskService,
taskCollectionService: TaskCollectionService,
pages: [],
currentPage: 0,
list: {},
tasks: [],
isTaskEdit: false,
list: {},
isTaskEdit: false,
taskEditTask: TaskModel,
newTaskText: '',
showTaskSearch: false,
searchTerm: '',
priorities: {},
}
},
components: {
PriorityLabel,
components: {
EditTask,
},
},
props: {
theList: {
type: ListModel,
@ -164,98 +117,40 @@
watch: {
theList() {
this.list = this.theList
},
'$route.query': 'loadTasksForPage', // Only listen for query path changes
}
},
created() {
this.listService = new ListService()
this.taskService = new TaskService()
this.taskCollectionService = new TaskCollectionService()
this.initTasks(1)
this.priorities = priorities
this.taskEditTask = null
this.isTaskEdit = false
},
methods: {
// This function initializes the tasks page and loads the first page of tasks
initTasks(page) {
this.taskEditTask = null
this.isTaskEdit = false
this.loadTasks(page)
},
addTask() {
let task = new TaskModel({text: this.newTaskText, listID: this.$route.params.id})
this.taskService.create(task)
.then(r => {
this.tasks.push(r)
this.sortTasks()
this.list.addTaskToList(r)
this.newTaskText = ''
this.success({message: 'The task was successfully created.'}, this)
message.success({message: 'The task was successfully created.'}, this)
})
.catch(e => {
this.error(e, this)
message.error(e, this)
})
},
loadTasks(page, search = '') {
const params = {sort_by: ['done', 'id'], order_by: ['asc', 'desc']}
if (search !== '') {
params.s = search
}
this.taskCollectionService.getAll({listID: this.$route.params.id}, params, page)
.then(r => {
this.$set(this, 'tasks', r)
this.$set(this, 'pages', [])
this.currentPage = page
for (let i = 0; i < this.taskCollectionService.totalPages; i++) {
// Show ellipsis instead of all pages
if(
i > 0 && // Always at least the first page
(i + 1) < this.taskCollectionService.totalPages && // And the last page
(
// And the current with current + 1 and current - 1
(i + 1) > this.currentPage + 1 ||
(i + 1) < this.currentPage - 1
)
) {
// Only add an ellipsis if the last page isn't already one
if(this.pages[i - 1] && !this.pages[i - 1].isEllipsis) {
this.pages.push({
number: 0,
isEllipsis: true,
})
}
continue
}
this.pages.push({
number: i + 1,
isEllipsis: false,
})
}
})
.catch(e => {
this.error(e, this)
})
},
loadTasksForPage(e) {
// The page parameter can be undefined, in the case where the user loads a new list from the side bar menu
let page = e.page
if (typeof e.page === 'undefined') {
page = 1
}
this.initTasks(page)
},
markAsDone(e) {
let updateFunc = () => {
// We get the task, update the 'done' property and then push it to the api.
let task = this.getTaskByID(e.target.id)
let task = this.list.getTaskByID(e.target.id)
task.done = e.target.checked
this.taskService.update(task)
.then(() => {
this.sortTasks()
this.success({message: 'The task was successfully ' + (task.done ? '' : 'un-') + 'marked as done.'}, this)
this.list.sortTasks()
message.success({message: 'The task was successfully ' + (task.done ? '' : 'un-') + 'marked as done.'}, this)
})
.catch(e => {
this.error(e, this)
message.error(e, this)
})
}
@ -267,50 +162,12 @@
},
editTask(id) {
// Find the selected task and set it to the current object
let theTask = this.getTaskByID(id) // Somehow this does not work if we directly assign this to this.taskEditTask
this.taskEditTask = theTask
let theTask = this.list.getTaskByID(id) // Somehow this does not work if we directly assign this to this.taskEditTask
this.taskEditTask = theTask
this.isTaskEdit = true
},
getTaskByID(id) {
for (const t in this.tasks) {
if (this.tasks[t].id === parseInt(id)) {
return this.tasks[t]
}
}
return {} // FIXME: This should probably throw something to make it clear to the user noting was found
},
sortTasks() {
if (this.tasks === null || this.tasks === []) {
return
}
return this.tasks.sort(function(a,b) {
if (a.done < b.done)
return -1
if (a.done > b.done)
return 1
if (a.id > b.id)
return -1
if (a.id < b.id)
return 1
return 0
})
},
searchTasks() {
if (this.searchTerm === '') {
return
}
this.loadTasks(1, this.searchTerm)
},
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 firering the search event.
setTimeout(() => {
this.showTaskSearch = false
}, 200)
gravatar(user) {
return 'https://www.gravatar.com/avatar/' + user.avatarUrl + '?s=27'
},
}
}

View File

@ -8,7 +8,7 @@
</template>
<div class="spinner" :class="{ 'is-loading': taskService.loading}"></div>
<div class="tasks" v-if="tasks && tasks.length > 0">
<div @click="gotoTask(l)" class="task" v-for="l in undoneTasks" :key="l.id">
<div @click="gotoList(l.listID)" class="task" v-for="l in tasks" :key="l.id" v-if="!l.done">
<label :for="l.id">
<div class="fancycheckbox">
<input type="checkbox" :id="l.id" :checked="l.done" style="display: none;" disabled>
@ -21,8 +21,18 @@
</div>
<span class="tasktext">
{{l.text}}
<i v-if="l.dueDate > 0" :class="{'overdue': l.dueDate <= new Date()}" v-tooltip="formatDate(l.dueDate)"> - Due {{formatDateSince(l.dueDate)}}</i>
<priority-label :priority="l.priority"/>
<i v-if="l.dueDate > 0" :class="{'overdue': (new Date(l.dueDate * 1000) <= new Date())}"> - Due on {{formatUnixDate(l.dueDate)}}</i>
<span v-if="l.priority >= priorities.HIGH" class="high-priority" :class="{'not-so-high': l.priority === priorities.HIGH}">
<span class="icon">
<icon icon="exclamation"/>
</span>
<template v-if="l.priority === priorities.HIGH">High</template>
<template v-if="l.priority === priorities.URGENT">Urgent</template>
<template v-if="l.priority === priorities.DO_NOW">DO NOW</template>
<span class="icon" v-if="l.priority === priorities.DO_NOW">
<icon icon="exclamation"/>
</span>
</span>
</span>
</label>
</div>
@ -31,19 +41,18 @@
</template>
<script>
import router from '../../router'
import message from '../../message'
import TaskService from '../../services/task'
import PriorityLabel from './reusable/priorityLabel'
import priorities from '../../models/priorities'
export default {
name: 'ShowTasks',
components: {
PriorityLabel
},
name: "ShowTasks",
data() {
return {
tasks: [],
hasUndoneTasks: false,
taskService: TaskService,
priorities: priorities,
}
},
props: {
@ -55,14 +64,9 @@
this.taskService = new TaskService()
this.loadPendingTasks()
},
computed: {
undoneTasks: function () {
return this.tasks.filter(t => !t.done)
}
},
methods: {
loadPendingTasks() {
let params = {sort_by: ['due_date_unix', 'id'], order_by: ['desc', 'desc']}
let params = {'sort': 'duedate'}
if (!this.showAll) {
params.startdate = Math.round(+ this.startDate / 1000)
params.enddate = Math.round(+ this.endDate / 1000)
@ -76,15 +80,22 @@
this.hasUndoneTasks = true
}
}
r.sort(this.sortyByDeadline)
}
this.$set(this, 'tasks', r)
})
.catch(e => {
this.error(e, this)
message.error(e, this)
})
},
gotoTask(task) {
router.push({name: 'taskDetailView', params: {id: task.id}})
formatUnixDate(dateUnix) {
return (new Date(dateUnix * 1000)).toLocaleString()
},
sortyByDeadline(a, b) {
return ((a.dueDate > b.dueDate) ? -1 : ((a.dueDate < b.dueDate) ? 1 : 0));
},
gotoList(lid) {
router.push({name: 'showList', params: {id: lid}})
},
},
}

View File

@ -1,450 +0,0 @@
<template>
<div class="loader-container" :class="{ 'is-loading': taskService.loading}">
<div class="task-view">
<div class="heading">
<h1 class="title task-id">
#{{ task.id }}
</h1>
<div class="is-done" v-if="task.done">Done</div>
<h1 class="title input" contenteditable="true" @focusout="saveTaskOnChange()" ref="taskTitle">{{ task.text }}</h1>
</div>
<h6 class="subtitle">
{{ namespace.name }} >
<router-link :to="{ name: 'showList', params: { id: list.id } }">
{{ list.title }}
</router-link>
</h6>
<!-- Content and buttons -->
<div class="columns">
<!-- Content -->
<div class="column">
<div class="columns details">
<div class="column assignees" v-if="activeFields.assignees">
<!-- Assignees -->
<div class="detail-title">
<icon icon="users"/>
Assignees
</div>
<edit-assignees
:task-i-d="task.id"
:list-i-d="task.listID"
:initial-assignees="task.assignees"
ref="assignees"
/>
</div>
<div class="column" v-if="activeFields.priority">
<!-- Priority -->
<div class="detail-title">
<icon :icon="['far', 'star']"/>
Priority
</div>
<priority-select v-model="task.priority" @change="saveTask" ref="priority"/>
</div>
<div class="column" v-if="activeFields.dueDate">
<!-- Due Date -->
<div class="detail-title">
<icon icon="calendar"/>
Due Date
</div>
<div class="date-input">
<flat-pickr
:class="{ 'disabled': taskService.loading}"
class="input"
:disabled="taskService.loading"
v-model="task.dueDate"
:config="flatPickerConfig"
@on-close="saveTask"
placeholder="Click here to set a due date"
ref="dueDate"
>
</flat-pickr>
<a v-if="task.dueDate" @click="() => {task.dueDate = null;saveTask()}">
<span class="icon is-small">
<icon icon="times"></icon>
</span>
</a>
</div>
</div>
<div class="column" v-if="activeFields.percentDone">
<!-- Percent Done -->
<div class="detail-title">
<icon icon="percent"/>
Percent Done
</div>
<percent-done-select v-model="task.percentDone" @change="saveTask" ref="percentDone"/>
</div>
<div class="column" v-if="activeFields.startDate">
<!-- Start Date -->
<div class="detail-title">
<icon icon="calendar-week"/>
Start Date
</div>
<div class="date-input">
<flat-pickr
:class="{ 'disabled': taskService.loading}"
class="input"
:disabled="taskService.loading"
v-model="task.startDate"
:config="flatPickerConfig"
@on-close="saveTask"
placeholder="Click here to set a start date"
ref="startDate"
>
</flat-pickr>
<a v-if="task.startDate" @click="() => {task.startDate = null;saveTask()}">
<span class="icon is-small">
<icon icon="times"></icon>
</span>
</a>
</div>
</div>
<div class="column" v-if="activeFields.endDate">
<!-- End Date -->
<div class="detail-title">
<icon icon="calendar-week"/>
End Date
</div>
<div class="date-input">
<flat-pickr
:class="{ 'disabled': taskService.loading}"
class="input"
:disabled="taskService.loading"
v-model="task.endDate"
:config="flatPickerConfig"
@on-close="saveTask"
placeholder="Click here to set an end date"
ref="endDate"
>
</flat-pickr>
<a v-if="task.endDate" @click="() => {task.endDate = null;saveTask()}">
<span class="icon is-small">
<icon icon="times"></icon>
</span>
</a>
</div>
</div>
<div class="column" v-if="activeFields.reminders">
<!-- Reminders -->
<div class="detail-title">
<icon icon="history"/>
Reminders
</div>
<reminders v-model="task.reminderDates" @change="saveTask" ref="reminders"/>
</div>
<div class="column" v-if="activeFields.repeatAfter">
<!-- Repeat after -->
<div class="detail-title">
<icon :icon="['far', 'clock']"/>
Repeat
</div>
<repeat-after v-model="task.repeatAfter" @change="saveTask" ref="repeatAfter"/>
</div>
</div>
<!-- Labels -->
<div class="labels-list details" v-if="activeFields.labels">
<div class="detail-title">
<span class="icon is-grey">
<icon icon="tags"/>
</span>
Labels
</div>
<edit-labels :task-i-d="taskID" :start-labels="task.labels" ref="labels"/>
</div>
<!-- Description -->
<div class="details content" :class="{ 'has-top-border': activeFields.labels }">
<h3>
<span class="icon is-grey">
<icon icon="align-left"/>
</span>
Description
</h3>
<!-- We're using a normal textarea until the problem with the icons is resolved in easymde -->
<!-- <easymde v-model="task.description" @change="saveTask"/>-->
<textarea class="textarea" v-model="task.description" @change="saveTask" rows="6" placeholder="Click here to enter a description..."></textarea>
</div>
<!-- Attachments -->
<div class="content attachments has-top-border" v-if="activeFields.attachments">
<attachments
:task-i-d="taskID"
:initial-attachments="task.attachments"
ref="attachments"
/>
</div>
<!-- Related Tasks -->
<div class="content details has-top-border" v-if="activeFields.relatedTasks">
<h3>
<span class="icon is-grey">
<icon icon="tasks"/>
</span>
Related Tasks
</h3>
<related-tasks
:task-i-d="taskID"
:initial-related-tasks="task.related_tasks"
:show-no-relations-notice="true"
ref="relatedTasks"
/>
</div>
</div>
<div class="column is-one-fifth action-buttons">
<a class="button is-outlined noshadow has-no-border" :class="{'is-success': !task.done}" @click="toggleTaskDone()">
<span class="icon is-small"><icon icon="check-double"/></span>
<template v-if="task.done">
Mark as undone
</template>
<template v-else>
Done!
</template>
</a>
<a class="button" @click="setFieldActive('assignees')">
<span class="icon is-small"><icon icon="users"/></span>
Assign this task to a user
</a>
<a class="button" @click="setFieldActive('labels')">
<span class="icon is-small"><icon icon="tags"/></span>
Add labels
</a>
<a class="button" @click="setFieldActive('reminders')">
<span class="icon is-small"><icon icon="history"/></span>
Set Reminders
</a>
<a class="button" @click="setFieldActive('dueDate')">
<span class="icon is-small"><icon icon="calendar"/></span>
Set Due Date
</a>
<a class="button" @click="setFieldActive('startDate')">
<span class="icon is-small"><icon icon="calendar-week"/></span>
Set a Start Date
</a>
<a class="button" @click="setFieldActive('endDate')">
<span class="icon is-small"><icon icon="calendar-week"/></span>
Set an End Date
</a>
<a class="button" @click="setFieldActive('repeatAfter')">
<span class="icon is-small"><icon :icon="['far', 'clock']"/></span>
Set a repeating interval
</a>
<a class="button" @click="setFieldActive('priority')">
<span class="icon is-small"><icon :icon="['far', 'star']"/></span>
Set Priority
</a>
<a class="button" @click="setFieldActive('percentDone')">
<span class="icon is-small"><icon icon="percent"/></span>
Set Percent Done
</a>
<a class="button" @click="setFieldActive('attachments')">
<span class="icon is-small"><icon icon="paperclip"/></span>
Add attachments
</a>
<a class="button" @click="setFieldActive('relatedTasks')">
<span class="icon is-small"><icon icon="tasks"/></span>
Add task relations
</a>
<a class="button is-danger is-outlined noshadow has-no-border" @click="showDeleteModal = true">
<span class="icon is-small"><icon icon="trash-alt"/></span>
Delete task
</a>
</div>
</div>
<!-- Created / Updated [by] -->
</div>
<modal
v-if="showDeleteModal"
@close="showDeleteModal = false"
@submit="deleteTask()">
<span slot="header">Delete this task</span>
<p slot="text">
Are you sure you want to remove this task? <br/>
This will also remove all attachments, reminders and relations associated with this task and
<b>cannot be undone!</b>
</p>
</modal>
</div>
</template>
<script>
import TaskService from '../../services/task'
import TaskModel from '../../models/task'
import relationKinds from '../../models/relationKinds'
import ListModel from '../../models/list'
import NamespaceModel from '../../models/namespace'
import priorites from '../../models/priorities'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import PrioritySelect from './reusable/prioritySelect'
import PercentDoneSelect from './reusable/percentDoneSelect'
import EditLabels from './reusable/editLabels'
import EditAssignees from './reusable/editAssignees'
import Attachments from './reusable/attachments'
import RelatedTasks from './reusable/relatedTasks'
import RepeatAfter from './reusable/repeatAfter'
import Reminders from './reusable/reminders'
import router from '../../router'
export default {
name: 'TaskDetailView',
components: {
Reminders,
RepeatAfter,
RelatedTasks,
Attachments,
EditAssignees,
EditLabels,
PercentDoneSelect,
PrioritySelect,
flatPickr,
},
data() {
return {
taskID: Number(this.$route.params.id),
taskService: TaskService,
task: TaskModel,
relationKinds: relationKinds,
list: ListModel,
namespace: NamespaceModel,
showDeleteModal: false,
taskTitle: '',
priorities: priorites,
flatPickerConfig: {
altFormat: 'j M Y H:i',
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
},
activeFields: {
assignees: false,
priority: false,
dueDate: false,
percentDone: false,
startDate: false,
endDate: false,
reminders: false,
repeatAfter: false,
labels: false,
attachments: false,
relatedTasks: false,
},
}
},
watch: {
'$route': 'loadTask'
},
created() {
this.taskService = new TaskService()
this.task = new TaskModel()
},
mounted() {
this.loadTask()
},
methods: {
loadTask() {
this.taskID = Number(this.$route.params.id)
this.taskService.get({id: this.taskID})
.then(r => {
this.$set(this, 'task', r)
this.setListAndNamespaceTitleFromParent()
this.taskTitle = this.task.text
this.setActiveFields()
})
.catch(e => {
this.error(e, this)
})
},
setActiveFields() {
this.task.dueDate = +new Date(this.task.dueDate) === 0 ? null : this.task.dueDate
this.task.startDate = +new Date(this.task.startDate) === 0 ? null : this.task.startDate
this.task.endDate = +new Date(this.task.endDate) === 0 ? null : this.task.endDate
// Set all active fields based on values in the model
this.activeFields.assignees = this.task.assignees.length > 0
this.activeFields.priority = this.task.priority !== priorites.UNSET
this.activeFields.dueDate = this.task.dueDate !== null
this.activeFields.percentDone = this.task.percentDone > 0
this.activeFields.startDate = this.task.startDate !== null
this.activeFields.endDate = this.task.endDate !== null
this.activeFields.reminders = this.task.reminderDates.length > 1
this.activeFields.repeatAfter = this.task.repeatAfter.amount > 0
this.activeFields.labels = this.task.labels.length > 0
this.activeFields.attachments = this.task.attachments.length > 0
this.activeFields.relatedTasks = Object.keys(this.task.related_tasks).length > 0
},
saveTaskOnChange() {
this.$refs.taskTitle.spellcheck = false
// Pull the task title from the contenteditable
let taskTitle = this.$refs.taskTitle.textContent
this.task.text = taskTitle
// We only want to save if the title was actually change.
// Because the contenteditable does not have a change event,
// we're building it ourselves and only calling saveTask()
// if the task title changed.
if (this.task.text !== this.taskTitle) {
this.saveTask()
this.taskTitle = taskTitle
}
},
saveTask() {
// If no end date is being set, but a start date and due date,
// use the due date as the end date
if (this.task.endDate === null && this.task.startDate !== null && this.task.dueDate !== null) {
this.task.endDate = this.task.dueDate
}
this.taskService.update(this.task)
.then(r => {
this.$set(this, 'task', r)
this.success({message: 'The task was saved successfully.'}, this)
this.setActiveFields()
})
.catch(e => {
this.error(e, this)
})
},
setListAndNamespaceTitleFromParent() {
// FIXME: Throw this away once we have vuex
this.$parent.namespaces.forEach(n => {
n.lists.forEach(l => {
if (l.id === this.task.listID) {
this.list = l
this.namespace = n
return
}
})
})
},
setFieldActive(fieldName) {
this.activeFields[fieldName] = true
this.$nextTick(() => this.$refs[fieldName].$el.focus())
},
deleteTask() {
this.taskService.delete(this.task)
.then(() => {
this.success({message: 'The task been deleted successfully.'}, this)
router.push({name: 'showList', params: {id: this.list.id}})
})
.catch(e => {
this.error(e, this)
})
},
toggleTaskDone() {
this.task.done = !this.task.done
this.saveTask()
},
},
}
</script>

View File

@ -1,233 +1,459 @@
<template>
<form @submit.prevent="editTaskSubmit()">
<div class="field">
<label class="label" for="tasktext">Task Text</label>
<div class="control">
<input v-focus :class="{ 'disabled': taskService.loading}" :disabled="taskService.loading" class="input"
type="text" id="tasktext" placeholder="The task text is here..." v-model="taskEditTask.text" @change="editTaskSubmit()">
</div>
</div>
<div class="field">
<label class="label" for="taskdescription">Description</label>
<div class="control">
<textarea :class="{ 'disabled': taskService.loading}" :disabled="taskService.loading" class="textarea"
placeholder="The tasks description goes here..." id="taskdescription"
v-model="taskEditTask.description" @change="editTaskSubmit()">
</textarea>
</div>
</div>
<form @submit.prevent="editTaskSubmit()">
<div class="field">
<label class="label" for="tasktext">Task Text</label>
<div class="control">
<input v-focus :class="{ 'disabled': taskService.loading}" :disabled="taskService.loading" class="input" type="text" id="tasktext" placeholder="The task text is here..." v-model="taskEditTask.text">
</div>
</div>
<div class="field">
<label class="label" for="taskdescription">Description</label>
<div class="control">
<textarea :class="{ 'disabled': taskService.loading}" :disabled="taskService.loading" class="textarea" placeholder="The tasks description goes here..." id="taskdescription" v-model="taskEditTask.description"></textarea>
</div>
</div>
<b>Reminder Dates</b>
<reminders v-model="taskEditTask.reminderDates" @change="editTaskSubmit()"/>
<b>Reminder Dates</b>
<div class="reminder-input" :class="{ 'overdue': (r < nowUnix && index !== (taskEditTask.reminderDates.length - 1))}" v-for="(r, index) in taskEditTask.reminderDates" :key="index">
<flat-pickr
:class="{ 'disabled': taskService.loading}"
:disabled="taskService.loading"
:v-model="taskEditTask.reminderDates"
:config="flatPickerConfig"
:id="'taskreminderdate' + index"
:value="r"
:data-index="index"
placeholder="Add a new reminder...">
</flat-pickr>
<a v-if="index !== (taskEditTask.reminderDates.length - 1)" @click="removeReminderByIndex(index)"><icon icon="times"></icon></a>
</div>
<div class="field">
<label class="label" for="taskduedate">Due Date</label>
<div class="control">
<flat-pickr
:class="{ 'disabled': taskService.loading}"
class="input"
:disabled="taskService.loading"
v-model="taskEditTask.dueDate"
:config="flatPickerConfig"
@on-close="editTaskSubmit()"
id="taskduedate"
placeholder="The tasks due date is here...">
</flat-pickr>
</div>
</div>
<div class="field">
<label class="label" for="taskduedate">Due Date</label>
<div class="control">
<flat-pickr
:class="{ 'disabled': taskService.loading}"
class="input"
:disabled="taskService.loading"
v-model="taskEditTask.dueDate"
:config="flatPickerConfig"
id="taskduedate"
placeholder="The tasks due date is here...">
</flat-pickr>
</div>
</div>
<div class="field">
<label class="label" for="">Duration</label>
<div class="control columns">
<div class="column">
<flat-pickr
:class="{ 'disabled': taskService.loading}"
class="input"
:disabled="taskService.loading"
v-model="taskEditTask.startDate"
:config="flatPickerConfig"
@on-close="editTaskSubmit()"
id="taskduedate"
placeholder="Start date">
</flat-pickr>
</div>
<div class="column">
<flat-pickr
:class="{ 'disabled': taskService.loading}"
class="input"
:disabled="taskService.loading"
v-model="taskEditTask.endDate"
:config="flatPickerConfig"
@on-close="editTaskSubmit()"
id="taskduedate"
placeholder="End date">
</flat-pickr>
</div>
</div>
</div>
<div class="field">
<label class="label" for="">Duration</label>
<div class="control columns">
<div class="column">
<flat-pickr
:class="{ 'disabled': taskService.loading}"
class="input"
:disabled="taskService.loading"
v-model="taskEditTask.startDate"
:config="flatPickerConfig"
id="taskduedate"
placeholder="Start date">
</flat-pickr>
</div>
<div class="column">
<flat-pickr
:class="{ 'disabled': taskService.loading}"
class="input"
:disabled="taskService.loading"
v-model="taskEditTask.endDate"
:config="flatPickerConfig"
id="taskduedate"
placeholder="End date">
</flat-pickr>
</div>
</div>
</div>
<div class="field">
<label class="label" for="">Repeat after</label>
<repeat-after v-model="taskEditTask.repeatAfter" @change="editTaskSubmit()"/>
</div>
<div class="field">
<label class="label" for="">Repeat after</label>
<div class="control repeat-after-input columns">
<div class="column">
<input class="input" placeholder="Specify an amount..." v-model="taskEditTask.repeatAfter.amount"/>
</div>
<div class="column is-3">
<div class="select">
<select v-model="taskEditTask.repeatAfter.type">
<option value="hours">Hours</option>
<option value="days">Days</option>
<option value="weeks">Weeks</option>
<option value="months">Months</option>
<option value="years">Years</option>
</select>
</div>
</div>
</div>
</div>
<div class="field">
<label class="label" for="">Priority</label>
<div class="control priority-select">
<priority-select v-model="taskEditTask.priority" @change="editTaskSubmit()"/>
</div>
</div>
<div class="field">
<label class="label" for="">Priority</label>
<div class="control priority-select">
<div class="select">
<select v-model="taskEditTask.priority">
<option :value="priorities.UNSET">Unset</option>
<option :value="priorities.LOW">Low</option>
<option :value="priorities.MEDIUM">Medium</option>
<option :value="priorities.HIGH">High</option>
<option :value="priorities.URGENT">Urgent</option>
<option :value="priorities.DO_NOW">DO NOW</option>
</select>
</div>
</div>
</div>
<div class="field">
<label class="label">Percent Done</label>
<div class="control">
<percent-done-select v-model="taskEditTask.percentDone" @change="editTaskSubmit()"/>
</div>
</div>
<div class="field">
<label class="label">Color</label>
<div class="control">
<verte
v-model="taskEditTask.hexColor"
menuPosition="top"
picker="square"
model="hex"
:enableAlpha="false"
:rgbSliders="true">
</verte>
</div>
</div>
<div class="field">
<label class="label">Color</label>
<div class="control">
<verte
v-model="taskEditTask.hexColor"
menuPosition="top"
picker="square"
model="hex"
:enableAlpha="false"
:rgbSliders="true"
@change="editTaskSubmit()"
/>
</div>
</div>
<div class="field">
<label class="label" for="">Assignees</label>
<ul class="assingees">
<li v-for="(a, index) in taskEditTask.assignees" :key="a.id">
{{a.username}}
<a @click="deleteAssigneeByIndex(index)"><icon icon="times"/></a>
</li>
</ul>
</div>
<div class="field">
<label class="label" for="">Assignees</label>
<ul class="assingees">
<li v-for="(a, index) in taskEditTask.assignees" :key="a.id">
{{a.username}}
<a @click="deleteAssigneeByIndex(index)">
<icon icon="times"/>
</a>
</li>
</ul>
</div>
<div class="field has-addons">
<div class="control is-expanded">
<multiselect
v-model="newAssignee"
:options="foundUsers"
:multiple="false"
:searchable="true"
:loading="listUserService.loading"
:internal-search="true"
@search-change="findUser"
placeholder="Type to search"
label="username"
track-by="id">
<template slot="clear" slot-scope="props">
<div class="multiselect__clear" v-if="newAssignee !== null && newAssignee.id !== 0" @mousedown.prevent.stop="clearAllFoundUsers(props.search)"></div>
</template>
<span slot="noResult">Oops! No user found. Consider changing the search query.</span>
</multiselect>
</div>
<div class="control">
<a @click="addAssignee" class="button is-primary fullheight">
<span class="icon is-small">
<icon icon="plus"/>
</span>
</a>
</div>
</div>
<div class="field has-addons">
<div class="control is-expanded">
<edit-assignees :task-i-d="taskEditTask.id" :list-i-d="taskEditTask.listID" :initial-assignees="taskEditTask.assignees"/>
</div>
</div>
<div class="field">
<label class="label">Labels</label>
<div class="control">
<multiselect
:multiple="true"
:close-on-select="false"
:clear-on-select="true"
:options-limit="300"
:hide-selected="true"
v-model="taskEditTask.labels"
:options="foundLabels"
:searchable="true"
:loading="labelService.loading || labelTaskService.loading"
:internal-search="true"
@search-change="findLabel"
@select="addLabel"
placeholder="Type to search"
label="title"
track-by="id"
:taggable="true"
@tag="createAndAddLabel"
tag-placeholder="Add this as new label"
>
<template slot="tag" slot-scope="{ option, remove }">
<span class="tag" :style="{'background': option.hex_color, 'color': option.textColor}">
<span>{{ option.title }}</span>
<a class="delete is-small" @click="removeLabel(option)"></a>
</span>
</template>
<template slot="clear" slot-scope="props">
<div class="multiselect__clear" v-if="taskEditTask.labels.length" @mousedown.prevent.stop="clearAllLabels(props.search)"></div>
</template>
</multiselect>
</div>
</div>
<div class="field">
<label class="label">Labels</label>
<div class="control">
<edit-labels :task-i-d="taskEditTask.id" :start-labels="taskEditTask.labels"/>
</div>
</div>
<div class="field">
<label class="label" for="subtasks">Subtasks</label>
<div class="tasks noborder" v-if="taskEditTask.subtasks && taskEditTask.subtasks.length > 0">
<div class="task" v-for="s in taskEditTask.subtasks" :key="s.id">
<label :for="s.id">
<div class="fancycheckbox">
<input @change="markAsDone" type="checkbox" :id="s.id" :checked="s.done" style="display: none;">
<label :for="s.id" class="check">
<svg width="18px" height="18px" viewBox="0 0 18 18">
<path d="M1,9 L1,3.5 C1,2 2,1 3.5,1 L14.5,1 C16,1 17,2 17,3.5 L17,14.5 C17,16 16,17 14.5,17 L3.5,17 C2,17 1,16 1,14.5 L1,9 Z"></path>
<polyline points="1 9 7 14 15 4"></polyline>
</svg>
</label>
</div>
<span class="tasktext" :class="{ 'done': s.done}">
{{s.text}}
</span>
</label>
</div>
</div>
</div>
<div class="field has-addons">
<div class="control is-expanded">
<input @keyup.enter="addSubtask()" :class="{ 'disabled': taskService.loading}" :disabled="taskService.loading" class="input" type="text" id="tasktext" placeholder="New subtask" v-model="newTask.text"/>
</div>
<div class="control">
<a class="button is-primary" @click="addSubtask()"><icon icon="plus"></icon></a>
</div>
</div>
<related-tasks
class="is-narrow"
:task-i-d="task.id"
:initial-related-tasks="task.related_tasks"
/>
<button type="submit" class="button is-success is-fullwidth" :class="{ 'is-loading': taskService.loading}">
Save
</button>
<button type="submit" class="button is-success is-fullwidth" :class="{ 'is-loading': taskService.loading}">
Save
</button>
</form>
</form>
</template>
<script>
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import message from '../../message'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import multiselect from 'vue-multiselect'
import {differenceWith} from 'lodash'
import verte from 'verte'
import 'verte/dist/verte.css'
import ListService from '../../services/list'
import TaskService from '../../services/task'
import TaskModel from '../../models/task'
import priorities from '../../models/priorities'
import PrioritySelect from './reusable/prioritySelect'
import PercentDoneSelect from './reusable/percentDoneSelect'
import EditLabels from './reusable/editLabels'
import EditAssignees from './reusable/editAssignees'
import RelatedTasks from './reusable/relatedTasks'
import RepeatAfter from './reusable/repeatAfter'
import Reminders from './reusable/reminders'
import ListService from '../../services/list'
import TaskService from '../../services/task'
import TaskModel from '../../models/task'
import UserModel from '../../models/user'
import ListUserService from '../../services/listUsers'
import priorities from '../../models/priorities'
import LabelTaskService from '../../services/labelTask'
import LabelService from '../../services/label'
import LabelTaskModel from '../../models/labelTask'
import LabelModel from '../../models/label'
export default {
name: 'edit-task',
data() {
return {
listID: this.$route.params.id,
listService: ListService,
taskService: TaskService,
export default {
name: 'edit-task',
data() {
return {
listID: this.$route.params.id,
listService: ListService,
taskService: TaskService,
priorities: priorities,
list: {},
newTask: TaskModel,
isTaskEdit: false,
taskEditTask: TaskModel,
flatPickerConfig: {
altFormat: 'j M Y H:i',
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
onOpen: this.updateLastReminderDate,
onClose: this.addReminderDate,
},
}
},
components: {
Reminders,
RepeatAfter,
RelatedTasks,
EditAssignees,
EditLabels,
PercentDoneSelect,
PrioritySelect,
flatPickr,
verte,
},
props: {
task: {
type: TaskModel,
required: true,
}
},
watch: {
task() {
this.taskEditTask = this.task
this.initTaskFields()
}
},
created() {
this.listService = new ListService()
this.taskService = new TaskService()
this.newTask = new TaskModel()
this.taskEditTask = this.task
this.initTaskFields()
},
methods: {
initTaskFields() {
this.taskEditTask.dueDate = +new Date(this.task.dueDate) === 0 ? null : this.task.dueDate
this.taskEditTask.startDate = +new Date(this.task.startDate) === 0 ? null : this.task.startDate
this.taskEditTask.endDate = +new Date(this.task.endDate) === 0 ? null : this.task.endDate
},
editTaskSubmit() {
this.taskService.update(this.taskEditTask)
.then(r => {
this.$set(this, 'taskEditTask', r)
this.success({message: 'The task was successfully updated.'}, this)
this.initTaskFields()
})
.catch(e => {
this.error(e, this)
})
},
},
}
priorities: priorities,
list: {},
newTask: TaskModel,
isTaskEdit: false,
taskEditTask: TaskModel,
lastReminder: 0,
nowUnix: new Date(),
flatPickerConfig:{
altFormat: 'j M Y H:i',
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
onOpen: this.updateLastReminderDate,
onClose: this.addReminderDate,
},
newAssignee: UserModel,
listUserService: ListUserService,
foundUsers: [],
labelService: LabelService,
labelTaskService: LabelTaskService,
foundLabels: [],
labelTimeout: null,
}
},
components: {
flatPickr,
multiselect,
verte,
},
props: {
task: {
type: TaskModel,
required: true,
}
},
watch: {
task() {
this.taskEditTask = this.task
}
},
created() {
this.listService = new ListService()
this.taskService = new TaskService()
this.newTask = new TaskModel()
this.listUserService = new ListUserService()
this.newAssignee = new UserModel()
this.labelService = new LabelService()
this.labelTaskService = new LabelTaskService()
this.taskEditTask = this.task
},
methods: {
editTaskSubmit() {
this.taskService.update(this.taskEditTask)
.then(r => {
this.$set(this, 'taskEditTask', r)
message.success({message: 'The task was successfully updated.'}, this)
})
.catch(e => {
message.error(e, this)
})
},
addSubtask() {
this.newTask.parentTaskID = this.taskEditTask.id
this.newTask.listID = this.$route.params.id
this.taskService.create(this.newTask)
.then(r => {
this.list.addTaskToList(r)
message.success({message: 'The task was successfully created.'}, this)
})
.catch(e => {
message.error(e, this)
})
this.newTask = {}
},
updateLastReminderDate(selectedDates) {
this.lastReminder = +new Date(selectedDates[0])
},
addReminderDate(selectedDates, dateStr, instance) {
let newDate = +new Date(selectedDates[0])
// Don't update if nothing changed
if (newDate === this.lastReminder) {
return
}
let index = parseInt(instance.input.dataset.index)
this.taskEditTask.reminderDates[index] = newDate
let lastIndex = this.taskEditTask.reminderDates.length - 1
// put a new null at the end if we changed something
if (lastIndex === index && !isNaN(newDate)) {
this.taskEditTask.reminderDates.push(null)
}
},
removeReminderByIndex(index) {
this.taskEditTask.reminderDates.splice(index, 1)
// Reset the last to 0 to have the "add reminder" button
this.taskEditTask.reminderDates[this.taskEditTask.reminderDates.length - 1] = null
},
addAssignee() {
this.taskEditTask.assignees.push(this.newAssignee)
},
deleteAssigneeByIndex(index) {
this.taskEditTask.assignees.splice(index, 1)
},
findUser(query) {
if(query === '') {
this.clearAllFoundUsers()
return
}
this.listUserService.getAll({listID: this.$route.params.id}, {s: query})
.then(response => {
// Filter the results to not include users who are already assigned
this.$set(this, 'foundUsers', differenceWith(response, this.taskEditTask.assignees, (first, second) => {
return first.id === second.id
}))
})
.catch(e => {
message.error(e, this)
})
},
clearAllFoundUsers () {
this.$set(this, 'foundUsers', [])
},
findLabel(query) {
if(query === '') {
this.clearAllLabels()
return
}
if(this.labelTimeout !== null) {
clearTimeout(this.labelTimeout)
}
// Delay the search 300ms to not send a request on every keystroke
this.labelTimeout = setTimeout(() => {
this.labelService.getAll({}, {s: query})
.then(response => {
this.$set(this, 'foundLabels', differenceWith(response, this.taskEditTask.labels, (first, second) => {
return first.id === second.id
}))
this.labelTimeout = null
})
.catch(e => {
message.error(e, this)
})
}, 300)
},
clearAllLabels () {
this.$set(this, 'foundLabels', [])
},
addLabel(label) {
let labelTask = new LabelTaskModel({taskID: this.taskEditTask.id, label_id: label.id})
this.labelTaskService.create(labelTask)
.then(() => {
message.success({message: 'The label was successfully added.'}, this)
})
.catch(e => {
message.error(e, this)
})
},
removeLabel(label) {
let labelTask = new LabelTaskModel({taskID: this.taskEditTask.id, label_id: label.id})
this.labelTaskService.delete(labelTask)
.then(() => {
// Remove the label from the list
for (const l in this.taskEditTask.labels) {
if (this.taskEditTask.labels[l].id === label.id) {
this.taskEditTask.labels.splice(l, 1)
}
}
message.success({message: 'The label was successfully removed.'}, this)
})
.catch(e => {
message.error(e, this)
})
},
createAndAddLabel(title) {
let newLabel = new LabelModel({title: title})
this.labelService.create(newLabel)
.then(r => {
this.addLabel(r)
this.taskEditTask.labels.push(r)
})
.catch(e => {
message.error(e, this)
})
}
},
}
</script>
<style scoped>

View File

@ -29,8 +29,8 @@
<VueDragResize
class="task"
:class="{'done': t.done, 'is-current-edit': taskToEdit !== null && taskToEdit.id === t.id, 'has-light-text': !t.hasDarkColor(), 'has-dark-text': t.hasDarkColor()}"
:style="{'border-color': t.hexColor, 'background-color': t.hexColor}"
:isActive="true"
:style="{'border-color': t.hexColor, 'background-color': t.hexColor}"
:isActive="true"
:x="t.offsetDays * dayWidth - 6"
:y="0"
:w="t.durationDays * dayWidth"
@ -46,17 +46,27 @@
@dragstop="resizeTask"
@clicked="taskDragged = t"
>
<span :class="{
'has-high-priority': t.priority >= priorities.HIGH,
'has-not-so-high-priority': t.priority === priorities.HIGH,
'has-super-high-priority': t.priority === priorities.DO_NOW
}">{{t.text}}</span>
<priority-label :priority="t.priority"/>
<span :class="{
'has-high-priority': t.priority >= priorities.HIGH,
'has-not-so-high-priority': t.priority === priorities.HIGH,
'has-super-high-priority': t.priority === priorities.DO_NOW
}">{{t.text}}</span>
<span v-if="t.priority >= priorities.HIGH" class="high-priority" :class="{'not-so-high': t.priority === priorities.HIGH}">
<span class="icon">
<icon icon="exclamation"/>
</span>
<template v-if="t.priority === priorities.HIGH">High</template>
<template v-if="t.priority === priorities.URGENT">Urgent</template>
<template v-if="t.priority === priorities.DO_NOW">DO NOW</template>
<span class="icon" v-if="t.priority === priorities.DO_NOW">
<icon icon="exclamation"/>
</span>
</span>
<!-- using the key here forces vue to use the updated version model and not the response returned by the api -->
<a @click="editTask(theTasks[k])" class="edit-toggle">
<icon icon="pen"/>
</a>
</VueDragResize>
<a @click="editTask(theTasks[k])" class="edit-toggle">
<icon icon="pen"/>
</a>
</VueDragResize>
</div>
<template v-if="showTaskswithoutDates">
<div class="row" v-for="(t, k) in tasksWithoutDates" :key="t.id" :style="{background: 'repeating-linear-gradient(90deg, #ededed, #ededed 1px, ' + (k % 2 === 0 ? '#fafafa 1px, #fafafa ' : '#fff 1px, #fff ') + dayWidth + 'px)'}">
@ -126,18 +136,17 @@
<script>
import VueDragResize from 'vue-drag-resize'
import message from '../../message'
import EditTask from './edit-task'
import TaskService from '../../services/task'
import TaskModel from '../../models/task'
import ListModel from '../../models/list'
import priorities from '../../models/priorities'
import PriorityLabel from "./reusable/priorityLabel";
export default {
name: 'GanttChart',
components: {
PriorityLabel,
EditTask,
VueDragResize,
},
@ -174,7 +183,7 @@
fullWidth: 0,
now: null,
dayOffsetUntilToday: 0,
isTaskEdit: false,
isTaskEdit: false,
taskToEdit: null,
newTaskTitle: '',
newTaskFieldActive: false,
@ -296,17 +305,17 @@
}
}
this.success({message: 'The task was successfully updated.'}, this)
message.success({message: 'The task was successfully updated.'}, this)
})
.catch(e => {
this.error(e, this)
message.error(e, this)
})
}, 100)
},
editTask(task) {
editTask(task) {
this.taskToEdit = task
this.isTaskEdit = true
},
},
showCreateNewTask() {
if(!this.newTaskFieldActive) {
// Timeout to not send the form if the field isn't even shown
@ -331,10 +340,10 @@
this.tasksWithoutDates.push(this.addGantAttributes(r))
this.newTaskTitle = ''
this.hideCrateNewTask()
this.success({message: 'The task was successfully created.'}, this)
message.success({message: 'The task was successfully created.'}, this)
})
.catch(e => {
this.error(e, this)
message.error(e, this)
})
},
},

View File

@ -1,190 +0,0 @@
<template>
<div class="attachments">
<h3>
<span class="icon is-grey">
<icon icon="paperclip"/>
</span>
Attachments
<a
class="button is-primary is-outlined is-small noshadow"
@click="$refs.files.click()"
:disabled="attachmentService.loading">
<span class="icon is-small"><icon icon="cloud-upload-alt"/></span>
Upload attachment
</a>
</h3>
<input type="file" id="files" ref="files" multiple @change="uploadNewAttachment()" :disabled="attachmentService.loading"/>
<progress v-if="attachmentService.uploadProgress > 0" class="progress is-primary" :value="attachmentService.uploadProgress" max="100">{{ attachmentService.uploadProgress }}%</progress>
<table>
<tr>
<th>Name</th>
<th>Size</th>
<th>Type</th>
<th>Date</th>
<th>Action</th>
</tr>
<tr class="attachment" v-for="a in attachments" :key="a.id">
<td>
{{ a.file.name }}
</td>
<td>{{ a.file.getHumanSize() }}</td>
<td>{{ a.file.mime }}</td>
<td v-tooltip="formatDate(a.created)">{{ formatDateSince(a.created) }}</td>
<td>
<div class="buttons has-addons">
<a class="button is-primary noshadow" @click="downloadAttachment(a)" v-tooltip="'Download this attachment'">
<span class="icon">
<icon icon="cloud-download-alt"/>
</span>
</a>
<a class="button is-danger noshadow" v-tooltip="'Delete this attachment'" @click="() => {attachmentToDelete = a; showDeleteModal = true}">
<span class="icon">
<icon icon="trash-alt"/>
</span>
</a>
</div>
</td>
</tr>
</table>
<!-- Dropzone -->
<div class="dropzone" :class="{ 'hidden': !showDropzone }">
<div class="drop-hint">
<div class="icon">
<icon icon="cloud-upload-alt"/>
</div>
<div class="hint">
Drop files here to upload
</div>
</div>
</div>
<!-- Delete modal -->
<modal
v-if="showDeleteModal"
@close="showDeleteModal = false"
v-on:submit="deleteAttachment()">
<span slot="header">Delete attachment</span>
<p slot="text">Are you sure you want to delete the attachment {{ attachmentToDelete.file.name }}?<br/>
<b>This CANNOT BE UNDONE!</b></p>
</modal>
</div>
</template>
<script>
import AttachmentService from '../../../services/attachment'
import AttachmentModel from '../../../models/attachment'
export default {
name: 'attachments',
data() {
return {
attachments: [],
attachmentService: AttachmentService,
showDropzone: false,
showDeleteModal: false,
attachmentToDelete: AttachmentModel,
}
},
props: {
taskID: {
required: true,
type: Number,
},
initialAttachments: {
type: Array,
}
},
created() {
this.attachmentService = new AttachmentService()
this.attachments = this.initialAttachments
},
mounted() {
document.addEventListener('dragenter', e => {
e.stopPropagation()
e.preventDefault()
this.showDropzone = true
});
window.addEventListener('dragleave', e => {
e.stopPropagation()
e.preventDefault()
this.showDropzone = false
});
document.addEventListener('dragover', e => {
e.stopPropagation()
e.preventDefault()
this.showDropzone = true
});
document.addEventListener('drop', e => {
e.stopPropagation()
e.preventDefault()
let files = e.dataTransfer.files
this.uploadFiles(files)
this.showDropzone = false
})
},
watch: {
initialAttachments(newVal) {
this.attachments = newVal
},
},
methods: {
downloadAttachment(attachment) {
this.attachmentService.download(attachment)
},
uploadNewAttachment() {
if(this.$refs.files.files.length === 0) {
return
}
this.uploadFiles(this.$refs.files.files)
},
uploadFiles(files) {
const attachmentModel = new AttachmentModel({task_id: this.taskID})
this.attachmentService.create(attachmentModel, files)
.then(r => {
if(r.success !== null) {
r.success.forEach(a => {
this.success({message: 'Successfully uploaded ' + a.file.name}, this)
this.attachments.push(a)
})
}
if(r.errors !== null) {
r.errors.forEach(m => {
this.error(m)
})
}
})
.catch(e => {
this.error(e, this)
})
},
deleteAttachment() {
this.attachmentService.delete(this.attachmentToDelete)
.then(r => {
// Remove the file from the list
for (const a in this.attachments) {
if (this.attachments[a].id === this.attachmentToDelete.id) {
this.attachments.splice(a, 1)
}
}
this.success(r, this)
})
.catch(e => {
this.error(e, this)
})
.finally(() => {
this.showDeleteModal = false
})
},
},
}
</script>

View File

@ -1,134 +0,0 @@
<template>
<multiselect
:multiple="true"
:close-on-select="false"
:clear-on-select="true"
:options-limit="300"
:hide-selected="true"
v-model="assignees"
:options="foundUsers"
:searchable="true"
:loading="listUserService.loading"
:internal-search="true"
@search-change="findUser"
@select="addAssignee"
placeholder="Type to assign a user..."
label="username"
track-by="id"
select-label="Assign this user"
:showNoOptions="false"
>
<template slot="tag" slot-scope="{ option }">
<user :user="option" :show-username="false" :avatar-size="30"/>
<a @click="removeAssignee(option)" class="remove-assignee">
<icon icon="times"/>
</a>
</template>
<template slot="clear" slot-scope="props">
<div class="multiselect__clear" v-if="newAssignee !== null && newAssignee.id !== 0"
@mousedown.prevent.stop="clearAllFoundUsers(props.search)"></div>
</template>
<span slot="noResult">No user found. Consider changing the search query.</span>
</multiselect>
</template>
<script>
import {differenceWith} from 'lodash'
import multiselect from 'vue-multiselect'
import UserModel from '../../../models/user'
import ListUserService from '../../../services/listUsers'
import TaskAssigneeService from '../../../services/taskAssignee'
import TaskAssigneeModel from '../../../models/taskAssignee'
import User from '../../global/user'
export default {
name: 'editAssignees',
components: {
User,
multiselect,
},
props: {
taskID: {
type: Number,
required: true,
},
listID: {
type: Number,
required: true,
},
initialAssignees: {
type: Array,
default: () => [],
}
},
data() {
return {
newAssignee: UserModel,
listUserService: ListUserService,
foundUsers: [],
assignees: [],
taskAssigneeService: TaskAssigneeService,
}
},
created() {
this.assignees = this.initialAssignees
this.listUserService = new ListUserService()
this.newAssignee = new UserModel()
this.taskAssigneeService = new TaskAssigneeService()
},
watch: {
initialAssignees(newVal) {
this.assignees = newVal
}
},
methods: {
addAssignee(user) {
const taskAssignee = new TaskAssigneeModel({user_id: user.id, task_id: this.taskID})
this.taskAssigneeService.create(taskAssignee)
.then(() => {
this.success({message: 'The user was successfully assigned.'}, this)
})
.catch(e => {
this.error(e, this)
})
},
removeAssignee(user) {
const taskAssignee = new TaskAssigneeModel({user_id: user.id, task_id: this.taskID})
this.taskAssigneeService.delete(taskAssignee)
.then(() => {
// Remove the assignee from the list
for (const a in this.assignees) {
if (this.assignees[a].id === user.id) {
this.assignees.splice(a, 1)
}
}
this.success({message: 'The user was successfully unassigned.'}, this)
})
.catch(e => {
this.error(e, this)
})
},
findUser(query) {
if (query === '') {
this.clearAllFoundUsers()
return
}
this.listUserService.getAll({listID: this.listID}, {s: query})
.then(response => {
// Filter the results to not include users who are already assigned
this.$set(this, 'foundUsers', differenceWith(response, this.assignees, (first, second) => {
return first.id === second.id
}))
})
.catch(e => {
this.error(e, this)
})
},
clearAllFoundUsers() {
this.$set(this, 'foundUsers', [])
},
},
}
</script>

View File

@ -1,154 +0,0 @@
<template>
<multiselect
:multiple="true"
:close-on-select="false"
:clear-on-select="true"
:options-limit="300"
:hide-selected="true"
v-model="labels"
:options="foundLabels"
:searchable="true"
:loading="labelService.loading || labelTaskService.loading"
:internal-search="true"
@search-change="findLabel"
@select="addLabel"
placeholder="Type to add a new label..."
label="title"
track-by="id"
:taggable="true"
:showNoOptions="false"
@tag="createAndAddLabel"
tag-placeholder="Add this as new label"
>
<template slot="tag" slot-scope="{ option }">
<span class="tag"
:style="{'background': option.hex_color, 'color': option.textColor}">
<span>{{ option.title }}</span>
<a class="delete is-small" @click="removeLabel(option)"></a>
</span>
</template>
<template slot="clear" slot-scope="props">
<div class="multiselect__clear" v-if="labels.length"
@mousedown.prevent.stop="clearAllLabels(props.search)"></div>
</template>
</multiselect>
</template>
<script>
import { differenceWith } from 'lodash'
import multiselect from 'vue-multiselect'
import LabelService from '../../../services/label'
import LabelModel from '../../../models/label'
import LabelTaskService from '../../../services/labelTask'
import LabelTaskModel from '../../../models/labelTask'
export default {
name: 'edit-labels',
props: {
startLabels: {
default: () => [],
type: Array,
},
taskID: {
type: Number,
required: true,
},
},
data() {
return {
labelService: LabelService,
labelTaskService: LabelTaskService,
foundLabels: [],
labelTimeout: null,
labels: [],
searchQuery: '',
}
},
components: {
multiselect,
},
watch: {
startLabels(newLabels) {
this.labels = newLabels
}
},
created() {
this.labelService = new LabelService()
this.labelTaskService = new LabelTaskService()
this.labels = this.startLabels
},
methods: {
findLabel(query) {
this.searchQuery = query
if (query === '') {
this.clearAllLabels()
return
}
if (this.labelTimeout !== null) {
clearTimeout(this.labelTimeout)
}
// Delay the search 300ms to not send a request on every keystroke
this.labelTimeout = setTimeout(() => {
this.labelService.getAll({}, {s: query})
.then(response => {
this.$set(this, 'foundLabels', differenceWith(response, this.labels, (first, second) => {
return first.id === second.id
}))
this.labelTimeout = null
})
.catch(e => {
this.error(e, this)
})
}, 300)
},
clearAllLabels() {
this.$set(this, 'foundLabels', [])
},
addLabel(label) {
let labelTask = new LabelTaskModel({taskID: this.taskID, label_id: label.id})
this.labelTaskService.create(labelTask)
.then(() => {
this.success({message: 'The label was successfully added.'}, this)
})
.catch(e => {
this.error(e, this)
})
},
removeLabel(label) {
let labelTask = new LabelTaskModel({taskID: this.taskID, label_id: label.id})
this.labelTaskService.delete(labelTask)
.then(() => {
// Remove the label from the list
for (const l in this.labels) {
if (this.labels[l].id === label.id) {
this.labels.splice(l, 1)
}
}
this.success({message: 'The label was successfully removed.'}, this)
})
.catch(e => {
this.error(e, this)
})
},
createAndAddLabel(title) {
let newLabel = new LabelModel({title: title})
this.labelService.create(newLabel)
.then(r => {
this.addLabel(r)
this.labels.push(r)
})
.catch(e => {
this.error(e, this)
})
},
},
}
</script>
<style scoped>
</style>

View File

@ -1,49 +0,0 @@
<template>
<div class="select">
<select v-model.number="percentDone" @change="updateData">
<option value="0">0%</option>
<option value="0.1">10%</option>
<option value="0.2">20%</option>
<option value="0.3">30%</option>
<option value="0.4">40%</option>
<option value="0.5">50%</option>
<option value="0.6">60%</option>
<option value="0.7">70%</option>
<option value="0.8">80%</option>
<option value="0.9">90%</option>
<option value="1">100%</option>
</select>
</div>
</template>
<script>
export default {
name: 'percentDoneSelect',
data() {
return {
percentDone: 0,
}
},
props: {
value: {
default: 0,
type: Number,
}
},
watch: {
// Set the priority to the :value every time it changes from the outside
value(newVal) {
this.percentDone = newVal
},
},
mounted() {
this.percentDone = this.value
},
methods: {
updateData() {
this.$emit('input', this.percentDone)
this.$emit('change')
}
},
}
</script>

View File

@ -1,51 +0,0 @@
<template>
<span v-if="priority >= priorities.HIGH" class="high-priority" :class="{'not-so-high': priority === priorities.HIGH}">
<span class="icon">
<icon icon="exclamation"/>
</span>
<template v-if="priority === priorities.HIGH">High</template>
<template v-if="priority === priorities.URGENT">Urgent</template>
<template v-if="priority === priorities.DO_NOW">DO NOW</template>
<span class="icon" v-if="priority === priorities.DO_NOW">
<icon icon="exclamation"/>
</span>
</span>
</template>
<script>
import priorites from '../../../models/priorities'
export default {
name: 'priorityLabel',
data() {
return {
priorities: priorites,
}
},
props: {
priority: {
default: 0,
type: Number,
}
}
}
</script>
<style lang="scss">
@import '../../../styles/theme/variables';
span.high-priority{
color: $red;
width: auto !important; // To override the width set in tasks
.icon {
vertical-align: middle;
width: auto !important;
padding: 0 .5em;
}
&.not-so-high {
color: $orange;
}
}
</style>

View File

@ -1,47 +0,0 @@
<template>
<div class="select">
<select v-model="priority" @change="updateData">
<option :value="priorities.UNSET">Unset</option>
<option :value="priorities.LOW">Low</option>
<option :value="priorities.MEDIUM">Medium</option>
<option :value="priorities.HIGH">High</option>
<option :value="priorities.URGENT">Urgent</option>
<option :value="priorities.DO_NOW">DO NOW</option>
</select>
</div>
</template>
<script>
import priorites from '../../../models/priorities'
export default {
name: 'prioritySelect',
data() {
return {
priorities: priorites,
priority: 0,
}
},
props: {
value: {
default: 0,
type: Number,
}
},
watch: {
// Set the priority to the :value every time it changes from the outside
value(newVal) {
this.priority = newVal
},
},
mounted() {
this.priority = this.value
},
methods: {
updateData() {
this.$emit('input', this.priority)
this.$emit('change')
}
},
}
</script>

View File

@ -1,193 +0,0 @@
<template>
<div class="task-relations">
<label class="label">New Task Relation</label>
<div class="columns">
<div class="column is-three-quarters">
<multiselect
v-model="newTaskRelationTask"
:options="foundTasks"
:multiple="false"
:searchable="true"
:loading="taskService.loading"
:internal-search="true"
@search-change="findTasks"
placeholder="Type search for a new task to add as related..."
label="text"
track-by="id"
>
<template slot="clear" slot-scope="props">
<div class="multiselect__clear"
v-if="newTaskRelationTask !== null && newTaskRelationTask.id !== 0"
@mousedown.prevent.stop="clearAllFoundTasks(props.search)"></div>
</template>
<span slot="noResult">No task found. Consider changing the search query.</span>
</multiselect>
</div>
<div class="column field has-addons">
<div class="control is-expanded">
<div class="select is-fullwidth has-defaults">
<select v-model="newTaskRelationKind">
<option value="unset">Select a relation kind</option>
<option v-for="(label, rk) in relationKinds" :key="rk" :value="rk">
{{ label }}
</option>
</select>
</div>
</div>
<div class="control">
<a class="button is-primary" @click="addTaskRelation()">Add task Relation</a>
</div>
</div>
</div>
<div class="related-tasks" v-for="(rts, kind ) in relatedTasks" :key="kind">
<template v-if="rts.length > 0">
<span class="title">{{ relationKinds[kind] }}</span>
<div class="tasks noborder">
<div class="task" v-for="t in rts" :key="t.id">
<router-link :to="{ name: 'taskDetailView', params: { id: t.id } }">
<span class="tasktext" :class="{ 'done': t.done}">
{{t.text}}
</span>
</router-link>
<a class="remove" @click="() => {showDeleteModal = true; relationToDelete = {relation_kind: kind, other_task_id: t.id}}">
<icon icon="trash-alt"/>
</a>
</div>
</div>
</template>
</div>
<p v-if="showNoRelationsNotice && Object.keys(relatedTasks).length === 0" class="none">No task relations yet.</p>
<!-- Delete modal -->
<modal
v-if="showDeleteModal"
@close="showDeleteModal = false"
@submit="removeTaskRelation()">
<span slot="header">Delete Task Relation</span>
<p slot="text">Are you sure you want to delete this task relation?<br/>
<b>This CANNOT BE UNDONE!</b></p>
</modal>
</div>
</template>
<script>
import TaskService from '../../../services/task'
import TaskModel from '../../../models/task'
import TaskRelationService from '../../../services/taskRelation'
import relationKinds from '../../../models/relationKinds'
import TaskRelationModel from '../../../models/taskRelation'
import multiselect from 'vue-multiselect'
export default {
name: 'relatedTasks',
data() {
return {
relatedTasks: {},
taskService: TaskService,
foundTasks: [],
relationKinds: relationKinds,
newTaskRelationTask: TaskModel,
newTaskRelationKind: 'unset',
taskRelationService: TaskRelationService,
showDeleteModal: false,
relationToDelete: {},
}
},
components: {
multiselect,
},
props: {
taskID: {
type: Number,
required: true,
},
initialRelatedTasks: {
type: Object,
default: () => {},
},
showNoRelationsNotice: {
type: Boolean,
default: false,
},
},
created() {
this.taskService = new TaskService()
this.taskRelationService = new TaskRelationService()
this.newTaskRelationTask = new TaskModel()
},
watch: {
initialRelatedTasks(newVal) {
this.relatedTasks = newVal
},
},
mounted() {
this.relatedTasks = this.initialRelatedTasks
},
methods: {
findTasks(query) {
if (query === '') {
this.clearAllFoundTasks()
return
}
this.taskService.getAll({}, {s: query})
.then(response => {
this.$set(this, 'foundTasks', response)
})
.catch(e => {
this.error(e, this)
})
},
clearAllFoundTasks() {
this.$set(this, 'foundTasks', [])
},
addTaskRelation() {
let rel = new TaskRelationModel({
task_id: this.taskID,
other_task_id: this.newTaskRelationTask.id,
relation_kind: this.newTaskRelationKind,
})
this.taskRelationService.create(rel)
.then(() => {
if (!this.relatedTasks[this.newTaskRelationKind]) {
this.$set(this.relatedTasks, this.newTaskRelationKind, [])
}
this.relatedTasks[this.newTaskRelationKind].push(this.newTaskRelationTask)
this.newTaskRelationKind = 'unset'
this.newTaskRelationTask = new TaskModel()
this.success({message: 'The task relation was created successfully'}, this)
})
.catch(e => {
this.error(e, this)
})
},
removeTaskRelation() {
let rel = new TaskRelationModel({
relation_kind: this.relationToDelete.relation_kind,
task_id: this.taskID,
other_task_id: this.relationToDelete.other_task_id,
})
this.taskRelationService.delete(rel)
.then(r => {
Object.keys(this.relatedTasks).forEach(relationKind => {
for (const t in this.relatedTasks[relationKind]) {
if (this.relatedTasks[relationKind][t].id === this.relationToDelete.other_task_id && relationKind === this.relationToDelete.relation_kind) {
this.relatedTasks[relationKind].splice(t, 1)
}
}
})
this.success(r, this)
})
.catch(e => {
this.error(e, this)
})
.finally(() => {
this.showDeleteModal = false
})
},
},
}
</script>

View File

@ -1,96 +0,0 @@
<template>
<div class="reminders">
<div class="reminder-input"
:class="{ 'overdue': (r < nowUnix && index !== (reminders.length - 1))}"
v-for="(r, index) in reminders" :key="index">
<flat-pickr
:v-model="reminders"
:config="flatPickerConfig"
:id="'taskreminderdate' + index"
:value="r"
:data-index="index"
placeholder="Add a new reminder..."
>
</flat-pickr>
<a v-if="index !== (reminders.length - 1)" @click="removeReminderByIndex(index)">
<icon icon="times"></icon>
</a>
</div>
</div>
</template>
<script>
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
export default {
name: 'reminders',
data() {
return {
reminders: [],
lastReminder: 0,
nowUnix: new Date(),
flatPickerConfig: {
altFormat: 'j M Y H:i',
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
onOpen: this.updateLastReminderDate,
onClose: this.addReminderDate,
},
}
},
props: {
value: {
default: () => [],
type: Array,
}
},
components: {
flatPickr,
},
mounted() {
this.reminders = this.value
},
watch: {
value(newVal) {
this.reminders = newVal
},
},
methods: {
updateData() {
this.$emit('input', this.reminders)
this.$emit('change')
},
updateLastReminderDate(selectedDates) {
this.lastReminder = +new Date(selectedDates[0])
},
addReminderDate(selectedDates, dateStr, instance) {
let newDate = +new Date(selectedDates[0])
// Don't update if nothing changed
if (newDate === this.lastReminder) {
return
}
let index = parseInt(instance.input.dataset.index)
this.reminders[index] = newDate
let lastIndex = this.reminders.length - 1
// put a new null at the end if we changed something
if (lastIndex === index && !isNaN(newDate)) {
this.reminders.push(null)
}
this.updateData()
},
removeReminderByIndex(index) {
this.reminders.splice(index, 1)
// Reset the last to 0 to have the "add reminder" button
this.reminders[this.reminders.length - 1] = null
this.updateData()
},
},
}
</script>

View File

@ -1,61 +0,0 @@
<template>
<div class="control repeat-after-input columns">
<div class="column">
<p>
Each
</p>
</div>
<div class="column is-two-fifths">
<input class="input" placeholder="Specify an amount..." v-model="repeatAfter.amount" @change="updateData"/>
</div>
<div class="column is-two-fifths">
<div class="select">
<select v-model="repeatAfter.type" @change="updateData">
<option value="hours">Hours</option>
<option value="days">Days</option>
<option value="weeks">Weeks</option>
<option value="months">Months</option>
<option value="years">Years</option>
</select>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'repeatAfter',
data() {
return {
repeatAfter: {},
}
},
props: {
value: {
default: () => {},
required: true,
}
},
watch: {
value(newVal) {
this.repeatAfter = newVal
},
},
mounted() {
this.repeatAfter = this.value
},
methods: {
updateData() {
this.$emit('input', this.repeatAfter)
this.$emit('change')
}
},
}
</script>
<style scoped>
p {
padding-top: 6px;
}
</style>

View File

@ -128,7 +128,7 @@
v-on:submit="deleteUser()">
<span slot="header">Remove a user from the team</span>
<p slot="text">Are you sure you want to remove this user from the team?<br/>
They will loose access to all lists and namespaces this team has access to.<br/>
He will loose access to all lists and namespaces this team has access to.<br/>
<b>This CANNOT BE UNDONE!</b></p>
</modal>
</div>
@ -137,6 +137,7 @@
<script>
import auth from '../../auth'
import router from '../../router'
import message from '../../message'
import TeamService from '../../services/team'
import TeamModel from '../../models/team'
@ -189,37 +190,37 @@
}
})
.catch(e => {
this.error(e, this)
message.error(e, this)
})
},
submit() {
this.teamService.update(this.team)
.then(response => {
this.team = response
this.success({message: 'The team was successfully updated.'}, this)
message.success({message: 'The team was successfully updated.'}, this)
})
.catch(e => {
this.error(e, this)
message.error(e, this)
})
},
deleteTeam() {
this.teamService.delete(this.team)
.then(() => {
this.success({message: 'The team was successfully deleted.'}, this)
message.success({message: 'The team was successfully deleted.'}, this)
router.push({name: 'listTeams'})
})
.catch(e => {
this.error(e, this)
message.error(e, this)
})
},
deleteUser() {
this.teamMemberService.delete(this.member)
.then(() => {
this.success({message: 'The user was successfully deleted from the team.'}, this)
message.success({message: 'The user was successfully deleted from the team.'}, this)
this.loadTeam()
})
.catch(e => {
this.error(e, this)
message.error(e, this)
})
.finally(() => {
this.showUserDeleteModal = false
@ -229,10 +230,10 @@
this.teamMemberService.create(this.member)
.then(() => {
this.loadTeam()
this.success({message: 'The team member was successfully added.'}, this)
message.success({message: 'The team member was successfully added.'}, this)
})
.catch(e => {
this.error(e, this)
message.error(e, this)
})
},
toggleUserType(member) {

View File

@ -20,6 +20,7 @@
<script>
import auth from '../../auth'
import router from '../../router'
import message from '../../message'
import TeamService from '../../services/team'
export default {
@ -47,7 +48,7 @@
this.$set(this, 'teams', response)
})
.catch(e => {
this.error(e, this)
message.error(e, this)
})
},
}

View File

@ -26,6 +26,7 @@
<script>
import auth from '../../auth'
import router from '../../router'
import message from '../../message'
import TeamModel from '../../models/team'
import TeamService from '../../services/team'
@ -45,7 +46,6 @@
},
created() {
this.teamService = new TeamService()
this.team = new TeamModel()
this.$parent.setFullPage();
},
methods: {
@ -53,10 +53,10 @@
this.teamService.create(this.team)
.then(response => {
router.push({name:'editTeam', params:{id: response.id}})
this.success({message: 'The team was successfully created.'}, this)
message.success({message: 'The team was successfully created.'}, this)
})
.catch(e => {
this.error(e, this)
message.error(e, this)
})
},
back() {

View File

@ -1,21 +1,19 @@
<template>
<div>
<h2 class="title has-text-centered">Login</h2>
<h2 class="title">Login</h2>
<div class="box">
<div v-if="confirmedEmailSuccess" class="notification is-success has-text-centered">
You successfully confirmed your email! You can log in now.
</div>
<form id="loginform" @submit.prevent="submit">
<div class="field">
<label class="label" for="username">Username</label>
<div class="control">
<input v-focus type="text" id="username" class="input" name="username" placeholder="e.g. frederick" v-model="credentials.username" required/>
<input v-focus type="text" class="input" name="username" placeholder="Username" v-model="credentials.username" required>
</div>
</div>
<div class="field">
<label class="label" for="password">Password</label>
<div class="control">
<input type="password" class="input" id="password" name="password" placeholder="e.g. ••••••••••••" v-model="credentials.password" required/>
<input type="password" class="input" name="password" placeholder="Password" v-model="credentials.password" required>
</div>
</div>
@ -26,8 +24,8 @@
<router-link :to="{ name: 'getPasswordReset' }" class="reset-password-link">Reset your password</router-link>
</div>
</div>
<div class="notification is-danger" v-if="errorMsg">
{{ errorMsg }}
<div class="notification is-danger" v-if="error">
{{ error }}
</div>
</form>
</div>
@ -47,7 +45,7 @@
username: '',
password: ''
},
errorMsg: '',
error: '',
confirmedEmailSuccess: false,
loading: false
}
@ -66,7 +64,7 @@
})
.catch(e => {
cancel()
this.errorMsg = e.response.data.message
this.error = e.response.data.message
})
}
@ -78,7 +76,7 @@
methods: {
submit() {
this.loading = true
this.errorMsg = ''
this.error = ''
let credentials = {
username: this.credentials.username,
password: this.credentials.password

View File

@ -1,18 +1,16 @@
<template>
<div>
<h2 class="title has-text-centered">Reset your password</h2>
<h2 class="title">Reset your password</h2>
<div class="box">
<form id="form" @submit.prevent="submit" v-if="!successMessage">
<div class="field">
<label class="label" for="password1">Password</label>
<div class="control">
<input v-focus type="password" class="input" id="password1" name="password1" placeholder="e.g. ••••••••••••" v-model="credentials.password" required/>
<input v-focus type="password" class="input" name="password1" placeholder="Password" v-model="credentials.password" required>
</div>
</div>
<div class="field">
<label class="label" for="password2">Retype your password</label>
<div class="control">
<input type="password" class="input" id="password2" name="password2" placeholder="e.g. ••••••••••••" v-model="credentials.password2" required/>
<input type="password" class="input" name="password2" placeholder="Retype password" v-model="credentials.password2" required>
</div>
</div>
@ -25,7 +23,7 @@
Loading...
</div>
<div class="notification is-danger" v-if="error">
{{ errorMsg }}
{{ error }}
</div>
</form>
<div v-if="successMessage" class="has-text-centered">
@ -50,7 +48,7 @@
password: '',
password2: '',
},
errorMsg: '',
error: '',
successMessage: ''
}
},
@ -59,10 +57,10 @@
},
methods: {
submit() {
this.errorMsg = ''
this.error = ''
if (this.credentials.password2 !== this.credentials.password) {
this.errorMsg = 'Passwords don\'t match'
this.error = 'Passwords don\'t match'
return
}
@ -73,7 +71,7 @@
localStorage.removeItem('passwordResetToken')
})
.catch(e => {
this.errorMsg = e.response.data.message
this.error = e.response.data.message
})
}
}

View File

@ -1,30 +1,26 @@
<template>
<div>
<h2 class="title has-text-centered">Register</h2>
<h2 class="title">Register</h2>
<div class="box">
<form id="registerform" @submit.prevent="submit">
<div class="field">
<label class="label" for="username">Username</label>
<div class="control">
<input v-focus type="text" id="username" class="input" name="username" placeholder="e.g. frederick" v-model="credentials.username" required/>
<input v-focus type="text" class="input" name="username" placeholder="Username" v-model="credentials.username" required>
</div>
</div>
<div class="field">
<label class="label" for="email">E-mail address</label>
<div class="control">
<input type="text" class="input" id="email" name="email" placeholder="e.g. frederic@vikunja.io" v-model="credentials.email" required/>
<input type="text" class="input" name="email" placeholder="E-mail address" v-model="credentials.email" required>
</div>
</div>
<div class="field">
<label class="label" for="password1">Password</label>
<div class="control">
<input type="password" class="input" id="password1" name="password1" placeholder="e.g. ••••••••••••" v-model="credentials.password" required/>
<input type="password" class="input" name="password1" placeholder="Password" v-model="credentials.password" required>
</div>
</div>
<div class="field">
<label class="label" for="password2">Retype your password</label>
<div class="control">
<input type="password" class="input" id="password2" name="password2" placeholder="e.g. ••••••••••••" v-model="credentials.password2" required/>
<input type="password" class="input" name="password2" placeholder="Retype password" v-model="credentials.password2" required>
</div>
</div>
@ -38,7 +34,7 @@
Loading...
</div>
<div class="notification is-danger" v-if="error">
{{ errorMsg }}
{{ error }}
</div>
</form>
</div>
@ -58,7 +54,7 @@
password: '',
password2: '',
},
errorMsg: '',
error: '',
loading: false
}
},
@ -72,11 +68,11 @@
submit() {
this.loading = true
this.errorMsg = ''
this.error = ''
if (this.credentials.password2 !== this.credentials.password) {
this.loading = false
this.errorMsg = 'Passwords don\'t match'
this.error = 'Passwords don\'t match'
return
}

View File

@ -1,12 +1,11 @@
<template>
<div>
<h2 class="title has-text-centered">Reset your password</h2>
<h2 class="title">Reset your password</h2>
<div class="box">
<form @submit.prevent="submit" v-if="!isSuccess">
<form id="loginform" @submit.prevent="submit" v-if="!isSuccess">
<div class="field">
<label class="label" for="email">E-mail address</label>
<div class="control">
<input v-focus type="text" class="input" id="email" name="email" placeholder="e.g. frederic@vikunja.io" v-model="passwordReset.email" required/>
<input v-focus type="text" class="input" name="email" placeholder="Email-Adress" v-model="passwordReset.email" required>
</div>
</div>
@ -17,7 +16,7 @@
</div>
</div>
<div class="notification is-danger" v-if="error">
{{ errorMsg }}
{{ error }}
</div>
</form>
<div v-if="isSuccess" class="has-text-centered">
@ -39,7 +38,7 @@
return {
passwordResetService: PasswordResetService,
passwordReset: PasswordResetModel,
errorMsg: '',
error: '',
isSuccess: false
}
},
@ -49,13 +48,13 @@
},
methods: {
submit() {
this.errorMsg = ''
this.error = ''
this.passwordResetService.requestResetPassword(this.passwordReset)
.then(() => {
this.isSuccess = true
})
.catch(e => {
this.errorMsg = e.response.data.message
this.error = e.response.data.message
})
},
}

View File

@ -12,7 +12,7 @@ import TaskOverview from './components/tasks/ShowTasks'
Vue.component('TaskOverview', TaskOverview)
// Add CSS
import './styles/vikunja.scss'
import './vikunja.scss'
Vue.config.productionTip = false
@ -20,12 +20,6 @@ Vue.config.productionTip = false
import Notifications from 'vue-notification'
Vue.use(Notifications)
import config from './config'
config.initConfig()
.then(() => {
Vue.prototype.$config = config.getConfig()
})
// Icons
import { library } from '@fortawesome/fontawesome-svg-core'
import { faSignOutAlt } from '@fortawesome/free-solid-svg-icons'
@ -51,19 +45,8 @@ import { faTags } from '@fortawesome/free-solid-svg-icons'
import { faChevronDown } from '@fortawesome/free-solid-svg-icons'
import { faCheck } from '@fortawesome/free-solid-svg-icons'
import { faPaste } from '@fortawesome/free-solid-svg-icons'
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons'
import { faTimesCircle } from '@fortawesome/free-regular-svg-icons'
import { faCalendarAlt } from '@fortawesome/free-regular-svg-icons'
import { faCloudDownloadAlt } from '@fortawesome/free-solid-svg-icons'
import { faCloudUploadAlt } from '@fortawesome/free-solid-svg-icons'
import { faPercent } from '@fortawesome/free-solid-svg-icons'
import { faStar } from '@fortawesome/free-regular-svg-icons'
import { faAlignLeft } from '@fortawesome/free-solid-svg-icons'
import { faPaperclip } from '@fortawesome/free-solid-svg-icons'
import { faClock } from '@fortawesome/free-regular-svg-icons'
import { faHistory } from '@fortawesome/free-solid-svg-icons'
import { faSearch } from '@fortawesome/free-solid-svg-icons'
import { faCheckDouble } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
library.add(faSignOutAlt)
@ -91,17 +74,6 @@ library.add(faTags)
library.add(faChevronDown)
library.add(faCheck)
library.add(faPaste)
library.add(faPencilAlt)
library.add(faCloudDownloadAlt)
library.add(faCloudUploadAlt)
library.add(faPercent)
library.add(faStar)
library.add(faAlignLeft)
library.add(faPaperclip)
library.add(faClock)
library.add(faHistory)
library.add(faSearch)
library.add(faCheckDouble)
Vue.component('icon', FontAwesomeIcon)
@ -109,39 +81,19 @@ Vue.component('icon', FontAwesomeIcon)
import VTooltip from 'v-tooltip'
Vue.use(VTooltip)
// PWA
import './registerServiceWorker'
// Set focus
Vue.directive('focus', {
// When the bound element is inserted into the DOM...
inserted: el => {
// Focus the element only if the viewport is big enough
// auto focusing elements on mobile can be annoying since in these cases the
// keyboard always pops up and takes half of the available space on the screen.
// The threshhold is the same as the breakpoints in css.
if (window.innerWidth > 769) {
el.focus()
}
inserted: function (el) {
// Focus the element
el.focus()
}
})
// Check the user's auth status when the app starts
auth.checkAuth()
// Mixins
import moment from 'moment'
import message from './message'
Vue.mixin({
methods: {
formatDateSince: date => moment(date).fromNow(),
formatDate: date => moment(date).format('LLL'),
error: (e, context) => message.error(e, context),
success: (s, context) => message.success(s, context),
}
})
new Vue({
router,
render: h => h(App)
router,
render: h => h(App)
}).$mount('#app')

View File

@ -40,4 +40,5 @@ export default {
context.loading = false
},
}

View File

@ -1,22 +0,0 @@
import AbstractModel from './abstractModel'
import UserModel from './user'
import FileModel from './file'
export default class AttachmentModel extends AbstractModel {
constructor(data) {
super(data)
this.created_by = new UserModel(this.created_by)
this.file = new FileModel(this.file)
this.created = new Date(this.created)
}
defaults() {
return {
id: 0,
task_id: 0,
file: FileModel,
created_by: UserModel,
created: null,
}
}
}

View File

@ -1,37 +0,0 @@
import AbstractModel from './abstractModel'
export default class FileModel extends AbstractModel {
constructor(data) {
super(data)
this.created = new Date(this.created)
}
defaults() {
return {
id: 0,
mime: '',
name: '',
size: '',
created: null,
}
}
getHumanSize() {
const sizes = {
0: 'B',
1: 'KB',
2: 'MB',
3: 'GB',
4: 'TB',
}
let it = 0
let size = this.size
while (size > 1024) {
size /= 1024
it++
}
return Number(Math.round(size+'e2')+'e-2') + ' ' + sizes[it]
}
}

View File

@ -13,9 +13,6 @@ export default class LabelModel extends AbstractModel {
}
this.textColor = this.hasDarkColor() ? '#4a4a4a' : '#e5e5e5'
this.created_by = new UserModel(this.created_by)
this.created = new Date(this.created)
this.updated = new Date(this.updated)
}
defaults() {
@ -28,8 +25,8 @@ export default class LabelModel extends AbstractModel {
listID: 0,
textColor: '',
created: null,
updated: null,
created: 0,
updated: 0
}
}

View File

@ -8,9 +8,6 @@ export default class ListModel extends AbstractModel {
super(data)
this.shared_by = new UserModel(this.shared_by)
this.created = new Date(this.created)
this.updated = new Date(this.updated)
}
// Default attributes that define the "empty" state.
@ -23,8 +20,8 @@ export default class ListModel extends AbstractModel {
sharing_type: 0,
listID: 0,
created: null,
updated: null,
created: 0,
updated: 0,
}
}
}

View File

@ -13,11 +13,9 @@ export default class ListModel extends AbstractModel {
})
this.owner = new UserModel(this.owner)
this.created = new Date(this.created)
this.updated = new Date(this.updated)
this.sortTasks()
}
// Default attributes that define the "empty" state.
defaults() {
return {
@ -27,9 +25,87 @@ export default class ListModel extends AbstractModel {
owner: UserModel,
tasks: [],
namespaceID: 0,
created: null,
updated: null,
created: 0,
updated: 0,
}
}
////////
// Helpers
//////
/**
* Sorts all tasks according to their due date
* @returns {this}
*/
sortTasks() {
if (this.tasks === null || this.tasks === []) {
return
}
return this.tasks.sort(function(a,b) {
if (a.done < b.done)
return -1
if (a.done > b.done)
return 1
return 0
})
}
/**
* Adds a task to the task array of this list. Usually only used when creating a new task
* @param task
*/
addTaskToList(task) {
// If it's a subtask, add it to its parent, otherwise append it to the list of tasks
if (task.parentTaskID === 0) {
this.tasks.push(task)
} else {
for (const t in this.tasks) {
if (this.tasks[t].id === task.parentTaskID) {
this.tasks[t].subtasks.push(task)
break
}
}
}
this.sortTasks()
}
/**
* Gets a task by its ID by looping through all tasks.
* @param id
* @returns {TaskModel}
*/
getTaskByID(id) {
// TODO: Binary search?
for (const t in this.tasks) {
if (this.tasks[t].id === parseInt(id)) {
return this.tasks[t]
}
}
return {} // FIXME: This should probably throw something to make it clear to the user noting was found
}
/**
* Loops through all tasks and updates the one with the id it has
* @param task
*/
updateTaskByID(task) {
for (const t in this.tasks) {
if (this.tasks[t].id === task.id) {
this.tasks[t] = task
break
}
if (this.tasks[t].id === task.parentTaskID) {
for (const s in this.tasks[t].subtasks) {
if (this.tasks[t].subtasks[s].id === task.id) {
this.tasks[t].subtasks[s] = task
break
}
}
}
}
this.sortTasks()
}
}

View File

@ -10,9 +10,6 @@ export default class NamespaceModel extends AbstractModel {
return new ListModel(l)
})
this.owner = new UserModel(this.owner)
this.created = new Date(this.created)
this.updated = new Date(this.updated)
}
// Default attributes that define the 'empty' state.
@ -24,8 +21,8 @@ export default class NamespaceModel extends AbstractModel {
owner: UserModel,
lists: [],
created: null,
updated: null,
created: 0,
updated: 0,
}
}
}

View File

@ -1,13 +0,0 @@
{
"subtask": "Subtask",
"parenttask": "Parent Task",
"related": "Related Task",
"duplicateof": "Duplicate Of",
"duplicates": "Duplicates",
"blocking": "Blocking",
"blocked": "Blocked By",
"precedes": "Preceds",
"follows": "Follows",
"copiedfrom": "Copied From",
"copiedto": "Copied To"
}

View File

@ -1,32 +1,21 @@
import AbstractModel from './abstractModel';
import UserModel from './user'
import LabelModel from './label'
import AttachmentModel from './attachment'
import LabelModel from "./label";
export default class TaskModel extends AbstractModel {
constructor(data) {
super(data)
this.id = Number(this.id)
this.listID = Number(this.listID)
// Make date objects from timestamps
this.dueDate = new Date(this.dueDate)
this.startDate = new Date(this.startDate)
this.endDate = new Date(this.endDate)
this.dueDate = this.parseDateIfNessecary(this.dueDate)
this.startDate = this.parseDateIfNessecary(this.startDate)
this.endDate = this.parseDateIfNessecary(this.endDate)
// Cancel all scheduled notifications for this task to be sure to only have available notifications
this.cancelScheduledNotifications()
.then(() => {
this.reminderDates = this.reminderDates.map(d => {
d = new Date(d)
// Every time we see a reminder, we schedule a notification for it
this.scheduleNotification(d)
return d
})
this.reminderDates.push(null) // To trigger the datepicker
})
this.reminderDates = this.reminderDates.map(d => {
return this.parseDateIfNessecary(d)
})
this.reminderDates.push(null) // To trigger the datepicker
// Parse the repeat after into something usable
this.parseRepeatAfter()
@ -48,21 +37,6 @@ export default class TaskModel extends AbstractModel {
if (this.hexColor.substring(0, 1) !== '#') {
this.hexColor = '#' + this.hexColor
}
// Make all subtasks to task models
Object.keys(this.related_tasks).forEach(relationKind => {
this.related_tasks[relationKind] = this.related_tasks[relationKind].map(t => {
return new TaskModel(t)
})
})
// Make all attachments to attachment models
this.attachments = this.attachments.map(a => {
return new AttachmentModel(a)
})
this.created = new Date(this.created)
this.updated = new Date(this.updated)
}
defaults() {
@ -80,17 +54,16 @@ export default class TaskModel extends AbstractModel {
endDate: 0,
repeatAfter: 0,
reminderDates: [],
subtasks: [],
parentTaskID: 0,
hexColor: '',
percentDone: 0,
related_tasks: {},
attachments: [],
createdBy: UserModel,
created: null,
updated: null,
created: 0,
updated: 0,
listID: 0, // Meta, only used when creating a new task
sortBy: 'duedate', // Meta, only used when listing all tasks
}
}
@ -98,6 +71,19 @@ export default class TaskModel extends AbstractModel {
// Helper functions
///////////////
/**
* Makes a js date object from a unix timestamp (in seconds).
* @param unixTimestamp
* @returns {*}
*/
parseDateIfNessecary(unixTimestamp) {
let dateobj = new Date(unixTimestamp * 1000)
if (unixTimestamp === 0) {
return null
}
return dateobj
}
/**
* Parses the "repeat after x seconds" from the task into a usable js object inside the task.
* This function should only be called from the constructor.
@ -143,69 +129,4 @@ export default class TaskModel extends AbstractModel {
let luma = 0.2126 * r + 0.7152 * g + 0.0722 * b; // per ITU-R BT.709
return luma > 128
}
async cancelScheduledNotifications() {
if (!('showTrigger' in Notification.prototype)) {
console.debug('This browser does not support triggered notifications')
return
}
const registration = await navigator.serviceWorker.getRegistration()
if (typeof registration === 'undefined') {
return
}
// Get all scheduled notifications for this task and cancel them
const scheduledNotifications = await registration.getNotifications({
tag: `vikunja-task-${this.id}`,
includeTriggered: true,
})
console.debug('Already scheduled notifications:', scheduledNotifications)
scheduledNotifications.forEach(n => n.close())
}
async scheduleNotification(date) {
if (!('showTrigger' in Notification.prototype)) {
console.debug('This browser does not support triggered notifications')
return
}
const {state} = await navigator.permissions.request({name: 'notifications'});
if (state !== 'granted') {
console.debug('Notification permission not granted, not showing notifications')
return
}
const registration = await navigator.serviceWorker.getRegistration()
if (typeof registration === 'undefined') {
return
}
// Register the actual notification
registration.showNotification('Vikunja Reminder', {
tag: `vikunja-task-${this.id}`, // Group notifications by task id so we're only showing one notification per task
body: this.text,
// eslint-disable-next-line no-undef
showTrigger: new TimestampTrigger(date),
badge: '/images/icons/badge-monochrome.png',
icon: '/images/icons/android-chrome-512x512.png',
data: {taskID: this.id},
actions: [
{
action: 'mark-as-done',
title: 'Done'
},
{
action: 'show-task',
title: 'Show task'
},
],
})
.then(() => {
console.debug('Notification scheduled for ' + date)
})
.catch(e => {
console.debug('Error scheduling notification', e)
})
}
}

View File

@ -1,16 +0,0 @@
import AbstractModel from './abstractModel'
export default class TaskAssigneeModel extends AbstractModel {
constructor(data) {
super(data)
this.created = new Date(this.created)
}
defaults() {
return {
created: null,
user_id: 0,
task_id: 0,
}
}
}

View File

@ -1,22 +0,0 @@
import AbstractModel from './abstractModel'
import UserModel from './user'
export default class TaskRelationModel extends AbstractModel {
constructor(data) {
super(data)
this.created_by = new UserModel(this.created_by)
this.created = new Date(this.created)
}
defaults() {
return {
id: 0,
other_task_id: 0,
task_id: 0,
relation_kind: '',
created_by: UserModel,
created: null,
}
}
}

View File

@ -11,9 +11,6 @@ export default class TeamModel extends AbstractModel {
return new TeamMemberModel(m)
})
this.createdBy = new UserModel(this.createdBy)
this.created = new Date(this.created)
this.updated = new Date(this.updated)
}
defaults() {
@ -25,8 +22,8 @@ export default class TeamModel extends AbstractModel {
right: 0,
createdBy: {},
created: null,
updated: null,
created: 0,
updated: 0
}
}
}

View File

@ -1,5 +1,5 @@
import TeamShareBaseModel from './teamShareBase'
import {merge} from 'lodash'
import {merge} from "lodash";
export default class TeamListModel extends TeamShareBaseModel {
defaults() {

View File

@ -5,19 +5,13 @@ import AbstractModel from './abstractModel'
* It is extended in a way so it can be used for namespaces as well for lists.
*/
export default class TeamShareBaseModel extends AbstractModel {
constructor(data) {
super(data)
this.created = new Date(this.created)
this.updated = new Date(this.updated)
}
defaults() {
return {
teamID: 0,
right: 0,
created: null,
updated: null
created: 0,
updated: 0
}
}
}

View File

@ -1,24 +1,14 @@
import AbstractModel from './abstractModel'
export default class UserModel extends AbstractModel {
constructor(data) {
super(data)
this.created = new Date(this.created)
this.updated = new Date(this.updated)
}
defaults() {
return {
id: 0,
avatarUrl: '',
email: '',
username: '',
created: null,
updated: null,
created: 0,
updated: 0
}
}
getAvatarUrl(size = 50) {
return `https://www.gravatar.com/avatar/${this.avatarUrl}?s=${size}&d=mp`
}
}

View File

@ -1,19 +1,13 @@
import AbstractModel from './abstractModel'
export default class UserShareBaseModel extends AbstractModel {
constructor(data) {
super(data)
this.created = new Date(this.created)
this.updated = new Date(this.updated)
}
defaults() {
return {
userID: 0,
right: 0,
created: null,
updated: null,
created: 0,
updated: 0,
}
}
}

View File

@ -1,58 +0,0 @@
/* eslint-disable no-console */
import { register } from 'register-service-worker'
import swEvents from './ServiceWorker/events'
import auth from './auth'
if (process.env.NODE_ENV === 'production') {
register(`${process.env.BASE_URL}sw.js`, {
ready () {
console.log('App is being served from cache by a service worker.')
},
registered () {
console.log('Service worker has been registered.')
},
cached () {
console.log('Content has been cached for offline use.')
},
updatefound () {
console.log('New content is downloading.')
},
updated (registration) {
console.log('New content is available; please refresh.')
// Send an event with the updated info
document.dispatchEvent(
new CustomEvent(swEvents.SW_UPDATED, { detail:registration })
)
},
offline () {
console.log('No internet connection found. App is running in offline mode.')
},
error (error) {
console.error('Error during service worker registration:', error)
}
})
}
if(navigator && navigator.serviceWorker) {
navigator.serviceWorker.addEventListener('message', event => {
// for every message we expect an action field
// determining operation that we should perform
const { action } = event.data;
// we use 2nd port provided by the message channel
const port = event.ports[0];
if(action === 'getBearerToken') {
console.debug('Token request from sw');
port.postMessage({
authToken: auth.getToken(),
})
} else {
console.error('Unknown event', event);
port.postMessage({
error: 'Unknown request',
})
}
});
}

View File

@ -13,7 +13,6 @@ import NewListComponent from '@/components/lists/NewList'
import EditListComponent from '@/components/lists/EditList'
import ShowTasksInRangeComponent from '@/components/tasks/ShowTasksInRange'
import LinkShareAuthComponent from '@/components/sharing/linkSharingAuth'
import TaskDetailViewComponent from '@/components/tasks/TaskDetailView'
// Namespace Handling
import NewNamespaceComponent from '@/components/namespaces/NewNamespace'
import EditNamespaceComponent from '@/components/namespaces/EditNamespace'
@ -23,30 +22,11 @@ import EditTeamComponent from '@/components/teams/EditTeam'
import NewTeamComponent from '@/components/teams/NewTeam'
// Label Handling
import ListLabelsComponent from '@/components/labels/ListLabels'
// Migration
import MigrationComponent from '../components/migrator/migrate'
import WunderlistMigrationComponent from '../components/migrator/wunderlist'
Vue.use(Router)
export default new Router({
mode: 'history',
scrollBehavior (to, from, savedPosition) {
// If the user is using their forward/backward keys to navigate, we want to restore the scroll view
if(savedPosition) {
return savedPosition
}
// Scroll to anchor should still work
if(to.hash) {
return {
selector: to.hash
}
}
// Otherwise just scroll to the top
return { x: 0, y: 0 }
},
routes: [
{
path: '/',
@ -119,15 +99,10 @@ export default new Router({
component: EditTeamComponent
},
{
path: '/tasks/by/:type',
path: '/tasks/:type',
name: 'showTasksInRange',
component: ShowTasksInRangeComponent
},
{
path: '/tasks/:id',
name: 'taskDetailView',
component: TaskDetailViewComponent,
},
{
path: '/labels',
name: 'listLabels',
@ -138,15 +113,5 @@ export default new Router({
name: 'linkShareAuth',
component: LinkShareAuthComponent
},
{
path: '/migrate',
name: 'migrateStart',
component: MigrationComponent,
},
{
path: '/migrate/wunderlist',
name: 'migrateWunderlist',
component: WunderlistMigrationComponent,
},
]
})

View File

@ -18,9 +18,6 @@ export default class AbstractService {
update: '',
delete: '',
}
// This contains the total number of pages and the number of results for the current page
totalPages = 0
resultCount = 0
/////////////
// Service init
@ -43,16 +40,13 @@ export default class AbstractService {
this.http.interceptors.request.use( (config) => {
switch (config.method) {
case 'post':
if(this.useUpdateInterceptor())
config.data = JSON.stringify(self.beforeUpdate(config.data))
config.data = JSON.stringify(self.beforeUpdate(config.data))
break
case 'put':
if(this.useCreateInterceptor())
config.data = JSON.stringify(self.beforeCreate(config.data))
config.data = JSON.stringify(self.beforeCreate(config.data))
break
case 'delete':
if(this.useDeleteInterceptor())
config.data = JSON.stringify(self.beforeDelete(config.data))
config.data = JSON.stringify(self.beforeDelete(config.data))
break
}
return config
@ -75,31 +69,7 @@ export default class AbstractService {
delete: paths.delete !== undefined ? paths.delete : '',
}
}
/**
* Whether or not to use the create interceptor which processes a request payload into json
* @returns {boolean}
*/
useCreateInterceptor() {
return true
}
/**
* Whether or not to use the update interceptor which processes a request payload into json
* @returns {boolean}
*/
useUpdateInterceptor() {
return true
}
/**
* Whether or not to use the delete interceptor which processes a request payload into json
* @returns {boolean}
*/
useDeleteInterceptor() {
return true
}
/////////////////////
// Global error handler
///////////////////
@ -282,21 +252,9 @@ export default class AbstractService {
return Promise.reject({message: 'This model is not able to get data.'})
}
return this.getM(this.paths.get, model, params)
}
/**
* This is a more abstract implementation which only does a get request.
* Services which need more flexibility can use this.
* @param url
* @param model
* @param params
* @returns {Q.Promise<unknown>}
*/
getM(url, model = {}, params = {}) {
const cancel = this.setLoading()
model = this.beforeGet(model)
return this.http.get(this.getReplacedRoute(url, model), {params: params})
return this.http.get(this.getReplacedRoute(this.paths.get, model), {params: params})
.catch(error => {
return this.errorHandler(error)
})
@ -313,16 +271,13 @@ export default class AbstractService {
* The difference between this and get() is this one is used to get a bunch of data (an array), not just a single object.
* @param model The model to use. The request path is built using the values from the model.
* @param params Optional query parameters
* @param page The page to get
* @returns {Q.Promise<any>}
*/
getAll(model = {}, params = {}, page = 1) {
getAll(model = {}, params = {}) {
if (this.paths.getAll === '') {
return Promise.reject({message: 'This model is not able to get data.'})
}
params.page = page
const cancel = this.setLoading()
model = this.beforeGet(model)
return this.http.get(this.getReplacedRoute(this.paths.getAll, model), {params: params})
@ -330,9 +285,6 @@ export default class AbstractService {
return this.errorHandler(error)
})
.then(response => {
this.resultCount = Number(response.headers['x-pagination-result-count'])
this.totalPages = Number(response.headers['x-pagination-total-pages'])
if (Array.isArray(response.data)) {
return Promise.resolve(response.data.map(entry => {
return this.modelGetAllFactory(entry)

View File

@ -1,91 +0,0 @@
import AbstractService from './abstractService'
import AttachmentModel from '../models/attachment'
import moment from 'moment'
export default class AttachmentService extends AbstractService {
constructor() {
super({
create: '/tasks/{task_id}/attachments',
getAll: '/tasks/{task_id}/attachments',
delete: '/tasks/{task_id}/attachments/{id}',
})
}
processModel(model) {
model.created = moment(model.created).toISOString()
return model
}
uploadProgress = 0
useCreateInterceptor() {
return false
}
modelFactory(data) {
return new AttachmentModel(data)
}
modelCreateFactory(data) {
// Success contains the uploaded attachments
data.success = (data.success === null ? [] : data.success).map(a => {
return this.modelFactory(a)
})
return data
}
download(model) {
this.http({
url: '/tasks/' + model.task_id + '/attachments/' + model.id,
method: 'GET',
responseType: 'blob',
}).then((response) => {
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', model.file.name);
link.click();
window.URL.revokeObjectURL(url);
});
}
/**
* Uploads a file to the server
* @param model
* @param files
* @returns {Promise<any|never>}
*/
create(model, files) {
let data = new FormData()
for (let i = 0; i < files.length; i++) {
// TODO: Validation of file size
data.append('files', new Blob([files[i]]), files[i].name);
}
const cancel = this.setLoading()
return this.http.put(
this.getReplacedRoute(this.paths.create, model),
data,
{
headers: {
'Content-Type':
'multipart/form-data; boundary=' + data._boundary,
},
onUploadProgress: progressEvent => {
this.uploadProgress = Math.round( (progressEvent.loaded * 100) / progressEvent.total );
}
}
)
.catch(error => {
return this.errorHandler(error)
})
.then(response => {
return Promise.resolve(this.modelCreateFactory(response.data))
})
.finally(() => {
this.uploadProgress = 0
cancel()
})
}
}

View File

@ -1,6 +1,5 @@
import AbstractService from './abstractService'
import AbstractService from "./abstractService";
import LabelModel from '../models/label'
import moment from 'moment'
export default class LabelService extends AbstractService {
constructor() {
@ -12,13 +11,7 @@ export default class LabelService extends AbstractService {
delete: '/labels/{id}',
})
}
processModel(model) {
model.created = moment(model.created).toISOString()
model.updated = moment(model.updated).toISOString()
return model
}
modelFactory(data) {
return new LabelModel(data)
}

View File

@ -1,6 +1,5 @@
import AbstractService from './abstractService'
import LinkShareModel from '../models/linkShare'
import moment from 'moment'
export default class ListService extends AbstractService {
constructor() {
@ -12,12 +11,6 @@ export default class ListService extends AbstractService {
})
}
processModel(model) {
model.created = moment(model.created).toISOString()
model.updated = moment(model.updated).toISOString()
return model
}
modelFactory(data) {
return new LinkShareModel(data)
}

View File

@ -1,7 +1,6 @@
import AbstractService from './abstractService'
import ListModel from '../models/list'
import TaskService from './task'
import moment from 'moment'
export default class ListService extends AbstractService {
constructor() {
@ -12,13 +11,7 @@ export default class ListService extends AbstractService {
delete: '/lists/{id}',
})
}
processModel(model) {
model.created = moment(model.created).toISOString()
model.updated = moment(model.updated).toISOString()
return model
}
modelFactory(data) {
return new ListModel(data)
}

View File

@ -1,6 +1,5 @@
import AbstractService from './abstractService'
import UserModel from '../models/user'
import moment from 'moment'
export default class ListUserService extends AbstractService {
constructor() {
@ -8,13 +7,7 @@ export default class ListUserService extends AbstractService {
getAll: '/lists/{listID}/listusers'
})
}
processModel(model) {
model.created = moment(model.created).toISOString()
model.updated = moment(model.updated).toISOString()
return model
}
modelFactory(data) {
return new UserModel(data)
}

View File

@ -1,26 +0,0 @@
import AbstractService from '../abstractService'
// This service builds on top of the abstract service and basically just hides away method names.
// It enables migration services to be created with minimal overhead and even better method names.
export default class AbstractMigrationService extends AbstractService {
serviceUrlKey = ''
constructor(serviceUrlKey) {
super({
update: '/migration/'+serviceUrlKey+'/migrate',
})
this.serviceUrlKey = serviceUrlKey
}
getAuthUrl() {
return this.getM('/migration/'+this.serviceUrlKey+'/auth')
}
getStatus() {
return this.getM('/migration/'+this.serviceUrlKey+'/status')
}
migrate(data) {
return this.update(data)
}
}

View File

@ -1,7 +0,0 @@
import AbstractMigrationService from './abstractMigrationService'
export default class WunderlistMigrationService extends AbstractMigrationService {
constructor() {
super('wunderlist')
}
}

View File

@ -1,6 +1,5 @@
import AbstractService from './abstractService'
import NamespaceModel from '../models/namespace'
import moment from 'moment'
export default class NamespaceService extends AbstractService {
constructor() {
@ -13,12 +12,6 @@ export default class NamespaceService extends AbstractService {
});
}
processModel(model) {
model.created = moment(model.created).toISOString()
model.updated = moment(model.updated).toISOString()
return model
}
modelFactory(data) {
return new NamespaceModel(data)
}

View File

@ -1,14 +1,11 @@
import AbstractService from './abstractService'
import TaskModel from '../models/task'
import AttachmentService from './attachment'
import moment from 'moment'
export default class TaskService extends AbstractService {
constructor() {
super({
create: '/lists/{listID}',
getAll: '/tasks/all',
get: '/tasks/{id}',
update: '/tasks/{id}',
delete: '/tasks/{id}',
});
@ -30,12 +27,10 @@ export default class TaskService extends AbstractService {
// Ensure the listID is an int
model.listID = Number(model.listID)
// Convert dates into an iso string
model.dueDate = moment(model.dueDate).toISOString()
model.startDate = moment(model.startDate).toISOString()
model.endDate = moment(model.endDate).toISOString()
model.created = moment(model.created).toISOString()
model.updated = moment(model.updated).toISOString()
// Convert the date in a unix timestamp
model.dueDate = model.dueDate !== null ? Math.round(+new Date(model.dueDate) / 1000) : model.dueDate
model.startDate = model.startDate !== null ? Math.round(+new Date(model.startDate) / 1000): model.startDate
model.endDate = model.endDate !== null ? Math.round(+new Date(model.endDate) / 1000) : model.endDate
// remove all nulls, these would create empty reminders
for (const index in model.reminderDates) {
@ -47,7 +42,7 @@ export default class TaskService extends AbstractService {
// Make normal timestamps from js dates
if(model.reminderDates.length > 0) {
model.reminderDates = model.reminderDates.map(r => {
return moment(r).toISOString()
return Math.round(+new Date(r) / 1000)
})
}
@ -78,21 +73,6 @@ export default class TaskService extends AbstractService {
model.hexColor = model.hexColor.substring(1, 7)
}
// Do the same for all related tasks
Object.keys(model.related_tasks).forEach(relationKind => {
model.related_tasks[relationKind] = model.related_tasks[relationKind].map(t => {
return this.processModel(t)
})
})
// Process all attachments to preven parsing errors
if(model.attachments.length > 0) {
const attachmentService = new AttachmentService()
model.attachments.map(a => {
return attachmentService.processModel(a)
})
}
return model
}
}

View File

@ -1,21 +0,0 @@
import AbstractService from './abstractService'
import TaskAssigneeModel from '../models/taskAssignee'
import moment from 'moment'
export default class TaskAssigneeService extends AbstractService {
constructor() {
super({
create: '/tasks/{task_id}/assignees',
delete: '/tasks/{task_id}/assignees/{user_id}',
})
}
processModel(model) {
model.created = moment(model.created).toISOString()
return model
}
modelFactory(data) {
return new TaskAssigneeModel(data)
}
}

View File

@ -1,21 +0,0 @@
import AbstractService from './abstractService'
import TaskModel from '../models/task'
import moment from 'moment'
export default class TaskCollectionService extends AbstractService {
constructor() {
super({
getAll: '/lists/{listID}/tasks',
})
}
processModel(model) {
model.created = moment(model.created).toISOString()
model.updated = moment(model.updated).toISOString()
return model
}
modelFactory(data) {
return new TaskModel(data)
}
}

View File

@ -1,21 +0,0 @@
import AbstractService from './abstractService'
import TaskRelationModel from '../models/taskRelation'
import moment from 'moment'
export default class TaskRelationService extends AbstractService {
constructor() {
super({
create: '/tasks/{task_id}/relations',
delete: '/tasks/{task_id}/relations',
})
}
processModel(model) {
model.created = moment(model.created).toISOString()
return model
}
modelFactory(data) {
return new TaskRelationModel(data)
}
}

View File

@ -1,6 +1,5 @@
import AbstractService from './abstractService'
import TeamModel from '../models/team'
import moment from 'moment'
export default class TeamService extends AbstractService {
constructor() {
@ -13,12 +12,6 @@ export default class TeamService extends AbstractService {
});
}
processModel(model) {
model.created = moment(model.created).toISOString()
model.updated = moment(model.updated).toISOString()
return model
}
modelFactory(data) {
return new TeamModel(data)
}

View File

@ -1,7 +1,6 @@
import AbstractService from './abstractService'
import TeamListModel from '../models/teamList'
import TeamModel from '../models/team'
import moment from 'moment'
export default class TeamListService extends AbstractService {
constructor() {
@ -13,12 +12,6 @@ export default class TeamListService extends AbstractService {
})
}
processModel(model) {
model.created = moment(model.created).toISOString()
model.updated = moment(model.updated).toISOString()
return model
}
modelFactory(data) {
return new TeamListModel(data)
}

View File

@ -1,6 +1,5 @@
import AbstractService from './abstractService'
import TeamMemberModel from '../models/teamMember'
import moment from 'moment'
export default class TeamMemberService extends AbstractService {
constructor() {
@ -9,13 +8,7 @@ export default class TeamMemberService extends AbstractService {
delete: '/teams/{teamID}/members/{id}', // "id" is the user id because we're intheriting from a normal user
});
}
processModel(model) {
model.created = moment(model.created).toISOString()
model.updated = moment(model.updated).toISOString()
return model
}
modelFactory(data) {
return new TeamMemberModel(data)
}

View File

@ -1,7 +1,6 @@
import AbstractService from './abstractService'
import TeamNamespaceModel from '../models/teamNamespace'
import TeamModel from '../models/team'
import moment from 'moment'
export default class TeamNamespaceService extends AbstractService {
constructor() {
@ -13,12 +12,6 @@ export default class TeamNamespaceService extends AbstractService {
})
}
processModel(model) {
model.created = moment(model.created).toISOString()
model.updated = moment(model.updated).toISOString()
return model
}
modelFactory(data) {
return new TeamNamespaceModel(data)
}

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