Merge branch 'main' into feature/vite

# Conflicts:
#	.drone.yml
#	Dockerfile
#	package.json
#	src/App.vue
#	src/components/home/contentAuth.vue
#	src/components/tasks/partials/editLabels.vue
#	src/main.ts
#	src/views/labels/NewLabel.vue
#	src/views/list/views/List.vue
#	src/views/tasks/TaskDetailView.vue
#	src/views/user/Login.vue
#	yarn.lock
This commit is contained in:
kolaente 2021-07-25 12:59:30 +02:00
commit 98ffdc62ff
Signed by: konrad
GPG Key ID: F40E70337AB24C9B
194 changed files with 10054 additions and 8369 deletions

View File

@ -37,7 +37,7 @@ steps:
- '.cache'
- name: dependencies
image: node:14
image: node:16
pull: true
environment:
YARN_CACHE_FOLDER: .cache/yarn/
@ -67,20 +67,42 @@ steps:
depends_on:
- dependencies
- name: build
image: node:14
- name: lint
image: node:16
pull: true
environment:
YARN_CACHE_FOLDER: .cache/yarn/
CYPRESS_CACHE_FOLDER: .cache/cypress/
commands:
- yarn run lint
- yarn run build
depends_on:
- dependencies
# Building in dev mode to avoid the service worker for testing
- name: build-dev
image: node:16
pull: true
environment:
YARN_CACHE_FOLDER: .cache/yarn/
CYPRESS_CACHE_FOLDER: .cache/cypress/
commands:
- yarn build --mode dev
depends_on:
- dependencies
- name: build-prod
image: node:16
pull: true
environment:
YARN_CACHE_FOLDER: .cache/yarn/
CYPRESS_CACHE_FOLDER: .cache/cypress/
commands:
- yarn build --dest dist-prod
depends_on:
- dependencies
- name: test-unit
image: node:14
image: node:16
pull: true
commands:
- yarn test:unit
@ -88,20 +110,21 @@ steps:
- dependencies
- name: test-frontend
image: cypress/browsers:node14.15.0-chrome86-ff82
image: cypress/browsers:node14.17.0-chrome91-ff89
pull: true
environment:
CYPRESS_API_URL: http://api:3456/api/v1
CYPRESS_TEST_SECRET: averyLongSecretToSe33dtheDB
YARN_CACHE_FOLDER: .cache/yarn/
CYPRESS_CACHE_FOLDER: .cache/cypress/
CYPRESS_DEFAULT_COMMAND_TIMEOUT: 10000
CYPRESS_DEFAULT_COMMAND_TIMEOUT: 60000
commands:
- sed -i 's/localhost/api/g' index.html
- yarn serve & npx wait-on http://localhost:5000
- sed -i 's/localhost/api/g' dist/index.html
- yarn serve:dist & npx wait-on http://localhost:8080
- yarn test:frontend --browser chrome
depends_on:
- build
- dependencies
- build-dev
- name: upload-test-results
image: plugins/s3:1
@ -163,7 +186,7 @@ steps:
- '.cache'
- name: build
image: node:14
image: node:16
pull: true
group: build-static
environment:
@ -238,7 +261,7 @@ steps:
- '.cache'
- name: build
image: node:14
image: node:16
pull: true
group: build-static
environment:
@ -506,23 +529,76 @@ trigger:
- "refs/tags/**"
depends_on:
- build
- release-version
- release-latest
- trigger-desktop-update
- docker-arm-release
- docker-amd64-release
- docker-manifest
steps:
- name: telegram
image: appleboy/drone-telegram:1-linux-amd64
- name: notify
image: plugins/matrix
settings:
token:
from_secret: TELEGRAM_TOKEN
to:
from_secret: TELEGRAM_TO
message: >
{{repo.owner}}/{{repo.name}}: \[{{build.status}}] Build {{build.number}}
{{commit.author}} pushed to {{commit.branch}} {{commit.sha}}: `{{commit.message}}`
Build started at {{datetime build.started "2006-Jan-02T15:04:05Z" "GMT+2"}} finished at {{datetime build.finished "2006-Jan-02T15:04:05Z" "GMT+2"}}.
homeserver: https://matrix.org
roomid: WqBDCxzghKcNflkErL:matrix.org
username:
from_secret: matrix_username
password:
from_secret: matrix_password
when:
status:
- success
- failure
---
kind: pipeline
name: update-translations
depends_on:
- build
trigger:
branch:
- main
event:
- push
steps:
# - name: download
# pull: always
# image: jonasfranz/crowdin
# settings:
# download: true
# export_dir: src/i18n/lang/
# ignore_branch: true
# project_identifier: vikunja
# environment:
# CROWDIN_KEY:
# from_secret: crowdin_key
#
# - name: push
# pull: always
# image: appleboy/drone-git-push
# settings:
# author_email: "frederik@vikunja.io"
# author_name: Frederick [Bot]
# branch: main
# commit: true
# commit_message: "[skip ci] Updated translations via Crowdin"
# remote: "git@kolaente.dev:9022/vikunja/frontend.git"
# environment:
# GIT_PUSH_SSH_KEY:
# from_secret: git_push_ssh_key
- name: upload
pull: always
image: jonasfranz/crowdin
settings:
files:
en.json: src/i18n/lang/en.json
ignore_branch: true
project_identifier: vikunja
environment:
CROWDIN_KEY:
from_secret: crowdin_key

22
.editorconfig Normal file
View File

@ -0,0 +1,22 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
indent_style = tab
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false
[*.vue]
indent_style = tab
[*.{yaml,yml}]
indent_style = space
indent_size = 2
[*.json]
indent_style = space
indent_size = 2

View File

@ -9,6 +9,271 @@ 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.17.0 - 2021-05-14]
### Added
* Add a "done" option to kanban buckets (#440)
* Add arm64 builds
* Add button to un-archive a namespace
* Add clearer call to action when no lists are available yet
* Add code highlighting for rendered user input text
* Add github sponsoring
* Add link share password authentication (#466)
* Add names to link shares when creating them (#456)
* Add notifications overview (#414)
* Add option to remove a list background
* Add overdue task reminder notification setting
* Add repeat after one-click intervals
* Add repeat mode setting for tasks
* Add security information to readme
* Add separate manifest template for latest
* Add settings for user search (#458)
* Add success message when modifying buckets
* Add "today" task filter
* Add view image modal for image attachments
* Pagingation for tasks in kanban buckets (#419)
* Persist show archived state
* Play a sound when marking a task as done
### Fixed
* Fix adding a label twice when selecting it and pressing enter
* Fix attachment hover
* Fix attachment not being added if the task was not a kanban task
* Fix attachments being added mutliple times
* Fix bucket test fixture when moving tasks between lists test
* Fix button height
* Fix caldav url not containing the api url if the frontend and api are on the same domain
* Fix checking for undefined behaviour when viewing a task
* Fix closing popups when clicking outside of them (#378)
* Fix "create new list" and import buttons on home page
* Fix create new list test
* Fix create new namespace test
* Fix current password id being available twice
* Fix datepicker popup not fully aligned on mobile
* Fix defer due date popup
* Fix delete buttons in forms
* Fix deleting task relations
* Fix editor buttons alignment
* Fix editor placeholder color
* Fix edit task description test
* Fix empty call to actions
* Fix filter container positioning
* Fix filter container positioning in link shares
* Fix flaky test
* Fix flaky test part 2
* Fix font caching in docker image
* Fix formatting invalid dates
* Fix getting back to the default task view when navigating back from a task modal
* Fix getting back to the kanban board after closing a task popup
* Fix iterating over check boxes and attachment images in the editor rendering
* Fix kanban board slightly scrolling
* Fix kanban height on mobile
* Fix kanban infinite scrolling on chrome
* Fix label spacing
* Fix labels randomly changing color after saving
* Fix list counter in the navigation counting archived lists
* Fix list layout when the list has no background for link shares
* Fix login or register not working when pressing enter
* Fix logout test
* Fix map_hash_max_size for docker images
* Fix misspelling (#415)
* Fix multiselect on mobile
* Fix namespace actions alignment in the menu
* Fix no color selected in the color picket
* Fix notification parsing for team memeber added
* Fix notification styling
* Fix pasting text into task comments or task descriptions
* Fix priority label width in task list
* Fix release pipeline steps
* Fix reloading the task list after changing a filter
* Fix removing dates from a filter
* Fix resetting colors from the color picker
* Fix setting a default color when none was saved
* Fix setting dates in safari
* Fix showing and hiding lists in the menu
* Fix sorting task by due date on task overview
* Fix spacing for lists with no rights to add new tasks
* Fix table names in test fixtures
* Fix task detail view spacings
* Fix task filter toggle button if the list has a background
* Fix task icon size
* Fix task icons on kanban if there were multiple different ones
* Fix task id spacing
* Fix task pagination
* Fix task relation search test
* Fix tasks moving infinitely in gantt chart (#493)
* Fix tasks not disappearing from the kanban board when moving them between lists
* Fix task title heading ux
* Fix team edit test
* Fix team edit test (#382)
* Fix team name in team member added notification
* Fix test
* Fix tests after changing button classes
* Fix text color
* Fix transition between pages
* Fix undo when marking a task as done
* Fix waiting for dependency step when building
* Fix yarn.lock
* Only check for token renew when the user is authenticated
* Only show the llama background for unauthenticated users
* Only use dark shadows for buttons
* Prevent setting a bucket limit < 0
### Changed
* Automatically go back after saving from a popup
* Better wording of new namespace and list buttons
* Bring up the keyboard shortcuts when pressing ?
* Change bucket background color
* Change main branch to main
* Cleanup font caching and requesting
* Don't hide all lists of namespaces when loosing network connectivity
* Don't save the editor text when it is loaded
* Don't show the list color in the list view
* Don't show the "new bucket" button when buckets are still loading
* Focus task detail elements when they show up
* Hide new related tasks form when related tasks exist
* Hide task elements while the task is loading
* Hide the bucket limit input when clicked away
* Hide the login form if no api url is configured
* Improve consistency of the layout (#386)
* Inline mutliselect search input for multiple elements
* Make filter buttons look better on mobile
* Make full task in task list clickable
* Make hidden lists in the menu more compact
* Make message undo button secondary
* Make release steps on master depend on building/testing
* Make sure all arm64 build steps run in parallel
* Make sure all empty pages have a call to action
* Make sure all popups & dropdowns are animated
* Make sure attachements are only added once to the list after uploading + Make sure the attachment list shows up every time after adding an attachment
* Make sure no cta's are visible while the page is loading
* Make sure the loading spinner is always visible at the end of the page
* Make the button shadow lighter
* Make the icons in the menu light grey
* Make the input full width by default
* Make the scrollbars a lighter grey (#394)
* Make the "upload attachment" button less obvious
* Move all content to cards (#387)
* Move all create views to better looking popups (#383)
* Move buttons to separate component (#380)
* Move list edit/namespace to separate pages and in a menu (#397)
* Move the search input to filters
* Open links to external sites in a new window
* Rearrange task actions
* Reduce quick task edit fields
* Remove the shadow at the "+" button for related tasks
* Rename .noshadow to .has-no-shadow
* Rework attachments list to look great everywhere
* Set user info from api instead of only relying on the info encoded in the jwt token
* Show call to action for task description if there is none
* Show label colors when searching for labels
* Show list if the search result for a task belongs to a different list
* Show "powered by Vikunja" in link shares
* Subscriptions and notifications for namespaces, tasks and lists (#410)
* Switch node-sass to sass
* Switch telegram notifications to matrix
* Update ShowTasks view to sort tasks by ascending (#406)
* Use a lighter grey for comment created dates
* Use buttons more consistently
* Use mousedown instead of click event to close modals
* Work around auto tag for main branch
### Dependency Updates
* Pin dependency browserslist to 4.16.6 (#500)
* Pin dependency highlight.js to 10.5.0 (#371)
* Update browserlist and caniuse-lite db
* Update dependency bulma to v0.9.2 (#392)
* Update dependency cypress-file-upload to v5.0.3 (#437)
* Update dependency cypress-file-upload to v5.0.4 (#455)
* Update dependency cypress-file-upload to v5.0.5 (#461)
* Update dependency cypress-file-upload to v5.0.6 (#481)
* Update dependency cypress-file-upload to v5.0.7 (#498)
* Update dependency cypress-file-upload to v5 (#379)
* Update dependency cypress to v6.3.0 (#381)
* Update dependency cypress to v6.4.0 (#399)
* Update dependency cypress to v6.5.0 (#412)
* Update dependency cypress to v6.6.0 (#421)
* Update dependency cypress to v6.7.1 (#430)
* Update dependency cypress to v6.8.0 (#435)
* Update dependency cypress to v6.9.1 (#452)
* Update dependency cypress to v7.1.0 (#472)
* Update dependency cypress to v7.2.0 (#494)
* Update dependency cypress to v7 (#453)
* Update dependency date-fns to v2.17.0 (#403)
* Update dependency date-fns to v2.18.0 (#420)
* Update dependency date-fns to v2.19.0 (#423)
* Update dependency date-fns to v2.20.0 (#459)
* Update dependency date-fns to v2.20.1 (#463)
* Update dependency date-fns to v2.20.2 (#470)
* Update dependency date-fns to v2.20.3 (#473)
* Update dependency date-fns to v2.21.0 (#477)
* Update dependency date-fns to v2.21.1 (#482)
* Update dependency date-fns to v2.21.2 (#499)
* Update dependency date-fns to v2.21.3 (#505)
* Update dependency dompurify to v2.2.7 (#426)
* Update dependency dompurify to v2.2.8 (#496)
* Update dependency eslint-plugin-vue to v7.5.0 (#384)
* Update dependency eslint-plugin-vue to v7.6.0 (#411)
* Update dependency eslint-plugin-vue to v7.7.0 (#422)
* Update dependency eslint-plugin-vue to v7.8.0 (#438)
* Update dependency eslint-plugin-vue to v7.9.0 (#469)
* Update dependency eslint to v7.18.0 (#376)
* Update dependency eslint to v7.19.0 (#398)
* Update dependency eslint to v7.20.0 (#409)
* Update dependency eslint to v7.21.0 (#418)
* Update dependency eslint to v7.22.0 (#427)
* Update dependency eslint to v7.23.0 (#443)
* Update dependency eslint to v7.24.0 (#464)
* Update dependency eslint to v7.25.0 (#490)
* Update dependency eslint to v7.26.0 (#504)
* Update dependency faker to v5.2.0 (#389)
* Update dependency faker to v5.3.1 (#400)
* Update dependency faker to v5.4.0 (#408)
* Update dependency faker to v5.5.0 (#442)
* Update dependency faker to v5.5.1 (#444)
* Update dependency faker to v5.5.2 (#450)
* Update dependency faker to v5.5.3 (#462)
* Update dependency highlight.js to v10.6.0 (#407)
* Update dependency highlight.js to v10.7.1 (#436)
* Update dependency highlight.js to v10.7.2 (#451)
* Update dependency lodash to v4.17.21 (#413)
* Update dependency marked to v1.2.8 (#391)
* Update dependency marked to v1.2.9 (#401)
* Update dependency marked to v2.0.1 (#417)
* Update dependency marked to v2.0.2 (#465)
* Update dependency marked to v2.0.3 (#468)
* Update dependency marked to v2 (#405)
* Update dependency sass-loader to v10.1.1 (#372)
* Update dependency sass-loader to v10.2.0 (#506)
* Update dependency sass to v1.32.13 (#509)
* Update dependency vue-advanced-cropper to v1.3.0 (#404)
* Update dependency vue-advanced-cropper to v1.3.1 (#424)
* Update dependency vue-advanced-cropper to v1.3.2 (#425)
* Update dependency vue-advanced-cropper to v1.3.3 (#439)
* Update dependency vue-advanced-cropper to v1.3.4 (#441)
* Update dependency vue-advanced-cropper to v1 (#393)
* Update dependency vue-advanced-cropper to v1.4.0 (#454)
* Update dependency vue-advanced-cropper to v1.4.1 (#460)
* Update dependency vue-advanced-cropper to v1.5.0 (#471)
* Update dependency vue-advanced-cropper to v1.5.1 (#495)
* Update dependency vue-advanced-cropper to v1.5.2 (#497)
* Update dependency vue-drag-resize to v1.5.1 (#457)
* Update dependency vue-drag-resize to v1.5.2 (#501)
* Update dependency vue-drag-resize to v1.5.4 (#502)
* Update dependency vue-easymde to v1.4.0 (#449)
* Update dependency vue-router to v3.5.0 (#388)
* Update dependency wait-on to v5.3.0 (#434)
* Update Font Awesome (#374)
* Update Font Awesome (#432)
* Update vue monorepo (#390)
* Update vue monorepo to v4.5.11 (#385)
* Update vue monorepo to v4.5.12 (#433)
* Update vue monorepo to v4.5.13 (#503)
## [0.16.0 - 2021-01-10]
### Added

View File

@ -1,5 +1,5 @@
# Stage 1: Build application
FROM node:14 AS compile-image
FROM node:16 AS compile-image
WORKDIR /build

View File

@ -4,32 +4,41 @@
[![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.16.0-brightgreen.svg)](https://dl.vikunja.io)
[![Download](https://img.shields.io/badge/download-v0.17.0-brightgreen.svg)](https://dl.vikunja.io)
[![Translation](https://hosted.weblate.org/widgets/vikunja/-/frontend/svg-badge.svg)](https://hosted.weblate.org/engage/vikunja/)
This is the web frontend for Vikunja, written in Vue.js.
Take a look at [our roadmap](https://my.vikunja.cloud/share/UrdhKPqumxDXUbYpEGJLSIyNTwAnbBzVlwdDpRbv/auth) (hosted on Vikunja!) for a list of things we're currently working on!
## Security Reports
If you find any security-related issues you don't want to disclose publicly, please use [the contact information on our website](https://vikunja.io/contact/#security).
## Docker
There is a [docker image available](https://hub.docker.com/r/vikunja/api) with support for http/2 and aggressive caching enabled.
## Project setup
```
```shell
yarn install
```
### Compiles and hot-reloads for development
```
```shell
yarn run serve
```
### Compiles and minifies for production
```
```shell
yarn run build
```
### Lints and fixes files
```
```shell
yarn run lint
```

View File

@ -1,5 +1,5 @@
module.exports = {
presets: [
'@vue/app'
]
'@vue/app',
],
}

View File

@ -4,5 +4,8 @@
"API_URL": "http://localhost:3456/api/v1",
"TEST_SECRET": "testingS3cr3et"
},
"video": false
"video": false,
"retries": {
"runMode": 2
}
}

View File

@ -2,7 +2,7 @@ import {Factory} from '../support/factory'
import {formatISO} from 'date-fns'
export class LabelTaskFactory extends Factory {
static table = 'label_task'
static table = 'label_tasks'
static factory() {
const now = new Date()

View File

@ -3,7 +3,7 @@ import {formatISO} from "date-fns"
import faker from 'faker'
export class LinkShareFactory extends Factory {
static table = 'link_sharing'
static table = 'link_shares'
static factory() {
const now = new Date()

View File

@ -3,7 +3,7 @@ import {formatISO} from "date-fns"
import faker from 'faker'
export class ListFactory extends Factory {
static table = 'list'
static table = 'lists'
static factory() {
const now = new Date()

View File

@ -14,7 +14,6 @@ export class TaskFactory extends Factory {
done: false,
list_id: 1,
created_by_id: 1,
is_favorite: false,
index: '{increment}',
created: formatISO(now),
updated: formatISO(now)

View File

@ -13,7 +13,7 @@ export class UserFactory extends Factory {
id: '{increment}',
username: faker.lorem.word(10) + faker.random.uuid(),
password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.', // 1234
is_active: true,
status: 0,
created: formatISO(now),
updated: formatISO(now)
}

View File

@ -2,7 +2,7 @@ import {Factory} from '../support/factory'
import {formatISO} from "date-fns"
export class UserListFactory extends Factory {
static table = 'users_list'
static table = 'users_lists'
static factory() {
const now = new Date()

View File

@ -10,10 +10,12 @@ import {BucketFactory} from '../../factories/bucket'
import '../../support/authenticateUser'
describe('Lists', () => {
let lists
beforeEach(() => {
UserFactory.create(1)
NamespaceFactory.create(1)
const lists = ListFactory.create(1, {
lists = ListFactory.create(1, {
title: 'First List'
})
TaskFactory.truncate()
@ -54,6 +56,64 @@ describe('Lists', () => {
.should('contain', '/lists/1/kanban')
})
it('Should rename the list in all places', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
})
const newListName = 'New list name'
cy.visit('/lists/1')
cy.get('.list-title h1')
.should('contain', 'First List')
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list li:first-child .dropdown .dropdown-trigger')
.click()
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list li:first-child .dropdown .dropdown-content')
.contains('Edit')
.click()
cy.get('#listtext')
.type(`{selectall}${newListName}`)
cy.get('footer.modal-card-foot .button')
.contains('Save')
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.list-title h1')
.should('contain', newListName)
.should('not.contain', lists[0].title)
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list li:first-child')
.should('contain', newListName)
.should('not.contain', lists[0].title)
cy.visit('/')
cy.get('.card-content .tasks')
.should('contain', newListName)
.should('not.contain', lists[0].title)
})
it('Should remove a list', () => {
cy.visit(`/lists/${lists[0].id}`)
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list li:first-child .dropdown .dropdown-trigger')
.click()
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list li:first-child .dropdown .dropdown-content')
.contains('Delete')
.click()
cy.url()
.should('contain', '/settings/delete')
cy.get('.modal-mask .modal-container .modal-content .actions a.button')
.contains('Do it')
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.namespace-container .menu.namespaces-lists .more-container .menu-list')
.should('not.contain', lists[0].title)
cy.location('pathname')
.should('equal', '/')
})
describe('List View', () => {
it('Should be an empty list', () => {
cy.visit('/lists/1')
@ -202,11 +262,15 @@ describe('Lists', () => {
it('Shows tasks from the current and next month', () => {
const now = new Date()
const nextMonth = now
nextMonth.setDate(1)
nextMonth.setMonth(now.getMonth() + 1)
cy.visit('/lists/1/gantt')
cy.get('.gantt-chart-container .gantt-chart .months')
.should('contain', format(now, 'MMMM'))
.should('contain', format(now.setMonth(now.getMonth() + 1), 'MMMM'))
.should('contain', format(nextMonth, 'MMMM'))
})
it('Shows tasks with dates', () => {
@ -324,7 +388,7 @@ describe('Lists', () => {
.first()
.click()
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item')
.contains('Limit: Not set')
.contains('Limit: Not Set')
.click()
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item .field input.input')
.first()
@ -401,7 +465,7 @@ describe('Lists', () => {
})
cy.visit('/lists/1/kanban')
cy.getAttached('.kanban .bucket .tasks .task')
cy.getSettled('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
.should('be.visible')
.click()
@ -412,6 +476,9 @@ describe('Lists', () => {
it('Should remove a task from the kanban board when moving it to another list', () => {
const lists = ListFactory.create(2)
BucketFactory.create(2, {
list_id: '{increment}',
})
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
@ -420,7 +487,7 @@ describe('Lists', () => {
const task = tasks[0]
cy.visit('/lists/1/kanban')
cy.getAttached('.kanban .bucket .tasks .task')
cy.getSettled('.kanban .bucket .tasks .task')
.contains(task.title)
.should('be.visible')
.click()
@ -444,4 +511,34 @@ describe('Lists', () => {
.should('not.contain', task.title)
})
})
describe('List history', () => {
it('should show a list history on the home page', () => {
const lists = ListFactory.create(6)
cy.visit('/')
cy.get('h3')
.contains('Last viewed')
.should('not.exist')
cy.visit(`/lists/${lists[0].id}`)
cy.visit(`/lists/${lists[1].id}`)
cy.visit(`/lists/${lists[2].id}`)
cy.visit(`/lists/${lists[3].id}`)
cy.visit(`/lists/${lists[4].id}`)
cy.visit(`/lists/${lists[5].id}`)
cy.visit('/')
cy.get('h3')
.contains('Last viewed')
.should('exist')
cy.get('.list-cards-wrapper-2-rows')
.should('not.contain', lists[0].title)
.should('contain', lists[1].title)
.should('contain', lists[2].title)
.should('contain', lists[3].title)
.should('contain', lists[4].title)
.should('contain', lists[5].title)
})
})
})

View File

@ -20,20 +20,126 @@ describe('Namepaces', () => {
})
it('Should create a new Namespace', () => {
const newNamespaceTitle = 'New Namespace'
cy.visit('/namespaces')
cy.get('a.button')
.contains('Create namespace')
.contains('Create a new namespace')
.click()
cy.url()
.should('contain', '/namespaces/new')
cy.get('.card-header-title')
.should('contain', 'Create a new namespace')
cy.get('input.input')
.type('New Namespace')
.type(newNamespaceTitle)
cy.get('.button')
.contains('Create')
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.namespace-container')
.should('contain', newNamespaceTitle)
cy.url()
.should('contain', '/namespaces')
})
it('Should rename the namespace all places', () => {
const newNamespaces = NamespaceFactory.create(5)
const newNamespaceName = 'New namespace name'
cy.visit('/namespaces')
cy.get(`.namespace-container .menu.namespaces-lists .namespace-title:contains(${newNamespaces[0].title}) .dropdown .dropdown-trigger`)
.click()
cy.get('.namespace-container .menu.namespaces-lists .namespace-title .dropdown .dropdown-content')
.contains('Edit')
.click()
cy.url()
.should('contain', '/settings/edit')
cy.get('#namespacetext')
.invoke('val')
.should('equal', newNamespaces[0].title) // wait until the namespace data is loaded
cy.get('#namespacetext')
.type(`{selectall}${newNamespaceName}`)
cy.get('footer.modal-card-foot .button')
.contains('Save')
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.namespace-container .menu.namespaces-lists')
.should('contain', newNamespaceName)
.should('not.contain', newNamespaces[0].title)
cy.get('.content.namespaces-list')
.should('contain', newNamespaceName)
.should('not.contain', newNamespaces[0].title)
})
it('Should remove a namespace when deleting it', () => {
const newNamespaces = NamespaceFactory.create(5)
cy.visit('/')
cy.get(`.namespace-container .menu.namespaces-lists .namespace-title:contains(${newNamespaces[0].title}) .dropdown .dropdown-trigger`)
.click()
cy.get('.namespace-container .menu.namespaces-lists .namespace-title .dropdown .dropdown-content')
.contains('Delete')
.click()
cy.url()
.should('contain', '/settings/delete')
cy.get('.modal-mask .modal-container .modal-content .actions a.button')
.contains('Do it')
.click()
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.namespace-container .menu.namespaces-lists')
.should('not.contain', newNamespaces[0].title)
})
it('Should not show archived lists & namespaces if the filter is not checked', () => {
const n = NamespaceFactory.create(1, {
id: 2,
is_archived: true,
}, false)
ListFactory.create(1, {
id: 2,
namespace_id: n[0].id,
}, false)
ListFactory.create(1, {
id: 3,
is_archived: true,
}, false)
// Initial
cy.visit('/namespaces')
cy.get('.namespaces-list .namespace')
.should('not.contain', 'Archived')
// Show archived
cy.get('.namespaces-list .fancycheckbox.show-archived-check label.check span')
.should('be.visible')
.click()
cy.get('.namespaces-list .fancycheckbox.show-archived-check input')
.should('be.checked')
cy.get('.namespaces-list .namespace')
.should('contain', 'Archived')
// Don't show archived
cy.get('.namespaces-list .fancycheckbox.show-archived-check label.check span')
.should('be.visible')
.click()
cy.get('.namespaces-list .fancycheckbox.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')
.should('not.be.checked')
cy.get('.namespaces-list .namespace')
.should('not.contain', 'Archived')
})
})

View File

@ -0,0 +1,35 @@
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')
})
})

View File

@ -11,7 +11,7 @@ describe('Team', () => {
const newTeamName = 'New Team'
cy.get('a.button')
.contains('New Team')
.contains('Create a new team')
.click()
cy.url()
.should('contain', '/teams/new')
@ -113,7 +113,7 @@ describe('Team', () => {
cy.get('.card')
.contains('Team Members')
.get('.card-content .button')
.contains('Add To Team')
.contains('Add to team')
.click()
cy.get('table.table td')

View File

@ -11,6 +11,7 @@ import '../../support/authenticateUser'
import {TaskAssigneeFactory} from '../../factories/task_assignee'
import {LabelFactory} from '../../factories/labels'
import {LabelTaskFactory} from '../../factories/label_task'
import {BucketFactory} from '../../factories/bucket'
describe('Task', () => {
let namespaces
@ -26,7 +27,7 @@ describe('Task', () => {
it('Should be created new', () => {
cy.visit('/lists/1/list')
cy.get('input.input[placeholder="Add a new task..."')
cy.get('input.input[placeholder="Add a new task"')
.type('New Task')
cy.get('.button')
.contains('Add')
@ -42,7 +43,7 @@ describe('Task', () => {
cy.visit('/lists/1/list')
cy.get('.list-is-empty-notice')
.should('not.exist')
cy.get('input.input[placeholder="Add a new task..."')
cy.get('input.input[placeholder="Add a new task"')
.type('New Task')
cy.get('.button')
.contains('Add')
@ -112,9 +113,10 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .heading .is-done')
.should('exist')
.should('be.visible')
.should('contain', 'Done')
cy.get('.task-view .action-buttons p.created')
.should('be.visible')
.should('contain', 'Done')
})
@ -182,9 +184,11 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .comments .media.comment .editor .vue-easymde .EasyMDEContainer .CodeMirror-scroll')
.should('be.visible')
.type('{selectall}New Comment')
cy.get('.task-view .comments .media.comment .button:not([disabled])')
.contains('Comment')
.should('be.visible')
.click()
cy.get('.task-view .comments .media.comment .editor')
@ -195,6 +199,9 @@ describe('Task', () => {
it('Can move a task to another list', () => {
const lists = ListFactory.create(2)
BucketFactory.create(2, {
list_id: '{increment}'
})
const tasks = TaskFactory.create(1, {
id: 1,
list_id: lists[0].id,
@ -228,6 +235,7 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.should('be.visible')
.contains('Delete task')
.click()
cy.get('.modal-mask .modal-container .modal-content .header')
@ -310,6 +318,7 @@ describe('Task', () => {
cy.get('.task-view .action-buttons .button')
.contains('Add labels')
.should('be.visible')
.click()
cy.get('.task-view .details.labels-list .multiselect input')
.type(newLabelText)
@ -365,6 +374,7 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .details.labels-list .multiselect .input-wrapper')
.should('be.visible')
.should('contain', labels[0].title)
cy.get('.task-view .details.labels-list .multiselect .input-wrapper')
.children()

View File

@ -34,6 +34,7 @@ context('Login', () => {
cy.get('input[id=password]').type(fixture.password)
cy.get('.button').contains('Login').click()
cy.url().should('include', '/')
cy.clock(1625656161057) // 13:00
cy.get('h2').should('contain', `Hi ${fixture.username}!`)
})

View File

@ -28,6 +28,7 @@ context('Registration', () => {
cy.get('#password2').type(fixture.password)
cy.get('#register-submit').click()
cy.url().should('include', '/')
cy.clock(1625656161057) // 13:00
cy.get('h2').should('contain', `Hi ${fixture.username}!`)
})

View File

@ -36,7 +36,6 @@ describe('User Settings', () => {
.contains('Save')
.click()
cy.wait(3000) // Wait for the request to finish
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.navbar .user .username')

View File

@ -1,17 +1,33 @@
/**
* getAttached(selector)
* getAttached(selectorFn)
* Recursively gets an element, returning only after it's determined to be attached to the DOM for good.
*
* Waits until the selector finds an attached element, then yields it (wrapped).
* selectorFn, if provided, is passed $(document). Don't use cy methods inside selectorFn.
*
* Source: https://github.com/cypress-io/cypress/issues/5743#issuecomment-650421731
* Source: https://github.com/cypress-io/cypress/issues/7306#issuecomment-850621378
*/
Cypress.Commands.add('getAttached', selector => {
const getElement = typeof selector === 'function' ? selector : $d => $d.find(selector);
let $el = null;
return cy.document().should($d => {
$el = getElement(Cypress.$($d));
expect(Cypress.dom.isDetached($el)).to.be.false;
}).then(() => cy.wrap($el));
});
Cypress.Commands.add('getSettled', (selector, opts = {}) => {
const retries = opts.retries || 3
const delay = opts.delay || 100
const isAttached = (resolve, count = 0) => {
const el = Cypress.$(selector)
// is element attached to the DOM?
count = Cypress.dom.isAttached(el) ? count + 1 : 0
// hit our base case, return the element
if (count >= retries) {
return resolve(el)
}
// retry after a bit of a delay
setTimeout(() => isAttached(resolve, count), delay)
}
// wrap, so we can chain cypress commands off the result
return cy.wrap(null).then(() => {
return new Cypress.Promise((resolve) => {
return isAttached(resolve, 0)
}).then((el) => {
return cy.wrap(el)
})
})
})

View File

@ -21,7 +21,7 @@ export class Factory {
* @param override
* @returns {[]}
*/
static create(count = 1, override = {}) {
static create(count = 1, override = {}, truncate = true) {
const data = []
for (let i = 1; i <= count; i++) {
@ -38,7 +38,7 @@ export class Factory {
data.push(entry)
}
seed(this.table, data)
seed(this.table, data, truncate)
return data
}

View File

@ -8,14 +8,14 @@
* @param table
* @param data
*/
export function seed(table, data = {}) {
if(data === null) {
export function seed(table, data = {}, truncate = true) {
if (data === null) {
data = []
}
cy.request({
method: 'PATCH',
url: `${Cypress.env('API_URL')}/test/${table}`,
url: `${Cypress.env('API_URL')}/test/${table}?truncate=${truncate ? 'true' : 'false'}`,
headers: {
'Authorization': Cypress.env('TEST_SECRET'),
},

View File

@ -13,56 +13,65 @@
"test:frontend": "cypress run"
},
"dependencies": {
"bulma": "0.9.2",
"browserslist": "4.16.6",
"bulma": "0.9.3",
"camel-case": "4.1.2",
"copy-to-clipboard": "3.3.1",
"date-fns": "2.19.0",
"dompurify": "2.2.6",
"highlight.js": "10.6.0",
"date-fns": "2.23.0",
"dompurify": "2.3.0",
"highlight.js": "11.1.0",
"lodash": "4.17.21",
"marked": "2.0.1",
"marked": "2.1.3",
"register-service-worker": "1.7.2",
"sass": "1.36.0",
"snake-case": "3.0.4",
"verte": "0.0.12",
"vue": "2.6.12",
"vue-advanced-cropper": "1.3.1",
"vue-drag-resize": "1.4.2",
"vue-easymde": "1.3.2",
"vue": "2.6.14",
"vue-advanced-cropper": "1.7.0",
"vue-drag-resize": "1.5.4",
"vue-easymde": "1.4.0",
"vue-i18n": "8.25.0",
"vue-shortkey": "3.1.7",
"vue-smooth-dnd": "0.8.1",
"vuex": "3.6.2",
"workbox-precaching": "^6.1.1"
},
"devDependencies": {
"@fortawesome/fontawesome-svg-core": "1.2.34",
"@fortawesome/free-regular-svg-icons": "5.15.2",
"@fortawesome/free-solid-svg-icons": "5.15.2",
"@fortawesome/fontawesome-svg-core": "1.2.35",
"@fortawesome/free-regular-svg-icons": "5.15.3",
"@fortawesome/free-solid-svg-icons": "5.15.3",
"@fortawesome/vue-fontawesome": "2.0.2",
"@vue/cli": "4.5.11",
"@vue/cli-plugin-babel": "4.5.11",
"@vue/cli-plugin-eslint": "4.5.11",
"@vue/cli-plugin-pwa": "4.5.11",
"@vue/cli-service": "4.5.11",
"@types/jest": "26.0.24",
"@typescript-eslint/eslint-plugin": "4.28.4",
"@typescript-eslint/parser": "4.28.4",
"@vue/cli": "4.5.13",
"@vue/cli-plugin-babel": "4.5.13",
"@vue/cli-plugin-eslint": "4.5.13",
"@vue/cli-plugin-pwa": "4.5.13",
"@vue/cli-plugin-typescript": "4.5.13",
"@vue/cli-service": "4.5.13",
"@vue/eslint-config-typescript": "7.0.0",
"axios": "0.21.1",
"babel-eslint": "10.1.0",
"cypress": "6.6.0",
"cypress-file-upload": "5.0.2",
"cypress": "8.0.0",
"cypress-file-upload": "5.0.8",
"esbuild": "0.8.51",
"eslint": "7.21.0",
"eslint-plugin-vue": "7.7.0",
"faker": "5.4.0",
"jest": "26.6.3",
"node-sass": "5.0.0",
"eslint": "7.31.0",
"eslint-plugin-vue": "7.14.0",
"faker": "5.5.3",
"jest": "27.0.6",
"sass-loader": "10.2.0",
"ts-jest": "27.0.4",
"sass": "1.32.8",
"sass-loader": "10.1.1",
"typescript": "4.3.5",
"vite": "2.0.2",
"vite-plugin-pwa": "0.5.6",
"vite-plugin-vue2": "1.2.1",
"vue-flatpickr-component": "8.1.6",
"vue-flatpickr-component": "8.1.7",
"vue-notification": "1.3.20",
"vue-router": "3.5.1",
"vue-template-compiler": "2.6.12",
"wait-on": "5.2.1",
"vue-router": "3.5.2",
"vue-template-compiler": "2.6.14",
"wait-on": "6.0.0",
"workbox-cli": "6.1.1"
},
"eslintConfig": {
@ -72,11 +81,29 @@
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
"eslint:recommended",
"@vue/typescript"
],
"rules": {},
"rules": {
"vue/html-quotes": [
"error",
"double"
],
"quotes": [
"error",
"single"
],
"comma-dangle": [
"error",
"always-multiline"
],
"semi": [
"error",
"never"
]
},
"parserOptions": {
"parser": "babel-eslint"
"parser": "@typescript-eslint/parser"
},
"ignorePatterns": [
"*.test.js",
@ -91,12 +118,27 @@
"browserslist": [
"> 1%",
"last 2 versions",
"not ie < 11"
"not ie > 0",
"not dead",
"Firefox ESR"
],
"license": "AGPL-3.0-or-later",
"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"
}

8
ping-weblate.sh Executable file
View File

@ -0,0 +1,8 @@
#!/bin/sh
set -e
# Shell script because yaml doesn't understand the header is a string literal and not a yaml symbol
curl -d operation=pull -H "Authorization: Token $WEBLATE_TOKEN" https://hosted.weblate.org/api/projects/vikunja/repository/
curl -d operation=push -H "Authorization: Token $WEBLATE_TOKEN" https://hosted.weblate.org/api/projects/vikunja/repository/

View File

@ -1,6 +1,6 @@
<template>
<div>
<template v-if="online">
<div :class="{'is-hidden': !online}">
<!-- This is a workaround to get the sw to "see" the to-be-cached version of the offline background image -->
<div class="offline" style="height: 0;width: 0;"></div>
<top-navigation v-if="authUser"/>
@ -8,8 +8,8 @@
<content-link-share v-else-if="authLinkShare"/>
<content-no-auth v-else/>
<notification/>
</template>
<div class="app offline" v-else>
</div>
<div class="app offline" v-if="!online">
<div class="offline-message">
<h1>You are offline.</h1>
<p>Please check your network connection and try again.</p>
@ -34,6 +34,7 @@ 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/setup'
export default {
name: 'app',
@ -53,6 +54,8 @@ export default {
beforeCreate() {
this.$store.dispatch('config/update')
this.$store.dispatch('auth/checkAuth')
setLanguage()
},
created() {
// Make sure to always load the home route when running with electron

View File

@ -19,14 +19,21 @@
class="app-content"
>
<a @click="$store.commit('menuActive', false)" class="mobile-overlay" v-if="menuActive"></a>
<quick-actions/>
<router-view/>
<transition name="modal">
<router-view name="popup"/>
</transition>
<a @click="$store.commit('keyboardShortcutsActive', true)" class="keyboard-shortcuts-button">
<a
class="keyboard-shortcuts-button"
@click="showKeyboardShortcuts()"
@shortkey="showKeyboardShortcuts()"
v-shortkey="['?']"
>
<icon icon="keyboard"/>
</a>
</div>
@ -36,17 +43,19 @@
<script>
import {mapState} from 'vuex'
import {CURRENT_LIST, MENU_ACTIVE} from '@/store/mutation-types'
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'
export default {
name: 'contentAuth',
components: {Navigation},
components: {QuickActions, Navigation},
watch: {
'$route': 'doStuffAfterRoute',
},
created() {
this.renewTokenOnFocus()
this.loadLabels()
},
computed: mapState({
namespaces(state) {
@ -56,6 +65,7 @@ export default {
background: 'background',
menuActive: MENU_ACTIVE,
userInfo: state => state.auth.info,
authenticated: state => state.auth.authenticated,
}),
methods: {
doStuffAfterRoute() {
@ -77,7 +87,7 @@ export default {
this.$route.name === 'user.settings' ||
this.$route.name === 'namespaces.index'
) {
this.$store.commit(CURRENT_LIST, {})
this.$store.commit(CURRENT_LIST, null)
}
},
renewTokenOnFocus() {
@ -88,7 +98,11 @@ export default {
// Check if the token is still valid if the window gets focus again to maybe renew it
window.addEventListener('focus', () => {
const expiresIn = this.userInfo.exp - +new Date() / 1000
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
@ -110,6 +124,15 @@ export default {
this.$store.commit(MENU_ACTIVE, false)
}
},
showKeyboardShortcuts() {
this.$store.commit(KEYBOARD_SHORTCUTS_ACTIVE, true)
},
loadLabels() {
this.$store.dispatch('labels/loadAllLabels')
.catch(e => {
this.error(e)
})
},
},
}
</script>

View File

@ -1,6 +1,6 @@
<template>
<div
:class="{'has-background': background}"
:class="[background ? 'has-background' : '', $route.name+'-view']"
:style="{'background-image': `url(${background})`}"
class="link-share-container"
>
@ -10,20 +10,12 @@
<h1
:style="{ 'opacity': currentList.title === '' ? '0': '1' }"
class="title">
{{ currentList.title === '' ? 'Loading...' : currentList.title }}
{{ currentList.title === '' ? $t('misc.loading') : currentList.title }}
</h1>
<div class="box has-text-left view">
<div class="logout">
<x-button @click="logout()" type="secondary">
<span>Logout</span>
<span class="icon is-small">
<icon icon="sign-out-alt"/>
</span>
</x-button>
</div>
<router-view/>
<a class="menu-bottom-link" href="https://vikunja.io" target="_blank">
Powered by Vikunja
{{ $t('misc.poweredBy') }}
</a>
</div>
</div>

View File

@ -4,7 +4,7 @@
<img alt="Vikunja" src="/images/logo-full.svg"/>
<div class="message is-info" v-if="motd !== ''">
<div class="message-header">
<p>Info</p>
<p>{{ $t('misc.info') }}</p>
</div>
<div class="message-body">
{{ motd }}

View File

@ -10,7 +10,7 @@
<span class="icon">
<icon icon="calendar"/>
</span>
Overview
{{ $t('navigation.overview') }}
</router-link>
</li>
<li>
@ -18,7 +18,7 @@
<span class="icon">
<icon :icon="['far', 'calendar-alt']"/>
</span>
Upcoming
{{ $t('navigation.upcoming') }}
</router-link>
</li>
<li>
@ -26,7 +26,7 @@
<span class="icon">
<icon icon="layer-group"/>
</span>
Namespaces & Lists
{{ $t('namespace.title') }}
</router-link>
</li>
<li>
@ -34,7 +34,7 @@
<span class="icon">
<icon icon="tags"/>
</span>
Labels
{{ $t('label.title') }}
</router-link>
</li>
<li>
@ -42,7 +42,7 @@
<span class="icon">
<icon icon="users"/>
</span>
Teams
{{ $t('team.title') }}
</router-link>
</li>
</ul>
@ -50,23 +50,30 @@
<aside class="menu namespaces-lists loader-container" :class="{'is-loading': loading}">
<template v-for="n in namespaces">
<div :key="n.id" class="namespace-title">
<div :key="n.id" class="namespace-title" :class="{'has-menu': n.id > 0}">
<span
@click="toggleLists(n.id)"
class="menu-label"
v-tooltip="n.title + ' (' + n.lists.filter(l => !l.isArchived).length + ')'">
v-tooltip="getNamespaceTitle(n) + ' (' + n.lists.filter(l => !l.isArchived).length + ')'">
<span class="name">
<span
:style="{ backgroundColor: n.hexColor }"
class="color-bubble"
v-if="n.hexColor !== ''">
</span>
{{ n.title }} ({{ n.lists.filter(l => !l.isArchived).length }})
{{ getNamespaceTitle(n) }} ({{ n.lists.filter(l => !l.isArchived).length }})
</span>
</span>
<a
class="icon is-small toggle-lists-icon"
:class="{'active': typeof listsVisible[n.id] !== 'undefined' ? listsVisible[n.id] : true}"
@click="toggleLists(n.id)"
>
<icon icon="chevron-down"/>
</a>
<namespace-settings-dropdown :namespace="n" v-if="n.id > 0"/>
</div>
<div :key="n.id + 'child'" class="more-container" v-if="listsVisible[n.id]">
<div :key="n.id + 'child'" class="more-container" v-if="typeof listsVisible[n.id] !== 'undefined' ? listsVisible[n.id] : true">
<ul class="menu-list can-be-hidden">
<template v-for="l in n.lists">
<!-- This is a bit ugly but vue wouldn't want to let me filter this - probably because the lists
@ -84,7 +91,7 @@
v-if="l.hexColor !== ''">
</span>
<span class="list-menu-title">
{{ l.title }}
{{ getListTitle(l) }}
</span>
<span
:class="{'is-favorite': l.isFavorite}"
@ -94,21 +101,17 @@
<icon :icon="['far', 'star']" v-else/>
</span>
</router-link>
<list-settings-dropdown :list="l"/>
<list-settings-dropdown :list="l" v-if="l.id > 0"/>
<span class="list-setting-spacer" v-else></span>
</li>
</template>
</ul>
</div>
<span
@click="toggleLists(n.id)"
:key="`${n.id}_hidden_hint`"
class="hidden-hint"
v-else-if="n.lists.filter(l => !l.isArchived).length > 0">
Show hidden lists ({{ n.lists.filter(l => !l.isArchived).length }})...
</span>
</template>
</aside>
<a class="menu-bottom-link" href="https://vikunja.io" target="_blank">Powered by Vikunja</a>
<a class="menu-bottom-link" href="https://vikunja.io" target="_blank">
{{ $t('misc.poweredBy') }}
</a>
</div>
</template>
@ -130,9 +133,7 @@ export default {
NamespaceSettingsDropdown,
},
computed: mapState({
namespaces(state) {
return state.namespaces.namespaces.filter(n => !n.isArchived)
},
namespaces: state => state.namespaces.namespaces.filter(n => !n.isArchived),
currentList: CURRENT_LIST,
background: 'background',
menuActive: MENU_ACTIVE,
@ -142,7 +143,9 @@ export default {
this.$store.dispatch('namespaces/loadNamespaces')
.then(namespaces => {
namespaces.forEach(n => {
this.$set(this.listsVisible, n.id, true)
if (typeof this.listsVisible[n.id] === 'undefined') {
this.$set(this.listsVisible, n.id, true)
}
})
})
},
@ -160,7 +163,7 @@ export default {
return
}
this.$store.dispatch('lists/toggleListFavorite', list)
.catch(e => this.error(e, this))
.catch(e => this.error(e))
},
resize() {
// Hide the menu by default on mobile
@ -176,3 +179,10 @@ export default {
},
}
</script>
<style scoped>
.list-setting-spacer {
width: 32px;
flex-shrink: 0;
}
</style>

View File

@ -16,31 +16,39 @@
@shortkey="() => $store.commit('toggleMenu')"
v-shortkey="['ctrl', 'e']"
>
<icon icon="bars"></icon>
</a>
</div>
<a
@click="$store.commit('toggleMenu')"
class="menu-show-button"
>
<icon icon="bars"></icon>
</a>
<div class="list-title" v-if="currentList.id">
<h1
:style="{ 'opacity': currentList.title === '' ? '0': '1' }"
class="title">
{{ currentList.title === '' ? 'Loading...' : currentList.title }}
</h1>
<div class="list-title" ref="listTitle" :style="{'display': currentList.id ? '': 'none'}">
<template v-if="currentList.id">
<h1
:style="{ 'opacity': currentList.title === '' ? '0': '1' }"
class="title">
{{ currentList.title === '' ? $t('misc.loading') : getListTitle(currentList) }}
</h1>
<list-settings-dropdown v-if="canWriteCurrentList" :list="currentList"/>
<list-settings-dropdown v-if="canWriteCurrentList && currentList.id !== -1" :list="currentList"/>
</template>
</div>
<div class="navbar-end">
<update/>
<a
@click="openQuickActions"
class="trigger-button pr-0"
@shortkey="openQuickActions"
v-shortkey="['ctrl', 'k']"
>
<icon icon="search"/>
</a>
<notifications/>
<div class="user">
<img :src="userAvatar" alt="" class="avatar"/>
<dropdown class="is-right">
<dropdown class="is-right" ref="usernameDropdown">
<template v-slot:trigger>
<x-button
type="secondary"
@ -53,26 +61,30 @@
</template>
<router-link :to="{name: 'user.settings'}" class="dropdown-item">
Settings
{{ $t('user.settings.title') }}
</router-link>
<a
:href="imprintUrl"
class="dropdown-item"
target="_blank"
v-if="imprintUrl">
Imprint
{{ $t('navigation.imprint') }}
</a>
<a
:href="privacyPolicyUrl"
class="dropdown-item"
target="_blank"
v-if="privacyPolicyUrl">
Privacy policy
{{ $t('navigation.privacy') }}
</a>
<a @click="$store.commit('keyboardShortcutsActive', true)" class="dropdown-item">Keyboard
Shortcuts</a>
<a @click="$store.commit('keyboardShortcutsActive', true)" class="dropdown-item">
{{ $t('keyboardShortcuts.title') }}
</a>
<router-link :to="{name: 'about'}" class="dropdown-item">
{{ $t('about.title') }}
</router-link>
<a @click="logout()" class="dropdown-item">
Logout
{{ $t('user.auth.logout') }}
</a>
</dropdown>
</div>
@ -82,7 +94,7 @@
<script>
import {mapState} from 'vuex'
import {CURRENT_LIST} from '@/store/mutation-types'
import {CURRENT_LIST, QUICK_ACTIONS_ACTIVE} from '@/store/mutation-types'
import Rights from '@/models/rights.json'
import Update from '@/components/home/update.vue'
import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue'
@ -107,11 +119,24 @@ export default {
privacyPolicyUrl: state => state.config.legal.privacyPolicyUrl,
canWriteCurrentList: state => state.currentList.maxRight > Rights.READ,
}),
mounted() {
this.$nextTick(() => {
if (typeof this.$refs.usernameDropdown === 'undefined' || typeof this.$refs.listTitle === 'undefined') {
return
}
const usernameWidth = this.$refs.usernameDropdown.$el.clientWidth
this.$refs.listTitle.style.setProperty('--nav-username-width', `${usernameWidth}px`)
})
},
methods: {
logout() {
this.$store.dispatch('auth/logout')
this.$router.push({name: 'user.login'})
},
openQuickActions() {
this.$store.commit(QUICK_ACTIONS_ACTIVE, true)
},
},
}
</script>

View File

@ -1,8 +1,8 @@
<template>
<div class="update-notification" v-if="updateAvailable">
<p>There is an update for Vikunja available!</p>
<p>{{ $t('update.available') }}</p>
<x-button @click="refreshApp()" :shadow="false">
Update Now
{{ $t('update.do') }}
</x-button>
</div>
</template>

View File

@ -55,7 +55,7 @@ export default {
computed: {
showIconOnly() {
return this.icon !== '' && typeof this.$slots.default === 'undefined'
}
},
},
methods: {
click(e) {

View File

@ -19,7 +19,7 @@
:class="{'is-empty': empty}"
/>
<x-button @click="reset" class="is-small ml-2" :shadow="false" type="secondary">
Reset Color
{{ $t('input.resetColor') }}
</x-button>
</div>
</template>

View File

@ -18,7 +18,7 @@
</span>
<span class="text">
<span>
Today
{{ $t('input.datepicker.today') }}
</span>
<span class="weekday">
{{ getWeekdayFromStringInterval('today') }}
@ -31,7 +31,7 @@
</span>
<span class="text">
<span>
Tomorrow
{{ $t('input.datepicker.tomorrow') }}
</span>
<span class="weekday">
{{ getWeekdayFromStringInterval('tomorrow') }}
@ -44,7 +44,7 @@
</span>
<span class="text">
<span>
Next Monday
{{ $t('input.datepicker.nextMonday') }}
</span>
<span class="weekday">
{{ getWeekdayFromStringInterval('nextMonday') }}
@ -57,7 +57,7 @@
</span>
<span class="text">
<span>
This Weekend
{{ $t('input.datepicker.thisWeekend') }}
</span>
<span class="weekday">
{{ getWeekdayFromStringInterval('thisWeekend') }}
@ -70,7 +70,7 @@
</span>
<span class="text">
<span>
Later This Week
{{ $t('input.datepicker.laterThisWeek') }}
</span>
<span class="weekday">
{{ getWeekdayFromStringInterval('laterThisWeek') }}
@ -83,7 +83,7 @@
</span>
<span class="text">
<span>
Next Week
{{ $t('input.datepicker.nextWeek') }}
</span>
<span class="weekday">
{{ getWeekdayFromStringInterval('nextWeek') }}
@ -102,7 +102,7 @@
:shadow="false"
@click="close"
>
Confirm
{{ $t('misc.confirm') }}
</x-button>
</div>
</transition>
@ -127,14 +127,6 @@ export default {
show: false,
changed: false,
flatPickerConfig: {
altFormat: 'j M Y H:i',
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
inline: true,
},
// Since flatpickr dates are strings, we need to convert them to native date objects.
// To make that work, we need a separate variable since flatpickr does not have a change event.
flatPickrDate: null,
@ -145,19 +137,21 @@ export default {
},
props: {
value: {
validator: prop => prop instanceof Date || prop === null || typeof prop === 'string'
validator: prop => prop instanceof Date || prop === null || typeof prop === 'string',
},
chooseDateLabel: {
type: String,
default: 'Choose a date'
default() {
return this.$t('input.datepicker.chooseDate')
},
},
disabled: {
type: Boolean,
default: false,
}
},
},
mounted() {
this.date = this.value
this.setDateValue(this.value)
document.addEventListener('click', this.hideDatePopup)
},
beforeDestroy() {
@ -165,18 +159,36 @@ export default {
},
watch: {
value(newVal) {
if(newVal === null) {
this.date = null
return
}
this.date = createDateFromString(newVal)
this.setDateValue(newVal)
},
flatPickrDate(newVal) {
this.date = createDateFromString(newVal)
this.updateData()
},
},
computed: {
flatPickerConfig() {
return {
altFormat: this.$t('date.altFormatLong'),
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
inline: true,
locale: {
firstDayOfWeek: this.$store.state.auth.settings.weekStart,
},
}
},
},
methods: {
setDateValue(newVal) {
if(newVal === null) {
this.date = null
return
}
this.date = createDateFromString(newVal)
},
updateData() {
this.changed = true
this.$emit('input', this.date)

View File

@ -1,7 +1,7 @@
<template>
<div :class="{'is-pulled-up': isEditEnabled}" class="editor">
<div class="is-pulled-right mb-4" v-if="hasPreview && isEditEnabled && !hasEditBottom">
<x-button
<x-button
v-if="!isEditActive"
@click="toggleEdit"
:shadow="false"
@ -9,13 +9,13 @@
>
<icon icon="pen"/>
</x-button>
<x-button
v-else
<x-button
v-else
@click="toggleEdit"
:shadow="false"
type="secondary"
>
Done
{{ $t('input.editor.done') }}
</x-button>
</div>
@ -34,14 +34,16 @@
<p class="has-text-centered has-text-grey is-italic" v-if="isPreviewActive && text === '' && emptyText !== ''">
{{ emptyText }}
<a @click="toggleEdit">Edit</a>.
<template v-if="isEditEnabled">
<a @click="toggleEdit">{{ $t('input.editor.edit') }}</a>.
</template>
</p>
<ul class="actions">
<template v-if="hasEditBottom">
<template v-if="hasEditBottom && isEditEnabled">
<li>
<a v-if="!isEditActive" @click="toggleEdit">Edit</a>
<a v-else @click="toggleEdit">Done</a>
<a v-if="!isEditActive" @click="toggleEdit">{{ $t('input.editor.edit') }}</a>
<a v-else @click="toggleEdit">{{ $t('input.editor.done') }}</a>
</li>
</template>
<li v-for="(action, k) in bottomActions" :key="k">
@ -115,6 +117,7 @@ export default {
preview: '',
attachmentService: null,
loadedAttachments: {},
config: {
autoDownloadFontAwesome: false,
@ -127,112 +130,112 @@ export default {
{
name: 'heading-1',
action: EasyMDE.toggleHeading1,
title: 'Heading 1',
title: this.$t('input.editor.heading1'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.2773 19.25L12.5773 4.34995C12.5773 4.34995 12.5773 4.24995 12.4773 4.24995C12.4773 4.24995 12.4773 4.14995 12.3773 4.14995C12.3773 4.14995 12.2773 4.14995 12.2773 4.04995L12.1773 3.94995H12.0773H11.9773C11.8773 3.94995 11.8773 3.94995 11.8773 3.94995H11.7773C11.6773 4.04995 11.6773 4.14995 11.5773 4.14995C11.5773 4.14995 11.5773 4.14995 11.4773 4.14995C11.4773 4.14995 11.4773 4.24995 11.3773 4.24995L11.2773 4.34995L5.67733 19.25C5.57733 19.55 5.67733 19.95 5.97733 20.05C6.07733 20.05 6.07733 20.05 6.17733 20.05C6.37733 20.05 6.67733 19.95 6.77733 19.65L7.87733 16.85H16.1773L17.2773 19.65C17.3773 19.85 17.5773 20.05 17.8773 20.05C17.9773 20.05 17.9773 20.05 18.0773 20.05C18.2773 19.85 18.4773 19.55 18.2773 19.25ZM8.27733 15.65L11.9773 6.24995L15.6773 15.65H8.27733Z"/></svg>',
},
{
name: 'heading-2',
action: EasyMDE.toggleHeading2,
title: 'Heading 2',
title: this.$t('input.editor.heading2'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.2773 19.25L12.5773 4.34995C12.5773 4.34995 12.5773 4.24995 12.4773 4.24995C12.4773 4.24995 12.4773 4.14995 12.3773 4.14995C12.3773 4.14995 12.2773 4.14995 12.2773 4.04995L12.1773 3.94995H12.0773H11.9773C11.8773 3.94995 11.8773 3.94995 11.8773 3.94995H11.7773C11.6773 4.04995 11.6773 4.14995 11.5773 4.14995C11.5773 4.14995 11.5773 4.14995 11.4773 4.14995C11.4773 4.14995 11.4773 4.24995 11.3773 4.24995L11.2773 4.34995L5.67733 19.25C5.57733 19.55 5.67733 19.95 5.97733 20.05C6.07733 20.05 6.07733 20.05 6.17733 20.05C6.37733 20.05 6.67733 19.95 6.77733 19.65L7.87733 16.85H16.1773L17.2773 19.65C17.3773 19.85 17.5773 20.05 17.8773 20.05C17.9773 20.05 17.9773 20.05 18.0773 20.05C18.2773 19.85 18.4773 19.55 18.2773 19.25ZM8.27733 15.65L11.9773 6.24995L15.6773 15.65H8.27733Z"/></svg>',
},
{
name: 'heading-3',
action: EasyMDE.toggleHeading3,
title: 'Heading 3',
title: this.$t('input.editor.heading3'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.2773 19.25L12.5773 4.34995C12.5773 4.34995 12.5773 4.24995 12.4773 4.24995C12.4773 4.24995 12.4773 4.14995 12.3773 4.14995C12.3773 4.14995 12.2773 4.14995 12.2773 4.04995L12.1773 3.94995H12.0773H11.9773C11.8773 3.94995 11.8773 3.94995 11.8773 3.94995H11.7773C11.6773 4.04995 11.6773 4.14995 11.5773 4.14995C11.5773 4.14995 11.5773 4.14995 11.4773 4.14995C11.4773 4.14995 11.4773 4.24995 11.3773 4.24995L11.2773 4.34995L5.67733 19.25C5.57733 19.55 5.67733 19.95 5.97733 20.05C6.07733 20.05 6.07733 20.05 6.17733 20.05C6.37733 20.05 6.67733 19.95 6.77733 19.65L7.87733 16.85H16.1773L17.2773 19.65C17.3773 19.85 17.5773 20.05 17.8773 20.05C17.9773 20.05 17.9773 20.05 18.0773 20.05C18.2773 19.85 18.4773 19.55 18.2773 19.25ZM8.27733 15.65L11.9773 6.24995L15.6773 15.65H8.27733Z"/></svg>',
},
{
name: 'heading-smaller',
action: EasyMDE.toggleHeadingSmaller,
title: 'Heading Smaller',
title: this.$t('input.editor.headingSmaller'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.2773 19.25L12.5773 4.34995C12.5773 4.34995 12.5773 4.24995 12.4773 4.24995C12.4773 4.24995 12.4773 4.14995 12.3773 4.14995C12.3773 4.14995 12.2773 4.14995 12.2773 4.04995L12.1773 3.94995H12.0773H11.9773C11.8773 3.94995 11.8773 3.94995 11.8773 3.94995H11.7773C11.6773 4.04995 11.6773 4.14995 11.5773 4.14995C11.5773 4.14995 11.5773 4.14995 11.4773 4.14995C11.4773 4.14995 11.4773 4.24995 11.3773 4.24995L11.2773 4.34995L5.67733 19.25C5.57733 19.55 5.67733 19.95 5.97733 20.05C6.07733 20.05 6.07733 20.05 6.17733 20.05C6.37733 20.05 6.67733 19.95 6.77733 19.65L7.87733 16.85H16.1773L17.2773 19.65C17.3773 19.85 17.5773 20.05 17.8773 20.05C17.9773 20.05 17.9773 20.05 18.0773 20.05C18.2773 19.85 18.4773 19.55 18.2773 19.25ZM8.27733 15.65L11.9773 6.24995L15.6773 15.65H8.27733Z"/></svg>',
},
{
name: 'heading-bigger',
action: EasyMDE.toggleHeadingBigger,
title: 'Heading Bigger',
title: this.$t('input.editor.headingBigger'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.2773 19.25L12.5773 4.34995C12.5773 4.34995 12.5773 4.24995 12.4773 4.24995C12.4773 4.24995 12.4773 4.14995 12.3773 4.14995C12.3773 4.14995 12.2773 4.14995 12.2773 4.04995L12.1773 3.94995H12.0773H11.9773C11.8773 3.94995 11.8773 3.94995 11.8773 3.94995H11.7773C11.6773 4.04995 11.6773 4.14995 11.5773 4.14995C11.5773 4.14995 11.5773 4.14995 11.4773 4.14995C11.4773 4.14995 11.4773 4.24995 11.3773 4.24995L11.2773 4.34995L5.67733 19.25C5.57733 19.55 5.67733 19.95 5.97733 20.05C6.07733 20.05 6.07733 20.05 6.17733 20.05C6.37733 20.05 6.67733 19.95 6.77733 19.65L7.87733 16.85H16.1773L17.2773 19.65C17.3773 19.85 17.5773 20.05 17.8773 20.05C17.9773 20.05 17.9773 20.05 18.0773 20.05C18.2773 19.85 18.4773 19.55 18.2773 19.25ZM8.27733 15.65L11.9773 6.24995L15.6773 15.65H8.27733Z"/></svg>',
},
'|',
{
name: 'bold',
action: EasyMDE.toggleBold,
title: 'Bold',
title: this.$t('input.editor.bold'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M3.5 3H6.5H15.25C18.15 3 20.5 5.36 20.5 8.25C20.5 9.8 19.81 11.19 18.73 12.15C20.37 13.04 21.5 14.76 21.5 16.75C21.5 19.64 19.15 22 16.25 22H6.5H3.5C2.95 22 2.5 21.55 2.5 21C2.5 20.45 2.95 20 3.5 20H5.5V5H3.5C2.95 5 2.5 4.55 2.5 4C2.5 3.45 2.95 3 3.5 3ZM7.5 20H16.25C18.04 20 19.5 18.54 19.5 16.75C19.5 14.96 18.04 13.5 16.25 13.5H7.5V20ZM7.5 11.5H15.25C17.04 11.5 18.5 10.04 18.5 8.25C18.5 6.46 17.04 5 15.25 5H7.5V11.5Z"/></svg>',
},
{
name: 'italic',
action: EasyMDE.toggleItalic,
title: 'Italic',
title: this.$t('input.editor.italic'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M14.0967 4.2H17.0001C17.3301 4.2 17.6001 3.93 17.6001 3.6C17.6001 3.27 17.3301 3 17.0001 3H10.2001C9.8701 3 9.6001 3.27 9.6001 3.6C9.6001 3.93 9.8701 4.2 10.2001 4.2H12.8748L9.90335 19.8H6.9999C6.6699 19.8 6.3999 20.07 6.3999 20.4C6.3999 20.73 6.6699 21 6.9999 21H13.7999C14.1299 21 14.3999 20.73 14.3999 20.4C14.3999 20.07 14.1299 19.8 13.7999 19.8H11.1253L14.0967 4.2Z"/></svg>',
},
{
name: 'strikethrough',
action: EasyMDE.toggleStrikethrough,
title: 'Strikethrough',
title: this.$t('input.editor.strikethrough'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.25 7.17005C18.25 7.50005 17.98 7.77005 17.65 7.77005C17.32 7.77005 17.05 7.50005 17.05 7.17005V5.96005C15.97 5.12005 14.17 4.56005 12.79 4.31005C11.1 4.00005 9.51 4.30005 8.41 5.12005C7.2 6.03005 6.67 7.67005 7.19 8.88005C7.56 9.73005 8.37 10.31 8.98 10.64C9.57215 10.9644 10.1961 11.2013 10.8465 11.3999H20.4C20.73 11.3999 21 11.6699 21 11.9999C21 12.3299 20.73 12.5999 20.4 12.5999H15.3012C16.6583 13.0929 17.5255 13.7765 17.95 14.69C18.73 16.36 17.74 18.33 16.36 19.41C15.05 20.4401 13.35 21 11.54 21H11.16C9.78 20.9401 8.34 20.5301 6.95 19.85V20.3601C6.95 20.6901 6.68 20.96 6.35 20.96C6.02 20.96 5.75 20.6901 5.75 20.3601V17.36C5.75 17.03 6.02 16.76 6.35 16.76C6.68 16.76 6.95 17.03 6.95 17.36V18.5C8.35 19.2801 9.81 19.74 11.21 19.8C12.86 19.89 14.46 19.39 15.62 18.48C16.6 17.71 17.37 16.3 16.86 15.21C16.55 14.54 15.8 14.0201 14.58 13.63C13.9711 13.4331 13.3222 13.2762 12.6906 13.1235C12.6168 13.1056 12.5432 13.0878 12.47 13.07C12.4313 13.0607 12.3925 13.0514 12.3537 13.0421C11.7861 12.9055 11.2108 12.767 10.6413 12.5999H3.6C3.27 12.5999 3 12.3299 3 11.9999C3 11.6699 3.27 11.3999 3.6 11.3999H7.90288C7.04984 10.8343 6.42752 10.1363 6.09 9.36005C5.34 7.63005 6.03 5.40005 7.69 4.16005C9.05 3.15005 10.99 2.77005 13 3.13005C13.64 3.25005 15.53 3.66005 17.05 4.53005V4.17005C17.05 3.84005 17.32 3.57005 17.65 3.57005C17.98 3.57005 18.25 3.84005 18.25 4.17005V7.17005Z"/></svg>',
},
{
name: 'code',
action: EasyMDE.toggleCodeBlock,
title: 'Code',
title: this.$t('input.editor.code'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M8.57 20.9601C8.64 20.9901 8.71 21.0001 8.78 21.0001C9.02 21.0001 9.24 20.8501 9.34 20.6101L15.79 3.81005C15.9 3.50005 15.75 3.15005 15.44 3.03005C15.14 2.92005 14.79 3.07005 14.67 3.38005L8.22 20.1801C8.11 20.4901 8.26 20.8401 8.57 20.9601ZM7.00007 18.0001C6.85007 18.0001 6.69007 17.9401 6.58007 17.8201L1.18007 12.4201C0.950068 12.1901 0.950068 11.8101 1.18007 11.5701L6.58007 6.17006C6.81007 5.94006 7.19007 5.94006 7.43007 6.17006C7.66007 6.40006 7.66007 6.78006 7.43007 7.02006L2.45007 12.0001L7.43007 16.9801C7.66007 17.2101 7.66007 17.5901 7.43007 17.8301C7.31007 17.9401 7.15007 18.0001 7.00007 18.0001ZM17 18.0001C16.85 18.0001 16.69 17.9401 16.58 17.8201C16.35 17.5901 16.35 17.2101 16.58 16.9701L21.55 12.0001L16.57 7.02006C16.34 6.79006 16.34 6.41006 16.57 6.17006C16.81 5.94006 17.19 5.94006 17.42 6.17006L22.82 11.5701C23.05 11.8001 23.05 12.1801 22.82 12.4201L17.42 17.8201C17.31 17.9401 17.15 18.0001 17 18.0001Z"/></svg>',
},
{
name: 'quote',
action: EasyMDE.toggleBlockquote,
title: 'Quote',
title: this.$t('input.editor.quote'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M19.373 5.16357H5.62695C4.79102 5.16357 4.11133 5.84326 4.11133 6.6792V16.2095C4.11133 17.0464 4.79102 17.7261 5.62695 17.7261H6.8877V21.1245C6.8877 21.3667 7.0332 21.5854 7.25684 21.6782C7.33203 21.7095 7.41016 21.7241 7.4873 21.7241C7.64258 21.7241 7.7959 21.6636 7.91113 21.5493L11.748 17.7261H19.373C20.209 17.7261 20.8887 17.0464 20.8887 16.2095V6.6792C20.8887 5.84326 20.209 5.16357 19.373 5.16357ZM19.6895 16.2095C19.6895 16.3843 19.5469 16.5269 19.373 16.5269H11.5C11.3408 16.5269 11.1895 16.5894 11.0762 16.7017L8.08691 19.6802V17.1265C8.08691 16.7954 7.81836 16.5269 7.4873 16.5269H5.62695C5.45312 16.5269 5.31055 16.3843 5.31055 16.2095V6.6792C5.31055 6.50537 5.45312 6.36279 5.62695 6.36279H19.373C19.5469 6.36279 19.6895 6.50537 19.6895 6.6792V16.2095ZM10.3431 8.45264C9.46326 8.45264 8.75 9.16589 8.75 10.0458C8.75 10.9257 9.46326 11.639 10.3431 11.639C10.4775 11.639 10.6058 11.6173 10.7305 11.5861V11.6195C10.7305 12.1322 10.3135 12.5492 9.75586 12.5492C9.4248 12.5492 9.17871 12.8177 9.17871 13.1488C9.17871 13.4799 9.46973 13.7484 9.80078 13.7484C10.9746 13.7484 11.9297 12.7933 11.9297 11.6195V10.1176L11.9294 10.1165L11.9292 10.1155C11.9297 10.1049 11.9312 10.0946 11.9326 10.0843L11.9326 10.0843C11.9345 10.0716 11.9363 10.059 11.9363 10.0458C11.9363 9.16589 11.223 8.45264 10.3431 8.45264ZM13.0637 10.0458C13.0637 9.16589 13.7771 8.45264 14.657 8.45264C15.5369 8.45264 16.2501 9.16589 16.2501 10.0458C16.2501 10.0584 16.2484 10.0706 16.2466 10.0828C16.2452 10.0929 16.2437 10.103 16.2433 10.1134C16.2433 10.1149 16.2441 10.1161 16.2441 10.1176V11.6195C16.2441 12.7933 15.2891 13.7484 14.1152 13.7484C13.7842 13.7484 13.4922 13.4799 13.4922 13.1488C13.4922 12.8177 13.7383 12.5492 14.0693 12.5492C14.6279 12.5492 15.0449 12.1322 15.0449 11.6195V11.5858C14.9202 11.6173 14.7915 11.639 14.657 11.639C13.7771 11.639 13.0637 10.9257 13.0637 10.0458Z"/></svg>',
},
{
name: 'unordered-list',
action: EasyMDE.toggleUnorderedList,
title: 'Unordered List',
title: this.$t('input.editor.unorderedList'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M6.5281 3.7002H3.5281C3.1981 3.7002 2.9281 3.9702 2.9281 4.3002V7.3002C2.9281 7.6302 3.1981 7.9002 3.5281 7.9002H6.5281C6.8581 7.9002 7.1281 7.6302 7.1281 7.3002V4.3002C7.1281 3.9702 6.8581 3.7002 6.5281 3.7002ZM5.9281 6.7002H4.1281V4.9002H5.9281V6.7002ZM3.5281 9.90015H6.5281C6.8581 9.90015 7.1281 10.1701 7.1281 10.5001V13.5001C7.1281 13.8301 6.8581 14.1001 6.5281 14.1001H3.5281C3.1981 14.1001 2.9281 13.8301 2.9281 13.5001V10.5001C2.9281 10.1701 3.1981 9.90015 3.5281 9.90015ZM4.1281 12.9001H5.9281V11.1001H4.1281V12.9001ZM3.5281 16.1001H6.5281C6.8581 16.1001 7.1281 16.3701 7.1281 16.7001V19.7001C7.1281 20.0301 6.8581 20.3001 6.5281 20.3001H3.5281C3.1981 20.3001 2.9281 20.0301 2.9281 19.7001V16.7001C2.9281 16.3701 3.1981 16.1001 3.5281 16.1001ZM4.1281 19.1001H5.9281V17.3001H4.1281V19.1001ZM9.72817 6.4002H20.7282C21.0582 6.4002 21.3282 6.1302 21.3282 5.8002C21.3282 5.4702 21.0582 5.2002 20.7282 5.2002H9.72817C9.39817 5.2002 9.12817 5.4702 9.12817 5.8002C9.12817 6.1302 9.39817 6.4002 9.72817 6.4002ZM9.72817 11.4001H20.7282C21.0582 11.4001 21.3282 11.6701 21.3282 12.0001C21.3282 12.3301 21.0582 12.6001 20.7282 12.6001H9.72817C9.39817 12.6001 9.12817 12.3301 9.12817 12.0001C9.12817 11.6701 9.39817 11.4001 9.72817 11.4001ZM9.72817 17.6001H20.7282C21.0582 17.6001 21.3282 17.8701 21.3282 18.2001C21.3282 18.5301 21.0582 18.8001 20.7282 18.8001H9.72817C9.39817 18.8001 9.12817 18.5301 9.12817 18.2001C9.12817 17.8701 9.39817 17.6001 9.72817 17.6001Z"/></svg>',
},
{
name: 'ordered-list',
action: EasyMDE.toggleOrderedList,
title: 'Ordered List',
title: this.$t('input.editor.orderedList'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M4.19494 8.29994H5.99494C6.26494 8.29994 6.49494 8.07995 6.49494 7.79994C6.49494 7.51995 6.27494 7.29994 5.99494 7.29994H5.59494V3.79994C5.59494 3.62994 5.50494 3.46994 5.36494 3.37994C5.22494 3.28994 5.04494 3.26994 4.89494 3.33994L3.89494 3.76994C3.64494 3.87994 3.52494 4.17994 3.63494 4.42994C3.74494 4.67994 4.03494 4.79994 4.29494 4.68994L4.59494 4.55994V7.29994H4.19494C3.91494 7.29994 3.69494 7.51995 3.69494 7.79994C3.69494 8.07995 3.91494 8.29994 4.19494 8.29994ZM20.195 6.39995H9.19497C8.86497 6.39995 8.59497 6.12995 8.59497 5.79995C8.59497 5.46995 8.86497 5.19995 9.19497 5.19995H20.195C20.525 5.19995 20.795 5.46995 20.795 5.79995C20.795 6.12995 20.525 6.39995 20.195 6.39995ZM3.78486 14.36H6.37486C6.65486 14.36 6.87486 14.14 6.87486 13.86C6.87486 13.58 6.65486 13.36 6.37486 13.36H4.88486C5.00486 13.23 5.12486 13.09 5.23486 12.95C5.26626 12.9151 5.29645 12.8802 5.32626 12.8458L5.32629 12.8457C5.38192 12.7814 5.43627 12.7186 5.49486 12.66C5.73486 12.4 5.98486 12.12 6.17486 11.79C6.47486 11.25 6.41486 10.63 6.01486 10.17C5.57486 9.66 4.86486 9.5 4.24486 9.74C3.74486 9.95 3.39486 10.35 3.22486 10.91C3.14486 11.18 3.29486 11.46 3.56486 11.54C3.82486 11.61 4.10486 11.46 4.18486 11.2C4.29486 10.85 4.48486 10.73 4.62486 10.67C4.88486 10.57 5.13486 10.68 5.26486 10.82C5.38486 10.96 5.40486 11.12 5.30486 11.29C5.17595 11.5202 4.99618 11.7165 4.80458 11.9257L4.75486 11.98C4.67298 12.0801 4.58283 12.1801 4.49946 12.2727L4.49945 12.2727L4.47486 12.3C4.12486 12.72 3.76486 13.13 3.40486 13.53C3.27486 13.68 3.23486 13.9 3.32486 14.07C3.41486 14.24 3.58486 14.36 3.78486 14.36ZM3.68486 20.3699C4.04486 20.5899 4.46486 20.6999 4.87486 20.6999C5.13486 20.6999 5.38486 20.6499 5.61486 20.5499C6.31486 20.2799 6.73486 19.5599 6.60486 18.8799C6.53486 18.5499 6.35486 18.2899 6.12486 18.0899C6.32486 17.8999 6.45486 17.6499 6.50486 17.3799C6.57486 17.0099 6.49486 16.6299 6.27486 16.3099C5.85486 15.6899 5.07486 15.5199 4.10486 15.8299C3.83486 15.9199 3.69486 16.1999 3.77486 16.4599C3.86486 16.7299 4.14486 16.8699 4.40486 16.7899C4.70486 16.6999 5.24486 16.5799 5.45486 16.8899C5.51486 16.9899 5.54486 17.0999 5.52486 17.1999C5.51486 17.2699 5.47486 17.3599 5.36486 17.4299C5.26486 17.4999 5.12486 17.5399 4.95486 17.5799L4.77486 17.6299C4.54486 17.6999 4.40486 17.9099 4.41486 18.1499C4.42486 18.3899 4.61486 18.5799 4.84486 18.6099C5.20486 18.6599 5.58486 18.8299 5.63486 19.0799C5.67486 19.2999 5.46486 19.5499 5.25486 19.6299C4.94486 19.7599 4.52486 19.7099 4.21486 19.5199C3.97486 19.3699 3.67486 19.4399 3.52486 19.6799C3.37486 19.9199 3.44486 20.2299 3.68486 20.3699ZM20.195 18.7999H9.19497C8.86497 18.7999 8.59497 18.5299 8.59497 18.1999C8.59497 17.8699 8.86497 17.5999 9.19497 17.5999H20.195C20.525 17.5999 20.795 17.8699 20.795 18.1999C20.795 18.5299 20.525 18.7999 20.195 18.7999ZM9.19497 12.5999H20.195C20.525 12.5999 20.795 12.3299 20.795 11.9999C20.795 11.6699 20.525 11.3999 20.195 11.3999H9.19497C8.86497 11.3999 8.59497 11.6699 8.59497 11.9999C8.59497 12.3299 8.86497 12.5999 9.19497 12.5999Z"/></svg>',
},
'|',
{
name: 'clean-block',
action: EasyMDE.cleanBlock,
title: 'Clean Block',
title: this.$t('input.editor.cleanBlock'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M9.25989 6.18384H20.4513C20.7823 6.18384 21.0509 6.45239 21.0509 6.78345V17.9749C21.0509 18.3059 20.7823 18.5745 20.4513 18.5745H9.25989C9.0929 18.5745 8.93469 18.5061 8.82043 18.384L3.6095 12.7883C3.39563 12.5579 3.39563 12.2004 3.6095 11.97L8.82043 6.37427C8.93469 6.2522 9.0929 6.18384 9.25989 6.18384ZM9.52063 17.3752H19.8517V7.38306H9.52063L4.86926 12.3792L9.52063 17.3752ZM12.7755 15.0686C12.6222 15.0686 12.4679 15.01 12.3517 14.8928C12.1173 14.6584 12.1173 14.2786 12.3517 14.0452L14.0503 12.3469L12.3517 10.6487C12.1173 10.4153 12.1173 10.0354 12.3517 9.80103C12.5841 9.56665 12.965 9.56665 13.1993 9.80103L14.8981 11.4994L16.5968 9.80103C16.8312 9.56665 17.212 9.56665 17.4445 9.80103C17.6788 10.0354 17.6788 10.4153 17.4445 10.6487L15.7458 12.3469L17.4445 14.0452C17.6788 14.2786 17.6788 14.6584 17.4445 14.8928C17.3282 15.01 17.174 15.0686 17.0206 15.0686C16.8673 15.0686 16.714 15.01 16.5968 14.8928L14.8981 13.1945L13.1993 14.8928C13.0822 15.01 12.9288 15.0686 12.7755 15.0686Z"/></svg>',
},
{
name: 'link',
action: EasyMDE.drawLink,
title: 'Link',
title: this.$t('input.editor.link'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M11.4399 15.3452C11.4999 15.3652 11.5699 15.3752 11.6299 15.3752C11.8799 15.3752 12.1199 15.2152 12.1999 14.9652C12.2999 14.6452 12.1299 14.3052 11.8199 14.2052C11.3499 14.0452 10.9299 13.7852 10.5699 13.4152C10.1999 13.0552 9.9399 12.6452 9.7799 12.1552C9.6599 11.8252 9.5999 11.4652 9.5999 11.0952C9.5999 10.2152 9.9399 9.38518 10.5699 8.75518L15.1599 4.15518C16.4499 2.87518 18.5399 2.87518 19.8299 4.15518C20.4499 4.78518 20.7899 5.61518 20.7899 6.49518C20.7899 7.37518 20.4499 8.20518 19.8299 8.82518L16.7399 11.9052C16.5099 12.1452 16.5099 12.5252 16.7399 12.7552C16.9799 12.9852 17.3599 12.9852 17.5899 12.7552L20.6799 9.67518C21.5299 8.83518 21.9999 7.69518 21.9999 6.49518C21.9999 5.29518 21.5299 4.16518 20.6899 3.30518C18.9299 1.55518 16.0799 1.55518 14.3199 3.30518L9.7299 7.90518C8.8699 8.75518 8.3999 9.88518 8.3999 11.0952C8.3999 11.6152 8.4899 12.1152 8.6499 12.5552C8.8599 13.1952 9.2399 13.7952 9.7199 14.2652C10.1999 14.7552 10.7999 15.1352 11.4399 15.3452ZM3.32 20.6851C4.2 21.5551 5.35 21.9951 6.5 21.9951C7.65 21.9951 8.81 21.5551 9.69 20.7051L14.28 16.1051C15.14 15.2551 15.61 14.1251 15.61 12.9151C15.61 12.4551 15.54 11.9951 15.4 11.5551C15.17 10.8651 14.8 10.2551 14.28 9.73509C13.76 9.21509 13.15 8.84509 12.46 8.61509C12.14 8.51509 11.8 8.68509 11.7 8.99509C11.6 9.30509 11.77 9.64509 12.1 9.75509C12.61 9.91509 13.06 10.1951 13.44 10.5751C13.82 10.9551 14.09 11.4051 14.26 11.9151C14.36 12.2351 14.41 12.5651 14.41 12.9051C14.41 13.7951 14.06 14.6251 13.43 15.2451L8.84 19.8451C7.55 21.1251 5.46 21.1251 4.17 19.8451C3.55 19.2151 3.21 18.3951 3.21 17.5051C3.21 16.6151 3.55 15.7851 4.17 15.1651L7.35 11.9851C7.58 11.7451 7.59 11.3651 7.35 11.1351C7.11 10.9051 6.73 10.9051 6.5 11.1351L3.32 14.3151C2.47 15.1551 2 16.2851 2 17.4951C2 18.7051 2.47 19.8351 3.32 20.6851Z"/></svg>',
},
{
name: 'image',
action: EasyMDE.drawImage,
title: 'Image',
title: this.$t('input.editor.image'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M4 4C2.89543 4 2 4.89543 2 6V16V17.5152V18C2 19.1046 2.89543 20 4 20H20C21.0528 20 21.9156 19.1866 21.9942 18.1539L22 18.1632V18V16V6C22 4.89543 21.1046 4 20 4H4ZM3.2 17.7V16.5642L6.78192 13.7254C6.8616 13.6622 6.97597 13.6689 7.04776 13.7409L10.3126 17.0146C10.7026 17.4056 11.3357 17.4065 11.7268 17.0165C11.7606 16.9827 11.792 16.9465 11.8207 16.9083L16.736 10.352C16.8023 10.2636 16.9277 10.2457 17.016 10.312C17.0355 10.3265 17.0521 10.3445 17.0651 10.365L20.8 16.2669V17.7C20.8 18.3075 20.3075 18.8 19.7 18.8H4.3C3.69249 18.8 3.2 18.3075 3.2 17.7ZM17.3865 8.61836L20.8 14.08V6.3C20.8 5.69249 20.3075 5.2 19.7 5.2H4.3C3.69249 5.2 3.2 5.69249 3.2 6.3V15.04L6.65054 12.2796C6.84949 12.1204 7.13629 12.1363 7.31645 12.3164L10.8369 15.8369C10.915 15.915 11.0417 15.915 11.1198 15.8369C11.1265 15.8302 16.5625 8.58336 16.5625 8.58336C16.7282 8.36245 17.0416 8.31768 17.2625 8.48336C17.3118 8.52034 17.3538 8.56611 17.3865 8.61836ZM8 8.5C8 9.32843 7.32843 10 6.5 10C5.67157 10 5 9.32843 5 8.5C5 7.67157 5.67157 7 6.5 7C7.32843 7 8 7.67157 8 8.5Z"/></svg>',
},
{
name: 'table',
action: EasyMDE.drawTable,
title: 'Table',
title: this.$t('input.editor.table'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M6.18524 3.08496H19.4152C20.6752 3.08496 21.7152 4.11496 21.7152 5.38496V18.615C21.7152 19.885 20.6852 20.915 19.4152 20.915H6.18524C4.91524 20.915 3.88525 19.885 3.88525 18.615V5.38496C3.88525 4.11496 4.91524 3.08496 6.18524 3.08496ZM19.4052 19.705C20.0152 19.705 20.5052 19.215 20.5052 18.605H20.5153V5.38496C20.5153 4.77496 20.0252 4.28496 19.4152 4.28496H6.18524C5.57524 4.28496 5.08521 4.77496 5.08521 5.38496V18.605C5.08521 19.215 5.57524 19.705 6.18524 19.705H19.4052ZM17.4453 9.15503H8.15527C7.82527 9.15503 7.5553 9.42503 7.5553 9.75503C7.5553 10.085 7.82527 10.355 8.15527 10.355H17.4453C17.7753 10.355 18.0453 10.085 18.0453 9.75503C18.0453 9.42503 17.7753 9.15503 17.4453 9.15503ZM17.4453 13.635H8.15527C7.82527 13.635 7.5553 13.905 7.5553 14.235C7.5553 14.565 7.82527 14.835 8.15527 14.835H17.4453C17.7753 14.835 18.0453 14.565 18.0453 14.235C18.0453 13.905 17.7753 13.635 17.4453 13.635Z"/></svg>',
},
{
name: 'horizontal-rule',
action: EasyMDE.drawHorizontalRule,
title: 'Horizontal Rule',
title: this.$t('input.editor.horizontalRule'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M21 13H3C2.45 13 2 12.55 2 12C2 11.45 2.45 11 3 11H21C21.55 11 22 11.45 22 12C22 12.55 21.55 13 21 13Z"/></svg>',
},
'|',
{
name: 'side-by-side',
action: EasyMDE.toggleSideBySide,
title: 'Side By Side',
title: this.$t('input.editor.sideBySide'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M18.4787 14.58C18.3587 14.69 18.2987 14.85 18.2987 15C18.2987 15.15 18.3587 15.31 18.4787 15.42C18.7187 15.65 19.0987 15.65 19.3287 15.42L22.3287 12.42C22.5587 12.18 22.5587 11.8 22.3287 11.57L19.3287 8.56996C19.0887 8.33996 18.7087 8.33996 18.4787 8.56996C18.2487 8.80996 18.2487 9.18996 18.4787 9.41996L20.451 11.3999L14.4487 11.3999L14.4487 4.6C14.4487 4.27 14.1787 4 13.8487 4C13.5187 4 13.2487 4.27 13.2487 4.6L13.2487 19.4C13.2487 19.73 13.5187 20 13.8487 20C14.1787 20 14.4487 19.73 14.4487 19.4L14.4487 12.5999L20.4511 12.5999L18.4787 14.58ZM9.54878 19.4L9.54878 12.5999L3.5486 12.5999L5.52867 14.58C5.75867 14.81 5.75867 15.19 5.52867 15.43C5.29867 15.66 4.91867 15.66 4.67867 15.43L1.67867 12.43C1.63274 12.384 1.5956 12.3323 1.56725 12.2774C1.53058 12.2077 1.50724 12.1299 1.50068 12.0477C1.49934 12.0317 1.49867 12.0158 1.49867 12C1.49867 11.9841 1.49933 11.9682 1.50067 11.9522C1.51454 11.778 1.60365 11.6242 1.73526 11.5234L4.67867 8.57997C4.90867 8.34997 5.28867 8.34997 5.52867 8.57997C5.75867 8.80997 5.75867 9.18997 5.52867 9.42997L3.55107 11.3999L9.54878 11.3999L9.54878 4.6C9.54878 4.27 9.81878 4 10.1488 4C10.4788 4 10.7488 4.27 10.7488 4.6L10.7488 11.9999L10.7488 19.4C10.7488 19.73 10.4788 20 10.1488 20C9.81878 20 9.54878 19.73 9.54878 19.4Z"/></svg>',
},
{
@ -240,7 +243,7 @@ export default {
action: () => {
window.open('https://www.markdownguide.org/basic-syntax/', '_blank')
},
title: 'Guide',
title: this.$t('input.editor.guide'),
icon: '<svg viewBox="0 0 24 24"><rect fill="none" rx="0" ry="0"/><path fill-rule="evenodd" clip-rule="evenodd" d="M19.4999 2.3999H6.4999C5.0699 2.3999 3.8999 3.5699 3.8999 4.9999V18.9999C3.8999 20.4299 5.0699 21.5999 6.4999 21.5999H19.4999C19.8299 21.5999 20.0999 21.3299 20.0999 20.9999V16.9999V2.9999C20.0999 2.6699 19.8299 2.3999 19.4999 2.3999ZM5.0999 4.9999V16.8118C5.50468 16.5513 5.98546 16.3999 6.4999 16.3999H18.8999V3.5999H6.4999C5.7299 3.5999 5.0999 4.2299 5.0999 4.9999ZM6.4999 17.5999H18.8999V20.3999H6.4999C5.7299 20.3999 5.0999 19.7699 5.0999 18.9999C5.0999 18.2299 5.7299 17.5999 6.4999 17.5999ZM8.4999 8.5999H15.4999C15.8299 8.5999 16.0999 8.3299 16.0999 7.9999C16.0999 7.6699 15.8299 7.3999 15.4999 7.3999H8.4999C8.1699 7.3999 7.8999 7.6699 7.8999 7.9999C7.8999 8.3299 8.1699 8.5999 8.4999 8.5999ZM15.4999 11.3999H8.4999C8.1699 11.3999 7.8999 11.6699 7.8999 11.9999C7.8999 12.3299 8.1699 12.5999 8.4999 12.5999H15.4999C15.8299 12.5999 16.0999 12.3299 16.0999 11.9999C16.0999 11.6699 15.8299 11.3999 15.4999 11.3999Z"/></svg>',
},
],
@ -253,14 +256,17 @@ export default {
this.$nextTick(this.renderPreview)
},
text(newVal, oldVal) {
if (oldVal === '') {
// Only bubble the new value if it actually changed, but not if the component just got mounted and the text changed from the outside.
if (oldVal === '' && this.text === this.value) {
return
}
this.bubble()
},
},
beforeMount() {
this.text = this.value
mounted() {
if (this.value !== '') {
this.text = this.value
}
if (this.previewIsDefault && this.hasPreview) {
this.$nextTick(this.renderPreview)
@ -279,7 +285,7 @@ export default {
// that in the end, only one change event is triggered to the outside per change.
handleInput(val) {
// Don't bubble if the text is up to date
if(val === this.text) {
if (val === this.text) {
return
}
@ -293,7 +299,7 @@ export default {
this.changeTimeout = setTimeout(() => {
this.$emit('input', this.text)
this.$emit('change')
this.$emit('change', this.text)
}, timeout)
},
replaceAt(str, index, replacement) {
@ -360,17 +366,17 @@ export default {
link: (href, title, text) => {
const isLocal = href.startsWith(`${location.protocol}//${location.hostname}`)
const html = linkRenderer.call(renderer, href, title, text)
return isLocal ? html : html.replace(/^<a /, `<a target="_blank" rel="noreferrer noopener nofollow" `)
return isLocal ? html : html.replace(/^<a /, '<a target="_blank" rel="noreferrer noopener nofollow" ')
},
},
highlight: function (code, language) {
const hljs = require('highlight.js')
const validLanguage = hljs.getLanguage(language) ? language : 'plaintext'
return hljs.highlight(validLanguage, code).value
return hljs.highlight(code, {language: validLanguage}).value
},
})
this.preview = DOMPurify.sanitize(marked(this.text), { ADD_ATTR: ['target'] })
this.preview = DOMPurify.sanitize(marked(this.text), {ADD_ATTR: ['target']})
// Since the render function is synchronous, we can't do async http requests in it.
// Therefore, we can't resolve the blob url at (markdown) compile time.
@ -387,6 +393,13 @@ export default {
const parts = img.dataset.src.substr(window.API_URL.length + 1).split('/')
const taskId = parseInt(parts[1])
const attachmentId = parseInt(parts[3])
const cacheKey = `${taskId}-${attachmentId}`
if (typeof this.loadedAttachments[cacheKey] !== 'undefined') {
img.src = this.loadedAttachments[cacheKey]
continue
}
const attachment = new AttachmentModel({taskId: taskId, id: attachmentId})
if (this.attachmentService === null) {
@ -396,6 +409,7 @@ export default {
this.attachmentService.getBlobUrl(attachment)
.then(url => {
img.src = url
this.loadedAttachments[cacheKey] = url
})
}
}
@ -445,7 +459,7 @@ export default {
</script>
<style lang="scss">
@import '../../../node_modules/highlight.js/scss/atelier-heath-light';
@import '../../../node_modules/highlight.js/scss/base16/equilibrium-gray-light';
@import '../../../node_modules/easymde/dist/easymde.min.css';
@import '../../styles/theme/variables/all';

View File

@ -3,6 +3,8 @@
class="multiselect"
:class="{'has-search-results': searchResultsVisible}"
ref="multiselectRoot"
tabindex="-1"
@focus="focus"
>
<div class="control" :class="{'is-loading': loading || localLoading}">
<div class="input-wrapper input" :class="{'has-multiple': multiple && Array.isArray(internalValue) && internalValue.length > 0}">
@ -24,36 +26,15 @@
@keyup="search"
@keyup.enter.exact.prevent="() => createOrSelectOnEnter()"
:placeholder="placeholder"
@keydown.down.exact.prevent="() => preSelect(0, true)"
@keydown.down.exact.prevent="() => preSelect(0)"
ref="searchInput"
@focus="() => showSearchResults = true"
@focus="handleFocus"
/>
</div>
</div>
<transition name="fade">
<div class="search-results" v-if="searchResultsVisible">
<button
v-if="creatableAvailable"
class="is-fullwidth"
ref="result--1"
@keydown.up.prevent="() => preSelect(-2)"
@keydown.down.prevent="() => preSelect(0)"
@keyup.enter.prevent="create"
@click.prevent.stop="create"
>
<span>
<slot name="searchResult" :option="query">
<span class="search-result">
{{ query }}
</span>
</slot>
</span>
<span class="hint-text">
{{ createPlaceholder }}
</span>
</button>
<div class="search-results" :class="{'search-results-inline': inline}" v-if="searchResultsVisible">
<button
class="is-fullwidth"
v-for="(data, key) in filteredSearchResults"
@ -72,6 +53,27 @@
{{ selectPlaceholder }}
</span>
</button>
<button
v-if="creatableAvailable"
class="is-fullwidth"
:ref="`result-${filteredSearchResults.length}`"
@keydown.up.prevent="() => preSelect(filteredSearchResults.length - 1)"
@keydown.down.prevent="() => preSelect(filteredSearchResults.length + 1)"
@keyup.enter.prevent="create"
@click.prevent.stop="create"
>
<span>
<slot name="searchResult" :option="query">
<span class="search-result">
{{ query }}
</span>
</slot>
</span>
<span class="hint-text">
{{ createPlaceholder }}
</span>
</button>
</div>
</transition>
@ -106,21 +108,21 @@ export default {
type: Boolean,
default() {
return false
}
},
},
// The placeholder of the search input
placeholder: {
type: String,
default() {
return ''
}
},
},
// The search results where the @search listener needs to put the results into
searchResults: {
type: Array,
default() {
return []
}
},
},
// The name of the property of the searched object to show the user.
// If empty the component will show all raw data of an entry.
@ -128,13 +130,13 @@ export default {
type: String,
default() {
return ''
}
},
},
// The object with the value, updated every time an entry is selected.
value: {
default() {
return null
}
},
},
// If true, will provide an "add this as a new value" entry which fires an @create event when clicking on it.
creatable: {
@ -147,14 +149,14 @@ export default {
createPlaceholder: {
type: String,
default() {
return 'Create new'
return this.$t('input.multiselect.createPlaceholder')
},
},
// The text shown next to an option.
selectPlaceholder: {
type: String,
default() {
return 'Click or press enter to select'
return this.$t('input.multiselect.selectPlaceholder')
},
},
// If true, allows for selecting multiple items. v-model will be an array with all selected values in that case.
@ -164,6 +166,27 @@ export default {
return false
},
},
// If true, displays the search results inline instead of using a dropdown.
inline: {
type: Boolean,
default() {
return false
},
},
// If true, shows search results when no query is specified.
showEmpty: {
type: Boolean,
default() {
return true
},
},
// The delay in ms after which the search event will be fired. Used to avoid hitting the network on every keystroke.
searchDelay: {
type: Number,
default() {
return 200
},
},
},
mounted() {
document.addEventListener('click', this.hideSearchResultsHandler)
@ -179,6 +202,10 @@ export default {
},
computed: {
searchResultsVisible() {
if (this.query === '' && !this.showEmpty) {
return false
}
return this.showSearchResults && (
(this.filteredSearchResults.length > 0) ||
(this.creatable && this.query !== '')
@ -195,7 +222,7 @@ export default {
})
},
filteredSearchResults() {
if (this.multiple && this.internalValue !== null) {
if (this.multiple && this.internalValue !== null && Array.isArray(this.internalValue)) {
return this.searchResults.filter(item => !this.internalValue.some(e => e === item))
}
@ -209,7 +236,7 @@ export default {
// Updating the query with a binding does not work on mobile for some reason,
// getting the value manual does.
this.query = this.$refs.searchInput.value
if (this.searchTimeout !== null) {
clearTimeout(this.searchTimeout)
this.searchTimeout = null
@ -223,7 +250,7 @@ export default {
this.localLoading = false
}, 100) // The duration of the loading timeout of the services
this.showSearchResults = true
}, 200)
}, this.searchDelay)
},
hideSearchResultsHandler(e) {
closeWhenClickedOutside(e, this.$refs.multiselectRoot, this.closeSearchResults)
@ -231,6 +258,13 @@ export default {
closeSearchResults() {
this.showSearchResults = false
},
handleFocus() {
// We need the timeout to avoid the hideSearchResultsHandler hiding the search results right after the input
// is focused. That would lead to flickering pre-loaded search results and hiding them right after showing.
setTimeout(() => {
this.showSearchResults = true
}, 10)
},
select(object) {
if (this.multiple) {
if (this.internalValue === null) {
@ -268,13 +302,8 @@ export default {
this.query = this.label !== '' ? object[this.label] : object
},
preSelect(index, lookForCreatable = false) {
if (index === 0 && this.creatable && lookForCreatable) {
index = -1
}
if (index < -1) {
preSelect(index) {
if (index < 0) {
this.$refs.searchInput.focus()
return
}
@ -324,6 +353,9 @@ export default {
this.$emit('input', this.internalValue)
this.$emit('remove', item)
},
focus() {
this.$refs.searchInput.focus()
},
},
}
</script>

View File

@ -5,13 +5,13 @@
:to="{ name: `${listRoutePrefix}.settings.edit`, params: { listId: list.id } }"
icon="pen"
>
Edit
{{ $t('menu.edit') }}
</dropdown-item>
<dropdown-item
:to="{ name: `${listRoutePrefix}.settings.delete`, params: { listId: list.id } }"
icon="trash-alt"
>
Delete
{{ $t('misc.delete') }}
</dropdown-item>
</template>
<template v-else-if="list.isArchived">
@ -19,7 +19,7 @@
:to="{ name: `${listRoutePrefix}.settings.archive`, params: { listId: list.id } }"
icon="archive"
>
Un-Archive
{{ $t('menu.unarchive') }}
</dropdown-item>
</template>
<template v-else>
@ -27,32 +27,32 @@
:to="{ name: `${listRoutePrefix}.settings.edit`, params: { listId: list.id } }"
icon="pen"
>
Edit
{{ $t('menu.edit') }}
</dropdown-item>
<dropdown-item
:to="{ name: `${listRoutePrefix}.settings.background`, params: { listId: list.id } }"
v-if="backgroundsEnabled"
icon="image"
>
Set background
{{ $t('menu.setBackground') }}
</dropdown-item>
<dropdown-item
:to="{ name: `${listRoutePrefix}.settings.share`, params: { listId: list.id } }"
icon="share-alt"
>
Share
{{ $t('menu.share') }}
</dropdown-item>
<dropdown-item
:to="{ name: `${listRoutePrefix}.settings.duplicate`, params: { listId: list.id } }"
icon="paste"
>
Duplicate
{{ $t('menu.duplicate') }}
</dropdown-item>
<dropdown-item
:to="{ name: `${listRoutePrefix}.settings.archive`, params: { listId: list.id } }"
icon="archive"
>
Archive
{{ $t('menu.archive') }}
</dropdown-item>
<task-subscription
class="dropdown-item has-no-shadow"
@ -67,7 +67,7 @@
icon="trash-alt"
class="has-text-danger"
>
Delete
{{ $t('menu.delete') }}
</dropdown-item>
</template>
</dropdown>
@ -106,7 +106,7 @@ export default {
listRoutePrefix() {
let name = 'list'
if (this.$route.name.startsWith('list.')) {
if (this.$route.name !== null && this.$route.name.startsWith('list.')) {
name = this.$route.name
}

View File

@ -1,24 +1,38 @@
<template>
<card class="filters has-overflow">
<fancycheckbox v-model="params.filter_include_nulls">
Include Tasks which don't have a value set
{{ $t('filters.attributes.includeNulls') }}
</fancycheckbox>
<fancycheckbox
v-model="filters.requireAllFilters"
@change="setFilterConcat()"
>
Require all filters to be true for a task to show up
{{ $t('filters.attributes.requireAll') }}
</fancycheckbox>
<div class="field">
<label class="label">Show Done Tasks</label>
<label class="label">
{{ $t('filters.attributes.showDoneTasks') }}
</label>
<div class="control">
<fancycheckbox @change="setDoneFilter" v-model="filters.done">
Show Done Tasks
{{ $t('filters.attributes.showDoneTasks') }}
</fancycheckbox>
</div>
</div>
<div class="field">
<label class="label">Priority</label>
<label class="label">{{ $t('misc.search') }}</label>
<div class="control">
<input
class="input"
:placeholder="$t('misc.search')"
v-model="params.s"
@blur="change()"
@keyup.enter="change()"
/>
</div>
</div>
<div class="field">
<label class="label">{{ $t('task.attributes.priority') }}</label>
<div class="control single-value-control">
<priority-select
:disabled="!filters.usePriority"
@ -29,12 +43,12 @@
v-model="filters.usePriority"
@change="setPriority"
>
Enable Filter By Priority
{{ $t('filters.attributes.enablePriority') }}
</fancycheckbox>
</div>
</div>
<div class="field">
<label class="label">Percent Done</label>
<label class="label">{{ $t('task.attributes.percentDone') }}</label>
<div class="control single-value-control">
<percent-done-select
v-model.number="filters.percentDone"
@ -45,65 +59,65 @@
v-model="filters.usePercentDone"
@change="setPercentDoneFilter"
>
Enable Filter By Percent Done
{{ $t('filters.attributes.enablePercentDone') }}
</fancycheckbox>
</div>
</div>
<div class="field">
<label class="label">Due Date</label>
<label class="label">{{ $t('task.attributes.dueDate') }}</label>
<div class="control">
<flat-pickr
:config="flatPickerConfig"
@on-close="setDueDateFilter"
class="input"
placeholder="Due Date Range"
:placeholder="$t('filters.attributes.dueDateRange')"
v-model="filters.dueDate"
/>
</div>
</div>
<div class="field">
<label class="label">Start Date</label>
<label class="label">{{ $t('task.attributes.startDate') }}</label>
<div class="control">
<flat-pickr
:config="flatPickerConfig"
@on-close="setStartDateFilter"
class="input"
placeholder="Start Date Range"
:placeholder="$t('filters.attributes.startDateRange')"
v-model="filters.startDate"
/>
</div>
</div>
<div class="field">
<label class="label">End Date</label>
<label class="label">{{ $t('task.attributes.endDate') }}</label>
<div class="control">
<flat-pickr
:config="flatPickerConfig"
@on-close="setEndDateFilter"
class="input"
placeholder="End Date Range"
:placeholder="$t('filters.attributes.endDateRange')"
v-model="filters.endDate"
/>
</div>
</div>
<div class="field">
<label class="label">Reminders</label>
<label class="label">{{ $t('task.attributes.reminders') }}</label>
<div class="control">
<flat-pickr
:config="flatPickerConfig"
@on-close="setReminderFilter"
class="input"
placeholder="Reminder Date Range"
:placeholder="$t('filters.attributes.reminderRange')"
v-model="filters.reminders"
/>
</div>
</div>
<div class="field">
<label class="label">Assignees</label>
<label class="label">{{ $t('task.attributes.assignees') }}</label>
<div class="control">
<multiselect
:loading="usersService.loading"
placeholder="Type to search for a user..."
:placeholder="$t('team.edit.search')"
@search="query => find('users', query)"
:search-results="foundusers"
@select="() => add('users', 'assignees')"
@ -116,37 +130,19 @@
</div>
<div class="field">
<label class="label">Labels</label>
<div class="control">
<multiselect
:loading="labelService.loading"
placeholder="Type to search for a label..."
@search="findLabels"
:search-results="foundLabels"
@select="label => addLabel(label)"
label="title"
:multiple="true"
v-model="labels"
>
<template v-slot:tag="props">
<span
:style="{'background': props.item.hexColor, 'color': props.item.textColor}"
class="tag ml-2 mt-2">
<span>{{ props.item.title }}</span>
<a @click="removeLabel(props.item)" class="delete is-small"></a>
</span>
</template>
</multiselect>
<label class="label">{{ $t('task.attributes.labels') }}</label>
<div class="control labels-list">
<edit-labels v-model="labels" @change="changeLabelFilter"/>
</div>
</div>
<template v-if="$route.name === 'filters.create' || $route.name === 'list.edit'">
<div class="field">
<label class="label">Lists</label>
<label class="label">{{ $t('list.lists') }}</label>
<div class="control">
<multiselect
:loading="listsService.loading"
placeholder="Type to search for a list..."
:placeholder="$t('list.search')"
@search="query => find('lists', query)"
:search-results="foundlists"
@select="() => add('lists', 'list_id')"
@ -158,11 +154,11 @@
</div>
</div>
<div class="field">
<label class="label">Namespaces</label>
<label class="label">{{ $t('namespace.namespaces') }}</label>
<div class="control">
<multiselect
:loading="namespaceService.loading"
placeholder="Type to search for a namespace..."
:placeholder="$t('namespace.search')"
@search="query => find('namespace', query)"
:search-results="foundnamespace"
@select="() => add('namespace', 'namespace')"
@ -190,13 +186,14 @@ import PercentDoneSelect from '@/components/tasks/partials/percentDoneSelect.vue
import Multiselect from '@/components/input/multiselect.vue'
import UserService from '@/services/user'
import LabelService from '@/services/label'
import ListService from '@/services/list'
import NamespaceService from '@/services/namespace'
import EditLabels from '@/components/tasks/partials/editLabels'
export default {
name: 'filters',
components: {
EditLabels,
PrioritySelect,
Fancycheckbox,
flatPickr,
@ -213,6 +210,7 @@ export default {
filter_comparator: [],
filter_include_nulls: true,
filter_concat: 'or',
s: '',
},
filters: {
done: false,
@ -230,21 +228,12 @@ export default {
list_id: '',
namespace: '',
},
flatPickerConfig: {
altFormat: 'j M Y H:i',
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
mode: 'range',
},
usersService: UserService,
foundusers: [],
users: [],
labelService: LabelService,
foundLabels: [],
labelQuery: '',
labels: [],
listsService: ListService,
@ -258,7 +247,6 @@ export default {
},
created() {
this.usersService = new UserService()
this.labelService = new LabelService()
this.listsService = new ListService()
this.namespaceService = new NamespaceService()
},
@ -278,6 +266,30 @@ export default {
this.prepareFilters()
},
},
computed: {
foundLabels() {
const labels = (Object.values(this.$store.state.labels.labels).filter(l => {
return l.title.toLowerCase().includes(this.labelQuery.toLowerCase())
}) ?? [])
return differenceWith(labels, this.labels, (first, second) => {
return first.id === second.id
})
},
flatPickerConfig() {
return {
altFormat: this.$t('date.altFormatLong'),
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
mode: 'range',
locale: {
firstDayOfWeek: this.$store.state.auth.settings.weekStart,
},
}
},
},
methods: {
change() {
this.$emit('input', this.params)
@ -292,11 +304,16 @@ export default {
this.prepareSingleValue('percent_done', 'percentDone', 'usePercentDone', true)
this.prepareDate('reminders')
this.prepareRelatedObjectFilter('users', 'assignees')
this.prepareRelatedObjectFilter('labels', 'labels', 'label')
this.prepareRelatedObjectFilter('lists', 'list_id')
this.prepareRelatedObjectFilter('namespace')
this.prepareSingleValue('labels')
const labelIds = (typeof this.filters.labels === 'string' ? this.filters.labels : '').split(',').map(i => parseInt(i))
this.labels = (Object.values(this.$store.state.labels.labels).filter(l => labelIds.includes(l.id)) ?? [])
},
removePropertyFromFilter(propertyName) {
// Because of the way arrays work, we can only ever remove one element at once.
// To remove multiple filter elements of the same name this function has to be called multiple times.
for (const i in this.params.filter_by) {
if (this.params.filter_by[i] === propertyName) {
this.params.filter_by.splice(i, 1)
@ -306,7 +323,11 @@ export default {
}
}
},
setDateFilter(filterName, variableName) {
setDateFilter(filterName, variableName = null) {
if (variableName === null) {
variableName = filterName
}
// Only filter if we have a start and end due date
if (this.filters[variableName] !== '') {
@ -341,7 +362,12 @@ export default {
this.params.filter_value.push(formatISO(new Date(parts[1])))
}
this.change()
return
}
this.removePropertyFromFilter(filterName)
this.removePropertyFromFilter(filterName)
this.change()
},
prepareDate(filterName, variableName) {
if (typeof this.params.filter_by === 'undefined') {
@ -457,7 +483,7 @@ export default {
.then(r => {
this.$set(this, kind, r)
})
.catch(e => this.error(e, this))
.catch(e => this.error(e))
}
},
setDoneFilter() {
@ -512,7 +538,7 @@ export default {
}))
})
.catch(e => {
this.error(e, this)
this.error(e)
})
},
add(kind, filterName) {
@ -540,25 +566,8 @@ export default {
this.$set(this.filters, filterName, ids.join(','))
this.setSingleValueFilter(filterName, filterName, '', 'in')
},
clearLabels() {
this.$set(this, 'foundLabels', [])
},
findLabels(query) {
if (query === '') {
this.clearLabels()
}
this.labelService.getAll({}, {s: query})
.then(response => {
// Filter the results to not include labels already selected
this.$set(this, 'foundLabels', differenceWith(response, this.labels, (first, second) => {
return first.id === second.id
}))
})
.catch(e => {
this.error(e, this)
})
this.labelQuery = query
},
addLabel() {
this.$nextTick(() => {

View File

@ -0,0 +1,89 @@
<template>
<router-link
:class="{
'has-light-text': !colorIsDark(list.hexColor),
'has-background': background !== null
}"
:style="{
'background-color': list.hexColor,
'background-image': background !== null ? `url(${background})` : false,
}"
:to="{ name: 'list.index', params: { listId: list.id} }"
class="list-card"
tag="span"
v-if="list !== null && (showArchived ? true : !list.isArchived)"
>
<div class="is-archived-container">
<span class="is-archived" v-if="list.isArchived">
{{ $t('namespace.archived') }}
</span>
<span
: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/>
</span>
</div>
<div class="title">{{ list.title }}</div>
</router-link>
</template>
<script>
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() {
this.loadBackground()
},
},
created() {
this.loadBackground()
},
methods: {
loadBackground() {
if (this.list === null || !this.list.backgroundInformation || this.backgroundLoading) {
return
}
this.backgroundLoading = true
const listService = new ListService()
listService.background(this.list)
.then(b => {
this.$set(this, 'background', b)
})
.catch(e => {
this.error(e)
})
.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)
.catch(e => this.error(e))
},
},
}
</script>

View File

@ -1,15 +1,15 @@
<template>
<div class="content">
<h1>Import your data from {{ name }} to Vikunja</h1>
<p>Vikunja will import all lists, tasks, notes, reminders and files you have access to.</p>
<h1>{{ $t('migrate.titleService', { name: name }) }}</h1>
<p>{{ $t('migrate.descriptionDo') }}</p>
<template v-if="isMigrating === false && message === '' && lastMigrationDate === null">
<p>To authorize Vikunja to access your {{ name }} Account, click the button below.</p>
<p>{{ $t('migrate.authorize', {name: name}) }}</p>
<x-button
:loading="migrationService.loading"
:disabled="migrationService.loading"
:href="authUrl"
>
Get Started
{{ $t('migrate.getStarted') }}
</x-button>
</template>
<div
@ -29,17 +29,16 @@
</div>
<img alt="Vikunja" src="/images/logo.svg">
</div>
<p>Importing in progress, hang tight...</p>
<p>{{ $t('migrate.inProgress') }}</p>
</div>
<div v-else-if="lastMigrationDate">
<p>
It looks like you've already imported your stuff from {{ name }} at {{ formatDate(lastMigrationDate) }}.<br/>
Importing again is possible, but might create duplicates.
Are you sure?
{{ $t('migrate.alreadyMigrated1', { name: name, date: formatDate(lastMigrationDate) }) }}<br/>
{{ $t('migrate.alreadyMigrated2') }}
</p>
<div class="buttons">
<x-button @click="migrate">I am sure, please start migrating now!</x-button>
<x-button :to="{name: 'home'}" type="tertary" class="has-text-danger">Cancel</x-button>
<x-button @click="migrate">{{ $t('migrate.confirm') }}</x-button>
<x-button :to="{name: 'home'}" type="tertary" class="has-text-danger">{{ $t('misc.cancel') }}</x-button>
</div>
</div>
<div v-else>
@ -48,7 +47,7 @@
{{ message }}
</div>
</div>
<x-button :to="{name: 'home'}">Refresh</x-button>
<x-button :to="{name: 'home'}">{{ $t('misc.refresh') }}</x-button>
</div>
</div>
</template>
@ -85,7 +84,7 @@ export default {
if (typeof this.$route.query.code !== 'undefined' || location.hash.startsWith('#token=')) {
if (location.hash.startsWith('#token=')) {
this.migratorAuthCode = location.hash.substring(7)
console.log(location.hash.substring(7))
console.debug(location.hash.substring(7))
} else {
this.migratorAuthCode = this.$route.query.code
}
@ -105,7 +104,7 @@ export default {
this.migrate()
})
.catch(e => {
this.error(e, this)
this.error(e)
})
}
},
@ -116,7 +115,7 @@ export default {
this.authUrl = r.url
})
.catch(e => {
this.error(e, this)
this.error(e)
})
},
migrate() {
@ -129,7 +128,7 @@ export default {
this.$store.dispatch('namespaces/loadNamespaces')
})
.catch(e => {
this.error(e, this)
this.error(e)
})
.finally(() => {
this.isMigrating = false

View File

@ -1,13 +1,13 @@
<template>
<div class="api-config">
<div v-if="configureApi">
<label class="label" for="api-url">Vikunja URL</label>
<label class="label" for="api-url">{{ $t('apiConfig.url') }}</label>
<div class="field has-addons">
<div class="control is-expanded">
<input
class="input"
id="api-url"
placeholder="eg. https://localhost:3456"
:placeholder="$t('apiConfig.urlPlaceholder')"
required
type="url"
v-focus
@ -17,16 +17,17 @@
</div>
<div class="control">
<x-button @click="setApiUrl" :disabled="apiUrl === ''">
Change
{{ $t('apiConfig.change') }}
</x-button>
</div>
</div>
</div>
<div class="api-url-info" v-else>
Sign in to your Vikunja account on
<span v-tooltip="apiUrl"> {{ apiDomain() }} </span>
<i18n path="apiConfig.signInOn">
<span class="url" v-tooltip="apiUrl"> {{ apiDomain() }} </span>
</i18n>
<br />
<a @click="() => (configureApi = true)">change</a>
<a @click="() => (configureApi = true)">{{ $t('apiConfig.change') }}</a>
</div>
<div
@ -178,17 +179,18 @@ export default {
.catch(() => {
// Still not found, url is still invalid
this.successMsg = ''
this.errorMsg = `Could not find or use Vikunja installation at "${this.apiDomain()}".`
this.errorMsg = this.$t('apiConfig.error', {domain: this.apiDomain()})
window.API_URL = oldUrl
})
.then((r) => {
if (typeof r !== 'undefined') {
// Set it + save it to local storage to save us the hoops
this.errorMsg = ''
this.successMsg = `Using Vikunja installation at "${this.apiDomain()}".`
this.successMsg = this.$t('apiConfig.success', {domain: this.apiDomain()})
localStorage.setItem('API_URL', window.API_URL)
this.configureApi = false
this.apiUrl = window.API_URL
this.$emit('foundApi', this.apiUrl)
}
})
},

View File

@ -26,7 +26,7 @@
type="secondary"
@click.prevent.stop="$router.back()"
>
Cancel
{{ $t('misc.cancel') }}
</x-button>
<x-button
type="primary"
@ -52,7 +52,9 @@ export default {
},
primaryLabel: {
type: String,
default: 'Create',
default() {
return this.$t('misc.create')
},
},
primaryIcon: {
type: String,

View File

@ -22,7 +22,7 @@ export default {
type: String,
required: false,
default: '',
}
},
},
}
</script>

View File

@ -42,6 +42,7 @@ export default {
if (this.open) {
closeWhenClickedOutside(e, this.$refs.dropdown, () => {
this.open = false
this.$emit('close', e)
})
}
},

View File

@ -1,7 +1,9 @@
<template>
<div class="notification is-danger">
Loading failed, please <a @click="() => location.reload()">try again</a>.
If the error persists, please <a href="https://vikunja.io/contact/">contact us</a>.
<i18n path="loadingError.failed">
<a @click="() => location.reload()">{{ $t('loadingError.tryAgain') }}</a>
<a href="https://vikunja.io/contact/">{{ $t('loadingError.contact') }}</a>
</i18n>
</div>
</template>

View File

@ -1,48 +1,57 @@
<template>
<div class="modal-mask keyboard-shortcuts-modal">
<div class="modal-mask hint-modal">
<div @click.self="close()" class="modal-container">
<div class="modal-content">
<card class="has-background-white has-no-shadow" title="Available Keyboard Shortcuts">
<p>
<strong>Toggle The Menu</strong>
<shortcut :keys="['ctrl', 'e']"/>
</p>
<h3>Kanban</h3>
<div class="message is-primary" v-if="$route.name === 'list.kanban'">
<card class="has-background-white has-no-shadow" :title="$t('keyboardShortcuts.title')">
<div class="message is-primary">
<div class="message-body">
These shortcuts work on the current page.
{{ $t('keyboardShortcuts.allPages') }}
</div>
</div>
<p>
<strong>Mark a task as done</strong>
<strong>{{ $t('keyboardShortcuts.toggleMenu') }}</strong>
<shortcut :keys="['ctrl', 'e']"/>
</p>
<p>
<strong>{{ $t('keyboardShortcuts.quickSearch') }}</strong>
<shortcut :keys="['ctrl', 'k']"/>
</p>
<h3>{{ $t('list.kanban.title') }}</h3>
<div class="message is-primary" v-if="$route.name === 'list.kanban'">
<div class="message-body">
{{ $t('keyboardShortcuts.currentPageOnly') }}
</div>
</div>
<p>
<strong>{{ $t('keyboardShortcuts.task.done') }}</strong>
<shortcut :keys="['ctrl', 'click']"/>
</p>
<h3>Task Page</h3>
<h3>{{ $t('keyboardShortcuts.task.title') }}</h3>
<div
class="message is-primary"
v-if="$route.name === 'task.detail' || $route.name === 'task.list.detail' || $route.name === 'task.gantt.detail' || $route.name === 'task.kanban.detail' || $route.name === 'task.detail'">
<div class="message-body">
These shortcuts work on the current page.
{{ $t('keyboardShortcuts.currentPageOnly') }}
</div>
</div>
<p>
<strong>Assign this task to a user</strong>
<strong>{{ $t('keyboardShortcuts.task.assign') }}</strong>
<shortcut :keys="['a']"/>
</p>
<p>
<strong>Add labels to this task</strong>
<strong>{{ $t('keyboardShortcuts.task.labels') }}</strong>
<shortcut :keys="['l']"/>
</p>
<p>
<strong>Change the due date of this task</strong>
<strong>{{ $t('keyboardShortcuts.task.dueDate') }}</strong>
<shortcut :keys="['d']"/>
</p>
<p>
<strong>Add an attachment to this task</strong>
<strong>{{ $t('keyboardShortcuts.task.attachment') }}</strong>
<shortcut :keys="['f']"/>
</p>
<p>
<strong>Modify related tasks of this task</strong>
<strong>{{ $t('keyboardShortcuts.task.related') }}</strong>
<shortcut :keys="['r']"/>
</p>
</card>

View File

@ -1,8 +1,8 @@
<template>
<div class="legal-links">
<a :href="imprintUrl" target="_blank" v-if="imprintUrl">Imprint</a>
<a :href="imprintUrl" target="_blank" v-if="imprintUrl">{{ $t('navigation.imprint') }}</a>
<span v-if="imprintUrl && privacyPolicyUrl"> | </span>
<a :href="privacyPolicyUrl" target="_blank" v-if="privacyPolicyUrl">Privacy policy</a>
<a :href="privacyPolicyUrl" target="_blank" v-if="privacyPolicyUrl">{{ $t('navigation.privacy') }}</a>
</div>
</template>

View File

@ -6,6 +6,6 @@
<script>
export default {
name: 'nothing'
name: 'nothing',
}
</script>

View File

@ -11,13 +11,17 @@
>
<div
class="notification-title"
v-html="props.item.title"
v-if="props.item.title"
></div>
>
{{ props.item.title }}
</div>
<div
class="notification-content"
v-html="props.item.text"
></div>
>
<template v-for="(t, k) in props.item.text">
{{ t }}<br :key="k"/>
</template>
</div>
<div
class="buttons is-right"
v-if="

View File

@ -14,7 +14,7 @@ export default {
keys: {
type: Array,
required: true,
}
},
},
}
</script>

View File

@ -54,16 +54,19 @@ export default {
},
computed: {
tooltipText() {
if(this.disabled) {
return `You can't unsubscribe here because you are subscribed to this ${this.entity} through its ${this.subscription.entity}.`
if (this.disabled) {
return this.$t('task.subscription.subscribedThroughParent', {
entity: this.entity,
parent: this.subscription.entity,
})
}
return this.subscription !== null ?
`You are currently subscribed to this ${this.entity} and will receive notifications for changes.` :
`You are not subscribed to this ${this.entity} and won't receive notifications for changes.`
this.$t('task.subscription.subscribed', {entity: this.entity}) :
this.$t('task.subscription.notSubscribed', {entity: this.entity})
},
buttonText() {
return this.subscription !== null ? 'Unsubscribe' : 'Subscribe'
return this.subscription !== null ? this.$t('task.subscription.unsubscribe') : this.$t('task.subscription.subscribe')
},
icon() {
return this.subscription !== null ? ['far', 'bell-slash'] : 'bell'
@ -78,7 +81,7 @@ export default {
},
methods: {
changeSubscription() {
if(this.disabled) {
if (this.disabled) {
return
}
@ -96,10 +99,10 @@ export default {
this.subscriptionService.create(subscription)
.then(() => {
this.$emit('change', subscription)
this.success({message: `You are now subscribed to this ${this.entity}`}, this)
this.success({message: this.$t('task.subscription.subscribeSuccess', {entity: this.entity})})
})
.catch(e => {
this.error(e, this)
this.error(e)
})
},
unsubscribe() {
@ -110,12 +113,12 @@ export default {
this.subscriptionService.delete(subscription)
.then(() => {
this.$emit('change', null)
this.success({message: `You are now unsubscribed to this ${this.entity}`}, this)
this.success({message: this.$t('task.subscription.unsubscribeSuccess', {entity: this.entity})})
})
.catch(e => {
this.error(e, this)
this.error(e)
})
}
},
},
}
</script>

View File

@ -1,7 +1,7 @@
<template>
<transition name="modal">
<div class="modal-mask">
<div class="modal-container" @click.self.prevent.stop="$emit('close')">
<div class="modal-mask has-overflow" :class="{'has-overflow': overflow}">
<div class="modal-container" @mousedown.self.prevent.stop="$emit('close')" :class="{'has-overflow': overflow}">
<div class="modal-content" :class="{'has-overflow': overflow, 'is-wide': wide}">
<slot>
<div class="header">
@ -16,14 +16,14 @@
type="tertary"
class="has-text-danger"
>
Cancel
{{ $t('misc.cancel') }}
</x-button>
<x-button
@click="$emit('submit')"
type="primary"
:shadow="false"
>
Do it!
{{ $t('misc.doit') }}
</x-button>
</div>
</slot>

View File

@ -1,7 +1,7 @@
<template>
<multiselect
:loading="namespaceService.loading"
placeholder="Search for a namespace..."
:placeholder="$t('namespace.search')"
@search="findNamespaces"
:search-results="namespaces"
@select="select"
@ -43,7 +43,7 @@ export default {
this.$set(this, 'namespaces', response)
})
.catch(e => {
this.error(e, this)
this.error(e)
})
},
clearAll() {

View File

@ -5,7 +5,7 @@
:to="{ name: 'namespace.settings.archive', params: { id: namespace.id } }"
icon="archive"
>
Un-Archive
{{ $t('menu.unarchive') }}
</dropdown-item>
</template>
<template v-else>
@ -13,25 +13,25 @@
:to="{ name: 'namespace.settings.edit', params: { id: namespace.id } }"
icon="pen"
>
Edit
{{ $t('menu.edit') }}
</dropdown-item>
<dropdown-item
:to="{ name: 'namespace.settings.share', params: { id: namespace.id } }"
icon="share-alt"
>
Share
{{ $t('menu.share') }}
</dropdown-item>
<dropdown-item
:to="{ name: 'list.create', params: { id: namespace.id } }"
icon="plus"
>
New list
{{ $t('menu.newList') }}
</dropdown-item>
<dropdown-item
:to="{ name: 'namespace.settings.archive', params: { id: namespace.id } }"
icon="archive"
>
Archive
{{ $t('menu.archive') }}
</dropdown-item>
<task-subscription
class="dropdown-item has-no-shadow"
@ -46,7 +46,7 @@
icon="trash-alt"
class="has-text-danger"
>
Delete
{{ $t('menu.delete') }}
</dropdown-item>
</template>
</dropdown>

View File

@ -1,13 +1,15 @@
<template>
<div class="notifications">
<a @click.stop="showNotifications = !showNotifications" class="trigger">
<span class="unread-indicator" v-if="unreadNotifications > 0"></span>
<icon icon="bell"/>
</a>
<div class="is-flex is-justify-content-center">
<a @click.stop="showNotifications = !showNotifications" class="trigger-button">
<span class="unread-indicator" v-if="unreadNotifications > 0"></span>
<icon icon="bell"/>
</a>
</div>
<transition name="fade">
<div class="notifications-list" v-if="showNotifications" ref="popup">
<span class="head">Notifications</span>
<span class="head">{{ $t('notification.title') }}</span>
<div
v-for="(n, index) in notifications"
:key="n.id"
@ -16,22 +18,28 @@
<div class="read-indicator" :class="{'read': n.readAt !== null}"></div>
<user
:user="n.notification.doer"
:show-username="true"
:show-username="false"
:avatar-size="16"
v-if="n.notification.doer"/>
<span class="detail">
<p>
<span class="has-text-weight-bold" v-if="n.notification.doer">
{{ n.notification.doer.getDisplayName() }}
</span>
<a @click="() => to(n, index)()">
{{ n.toText(userInfo) }}
</a>
<span class="created" v-tooltip="formatDate(n.created)">
{{ formatDateSince(n.created) }}
</span>
</p>
<div class="created" v-tooltip="formatDate(n.created)">
{{ formatDateSince(n.created) }}
</div>
</span>
</div>
<p class="nothing" v-if="notifications.length === 0">
You don't have any notifications. Have a nice day!<br/>
{{ $t('notification.none') }}<br/>
<span class="explainer">
Notifications will appear here when actions on namespaces, lists or tasks you subscribed to happen.
{{ $t('notification.explainer') }}
</span>
</p>
</div>
@ -52,7 +60,7 @@ export default {
data() {
return {
notificationService: NotificationService,
notifications: [],
allNotifications: [],
showNotifications: false,
interval: null,
}
@ -73,6 +81,9 @@ export default {
unreadNotifications() {
return this.notifications.filter(n => n.readAt === null).length
},
notifications() {
return this.allNotifications.filter(n => n.name !== '')
},
...mapState({
userInfo: state => state.auth.info,
}),
@ -86,10 +97,10 @@ export default {
loadNotifications() {
this.notificationService.getAll()
.then(r => {
this.$set(this, 'notifications', r)
this.$set(this, 'allNotifications', r)
})
.catch(e => {
this.error(e, this)
this.error(e)
})
},
to(n, index) {
@ -125,9 +136,9 @@ export default {
n.read = true
this.notificationService.update(n)
.then(r => {
this.$set(this.notifications, index, r)
this.$set(this.allNotifications, index, r)
})
.catch(e => this.error(e, this))
.catch(e => this.error(e))
}
},
},

View File

@ -0,0 +1,487 @@
<template>
<modal v-if="active" class="quick-actions" @close="closeQuickActions" :overflow="isNewTaskCommand">
<div class="card">
<div class="action-input" :class="{'has-active-cmd': selectedCmd !== null}">
<div class="active-cmd tag" v-if="selectedCmd !== null">
{{ selectedCmd.title }}
</div>
<input
v-focus
class="input"
:class="{'is-loading': loading}"
v-model="query"
:placeholder="placeholder"
@keyup="search"
ref="searchInput"
@keydown.down.prevent="() => select(0, 0)"
@keyup.prevent.delete="unselectCmd"
@keyup.prevent.enter="doCmd"
@keyup.prevent.esc="closeQuickActions"
/>
</div>
<div class="help has-text-grey-light p-2" v-if="hintText !== '' && !isNewTaskCommand">
{{ hintText }}
</div>
<quick-add-magic class="p-2 modal-container-smaller" v-if="isNewTaskCommand"/>
<div class="results" v-if="selectedCmd === null">
<div v-for="(r, k) in results" :key="k" class="result">
<span class="result-title">
{{ r.title }}
</span>
<div class="result-items">
<button
v-for="(i, key) in r.items"
:key="key"
:ref="`result-${k}_${key}`"
@keydown.up.prevent="() => select(k, key - 1)"
@keydown.down.prevent="() => select(k, key + 1)"
@click.prevent.stop="() => doAction(r.type, i)"
@keyup.prevent.enter="() => doAction(r.type, i)"
@keyup.prevent.esc="() => $refs.searchInput.focus()"
:class="{'is-strikethrough': i.done}"
>
{{ i.title }}
</button>
</div>
</div>
</div>
</div>
</modal>
</template>
<script>
import TaskService from '@/services/task'
import TeamService from '@/services/team'
import NamespaceModel from '@/models/namespace'
import TeamModel from '@/models/team'
import {CURRENT_LIST, LOADING, LOADING_MODULE, QUICK_ACTIONS_ACTIVE} from '@/store/mutation-types'
import ListModel from '@/models/list'
import createTask from '@/components/tasks/mixins/createTask'
import QuickAddMagic from '@/components/tasks/partials/quick-add-magic'
import {getHistory} from '../../modules/listHistory'
const TYPE_LIST = 'list'
const TYPE_TASK = 'task'
const TYPE_CMD = 'cmd'
const TYPE_TEAM = 'team'
const CMD_NEW_TASK = 'newTask'
const CMD_NEW_LIST = 'newList'
const CMD_NEW_NAMESPACE = 'newNamespace'
const CMD_NEW_TEAM = 'newTeam'
const SEARCH_MODE_ALL = 'all'
const SEARCH_MODE_TASKS = 'tasks'
const SEARCH_MODE_LISTS = 'lists'
const SEARCH_MODE_TEAMS = 'teams'
export default {
name: 'quick-actions',
components: {QuickAddMagic},
data() {
return {
query: '',
selectedCmd: null,
foundTasks: [],
taskSearchTimeout: null,
taskService: null,
foundTeams: [],
teamSearchTimeout: null,
teamService: null,
}
},
mixins: [
createTask,
],
computed: {
active() {
const active = this.$store.state[QUICK_ACTIONS_ACTIVE]
if (!active) {
this.reset()
}
return active
},
results() {
let lists = []
if (this.searchMode === SEARCH_MODE_ALL || this.searchMode === SEARCH_MODE_LISTS) {
let query = this.query
if (this.searchMode === SEARCH_MODE_LISTS) {
query = query.substr(1)
}
const ncache = {}
const history = getHistory()
// Puts recently visited lists at the top
const allLists = [...new Set([
...history.map(l => {
return this.$store.getters['lists/getListById'](l.id)
}),
...Object.values(this.$store.state.lists)])]
lists = (allLists.filter(l => {
if (typeof l === 'undefined' || l === null) {
return false
}
if (l.isArchived) {
return false
}
if (typeof ncache[l.namespaceId] === 'undefined') {
ncache[l.namespaceId] = this.$store.getters['namespaces/getNamespaceById'](l.namespaceId)
}
if (ncache[l.namespaceId].isArchived) {
return false
}
return l.title.toLowerCase().includes(query.toLowerCase())
}) ?? [])
}
const cmds = this.availableCmds
.filter(a => a.title.toLowerCase().includes(this.query.toLowerCase()))
return [
{
type: TYPE_CMD,
title: this.$t('quickActions.commands'),
items: cmds,
},
{
type: TYPE_TASK,
title: this.$t('quickActions.tasks'),
items: this.foundTasks,
},
{
type: TYPE_LIST,
title: this.$t('quickActions.lists'),
items: lists,
},
{
type: TYPE_TEAM,
title: this.$t('quickActions.teams'),
items: this.foundTeams,
},
].filter(i => i.items.length > 0)
},
nothing() {
return this.search === '' || Object.keys(this.results).length === 0
},
loading() {
return this.taskService.loading ||
(this.$store.state[LOADING] && this.$store.state[LOADING_MODULE] === 'namespaces') ||
(this.$store.state[LOADING] && this.$store.state[LOADING_MODULE] === 'lists') ||
this.teamService.loading
},
placeholder() {
if (this.selectedCmd !== null) {
switch (this.selectedCmd.action) {
case CMD_NEW_TASK:
return this.$t('quickActions.newTask')
case CMD_NEW_LIST:
return this.$t('quickActions.newList')
case CMD_NEW_NAMESPACE:
return this.$t('quickActions.newNamespace')
case CMD_NEW_TEAM:
return this.$t('quickActions.newTeam')
}
}
return this.$t('quickActions.placeholder')
},
hintText() {
let namespace
if (this.selectedCmd !== null && this.currentList !== null) {
switch (this.selectedCmd.action) {
case CMD_NEW_TASK:
return this.$t('quickActions.createTask', {title: this.currentList.title})
case CMD_NEW_LIST:
namespace = this.$store.getters['namespaces/getNamespaceById'](this.currentList.namespaceId)
return this.$t('quickActions.createList', {title: namespace.title})
}
}
return this.$t('quickActions.hint')
},
currentList() {
return Object.keys(this.$store.state[CURRENT_LIST]).length === 0 ? null : this.$store.state[CURRENT_LIST]
},
availableCmds() {
const cmds = []
if (this.currentList !== null) {
cmds.push({
title: this.$t('quickActions.cmds.newTask'),
action: CMD_NEW_TASK,
})
cmds.push({
title: this.$t('quickActions.cmds.newList'),
action: CMD_NEW_LIST,
})
}
cmds.push({
title: this.$t('quickActions.cmds.newNamespace'),
action: CMD_NEW_NAMESPACE,
})
cmds.push({
title: this.$t('quickActions.cmds.newTeam'),
action: CMD_NEW_TEAM,
})
return cmds
},
searchMode() {
if (this.query === '') {
return SEARCH_MODE_ALL
}
if (this.query.startsWith('#')) {
return SEARCH_MODE_TASKS
}
if (this.query.startsWith('*')) {
return SEARCH_MODE_LISTS
}
if (this.query.startsWith('@')) {
return SEARCH_MODE_TEAMS
}
return SEARCH_MODE_ALL
},
isNewTaskCommand() {
return this.selectedCmd !== null && this.selectedCmd.action === CMD_NEW_TASK
},
},
created() {
this.taskService = new TaskService()
this.teamService = new TeamService()
},
methods: {
search() {
this.searchTasks()
this.searchTeams()
},
searchTasks() {
if (this.searchMode !== SEARCH_MODE_ALL && this.searchMode !== SEARCH_MODE_TASKS) {
this.foundTasks = []
return
}
let query = this.query
if (this.searchMode === SEARCH_MODE_TASKS) {
query = query.substr(1)
}
if (query === '' || this.selectedCmd !== null) {
return
}
if (this.taskSearchTimeout !== null) {
clearTimeout(this.taskSearchTimeout)
this.taskSearchTimeout = null
}
this.taskSearchTimeout = setTimeout(() => {
this.taskService.getAll({}, {s: query})
.then(r => {
r = r.map(t => {
t.type = TYPE_TASK
const list = this.$store.getters['lists/getListById'](t.listId) === null ? null : this.$store.getters['lists/getListById'](t.listId)
if (list !== null) {
t.title = `${t.title} (${list.title})`
}
return t
})
this.$set(this, 'foundTasks', r)
})
}, 150)
},
searchTeams() {
if (this.searchMode !== SEARCH_MODE_ALL && this.searchMode !== SEARCH_MODE_TEAMS) {
this.foundTeams = []
return
}
let query = this.query
if (this.searchMode === SEARCH_MODE_TEAMS) {
query = query.substr(1)
}
if (query === '' || this.selectedCmd !== null) {
return
}
if (this.teamSearchTimeout !== null) {
clearTimeout(this.teamSearchTimeout)
this.teamSearchTimeout = null
}
this.teamSearchTimeout = setTimeout(() => {
this.teamService.getAll({}, {s: query})
.then(r => {
r = r.map(t => {
t.title = t.name
return t
})
this.$set(this, 'foundTeams', r)
})
}, 150)
},
closeQuickActions() {
this.$store.commit(QUICK_ACTIONS_ACTIVE, false)
},
doAction(type, item) {
switch (type) {
case TYPE_LIST:
this.$router.push({name: 'list.index', params: {listId: item.id}})
this.closeQuickActions()
break
case TYPE_TASK:
this.$router.push({name: 'task.detail', params: {id: item.id}})
this.closeQuickActions()
break
case TYPE_CMD:
this.query = ''
this.selectedCmd = item
this.$refs.searchInput.focus()
break
}
},
doCmd() {
if (this.selectedCmd === null) {
return
}
if (this.query === '') {
return
}
switch (this.selectedCmd.action) {
case CMD_NEW_TASK:
this.newTask()
break
case CMD_NEW_LIST:
this.newList()
break
case CMD_NEW_NAMESPACE:
this.newNamespace()
break
case CMD_NEW_TEAM:
this.newTeam()
break
}
},
newTask() {
if (this.currentList === null) {
return
}
this.createNewTask(this.query, 0, this.currentList.id)
.then(r => {
this.success({message: this.$t('task.createSuccess')})
this.$router.push({name: 'task.detail', params: {id: r.id}})
this.closeQuickActions()
})
.catch((e) => {
this.error(e)
})
},
newList() {
if (this.currentList === null) {
return
}
const newList = new ListModel({
title: this.query,
namespaceId: this.currentList.namespaceId,
})
this.$store.dispatch('lists/createList', newList)
.then(r => {
this.success({message: this.$t('list.create.createdSuccess')})
this.$router.push({name: 'list.index', params: {listId: r.id}})
this.closeQuickActions()
})
.catch((e) => {
this.error(e)
})
},
newNamespace() {
const newNamespace = new NamespaceModel({title: this.query})
this.$store.dispatch('namespaces/createNamespace', newNamespace)
.then(() => {
this.success({message: this.$t('namespace.create.success')})
this.closeQuickActions()
})
.catch((e) => {
this.error(e)
})
},
newTeam() {
const newTeam = new TeamModel({name: this.query})
this.teamService.create(newTeam)
.then(r => {
this.$router.push({
name: 'teams.edit',
params: {id: r.id},
})
this.success({message: this.$t('team.create.success')})
this.closeQuickActions()
})
.catch((e) => {
this.error(e)
})
},
select(parentIndex, index) {
if (index < 0 && parentIndex === 0) {
this.$refs.searchInput.focus()
return
}
if (index < 0) {
parentIndex--
index = this.results[parentIndex].items.length - 1
}
let elems = this.$refs[`result-${parentIndex}_${index}`]
if (this.results[parentIndex].items.length === index) {
elems = this.$refs[`result-${parentIndex + 1}_0`]
}
if (typeof elems === 'undefined' || elems.length === 0) {
return
}
if (Array.isArray(elems)) {
elems[0].focus()
return
}
elems.focus()
},
unselectCmd() {
if (this.query !== '') {
return
}
this.selectedCmd = null
},
reset() {
this.query = ''
this.selectedCmd = null
},
},
}
</script>

View File

@ -1,36 +1,89 @@
<template>
<div>
<p class="has-text-weight-bold">Share Links</p>
<p class="has-text-weight-bold">
{{ $t('list.share.links.title') }}
<span
class="is-size-7"
v-tooltip="$t('list.share.links.explanation')">
{{ $t('list.share.links.what') }}
</span>
</p>
<div class="sharables-list">
<div class="p-4">
<p>Share with a link:</p>
<div class="field has-addons">
<x-button
v-if="!(linkShares.length === 0 || showNewForm)"
@click="showNewForm = true"
icon="plus"
class="mb-4">
{{ $t('list.share.links.create') }}
</x-button>
<div class="p-4" v-if="linkShares.length === 0 || showNewForm">
<div class="field">
<label class="label" for="linkShareRight">
{{ $t('list.share.right.title') }}
</label>
<div class="control">
<div class="select">
<select v-model="selectedRight">
<option :value="rights.READ">Read only</option>
<option :value="rights.READ_WRITE">
Read & write
<select v-model="selectedRight" id="linkShareRight">
<option :value="rights.READ">
{{ $t('list.share.right.read') }}
</option>
<option :value="rights.READ_WRITE">
{{ $t('list.share.right.readWrite') }}
</option>
<option :value="rights.ADMIN">
{{ $t('list.share.right.admin') }}
</option>
<option :value="rights.ADMIN">Admin</option>
</select>
</div>
</div>
</div>
<div class="field">
<label class="label" for="linkShareName">
{{ $t('list.share.links.name') }}
</label>
<div class="control">
<x-button @click="add"> Share</x-button>
<input
id="linkShareName"
class="input"
:placeholder="$t('list.share.links.namePlaceholder')"
v-tooltip="$t('list.share.links.nameExplanation')"
v-model="name"
/>
</div>
</div>
<div class="field">
<label class="label" for="linkSharePassword">
{{ $t('list.share.links.password') }}
</label>
<div class="control">
<input
id="linkSharePassword"
type="password"
class="input"
:placeholder="$t('user.auth.passwordPlaceholder')"
v-tooltip="$t('list.share.links.passwordExplanation')"
v-model="password"
/>
</div>
</div>
<x-button @click="add" icon="plus">
{{ $t('list.share.share') }}
</x-button>
</div>
<table
class="table has-actions is-striped is-hoverable is-fullwidth link-share-list"
v-if="linkShares.length > 0"
>
<thead>
<tr>
<th>Link</th>
<th>Shared by</th>
<th>Right</th>
<th>Delete</th>
<th>{{ $t('list.share.attributes.link') }}</th>
<th>{{ $t('list.share.attributes.name') }}</th>
<th>{{ $t('list.share.attributes.sharedBy') }}</th>
<th>{{ $t('list.share.attributes.right') }}</th>
<th>{{ $t('list.share.attributes.delete') }}</th>
</tr>
</thead>
<tbody>
@ -49,36 +102,42 @@
<x-button
@click="copy(getShareLink(s.hash))"
:shadow="false"
v-tooltip="'Copy to clipboard'"
v-tooltip="$t('misc.copy')"
>
<span class="icon">
<icon icon="paste"/>
</span>
<span class="icon">
<icon icon="paste"/>
</span>
</x-button>
</div>
</div>
</td>
<td>
<template v-if="s.name !== ''">
{{ s.name }}
</template>
<i v-else>{{ $t('list.share.links.noName') }}</i>
</td>
<td>
{{ s.sharedBy.getDisplayName() }}
</td>
<td class="type">
<template v-if="s.right === rights.ADMIN">
<span class="icon is-small">
<icon icon="lock"/>
</span>
Admin
<span class="icon is-small">
<icon icon="lock"/>
</span>&nbsp;
{{ $t('list.share.right.admin') }}
</template>
<template v-else-if="s.right === rights.READ_WRITE">
<span class="icon is-small">
<icon icon="pen"/>
</span>
Write
<span class="icon is-small">
<icon icon="pen"/>
</span>&nbsp;
{{ $t('list.share.right.readWrite') }}
</template>
<template v-else>
<span class="icon is-small">
<icon icon="users"/>
</span>
Read-only
<span class="icon is-small">
<icon icon="users"/>
</span>&nbsp;
{{ $t('list.share.right.read') }}
</template>
</td>
<td class="actions">
@ -104,12 +163,9 @@
@submit="remove()"
v-if="showDeleteModal"
>
<span slot="header">Remove a link share</span>
<span slot="header">{{ $t('list.share.links.remove') }}</span>
<p slot="text">
Are you sure you want to remove this link share?<br/>
It will no longer be possible to access this list with this link
share.<br/>
<b>This CANNOT BE UNDONE!</b>
{{ $t('list.share.links.removeText') }}
</p>
</modal>
</transition>
@ -140,8 +196,11 @@ export default {
newLinkShare: LinkShareModel,
rights: rights,
selectedRight: rights.READ,
name: '',
password: '',
showDeleteModal: false,
linkIdToDelete: 0,
showNewForm: false,
}
},
beforeMount() {
@ -173,44 +232,43 @@ export default {
this.linkShares = r
})
.catch((e) => {
this.error(e, this)
this.error(e)
})
},
add() {
let newLinkShare = new LinkShareModel({
const newLinkShare = new LinkShareModel({
right: this.selectedRight,
listId: this.listId,
name: this.name,
password: this.password,
})
this.linkShareService
.create(newLinkShare)
.then(() => {
this.selectedRight = rights.READ
this.success(
{message: 'The link share was successfully created'},
this
)
this.name = ''
this.password = ''
this.showNewForm = false
this.success({message: this.$t('list.share.links.createSuccess')})
this.load()
})
.catch((e) => {
this.error(e, this)
this.error(e)
})
},
remove() {
let linkshare = new LinkShareModel({
const linkshare = new LinkShareModel({
id: this.linkIdToDelete,
listId: this.listId,
})
this.linkShareService
.delete(linkshare)
.then(() => {
this.success(
{message: 'The link share was successfully deleted'},
this
)
this.success({message: this.$t('list.share.links.deleteSuccess')})
this.load()
})
.catch((e) => {
this.error(e, this)
this.error(e)
})
.finally(() => {
this.showDeleteModal = false

View File

@ -1,6 +1,8 @@
<template>
<div>
<p class="has-text-weight-bold">Shared with these {{ shareType }}s</p>
<p class="has-text-weight-bold">
{{ $t('list.share.userTeam.shared', {type: shareTypeNames}) }}
</p>
<div v-if="userIsAdmin">
<div class="field has-addons">
<p
@ -9,7 +11,7 @@
>
<multiselect
:loading="searchService.loading"
placeholder="Type to search..."
:placeholder="$t('misc.searchPlaceholder')"
@search="find"
:search-results="found"
:label="searchLabel"
@ -17,7 +19,7 @@
/>
</p>
<p class="control">
<x-button @click="add()"> Share</x-button>
<x-button @click="add()">{{ $t('list.share.share') }}</x-button>
</p>
</div>
</div>
@ -29,7 +31,7 @@
<td>{{ s.getDisplayName() }}</td>
<td>
<template v-if="s.id === userInfo.id">
<b class="is-success">You</b>
<b class="is-success">{{ $t('list.share.userTeam.you') }}</b>
</template>
</td>
</template>
@ -50,19 +52,19 @@
<span class="icon is-small">
<icon icon="lock"/>
</span>
Admin
{{ $t('list.share.right.admin') }}
</template>
<template v-else-if="s.right === rights.READ_WRITE">
<span class="icon is-small">
<icon icon="pen"/>
</span>
Write
{{ $t('list.share.right.readWrite') }}
</template>
<template v-else>
<span class="icon is-small">
<icon icon="users"/>
</span>
Read-only
{{ $t('list.share.right.read') }}
</template>
</td>
<td class="actions" v-if="userIsAdmin">
@ -76,19 +78,19 @@
:selected="s.right === rights.READ"
:value="rights.READ"
>
Read only
{{ $t('list.share.right.read') }}
</option>
<option
:selected="s.right === rights.READ_WRITE"
:value="rights.READ_WRITE"
>
Read & write
{{ $t('list.share.right.readWrite') }}
</option>
<option
:selected="s.right === rights.ADMIN"
:value="rights.ADMIN"
>
Admin
{{ $t('list.share.right.admin') }}
</option>
</select>
</div>
@ -108,7 +110,7 @@
</table>
<nothing v-else>
Not shared with any {{ shareType }} yet.
{{ $t('list.share.userTeam.notShared', {type: shareTypeNames}) }}
</nothing>
<transition name="modal">
@ -117,13 +119,11 @@
@submit="deleteSharable()"
v-if="showDeleteModal"
>
<span slot="header"
>Remove a {{ shareType }} from the {{ typeString }}</span
>
<span slot="header">
{{ $t('list.share.userTeam.removeHeader', {type: shareTypeName, sharable: sharableName}) }}
</span>
<p slot="text">
Are you sure you want to remove this {{ shareType }} from the
{{ typeString }}?<br/>
<b>This CANNOT BE UNDONE!</b>
{{ $t('list.share.userTeam.removeText', {type: shareTypeName, sharable: sharableName}) }}
</p>
</modal>
</transition>
@ -131,8 +131,6 @@
</template>
<script>
import {mapState} from 'vuex'
import UserNamespaceService from '../../services/userNamespace'
import UserNamespaceModel from '../../models/userNamespace'
import UserListModel from '../../models/userList'
@ -192,9 +190,44 @@ export default {
Nothing,
Multiselect,
},
computed: mapState({
userInfo: (state) => state.auth.info,
}),
computed: {
userInfo() {
return this.$store.state.auth.info
},
shareTypeNames() {
if (this.shareType === 'user') {
return this.$tc('list.share.userTeam.typeUser', 2)
}
if (this.shareType === 'team') {
return this.$tc('list.share.userTeam.typeTeam', 2)
}
return ''
},
shareTypeName() {
if (this.shareType === 'user') {
return this.$tc('list.share.userTeam.typeUser', 1)
}
if (this.shareType === 'team') {
return this.$tc('list.share.userTeam.typeTeam', 1)
}
return ''
},
sharableName() {
if (this.type === 'list') {
return this.$t('list.list.title')
}
if (this.shareType === 'namespace') {
return this.$t('namespace.namespace')
}
return ''
},
},
created() {
if (this.shareType === 'user') {
this.searchService = new UserService()
@ -202,11 +235,11 @@ export default {
this.searchLabel = 'username'
if (this.type === 'list') {
this.typeString = `list`
this.typeString = 'list'
this.stuffService = new UserListService()
this.stuffModel = new UserListModel({listId: this.id})
} else if (this.type === 'namespace') {
this.typeString = `namespace`
this.typeString = 'namespace'
this.stuffService = new UserNamespaceService()
this.stuffModel = new UserNamespaceModel({
namespaceId: this.id,
@ -220,11 +253,11 @@ export default {
this.searchLabel = 'name'
if (this.type === 'list') {
this.typeString = `list`
this.typeString = 'list'
this.stuffService = new TeamListService()
this.stuffModel = new TeamListModel({listId: this.id})
} else if (this.type === 'namespace') {
this.typeString = `namespace`
this.typeString = 'namespace'
this.stuffService = new TeamNamespaceService()
this.stuffModel = new TeamNamespaceModel({
namespaceId: this.id,
@ -245,11 +278,11 @@ export default {
.then((r) => {
this.$set(this, 'sharables', r)
r.forEach((s) =>
this.$set(this.selectedRight, s.id, s.right)
this.$set(this.selectedRight, s.id, s.right),
)
})
.catch((e) => {
this.error(e, this)
this.error(e)
})
},
deleteSharable() {
@ -258,34 +291,23 @@ export default {
} else if (this.shareType === 'team') {
this.stuffModel.teamId = this.sharable.id
}
this.stuffService
.delete(this.stuffModel)
.then(() => {
this.showDeleteModal = false
for (const i in this.sharables) {
if (
(this.sharables[i].id === this.stuffModel.userId &&
this.shareType === 'user') ||
(this.sharables[i].id === this.stuffModel.teamId &&
this.shareType === 'team')
(this.sharables[i].username === this.stuffModel.userId && this.shareType === 'user') ||
(this.sharables[i].id === this.stuffModel.teamId && this.shareType === 'team')
) {
this.sharables.splice(i, 1)
}
}
this.success(
{
message:
'The ' +
this.shareType +
' was successfully deleted from the ' +
this.typeString +
'.',
},
this
)
this.success({message: this.$t('list.share.userTeam.removeSuccess', {type: this.shareTypeName, sharable: this.sharableName})})
})
.catch((e) => {
this.error(e, this)
this.error(e)
})
},
add(admin) {
@ -306,19 +328,11 @@ export default {
this.stuffService
.create(this.stuffModel)
.then(() => {
this.success(
{
message:
'The ' +
this.shareType +
' was successfully added.',
},
this
)
this.success({message: this.$t('list.share.userTeam.addedSuccess', {type: this.shareTypeName})})
this.load()
})
.catch((e) => {
this.error(e, this)
this.error(e)
})
},
toggleType(sharable) {
@ -351,18 +365,10 @@ export default {
this.$set(this.sharables[i], 'right', r.right)
}
}
this.success(
{
message:
'The ' +
this.shareType +
' right was successfully updated.',
},
this
)
this.success({message: this.$t('list.share.userTeam.updatedSuccess', {type: this.shareTypeName})})
})
.catch((e) => {
this.error(e, this)
this.error(e)
})
},
find(query) {
@ -377,7 +383,7 @@ export default {
this.$set(this, 'found', response)
})
.catch((e) => {
this.error(e, this)
this.error(e)
})
},
clearAll() {

View File

@ -0,0 +1,102 @@
<template>
<div class="task-add">
<div class="field is-grouped">
<p :class="{ 'is-loading': taskService.loading}" class="control has-icons-left is-expanded">
<input
:class="{ 'disabled': taskService.loading}"
@keyup.enter="addTask()"
class="input"
:placeholder="$t('list.list.addPlaceholder')"
type="text"
v-focus
v-model="newTaskTitle"
ref="newTaskInput"
@keyup="errorMessage = ''"
/>
<span class="icon is-small is-left">
<icon icon="tasks"/>
</span>
</p>
<p class="control">
<x-button
:disabled="newTaskTitle.length === 0"
@click="addTask()"
icon="plus"
>
{{ $t('list.list.add') }}
</x-button>
</p>
</div>
<p class="help is-danger" v-if="errorMessage !== ''">
{{ errorMessage }}
</p>
<quick-add-magic v-if="errorMessage === ''"/>
</div>
</template>
<script>
import ListService from '../../services/list'
import TaskService from '../../services/task'
import LabelService from '../../services/label'
import LabelTaskService from '../../services/labelTask'
import createTask from '@/components/tasks/mixins/createTask'
import QuickAddMagic from '@/components/tasks/partials/quick-add-magic'
export default {
name: 'add-task',
data() {
return {
newTaskTitle: '',
listService: ListService,
taskService: TaskService,
labelService: LabelService,
labelTaskService: LabelTaskService,
errorMessage: '',
}
},
mixins: [
createTask,
],
components: {
QuickAddMagic,
},
created() {
this.listService = new ListService()
this.taskService = new TaskService()
this.labelService = new LabelService()
this.labelTaskService = new LabelTaskService()
},
methods: {
addTask() {
if (this.newTaskTitle === '') {
this.errorMessage = this.$t('list.create.addTitleRequired')
return
}
this.errorMessage = ''
this.createNewTask(this.newTaskTitle, 0, this.$store.state.auth.settings.defaultListId)
.then(task => {
this.newTaskTitle = ''
this.$emit('taskAdded', task)
})
.catch(e => {
if (e === 'NO_LIST') {
this.errorMessage = this.$t('list.create.addListRequired')
return
}
this.error(e)
})
},
},
}
</script>
<style lang="scss" scoped>
.task-add {
margin-bottom: 0;
.button {
height: 2.5rem;
}
}
</style>

View File

@ -1,7 +1,7 @@
<template>
<form @submit.prevent="editTaskSubmit()">
<div class="field">
<label class="label" for="tasktext">Task Text</label>
<label class="label" for="tasktext">{{ $t('task.attributes.title') }}</label>
<div class="control">
<input
:class="{ disabled: taskService.loading }"
@ -9,7 +9,6 @@
@change="editTaskSubmit()"
class="input"
id="tasktext"
placeholder="The task text is here..."
type="text"
v-focus
v-model="taskEditTask.title"
@ -17,132 +16,26 @@
</div>
</div>
<div class="field">
<label class="label" for="taskdescription">Description</label>
<label class="label" for="taskdescription">{{ $t('task.attributes.description') }}</label>
<div class="control">
<editor
:preview-is-default="false"
id="taskdescription"
placeholder="The tasks description goes here..."
:placeholder="$t('task.description.placeholder')"
v-if="editorActive"
v-model="taskEditTask.description"
/>
</div>
</div>
<b>Reminder Dates</b>
<strong>{{ $t('task.attributes.reminders') }}</strong>
<reminders
@change="editTaskSubmit()"
v-model="taskEditTask.reminderDates"
/>
<div class="field">
<label class="label" for="taskduedate">Due Date</label>
<div class="control">
<flat-pickr
:class="{ disabled: taskService.loading }"
:config="flatPickerConfig"
:disabled="taskService.loading"
@on-close="editTaskSubmit()"
class="input"
id="taskduedate"
placeholder="The tasks due date is here..."
v-model="taskEditTask.dueDate"
>
</flat-pickr>
</div>
</div>
<div class="field">
<label class="label" for="">Duration</label>
<div class="control columns">
<div class="column">
<flat-pickr
:class="{ disabled: taskService.loading }"
:config="flatPickerConfig"
:disabled="taskService.loading"
@on-close="editTaskSubmit()"
class="input"
id="taskduedate"
placeholder="Start date"
v-model="taskEditTask.startDate"
>
</flat-pickr>
</div>
<div class="column">
<flat-pickr
:class="{ disabled: taskService.loading }"
:config="flatPickerConfig"
:disabled="taskService.loading"
@on-close="editTaskSubmit()"
class="input"
id="taskduedate"
placeholder="End date"
v-model="taskEditTask.endDate"
>
</flat-pickr>
</div>
</div>
</div>
<div class="field">
<label class="label" for="">Repeat after</label>
<repeat-after
@change="editTaskSubmit()"
v-model="taskEditTask.repeatAfter"
/>
</div>
<div class="field">
<label class="label" for="">Priority</label>
<div class="control priority-select">
<priority-select
@change="editTaskSubmit()"
v-model="taskEditTask.priority"
/>
</div>
</div>
<div class="field">
<label class="label">Percent Done</label>
<div class="control">
<percent-done-select
@change="editTaskSubmit()"
v-model="taskEditTask.percentDone"
/>
</div>
</div>
<div class="field">
<label class="label">Color</label>
<div class="control">
<color-picker v-model="taskEditTask.hexColor" />
</div>
</div>
<div class="field">
<label class="label" for="">Assignees</label>
<ul class="assingees">
<li :key="a.id" v-for="(a, index) in taskEditTask.assignees">
{{ a.getDisplayName() }}
<a @click="deleteAssigneeByIndex(index)">
<icon icon="times" />
</a>
</li>
</ul>
</div>
<div class="field has-addons">
<div class="control is-expanded">
<edit-assignees
:initial-assignees="taskEditTask.assignees"
:list-id="taskEditTask.listId"
:task-id="taskEditTask.id"
/>
</div>
</div>
<div class="field">
<label class="label">Labels</label>
<label class="label">{{ $t('task.attributes.labels') }}</label>
<div class="control">
<edit-labels
:task-id="taskEditTask.id"
@ -151,37 +44,36 @@
</div>
</div>
<related-tasks
:initial-related-tasks="task.relatedTasks"
:list-id="task.listId"
:task-id="task.id"
class="is-narrow"
/>
<div class="field">
<label class="label">{{ $t('task.attributes.color') }}</label>
<div class="control">
<color-picker v-model="taskEditTask.hexColor" />
</div>
</div>
<x-button
:loading="taskService.loading"
class="is-fullwidth"
@click="editTaskSubmit()"
>
Save
{{ $t('misc.save') }}
</x-button>
<router-link
class="mt-2 has-text-centered is-block"
:to="{name: 'task.detail', params: {id: taskEditTask.id}}"
>
{{ $t('task.openDetail') }}
</router-link>
</form>
</template>
<script>
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import ListService from '../../services/list'
import TaskService from '../../services/task'
import TaskModel from '../../models/task'
import priorities from '../../models/priorities'
import PrioritySelect from './partials/prioritySelect'
import PercentDoneSelect from './partials/percentDoneSelect'
import EditLabels from './partials/editLabels'
import EditAssignees from './partials/editAssignees'
import RelatedTasks from './partials/relatedTasks'
import RepeatAfter from './partials/repeatAfter'
import Reminders from './partials/reminders'
import ColorPicker from '../input/colorPicker'
import LoadingComponent from '../misc/loading'
@ -201,30 +93,14 @@ export default {
newTask: TaskModel,
isTaskEdit: false,
taskEditTask: TaskModel,
flatPickerConfig: {
altFormat: 'j M Y H:i',
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
onOpen: this.updateLastReminderDate,
onClose: this.addReminderDate,
},
}
},
components: {
ColorPicker,
Reminders,
RepeatAfter,
RelatedTasks,
EditAssignees,
EditLabels,
PercentDoneSelect,
PrioritySelect,
flatPickr,
editor: () => ({
component: import(
/* webpackChunkName: "editor" */ '../../components/input/editor'
),
component: import(/* webpackChunkName: "editor" */ '../../components/input/editor'),
loading: LoadingComponent,
error: ErrorComponent,
timeout: 60000,
@ -273,17 +149,12 @@ export default {
.then((r) => {
this.$set(this, 'taskEditTask', r)
this.initTaskFields()
this.success({message: this.$t('task.detail.updateSuccess')})
})
.catch((e) => {
this.error(e, this)
this.error(e)
})
},
},
}
</script>
<style scoped>
form {
margin-bottom: 1rem;
}
</style>

View File

@ -7,7 +7,7 @@
type="secondary"
icon="filter"
>
Filters
{{ $t('filters.title') }}
</x-button>
</div>
<filter-popup
@ -59,7 +59,8 @@
</div>
<div :style="{ width: fullWidth + 'px' }" class="tasks">
<div
:key="t.id"
v-for="(t, k) in theTasks"
:key="t ? t.id : 0"
:style="{
background:
'repeating-linear-gradient(90deg, #ededed, #ededed 1px, ' +
@ -70,15 +71,13 @@
'px)',
}"
class="row"
v-for="(t, k) in theTasks"
>
<VueDragResize
:class="{
done: t.done,
'is-current-edit':
taskToEdit !== null && taskToEdit.id === t.id,
'has-light-text': !colorIsDark(t.hexColor),
'has-dark-text': colorIsDark(t.hexColor),
done: t ? t.done : false,
'is-current-edit': taskToEdit !== null && taskToEdit.id === t.id,
'has-light-text': !colorIsDark(t.getHexColor()),
'has-dark-text': colorIsDark(t.getHexColor()),
}"
:gridX="dayWidth"
:h="31"
@ -89,8 +88,8 @@
:snapToGrid="true"
:sticks="['mr', 'ml']"
:style="{
'border-color': t.hexColor,
'background-color': t.hexColor,
'border-color': t.getHexColor(),
'background-color': t.getHexColor(),
}"
:w="t.durationDays * dayWidth"
:x="t.offsetDays * dayWidth - 6"
@ -109,8 +108,9 @@
'has-super-high-priority':
t.priority === priorities.DO_NOW,
}"
>{{ t.title }}</span
>
{{ t.title }}
</span>
<priority-label :priority="t.priority"/>
<!-- using the key here forces vue to use the updated version model and not the response returned by the api -->
<a @click="editTask(theTasks[k])" class="edit-toggle">
@ -149,7 +149,7 @@
@resizestop="resizeTask"
axis="x"
class="task nodate"
v-tooltip="'This task has no dates set.'"
v-tooltip="$t('list.gantt.noDates')"
>
<span>{{ t.title }}</span>
</VueDragResize>
@ -173,14 +173,14 @@
/>
</transition>
<x-button @click="showCreateNewTask" :shadow="false" icon="plus">
Add a new task
{{ $t('list.list.newTaskCta') }}
</x-button>
</form>
<transition name="fade">
<card
v-if="isTaskEdit"
class="taskedit"
title="Edit Task"
:title="$t('list.list.editTask')"
@close="() => {isTaskEdit = false;taskToEdit = null}"
:has-close="true"
>
@ -363,87 +363,93 @@ export default {
})
})
.catch((e) => {
this.error(e, this)
this.error(e)
})
},
addGantAttributes(t) {
if (typeof t.durationDays !== 'undefined' && typeof t.offsetDays !== 'undefined') {
return t
}
t.endDate === null ? this.endDate : t.endDate
t.durationDays =
Math.floor((t.endDate - t.startDate) / 1000 / 60 / 60 / 24) + 1
t.offsetDays =
Math.floor(
(t.startDate - this.startDate) / 1000 / 60 / 60 / 24
) + 1
t.durationDays = Math.floor((t.endDate - t.startDate) / 1000 / 60 / 60 / 24)
t.offsetDays = Math.floor((t.startDate - this.startDate) / 1000 / 60 / 60 / 24)
return t
},
setTaskDragged(t) {
this.taskDragged = t
},
resizeTask(newRect) {
// Timeout to definitly catch if the user clicked on taskedit
setTimeout(() => {
if (this.isTaskEdit) {
return
if (this.isTaskEdit) {
return
}
const didntHaveDates = this.taskDragged.startDate === null ? true : false
let startDate = new Date(this.startDate)
startDate.setDate(
startDate.getDate() + newRect.left / this.dayWidth,
)
startDate.setUTCHours(0)
startDate.setUTCMinutes(0)
startDate.setUTCSeconds(0)
startDate.setUTCMilliseconds(0)
this.taskDragged.startDate = startDate
let endDate = new Date(startDate)
endDate.setDate(
startDate.getDate() + newRect.width / this.dayWidth,
)
this.taskDragged.startDate = startDate
this.taskDragged.endDate = endDate
// We take the task from the overall tasks array because the one in it has bad data after it was updated once.
// FIXME: This is a workaround. We should use a better mechanism to get the task or, even better,
// prevent it from containing outdated Data in the first place.
for (const tt in this.theTasks) {
if (this.theTasks[tt].id === this.taskDragged.id) {
this.$set(this, 'taskDragged', this.theTasks[tt])
break
}
}
let didntHaveDates =
this.taskDragged.startDate === null ? true : false
const ganttData = {
endDate: this.taskDragged.endDate,
durationDays: this.taskDragged.durationDays,
offsetDays: this.taskDragged.offsetDays,
}
let startDate = new Date(this.startDate)
startDate.setDate(
startDate.getDate() + newRect.left / this.dayWidth
)
startDate.setUTCHours(0)
startDate.setUTCMinutes(0)
startDate.setUTCSeconds(0)
startDate.setUTCMilliseconds(0)
this.taskDragged.startDate = startDate
let endDate = new Date(startDate)
endDate.setDate(
startDate.getDate() + newRect.width / this.dayWidth
)
this.taskDragged.startDate = startDate
this.taskDragged.endDate = endDate
this.taskService
.update(this.taskDragged)
.then(r => {
r.endDate = ganttData.endDate
r.durationDays = ganttData.durationDays
r.offsetDays = ganttData.offsetDays
// We take the task from the overall tasks array because the one in it has bad data after it was updated once.
// FIXME: This is a workaround. We should use a better mechanism to get the task or, even better,
// prevent it from containing outdated Data in the first place.
for (const tt in this.theTasks) {
if (this.theTasks[tt].id === this.taskDragged.id) {
this.$set(this, 'taskDragged', this.theTasks[tt])
break
}
}
this.taskService
.update(this.taskDragged)
.then((r) => {
// If the task didn't have dates before, we'll update the list
if (didntHaveDates) {
for (const t in this.tasksWithoutDates) {
if (this.tasksWithoutDates[t].id === r.id) {
this.tasksWithoutDates.splice(t, 1)
break
}
}
this.theTasks.push(this.addGantAttributes(r))
} else {
for (const tt in this.theTasks) {
if (this.theTasks[tt].id === r.id) {
this.$set(
this.theTasks,
tt,
this.addGantAttributes(r)
)
break
}
// If the task didn't have dates before, we'll update the list
if (didntHaveDates) {
for (const t in this.tasksWithoutDates) {
if (this.tasksWithoutDates[t].id === r.id) {
this.tasksWithoutDates.splice(t, 1)
break
}
}
})
.catch((e) => {
this.error(e, this)
})
}, 100)
this.theTasks.push(this.addGantAttributes(r))
} else {
for (const tt in this.theTasks) {
if (this.theTasks[tt].id === r.id) {
this.$set(
this.theTasks,
tt,
this.addGantAttributes(r),
)
break
}
}
}
})
.catch((e) => {
this.error(e)
})
},
editTask(task) {
this.taskToEdit = task
@ -479,7 +485,7 @@ export default {
this.hideCrateNewTask()
})
.catch((e) => {
this.error(e, this)
this.error(e)
})
},
},

View File

@ -1,5 +1,6 @@
import AttachmentModel from '../../../models/attachment'
import AttachmentService from '../../../services/attachment'
import {generateAttachmentUrl} from '@/helpers/generateAttachmentUrl'
export default {
methods: {
@ -21,7 +22,7 @@ export default {
taskId: this.taskId,
attachment: a,
})
onSuccess(`${window.API_URL}/tasks/${this.taskId}/attachments/${a.id}`)
onSuccess(generateAttachmentUrl(this.taskId, a.id))
})
}
if (r.errors !== null) {
@ -31,7 +32,7 @@ export default {
}
})
.catch(e => {
this.error(e, this)
this.error(e)
})
},
},

View File

@ -0,0 +1,133 @@
import {parseTaskText} from '@/modules/parseTaskText'
import TaskModel from '@/models/task'
import {formatISO} from 'date-fns'
import LabelTask from '@/models/labelTask'
import LabelModel from '@/models/label'
import LabelTaskService from '@/services/labelTask'
import {mapState} from 'vuex'
import UserService from '@/services/user'
export default {
data() {
return {
labelTaskService: LabelTaskService,
userService: UserService,
}
},
created() {
this.labelTaskService = new LabelTaskService()
this.userService = new UserService()
},
computed: mapState({
labels: state => state.labels.labels,
}),
methods: {
createNewTask(newTaskTitle, bucketId = 0, lId = 0) {
const parsedTask = parseTaskText(newTaskTitle)
const assignees = []
// Uses the following ways to get the list id of the new task:
// 1. If specified in quick add magic, look in store if it exists and use it if it does
// 2. Else check if a list was passed as parameter
// 3. Otherwise use the id from the route parameter
// 4. If none of the above worked, reject the promise with an error.
let listId = null
if (parsedTask.list !== null) {
const list = this.$store.getters['lists/findListByExactname'](parsedTask.list)
listId = list === null ? null : list.id
}
if (listId === null) {
listId = lId !== 0 ? lId : this.$route.params.listId
}
if (typeof listId === 'undefined' || listId === 0) {
return Promise.reject('NO_LIST')
}
// Separate closure because we need to wait for the results of the user search if users were entered in the
// task create request. Because _that_ happens in a promise, we'll need something to call when it resolves.
const createTask = () => {
const task = new TaskModel({
title: parsedTask.text,
listId: listId,
dueDate: parsedTask.date !== null ? formatISO(parsedTask.date) : null, // I don't know why, but it all goes up in flames when I just pass in the date normally.
priority: parsedTask.priority,
assignees: assignees,
bucketId: bucketId,
})
return this.taskService.create(task)
.then(task => {
if (parsedTask.labels.length > 0) {
const labelAddsToWaitFor = []
const addLabelToTask = label => {
const labelTask = new LabelTask({
taskId: task.id,
labelId: label.id,
})
return this.labelTaskService.create(labelTask)
.then(result => {
task.labels.push(label)
return Promise.resolve(result)
})
.catch(e => Promise.reject(e))
}
// Then do everything that is involved in finding, creating and adding the label to the task
parsedTask.labels.forEach(labelTitle => {
// Check if the label exists
const label = Object.values(this.labels).find(l => {
return l.title.toLowerCase() === labelTitle.toLowerCase()
})
// Label found, use it
if (typeof label !== 'undefined') {
labelAddsToWaitFor.push(addLabelToTask(label))
} else {
// label not found, create it
const label = new LabelModel({title: labelTitle})
labelAddsToWaitFor.push(this.$store.dispatch('labels/createLabel', label)
.then(res => {
return addLabelToTask(res)
})
.catch(e => Promise.reject(e)),
)
}
})
// This waits until all labels are created and added to the task
return Promise.all(labelAddsToWaitFor)
.then(() => {
return Promise.resolve(task)
})
}
return Promise.resolve(task)
})
.catch(e => Promise.reject(e))
}
if (parsedTask.assignees.length > 0) {
const searches = []
parsedTask.assignees.forEach(a => {
searches.push(this.userService.getAll({}, {s: a})
.then(users => {
const user = users.find(u => u.username.toLowerCase() === a.toLowerCase())
if (typeof user !== 'undefined') {
assignees.push(user)
}
return Promise.resolve(users)
}),
)
})
return Promise.all(searches)
.then(() => createTask())
}
return createTask()
},
},
}

View File

@ -31,6 +31,7 @@ export default {
},
watch: {
'$route.query': 'loadTasksForPage', // Only listen for query path changes
'$route.path': 'loadTasksOnSavedFilter',
},
beforeMount() {
// Triggering loading the tasks in beforeMount lets the component maintain the current page, therefore the page
@ -45,13 +46,15 @@ export default {
page,
search = '',
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 (
this.$route.name !== 'list.list' &&
this.$route.name !== 'list.table'
this.$route.name !== 'list.table' &&
!forceLoading
) {
return
}
@ -72,7 +75,7 @@ export default {
search: search,
page: page,
}
if (JSON.stringify(currentList) === JSON.stringify(this.loadedList)) {
if (JSON.stringify(currentList) === JSON.stringify(this.loadedList) && !forceLoading) {
return
}
@ -115,7 +118,7 @@ export default {
this.loadedList = cloneDeep(currentList)
})
.catch(e => {
this.error(e, this)
this.error(e)
})
},
loadTasksForPage(e) {
@ -130,6 +133,11 @@ export default {
}
this.initTasks(page, search)
},
loadTasksOnSavedFilter() {
if(typeof this.$route.params.listId !== 'undefined' && parseInt(this.$route.params.listId) < 0) {
this.loadTasks(1, '', null, true)
}
},
sortTasks() {
if (this.tasks === null || this.tasks === []) {
return

View File

@ -4,7 +4,7 @@
<span class="icon is-grey">
<icon icon="paperclip"/>
</span>
Attachments
{{ $t('task.attachment.title') }}
</h3>
<input
@ -35,18 +35,16 @@
<div class="filename">{{ a.file.name }}</div>
<div class="info">
<p class="collapses">
<span>
created
<span v-tooltip="formatDate(a.created)">{{
formatDateSince(a.created)
}}</span>
by
<i18n path="task.attachment.createdBy">
<span v-tooltip="formatDate(a.created)">
{{ formatDateSince(a.created) }}
</span>
<user
:avatar-size="24"
:user="a.createdBy"
:is-inline="true"
/>
</span>
</i18n>
<span>
{{ a.file.getHumanSize() }}
</span>
@ -57,16 +55,22 @@
<p>
<a
@click.prevent.stop="downloadAttachment(a)"
v-tooltip="'Download this attachment'"
v-tooltip="$t('task.attachment.downloadTooltip')"
>
Download
{{ $t('task.attachment.download') }}
</a>
<a
@click.stop="copyUrl(a)"
v-tooltip="$t('task.attachment.copyUrlTooltip')"
>
{{ $t('task.attachment.copyUrl') }}
</a>
<a
@click.prevent.stop="() => {attachmentToDelete = a; showDeleteModal = true}"
v-if="editEnabled"
v-tooltip="'Delete this attachment'"
v-tooltip="$t('task.attachment.deleteTooltip')"
>
Delete
{{ $t('misc.delete') }}
</a>
</p>
</div>
@ -82,7 +86,7 @@
type="secondary"
:shadow="false"
>
Upload attachment
{{ $t('task.attachment.upload') }}
</x-button>
<!-- Dropzone -->
@ -95,7 +99,7 @@
<div class="icon">
<icon icon="cloud-upload-alt"/>
</div>
<div class="hint">Drop files here to upload</div>
<div class="hint">{{ $t('task.attachment.drop') }}</div>
</div>
</div>
@ -106,11 +110,10 @@
v-if="showDeleteModal"
@submit="deleteAttachment()"
>
<span slot="header">Delete attachment</span>
<span slot="header">{{ $t('task.attachment.delete') }}</span>
<p slot="text">
Are you sure you want to delete the attachment
{{ attachmentToDelete.file.name }}?<br/>
<b>This CANNOT BE UNDONE!</b>
{{ $t('task.attachment.deleteText1', {filename: attachmentToDelete.file.name}) }}<br/>
<strong>{{ $t('task.attachment.deleteText2') }}</strong>
</p>
</modal>
</transition>
@ -136,7 +139,9 @@ import AttachmentService from '../../../services/attachment'
import AttachmentModel from '../../../models/attachment'
import User from '../../misc/user'
import attachmentUpload from '@/components/tasks/mixins/attachmentUpload'
import {generateAttachmentUrl} from '@/helpers/generateAttachmentUrl'
import {mapState} from 'vuex'
import copy from 'copy-to-clipboard'
export default {
name: 'attachments',
@ -224,12 +229,12 @@ export default {
.then((r) => {
this.$store.commit(
'attachments/removeById',
this.attachmentToDelete.id
this.attachmentToDelete.id,
)
this.success(r, this)
this.success(r)
})
.catch((e) => {
this.error(e, this)
this.error(e)
})
.finally(() => {
this.showDeleteModal = false
@ -250,6 +255,9 @@ export default {
this.downloadAttachment(attachment)
}
},
copyUrl(attachment) {
copy(generateAttachmentUrl(this.taskId, attachment.id))
},
},
}
</script>

View File

@ -4,17 +4,15 @@
<span class="icon is-grey">
<icon :icon="['far', 'comments']"/>
</span>
Comments
{{ $t('task.comment.title') }}
</h3>
<div class="comments">
<span
class="is-inline-flex is-align-items-center"
v-if="
taskCommentService.loading && saving === null && !creating
"
v-if="taskCommentService.loading && saving === null && !creating"
>
<span class="loader is-inline-block mr-2"></span>
Loading comments...
{{ $t('task.comment.loading') }}
</span>
<div :key="c.id" class="media comment" v-for="c in comments">
<figure class="media-left is-hidden-mobile">
@ -35,16 +33,15 @@
height="20"
width="20"
/>
<strong>{{ c.author.getDisplayName() }}</strong
>&nbsp;
<span v-tooltip="formatDate(c.created)">{{
formatDateSince(c.created)
}}</span>
<strong>{{ c.author.getDisplayName() }}</strong>&nbsp;
<span v-tooltip="formatDate(c.created)" class="has-text-grey">
{{ formatDateSince(c.created) }}
</span>
<span
v-if="+new Date(c.created) !== +new Date(c.updated)"
v-tooltip="formatDate(c.updated)"
>
· edited {{ formatDateSince(c.updated) }}
· {{ $t('task.comment.edited', {date: formatDateSince(c.updated)}) }}
</span>
<transition name="fade">
<span
@ -54,10 +51,8 @@
saving === c.id
"
>
<span
class="loader is-inline-block mr-2"
></span>
Saving...
<span class="loader is-inline-block mr-2"></span>
{{ $t('misc.saving') }}
</span>
<span
class="has-text-success"
@ -66,7 +61,7 @@
saved === c.id
"
>
Saved!
{{ $t('misc.saved') }}
</span>
</transition>
</div>
@ -104,10 +99,8 @@
class="is-inline-flex"
v-if="taskCommentService.loading && creating"
>
<span
class="loader is-inline-block mr-2"
></span>
Creating comment...
<span class="loader is-inline-block mr-2"></span>
{{ $t('task.comment.creating') }}
</span>
</transition>
<div class="field">
@ -120,20 +113,18 @@
:has-preview="false"
:upload-callback="attachmentUpload"
:upload-enabled="true"
placeholder="Add your comment..."
:placeholder="$t('task.comment.placeholder')"
v-if="editorActive"
v-model="newComment.comment"
/>
</div>
<div class="field">
<x-button
:loading="
taskCommentService.loading && !isCommentEdit
"
:loading="taskCommentService.loading && !isCommentEdit"
:disabled="newComment.comment === ''"
@click="addComment()"
>
Comment
{{ $t('task.comment.comment') }}
</x-button>
</div>
</div>
@ -147,10 +138,10 @@
@submit="deleteComment()"
v-if="showDeleteModal"
>
<span slot="header">Delete this comment</span>
<span slot="header">{{ $t('task.comment.delete') }}</span>
<p slot="text">
Are you sure you want to delete this comment? <br/>This
<b>CANNOT BE UNDONE!</b>
{{ $t('task.comment.deleteText1') }}<br/>
<strong>{{ $t('task.comment.deleteText2') }}</strong>
</p>
</modal>
</transition>
@ -168,9 +159,7 @@ export default {
name: 'comments',
components: {
editor: () => ({
component: import(
/* webpackChunkName: "editor" */ '../../input/editor'
),
component: import(/* webpackChunkName: "editor" */ '../../input/editor'),
loading: LoadingComponent,
error: ErrorComponent,
timeout: 60000,
@ -233,12 +222,12 @@ export default {
loadComments() {
this.taskCommentService
.getAll({taskId: this.taskId})
.then((r) => {
.then(r => {
this.$set(this, 'comments', r)
this.makeActions()
})
.catch((e) => {
this.error(e, this)
this.error(e)
})
},
addComment() {
@ -260,13 +249,10 @@ export default {
.then((r) => {
this.comments.push(r)
this.newComment.comment = ''
this.success(
{message: 'The comment was added successfully.'},
this
)
this.success({message: this.$t('task.comment.addedSuccess')})
})
.catch((e) => {
this.error(e, this)
this.error(e)
})
.finally(() => {
this.creating = false
@ -302,7 +288,7 @@ export default {
}, 2000)
})
.catch((e) => {
this.error(e, this)
this.error(e)
})
.finally(() => {
this.isCommentEdit = false
@ -320,7 +306,7 @@ export default {
}
})
.catch((e) => {
this.error(e, this)
this.error(e)
})
.finally(() => {
this.showDeleteModal = false
@ -332,7 +318,7 @@ export default {
this.$set(this.actions, c.id, [
{
action: () => this.toggleDelete(c.id),
title: 'Remove',
title: this.$t('misc.delete'),
},
])
})

View File

@ -3,28 +3,28 @@
:class="{ 'is-loading': taskService.loading }"
class="defer-task loading-container"
>
<label class="label">Defer due date</label>
<label class="label">{{ $t('task.deferDueDate.title') }}</label>
<div class="defer-days">
<x-button
@click.prevent.stop="() => deferDays(1)"
:shadow="false"
type="secondary"
>
1 day
{{ $t('task.deferDueDate.1day') }}
</x-button>
<x-button
@click.prevent.stop="() => deferDays(3)"
:shadow="false"
type="secondary"
>
3 days
{{ $t('task.deferDueDate.3days') }}
</x-button>
<x-button
@click.prevent.stop="() => deferDays(7)"
:shadow="false"
type="secondary"
>
1 week
{{ $t('task.deferDueDate.1week') }}
</x-button>
</div>
<flat-pickr
@ -51,15 +51,6 @@ export default {
dueDate: null,
lastValue: null,
changeInterval: null,
flatPickerConfig: {
altFormat: 'j M Y H:i',
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
inline: true,
},
}
},
components: {
@ -102,6 +93,21 @@ export default {
this.lastValue = this.dueDate
},
},
computed: {
flatPickerConfig() {
return {
altFormat: this.$t('date.altFormatLong'),
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
inline: true,
locale: {
firstDayOfWeek: this.$store.state.auth.settings.weekStart,
},
}
},
},
methods: {
deferDays(days) {
this.dueDate = new Date(this.dueDate)
@ -126,7 +132,7 @@ export default {
this.$emit('input', r)
})
.catch((e) => {
this.error(e, this)
this.error(e)
})
},
},

View File

@ -4,15 +4,15 @@
<span class="icon is-grey">
<icon icon="align-left"/>
</span>
Description
{{ $t('task.attributes.description') }}
<transition name="fade">
<span class="is-small is-inline-flex" v-if="loading && saving">
<span class="loader is-inline-block mr-2"></span>
Saving...
{{ $t('misc.saving') }}
</span>
<span class="is-small has-text-success" v-if="!loading && saved">
<icon icon="check"/>
Saved!
{{ $t('misc.saved') }}
</span>
</transition>
</h3>
@ -21,8 +21,8 @@
:upload-callback="attachmentUpload"
:upload-enabled="true"
@change="save"
placeholder="Click here to enter a description..."
empty-text="No description available yet."
:placeholder="$t('task.description.placeholder')"
:empty-text="$t('task.description.empty')"
v-model="task.description"/>
</div>
</template>
@ -86,12 +86,12 @@ export default {
}, 2000)
})
.catch(e => {
this.error(e, this)
this.error(e)
})
.finally(() => {
this.saving = false
})
}
},
},
}
</script>

View File

@ -1,25 +1,31 @@
<template>
<multiselect
:loading="listUserService.loading"
placeholder="Type to assign a user..."
:disabled="disabled"
:multiple="true"
@search="findUser"
:search-results="foundUsers"
@select="addAssignee"
label="username"
select-placeholder="Assign this user"
v-model="assignees"
<div
tabindex="-1"
@focus="focus"
>
<template v-slot:tag="props">
<span class="assignee">
<user :avatar-size="32" :show-username="false" :user="props.item"/>
<a @click="removeAssignee(props.item)" class="remove-assignee" v-if="!disabled">
<icon icon="times"/>
</a>
</span>
</template>
</multiselect>
<multiselect
:loading="listUserService.loading"
:placeholder="$t('task.assignee.placeholder')"
:disabled="disabled"
:multiple="true"
@search="findUser"
:search-results="foundUsers"
@select="addAssignee"
label="username"
:select-placeholder="$t('task.assignee.selectPlaceholder')"
v-model="assignees"
ref="multiselect"
>
<template v-slot:tag="props">
<span class="assignee">
<user :avatar-size="32" :show-username="false" :user="props.item"/>
<a @click="removeAssignee(props.item)" class="remove-assignee" v-if="!disabled">
<icon icon="times"/>
</a>
</span>
</template>
</multiselect>
</div>
</template>
<script>
@ -78,10 +84,10 @@ export default {
this.$store.dispatch('tasks/addAssignee', {user: user, taskId: this.taskId})
.then(() => {
this.$emit('input', this.assignees)
this.success({message: 'The user has been assigned successfully.'}, this)
this.success({message: this.$t('task.assignee.assignSuccess')})
})
.catch(e => {
this.error(e, this)
this.error(e)
})
},
removeAssignee(user) {
@ -93,10 +99,10 @@ export default {
this.assignees.splice(a, 1)
}
}
this.success({message: 'The user has been unassinged successfully.'}, this)
this.success({message: this.$t('task.assignee.assignSuccess')})
})
.catch(e => {
this.error(e, this)
this.error(e)
})
},
findUser(query) {
@ -113,12 +119,15 @@ export default {
}))
})
.catch(e => {
this.error(e, this)
this.error(e)
})
},
clearAllFoundUsers() {
this.$set(this, 'foundUsers', [])
},
focus() {
this.$refs.multiselect.focus()
},
},
}
</script>

View File

@ -1,7 +1,7 @@
<template>
<multiselect
:loading="labelService.loading || labelTaskService.loading"
placeholder="Type to add a new label..."
:loading="loading"
:placeholder="$t('task.label.placeholder')"
:multiple="true"
@search="findLabel"
:search-results="foundLabels"
@ -9,8 +9,9 @@
label="title"
:creatable="true"
@create="createAndAddLabel"
create-placeholder="Add this as new label"
:create-placeholder="$t('task.label.createPlaceholder')"
v-model="labels"
:search-delay="10"
>
<template v-slot:tag="props">
<span
@ -39,11 +40,11 @@
<script>
import differenceWith from 'lodash/differenceWith'
import LabelService from '../../../services/label'
import LabelModel from '../../../models/label'
import LabelTaskService from '../../../services/labelTask'
import Multiselect from '@/components/input/multiselect.vue'
import {LOADING, LOADING_MODULE} from '@/store/mutation-types'
export default {
name: 'edit-labels',
@ -54,7 +55,8 @@ export default {
},
taskId: {
type: Number,
required: true,
required: false,
default: () => 0,
},
disabled: {
default: false,
@ -62,12 +64,10 @@ export default {
},
data() {
return {
labelService: LabelService,
labelTaskService: LabelTaskService,
foundLabels: [],
labelTimeout: null,
labels: [],
searchQuery: '',
query: '',
}
},
components: {
@ -79,84 +79,91 @@ export default {
},
},
created() {
this.labelService = new LabelService()
this.labelTaskService = new LabelTaskService()
this.labels = this.value
},
computed: {
foundLabels() {
const labels = (Object.values(this.$store.state.labels.labels).filter(l => {
return l.title.toLowerCase().includes(this.query.toLowerCase())
}) ?? [])
return differenceWith(labels, this.labels, (first, second) => {
return first.id === second.id
})
},
loading() {
return this.labelTaskService.loading || (this.$store.state[LOADING] && this.$store.state[LOADING_MODULE] === 'labels')
},
},
methods: {
findLabel(query) {
this.searchQuery = query
if (query === '') {
this.clearAllLabels()
this.query = query
},
addLabel(label, showNotification = true) {
const bubble = () => {
this.$emit('input', this.labels)
this.$emit('change', this.labels)
}
if (this.taskId === 0) {
bubble()
return
}
if (this.labelTimeout !== null) {
clearTimeout(this.labelTimeout)
}
// Delay the search 300ms to not send a request on every keystroke
this.labelTimeout = setTimeout(() => {
this.labelService.getAll({}, {s: query})
.then(response => {
this.$set(this, 'foundLabels', differenceWith(response, this.labels, (first, second) => {
return first.id === second.id
}))
this.labelTimeout = null
})
.catch(e => {
this.error(e, this)
})
}, 300)
},
clearAllLabels() {
this.$set(this, 'foundLabels', [])
},
addLabel(label, showNotification = true) {
this.$store.dispatch('tasks/addLabel', {label: label, taskId: this.taskId})
.then(() => {
this.$emit('input', this.labels)
bubble()
if (showNotification) {
this.success({message: 'The label has been added successfully.'}, this)
this.success({message: this.$t('task.label.addSuccess')})
}
})
.catch(e => {
this.error(e, this)
this.error(e)
})
},
removeLabel(label) {
const removeFromState = () => {
for (const l in this.labels) {
if (this.labels[l].id === label.id) {
this.labels.splice(l, 1)
}
}
this.$emit('input', this.labels)
this.$emit('change', this.labels)
}
if (this.taskId === 0) {
removeFromState()
return
}
this.$store.dispatch('tasks/removeLabel', {label: label, taskId: this.taskId})
.then(() => {
// Remove the label from the list
for (const l in this.labels) {
if (this.labels[l].id === label.id) {
this.labels.splice(l, 1)
}
}
this.$emit('input', this.labels)
this.success({message: 'The label has been removed successfully.'}, this)
removeFromState()
this.success({message: this.$t('task.label.removeSuccess')})
})
.catch(e => {
this.error(e, this)
this.error(e)
})
},
createAndAddLabel(title) {
let newLabel = new LabelModel({title: title})
this.labelService.create(newLabel)
if (this.taskId === 0) {
return
}
const newLabel = new LabelModel({title: title})
this.$store.dispatch('labels/createLabel', newLabel)
.then(r => {
this.addLabel(r, false)
this.labels.push(r)
this.success({message: 'The label has been created successfully.'}, this)
this.success({message: this.$t('task.label.removeSuccess')})
})
.catch(e => {
this.error(e, this)
this.error(e)
})
},
},
}
</script>
<style scoped>
</style>

View File

@ -1,7 +1,7 @@
<template>
<div class="heading">
<h1 class="title task-id">
{{ task.getTextIdentifier() }}
{{ task.getTextIdentifier && task.getTextIdentifier() ? task.getTextIdentifier() : '' }}
</h1>
<div class="is-done" v-if="task.done">Done</div>
<h1
@ -14,11 +14,11 @@
<transition name="fade">
<span class="is-inline-flex is-align-items-center" v-if="loading && saving">
<span class="loader is-inline-block mr-2"></span>
Saving...
{{ $t('misc.saving') }}
</span>
<span class="has-text-success is-inline-flex is-align-content-center" v-if="!loading && saved">
<icon icon="check" class="mr-2"/>
Saved!
{{ $t('misc.saved') }}
</span>
</transition>
</div>
@ -90,12 +90,12 @@ export default {
}, 2000)
})
.catch(e => {
this.error(e, this)
this.error(e)
})
.finally(() => {
this.saving = false
})
}
},
},
}
</script>

View File

@ -3,13 +3,13 @@
class="control is-expanded"
v-focus
:loading="listSerivce.loading"
placeholder="Type to search for a list..."
:placeholder="$t('list.search')"
@search="findLists"
:search-results="foundLists"
@select="select"
label="title"
v-model="list"
select-placeholder="Click or press enter to select this list"
:select-placeholder="$t('list.searchSelect')"
>
<template v-slot:searchResult="props">
<span class="list-namespace-title">{{ namespace(props.option.namespaceId) }} ></span>
@ -32,6 +32,11 @@ export default {
foundLists: [],
}
},
props: {
value: {
required: false,
},
},
components: {
Multiselect,
},
@ -39,6 +44,14 @@ export default {
this.listSerivce = new ListService()
this.list = new ListModel()
},
watch: {
value(newVal) {
this.list = newVal
},
},
mounted() {
this.list = this.value
},
methods: {
findLists(query) {
if (query === '') {
@ -51,21 +64,23 @@ export default {
this.$set(this, 'foundLists', response)
})
.catch(e => {
this.error(e, this)
this.error(e)
})
},
clearAll() {
this.$set(this, 'foundLists', [])
},
select(list) {
this.list = list
this.$emit('selected', list)
this.$emit('input', list)
},
namespace(namespaceId) {
const namespace = this.$store.getters['namespaces/getNamespaceById'](namespaceId)
if (namespace !== null) {
return namespace.title
}
return 'Shared Lists'
return this.$t('list.shared')
},
},
}

View File

@ -7,12 +7,12 @@
<icon icon="exclamation"/>
</span>
<span>
<template v-if="priority === priorities.UNSET">Unset</template>
<template v-if="priority === priorities.LOW">Low</template>
<template v-if="priority === priorities.MEDIUM">Medium</template>
<template v-if="priority === priorities.HIGH">High</template>
<template v-if="priority === priorities.URGENT">Urgent</template>
<template v-if="priority === priorities.DO_NOW">DO NOW</template>
<template v-if="priority === priorities.UNSET">{{ $t('task.priority.unset') }}</template>
<template v-if="priority === priorities.LOW">{{ $t('task.priority.low') }}</template>
<template v-if="priority === priorities.MEDIUM">{{ $t('task.priority.medium') }}</template>
<template v-if="priority === priorities.HIGH">{{ $t('task.priority.high') }}</template>
<template v-if="priority === priorities.URGENT">{{ $t('task.priority.urgent') }}</template>
<template v-if="priority === priorities.DO_NOW">{{ $t('task.priority.doNow') }}</template>
</span>
<span class="icon" v-if="priority === priorities.DO_NOW">
<icon icon="exclamation"/>

View File

@ -1,12 +1,12 @@
<template>
<div class="select">
<select :disabled="disabled" @change="updateData" v-model="priority">
<option :value="priorities.UNSET">Unset</option>
<option :value="priorities.LOW">Low</option>
<option :value="priorities.MEDIUM">Medium</option>
<option :value="priorities.HIGH">High</option>
<option :value="priorities.URGENT">Urgent</option>
<option :value="priorities.DO_NOW">DO NOW</option>
<option :value="priorities.UNSET">{{ $t('task.priority.unset') }}</option>
<option :value="priorities.LOW">{{ $t('task.priority.low') }}</option>
<option :value="priorities.MEDIUM">{{ $t('task.priority.medium') }}</option>
<option :value="priorities.HIGH">{{ $t('task.priority.high') }}</option>
<option :value="priorities.URGENT">{{ $t('task.priority.urgent') }}</option>
<option :value="priorities.DO_NOW">{{ $t('task.priority.doNow') }}</option>
</select>
</div>
</template>

View File

@ -0,0 +1,82 @@
<template>
<div>
<p class="help has-text-grey">
{{ $t('task.quickAddMagic.hint') }}.
<a @click="() => visible = true">{{ $t('task.quickAddMagic.what') }}</a>
</p>
<transition name="fade">
<div class="modal-mask hint-modal" v-if="visible">
<div @click.self="() => visible = false" class="modal-container">
<div class="modal-content">
<card class="has-background-white has-no-shadow" :title="$t('task.quickAddMagic.title')">
<p>{{ $t('task.quickAddMagic.intro') }}</p>
<h3>{{ $t('task.attributes.labels') }}</h3>
<p>
{{ $t('task.quickAddMagic.label1', {prefix: '@'}) }}
{{ $t('task.quickAddMagic.label2') }}
{{ $t('task.quickAddMagic.multiple') }}
</p>
<p>
{{ $t('task.quickAddMagic.label3') }}
{{ $t('task.quickAddMagic.label4', {prefix: '@'}) }}
</p>
<h3>{{ $t('task.attributes.priority') }}</h3>
<p>
{{ $t('task.quickAddMagic.priority1', {prefix: '!'}) }}
{{ $t('task.quickAddMagic.priority2') }}
</p>
<h3>{{ $t('task.attributes.assignees') }}</h3>
<p>
{{ $t('task.quickAddMagic.assignees') }}
{{ $t('task.quickAddMagic.multiple') }}
</p>
<h3>{{ $t('list.list.title') }}</h3>
<p>
{{ $t('task.quickAddMagic.list1', {prefix: '#'}) }}
{{ $t('task.quickAddMagic.list2') }}
</p>
<h3>{{ $t('task.quickAddMagic.dateAndTime') }}</h3>
<p>
{{ $t('task.quickAddMagic.date') }}
</p>
<ul>
<!-- Not localized because these only work in english -->
<li>Today</li>
<li>Tomorrow</li>
<li>Next monday</li>
<li>This weekend</li>
<li>Later this week</li>
<li>Later next week</li>
<li>Next week</li>
<li>Next month</li>
<li>End of month</li>
<li>In 5 days [hours/weeks/months]</li>
<li>Tuesday ({{ $t('task.quickAddMagic.dateWeekday') }})</li>
<li>17/02/2021</li>
<li>Feb 17 ({{ $t('task.quickAddMagic.dateCurrentYear') }})</li>
<li>17th ({{ $t('task.quickAddMagic.dateNth', {day: '17'}) }})</li>
</ul>
<p>{{ $t('task.quickAddMagic.dateTime', {time: 'at 17:00', timePM: '5pm'}) }}</p>
</card>
</div>
</div>
</div>
</transition>
</div>
</template>
<script>
export default {
name: 'quick-add-magic',
data() {
return {
visible: false,
}
},
}
</script>

View File

@ -5,7 +5,7 @@
@click="showNewRelationForm = !showNewRelationForm"
class="is-pulled-right add-task-relation-button"
:class="{'is-active': showNewRelationForm}"
v-tooltip="'Add a New Task Relation'"
v-tooltip="$t('task.relation.add')"
type="secondary"
icon="plus"
:shadow="false"
@ -13,27 +13,27 @@
<transition-group name="fade">
<template v-if="editEnabled && showCreate">
<label class="label" key="label">
New Task Relation
{{ $t('task.relation.new') }}
<transition name="fade">
<span class="is-inline-flex" v-if="taskRelationService.loading">
<span class="loader is-inline-block mr-2"></span>
Saving...
{{ $t('misc.saving') }}
</span>
<span class="has-text-success" v-if="!taskRelationService.loading && saved">
Saved!
{{ $t('misc.saved') }}
</span>
</transition>
</label>
<div class="field" key="field-search">
<multiselect
placeholder="Type search for a new task to add as related..."
:placeholder="$t('task.relation.searchPlaceholder')"
@search="findTasks"
:loading="taskService.loading"
:search-results="foundTasks"
label="title"
v-model="newTaskRelationTask"
:creatable="true"
create-placeholder="Add this as new related task"
:create-placeholder="$t('task.relation.createPlaceholder')"
@create="createAndRelateTask"
>
<template v-slot:searchResult="props">
@ -41,7 +41,7 @@
<span
class="different-list"
v-if="props.option.listId !== listId"
v-tooltip="'This task belongs to a different list.'">
v-tooltip="$t('task.relation.differentList')">
{{ $store.getters['lists/getListById'](props.option.listId) === null ? '' : $store.getters['lists/getListById'](props.option.listId).title }} >
</span>
{{ props.option.title }}
@ -64,7 +64,7 @@
</div>
</div>
<div class="control">
<x-button @click="addTaskRelation()">Add Task Relation</x-button>
<x-button @click="addTaskRelation()">{{ $t('task.relation.add') }}</x-button>
</div>
</div>
</template>
@ -80,7 +80,7 @@
<span
class="different-list"
v-if="t.listId !== listId"
v-tooltip="'This task belongs to a different list.'">
v-tooltip="$t('task.relation.differentList')">
{{
$store.getters['lists/getListById'](t.listId) === null ? '' : $store.getters['lists/getListById'](t.listId).title
}} >
@ -99,7 +99,7 @@
</template>
</div>
<p class="none" v-if="showNoRelationsNotice && Object.keys(relatedTasks).length === 0">
No task relations yet.
{{ $t('task.relation.noneYet') }}
</p>
<!-- Delete modal -->
@ -108,9 +108,11 @@
@close="showDeleteModal = false"
@submit="removeTaskRelation()"
v-if="showDeleteModal">
<span slot="header">Delete Task Relation</span>
<p slot="text">Are you sure you want to delete this task relation?<br/>
<b>This CANNOT BE UNDONE!</b></p>
<span slot="header">{{ $t('task.relation.delete') }}</span>
<p slot="text">
{{ $t('task.relation.deleteText1') }}<br/>
<strong>{{ $t('task.relation.deleteText2') }}</strong>
</p>
</modal>
</transition>
</div>
@ -192,7 +194,7 @@ export default {
this.$set(this, 'foundTasks', response)
})
.catch(e => {
this.error(e, this)
this.error(e)
})
},
addTaskRelation() {
@ -215,7 +217,7 @@ export default {
}, 2000)
})
.catch(e => {
this.error(e, this)
this.error(e)
})
},
removeTaskRelation() {
@ -239,7 +241,7 @@ export default {
}, 2000)
})
.catch(e => {
this.error(e, this)
this.error(e)
})
.finally(() => {
this.showDeleteModal = false
@ -253,7 +255,7 @@ export default {
this.addTaskRelation()
})
.catch(e => {
this.error(e, this)
this.error(e)
})
},
relationKindTitle(kind, length) {

View File

@ -19,7 +19,7 @@
<datepicker
v-model="newReminder"
@close-on-change="() => addReminderDate()"
choose-date-label="Add a new reminder..."
:choose-date-label="$t('task.addReminder')"
/>
</div>
</div>

View File

@ -1,56 +1,60 @@
<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')">Every Day</x-button>
<x-button type="secondary" class="is-small" @click="() => setRepeatAfter(1, 'weeks')">Every Week</x-button>
<x-button type="secondary" class="is-small" @click="() => setRepeatAfter(1, 'months')">Every Month</x-button>
<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>
</div>
<div class="columns is-align-items-center">
<div class="is-flex column">
<p class="pr-4">
Each
</p>
<div class="field has-addons is-fullwidth">
<div class="control">
<input
:disabled="disabled"
@change="updateData"
class="input"
placeholder="Specify an amount..."
v-model="repeatAfter.amount"/>
</div>
<div class="control">
<div class="select">
<select :disabled="disabled" @change="updateData" v-model="repeatAfter.type">
<option value="hours">Hours</option>
<option value="days">Days</option>
<option value="weeks">Weeks</option>
<option value="months">Months</option>
<option value="years">Years</option>
</select>
</div>
<div class="is-flex is-align-items-center mb-2">
<label for="repeatMode" class="is-fullwidth">
{{ $t('task.repeat.mode') }}:
</label>
<div class="control">
<div class="select">
<select @change="updateData" v-model="task.repeatMode" id="repeatMode">
<option :value="repeatModes.REPEAT_MODE_DEFAULT">{{ $t('misc.default') }}</option>
<option :value="repeatModes.REPEAT_MODE_MONTH">{{ $t('task.repeat.monthly') }}</option>
<option :value="repeatModes.REPEAT_MODE_FROM_CURRENT_DATE">{{ $t('task.repeat.fromCurrentDate') }}</option>
</select>
</div>
</div>
</div>
<div class="is-flex" v-if="task.repeatMode !== repeatModes.REPEAT_MODE_MONTH">
<p class="pr-4">
{{ $t('task.repeat.each') }}
</p>
<div class="field has-addons is-fullwidth">
<div class="control">
<input
:disabled="disabled"
@change="updateData"
class="input"
:placeholder="$t('task.repeat.specifyAmount')"
v-model="repeatAfter.amount"
type="number"
/>
</div>
<div class="control">
<div class="select">
<select :disabled="disabled" @change="updateData" v-model="repeatAfter.type">
<option value="hours">{{ $t('task.repeat.hours') }}</option>
<option value="days">{{ $t('task.repeat.days') }}</option>
<option value="weeks">{{ $t('task.repeat.weeks') }}</option>
<option value="months">{{ $t('task.repeat.months') }}</option>
<option value="years">{{ $t('task.repeat.years') }}</option>
</select>
</div>
</div>
</div>
<fancycheckbox
:disabled="disabled"
@change="updateData"
class="column"
v-model="task.repeatFromCurrentDate"
v-tooltip="'When marking the task as done, all dates will be set relative to the current date rather than the date they had before.'"
>
Repeat from current date
</fancycheckbox>
</div>
</div>
</template>
<script>
import Fancycheckbox from '../../input/fancycheckbox'
import repeatModes from '@/models/taskRepeatModes'
export default {
name: 'repeatAfter',
components: {Fancycheckbox},
data() {
return {
task: {},
@ -58,12 +62,12 @@ export default {
amount: 0,
type: '',
},
repeatModes: repeatModes,
}
},
props: {
value: {
default: () => {
},
default: () => {},
required: true,
},
disabled: {
@ -107,8 +111,4 @@ p {
.input {
min-width: 2rem;
}
.fancycheckbox {
padding: 0;
}
</style>

View File

@ -16,7 +16,7 @@
:to="{ name: 'list.list', params: { listId: task.listId } }"
class="task-list"
v-if="showList && $store.getters['lists/getListById'](task.listId) !== null"
v-tooltip="`This task belongs to list '${$store.getters['lists/getListById'](task.listId).title}'`">
v-tooltip="$t('task.detail.belongsToList', {list: $store.getters['lists/getListById'](task.listId).title})">
{{ $store.getters['lists/getListById'](task.listId).title }}
</router-link>
@ -45,7 +45,7 @@
v-if="+new Date(task.dueDate) > 0"
v-tooltip="formatDate(task.dueDate)"
>
- Due {{ formatDateSince(task.dueDate) }}
- {{ $t('task.detail.due', {at: formatDateSince(task.dueDate)}) }}
</i>
<transition name="fade">
<defer-task v-if="+new Date(task.dueDate) > 0 && showDefer" v-model="task" ref="deferDueDate"/>
@ -70,7 +70,7 @@
:to="{ name: 'list.list', params: { listId: task.listId } }"
class="task-list"
v-if="!showList && currentList.id !== task.listId && $store.getters['lists/getListById'](task.listId) !== null"
v-tooltip="`This task belongs to list '${$store.getters['lists/getListById'](task.listId).title}'`">
v-tooltip="$t('task.detail.belongsToList', {list: $store.getters['lists/getListById'](task.listId).title})">
{{ $store.getters['lists/getListById'](task.listId).title }}
</router-link>
<a
@ -135,7 +135,7 @@ export default {
showListColor: {
type: Boolean,
default: true,
}
},
},
watch: {
theTask(newVal) {
@ -170,26 +170,25 @@ export default {
const updateFunc = () => {
this.taskService.update(this.task)
.then(t => {
if(this.task.done) {
if (this.task.done) {
playPop()
}
this.task = t
this.$emit('task-updated', t)
this.success(
{message: 'The task was successfully ' + (this.task.done ? '' : 'un-') + 'marked as done.'},
this,
[{
title: 'Undo',
callback: () => this.markAsDone({
target: {
checked: !checked,
},
}),
}],
)
this.success({
message: this.task.done ?
this.$t('task.doneSuccess') :
this.$t('task.undoneSuccess'),
}, [{
title: 'Undo',
callback: () => {
this.task.done = !this.task.done
this.markAsDone(!checked)
},
}])
})
.catch(e => {
this.error(e, this)
this.error(e)
})
}
@ -208,7 +207,7 @@ export default {
this.$store.dispatch('namespaces/loadNamespacesIfFavoritesDontExist')
})
.catch(e => {
this.error(e, this)
this.error(e)
})
},
hideDeferDueDatePopup(e) {

View File

@ -1,21 +1,21 @@
<template>
<card title="Avatar">
<card :title="$t('user.settings.avatar.title')">
<div class="control mb-4">
<label class="radio">
<input name="avatarProvider" type="radio" v-model="avatarProvider" value="default"/>
Default
{{ $t('misc.default') }}
</label>
<label class="radio">
<input name="avatarProvider" type="radio" v-model="avatarProvider" value="initials"/>
Initials
{{ $t('user.settings.avatar.initials') }}
</label>
<label class="radio">
<input name="avatarProvider" type="radio" v-model="avatarProvider" value="gravatar"/>
Gravatar
{{ $t('user.settings.avatar.gravatar') }}
</label>
<label class="radio">
<input name="avatarProvider" type="radio" v-model="avatarProvider" value="upload"/>
Upload
{{ $t('user.settings.avatar.upload') }}
</label>
</div>
@ -31,7 +31,7 @@
:loading="avatarService.loading || loading"
@click="$refs.avatarUploadInput.click()"
v-if="!isCropAvatar">
Upload Avatar
{{ $t('user.settings.avatar.uploadAvatar') }}
</x-button>
<template v-else>
<cropper
@ -45,7 +45,7 @@
:loading="avatarService.loading || loading"
@click="uploadAvatar"
>
Upload Avatar
{{ $t('user.settings.avatar.uploadAvatar') }}
</x-button>
</template>
</template>
@ -56,7 +56,7 @@
@click="updateAvatarStatus()"
class="is-fullwidth"
>
Save
{{ $t('misc.save') }}
</x-button>
</div>
</card>
@ -93,16 +93,16 @@ export default {
.then(r => {
this.avatarProvider = r.avatarProvider
})
.catch(e => this.error(e, this))
.catch(e => this.error(e))
},
updateAvatarStatus() {
const avatarStatus = new AvatarModel({avatarProvider: this.avatarProvider})
this.avatarService.update(avatarStatus)
.then(() => {
this.success({message: 'Avatar status was updated successfully!'}, this)
this.success({message: this.$t('user.settings.avatar.statusUpdateSuccess')})
this.$store.commit('auth/reloadAvatar')
})
.catch(e => this.error(e, this))
.catch(e => this.error(e))
},
uploadAvatar() {
this.loading = true
@ -112,10 +112,10 @@ export default {
canvas.toBlob(blob => {
this.avatarService.create(blob)
.then(() => {
this.success({message: 'The avatar has been set successfully!'}, this)
this.success({message: this.$t('user.settings.avatar.setSuccess')})
this.$store.commit('auth/reloadAvatar')
})
.catch(e => this.error(e, this))
.catch(e => this.error(e))
.finally(() => {
this.loading = false
this.isCropAvatar = false

61
src/helpers/auth.ts Normal file
View File

@ -0,0 +1,61 @@
import {HTTPFactory} from '@/http-common'
import {AxiosResponse} from 'axios'
let savedToken: string | null = null
/**
* Saves a token while optionally saving it to lacal storage. This is used when viewing a link share:
* It enables viewing multiple link shares indipendently from each in multiple tabs other without overriding any other open ones.
* @param token
* @param persist
*/
export const saveToken = (token: string, persist: boolean) => {
savedToken = token
if (persist) {
localStorage.setItem('token', token)
}
}
/**
* Returns a saved token. If there is one saved in memory it will use that before anything else.
* @returns {string|null}
*/
export const getToken = (): string | null => {
if (savedToken !== null) {
return savedToken
}
savedToken = localStorage.getItem('token')
return savedToken
}
/**
* Removes all tokens everywhere.
*/
export const removeToken = () => {
savedToken = null
localStorage.removeItem('token')
}
/**
* Refreshes an auth token while ensuring it is updated everywhere.
* @returns {Promise<AxiosResponse<any>>}
*/
export const refreshToken = (persist: boolean): Promise<AxiosResponse> => {
const HTTP = HTTPFactory()
return HTTP.post('user/token', null, {
headers: {
Authorization: `Bearer ${getToken()}`,
},
})
.then(r => {
saveToken(r.data.token, persist)
return Promise.resolve(r)
})
.catch(e => {
// eslint-disable-next-line
console.log('Error renewing token: ', e)
return Promise.reject(e)
})
}

View File

@ -1,4 +1,8 @@
export const colorIsDark = color => {
if (typeof color === 'undefined') {
return true // Defaults to dark
}
if (color === '#' || color === '') {
return true // Defaults to dark
}

View File

@ -0,0 +1,3 @@
export const generateAttachmentUrl = (taskId, attachmentId) => {
return `${window.API_URL}/tasks/${taskId}/attachments/${attachmentId}`
}

View File

@ -0,0 +1,6 @@
export const getListTitle = (l, $t) => {
if (l.id === -1) {
return $t('list.pseudo.favorites.title')
}
return l.title
}

View File

@ -0,0 +1,12 @@
export const getNamespaceTitle = (n, $t) => {
if (n.id === -1) {
return $t('namespace.pseudo.sharedLists.title')
}
if (n.id === -2) {
return $t('namespace.pseudo.favorites.title')
}
if (n.id === -3) {
return $t('namespace.pseudo.savedFilters.title')
}
return n.title
}

View File

@ -1,4 +1,8 @@
export const parseDateOrNull = date => {
if (date instanceof Date) {
return date
}
if (date && !date.startsWith('0001')) {
return new Date(date)
}

15
src/helpers/replaceAll.js Normal file
View File

@ -0,0 +1,15 @@
/**
* This function replaces all text, no matter the case.
*
* See https://stackoverflow.com/a/7313467/10924593
*
* @parma str
* @param search
* @param replace
* @returns {*}
*/
export const replaceAll = (str, search, replace) => {
const esc = search.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')
const reg = new RegExp(esc, 'ig')
return str.replace(reg, replace)
}

View File

@ -0,0 +1,30 @@
const key = 'collapsedBuckets'
const getAllState = () => {
const saved = localStorage.getItem(key)
if (saved === null) {
return {}
}
return JSON.parse(saved)
}
export const saveCollapsedBucketState = (listId, collapsedBuckets) => {
const state = getAllState()
state[listId] = collapsedBuckets
for (const bucketId in state[listId]) {
if (!state[listId][bucketId]) {
delete state[listId][bucketId]
}
}
localStorage.setItem(key, JSON.stringify(state))
}
export const getCollapsedBucketState = listId => {
const state = getAllState()
if (typeof state[listId] !== 'undefined') {
return state[listId]
}
return {}
}

View File

@ -1,4 +1,4 @@
export function calculateNearestHours(currentDate = new Date()) {
export function calculateNearestHours(currentDate: Date = new Date()): number {
if (currentDate.getHours() <= 9 || currentDate.getHours() > 21) {
return 9
}
@ -18,4 +18,7 @@ export function calculateNearestHours(currentDate = new Date()) {
if (currentDate.getHours() <= 21) {
return 21
}
// Same case as in the first if, will never be called
return 9
}

View File

@ -7,8 +7,12 @@
* @returns {Date}
*/
export const createDateFromString = dateString => {
if (dateString instanceof Date) {
return dateString
}
if (dateString.includes('-')) {
dateString = dateString.replace(/-/g, "/")
dateString = dateString.replace(/-/g, '/')
}
return new Date(dateString)

View File

@ -0,0 +1,40 @@
import {createDateFromString} from '@/helpers/time/createDateFromString'
import {format, formatDistance} from 'date-fns'
import {enGB, de} from 'date-fns/locale'
const locales = {enGB, de}
const dateIsValid = date => {
if (date === null) {
return false
}
return date instanceof Date && !isNaN(date)
}
export const formatDate = (date, f, locale) => {
if (!dateIsValid(date)) {
return ''
}
date = createDateFromString(date)
return date ? format(date, f, {locale: locales[locale]}) : ''
}
export const formatDateSince = (date, $t) => {
if (!dateIsValid(date)) {
return ''
}
date = createDateFromString(date)
const currentDate = new Date()
const distance = formatDistance(date, currentDate, {locale: locales[$t('date.locale')]})
if (date > currentDate) {
return $t('date.in', {date: distance})
}
return $t('date.ago', {date: distance})
}

View File

@ -0,0 +1,314 @@
import {calculateDayInterval} from './calculateDayInterval'
import {calculateNearestHours} from './calculateNearestHours'
import {replaceAll} from '../replaceAll'
interface dateParseResult {
newText: string,
date: Date | null,
}
interface dateFoundResult {
foundText: string | null,
date: Date | null,
}
export const parseDate = (text: string): dateParseResult => {
const lowerText: string = text.toLowerCase()
if (lowerText.includes('today')) {
return addTimeToDate(text, getDateFromInterval(calculateDayInterval('today')), 'today')
}
if (lowerText.includes('tomorrow')) {
return addTimeToDate(text, getDateFromInterval(calculateDayInterval('tomorrow')), 'tomorrow')
}
if (lowerText.includes('next monday')) {
return addTimeToDate(text, getDateFromInterval(calculateDayInterval('nextMonday')), 'next monday')
}
if (lowerText.includes('this weekend')) {
return addTimeToDate(text, getDateFromInterval(calculateDayInterval('thisWeekend')), 'this weekend')
}
if (lowerText.includes('later this week')) {
return addTimeToDate(text, getDateFromInterval(calculateDayInterval('laterThisWeek')), 'later this week')
}
if (lowerText.includes('later next week')) {
return addTimeToDate(text, getDateFromInterval(calculateDayInterval('laterNextWeek')), 'later next week')
}
if (lowerText.includes('next week')) {
return addTimeToDate(text, getDateFromInterval(calculateDayInterval('nextWeek')), 'next week')
}
if (lowerText.includes('next month')) {
const date: Date = new Date()
date.setDate(1)
date.setMonth(date.getMonth() + 1)
date.setHours(calculateNearestHours(date))
date.setMinutes(0)
date.setSeconds(0)
return addTimeToDate(text, date, 'next month')
}
if (lowerText.includes('end of month')) {
const curDate: Date = new Date()
const date: Date = new Date(curDate.getFullYear(), curDate.getMonth() + 1, 0)
date.setHours(calculateNearestHours(date))
date.setMinutes(0)
date.setSeconds(0)
return addTimeToDate(text, date, 'end of month')
}
let parsed = getDateFromWeekday(text)
if (parsed.date !== null) {
return addTimeToDate(text, parsed.date, parsed.foundText)
}
parsed = getDayFromText(text)
if (parsed.date !== null) {
return addTimeToDate(text, parsed.date, parsed.foundText)
}
parsed = getDateFromTextIn(text)
if (parsed.date !== null) {
return {
newText: replaceAll(text, parsed.foundText, ''),
date: parsed.date,
}
}
parsed = getDateFromText(text)
return {
newText: replaceAll(text, parsed.foundText, ''),
date: parsed.date,
}
}
const addTimeToDate = (text: string, date: Date, match: string | null): dateParseResult => {
if (match === null) {
return {
newText: text,
date: null,
}
}
const matcher = new RegExp(`(${match} (at|@) )([0-9][0-9]?(:[0-9][0-9]?)?( ?(a|p)m)?)`, 'ig')
const results = matcher.exec(text)
if (results !== null) {
const time = results[3]
const parts = time.split(':')
let hours = parseInt(parts[0])
let minutes = 0
if (time.endsWith('pm')) {
hours += 12
}
if (parts.length > 1) {
minutes = parseInt(parts[1])
}
date.setHours(hours)
date.setMinutes(minutes)
date.setSeconds(0)
}
const replace = results !== null ? results[0] : match
return {
newText: replaceAll(text, replace, ''),
date: date,
}
}
export const getDateFromText = (text: string, now: Date = new Date()) => {
const fullDateRegex: RegExp = /([0-9][0-9]?\/[0-9][0-9]?\/[0-9][0-9]([0-9][0-9])?|[0-9][0-9][0-9][0-9]\/[0-9][0-9]?\/[0-9][0-9]?|[0-9][0-9][0-9][0-9]-[0-9][0-9]?-[0-9][0-9]?)/ig
// 1. Try parsing the text as a "usual" date, like 2021-06-24 or 06/24/2021
let results: string[] | null = fullDateRegex.exec(text)
let result: string | null = results === null ? null : results[0]
let foundText: string | null = result
let containsYear: boolean = true
if (result === null) {
// 2. Try parsing the date as something like "jan 21" or "21 jan"
const monthRegex: RegExp = /((jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec) [0-9][0-9]?|[0-9][0-9]? (jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec))/ig
results = monthRegex.exec(text)
result = results === null ? null : `${results[0]} ${now.getFullYear()}`
foundText = results === null ? '' : results[0]
containsYear = false
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
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) {
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()}`
}
if (result === null || (isNaN(new Date(result).getTime()) && foundText !== '')) {
const parts = foundText.split('/')
result = `${parts[1]}/${parts[0]}/${now.getFullYear()}`
}
}
}
if (result === null) {
return {
foundText,
date: null,
}
}
const date = new Date(result)
if (isNaN(date.getTime())) {
return {
foundText,
date: null,
}
}
if (!containsYear && date < now) {
date.setFullYear(date.getFullYear() + 1)
}
return {
foundText,
date,
}
}
export const getDateFromTextIn = (text: string, now: Date = new Date()) => {
const regex = /(in [0-9]+ (hours?|days?|weeks?|months?))/ig
const results = regex.exec(text)
if (results === null) {
return {
foundText: '',
date: null,
}
}
const foundText: string = results[0]
const date = new Date(now)
const parts = foundText.split(' ')
switch (parts[2]) {
case 'hours':
case 'hour':
date.setHours(date.getHours() + parseInt(parts[1]))
break
case 'days':
case 'day':
date.setDate(date.getDate() + parseInt(parts[1]))
break
case 'weeks':
case 'week':
date.setDate(date.getDate() + parseInt(parts[1]) * 7)
break
case 'months':
case 'month':
date.setMonth(date.getMonth() + parseInt(parts[1]))
break
}
return {
foundText,
date,
}
}
const getDateFromWeekday = (text: string): dateFoundResult => {
const matcher: RegExp = / (mon|monday|tue|tuesday|wed|wednesday|thu|thursday|fri|friday|sat|saturday|sun|sunday)/ig
const results: string[] | null = matcher.exec(text)
if (results === null) {
return {
foundText: null,
date: null,
}
}
const date: Date = new Date()
const currentDay: number = date.getDay()
let day: number = 0
switch (results[1]) {
case 'mon':
case 'monday':
day = 1
break
case 'tue':
case 'tuesday':
day = 2
break
case 'wed':
case 'wednesday':
day = 3
break
case 'thu':
case 'thursday':
day = 4
break
case 'fri':
case 'friday':
day = 5
break
case 'sat':
case 'saturday':
day = 6
break
case 'sun':
case 'sunday':
day = 0
break
default:
return {
foundText: null,
date: null,
}
}
const distance: number = (day + 7 - currentDay) % 7
date.setDate(date.getDate() + distance)
return {
foundText: results[1],
date: date,
}
}
const getDayFromText = (text: string) => {
const matcher = /(([1-2][0-9])|(3[01])|(0?[1-9]))(st|nd|rd|th|\.)/ig
const results = matcher.exec(text)
if (results === null) {
return {
foundText: null,
date: null,
}
}
const date = new Date()
date.setDate(parseInt(results[0]))
if (date < new Date()) {
date.setMonth(date.getMonth() + 1)
}
return {
foundText: results[0],
date: date,
}
}
const getDateFromInterval = (interval: number): Date => {
const newDate = new Date()
newDate.setDate(newDate.getDate() + interval)
newDate.setHours(calculateNearestHours(newDate))
newDate.setMinutes(0)
newDate.setSeconds(0)
return newDate
}

309
src/i18n/lang/de.json Normal file
View File

@ -0,0 +1,309 @@
{
"404": {
"title": "Nicht gefunden",
"text": "Die angeforderte Seite existiert nicht."
},
"filters": {
"create": {
"action": "Neuen gespeicherten Filter erstellen",
"description": "Ein gespeicherter Filter ist eine virtuelle Liste, die bei jedem Zugriff aus einem Satz von Filtern errechnet wird. Einmal erstellt, erscheint er in einem speziellen Namensraum.",
"title": "Einen gespeicherten Filter erstellen"
},
"attributes": {
"descriptionPlaceholder": "Die Beschreibung steht hier …",
"description": "Beschreibung",
"titlePlaceholder": "Der gespeicherte Filtertitel steht hier …",
"title": "Titel"
},
"delete": {
"header": "Diesen gespeicherten Filter löschen",
"success": "Der Filter wurde erfolgreich gelöscht."
},
"edit": {
"success": "Der Filter wurde erfolgreich gespeichert.",
"title": "Diesen gespeicherten Filter bearbeiten"
},
"title": "Filter"
},
"sharing": {
"authenticating": "Authentifizierung …",
"invalidPassword": "Das Passwort ist ungültig.",
"error": "Es ist ein Fehler aufgetreten."
},
"label": {
"attributes": {
"color": "Farbe",
"description": "Beschreibung",
"title": "Titel"
}
},
"misc": {
"search": "Suchen",
"copy": "In Zwischenablage kopieren",
"disable": "Deaktivieren",
"confirm": "Bestätigen",
"delete": "Löschen",
"save": "Speichern",
"loading": "Wird geladen …",
"previous": "Vorherige",
"next": "Weiter"
},
"task": {
"delete": "Diese Aufgabe löschen",
"new": "Eine neue Aufgabe erstellen",
"task": "Aufgabe",
"show": {
"titleCurrent": "Aktuelle Aufgaben",
"noTasks": "Nichts zu tun Einen schönen Tag noch!",
"today": "Heute",
"nextWeek": "Nächste Woche"
},
"detail": {
"created": "Erstellt {0} von {1}",
"undone": "Als unerledigt markieren",
"done": "Fertig!",
"move": "Aufgabe in eine andere Liste verschieben",
"delete": {
"header": "Diese Aufgabe löschen"
},
"deleteSuccess": "Die Aufgabe wurde erfolgreich gelöscht.",
"updateSuccess": "Die Aufgabe wurde erfolgreich gespeichert.",
"doneAt": "Erledigt {0}",
"updated": "Aktualisiert {0}",
"actions": {
"priority": "Priorität einstellen",
"reminders": "Erinnerungen einstellen",
"relatedTasks": "Aufgabenbeziehungen hinzufügen",
"attachments": "Anhänge hinzufügen",
"delete": "Aufgabe löschen",
"color": "Taskfarbe einstellen",
"moveList": "Aufgabe verschieben"
}
},
"attributes": {
"color": "Farbe",
"done": "Fertig",
"createdBy": "Erstellt von",
"created": "Erstellt",
"endDate": "Enddatum",
"dueDate": "Fälligkeitsdatum",
"title": "Titel",
"startDate": "Anfangsdatum",
"relatedTasks": "Verwandte Aufgaben",
"priority": "Priorität",
"percentDone": "% erledigt",
"repeat": "Wiederholen",
"reminders": "Erinnerungen",
"updated": "Aktualisiert"
}
},
"team": {
"edit": {
"delete": {
"header": "Team löschen",
"success": "Das Team wurde erfolgreich gelöscht."
},
"madeAdmin": "Das Teammitglied wurde erfolgreich zum Admin gemacht.",
"madeMember": "Das Teammitglied wurde erfolgreich zum Mitglied gemacht.",
"userAddedSuccess": "Das Teammitglied wurde erfolgreich hinzugefügt.",
"success": "Das Team wurde erfolgreich aktualisiert.",
"addUser": "Zum Team hinzufügen",
"members": "Teammitglieder",
"title": "Team „{team}“ bearbeiten",
"deleteUser": {
"header": "Benutzer aus dem Team entfernen"
}
},
"create": {
"success": "Das Team wurde erfolgreich erstellt.",
"title": "Ein neues Team erstellen"
},
"title": "Teams",
"attributes": {
"description": "Beschreibung",
"descriptionPlaceholder": "Die Beschreibung des Teams steht hier …",
"member": "Mitglied",
"admin": "Admin",
"name": "Teamname",
"namePlaceholder": "Der Name des Teams steht hier …"
}
},
"namespace": {
"create": {
"explanation": "Ein Namensraum ist eine Sammlung von Listen, die man teilen und verwenden kann, um seine Listen zu organisieren. Tatsächlich gehört jede Liste zu einem Namensraum.",
"success": "Der Namensraum wurde erfolgreich angelegt.",
"tooltip": "Was ist ein Namensraum?",
"title": "Einen neuen Namensraum erstellen"
},
"attributes": {
"isArchived": "Dieser Namensraum wird archiviert",
"archived": "Ist archiviert",
"color": "Farbe",
"descriptionPlaceholder": "Die Beschreibung des Namensraums steht hier …",
"description": "Beschreibung",
"titlePlaceholder": "Der Titel des Namensraums steht hier …",
"title": "Namensraumtitel"
},
"share": {
"title": "„{namespace}“ teilen"
},
"edit": {
"success": "Der Namensraum wurde erfolgreich aktualisiert.",
"title": "„{namespace}“ bearbeiten"
},
"delete": {
"success": "Der Namensraum wurde erfolgreich gelöscht.",
"text2": "Dies umfasst alle Listen und Aufgaben und kann NICHT rückgängig gemacht werden!",
"title": "„{namespace}“ löschen"
},
"archive": {
"description": "Wenn ein Namensraum archiviert ist, kann man keine neuen Listen erstellen oder ihn bearbeiten.",
"success": "Der Namensraum wurde erfolgreich archiviert.",
"titleUnarchive": "Archivierung von „{namespace}“ aufheben",
"titleArchive": "„{namespace}“ archivieren"
},
"noLists": "Dieser Namensraum enthält keine Listen.",
"title": "Namensräume & Listen",
"unarchive": "Archivierung aufheben",
"archived": "Archiviert",
"showArchived": "Archivierte anzeigen"
},
"list": {
"kanban": {
"bucketTitleSavedSuccess": "Der Eimertitel wurde erfolgreich gespeichert.",
"deleteBucketSuccess": "Der Eimer wurde erfolgreich gelöscht.",
"deleteBucketText2": "Dies löscht keine Aufgaben, sondern verschiebt sie in den Standard-Eimer.",
"deleteHeaderBucket": "Den Eimer löschen",
"addBucket": "Einen neuen Eimer erstellen",
"addAnotherTask": "Weitere Aufgabe hinzufügen",
"addTask": "Eine Aufgabe hinzufügen",
"doneBucket": "Erledigte-Dinge-Eimer",
"noLimit": "Nicht eingestellt"
},
"table": {
"columns": "Spalten",
"title": "Tabelle"
},
"gantt": {
"to": "An",
"from": "Von",
"day": "Tag",
"month": "Monat",
"default": "Standard",
"size": "Größe",
"showTasksWithoutDates": "Aufgaben anzeigen, für die keine Termine festgelegt sind",
"title": "Gantt"
},
"list": {
"empty": "Diese Liste ist derzeit leer.",
"addPlaceholder": "Eine neue Aufgabe hinzufügen …",
"add": "Hinzufügen",
"title": "Liste"
},
"share": {
"title": "„{Liste}“ teilen",
"header": "Diese Liste teilen"
},
"edit": {
"success": "Die Liste wurde erfolgreich aktualisiert.",
"color": "Farbe",
"descriptionPlaceholder": "Die Listenbeschreibung geht hier …",
"description": "Beschreibung",
"identifierPlaceholder": "Der Listenbezeichner geht hier …",
"identifier": "Listebezeichner",
"identifierTooltip": "Der Listenbezeichner kann zur eindeutigen Identifizierung einer Aufgabe über Listen hinweg verwendet werden. Man kann ihn auf leer setzen, um ihn zu deaktivieren.",
"titlePlaceholder": "Der Titel der Liste steht hier …",
"title": "„{list}“ bearbeiten",
"header": "Diese Liste bearbeiten"
},
"duplicate": {
"success": "Die Liste wurde erfolgreich dupliziert.",
"label": "Duplizieren",
"title": "Diese Liste duplizieren"
},
"delete": {
"success": "Die Liste wurde erfolgreich gelöscht.",
"text2": "Dies umfasst alle Aufgaben und kann NICHT rückgängig gemacht werden!",
"header": "Diese Liste löschen",
"title": "„{list}“ löschen"
},
"background": {
"removeSuccess": "Der Hintergrund ist erfolgreich entfernt worden!",
"success": "Der Hintergrund ist erfolgreich eingestellt worden!",
"loadMore": "Mehr Fotos laden",
"poweredByUnsplash": "Angetrieben von Unsplash",
"searchPlaceholder": "Nach einem Hintergrund suchen …",
"remove": "Hintergrund entfernen",
"title": "Listenhintergrund festlegen"
},
"archive": {
"success": "Die Liste wurde erfolgreich archiviert.",
"unarchive": "Archivierung dieser Liste aufheben",
"archive": "Diese Liste archivieren",
"title": "„{Liste}“ archivieren"
},
"create": {
"createdSuccess": "Die Liste wurde erfolgreich erstellt.",
"titlePlaceholder": "Der Titel der Liste steht hier …",
"header": "Eine neue Liste erstellen"
},
"color": "Farbe"
},
"user": {
"settings": {
"caldav": {
"title": "Caldav"
},
"totp": {
"disableSuccess": "Die Zwei-Faktor-Authentifizierung wurde erfolgreich deaktiviert.",
"passcode": "Passcode",
"enroll": "Einschreiben",
"title": "Zwei-Faktor-Authentifizierung"
},
"general": {
"weekStartMonday": "Montag",
"weekStartSunday": "Sonntag",
"weekStart": "Woche beginnt am",
"playSoundWhenDone": "Einen Ton abspielen, wenn Aufgaben als erledigt markiert werden",
"discoverableByEmail": "Andere Benutzer mich finden lassen, wenn sie nach meiner vollständigen E-Mail suchen",
"discoverableByName": "Andere Benutzer mich finden lassen, wenn sie nach meinem Namen suchen",
"overdueReminders": "Mir jeden Morgen Erinnerungen für überfällige unerledigte Aufgaben per E-Mail senden",
"emailReminders": "Mir Erinnerungen für Aufgaben per E-Mail senden",
"savedSuccess": "Die Einstellungen wurden erfolgreich aktualisiert.",
"newName": "Der neue Name",
"name": "Name",
"title": "Allgemeine Einstellungen"
},
"updateEmailNew": "Neue E-Mail-Adresse",
"passwordUpdateSuccess": "Das Passwort wurde erfolgreich aktualisiert.",
"passwordsDontMatch": "Das neue Passwort und seine Bestätigung stimmen nicht überein.",
"currentPassword": "Aktuelles Passwort",
"newPasswordConfirm": "Neue Passwortbestätigung",
"newPassword": "Neues Passwort",
"title": "Einstellungen"
},
"auth": {
"openIdStateError": "Zustand stimmt nicht überein, weigert sich fortzufahren!",
"authenticating": "Authentifizierung …",
"loginWith": "Mit {provider} anmelden",
"register": "Registrieren",
"login": "Anmelden",
"totpPlaceholder": "z.B. 123456",
"totpTitle": "Zwei-Faktor-Authentifizierungscode",
"passwordsDontMatch": "Passwörter stimmen nicht überein",
"passwordPlaceholder": "z.B. •••••••••••",
"password": "Passwort",
"emailPlaceholder": "z.B. frederic@vikunja.io",
"email": "E-Mail-Adresse",
"usernamePlaceholder": "z.B. frederick",
"usernameEmail": "Benutzername oder E-Mail-Adresse",
"username": "Benutzername"
}
},
"home": {
"list": {
"new": "Eine neue Liste erstellen"
},
"welcome": "Hallo {username}"
}
}

851
src/i18n/lang/en.json Normal file
View File

@ -0,0 +1,851 @@
{
"home": {
"welcomeNight": "Good Night {username}",
"welcomeMorning": "Good Morning {username}",
"welcomeDay": "Hi {username}",
"welcomeEvening": "Good Evening {username}",
"lastViewed": "Last viewed",
"list": {
"newText": "You can create a new list for your new tasks:",
"new": "Create a new list",
"importText": "Or import your lists and tasks from other services into Vikunja:",
"import": "Import your data into Vikunja"
}
},
"404": {
"title": "Not found",
"text": "The page you requested does not exist."
},
"user": {
"auth": {
"username": "Username",
"usernameEmail": "Username Or Email Address",
"usernamePlaceholder": "e.g. frederick",
"email": "E-mail address",
"emailPlaceholder": "e.g. frederic@vikunja.io",
"password": "Password",
"passwordRepeat": "Retype your password",
"passwordPlaceholder": "e.g. •••••••••••",
"resetPassword": "Reset your password",
"resetPasswordAction": "Send me a password reset link",
"restPasswordSuccess": "Check your inbox! You should have an e-mail with instructions on how to reset your password.",
"passwordsDontMatch": "Passwords don't match",
"confirmEmailSuccess": "You successfully confirmed your email! You can log in now.",
"totpTitle": "Two Factor Authentication Code",
"totpPlaceholder": "e.g. 123456",
"login": "Login",
"register": "Register",
"loginWith": "Log in with {provider}",
"authenticating": "Authenticating…",
"openIdStateError": "State does not match, refusing to continue!",
"logout": "Logout"
},
"settings": {
"title": "Settings",
"newPasswordTitle": "Update Your Password",
"newPassword": "New Password",
"newPasswordConfirm": "New Password Confirmation",
"currentPassword": "Current Password",
"currentPasswordPlaceholder": "Your current password",
"passwordsDontMatch": "The new password and its confirmation don't match.",
"passwordUpdateSuccess": "The password was successfully updated.",
"updateEmailTitle": "Update Your E-Mail Address",
"updateEmailNew": "New Email Address",
"updateEmailSuccess": "Your email address was successfully updated. We've sent you a link to confirm it.",
"general": {
"title": "General Settings",
"name": "Name",
"newName": "The new Name",
"savedSuccess": "The settings were successfully updated.",
"emailReminders": "Send me reminders for tasks via Email",
"overdueReminders": "Send me reminders for overdue undone tasks via email each morning",
"discoverableByName": "Let other users find me when they search for my name",
"discoverableByEmail": "Let other users find me when they search for my full email",
"playSoundWhenDone": "Play a sound when marking tasks as done",
"weekStart": "Week starts on",
"weekStartSunday": "Sunday",
"weekStartMonday": "Monday",
"language": "Language",
"defaultList": "Default List"
},
"totp": {
"title": "Two Factor Authentication",
"enroll": "Enroll",
"finishSetupPart1": "To finish your setup, use this secret in your totp app (Google Authenticator or similar):",
"finishSetupPart2": "After that, enter a code from your app below.",
"scanQR": "Alternatively you can scan this QR code:",
"passcode": "Passcode",
"passcodePlaceholder": "A code generated by your totp application",
"setupSuccess": "You've sucessfully set up two factor authentication!",
"enterPassword": "Please Enter Your Password",
"disable": "Disable two factor authentication",
"confirmSuccess": "You've successfully confirmed your totp setup and can use it from now on!",
"disableSuccess": "Two factor authentication was sucessfully disabled."
},
"caldav": {
"title": "Caldav",
"howTo": "You can connect Vikunja to caldav clients to view and manage all tasks from different clients. Enter this url into your client:",
"more": "More information about caldav in Vikunja"
},
"avatar": {
"title": "Avatar",
"initials": "Initials",
"gravatar": "Gravatar",
"upload": "Upload",
"uploadAvatar": "Upload Avatar",
"statusUpdateSuccess": "Avatar status was updated successfully!",
"setSuccess": "The avatar has been set successfully!"
}
}
},
"list": {
"archived": "This list is archived. It is not possible to create new or edit tasks or it.",
"title": "List Title",
"color": "Color",
"lists": "Lists",
"search": "Type to search for a list…",
"searchSelect": "Click or press enter to select this list",
"shared": "Shared Lists",
"create": {
"header": "Create a new list",
"titlePlaceholder": "The list's title goes here…",
"addTitleRequired": "Please specify a title.",
"createdSuccess": "The list was successfully created.",
"addListRequired": "Please specify a list or set a default list in the settings."
},
"archive": {
"title": "Archive \"{list}\"",
"archive": "Archive this list",
"unarchive": "Un-Archive this list",
"unarchiveText": "You will be able to create new tasks or edit it.",
"archiveText": "You won't be able to edit this list or create new tasks until you un-archive it.",
"success": "The list was successfully archived."
},
"background": {
"title": "Set list background",
"remove": "Remove Background",
"upload": "Choose a background from your pc",
"searchPlaceholder": "Search for a background…",
"poweredByUnsplash": "Powered by Unsplash",
"loadMore": "Load more photos",
"success": "The background has been set successfully!",
"removeSuccess": "The background has been removed successfully!"
},
"delete": {
"title": "Delete \"{list}\"",
"header": "Delete this list",
"text1": "Are you sure you want to delete this list and all of its contents?",
"text2": "This includes all tasks and CANNOT BE UNDONE!",
"success": "The list was successfully deleted."
},
"duplicate": {
"title": "Duplicate this list",
"label": "Duplicate",
"text": "Select a namespace which should hold the duplicated list:",
"success": "The list was successfully duplicated."
},
"edit": {
"header": "Edit This List",
"title": "Edit \"{list}\"",
"titlePlaceholder": "The list title goes here…",
"identifierTooltip": "The list identifier can be used to uniquely identify a task across lists. You can set it to empty to disable it.",
"identifier": "List Identifier",
"identifierPlaceholder": "The list identifier goes here…",
"description": "Description",
"descriptionPlaceholder": "The lists description goes here…",
"color": "Color",
"success": "The list was successfully updated."
},
"share": {
"header": "Share this list",
"title": "Share \"{list}\"",
"share": "Share",
"links": {
"title": "Share Links",
"what": "What is a share link?",
"explanation": "Share Links allow you to easily share a list with other users who don't have an account on Vikunja.",
"create": "Create a new link share",
"name": "Name (optional)",
"namePlaceholder": "e.g. Lorem Ipsum",
"nameExplanation": "All actions done by this link share will show up with the name.",
"password": "Password (optional)",
"passwordExplanation": "When authenticating, the user will be required to enter this password.",
"noName": "No name set",
"remove": "Remove a link share",
"removeText": "Are you sure you want to remove this link share? It will no longer be possible to access this list with this link share. This cannot be undone!",
"createSuccess": "The link share was successfully created.",
"deleteSuccess": "The link share was successfully deleted"
},
"userTeam": {
"typeUser": "user | users",
"typeTeam": "team | teams",
"shared": "Shared with these {type}",
"you": "You",
"notShared": "Not shared with any {type} yet.",
"removeHeader": "Remove a {type} from the {sharable}",
"removeText": "Are you sure you want to remove this {sharable} from the {type}? This cannot be undone!",
"removeSuccess": "The {sharable} was successfully removed from the {type}.",
"addedSuccess": "The {type} was successfully added.",
"updatedSuccess": "The {type} was successfully added."
},
"right": {
"title": "Right",
"read": "Read only",
"readWrite": "Read & write",
"admin": "Admin"
},
"attributes": {
"link": "Link",
"name": "Name",
"sharedBy": "Shared by",
"right": "Right",
"delete": "Delete"
}
},
"list": {
"title": "List",
"add": "Add",
"addPlaceholder": "Add a new task…",
"empty": "This list is currently empty.",
"newTaskCta": "Create a new task.",
"editTask": "Edit Task"
},
"gantt": {
"title": "Gantt",
"showTasksWithoutDates": "Show tasks which don't have dates set",
"size": "Size",
"default": "Default",
"month": "Month",
"day": "Day",
"from": "From",
"to": "To",
"noDates": "This task has no dates set."
},
"table": {
"title": "Table",
"columns": "Columns"
},
"kanban": {
"title": "Kanban",
"limit": "Limit: {limit}",
"noLimit": "Not Set",
"doneBucket": "Done bucket",
"doneBucketHint": "All tasks moved into this bucket will automatically marked as done.",
"doneBucketHintExtended": "All tasks moved into the done bucket will be marked as done automatically. All tasks marked as done from elsewhere will be moved as well.",
"doneBucketSavedSuccess": "The done bucket has been saved successfully.",
"deleteLast": "You cannot remove the last bucket.",
"addTaskPlaceholder": "Enter the new task title…",
"addTask": "Add a task",
"addAnotherTask": "Add another task",
"addBucket": "Create a new bucket",
"addBucketPlaceholder": "Enter the new bucket title…",
"deleteHeaderBucket": "Delete the bucket",
"deleteBucketText1": "Are you sure you want to delete this bucket?",
"deleteBucketText2": "This will not delete any tasks but move them into the default bucket.",
"deleteBucketSuccess": "The bucket has been deleted successfully.",
"bucketTitleSavedSuccess": "The bucket title has been saved successfully.",
"bucketLimitSavedSuccess": "The bucket limit been saved successfully.",
"collapse": "Collapse this bucket"
},
"pseudo": {
"favorites": {
"title": "Favorites"
}
}
},
"namespace": {
"title": "Namespaces & Lists",
"namespace": "Namespace",
"showArchived": "Show Archived",
"noneAvailable": "You don't have any namespaces right now.",
"unarchive": "Un-Archive",
"archived": "Archived",
"noLists": "This namespace does not contain any lists.",
"createList": "Create a new list in this namespace.",
"namespaces": "Namespaces",
"search": "Type to search for a namespace…",
"create": {
"title": "Create a new namespace",
"titleRequired": "Please specify a title.",
"explanation": "A namespace is a collection of lists you can share and use to organize your lists with. In fact, every list belongs to a namepace.",
"tooltip": "What's a namespace?",
"success": "The namespace was successfully created."
},
"archive": {
"titleArchive": "Archive \"{namespace}\"",
"titleUnarchive": "Un-Archive \"{namespace}\"",
"archiveText": "You won't be able to edit this namespace or create new lists until you un-archive it. This will also archive all lists in this namespace.",
"unarchiveText": "You will be able to create new lists or edit it.",
"success": "The namespace was successfully archived.",
"description": "If a namespace is archived, you cannot create new lists or edit it."
},
"delete": {
"title": "Delete \"{namespace}\"",
"text1": "Are you sure you want to delete this namespace and all of its contents?",
"text2": "This includes all lists and tasks and CANNOT BE UNDONE!",
"success": "The namespace was successfully deleted."
},
"edit": {
"title": "Edit \"{namespace}\"",
"success": "The namespace was successfully updated."
},
"share": {
"title": "Share \"{namespace}\""
},
"attributes": {
"title": "Namespace Title",
"titlePlaceholder": "The namespace title goes here…",
"description": "Description",
"descriptionPlaceholder": "The namespaces description goes here…",
"color": "Color",
"archived": "Is Archived",
"isArchived": "This namespace is archived"
},
"pseudo": {
"sharedLists": {
"title": "Shared Lists"
},
"favorites": {
"title": "Favorites"
},
"savedFilters": {
"title": "Filters"
}
}
},
"filters": {
"title": "Filters",
"attributes": {
"title": "Title",
"titlePlaceholder": "The saved filter title goes here…",
"description": "Description",
"descriptionPlaceholder": "The description goes here…",
"includeNulls": "Include Tasks which don't have a value set",
"requireAll": "Require all filters to be true for a task to show up",
"showDoneTasks": "Show Done Tasks",
"enablePriority": "Enable Filter By Priority",
"enablePercentDone": "Enable Filter By Percent Done",
"dueDateRange": "Due Date Range",
"startDateRange": "Start Date Range",
"endDateRange": "End Date Range",
"reminderRange": "Reminder Date Range"
},
"create": {
"title": "Create A Saved Filter",
"description": "A saved filter is a virtual list which is computed from a set of filters each time it is accessed. Once created, it will appear in a special namespace.",
"action": "Create new saved filter"
},
"delete": {
"header": "Delete this saved filter",
"text": "Are you sure you want to delete this saved filter?",
"success": "The filter was deleted successfully."
},
"edit": {
"title": "Edit This Saved Filter",
"success": "The filter was saved successfully."
}
},
"migrate": {
"title": "Migrate from other services to Vikunja",
"titleService": "Import your data from {name} into Vikunja",
"import": "Import your data into Vikunja",
"description": "Click on the logo of one of the third-party services below to get started.",
"descriptionDo": "Vikunja will import all lists, tasks, notes, reminders and files you have access to.",
"authorize": "To authorize Vikunja to access your {name} Account, click the button below.",
"getStarted": "Get Started",
"inProgress": "Importing in progress…",
"alreadyMigrated1": "It looks like you've already imported your stuff from {name} at {date}.",
"alreadyMigrated2": "Importing again is possible, but might create duplicates. Are you sure?",
"confirm": "I am sure, please start migrating now!"
},
"label": {
"title": "Labels",
"manage": "Manage labels",
"description": "Click on a label to edit it. You can edit all labels you created, you can use all labels which are associated with a task to whose list you have access.",
"newCTA": "You currently do not have any labels.",
"search": "Type to search for a label…",
"create": {
"header": "New label",
"title": "Create a new label",
"titleRequired": "Please specify a title.",
"success": "The label was successfully created."
},
"edit": {
"header": "Edit Label",
"forbidden": "You are not allowed to edit this label because you dont own it.",
"success": "The label was successfully updated."
},
"deleteSuccess": "The label was successfully deleted.",
"attributes": {
"title": "Title",
"titlePlaceholder": "The label title goes here…",
"description": "Description",
"descriptionPlaceholder": "Label description",
"color": "Color"
}
},
"sharing": {
"authenticating": "Authenticating…",
"passwordRequired": "This shared list requires a password. Please enter it below:",
"error": "An error occured.",
"invalidPassword": "The password is invalid."
},
"navigation": {
"overview": "Overview",
"upcoming": "Upcoming",
"settings": "Settings",
"imprint": "Imprint",
"privacy": "Privacy Policy"
},
"misc": {
"loading": "Loading…",
"save": "Save",
"delete": "Delete",
"confirm": "Confirm",
"cancel": "Cancel",
"refresh": "Refresh",
"disable": "Disable",
"copy": "Copy to clipboard",
"search": "Search",
"searchPlaceholder": "Type to search…",
"previous": "Previous",
"next": "Next",
"poweredBy": "Powered by Vikunja",
"info": "Info",
"create": "Create",
"doit": "Do it!",
"saving": "Saving…",
"saved": "Saved!",
"default": "Default",
"close": "Close"
},
"input": {
"resetColor": "Reset Color",
"datepicker": {
"today": "Today",
"tomorrow": "Tomorrow",
"nextMonday": "Next Monday",
"thisWeekend": "This Weekend",
"laterThisWeek": "Later This Week",
"nextWeek": "Next Week",
"chooseDate": "Choose a date"
},
"editor": {
"edit": "Edit",
"done": "Done",
"heading1": "Heading 1",
"heading2": "Heading 2",
"heading3": "Heading 3",
"headingSmaller": "Heading Smaller",
"headingBigger": "Heading Bigger",
"bold": "Bold",
"italic": "Italic",
"strikethrough": "Strikethrough",
"code": "Code",
"quote": "Quote",
"unorderedList": "Unordered List",
"orderedList": "Ordered List",
"cleanBlock": "Clean Block",
"link": "Link",
"image": "Image",
"table": "Table",
"horizontalRule": "Horizontal Rule",
"sideBySide": "Side By Side",
"guide": "Guide"
},
"multiselect": {
"createPlaceholder": "Create new",
"selectPlaceholder": "Click or press enter to select"
}
},
"task": {
"task": "Task",
"new": "Create a new task",
"delete": "Delete this task",
"createSuccess": "The task was successfully created.",
"addReminder": "Add a new reminder…",
"doneSuccess": "The task was successfully marked as done.",
"undoneSuccess": "The task was successfully un-marked as done.",
"openDetail": "Open task detail view",
"show": {
"titleCurrent": "Current Tasks",
"titleDates": "Tasks from {from} until {to}",
"noDates": "Show tasks without dates",
"current": "Current tasks",
"from": "Tasks from",
"until": "until",
"today": "Today",
"nextWeek": "Next Week",
"nextMonth": "Next Month",
"noTasks": "Nothing to do - Have a nice day!"
},
"detail": {
"chooseDueDate": "Click here to set a due date",
"chooseStartDate": "Click here to set a start date",
"chooseEndDate": "Click here to set an end date",
"move": "Move task to a different list",
"done": "Done!",
"undone": "Mark as undone",
"created": "Created {0} by {1}",
"updated": "Updated {0}",
"doneAt": "Done {0}",
"updateSuccess": "The task was saved successfully.",
"deleteSuccess": "The task has been deleted successfully.",
"belongsToList": "This task belongs to list '{list}'",
"due": "Due {at}",
"delete": {
"header": "Delete this task",
"text1": "Are you sure you want to remove this task?",
"text2": "This will also remove all attachments, reminders and relations associated with this task and cannot be undone!"
},
"actions": {
"assign": "Assign this task to a user",
"label": "Add labels",
"priority": "Set Priority",
"dueDate": "Set Due Date",
"startDate": "Set a Start Date",
"endDate": "Set an End Date",
"reminders": "Set Reminders",
"repeatAfter": "Set a repeating interval",
"percentDone": "Set Percent Done",
"attachments": "Add attachments",
"relatedTasks": "Add task relations",
"moveList": "Move task",
"color": "Set task color",
"delete": "Delete task"
}
},
"attributes": {
"assignees": "Assignees",
"color": "Color",
"created": "Created",
"createdBy": "Created By",
"description": "Description",
"done": "Done",
"dueDate": "Due Date",
"endDate": "End Date",
"labels": "Labels",
"percentDone": "% Done",
"priority": "Priority",
"relatedTasks": "Related Tasks",
"reminders": "Reminders",
"repeat": "Repeat",
"startDate": "Start Date",
"title": "Title",
"updated": "Updated"
},
"subscription": {
"subscribedThroughParent": "You can't unsubscribe here because you are subscribed to this {entity} through its {parent}.",
"subscribed": "You are currently subscribed to this {entity} and will receive notifications for changes.",
"notSubscribed": "You are not subscribed to this {entity} and won't receive notifications for changes.",
"subscribe": "Subscribe",
"unsubscribe": "Unsubscribe",
"subscribeSuccess": "You are now subscribed to this {entity}",
"unsubscribeSuccess": "You are now unsubscribed to this {entity}"
},
"attachment": {
"title": "Attachments",
"createdBy": "created {0} by {1}",
"download": "Download",
"downloadTooltip": "Download this attachment",
"upload": "Upload attachment",
"drop": "Drop files here to upload",
"delete": "Delete attachment",
"deleteTooltip": "Delete this attachment",
"deleteText1": "Are you sure you want to delete the attachment {filename}?",
"deleteText2": "This cannot be undone!",
"copyUrl": "Copy URL",
"copyUrlTooltip": "Copy the url of this attachment for usage in text"
},
"comment": {
"title": "Comments",
"loading": "Loading comments…",
"edited": "edited {date}",
"creating": "Creating comment…",
"placeholder": "Add your comment…",
"comment": "Comment",
"delete": "Delete this comment",
"deleteText1": "Are you sure you want to delete this comment?",
"deleteText2": "This cannot be undone!",
"addedSuccess": "The comment was added successfully."
},
"deferDueDate": {
"title": "Defer due date",
"1day": "1 day",
"3days": "3 days",
"1week": "1 week"
},
"description": {
"placeholder": "Click here to enter a description…",
"empty": "No description available yet."
},
"assignee": {
"placeholder": "Type to assign a user…",
"selectPlaceholder": "Assign this user",
"assignSuccess": "The user has been assigned successfully.",
"unassignSuccess": "The user has been unassigned successfully."
},
"label": {
"placeholder": "Type to add a new label…",
"createPlaceholder": "Add this as new label",
"addSuccess": "The label has been added successfully.",
"createSuccess": "The label has been created successfully.",
"removeSuccess": "The label has been removed successfully."
},
"priority": {
"unset": "Unset",
"low": "Low",
"medium": "Medium",
"high": "high",
"urgent": "Urgent",
"doNow": "DO NOW"
},
"relation": {
"add": "Add a New Task Relation",
"new": "New Task Relation",
"searchPlaceholder": "Type search for a new task to add as related…",
"createPlaceholder": "Add this as new related task",
"differentList": "This task belongs to a different list.",
"noneYet": "No task relations yet.",
"delete": "Delete Task Relation",
"deleteText1": "Are you sure you want to delete this task relation?",
"deleteText2": "This cannot be undone!"
},
"repeat": {
"everyDay": "Every Day",
"everyWeek": "Every Week",
"everyMonth": "Every Month",
"mode": "Repeat mode",
"monthly": "Monthly",
"fromCurrentDate": "From Current Date",
"each": "Each",
"specifyAmount": "Specify an amount…",
"hours": "Hours",
"days": "Days",
"weeks": "Weeks",
"months": "Months",
"years": "Years"
},
"quickAddMagic": {
"hint": "You can use Quick Add Magic",
"what": "What?",
"title": "Quick Add Magic",
"intro": "When creating a task, you can use special keywords to directly add attributes to the newly created task. This allows to add commonly used attributes to tasks much faster.",
"multiple": "You can use this multiple times.",
"label1": "To add a label, simply prefix the name of the label with {prefix}.",
"label2": "Vikunja will first check if the label already exist and create it if not.",
"label3": "To use spaces, simply add a \" around the label name.",
"label4": "For example: {prefix}\"Label with spaces\".",
"priority1": "To set a task's priority, add a number 1-5, prefixed with a {prefix}.",
"priority2": "The higher the number, the higher the priority.",
"assignees": "To directly assign the task to a user, add their username prefixed with @ to the task.",
"list1": "To set a list for the task to appear in, enter its name prefixed with {prefix}.",
"list2": "This will return an error if the list does not exist.",
"dateAndTime": "Date and time",
"date": "Any date will be used as the due date of the new task. You can use dates in any of these formats:",
"dateWeekday": "any weekday, will use the next date with that date",
"dateCurrentYear": "will use the current year",
"dateNth": "will use the {day}th of the current month",
"dateTime": "Combine any of the date formats with \"{time}\" (or {timePM}) to set a time."
}
},
"team": {
"title": "Teams",
"noTeams": "You are currently not part of any teams.",
"create": {
"title": "Create a new team",
"success": "The team was successfully created."
},
"edit": {
"title": "Edit Team \"{team}\"",
"members": "Team Members",
"search": "Type to search a user…",
"addUser": "Add to team",
"makeMember": "Make Member",
"makeAdmin": "Make Admin",
"success": "The team was successfully updated.",
"userAddedSuccess": "The team member was successfully added.",
"madeMember": "The team member was successfully made member.",
"madeAdmin": "The team member was successfully made admin.",
"delete": {
"header": "Delete the team",
"text1": "Are you sure you want to delete this team and all of its members?",
"text2": "All team members will loose access to lists and namespaces shared with this team. This CANNOT BE UNDONE!",
"success": "The team was successfully deleted."
},
"deleteUser": {
"header": "Remove a user from the team",
"text1": "Are you sure you want to remove this user from the team?",
"text2": "They will loose access to all lists and namespaces this team has access to. This CANNOT BE UNDONE!",
"success": "The user was successfully deleted from the team."
}
},
"attributes": {
"name": "Team Name",
"namePlaceholder": "The team's name goes here…",
"nameRequired": "Please specify a name.",
"description": "Description",
"descriptionPlaceholder": "The teams description goes here…",
"admin": "Admin",
"member": "Member"
}
},
"keyboardShortcuts": {
"title": "Keyboard Shortcuts",
"allPages": "These shortcuts work on all pages.",
"currentPageOnly": "These shortcuts work only on the current page.",
"toggleMenu": "Toggle The Menu",
"quickSearch": "Open the search/quick action bar",
"task": {
"title": "Task Page",
"done": "Mark a task as done",
"assign": "Assign this task to a user",
"labels": "Add labels to this task",
"dueDate": "Change the due date of this task",
"attachment": "Add an attachment to this task",
"related": "Modify related tasks of this task"
}
},
"update": {
"available": "There is an update for Vikunja available!",
"do": "Update Now"
},
"menu": {
"edit": "Edit",
"archive": "Archive",
"duplicate": "Duplicate",
"delete": "Delete",
"unarchive": "Un-Archive",
"setBackground": "Set background",
"share": "Share",
"newList": "New list"
},
"apiConfig": {
"url": "Vikunja URL",
"urlPlaceholder": "eg. https://localhost:3456",
"change": "change",
"signInOn": "Sign in to your Vikunja account on {0}",
"error": "Could not find or use Vikunja installation at \"{domain}\".",
"success": "Using Vikunja installation at \"{domain}\"."
},
"loadingError": {
"failed": "Loading failed, please {0}. If the error persists, please {1}.",
"tryAgain": "try again",
"contact": "contact us"
},
"notification": {
"title": "Notifications",
"none": "You don't have any notifications. Have a nice day!",
"explainer": "Notifications will appear here when actions on namespaces, lists or tasks you subscribed to happen."
},
"quickActions": {
"commands": "Commands",
"placeholder": "Type a command or search…",
"hint": "You can use # to only seach for tasks, * to only search for lists and @ to only search for teams.",
"tasks": "Tasks",
"lists": "Lists",
"teams": "Teams",
"newList": "Enter the title of the new list…",
"newTask": "Enter the title of the new task…",
"newNamespace": "Enter the title of the new namespace…",
"newTeam": "Enter the name of the new team…",
"createTask": "Create a task in the current list ({title})",
"createList": "Create a list in the current namespace ({title})",
"cmds": {
"newTask": "New task",
"newList": "New list",
"newNamespace": "New namespace",
"newTeam": "New team"
}
},
"date": {
"locale": "en",
"in": "in {date}",
"ago": "{date} ago",
"altFormatLong": "j M Y H:i",
"altFormatShort": "j M Y"
},
"error": {
"error": "Error",
"success": "Success",
"0001": "You're not allowed to do that.",
"1001": "A user with this username already exists.",
"1002": "A user with this email address already exists.",
"1004": "No username and password specified.",
"1005": "The user does not exist.",
"1006": "Could not get the user id.",
"1008": "No password reset token provided.",
"1009": "Invalid password reset token.",
"1010": "Invalid email confirm token.",
"1011": "Wrong username or password.",
"1012": "Email address of the user not confirmed.",
"1013": "New password is empty.",
"1014": "Old password is empty.",
"1015": "Totp is already enabled for this user.",
"1016": "Totp is not enabled for this user.",
"1017": "The totp passcode is invalid.",
"1018": "The user avatar type setting is invalid.",
"2001": "ID cannot be empty or 0.",
"2002": "Some of the request data was invalid.",
"3001": "The list does not exist.",
"3004": "You need to have read permissions on that list to perform that action.",
"3005": "The list title cannot be empty.",
"3006": "The list share does not exist.",
"3007": "A list with this identifier already exists.",
"3008": "The list is archived and can therefore only be accessed read only. This is also true for all tasks associated with this list.",
"4001": "The list task text cannot be empty.",
"4002": "The list task does not exist.",
"4003": "All bulk editing tasks must belong to the same list.",
"4004": "Need at least one task when bulk editing tasks.",
"4005": "You do not have the right to see the task.",
"4006": "You can't set a parent task as the task itself.",
"4007": "You can't create a task relation with an invalid kind of relation.",
"4008": "You can't create a task relation which already exists.",
"4009": "The task relation does not exist.",
"4010": "Cannot relate a task with itself.",
"4011": "The task attachment does not exist.",
"4012": "The task attachment is too large.",
"4013": "The task sort param is invalid.",
"4014": "The task sort order is invalid.",
"4015": "The task comment does not exist.",
"4016": "Invalid task field.",
"4017": "Invalid task filter comparator.",
"4018": "Invalid task filter concatinator.",
"4019": "Invalid task filter value.",
"5001": "The namespace does not exist.",
"5003": "You do not have access to the specified namespace.",
"5006": "The namespace name cannot be empty.",
"5009": "You need to have namespace read access to perform that action.",
"5010": "This team does not have access to that namespace.",
"5011": "This user has already access to that namespace.",
"5012": "The namespace is archived and can therefore only be accessed read only.",
"6001": "The team name cannot be emtpy.",
"6002": "The team does not exist.",
"6004": "The team already has access to that namespace or list.",
"6005": "The user is already a member of that team.",
"6006": "Cannot delete the last team member.",
"6007": "The team does not have access to the list to perform that action.",
"7002": "The user already has access to that list.",
"7003": "You do not have access to that list.",
"8001": "This label already exists on that task.",
"8002": "The label does not exist.",
"8003": "You do not have access to this label.",
"9001": "The right is invalid.",
"10001": "The bucket does not exist.",
"10002": "The bucket does not belong to that list.",
"10003": "You cannot remove the last bucket on a list.",
"10004": "You cannot add the task to this bucket as it already exceeded the limit of tasks it can hold.",
"10005": "There can be only one done bucket per list.",
"11001": "The saved filter does not exist.",
"11002": "Saved filters are not available for link shares.",
"12001": "The subscription entity type is invalid.",
"12002": "You are already subscribed to the entity itself or a parent entity.",
"13001": "This link share requires a password for authentication, but none was provided.",
"13002": "The provided link share password was invalid."
},
"about": {
"title": "About",
"frontendVersion": "Frontend Version: {version}",
"apiVersion": "API Version: {version}"
}
}

1
src/i18n/lang/es.json Normal file
View File

@ -0,0 +1 @@
{}

1
src/i18n/lang/fr.json Normal file
View File

@ -0,0 +1 @@
{}

1
src/i18n/lang/ro.json Normal file
View File

@ -0,0 +1 @@
{}

1
src/i18n/lang/ru.json Normal file
View File

@ -0,0 +1 @@
{}

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