Compare commits
261 Commits
feature/gl
...
master
Author | SHA1 | Date | |
---|---|---|---|
cc51ea8c61 | |||
c3ba068dd7 | |||
bc603605a7 | |||
32984b88a3 | |||
fe8f0ecd67 | |||
58eec4939e | |||
405dd1c1a6 | |||
991de38980 | |||
0c77c591e4 | |||
fa37d5bf59 | |||
0953400321 | |||
8592652e5b | |||
68e6b23610 | |||
a4bc95902a | |||
0d94386e99 | |||
12727900de | |||
978e7b4acb | |||
fdac36ff1c | |||
1da7ffb23c | |||
d7b4b2189a | |||
c4b92a8f52 | |||
f63576960d | |||
d0f6a4ce99 | |||
b876a4d4dc | |||
0dc4e6b95d | |||
cc46809639 | |||
687b8dc824 | |||
d409957de5 | |||
995dec33ea | |||
b85f66140b | |||
efd2e38357 | |||
058570c9a7 | |||
f524a3efc1 | |||
b1de52fc0b | |||
2ca8eef4f7 | |||
c2135338d3 | |||
495350fa83 | |||
f878063015 | |||
b79593a372 | |||
bd351550ee | |||
15edfe0a49 | |||
ce1bfba5fd | |||
f75c3ed4f7 | |||
8789135eed | |||
48df1a44e8 | |||
2d59b0a1b0 | |||
a822a07c89 | |||
e74c72f486 | |||
85a1f9f2a1 | |||
4e42810522 | |||
2270272a8f | |||
67de7e7c8f | |||
be10ba0f62 | |||
bdb2dba49c | |||
0b35f1cfd2 | |||
8ae03fa6e9 | |||
5724b98358 | |||
2ba6b7ef3a | |||
b1b5fd3cf8 | |||
ff70696111 | |||
815844fe2a | |||
f1561a491b | |||
9fdbcd56cf | |||
38c7e4b3c2 | |||
0c6b0cb48d | |||
d46faec23d | |||
1bad154da6 | |||
4c50bc148b | |||
9aaaa8394f | |||
3edcc790de | |||
183f1411f9 | |||
40721e7a74 | |||
234db32e30 | |||
2b59fabbc6 | |||
010da8cf07 | |||
5009308c52 | |||
231b51445a | |||
b043369245 | |||
120d5a8c19 | |||
fb64aeb2d4 | |||
229999546c | |||
dca90477d9 | |||
623879381b | |||
a5c0b035e7 | |||
d6642550bd | |||
f4847be320 | |||
c59501958f | |||
86aebc48a0 | |||
747a912475 | |||
31b025cc55 | |||
2a7bbf3c83 | |||
6c24cc66b2 | |||
96616eff8c | |||
c7845bb9c1 | |||
ea6fda8a9d | |||
95b5ed0d35 | |||
6f3f7d227d | |||
d4b82a4cc9 | |||
99c10d49be | |||
a75670e4f0 | |||
335ea49801 | |||
e7c1c98c6a | |||
588b87fb96 | |||
cc02fc82fc | |||
bb84d03776 | |||
7587821927 | |||
a77b4253cb | |||
e2137d08a5 | |||
cd6dee88b9 | |||
575b2f28ef | |||
f552b834d6 | |||
4a413e7f3c | |||
de36296bac | |||
43e464c113 | |||
bd998469a1 | |||
8b8543e011 | |||
3141850fa2 | |||
e098f87a33 | |||
d380488b32 | |||
1c734f15d1 | |||
70059b4f9a | |||
1f87191d3c | |||
8d1d71fa36 | |||
6aaea4927b | |||
7c6108ce08 | |||
c2678b8dab | |||
46aa7ad3ac | |||
641eeaf1c1 | |||
128ce592ab | |||
c352f47d01 | |||
16a0b52ebc | |||
69ee52e182 | |||
c7151f3ae9 | |||
7c6438c50d | |||
7d41603ba6 | |||
ede990ed85 | |||
066cd63771 | |||
8480bf334f | |||
27e2839f4c | |||
724275e653 | |||
cc513b5274 | |||
701a46ecd4 | |||
cafb960c8d | |||
51de1fe880 | |||
87f74e3a4b | |||
3b18b83239 | |||
f2fec2030e | |||
28c2f3573d | |||
35d0058026 | |||
7d2bd192ab | |||
ce80fa2dbd | |||
a706089f7b | |||
ab8dd6f67a | |||
4408115f41 | |||
e586c66095 | |||
2b5888805f | |||
2104d1ea4b | |||
fe6c859150 | |||
3c3767a91e | |||
aeba5651af | |||
f690a6f457 | |||
5972476735 | |||
a4acfb5ef2 | |||
c458f902da | |||
94714b2964 | |||
d70aa1b21d | |||
057f3c8337 | |||
ff4299beb1 | |||
fec60578ab | |||
6d4ac2f2b6 | |||
f3ec9be8e5 | |||
22cf54f1f9 | |||
269b80e64e | |||
8c82c2302f | |||
57f78ee0d4 | |||
683012f468 | |||
09dda84d75 | |||
a4685b50e8 | |||
0591531949 | |||
00c1ed7ad7 | |||
e0dcf1faa9 | |||
1111d60d61 | |||
dd0703562f | |||
6258c59c18 | |||
301c23fa9a | |||
6324fc384b | |||
2f2ddb4603 | |||
4f9f3afc34 | |||
866218c479 | |||
4f81e96021 | |||
63d21b54c4 | |||
2223072881 | |||
64cbfc113a | |||
b41a4380d8 | |||
05da96e545 | |||
783401723a | |||
010812ef06 | |||
80b363872e | |||
d42e88b26d | |||
1df1be2eab | |||
37d6ceb963 | |||
04d7d48b68 | |||
161f853361 | |||
fc17518e8c | |||
8dcabc9385 | |||
96fddd9bbd | |||
604488c68c | |||
5f0b5a0945 | |||
309f75b19d | |||
c8130bef61 | |||
1170e030f6 | |||
a0c4732f81 | |||
da10b4310b | |||
22d2d1a777 | |||
ed0ae210ac | |||
d61a7511da | |||
9b232c7d4f | |||
74f5d43097 | |||
a06e709da6 | |||
9c66a7570a | |||
752d6cc6f9 | |||
500e0cfaf4 | |||
ed4d41e2d8 | |||
6b7fe8ee47 | |||
ff0f078ee6 | |||
81e9eef154 | |||
d041384999 | |||
11d9aaae12 | |||
ce1a524429 | |||
52017aca83 | |||
a9291a5f2f | |||
2302a46d9b | |||
99cc06edee | |||
e1dfe5abf7 | |||
c5691ec293 | |||
2c78b36f1e | |||
4e5d14d969 | |||
e00f0046b5 | |||
c6d7b288ce | |||
c4489c20e3 | |||
2705c1571e | |||
7a997b52a6 | |||
1f504b1e6d | |||
c60f061307 | |||
7419f2a3fb | |||
0af1deaa00 | |||
8267f50249 | |||
ce3a7a2131 | |||
bdf0d00bff | |||
b2408eef04 | |||
cad4df5558 | |||
a0d281b0b4 | |||
bcfded6efc | |||
e1cd40980a | |||
eb15046c5e | |||
cb9b6592c1 | |||
befb5143c8 | |||
c635052c0f | |||
52bdae90d3 | |||
3211e1e8ea | |||
2a7871cf96 |
84
.drone.yml
|
@ -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
|
@ -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
|
||||
|
30
Dockerfile
|
@ -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"
|
|
@ -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.
|
||||
|
|
|
@ -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 {
|
||||
|
|
66
package.json
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"VIKUNJA_API_BASE_URL": "http://localhost:8080/api/v1/"
|
||||
}
|
|
@ -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 */
|
||||
|
|
BIN
public/images/icons/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
public/images/icons/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 29 KiB |
BIN
public/images/icons/apple-touch-icon-120x120.png
Normal file
After Width: | Height: | Size: 6.3 KiB |
BIN
public/images/icons/apple-touch-icon-152x152.png
Normal file
After Width: | Height: | Size: 8.2 KiB |
BIN
public/images/icons/apple-touch-icon-180x180.png
Normal file
After Width: | Height: | Size: 9.8 KiB |
BIN
public/images/icons/apple-touch-icon-60x60.png
Normal file
After Width: | Height: | Size: 3.1 KiB |
BIN
public/images/icons/apple-touch-icon-76x76.png
Normal file
After Width: | Height: | Size: 3.9 KiB |
BIN
public/images/icons/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 9.8 KiB |
BIN
public/images/icons/badge-monochrome.png
Normal file
After Width: | Height: | Size: 2.0 KiB |
BIN
public/images/icons/favicon-16x16.png
Normal file
After Width: | Height: | Size: 746 B |
BIN
public/images/icons/favicon-32x32.png
Normal file
After Width: | Height: | Size: 1.5 KiB |
BIN
public/images/icons/msapplication-icon-144x144.png
Normal file
After Width: | Height: | Size: 7.6 KiB |
BIN
public/images/icons/mstile-150x150.png
Normal file
After Width: | Height: | Size: 3.3 KiB |
149
public/images/icons/safari-pinned-tab.svg
Normal 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 |
BIN
public/images/llama-nightscape.png
Normal file
After Width: | Height: | Size: 126 KiB |
970
public/images/llama-nightscape.svg
Normal file
After Width: | Height: | Size: 174 KiB |
|
@ -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 |
BIN
public/images/migration/todoist.png
Normal file
After Width: | Height: | Size: 3.1 KiB |
BIN
public/images/migration/wunderlist.png
Normal file
After Width: | Height: | Size: 26 KiB |
|
@ -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
|
@ -0,0 +1,2 @@
|
|||
User-agent: *
|
||||
Disallow:
|
16
renovate.json
Normal 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
|
@ -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;"
|
462
src/App.vue
|
@ -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');
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
3
src/ServiceWorker/events.json
Normal file
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"SW_UPDATED": "swUpdated"
|
||||
}
|
118
src/ServiceWorker/sw.js
Normal 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, {});
|
|
@ -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
|
@ -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>
|
|
@ -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>
|
||||
|
|
96
src/components/global/easymde.vue
Normal 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>
|
58
src/components/global/fancycheckbox.vue
Normal 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>
|
49
src/components/global/notification.vue
Normal 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>
|
52
src/components/global/user.vue
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
})
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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>
|
452
src/components/lists/views/Kanban.vue
Normal 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>
|
195
src/components/lists/views/List.vue
Normal 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">…</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>
|
238
src/components/lists/views/Table.vue
Normal 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 Date
|
||||
<sort :order="sortBy.due_date_unix" @click="sort('due_date_unix')"/>
|
||||
</th>
|
||||
<th v-if="activeColumns.startDate">
|
||||
Start Date
|
||||
<sort :order="sortBy.start_date_unix" @click="sort('start_date_unix')"/>
|
||||
</th>
|
||||
<th v-if="activeColumns.endDate">
|
||||
End Date
|
||||
<sort :order="sortBy.end_date_unix" @click="sort('end_date_unix')"/>
|
||||
</th>
|
||||
<th v-if="activeColumns.percentDone">
|
||||
% 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 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">…</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>
|
39
src/components/migrator/migrate-service.vue
Normal 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>
|
||||
|
23
src/components/migrator/migrate.vue
Normal 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>
|
118
src/components/migrator/migration.vue
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -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>
|
|
@ -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()
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
529
src/components/tasks/TaskDetailView.vue
Normal 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>
|
29
src/components/tasks/TaskDetailViewModal.vue
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
},
|
||||
},
|
||||
|
|
147
src/components/tasks/helpers/taskList.js
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
197
src/components/tasks/reusable/attachments.vue
Normal 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>
|
185
src/components/tasks/reusable/comments.vue
Normal 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>
|
||||
<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> ·
|
||||
<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>
|
17
src/components/tasks/reusable/date-table-cell.vue
Normal 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>
|
131
src/components/tasks/reusable/editAssignees.vue
Normal 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>
|
153
src/components/tasks/reusable/editLabels.vue
Normal 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>
|
24
src/components/tasks/reusable/labels.vue
Normal 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>
|
69
src/components/tasks/reusable/listSearch.vue
Normal 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>
|
49
src/components/tasks/reusable/percentDoneSelect.vue
Normal 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>
|
58
src/components/tasks/reusable/priorityLabel.vue
Normal 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>
|
47
src/components/tasks/reusable/prioritySelect.vue
Normal 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>
|
224
src/components/tasks/reusable/relatedTasks.vue
Normal 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>
|
96
src/components/tasks/reusable/reminders.vue
Normal 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>
|
61
src/components/tasks/reusable/repeatAfter.vue
Normal 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>
|
124
src/components/tasks/reusable/singleTaskInList.vue
Normal 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">, </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>
|
24
src/components/tasks/reusable/sort.vue
Normal 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>
|
|
@ -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>
|
|
@ -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)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
})
|
||||
},
|
||||
}
|
||||
|
|
240
src/components/user/Settings.vue
Normal 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>
|
|
@ -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
|
@ -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
|
@ -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
|
||||
}
|
11
src/helpers/filterObject.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
|
||||
export const filterObject = (obj, fn) => {
|
||||
let key
|
||||
|
||||
for (key in obj) {
|
||||
if (fn(obj[key])) {
|
||||
return key
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
38
src/helpers/saveListView.js
Normal 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]
|
||||
}
|
|
@ -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
|
||||
})
|
||||
|
|
102
src/main.js
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
},
|
||||
|
||||
},
|
||||
}
|
|
@ -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
|
@ -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
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|