Compare commits

...

261 Commits

Author SHA1 Message Date
cc51ea8c61
Fix typo when no upcoming tasks are available 2020-05-29 23:12:38 +02:00
c3ba068dd7
Hide totp settings if it is disabled server side 2020-05-29 18:49:50 +02:00
bc603605a7
Fix error messages when trying to update tasks in kanban if kanban hasn't been opened yet 2020-05-29 16:34:29 +02:00
32984b88a3
Fix saving list view if not present in browser 2020-05-29 16:33:57 +02:00
fe8f0ecd67 Update dependency vue-router to v3.3.1 (#141)
Update dependency vue-router to v3.3.1

Reviewed-on: vikunja/frontend#141
2020-05-29 09:37:25 +00:00
58eec4939e Update vue monorepo to v4.4.1 (#140)
Update vue monorepo to v4.4.1

Reviewed-on: vikunja/frontend#140
2020-05-27 08:47:30 +00:00
405dd1c1a6
Fix getting migration status 2020-05-24 15:49:58 +02:00
991de38980
Add todoist migrator to the frontend 2020-05-24 15:31:27 +02:00
0c77c591e4 Update dependency eslint to v7.1.0 (#139)
Update dependency eslint to v7.1.0

Reviewed-on: vikunja/frontend#139
2020-05-23 09:54:47 +00:00
fa37d5bf59
Add changing the uid and gid in docker through env variables 2020-05-22 18:11:20 +02:00
0953400321
Enable resetting search input 2020-05-22 17:32:18 +02:00
8592652e5b
Save list view per list and not globally 2020-05-22 17:28:26 +02:00
68e6b23610
Remove old tasks when loading list view 2020-05-21 11:36:42 +02:00
a4bc95902a
Fix trying to load kanban buckets if the kanban board is not in focus 2020-05-21 11:35:09 +02:00
0d94386e99
Fix gantt chart not updating when navigating between lists 2020-05-21 11:19:44 +02:00
12727900de
Remember list view when navigating between lists 2020-05-21 11:13:19 +02:00
978e7b4acb Update dependency vue-router to v3.2.0 (#137)
Update dependency vue-router to v3.2.0

Reviewed-on: vikunja/frontend#137
2020-05-19 17:28:58 +00:00
fdac36ff1c Update dependency date-fns to v2.14.0 (#136)
Update dependency date-fns to v2.14.0

Reviewed-on: vikunja/frontend#136
2020-05-19 11:26:28 +00:00
1da7ffb23c
Add changing list identifier 2020-05-16 13:14:57 +02:00
d7b4b2189a Ensure consistent naming of title fields (#134)
Merge branch 'master' into fix/title-fields

Change task text field to title

Change namespace name field to title

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#134
2020-05-16 10:31:16 +00:00
c4b92a8f52
Fix redirect when not logged in 2020-05-16 12:02:30 +02:00
f63576960d
0.13 release preperations 2020-05-12 22:16:04 +02:00
d0f6a4ce99
Better responsive layout for unauthenticated pages 2020-05-12 15:18:37 +02:00
b876a4d4dc
Fix redirecting for unauthenticated pages to login 2020-05-12 15:17:47 +02:00
0dc4e6b95d
Fix trying to load the current tasks even when not logged in (Fixes #133) 2020-05-12 15:08:17 +02:00
cc46809639
Fix sharing rights not displayed correctly 2020-05-11 20:56:35 +02:00
687b8dc824
Remove task in kanban state when removing in task detail view 2020-05-11 17:25:04 +02:00
d409957de5 Update dependency vuex to v3.4.0 (#132)
Update dependency vuex to v3.4.0

Reviewed-on: vikunja/frontend#132
2020-05-11 15:13:51 +00:00
995dec33ea
Add list title in overview page 2020-05-11 16:52:58 +02:00
b85f66140b
Fix %done in table view 2020-05-11 11:43:25 +02:00
efd2e38357 Update dependency eslint to v7 (#129)
Update dependency eslint to v7

Reviewed-on: vikunja/frontend#129
2020-05-11 09:38:38 +00:00
058570c9a7
Make sure the api url does not have a / at the end 2020-05-10 18:26:33 +02:00
f524a3efc1
Less explicit matching of api routes for service worker 2020-05-10 18:10:29 +02:00
b1de52fc0b
Fix redirecting to list view from task detail 2020-05-09 23:38:32 +02:00
2ca8eef4f7
Fix undefined getter for related tasks 2020-05-09 23:32:04 +02:00
c2135338d3
Show parent list and namespace for tasks in detail views 2020-05-09 23:28:54 +02:00
495350fa83
Fix not redirecting to login page after logging out 2020-05-09 22:31:33 +02:00
f878063015
Only set fullpage state to false if the page is actually fullpage 2020-05-09 22:24:42 +02:00
b79593a372
Fetch tags when building in ci to display proper versions 2020-05-09 22:09:46 +02:00
bd351550ee
Fix loading state for kanban board 2020-05-09 22:08:18 +02:00
15edfe0a49
Fix version console log when compiling for Docker 2020-05-09 21:57:59 +02:00
ce1bfba5fd
Don't show the llama background when on mobile 2020-05-09 21:46:28 +02:00
f75c3ed4f7
Fix drone config 2020-05-09 21:42:29 +02:00
8789135eed
Add logging frontend version to console on startup 2020-05-09 21:39:46 +02:00
48df1a44e8
Show the list of a related task if it belongs to a different list 2020-05-09 19:33:37 +02:00
2d59b0a1b0
Fix date table cell getting wrong data 2020-05-09 19:23:46 +02:00
a822a07c89
Open popup detail view when opening from task overview 2020-05-09 19:21:17 +02:00
e74c72f486
Don't open task detail in popup for list and table view 2020-05-09 19:19:06 +02:00
85a1f9f2a1
Fix listId not defined in list view switcher 2020-05-09 19:15:20 +02:00
4e42810522 Update tasks in kanban board after editing them in task detail view (#130)
Fix due date disappearing after moving it

Fix removing labels not being updated in store

Fix adding labels not being updated in store

Fix removing assignees not being updated in store

Fix adding assignees not being updated in store

Fix due date not resetting

Fix task attachments not updating in store after being modified in popup view

Fix due date not updating in store after being modified in popup view

Fix using filters for overview views

Fix not re-loading tasks when switching between overviews

Only show undone tasks on task overview page

Update task in bucket when updating in task detail view

Put all bucket related stuff in store

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#130
2020-05-09 17:00:54 +00:00
2270272a8f
Fix using filters for overview views 2020-05-09 15:46:05 +02:00
67de7e7c8f
Fix not re-loading tasks when switching between overviews 2020-05-09 14:54:42 +02:00
be10ba0f62
Only show undone tasks on task overview page 2020-05-09 14:51:37 +02:00
bdb2dba49c
Switch docker image to node for building 2020-05-08 21:59:06 +02:00
0b35f1cfd2 Pin dependency vuex to 3.3.0 (#128)
Pin dependency vuex to 3.3.0

Reviewed-on: vikunja/frontend#128
2020-05-08 19:54:47 +00:00
8ae03fa6e9
Highlight the current list when something list related is called 2020-05-08 21:07:33 +02:00
5724b98358 Vuex (#126)
Merge branch 'master' into feature/vuex

Cleanup

Move namespaces handling to store

Move fullpage setting to store

Move online/offline handling to store

Remove debug log

Fix loading namepaces

Rename errorMsg

Handle registering through the store

Use store to determine wether or not a user is authenticated on login page

Use store in edit team

Use store to get the current user's avatar

Use store to figure out if the current user belongs to a team

Use store to figure out if the current user is list admin

Use store to get user info when listing labels

Use store to get username on home

Use store to figure out if the user is namespace admin

Use the store for configuration

Use the store to check if a user is authenticated everywhere

Only renew token if the user is logged in

Fix renewing auth from jwt token

Move logout to store

Move renew token to store

Move log in to store

Only show enabled migrators

Only show registration button if registration is enabled

Put all config information from the backend in the store

Remove old config handler

Move config state to seperate module

Add vuex and get the motd through it

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#126
2020-05-08 18:43:51 +00:00
2ba6b7ef3a Update dependency date-fns to v2.13.0 (#127)
Update dependency date-fns to v2.13.0

Reviewed-on: vikunja/frontend#127
2020-05-07 08:36:05 +00:00
b1b5fd3cf8
Let labels take all available space on tasks 2020-05-06 21:43:31 +02:00
ff70696111
Fix task modal with when attachments are present 2020-05-06 21:41:42 +02:00
815844fe2a
Fix opening link share list view 2020-05-06 21:23:47 +02:00
f1561a491b
Fix changing api url when releasing 2020-05-05 23:34:58 +02:00
9fdbcd56cf
Add docker run script to change api url on startup 2020-05-05 23:31:01 +02:00
38c7e4b3c2
Fix avatar url 2020-05-05 23:30:15 +02:00
0c6b0cb48d
Change default api url to 3456 (Vikunja default) 2020-05-05 22:47:17 +02:00
d46faec23d
Make api url configurable in index.html 2020-05-05 22:44:58 +02:00
1bad154da6 Update dependency node-sass to v4.14.1 (#125)
Update dependency node-sass to v4.14.1

Reviewed-on: vikunja/frontend#125
2020-05-05 20:20:19 +00:00
4c50bc148b Merge pull request 'fix/task-detail-view' (#124) from Furai/frontend:fix/task-detail-view into master
Reviewed-on: vikunja/frontend#124
2020-05-04 19:47:54 +00:00
9aaaa8394f
Make "Move task to different list" wording shorter 2020-05-04 21:33:01 +02:00
3edcc790de
Remove llama-upside-down.svg 2020-05-04 20:45:15 +02:00
183f1411f9
Fix not all labels being shown 2020-05-04 10:14:17 +02:00
40721e7a74
Fix setting api url when building docker image 2020-05-01 12:19:52 +02:00
234db32e30
Remove dependency in docker build step when releasing latest 2020-05-01 11:51:53 +02:00
2b59fabbc6
Remove dependency in docker build step when releasing 2020-05-01 11:51:26 +02:00
010da8cf07
Fix listId not changing when switching between lists 2020-05-01 11:50:12 +02:00
5009308c52
Fix parsing nested array with non-objects when updating 2020-04-30 23:45:22 +02:00
231b51445a Update Node.js to v13.14.0 (#123)
Update Node.js to v13.14.0

Reviewed-on: vikunja/frontend#123
2020-04-30 19:20:33 +00:00
b043369245
Fix navigating back to list view after deleting a task 2020-04-30 12:49:42 +02:00
120d5a8c19
Fix user search bar not hiding in edit team view 2020-04-28 12:09:09 +02:00
fb64aeb2d4
Fix pagination for tasks 2020-04-27 21:05:53 +02:00
229999546c
Fix bucket spacing on kanban board 2020-04-27 13:06:03 +02:00
dca90477d9
Fix task title overflowing in detail view 2020-04-27 13:03:47 +02:00
623879381b Fix team managment (#121)
Fix member/admin button overflowing

Fix changing team member admin status

Fix adding team members

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#121
2020-04-27 10:59:49 +00:00
a5c0b035e7
Fix creating a new task on a list when in list view 2020-04-27 12:08:38 +02:00
d6642550bd
Add input length validation for team names 2020-04-26 18:32:34 +02:00
f4847be320
Remove debug log 2020-04-26 18:22:18 +02:00
c59501958f
Fix attachment icon 2020-04-26 16:32:30 +02:00
86aebc48a0
Fix uploading attachments 2020-04-26 16:28:02 +02:00
747a912475
Make the task font size smaller for task cards 2020-04-26 15:55:14 +02:00
31b025cc55
Fix related tasks input size 2020-04-26 15:44:23 +02:00
2a7bbf3c83
Fix related tasks list being too large 2020-04-26 15:37:49 +02:00
6c24cc66b2
Fix parsing nested models 2020-04-26 15:04:58 +02:00
96616eff8c Pin dependency vue-smooth-dnd to 0.8.1 (#120)
Pin dependency vue-smooth-dnd to 0.8.1

Reviewed-on: vikunja/frontend#120
2020-04-26 07:44:44 +00:00
c7845bb9c1 Kanban (#118)
Add error message when trying to create an invalid new task in a bucket

Prevent creation of new buckets if the bucket title is empty

Disable deleting a bucket if it's the last one

Disable dragging tasks when they are being updated

Fix transition when opening tasks

Send the user to list view by default

Show loading spinner when updating multiple tasks

Add loading spinner when moving tasks

Add loading animation when bucket is loading / updating etc

Add bucket title edit

Fix creating new buckets

Add loading animation

Add removing buckets

Fix creating a new bucket after tasks were moved

Fix warning about labels on tasks

Fix labels on tasks not updating after retrieval from api

Fix property width

Add closing and mobile design

Make the task detail popup look good

Move list views

Move task detail view in a popup

Add link to tasks

Add saving the new task position after it was moved

Fix creating new bucket

Fix creating a new task

Cleanup

Disable user selection for task cards

Fix drag placeholder

Add dragging style to task

Add placeholder + change animation duration

More cleanup

Cleanup / docs

Working of dragging and dropping tasks

Adjust markup and styling for new library

Change kanban library to something that works

Add basic calculation of new positions

Don't try to create empty tasks

Add indicator if a task is done

Add moving tasks between buckets

Make empty buckets a little smaller

Add gimmick for button description

Fix color

Fix scrolling bucket layout

Add creating a new bucket

Add hiding the task input field

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#118
2020-04-25 23:11:34 +00:00
ea6fda8a9d Update dependency node-sass to v4.14.0 (#119)
Update dependency node-sass to v4.14.0

Reviewed-on: vikunja/frontend#119
2020-04-23 22:22:34 +00:00
95b5ed0d35 Update dependency vue-easymde to v1.2.0 (#116)
Update dependency vue-easymde to v1.2.0

Reviewed-on: vikunja/frontend#116
2020-04-21 09:34:30 +00:00
6f3f7d227d Docker multistage build (#113)
Frozen lockfile

Adjust to mutlistage build

Reviewed-on: vikunja/frontend#113
2020-04-19 17:27:15 +00:00
d4b82a4cc9
Add moving tasks between lists 2020-04-18 14:39:56 +02:00
99c10d49be TOTP (#109)
Fix not telling the user about invalid totp passcodes when logging in

Add disabling totp authentication

Add totp passcode when logging in

Add totp settings

Add general post method function

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#109
2020-04-17 23:46:07 +00:00
a75670e4f0 Add user settings (#108)
Add email update

Add settings link to menu

Add password update route

Add password update page

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#108
2020-04-17 20:46:50 +00:00
335ea49801
Upgrade vue-cli 2020-04-17 21:30:56 +02:00
e7c1c98c6a
Fix id params not being named correctly 2020-04-17 12:19:53 +02:00
588b87fb96
Fix maintaining the current page for the list view when navigating back from another page 2020-04-14 23:37:08 +02:00
cc02fc82fc
Fix closing of notifications by clicking on it not working 2020-04-14 23:09:18 +02:00
bb84d03776
Remove debug logging 2020-04-14 22:55:50 +02:00
7587821927
Move conversion of snake_case to camelCase to model to make recursive models still work 2020-04-14 22:46:27 +02:00
a77b4253cb
Fix task relation kind dropdown 2020-04-14 22:25:46 +02:00
e2137d08a5
Pluralize related task kinds if there is more than one 2020-04-14 22:23:42 +02:00
cd6dee88b9
Add scrolling for task table view 2020-04-14 22:15:35 +02:00
575b2f28ef
Fix task sort parameters 2020-04-14 21:54:27 +02:00
f552b834d6 Pin dependencies (#106)
Pin dependencies

Reviewed-on: vikunja/frontend#106
2020-04-12 22:03:46 +00:00
4a413e7f3c Make all api fields snake_case (#105)
Change all snake/camelCase mix and match to camelCase everywhere

Fix conversion to not interfer with service interceptors

Add dynamic conversion between camelCase and snake_case to services

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#105
2020-04-12 21:54:46 +00:00
de36296bac Update dependency bulma to v0.8.2 (#104)
Update dependency bulma to v0.8.2

Reviewed-on: vikunja/frontend#104
2020-04-11 13:04:47 +00:00
43e464c113 Update dependency date-fns to v2.12.0 (#103)
Update dependency date-fns to v2.12.0

Reviewed-on: vikunja/frontend#103
2020-04-09 19:40:08 +00:00
bd998469a1 Update dependency core-js to v3.6.5 (#102)
Update dependency core-js to v3.6.5

Reviewed-on: vikunja/frontend#102
2020-04-09 19:39:58 +00:00
8b8543e011 Update dependency copy-to-clipboard to v3.3.1 (#100)
Update dependency copy-to-clipboard to v3.3.1

Reviewed-on: vikunja/frontend#100
2020-04-07 19:00:58 +00:00
3141850fa2 Update dependency core-js to v3.6.4 (#101)
Update dependency core-js to v3.6.4

Reviewed-on: vikunja/frontend#101
2020-04-07 19:00:40 +00:00
e098f87a33 Update vue-cli monorepo to v4.3.1 (#99)
Update vue-cli monorepo to v4.3.1

Reviewed-on: vikunja/frontend#99
2020-04-07 16:05:30 +00:00
d380488b32 Update dependency register-service-worker to v1.7.1 (#93)
Update dependency register-service-worker to v1.7.1

Reviewed-on: vikunja/frontend#93
2020-04-06 20:09:19 +00:00
1c734f15d1 Add telegram release notificiation (#98)
Add telegram release notificiation

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#98
2020-04-06 19:58:49 +00:00
70059b4f9a Update dependency sass-loader to v8.0.2 (#94)
Update dependency sass-loader to v8.0.2

Reviewed-on: vikunja/frontend#94
2020-04-06 19:44:31 +00:00
1f87191d3c Update dependency eslint-plugin-vue to v6.2.2 (#91)
Update dependency eslint-plugin-vue to v6.2.2

Reviewed-on: vikunja/frontend#91
2020-04-06 19:25:47 +00:00
8d1d71fa36 Update vue-cli monorepo to v4.3.0 (#97)
Update vue-cli monorepo to v4.3.0

Reviewed-on: vikunja/frontend#97
2020-04-06 19:25:19 +00:00
6aaea4927b Update dependency node-sass to v4.13.1 (#92)
Update dependency node-sass to v4.13.1

Reviewed-on: vikunja/frontend#92
2020-04-06 19:05:05 +00:00
7c6108ce08 Update dependency vue-router to v3.1.6 (#96)
Update dependency vue-router to v3.1.6

Reviewed-on: vikunja/frontend#96
2020-04-06 18:44:46 +00:00
c2678b8dab Update dependency v-tooltip to v2.0.3 (#95)
Update dependency v-tooltip to v2.0.3

Update dependency bulma to v0.8.1 (#85)

Update dependency bulma to v0.8.1

Update dependency date-fns to v2.11.1 (#88)

Update dependency date-fns to v2.11.1

Reviewed-on: vikunja/frontend#88

Reviewed-on: vikunja/frontend#85

Update dependency babel-eslint to v10.1.0 (#84)

Update dependency babel-eslint to v10.1.0

Update dependency axios to v0.19.2 (#83)

Update dependency axios to v0.19.2

Add github token for renovate (#89)

Add github token for renovate

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#89

Update dependency date-fns to v2.11.1 (#88)

Update dependency date-fns to v2.11.1

Reviewed-on: vikunja/frontend#88

Update Font Awesome (#82)

Update Font Awesome

Reviewed-on: vikunja/frontend#82

Co-authored-by: konrad <konrad@kola-entertainments.de>
Reviewed-on: vikunja/frontend#83

Add github token for renovate (#89)

Add github token for renovate

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#89

Update dependency date-fns to v2.11.1 (#88)

Update dependency date-fns to v2.11.1

Reviewed-on: vikunja/frontend#88

Co-authored-by: konrad <konrad@kola-entertainments.de>
Reviewed-on: vikunja/frontend#84

Reviewed-on: vikunja/frontend#95
2020-04-06 18:39:25 +00:00
46aa7ad3ac Update dependency eslint to v6.8.0 (#90)
Update dependency eslint to v6.8.0

Update dependency bulma to v0.8.1 (#85)

Update dependency bulma to v0.8.1

Update dependency date-fns to v2.11.1 (#88)

Update dependency date-fns to v2.11.1

Reviewed-on: vikunja/frontend#88

Reviewed-on: vikunja/frontend#85

Update dependency babel-eslint to v10.1.0 (#84)

Update dependency babel-eslint to v10.1.0

Update dependency axios to v0.19.2 (#83)

Update dependency axios to v0.19.2

Add github token for renovate (#89)

Add github token for renovate

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#89

Update dependency date-fns to v2.11.1 (#88)

Update dependency date-fns to v2.11.1

Reviewed-on: vikunja/frontend#88

Update Font Awesome (#82)

Update Font Awesome

Reviewed-on: vikunja/frontend#82

Co-authored-by: konrad <konrad@kola-entertainments.de>
Reviewed-on: vikunja/frontend#83

Add github token for renovate (#89)

Add github token for renovate

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#89

Update dependency date-fns to v2.11.1 (#88)

Update dependency date-fns to v2.11.1

Reviewed-on: vikunja/frontend#88

Co-authored-by: konrad <konrad@kola-entertainments.de>
Reviewed-on: vikunja/frontend#84

Update dependency axios to v0.19.2 (#83)

Update dependency axios to v0.19.2

Add github token for renovate (#89)

Add github token for renovate

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#89

Update dependency date-fns to v2.11.1 (#88)

Update dependency date-fns to v2.11.1

Reviewed-on: vikunja/frontend#88

Update Font Awesome (#82)

Update Font Awesome

Reviewed-on: vikunja/frontend#82

Co-authored-by: konrad <konrad@kola-entertainments.de>
Reviewed-on: vikunja/frontend#83

Reviewed-on: vikunja/frontend#90
2020-04-06 18:39:10 +00:00
641eeaf1c1 Update dependency bulma to v0.8.1 (#85)
Update dependency bulma to v0.8.1

Update dependency date-fns to v2.11.1 (#88)

Update dependency date-fns to v2.11.1

Reviewed-on: vikunja/frontend#88

Reviewed-on: vikunja/frontend#85
2020-04-06 06:38:53 +00:00
128ce592ab Update dependency babel-eslint to v10.1.0 (#84)
Update dependency babel-eslint to v10.1.0

Update dependency axios to v0.19.2 (#83)

Update dependency axios to v0.19.2

Add github token for renovate (#89)

Add github token for renovate

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#89

Update dependency date-fns to v2.11.1 (#88)

Update dependency date-fns to v2.11.1

Reviewed-on: vikunja/frontend#88

Update Font Awesome (#82)

Update Font Awesome

Reviewed-on: vikunja/frontend#82

Co-authored-by: konrad <konrad@kola-entertainments.de>
Reviewed-on: vikunja/frontend#83

Add github token for renovate (#89)

Add github token for renovate

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#89

Update dependency date-fns to v2.11.1 (#88)

Update dependency date-fns to v2.11.1

Reviewed-on: vikunja/frontend#88

Co-authored-by: konrad <konrad@kola-entertainments.de>
Reviewed-on: vikunja/frontend#84
2020-04-06 06:38:43 +00:00
c352f47d01 Update dependency axios to v0.19.2 (#83)
Update dependency axios to v0.19.2

Add github token for renovate (#89)

Add github token for renovate

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#89

Update dependency date-fns to v2.11.1 (#88)

Update dependency date-fns to v2.11.1

Reviewed-on: vikunja/frontend#88

Update Font Awesome (#82)

Update Font Awesome

Reviewed-on: vikunja/frontend#82

Co-authored-by: konrad <konrad@kola-entertainments.de>
Reviewed-on: vikunja/frontend#83
2020-04-05 19:35:19 +00:00
16a0b52ebc Add github token for renovate (#89)
Add github token for renovate

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#89
2020-04-05 18:42:38 +00:00
69ee52e182 Update dependency date-fns to v2.11.1 (#88)
Update dependency date-fns to v2.11.1

Reviewed-on: vikunja/frontend#88
2020-04-05 18:26:29 +00:00
c7151f3ae9 Update Font Awesome (#82)
Update Font Awesome

Reviewed-on: vikunja/frontend#82
2020-04-05 15:30:03 +00:00
7c6438c50d Pin dependencies (#81)
Pin dependencies

Reviewed-on: vikunja/frontend#81
2020-04-05 15:16:03 +00:00
7d41603ba6 Configure Renovate (#80)
Add renovate.json

Reviewed-on: vikunja/frontend#80
2020-04-05 15:11:10 +00:00
ede990ed85
0.12 Release Preparations 2020-04-04 22:38:01 +02:00
066cd63771
Schedule token renew every minute 2020-04-04 18:29:36 +02:00
8480bf334f Fix gantt chart (#79)
Fix moving tasks in Gantt

Start fixing gantt

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#79
2020-04-04 16:26:35 +00:00
27e2839f4c
Work around browsers preventing Vue bindings from working with autofill (Fixes #78) 2020-04-02 21:53:18 +02:00
724275e653 Table View for tasks (#76)
Save sort order to local storage

Save selected columns to localStorage

Loading spinner

More sorting

Add sorting

Styling and hiding of column filter

Add checkbox to show/hide columns

Use fancycheckbox everywhere

Fix is done badge

Change sort order in table view

Add is done column to table

Better text handling

Refactor is done into seperate component

Add pagination to table view

Add assignees to table view

Fix redirecting to table view

Add date tooltip to date field

Add date tooltip to date field

labels for table view

Styling

Add basic table view

Extend priority label

Split list view in mixins

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#76
2020-04-01 20:13:57 +00:00
cc513b5274
Easymde preparations 2020-03-26 22:21:09 +01:00
701a46ecd4
Fix updating a task with repeat after interval from list view (Fixes #75) 2020-03-26 20:01:05 +01:00
cafb960c8d Colors for lists and namespaces (#74)
Show colors for namespaces bigger

Show colors for lists and namespaces

Add changing color for lists

Add changing color for namespace

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#74
2020-03-25 21:27:29 +00:00
51de1fe880
Enable marking tasks as done from the task overview 2020-03-23 23:24:14 +01:00
87f74e3a4b
Pre/Suffix formatted dates with relative pronouns like "in [one day]" or "[two days] ago" 2020-03-23 18:46:33 +01:00
3b18b83239
Add task search term to query param to enable navigation 2020-03-23 18:39:55 +01:00
f2fec2030e
Fix error notification still being shown on password reset pages despite no error 2020-03-23 18:32:06 +01:00
28c2f3573d
Fix comments not being loaded again when switching between tasks 2020-03-23 18:29:25 +01:00
35d0058026
Fix icon overflowing in navigation 2020-03-22 22:44:33 +01:00
7d2bd192ab Add support for archiving lists and namespaces (#73)
Use fancy checkbox for archiving namespace

Show is archived badge for namespaces

Fix is archived badge in navigation bar

Add check to filter out archived lists or namespaces

Show if a list is archived in menu

Hide edit task if the list is archived

Hide marking tasks as done if the list is archived

Show is archived message on list

Add archiving a list

Add archiving a namespace

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#73
2020-03-22 20:40:13 +00:00
ce80fa2dbd
Fix namespace model name showing wrong placeholder until the namespace was loaded 2020-03-21 13:57:42 +01:00
a706089f7b
Fix changing task dates (due/start/end/reminders)
Fixes #71
2020-03-08 20:07:21 +01:00
ab8dd6f67a
Fix not highlighting the current list in menu when paginating 2020-03-04 21:43:11 +01:00
4408115f41
Add creating new related tasks 2020-03-04 21:29:40 +01:00
e586c66095
Fix new related task not being visible in the search field 2020-03-04 20:38:17 +01:00
2b5888805f
Add user to attachments list 2020-03-04 20:35:00 +01:00
2104d1ea4b Input length validation for new tasks, lists and namespaces (#70)
Fix input validation for new tasks

Better layout for input validation for new lists

Add input length validation for new namepaces

Add input length validation for new lists

Add input length validation for new tasks

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#70
2020-03-04 19:27:27 +00:00
fe6c859150
Revert "Use deep imports for importing lodash to make tree shaking easier"
This reverts commit 3c3767a9
2020-03-03 21:02:13 +01:00
3c3767a91e
Use deep imports for importing lodash to make tree shaking easier 2020-03-02 22:37:36 +01:00
aeba5651af
Ensure labels of a task get updated when updating them 2020-03-02 22:25:38 +01:00
f690a6f457
Swap moment.js with date-fns 2020-03-02 21:55:22 +01:00
5972476735
Add undo button to notification when marking a task as done 2020-03-02 21:19:26 +01:00
a4acfb5ef2
Add 404 page 2020-03-02 20:30:49 +01:00
c458f902da
Change release bucket 2020-03-01 22:53:40 +01:00
94714b2964
Fix avatar sizes 2020-03-01 21:58:58 +01:00
d70aa1b21d Add getting the user avatar from the api (#68)
Add getting the user avatar from the api

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#68
2020-03-01 20:30:54 +00:00
057f3c8337
Prepare changelog & readme for 0.11 release 2020-03-01 17:40:41 +01:00
ff4299beb1
Fix reminders not being shown on task detail view on mobile 2020-03-01 17:20:25 +01:00
fec60578ab
Don't schedule a reminder if the reminder date is in the past 2020-03-01 17:13:25 +01:00
6d4ac2f2b6
Add saving task title with ctrl+enter 2020-03-01 16:57:42 +01:00
f3ec9be8e5
Fix drone testing pipeline triggering only when pushing to master and not on prs 2020-03-01 16:52:36 +01:00
22cf54f1f9
Add saving the description with ctrl+enter 2020-03-01 16:50:05 +01:00
269b80e64e
Fix error container at registration page always being displayed 2020-02-28 22:08:16 +01:00
8c82c2302f
Fix changing the right of a list shared with a user 2020-02-26 20:34:14 +01:00
57f78ee0d4 Task Comments (#66)
Better edit/remove buttons

Spacing

More loading

Add loading

Better dates formatting

Add editing comments

Move closing delete modal to finally

Add delete comments

Add keycode modifier

Comment styling

Comment form

Add basic task comments functionality

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#66
2020-02-25 20:11:36 +00:00
683012f468
Fix gravatar url 2020-02-18 19:11:25 +01:00
09dda84d75 Fix a typo (#64)
Fix a typo

Co-authored-by: Jan Tojnar <jtojnar@gmail.com>
Reviewed-on: vikunja/frontend#64
2020-02-18 06:49:20 +00:00
a4685b50e8 Fix email field type (#58)
Fix email field type

Co-authored-by: Jan Tojnar <jtojnar@gmail.com>
Reviewed-on: vikunja/frontend#58
Reviewed-by: konrad <konrad@kola-entertainments.de>
2020-02-17 17:28:46 +00:00
0591531949
Fix adding a task to an empty list 2020-02-14 17:45:06 +01:00
00c1ed7ad7
Move "Next Week" section in menu below "Next Month" 2020-02-14 17:38:48 +01:00
e0dcf1faa9
Load Fonts directly 2020-02-11 22:32:10 +01:00
1111d60d61
Preload fonts css 2020-02-11 22:21:25 +01:00
dd0703562f
Improve link share layout 2020-02-09 17:54:02 +01:00
6258c59c18
Fix initial dates on task edit sidebar 2020-02-09 17:33:04 +01:00
301c23fa9a
Add auto save for task edit sidebar 2020-02-09 17:30:42 +01:00
6324fc384b
Move the Vikunja logo to the hamburger menu on mobile 2020-02-09 15:29:55 +01:00
2f2ddb4603
Hide the llama from the top on the task detail page 2020-02-09 15:22:42 +01:00
4f9f3afc34
Better wording for shared settings 2020-02-09 15:11:14 +01:00
866218c479
Add slight background change when hovering over a task in the list 2020-02-09 15:09:11 +01:00
4f81e96021
Fix label input field breaking in a new line on task detail page 2020-02-09 15:02:39 +01:00
63d21b54c4
Rearrange button order on task detail view 2020-02-09 14:54:09 +01:00
2223072881
Set the end date to the same as the due date if a start date was set but no end date 2020-02-09 14:52:45 +01:00
64cbfc113a
Fix "Add a reminder" being shown 2020-02-09 14:48:21 +01:00
b41a4380d8
Add a button to the task detail page to mark a task as done 2020-02-09 14:46:01 +01:00
05da96e545
Use the same method everywhere to calculate the avatar url 2020-02-09 13:28:33 +01:00
783401723a
Better default profile image 2020-02-09 13:16:34 +01:00
010812ef06
Don't try to cancel notifications if the browser does not support it 2020-02-09 13:12:54 +01:00
80b363872e
Set user menu inactive when logging out 2020-02-09 13:07:31 +01:00
d42e88b26d
But the add reminders button on the task detail page higher up 2020-02-09 12:50:57 +01:00
1df1be2eab
Always schedule notification
(Caused bugs)
2020-02-09 10:48:39 +01:00
37d6ceb963
Fix date handling on task detail page 2020-02-08 18:37:23 +01:00
04d7d48b68 Notifications for task reminders (#57)
Add actions for reminders

Remove scheduled reminders

Better styling

Start adding support for triggered offline notifications

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#57
2020-02-08 17:28:17 +00:00
161f853361
Make sure to use date objects everywhere where dealing with dates 2020-02-08 14:16:06 +01:00
fc17518e8c Add a link to vikunja.io (#56)
Add a link to vikunja.io

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#56
2020-01-31 16:33:34 +00:00
8dcabc9385 Only focus inputs if the viewport is large enough (#55)
Only focus inputs if the viewport is large enough

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#55
2020-01-31 16:19:04 +00:00
96fddd9bbd Fix task title on mobile (#54)
Use a contenteditable for task title to make it look good on mobile

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#54
2020-01-31 16:09:29 +00:00
604488c68c Fix using the error data prop in components (#53)
Fix error msg data props everywhere

Fix error msg data props

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#53
2020-01-31 15:33:14 +00:00
5f0b5a0945
Sort tasks on start page by due date desc and id desc 2020-01-31 11:09:45 +01:00
309f75b19d Task Search (#52)
Add hiding the search

Add actually searching for tasks

Fix jumping search button on page load

Add search button

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#52
2020-01-31 10:05:53 +00:00
c8130bef61
Directly link to the task for tasks on the start page 2020-01-30 22:50:28 +01:00
1170e030f6 Use message mixin for handling success and error messages (#51)
Use message mixin everywhere

Add mixin for success and error messages

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#51
2020-01-30 21:47:08 +00:00
a0c4732f81 Add moment.js for date related things (#50)
Fix saving

Use mixin everywhere

Format attachment dates

Add format date mixing

Use moment js on task list page

Use moment js on home page tasks

Add moment js

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#50
2020-01-30 20:49:00 +00:00
da10b4310b Show if a related task is done (#49)
Show if a related task is done

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#49
2020-01-22 20:27:48 +00:00
22d2d1a777 Add removing of tasks (#48)
Add removing of tasks

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#48
2020-01-22 20:18:39 +00:00
ed0ae210ac
Fix height of task add button 2020-01-22 21:00:41 +01:00
d61a7511da Migration Improvements (#47)
Make "migration" "import everywhere"

Add checking for migration status before trying to migrate

Add get status from migrations

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#47
2020-01-20 21:22:32 +00:00
9b232c7d4f Add Wunderlist migration (#46)
Complete migration flow

Add migration in progress animation

Add handling wunderlist migration flow

Basic migration init structure

Add migrator structure

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#46
2020-01-19 19:23:06 +00:00
74f5d43097
#937 Fix textarea in task detail view not having a background when focused 2020-01-10 22:43:17 +01:00
a06e709da6
Show motd everywhere 2019-12-25 17:38:49 +01:00
9c66a7570a Reorganize Styles (#45) 2019-12-19 22:09:23 +00:00
752d6cc6f9
Fix task text breaking on list home on mobile 2019-12-19 22:18:51 +01:00
500e0cfaf4 Fix update notification layout on mobile (#44) 2019-12-19 21:11:38 +00:00
ed4d41e2d8 Add automatic user token renew (#43) 2019-12-19 20:50:07 +00:00
6b7fe8ee47
Fix priority label styling 2019-12-18 22:38:26 +01:00
ff0f078ee6 PWA update available notification (#42) 2019-12-18 21:30:20 +00:00
81e9eef154 Show parent tasks in task overview list (#41) 2019-12-18 18:55:28 +00:00
d041384999
Fix loading tasks for the first page after navigating to a new list 2019-12-17 23:03:55 +01:00
11d9aaae12 Update dependencies (#40) 2019-12-15 20:42:40 +00:00
ce1a524429
Bump npm to 6.13 2019-12-15 11:51:14 +01:00
52017aca83 Task sorting (#39) 2019-12-07 16:35:42 +00:00
a9291a5f2f
Fix not using router links for previous and back buttons 2019-12-03 19:14:05 +01:00
2302a46d9b Task Pagination (#38) 2019-12-03 18:09:12 +00:00
99cc06edee
Disable production source maps 2019-12-02 18:59:43 +01:00
e1dfe5abf7
Fix changelog version 2019-11-24 19:55:35 +01:00
c5691ec293
Prepare changelog & readme for 0.9 release 2019-11-24 19:51:45 +01:00
2c78b36f1e
[skip ci] Add changelog in repo 2019-11-24 19:49:12 +01:00
4e5d14d969 Task Detail View (#37) 2019-11-24 13:16:24 +00:00
e00f0046b5
Replace all spaces with tabs 2019-11-03 13:44:40 +01:00
c6d7b288ce
Fix edit label pane not closing when clicking on it 2019-11-02 20:00:10 +01:00
c4489c20e3
Moved non-theme stuff in general.scss 2019-10-30 20:05:40 +01:00
2705c1571e Handle task relations the right way (#36) 2019-10-28 21:45:37 +00:00
7a997b52a6
Fixed label edit still opening when deleting a label 2019-10-26 14:39:27 +02:00
1f504b1e6d
Fixed team creating not working 2019-10-26 14:19:56 +02:00
c60f061307
Updated dependencies 2019-10-22 19:38:35 +02:00
7419f2a3fb
Fixed scroll behaviour 2019-10-20 21:40:44 +02:00
0af1deaa00
Fixed shared lists overflowing 2019-10-20 21:35:35 +02:00
8267f50249
Moved markdown-based todo list to Vikunja [skip ci] 2019-10-20 20:55:55 +02:00
ce3a7a2131
[skip ci] prepared todo file for migration 2019-10-20 18:15:01 +02:00
bdf0d00bff
Different edit icon 2019-10-19 21:41:23 +02:00
b2408eef04
Sort tasks by done/undone first and then newest 2019-10-19 21:26:52 +02:00
cad4df5558
Use yarn image instead of installing it every time 2019-10-19 18:36:41 +02:00
a0d281b0b4
Added changing %Done on a task 2019-10-19 18:27:31 +02:00
bcfded6efc
Added the function to collapse all lists in a namespace in the sidebar menu 2019-10-19 17:56:47 +02:00
e1cd40980a
Correctly preload fonts 2019-10-19 16:44:22 +02:00
eb15046c5e
Added labels for login and register inputs 2019-10-19 16:27:56 +02:00
cb9b6592c1
Removed unused preload fonts tags 2019-10-18 18:21:37 +02:00
befb5143c8
Added meta description tag 2019-10-18 18:15:47 +02:00
c635052c0f
Improved font handling 2019-10-18 18:01:04 +02:00
52bdae90d3
Load the offline image quietly in the background 2019-10-17 21:54:05 +02:00
3211e1e8ea Added handling if the user is offline (#35) 2019-10-16 21:27:21 +00:00
2a7871cf96 Add minimal PWA (#34) 2019-10-16 18:25:10 +00:00
205 changed files with 17923 additions and 6443 deletions

View File

@ -3,19 +3,20 @@ name: testing
trigger:
branch:
exclude:
include:
- master
event:
- push
include:
- push
- pull_request
steps:
- name: build
image: node:11-alpine
image: node:13
pull: true
group: build-static
commands:
- apk add yarn
- yarn
- yarn --frozen-lockfile
- yarn run lint
- yarn run build
@ -30,16 +31,21 @@ trigger:
- push
steps:
- name: fetch-tags
image: docker:git
commands:
- git fetch --tags
- name: build
image: node:11-alpine
image: node:13
pull: true
group: build-static
commands:
- apk add yarn
- yarn
- yarn --frozen-lockfile
- yarn run lint
- "echo '{\"VIKUNJA_API_BASE_URL\": \"/api/v1/\"}' > /drone/src/public/config.json" # Override config
- "echo '{\"VERSION\": \"'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'\"}' > src/version.json"
- yarn run build
- sed -i 's/http\:\\/\\/localhost\\:3456\\/api\\/v1/\\/api\\/v1/g' dist/index.html # Override the default api url used for developing
- name: static
image: kolaente/zip
@ -54,7 +60,7 @@ steps:
image: plugins/s3:1
pull: true
settings:
bucket: vikunja-frontend
bucket: vikunja
access_key:
from_secret: aws_access_key_id
secret_key:
@ -62,6 +68,7 @@ steps:
endpoint: https://storage.kolaente.de
path_style: true
source: vikunja-frontend-master.zip
target: /frontend/
depends_on: [ static ]
# Build the docker image and push it to docker hub
@ -75,7 +82,25 @@ steps:
from_secret: docker_password
repo: vikunja/frontend
auto_tag: true
depends_on: [ static ]
- name: telegram
image: appleboy/drone-telegram
depends_on:
- release
- docker
settings:
token:
from_secret: TELEGRAM_TOKEN
to:
from_secret: TELEGRAM_TO
message: >
{{repo.owner}}/{{repo.name}}: \[{{build.status}}] Build {{build.number}}
{{commit.author}} pushed to {{commit.branch}} {{commit.sha}}: `{{commit.message}}`
Build started at {{datetime build.started "2006-Jan-02T15:04:05Z" "GMT+2"}} finished at {{datetime build.finished "2006-Jan-02T15:04:05Z" "GMT+2"}}.
when:
status:
- success
- failure
---
kind: pipeline
@ -86,16 +111,21 @@ trigger:
- tag
steps:
- name: fetch-tags
image: docker:git
commands:
- git fetch --tags
- name: build
image: node:11-alpine
image: node:13
pull: true
group: build-static
commands:
- apk add yarn
- yarn
- yarn --frozen-lockfile
- yarn run lint
- "echo '{\"VIKUNJA_API_BASE_URL\": \"/api/v1/\"}' > /drone/src/public/config.json" # Override config
- "echo '{\"VERSION\": \"'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'\"}' > src/version.json"
- yarn run build
- sed -i 's/http\:\\/\\/localhost\\:3456\\/api\\/v1/\\/api\\/v1/g' dist/index.html # Override the default api url used for developing
- name: static
image: kolaente/zip
@ -110,7 +140,7 @@ steps:
image: plugins/s3:1
pull: true
settings:
bucket: vikunja-frontend
bucket: vikunja
access_key:
from_secret: aws_access_key_id
secret_key:
@ -118,6 +148,7 @@ steps:
endpoint: https://storage.kolaente.de
path_style: true
source: vikunja-frontend-${DRONE_TAG##v}.zip
target: /frontend/
depends_on: [ static ]
# Build the docker image and push it to docker hub
@ -131,4 +162,23 @@ steps:
from_secret: docker_password
repo: vikunja/frontend
auto_tag: true
depends_on: [ static ]
- name: telegram
image: appleboy/drone-telegram
depends_on:
- release
- docker
settings:
token:
from_secret: TELEGRAM_TOKEN
to:
from_secret: TELEGRAM_TO
message: >
{{repo.owner}}/{{repo.name}}: \[{{build.status}}] Build {{build.number}}
{{commit.author}} pushed to {{commit.branch}} {{commit.sha}}: `{{commit.message}}`
Build started at {{datetime build.started "2006-Jan-02T15:04:05Z" "GMT+2"}} finished at {{datetime build.finished "2006-Jan-02T15:04:05Z" "GMT+2"}}.
when:
status:
- success
- failure

363
CHANGELOG.md Normal file
View File

@ -0,0 +1,363 @@
# 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.13] - 2020-05-12
#### Added
* Add docker run script to change api url on startup
* Add github token for renovate (#89)
* Add input length validation for team names
* Add list title in overview page
* Add logging frontend version to console on startup
* Add moving tasks between lists
* Add scrolling for task table view
* Add telegram release notificiation (#98)
* Add user settings (#108)
* Better responsive layout for unauthenticated pages
* Change default api url to 3456 (Vikunja default)
* Configure Renovate (#80)
* Docker multistage build (#113)
* Don't open task detail in popup for list and table view
* Don't show the llama background when on mobile
* Highlight the current list when something list related is called
* Kanban (#118)
* Make api url configurable in index.html
* Make "Move task to different list" wording shorter
* Make sure the api url does not have a / at the end
* Show parent list and namespace for tasks in detail views
* Show the list of a related task if it belongs to a different list
* TOTP (#109)
* Open popup detail view when opening from task overview
* Vuex (#126)
#### Fixed
* Fetch tags when building in ci to display proper versions
* Fix attachment icon
* Fix avatar url
* Fix bucket spacing on kanban board
* Fix changing api url when releasing
* Fix closing of notifications by clicking on it not working
* Fix creating a new task on a list when in list view
* Fix date table cell getting wrong data
* Fix %done in table view
* Fix drone config
* Fix id params not being named correctly
* Fix listId not changing when switching between lists
* Fix listId not defined in list view switcher
* Fix loading state for kanban board
* Fix maintaining the current page for the list view when navigating back from another page
* Fix navigating back to list view after deleting a task
* Fix not all labels being shown
* Fix not redirecting to login page after logging out
* Fix not re-loading tasks when switching between overviews
* Fix opening link share list view
* Fix pagination for tasks
* Fix parsing nested array with non-objects when updating
* Fix parsing nested models
* Fix redirecting for unauthenticated pages to login
* Fix redirecting to list view from task detail
* Fix related tasks input size
* Fix related tasks list being too large
* Fix setting api url when building docker image
* Fix sharing rights not displayed correctly
* Fix task modal with when attachments are present
* Fix task relation kind dropdown
* Fix task sort parameters
* Fix task title overflowing in detail view
* Fix team managment (#121)
* Fix trying to load the current tasks even when not logged in (Fixes #133)
* Fix undefined getter for related tasks
* Fix uploading attachments
* Fix user search bar not hiding in edit team view
* Fix using filters for overview views
* Fix version console log when compiling for Docker
* Let labels take all available space on tasks
#### Changed
* Less explicit matching of api routes for service worker
* Make all api fields snake_case (#105)
* Make the task font size smaller for task cards
* Move conversion of snake_case to camelCase to model to make recursive models still work
* Only set fullpage state to false if the page is actually fullpage
* Only show undone tasks on task overview page
* Pin dependencies (#106)
* Pin dependencies (#81)
* Pin dependency vue-smooth-dnd to 0.8.1 (#120)
* Pin dependency vuex to 3.3.0 (#128)
* Pluralize related task kinds if there is more than one
* Remove debug log
* Remove debug logging
* Remove dependency in docker build step when releasing
* Remove dependency in docker build step when releasing latest
* Remove llama-upside-down.svg
* Remove task in kanban state when removing in task detail view
* Switch docker image to node for building
* Update dependency axios to v0.19.2 (#83)
* Update dependency babel-eslint to v10.1.0 (#84)
* Update dependency bulma to v0.8.1 (#85)
* Update dependency bulma to v0.8.2 (#104)
* Update dependency copy-to-clipboard to v3.3.1 (#100)
* Update dependency core-js to v3.6.4 (#101)
* Update dependency core-js to v3.6.5 (#102)
* Update dependency date-fns to v2.11.1 (#88)
* Update dependency date-fns to v2.12.0 (#103)
* Update dependency date-fns to v2.13.0 (#127)
* Update dependency eslint-plugin-vue to v6.2.2 (#91)
* Update dependency eslint to v6.8.0 (#90)
* Update dependency eslint to v7 (#129)
* Update dependency node-sass to v4.13.1 (#92)
* Update dependency node-sass to v4.14.0 (#119)
* Update dependency node-sass to v4.14.1 (#125)
* Update dependency register-service-worker to v1.7.1 (#93)
* Update dependency sass-loader to v8.0.2 (#94)
* Update dependency v-tooltip to v2.0.3 (#95)
* Update dependency vue-easymde to v1.2.0 (#116)
* Update dependency vue-router to v3.1.6 (#96)
* Update dependency vuex to v3.4.0 (#132)
* Update Font Awesome (#82)
* Update Node.js to v13.14.0 (#123)
* Update tasks in kanban board after editing them in task detail view (#130)
* Update vue-cli monorepo to v4.3.0 (#97)
* Update vue-cli monorepo to v4.3.1 (#99)
* Upgrade vue-cli
## [0.12] - 2020-04-04
#### Added
* Table View for tasks (#76)
* 404 page
* Add creating new related tasks
* Add getting the user avatar from the api (#68)
* Add support for archiving lists and namespaces (#73)
* Add task search term to query param to enable navigation
* Add undo button to notification when marking a task as done
* Add user to attachments list
* Colors for lists and namespaces (#74)
* Enable marking tasks as done from the task overview
* Ensure labels of a task get updated when updating them
* Input length validation for new tasks, lists and namespaces (#70)
* Pre/Suffix formatted dates with relative pronouns like "in [one day]" or "[two days] ago"
#### Fixed
* Fix avatar sizes
* Fix changing task dates (due/start/end/reminders)
* Fix comments not being loaded again when switching between tasks
* Fix error notification still being shown on password reset pages despite no error
* Fix gantt chart (#79)
* Fix icon overflowing in navigation
* Fix namespace model name showing wrong placeholder until the namespace was loaded
* Fix new related task not being visible in the search field
* Fix not highlighting the current list in menu when paginating
* Fix updating a task with repeat after interval from list view (Fixes #75)
* Use deep imports for importing lodash to make tree shaking easier
* Revert "Use deep imports for importing lodash to make tree shaking easier"
* Work around browsers preventing Vue bindings from working with autofill (Fixes #78)
#### Changed
* Schedule token renew every minute
* Swap moment.js with date-fns
* Change release bucket
## [0.11] - 2020-03-01
### Added
* Add a button to the task detail page to mark a task as done
* Add a link to vikunja.io (#56)
* Add automatic user token renew (#43)
* Add auto save for task edit sidebar
* Add moment.js for date related things (#50)
* Add removing of tasks (#48)
* Add saving task title with ctrl+enter
* Add saving the description with ctrl+enter
* Add slight background change when hovering over a task in the list
* Add Wunderlist migration (#46)
* Task Comments (#66)
* Task Pagination (#38)
* Task Search (#52)
* Task sorting (#39)
* Notifications for task reminders (#57)
* PWA update available notification (#42)
* Set the end date to the same as the due date if a start date was set but no end date
* Show parent tasks in task overview list (#41)
### Fixed
* Fix textarea in task detail view not having a background when focused (#937 in Vikunja)
* Fix "Add a reminder" being shown
* Fix adding a task to an empty list
* Fix a typo (#64)
* Fix changelog version
* Fix changing the right of a list shared with a user
* Fix date handling on task detail page
* Fix drone testing pipeline triggering only when pushing to master and not on prs
* Fix email field type (#58)
* Fix error container at registration page always being displayed
* Fix gravatar url
* Fix height of task add button
* Fix initial dates on task edit sidebar
* Fix label input field breaking in a new line on task detail page
* Fix loading tasks for the first page after navigating to a new list
* Fix not using router links for previous and back buttons
* Fix priority label styling
* Fix reminders not being shown on task detail view on mobile
* Fix task text breaking on list home on mobile
* Fix task title on mobile (#54)
* Fix update notification layout on mobile (#44)
* Fix using the error data prop in components (#53)
* Don't schedule a reminder if the reminder date is in the past
* Don't try to cancel notifications if the browser does not support it
* Only focus inputs if the viewport is large enough (#55)
* Set user menu inactive when logging out
* Show if a related task is done (#49)
### Changed
* Always schedule notification
* Hide the llama from the top on the task detail page
* Improve link share layout
* Load Fonts directly
* Make sure to use date objects everywhere where dealing with dates
* Migration Improvements (#47)
* Move "Next Week" section in menu below "Next Month"
* Move the Vikunja logo to the hamburger menu on mobile
* Preload fonts css
* Rearrange button order on task detail view
* Reorganize Styles (#45)
* Show motd everywhere
* Sort tasks on start page by due date desc and id desc
* Update dependencies (#40)
* Use message mixin for handling success and error messages (#51)
* Use the same method everywhere to calculate the avatar url
* Better default profile image
* Better wording for shared settings
* Bump npm to 6.13
* Put the add reminders button on the task detail page higher up
* Directly link to the task for tasks on the start page
* Disable production source maps
## [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

@ -1,6 +1,18 @@
FROM nginx
# Stage 1: Build application
FROM node:13.14.0 AS compile-image
MAINTAINER maintainers@vikunja.io
WORKDIR /build
COPY . ./
RUN \
# Build the frontend
yarn install --frozen-lockfile && \
echo '{"VERSION": "'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'"}' > src/version.json && \
yarn run build
# Stage 2: copy
FROM nginx
RUN apt-get update && apt-get install -y apt-utils openssl && \
mkdir -p /etc/nginx/ssl && \
@ -8,6 +20,16 @@ RUN apt-get update && apt-get install -y apt-utils openssl && \
openssl req -new -key /etc/nginx/ssl/dummy.key -out /etc/nginx/ssl/dummy.csr -subj "/C=DE/L=Berlin/O=Vikunja/CN=Vikunja Snakeoil" && \
openssl x509 -req -days 3650 -in /etc/nginx/ssl/dummy.csr -signkey /etc/nginx/ssl/dummy.key -out /etc/nginx/ssl/dummy.crt
ADD nginx.conf /etc/nginx/nginx.conf
COPY nginx.conf /etc/nginx/nginx.conf
COPY run.sh /run.sh
COPY dist /usr/share/nginx/html
# copy compiled files from stage 1
COPY --from=compile-image /build/dist /usr/share/nginx/html
# Unprivileged user
ENV PUID 1000
ENV PGID 1000
LABEL maintainer="maintainers@vikunja.io"
CMD "/run.sh"

View File

@ -4,10 +4,12 @@
[![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.8-brightgreen.svg)](https://storage.kolaente.de/minio/vikunja/)
[![Download](https://img.shields.io/badge/download-v0.13-brightgreen.svg)](https://dl.vikunja.io)
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 text/html 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 image/x-icon;
# Expires map
map $sent_http_content_type $expires {
@ -43,6 +43,7 @@ http {
text/css max;
application/javascript max;
~image/ max;
~font/ max;
}
server {

View File

@ -1,7 +1,6 @@
{
"name": "vikunja-frontend",
"version": "0.8.0",
"license": "LGPL-3.0-or-later",
"version": "0.10.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
@ -9,33 +8,43 @@
"lint": "vue-cli-service lint"
},
"dependencies": {
"bulma": "^0.7.1",
"copy-to-clipboard": "^3.2.0",
"lodash": "^4.17.11",
"v-tooltip": "^2.0.0-rc.33",
"verte": "^0.0.12",
"vue": "^2.5.17",
"vue-drag-resize": "^1.3.2"
"bulma": "0.8.2",
"camel-case": "4.1.1",
"copy-to-clipboard": "3.3.1",
"date-fns": "2.14.0",
"lodash": "4.17.15",
"register-service-worker": "1.7.1",
"snake-case": "3.0.3",
"v-tooltip": "2.0.3",
"verte": "0.0.12",
"vue": "2.6.11",
"vue-drag-resize": "1.3.2",
"vue-easymde": "1.2.0",
"vue-smooth-dnd": "0.8.1",
"vuex": "3.4.0"
},
"devDependencies": {
"@fortawesome/fontawesome-svg-core": "^1",
"@fortawesome/free-regular-svg-icons": "^5",
"@fortawesome/free-solid-svg-icons": "^5",
"@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",
"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"
"@fortawesome/fontawesome-svg-core": "1.2.28",
"@fortawesome/free-regular-svg-icons": "5.13.0",
"@fortawesome/free-solid-svg-icons": "5.13.0",
"@fortawesome/vue-fontawesome": "0.1.9",
"@vue/cli": "4.4.1",
"@vue/cli-plugin-babel": "4.4.1",
"@vue/cli-plugin-eslint": "4.4.1",
"@vue/cli-plugin-pwa": "4.4.1",
"@vue/cli-service": "4.4.1",
"axios": "0.19.2",
"babel-eslint": "10.1.0",
"core-js": "3.6.5",
"eslint": "7.1.0",
"eslint-plugin-vue": "6.2.2",
"node-sass": "4.14.1",
"sass-loader": "8.0.2",
"vue-flatpickr-component": "8.1.5",
"vue-multiselect": "2.1.6",
"vue-notification": "1.3.20",
"vue-router": "3.3.1",
"vue-template-compiler": "2.6.11"
},
"eslintConfig": {
"root": true,
@ -60,5 +69,6 @@
"> 1%",
"last 2 versions",
"not ie <= 8"
]
],
"license": "LGPL-3.0-or-later"
}

View File

@ -1,3 +0,0 @@
{
"VIKUNJA_API_BASE_URL": "http://localhost:8080/api/v1/"
}

View File

@ -3,6 +3,7 @@
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 */
@ -17,6 +18,7 @@
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 */
@ -31,6 +33,7 @@
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 */
@ -45,6 +48,7 @@
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 */
@ -59,6 +63,7 @@
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 */
@ -73,6 +78,7 @@
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 */
@ -87,6 +93,7 @@
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 */
@ -101,6 +108,7 @@
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.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 746 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -0,0 +1,149 @@
<?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>

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 174 KiB

View File

@ -1,130 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
id="svg2"
xml:space="preserve"
width="135.53055"
height="88.441666"
viewBox="0 0 135.53055 88.441666"
sodipodi:docname="llama.svg"
inkscape:version="0.92.2 2405546, 2018-03-11"><metadata
id="metadata8"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
id="defs6"><clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath58"><path
d="m 5210.02,5148.46 c -111.47,0 -201.85,62.7 -201.85,140.04 0,77.34 90.38,140.04 201.85,140.04 111.49,0 201.87,-62.7 201.87,-140.04 0,-77.34 -90.38,-140.04 -201.87,-140.04"
id="path56"
inkscape:connector-curvature="0" /></clipPath></defs><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1017"
id="namedview4"
showgrid="false"
inkscape:zoom="2.506291"
inkscape:cx="-17.664601"
inkscape:cy="31.690825"
inkscape:window-x="0"
inkscape:window-y="32"
inkscape:window-maximized="1"
inkscape:current-layer="g12"
fit-margin-left="0"
fit-margin-top="-10"
fit-margin-right="80"
fit-margin-bottom="0" /><g
id="g10"
inkscape:groupmode="layer"
inkscape:label="ink_ext_XXXXXX"
transform="matrix(1.3333333,0,0,-1.3333333,-419.82,446.75205)"><g
id="g12"
transform="scale(0.1)"><g
id="g934"
transform="matrix(-0.15928768,0,0,0.15928768,4284.5056,2152.9319)"><path
inkscape:connector-curvature="0"
id="path44"
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 7120.99,6297.14 c -33.68,263.91 -141.27,339.14 -141.27,339.14 184.45,98.16 155.97,374.2 101.59,477.89 -34.26,65.34 -81.51,67.58 -81.51,67.58 167.1,246.94 -31.21,422.35 -31.21,422.35 43.13,139.67 55.37,277.44 38.1,385.9 H 4535.67 c -14.3,-39.21 -29.97,-109.86 -9.99,-234.71 41.47,-259.2 124.99,-364.58 143.14,-384.52 0.15,-0.93 -157.72,-232.39 -143.14,-439.44 23.31,-331.26 109.87,-379.53 109.87,-379.53 -166.47,-226.39 -54.03,-460.77 9.98,-539.32 1.37,-28.23 -88.55,-335.44 -24.97,-609.24 21.15,-91.08 68.39,-349.23 78.23,-362.38 -9.99,-31.64 -69.14,-144.83 24.96,-370.26 39.13,-93.73 94.22,-171.45 131.91,-218.55 16.44,-39.6 27.38,-63.94 27.38,-63.94 -158.41,-511.94 -70.23,-1040.11 16.44,-1033.13 163.96,13.22 389.98,619.48 406.15,660 6.27,15.67 177.15,28.5 367.55,42.01 176.7,-13.53 358.19,-23.16 453.13,-8.02 118.39,18.89 219.1,54.31 301.85,92.61 113.73,16.27 156.16,19.5 162.81,6.08 37.29,-75.51 156.81,-604.79 371.31,-735.45 114.61,-69.81 260.28,825.01 -25.35,1102.71 0,0 -20.75,83.7 -32.32,152.85 82.07,179.84 6.37,422.4 6.37,422.4 339.1,140.77 38.42,642.11 38.42,642.11 75.3,81.52 220.5,181.35 171.59,564.86" /><path
inkscape:connector-curvature="0"
id="path46"
style="fill:#fef2e2;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 6930.63,3617.98 c 69.17,-120.79 222.95,452.24 -73.4,732.36 -72.64,68.66 -74.73,-48.85 -23.97,-387.94 17.38,-116.24 47.34,-257.08 97.37,-344.42" /><path
inkscape:connector-curvature="0"
id="path48"
style="fill:#fef2e2;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 4967.59,3559.34 c -75.73,-116.77 -197.69,463.85 113.64,727.21 76.33,64.55 71.92,-52.9 2.56,-388.66 -23.76,-115.11 -61.45,-254.07 -116.2,-338.55" /><path
inkscape:connector-curvature="0"
id="path50"
style="fill:#fef2e2;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 5044.21,4722.96 c 190.89,-197.02 760.78,-149.78 760.78,-149.78 0,0 568.13,-64.99 765.13,125.98 0,0 624.48,1391.42 -740.95,1422.38 -1365.78,11.66 -784.96,-1398.58 -784.96,-1398.58" /><g
id="g52"><g
clip-path="url(#clipPath58)"
id="g54"><path
inkscape:connector-curvature="0"
id="path60"
style="fill:#fee8de;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 5008.17,5148.46 h 403.719 v 280.078 H 5008.17 Z" /></g></g><path
inkscape:connector-curvature="0"
id="path62"
style="fill:#fee8de;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 6389.08,5172.12 c -111.45,0 -201.82,62.69 -201.82,140.04 0,77.35 90.37,140.04 201.82,140.04 111.51,0 201.89,-62.69 201.89,-140.04 0,-77.35 -90.38,-140.04 -201.89,-140.04" /><path
inkscape:connector-curvature="0"
id="path64"
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 5385.11,5039.99 c 0.58,37.09 30.86,66.79 68.16,66.2 37.04,-0.58 66.79,-31.21 66.22,-68.29 -0.56,-37.13 -31.74,-94.9 -68.76,-94.34 -37.29,0.57 -66.21,59.32 -65.62,96.43" /><path
inkscape:connector-curvature="0"
id="path66"
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 6110.71,5055.78 c 0.56,37.11 31,66.72 68.13,66.13 37.12,-0.56 66.84,-31.13 66.29,-68.23 -0.59,-37.18 -31.71,-94.89 -68.82,-94.32 -37.14,0.57 -66.21,59.24 -65.6,96.42" /><path
inkscape:connector-curvature="0"
id="path68"
style="fill:#eedbcc;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 5230.04,5625.36 c -2.72,-173.65 264.28,-378.16 549.42,-382.61 285.03,-4.44 542.05,186.43 544.77,360.1 1.42,92.58 -82.23,138.83 -180,206.25 -99.28,68.51 -203.35,222.28 -360.56,224.74 -157.91,2.43 -266.72,-149.26 -368.51,-214.58 -98.62,-63.26 -183.72,-103.97 -185.12,-193.9" /><path
inkscape:connector-curvature="0"
id="path70"
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 4941.64,4368.85 c 6.49,-59.53 53.48,-110.16 111.92,-107.68 -30.49,-49 -50.38,-160.19 51.94,-209.5 63.24,-30.55 137.82,24.87 137.82,24.87 0,0 11.93,-127.5 149.64,-152.12 146.17,-26.17 224.81,117.65 224.81,117.65 -1.79,-114.55 193.55,-182.57 301.74,-163.6 132.99,23.25 196.44,119.87 196.44,119.87 262.08,-159.82 361.21,90.36 361.21,90.36 57.96,-141.1 266.81,-97.6 331.86,-12.31 83.44,109.31 -14.76,201.95 -14.76,201.95 0,0 180.24,122.43 101.2,264.61 -89.16,160.5 -259.46,61.6 -259.46,61.6 -67.81,340.22 -332.82,43.64 -332.82,43.64 -19.14,158.61 -88.7,295.17 -288.6,249.37 -137.55,-31.52 -180.31,-211.76 -180.31,-211.76 -48.06,185.24 -191.87,159 -246.55,105.47 -34.46,-33.75 -36.35,-80.98 -36.35,-80.98 -125.63,169.08 -219.81,-27.75 -219.81,-27.75 -110.6,68.11 -220.24,63.44 -276.17,-11.02 -31.63,-42.08 -40.69,-89.4 -24.48,-167.23 -46.06,-13.04 -98.36,-52.03 -89.27,-135.44" /><path
inkscape:connector-curvature="0"
id="path72"
style="fill:#fef2e2;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 6739.36,4325.68 c 6.1,1.83 12.07,4.1 16.21,6.6 4.52,2.94 8.52,5.93 11.3,9.83 2.88,3.67 5.13,7.56 6.34,12.07 1.45,4.29 2.29,8.95 2.72,13.7 0.41,9.59 -0.3,19.76 -2.94,29.68 -1.29,4.96 -2.58,9.9 -4.3,14.75 -1.82,4.82 -3.9,9.5 -6.2,14.05 -9.29,17.96 -19.86,35.15 -36.44,45.33 -4.17,2.36 -8.76,4.23 -13.72,5.35 -4.93,0.78 -10.2,1.34 -15.5,0.52 -5.23,-0.99 -10.74,-1.93 -15.77,-4.43 -5.3,-1.87 -9.93,-5.36 -14.76,-8.31 l -12.63,-7.7 2.07,14.82 c 1.22,8.63 2.85,17.17 3.6,25.79 0.31,8.61 -0.98,17.21 -1.57,25.6 -1.83,8.27 -4.6,16.23 -6.84,24 -4.04,7.21 -6.97,14.68 -11.43,21.07 -5.06,6.05 -8.36,13 -14.29,17.93 -2.67,2.65 -5.32,5.31 -7.81,8.04 -2.89,2.38 -6.13,4.35 -9.1,6.57 -5.61,4.9 -12.87,7.45 -19.44,10.83 -6.42,3.72 -14.39,4.48 -21.3,7.08 -7.32,1.79 -15.04,2.36 -22.5,4.08 -7.77,0.25 -15.52,0.72 -23.36,1.3 -7.97,-0.54 -15.92,-1.11 -24.04,-1.24 l -24.36,-3.91 c -8.25,-1.22 -16.21,-4.13 -24.52,-6.04 -8.51,-1.49 -16.31,-5.1 -24.62,-7.79 -8.31,-2.73 -16.81,-5.4 -24.85,-9.17 -8.15,-3.55 -16.65,-6.82 -25.43,-10.02 l -2.72,3.59 c 6.09,6.93 12.46,13.77 19.17,20.33 6.4,7.03 14.05,12.44 21.62,18.12 7.69,5.56 15.26,11.45 23.87,15.7 8.59,4.25 16.94,9.25 26.03,12.75 9.19,3.26 18.48,6.38 27.95,9.27 9.73,2.01 19.64,3.55 29.56,5.17 10.12,0.2 20.36,0.41 30.54,-0.04 10.21,-1.73 20.49,-3.12 30.47,-6.02 9.7,-4.14 19.89,-6.65 28.83,-12.49 9.13,-5.4 18.24,-10.63 25.77,-18.27 3.88,-3.6 7.94,-6.97 11.53,-10.8 l 9.52,-12.61 c 6.95,-7.97 10.18,-18.26 14.5,-27.49 3.74,-9.56 4.75,-19.94 7,-29.53 0.27,-10.01 0.87,-19.82 0.46,-29.4 -1.24,-9.58 -1.98,-19.04 -3.31,-28.3 -2.38,-9.12 -3.87,-18.28 -5.77,-27.37 l -10.54,7.12 c 10.54,7.14 21.49,14.08 34.37,18.57 6.45,2.07 13.23,3.82 20.37,4.44 7.13,0.45 14.51,0.24 21.78,-1.22 14.58,-2.89 28.12,-10.23 38.98,-19.76 5.33,-4.9 10.2,-10.19 14.47,-15.87 4.2,-5.75 7.67,-11.87 10.7,-18.2 5.63,-12.79 9.53,-26.21 11.18,-40.12 0.61,-6.98 0.76,-14.02 0.34,-21.11 -0.42,-7.11 -1.77,-14.28 -3.98,-21.26 -2.48,-6.98 -5.24,-13.97 -9.91,-20.13 -4.33,-6.24 -10.2,-11.62 -16.66,-15.53 -6.59,-3.82 -13.73,-5.89 -20.71,-6.61 -3.63,-0.02 -7.26,-0.14 -10.5,0.16 l -9.16,2.04 -0.27,4.49" /><path
inkscape:connector-curvature="0"
id="path74"
style="fill:#fef2e2;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 5865.09,4113.39 5.66,-7.51 c 1.62,-2.81 3,-6.16 4.48,-9.48 2.23,-6.65 3.32,-14.01 2.6,-21.6 -0.88,-7.49 -3.37,-15.07 -7.23,-21.59 -3.67,-6.79 -8.9,-12.21 -14.2,-17.36 -5.46,-4.92 -11.39,-9.09 -17.72,-12.45 -6.28,-3.31 -12.73,-6.11 -19.34,-8.42 -13.35,-4.28 -27.15,-6.3 -41.17,-6.48 -6.99,0.16 -14.01,0.78 -20.98,2.18 -6.96,1.55 -13.78,3.81 -20.45,6.61 -13.19,5.96 -25.46,15.22 -34.14,27.3 -4.32,6.04 -7.57,12.64 -10.12,19.31 -2.4,6.79 -3.59,13.67 -4.38,20.41 -1.25,13.57 0.53,26.41 2.64,38.97 l 10.87,-6.63 c -7.49,-5.55 -15.22,-10.69 -22.53,-16.63 -7.87,-5.02 -16.17,-9.65 -24.34,-14.76 -8.57,-4.35 -17.76,-7.86 -26.96,-11.76 -9.68,-1.95 -19.56,-5.35 -29.77,-5.89 -10.23,0.1 -20.91,-1.23 -31.06,1.79 l -15.43,3.41 c -4.98,1.7 -9.71,3.98 -14.61,6.03 -10.06,3.67 -18.61,9.79 -27.31,15.84 -9,5.72 -15.52,13.97 -23.33,21.07 -6.78,7.88 -12.29,16.66 -18.12,25.24 -4.63,9.07 -8.68,18.49 -12.7,27.78 -2.66,9.69 -5.35,19.35 -7.55,29.02 -1.31,9.82 -2.32,19.59 -3.17,29.3 -0.55,9.72 0.49,19.41 0.83,28.97 0.3,9.6 2.52,18.94 4.38,28.23 2.04,9.26 3.79,18.43 7.54,27.19 3.18,8.84 6.75,17.48 10.54,25.88 l 4.4,-0.97 c 0.72,-9.32 1.27,-18.41 1.41,-27.31 -0.08,-8.89 1,-17.71 1.97,-26.41 1,-8.68 0.96,-17.28 3.14,-25.65 1.68,-8.35 2.34,-16.8 4.63,-24.82 l 6.57,-23.77 c 3.25,-7.45 6.02,-14.95 8.83,-22.4 3.78,-6.88 7.44,-13.79 10.86,-20.73 4.64,-6.05 8.39,-12.86 13.06,-18.77 5.23,-5.23 9.22,-12.17 15.27,-16.46 5.8,-4.59 11.16,-10.11 17.95,-13.19 3.24,-1.79 6.37,-3.94 9.75,-5.56 l 10.56,-3.79 c 6.92,-3.33 14.64,-3.44 22.21,-5.55 7.69,-1.41 15.68,-0.96 23.94,-1.64 8.01,1.17 16.4,1.95 24.68,3.75 7.87,2.92 16.25,5.32 23.94,9.2 7.53,4.25 14.64,9.27 21.97,13.93 l 12.64,8.03 -1.78,-14.66 c -0.68,-5.62 -1.93,-11.28 -1.46,-16.86 -0.18,-5.62 1.25,-11.04 2.52,-16.19 1.44,-5.19 4.16,-9.73 6.88,-13.93 3.09,-4.03 6.69,-7.41 10.58,-10.26 16.11,-10.88 36.13,-13.38 56.33,-14.38 5.09,-0.21 10.2,-0.17 15.34,0.19 5.15,0.44 10.18,1.32 15.21,2.22 10.14,1.67 19.67,5.27 28.26,9.6 4.1,2.35 8,5.06 11.32,8.15 3.6,2.96 6.2,6.63 8.37,10.81 2.39,4.13 3.43,9.01 4.23,14.35 0.57,4.8 0.16,11.16 -0.72,17.46 l 4.21,1.64" /><path
inkscape:connector-curvature="0"
id="path76"
style="fill:#fef2e2;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 5271.78,4582.82 c 0,0 -4.4,1.07 -12.08,2.99 -3.9,1.3 -8.55,1.53 -13.48,1.57 -2.46,0.1 -5.03,0.24 -7.8,0.33 -2.83,-0.4 -5.84,-0.84 -8.96,-1.28 -6.18,-0.39 -12.65,-2.73 -19.34,-4.92 -6.68,-2.11 -13.39,-5.81 -20.16,-9.1 -6.45,-4.33 -13.28,-8.1 -19.34,-13.45 -6.52,-4.64 -12.08,-10.63 -17.7,-16.3 -5.05,-6.17 -10.17,-12.41 -13.55,-19.3 -1.8,-3.39 -3.48,-6.74 -5.36,-9.87 -1.27,-3.41 -2.86,-6.53 -4.36,-9.61 -3.47,-5.96 -5.51,-11.92 -8.57,-17.36 -1.58,-5.56 -5.14,-10.98 -5.86,-16.15 -0.57,-2.62 -1.47,-5.21 -2.33,-7.79 -0.53,-2.5 -0.38,-4.86 -0.9,-7.26 -0.81,-4.83 -1.52,-9.42 -1.09,-13.09 0.2,-7.58 -0.04,-13.08 -0.04,-13.08 l -3.8,-2.41 c 0,0 -4.22,2.56 -10.93,9.06 -3.8,3.12 -6.76,7.74 -9.78,13.67 -3.5,5.85 -6.01,13 -7.1,21.24 -2.18,8.42 -1.21,17.17 -0.43,26.79 1.76,9.05 4.24,19.01 9.09,27.66 2.54,4.25 5.04,8.62 7.99,12.81 3.16,3.89 6.76,7.51 10.18,11.26 6.87,7.45 15,13.6 22.6,20.01 8.11,5.89 16.05,11.79 24.82,16.12 8.24,5.14 17.57,8.01 26.06,11.33 8.99,2.28 17.46,5.05 25.95,5.58 8.49,0.68 16.29,1.36 23.66,0.18 3.6,-0.37 7.05,-0.73 10.32,-1.08 3.31,-0.94 6.44,-1.85 9.38,-2.69 5.89,-1.56 10.58,-3.2 13.78,-5.5 6.86,-3.96 10.8,-6.21 10.8,-6.21 l -1.67,-4.15" /><path
inkscape:connector-curvature="0"
id="path78"
style="fill:#fef2e2;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 6369.15,4263.8 c 0,0 1.45,-4.27 3.98,-11.78 1.64,-3.78 2.26,-8.38 2.71,-13.3 0.31,-2.44 0.65,-4.99 0.98,-7.75 -0.16,-2.86 -0.35,-5.89 -0.53,-9.03 0.13,-6.18 -1.68,-12.83 -3.28,-19.7 -1.54,-6.81 -4.68,-13.81 -7.39,-20.83 -3.78,-6.79 -6.95,-13.91 -11.78,-20.42 -4.08,-6.87 -9.57,-12.89 -14.77,-18.99 -5.72,-5.55 -11.51,-11.15 -18.09,-15.11 -3.23,-2.09 -6.45,-4.02 -9.4,-6.15 -3.29,-1.56 -6.26,-3.41 -9.21,-5.17 -5.63,-3.93 -11.42,-6.47 -16.59,-9.98 -5.41,-2.03 -10.52,-6.05 -15.6,-7.18 -2.54,-0.8 -5.07,-1.92 -7.55,-2.99 -2.44,-0.74 -4.82,-0.78 -7.17,-1.5 -4.74,-1.21 -9.26,-2.3 -12.95,-2.16 -7.57,-0.45 -13.03,-1.15 -13.03,-1.15 l -2.09,-4 c 0,0 2.91,-3.98 9.93,-10.13 3.43,-3.53 8.29,-6.08 14.47,-8.6 6.1,-3.01 13.43,-4.92 21.76,-5.31 8.54,-1.46 17.2,0.24 26.72,1.81 8.86,2.52 18.59,5.84 26.8,11.38 4.02,2.89 8.16,5.75 12.1,9.03 3.61,3.47 6.92,7.35 10.37,11.1 6.86,7.47 12.3,16.07 18.06,24.19 5.18,8.56 10.39,16.97 13.97,26.08 4.44,8.63 6.52,18.18 9.11,26.92 1.53,9.15 3.58,17.83 3.4,26.33 -0.04,8.49 0,16.33 -1.81,23.59 -0.67,3.55 -1.31,6.96 -1.94,10.18 -1.21,3.24 -2.38,6.26 -3.45,9.13 -2.05,5.74 -4.08,10.28 -6.65,13.27 -4.51,6.51 -7.09,10.25 -7.09,10.25 l -3.99,-2.03" /><path
inkscape:connector-curvature="0"
id="path80"
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 5769.92,5366.76 c 95.55,-0.97 278.19,1.35 244.67,74.19 -18.61,40.39 -116.92,93.83 -242.97,96.85 -126.07,-0.51 -225.44,-51.97 -244.85,-91.99 -34.88,-72.14 147.62,-78.11 243.15,-79.05" /><path
inkscape:connector-curvature="0"
id="path82"
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 5735.72,5520.16 c -10.07,82.73 12.09,161.3 85.42,207 80.86,50.42 187.23,18.01 263.41,-23.73 28.24,-15.47 3.04,-58.59 -25.2,-43.11 -60.87,33.33 -127.19,53.65 -195.43,31.99 -73.89,-23.44 -86.25,-106.33 -78.26,-172.15 3.89,-31.92 -46.09,-31.54 -49.94,0" /><path
inkscape:connector-curvature="0"
id="path84"
style="fill:#231f20;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 5723.46,5537.13 c 1.54,57.24 -22.39,111.21 -69.34,145.16 -69.71,50.41 -140.71,-6.31 -184.18,-61.67 -19.88,-25.32 -54.94,10.29 -35.3,35.31 52.39,66.75 136.72,124.18 223.06,81.37 75.02,-37.19 117.92,-117.58 115.7,-200.17 -0.87,-32.13 -50.8,-32.22 -49.94,0" /></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -1,11 +1,23 @@
<!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">
<title>Vikunja</title>
<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">
</head>
<body>
<noscript>
@ -13,5 +25,13 @@
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
<script>
//
// This variable points the frontend to the api.
// It has to be the full url, including the last /api/v1 part and port.
// You can change this if your api is not reachable on the same port as the frontend.
window.API_URL = 'http://localhost:3456/api/v1'
//
</script>
</body>
</html>

2
public/robots.txt Normal file
View File

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

16
renovate.json Normal file
View File

@ -0,0 +1,16 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"labels": ["dependencies"],
"hostRules": [
{
"domainName": "github.com",
"encrypted": {
"token": "jaazUwAHa7jio3jq+UFvUeVR5/fOwt3BNAzEBlhixggSYqooJ7Paq7aJ77mNHYBEYwCWFuUG3+SlQ/uLeMU0AtGNdxk9VQ2mbcZIpSbIPR2YU4NoQ0HrhL0XyrN6eShqLBQYKz47o3gaHd3ltWhVeMCxGjfoAlPw+z0DhUQCfuFWUJu3lYYNIhY+CVeir6r0s0AablpxMJ1kpak6fCQ6BdaOW11rC/bQfW82fAp4Pkv877AolB+fVU7klMXfU6d2Ihk343jOEvltI5g1l5ss0vjiJnGZh4Sxump0ivoc73/P1TnywKTrvEdWs9df42IUAZozJwfqAIOUCtWbZifEXg=="
}
}
],
"extends": [
"config:base"
]
}

18
run.sh Executable file
View File

@ -0,0 +1,18 @@
#!/bin/bash
# This shell script sets the api url based on an environment variable and starts nginx in foreground.
if [ -z "$VIKUNJA_API_URL" ]; then
VIKUNJA_API_URL="/api/v1"
fi
# Escape the variable to prevent sed from complaining
VIKUNJA_API_URL=$(echo $VIKUNJA_API_URL |sed 's/\//\\\//g')
sed -i "s/http\:\/\/localhost\:3456\/api\/v1/$VIKUNJA_API_URL/g" /usr/share/nginx/html/index.html
# Set the uid and gid of the nginx run user
usermod --non-unique --uid ${PUID} nginx
groupmod --non-unique --gid ${PGID} nginx
nginx -g "daemon off;"

View File

@ -1,233 +1,371 @@
<template>
<div id="app">
<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">
<div>
<div v-if="online">
<!-- 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="userAuthenticated && (userInfo && userInfo.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="userInfo.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">{{userInfo.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>
</button>
</div>
<transition name="fade">
<div class="dropdown-menu" v-if="userMenuActive">
<div class="dropdown-content">
<router-link :to="{name: 'userSettings'}" class="dropdown-item">
Settings
</router-link>
<a @click="logout()" class="dropdown-item">
Logout
</a>
</div>
</div>
</transition>
</div>
</div>
</div>
</nav>
<div v-if="userAuthenticated && (userInfo && userInfo.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: 'week'}}">
<span class="icon">
<icon icon="calendar-week"/>
</span>
Next Week
</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: '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">
<fancycheckbox v-model="showArchived" class="show-archived-check">
Show Archived
</fancycheckbox>
<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.title + ' 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.title + ' (' + n.lists.length + ')'" :for="n.id + 'checker'">
<span class="name">
<span class="color-bubble" v-if="n.hexColor !== ''" :style="{ backgroundColor: n.hexColor }"></span>
{{n.title}} ({{n.lists.length}})
</span>
<span class="is-archived" v-if="n.isArchived">
Archived
</span>
</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: 'list.index', params: { listId: l.id} }" :class="{'router-link-exact-active': currentList === l.id}">
<span class="name">
<span class="color-bubble" v-if="l.hexColor !== ''" :style="{ backgroundColor: l.hexColor }"></span>
{{l.title}}
</span>
<span class="is-archived" v-if="l.isArchived">
Archived
</span>
</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/>
</transition>
</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">
<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>
<div v-else-if="userAuthenticated && (userInfo && userInfo.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>
</a>
</div>
<router-view/>
</div>
<router-view/>
</div>
</div>
</div>
</div>
<div v-else>
<div class="container has-text-centered">
<div class="column is-4 is-offset-4">
<div v-else>
<div class="noauth-container">
<img src="/images/logo-full.svg" alt="Vikunja"/>
<router-view/>
<div class="message is-info" v-if="motd !== ''">
<div class="message-header">
<p>Info</p>
</div>
<div class="message-body">
{{ motd }}
</div>
</div>
<router-view/>
</div>
</div>
<notification/>
</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" />
</div>
</template>
<script>
import auth from './auth'
import message from './message'
import router from './router'
import {mapState} from 'vuex'
import NamespaceService from './services/namespace'
import authTypes from './models/authTypes'
import swEvents from './ServiceWorker/events'
import Notification from './components/global/notification'
import Fancycheckbox from './components/global/fancycheckbox'
import {CURRENT_LIST, IS_FULLPAGE, ONLINE} from './store/mutation-types'
export default {
name: 'app',
components: {
Fancycheckbox,
Notification,
},
data() {
return {
user: auth.user,
namespaces: [],
namespaceService: NamespaceService,
mobileMenuActive: false,
fullpage: false,
currentDate: new Date(),
userMenuActive: false,
authTypes: authTypes,
showArchived: false,
// Service Worker stuff
updateAvailable: false,
registration: null,
refreshing: false,
}
},
beforeMount() {
// Check if the user is offline, show a message then
this.$store.commit(ONLINE, navigator.onLine)
window.addEventListener('online', () => this.$store.commit(ONLINE, navigator.onLine));
window.addEventListener('offline', () => this.$store.commit(ONLINE, 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'})
}
},
beforeCreate() {
this.$store.dispatch('config/update')
this.$store.dispatch('auth/checkAuth')
.then(() => {
// Check if the user is already logged in, if so, redirect them to the homepage
if (
!this.userAuthenticated &&
this.$route.name !== 'login' &&
this.$route.name !== 'getPasswordReset' &&
this.$route.name !== 'passwordReset' &&
this.$route.name !== 'register' &&
this.$route.name !== 'linkShareAuth'
) {
router.push({name: 'login'})
}
if (this.userAuthenticated && this.userInfo.type === authTypes.USER && (this.$route.params.name === 'home' || this.namespaces.length === 0)) {
this.loadNamespaces()
}
})
},
created() {
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 minute
setTimeout(() => {
this.$store.dispatch('auth/renewToken')
}, 1000 * 60)
},
watch: {
// call the method again if the route changes
'$route': 'doStuffAfterRoute'
'$route': 'doStuffAfterRoute',
},
computed: mapState({
userInfo: state => state.auth.info,
userAuthenticated: state => state.auth.authenticated,
motd: state => state.config.motd,
online: ONLINE,
fullpage: IS_FULLPAGE,
namespaces(state) {
return state.namespaces.namespaces.filter(n => this.showArchived ? true : !n.isArchived)
},
currentList: CURRENT_LIST,
}),
methods: {
logout() {
auth.logout()
},
gravatar() {
return 'https://www.gravatar.com/avatar/' + this.user.infos.avatar + '?s=50'
this.$store.dispatch('auth/logout')
router.push({name: 'login'})
},
loadNamespaces() {
this.namespaceService = new NamespaceService()
this.namespaceService.getAll()
.then(r => {
this.$set(this, 'namespaces', r)
})
.catch(e => {
message.error(e, this)
})
this.$store.dispatch('namespaces/loadNamespaces')
},
loadNamespacesIfNeeded(e){
if (auth.user.authenticated && auth.user.infos.type === authTypes.USER && (e.name === 'home' || this.namespaces.length === 0)) {
loadNamespacesIfNeeded(e) {
if (this.userAuthenticated && (this.userInfo && this.userInfo.type === authTypes.USER) && (e.name === 'home' || this.namespaces.length === 0)) {
this.loadNamespaces()
}
},
doStuffAfterRoute(e) {
this.fullpage = false;
if (this.$store.state[IS_FULLPAGE]) {
this.$store.commit(IS_FULLPAGE, false)
}
this.loadNamespacesIfNeeded(e)
this.mobileMenuActive = false
this.userMenuActive = false
// Reset the current list highlight in menu if the current list is not list related.
if (
this.$route.name === 'home' ||
this.$route.name === 'editNamespace' ||
this.$route.name === 'listTeams' ||
this.$route.name === 'editTeam' ||
this.$route.name === 'showTasksInRange' ||
this.$route.name === 'listLabels' ||
this.$route.name === 'migrateStart' ||
this.$route.name === 'migrate.wunderlist' ||
this.$route.name === 'userSettings'
) {
this.$store.commit(CURRENT_LIST, 0)
}
},
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');
},
},
}

View File

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

118
src/ServiceWorker/sw.js Normal file
View File

@ -0,0 +1,118 @@
/* 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,127 +0,0 @@
import {HTTP} from '../http-common'
import router from '../router'
// const API_URL = 'http://localhost:8082/api/v1/'
// const LOGIN_URL = 'http://localhost:8082/login'
export default {
user: {
authenticated: false,
infos: {},
},
login(context, creds, redirect) {
localStorage.removeItem('token') // Delete an eventually preexisting old token
HTTP.post('login', {
username: creds.username,
password: creds.password
})
.then(response => {
// Save the token to local storage for later use
localStorage.setItem('token', response.data.token)
// Tell others the user is autheticated
this.user.authenticated = true
this.user.isLinkShareAuth = false
const inf = this.getUserInfos()
// eslint-disable-next-line
console.log(inf)
// Hide the loader
context.loading = false
// Redirect if nessecary
if (redirect) {
router.push({name: redirect})
}
})
.catch(e => {
// Hide the loader
context.loading = false
if (e.response) {
context.error = e.response.data.message
if (e.response.status === 401) {
context.error = 'Wrong username or password.'
}
}
})
},
register(context, creds, redirect) {
HTTP.post('register', {
username: creds.username,
email: creds.email,
password: creds.password
})
.then(() => {
this.login(context, creds, redirect)
})
.catch(e => {
// Hide the loader
context.loading = false
if (e.response) {
context.error = e.response.data.message
if (e.response.status === 401) {
context.error = 'Wrong username or password.'
}
}
})
},
logout() {
localStorage.removeItem('token')
router.push({name: 'login'})
this.user.authenticated = false
},
linkShareAuth(hash) {
return HTTP.post('/shares/'+hash+'/auth')
.then(r => {
localStorage.setItem('token', r.data.token)
this.getUserInfos()
return Promise.resolve(r.data)
}).catch(e => {
return Promise.reject(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 (infos.exp >= ts) {
this.user.authenticated = true
}
}
},
getUserInfos() {
let jwt = localStorage.getItem('token')
if (jwt) {
this.user.infos = this.parseJwt(localStorage.getItem('token'))
return this.parseJwt(localStorage.getItem('token'))
} else {
return {}
}
},
parseJwt(token) {
let base64Url = token.split('.')[1]
let base64 = base64Url.replace('-', '+').replace('_', '/')
return JSON.parse(window.atob(base64))
},
getAuthHeader() {
return {
'Authorization': 'Bearer ' + localStorage.getItem('token')
}
},
getToken() {
return localStorage.getItem('token')
}
}

12
src/components/404.vue Normal file
View File

@ -0,0 +1,12 @@
<template>
<div class="content has-text-centered">
<h1>Not found</h1>
<p>The page you requested does not exist.</p>
</div>
</template>
<script>
export default {
name: '404'
}
</script>

View File

@ -1,35 +1,38 @@
<template>
<div class="content has-text-centered">
<h2>Hi {{user.infos.username}}!</h2>
<h2>Hi {{userInfo.username}}!</h2>
<p>Click on a list or namespace on the left to get started.</p>
<TaskOverview :show-all="true"/>
<router-link
class="button is-primary is-right noshadow is-outlined"
:to="{name: 'migrateStart'}"
v-if="migratorsEnabled"
>
Import your data into Vikunja
</router-link>
<ShowTasks :show-all="true"/>
</div>
</template>
<script>
import auth from '../auth'
import router from '../router'
import {mapState} from 'vuex'
import ShowTasks from './tasks/ShowTasks'
export default {
name: "Home",
name: 'Home',
components: {
ShowTasks,
},
data() {
return {
user: auth.user,
loading: false,
currentDate: new Date(),
tasks: []
}
},
beforeMount() {
// Check if the user is already logged in, if so, redirect him to the homepage
if (!auth.user.authenticated) {
router.push({name: 'login'})
}
},
methods: {
logout() {
auth.logout()
},
},
computed: mapState({
migratorsEnabled: state => state.config.availableMigrators !== null && state.config.availableMigrators.length > 0,
authenticated: state => state.auth.authenticated,
userInfo: state => state.auth.info,
}),
}
</script>

View File

@ -0,0 +1,96 @@
<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,
placeholder: 'Click here to enter a description...',
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

@ -0,0 +1,58 @@
<template>
<div class="fancycheckbox" :class="{'is-disabled': disabled}">
<input @change="updateData" type="checkbox" :id="checkBoxId" :checked="checked" style="display: none;" :disabled="disabled">
<label :for="checkBoxId" 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>
<span>
<slot></slot>
</span>
</label>
</div>
</template>
<script>
export default {
name: 'fancycheckbox',
data() {
return {
checked: false,
checkBoxId: '',
}
},
props: {
value: {
required: true,
},
disabled: {
type: Boolean,
required: false,
default: false,
},
},
watch: {
value(newVal) {
this.checked = newVal
},
},
mounted() {
this.checked = this.value
},
created() {
this.checkBoxId = 'fancycheckbox' + Math.random()
},
methods: {
updateData(e) {
this.checked = e.target.checked
this.$emit('input', this.checked)
this.$emit('change', e.target.checked)
},
},
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,49 @@
<template>
<notifications position="bottom left">
<template slot="body" slot-scope="props">
<div :class="['vue-notification-template', 'vue-notification', props.item.type]" @click="close(props)">
<div
v-if="props.item.title"
class="notification-title"
v-html="props.item.title"
>
</div>
<div
class="notification-content"
v-html="props.item.text"
>
</div>
<div class="buttons is-right" v-if="props.item.data && props.item.data.actions && props.item.data.actions.length > 0">
<button
class="button noshadow is-small"
@click="action.callback"
v-for="(action, i) in props.item.data.actions" :key="'action_'+i">
{{ action.title }}
</button>
</div>
</div>
</template>
</notifications>
</template>
<script>
export default {
name: 'notification',
methods: {
close(props) {
props.close()
},
},
}
</script>
<style scoped>
.vue-notification {
z-index: 9999;
}
.buttons {
margin-top: .5em;
}
</style>

View File

@ -0,0 +1,52 @@
<template>
<div class="user" :class="{'is-inline': isInline}">
<img :src="user.getAvatarUrl(avatarSize)" class="avatar" alt="" v-tooltip="user.username" :width="avatarSize" :height="avatarSize"/>
<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,
},
isInline: {
required: false,
type: Boolean,
default: false,
},
},
}
</script>
<style lang="scss" scoped>
.user {
margin: .5em;
&.is-inline {
display: inline;
}
img {
-webkit-border-radius: 100%;
-moz-border-radius: 100%;
border-radius: 100%;
vertical-align: middle;
margin-right: .5em;
}
}
</style>

View File

@ -3,26 +3,30 @@
<h1>Manage labels</h1>
<p>
Click on a label to edit it.
You can edit all labels you created, you can use all lables which are associated with a task to whose list you have access.
You can edit all labels you created, you can use all labels which are associated with a task to whose list
you have access.
</p>
<div class="columns">
<div class="labels-list column">
<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
v-for="l in labels" :key="l.id"
class="tag"
:class="{'disabled': userInfo.id !== l.createdBy.id}"
:style="{'background': l.hexColor, 'color': l.textColor}"
>
<span
v-if="user.infos.id !== l.created_by.id"
v-tooltip.bottom="'You are not allowed to edit this label because you dont own it.'">
v-if="userInfo.id !== l.createdBy.id"
v-tooltip.bottom="'You are not allowed to edit this label because you dont own it.'">
{{ l.title }}
</span>
<span v-else>{{ l.title }}</span>
<a class="delete is-small" @click="deleteLabel(l)" v-if="user.infos.id === l.created_by.id"></a>
</a>
<a
@click="editLabel(l)"
:style="{'color': l.textColor}"
v-else>
{{ l.title }}
</a>
<a class="delete is-small" @click="deleteLabel(l)" v-if="userInfo.id === l.createdBy.id"></a>
</span>
</div>
<div class="column is-4" v-if="isLabelEdit">
<div class="card">
@ -30,9 +34,9 @@
<span class="card-header-title">
Edit Label
</span>
<a class="card-header-icon" @click="isTaskEdit = false">
<a class="card-header-icon" @click="isLabelEdit = false">
<span class="icon">
<icon icon="angle-right"/>
<icon icon="times"/>
</span>
</a>
</header>
@ -41,20 +45,27 @@
<div class="field">
<label class="label">Title</label>
<div class="control">
<input class="input" type="text" placeholder="Label title" v-model="labelEditLabel.title"/>
<input
class="input"
type="text"
placeholder="Label title"
v-model="labelEditLabel.title"/>
</div>
</div>
<div class="field">
<label class="label">Description</label>
<div class="control">
<textarea class="textarea" placeholder="Label description" v-model="labelEditLabel.description"></textarea>
<textarea
class="textarea"
placeholder="Label description"
v-model="labelEditLabel.description"></textarea>
</div>
</div>
<div class="field">
<label class="label">Color</label>
<div class="control">
<verte
v-model="labelEditLabel.hex_color"
v-model="labelEditLabel.hexColor"
menuPosition="top"
picker="square"
model="hex"
@ -65,12 +76,15 @@
</div>
<div class="field has-addons">
<div class="control is-expanded">
<button type="submit" class="button is-fullwidth is-success" :class="{ 'is-loading': labelService.loading}">
<button type="submit" class="button is-fullwidth is-success"
:class="{ 'is-loading': labelService.loading}">
Save
</button>
</div>
<div class="control">
<a class="button has-icon is-danger" @click="deleteLabel(labelEditLabel);isLabelEdit = false;">
<a
class="button has-icon is-danger"
@click="() => {deleteLabel(labelEditLabel);isLabelEdit = false}">
<span class="icon">
<icon icon="trash-alt"/>
</span>
@ -88,11 +102,10 @@
<script>
import verte from 'verte'
import 'verte/dist/verte.css'
import {mapState} from 'vuex'
import LabelService from '../../services/label'
import LabelModel from '../../models/label'
import message from '../../message'
import auth from '../../auth'
export default {
name: 'ListLabels',
@ -105,7 +118,6 @@
labels: [],
labelEditLabel: LabelModel,
isLabelEdit: false,
user: auth.user,
}
},
created() {
@ -113,14 +125,34 @@
this.labelEditLabel = new LabelModel()
this.loadLabels()
},
computed: mapState({
userInfo: state => state.auth.info
}),
methods: {
loadLabels() {
this.labelService.getAll()
const getAllLabels = (page = 1) => {
return this.labelService.getAll({}, {}, page)
.then(labels => {
if(page < this.labelService.totalPages) {
return getAllLabels(page + 1)
.then(nextLabels => {
return labels.concat(nextLabels)
})
} else {
return labels
}
})
.catch(e => {
return Promise.reject(e)
})
}
getAllLabels()
.then(r => {
this.$set(this, 'labels', r)
})
.catch(e => {
message.error(e, this)
this.error(e, this)
})
},
deleteLabel(label) {
@ -132,10 +164,10 @@
this.labels.splice(l, 1)
}
}
message.success({message: 'The label was successfully deleted.'}, this)
this.success({message: 'The label was successfully deleted.'}, this)
})
.catch(e => {
message.error(e, this)
this.error(e, this)
})
},
editLabelSubmit() {
@ -146,14 +178,14 @@
this.$set(this.labels, l, r)
}
}
message.success({message: 'The label was successfully updated.'}, this)
this.success({message: 'The label was successfully updated.'}, this)
})
.catch(e => {
message.error(e, this)
this.error(e, this)
})
},
editLabel(label) {
if(label.created_by.id !== this.user.infos.id) {
if (label.createdBy.id !== this.userInfo.id) {
return
}
this.labelEditLabel = label
@ -161,4 +193,4 @@
}
}
}
</script>
</script>

View File

@ -1,5 +1,9 @@
<template>
<div class="loader-container" :class="{ 'is-loading': listService.loading}">
<div class="notification is-warning" v-if="list.isArchived">
This list is archived.
It is not possible to create new or edit tasks or it.
</div>
<div class="card">
<header class="card-header">
<p class="card-header-title">
@ -8,29 +12,88 @@
</header>
<div class="card-content">
<div class="content">
<form @submit.prevent="submit()">
<form @submit.prevent="submit()">
<div class="field">
<label class="label" for="listtext">List Name</label>
<div class="control">
<input v-focus :class="{ 'disabled': listService.loading}" :disabled="listService.loading" class="input" type="text" id="listtext" placeholder="The list title goes here..." v-model="list.title">
<input
v-focus
:class="{ 'disabled': listService.loading}"
:disabled="listService.loading"
class="input"
type="text"
id="listtext"
placeholder="The list title goes here..."
@keyup.enter="submit"
v-model="list.title"/>
</div>
</div>
<div class="field">
<label
class="label"
for="listtext"
v-tooltip="'The list identifier can be used to uniquely identify a task across lists. You can set it to empty to disable it.'">
List Identifier
</label>
<div class="control">
<input
v-focus
:class="{ 'disabled': listService.loading}"
:disabled="listService.loading"
class="input"
type="text"
id="listtext"
placeholder="The list identifier goes here..."
@keyup.enter="submit"
v-model="list.identifier"/>
</div>
</div>
<div class="field">
<label class="label" for="listdescription">Description</label>
<div class="control">
<textarea :class="{ 'disabled': listService.loading}" :disabled="listService.loading" class="textarea" placeholder="The lists description goes here..." id="listdescription" v-model="list.description"></textarea>
<textarea
:class="{ 'disabled': listService.loading}"
:disabled="listService.loading"
class="textarea"
placeholder="The lists description goes here..."
id="listdescription"
v-model="list.description"></textarea>
</div>
</div>
<div class="field">
<label class="label" for="isArchivedCheck">Is Archived</label>
<div class="control">
<fancycheckbox
v-model="list.isArchived"
v-tooltip="'If a list is archived, you cannot create new tasks or edit the list or existing tasks.'">
This list is archived
</fancycheckbox>
</div>
</div>
<div class="field">
<label class="label">Color</label>
<div class="control">
<verte
v-model="list.hexColor"
menuPosition="top"
picker="square"
model="hex"
:enableAlpha="false"
:rgbSliders="true"/>
</div>
</div>
</form>
<div class="columns bigbuttons">
<div class="column">
<button @click="submit()" class="button is-primary is-fullwidth" :class="{ 'is-loading': listService.loading}">
<button @click="submit()" class="button is-primary is-fullwidth"
:class="{ 'is-loading': listService.loading}">
Save
</button>
</div>
<div class="column is-1">
<button @click="showDeleteModal = true" class="button is-danger is-fullwidth" :class="{ 'is-loading': listService.loading}">
<button @click="showDeleteModal = true" class="button is-danger is-fullwidth"
:class="{ 'is-loading': listService.loading}">
<span class="icon is-small">
<icon icon="trash-alt"/>
</span>
@ -41,10 +104,20 @@
</div>
</div>
<component :is="manageUsersComponent" :id="list.id" type="list" shareType="user" :userIsAdmin="userIsAdmin"></component>
<component :is="manageTeamsComponent" :id="list.id" type="list" shareType="team" :userIsAdmin="userIsAdmin"></component>
<component
:is="manageUsersComponent"
:id="list.id"
type="list"
shareType="user"
:userIsAdmin="userIsAdmin"/>
<component
:is="manageTeamsComponent"
:id="list.id"
type="list"
shareType="team"
:userIsAdmin="userIsAdmin"/>
<link-sharing :list-i-d="$route.params.id"/>
<link-sharing :list-id="$route.params.id" v-if="linkSharingEnabled"/>
<modal
v-if="showDeleteModal"
@ -58,14 +131,16 @@
</template>
<script>
import auth from '../../auth'
import verte from 'verte'
import 'verte/dist/verte.css'
import router from '../../router'
import message from '../../message'
import manageSharing from '../sharing/userTeam'
import LinkSharing from '../sharing/linkSharing';
import LinkSharing from '../sharing/linkSharing'
import ListModel from '../../models/list'
import ListService from '../../services/list'
import Fancycheckbox from '../global/fancycheckbox'
export default {
name: "EditList",
@ -75,22 +150,16 @@
listService: ListService,
showDeleteModal: false,
user: auth.user,
userIsAdmin: false, // FIXME: we should be able to know somehow if the user is admin, not only based on if he's the owner
manageUsersComponent: '',
manageTeamsComponent: '',
}
},
components: {
Fancycheckbox,
LinkSharing,
manageSharing,
},
beforeMount() {
// Check if the user is already logged in, if so, redirect him to the homepage
if (!auth.user.authenticated) {
router.push({name: 'home'})
}
verte,
},
created() {
this.listService = new ListService()
@ -100,49 +169,46 @@
// call again the method if the route changes
'$route': 'loadList'
},
computed: {
linkSharingEnabled() {
return this.$store.state.config.linkSharingEnabled
},
userIsAdmin() {
return this.list.owner && this.list.owner.id === this.$store.state.auth.info.id
},
},
methods: {
loadList() {
let list = new ListModel({id: this.$route.params.id})
this.listService.get(list)
.then(r => {
this.$set(this, 'list', r)
if (r.owner.id === this.user.infos.id) {
this.userIsAdmin = true
}
// This will trigger the dynamic loading of components once we actually have all the data to pass to them
this.manageTeamsComponent = 'manageSharing'
this.manageUsersComponent = 'manageSharing'
})
.catch(e => {
message.error(e, this)
this.error(e, this)
})
},
submit() {
this.listService.update(this.list)
.then(r => {
// Update the list in the parent
for (const n in this.$parent.namespaces) {
let lists = this.$parent.namespaces[n].lists
for (const l in lists) {
if (lists[l].id === r.id) {
this.$set(this.$parent.namespaces[n].lists, l, r)
}
}
}
message.success({message: 'The list was successfully updated.'}, this)
this.$store.commit('namespaces/setListInNamespaceById', r)
this.success({message: 'The list was successfully updated.'}, this)
})
.catch(e => {
message.error(e, this)
this.error(e, this)
})
},
deleteList() {
this.listService.delete(this.list)
.then(() => {
message.success({message: 'The list was successfully deleted.'}, this)
this.success({message: 'The list was successfully deleted.'}, this)
router.push({name: 'home'})
})
.catch(e => {
message.error(e, this)
this.error(e, this)
})
},
}

View File

@ -5,61 +5,70 @@
</icon>
</a>
<h3>Create a new list</h3>
<form @submit.prevent="newList" @keyup.esc="back()">
<div class="field is-grouped">
<p class="control is-expanded" :class="{ 'is-loading': listService.loading}">
<input v-focus class="input" :class="{ 'disabled': listService.loading}" v-model="list.title" type="text" placeholder="The list's name goes here...">
</p>
<p class="control">
<button type="submit" class="button is-success noshadow">
<div class="field is-grouped">
<p class="control is-expanded" :class="{ 'is-loading': listService.loading}">
<input v-focus
class="input"
:class="{ 'disabled': listService.loading}"
v-model="list.title"
type="text"
placeholder="The list's name goes here..."
@keyup.esc="back()"
@keyup.enter="newList()"/>
</p>
<p class="control">
<button class="button is-success noshadow" @click="newList()" :disabled="list.title.length < 3">
<span class="icon is-small">
<icon icon="plus"/>
</span>
Add
</button>
</p>
</div>
</form>
Add
</button>
</p>
</div>
<p class="help is-danger" v-if="showError && list.title.length < 3">
Please specify at least three characters.
</p>
</div>
</template>
<script>
import auth from '../../auth'
import router from '../../router'
import message from '../../message'
import ListService from '../../services/list'
import ListModel from '../../models/list'
import {IS_FULLPAGE} from '../../store/mutation-types'
export default {
name: "NewList",
data() {
return {
showError: false,
list: ListModel,
listService: ListService,
}
},
beforeMount() {
// Check if the user is already logged in, if so, redirect him to the homepage
if (!auth.user.authenticated) {
router.push({name: 'home'})
}
},
created() {
this.list = new ListModel()
this.listService = new ListService()
this.$parent.setFullPage();
this.$store.commit(IS_FULLPAGE, true)
},
methods: {
newList() {
this.list.namespaceID = this.$route.params.id
if (this.list.title.length < 3) {
this.showError = true
return
}
this.showError = false
this.list.namespaceId = this.$route.params.id
this.listService.create(this.list)
.then(response => {
this.$parent.loadNamespaces()
message.success({message: 'The list was successfully created.'}, this)
router.push({name: 'showList', params: {id: response.id}})
response.namespaceId = this.list.namespaceId
this.$store.commit('namespaces/addListToNamespace', response)
this.success({message: 'The list was successfully created.'}, this)
router.push({name: 'list.index', params: {listId: response.id}})
})
.catch(e => {
message.error(e, this)
this.error(e, this)
})
},
back() {

View File

@ -4,72 +4,91 @@
<router-link :to="{ name: 'editList', params: { id: list.id } }" class="icon settings is-medium">
<icon icon="cog" size="2x"/>
</router-link>
<h1>{{ list.title }}</h1>
<h1 :style="{ 'opacity': list.title === '' ? '0': '1' }">{{ list.title === '' ? 'Loading...': list.title}}</h1>
<div class="notification is-warning" v-if="list.isArchived">
This list is archived.
It is not possible to create new or edit tasks or it.
</div>
<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>
<router-link :to="{ name: 'list.list', params: { listId: listId } }" :class="{'is-active': $route.name === 'list.list'}">List</router-link>
<router-link :to="{ name: 'list.gantt', params: { listId: listId } }" :class="{'is-active': $route.name === 'list.gantt'}">Gantt</router-link>
<router-link :to="{ name: 'list.table', params: { listId: listId } }" :class="{'is-active': $route.name === 'list.table'}">Table</router-link>
<router-link :to="{ name: 'list.kanban', params: { listId: listId } }" :class="{'is-active': $route.name === 'list.kanban'}">Kanban</router-link>
</div>
</div>
<gantt :list="list" v-if="$route.params.type === 'gantt'"/>
<show-list-task :the-list="list" v-else/>
<router-view/>
</div>
</template>
<script>
import auth from '../../auth'
import router from '../../router'
import message from '../../message'
import ShowListTask from '../tasks/ShowListTasks'
import Gantt from '../tasks/Gantt'
import ListModel from '../../models/list'
import ListService from '../../services/list'
import authType from '../../models/authTypes'
import {CURRENT_LIST} from '../../store/mutation-types'
import {getListView} from '../../helpers/saveListView'
export default {
data() {
return {
listID: this.$route.params.id,
listService: ListService,
list: ListModel,
}
},
components: {
Gantt,
ShowListTask,
},
beforeMount() {
// Check if the user is already logged in, if so, redirect him to the homepage
if (!auth.user.authenticated && auth.user.infos.type !== authType.LINK_SHARE) {
router.push({name: 'home'})
}
// If the type is invalid, redirect the user
if (auth.user.authenticated && auth.user.infos.type !== authType.LINK_SHARE && this.$route.params.type !== 'gantt' && this.$route.params.type !== '') {
router.push({name: 'showList', params: { id: this.$route.params.id }})
listLoaded: 0,
}
},
created() {
this.listService = new ListService()
this.list = new ListModel()
},
mounted() {
this.loadList()
},
watch: {
// call again the method if the route changes
'$route': 'loadList'
'$route.path': 'loadList',
},
computed: {
// Computed property to let "listId" always have a value
listId() {
return typeof this.$route.params.listId === 'undefined' ? 0 : this.$route.params.listId
},
},
methods: {
loadList() {
// Don't load the list if we either already loaded it or aren't dealing with a list at all currently
if(this.$route.params.listId === this.listLoaded || typeof this.$route.params.listId === 'undefined') {
return
}
// Redirect the user to list view by default
if (
this.$route.name !== 'list.list' &&
this.$route.name !== 'list.gantt' &&
this.$route.name !== 'list.table' &&
this.$route.name !== 'list.kanban'
) {
const savedListView = getListView(this.$route.params.listId)
router.replace({name: savedListView, params: {id: this.$route.params.listId}})
return
}
this.$store.commit(CURRENT_LIST, Number(this.$route.params.listId))
// We create an extra list object instead of creating it in this.list because that would trigger a ui update which would result in bad ux.
let list = new ListModel({id: this.$route.params.id})
let list = new ListModel({id: this.$route.params.listId})
this.listService.get(list)
.then(r => {
this.$set(this, 'list', r)
})
.catch(e => {
message.error(e, this)
this.error(e, this)
})
.finally(() => {
this.listLoaded = this.$route.params.listId
})
},
}

View File

@ -1,18 +1,9 @@
<template>
<div>
<div class="gantt-options">
<div class="fancycheckbox is-block">
<input id="showTaskswithoutDates" type="checkbox" style="display: none;" v-model="showTaskswithoutDates">
<label for="showTaskswithoutDates" 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>
<span>
Show tasks which don't have dates set
</span>
</label>
</div>
<fancycheckbox v-model="showTaskswithoutDates" class="is-block">
Show tasks which don't have dates set
</fancycheckbox>
<div class="range-picker">
<div class="field">
<label class="label" for="dayWidth">Size</label>
@ -53,26 +44,39 @@
</div>
</div>
<gantt-chart
:list="list"
:list-id="Number($route.params.listId)"
:show-taskswithout-dates="showTaskswithoutDates"
:date-from="dateFrom"
:date-to="dateTo"
:day-width="dayWidth"
/>
<!-- This router view is used to show the task popup while keeping the gantt chart itself -->
<transition name="modal">
<router-view/>
</transition>
</div>
</template>
<script>
import GanttChart from './gantt-component'
import GanttChart from '../../tasks/gantt-component'
import flatPickr from 'vue-flatpickr-component'
import ListModel from '../../models/list'
import Fancycheckbox from '../../global/fancycheckbox'
import {saveListView} from '../../../helpers/saveListView'
export default {
name: 'Gantt',
components: {
Fancycheckbox,
flatPickr,
GanttChart
},
created() {
// Save the current list view to local storage
// We use local storage and not vuex here to make it persistent across reloads.
saveListView(this.$route.params.listId, this.$route.name)
},
data() {
return {
showTaskswithoutDates: false,
@ -91,11 +95,5 @@
this.dateFrom = new Date((new Date()).setDate((new Date()).getDate() - 15))
this.dateTo = new Date((new Date()).setDate((new Date()).getDate() + 30))
},
props: {
list: {
type: ListModel,
required: true,
}
},
}
</script>

View File

@ -0,0 +1,452 @@
<template>
<div class="kanban loader-container" :class="{ 'is-loading': loading}">
<div v-for="bucket in buckets" :key="`bucket${bucket.id}`" class="bucket">
<div class="bucket-header">
<h2
class="title input"
contenteditable="true"
@focusout="() => saveBucketTitle(bucket.id)"
:ref="`bucket${bucket.id}title`"
@keyup.ctrl.enter="() => saveBucketTitle(bucket.id)">{{ bucket.title }}</h2>
<div class="dropdown is-right options" :class="{ 'is-active': bucketOptionsDropDownActive[bucket.id] }">
<div class="dropdown-trigger" @click.stop="toggleBucketDropdown(bucket.id)">
<span class="icon">
<icon icon="ellipsis-v"/>
</span>
</div>
<div class="dropdown-menu" role="menu">
<div class="dropdown-content">
<a
class="dropdown-item has-text-danger"
@click="() => deleteBucketModal(bucket.id)"
:class="{'is-disabled': buckets.length <= 1}"
v-tooltip="buckets.length <= 1 ? 'You cannot remove the last bucket.' : ''"
>
<span class="icon is-small"><icon icon="trash-alt"/></span>
Delete
</a>
</div>
</div>
</div>
</div>
<div class="tasks">
<Container
@drop="e => onDrop(bucket.id, e)"
group-name="buckets"
:get-child-payload="getTaskPayload(bucket.id)"
:drop-placeholder="dropPlaceholderOptions"
:animation-duration="150"
drag-class="ghost-task"
drag-class-drop="ghost-task-drop"
drag-handle-selector=".task.draggable"
>
<Draggable v-for="task in bucket.tasks" :key="`bucket${bucket.id}-task${task.id}`">
<router-link
:to="{ name: 'task.kanban.detail', params: { id: task.id } }"
class="task loader-container draggable"
tag="div"
:class="{
'is-loading': taskService.loading && taskUpdating[task.id],
'draggable': !taskService.loading || !taskUpdating[task.id]
}"
>
<span
class="color"
:style="{ 'background-color': task.hexColor }"
v-if="task.hexColor !== '#' + task.defaultColor">
</span>
<span class="task-id">
<span class="is-done" v-if="task.done">Done</span>
#{{ task.id }}
</span>
<span
v-if="task.dueDate > 0"
class="due-date"
:class="{'overdue': task.dueDate <= new Date() && !task.done}"
v-tooltip="formatDate(task.dueDate)">
<span class="icon">
<icon :icon="['far', 'calendar-alt']"/>
</span>
<span>
{{ formatDateSince(task.dueDate) }}
</span>
</span>
<h3>{{ task.title }}</h3>
<labels :labels="task.labels"/>
<div class="footer">
<div class="items">
<priority-label :priority="task.priority" class="priority-label"/>
<div class="assignees" v-if="task.assignees.length > 0">
<user
v-for="u in task.assignees"
:key="task.id + 'assignee' + u.id"
:user="u"
:show-username="false"
:avatar-size="24"
/>
</div>
</div>
<div>
<span class="icon" v-if="task.attachments.length > 0">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect fill="none" rx="0" ry="0"></rect>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M19.86 8.29994C19.8823 8.27664 19.9026 8.25201 19.9207 8.22634C20.5666 7.53541 20.93 6.63567 20.93 5.68001C20.93 4.69001 20.55 3.76001 19.85 3.06001C18.45 1.66001 16.02 1.66001 14.62 3.06001L9.88002 7.80001C9.86705 7.81355 9.85481 7.82753 9.8433 7.8419L4.58 13.1C3.6 14.09 3.06 15.39 3.06 16.78C3.06 18.17 3.6 19.48 4.58 20.46C5.6 21.47 6.93 21.98 8.26 21.98C9.59 21.98 10.92 21.47 11.94 20.46L17.74 14.66C17.97 14.42 17.98 14.04 17.74 13.81C17.5 13.58 17.12 13.58 16.89 13.81L11.09 19.61C10.33 20.36 9.33 20.78 8.26 20.78C7.19 20.78 6.19 20.37 5.43 19.61C4.68 18.85 4.26 17.85 4.26 16.78C4.26 15.72 4.68 14.71 5.43 13.96L15.47 3.91996C15.4962 3.89262 15.5195 3.86346 15.54 3.83292C16.4992 2.95103 18.0927 2.98269 19.01 3.90001C19.48 4.37001 19.74 5.00001 19.74 5.67001C19.74 6.34001 19.48 6.97001 19.01 7.44001L14.27 12.18C14.2571 12.1935 14.2448 12.2075 14.2334 12.2218L8.96 17.4899C8.59 17.8699 7.93 17.8699 7.55 17.4899C7.36 17.2999 7.26 17.0399 7.26 16.7799C7.26 16.5199 7.36 16.2699 7.55 16.0699L15.47 8.14994C15.7 7.90994 15.71 7.52994 15.47 7.29994C15.23 7.06994 14.85 7.06994 14.62 7.29994L6.7 15.2199C6.29 15.6399 6.06 16.1899 6.06 16.7799C6.06 17.3699 6.29 17.9199 6.7 18.3399C7.12 18.7499 7.67 18.9799 8.26 18.9799C8.85 18.9799 9.4 18.7599 9.82 18.3399L19.86 8.29994Z"></path>
</svg>
</span>
</div>
</div>
</router-link>
</Draggable>
</Container>
</div>
<div class="bucket-footer">
<div class="field" v-if="showNewTaskInput[bucket.id]">
<div class="control">
<input
class="input"
type="text"
placeholder="Enter the new task text..."
v-focus
@focusout="toggleShowNewTaskInput(bucket.id)"
@keyup.esc="toggleShowNewTaskInput(bucket.id)"
@keyup.enter="addTaskToBucket(bucket.id)"
v-model="newTaskText"
:disabled="taskService.loading"
:class="{'is-loading': taskService.loading}"
/>
</div>
<p class="help is-danger" v-if="newTaskError[bucket.id] && newTaskText.length < 3">
Please specify at least three characters.
</p>
</div>
<a
class="button noshadow is-transparent is-fullwidth has-text-centered"
@click="toggleShowNewTaskInput(bucket.id)"
v-if="!showNewTaskInput[bucket.id]">
<span class="icon is-small">
<icon icon="plus"/>
</span>
<span v-if="bucket.tasks.length === 0">
Add a task
</span>
<span v-else>
Add another task
</span>
</a>
</div>
</div>
<div class="bucket new-bucket" v-if="!loading">
<input
v-if="showNewBucketInput"
class="input"
type="text"
placeholder="Enter the new bucket title..."
v-focus
@focusout="() => showNewBucketInput = false"
@keyup.esc="() => showNewBucketInput = false"
@keyup.enter="createNewBucket"
v-model="newBucketTitle"
:disabled="loading"
:class="{'is-loading': loading}"
/>
<a
class="button noshadow is-transparent is-fullwidth has-text-centered"
@click="() => showNewBucketInput = true" v-if="!showNewBucketInput">
<span class="icon is-small">
<icon icon="plus"/>
</span>
<span>
Create a new bucket
</span>
</a>
</div>
<!-- This router view is used to show the task popup while keeping the kanban board itself -->
<transition name="modal">
<router-view/>
</transition>
<modal
v-if="showBucketDeleteModal"
@close="showBucketDeleteModal = false"
@submit="deleteBucket()">
<span slot="header">Delete the bucket</span>
<p slot="text">
Are you sure you want to delete this bucket?<br/>
This will not delete any tasks but move them into the default bucket.
</p>
</modal>
</div>
</template>
<script>
import TaskService from '../../../services/task'
import TaskModel from '../../../models/task'
import BucketModel from '../../../models/bucket'
import {Container, Draggable} from 'vue-smooth-dnd'
import PriorityLabel from '../../tasks/reusable/priorityLabel'
import User from '../../global/user'
import Labels from '../../tasks/reusable/labels'
import {filterObject} from '../../../helpers/filterObject'
import {applyDrag} from '../../../helpers/applyDrag'
import {mapState} from 'vuex'
import {LOADING} from '../../../store/mutation-types'
import {saveListView} from '../../../helpers/saveListView'
export default {
name: 'Kanban',
components: {
Container,
Draggable,
Labels,
User,
PriorityLabel,
},
data() {
return {
taskService: TaskService,
dropPlaceholderOptions: {
className: 'drop-preview',
animationDuration: 150,
showOnTop: true,
},
bucketOptionsDropDownActive: {},
showBucketDeleteModal: false,
bucketToDelete: 0,
newTaskText: '',
showNewTaskInput: {},
newBucketTitle: '',
showNewBucketInput: false,
newTaskError: {},
// We're using this to show the loading animation only at the task when updating it
taskUpdating: {},
}
},
created() {
this.taskService = new TaskService()
this.loadBuckets()
setTimeout(() => document.addEventListener('click', this.closeBucketDropdowns), 0)
// Save the current list view to local storage
// We use local storage and not vuex here to make it persistent across reloads.
saveListView(this.$route.params.listId, this.$route.name)
},
watch: {
'$route.params.listId': 'loadBuckets',
},
computed: mapState({
buckets: state => state.kanban.buckets,
loadedListId: state => state.kanban.listId,
loading: LOADING,
}),
methods: {
loadBuckets() {
// Prevent trying to load buckets if the task popup view is active
if(this.$route.name !== 'list.kanban') {
return
}
// Only load buckets if we don't already loaded them
if(this.loadedListId === this.$route.params.listId) {
return
}
this.$store.dispatch('kanban/loadBucketsForList', this.$route.params.listId)
.catch(e => {
this.error(e, this)
})
},
onDrop(bucketId, dropResult) {
// Note: A lot of this example comes from the excellent kanban example on https://github.com/kutlugsahin/vue-smooth-dnd/blob/master/demo/src/pages/cards.vue
const bucketIndex = filterObject(this.buckets, b => b.id === bucketId)
if (dropResult.removedIndex !== null || dropResult.addedIndex !== null) {
// FIXME: This is probably not the best solution and more of a naive brute-force approach
// Duplicate the buckets to avoid stuff moving around without noticing
const buckets = Object.assign({}, this.buckets)
// Get the index of the bucket and the bucket itself
const bucket = buckets[bucketIndex]
// Rebuild the tasks from the bucket, removing/adding the moved task
bucket.tasks = applyDrag(bucket.tasks, dropResult)
// Update the bucket in the list of all buckets
delete buckets[bucketIndex]
buckets[bucketIndex] = bucket
// Set the buckets, triggering a state update in vue
// FIXME: This seems to set some task attributes (like due date) wrong. Commented out, but seems to still work?
// Not sure what to do about this.
// this.$store.commit('kanban/setBuckets', buckets)
}
if (dropResult.addedIndex !== null) {
const taskIndex = dropResult.addedIndex
const taskBefore = typeof this.buckets[bucketIndex].tasks[taskIndex - 1] === 'undefined' ? null : this.buckets[bucketIndex].tasks[taskIndex - 1]
const taskAfter = typeof this.buckets[bucketIndex].tasks[taskIndex + 1] === 'undefined' ? null : this.buckets[bucketIndex].tasks[taskIndex + 1]
const task = this.buckets[bucketIndex].tasks[taskIndex]
this.$set(this.taskUpdating, task.id, true)
// If there is no task before, our task is the first task in which case we let it have half of the position of the task after it
if (taskBefore === null && taskAfter !== null) {
task.position = taskAfter.position / 2
}
// If there is no task after it, we just add 2^16 to the last position
if (taskBefore !== null && taskAfter === null) {
task.position = taskBefore.position + Math.pow(2, 16)
}
// If we have both a task before and after it, we acually calculate the position
if (taskAfter !== null && taskBefore !== null) {
task.position = taskBefore.position + (taskAfter.position - taskBefore.position) / 2
}
task.bucketId = bucketId
this.$store.dispatch('tasks/update', task)
.then(() => {
// Update the block with the new task details
// this.$store.commit('kanban/setTaskInBucketByIndex', {bucketIndex, taskIndex, task: t})
this.success({message: 'The task was moved successfully!'}, this)
})
.catch(e => {
this.error(e, this)
})
.finally(() => {
this.$set(this.taskUpdating, task.id, false)
})
}
},
getTaskPayload(bucketId) {
return index => {
const bucket = this.buckets[filterObject(this.buckets, b => b.id === bucketId)]
return bucket.tasks[index]
}
},
toggleShowNewTaskInput(bucket) {
this.$set(this.showNewTaskInput, bucket, !this.showNewTaskInput[bucket])
},
toggleBucketDropdown(bucketId) {
this.$set(this.bucketOptionsDropDownActive, bucketId, !this.bucketOptionsDropDownActive[bucketId])
},
closeBucketDropdowns() {
for (const bucketId in this.bucketOptionsDropDownActive) {
this.bucketOptionsDropDownActive[bucketId] = false
}
},
addTaskToBucket(bucketId) {
if (this.newTaskText.length < 3) {
this.$set(this.newTaskError, bucketId, true)
return
}
this.$set(this.newTaskError, bucketId, false)
// We need the actual bucket index so we put that in a seperate function
const bucketIndex = () => {
for (const t in this.buckets) {
if (this.buckets[t].id === bucketId) {
return t
}
}
}
const bi = bucketIndex()
const task = new TaskModel({title: this.newTaskText, bucketId: this.buckets[bi].id, listId: this.$route.params.listId})
this.taskService.create(task)
.then(r => {
this.newTaskText = ''
this.$store.commit('kanban/addTaskToBucket', r)
this.success({message: 'The task was created successfully!'}, this)
})
.catch(e => {
this.error(e, this)
})
},
createNewBucket() {
if (this.newBucketTitle === '') {
return
}
const newBucket = new BucketModel({title: this.newBucketTitle, listId: parseInt(this.$route.params.listId)})
this.$store.dispatch('kanban/createBucket', newBucket)
.then(() => {
this.newBucketTitle = ''
this.showNewBucketInput = false
this.success({message: 'The bucket was created successfully!'}, this)
})
.catch(e => {
this.error(e, this)
})
},
deleteBucketModal(bucketId) {
if (this.buckets.length <= 1) {
return
}
this.bucketToDelete = bucketId
this.showBucketDeleteModal = true
},
deleteBucket() {
const bucket = new BucketModel({
id: this.bucketToDelete,
listId: this.$route.params.listId,
})
this.$store.dispatch('kanban/deleteBucket', bucket)
.then(r => {
this.success(r, this)
})
.catch(e => {
this.error(e, this)
})
.finally(() => {
this.showBucketDeleteModal = false
})
},
saveBucketTitle(bucketId) {
const bucketTitle = this.$refs[`bucket${bucketId}title`][0].textContent
const bucket = new BucketModel({
id: bucketId,
title: bucketTitle,
listId: Number(this.$route.params.listId),
})
// Because the contenteditable does not have a change event,
// we're building it ourselves here and only updating the bucket
// if the title changed.
const realBucket = this.buckets[filterObject(this.buckets, b => b.id === bucketId)]
if (realBucket.title === bucketTitle) {
return
}
this.$store.dispatch('kanban/updateBucket', bucket)
.then(r => {
this.success({message: 'The bucket title was updated successfully!'}, this)
realBucket.title = r.title
})
.catch(e => {
this.error(e, this)
})
},
},
}
</script>

View File

@ -0,0 +1,195 @@
<template>
<div class="loader-container" :class="{ 'is-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}">
Search
</button>
</div>
</div>
<button class="button" @click="showTaskSearch = !showTaskSearch" v-if="!showTaskSearch">
<span class="icon">
<icon icon="search"/>
</span>
</button>
</div>
<div class="field task-add" v-if="!list.isArchived">
<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..." @keyup.enter="addTask()"/>
<span class="icon is-small is-left">
<icon icon="tasks"/>
</span>
</p>
<p class="control">
<button class="button is-success" :disabled="newTaskText.length < 3" @click="addTask()">
<span class="icon is-small">
<icon icon="plus"/>
</span>
Add
</button>
</p>
</div>
<p class="help is-danger" v-if="showError && newTaskText.length < 3">
Please specify at least three characters.
</p>
</div>
<div class="columns">
<div class="column">
<div class="tasks" v-if="tasks && tasks.length > 0" :class="{'short': isTaskEdit}">
<div class="task" v-for="t in tasks" :key="t.id">
<single-task-in-list :the-task="t" @taskUpdated="updateTasks" task-detail-route="task.detail"/>
<div @click="editTask(t.id)" class="icon settings" v-if="!list.isArchived">
<icon icon="pencil-alt"/>
</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>
</div>
</div>
</div>
<nav class="pagination is-centered" role="navigation" aria-label="pagination" v-if="taskCollectionService.totalPages > 1">
<router-link class="pagination-previous" :to="getRouteForPagination(currentPage - 1)" tag="button" :disabled="currentPage === 1">Previous</router-link>
<router-link class="pagination-next" :to="getRouteForPagination(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="getRouteForPagination(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>
<!-- This router view is used to show the task popup while keeping the kanban board itself -->
<transition name="modal">
<router-view/>
</transition>
</div>
</template>
<script>
import TaskService from '../../../services/task'
import EditTask from '../../tasks/edit-task'
import TaskModel from '../../../models/task'
import SingleTaskInList from '../../tasks/reusable/singleTaskInList'
import taskList from '../../tasks/helpers/taskList'
import {saveListView} from '../../../helpers/saveListView'
export default {
name: 'List',
data() {
return {
taskService: TaskService,
list: {},
isTaskEdit: false,
taskEditTask: TaskModel,
newTaskText: '',
showError: false,
}
},
mixins: [
taskList,
],
components: {
SingleTaskInList,
EditTask,
},
created() {
this.taskService = new TaskService()
// Save the current list view to local storage
// We use local storage and not vuex here to make it persistent across reloads.
saveListView(this.$route.params.listId, this.$route.name)
},
methods: {
// This function initializes the tasks page and loads the first page of tasks
initTasks(page, search = '') {
this.taskEditTask = null
this.isTaskEdit = false
this.loadTasks(page, search)
},
addTask() {
if (this.newTaskText.length < 3) {
this.showError = true
return
}
this.showError = false
let task = new TaskModel({title: this.newTaskText, listId: this.$route.params.listId})
this.taskService.create(task)
.then(r => {
this.tasks.push(r)
this.sortTasks()
this.newTaskText = ''
this.success({message: 'The task was successfully created.'}, this)
})
.catch(e => {
this.error(e, this)
})
},
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
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
},
updateTasks(updatedTask) {
for (const t in this.tasks) {
if (this.tasks[t].id === updatedTask.id) {
this.$set(this.tasks, t, updatedTask)
break
}
}
this.sortTasks()
},
}
}
</script>

View File

@ -0,0 +1,238 @@
<template>
<div class="table-view loader-container" :class="{'is-loading': taskCollectionService.loading}">
<div class="column-filter">
<button class="button" @click="showActiveColumnsFilter = !showActiveColumnsFilter">
<span class="icon is-small">
<icon icon="th"/>
</span>
Columns
</button>
<transition name="fade">
<div class="card" v-if="showActiveColumnsFilter">
<div class="card-content">
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.id">#</fancycheckbox>
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.done">Done</fancycheckbox>
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.title">Title</fancycheckbox>
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.priority">Priority</fancycheckbox>
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.labels">Labels</fancycheckbox>
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.assignees">Assignees</fancycheckbox>
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.dueDate">Due Date</fancycheckbox>
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.startDate">Start Date</fancycheckbox>
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.endDate">End Date</fancycheckbox>
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.percentDone">% Done</fancycheckbox>
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.created">Created</fancycheckbox>
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.updated">Updated</fancycheckbox>
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.createdBy">Created By</fancycheckbox>
</div>
</div>
</transition>
</div>
<table class="table is-hoverable is-fullwidth">
<thead>
<tr>
<th v-if="activeColumns.id">
#
<sort :order="sortBy.id" @click="sort('id')"/>
</th>
<th v-if="activeColumns.done">
Done
<sort :order="sortBy.done" @click="sort('done')"/>
</th>
<th v-if="activeColumns.title">
Name
<sort :order="sortBy.title" @click="sort('title')"/>
</th>
<th v-if="activeColumns.priority">
Priority
<sort :order="sortBy.priority" @click="sort('priority')"/>
</th>
<th v-if="activeColumns.labels">
Labels
</th>
<th v-if="activeColumns.assignees">
Assignees
</th>
<th v-if="activeColumns.dueDate">
Due&nbsp;Date
<sort :order="sortBy.due_date_unix" @click="sort('due_date_unix')"/>
</th>
<th v-if="activeColumns.startDate">
Start&nbsp;Date
<sort :order="sortBy.start_date_unix" @click="sort('start_date_unix')"/>
</th>
<th v-if="activeColumns.endDate">
End&nbsp;Date
<sort :order="sortBy.end_date_unix" @click="sort('end_date_unix')"/>
</th>
<th v-if="activeColumns.percentDone">
%&nbsp;Done
<sort :order="sortBy.percent_done" @click="sort('percent_done')"/>
</th>
<th v-if="activeColumns.created">
Created
<sort :order="sortBy.created" @click="sort('created')"/>
</th>
<th v-if="activeColumns.updated">
Updated
<sort :order="sortBy.updated" @click="sort('updated')"/>
</th>
<th v-if="activeColumns.createdBy">
Created&nbsp;By
</th>
</tr>
</thead>
<tbody>
<tr v-for="t in tasks" :key="t.id">
<td v-if="activeColumns.id">
<router-link :to="{name: 'task.detail', params: { id: t.id }}">{{ t.id }}</router-link>
</td>
<td v-if="activeColumns.done">
<div class="is-done" v-if="t.done">Done</div>
</td>
<td v-if="activeColumns.title">
<router-link :to="{name: 'task.detail', params: { id: t.id }}">{{ t.title }}</router-link>
</td>
<td v-if="activeColumns.priority">
<priority-label :priority="t.priority" :show-all="true"/>
</td>
<td v-if="activeColumns.labels">
<labels :labels="t.labels"/>
</td>
<td v-if="activeColumns.assignees">
<user
:user="a"
:avatar-size="27"
:show-username="false"
:is-inline="true"
v-for="(a, i) in t.assignees"
:key="t.id + 'assignee' + a.id + i"
/>
</td>
<date-table-cell :date="t.dueDate" v-if="activeColumns.dueDate"/>
<date-table-cell :date="t.startDate" v-if="activeColumns.startDate"/>
<date-table-cell :date="t.endDate" v-if="activeColumns.endDate"/>
<td v-if="activeColumns.percentDone">{{ t.percentDone * 100 }}%</td>
<date-table-cell :date="t.created" v-if="activeColumns.created"/>
<date-table-cell :date="t.updated" v-if="activeColumns.updated"/>
<td v-if="activeColumns.createdBy">
<user
:user="t.createdBy"
:show-username="false"
:avatar-size="27"/>
</td>
</tr>
</tbody>
</table>
<nav class="pagination is-centered" role="navigation" aria-label="pagination" v-if="taskCollectionService.totalPages > 1">
<router-link class="pagination-previous" :to="getRouteForPagination(currentPage - 1, 'table')" tag="button" :disabled="currentPage === 1">Previous</router-link>
<router-link class="pagination-next" :to="getRouteForPagination(currentPage + 1, 'table')" 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="getRouteForPagination(p.number, 'table')" :class="{'is-current': p.number === currentPage}" class="pagination-link" :aria-label="'Goto page ' + p.number">{{ p.number }}</router-link>
</li>
</template>
</ul>
</nav>
<!-- This router view is used to show the task popup while keeping the table view itself -->
<transition name="modal">
<router-view/>
</transition>
</div>
</template>
<script>
import taskList from '../../tasks/helpers/taskList'
import User from '../../global/user'
import PriorityLabel from '../../tasks/reusable/priorityLabel'
import Labels from '../../tasks/reusable/labels'
import DateTableCell from '../../tasks/reusable/date-table-cell'
import Fancycheckbox from '../../global/fancycheckbox'
import Sort from '../../tasks/reusable/sort'
import {saveListView} from '../../../helpers/saveListView'
export default {
name: 'Table',
components: {
Sort,
Fancycheckbox,
DateTableCell,
Labels,
PriorityLabel,
User,
},
mixins: [
taskList,
],
data() {
return {
showActiveColumnsFilter: false,
activeColumns: {
id: true,
done: true,
title: true,
priority: false,
labels: true,
assignees: true,
dueDate: true,
startDate: false,
endDate: false,
percentDone: false,
created: false,
updated: false,
createdBy: false,
},
sortBy: {
id: 'desc',
},
}
},
created() {
const savedShowColumns = localStorage.getItem('tableViewColumns')
if (savedShowColumns !== null) {
this.$set(this, 'activeColumns', JSON.parse(savedShowColumns))
}
const savedSortBy = localStorage.getItem('tableViewSortBy')
if (savedSortBy !== null) {
this.$set(this, 'sortBy', JSON.parse(savedSortBy))
}
this.initTasks(1)
// Save the current list view to local storage
// We use local storage and not vuex here to make it persistent across reloads.
saveListView(this.$route.params.listId, this.$route.name)
},
methods: {
initTasks(page, search = '') {
let params = {sort_by: [], order_by: []}
Object.keys(this.sortBy).map(s => {
params.sort_by.push(s)
params.order_by.push(this.sortBy[s])
})
this.loadTasks(page, search, params)
},
sort(property) {
const order = this.sortBy[property]
if (typeof order === 'undefined' || order === 'none') {
this.$set(this.sortBy, property, 'desc')
} else if (order === 'desc') {
this.$set(this.sortBy, property, 'asc')
} else {
this.$delete(this.sortBy, property)
}
this.initTasks(this.currentPage, this.searchTerm)
// Save the order to be able to retrieve them later
localStorage.setItem('tableViewSortBy', JSON.stringify(this.sortBy))
},
saveTaskColumns() {
localStorage.setItem('tableViewColumns', JSON.stringify(this.activeColumns))
},
},
}
</script>

View File

@ -0,0 +1,39 @@
<template>
<migration
:identifier="identifier"
:name="name"
/>
</template>
<script>
import Migration from './migration'
import router from '../../router'
export default {
name: 'migrateService',
components: {
Migration,
},
data() {
return {
name: '',
identifier: '',
}
},
created() {
switch (this.$route.params.service) {
case 'wunderlist':
this.name = 'Wunderlist'
this.identifier = 'wunderlist'
break
case 'todoist':
this.name = 'Todoist'
this.identifier = 'todoist'
break
default:
router.push({name: '404'})
}
},
}
</script>

View File

@ -0,0 +1,23 @@
<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: 'migrate', params: {service: m}}" v-for="m in availableMigrators" :key="m">
<img :src="`/images/migration/${m}.png`" :alt="m"/>
{{ m }}
</router-link>
</div>
</div>
</template>
<script>
export default {
name: 'migrate',
computed: {
availableMigrators() {
return this.$store.state.config.availableMigrators
},
},
}
</script>

View File

@ -0,0 +1,118 @@
<template>
<div class="content">
<h1>Import your data from {{ name }} to Vikunja</h1>
<p>Vikunja will import all 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 {{ name }} 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/${identifier}.png`" :alt="name"/>
<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">
</div>
<p>Importing in progress, hang tight...</p>
</div>
<div v-else-if="lastMigrationDate">
<p>
It looks like you've already imported your stuff from {{ name }} at {{ formatDate(lastMigrationDate) }}.<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 AbstractMigrationService from "../../services/migrator/abstractMigrationService";
export default {
name: 'migration',
data() {
return {
authUrl: '',
isMigrating: false,
lastMigrationDate: null,
message: '',
wunderlistCode: '',
}
},
props: {
name: {
type: String,
required: true,
},
identifier: {
type: String,
required: true,
},
},
created() {
this.migrationService = new AbstractMigrationService(this.identifier)
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) {
this.lastMigrationDate = new Date(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

@ -32,58 +32,3 @@
}
}
</script>
<style lang="scss">
.modal-mask {
position: fixed;
z-index: 9998;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, .8);
transition: opacity .15s ease;
color: #fff;
.modal-container {
transition: all .15s ease;
position: relative;
width: 100%;
height: 100%;
.modal-content {
text-align: center;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
.header {
font-size: 2rem;
font-weight: 700;
}
.button {
margin: 0 0.5rem;
}
}
}
}
/* Transitions */
.modal-enter {
opacity: 0;
}
.modal-leave-active {
opacity: 0;
}
.modal-enter .modal-container,
.modal-leave-active .modal-container {
-webkit-transform: scale(0.9);
transform: scale(0.9);
}
</style>

View File

@ -1,5 +1,9 @@
<template>
<div class="loader-container" v-bind:class="{ 'is-loading': namespaceService.loading}">
<div class="notification is-warning" v-if="namespace.isArchived">
This namespace is archived.
It is not possible to create new lists or edit it.
</div>
<div class="card">
<header class="card-header">
<p class="card-header-title">
@ -8,29 +12,67 @@
</header>
<div class="card-content">
<div class="content">
<form @submit.prevent="submit()">
<form @submit.prevent="submit()">
<div class="field">
<label class="label" for="namespacetext">Namespace Name</label>
<div class="control">
<input v-focus :class="{ 'disabled': namespaceService.loading}" :disabled="namespaceService.loading" class="input" type="text" id="namespacetext" placeholder="The namespace text is here..." v-model="namespace.name">
<input
v-focus
:class="{ 'disabled': namespaceService.loading}"
:disabled="namespaceService.loading"
class="input"
type="text"
id="namespacetext"
placeholder="The namespace text is here..."
v-model="namespace.title"/>
</div>
</div>
<div class="field">
<label class="label" for="namespacedescription">Description</label>
<div class="control">
<textarea :class="{ 'disabled': namespaceService.loading}" :disabled="namespaceService.loading" class="textarea" placeholder="The namespaces description goes here..." id="namespacedescription" v-model="namespace.description"></textarea>
<textarea
:class="{ 'disabled': namespaceService.loading}"
:disabled="namespaceService.loading"
class="textarea"
placeholder="The namespaces description goes here..."
id="namespacedescription"
v-model="namespace.description"></textarea>
</div>
</div>
<div class="field">
<label class="label" for="isArchivedCheck">Is Archived</label>
<div class="control">
<fancycheckbox
v-model="namespace.isArchived"
v-tooltip="'If a namespace is archived, you cannot create new lists or edit it.'">
This namespace is archived
</fancycheckbox>
</div>
</div>
<div class="field">
<label class="label">Color</label>
<div class="control">
<verte
v-model="namespace.hexColor"
menuPosition="top"
picker="square"
model="hex"
:enableAlpha="false"
:rgbSliders="true"/>
</div>
</div>
</form>
<div class="columns bigbuttons">
<div class="column">
<button @click="submit()" class="button is-primary is-fullwidth" :class="{ 'is-loading': namespaceService.loading}">
<button @click="submit()" class="button is-primary is-fullwidth"
:class="{ 'is-loading': namespaceService.loading}">
Save
</button>
</div>
<div class="column is-1">
<button @click="showDeleteModal = true" class="button is-danger is-fullwidth" :class="{ 'is-loading': namespaceService.loading}">
<button @click="showDeleteModal = true" class="button is-danger is-fullwidth"
:class="{ 'is-loading': namespaceService.loading}">
<span class="icon is-small">
<icon icon="trash-alt"/>
</span>
@ -41,8 +83,18 @@
</div>
</div>
<component :is="manageUsersComponent" :id="namespace.id" type="namespace" shareType="user" :userIsAdmin="userIsAdmin"></component>
<component :is="manageTeamsComponent" :id="namespace.id" type="namespace" shareType="team" :userIsAdmin="userIsAdmin"></component>
<component
:is="manageUsersComponent"
:id="namespace.id"
type="namespace"
shareType="user"
:userIsAdmin="userIsAdmin"/>
<component
:is="manageTeamsComponent"
:id="namespace.id"
type="namespace"
shareType="team"
:userIsAdmin="userIsAdmin"/>
<modal
v-if="showDeleteModal"
@ -56,88 +108,83 @@
</template>
<script>
import auth from '../../auth'
import verte from 'verte'
import 'verte/dist/verte.css'
import router from '../../router'
import message from '../../message'
import manageSharing from '../sharing/userTeam'
import NamespaceService from '../../services/namespace'
import NamespaceModel from '../../models/namespace'
import Fancycheckbox from '../global/fancycheckbox'
export default {
name: "EditNamespace",
data() {
return {
namespaceService: NamespaceService,
userIsAdmin: false,
manageUsersComponent: '',
manageTeamsComponent: '',
namespace: NamespaceModel,
showDeleteModal: false,
user: auth.user,
}
},
components: {
Fancycheckbox,
manageSharing,
verte,
},
beforeMount() {
// Check if the user is already logged in, if so, redirect him to the homepage
if (!auth.user.authenticated) {
router.push({name: 'home'})
}
this.namespace.id = this.$route.params.id
},
created() {
this.namespaceService = new NamespaceService()
this.namespace = new NamespaceModel()
this.loadNamespace()
},
watch: {
// call again the method if the route changes
'$route': 'loadNamespace'
},
computed: {
userIsAdmin() {
return this.namespace.owner && this.namespace.owner.id === this.$store.state.auth.info.id
},
},
methods: {
loadNamespace() {
let namespace = new NamespaceModel({id: this.$route.params.id})
this.namespaceService.get(namespace)
.then(r => {
this.$set(this, 'namespace', r)
if (r.owner.id === this.user.infos.id) {
this.userIsAdmin = true
}
// This will trigger the dynamic loading of components once we actually have all the data to pass to them
this.manageTeamsComponent = 'manageSharing'
this.manageUsersComponent = 'manageSharing'
})
.catch(e => {
message.error(e, this)
this.error(e, this)
})
},
submit() {
this.namespaceService.update(this.namespace)
.then(r => {
// Update the namespace in the parent
for (const n in this.$parent.namespaces) {
if (this.$parent.namespaces[n].id === r.id) {
r.lists = this.$parent.namespaces[n].lists
this.$set(this.$parent.namespaces, n, r)
}
}
message.success({message: 'The namespace was successfully updated.'}, this)
this.$store.commit('namespaces/setNamespaceById', r)
this.success({message: 'The namespace was successfully updated.'}, this)
})
.catch(e => {
message.error(e, this)
this.error(e, this)
})
},
deleteNamespace() {
this.namespaceService.delete(this.namespace)
.then(() => {
message.success({message: 'The namespace was successfully deleted.'}, this)
this.success({message: 'The namespace was successfully deleted.'}, this)
router.push({name: 'home'})
})
.catch(e => {
message.error(e, this)
this.error(e, this)
})
}
}

View File

@ -5,61 +5,70 @@
</icon>
</a>
<h3>Create a new namespace</h3>
<form @submit.prevent="newNamespace" @keyup.esc="back()">
<div class="field is-grouped">
<p class="control is-expanded" v-bind:class="{ 'is-loading': namespaceService.loading}">
<input v-focus class="input" v-bind:class="{ 'disabled': namespaceService.loading}" v-model="namespace.name" type="text" placeholder="The namespace's name goes here...">
</p>
<p class="control">
<button type="submit" class="button is-success noshadow">
<div class="field is-grouped">
<p class="control is-expanded" v-bind:class="{ 'is-loading': namespaceService.loading}">
<input v-focus
class="input"
v-bind:class="{ 'disabled': namespaceService.loading}"
v-model="namespace.title"
type="text"
@keyup.enter="newNamespace()"
@keyup.esc="back()"
placeholder="The namespace's name goes here..."/>
</p>
<p class="control">
<button class="button is-success noshadow" @click="newNamespace()" :disabled="namespace.title.length <= 5">
<span class="icon is-small">
<icon icon="plus"/>
</span>
Add
</button>
</p>
</div>
</form>
<p class="small" v-tooltip.bottom="'A namespace is a collection of lists you can share and use to organize your lists with.<br/>In fact, every list belongs to a namepace.'">What's a namespace?</p>
Add
</button>
</p>
</div>
<p class="help is-danger" v-if="showError && namespace.title.length <= 5">
Please specify at least five characters.
</p>
<p class="small" v-tooltip.bottom="'A namespace is a collection of lists you can share and use to organize your lists with.<br/>In fact, every list belongs to a namepace.'">
What's a namespace?</p>
</div>
</template>
<script>
import auth from '../../auth'
import router from '../../router'
import message from '../../message'
import NamespaceModel from "../../models/namespace";
import NamespaceService from "../../services/namespace";
import {IS_FULLPAGE} from '../../store/mutation-types'
export default {
name: "NewNamespace",
data() {
return {
showError: false,
namespace: NamespaceModel,
namespaceService: NamespaceService,
}
},
beforeMount() {
// Check if the user is already logged in, if so, redirect him to the homepage
if (!auth.user.authenticated) {
router.push({name: 'home'})
}
},
created() {
this.namespace = new NamespaceModel()
this.namespaceService = new NamespaceService()
this.$parent.setFullPage();
this.$store.commit(IS_FULLPAGE, true)
},
methods: {
newNamespace() {
if (this.namespace.title.length <= 4) {
this.showError = true
return
}
this.showError = false
this.namespaceService.create(this.namespace)
.then(() => {
this.$parent.loadNamespaces()
message.success({message: 'The namespace was successfully created.'}, this)
router.push({name: 'home'})
.then(r => {
this.$store.commit('namespaces/addNamespace', r)
this.success({message: 'The namespace was successfully created.'}, this)
router.back()
})
.catch(e => {
message.error(e, this)
this.error(e, this)
})
},
back() {

View File

@ -1,195 +1,194 @@
<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">
<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>
<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.sharedBy.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>
<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 message from '../../message'
import rights from '../../models/rights'
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'
import {mapState} from 'vuex'
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()
}
},
computed: mapState({
frontendUrl: state => state.config.frontendUrl,
}),
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 => {
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.$config.frontend_url + 'share/' + hash + '/auth'
},
},
}
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.frontendUrl + 'share/' + hash + '/auth'
},
},
}
</script>

View File

@ -1,40 +1,38 @@
<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 message from '../../message'
import router from '../../router'
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)
})
}
},
}
export default {
name: 'linkSharingAuth',
data() {
return {
hash: '',
loading: true,
}
},
created() {
this.auth()
},
methods: {
auth() {
this.$store.dispatch('auth/linkShareAuth', this.$route.params.share)
.then((r) => {
this.loading = false
router.push({name: 'list.list', params: {listId: r.list_id}})
})
.catch(e => {
this.error(e, this)
})
}
},
}
</script>

View File

@ -2,7 +2,7 @@
<div class="card is-fullwidth">
<header class="card-header">
<p class="card-header-title">
{{shareType}}s with access to this {{typeString}}
Shared with these {{shareType}}s
</p>
</header>
<div class="card-content content sharables-list">
@ -42,7 +42,7 @@
<template v-if="shareType === 'user'">
<td>{{s.username}}</td>
<td>
<template v-if="s.id === currentUser.id">
<template v-if="s.id === userInfo.id">
<b class="is-success">You</b>
</template>
</td>
@ -76,13 +76,13 @@
</td>
<td class="actions" v-if="userIsAdmin">
<div class="select">
<select @change="sharableID = s.id;toggleType()" v-model="selectedRight" class="button buttonright">
<select @change="toggleType(s)" v-model="selectedRight[s.id]" class="button buttonright">
<option :value="rights.READ" :selected="s.right === rights.READ">Read only</option>
<option :value="rights.READ_WRITE" :selected="s.right === rights.READ_WRITE">Read & write</option>
<option :value="rights.ADMIN" :selected="s.right === rights.ADMIN">Admin</option>
</select>
</div>
<button @click="sharableID = s.id; showDeleteModal = true" class="button is-danger icon-only">
<button @click="() => {sharable = s; showDeleteModal = true}" class="button is-danger icon-only">
<span class="icon is-small">
<icon icon="trash-alt"/>
</span>
@ -105,9 +105,8 @@
</template>
<script>
import auth from '../../auth'
import message from '../../message'
import multiselect from 'vue-multiselect'
import {mapState} from 'vuex'
import UserNamespaceService from '../../services/userNamespace'
import UserNamespaceModel from '../../models/userNamespace'
@ -128,10 +127,22 @@
export default {
name: 'userTeamShare',
props: {
type: '',
shareType: '',
id: 0,
userIsAdmin: false,
type: {
type: String,
default: '',
},
shareType: {
type: String,
default: '',
},
id: {
type: Number,
default: 0,
},
userIsAdmin: {
type: Boolean,
default: false,
},
},
data() {
return {
@ -139,14 +150,12 @@
stuffModel: Object,
searchService: Object,
sharable: Object,
sharableID: 0, // This holds either user or team id for stuff like rights update or deleting
found: [],
searchLabel: '',
rights: rights,
selectedRight: rights.READ,
selectedRight: {},
currentUser: auth.user.infos,
typeString: '',
sharables: [], // This holds either teams or users who this namepace or list is shared with
showDeleteModal: false,
@ -155,6 +164,9 @@
components: {
multiselect
},
computed: mapState({
userInfo: state => state.auth.info
}),
created() {
if (this.shareType === 'user') {
@ -165,11 +177,11 @@
if (this.type === 'list') {
this.typeString = `list`
this.stuffService = new UserListService()
this.stuffModel = new UserListModel({listID: this.id})
this.stuffModel = new UserListModel({listId: this.id})
} else if (this.type === 'namespace') {
this.typeString = `namespace`
this.stuffService = new UserNamespaceService()
this.stuffModel = new UserNamespaceModel({namespaceID: this.id})
this.stuffModel = new UserNamespaceModel({namespaceId: this.id})
} else {
throw new Error('Unknown type: ' + this.type)
}
@ -182,11 +194,11 @@
if (this.type === 'list') {
this.typeString = `list`
this.stuffService = new TeamListService()
this.stuffModel = new TeamListModel({listID: this.id})
this.stuffModel = new TeamListModel({listId: this.id})
} else if (this.type === 'namespace') {
this.typeString = `namespace`
this.stuffService = new TeamNamespaceService()
this.stuffModel = new TeamNamespaceModel({namespaceID: this.id})
this.stuffModel = new TeamNamespaceModel({namespaceId: this.id})
} else {
throw new Error('Unknown type: ' + this.type)
}
@ -201,33 +213,34 @@
this.stuffService.getAll(this.stuffModel)
.then(r => {
this.$set(this, 'sharables', r)
r.forEach(s => this.$set(this.selectedRight, s.id, s.right))
})
.catch(e => {
message.error(e, this)
this.error(e, this)
})
},
deleteSharable() {
if (this.shareType === 'user') {
this.stuffModel.userID = this.sharable.id
this.stuffModel.userId = this.sharable.username
} else if (this.shareType === 'team') {
this.stuffModel.teamID = this.sharable.id
this.stuffModel.teamId = this.sharable.id
}
this.stuffService.delete(this.stuffModel)
.then(() => {
this.showDeleteModal = false
for (const i in this.sharables) {
if (
(this.sharables[i].id === this.stuffModel.userID && this.shareType === 'user') ||
(this.sharables[i].id === this.stuffModel.teamID && this.shareType === 'team')
(this.sharables[i].id === this.stuffModel.userId && this.shareType === 'user') ||
(this.sharables[i].id === this.stuffModel.teamId && this.shareType === 'team')
) {
this.sharables.splice(i, 1)
}
}
message.success({message: 'The ' + this.shareType + ' was successfully deleted from the ' + this.typeString + '.'}, this)
this.success({message: 'The ' + this.shareType + ' was successfully deleted from the ' + this.typeString + '.'}, this)
})
.catch(e => {
message.error(e, this)
this.error(e, this)
})
},
add(admin) {
@ -240,50 +253,50 @@
}
if (this.shareType === 'user') {
this.stuffModel.userID = this.sharable.username
this.stuffModel.userId = this.sharable.username
} else if (this.shareType === 'team') {
this.stuffModel.teamID = this.sharable.id
this.stuffModel.teamId = this.sharable.id
}
this.stuffService.create(this.stuffModel)
.then(() => {
message.success({message: 'The ' + this.shareType + ' was successfully added.'}, this)
this.success({message: 'The ' + this.shareType + ' was successfully added.'}, this)
this.load()
})
.catch(e => {
message.error(e, this)
this.error(e, this)
})
},
toggleType() {
if (this.selectedRight !== rights.ADMIN &&
this.selectedRight !== rights.READ &&
this.selectedRight !== rights.READ_WRITE
toggleType(sharable) {
if (this.selectedRight[sharable.id] !== rights.ADMIN &&
this.selectedRight[sharable.id] !== rights.READ &&
this.selectedRight[sharable.id] !== rights.READ_WRITE
) {
this.selectedRight = rights.READ
this.selectedRight[sharable.id] = rights.READ
}
this.stuffModel.right = this.selectedRight
this.stuffModel.right = this.selectedRight[sharable.id]
if (this.shareType === 'user') {
this.stuffModel.userID = this.sharableID
this.stuffModel.userId = sharable.username
} else if (this.shareType === 'team') {
this.stuffModel.teamID = this.sharableID
this.stuffModel.teamId = sharable.id
}
this.stuffService.update(this.stuffModel)
.then(r => {
for (const i in this.sharables) {
if (
(this.sharables[i].id === this.stuffModel.userID && this.shareType === 'user') ||
(this.sharables[i].id === this.stuffModel.teamID && this.shareType === 'team')
(this.sharables[i].username === this.stuffModel.userId && this.shareType === 'user') ||
(this.sharables[i].id === this.stuffModel.teamId && this.shareType === 'team')
) {
this.$set(this.sharables[i], 'right', r.right)
}
}
message.success({message: 'The ' + this.shareType + ' right was successfully updated.'}, this)
this.success({message: 'The ' + this.shareType + ' right was successfully updated.'}, this)
})
.catch(e => {
message.error(e, this)
this.error(e, this)
})
},
find(query) {
@ -297,7 +310,7 @@
this.$set(this, 'found', response)
})
.catch(e => {
message.error(e, this)
this.error(e, this)
})
},
clearAll () {

View File

@ -1,174 +0,0 @@
<template>
<div class="loader-container" :class="{ 'is-loading': listService.loading}">
<form @submit.prevent="addTask()">
<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">
<icon icon="tasks"/>
</span>
</p>
<p class="control">
<button type="submit" class="button is-success">
<span class="icon is-small">
<icon icon="plus"/>
</span>
Add
</button>
</p>
</div>
</form>
<div class="columns">
<div class="column">
<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">
<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': 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="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="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>
</div>
</div>
</div>
</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 priorities from '../../models/priorities'
export default {
data() {
return {
listID: this.$route.params.id,
listService: ListService,
taskService: TaskService,
list: {},
isTaskEdit: false,
taskEditTask: TaskModel,
newTaskText: '',
priorities: {},
}
},
components: {
EditTask,
},
props: {
theList: {
type: ListModel,
required: true,
}
},
watch: {
theList() {
this.list = this.theList
}
},
created() {
this.listService = new ListService()
this.taskService = new TaskService()
this.priorities = priorities
this.taskEditTask = null
this.isTaskEdit = false
},
methods: {
addTask() {
let task = new TaskModel({text: this.newTaskText, listID: this.$route.params.id})
this.taskService.create(task)
.then(r => {
this.list.addTaskToList(r)
this.newTaskText = ''
message.success({message: 'The task was successfully created.'}, this)
})
.catch(e => {
message.error(e, this)
})
},
markAsDone(e) {
let updateFunc = () => {
// We get the task, update the 'done' property and then push it to the api.
let task = this.list.getTaskByID(e.target.id)
task.done = e.target.checked
this.taskService.update(task)
.then(() => {
this.list.sortTasks()
message.success({message: 'The task was successfully ' + (task.done ? '' : 'un-') + 'marked as done.'}, this)
})
.catch(e => {
message.error(e, this)
})
}
if (e.target.checked) {
setTimeout(updateFunc(), 300); // Delay it to show the animation when marking a task as done
} else {
updateFunc() // Don't delay it when un-marking it as it doesn't have an animation the other way around
}
},
editTask(id) {
// Find the selected task and set it to the current object
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
},
gravatar(user) {
return 'https://www.gravatar.com/avatar/' + user.avatarUrl + '?s=27'
},
}
}
</script>

View File

@ -3,56 +3,31 @@
<h3 v-if="showAll">Current tasks</h3>
<h3 v-else>Tasks from {{startDate.toLocaleDateString()}} until {{endDate.toLocaleDateString()}}</h3>
<template v-if="!taskService.loading && (!hasUndoneTasks || !tasks)">
<h3 class="nothing">Nothing to to - Have a nice day!</h3>
<h3 class="nothing">Nothing to do - Have a nice day!</h3>
<img src="/images/cool.svg" alt=""/>
</template>
<div class="spinner" :class="{ 'is-loading': taskService.loading}"></div>
<div class="tasks" v-if="tasks && tasks.length > 0">
<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>
<label :for="l.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">
{{l.text}}
<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 class="task" v-for="t in tasks" :key="t.id">
<single-task-in-list :the-task="t" @taskUpdated="updateTasks" :show-list="true"/>
</div>
</div>
</div>
</template>
<script>
import router from '../../router'
import message from '../../message'
import TaskService from '../../services/task'
import priorities from '../../models/priorities'
import SingleTaskInList from "./reusable/singleTaskInList";
export default {
name: "ShowTasks",
name: 'ShowTasks',
components: {
SingleTaskInList,
},
data() {
return {
tasks: [],
hasUndoneTasks: false,
taskService: TaskService,
priorities: priorities,
}
},
props: {
@ -64,12 +39,31 @@
this.taskService = new TaskService()
this.loadPendingTasks()
},
watch: {
'$route': 'loadPendingTasks',
},
methods: {
loadPendingTasks() {
let params = {'sort': 'duedate'}
const params = {
sort_by: ['due_date_unix', 'id'],
order_by: ['desc', 'desc'],
filter_by: ['done'],
filter_value: [false],
filter_comparator: ['equals'],
filter_concat: 'and',
}
if (!this.showAll) {
params.startdate = Math.round(+ this.startDate / 1000)
params.enddate = Math.round(+ this.endDate / 1000)
params.filter_by.push('start_date')
params.filter_value.push(Math.round(+ this.startDate / 1000))
params.filter_comparator.push('greater')
params.filter_by.push('end_date')
params.filter_value.push(Math.round(+ this.endDate / 1000))
params.filter_comparator.push('less')
params.filter_by.push('due_date')
params.filter_value.push(Math.round(+ this.endDate / 1000))
params.filter_comparator.push('less')
}
this.taskService.getAll({}, params)
@ -80,22 +74,38 @@
this.hasUndoneTasks = true
}
}
r.sort(this.sortyByDeadline)
}
this.$set(this, 'tasks', r)
this.$set(this, 'tasks', r.filter(t => !t.done))
})
.catch(e => {
message.error(e, this)
this.error(e, this)
})
},
formatUnixDate(dateUnix) {
return (new Date(dateUnix * 1000)).toLocaleString()
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
})
},
sortyByDeadline(a, b) {
return ((a.dueDate > b.dueDate) ? -1 : ((a.dueDate < b.dueDate) ? 1 : 0));
},
gotoList(lid) {
router.push({name: 'showList', params: {id: lid}})
updateTasks(updatedTask) {
for (const t in this.tasks) {
if (this.tasks[t].id === updatedTask.id) {
this.$set(this.tasks, t, updatedTask)
break
}
}
this.sortTasks()
},
},
}

View File

@ -1,6 +1,6 @@
<template>
<div class="content has-text-centered">
<TaskOverview
<ShowTasks
:start-date="startDate"
:end-date="endDate"
/>
@ -8,8 +8,13 @@
</template>
<script>
import ShowTasks from './ShowTasks'
export default {
name: "ShowTasksInRange",
name: 'ShowTasksInRange',
components: {
ShowTasks,
},
data() {
return {
startDate: new Date(this.$route.params.startDateUnix),

View File

@ -0,0 +1,529 @@
<template>
<div class="loader-container" :class="{ 'is-loading': taskService.loading}">
<div class="task-view">
<div class="heading">
<h1 class="title task-id" v-if="task.identifier === ''">
#{{ task.index }}
</h1>
<h1 class="title task-id" v-else>
{{ task.identifier }}
</h1>
<div class="is-done" v-if="task.done">Done</div>
<h1 class="title input" contenteditable="true" @focusout="saveTaskOnChange()" ref="taskTitle"
@keyup.ctrl.enter="saveTaskOnChange()">{{ task.title }}</h1>
</div>
<h6 class="subtitle" v-if="parent && parent.namespace && parent.list">
{{ parent.namespace.title }} >
<router-link :to="{ name: 'list.list', params: { listId: parent.list.id } }">
{{ parent.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-id="task.id"
:list-id="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="dueDate"
:config="flatPickerConfig"
@on-close="saveTask"
placeholder="Click here to set a due date"
ref="dueDate"
>
</flat-pickr>
<a v-if="dueDate" @click="() => {dueDate = 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-id="taskId" v-model="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"
rows="6"
placeholder="Click here to enter a description..."
@keyup.ctrl.enter="saveTaskIfDescriptionChanged"
@keydown="setDescriptionChanged"
@change="saveTaskIfDescriptionChanged"
></textarea>
</div>
<!-- Attachments -->
<div class="content attachments has-top-border" v-if="activeFields.attachments">
<attachments
:task-id="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-id="taskId"
:list-id="task.listId"
:initial-related-tasks="task.relatedTasks"
:show-no-relations-notice="true"
ref="relatedTasks"
/>
</div>
<!-- Move Task -->
<div class="content details has-top-border" v-if="activeFields.moveList">
<h3>
<span class="icon is-grey">
<icon icon="list"/>
</span>
Move task to a different list
</h3>
<div class="field has-addons">
<div class="control is-expanded">
<list-search @selected="changeList"/>
</div>
</div>
</div>
<!-- Comments -->
<comments :task-id="taskId"/>
</div>
<div class="column is-one-third 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" @click="setFieldActive('moveList')">
<span class="icon is-small"><icon icon="list"/></span>
Move task
</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 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 Comments from './reusable/comments'
import router from '../../router'
import ListSearch from './reusable/listSearch'
export default {
name: 'TaskDetailView',
components: {
ListSearch,
Reminders,
RepeatAfter,
RelatedTasks,
Attachments,
EditAssignees,
EditLabels,
PercentDoneSelect,
PrioritySelect,
Comments,
flatPickr,
},
data() {
return {
taskId: Number(this.$route.params.id),
taskService: TaskService,
task: TaskModel,
relationKinds: relationKinds,
// The due date is a seperate property in the task to prevent flatpickr from modifying the task model
// in store right after updating it from the api resulting in the wrong due date format being saved in the task.
dueDate: null,
showDeleteModal: false,
taskTitle: '',
descriptionChanged: false,
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,
moveList: false,
},
}
},
watch: {
'$route': 'loadTask'
},
created() {
this.taskService = new TaskService()
this.task = new TaskModel()
},
mounted() {
this.loadTask()
},
computed: {
parent() {
if(!this.task.listId) {
return {
namespace: null,
list: null,
}
}
if(!this.$store.getters["namespaces/getListAndNamespaceById"]) {
return null
}
return this.$store.getters["namespaces/getListAndNamespaceById"](this.task.listId)
},
},
methods: {
loadTask() {
this.taskId = Number(this.$route.params.id)
this.taskService.get({id: this.taskId})
.then(r => {
this.$set(this, 'task', r)
this.taskTitle = this.task.title
this.setActiveFields()
})
.catch(e => {
this.error(e, this)
})
},
setActiveFields() {
this.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
// On chrome, reminderDates.length holds the actual number of reminders that are not null.
// Unlike on desktop where it holds all reminders, including the ones which are null.
// This causes the reminders to dissapear entierly when only one is set and the user is on mobile.
this.activeFields.reminders = this.task.reminderDates.length > 1 || (window.innerWidth < 769 && this.task.reminderDates.length > 0)
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.relatedTasks).length > 0
},
saveTaskOnChange() {
this.$refs.taskTitle.spellcheck = false
// Pull the task title from the contenteditable
let taskTitle = this.$refs.taskTitle.textContent
this.task.title = 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.title !== this.taskTitle) {
this.saveTask()
this.taskTitle = taskTitle
}
},
saveTask(undoCallback = null) {
this.task.dueDate = this.dueDate
// 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.$store.dispatch('tasks/update', this.task)
.then(r => {
this.$set(this, 'task', r)
let actions = []
if (undoCallback !== null) {
actions = [{
title: 'Undo',
callback: undoCallback,
}]
}
this.dueDate = this.task.dueDate
this.success({message: 'The task was saved successfully.'}, this, actions)
this.setActiveFields()
})
.catch(e => {
this.error(e, this)
})
},
setFieldActive(fieldName) {
this.activeFields[fieldName] = true
this.$nextTick(() => this.$refs[fieldName].$el.focus())
},
deleteTask() {
this.$store.dispatch('tasks/delete', this.task)
.then(() => {
this.success({message: 'The task been deleted successfully.'}, this)
router.back()
})
.catch(e => {
this.error(e, this)
})
},
toggleTaskDone() {
this.task.done = !this.task.done
this.saveTask(() => this.toggleTaskDone())
},
setDescriptionChanged(e) {
if (e.key === 'Enter' || e.key === 'Control') {
return
}
this.descriptionChanged = true
},
saveTaskIfDescriptionChanged() {
// We want to only save the description if it was changed.
// Since we can either trigger this with ctrl+enter or @change, it would be possible to save a task first
// with ctrl+enter and then with @change although nothing changed since the last save when @change gets fired.
// To only save one time we added this method.
if (this.descriptionChanged) {
this.descriptionChanged = false
this.saveTask()
}
},
changeList(list) {
this.task.listId = list.id
this.saveTask()
}
},
}
</script>

View File

@ -0,0 +1,29 @@
<template>
<div class="modal-mask">
<div class="modal-container" @click.self="close()">
<div class="scrolling-content">
<a @click="close()" class="close">
<icon icon="times"/>
</a>
<task-detail-view/>
</div>
</div>
</div>
</template>
<script>
import TaskDetailView from './TaskDetailView'
import router from '../../router'
export default {
name: 'TaskDetailViewModal',
components: {
TaskDetailView,
},
methods: {
close() {
router.back()
},
},
}
</script>

View File

@ -1,459 +1,234 @@
<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">
</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>
<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>
<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>
<b>Reminder Dates</b>
<reminders v-model="taskEditTask.reminderDates" @change="editTaskSubmit()"/>
<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="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="">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="">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="">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="">Repeat after</label>
<repeat-after v-model="taskEditTask.repeatAfter" @change="editTaskSubmit()"/>
</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" for="">Priority</label>
<div class="control priority-select">
<priority-select v-model="taskEditTask.priority" @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">Percent Done</label>
<div class="control">
<percent-done-select v-model="taskEditTask.percentDone" @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">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 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">
<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">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 has-addons">
<div class="control is-expanded">
<edit-assignees :task-id="taskEditTask.id" :list-id="taskEditTask.listId" :initial-assignees="taskEditTask.assignees"/>
</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>
<div class="field">
<label class="label">Labels</label>
<div class="control">
<edit-labels :task-id="taskEditTask.id" v-model="taskEditTask.labels"/>
</div>
</div>
<button type="submit" class="button is-success is-fullwidth" :class="{ 'is-loading': taskService.loading}">
Save
</button>
<related-tasks
class="is-narrow"
:task-id="task.id"
:list-id="task.listId"
:initial-related-tasks="task.relatedTasks"
/>
</form>
<button type="submit" class="button is-success is-fullwidth" :class="{ 'is-loading': taskService.loading}">
Save
</button>
</form>
</template>
<script>
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 flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
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 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'
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'
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,
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)
})
}
},
}
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)
})
},
},
}
</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"
@ -44,29 +44,19 @@
:parentW="fullWidth"
@resizestop="resizeTask"
@dragstop="resizeTask"
@clicked="taskDragged = t"
@clicked="setTaskDragged(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>
<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>
<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.title}}</span>
<priority-label :priority="t.priority"/>
<!-- 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)'}">
@ -85,10 +75,10 @@
:parentW="fullWidth"
@resizestop="resizeTask"
@dragstop="resizeTask"
@clicked="taskDragged = t"
@clicked="setTaskDragged(t)"
v-tooltip="'This task has no dates set.'"
>
<span>{{t.text}}</span>
<span>{{t.title}}</span>
</VueDragResize>
</div>
</template>
@ -118,7 +108,7 @@
<p class="card-header-title">
Edit Task
</p>
<a class="card-header-icon" @click="isTaskEdit = false;taskToEdit = null">
<a class="card-header-icon" @click="() => {isTaskEdit = false; taskToEdit = null}">
<span class="icon">
<icon icon="times"/>
</span>
@ -136,23 +126,24 @@
<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'
import TaskCollectionService from '../../services/taskCollection'
export default {
name: 'GanttChart',
components: {
PriorityLabel,
EditTask,
VueDragResize,
},
props: {
list: {
type: ListModel,
listId: {
type: Number,
required: true,
},
showTaskswithoutDates: {
@ -183,28 +174,26 @@
fullWidth: 0,
now: null,
dayOffsetUntilToday: 0,
isTaskEdit: false,
isTaskEdit: false,
taskToEdit: null,
newTaskTitle: '',
newTaskFieldActive: false,
priorities: {},
taskCollectionService: TaskCollectionService,
}
},
watch: {
list() {
this.parseTasks()
},
dateFrom() {
this.buildTheGanttChart()
},
dateTo() {
this.buildTheGanttChart()
},
'dateFrom': 'buildTheGanttChart',
'dateTo': 'buildTheGanttChart',
'listId': 'parseTasks',
},
beforeMount() {
created() {
this.now = new Date()
this.taskCollectionService = new TaskCollectionService()
this.taskService = new TaskService()
this.priorities = priorities
},
mounted() {
this.buildTheGanttChart()
},
methods: {
@ -240,22 +229,46 @@
this.prepareTasks()
},
prepareTasks() {
this.theTasks = this.list.tasks
.filter(t => {
if(t.startDate === null && !t.done) {
this.tasksWithoutDates.push(t)
}
return t.startDate >= this.startDate && t.endDate <= this.endDate
const getAllTasks = (page = 1) => {
return this.taskCollectionService.getAll({listId: this.listId}, {}, page)
.then(tasks => {
if(page < this.taskCollectionService.totalPages) {
return getAllTasks(page + 1)
.then(nextTasks => {
return tasks.concat(nextTasks)
})
} else {
return tasks
}
})
.catch(e => {
return Promise.reject(e)
})
}
getAllTasks()
.then(tasks => {
this.theTasks = tasks
.filter(t => {
if(t.startDate === null && !t.done) {
this.tasksWithoutDates.push(t)
}
return t.startDate >= this.startDate && t.endDate <= this.endDate
})
.map(t => {
return this.addGantAttributes(t)
})
.sort(function(a,b) {
if (a.startDate < b.startDate)
return -1
if (a.startDate > b.startDate)
return 1
return 0
})
})
.map(t => {
return this.addGantAttributes(t)
})
.sort(function(a,b) {
if (a.startDate < b.startDate)
return -1
if (a.startDate > b.startDate)
return 1
return 0
.catch(e => {
this.error(e, this)
})
},
addGantAttributes(t) {
@ -264,6 +277,9 @@
t.offsetDays = Math.floor((t.startDate - this.startDate) / 1000 / 60 / 60 / 24) + 1
return t
},
setTaskDragged(t) {
this.taskDragged = t
},
resizeTask(newRect) {
// Timeout to definitly catch if the user clicked on taskedit
@ -287,6 +303,17 @@
this.taskDragged.startDate = startDate
this.taskDragged.endDate = endDate
// We take the task from the overall tasks array because the one in it has bad data after it was updated once.
// FIXME: This is a workaround. We should use a better mechanism to get the task or, even better,
// prevent it from containing outdated Data in the first place.
for (const tt in this.theTasks) {
if (this.theTasks[tt].id === this.taskDragged.id) {
this.$set(this, 'taskDragged', this.theTasks[tt])
break
}
}
this.taskService.update(this.taskDragged)
.then(r => {
// If the task didn't have dates before, we'll update the list
@ -294,28 +321,30 @@
for (const t in this.tasksWithoutDates) {
if (this.tasksWithoutDates[t].id === r.id) {
this.tasksWithoutDates.splice(t, 1)
break
}
}
this.theTasks.push(this.addGantAttributes(r))
} else {
for (const tt in this.theTasks) {
if (this.theTasks[tt].id === r.id) {
this.theTasks[tt] = this.addGantAttributes(r)
this.$set(this.theTasks, tt, this.addGantAttributes(r))
break
}
}
}
message.success({message: 'The task was successfully updated.'}, this)
this.success({message: 'The task was successfully updated.'}, this)
})
.catch(e => {
message.error(e, this)
this.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
@ -334,16 +363,16 @@
if (!this.newTaskFieldActive) {
return
}
let task = new TaskModel({text: this.newTaskTitle, listID: this.list.id})
let task = new TaskModel({title: this.newTaskTitle, listId: this.listId})
this.taskService.create(task)
.then(r => {
this.tasksWithoutDates.push(this.addGantAttributes(r))
this.newTaskTitle = ''
this.hideCrateNewTask()
message.success({message: 'The task was successfully created.'}, this)
this.success({message: 'The task was successfully created.'}, this)
})
.catch(e => {
message.error(e, this)
this.error(e, this)
})
},
},

View File

@ -0,0 +1,147 @@
import TaskCollectionService from '../../../services/taskCollection'
/**
* This mixin provides a base set of methods and properties to get tasks on a list.
*/
export default {
data() {
return {
taskCollectionService: TaskCollectionService,
tasks: [],
pages: [],
currentPage: 0,
showTaskSearch: false,
searchTerm: '',
}
},
watch: {
'$route.query': 'loadTasksForPage', // Only listen for query path changes
},
beforeMount() {
// Triggering loading the tasks in beforeMount lets the component maintain the current page, therefore the page
// is not lost after navigating back from a task detail page for example.
this.loadTasksForPage(this.$route.query)
},
created() {
this.taskCollectionService = new TaskCollectionService()
},
methods: {
loadTasks(page, search = '', params = {sort_by: ['done', 'id'], order_by: ['asc', 'desc']}) {
// Because this function is triggered every time on navigation, we're putting a condition here to only load it when we actually want to show tasks
// FIXME: This is a bit hacky -> Cleanup.
if (
this.$route.name !== 'list.list' &&
this.$route.name !== 'list.table'
) {
return
}
this.$set(this, 'tasks', [])
if (search !== '') {
params.s = search
}
this.taskCollectionService.getAll({listId: this.$route.params.listId}, 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 = Number(e.page)
if (typeof e.page === 'undefined') {
page = 1
}
let search = e.search
if (typeof e.search === 'undefined') {
search = ''
}
this.initTasks(page, search)
},
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() {
// Only search if the search term changed
if (this.$route.query === this.searchTerm) {
return
}
this.$router.push({
name: 'list.list',
query: {search: 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)
},
getRouteForPagination(page = 1, type = 'list') {
return {
name: 'list.' + type,
params: {
type: type
},
query: {
page: page,
},
}
}
}
}

View File

@ -0,0 +1,197 @@
<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>Created By</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><user :user="a.createdBy" :avatar-size="30"/></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'
import User from '../../global/user'
export default {
name: 'attachments',
components: {
User,
},
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({taskId: 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)
this.$store.dispatch('tasks/addTaskAttachment', {taskId: this.taskId, attachment: 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

@ -0,0 +1,185 @@
<template>
<div class="content details has-top-border">
<h1>
<span class="icon is-grey">
<icon :icon="['far', 'comments']"/>
</span>
Comments
</h1>
<div class="comments">
<progress class="progress is-small is-info" max="100" v-if="taskCommentService.loading">Loading comments...</progress>
<div class="media comment" v-for="c in comments" :key="c.id">
<figure class="media-left">
<img class="image is-avatar" :src="c.author.getAvatarUrl(48)" alt="" width="48" height="48"/>
</figure>
<div class="media-content">
<div class="form" v-if="isCommentEdit && commentEdit.id === c.id">
<div class="field">
<textarea class="textarea" :class="{'is-loading': taskCommentService.loading}" placeholder="Add your comment..." v-model="commentEdit.comment" @keyup.ctrl.enter="editComment()"></textarea>
</div>
<div class="field">
<button class="button is-primary" :class="{'is-loading': taskCommentService.loading}" @click="editComment()" :disabled="commentEdit.comment === ''">Comment</button>
<a @click="() => isCommentEdit = false">Cancel</a>
</div>
</div>
<div class="content" v-else>
<strong>{{ c.author.username }}</strong>&nbsp;
<small v-tooltip="formatDate(c.created)">{{ formatDateSince(c.created) }}</small>
<small v-if="+new Date(c.created) !== +new Date(c.updated)" v-tooltip="formatDate(c.updated)"> · edited {{ formatDateSince(c.updated) }}</small>
<br/>
<p>
{{c.comment}}
</p>
<div class="comment-actions">
<a @click="toggleEdit(c)">Edit</a>&nbsp;·&nbsp;
<a @click="toggleDelete(c.id)">Remove</a>
</div>
</div>
</div>
</div>
<div class="media comment">
<figure class="media-left">
<img class="image is-avatar" :src="userAvatar" alt="" width="48" height="48"/>
</figure>
<div class="media-content">
<div class="form">
<div class="field">
<textarea class="textarea" :class="{'is-loading': taskCommentService.loading && !isCommentEdit}" placeholder="Add your comment..." v-model="newComment.comment" @keyup.ctrl.enter="addComment()"></textarea>
</div>
<div class="field">
<button class="button is-primary" :class="{'is-loading': taskCommentService.loading && !isCommentEdit}" @click="addComment()" :disabled="newComment.comment === ''">Comment</button>
</div>
</div>
</div>
</div>
</div>
<modal
v-if="showDeleteModal"
@close="showDeleteModal = false"
@submit="deleteComment()">
<span slot="header">Delete this comment</span>
<p slot="text">Are you sure you want to delete this comment?
<br/>This <b>CANNOT BE UNDONE!</b></p>
</modal>
</div>
</template>
<script>
import TaskCommentService from '../../../services/taskComment'
import TaskCommentModel from '../../../models/taskComment'
export default {
name: 'comments',
props: {
taskId: {
type: Number,
required: true,
}
},
data() {
return {
comments: [],
showDeleteModal: false,
commentToDelete: TaskCommentModel,
isCommentEdit: false,
commentEdit: TaskCommentModel,
taskCommentService: TaskCommentService,
newComment: TaskCommentModel,
}
},
created() {
this.taskCommentService = new TaskCommentService()
this.newComment = new TaskCommentModel({taskId: this.taskId})
this.commentEdit = new TaskCommentModel({taskId: this.taskId})
this.commentToDelete = new TaskCommentModel({taskId: this.taskId})
this.comments = []
},
mounted() {
this.loadComments()
},
watch: {
taskId() {
this.loadComments()
}
},
computed: {
userAvatar() {
return this.$store.state.auth.info.getAvatarUrl(48)
},
},
methods: {
loadComments() {
this.taskCommentService.getAll({taskId: this.taskId})
.then(r => {
this.$set(this, 'comments', r)
})
.catch(e => {
this.error(e, this)
})
},
addComment() {
if (this.newComment.comment === '') {
return
}
this.taskCommentService.create(this.newComment)
.then(r => {
this.comments.push(r)
this.success({message: 'The comment was sucessfully added.'}, this)
this.newComment.comment = ''
})
.catch(e => {
this.error(e, this)
})
},
toggleEdit(comment) {
this.isCommentEdit = !this.isCommentEdit
this.commentEdit = comment
},
toggleDelete(commentId) {
this.showDeleteModal = !this.showDeleteModal
this.commentToDelete.id = commentId
},
editComment() {
if (this.commentEdit.comment === '') {
return
}
this.commentEdit.taskId = this.taskId
this.taskCommentService.update(this.commentEdit)
.then(r => {
for (const c in this.comments) {
if (this.comments[c].id === this.commentEdit.id) {
this.$set(this.comments, c, r)
}
}
this.success({message: 'The comment was successfully updated.'}, this)
})
.catch(e => {
this.error(e, this)
})
.finally(() => {
this.isCommentEdit = false
})
},
deleteComment() {
this.taskCommentService.delete(this.commentToDelete)
.then(r => {
for (const a in this.comments) {
if (this.comments[a].id === this.commentToDelete.id) {
this.comments.splice(a, 1)
}
}
this.success(r, this)
})
.catch(e => {
this.error(e, this)
})
.finally(() => {
this.showDeleteModal = false
})
},
},
}
</script>

View File

@ -0,0 +1,17 @@
<template>
<td v-tooltip="+date === 0 ? '' : formatDate(date)">
{{ +date === 0 ? '-' : formatDateSince(date) }}
</td>
</template>
<script>
export default {
name: 'date-table-cell',
props: {
date: {
type: Date,
default: 0,
}
},
}
</script>

View File

@ -0,0 +1,131 @@
<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 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) {
this.$store.dispatch('tasks/addAssignee', {user: user, taskId: this.taskId})
.then(() => {
this.success({message: 'The user was successfully assigned.'}, this)
})
.catch(e => {
this.error(e, this)
})
},
removeAssignee(user) {
this.$store.dispatch('tasks/removeAssignee', {user: user, taskId: this.taskId})
.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

@ -0,0 +1,153 @@
<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.hexColor, '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'
export default {
name: 'edit-labels',
props: {
value: {
default: () => [],
type: Array,
},
taskId: {
type: Number,
required: true,
},
},
data() {
return {
labelService: LabelService,
labelTaskService: LabelTaskService,
foundLabels: [],
labelTimeout: null,
labels: [],
searchQuery: '',
}
},
components: {
multiselect,
},
watch: {
value(newLabels) {
this.labels = newLabels
}
},
created() {
this.labelService = new LabelService()
this.labelTaskService = new LabelTaskService()
this.labels = this.value
},
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) {
this.$store.dispatch('tasks/addLabel', {label: label, taskId: this.taskId})
.then(() => {
this.success({message: 'The label was successfully added.'}, this)
this.$emit('input', this.labels)
})
.catch(e => {
this.error(e, this)
})
},
removeLabel(label) {
this.$store.dispatch('tasks/removeLabel', {label: label, taskId: this.taskId})
.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)
this.$emit('input', this.labels)
})
.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

@ -0,0 +1,24 @@
<template>
<div class="label-wrapper">
<span class="tag" v-for="label in labels" :style="{'background': label.hexColor, 'color': label.textColor}" :key="label.id">
<span>{{ label.title }}</span>
</span>
</div>
</template>
<script>
export default {
name: 'labels',
props: {
labels: {
required: true,
}
}
}
</script>
<style scoped>
.label-wrapper {
display: inline;
}
</style>

View File

@ -0,0 +1,69 @@
<template>
<multiselect
v-model="list"
:options="foundLists"
:multiple="false"
:searchable="true"
:loading="listSerivce.loading"
:internal-search="true"
@search-change="findLists"
@select="select"
placeholder="Type to search for a list..."
label="title"
track-by="id"
:showNoOptions="false"
class="control is-expanded"
v-focus
>
<template slot="clear" slot-scope="props">
<div class="multiselect__clear" v-if="list !== null && list.id !== 0" @mousedown.prevent.stop="clearAll(props.search)"></div>
</template>
<span slot="noResult">No list found. Consider changing the search query.</span>
</multiselect>
</template>
<script>
import ListService from '../../../services/list'
import ListModel from '../../../models/list'
import multiselect from 'vue-multiselect'
export default {
name: 'listSearch',
data() {
return {
listSerivce: ListService,
list: ListModel,
foundLists: [],
}
},
components: {
multiselect,
},
beforeMount() {
this.listSerivce = new ListService()
this.list = new ListModel()
},
methods: {
findLists(query) {
if (query === '') {
this.clearAll()
return
}
this.listSerivce.getAll({}, {s: query})
.then(response => {
this.$set(this, 'foundLists', response)
})
.catch(e => {
this.error(e, this)
})
},
clearAll() {
this.$set(this, 'foundLists', [])
},
select(list) {
this.$emit('selected', list)
},
},
}
</script>

View File

@ -0,0 +1,49 @@
<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

@ -0,0 +1,58 @@
<template>
<span v-if="showAll || priority >= priorities.HIGH" :class="{'not-so-high': priority === priorities.HIGH, 'high-priority': priority >= priorities.HIGH}">
<span class="icon" v-if="priority >= priorities.HIGH">
<icon icon="exclamation"/>
</span>
<template v-if="priority === priorities.UNSET">Unset</template>
<template v-if="priority === priorities.LOW">Low</template>
<template v-if="priority === priorities.MEDIUM">Medium</template>
<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,
},
showAll: {
type: Boolean,
default: false,
},
}
}
</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

@ -0,0 +1,47 @@
<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

@ -0,0 +1,224 @@
<template>
<div class="task-relations">
<label class="label">New Task Relation</label>
<div class="field">
<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"
:taggable="true"
:showNoOptions="false"
@tag="createAndRelateTask"
tag-placeholder="Add this as new related task"
>
<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="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[0] }}
</option>
</select>
</div>
</div>
<div class="control">
<a class="button is-primary" @click="addTaskRelation()">Add task Relation</a>
</div>
</div>
<div class="related-tasks" v-for="(rts, kind ) in relatedTasks" :key="kind">
<template v-if="rts.length > 0">
<span class="title">{{ relationKindTitle(kind, rts.length) }}</span>
<div class="tasks noborder">
<div class="task" v-for="t in rts" :key="t.id">
<router-link :to="{ name: $route.name, params: { id: t.id } }">
<span class="tasktext" :class="{ 'done': t.done}">
<span v-if="t.listId !== listId" class="different-list" v-tooltip="'This task belongs to a different list.'">
{{ $store.getters['lists/getListById'](t.listId) === null ? '' : $store.getters['lists/getListById'](t.listId).title }} >
</span>
{{t.title}}
</span>
</router-link>
<a
class="remove"
@click="() => {showDeleteModal = true; relationToDelete = {relationKind: kind, otherTaskId: 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: 'related',
taskRelationService: TaskRelationService,
showDeleteModal: false,
relationToDelete: {},
}
},
components: {
multiselect,
},
props: {
taskId: {
type: Number,
required: true,
},
initialRelatedTasks: {
type: Object,
default: () => {
},
},
showNoRelationsNotice: {
type: Boolean,
default: false,
},
listId: {
type: Number,
default: 0,
}
},
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({
taskId: this.taskId,
otherTaskId: this.newTaskRelationTask.id,
relationKind: 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({
relationKind: this.relationToDelete.relationKind,
taskId: this.taskId,
otherTaskId: this.relationToDelete.otherTaskId,
})
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.otherTaskId && relationKind === this.relationToDelete.relationKind) {
this.relatedTasks[relationKind].splice(t, 1)
}
}
})
this.success(r, this)
})
.catch(e => {
this.error(e, this)
})
.finally(() => {
this.showDeleteModal = false
})
},
createAndRelateTask(title) {
const newTask = new TaskModel({title: title, listId: this.listId})
this.taskService.create(newTask)
.then(r => {
this.newTaskRelationTask = r
this.addTaskRelation()
})
.catch(e => {
this.error(e, this)
})
},
relationKindTitle(kind, length) {
if (length > 1) {
return relationKinds[kind][1]
}
return relationKinds[kind][0]
}
},
}
</script>

View File

@ -0,0 +1,96 @@
<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

@ -0,0 +1,61 @@
<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

@ -0,0 +1,124 @@
<template>
<span>
<fancycheckbox v-model="task.done" @change="markAsDone" :disabled="isArchived"/>
<router-link :to="{ name: taskDetailRoute, params: { id: task.id } }" class="tasktext" :class="{ 'done': task.done}">
<router-link
v-if="showList && $store.getters['lists/getListById'](task.listId) !== null"
v-tooltip="`This task belongs to list '${$store.getters['lists/getListById'](task.listId).title}'`"
:to="{ name: 'list.list', params: { listId: task.listId } }"
class="task-list">
{{ $store.getters['lists/getListById'](task.listId).title }}
</router-link>
<!-- Show any parent tasks to make it clear this task is a sub task of something -->
<span class="parent-tasks" v-if="typeof task.relatedTasks.parenttask !== 'undefined'">
<template v-for="(pt, i) in task.relatedTasks.parenttask">
{{ pt.title }}<template v-if="(i + 1) < task.relatedTasks.parenttask.length">,&nbsp;</template>
</template>
>
</span>
{{ task.title }}
<labels :labels="task.labels"/>
<user
:user="a"
:avatar-size="27"
:show-username="false"
:is-inline="true"
v-for="(a, i) in task.assignees"
:key="task.id + 'assignee' + a.id + i"
/>
<i v-if="task.dueDate > 0"
:class="{'overdue': task.dueDate <= new Date() && !task.done}"
v-tooltip="formatDate(task.dueDate)"> - Due {{formatDateSince(task.dueDate)}}</i>
<priority-label :priority="task.priority"/>
</router-link>
</span>
</template>
<script>
import TaskModel from '../../../models/task'
import PriorityLabel from './priorityLabel'
import TaskService from '../../../services/task'
import Labels from './labels'
import User from '../../global/user'
import Fancycheckbox from '../../global/fancycheckbox'
export default {
name: 'singleTaskInList',
data() {
return {
taskService: TaskService,
task: TaskModel,
}
},
components: {
Fancycheckbox,
User,
Labels,
PriorityLabel,
},
props: {
theTask: {
type: TaskModel,
required: true,
},
isArchived: {
type: Boolean,
default: false,
},
taskDetailRoute: {
type: String,
default: 'task.list.detail'
},
showList: {
type: Boolean,
default: false,
},
},
watch: {
theTask(newVal) {
this.task = newVal
},
},
mounted() {
this.task = this.theTask
},
created() {
this.task = new TaskModel()
this.taskService = new TaskService()
},
methods: {
markAsDone(checked) {
const updateFunc = () => {
this.taskService.update(this.task)
.then(t => {
this.task = t
this.$emit('taskUpdated', t)
this.success(
{message: 'The task was successfully ' + (this.task.done ? '' : 'un-') + 'marked as done.'},
this,
[{
title: 'Undo',
callback: () => this.markAsDone({
target: {
checked: !checked
}
}),
}]
)
})
.catch(e => {
this.error(e, this)
})
}
if (checked) {
setTimeout(updateFunc, 300); // Delay it to show the animation when marking a task as done
} else {
updateFunc() // Don't delay it when un-marking it as it doesn't have an animation the other way around
}
},
},
}
</script>

View File

@ -0,0 +1,24 @@
<template>
<a @click="click">
<icon icon="sort-up" v-if="order === 'asc'"/>
<icon icon="sort-up" v-else-if="order === 'desc'" rotation="180"/>
<icon icon="sort" v-else/>
</a>
</template>
<script>
export default {
name: 'sort',
props: {
order: {
type: String,
default: 'none',
},
},
methods: {
click() {
this.$emit('click')
},
},
}
</script>

View File

@ -1,6 +1,6 @@
<template>
<div class="loader-container" v-bind:class="{ 'is-loading': teamService.loading}">
<div class="card" v-if="userIsAdmin">
<div class="card is-fullwidth" v-if="userIsAdmin">
<header class="card-header">
<p class="card-header-title">
Edit Team
@ -8,29 +8,48 @@
</header>
<div class="card-content">
<div class="content">
<form @submit.prevent="submit()">
<form @submit.prevent="submit()">
<div class="field">
<label class="label" for="teamtext">Team Name</label>
<div class="control">
<input v-focus :class="{ 'disabled': teamMemberService.loading}" :disabled="teamMemberService.loading" class="input" type="text" id="teamtext" placeholder="The team text is here..." v-model="team.name">
<input
v-focus
:class="{ 'disabled': teamMemberService.loading}"
:disabled="teamMemberService.loading"
class="input"
type="text"
id="teamtext"
placeholder="The team text is here..."
v-model="team.name"/>
</div>
</div>
<p class="help is-danger" v-if="showError && team.name.length <= 5">
Please specify at least five characters.
</p>
<div class="field">
<label class="label" for="teamdescription">Description</label>
<div class="control">
<textarea :class="{ 'disabled': teamService.loading}" :disabled="teamService.loading" class="textarea" placeholder="The teams description goes here..." id="teamdescription" v-model="team.description"></textarea>
<textarea
:class="{ 'disabled': teamService.loading}"
:disabled="teamService.loading"
class="textarea"
placeholder="The teams description goes here..."
id="teamdescription"
v-model="team.description"></textarea>
</div>
</div>
</form>
<div class="columns bigbuttons">
<div class="column">
<button @click="submit()" class="button is-success is-fullwidth" :class="{ 'is-loading': teamService.loading}">
<button @click="submit()" class="button is-success is-fullwidth"
:class="{ 'is-loading': teamService.loading}">
Save
</button>
</div>
<div class="column is-1">
<button @click="showDeleteModal = true" class="button is-danger is-fullwidth" :class="{ 'is-loading': teamService.loading}">
<button @click="showDeleteModal = true" class="button is-danger is-fullwidth"
:class="{ 'is-loading': teamService.loading}">
<span class="icon is-small">
<icon icon="trash-alt"/>
</span>
@ -40,7 +59,7 @@
</div>
</div>
</div>
<div class="card">
<div class="card is-fullwidth">
<header class="card-header">
<p class="card-header-title">
@ -50,11 +69,28 @@
<div class="card-content content team-members">
<form @submit.prevent="addUser()" class="add-member-form" v-if="userIsAdmin">
<div class="field is-grouped">
<p class="control has-icons-left is-expanded" v-bind:class="{ 'is-loading': teamMemberService.loading}">
<input class="input" v-bind:class="{ 'disabled': teamMemberService.loading}" v-model.number="member.id" type="text" placeholder="Add a new user...">
<span class="icon is-small is-left">
<icon icon="user"/>
</span>
<p
class="control has-icons-left is-expanded"
:class="{ 'is-loading': teamMemberService.loading}">
<multiselect
v-model="newMember"
:options="foundUsers"
:multiple="false"
:searchable="true"
:loading="userService.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="newMember !== null && newMember.id !== 0"
@mousedown.prevent.stop="clearAll(props.search)">
</div>
</template>
<span slot="noResult">Oops! No user found. Consider changing the search query.</span>
</multiselect>
</p>
<p class="control">
<button type="submit" class="button is-success">
@ -68,44 +104,46 @@
</form>
<table class="table is-striped is-hoverable is-fullwidth">
<tbody>
<tr v-for="m in team.members" :key="m.id">
<td>{{m.username}}</td>
<td>
<template v-if="m.id === user.infos.id">
<b class="is-success">You</b>
</template>
</td>
<td class="type">
<template v-if="m.admin">
<tr v-for="m in team.members" :key="m.id">
<td>{{m.username}}</td>
<td>
<template v-if="m.id === userInfo.id">
<b class="is-success">You</b>
</template>
</td>
<td class="type">
<template v-if="m.admin">
<span class="icon is-small">
<icon icon="lock"/>
</span>
Admin
</template>
<template v-else>
Admin
</template>
<template v-else>
<span class="icon is-small">
<icon icon="user"/>
</span>
Member
</template>
</td>
<td class="actions" v-if="userIsAdmin">
<button @click="toggleUserType(m)" class="button buttonright is-primary"
v-if="m.id !== userInfo.id">
Make
<template v-if="!m.admin">
Admin
</template>
<template v-else>
Member
</template>
</td>
<td class="actions" v-if="userIsAdmin">
<button @click="toggleUserType(m)" class="button buttonright is-primary" v-if="m.id !== user.infos.id">
Make
<template v-if="!m.admin">
Admin
</template>
<template v-else>
Member
</template>
</button>
<button @click="member = m; showUserDeleteModal = true" class="button is-danger" v-if="m.id !== user.infos.id">
</button>
<button @click="() => {member = m; showUserDeleteModal = true}" class="button is-danger"
v-if="m.id !== userInfo.id">
<span class="icon is-small">
<icon icon="trash-alt"/>
</span>
</button>
</td>
</tr>
</button>
</td>
</tr>
</tbody>
</table>
</div>
@ -115,7 +153,7 @@
<modal
v-if="showDeleteModal"
@close="showDeleteModal = false"
v-on:submit="deleteTeam()">
@submit="deleteTeam()">
<span slot="header">Delete the team</span>
<p slot="text">Are you sure you want to delete this team and all of its members?<br/>
All team members will loose access to lists and namespaces shared with this team.<br/>
@ -125,155 +163,178 @@
<modal
v-if="showUserDeleteModal"
@close="showUserDeleteModal = false"
v-on:submit="deleteUser()">
@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/>
He will loose access to all lists and namespaces this team has access to.<br/>
They will loose access to all lists and namespaces this team has access to.<br/>
<b>This CANNOT BE UNDONE!</b></p>
</modal>
</div>
</template>
<script>
import auth from '../../auth'
import router from '../../router'
import message from '../../message'
import multiselect from 'vue-multiselect'
import {mapState} from 'vuex'
import TeamService from '../../services/team'
import TeamModel from '../../models/team'
import TeamMemberService from '../../services/teamMember'
import TeamMemberModel from '../../models/teamMember'
import UserModel from '../../models/user'
import UserService from '../../services/user'
export default {
name: "EditTeam",
name: 'EditTeam',
data() {
return {
teamService: TeamService,
teamMemberService: TeamMemberService,
team: TeamModel,
teamId: this.$route.params.id,
member: TeamMemberModel,
showDeleteModal: false,
showUserDeleteModal: false,
user: auth.user,
userIsAdmin: false,
newMember: UserModel,
foundUsers: [],
userService: UserService,
showError: false,
}
},
beforeMount() {
// Check if the user is already logged in, if so, redirect him to the homepage
if (!auth.user.authenticated) {
router.push({name: 'home'})
}
components: {
multiselect,
},
created() {
this.teamService = new TeamService()
this.teamMemberService = new TeamMemberService()
this.userService = new UserService()
this.loadTeam()
},
watch: {
// call again the method if the route changes
'$route': 'loadTeam'
},
computed: mapState({
userInfo: state => state.auth.info,
}),
methods: {
loadTeam() {
this.member = new TeamMemberModel({teamID: this.$route.params.id})
this.team = new TeamModel({id: this.$route.params.id})
this.team = new TeamModel({id: this.teamId})
this.teamService.get(this.team)
.then(response => {
this.$set(this, 'team', response)
let members = response.members
for (const m in members) {
members[m].teamID = this.$route.params.id
if (members[m].id === this.user.infos.id && members[m].admin) {
members[m].teamId = this.teamId
if (members[m].id === this.userInfo.id && members[m].admin) {
this.userIsAdmin = true
}
}
})
.catch(e => {
message.error(e, this)
this.error(e, this)
})
},
submit() {
if (this.team.name.length <= 4) {
this.showError = true
return
}
this.showError = false
this.teamService.update(this.team)
.then(response => {
this.team = response
message.success({message: 'The team was successfully updated.'}, this)
this.success({message: 'The team was successfully updated.'}, this)
})
.catch(e => {
message.error(e, this)
this.error(e, this)
})
},
deleteTeam() {
this.teamService.delete(this.team)
.then(() => {
message.success({message: 'The team was successfully deleted.'}, this)
this.success({message: 'The team was successfully deleted.'}, this)
router.push({name: 'listTeams'})
})
.catch(e => {
message.error(e, this)
this.error(e, this)
})
},
deleteUser() {
this.teamMemberService.delete(this.member)
.then(() => {
message.success({message: 'The user was successfully deleted from the team.'}, this)
this.success({message: 'The user was successfully deleted from the team.'}, this)
this.loadTeam()
})
.catch(e => {
message.error(e, this)
this.error(e, this)
})
.finally(() => {
this.showUserDeleteModal = false
})
},
addUser() {
this.teamMemberService.create(this.member)
const newMember = new TeamMemberModel({
teamId: this.teamId,
username: this.newMember.username,
})
this.teamMemberService.create(newMember)
.then(() => {
this.loadTeam()
message.success({message: 'The team member was successfully added.'}, this)
this.success({message: 'The team member was successfully added.'}, this)
})
.catch(e => {
message.error(e, this)
this.error(e, this)
})
},
toggleUserType(member) {
this.member = member
this.member.admin = !member.admin
this.deleteUser()
this.addUser()
}
member.admin = !member.admin
this.teamMemberService.delete(member)
.then(() => this.teamMemberService.create(member))
.then(() => {
this.loadTeam()
this.success({message: 'The team member was successfully made ' + (member.admin ? 'admin': 'member') + '.'}, this)
})
.catch(e => {
this.error(e, this)
})
},
findUser(query) {
if (query === '') {
this.$set(this, 'foundUsers', [])
return
}
this.userService.getAll({}, {s: query})
.then(response => {
this.$set(this, 'foundUsers', response)
})
.catch(e => {
this.error(e, this)
})
},
clearAll() {
this.$set(this, 'foundUsers', [])
},
}
}
</script>
<style lang="scss" scoped>
.card{
.card {
margin-bottom: 1rem;
.add-member-form {
margin: 1rem;
}
.table{
border-top: 1px solid darken(#fff, 15%);
border-radius: 4px;
overflow: hidden;
td{
vertical-align: middle;
}
td.type, td.actions{
width: 200px;
}
td.actions{
text-align: right;
}
}
}
.team-members{
.team-members {
padding: 0;
}
</style>

View File

@ -18,9 +18,6 @@
</template>
<script>
import auth from '../../auth'
import router from '../../router'
import message from '../../message'
import TeamService from '../../services/team'
export default {
@ -31,12 +28,6 @@
teams: [],
}
},
beforeMount() {
// Check if the user is already logged in, if so, redirect him to the homepage
if (!auth.user.authenticated) {
router.push({name: 'home'})
}
},
created() {
this.teamService = new TeamService()
this.loadTeams()
@ -48,7 +39,7 @@
this.$set(this, 'teams', response)
})
.catch(e => {
message.error(e, this)
this.error(e, this)
})
},
}

View File

@ -8,7 +8,12 @@
<form @submit.prevent="newTeam" @keyup.esc="back()">
<div class="field is-grouped">
<p class="control is-expanded" v-bind:class="{ 'is-loading': teamService.loading}">
<input v-focus class="input" v-bind:class="{ 'disabled': teamService.loading}" v-model="team.name" type="text" placeholder="The team's name goes here...">
<input
v-focus
class="input"
:class="{ 'disabled': teamService.loading}" v-model="team.name"
type="text"
placeholder="The team's name goes here..."/>
</p>
<p class="control">
<button type="submit" class="button is-success noshadow">
@ -19,16 +24,18 @@
</button>
</p>
</div>
<p class="help is-danger" v-if="showError && team.name.length <= 5">
Please specify at least five characters.
</p>
</form>
</div>
</template>
<script>
import auth from '../../auth'
import router from '../../router'
import message from '../../message'
import TeamModel from '../../models/team'
import TeamService from '../../services/team'
import {IS_FULLPAGE} from '../../store/mutation-types'
export default {
name: "NewTeam",
@ -36,27 +43,30 @@
return {
teamService: TeamService,
team: TeamModel,
}
},
beforeMount() {
// Check if the user is already logged in, if so, redirect him to the homepage
if (!auth.user.authenticated) {
router.push({name: 'home'})
showError: false,
}
},
created() {
this.teamService = new TeamService()
this.$parent.setFullPage();
this.team = new TeamModel()
this.$store.commit(IS_FULLPAGE, true)
},
methods: {
newTeam() {
if (this.team.name.length <= 4) {
this.showError = true
return
}
this.showError = false
this.teamService.create(this.team)
.then(response => {
router.push({name:'editTeam', params:{id: response.id}})
message.success({message: 'The team was successfully created.'}, this)
router.push({name: 'editTeam', params: {id: response.id}})
this.success({message: 'The team was successfully created.'}, this)
})
.catch(e => {
message.error(e, this)
this.error(e, this)
})
},
back() {

View File

@ -1,31 +1,69 @@
<template>
<div>
<h2 class="title">Login</h2>
<h2 class="title has-text-centered">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" class="input" name="username" placeholder="Username" v-model="credentials.username" required>
<input
v-focus type="text"
id="username"
class="input"
name="username"
placeholder="e.g. frederick"
ref="username"
required
/>
</div>
</div>
<div class="field">
<label class="label" for="password">Password</label>
<div class="control">
<input type="password" class="input" name="password" placeholder="Password" v-model="credentials.password" required>
<input
type="password"
class="input"
id="password"
name="password"
placeholder="e.g. ••••••••••••"
ref="password"
required
/>
</div>
</div>
<div class="field" v-if="needsTotpPasscode">
<label class="label" for="totpPasscode">Two Factor Authentication Code</label>
<div class="control">
<input
type="text"
class="input"
id="totpPasscode"
placeholder="e.g. 123456"
ref="totpPasscode"
required
v-focus
/>
</div>
</div>
<div class="field is-grouped">
<div class="control is-expanded">
<button type="submit" class="button is-primary" v-bind:class="{ 'is-loading': loading}">Login
</button>
<router-link :to="{ name: 'register' }" class="button" v-if="registrationEnabled">Register
</router-link>
</div>
<div class="control">
<button type="submit" class="button is-primary" v-bind:class="{ 'is-loading': loading}">Login</button>
<router-link :to="{ name: 'register' }" class="button">Register</router-link>
<router-link :to="{ name: 'getPasswordReset' }" class="reset-password-link">Reset your password</router-link>
<router-link :to="{ name: 'getPasswordReset' }" class="reset-password-link">Reset your
password
</router-link>
</div>
</div>
<div class="notification is-danger" v-if="error">
{{ error }}
<div class="notification is-danger" v-if="errorMessage">
{{ errorMessage }}
</div>
</form>
</div>
@ -33,21 +71,17 @@
</template>
<script>
import auth from '../../auth'
import {mapState} from 'vuex'
import router from '../../router'
import {HTTP} from '../../http-common'
import message from '../../message'
import {ERROR_MESSAGE, LOADING} from '../../store/mutation-types'
export default {
data() {
return {
credentials: {
username: '',
password: ''
},
error: '',
confirmedEmailSuccess: false,
loading: false
}
},
beforeMount() {
@ -64,25 +98,42 @@
})
.catch(e => {
cancel()
this.error = e.response.data.message
this.$store.commit(ERROR_MESSAGE, e.response.data.message)
})
}
// Check if the user is already logged in, if so, redirect him to the homepage
if (auth.user.authenticated) {
if (this.authenticated) {
router.push({name: 'home'})
}
},
computed: mapState({
registrationEnabled: state => state.config.registrationEnabled,
loading: LOADING,
errorMessage: ERROR_MESSAGE,
needsTotpPasscode: state => state.auth.needsTotpPasscode,
authenticated: state => state.auth.authenticated,
}),
methods: {
submit() {
this.loading = true
this.error = ''
let credentials = {
username: this.credentials.username,
password: this.credentials.password
this.$store.commit(ERROR_MESSAGE, '')
// Some browsers prevent Vue bindings from working with autofilled values.
// To work around this, we're manually getting the values here instead of relying on vue bindings.
// For more info, see https://kolaente.dev/vikunja/frontend/issues/78
const credentials = {
username: this.$refs.username.value,
password: this.$refs.password.value,
}
auth.login(this, credentials, 'home')
if (this.needsTotpPasscode) {
credentials.totpPasscode = this.$refs.totpPasscode.value
}
this.$store.dispatch('auth/login', credentials)
.then(() => {
router.push({name: 'home'})
})
.catch(() => {})
}
}
}
@ -93,7 +144,7 @@
margin: 0 0.4em 0 0;
}
.reset-password-link{
.reset-password-link {
display: inline-block;
padding-top: 5px;
}

View File

@ -1,16 +1,18 @@
<template>
<div>
<h2 class="title">Reset your password</h2>
<h2 class="title has-text-centered">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" name="password1" placeholder="Password" v-model="credentials.password" required>
<input v-focus type="password" class="input" id="password1" name="password1" placeholder="e.g. ••••••••••••" 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" name="password2" placeholder="Retype password" v-model="credentials.password2" required>
<input type="password" class="input" id="password2" name="password2" placeholder="e.g. ••••••••••••" v-model="credentials.password2" required/>
</div>
</div>
@ -22,8 +24,8 @@
<div class="notification is-info" v-if="this.passwordResetService.loading">
Loading...
</div>
<div class="notification is-danger" v-if="error">
{{ error }}
<div class="notification is-danger" v-if="errorMsg">
{{ errorMsg }}
</div>
</form>
<div v-if="successMessage" class="has-text-centered">
@ -48,7 +50,7 @@
password: '',
password2: '',
},
error: '',
errorMsg: '',
successMessage: ''
}
},
@ -57,21 +59,21 @@
},
methods: {
submit() {
this.error = ''
this.errorMsg = ''
if (this.credentials.password2 !== this.credentials.password) {
this.error = 'Passwords don\'t match'
this.errorMsg = 'Passwords don\'t match'
return
}
let passwordReset = new PasswordResetModel({new_password: this.credentials.password})
let passwordReset = new PasswordResetModel({newPassword: this.credentials.password})
this.passwordResetService.resetPassword(passwordReset)
.then(response => {
this.successMessage = response.data.message
localStorage.removeItem('passwordResetToken')
})
.catch(e => {
this.error = e.response.data.message
this.errorMsg = e.response.data.message
})
}
}

View File

@ -1,26 +1,30 @@
<template>
<div>
<h2 class="title">Register</h2>
<h2 class="title has-text-centered">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" class="input" name="username" placeholder="Username" v-model="credentials.username" required>
<input v-focus type="text" id="username" class="input" name="username" placeholder="e.g. frederick" 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" name="email" placeholder="E-mail address" v-model="credentials.email" required>
<input type="email" class="input" id="email" name="email" placeholder="e.g. frederic@vikunja.io" 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" name="password1" placeholder="Password" v-model="credentials.password" required>
<input type="password" class="input" id="password1" name="password1" placeholder="e.g. ••••••••••••" 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" name="password2" placeholder="Retype password" v-model="credentials.password2" required>
<input type="password" class="input" id="password2" name="password2" placeholder="e.g. ••••••••••••" v-model="credentials.password2" required/>
</div>
</div>
@ -33,8 +37,8 @@
<div class="notification is-info" v-if="loading">
Loading...
</div>
<div class="notification is-danger" v-if="error">
{{ error }}
<div class="notification is-danger" v-if="errorMessage !== ''">
{{ errorMessage }}
</div>
</form>
</div>
@ -42,8 +46,9 @@
</template>
<script>
import auth from '../../auth'
import router from '../../router'
import {mapState} from 'vuex'
import {ERROR_MESSAGE, LOADING} from '../../store/mutation-types'
export default {
data() {
@ -54,35 +59,37 @@
password: '',
password2: '',
},
error: '',
loading: false
}
},
beforeMount() {
// Check if the user is already logged in, if so, redirect him to the homepage
if (auth.user.authenticated) {
if (this.authenticated) {
router.push({name: 'home'})
}
},
computed: mapState({
authenticated: state => state.auth.authenticated,
loading: LOADING,
errorMessage: ERROR_MESSAGE,
}),
methods: {
submit() {
this.loading = true
this.error = ''
this.$store.commit(LOADING, true)
this.$store.commit(ERROR_MESSAGE, '')
if (this.credentials.password2 !== this.credentials.password) {
this.loading = false
this.error = 'Passwords don\'t match'
this.$store.commit(ERROR_MESSAGE, 'Passwords don\'t match.')
this.$store.commit(LOADING, false)
return
}
let credentials = {
const credentials = {
username: this.credentials.username,
email: this.credentials.email,
password: this.credentials.password
}
auth.register(this, credentials, 'home')
this.$store.dispatch('auth/register', credentials)
}
}
}

View File

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

View File

@ -0,0 +1,240 @@
<template>
<div class="loader-container" :class="{ 'is-loading': passwordUpdateService.loading || emailUpdateService.loading || totpService.loading }">
<div class="card">
<header class="card-header">
<p class="card-header-title">
Update Your Password
</p>
</header>
<div class="card-content">
<div class="content">
<form @submit.prevent="updatePassword()">
<div class="field">
<label class="label" for="newPassword">New Password</label>
<div class="control">
<input class="input" type="password" id="newPassword" placeholder="The new password..."
v-model="passwordUpdate.newPassword" @keyup.enter="updatePassword"/>
</div>
</div>
<div class="field">
<label class="label" for="newPasswordConfirm">New Password Confirmation</label>
<div class="control">
<input class="input" type="password" id="newPasswordConfirm" placeholder="Confirm your new password..."
v-model="passwordConfirm" @keyup.enter="updatePassword"/>
</div>
</div>
<div class="field">
<label class="label" for="currentPassword">Current Password</label>
<div class="control">
<input class="input" type="password" id="currentPassword" placeholder="Your current password"
v-model="passwordUpdate.oldPassword" @keyup.enter="updatePassword"/>
</div>
</div>
</form>
<div class="bigbuttons">
<button @click="updatePassword()" class="button is-primary is-fullwidth"
:class="{ 'is-loading': passwordUpdateService.loading}">
Save
</button>
</div>
</div>
</div>
</div>
<div class="card">
<header class="card-header">
<p class="card-header-title">
Update Your E-Mail Address
</p>
</header>
<div class="card-content">
<div class="content">
<form @submit.prevent="updateEmail()">
<div class="field">
<label class="label" for="newEmail">New Email Address</label>
<div class="control">
<input class="input" type="email" id="newEmail" placeholder="The new email address..."
v-model="emailUpdate.newEmail" @keyup.enter="updateEmail"/>
</div>
</div>
<div class="field">
<label class="label" for="currentPassword">Current Password</label>
<div class="control">
<input class="input" type="password" id="currentPassword" placeholder="Your current password"
v-model="emailUpdate.password" @keyup.enter="updateEmail"/>
</div>
</div>
</form>
<div class="bigbuttons">
<button @click="updateEmail()" class="button is-primary is-fullwidth"
:class="{ 'is-loading': emailUpdateService.loading}">
Save
</button>
</div>
</div>
</div>
</div>
<div class="card" v-if="totpEnabled">
<header class="card-header">
<p class="card-header-title">
Two Factor Authentication
</p>
</header>
<div class="card-content">
<a class="button is-primary" v-if="!totpEnrolled && totp.secret === ''" @click="totpEnroll()" :class="{ 'is-loading': totpService.loading }">Enroll</a>
<div class="content" v-else-if="totp.secret !== '' && !totp.enabled">
<p>
To finish your setup, use this secret in your totp app (Google Authenticator or similar): <strong>{{ totp.secret }}</strong><br/>
After that, enter a code from your app below.
</p>
<p>
Alternatively you can scan this QR code:<br/>
<img :src="totpQR" alt=""/>
</p>
<div class="field">
<label class="label" for="totpConfirmPasscode">Passcode</label>
<div class="control">
<input class="input" type="text" id="totpConfirmPasscode" placeholder="A code generated by your totp application"
v-model="totpConfirmPasscode" @keyup.enter="totpConfirm()"/>
</div>
</div>
<a class="button is-primary" @click="totpConfirm()">Confirm</a>
</div>
<div class="content" v-else-if="totp.secret !== '' && totp.enabled">
<p>
You've sucessfully set up two factor authentication!
</p>
<p v-if="!totpDisableForm">
<a class="button is-danger" @click="totpDisableForm = true">Disable</a>
</p>
<div v-if="totpDisableForm">
<div class="field">
<label class="label" for="currentPassword">Please Enter Your Password</label>
<div class="control">
<input class="input" type="password" id="currentPassword" placeholder="Your current password"
v-model="totpDisablePassword" @keyup.enter="totpDisable" v-focus/>
</div>
</div>
<a class="button is-danger" @click="totpDisable()">Disable two factor authentication</a>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import PasswordUpdateModel from '../../models/passwordUpdate'
import PasswordUpdateService from '../../services/passwordUpdateService'
import EmailUpdateService from '../../services/emailUpdate'
import EmailUpdateModel from '../../models/emailUpdate'
import TotpModel from '../../models/totp'
import TotpService from '../../services/totp'
import {mapState} from 'vuex'
export default {
name: 'Settings',
data() {
return {
passwordUpdateService: PasswordUpdateService,
passwordUpdate: PasswordUpdateModel,
passwordConfirm: '',
emailUpdateService: EmailUpdateService,
emailUpdate: EmailUpdateModel,
totpService: TotpService,
totp: TotpModel,
totpQR: '',
totpEnrolled: false,
totpConfirmPasscode: '',
totpDisableForm: false,
totpDisablePassword: '',
}
},
created() {
this.passwordUpdateService = new PasswordUpdateService()
this.passwordUpdate = new PasswordUpdateModel()
this.emailUpdateService = new EmailUpdateService()
this.emailUpdate = new EmailUpdateModel()
this.totpService = new TotpService()
this.totp = new TotpModel()
this.totpService.get()
.then(r => {
this.$set(this, 'totp', r)
this.totpSetQrCode()
})
.catch(e => {
// Error code 1016 means totp is not enabled, we don't need an error in that case.
if (e.response && e.response.data && e.response.data.code && e.response.data.code === 1016) {
this.totpEnrolled = false
return
}
this.error(e, this)
})
},
computed: mapState({
totpEnabled: state => state.config.totpEnabled
}),
methods: {
updatePassword() {
if (this.passwordConfirm !== this.passwordUpdate.newPassword) {
this.error({message: 'The new password and its confirmation don\'t match.'}, this)
return
}
this.passwordUpdateService.update(this.passwordUpdate)
.then(() => {
this.success({message: 'The password was successfully updated.'}, this)
})
.catch(e => this.error(e, this))
},
updateEmail() {
this.emailUpdateService.update(this.emailUpdate)
.then(() => {
this.success({message: 'Your email address was successfully updated. We\'ve sent you a link to confirm it.'}, this)
})
.catch(e => this.error(e, this))
},
totpSetQrCode() {
this.totpService.qrcode()
.then(qr => {
const urlCreator = window.URL || window.webkitURL
this.totpQR = urlCreator.createObjectURL(qr)
})
},
totpEnroll() {
this.totpService.enroll()
.then(r => {
this.totpEnrolled = true
this.$set(this, 'totp', r)
this.totpSetQrCode()
})
.catch(e => this.error(e, this))
},
totpConfirm() {
this.totpService.enable({passcode: this.totpConfirmPasscode})
.then(() => {
this.$set(this.totp, 'enabled', true)
this.success({message: 'You\'ve successfully confirmed your totp setup and can use it from now on!'}, this)
})
.catch(e => this.error(e, this))
},
totpDisable() {
this.totpService.disable({password: this.totpDisablePassword})
.then(() => {
this.totpEnrolled = false
this.$set(this, 'totp', new TotpModel())
this.success({message: 'Two factor authentication was sucessfully disabled.'}, this)
})
.catch(e => this.error(e, this))
},
},
}
</script>

View File

@ -1,16 +0,0 @@
import {HTTP} from './http-common'
export default {
config: null,
getConfig() {
return this.config
},
initConfig() {
return HTTP.get('info')
.then(r => {
this.config = r.data
})
}
}

19
src/helpers/applyDrag.js Normal file
View File

@ -0,0 +1,19 @@
export const applyDrag = (arr, dragResult) => {
const { removedIndex, addedIndex, payload } = dragResult
if (removedIndex === null && addedIndex === null) return arr
const result = [...arr]
// The payload comes from the task itself
let itemToAdd = payload
if (removedIndex !== null) {
itemToAdd = result.splice(removedIndex, 1)[0]
}
if (addedIndex !== null) {
result.splice(addedIndex, 0, itemToAdd)
}
return result
}

80
src/helpers/case.js Normal file
View File

@ -0,0 +1,80 @@
import {camelCase} from 'camel-case'
import {snakeCase} from 'snake-case'
/**
* Transforms field names to camel case.
* @param object
* @returns {*}
*/
export function objectToCamelCase(object) {
// When calling recursively, this can be called without being and object or array in which case we just return the value
if (typeof object !== 'object') {
return object
}
let parsedObject = {}
for (const m in object) {
parsedObject[camelCase(m)] = object[m]
// Recursive processing
// Prevent processing for some cases
if(object[m] === null) {
continue
}
// Call it again for arrays
if (Array.isArray(object[m])) {
parsedObject[camelCase(m)] = object[m].map(o => objectToCamelCase(o))
// Because typeof [] === 'object' is true for arrays, we leave the loop here to prevent converting arrays to objects.
continue
}
// Call it again for nested objects
if(typeof object[m] === 'object') {
parsedObject[camelCase(m)] = objectToCamelCase(object[m])
}
}
return parsedObject
}
/**
* Transforms field names to snake case - used before making an api request.
* @param object
* @returns {*}
*/
export function objectToSnakeCase(object) {
// When calling recursively, this can be called without being and object or array in which case we just return the value
if (typeof object !== 'object') {
return object
}
let parsedObject = {}
for (const m in object) {
parsedObject[snakeCase(m)] = object[m]
// Recursive processing
// Prevent processing for some cases
if(
object[m] === null ||
(object[m] instanceof Date)
) {
continue
}
// Call it again for arrays
if (Array.isArray(object[m])) {
parsedObject[snakeCase(m)] = object[m].map(o => objectToSnakeCase(o))
// Because typeof [] === 'object' is true for arrays, we leave the loop here to prevent converting arrays to objects.
continue
}
// Call it again for nested objects
if(typeof object[m] === 'object') {
parsedObject[snakeCase(m)] = objectToSnakeCase(object[m])
}
}
return parsedObject
}

View File

@ -0,0 +1,11 @@
export const filterObject = (obj, fn) => {
let key
for (key in obj) {
if (fn(obj[key])) {
return key
}
}
return null
}

View File

@ -0,0 +1,38 @@
export const saveListView = (listId, routeName) => {
const savedListView = localStorage.getItem('listView')
let savedListViewJson = false
if (savedListView !== null) {
savedListViewJson = JSON.parse(savedListView)
}
let listView = {}
if(savedListViewJson) {
listView = savedListViewJson
}
listView[listId] = routeName
localStorage.setItem('listView', JSON.stringify(listView))
}
export const getListView = listId => {
// Remove old stored settings
const savedListView = localStorage.getItem('listView')
if(savedListView !== null && savedListView.startsWith('list.')) {
localStorage.removeItem('listView')
}
console.log('saved list view state', savedListView)
if (!savedListView) {
return 'list.list'
}
const savedListViewJson = JSON.parse(savedListView)
if(!savedListViewJson[listId]) {
return 'list.list'
}
return savedListViewJson[listId]
}

View File

@ -1,6 +1,5 @@
import axios from 'axios'
let config = require('../../public/config.json')
export const HTTP = axios.create({
baseURL: config.VIKUNJA_API_BASE_URL
baseURL: window.API_URL
})

View File

@ -1,18 +1,21 @@
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import auth from './auth'
import {VERSION} from './version.json'
console.info(`Vikunja frontend version ${VERSION}`)
// Make sure the api url does not contain a / at the end
if(window.API_URL.substr(window.API_URL.length - 1, window.API_URL.length) === '/') {
window.API_URL = window.API_URL.substr(0, window.API_URL.length - 1)
}
// Register the modal
import Modal from './components/modal/Modal'
Vue.component('modal', Modal)
// Register the task overview component
import TaskOverview from './components/tasks/ShowTasks'
Vue.component('TaskOverview', TaskOverview)
// Add CSS
import './vikunja.scss'
import './styles/vikunja.scss'
Vue.config.productionTip = false
@ -20,12 +23,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,8 +48,25 @@ 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 { faTh } from '@fortawesome/free-solid-svg-icons'
import { faSort } from '@fortawesome/free-solid-svg-icons'
import { faSortUp } from '@fortawesome/free-solid-svg-icons'
import { faList } from '@fortawesome/free-solid-svg-icons'
import { faEllipsisV } from '@fortawesome/free-solid-svg-icons'
import { faComments } from '@fortawesome/free-regular-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
library.add(faSignOutAlt)
@ -80,6 +94,23 @@ 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)
library.add(faComments)
library.add(faTh)
library.add(faSort)
library.add(faSortUp)
library.add(faList)
library.add(faEllipsisV)
Vue.component('icon', FontAwesomeIcon)
@ -87,19 +118,52 @@ 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: function (el) {
// Focus the element
el.focus()
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()
}
}
})
// Check the user's auth status when the app starts
auth.checkAuth()
// Mixins
import message from './message'
import {format, formatDistance} from 'date-fns'
Vue.mixin({
methods: {
formatDateSince: date => {
const currentDate = new Date()
let formatted = '';
if (date > currentDate) {
formatted += 'in '
}
formatted += formatDistance(date, currentDate)
if(date < currentDate) {
formatted += ' ago'
}
return formatted;
},
formatDate: date => format(date, 'PPPPpppp'),
error: (e, context, actions = []) => message.error(e, context, actions),
success: (s, context, actions = []) => message.success(s, context, actions),
}
})
// Vuex
import {store} from './store'
new Vue({
router,
render: h => h(App)
router,
store,
render: h => h(App)
}).$mount('#app')

View File

@ -8,37 +8,40 @@ export default {
context.loading = false
};
},
error(e, context) {
// Build the notification text from error response
let err = e.message
if (e.response && e.response.data && e.response.data.message) {
err += '<br/>' + e.response.data.message
}
error(e, context, actions = []) {
// Build the notification text from error response
let err = e.message
if (e.response && e.response.data && e.response.data.message) {
err += '<br/>' + e.response.data.message
}
// Fire a notification
context.$notify({
type: 'error',
title: 'Error',
text: err
})
// Fire a notification
context.$notify({
type: 'error',
title: 'Error',
text: err,
actions: actions,
})
context.loading = false
},
success(e, context) {
// Build the notification text from error response
let err = e.message
if (e.response && e.response.data && e.response.data.message) {
err += '<br/>' + e.response.data.message
}
},
success(e, context, actions = []) {
// Build the notification text from error response
let err = e.message
if (e.response && e.response.data && e.response.data.message) {
err += '<br/>' + e.response.data.message
}
// Fire a notification
context.$notify({
type: 'success',
title: 'Success',
text: err
})
// Fire a notification
context.$notify({
type: 'success',
title: 'Success',
text: err,
data: {
actions: actions,
},
})
context.loading = false
},
},
}

View File

@ -1,4 +1,5 @@
import {defaults, omitBy, isNil} from 'lodash'
import {objectToCamelCase} from '../helpers/case'
export default class AbstractModel {
@ -7,6 +8,9 @@ export default class AbstractModel {
* @param data
*/
constructor(data) {
data = objectToCamelCase(data)
// Put all data in our model while overriding those with a value of null or undefined with their defaults
defaults(this, omitBy(data, isNil), this.defaults())
}

22
src/models/attachment.js Normal file
View File

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

28
src/models/bucket.js Normal file
View File

@ -0,0 +1,28 @@
import AbstractModel from './abstractModel'
import UserModel from './user'
import TaskModel from "./task";
export default class BucketModel extends AbstractModel {
constructor(bucket) {
super(bucket)
this.tasks = this.tasks.map(t => new TaskModel(t))
this.createdBy = new UserModel(this.createdBy)
this.created = new Date(this.created)
this.updated = new Date(this.updated)
}
defaults() {
return {
id: 0,
title: '',
listId: 0,
tasks: [],
createdBy: null,
created: null,
updated: null,
}
}
}

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