Compare commits
309 Commits
feature/te
...
main
Author | SHA1 | Date | |
---|---|---|---|
a94198f2eb | |||
a6e7e6efa6 | |||
6f5f7b190a | |||
0473c385d6 | |||
68a76faacc | |||
4579dd3ce7 | |||
f4fee26fe4 | |||
00398085fd | |||
13c8e6dbcd | |||
1b5f8a069b | |||
21fec9461d | |||
df3af739f8 | |||
b08d34bc96 | |||
be03efd015 | |||
c353fd151d | |||
c32e9badf0 | |||
8f64ab5dce | |||
63ca8ffc7c | |||
fe9ddf33ca | |||
74777d6bed | |||
76d1c56fab | |||
f75e9135c2 | |||
480f0f8da9 | |||
ac832186d6 | |||
738e1e8370 | |||
|
9b85817ddb | ||
|
49a6569db0 | ||
e762f7f073 | |||
e5d2b23cb3 | |||
6eddf23c0d | |||
70934c6a0b | |||
49955eb03a | |||
2b302974cc | |||
|
64d632b0a5 | ||
|
e28f0f5be4 | ||
c618b7e0b6 | |||
380af7fbf2 | |||
9d3ef30be6 | |||
fc00169863 | |||
b652225a12 | |||
29d8422e94 | |||
|
cdbd1c2ac4 | ||
cb37fd773d | |||
d2577f1df6 | |||
27534a98e9 | |||
de77393905 | |||
dd450263fb | |||
d8106dcb73 | |||
8114012997 | |||
bc4ea82639 | |||
cd97cfe612 | |||
e6136fdee4 | |||
2c395c720a | |||
8b639fd4af | |||
f7bd5f13ac | |||
0ae774b95c | |||
be899c3eb0 | |||
dc02827a33 | |||
951e511bf9 | |||
5f1d936ca4 | |||
c3845e5690 | |||
f4db2df37b | |||
5668fc7a2c | |||
12a0099fbf | |||
7f4027cbe8 | |||
b57aa33cae | |||
b80de79594 | |||
ead3e45a59 | |||
4a7d2d8414 | |||
0befa58908 | |||
8ae84eaf42 | |||
cd10bc9d7a | |||
f4545fbe2f | |||
c6ffe8acab | |||
2fb16f9a77 | |||
be427449e4 | |||
5e889ebe36 | |||
8c62c96109 | |||
7908a1e657 | |||
09b62da2ae | |||
076494afa5 | |||
b604d5da75 | |||
59889cccf3 | |||
f8ffb428d3 | |||
c2ea932c09 | |||
033d97e919 | |||
4694c14760 | |||
7e8e26679c | |||
838db379ff | |||
77da84d21a | |||
91b80cba67 | |||
ac57c8572a | |||
858448b877 | |||
8409db98f6 | |||
04cc01daaf | |||
70f8d46b6c | |||
15114e487b | |||
7f79f2a7b3 | |||
7387112172 | |||
|
3743cdc058 | ||
|
ce02462cfe | ||
|
7af21c48d5 | ||
|
943e554a58 | ||
e885d8ae70 | |||
2cef6cb343 | |||
7ebca9afc5 | |||
d86eb9ea0b | |||
|
063592ca3d | ||
d9fa0dd2bc | |||
4cbacafbbb | |||
2ce260bcf1 | |||
d606be3459 | |||
d79b14221d | |||
fdbacc2084 | |||
dd123dadd2 | |||
b088a5e864 | |||
63008e59a3 | |||
3a1da44c94 | |||
fccb0dcc61 | |||
|
b3b7669983 | ||
d114c673d8 | |||
a6771b8d37 | |||
91b06a9af6 | |||
08502619c4 | |||
26de8e66fa | |||
f944c35e99 | |||
36fb250d1f | |||
b7aa7891e9 | |||
ccaed029f2 | |||
770578240a | |||
791678720b | |||
21e44e15bd | |||
9a612849a3 | |||
c4173c3c35 | |||
46afeb159a | |||
940494a02d | |||
1a55d04c97 | |||
8ccfe716f6 | |||
e3243f012d | |||
eef37c4f70 | |||
e7e1cc0e55 | |||
f8031b5aef | |||
ac61e8d449 | |||
2c671c6a13 | |||
d01c3eabb9 | |||
d85d86eaa7 | |||
e1b04eed72 | |||
543dae2f30 | |||
40479d0c6e | |||
f5fba7880f | |||
ed332f5dd3 | |||
9ecd18a5ee | |||
63070e63bd | |||
32353e3b76 | |||
14f1ee1885 | |||
8440869bcd | |||
14397ffb31 | |||
c3c4d2a0a5 | |||
b03d5d80cd | |||
2bf9be3676 | |||
807fb6a9fe | |||
a106511646 | |||
7ed71f66ea | |||
4463b83b78 | |||
14f14f6d3e | |||
e1449642bd | |||
5dfaeb39ca | |||
9584ef127a | |||
|
cb9e1e891d | ||
41c0594bd2 | |||
9acc9039a6 | |||
f530d4763e | |||
3f4bcbcecd | |||
89d8c9d639 | |||
2de523f2e0 | |||
22e62a2cea | |||
92c89d145c | |||
306d562f65 | |||
4b8a7e1556 | |||
d0c6576efa | |||
e684e9a90b | |||
805e1bc554 | |||
8ee793c054 | |||
1a119f97c5 | |||
10fe38cef6 | |||
b4cbe1e1fd | |||
8b8e413af0 | |||
c8029ec3c4 | |||
6225c54447 | |||
809e876091 | |||
470022899f | |||
8d1d60ba80 | |||
028ad3dc14 | |||
f4df628e47 | |||
150b847638 | |||
684acc01bd | |||
3218cf60f0 | |||
bba9a8e008 | |||
852d71e8b7 | |||
c65bb4e93b | |||
1c3f655323 | |||
bd19234041 | |||
|
ac630ac775 | ||
f758eefa88 | |||
|
4137bab7fc | ||
d253d2e743 | |||
fe5770082a | |||
2041722b8a | |||
648b947a05 | |||
f58e114947 | |||
144e7bd10c | |||
b96e89ca8c | |||
|
20f0496fa5 | ||
e535584412 | |||
c5b9e2a1ff | |||
|
e45bc83132 | ||
|
bc8b04fc7a | ||
|
84284a6211 | ||
|
716de2c99c | ||
|
769d94e879 | ||
|
baa86530c8 | ||
7613afbf27 | |||
aeb886e4c5 | |||
306bd1c179 | |||
f3cf79fa65 | |||
8c945b049a | |||
f69111c105 | |||
03afbfc6c8 | |||
c07288dd8b | |||
03f3c52548 | |||
384037c694 | |||
734db0795c | |||
8f6c0f3738 | |||
f3324c6aaf | |||
f8d009a6aa | |||
|
59e915cc10 | ||
|
0c9dad9891 | ||
|
b7ad29f056 | ||
a7434f24df | |||
f61d5bac46 | |||
2911dee3cc | |||
58c361ee29 | |||
d3fc1439b5 | |||
bb544c353c | |||
cc90a1cea5 | |||
cffba33748 | |||
055e0a2901 | |||
c8d1921bcd | |||
9e09314f75 | |||
c3833d90d8 | |||
e24cb55e1b | |||
f897611ad1 | |||
ea8fe297d9 | |||
c6b604f1fa | |||
4792adfbd1 | |||
|
04c94418d7 | ||
12bec04c42 | |||
e8eb94d71b | |||
5137f9f6cb | |||
a2f65d86c2 | |||
08dcc897cc | |||
c975fb0fee | |||
b73cf344b6 | |||
d4b45dc255 | |||
e2beaba3b9 | |||
dd9be97793 | |||
6cde8e2640 | |||
5c6fcffd75 | |||
9b243873c5 | |||
dc347ed8ba | |||
d0d1086dac | |||
fb91b71395 | |||
|
b688f35446 | ||
|
91580f97a1 | ||
d95fc32d67 | |||
|
6b5ac20ef8 | ||
|
66f0df5037 | ||
|
981babd691 | ||
46fa43d67f | |||
4ef54f1bc2 | |||
|
b029889f27 | ||
|
44f8e3ea9b | ||
|
ae36c041a7 | ||
8a722f294c | |||
5674acbee6 | |||
bf9371c60a | |||
181930f537 | |||
cee22a1942 | |||
673458b41d | |||
b56e99bfdf | |||
a5b5d99129 | |||
e1b9a9921c | |||
709ebdf567 | |||
d41ee3dc8c | |||
e342f6e3ed | |||
8b2450d6f9 | |||
943eab5e7e | |||
d55328e03b | |||
30aa1cd1cf | |||
01f3196938 | |||
ced8e0fd3c | |||
|
b838e7494d | ||
745b4b56ec | |||
c5b539912d | |||
|
ed6dc94873 | ||
233b9693eb | |||
|
2656c74f37 | ||
28b571588e | |||
ae5d3ecac5 |
11
.drone.yml
|
@ -98,8 +98,17 @@ steps:
|
|||
depends_on:
|
||||
- dependencies
|
||||
|
||||
- name: typecheck
|
||||
failure: ignore
|
||||
image: node:16
|
||||
pull: true
|
||||
commands:
|
||||
- yarn typecheck
|
||||
depends_on:
|
||||
- dependencies
|
||||
|
||||
- name: test-frontend
|
||||
image: cypress/browsers:node14.17.0-chrome91-ff89
|
||||
image: cypress/browsers:node16.5.0-chrome94-ff93
|
||||
pull: true
|
||||
environment:
|
||||
CYPRESS_API_URL: http://api:3456/api/v1
|
||||
|
|
|
@ -9,6 +9,13 @@ 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.18.2] - 2021-11-23
|
||||
|
||||
### Fixed
|
||||
|
||||
* fix(docker): properly replace api url
|
||||
* fix: edit saved filter title
|
||||
|
||||
## [0.18.1] - 2021-09-08
|
||||
|
||||
### Added
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
[![Build Status](https://drone.kolaente.de/api/badges/vikunja/frontend/status.svg)](https://drone.kolaente.de/vikunja/frontend)
|
||||
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](LICENSE)
|
||||
[![Download](https://img.shields.io/badge/download-v0.18.1-brightgreen.svg)](https://dl.vikunja.io)
|
||||
[![Download](https://img.shields.io/badge/download-v0.18.2-brightgreen.svg)](https://dl.vikunja.io)
|
||||
[![Translation](https://badges.crowdin.net/vikunja/localized.svg)](https://crowdin.com/project/vikunja)
|
||||
|
||||
This is the web frontend for Vikunja, written in Vue.js.
|
||||
|
|
|
@ -31,7 +31,7 @@ describe('Lists', () => {
|
|||
cy.url()
|
||||
.should('contain', '/namespaces/1/list')
|
||||
cy.get('.card-header-title')
|
||||
.contains('Create a new list')
|
||||
.contains('New list')
|
||||
cy.get('input.input')
|
||||
.type('New List')
|
||||
cy.get('.button')
|
||||
|
@ -101,7 +101,7 @@ describe('Lists', () => {
|
|||
.click()
|
||||
cy.url()
|
||||
.should('contain', '/settings/delete')
|
||||
cy.get('.modal-mask .modal-container .modal-content .actions a.button')
|
||||
cy.get('[data-cy="modalPrimary"]')
|
||||
.contains('Do it')
|
||||
.click()
|
||||
|
||||
|
@ -392,7 +392,7 @@ describe('Lists', () => {
|
|||
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item .field input.input')
|
||||
.first()
|
||||
.type(3)
|
||||
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item .field a.button.is-primary')
|
||||
cy.get('[data-cy="setBucketLimit"]')
|
||||
.first()
|
||||
.click()
|
||||
|
||||
|
|
|
@ -15,7 +15,7 @@ describe('Namepaces', () => {
|
|||
|
||||
it('Should be all there', () => {
|
||||
cy.visit('/namespaces')
|
||||
cy.get('.namespace h1 span')
|
||||
cy.get('[data-cy="namespace-title"]')
|
||||
.should('contain', namespaces[0].title)
|
||||
})
|
||||
|
||||
|
@ -23,14 +23,14 @@ describe('Namepaces', () => {
|
|||
const newNamespaceTitle = 'New Namespace'
|
||||
|
||||
cy.visit('/namespaces')
|
||||
cy.get('a.button')
|
||||
.contains('Create a new namespace')
|
||||
cy.get('[data-cy="new-namespace"]')
|
||||
.should('contain', 'New namespace')
|
||||
.click()
|
||||
|
||||
cy.url()
|
||||
.should('contain', '/namespaces/new')
|
||||
cy.get('.card-header-title')
|
||||
.should('contain', 'Create a new namespace')
|
||||
.should('contain', 'New namespace')
|
||||
cy.get('input.input')
|
||||
.type(newNamespaceTitle)
|
||||
cy.get('.button')
|
||||
|
@ -72,7 +72,7 @@ describe('Namepaces', () => {
|
|||
cy.get('.namespace-container .menu.namespaces-lists')
|
||||
.should('contain', newNamespaceName)
|
||||
.should('not.contain', newNamespaces[0].title)
|
||||
cy.get('.content.namespaces-list')
|
||||
cy.get('[data-cy="namespaces-list"]')
|
||||
.should('contain', newNamespaceName)
|
||||
.should('not.contain', newNamespaces[0].title)
|
||||
})
|
||||
|
@ -89,7 +89,7 @@ describe('Namepaces', () => {
|
|||
.click()
|
||||
cy.url()
|
||||
.should('contain', '/settings/delete')
|
||||
cy.get('.modal-mask .modal-container .modal-content .actions a.button')
|
||||
cy.get('[data-cy="modalPrimary"]')
|
||||
.contains('Do it')
|
||||
.click()
|
||||
|
||||
|
@ -116,30 +116,30 @@ describe('Namepaces', () => {
|
|||
|
||||
// Initial
|
||||
cy.visit('/namespaces')
|
||||
cy.get('.namespaces-list .namespace')
|
||||
cy.get('.namespace')
|
||||
.should('not.contain', 'Archived')
|
||||
|
||||
// Show archived
|
||||
cy.get('.namespaces-list .fancycheckbox.show-archived-check label.check span')
|
||||
cy.get('[data-cy="show-archived-check"] label.check span')
|
||||
.should('be.visible')
|
||||
.click()
|
||||
cy.get('.namespaces-list .fancycheckbox.show-archived-check input')
|
||||
cy.get('[data-cy="show-archived-check"] input')
|
||||
.should('be.checked')
|
||||
cy.get('.namespaces-list .namespace')
|
||||
cy.get('.namespace')
|
||||
.should('contain', 'Archived')
|
||||
|
||||
// Don't show archived
|
||||
cy.get('.namespaces-list .fancycheckbox.show-archived-check label.check span')
|
||||
cy.get('[data-cy="show-archived-check"] label.check span')
|
||||
.should('be.visible')
|
||||
.click()
|
||||
cy.get('.namespaces-list .fancycheckbox.show-archived-check input')
|
||||
cy.get('[data-cy="show-archived-check"] input')
|
||||
.should('not.be.checked')
|
||||
|
||||
// Second time visiting after unchecking
|
||||
cy.visit('/namespaces')
|
||||
cy.get('.namespaces-list .fancycheckbox.show-archived-check input')
|
||||
cy.get('[data-cy="show-archived-check"] input')
|
||||
.should('not.be.checked')
|
||||
cy.get('.namespaces-list .namespace')
|
||||
cy.get('.namespace')
|
||||
.should('not.contain', 'Archived')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,35 +0,0 @@
|
|||
import '../../support/authenticateUser'
|
||||
|
||||
const setHours = hours => {
|
||||
const date = new Date()
|
||||
date.setHours(hours)
|
||||
cy.clock(+date)
|
||||
}
|
||||
|
||||
describe('Home Page', () => {
|
||||
it('shows the right salutation in the night', () => {
|
||||
setHours(4)
|
||||
cy.visit('/')
|
||||
cy.get('h2').should('contain', 'Good Night')
|
||||
})
|
||||
it('shows the right salutation in the morning', () => {
|
||||
setHours(8)
|
||||
cy.visit('/')
|
||||
cy.get('h2').should('contain', 'Good Morning')
|
||||
})
|
||||
it('shows the right salutation in the day', () => {
|
||||
setHours(13)
|
||||
cy.visit('/')
|
||||
cy.get('h2').should('contain', 'Hi')
|
||||
})
|
||||
it('shows the right salutation in the night', () => {
|
||||
setHours(20)
|
||||
cy.visit('/')
|
||||
cy.get('h2').should('contain', 'Good Evening')
|
||||
})
|
||||
it('shows the right salutation in the night again', () => {
|
||||
setHours(23)
|
||||
cy.visit('/')
|
||||
cy.get('h2').should('contain', 'Good Night')
|
||||
})
|
||||
})
|
|
@ -128,7 +128,7 @@ describe('Task', () => {
|
|||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.task-view .action-buttons .button')
|
||||
.contains('Done!')
|
||||
.contains('Mark task done!')
|
||||
.click()
|
||||
|
||||
cy.get('.task-view .heading .is-done')
|
||||
|
@ -168,7 +168,7 @@ describe('Task', () => {
|
|||
.click()
|
||||
cy.get('.task-view .details.content.description .editor .vue-easymde .EasyMDEContainer .CodeMirror-scroll')
|
||||
.type('{selectall}New Description')
|
||||
cy.get('.task-view .details.content.description .editor a')
|
||||
cy.get('[data-cy="saveEditor"]')
|
||||
.contains('Save')
|
||||
.click()
|
||||
|
||||
|
@ -263,8 +263,7 @@ describe('Task', () => {
|
|||
|
||||
cy.visit(`/tasks/${tasks[0].id}`)
|
||||
|
||||
cy.get('.task-view .action-buttons .button')
|
||||
.contains('Assign this task to a user')
|
||||
cy.get('[data-cy="taskDetail.assign"]')
|
||||
.click()
|
||||
cy.get('.task-view .column.assignees .multiselect input')
|
||||
.type(users[1].username)
|
||||
|
@ -405,7 +404,7 @@ describe('Task', () => {
|
|||
cy.get('.datepicker .datepicker-popup a')
|
||||
.contains('Tomorrow')
|
||||
.click()
|
||||
cy.get('.datepicker .datepicker-popup a.button')
|
||||
cy.get('[data-cy="closeDatepicker"]')
|
||||
.contains('Confirm')
|
||||
.click()
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ const testAndAssertFailed = fixture => {
|
|||
|
||||
cy.wait(5000) // It can take waaaayy too long to log the user in
|
||||
cy.url().should('include', '/')
|
||||
cy.get('div.notification.is-danger').contains('Wrong username or password.')
|
||||
cy.get('div.message.danger').contains('Wrong username or password.')
|
||||
}
|
||||
|
||||
context('Login', () => {
|
||||
|
|
|
@ -32,7 +32,7 @@ context('Registration', () => {
|
|||
cy.get('h2').should('contain', `Hi ${fixture.username}!`)
|
||||
})
|
||||
|
||||
it('Should fail', () => {
|
||||
it.only('Should fail', () => {
|
||||
const fixture = {
|
||||
username: 'test',
|
||||
password: '123456',
|
||||
|
@ -45,6 +45,6 @@ context('Registration', () => {
|
|||
cy.get('#password').type(fixture.password)
|
||||
cy.get('#passwordValidation').type(fixture.password)
|
||||
cy.get('#register-submit').click()
|
||||
cy.get('div.notification.is-danger').contains('A user with this username already exists.')
|
||||
cy.get('div.message.danger').contains('A user with this username already exists.')
|
||||
})
|
||||
})
|
|
@ -18,7 +18,7 @@ describe('User Settings', () => {
|
|||
.trigger('mousedown', {which: 1})
|
||||
.trigger('mousemove', {clientY: 100})
|
||||
.trigger('mouseup')
|
||||
cy.get('a.button.is-primary')
|
||||
cy.get('[data-cy="uploadAvatar"]')
|
||||
.contains('Upload Avatar')
|
||||
.click()
|
||||
|
||||
|
@ -33,7 +33,7 @@ describe('User Settings', () => {
|
|||
cy.get('.general-settings .control input.input')
|
||||
.first()
|
||||
.type('Lorem Ipsum')
|
||||
cy.get('.card.general-settings .button.is-primary')
|
||||
cy.get('[data-cy="saveGeneralSettings"]')
|
||||
.contains('Save')
|
||||
.click()
|
||||
|
||||
|
|
|
@ -49,7 +49,7 @@
|
|||
inkscape:label="ink_ext_XXXXXX 1"
|
||||
style="display:inline"
|
||||
transform="translate(-92.67749,-674.48297)"><circle
|
||||
style="fill:#5974d9;fill-opacity:1;stroke:none;stroke-width:2.88757133;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
|
||||
style="fill:#196aff;fill-opacity:1;stroke:none;stroke-width:2.88757133;stroke-linecap:square;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:normal"
|
||||
id="path920"
|
||||
cx="242.67749"
|
||||
cy="828.77881"
|
||||
|
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
121
package.json
|
@ -9,91 +9,97 @@
|
|||
"build": "vite build && workbox copyLibraries dist/",
|
||||
"build:modern-only": "BUILD_MODERN_ONLY=true vite build && workbox copyLibraries dist/",
|
||||
"build:dev": "vite build -m development --outDir dist-dev/",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"lint": "eslint --ignore-pattern '*.test.*' ./src --ext .vue,.js,.ts",
|
||||
"lint:markup": "vue-tsc --noEmit",
|
||||
"cypress:open": "cypress open",
|
||||
"test:unit": "jest",
|
||||
"test:unit": "vitest run",
|
||||
"test:frontend": "cypress run",
|
||||
"browserslist:update": "npx browserslist@latest --update-db"
|
||||
},
|
||||
"dependencies": {
|
||||
"@github/hotkey": "1.6.0",
|
||||
"@github/hotkey": "1.6.1",
|
||||
"@kyvg/vue3-notification": "2.3.4",
|
||||
"@sentry/tracing": "6.15.0",
|
||||
"@sentry/vue": "6.15.0",
|
||||
"@vue/compat": "3.2.22",
|
||||
"bulma": "0.9.3",
|
||||
"@sentry/tracing": "6.16.1",
|
||||
"@sentry/vue": "6.16.1",
|
||||
"@types/is-touch-device": "1.0.0",
|
||||
"@vue/compat": "3.2.26",
|
||||
"@vueuse/core": "7.5.2",
|
||||
"@vueuse/router": "7.5.3",
|
||||
"bulma-css-variables": "0.9.33",
|
||||
"camel-case": "4.1.2",
|
||||
"codemirror": "5.63.3",
|
||||
"codemirror": "5.65.0",
|
||||
"copy-to-clipboard": "3.3.1",
|
||||
"date-fns": "2.25.0",
|
||||
"dompurify": "2.3.3",
|
||||
"date-fns": "2.28.0",
|
||||
"dompurify": "2.3.4",
|
||||
"easymde": "2.15.0",
|
||||
"flatpickr": "4.6.9",
|
||||
"flexsearch": "0.7.21",
|
||||
"highlight.js": "11.3.1",
|
||||
"highlight.js": "11.4.0",
|
||||
"is-touch-device": "1.0.1",
|
||||
"lodash.clonedeep": "4.5.0",
|
||||
"lodash.debounce": "4.0.8",
|
||||
"marked": "4.0.3",
|
||||
"marked": "4.0.9",
|
||||
"register-service-worker": "1.7.2",
|
||||
"snake-case": "3.0.4",
|
||||
"ufo": "0.7.9",
|
||||
"vue": "3.2.22",
|
||||
"vue-advanced-cropper": "2.7.0",
|
||||
"v-tooltip": "4.0.0-beta.13",
|
||||
"vue": "3.2.26",
|
||||
"vue-advanced-cropper": "2.7.1",
|
||||
"vue-drag-resize": "2.0.3",
|
||||
"vue-flatpickr-component": "9.0.5",
|
||||
"vue-i18n": "9.2.0-beta.18",
|
||||
"vue-i18n": "9.2.0-beta.26",
|
||||
"vue-router": "4.0.12",
|
||||
"vuedraggable": "4.1.0",
|
||||
"vuex": "4.0.2",
|
||||
"workbox-precaching": "6.4.1"
|
||||
"workbox-precaching": "6.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@4tw/cypress-drag-drop": "2.0.0",
|
||||
"@4tw/cypress-drag-drop": "2.1.0",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.36",
|
||||
"@fortawesome/free-regular-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-solid-svg-icons": "5.15.4",
|
||||
"@fortawesome/vue-fontawesome": "3.0.0-5",
|
||||
"@types/flexsearch": "0.7.2",
|
||||
"@types/jest": "27.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "5.4.0",
|
||||
"@typescript-eslint/parser": "5.4.0",
|
||||
"@vitejs/plugin-legacy": "1.6.2",
|
||||
"@vitejs/plugin-vue": "1.9.4",
|
||||
"@vue/eslint-config-typescript": "9.1.0",
|
||||
"autoprefixer": "10.4.0",
|
||||
"@typescript-eslint/eslint-plugin": "5.9.0",
|
||||
"@typescript-eslint/parser": "5.9.0",
|
||||
"@vitejs/plugin-legacy": "1.6.4",
|
||||
"@vitejs/plugin-vue": "2.0.1",
|
||||
"@vue/eslint-config-typescript": "10.0.0",
|
||||
"autoprefixer": "10.4.2",
|
||||
"axios": "0.24.0",
|
||||
"browserslist": "4.18.1",
|
||||
"cypress": "9.0.0",
|
||||
"browserslist": "4.19.1",
|
||||
"caniuse-lite": "1.0.30001298",
|
||||
"cypress": "9.2.0",
|
||||
"cypress-file-upload": "5.0.8",
|
||||
"esbuild": "0.13.14",
|
||||
"eslint": "8.2.0",
|
||||
"eslint-plugin-vue": "8.0.3",
|
||||
"express": "4.17.1",
|
||||
"esbuild": "0.14.10",
|
||||
"eslint": "8.6.0",
|
||||
"eslint-plugin-vue": "8.2.0",
|
||||
"express": "4.17.2",
|
||||
"faker": "5.5.3",
|
||||
"jest": "27.3.1",
|
||||
"netlify-cli": "6.14.25",
|
||||
"postcss": "8.3.11",
|
||||
"rollup": "2.60.0",
|
||||
"netlify-cli": "8.6.15",
|
||||
"happy-dom": "2.25.1",
|
||||
"postcss": "8.4.5",
|
||||
"postcss-preset-env": "7.2.0",
|
||||
"rollup": "2.63.0",
|
||||
"rollup-plugin-visualizer": "5.5.2",
|
||||
"sass": "1.43.4",
|
||||
"slugify": "1.6.2",
|
||||
"ts-jest": "27.0.7",
|
||||
"typescript": "4.4.4",
|
||||
"vite": "2.6.14",
|
||||
"vite-plugin-pwa": "0.11.5",
|
||||
"vite-svg-loader": "3.1.0",
|
||||
"vue-tsc": "0.29.5",
|
||||
"sass": "1.47.0",
|
||||
"slugify": "1.6.5",
|
||||
"typescript": "4.5.4",
|
||||
"vite": "2.7.10",
|
||||
"vite-plugin-pwa": "0.11.12",
|
||||
"vite-svg-loader": "3.1.1",
|
||||
"vitest": "0.0.139",
|
||||
"vue-tsc": "0.30.2",
|
||||
"wait-on": "6.0.0",
|
||||
"workbox-cli": "6.4.1"
|
||||
"workbox-cli": "6.4.2"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true,
|
||||
"node": true
|
||||
"node": true,
|
||||
"vue/setup-compiler-macros": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
|
@ -117,6 +123,7 @@
|
|||
"error",
|
||||
"never"
|
||||
],
|
||||
"vue/script-setup-uses-vars": "error",
|
||||
"vue/multi-word-component-names": 0
|
||||
},
|
||||
"parser": "vue-eslint-parser",
|
||||
|
@ -127,30 +134,16 @@
|
|||
"ignorePatterns": [
|
||||
"*.test.*",
|
||||
"cypress/*"
|
||||
]
|
||||
],
|
||||
"globals": {
|
||||
"defineProps": "readonly"
|
||||
}
|
||||
},
|
||||
"postcss": {
|
||||
"plugins": {
|
||||
"autoprefixer": {}
|
||||
}
|
||||
},
|
||||
"jest": {
|
||||
"testPathIgnorePatterns": [
|
||||
"cypress"
|
||||
],
|
||||
"testEnvironment": "jsdom",
|
||||
"preset": "ts-jest",
|
||||
"roots": [
|
||||
"<rootDir>/src"
|
||||
],
|
||||
"transform": {
|
||||
"^.+\\.(js|tsx?)$": "ts-jest"
|
||||
},
|
||||
"moduleFileExtensions": [
|
||||
"ts",
|
||||
"js",
|
||||
"json"
|
||||
]
|
||||
},
|
||||
"license": "AGPL-3.0-or-later"
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"packageManager": "yarn@1.22.17"
|
||||
}
|
||||
|
|
2
run.sh
|
@ -4,7 +4,7 @@
|
|||
|
||||
VIKUNJA_API_URL="${VIKUNJA_API_URL:-"/api/v1"}"
|
||||
VIKUNJA_SENTRY_ENABLED="${VIKUNJA_SENTRY_ENABLED:-"false"}"
|
||||
VIKUNJA_SENTRY_DSN="${VIKUNJA_SENTRY_DSN:-"https://7e684483a06a4225b3e05cc47cae7a11@sentry.kolaente.de/2"}"
|
||||
VIKUNJA_SENTRY_DSN="${VIKUNJA_SENTRY_DSN:-"https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480"}"
|
||||
VIKUNJA_HTTP_PORT="${VIKUNJA_HTTP_PORT:-80}"
|
||||
VIKUNJA_HTTPS_PORT="${VIKUNJA_HTTPS_PORT:-443}"
|
||||
|
||||
|
|
170
src/App.vue
|
@ -1,112 +1,92 @@
|
|||
<template>
|
||||
<ready>
|
||||
<div :class="{'is-touch': isTouch}">
|
||||
<div :class="{'is-hidden': !online}">
|
||||
<template v-if="authUser">
|
||||
<top-navigation/>
|
||||
<content-auth/>
|
||||
</template>
|
||||
<content-link-share v-else-if="authLinkShare"/>
|
||||
<content-no-auth v-else/>
|
||||
<notification/>
|
||||
</div>
|
||||
<template v-if="authUser">
|
||||
<top-navigation/>
|
||||
<content-auth/>
|
||||
</template>
|
||||
<content-link-share v-else-if="authLinkShare"/>
|
||||
<no-auth-wrapper v-else>
|
||||
<router-view/>
|
||||
</no-auth-wrapper>
|
||||
<Notification/>
|
||||
|
||||
<transition name="fade">
|
||||
<keyboard-shortcuts v-if="keyboardShortcutsActive"/>
|
||||
</transition>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<keyboard-shortcuts v-if="keyboardShortcutsActive"/>
|
||||
</transition>
|
||||
</ready>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {defineComponent} from 'vue'
|
||||
import {mapState, mapGetters} from 'vuex'
|
||||
<script lang="ts" setup>
|
||||
import {computed, watch, Ref} from 'vue'
|
||||
import {useRouter} from 'vue-router'
|
||||
import {useRouteQuery} from '@vueuse/router'
|
||||
import {useStore} from 'vuex'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import isTouchDevice from 'is-touch-device'
|
||||
import {success} from '@/message'
|
||||
|
||||
import Notification from '@/components/misc/notification.vue'
|
||||
import KeyboardShortcuts from './components/misc/keyboard-shortcuts/index.vue'
|
||||
import TopNavigation from './components/home/topNavigation.vue'
|
||||
import ContentAuth from './components/home/contentAuth.vue'
|
||||
import ContentLinkShare from './components/home/contentLinkShare.vue'
|
||||
import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue'
|
||||
import Ready from '@/components/misc/ready.vue'
|
||||
|
||||
import Notification from './components/misc/notification'
|
||||
import {KEYBOARD_SHORTCUTS_ACTIVE, ONLINE} from './store/mutation-types'
|
||||
import KeyboardShortcuts from './components/misc/keyboard-shortcuts'
|
||||
import TopNavigation from './components/home/topNavigation'
|
||||
import ContentAuth from './components/home/contentAuth'
|
||||
import ContentLinkShare from './components/home/contentLinkShare'
|
||||
import ContentNoAuth from './components/home/contentNoAuth'
|
||||
import {setLanguage} from './i18n'
|
||||
import AccountDeleteService from '@/services/accountDelete'
|
||||
import Ready from '@/components/misc/ready'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'app',
|
||||
components: {
|
||||
ContentNoAuth,
|
||||
ContentLinkShare,
|
||||
ContentAuth,
|
||||
TopNavigation,
|
||||
KeyboardShortcuts,
|
||||
Notification,
|
||||
Ready,
|
||||
},
|
||||
beforeMount() {
|
||||
this.setupOnlineStatus()
|
||||
this.setupPasswortResetRedirect()
|
||||
this.setupEmailVerificationRedirect()
|
||||
this.setupAccountDeletionVerification()
|
||||
},
|
||||
beforeCreate() {
|
||||
setLanguage()
|
||||
},
|
||||
created() {
|
||||
// Make sure to always load the home route when running with electron
|
||||
if (this.$route.fullPath.endsWith('frontend/index.html')) {
|
||||
this.$router.push({name: 'home'})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isTouch() {
|
||||
return isTouchDevice()
|
||||
},
|
||||
...mapState({
|
||||
online: ONLINE,
|
||||
keyboardShortcutsActive: KEYBOARD_SHORTCUTS_ACTIVE,
|
||||
}),
|
||||
...mapGetters('auth', [
|
||||
'authUser',
|
||||
'authLinkShare',
|
||||
]),
|
||||
},
|
||||
methods: {
|
||||
setupOnlineStatus() {
|
||||
this.$store.commit(ONLINE, navigator.onLine)
|
||||
window.addEventListener('online', () => this.$store.commit(ONLINE, navigator.onLine))
|
||||
window.addEventListener('offline', () => this.$store.commit(ONLINE, navigator.onLine))
|
||||
},
|
||||
setupPasswortResetRedirect() {
|
||||
if (typeof this.$route.query.userPasswordReset === 'undefined') {
|
||||
return
|
||||
}
|
||||
import {useColorScheme} from '@/composables/useColorScheme'
|
||||
import {useBodyClass} from '@/composables/useBodyClass'
|
||||
|
||||
localStorage.setItem('passwordResetToken', this.$route.query.userPasswordReset)
|
||||
this.$router.push({name: 'user.password-reset.reset'})
|
||||
},
|
||||
setupEmailVerificationRedirect() {
|
||||
if (typeof this.$route.query.userEmailConfirm === 'undefined') {
|
||||
return
|
||||
}
|
||||
const store = useStore()
|
||||
const router = useRouter()
|
||||
|
||||
localStorage.setItem('emailConfirmToken', this.$route.query.userEmailConfirm)
|
||||
this.$router.push({name: 'user.login'})
|
||||
},
|
||||
async setupAccountDeletionVerification() {
|
||||
if (typeof this.$route.query.accountDeletionConfirm === 'undefined') {
|
||||
return
|
||||
}
|
||||
useBodyClass('is-touch', isTouchDevice)
|
||||
const keyboardShortcutsActive = computed(() => store.state.keyboardShortcutsActive)
|
||||
|
||||
const accountDeletionService = new AccountDeleteService()
|
||||
await accountDeletionService.confirm(this.$route.query.accountDeletionConfirm)
|
||||
this.$message.success({message: this.$t('user.deletion.confirmSuccess')})
|
||||
this.$store.dispatch('auth/refreshUserInfo')
|
||||
},
|
||||
},
|
||||
})
|
||||
const authUser = computed(() => store.getters['auth/authUser'])
|
||||
const authLinkShare = computed(() => store.getters['auth/authLinkShare'])
|
||||
|
||||
const {t} = useI18n()
|
||||
|
||||
// setup account deletion verification
|
||||
const accountDeletionConfirm = useRouteQuery('accountDeletionConfirm') as Ref<null | string>
|
||||
watch(accountDeletionConfirm, async (accountDeletionConfirm) => {
|
||||
if (accountDeletionConfirm === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const accountDeletionService = new AccountDeleteService()
|
||||
await accountDeletionService.confirm(accountDeletionConfirm)
|
||||
success({message: t('user.deletion.confirmSuccess')})
|
||||
store.dispatch('auth/refreshUserInfo')
|
||||
}, { immediate: true })
|
||||
|
||||
// setup passwort reset redirect
|
||||
const userPasswordReset = useRouteQuery('userPasswordReset') as Ref<null | string>
|
||||
watch(userPasswordReset, (userPasswordReset) => {
|
||||
if (userPasswordReset === null) {
|
||||
return
|
||||
}
|
||||
|
||||
localStorage.setItem('passwordResetToken', userPasswordReset)
|
||||
router.push({name: 'user.password-reset.reset'})
|
||||
}, { immediate: true })
|
||||
|
||||
// setup email verification redirect
|
||||
const userEmailConfirm = useRouteQuery('userEmailConfirm') as Ref<null | string>
|
||||
watch(userEmailConfirm, (userEmailConfirm) => {
|
||||
if (userEmailConfirm === null) {
|
||||
return
|
||||
}
|
||||
|
||||
localStorage.setItem('emailConfirmToken', userEmailConfirm)
|
||||
router.push({name: 'user.login'})
|
||||
}, { immediate: true })
|
||||
|
||||
setLanguage()
|
||||
useColorScheme()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.3 KiB |
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.6 KiB |
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
BIN
src/assets/no-auth-image.jpg
Normal file
After Width: | Height: | Size: 519 KiB |
118
src/components/base/BaseButton.vue
Normal file
|
@ -0,0 +1,118 @@
|
|||
<template>
|
||||
<component
|
||||
:is="componentNodeName"
|
||||
class="base-button"
|
||||
:class="{ 'base-button--type-button': isButton }"
|
||||
v-bind="elementBindings"
|
||||
:disabled="disabled || undefined"
|
||||
>
|
||||
<slot />
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
// see https://v3.vuejs.org/api/sfc-script-setup.html#usage-alongside-normal-script
|
||||
export default {
|
||||
inheritAttrs: false,
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts" setup>
|
||||
// this component removes styling differences between links / vue-router links and button elements
|
||||
// by doing so we make it easy abstract the functionality from style and enable easier and semantic
|
||||
// correct button and link usage. Also see: https://css-tricks.com/a-complete-guide-to-links-and-buttons/#accessibility-considerations
|
||||
|
||||
// the component tries to heuristically determine what it should be checking the props (see the
|
||||
// componentNodeName and elementBindings ref for this).
|
||||
|
||||
// NOTE: Do NOT use buttons with @click to push routes. => Use router-links instead!
|
||||
|
||||
import { ref, watchEffect, computed, useAttrs, PropType } from 'vue'
|
||||
|
||||
const BASE_BUTTON_TYPES_MAP = Object.freeze({
|
||||
button: 'button',
|
||||
submit: 'submit',
|
||||
})
|
||||
|
||||
type BaseButtonTypes = keyof typeof BASE_BUTTON_TYPES_MAP
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: String as PropType<BaseButtonTypes>,
|
||||
default: 'button',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
const componentNodeName = ref<Node['nodeName']>('button')
|
||||
interface ElementBindings {
|
||||
type?: string;
|
||||
rel?: string,
|
||||
}
|
||||
|
||||
const elementBindings = ref({})
|
||||
|
||||
const attrs = useAttrs()
|
||||
watchEffect(() => {
|
||||
// by default this component is a button element with the attribute of the type "button" (default prop value)
|
||||
let nodeName = 'button'
|
||||
let bindings: ElementBindings = {type: props.type}
|
||||
|
||||
// if we find a "to" prop we set it as router-link
|
||||
if ('to' in attrs) {
|
||||
nodeName = 'router-link'
|
||||
bindings = {}
|
||||
}
|
||||
|
||||
// if there is a href we assume the user wants an external link via a link element
|
||||
// we also set the attribute rel to "noopener" but make it possible to overwrite this by the user.
|
||||
if ('href' in attrs) {
|
||||
nodeName = 'a'
|
||||
bindings = {rel: 'noopener'}
|
||||
}
|
||||
|
||||
componentNodeName.value = nodeName
|
||||
elementBindings.value = {
|
||||
...bindings,
|
||||
...attrs,
|
||||
}
|
||||
})
|
||||
|
||||
const isButton = computed(() => componentNodeName.value === 'button')
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
// NOTE: we do not use scoped styles to reduce specifity and make it easy to overwrite
|
||||
|
||||
// We reset the default styles of a button element to enable easier styling
|
||||
:where(.base-button--type-button) {
|
||||
border: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-decoration: none;
|
||||
background-color: transparent;
|
||||
text-align: center;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
:where(.base-button) {
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
user-select: none;
|
||||
pointer-events: auto; // disable possible resets
|
||||
|
||||
&:focus {
|
||||
outline: transparent;
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -7,5 +7,11 @@ const Logo = computed(() => new Date().getMonth() === 5 ? LogoFullPride : LogoFu
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<Logo alt="Vikunja" />
|
||||
</template>
|
||||
<Logo alt="Vikunja" class="logo" />
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.logo {
|
||||
color: var(--logo-text-color);
|
||||
}
|
||||
</style>
|
|
@ -43,7 +43,7 @@ $size: $lineWidth + 1rem;
|
|||
width: $lineWidth;
|
||||
left: 50%;
|
||||
transform: $transformX;
|
||||
background-color: $grey-400;
|
||||
background-color: var(--grey-400);
|
||||
border-radius: 2px;
|
||||
transition: all $transition;
|
||||
}
|
||||
|
@ -62,7 +62,7 @@ $size: $lineWidth + 1rem;
|
|||
&:focus {
|
||||
&::before,
|
||||
&::after {
|
||||
background-color: $grey-600;
|
||||
background-color: var(--grey-600);
|
||||
}
|
||||
|
||||
&::before {
|
||||
|
|
|
@ -10,7 +10,7 @@ import {POWERED_BY as poweredByUrl} from '@/urls'
|
|||
|
||||
<style lang="scss">
|
||||
.menu-bottom-link {
|
||||
color: $grey-300;
|
||||
color: var(--grey-300);
|
||||
text-align: center;
|
||||
display: block;
|
||||
padding-top: 1rem;
|
||||
|
|
|
@ -40,96 +40,92 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapState} from 'vuex'
|
||||
<script lang="ts" setup>
|
||||
import {watch, computed} from 'vue'
|
||||
import {useStore} from 'vuex'
|
||||
import {useRoute, useRouter} from 'vue-router'
|
||||
import {useEventListener} from '@vueuse/core'
|
||||
|
||||
import {CURRENT_LIST, KEYBOARD_SHORTCUTS_ACTIVE, MENU_ACTIVE} from '@/store/mutation-types'
|
||||
import Navigation from '@/components/home/navigation.vue'
|
||||
import QuickActions from '@/components/quick-actions/quick-actions.vue'
|
||||
|
||||
export default {
|
||||
name: 'contentAuth',
|
||||
components: {QuickActions, Navigation},
|
||||
watch: {
|
||||
'$route': {
|
||||
handler: 'doStuffAfterRoute',
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.renewTokenOnFocus()
|
||||
this.loadLabels()
|
||||
},
|
||||
computed: mapState({
|
||||
background: 'background',
|
||||
menuActive: MENU_ACTIVE,
|
||||
userInfo: state => state.auth.info,
|
||||
authenticated: state => state.auth.authenticated,
|
||||
}),
|
||||
methods: {
|
||||
doStuffAfterRoute() {
|
||||
// this.setTitle('') // Reset the title if the page component does not set one itself
|
||||
this.hideMenuOnMobile()
|
||||
this.resetCurrentList()
|
||||
},
|
||||
resetCurrentList() {
|
||||
// Reset the current list highlight in menu if the current list is not list related.
|
||||
if (
|
||||
this.$route.name === 'home' ||
|
||||
this.$route.name === 'namespace.edit' ||
|
||||
this.$route.name === 'teams.index' ||
|
||||
this.$route.name === 'teams.edit' ||
|
||||
this.$route.name === 'tasks.range' ||
|
||||
this.$route.name === 'labels.index' ||
|
||||
this.$route.name === 'migrate.start' ||
|
||||
this.$route.name === 'migrate.wunderlist' ||
|
||||
this.$route.name === 'user.settings' ||
|
||||
this.$route.name === 'namespaces.index'
|
||||
) {
|
||||
return this.$store.dispatch(CURRENT_LIST, null)
|
||||
}
|
||||
},
|
||||
renewTokenOnFocus() {
|
||||
// Try renewing the token every time vikunja is loaded initially
|
||||
// (When opening the browser the focus event is not fired)
|
||||
this.$store.dispatch('auth/renewToken')
|
||||
const store = useStore()
|
||||
|
||||
// Check if the token is still valid if the window gets focus again to maybe renew it
|
||||
window.addEventListener('focus', () => {
|
||||
const background = computed(() => store.state.background)
|
||||
const menuActive = computed(() => store.state.menuActive)
|
||||
|
||||
if (!this.authenticated) {
|
||||
return
|
||||
}
|
||||
|
||||
const expiresIn = (this.userInfo !== null ? this.userInfo.exp : 0) - +new Date() / 1000
|
||||
|
||||
// If the token expiry is negative, it is already expired and we have no choice but to redirect
|
||||
// the user to the login page
|
||||
if (expiresIn < 0) {
|
||||
this.$store.dispatch('auth/checkAuth')
|
||||
this.$router.push({name: 'user.login'})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the token is valid for less than 60 hours and renew if thats the case
|
||||
if (expiresIn < 60 * 3600) {
|
||||
this.$store.dispatch('auth/renewToken')
|
||||
console.debug('renewed token')
|
||||
}
|
||||
})
|
||||
},
|
||||
hideMenuOnMobile() {
|
||||
if (window.innerWidth < 769) {
|
||||
this.$store.commit(MENU_ACTIVE, false)
|
||||
}
|
||||
},
|
||||
showKeyboardShortcuts() {
|
||||
this.$store.commit(KEYBOARD_SHORTCUTS_ACTIVE, true)
|
||||
},
|
||||
loadLabels() {
|
||||
this.$store.dispatch('labels/loadAllLabels')
|
||||
},
|
||||
},
|
||||
function showKeyboardShortcuts() {
|
||||
store.commit(KEYBOARD_SHORTCUTS_ACTIVE, true)
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// hide menu on mobile
|
||||
watch(() => route.fullPath, () => window.innerWidth < 769 && store.commit(MENU_ACTIVE, false))
|
||||
|
||||
// FIXME: this is really error prone
|
||||
// Reset the current list highlight in menu if the current route is not list related.
|
||||
watch(() => route.name as string, (routeName) => {
|
||||
if (
|
||||
routeName &&
|
||||
(
|
||||
[
|
||||
'home',
|
||||
'namespace.edit',
|
||||
'teams.index',
|
||||
'teams.edit',
|
||||
'tasks.range',
|
||||
'labels.index',
|
||||
'migrate.start',
|
||||
'migrate.wunderlist',
|
||||
'namespaces.index',
|
||||
].includes(routeName) ||
|
||||
routeName.startsWith('user.settings')
|
||||
)
|
||||
) {
|
||||
store.dispatch(CURRENT_LIST, null)
|
||||
}
|
||||
})
|
||||
|
||||
// TODO: Reset the title if the page component does not set one itself
|
||||
|
||||
function useRenewTokenOnFocus() {
|
||||
const router = useRouter()
|
||||
|
||||
const userInfo = computed(() => store.state.auth.info)
|
||||
const authenticated = computed(() => store.state.auth.authenticated)
|
||||
|
||||
// Try renewing the token every time vikunja is loaded initially
|
||||
// (When opening the browser the focus event is not fired)
|
||||
store.dispatch('auth/renewToken')
|
||||
|
||||
// Check if the token is still valid if the window gets focus again to maybe renew it
|
||||
useEventListener('focus', () => {
|
||||
if (!authenticated.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const expiresIn = (userInfo.value !== null ? userInfo.value.exp : 0) - +new Date() / 1000
|
||||
|
||||
// If the token expiry is negative, it is already expired and we have no choice but to redirect
|
||||
// the user to the login page
|
||||
if (expiresIn < 0) {
|
||||
store.dispatch('auth/checkAuth')
|
||||
router.push({name: 'user.login'})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the token is valid for less than 60 hours and renew if thats the case
|
||||
if (expiresIn < 60 * 3600) {
|
||||
store.dispatch('auth/renewToken')
|
||||
console.debug('renewed token')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
useRenewTokenOnFocus()
|
||||
store.dispatch('labels/loadAllLabels')
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
@ -144,7 +140,7 @@ export default {
|
|||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 2rem;
|
||||
color: $grey-400;
|
||||
color: var(--grey-400);
|
||||
line-height: 1;
|
||||
transition: all $transition;
|
||||
|
||||
|
@ -155,7 +151,7 @@ export default {
|
|||
&:hover,
|
||||
&:focus {
|
||||
height: 1rem;
|
||||
color: $grey-600;
|
||||
color: var(--grey-600);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -191,7 +187,7 @@ export default {
|
|||
}
|
||||
|
||||
.card {
|
||||
background: $white;
|
||||
background: var(--white);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -220,7 +216,7 @@ export default {
|
|||
right: 1rem;
|
||||
z-index: 4500; // The modal has a z-index of 4000
|
||||
|
||||
color: $grey-500;
|
||||
color: var(--grey-500);
|
||||
transition: color $transition;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div
|
||||
:class="[background ? 'has-background' : '', $route.name+'-view']"
|
||||
:class="[background ? 'has-background' : '', $route.name as string +'-view']"
|
||||
:style="{'background-image': `url(${background})`}"
|
||||
class="link-share-container"
|
||||
>
|
||||
|
@ -21,23 +21,16 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapState} from 'vuex'
|
||||
<script lang="ts" setup>
|
||||
import {computed} from 'vue'
|
||||
import {useStore} from 'vuex'
|
||||
|
||||
import Logo from '@/components/home/Logo.vue'
|
||||
import PoweredByLink from './PoweredByLink.vue'
|
||||
|
||||
export default {
|
||||
name: 'contentLinkShare',
|
||||
components: {
|
||||
Logo,
|
||||
PoweredByLink,
|
||||
},
|
||||
computed: mapState([
|
||||
'currentList',
|
||||
'background',
|
||||
]),
|
||||
}
|
||||
const store = useStore()
|
||||
const currentList = computed(() => store.state.currentList)
|
||||
const background = computed(() => store.state.background)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
@ -57,11 +50,11 @@ export default {
|
|||
}
|
||||
|
||||
.title {
|
||||
text-shadow: 0 0 1rem $white;
|
||||
text-shadow: 0 0 1rem var(--white);
|
||||
}
|
||||
|
||||
// FIXME: this should be defined somewhere deep
|
||||
.link-share-view .card {
|
||||
background-color: $white;
|
||||
background-color: var(--white);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,47 +0,0 @@
|
|||
<template>
|
||||
<no-auth-wrapper>
|
||||
<router-view/>
|
||||
</no-auth-wrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {saveLastVisited} from '@/helpers/saveLastVisited'
|
||||
import NoAuthWrapper from '@/components/misc/no-auth-wrapper'
|
||||
|
||||
export default {
|
||||
name: 'contentNoAuth',
|
||||
components: {NoAuthWrapper},
|
||||
computed: {
|
||||
routeName() {
|
||||
return this.$route.name
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
routeName: {
|
||||
handler(routeName) {
|
||||
if (!routeName) return
|
||||
this.redirectToHome()
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
redirectToHome() {
|
||||
// Check if the user is already logged in and redirect them to the home page if not
|
||||
if (
|
||||
this.$route.name !== 'user.login' &&
|
||||
this.$route.name !== 'user.password-reset.request' &&
|
||||
this.$route.name !== 'user.password-reset.reset' &&
|
||||
this.$route.name !== 'user.register' &&
|
||||
this.$route.name !== 'link-share.auth' &&
|
||||
this.$route.name !== 'openid.auth' &&
|
||||
localStorage.getItem('passwordResetToken') === null &&
|
||||
localStorage.getItem('emailConfirmToken') === null
|
||||
) {
|
||||
saveLastVisited(this.$route.name, this.$route.params)
|
||||
this.$router.push({name: 'user.login'})
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
|
@ -280,8 +280,8 @@ export default {
|
|||
|
||||
<style lang="scss" scoped>
|
||||
$navbar-padding: 2rem;
|
||||
$vikunja-nav-background: $light-background;
|
||||
$vikunja-nav-color: $grey-700;
|
||||
$vikunja-nav-background: var(--site-background);
|
||||
$vikunja-nav-color: var(--grey-700);
|
||||
$vikunja-nav-selected-width: 0.4rem;
|
||||
|
||||
.namespace-container {
|
||||
|
@ -349,12 +349,12 @@ $vikunja-nav-selected-width: 0.4rem;
|
|||
opacity: 0;
|
||||
|
||||
&:hover {
|
||||
color: $orange;
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
&.is-favorite {
|
||||
opacity: 1;
|
||||
color: $orange;
|
||||
color: var(--warning);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -436,7 +436,7 @@ $vikunja-nav-selected-width: 0.4rem;
|
|||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
background: $white;
|
||||
background: var(--white);
|
||||
}
|
||||
|
||||
:deep(.dropdown-trigger) {
|
||||
|
@ -456,7 +456,7 @@ $vikunja-nav-selected-width: 0.4rem;
|
|||
}
|
||||
|
||||
.ghost {
|
||||
background: $grey-200;
|
||||
background: var(--grey-200);
|
||||
|
||||
* {
|
||||
opacity: 0;
|
||||
|
@ -496,16 +496,16 @@ $vikunja-nav-selected-width: 0.4rem;
|
|||
}
|
||||
|
||||
&.router-link-exact-active {
|
||||
color: $primary;
|
||||
border-left: $vikunja-nav-selected-width solid $primary;
|
||||
color: var(--primary);
|
||||
border-left: $vikunja-nav-selected-width solid var(--primary);
|
||||
|
||||
.icon {
|
||||
color: $primary;
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-left: $vikunja-nav-selected-width solid $primary;
|
||||
border-left: $vikunja-nav-selected-width solid var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -526,7 +526,7 @@ $vikunja-nav-selected-width: 0.4rem;
|
|||
}
|
||||
|
||||
.icon {
|
||||
color: $grey-400 !important;
|
||||
color: var(--grey-400) !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -555,4 +555,8 @@ $vikunja-nav-selected-width: 0.4rem;
|
|||
width: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.namespaces-list.loader-container.is-loading {
|
||||
min-height: calc(100vh - #{$navbar-height + 1.5rem + 1rem + 1.5rem});
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
class="navbar main-theme is-fixed-top"
|
||||
role="navigation"
|
||||
>
|
||||
<router-link :to="{name: 'home'}" class="navbar-item logo">
|
||||
<router-link :to="{name: 'home'}" class="logo-link">
|
||||
<Logo width="164" height="48"/>
|
||||
</router-link>
|
||||
<MenuButton class="menu-button"/>
|
||||
|
@ -37,7 +37,7 @@
|
|||
<dropdown class="is-right" ref="usernameDropdown">
|
||||
<template #trigger>
|
||||
<x-button
|
||||
type="secondary"
|
||||
variant="secondary"
|
||||
:shadow="false">
|
||||
<span class="username">{{ userInfo.name !== '' ? userInfo.name : userInfo.username }}</span>
|
||||
<span class="icon is-small">
|
||||
|
@ -137,13 +137,18 @@ export default {
|
|||
|
||||
<style lang="scss" scoped>
|
||||
$vikunja-nav-logo-full-width: 164px;
|
||||
$user-dropdown-width-mobile: 5rem;
|
||||
|
||||
$hamburger-menu-icon-spacing: 1rem;
|
||||
$hamburger-menu-icon-width: 28px;
|
||||
|
||||
.navbar {
|
||||
z-index: 4 !important;
|
||||
}
|
||||
|
||||
.logo {
|
||||
.logo-link {
|
||||
display: none;
|
||||
padding: 0.5rem 0.75rem;
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
align-self: stretch;
|
||||
|
@ -164,7 +169,7 @@ $vikunja-nav-logo-full-width: 164px;
|
|||
}
|
||||
|
||||
.navbar.main-theme {
|
||||
background: $light-background;
|
||||
background: var(--site-background);
|
||||
z-index: 5 !important;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
@ -219,7 +224,7 @@ $vikunja-nav-logo-full-width: 164px;
|
|||
:deep() {
|
||||
.trigger-button {
|
||||
cursor: pointer;
|
||||
color: $grey-400;
|
||||
color: var(--grey-400);
|
||||
padding: .5rem;
|
||||
font-size: 1.25rem;
|
||||
position: relative;
|
||||
|
@ -278,7 +283,7 @@ $vikunja-nav-logo-full-width: 164px;
|
|||
}
|
||||
|
||||
:deep(.dropdown-trigger) {
|
||||
color: $grey-400;
|
||||
color: var(--grey-400);
|
||||
margin-left: 1rem;
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
|
|
|
@ -57,7 +57,7 @@ export default {
|
|||
padding: 0 0 0 .5rem;
|
||||
border-radius: $radius;
|
||||
font-size: .9rem;
|
||||
color: $grey-900;
|
||||
color: var(--grey-900);
|
||||
justify-content: space-between;
|
||||
|
||||
@media screen and (max-width: $desktop) {
|
||||
|
|
|
@ -1,79 +1,64 @@
|
|||
<template>
|
||||
<a
|
||||
<BaseButton
|
||||
class="button"
|
||||
:class="{
|
||||
'is-loading': loading,
|
||||
'has-no-shadow': !shadow,
|
||||
'is-primary': type === 'primary',
|
||||
'is-outlined': type === 'secondary',
|
||||
'is-text is-inverted has-no-shadow underline-none':
|
||||
type === 'tertary',
|
||||
}"
|
||||
:disabled="disabled || null"
|
||||
@click="click"
|
||||
:href="href !== '' ? href : null"
|
||||
:class="[
|
||||
variantClass,
|
||||
{
|
||||
'is-loading': loading,
|
||||
'has-no-shadow': !shadow || variant === 'tertiary',
|
||||
}
|
||||
]"
|
||||
>
|
||||
<icon :icon="icon" v-if="showIconOnly"/>
|
||||
<span class="icon is-small" v-else-if="icon !== ''">
|
||||
<icon :icon="icon"/>
|
||||
</span>
|
||||
<slot></slot>
|
||||
</a>
|
||||
<slot />
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
export default {
|
||||
name: 'x-button',
|
||||
props: {
|
||||
type: {
|
||||
type: String,
|
||||
default: 'primary',
|
||||
},
|
||||
href: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
to: {
|
||||
default: false,
|
||||
},
|
||||
icon: {
|
||||
default: '',
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
shadow: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['click'],
|
||||
computed: {
|
||||
showIconOnly() {
|
||||
return this.icon !== '' && typeof this.$slots.default === 'undefined'
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
click(e) {
|
||||
if (this.disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.to !== false) {
|
||||
this.$router.push(this.to)
|
||||
}
|
||||
|
||||
this.$emit('click', e)
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {computed, useSlots, PropType} from 'vue'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
|
||||
const BUTTON_TYPES_MAP = Object.freeze({
|
||||
primary: 'is-primary',
|
||||
secondary: 'is-outlined',
|
||||
tertiary: 'is-text is-inverted underline-none',
|
||||
})
|
||||
|
||||
type ButtonTypes = keyof typeof BUTTON_TYPES_MAP
|
||||
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String as PropType<ButtonTypes>,
|
||||
default: 'primary',
|
||||
},
|
||||
icon: {
|
||||
default: '',
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
shadow: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const variantClass = computed(() => BUTTON_TYPES_MAP[props.variant])
|
||||
|
||||
const slots = useSlots()
|
||||
const showIconOnly = computed(() => props.icon !== '' && typeof slots.default === 'undefined')
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.button {
|
||||
transition: all $transition;
|
||||
|
@ -82,11 +67,11 @@ export default {
|
|||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
height: $button-height;
|
||||
box-shadow: $shadow-sm;
|
||||
box-shadow: var(--shadow-sm);
|
||||
display: inline-flex;
|
||||
|
||||
&.is-hovered,
|
||||
&:hover {
|
||||
box-shadow: $shadow-md;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
&.fullheight {
|
||||
|
@ -99,16 +84,17 @@ export default {
|
|||
&:active,
|
||||
&:focus,
|
||||
&:focus:not(:active) {
|
||||
box-shadow: $shadow-xs !important;
|
||||
box-shadow: var(--shadow-xs) !important;
|
||||
}
|
||||
|
||||
&.is-primary.is-outlined:hover {
|
||||
color: $white;
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
&.is-small {
|
||||
border-radius: $radius;
|
||||
}
|
||||
}
|
||||
|
||||
.is-small {
|
||||
border-radius: $radius;
|
||||
}
|
||||
|
||||
.underline-none {
|
||||
|
|
|
@ -27,7 +27,7 @@
|
|||
@click="reset"
|
||||
class="is-small ml-2"
|
||||
:shadow="false"
|
||||
type="secondary"
|
||||
variant="secondary"
|
||||
>
|
||||
{{ $t('input.resetColor') }}
|
||||
</x-button>
|
||||
|
@ -134,7 +134,7 @@ export default {
|
|||
height: $PICKER_SIZE;
|
||||
overflow: hidden;
|
||||
border-radius: 100%;
|
||||
border: $BORDER_WIDTH solid $grey-300;
|
||||
border: $BORDER_WIDTH solid var(--grey-300);
|
||||
box-shadow: $shadow;
|
||||
|
||||
& > * {
|
||||
|
|
|
@ -101,6 +101,7 @@
|
|||
class="is-fullwidth"
|
||||
:shadow="false"
|
||||
@click="close"
|
||||
v-cy="'closeDatepicker'"
|
||||
>
|
||||
{{ $t('misc.confirm') }}
|
||||
</x-button>
|
||||
|
@ -258,7 +259,7 @@ export default {
|
|||
position: absolute;
|
||||
z-index: 99;
|
||||
width: 320px;
|
||||
background: $white;
|
||||
background: var(--white);
|
||||
border-radius: $radius;
|
||||
box-shadow: $shadow;
|
||||
|
||||
|
@ -272,7 +273,7 @@ export default {
|
|||
padding: 0 .5rem;
|
||||
width: 100%;
|
||||
height: 2.25rem;
|
||||
color: $text;
|
||||
color: var(--text);
|
||||
transition: all $transition;
|
||||
|
||||
&:first-child {
|
||||
|
@ -280,7 +281,7 @@ export default {
|
|||
}
|
||||
|
||||
&:hover {
|
||||
background: $light;
|
||||
background: var(--light);
|
||||
}
|
||||
|
||||
.text {
|
||||
|
@ -291,7 +292,7 @@ export default {
|
|||
padding-right: .25rem;
|
||||
|
||||
.weekday {
|
||||
color: $text-light;
|
||||
color: var(--text-light);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,7 +35,7 @@
|
|||
<a @click="toggleEdit">{{ $t('input.editor.edit') }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
<x-button v-else-if="isEditActive" @click="toggleEdit" type="secondary" :shadow="false">
|
||||
<x-button v-else-if="isEditActive" @click="toggleEdit" variant="secondary" :shadow="false" v-cy="'saveEditor'">
|
||||
{{ $t('misc.save') }}
|
||||
</x-button>
|
||||
</template>
|
||||
|
@ -338,15 +338,20 @@ $editor-border-color: #ddd;
|
|||
.CodeMirror {
|
||||
padding: .5rem;
|
||||
border: 1px solid $editor-border-color;
|
||||
background: var(--white);
|
||||
|
||||
&-lines pre {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
&-placeholder {
|
||||
color: $grey-400 !important;
|
||||
color: var(--grey-400) !important;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
&-cursor {
|
||||
border-color: var(--grey-700);
|
||||
}
|
||||
}
|
||||
|
||||
.editor-preview {
|
||||
|
@ -383,7 +388,7 @@ $editor-border-color: #ddd;
|
|||
|
||||
pre.CodeMirror-line {
|
||||
margin-bottom: 0 !important;
|
||||
color: $grey-700 !important;
|
||||
color: var(--grey-700) !important;
|
||||
}
|
||||
|
||||
.cm-header {
|
||||
|
@ -409,10 +414,10 @@ ul.actions {
|
|||
}
|
||||
|
||||
&, a {
|
||||
color: $grey-500;
|
||||
color: var(--grey-500);
|
||||
|
||||
&.done-edit {
|
||||
color: $primary;
|
||||
color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -106,7 +106,7 @@ svg {
|
|||
}
|
||||
|
||||
.check:hover svg {
|
||||
stroke: $primary;
|
||||
stroke: var(--primary);
|
||||
}
|
||||
|
||||
.is-disabled .check:hover svg {
|
||||
|
@ -125,7 +125,7 @@ polyline {
|
|||
|
||||
input[type=checkbox]:checked + .check {
|
||||
svg {
|
||||
stroke: $primary;
|
||||
stroke: var(--primary);
|
||||
}
|
||||
|
||||
path {
|
||||
|
|
|
@ -380,23 +380,23 @@ export default {
|
|||
|
||||
&.has-search-results .input-wrapper {
|
||||
border-radius: $radius $radius 0 0;
|
||||
border-color: $primary !important;
|
||||
background: $white !important;
|
||||
border-color: var(--primary) !important;
|
||||
background: var(--white) !important;
|
||||
|
||||
&, &:focus-within {
|
||||
border-bottom-color: $grey-200 !important;
|
||||
border-bottom-color: var(--grey-200) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
padding: 0;
|
||||
background: $white !important;
|
||||
border-color: $grey-200 !important;
|
||||
background: var(--white) !important;
|
||||
border-color: var(--grey-200) !important;
|
||||
flex-wrap: wrap;
|
||||
height: auto;
|
||||
|
||||
&:hover {
|
||||
border-color: $grey-300 !important;
|
||||
border-color: var(--grey-300) !important;
|
||||
}
|
||||
|
||||
.input {
|
||||
|
@ -422,8 +422,8 @@ export default {
|
|||
}
|
||||
|
||||
&:focus-within {
|
||||
border-color: $primary !important;
|
||||
background: $white !important;
|
||||
border-color: var(--primary) !important;
|
||||
background: var(--white) !important;
|
||||
}
|
||||
|
||||
.loader {
|
||||
|
@ -432,9 +432,9 @@ export default {
|
|||
}
|
||||
|
||||
.search-results {
|
||||
background: $white;
|
||||
background: var(--white);
|
||||
border-radius: 0 0 $radius $radius;
|
||||
border: 1px solid $primary;
|
||||
border: 1px solid var(--primary);
|
||||
border-top: none;
|
||||
|
||||
max-height: 50vh;
|
||||
|
@ -457,7 +457,7 @@ export default {
|
|||
text-transform: none;
|
||||
font-family: $family-sans-serif;
|
||||
font-weight: normal;
|
||||
padding: .5rem 0;
|
||||
padding: .5rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
|
@ -477,20 +477,20 @@ export default {
|
|||
font-size: .75rem;
|
||||
color: transparent;
|
||||
transition: color $transition;
|
||||
padding: 0 .5rem;
|
||||
padding-left: .5rem;
|
||||
}
|
||||
|
||||
&:focus, &:hover {
|
||||
background: $grey-100;
|
||||
background: var(--grey-100);
|
||||
box-shadow: none !important;
|
||||
|
||||
.hint-text {
|
||||
color: $text;
|
||||
color: var(--text);
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: $grey-200;
|
||||
background: var(--grey-200);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<x-button
|
||||
v-if="hasFilters"
|
||||
type="secondary"
|
||||
variant="secondary"
|
||||
@click="clearFilters"
|
||||
>
|
||||
{{ $t('filters.clear') }}
|
||||
|
@ -10,7 +10,7 @@
|
|||
<template #trigger="{toggle}">
|
||||
<x-button
|
||||
@click.prevent.stop="toggle()"
|
||||
type="secondary"
|
||||
variant="secondary"
|
||||
icon="filter"
|
||||
>
|
||||
{{ $t('filters.title') }}
|
||||
|
|
|
@ -1,23 +1,24 @@
|
|||
<template>
|
||||
<card class="filters has-overflow">
|
||||
<fancycheckbox v-model="params.filter_include_nulls">
|
||||
{{ $t('filters.attributes.includeNulls') }}
|
||||
</fancycheckbox>
|
||||
<fancycheckbox
|
||||
v-model="filters.requireAllFilters"
|
||||
@change="setFilterConcat()"
|
||||
>
|
||||
{{ $t('filters.attributes.requireAll') }}
|
||||
</fancycheckbox>
|
||||
<div class="field">
|
||||
<label class="label">
|
||||
<fancycheckbox v-model="params.filter_include_nulls">
|
||||
{{ $t('filters.attributes.includeNulls') }}
|
||||
</fancycheckbox>
|
||||
<fancycheckbox
|
||||
v-model="filters.requireAllFilters"
|
||||
@change="setFilterConcat()"
|
||||
>
|
||||
{{ $t('filters.attributes.requireAll') }}
|
||||
</fancycheckbox>
|
||||
<fancycheckbox @change="setDoneFilter" v-model="filters.done">
|
||||
{{ $t('filters.attributes.showDoneTasks') }}
|
||||
</label>
|
||||
<div class="control">
|
||||
<fancycheckbox @change="setDoneFilter" v-model="filters.done">
|
||||
{{ $t('filters.attributes.showDoneTasks') }}
|
||||
</fancycheckbox>
|
||||
</div>
|
||||
</fancycheckbox>
|
||||
<fancycheckbox
|
||||
v-if="!$route.name.includes('list.kanban') || !$route.name.includes('list.table')"
|
||||
v-model="sortAlphabetically"
|
||||
>
|
||||
{{ $t('filters.attributes.sortAlphabetically') }}
|
||||
</fancycheckbox>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label">{{ $t('misc.search') }}</label>
|
||||
|
@ -190,6 +191,7 @@ import NamespaceService from '@/services/namespace'
|
|||
import EditLabels from '@/components/tasks/partials/editLabels.vue'
|
||||
|
||||
import {objectToSnakeCase} from '@/helpers/case'
|
||||
import {getDefaultParams} from '@/components/tasks/mixins/taskList'
|
||||
|
||||
// FIXME: merge with DEFAULT_PARAMS in taskList.js
|
||||
const DEFAULT_PARAMS = {
|
||||
|
@ -220,6 +222,8 @@ const DEFAULT_FILTERS = {
|
|||
namespace: '',
|
||||
}
|
||||
|
||||
export const ALPHABETICAL_SORT = 'title'
|
||||
|
||||
export default {
|
||||
name: 'filters',
|
||||
components: {
|
||||
|
@ -272,6 +276,18 @@ export default {
|
|||
},
|
||||
},
|
||||
computed: {
|
||||
sortAlphabetically: {
|
||||
get() {
|
||||
return this.params?.sort_by?.find(sortBy => sortBy === ALPHABETICAL_SORT) !== undefined
|
||||
},
|
||||
set(sortAlphabetically) {
|
||||
this.params.sort_by = sortAlphabetically
|
||||
? [ALPHABETICAL_SORT]
|
||||
: getDefaultParams().sort_by
|
||||
|
||||
this.change()
|
||||
},
|
||||
},
|
||||
foundLabels() {
|
||||
return this.$store.getters['labels/filterLabelsByQuery'](this.labels, this.query)
|
||||
},
|
||||
|
|
|
@ -20,64 +20,61 @@
|
|||
:class="{'is-favorite': list.isFavorite, 'is-archived': list.isArchived}"
|
||||
@click.stop="toggleFavoriteList(list)"
|
||||
class="favorite">
|
||||
<icon icon="star" v-if="list.isFavorite"/>
|
||||
<icon :icon="['far', 'star']" v-else/>
|
||||
<icon :icon="list.isFavorite ? 'star' : ['far', 'star']" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="title">{{ list.title }}</div>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts" setup>
|
||||
import {ref, watch} from 'vue'
|
||||
import {useStore} from 'vuex'
|
||||
|
||||
import ListService from '@/services/list'
|
||||
|
||||
export default {
|
||||
name: 'list-card',
|
||||
data() {
|
||||
return {
|
||||
background: null,
|
||||
backgroundLoading: false,
|
||||
}
|
||||
},
|
||||
props: {
|
||||
list: {
|
||||
required: true,
|
||||
},
|
||||
showArchived: {
|
||||
default: false,
|
||||
type: Boolean,
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
list: {
|
||||
handler: 'loadBackground',
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async loadBackground() {
|
||||
if (this.list === null || !this.list.backgroundInformation || this.backgroundLoading) {
|
||||
return
|
||||
}
|
||||
import {colorIsDark} from '@/helpers/color/colorIsDark'
|
||||
|
||||
this.backgroundLoading = true
|
||||
const background = ref<string | null>(null)
|
||||
const backgroundLoading = ref(false)
|
||||
|
||||
const listService = new ListService()
|
||||
try {
|
||||
this.background = await listService.background(this.list)
|
||||
} finally {
|
||||
this.backgroundLoading = false
|
||||
}
|
||||
},
|
||||
toggleFavoriteList(list) {
|
||||
// The favorites pseudo list is always favorite
|
||||
// Archived lists cannot be marked favorite
|
||||
if (list.id === -1 || list.isArchived) {
|
||||
return
|
||||
}
|
||||
this.$store.dispatch('lists/toggleListFavorite', list)
|
||||
},
|
||||
const props = defineProps({
|
||||
list: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
showArchived: {
|
||||
default: false,
|
||||
type: Boolean,
|
||||
},
|
||||
})
|
||||
|
||||
watch(props.list, loadBackground, { immediate: true })
|
||||
|
||||
async function loadBackground() {
|
||||
if (props.list === null || !props.list.backgroundInformation || backgroundLoading.value) {
|
||||
return
|
||||
}
|
||||
|
||||
backgroundLoading.value = true
|
||||
|
||||
const listService = new ListService()
|
||||
try {
|
||||
background.value = await listService.background(props.list)
|
||||
} finally {
|
||||
backgroundLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const store = useStore()
|
||||
|
||||
function toggleFavoriteList(list) {
|
||||
// The favorites pseudo list is always favorite
|
||||
// Archived lists cannot be marked favorite
|
||||
if (list.id === -1 || list.isArchived) {
|
||||
return
|
||||
}
|
||||
store.dispatch('lists/toggleListFavorite', list)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -86,11 +83,11 @@ export default {
|
|||
cursor: pointer;
|
||||
width: calc((100% - #{($lists-per-row - 1) * 1rem}) / #{$lists-per-row});
|
||||
height: $list-height;
|
||||
background: $white;
|
||||
background: var(--white);
|
||||
margin: 0 $list-spacing $list-spacing 0;
|
||||
padding: 1rem;
|
||||
border-radius: $radius;
|
||||
box-shadow: $shadow-sm;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow $transition;
|
||||
|
||||
display: flex;
|
||||
|
@ -98,13 +95,13 @@ export default {
|
|||
flex-wrap: wrap;
|
||||
|
||||
&:hover {
|
||||
box-shadow: $shadow-md;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:focus:not(:active) {
|
||||
box-shadow: $shadow-xs !important;
|
||||
box-shadow: var(--shadow-xs) !important;
|
||||
}
|
||||
|
||||
@media screen and (min-width: $widescreen) {
|
||||
|
@ -158,7 +155,7 @@ export default {
|
|||
font-family: $vikunja-font;
|
||||
font-weight: 400;
|
||||
font-size: 1.5rem;
|
||||
color: $text;
|
||||
color: var(--text);
|
||||
width: 100%;
|
||||
margin-bottom: 0;
|
||||
max-height: calc(100% - 2rem); // 1rem padding, 1rem height of the "is archived" badge
|
||||
|
@ -171,7 +168,7 @@ export default {
|
|||
}
|
||||
|
||||
&.has-light-text .title {
|
||||
color: $light;
|
||||
color: var(--light);
|
||||
}
|
||||
|
||||
&.has-background {
|
||||
|
@ -180,8 +177,8 @@ export default {
|
|||
background-position: center;
|
||||
|
||||
.title {
|
||||
text-shadow: 0 0 10px $black, 1px 1px 5px $grey-700, -1px -1px 5px $grey-700;
|
||||
color: $white;
|
||||
text-shadow: 0 0 10px var(--black), 1px 1px 5px var(--grey-700), -1px -1px 5px var(--grey-700);
|
||||
color: var(--white);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -190,7 +187,7 @@ export default {
|
|||
opacity: 0;
|
||||
|
||||
&:hover {
|
||||
color: $orange;
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
&.is-archived {
|
||||
|
@ -200,7 +197,7 @@ export default {
|
|||
&.is-favorite {
|
||||
display: inline-block;
|
||||
opacity: 1;
|
||||
color: $orange;
|
||||
color: var(--warning);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,47 +1,34 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="isDone"
|
||||
class="is-done"
|
||||
:class="{ 'is-done--small': variant === variants.SMALL }"
|
||||
>
|
||||
{{ $t('task.attributes.done') }}
|
||||
</div>
|
||||
v-if="isDone"
|
||||
class="is-done"
|
||||
:class="{ 'is-done--small': variant === 'small' }"
|
||||
>
|
||||
{{ $t('task.attributes.done') }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const VARIANTS = {
|
||||
DEFAULT: 'default',
|
||||
SMALL: 'small',
|
||||
}
|
||||
<script lang="ts" setup>
|
||||
import {PropType} from 'vue'
|
||||
type Variants = 'default' | 'small'
|
||||
|
||||
export default {
|
||||
name: 'Done',
|
||||
|
||||
data() {
|
||||
return {
|
||||
variants: VARIANTS,
|
||||
}
|
||||
defineProps({
|
||||
isDone: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
props: {
|
||||
isDone: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: VARIANTS.DEFAULT,
|
||||
validator: (variant) => Object.values(VARIANTS).includes(variant),
|
||||
},
|
||||
variant: {
|
||||
type: String as PropType<Variants>,
|
||||
default: 'default',
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.is-done {
|
||||
background: $green;
|
||||
color: $white;
|
||||
background: var(--success);
|
||||
color: var(--white);
|
||||
padding: .5rem;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
|
|
|
@ -23,34 +23,32 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="api-url-info" v-else>
|
||||
<i18n-t keypath="apiConfig.signInOn">
|
||||
<i18n-t keypath="apiConfig.use">
|
||||
<span class="url" v-tooltip="apiUrl"> {{ apiDomain }} </span>
|
||||
</i18n-t>
|
||||
<br/>
|
||||
<a @click="() => (configureApi = true)">{{ $t('apiConfig.change') }}</a>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="notification is-success mt-2"
|
||||
v-if="successMsg !== '' && errorMsg === ''"
|
||||
>
|
||||
<message variant="success" v-if="successMsg !== '' && errorMsg === ''" class="mt-2">
|
||||
{{ successMsg }}
|
||||
</div>
|
||||
<div
|
||||
class="notification is-danger mt-2"
|
||||
v-if="errorMsg !== '' && successMsg === ''"
|
||||
>
|
||||
</message>
|
||||
<message variant="danger" v-if="errorMsg !== '' && successMsg === ''" class="mt-2">
|
||||
{{ errorMsg }}
|
||||
</div>
|
||||
</message>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Message from '@/components/misc/message'
|
||||
import {parseURL} from 'ufo'
|
||||
import {checkAndSetApiUrl} from '@/helpers/checkAndSetApiUrl'
|
||||
|
||||
export default {
|
||||
name: 'apiConfig',
|
||||
components: {
|
||||
Message,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
configureApi: false,
|
||||
|
@ -103,7 +101,7 @@ export default {
|
|||
|
||||
// Set it + save it to local storage to save us the hoops
|
||||
this.errorMsg = ''
|
||||
this.successMsg = this.$t('apiConfig.success', {domain: this.apiDomain})
|
||||
this.$message.success({message: this.$t('apiConfig.success', {domain: this.apiDomain})})
|
||||
this.configureApi = false
|
||||
this.apiUrl = url
|
||||
this.$emit('foundApi', this.apiUrl)
|
||||
|
@ -128,6 +126,6 @@ export default {
|
|||
}
|
||||
|
||||
.url {
|
||||
border-bottom: 1px dashed $primary;
|
||||
border-bottom: 1px dashed var(--primary);
|
||||
}
|
||||
</style>
|
|
@ -24,61 +24,59 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'card',
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
padding: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
hasClose: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
closeIcon: {
|
||||
type: String,
|
||||
default: 'times',
|
||||
},
|
||||
shadow: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
hasContent: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
emits: ['close'],
|
||||
}
|
||||
padding: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
hasClose: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
closeIcon: {
|
||||
type: String,
|
||||
default: 'times',
|
||||
},
|
||||
shadow: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
hasContent: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
defineEmits(['close'])
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.card {
|
||||
background-color: $white;
|
||||
background-color: var(--white);
|
||||
border-radius: $radius;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid $grey-200;
|
||||
box-shadow: $shadow-sm;
|
||||
border: 1px solid var(--card-border-color);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
box-shadow: none;
|
||||
border-bottom: 1px solid $grey-200;
|
||||
border-bottom: 1px solid var(--card-border-color);
|
||||
border-radius: $radius $radius 0 0;
|
||||
}
|
||||
|
||||
// FIXME: should maybe be merged somehow with modal
|
||||
:deep(.modal-card-foot) {
|
||||
background-color: $grey-50;
|
||||
background-color: var(--grey-50);
|
||||
border-top: 0;
|
||||
}
|
||||
</style>
|
|
@ -14,25 +14,25 @@
|
|||
</div>
|
||||
<footer class="modal-card-foot is-flex is-justify-content-flex-end">
|
||||
<x-button
|
||||
v-if="tertiary !== ''"
|
||||
:shadow="false"
|
||||
type="tertary"
|
||||
@click.prevent.stop="$emit('tertary')"
|
||||
v-if="tertary !== ''"
|
||||
variant="tertiary"
|
||||
@click.prevent.stop="$emit('tertiary')"
|
||||
>
|
||||
{{ tertary }}
|
||||
{{ tertiary }}
|
||||
</x-button>
|
||||
<x-button
|
||||
type="secondary"
|
||||
variant="secondary"
|
||||
@click.prevent.stop="$router.back()"
|
||||
>
|
||||
{{ $t('misc.cancel') }}
|
||||
</x-button>
|
||||
<x-button
|
||||
type="primary"
|
||||
v-if="primaryLabel !== ''"
|
||||
variant="primary"
|
||||
@click.prevent.stop="primary"
|
||||
:icon="primaryIcon"
|
||||
:disabled="primaryDisabled"
|
||||
v-if="primaryLabel !== ''"
|
||||
>
|
||||
{{ primaryLabel }}
|
||||
</x-button>
|
||||
|
@ -65,7 +65,7 @@ export default {
|
|||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
tertary: {
|
||||
tertiary: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
|
@ -78,7 +78,7 @@ export default {
|
|||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ['create', 'primary', 'tertary'],
|
||||
emits: ['create', 'primary', 'tertiary'],
|
||||
methods: {
|
||||
primary() {
|
||||
this.$emit('create')
|
||||
|
|
|
@ -11,18 +11,15 @@
|
|||
</router-link>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'dropdown-item',
|
||||
props: {
|
||||
to: {
|
||||
required: true,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
<script lang="ts" setup>
|
||||
defineProps({
|
||||
to: {
|
||||
required: true,
|
||||
},
|
||||
}
|
||||
icon: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div class="dropdown is-right is-active" ref="dropdown">
|
||||
<div class="dropdown-trigger" @click="open = !open">
|
||||
<div class="dropdown-trigger is-flex" @click="open = !open">
|
||||
<slot name="trigger" :close="close">
|
||||
<icon :icon="triggerIcon" class="icon"/>
|
||||
</slot>
|
||||
|
|
|
@ -1,19 +1,16 @@
|
|||
<template>
|
||||
<div class="notification is-danger">
|
||||
<message variant="danger">
|
||||
<i18n-t keypath="loadingError.failed">
|
||||
<a @click="reload">{{ $t('loadingError.tryAgain') }}</a>
|
||||
<a href="https://vikunja.io/contact/" rel="noreferrer noopener nofollow" target="_blank">{{ $t('loadingError.contact') }}</a>
|
||||
</i18n-t>
|
||||
</div>
|
||||
</message>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'error',
|
||||
methods: {
|
||||
reload() {
|
||||
window.location.reload()
|
||||
},
|
||||
},
|
||||
<script lang="ts" setup>
|
||||
import Message from '@/components/misc/message.vue'
|
||||
|
||||
function reload() {
|
||||
window.location.reload()
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -4,13 +4,11 @@
|
|||
<template v-for="(s, i) in shortcuts" :key="i">
|
||||
<h3>{{ $t(s.title) }}</h3>
|
||||
|
||||
<div class="message is-primary">
|
||||
<div class="message-body">
|
||||
{{
|
||||
s.available($route) ? $t('keyboardShortcuts.currentPageOnly') : $t('keyboardShortcuts.allPages')
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<message>
|
||||
{{
|
||||
s.available($route) ? $t('keyboardShortcuts.currentPageOnly') : $t('keyboardShortcuts.allPages')
|
||||
}}
|
||||
</message>
|
||||
|
||||
<dl class="shortcut-list">
|
||||
<template v-for="(sc, si) in s.shortcuts" :key="si">
|
||||
|
@ -30,11 +28,15 @@
|
|||
<script>
|
||||
import {KEYBOARD_SHORTCUTS_ACTIVE} from '@/store/mutation-types'
|
||||
import Shortcut from '@/components/misc/shortcut.vue'
|
||||
import Message from '@/components/misc/message'
|
||||
import {KEYBOARD_SHORTCUTS} from './shortcuts'
|
||||
|
||||
export default {
|
||||
name: 'keyboard-shortcuts',
|
||||
components: {Shortcut},
|
||||
components: {
|
||||
Message,
|
||||
Shortcut,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
shortcuts: KEYBOARD_SHORTCUTS,
|
||||
|
|
|
@ -6,23 +6,21 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {mapState} from 'vuex'
|
||||
<script lang="ts" setup>
|
||||
import {computed} from 'vue'
|
||||
import {useStore} from 'vuex'
|
||||
|
||||
export default {
|
||||
name: 'legal',
|
||||
computed: mapState({
|
||||
imprintUrl: state => state.config.legal.imprintUrl,
|
||||
privacyPolicyUrl: state => state.config.legal.privacyPolicyUrl,
|
||||
}),
|
||||
}
|
||||
const store = useStore()
|
||||
|
||||
const imprintUrl = computed(() => store.state.config.legal.imprintUrl)
|
||||
const privacyPolicyUrl = computed(() => store.state.config.legal.privacyPolicyUrl)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.legal-links {
|
||||
margin-top: 1rem;
|
||||
text-align: right;
|
||||
color: $grey-300;
|
||||
color: var(--grey-300);
|
||||
font-size: 1rem;
|
||||
}
|
||||
</style>
|
|
@ -2,12 +2,6 @@
|
|||
<div class="loader-container is-loading"></div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'loading',
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loader-container {
|
||||
height: 100%;
|
||||
|
|
48
src/components/misc/message.vue
Normal file
|
@ -0,0 +1,48 @@
|
|||
<template>
|
||||
<div class="message-wrapper">
|
||||
<div class="message" :class="variant">
|
||||
<slot/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'info',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.message-wrapper {
|
||||
border-radius: $radius;
|
||||
background: var(--white);
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: .75rem 1rem;
|
||||
border-radius: $radius;
|
||||
}
|
||||
|
||||
.info {
|
||||
border: 1px solid var(--primary);
|
||||
background: hsla(var(--primary-hsl), .05);
|
||||
}
|
||||
|
||||
.danger {
|
||||
border: 1px solid var(--danger);
|
||||
background: hsla(var(--danger-h), var(--danger-s), var(--danger-l), .05);
|
||||
}
|
||||
|
||||
.warning {
|
||||
border: 1px solid var(--warning);
|
||||
background: hsla(var(--warning-h), var(--warning-s), var(--warning-l), .05);
|
||||
}
|
||||
|
||||
.success {
|
||||
border: 1px solid var(--success);
|
||||
background: hsla(var(--success-h), var(--success-s), var(--success-l), .05);
|
||||
}
|
||||
</style>
|
|
@ -1,39 +1,134 @@
|
|||
<template>
|
||||
<div class="no-auth-wrapper">
|
||||
<Logo class="logo" width="200" height="58"/>
|
||||
<div class="noauth-container">
|
||||
<Logo width="400" height="117" />
|
||||
<div class="message is-info" v-if="motd !== ''">
|
||||
<div class="message-header">
|
||||
<p>{{ $t('misc.info') }}</p>
|
||||
</div>
|
||||
<div class="message-body">
|
||||
<section class="image" :class="{'has-message': motd !== ''}">
|
||||
<Message v-if="motd !== ''">
|
||||
{{ motd }}
|
||||
</Message>
|
||||
<h2 class="image-title">
|
||||
{{ $t('misc.welcomeBack') }}
|
||||
</h2>
|
||||
</section>
|
||||
<section class="content">
|
||||
<div>
|
||||
<h2 class="title" v-if="title">{{ title }}</h2>
|
||||
<api-config/>
|
||||
<slot/>
|
||||
</div>
|
||||
</div>
|
||||
<slot/>
|
||||
<legal/>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script lang="ts" setup>
|
||||
import Logo from '@/components/home/Logo.vue'
|
||||
import Message from '@/components/misc/message.vue'
|
||||
import Legal from '@/components/misc/legal.vue'
|
||||
import ApiConfig from '@/components/misc/api-config.vue'
|
||||
import {useStore} from 'vuex'
|
||||
import {computed} from 'vue'
|
||||
import {useRoute} from 'vue-router'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useStore()
|
||||
const {t} = useI18n()
|
||||
|
||||
const motd = computed(() => store.state.config.motd)
|
||||
// @ts-ignore
|
||||
const title = computed(() => t(route.meta.title ?? ''))
|
||||
useTitle(() => title.value)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.no-auth-wrapper {
|
||||
background: url('@/assets/llama.svg') no-repeat bottom left fixed $light-background;
|
||||
background: var(--site-background) url('@/assets/llama.svg?url') no-repeat fixed bottom left;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
place-items: center;
|
||||
|
||||
@media screen and (max-width: $fullhd) {
|
||||
padding-bottom: 15rem;
|
||||
}
|
||||
}
|
||||
|
||||
.noauth-container {
|
||||
max-width: 450px;
|
||||
max-width: $desktop;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
min-height: 60vh;
|
||||
display: flex;
|
||||
background-color: var(--white);
|
||||
box-shadow: var(--shadow-md);
|
||||
overflow: hidden;
|
||||
|
||||
@media screen and (min-width: $desktop) {
|
||||
border-radius: $radius;
|
||||
}
|
||||
}
|
||||
|
||||
.image {
|
||||
width: 50%;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media screen and (min-width: $tablet) {
|
||||
background: url('@/assets/no-auth-image.jpg') no-repeat bottom/cover;
|
||||
position: relative;
|
||||
|
||||
&.has-message {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, .2);
|
||||
}
|
||||
|
||||
> * {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
padding: 2rem 2rem 1.5rem;
|
||||
|
||||
@media screen and (max-width: $desktop) {
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
@media screen and (min-width: $desktop) {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
max-width: 100%;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.image-title {
|
||||
color: var(--white);
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -26,7 +26,7 @@
|
|||
@click="action.callback"
|
||||
:shadow="false"
|
||||
class="is-small"
|
||||
type="secondary"
|
||||
variant="secondary"
|
||||
v-for="(action, i) in item.data.actions"
|
||||
>
|
||||
{{ action.title }}
|
||||
|
|
|
@ -21,11 +21,11 @@
|
|||
<li :key="`page-${i}`" v-for="(p, i) in pages">
|
||||
<span class="pagination-ellipsis" v-if="p.isEllipsis">…</span>
|
||||
<router-link
|
||||
v-else
|
||||
class="pagination-link"
|
||||
:aria-label="'Goto page ' + p.number"
|
||||
:class="{ 'is-current': p.number === currentPage }"
|
||||
:to="getRouteForPagination(p.number)"
|
||||
class="pagination-link"
|
||||
v-else
|
||||
>
|
||||
{{ p.number }}
|
||||
</router-link>
|
||||
|
@ -34,8 +34,10 @@
|
|||
</nav>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
function createPagination(totalPages, currentPage) {
|
||||
<script lang="ts" setup>
|
||||
import {computed} from 'vue'
|
||||
|
||||
function createPagination(totalPages: number, currentPage: number) {
|
||||
const pages = []
|
||||
for (let i = 0; i < totalPages; i++) {
|
||||
|
||||
|
@ -79,42 +81,30 @@ function getRouteForPagination(page = 1, type = 'list') {
|
|||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'Pagination',
|
||||
|
||||
props: {
|
||||
totalPages: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
currentPage: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
const props = defineProps({
|
||||
totalPages: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
|
||||
computed: {
|
||||
pages() {
|
||||
return createPagination(this.totalPages, this.currentPage)
|
||||
},
|
||||
currentPage: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
})
|
||||
|
||||
methods: {
|
||||
getRouteForPagination,
|
||||
},
|
||||
}
|
||||
const pages = computed(() => createPagination(props.totalPages, props.currentPage))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.pagination {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.pagination-previous,
|
||||
.pagination-next {
|
||||
&:not(:disabled):hover {
|
||||
background: $scheme-main;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pagination-previous,
|
||||
.pagination-next {
|
||||
&:not(:disabled):hover {
|
||||
background: $scheme-main;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -13,10 +13,10 @@
|
|||
<section v-else-if="error !== ''">
|
||||
<no-auth-wrapper>
|
||||
<card>
|
||||
<p v-if="error === errorNoApiUrl">
|
||||
<p v-if="error === ERROR_NO_API_URL">
|
||||
{{ $t('ready.noApiUrlConfigured') }}
|
||||
</p>
|
||||
<div class="notification is-danger" v-else>
|
||||
<message variant="danger" v-else>
|
||||
<p>
|
||||
{{ $t('ready.errorOccured') }}<br/>
|
||||
{{ error }}
|
||||
|
@ -24,7 +24,7 @@
|
|||
<p>
|
||||
{{ $t('ready.checkApiUrl') }}
|
||||
</p>
|
||||
</div>
|
||||
</message>
|
||||
<api-config :configure-open="true" @found-api="load"/>
|
||||
</card>
|
||||
</no-auth-wrapper>
|
||||
|
@ -40,49 +40,35 @@
|
|||
</transition>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Logo from '@/assets/logo.svg?component'
|
||||
import ApiConfig from '@/components/misc/api-config'
|
||||
import NoAuthWrapper from '@/components/misc/no-auth-wrapper'
|
||||
import {mapState} from 'vuex'
|
||||
import {ERROR_NO_API_URL} from '@/helpers/checkAndSetApiUrl'
|
||||
<script lang="ts" setup>
|
||||
import {ref, computed} from 'vue'
|
||||
import {useStore} from 'vuex'
|
||||
|
||||
export default {
|
||||
name: 'ready',
|
||||
components: {
|
||||
Logo,
|
||||
NoAuthWrapper,
|
||||
ApiConfig,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
error: '',
|
||||
errorNoApiUrl: ERROR_NO_API_URL,
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.load()
|
||||
},
|
||||
computed: {
|
||||
ready() {
|
||||
return this.$store.state.vikunjaReady
|
||||
},
|
||||
showLoading() {
|
||||
return !this.ready && this.error === ''
|
||||
},
|
||||
...mapState([
|
||||
'online',
|
||||
]),
|
||||
},
|
||||
methods: {
|
||||
load() {
|
||||
this.$store.dispatch('loadApp')
|
||||
.catch(e => {
|
||||
this.error = e
|
||||
})
|
||||
},
|
||||
},
|
||||
import Logo from '@/assets/logo.svg?component'
|
||||
import ApiConfig from '@/components/misc/api-config.vue'
|
||||
import Message from '@/components/misc/message.vue'
|
||||
import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue'
|
||||
|
||||
import {ERROR_NO_API_URL} from '@/helpers/checkAndSetApiUrl'
|
||||
import {useOnline} from '@/composables/useOnline'
|
||||
|
||||
const store = useStore()
|
||||
|
||||
const ready = computed(() => store.state.vikunjaReady)
|
||||
const online = useOnline()
|
||||
|
||||
const error = ref('')
|
||||
const showLoading = computed(() => !ready.value && error.value === '')
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
await store.dispatch('loadApp')
|
||||
} catch(e: any) {
|
||||
error.value = e
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
@ -98,7 +84,7 @@ export default {
|
|||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background: $grey-100;
|
||||
background: var(--grey-100);
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
|
@ -112,8 +98,8 @@ export default {
|
|||
margin-right: 1rem;
|
||||
|
||||
&.is-loading::after {
|
||||
border-left-color: $grey-400;
|
||||
border-bottom-color: $grey-400;
|
||||
border-left-color: var(--grey-400);
|
||||
border-bottom-color: var(--grey-400);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -7,24 +7,21 @@
|
|||
</component>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'shortcut',
|
||||
props: {
|
||||
keys: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
combination: {
|
||||
type: String,
|
||||
default: '+',
|
||||
},
|
||||
is: {
|
||||
type: String,
|
||||
default: 'div',
|
||||
},
|
||||
<script lang="ts" setup>
|
||||
defineProps({
|
||||
keys: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
}
|
||||
combination: {
|
||||
type: String,
|
||||
default: '+',
|
||||
},
|
||||
is: {
|
||||
type: String,
|
||||
default: 'div',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
@ -35,8 +32,8 @@ export default {
|
|||
|
||||
kbd {
|
||||
padding: .1rem .35rem;
|
||||
border: 1px solid $grey-300;
|
||||
background: $grey-100;
|
||||
border: 1px solid var(--grey-300);
|
||||
background: var(--grey-100);
|
||||
border-radius: 3px;
|
||||
font-size: .75rem;
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<x-button
|
||||
type="secondary"
|
||||
variant="secondary"
|
||||
:icon="icon"
|
||||
v-tooltip="tooltipText"
|
||||
@click="changeSubscription"
|
||||
|
@ -22,91 +22,89 @@
|
|||
</a>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts" setup>
|
||||
import {computed, shallowRef} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import SubscriptionService from '@/services/subscription'
|
||||
import SubscriptionModel from '@/models/subscription'
|
||||
|
||||
export default {
|
||||
name: 'task-subscription',
|
||||
data() {
|
||||
return {
|
||||
subscriptionService: new SubscriptionService(),
|
||||
}
|
||||
},
|
||||
props: {
|
||||
entity: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
subscription: {
|
||||
required: true,
|
||||
},
|
||||
entityId: {
|
||||
required: true,
|
||||
},
|
||||
isButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
emits: ['change'],
|
||||
computed: {
|
||||
tooltipText() {
|
||||
if (this.disabled) {
|
||||
return this.$t('task.subscription.subscribedThroughParent', {
|
||||
entity: this.entity,
|
||||
parent: this.subscription.entity,
|
||||
})
|
||||
}
|
||||
import {success} from '@/message'
|
||||
|
||||
return this.subscription !== null ?
|
||||
this.$t('task.subscription.subscribed', {entity: this.entity}) :
|
||||
this.$t('task.subscription.notSubscribed', {entity: this.entity})
|
||||
},
|
||||
buttonText() {
|
||||
return this.subscription !== null ? this.$t('task.subscription.unsubscribe') : this.$t('task.subscription.subscribe')
|
||||
},
|
||||
icon() {
|
||||
return this.subscription !== null ? ['far', 'bell-slash'] : 'bell'
|
||||
},
|
||||
disabled() {
|
||||
if (this.subscription === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
return this.subscription.entity !== this.entity
|
||||
},
|
||||
const props = defineProps({
|
||||
entity: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
methods: {
|
||||
changeSubscription() {
|
||||
if (this.disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.subscription === null) {
|
||||
this.subscribe()
|
||||
} else {
|
||||
this.unsubscribe()
|
||||
}
|
||||
},
|
||||
async subscribe() {
|
||||
const subscription = new SubscriptionModel({
|
||||
entity: this.entity,
|
||||
entityId: this.entityId,
|
||||
})
|
||||
await this.subscriptionService.create(subscription)
|
||||
this.$emit('change', subscription)
|
||||
this.$message.success({message: this.$t('task.subscription.subscribeSuccess', {entity: this.entity})})
|
||||
},
|
||||
async unsubscribe() {
|
||||
const subscription = new SubscriptionModel({
|
||||
entity: this.entity,
|
||||
entityId: this.entityId,
|
||||
})
|
||||
await this.subscriptionService.delete(subscription)
|
||||
this.$emit('change', null)
|
||||
this.$message.success({message: this.$t('task.subscription.unsubscribeSuccess', {entity: this.entity})})
|
||||
},
|
||||
subscription: {
|
||||
required: true,
|
||||
},
|
||||
entityId: {
|
||||
required: true,
|
||||
},
|
||||
isButton: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
const subscriptionService = shallowRef(new SubscriptionService())
|
||||
|
||||
const {t} = useI18n()
|
||||
const tooltipText = computed(() => {
|
||||
if (disabled.value) {
|
||||
return t('task.subscription.subscribedThroughParent', {
|
||||
entity: props.entity,
|
||||
parent: props.subscription.entity,
|
||||
})
|
||||
}
|
||||
|
||||
return props.subscription !== null ?
|
||||
t('task.subscription.subscribed', {entity: props.entity}) :
|
||||
t('task.subscription.notSubscribed', {entity: props.entity})
|
||||
})
|
||||
|
||||
const buttonText = computed(() => props.subscription !== null ? t('task.subscription.unsubscribe') : t('task.subscription.subscribe'))
|
||||
const icon = computed(() => props.subscription !== null ? ['far', 'bell-slash'] : 'bell')
|
||||
const disabled = computed(() => {
|
||||
if (props.subscription === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
return props.subscription.entity !== props.entity
|
||||
})
|
||||
|
||||
function changeSubscription() {
|
||||
if (disabled.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (props.subscription === null) {
|
||||
subscribe()
|
||||
} else {
|
||||
unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribe() {
|
||||
const subscription = new SubscriptionModel({
|
||||
entity: props.entity,
|
||||
entityId: props.entityId,
|
||||
})
|
||||
await subscriptionService.value.create(subscription)
|
||||
emit('change', subscription)
|
||||
success({message: t('task.subscription.subscribeSuccess', {entity: props.entity})})
|
||||
}
|
||||
|
||||
async function unsubscribe() {
|
||||
const subscription = new SubscriptionModel({
|
||||
entity: props.entity,
|
||||
entityId: props.entityId,
|
||||
})
|
||||
await subscriptionService.value.delete(subscription)
|
||||
emit('change', null)
|
||||
success({message: t('task.subscription.unsubscribeSuccess', {entity: props.entity})})
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -11,31 +11,28 @@
|
|||
</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 lang="ts" setup>
|
||||
defineProps({
|
||||
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>
|
||||
|
|
|
@ -31,14 +31,15 @@
|
|||
<div class="actions">
|
||||
<x-button
|
||||
@click="$emit('close')"
|
||||
type="tertary"
|
||||
variant="tertiary"
|
||||
class="has-text-danger"
|
||||
>
|
||||
{{ $t('misc.cancel') }}
|
||||
</x-button>
|
||||
<x-button
|
||||
@click="$emit('submit')"
|
||||
type="primary"
|
||||
variant="primary"
|
||||
v-cy="'modalPrimary'"
|
||||
:shadow="false"
|
||||
>
|
||||
{{ $t('misc.doit') }}
|
||||
|
@ -193,10 +194,6 @@ export default {
|
|||
align-items: center;
|
||||
|
||||
}
|
||||
|
||||
.message-body {
|
||||
padding: .5rem .75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -9,32 +9,23 @@
|
|||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts" setup>
|
||||
import {ref, computed} from 'vue'
|
||||
import {useStore} from 'vuex'
|
||||
import Multiselect from '@/components/input/multiselect.vue'
|
||||
|
||||
export default {
|
||||
name: 'namespace-search',
|
||||
emits: ['selected'],
|
||||
data() {
|
||||
return {
|
||||
query: '',
|
||||
}
|
||||
},
|
||||
components: {
|
||||
Multiselect,
|
||||
},
|
||||
computed: {
|
||||
namespaces() {
|
||||
return this.$store.getters['namespaces/searchNamespace'](this.query)
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
findNamespaces(query) {
|
||||
this.query = query
|
||||
},
|
||||
select(namespace) {
|
||||
this.$emit('selected', namespace)
|
||||
},
|
||||
},
|
||||
const emit = defineEmits(['selected'])
|
||||
|
||||
const query = ref('')
|
||||
|
||||
const store = useStore()
|
||||
const namespaces = computed(() => store.getters['namespaces/searchNamespace'](query.value))
|
||||
|
||||
function findNamespaces(newQuery: string) {
|
||||
query.value = newQuery
|
||||
}
|
||||
|
||||
function select(namespace) {
|
||||
emit('selected', namespace)
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -52,30 +52,22 @@
|
|||
</dropdown>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script setup lang="ts">
|
||||
import {ref, onMounted} from 'vue'
|
||||
|
||||
import Dropdown from '@/components/misc/dropdown.vue'
|
||||
import DropdownItem from '@/components/misc/dropdown-item.vue'
|
||||
import TaskSubscription from '@/components/misc/subscription.vue'
|
||||
|
||||
export default {
|
||||
name: 'namespace-settings-dropdown',
|
||||
data() {
|
||||
return {
|
||||
subscription: null,
|
||||
}
|
||||
const props = defineProps({
|
||||
namespace: {
|
||||
type: Object, // NamespaceModel
|
||||
required: true,
|
||||
},
|
||||
components: {
|
||||
DropdownItem,
|
||||
Dropdown,
|
||||
TaskSubscription,
|
||||
},
|
||||
props: {
|
||||
namespace: {
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.subscription = this.namespace.subscription
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const subscription = ref(null)
|
||||
onMounted(() => {
|
||||
subscription.value = props.namespace.subscription
|
||||
})
|
||||
</script>
|
||||
|
|
|
@ -145,9 +145,9 @@ export default {
|
|||
width: .75rem;
|
||||
height: .75rem;
|
||||
|
||||
background: $primary;
|
||||
background: var(--primary);
|
||||
border-radius: 100%;
|
||||
border: 2px solid $white;
|
||||
border: 2px solid var(--white);
|
||||
}
|
||||
|
||||
.notifications-list {
|
||||
|
@ -157,12 +157,12 @@ export default {
|
|||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
|
||||
background: $white;
|
||||
background: var(--white);
|
||||
width: 350px;
|
||||
max-width: calc(100vw - 2rem);
|
||||
padding: .75rem .25rem;
|
||||
border-radius: $radius;
|
||||
box-shadow: $shadow-sm;
|
||||
box-shadow: var(--shadow-sm);
|
||||
font-size: .85rem;
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
|
@ -183,14 +183,14 @@ export default {
|
|||
transition: background-color $transition;
|
||||
|
||||
&:hover {
|
||||
background: $grey-100;
|
||||
background: var(--grey-100);
|
||||
border-radius: $radius;
|
||||
}
|
||||
|
||||
.read-indicator {
|
||||
width: .35rem;
|
||||
height: .35rem;
|
||||
background: $primary;
|
||||
background: var(--primary);
|
||||
border-radius: 100%;
|
||||
margin-left: .5rem;
|
||||
|
||||
|
@ -219,7 +219,7 @@ export default {
|
|||
}
|
||||
|
||||
.created {
|
||||
color: $grey-400;
|
||||
color: var(--grey-400);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
|
@ -227,14 +227,14 @@ export default {
|
|||
}
|
||||
|
||||
a {
|
||||
color: $grey-800;
|
||||
color: var(--grey-800);
|
||||
}
|
||||
}
|
||||
|
||||
.nothing {
|
||||
text-align: center;
|
||||
padding: 1rem 0;
|
||||
color: $grey-500;
|
||||
color: var(--grey-500);
|
||||
|
||||
.explainer {
|
||||
font-size: .75rem;
|
||||
|
|
|
@ -507,17 +507,19 @@ export default {
|
|||
.active-cmd {
|
||||
font-size: 1.25rem;
|
||||
margin-left: .5rem;
|
||||
background-color: var(--grey-100);
|
||||
color: var(--grey-800);
|
||||
}
|
||||
}
|
||||
|
||||
.results {
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
color: $grey-800;
|
||||
color: var(--grey-800);
|
||||
|
||||
.result {
|
||||
&-title {
|
||||
background: $grey-50;
|
||||
background: var(--grey-50);
|
||||
padding: .5rem;
|
||||
display: block;
|
||||
font-size: .75rem;
|
||||
|
@ -528,6 +530,7 @@ export default {
|
|||
font-size: .9rem;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
color: var(--grey-800);
|
||||
text-align: left;
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
|
@ -539,12 +542,12 @@ export default {
|
|||
cursor: pointer;
|
||||
|
||||
&:focus, &:hover {
|
||||
background: $grey-50;
|
||||
background: var(--grey-50);
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: $grey-100;
|
||||
background: var(--grey-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,17 +3,13 @@
|
|||
<div class="field is-grouped">
|
||||
<p class="control has-icons-left is-expanded">
|
||||
<textarea
|
||||
:disabled="taskService.loading || null"
|
||||
class="input"
|
||||
:disabled="taskService.loading || undefined"
|
||||
class="add-task-textarea input"
|
||||
:placeholder="$t('list.list.addPlaceholder')"
|
||||
cols="1"
|
||||
rows="1"
|
||||
v-focus
|
||||
v-model="newTaskTitle"
|
||||
ref="newTaskInput"
|
||||
:style="{
|
||||
'minHeight': `${initialTextAreaHeight}px`,
|
||||
'height': `calc(${textAreaHeight}px - 2px + 1rem)`
|
||||
}"
|
||||
@keyup="errorMessage = ''"
|
||||
@keydown.enter="handleEnter"
|
||||
/>
|
||||
|
@ -23,7 +19,8 @@
|
|||
</p>
|
||||
<p class="control">
|
||||
<x-button
|
||||
:disabled="newTaskTitle === '' || taskService.loading || null"
|
||||
class="add-task-button"
|
||||
:disabled="newTaskTitle === '' || taskService.loading || undefined"
|
||||
@click="addTask()"
|
||||
icon="plus"
|
||||
:loading="taskService.loading"
|
||||
|
@ -35,121 +32,171 @@
|
|||
<p class="help is-danger" v-if="errorMessage !== ''">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<quick-add-magic v-if="errorMessage === ''"/>
|
||||
<quick-add-magic v-else />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TaskService from '../../services/task'
|
||||
<script setup lang="ts">
|
||||
import {ref, watch, unref, shallowReactive} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {useStore} from 'vuex'
|
||||
import { tryOnMounted, debouncedWatch, useWindowSize, MaybeRef } from '@vueuse/core'
|
||||
|
||||
import TaskService from '@/services/task'
|
||||
import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue'
|
||||
|
||||
const INPUT_BORDER_PX = 2
|
||||
const LINE_HEIGHT = 1.5 // using getComputedStyles().lineHeight returns an (wrong) absolute pixel value, we need the factor to do calculations with it.
|
||||
|
||||
const cleanupTitle = title => {
|
||||
function cleanupTitle(title: string) {
|
||||
return title.replace(/^((\* |\+ |- )(\[ \] )?)/g, '')
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'add-task',
|
||||
emits: ['taskAdded'],
|
||||
data() {
|
||||
return {
|
||||
newTaskTitle: '',
|
||||
taskService: new TaskService(),
|
||||
errorMessage: '',
|
||||
textAreaHeight: null,
|
||||
initialTextAreaHeight: null,
|
||||
function useAutoHeightTextarea(value: MaybeRef<string>) {
|
||||
const textarea = ref<HTMLInputElement>()
|
||||
const minHeight = ref(0)
|
||||
|
||||
// adapted from https://github.com/LeaVerou/stretchy/blob/47f5f065c733029acccb755cae793009645809e2/src/stretchy.js#L34
|
||||
function resize(textareaEl: HTMLInputElement|undefined) {
|
||||
if (!textareaEl) return
|
||||
|
||||
let empty
|
||||
|
||||
// the value here is the the attribute value
|
||||
if (!textareaEl.value && textareaEl.placeholder) {
|
||||
empty = true
|
||||
textareaEl.value = textareaEl.placeholder
|
||||
}
|
||||
},
|
||||
components: {
|
||||
QuickAddMagic,
|
||||
},
|
||||
props: {
|
||||
defaultPosition: {
|
||||
type: Number,
|
||||
required: false,
|
||||
|
||||
const cs = getComputedStyle(textareaEl)
|
||||
|
||||
textareaEl.style.minHeight = ''
|
||||
textareaEl.style.height = '0'
|
||||
const offset = textareaEl.offsetHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom)
|
||||
const height = textareaEl.scrollHeight + offset + 'px'
|
||||
|
||||
textareaEl.style.height = height
|
||||
|
||||
// calculate min-height for the first time
|
||||
if (!minHeight.value) {
|
||||
minHeight.value = parseFloat(height)
|
||||
}
|
||||
|
||||
textareaEl.style.minHeight = minHeight.value.toString()
|
||||
|
||||
|
||||
if (empty) {
|
||||
textareaEl.value = ''
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
tryOnMounted(() => {
|
||||
if (textarea.value) {
|
||||
// we don't want scrollbars
|
||||
textarea.value.style.overflowY = 'hidden'
|
||||
}
|
||||
})
|
||||
|
||||
const { width: windowWidth } = useWindowSize()
|
||||
|
||||
debouncedWatch(
|
||||
windowWidth,
|
||||
() => resize(textarea.value),
|
||||
{ debounce: 200 },
|
||||
)
|
||||
|
||||
// It is not possible to get notified of a change of the value attribute of a textarea without workarounds (setTimeout)
|
||||
// So instead we watch the value that we bound to it.
|
||||
watch(
|
||||
() => [textarea.value, unref(value)],
|
||||
() => resize(textarea.value),
|
||||
{
|
||||
immediate: true, // calculate initial size
|
||||
flush: 'post', // resize after value change is rendered to DOM
|
||||
},
|
||||
)
|
||||
|
||||
return textarea
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
defaultPosition: {
|
||||
type: Number,
|
||||
required: false,
|
||||
},
|
||||
watch: {
|
||||
newTaskTitle(newVal) {
|
||||
// Calculating the textarea height based on lines of input in it.
|
||||
// That is more reliable when removing a line from the input.
|
||||
const numberOfLines = newVal.split(/\r\n|\r|\n/).length
|
||||
const fontSize = parseFloat(window.getComputedStyle(this.$refs.newTaskInput, null).getPropertyValue('font-size'))
|
||||
})
|
||||
|
||||
this.textAreaHeight = numberOfLines * fontSize * LINE_HEIGHT + INPUT_BORDER_PX
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.initialTextAreaHeight = this.$refs.newTaskInput.scrollHeight + INPUT_BORDER_PX
|
||||
},
|
||||
methods: {
|
||||
async addTask() {
|
||||
if (this.newTaskTitle === '') {
|
||||
this.errorMessage = this.$t('list.create.addTitleRequired')
|
||||
return
|
||||
}
|
||||
this.errorMessage = ''
|
||||
const emit = defineEmits(['taskAdded'])
|
||||
|
||||
if (this.taskService.loading) {
|
||||
return
|
||||
}
|
||||
const newTaskTitle = ref('')
|
||||
const newTaskInput = useAutoHeightTextarea(newTaskTitle)
|
||||
|
||||
const newTasks = this.newTaskTitle.split(/[\r\n]+/).map(async t => {
|
||||
const title = cleanupTitle(t)
|
||||
if (title === '') {
|
||||
return
|
||||
}
|
||||
const { t } = useI18n()
|
||||
const store = useStore()
|
||||
|
||||
const task = await this.$store.dispatch('tasks/createNewTask', {
|
||||
title,
|
||||
listId: this.$store.state.auth.settings.defaultListId,
|
||||
position: this.defaultPosition,
|
||||
})
|
||||
this.$emit('taskAdded', task)
|
||||
return task
|
||||
})
|
||||
const taskService = shallowReactive(new TaskService())
|
||||
const errorMessage = ref('')
|
||||
|
||||
try {
|
||||
await Promise.all(newTasks)
|
||||
this.newTaskTitle = ''
|
||||
} catch (e) {
|
||||
if (e.message === 'NO_LIST') {
|
||||
this.errorMessage = this.$t('list.create.addListRequired')
|
||||
return
|
||||
}
|
||||
throw e
|
||||
}
|
||||
},
|
||||
handleEnter(e) {
|
||||
// when pressing shift + enter we want to continue as we normally would. Otherwise, we want to create
|
||||
// the new task(s). The vue event modifier don't allow this, hence this method.
|
||||
if (e.shiftKey) {
|
||||
return
|
||||
}
|
||||
async function addTask() {
|
||||
if (newTaskTitle.value === '') {
|
||||
errorMessage.value = t('list.create.addTitleRequired')
|
||||
return
|
||||
}
|
||||
errorMessage.value = ''
|
||||
|
||||
e.preventDefault()
|
||||
this.addTask()
|
||||
},
|
||||
},
|
||||
if (taskService.loading) {
|
||||
return
|
||||
}
|
||||
|
||||
const taskTitleBackup = newTaskTitle.value
|
||||
const newTasks = newTaskTitle.value.split(/[\r\n]+/).map(async uncleanedTitle => {
|
||||
const title = cleanupTitle(uncleanedTitle)
|
||||
if (title === '') {
|
||||
return
|
||||
}
|
||||
|
||||
const task = await store.dispatch('tasks/createNewTask', {
|
||||
title,
|
||||
listId: store.state.auth.settings.defaultListId,
|
||||
position: props.defaultPosition,
|
||||
})
|
||||
emit('taskAdded', task)
|
||||
return task
|
||||
})
|
||||
|
||||
try {
|
||||
newTaskTitle.value = ''
|
||||
await Promise.all(newTasks)
|
||||
} catch (e: any) {
|
||||
newTaskTitle.value = taskTitleBackup
|
||||
if (e?.message === 'NO_LIST') {
|
||||
errorMessage.value = t('list.create.addListRequired')
|
||||
return
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnter(e: KeyboardEvent) {
|
||||
// when pressing shift + enter we want to continue as we normally would. Otherwise, we want to create
|
||||
// the new task(s). The vue event modifier don't allow this, hence this method.
|
||||
if (e.shiftKey) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
addTask()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.task-add {
|
||||
margin-bottom: 0;
|
||||
|
||||
.button {
|
||||
height: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.input, .textarea {
|
||||
.add-task-button {
|
||||
height: 2.5rem;
|
||||
}
|
||||
.add-task-textarea {
|
||||
transition: border-color $transition;
|
||||
}
|
||||
|
||||
.input {
|
||||
resize: vertical;
|
||||
resize: none;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -78,7 +78,6 @@
|
|||
<script>
|
||||
import AsyncEditor from '@/components/input/AsyncEditor'
|
||||
|
||||
import ListService from '../../services/list'
|
||||
import TaskService from '../../services/task'
|
||||
import TaskModel from '../../models/task'
|
||||
import priorities from '../../models/constants/priorities'
|
||||
|
@ -90,14 +89,10 @@ export default {
|
|||
name: 'edit-task',
|
||||
data() {
|
||||
return {
|
||||
listId: this.$route.params.id,
|
||||
listService: new ListService(),
|
||||
taskService: new TaskService(),
|
||||
|
||||
priorities: priorities,
|
||||
list: {},
|
||||
editorActive: false,
|
||||
newTask: new TaskModel(),
|
||||
isTaskEdit: false,
|
||||
taskEditTask: TaskModel,
|
||||
}
|
||||
|
@ -167,7 +162,7 @@ ul.assingees {
|
|||
|
||||
a {
|
||||
float: right;
|
||||
color: $red;
|
||||
color: var(--danger);
|
||||
transition: all $transition;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,15 +2,7 @@
|
|||
<div class="gantt-chart">
|
||||
<div class="filter-container">
|
||||
<div class="items">
|
||||
<x-button
|
||||
@click.prevent.stop="showTaskFilter = !showTaskFilter"
|
||||
type="secondary"
|
||||
icon="filter"
|
||||
>
|
||||
{{ $t('filters.title') }}
|
||||
</x-button>
|
||||
<filter-popup
|
||||
:visible="showTaskFilter"
|
||||
v-model="params"
|
||||
@update:modelValue="loadTasks()"
|
||||
/>
|
||||
|
@ -191,6 +183,8 @@ import {mapState} from 'vuex'
|
|||
import Rights from '../../models/constants/rights.json'
|
||||
import FilterPopup from '@/components/list/partials/filter-popup.vue'
|
||||
|
||||
import {colorIsDark} from '@/helpers/color/colorIsDark'
|
||||
|
||||
export default {
|
||||
name: 'GanttChart',
|
||||
components: {
|
||||
|
@ -209,10 +203,10 @@ export default {
|
|||
default: false,
|
||||
},
|
||||
dateFrom: {
|
||||
default: new Date(new Date().setDate(new Date().getDate() - 15)),
|
||||
default: () => new Date(new Date().setDate(new Date().getDate() - 15)),
|
||||
},
|
||||
dateTo: {
|
||||
default: new Date(new Date().setDate(new Date().getDate() + 30)),
|
||||
default: () => new Date(new Date().setDate(new Date().getDate() + 30)),
|
||||
},
|
||||
// The width of a day in pixels, used to calculate all sorts of things.
|
||||
dayWidth: {
|
||||
|
@ -237,7 +231,6 @@ export default {
|
|||
newTaskFieldActive: false,
|
||||
priorities: priorities,
|
||||
taskCollectionService: new TaskCollectionService(),
|
||||
showTaskFilter: false,
|
||||
|
||||
params: {
|
||||
sort_by: ['done', 'id'],
|
||||
|
@ -261,6 +254,7 @@ export default {
|
|||
canWrite: (state) => state.currentList.maxRight > Rights.READ,
|
||||
}),
|
||||
methods: {
|
||||
colorIsDark,
|
||||
buildTheGanttChart() {
|
||||
this.setDates()
|
||||
this.prepareGanttDays()
|
||||
|
@ -445,12 +439,12 @@ export default {
|
|||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$gantt-border: 1px solid $grey-200;
|
||||
$gantt-vertical-border-color: $grey-100;
|
||||
$gantt-border: 1px solid var(--grey-200);
|
||||
$gantt-vertical-border-color: var(--grey-100);
|
||||
|
||||
.gantt-chart {
|
||||
overflow-x: auto;
|
||||
border-top: 1px solid $grey-200;
|
||||
border-top: 1px solid var(--grey-200);
|
||||
|
||||
.dates {
|
||||
display: flex;
|
||||
|
@ -477,8 +471,8 @@ $gantt-vertical-border-color: $grey-100;
|
|||
font-weight: normal;
|
||||
|
||||
&.today {
|
||||
background: $primary;
|
||||
color: $white;
|
||||
background: var(--primary);
|
||||
color: var(--white);
|
||||
border-radius: 5px 5px 0 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
@ -500,7 +494,6 @@ $gantt-vertical-border-color: $grey-100;
|
|||
|
||||
.tasks {
|
||||
max-width: unset !important;
|
||||
margin: 0;
|
||||
border-top: $gantt-border;
|
||||
|
||||
.row {
|
||||
|
@ -508,7 +501,7 @@ $gantt-vertical-border-color: $grey-100;
|
|||
|
||||
.task {
|
||||
display: inline-block;
|
||||
border: 2px solid $primary;
|
||||
border: 2px solid var(--primary);
|
||||
font-size: 0.85rem;
|
||||
margin: 0.5rem;
|
||||
border-radius: 6px;
|
||||
|
@ -521,30 +514,30 @@ $gantt-vertical-border-color: $grey-100;
|
|||
user-select: none; // Non-prefixed version
|
||||
|
||||
&.is-current-edit {
|
||||
border-color: $orange !important;
|
||||
border-color: var(--warning) !important;
|
||||
}
|
||||
|
||||
&.has-light-text {
|
||||
color: $light;
|
||||
color: var(--light);
|
||||
|
||||
&.done span:after {
|
||||
border-top: 1px solid $light;
|
||||
border-top: 1px solid var(--light);
|
||||
}
|
||||
|
||||
.edit-toggle {
|
||||
color: $light;
|
||||
color: var(--light);
|
||||
}
|
||||
}
|
||||
|
||||
&.has-dark-text {
|
||||
color: $text;
|
||||
color: var(--text);
|
||||
|
||||
&.done span:after {
|
||||
border-top: 1px solid $dark;
|
||||
border-top: 1px solid var(--dark);
|
||||
}
|
||||
|
||||
.edit-toggle {
|
||||
color: $text;
|
||||
color: var(--text);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -597,8 +590,8 @@ $gantt-vertical-border-color: $grey-100;
|
|||
}
|
||||
|
||||
&.nodate {
|
||||
border: 2px dashed $grey-300;
|
||||
background: $grey-100;
|
||||
border: 2px dashed var(--grey-300);
|
||||
background: var(--grey-100);
|
||||
}
|
||||
|
||||
&:active {
|
||||
|
|
|
@ -44,7 +44,6 @@ export default {
|
|||
params = null,
|
||||
forceLoading = false,
|
||||
) {
|
||||
|
||||
// Because this function is triggered every time on topNavigation, 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 (
|
||||
|
|
|
@ -83,7 +83,7 @@
|
|||
@click="$refs.files.click()"
|
||||
class="mb-4"
|
||||
icon="cloud-upload-alt"
|
||||
type="secondary"
|
||||
variant="secondary"
|
||||
:shadow="false"
|
||||
>
|
||||
{{ $t('task.attachment.upload') }}
|
||||
|
@ -267,17 +267,17 @@ export default {
|
|||
padding: .5rem;
|
||||
|
||||
&:hover {
|
||||
background-color: $grey-200;
|
||||
background-color: var(--grey-200);
|
||||
}
|
||||
|
||||
.filename {
|
||||
font-weight: bold;
|
||||
margin-bottom: .25rem;
|
||||
color: $text;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.info {
|
||||
color: $grey-500;
|
||||
color: var(--grey-500);
|
||||
font-size: .9rem;
|
||||
|
||||
p {
|
||||
|
@ -339,17 +339,17 @@ export default {
|
|||
width: 100%;
|
||||
font-size: 5rem;
|
||||
height: auto;
|
||||
text-shadow: $shadow-md;
|
||||
text-shadow: var(--shadow-md);
|
||||
animation: bounce 2s infinite;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: .5rem auto 2rem;
|
||||
border-radius: 2px;
|
||||
box-shadow: $shadow-md;
|
||||
background: $primary;
|
||||
box-shadow: var(--shadow-md);
|
||||
background: var(--primary);
|
||||
padding: 1rem;
|
||||
color: $white;
|
||||
color: var(--white);
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
|
|
@ -39,7 +39,7 @@ export default {
|
|||
|
||||
<style scoped lang="scss">
|
||||
.checklist-summary {
|
||||
color: $grey-500;
|
||||
color: var(--grey-500);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
|
@ -49,10 +49,10 @@ export default {
|
|||
margin-right: .25rem;
|
||||
|
||||
circle {
|
||||
stroke: $grey-400;
|
||||
stroke: var(--grey-400);
|
||||
|
||||
&:last-child {
|
||||
stroke: $primary;
|
||||
stroke: var(--primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -276,7 +276,7 @@ export default {
|
|||
|
||||
this.commentEdit.taskId = this.taskId
|
||||
try {
|
||||
const comment = this.taskCommentService.update(this.commentEdit)
|
||||
const comment = await this.taskCommentService.update(this.commentEdit)
|
||||
for (const c in this.comments) {
|
||||
if (this.comments[c].id === this.commentEdit.id) {
|
||||
this.comments[c] = comment
|
||||
|
|
|
@ -8,21 +8,21 @@
|
|||
<x-button
|
||||
@click.prevent.stop="() => deferDays(1)"
|
||||
:shadow="false"
|
||||
type="secondary"
|
||||
variant="secondary"
|
||||
>
|
||||
{{ $t('task.deferDueDate.1day') }}
|
||||
</x-button>
|
||||
<x-button
|
||||
@click.prevent.stop="() => deferDays(3)"
|
||||
:shadow="false"
|
||||
type="secondary"
|
||||
variant="secondary"
|
||||
>
|
||||
{{ $t('task.deferDueDate.3days') }}
|
||||
</x-button>
|
||||
<x-button
|
||||
@click.prevent.stop="() => deferDays(7)"
|
||||
:shadow="false"
|
||||
type="secondary"
|
||||
variant="secondary"
|
||||
>
|
||||
{{ $t('task.deferDueDate.1week') }}
|
||||
</x-button>
|
||||
|
@ -141,14 +141,14 @@ $defer-task-max-width: 350px + 100px;
|
|||
width: 100%;
|
||||
max-width: $defer-task-max-width;
|
||||
border-radius: $radius;
|
||||
border: 1px solid $grey-200;
|
||||
border: 1px solid var(--grey-200);
|
||||
padding: 1rem;
|
||||
margin: 1rem;
|
||||
background: $white;
|
||||
color: $text;
|
||||
background: var(--white);
|
||||
color: var(--text);
|
||||
cursor: default;
|
||||
z-index: 10;
|
||||
box-shadow: $shadow-lg;
|
||||
box-shadow: var(--shadow-lg);
|
||||
|
||||
@media screen and (max-width: ($defer-task-max-width)) {
|
||||
left: .5rem;
|
||||
|
|
|
@ -127,7 +127,7 @@ export default {
|
|||
}
|
||||
|
||||
:deep(.user img) {
|
||||
border: 2px solid $white;
|
||||
border: 2px solid var(--white);
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
|
@ -135,8 +135,8 @@ export default {
|
|||
position: absolute;
|
||||
top: 4px;
|
||||
left: 2px;
|
||||
color: $red;
|
||||
background: $white;
|
||||
color: var(--danger);
|
||||
background: var(--white);
|
||||
padding: 0 4px;
|
||||
display: block;
|
||||
border-radius: 100%;
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
:class="{'disabled': !canWrite}"
|
||||
@blur="save($event.target.textContent)"
|
||||
@keydown.enter.prevent.stop="$event.target.blur()"
|
||||
:contenteditable="canWrite ? 'true' : 'false'"
|
||||
:contenteditable="canWrite ? true : undefined"
|
||||
:spellcheck="false"
|
||||
>
|
||||
{{ task.title.trim() }}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<template>
|
||||
<div
|
||||
class="task loader-container draggable"
|
||||
:class="{
|
||||
'is-loading': loadingInternal || loading,
|
||||
'draggable': !(loadingInternal || loading),
|
||||
|
@ -9,7 +10,6 @@
|
|||
@click.ctrl="() => toggleTaskDone(task)"
|
||||
@click.exact="() => $router.push({ name: 'task.kanban.detail', params: { id: task.id } })"
|
||||
@click.meta="() => toggleTaskDone(task)"
|
||||
class="task loader-container draggable"
|
||||
>
|
||||
<span class="task-id">
|
||||
<Done class="kanban-card__done" :is-done="task.done" variant="small" />
|
||||
|
@ -73,6 +73,8 @@ import Done from '@/components/misc/Done.vue'
|
|||
import Labels from '../../../components/tasks/partials/labels'
|
||||
import ChecklistSummary from './checklist-summary'
|
||||
|
||||
import {colorIsDark} from '@/helpers/color/colorIsDark'
|
||||
|
||||
export default {
|
||||
name: 'kanban-card',
|
||||
components: {
|
||||
|
@ -98,6 +100,7 @@ export default {
|
|||
},
|
||||
},
|
||||
methods: {
|
||||
colorIsDark,
|
||||
async toggleTaskDone(task) {
|
||||
this.loadingInternal = true
|
||||
try {
|
||||
|
@ -117,13 +120,13 @@ export default {
|
|||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
$task-background: $white;
|
||||
$task-background: var(--white);
|
||||
|
||||
.task {
|
||||
-webkit-touch-callout: none; // iOS Safari
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
box-shadow: $shadow-xs;
|
||||
box-shadow: var(--shadow-xs);
|
||||
display: block;
|
||||
border: 3px solid transparent;
|
||||
|
||||
|
@ -163,7 +166,7 @@ $task-background: $white;
|
|||
}
|
||||
|
||||
&.overdue {
|
||||
color: $red;
|
||||
color: var(--danger);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -219,7 +222,7 @@ $task-background: $white;
|
|||
.footer .icon,
|
||||
.due-date,
|
||||
.priority-label {
|
||||
background: $grey-100;
|
||||
background: var(--grey-100);
|
||||
border-radius: $radius;
|
||||
padding: 0 .5rem;
|
||||
}
|
||||
|
@ -229,7 +232,7 @@ $task-background: $white;
|
|||
}
|
||||
|
||||
.task-id {
|
||||
color: $grey-500;
|
||||
color: var(--grey-500);
|
||||
font-size: .8rem;
|
||||
margin-bottom: .25rem;
|
||||
display: flex;
|
||||
|
@ -244,21 +247,21 @@ $task-background: $white;
|
|||
}
|
||||
|
||||
&.has-light-text {
|
||||
color: $white;
|
||||
color: var(--white);
|
||||
|
||||
.task-id {
|
||||
color: $grey-200;
|
||||
color: var(--grey-200);
|
||||
}
|
||||
|
||||
.footer .icon,
|
||||
.due-date,
|
||||
.priority-label {
|
||||
background: $grey-800;
|
||||
background: var(--grey-800);
|
||||
}
|
||||
|
||||
.footer {
|
||||
.icon svg {
|
||||
fill: $white;
|
||||
fill: var(--white);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,60 +16,54 @@
|
|||
</multiselect>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ListModel from '../../../models/list'
|
||||
<script lang="ts" setup>
|
||||
import {reactive, ref, watchEffect} from 'vue'
|
||||
import {useStore} from 'vuex'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import ListModel from '@/models/list'
|
||||
import Multiselect from '@/components/input/multiselect.vue'
|
||||
|
||||
export default {
|
||||
name: 'listSearch',
|
||||
data() {
|
||||
return {
|
||||
list: new ListModel(),
|
||||
foundLists: [],
|
||||
}
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
emits: ['update:modelValue', 'selected'],
|
||||
components: {
|
||||
Multiselect,
|
||||
},
|
||||
watch: {
|
||||
modelValue: {
|
||||
handler(value) {
|
||||
this.list = value
|
||||
},
|
||||
immeditate: true,
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
findLists(query) {
|
||||
this.foundLists = this.$store.getters['lists/searchList'](query)
|
||||
},
|
||||
const store = useStore()
|
||||
const {t} = useI18n()
|
||||
|
||||
select(list) {
|
||||
this.list = list
|
||||
this.$emit('selected', list)
|
||||
this.$emit('update:modelValue', list)
|
||||
},
|
||||
|
||||
namespace(namespaceId) {
|
||||
const namespace = this.$store.getters['namespaces/getNamespaceById'](namespaceId)
|
||||
if (namespace !== null) {
|
||||
return namespace.title
|
||||
}
|
||||
return this.$t('list.shared')
|
||||
const list = reactive(new ListModel())
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
validator(value) {
|
||||
return value instanceof ListModel
|
||||
},
|
||||
required: false,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
watchEffect(() => {
|
||||
Object.assign(list, props.modelValue)
|
||||
})
|
||||
|
||||
const foundLists = ref([])
|
||||
function findLists(query: string) {
|
||||
if (query === '') {
|
||||
select(null)
|
||||
}
|
||||
foundLists.value = store.getters['lists/searchList'](query)
|
||||
}
|
||||
|
||||
function select(l: ListModel | null) {
|
||||
Object.assign(list, l)
|
||||
emit('update:modelValue', list)
|
||||
}
|
||||
|
||||
function namespace(namespaceId: number) {
|
||||
const namespace = store.getters['namespaces/getNamespaceById'](namespaceId)
|
||||
return namespace !== null
|
||||
? namespace.title
|
||||
: t('list.shared')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.list-namespace-title {
|
||||
color: $grey-500;
|
||||
color: var(--grey-500);
|
||||
}
|
||||
</style>
|
|
@ -54,7 +54,7 @@ export default {
|
|||
}
|
||||
|
||||
span.high-priority {
|
||||
color: $red;
|
||||
color: var(--danger);
|
||||
width: auto !important; // To override the width set in tasks
|
||||
|
||||
.icon {
|
||||
|
@ -64,7 +64,7 @@ span.high-priority {
|
|||
}
|
||||
|
||||
&.not-so-high {
|
||||
color: $orange;
|
||||
color: var(--warning);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -65,6 +65,21 @@
|
|||
<li>17th ({{ $t('task.quickAddMagic.dateNth', {day: '17'}) }})</li>
|
||||
</ul>
|
||||
<p>{{ $t('task.quickAddMagic.dateTime', {time: 'at 17:00', timePM: '5pm'}) }}</p>
|
||||
|
||||
<h3>{{ $t('task.quickAddMagic.repeats') }}</h3>
|
||||
<p>{{ $t('task.quickAddMagic.repeatsDescription', {suffix: 'every {amount} {type}'}) }}</p>
|
||||
<p>{{ $t('misc.forExample') }}</p>
|
||||
<ul>
|
||||
<!-- Not localized because these only work in english -->
|
||||
<li>Every day</li>
|
||||
<li>Every 3 days</li>
|
||||
<li>Every week</li>
|
||||
<li>Every 2 weeks</li>
|
||||
<li>Every month</li>
|
||||
<li>Every 6 months</li>
|
||||
<li>Every year</li>
|
||||
<li>Every 2 years</li>
|
||||
</ul>
|
||||
</card>
|
||||
</modal>
|
||||
</div>
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
class="is-pulled-right add-task-relation-button"
|
||||
:class="{'is-active': showNewRelationForm}"
|
||||
v-tooltip="$t('task.relation.add')"
|
||||
type="secondary"
|
||||
variant="secondary"
|
||||
icon="plus"
|
||||
:shadow="false"
|
||||
/>
|
||||
|
@ -310,7 +310,7 @@ export default {
|
|||
}
|
||||
|
||||
.different-list {
|
||||
color: $grey-500;
|
||||
color: var(--grey-500);
|
||||
width: auto;
|
||||
}
|
||||
|
||||
|
@ -319,6 +319,10 @@ export default {
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
.tasks {
|
||||
padding: .5rem;
|
||||
}
|
||||
|
||||
.task {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
@ -328,21 +332,21 @@ export default {
|
|||
border-radius: $radius;
|
||||
|
||||
&:hover {
|
||||
background-color: $grey-200;
|
||||
background-color: var(--grey-200);
|
||||
}
|
||||
|
||||
a {
|
||||
color: $text;
|
||||
color: var(--text);
|
||||
transition: color ease $transition-duration;
|
||||
|
||||
&:hover {
|
||||
color: $grey-900;
|
||||
color: var(--grey-900);
|
||||
}
|
||||
}
|
||||
|
||||
.remove {
|
||||
text-align: center;
|
||||
color: $red;
|
||||
color: var(--danger);
|
||||
opacity: 0;
|
||||
transition: opacity $transition;
|
||||
}
|
||||
|
|
|
@ -112,7 +112,7 @@ export default {
|
|||
align-items: center;
|
||||
|
||||
&.overdue :deep(.datepicker a.show) {
|
||||
color: $red;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
|
@ -120,7 +120,7 @@ export default {
|
|||
}
|
||||
|
||||
a.remove {
|
||||
color: $red;
|
||||
color: var(--danger);
|
||||
padding-left: .5rem;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
<template>
|
||||
<div class="control repeat-after-input">
|
||||
<div class="buttons has-addons is-centered mt-2">
|
||||
<x-button type="secondary" class="is-small" @click="() => setRepeatAfter(1, 'days')">{{ $t('task.repeat.everyDay') }}</x-button>
|
||||
<x-button type="secondary" class="is-small" @click="() => setRepeatAfter(1, 'weeks')">{{ $t('task.repeat.everyWeek') }}</x-button>
|
||||
<x-button type="secondary" class="is-small" @click="() => setRepeatAfter(1, 'months')">{{ $t('task.repeat.everyMonth') }}</x-button>
|
||||
<x-button variant="secondary" class="is-small" @click="() => setRepeatAfter(1, 'days')">{{ $t('task.repeat.everyDay') }}</x-button>
|
||||
<x-button variant="secondary" class="is-small" @click="() => setRepeatAfter(1, 'weeks')">{{ $t('task.repeat.everyWeek') }}</x-button>
|
||||
<x-button variant="secondary" class="is-small" @click="() => setRepeatAfter(1, 'months')">{{ $t('task.repeat.everyMonth') }}</x-button>
|
||||
</div>
|
||||
<div class="is-flex is-align-items-center mb-2">
|
||||
<label for="repeatMode" class="is-fullwidth">
|
||||
|
|
|
@ -227,7 +227,7 @@ export default {
|
|||
border: 2px solid transparent;
|
||||
|
||||
&:hover {
|
||||
background-color: $grey-100;
|
||||
background-color: var(--grey-100);
|
||||
}
|
||||
|
||||
.tasktext,
|
||||
|
@ -239,13 +239,13 @@ export default {
|
|||
flex: 1 0 50%;
|
||||
|
||||
.overdue {
|
||||
color: $red;
|
||||
color: var(--danger);
|
||||
}
|
||||
}
|
||||
|
||||
.task-list {
|
||||
width: auto;
|
||||
color: $grey-400;
|
||||
color: var(--grey-400);
|
||||
font-size: .9rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
@ -273,11 +273,11 @@ export default {
|
|||
}
|
||||
|
||||
a {
|
||||
color: $text;
|
||||
color: var(--text);
|
||||
transition: color ease $transition-duration;
|
||||
|
||||
&:hover {
|
||||
color: $grey-900;
|
||||
color: var(--grey-900);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -288,12 +288,12 @@ export default {
|
|||
transition: opacity $transition, color $transition;
|
||||
|
||||
&:hover {
|
||||
color: $orange;
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
&.is-favorite {
|
||||
opacity: 1;
|
||||
color: $orange;
|
||||
color: var(--warning);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -324,16 +324,16 @@ export default {
|
|||
|
||||
.tasktext.done {
|
||||
text-decoration: line-through;
|
||||
color: $grey-500;
|
||||
color: var(--grey-500);
|
||||
}
|
||||
|
||||
span.parent-tasks {
|
||||
color: $grey-500;
|
||||
color: var(--grey-500);
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.remove {
|
||||
color: $red;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
|
@ -351,8 +351,8 @@ export default {
|
|||
left: calc(50% - 1rem);
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-left-color: $grey-300;
|
||||
border-bottom-color: $grey-300;
|
||||
border-left-color: var(--grey-300);
|
||||
border-bottom-color: var(--grey-300);
|
||||
}
|
||||
}
|
||||
</style>
|
16
src/composables/useBodyClass.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import {ref, watchEffect} from 'vue'
|
||||
import {tryOnBeforeUnmount} from '@vueuse/core'
|
||||
|
||||
export function useBodyClass(className: string, defaultValue = false) {
|
||||
const isActive = ref(defaultValue)
|
||||
|
||||
watchEffect(() => {
|
||||
isActive.value
|
||||
? document.body.classList.add(className)
|
||||
: document.body.classList.remove(className)
|
||||
})
|
||||
|
||||
tryOnBeforeUnmount(() => isActive.value && document.body.classList.remove(className))
|
||||
|
||||
return isActive
|
||||
}
|
48
src/composables/useColorScheme.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
import {computed, watch, readonly} from 'vue'
|
||||
import {useStorage, createSharedComposable, ColorSchema, usePreferredColorScheme, tryOnMounted} from '@vueuse/core'
|
||||
|
||||
const STORAGE_KEY = 'color-scheme'
|
||||
|
||||
const DEFAULT_COLOR_SCHEME_SETTING: ColorSchema = 'light'
|
||||
|
||||
const CLASS_DARK = 'dark'
|
||||
const CLASS_LIGHT = 'light'
|
||||
|
||||
// This is built upon the vueuse useDark
|
||||
// Main differences:
|
||||
// - usePreferredColorScheme
|
||||
// - doesn't allow setting via the `isDark` ref.
|
||||
// - instead the store is exposed
|
||||
// - value is synced via `createSharedComposable`
|
||||
// https://github.com/vueuse/vueuse/blob/main/packages/core/useDark/index.ts
|
||||
export const useColorScheme = createSharedComposable(() => {
|
||||
const store = useStorage<ColorSchema>(STORAGE_KEY, DEFAULT_COLOR_SCHEME_SETTING)
|
||||
|
||||
const preferredColorScheme = usePreferredColorScheme()
|
||||
|
||||
const isDark = computed<boolean>(() => {
|
||||
if (store.value !== 'auto') {
|
||||
return store.value === 'dark'
|
||||
}
|
||||
|
||||
const autoColorScheme = preferredColorScheme.value === 'no-preference'
|
||||
? DEFAULT_COLOR_SCHEME_SETTING
|
||||
: preferredColorScheme.value
|
||||
return autoColorScheme === 'dark'
|
||||
})
|
||||
|
||||
function onChanged(v: boolean) {
|
||||
const el = window?.document.querySelector('html')
|
||||
el?.classList.toggle(CLASS_DARK, v)
|
||||
el?.classList.toggle(CLASS_LIGHT, !v)
|
||||
}
|
||||
|
||||
watch(isDark, onChanged, { flush: 'post' })
|
||||
|
||||
tryOnMounted(() => onChanged(isDark.value))
|
||||
|
||||
return {
|
||||
store,
|
||||
isDark: readonly(isDark),
|
||||
}
|
||||
})
|
31
src/composables/useDateTimeSalutation.test.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
import {describe, it, expect} from 'vitest'
|
||||
import {hourToSalutation} from './useDateTimeSalutation'
|
||||
|
||||
const dateWithHour = (hours: number): Date => {
|
||||
const date = new Date()
|
||||
date.setHours(hours)
|
||||
return date
|
||||
}
|
||||
|
||||
describe('Salutation', () => {
|
||||
it('shows the right salutation in the night', () => {
|
||||
const salutation = hourToSalutation(dateWithHour(4))
|
||||
expect(salutation).toBe('home.welcomeNight')
|
||||
})
|
||||
it('shows the right salutation in the morning', () => {
|
||||
const salutation = hourToSalutation(dateWithHour(8))
|
||||
expect(salutation).toBe('home.welcomeMorning')
|
||||
})
|
||||
it('shows the right salutation in the day', () => {
|
||||
const salutation = hourToSalutation(dateWithHour(13))
|
||||
expect(salutation).toBe('home.welcomeDay')
|
||||
})
|
||||
it('shows the right salutation in the night', () => {
|
||||
const salutation = hourToSalutation(dateWithHour(20))
|
||||
expect(salutation).toBe('home.welcomeEvening')
|
||||
})
|
||||
it('shows the right salutation in the night again', () => {
|
||||
const salutation = hourToSalutation(dateWithHour(23))
|
||||
expect(salutation).toBe('home.welcomeNight')
|
||||
})
|
||||
})
|
31
src/composables/useDateTimeSalutation.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
import {computed} from 'vue'
|
||||
import {useNow} from '@vueuse/core'
|
||||
|
||||
const TRANSLATION_KEY_PREFIX = 'home.welcome'
|
||||
|
||||
export function hourToSalutation(now: Date) {
|
||||
const hours = now.getHours()
|
||||
|
||||
if (hours < 5) {
|
||||
return `${TRANSLATION_KEY_PREFIX}Night`
|
||||
}
|
||||
|
||||
if (hours < 11) {
|
||||
return `${TRANSLATION_KEY_PREFIX}Morning`
|
||||
}
|
||||
|
||||
if (hours < 18) {
|
||||
return `${TRANSLATION_KEY_PREFIX}Day`
|
||||
}
|
||||
|
||||
if (hours < 23) {
|
||||
return `${TRANSLATION_KEY_PREFIX}Evening`
|
||||
}
|
||||
|
||||
return `${TRANSLATION_KEY_PREFIX}Night`
|
||||
}
|
||||
|
||||
export function useDateTimeSalutation() {
|
||||
const now = useNow()
|
||||
return computed(() => hourToSalutation(now.value))
|
||||
}
|
14
src/composables/useOnline.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import {ref} from 'vue'
|
||||
import {useOnline as useNetworkOnline, ConfigurableWindow} from '@vueuse/core'
|
||||
|
||||
|
||||
export function useOnline(options?: ConfigurableWindow) {
|
||||
const fakeOnlineState = !!import.meta.env.VITE_IS_ONLINE
|
||||
if (fakeOnlineState) {
|
||||
console.log('Setting fake online state', fakeOnlineState)
|
||||
}
|
||||
|
||||
return fakeOnlineState
|
||||
? ref(true)
|
||||
: useNetworkOnline(options)
|
||||
}
|
23
src/directives/cypress.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import {Directive} from 'vue'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Cypress: object;
|
||||
}
|
||||
}
|
||||
|
||||
const cypressDirective: Directive = {
|
||||
mounted(el, {value}) {
|
||||
if (
|
||||
(window.Cypress || import.meta.env.DEV) &&
|
||||
value
|
||||
) {
|
||||
el.setAttribute('data-cy', value)
|
||||
}
|
||||
},
|
||||
beforeUnmount(el) {
|
||||
el.removeAttribute('data-cy')
|
||||
},
|
||||
}
|
||||
|
||||
export default cypressDirective
|
|
@ -5,7 +5,7 @@ export default {
|
|||
// 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 || (typeof modifiers.always !== 'undefined' && modifiers.always)) {
|
||||
if (window.innerWidth > 769 || modifiers?.always) {
|
||||
el.focus()
|
||||
}
|
||||
},
|
||||
|
|
|
@ -1,83 +0,0 @@
|
|||
const calculateTop = (coords, tooltip) => {
|
||||
// Bottom tooltip use the exact inverse calculation compared to the default.
|
||||
if (tooltip.classList.contains('bottom')) {
|
||||
return coords.top + tooltip.offsetHeight + 5
|
||||
}
|
||||
|
||||
// The top position of the tooltip is the coordinates of the bound element - the height of the tooltip -
|
||||
// 5px spacing for the arrow (which is exactly 5px high)
|
||||
return coords.top - tooltip.offsetHeight - 5
|
||||
}
|
||||
|
||||
const calculateArrowTop = (top, tooltip) => {
|
||||
if (tooltip.classList.contains('bottom')) {
|
||||
return `${top - 5}px` // 5px arrow height
|
||||
}
|
||||
return `${top + tooltip.offsetHeight}px`
|
||||
}
|
||||
|
||||
// This global object holds all created tooltip elements (and their arrows) using the element they were created for as
|
||||
// key. This allows us to find the tooltip elements if the element the tooltip was created for is unbound so that
|
||||
// we can remove the tooltip element.
|
||||
const createdTooltips = {}
|
||||
|
||||
export default {
|
||||
mounted(el, {value, modifiers}) {
|
||||
// First, we create the tooltip and arrow elements
|
||||
const tooltip = document.createElement('div')
|
||||
tooltip.style.position = 'fixed'
|
||||
tooltip.innerText = value
|
||||
tooltip.classList.add('tooltip')
|
||||
const arrow = document.createElement('div')
|
||||
arrow.classList.add('tooltip-arrow')
|
||||
arrow.style.position = 'fixed'
|
||||
|
||||
if (typeof modifiers.bottom !== 'undefined') {
|
||||
tooltip.classList.add('bottom')
|
||||
arrow.classList.add('bottom')
|
||||
}
|
||||
|
||||
// We don't append the element until hovering over it because that's the most reliable way to determine
|
||||
// where the parent elemtent is located at the time the user hovers over it.
|
||||
el.addEventListener('mouseover', () => {
|
||||
// Appending the element right away because we can only calculate the height of the element if it is
|
||||
// already in the DOM.
|
||||
document.body.appendChild(tooltip)
|
||||
document.body.appendChild(arrow)
|
||||
|
||||
const coords = el.getBoundingClientRect()
|
||||
const top = calculateTop(coords, tooltip)
|
||||
// The left position of the tooltip is calculated so that the middle point of the tooltip
|
||||
// (where the arrow will be) is the middle of the bound element
|
||||
const left = coords.left - (tooltip.offsetWidth / 2) + (el.offsetWidth / 2)
|
||||
// Now setting all the values
|
||||
tooltip.style.top = `${top}px`
|
||||
tooltip.style.left = `${coords.left}px`
|
||||
tooltip.style.left = `${left}px`
|
||||
|
||||
arrow.style.left = `${left + (tooltip.offsetWidth / 2) - (arrow.offsetWidth / 2)}px`
|
||||
arrow.style.top = calculateArrowTop(top, tooltip)
|
||||
|
||||
// And finally make it visible to the user. This will also trigger a nice fade-in animation through
|
||||
// css transitions
|
||||
tooltip.classList.add('visible')
|
||||
arrow.classList.add('visible')
|
||||
})
|
||||
|
||||
el.addEventListener('mouseout', () => {
|
||||
tooltip.classList.remove('visible')
|
||||
arrow.classList.remove('visible')
|
||||
})
|
||||
|
||||
createdTooltips[el] = {
|
||||
tooltip: tooltip,
|
||||
arrow: arrow,
|
||||
}
|
||||
},
|
||||
unmounted(el) {
|
||||
if (typeof createdTooltips[el] !== 'undefined') {
|
||||
createdTooltips[el].tooltip.remove()
|
||||
createdTooltips[el].arrow.remove()
|
||||
}
|
||||
},
|
||||
}
|
|
@ -1,3 +1,5 @@
|
|||
import {it, expect} from 'vitest'
|
||||
|
||||
import {calculateItemPosition} from './calculateItemPosition'
|
||||
|
||||
it('should calculate the task position', () => {
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import {describe, it, expect} from 'vitest'
|
||||
|
||||
import {findCheckboxesInText, getChecklistStatistics} from './checklistFromText'
|
||||
|
||||
describe('Find checklists in text', () => {
|
||||
|
@ -21,6 +23,7 @@ Here's some text in between
|
|||
expect(checkboxes[0]).toBe(0)
|
||||
expect(checkboxes[1]).toBe(18)
|
||||
expect(checkboxes[2]).toBe(69)
|
||||
expect(checkboxes[3]).toBe(90)
|
||||
})
|
||||
it('should find one checkbox with *', () => {
|
||||
const text: string = '* [ ] Lorem Ipsum'
|
||||
|
|
|
@ -40,7 +40,7 @@ export const findCheckboxesInText = (text: string): number[] => {
|
|||
return [
|
||||
...checkboxes.checked,
|
||||
...checkboxes.unchecked,
|
||||
].sort()
|
||||
].sort((a, b) => a < b ? -1 : 1)
|
||||
}
|
||||
|
||||
export const getChecklistStatistics = (text: string): CheckboxStatistics => {
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import {test, expect} from 'vitest'
|
||||
|
||||
import {colorFromHex} from './colorFromHex'
|
||||
|
||||
test('hex', () => {
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import {test, expect} from 'vitest'
|
||||
|
||||
import {colorIsDark} from './colorIsDark'
|
||||
|
||||
test('dark color', () => {
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import {describe, it, expect} from 'vitest'
|
||||
|
||||
import {filterLabelsByQuery} from './labels'
|
||||
import {createNewIndexer} from '../indexes'
|
||||
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
export const playSoundWhenDoneKey = 'playSoundWhenTaskDone'
|
||||
|
||||
export const playPop = () => {
|
||||
const enabled = localStorage.getItem(playSoundWhenDoneKey) === 'true' || localStorage.getItem(playSoundWhenDoneKey) === null
|
||||
if(!enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const popSound = new Audio('/audio/pop.mp3')
|
||||
popSound.play()
|
||||
}
|
13
src/helpers/playPop.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import popSoundFile from '@/assets/audio/pop.mp3'
|
||||
|
||||
export const playSoundWhenDoneKey = 'playSoundWhenTaskDone'
|
||||
|
||||
export function playPop() {
|
||||
const enabled = Boolean(localStorage.getItem(playSoundWhenDoneKey))
|
||||
if(!enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const popSound = new Audio(popSoundFile)
|
||||
popSound.play()
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
import {createRandomID} from '@/helpers/randomId'
|
||||
import {parseURL} from 'ufo'
|
||||
|
||||
interface Provider {
|
||||
name: string
|
||||
|
@ -7,7 +8,15 @@ interface Provider {
|
|||
clientId: string
|
||||
}
|
||||
|
||||
export const redirectToProvider = (provider: Provider, redirectUrl: string) => {
|
||||
export const redirectToProvider = (provider: Provider, redirectUrl: string = '') => {
|
||||
|
||||
// We're not using the redirect url provided by the server to allow redirects when using the electron app.
|
||||
// The implications are not quite clear yet hence the logic to pass in another redirect url still exists.
|
||||
if (redirectUrl === '') {
|
||||
const {host, protocol} = parseURL(window.location.href)
|
||||
redirectUrl = `${protocol}//${host}/auth/openid/`
|
||||
}
|
||||
|
||||
const state = createRandomID(24)
|
||||
localStorage.setItem('state', state)
|
||||
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import {test, expect} from 'vitest'
|
||||
|
||||
import {calculateDayInterval} from './calculateDayInterval'
|
||||
|
||||
const days = {
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import {test, expect} from 'vitest'
|
||||
|
||||
import {calculateNearestHours} from './calculateNearestHours'
|
||||
|
||||
test('5:00', () => {
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import {test, expect} from 'vitest'
|
||||
|
||||
import {createDateFromString} from './createDateFromString'
|
||||
|
||||
test('YYYY-MM-DD HH:MM', () => {
|
||||
|
|
|
@ -135,18 +135,18 @@ export const getDateFromText = (text: string, now: Date = new Date()) => {
|
|||
|
||||
if (result === null) {
|
||||
// 3. Try parsing the date as "27/01" or "01/27"
|
||||
const monthNumericRegex:RegExp = /([0-9][0-9]?\/[0-9][0-9]?)/ig
|
||||
const monthNumericRegex: RegExp = /([0-9][0-9]?\/[0-9][0-9]?)/ig
|
||||
results = monthNumericRegex.exec(text)
|
||||
|
||||
// Put the year before or after the date, depending on what works
|
||||
result = results === null ? null : `${now.getFullYear()}/${results[0]}`
|
||||
if(result === null) {
|
||||
if (result === null) {
|
||||
return {
|
||||
foundText,
|
||||
date: null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
foundText = results === null ? '' : results[0]
|
||||
if (result === null || isNaN(new Date(result).getTime())) {
|
||||
result = results === null ? null : `${results[0]}/${now.getFullYear()}`
|
||||
|
@ -280,7 +280,7 @@ const getDateFromWeekday = (text: string): dateFoundResult => {
|
|||
if (foundText.endsWith(' ')) {
|
||||
foundText = foundText.substr(0, foundText.length - 1)
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
foundText: foundText,
|
||||
date: date,
|
||||
|
@ -297,19 +297,20 @@ const getDayFromText = (text: string) => {
|
|||
}
|
||||
}
|
||||
|
||||
const date = new Date()
|
||||
const now = new Date()
|
||||
const date = new Date(now)
|
||||
const day = parseInt(results[0])
|
||||
date.setDate(day)
|
||||
|
||||
|
||||
// If the parsed day is the 31st but the next month only has 30 days, setting the day to 31 will "overflow" the
|
||||
// date to the next month, but the first.
|
||||
// This would look like a very weired bug. Now, to prevent that, we check if the day is the same as parsed after
|
||||
// setting it for the first time and set it again if it isn't - that would mean the month overflowed.
|
||||
if(day === 31 && date.getDate() !== day) {
|
||||
if (day === 31 && date.getDate() !== day) {
|
||||
date.setDate(day)
|
||||
}
|
||||
|
||||
if (date < new Date()) {
|
||||
if (date < now) {
|
||||
date.setMonth(date.getMonth() + 1)
|
||||
}
|
||||
|
||||
|
|