Merge branch 'main' into feature/blur-hash

# Conflicts:
#	package.json
#	src/components/home/contentAuth.vue
#	src/store/index.js
#	src/views/list/ShowList.vue
This commit is contained in:
kolaente 2022-04-02 14:27:32 +02:00
commit f8ca1c47a9
Signed by: konrad
GPG Key ID: F40E70337AB24C9B
161 changed files with 10143 additions and 6471 deletions

View File

@ -116,36 +116,15 @@ steps:
YARN_CACHE_FOLDER: .cache/yarn/ YARN_CACHE_FOLDER: .cache/yarn/
CYPRESS_CACHE_FOLDER: .cache/cypress/ CYPRESS_CACHE_FOLDER: .cache/cypress/
CYPRESS_DEFAULT_COMMAND_TIMEOUT: 60000 CYPRESS_DEFAULT_COMMAND_TIMEOUT: 60000
CYPRESS_RECORD_KEY:
from_secret: cypress_project_key
commands: commands:
- sed -i 's/localhost/api/g' dist/index.html - sed -i 's/localhost/api/g' dist/index.html
- yarn serve:dist & npx wait-on http://localhost:5000 - yarn serve:dist & npx wait-on http://localhost:4173
- yarn test:frontend --browser chrome - yarn test:frontend --browser chrome --record
depends_on: depends_on:
- dependencies
- build-prod - build-prod
- name: upload-test-results
image: plugins/s3
pull: true
settings:
bucket: drone-test-results
access_key:
from_secret: test_results_aws_access_key_id
secret_key:
from_secret: test_results_aws_secret_access_key
endpoint: https://s3.fr-par.scw.cloud
region: fr-par
path_style: true
source: cypress/screenshots/**/**/*
strip_prefix: cypress/screenshots/
target: /${DRONE_REPO}/${DRONE_PULL_REQUEST}_${DRONE_BRANCH}/${DRONE_BUILD_NUMBER}/
depends_on:
- test-frontend
when:
status:
- failure
- success
- name: deploy-preview - name: deploy-preview
image: node:16 image: node:16
pull: true pull: true
@ -157,6 +136,9 @@ steps:
GITEA_TOKEN: GITEA_TOKEN:
from_secret: gitea_token from_secret: gitea_token
commands: commands:
- cp -r dist dist-preview
# Override the default api url used for preview
- sed -i 's|localhost:3456|try.vikunja.io|g' dist-preview/index.html
- shasum -a 384 -c ./scripts/deploy-preview-netlify.js.sha384 - shasum -a 384 -c ./scripts/deploy-preview-netlify.js.sha384
- node ./scripts/deploy-preview-netlify.js - node ./scripts/deploy-preview-netlify.js
depends_on: depends_on:
@ -665,6 +647,6 @@ steps:
from_secret: crowdin_key from_secret: crowdin_key
--- ---
kind: signature kind: signature
hmac: 188ee90100c5fc5922a445e531e7a47453121edddb2a64a182eb23ed2bf602de hmac: 997e1badebe484ac29557c4af356e63db4d3d57f3d32e92d482f117f8cec64da
... ...

View File

@ -1,5 +1,5 @@
{ {
"baseUrl": "http://localhost:5000", "baseUrl": "http://localhost:4173",
"env": { "env": {
"API_URL": "http://localhost:3456/api/v1", "API_URL": "http://localhost:3456/api/v1",
"TEST_SECRET": "averyLongSecretToSe33dtheDB" "TEST_SECRET": "averyLongSecretToSe33dtheDB"
@ -7,5 +7,6 @@
"video": false, "video": false,
"retries": { "retries": {
"runMode": 2 "runMode": 2
} },
"projectId": "181c7x"
} }

View File

@ -1,4 +1,4 @@
import faker from 'faker' import faker from '@faker-js/faker'
import {Factory} from '../support/factory' import {Factory} from '../support/factory'
import {formatISO} from 'date-fns' import {formatISO} from 'date-fns'

View File

@ -1,4 +1,4 @@
import faker from 'faker' import faker from '@faker-js/faker'
import {Factory} from '../support/factory' import {Factory} from '../support/factory'
import {formatISO} from 'date-fns' import {formatISO} from 'date-fns'

View File

@ -1,6 +1,6 @@
import {Factory} from '../support/factory' import {Factory} from '../support/factory'
import {formatISO} from "date-fns" import {formatISO} from "date-fns"
import faker from 'faker' import faker from '@faker-js/faker'
export class LinkShareFactory extends Factory { export class LinkShareFactory extends Factory {
static table = 'link_shares' static table = 'link_shares'

View File

@ -1,6 +1,6 @@
import {Factory} from '../support/factory' import {Factory} from '../support/factory'
import {formatISO} from "date-fns" import {formatISO} from "date-fns"
import faker from 'faker' import faker from '@faker-js/faker'
export class ListFactory extends Factory { export class ListFactory extends Factory {
static table = 'lists' static table = 'lists'

View File

@ -1,4 +1,4 @@
import faker from 'faker' import faker from '@faker-js/faker'
import {Factory} from '../support/factory' import {Factory} from '../support/factory'
import {formatISO} from 'date-fns' import {formatISO} from 'date-fns'

View File

@ -1,4 +1,4 @@
import faker from 'faker' import faker from '@faker-js/faker'
import {Factory} from '../support/factory' import {Factory} from '../support/factory'
import {formatISO} from 'date-fns' import {formatISO} from 'date-fns'

View File

@ -1,4 +1,4 @@
import faker from 'faker' import faker from '@faker-js/faker'
import {Factory} from '../support/factory' import {Factory} from '../support/factory'
import {formatISO} from "date-fns" import {formatISO} from "date-fns"

View File

@ -1,4 +1,4 @@
import faker from 'faker' import faker from '@faker-js/faker'
import {Factory} from '../support/factory' import {Factory} from '../support/factory'
import {formatISO} from 'date-fns' import {formatISO} from 'date-fns'

View File

@ -1,4 +1,4 @@
import faker from 'faker' import faker from '@faker-js/faker'
import {Factory} from '../support/factory' import {Factory} from '../support/factory'
import {formatISO} from "date-fns" import {formatISO} from "date-fns"

View File

@ -0,0 +1,56 @@
import {ListFactory} from '../../factories/list'
import '../../support/authenticateUser'
import {prepareLists} from './prepareLists'
describe('List History', () => {
prepareLists()
it('should show a list history on the home page', () => {
cy.intercept(Cypress.env('API_URL') + '/namespaces*').as('loadNamespaces')
cy.intercept(Cypress.env('API_URL') + '/lists/*').as('loadList')
const lists = ListFactory.create(6)
cy.visit('/')
cy.wait('@loadNamespaces')
cy.get('body')
.should('not.contain', 'Last viewed')
cy.visit(`/lists/${lists[0].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
cy.visit(`/lists/${lists[1].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
cy.visit(`/lists/${lists[2].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
cy.visit(`/lists/${lists[3].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
cy.visit(`/lists/${lists[4].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
cy.visit(`/lists/${lists[5].id}`)
cy.wait('@loadNamespaces')
cy.wait('@loadList')
// cy.visit('/')
// cy.wait('@loadNamespaces')
// Not using cy.visit here to work around the redirect issue fixed in #1337
cy.get('nav.menu.top-menu a')
.contains('Overview')
.click()
cy.get('body')
.should('contain', 'Last viewed')
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

@ -0,0 +1,76 @@
import {formatISO, format} from 'date-fns'
import {TaskFactory} from '../../factories/task'
import {prepareLists} from './prepareLists'
import '../../support/authenticateUser'
describe('List View Gantt', () => {
prepareLists()
it('Hides tasks with no dates', () => {
const tasks = TaskFactory.create(1)
cy.visit('/lists/1/gantt')
cy.get('.gantt-chart .tasks')
.should('not.contain', tasks[0].title)
})
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 .months')
.should('contain', format(now, 'MMMM'))
.should('contain', format(nextMonth, 'MMMM'))
})
it('Shows tasks with dates', () => {
const now = new Date()
const tasks = TaskFactory.create(1, {
start_date: formatISO(now),
end_date: formatISO(now.setDate(now.getDate() + 4))
})
cy.visit('/lists/1/gantt')
cy.get('.gantt-chart .tasks')
.should('not.be.empty')
cy.get('.gantt-chart .tasks')
.should('contain', tasks[0].title)
})
it('Shows tasks with no dates after enabling them', () => {
TaskFactory.create(1, {
start_date: null,
end_date: null,
})
cy.visit('/lists/1/gantt')
cy.get('.gantt-options .fancycheckbox')
.contains('Show tasks which don\'t have dates set')
.click()
cy.get('.gantt-chart .tasks')
.should('not.be.empty')
cy.get('.gantt-chart .tasks .task.nodate')
.should('exist')
})
it('Drags a task around', () => {
const now = new Date()
TaskFactory.create(1, {
start_date: formatISO(now),
end_date: formatISO(now.setDate(now.getDate() + 4))
})
cy.visit('/lists/1/gantt')
cy.get('.gantt-chart .tasks .task')
.first()
.trigger('mousedown', {which: 1})
.trigger('mousemove', {clientX: 500, clientY: 0})
.trigger('mouseup', {force: true})
})
})

View File

@ -0,0 +1,196 @@
import {BucketFactory} from '../../factories/bucket'
import {ListFactory} from '../../factories/list'
import {TaskFactory} from '../../factories/task'
import {prepareLists} from './prepareLists'
import '../../support/authenticateUser'
describe('List View Kanban', () => {
let buckets
prepareLists()
beforeEach(() => {
buckets = BucketFactory.create(2)
})
it('Shows all buckets with their tasks', () => {
const data = TaskFactory.create(10, {
list_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .title')
.contains(buckets[0].title)
.should('exist')
cy.get('.kanban .bucket .title')
.contains(buckets[1].title)
.should('exist')
cy.get('.kanban .bucket')
.first()
.should('contain', data[0].title)
})
it('Can add a new task to a bucket', () => {
TaskFactory.create(2, {
list_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.getSettled('.kanban .bucket')
.contains(buckets[0].title)
.get('.bucket-footer .button')
.contains('Add another task')
.click()
cy.get('.kanban .bucket')
.contains(buckets[0].title)
.get('.bucket-footer .field .control input.input')
.type('New Task{enter}')
cy.get('.kanban .bucket')
.first()
.should('contain', 'New Task')
})
it('Can create a new bucket', () => {
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket.new-bucket .button')
.click()
cy.get('.kanban .bucket.new-bucket input.input')
.type('New Bucket{enter}')
cy.wait(1000) // Wait for the request to finish
cy.get('.kanban .bucket .title')
.contains('New Bucket')
.should('exist')
})
it('Can set a bucket limit', () => {
cy.visit('/lists/1/kanban')
cy.getSettled('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
.first()
.click()
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item')
.contains('Limit: Not Set')
.click()
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item .field input.input')
.first()
.type(3)
cy.get('[data-cy="setBucketLimit"]')
.first()
.click()
cy.get('.kanban .bucket .bucket-header span.limit')
.contains('0/3')
.should('exist')
})
it('Can rename a bucket', () => {
cy.visit('/lists/1/kanban')
cy.getSettled('.kanban .bucket .bucket-header .title')
.first()
.type('{selectall}New Bucket Title{enter}')
cy.get('.kanban .bucket .bucket-header .title')
.first()
.should('contain', 'New Bucket Title')
})
it('Can delete a bucket', () => {
cy.visit('/lists/1/kanban')
cy.getSettled('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
.first()
.click()
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item')
.contains('Delete')
.click()
cy.get('.modal-mask .modal-container .modal-content .header')
.should('contain', 'Delete the bucket')
cy.get('.modal-mask .modal-container .modal-content .actions .button')
.contains('Do it!')
.click()
cy.get('.kanban .bucket .title')
.contains(buckets[0].title)
.should('not.exist')
cy.get('.kanban .bucket .title')
.contains(buckets[1].title)
.should('exist')
})
it('Can drag tasks around', () => {
const tasks = TaskFactory.create(2, {
list_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.getSettled('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
.first()
.drag('.kanban .bucket:nth-child(2) .tasks')
cy.get('.kanban .bucket:nth-child(2) .tasks')
.should('contain', tasks[0].title)
cy.get('.kanban .bucket:nth-child(1) .tasks')
.should('not.contain', tasks[0].title)
})
it('Should navigate to the task when the task card is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.getSettled('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
.should('be.visible')
.click()
cy.url()
.should('contain', `/tasks/${tasks[0].id}`, { timeout: 1000 })
})
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,
bucket_id: 1,
})
const task = tasks[0]
cy.visit('/lists/1/kanban')
cy.getSettled('.kanban .bucket .tasks .task')
.contains(task.title)
.should('be.visible')
.click()
cy.get('.task-view .action-buttons .button', { timeout: 3000 })
.contains('Move')
.click()
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
.type(`${lists[1].title}{enter}`)
// The requests happen with a 200ms timeout. Because of that, the results are not yet there when cypress
// presses enter and we can't simulate pressing on enter to select the item.
cy.get('.task-view .content.details .field .multiselect.control .search-results')
.children()
.first()
.click()
cy.get('.global-notification', { timeout: 1000 })
.should('contain', 'Success')
cy.go('back')
cy.get('.kanban .bucket')
.should('not.contain', task.title)
})
})

View File

@ -0,0 +1,97 @@
import {UserListFactory} from '../../factories/users_list'
import {TaskFactory} from '../../factories/task'
import {UserFactory} from '../../factories/user'
import {ListFactory} from '../../factories/list'
import {prepareLists} from './prepareLists'
import '../../support/authenticateUser'
describe('List View List', () => {
prepareLists()
it('Should be an empty list', () => {
cy.visit('/lists/1')
cy.url()
.should('contain', '/lists/1/list')
cy.get('.list-title h1')
.should('contain', 'First List')
cy.get('.list-title .dropdown')
.should('exist')
cy.get('p')
.contains('This list is currently empty.')
.should('exist')
})
it('Should navigate to the task when the title is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
})
cy.visit('/lists/1/list')
cy.get('.tasks .task .tasktext')
.contains(tasks[0].title)
.first()
.click()
cy.url()
.should('contain', `/tasks/${tasks[0].id}`)
})
it('Should not see any elements for a list which is shared read only', () => {
UserFactory.create(2)
UserListFactory.create(1, {
list_id: 2,
user_id: 1,
right: 0,
})
const lists = ListFactory.create(2, {
owner_id: '{increment}',
namespace_id: '{increment}',
})
cy.visit(`/lists/${lists[1].id}/`)
cy.get('.list-title a.icon')
.should('not.exist')
cy.get('input.input[placeholder="Add a new task..."')
.should('not.exist')
})
it('Should only show the color of a list in the navigation and not in the list view', () => {
const lists = ListFactory.create(1, {
hex_color: '00db60',
})
TaskFactory.create(10, {
list_id: lists[0].id,
})
cy.visit(`/lists/${lists[0].id}/`)
cy.get('.menu-list li .list-menu-link .color-bubble')
.should('have.css', 'background-color', 'rgb(0, 219, 96)')
cy.get('.tasks-container .tasks .color-bubble')
.should('not.exist')
})
it('Should paginate for > 50 tasks', () => {
const tasks = TaskFactory.create(100, {
id: '{increment}',
title: i => `task${i}`,
list_id: 1,
})
cy.visit('/lists/1/list')
cy.get('.tasks-container .tasks')
.should('contain', tasks[99].title)
cy.get('.card-content .pagination .pagination-link')
.contains('2')
.click()
cy.url()
.should('contain', '?page=2')
cy.get('.tasks-container .tasks')
.should('contain', tasks[1].title)
cy.get('.tasks-container .tasks')
.should('not.contain', tasks[99].title)
})
})

View File

@ -0,0 +1,52 @@
import {TaskFactory} from '../../factories/task'
import '../../support/authenticateUser'
describe('List View Table', () => {
it('Should show a table with tasks', () => {
const tasks = TaskFactory.create(1)
cy.visit('/lists/1/table')
cy.get('.list-table table.table')
.should('exist')
cy.get('.list-table table.table')
.should('contain', tasks[0].title)
})
it('Should have working column switches', () => {
TaskFactory.create(1)
cy.visit('/lists/1/table')
cy.get('.list-table .filter-container .items .button')
.contains('Columns')
.click()
cy.get('.list-table .filter-container .card.columns-filter .card-content .fancycheckbox .check')
.contains('Priority')
.click()
cy.get('.list-table .filter-container .card.columns-filter .card-content .fancycheckbox .check')
.contains('Done')
.click()
cy.get('.list-table table.table th')
.contains('Priority')
.should('exist')
cy.get('.list-table table.table th')
.contains('Done')
.should('not.exist')
})
it('Should navigate to the task when the title is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
})
cy.visit('/lists/1/table')
cy.get('.list-table table.table')
.contains(tasks[0].title)
.click()
cy.url()
.should('contain', `/tasks/${tasks[0].id}`)
})
})

View File

@ -1,25 +1,11 @@
import {formatISO, format} from 'date-fns'
import {TaskFactory} from '../../factories/task' import {TaskFactory} from '../../factories/task'
import {ListFactory} from '../../factories/list' import {prepareLists} from './prepareLists'
import {UserListFactory} from '../../factories/users_list'
import {UserFactory} from '../../factories/user'
import {NamespaceFactory} from '../../factories/namespace'
import {BucketFactory} from '../../factories/bucket'
import '../../support/authenticateUser' import '../../support/authenticateUser'
describe('Lists', () => { describe('Lists', () => {
let lists let lists
prepareLists((newLists) => (lists = newLists))
beforeEach(() => {
UserFactory.create(1)
NamespaceFactory.create(1)
lists = ListFactory.create(1, {
title: 'First List'
})
TaskFactory.truncate()
})
it('Should create a new list', () => { it('Should create a new list', () => {
cy.visit('/') cy.visit('/')
@ -29,7 +15,7 @@ describe('Lists', () => {
.contains('New list') .contains('New list')
.click() .click()
cy.url() cy.url()
.should('contain', '/namespaces/1/list') .should('contain', '/lists/new/1')
cy.get('.card-header-title') cy.get('.card-header-title')
.contains('New list') .contains('New list')
cy.get('input.input') cy.get('input.input')
@ -56,7 +42,7 @@ describe('Lists', () => {
}) })
it('Should rename the list in all places', () => { it('Should rename the list in all places', () => {
const tasks = TaskFactory.create(5, { TaskFactory.create(5, {
id: '{increment}', id: '{increment}',
list_id: 1, list_id: 1,
}) })
@ -86,7 +72,7 @@ describe('Lists', () => {
.should('contain', newListName) .should('contain', newListName)
.should('not.contain', lists[0].title) .should('not.contain', lists[0].title)
cy.visit('/') cy.visit('/')
cy.get('.card-content .tasks') cy.get('.card-content')
.should('contain', newListName) .should('contain', newListName)
.should('not.contain', lists[0].title) .should('not.contain', lists[0].title)
}) })
@ -112,429 +98,4 @@ describe('Lists', () => {
cy.location('pathname') cy.location('pathname')
.should('equal', '/') .should('equal', '/')
}) })
describe('List View', () => {
it('Should be an empty list', () => {
cy.visit('/lists/1')
cy.url()
.should('contain', '/lists/1/list')
cy.get('.list-title h1')
.should('contain', 'First List')
cy.get('.list-title .dropdown')
.should('exist')
cy.get('p')
.contains('This list is currently empty.')
.should('exist')
})
it('Should navigate to the task when the title is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
})
cy.visit('/lists/1/list')
cy.get('.tasks .task .tasktext')
.contains(tasks[0].title)
.first()
.click()
cy.url()
.should('contain', `/tasks/${tasks[0].id}`)
})
it('Should not see any elements for a list which is shared read only', () => {
UserFactory.create(2)
UserListFactory.create(1, {
list_id: 2,
user_id: 1,
right: 0,
})
const lists = ListFactory.create(2, {
owner_id: '{increment}',
namespace_id: '{increment}',
})
cy.visit(`/lists/${lists[1].id}/`)
cy.get('.list-title a.icon')
.should('not.exist')
cy.get('input.input[placeholder="Add a new task..."')
.should('not.exist')
})
it('Should only show the color of a list in the navigation and not in the list view', () => {
const lists = ListFactory.create(1, {
hex_color: '00db60',
})
TaskFactory.create(10, {
list_id: lists[0].id,
})
cy.visit(`/lists/${lists[0].id}/`)
cy.get('.menu-list li .list-menu-link .color-bubble')
.should('have.css', 'background-color', 'rgb(0, 219, 96)')
cy.get('.tasks-container .tasks .color-bubble')
.should('not.exist')
})
it('Should paginate for > 50 tasks', () => {
const tasks = TaskFactory.create(100, {
id: '{increment}',
title: i => `task${i}`,
list_id: 1,
})
cy.visit('/lists/1/list')
cy.get('.tasks-container .tasks')
.should('contain', tasks[99].title)
cy.get('.card-content .pagination .pagination-link')
.contains('2')
.click()
cy.url()
.should('contain', '?page=2')
cy.get('.tasks-container .tasks')
.should('contain', tasks[1].title)
cy.get('.tasks-container .tasks')
.should('not.contain', tasks[99].title)
})
})
describe('Table View', () => {
it('Should show a table with tasks', () => {
const tasks = TaskFactory.create(1)
cy.visit('/lists/1/table')
cy.get('.table-view table.table')
.should('exist')
cy.get('.table-view table.table')
.should('contain', tasks[0].title)
})
it('Should have working column switches', () => {
TaskFactory.create(1)
cy.visit('/lists/1/table')
cy.get('.table-view .filter-container .items .button')
.contains('Columns')
.click()
cy.get('.table-view .filter-container .card.columns-filter .card-content .fancycheckbox .check')
.contains('Priority')
.click()
cy.get('.table-view .filter-container .card.columns-filter .card-content .fancycheckbox .check')
.contains('Done')
.click()
cy.get('.table-view table.table th')
.contains('Priority')
.should('exist')
cy.get('.table-view table.table th')
.contains('Done')
.should('not.exist')
})
it('Should navigate to the task when the title is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
})
cy.visit('/lists/1/table')
cy.get('.table-view table.table')
.contains(tasks[0].title)
.click()
cy.url()
.should('contain', `/tasks/${tasks[0].id}`)
})
})
describe('Gantt View', () => {
it('Hides tasks with no dates', () => {
const tasks = TaskFactory.create(1)
cy.visit('/lists/1/gantt')
cy.get('.gantt-chart-container .gantt-chart .tasks')
.should('not.contain', tasks[0].title)
})
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(nextMonth, 'MMMM'))
})
it('Shows tasks with dates', () => {
const now = new Date()
const tasks = TaskFactory.create(1, {
start_date: formatISO(now),
end_date: formatISO(now.setDate(now.getDate() + 4))
})
cy.visit('/lists/1/gantt')
cy.get('.gantt-chart-container .gantt-chart .tasks')
.should('not.be.empty')
cy.get('.gantt-chart-container .gantt-chart .tasks')
.should('contain', tasks[0].title)
})
it('Shows tasks with no dates after enabling them', () => {
TaskFactory.create(1, {
start_date: null,
end_date: null,
})
cy.visit('/lists/1/gantt')
cy.get('.gantt-chart-container .gantt-options .fancycheckbox')
.contains('Show tasks which don\'t have dates set')
.click()
cy.get('.gantt-chart-container .gantt-chart .tasks')
.should('not.be.empty')
cy.get('.gantt-chart-container .gantt-chart .tasks .task.nodate')
.should('exist')
})
it('Drags a task around', () => {
const now = new Date()
TaskFactory.create(1, {
start_date: formatISO(now),
end_date: formatISO(now.setDate(now.getDate() + 4))
})
cy.visit('/lists/1/gantt')
cy.get('.gantt-chart-container .gantt-chart .tasks .task')
.first()
.trigger('mousedown', {which: 1})
.trigger('mousemove', {clientX: 500, clientY: 0})
.trigger('mouseup', {force: true})
})
})
describe('Kanban', () => {
let buckets
beforeEach(() => {
buckets = BucketFactory.create(2)
})
it('Shows all buckets with their tasks', () => {
const data = TaskFactory.create(10, {
list_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .title')
.contains(buckets[0].title)
.should('exist')
cy.get('.kanban .bucket .title')
.contains(buckets[1].title)
.should('exist')
cy.get('.kanban .bucket')
.first()
.should('contain', data[0].title)
})
it('Can add a new task to a bucket', () => {
const data = TaskFactory.create(2, {
list_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket')
.contains(buckets[0].title)
.get('.bucket-footer .button')
.contains('Add another task')
.click()
cy.get('.kanban .bucket')
.contains(buckets[0].title)
.get('.bucket-footer .field .control input.input')
.type('New Task{enter}')
cy.get('.kanban .bucket')
.first()
.should('contain', 'New Task')
})
it('Can create a new bucket', () => {
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket.new-bucket .button')
.click()
cy.get('.kanban .bucket.new-bucket input.input')
.type('New Bucket{enter}')
cy.wait(1000) // Wait for the request to finish
cy.get('.kanban .bucket .title')
.contains('New Bucket')
.should('exist')
})
it('Can set a bucket limit', () => {
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
.first()
.click()
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item')
.contains('Limit: Not Set')
.click()
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item .field input.input')
.first()
.type(3)
cy.get('[data-cy="setBucketLimit"]')
.first()
.click()
cy.get('.kanban .bucket .bucket-header span.limit')
.contains('0/3')
.should('exist')
})
it('Can rename a bucket', () => {
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .bucket-header .title')
.first()
.type('{selectall}New Bucket Title{enter}')
cy.get('.kanban .bucket .bucket-header .title')
.first()
.should('contain', 'New Bucket Title')
})
it('Can delete a bucket', () => {
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-trigger')
.first()
.click()
cy.get('.kanban .bucket .bucket-header .dropdown.options .dropdown-menu .dropdown-item')
.contains('Delete')
.click()
cy.get('.modal-mask .modal-container .modal-content .header')
.should('contain', 'Delete the bucket')
cy.get('.modal-mask .modal-container .modal-content .actions .button')
.contains('Do it!')
.click()
cy.get('.kanban .bucket .title')
.contains(buckets[0].title)
.should('not.exist')
cy.get('.kanban .bucket .title')
.contains(buckets[1].title)
.should('exist')
})
it('Can drag tasks around', () => {
const tasks = TaskFactory.create(2, {
list_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.get('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
.first()
.drag('.kanban .bucket:nth-child(2) .tasks .dropper')
cy.get('.kanban .bucket:nth-child(2) .tasks')
.should('contain', tasks[0].title)
cy.get('.kanban .bucket:nth-child(1) .tasks')
.should('not.contain', tasks[0].title)
})
it('Should navigate to the task when the task card is clicked', () => {
const tasks = TaskFactory.create(5, {
id: '{increment}',
list_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.getSettled('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
.should('be.visible')
.click()
cy.url()
.should('contain', `/tasks/${tasks[0].id}`)
})
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,
bucket_id: 1,
})
const task = tasks[0]
cy.visit('/lists/1/kanban')
cy.getSettled('.kanban .bucket .tasks .task')
.contains(task.title)
.should('be.visible')
.click()
cy.get('.task-view .action-buttons .button')
.contains('Move task')
.click()
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
.type(`${lists[1].title}{enter}`)
// The requests happen with a 200ms timeout. Because of that, the results are not yet there when cypress
// presses enter and we can't simulate pressing on enter to select the item.
cy.get('.task-view .content.details .field .multiselect.control .search-results')
.children()
.first()
.click()
cy.get('.global-notification', { timeout: 1000 })
.should('contain', 'Success')
cy.go('back')
cy.get('.kanban .bucket')
.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

@ -0,0 +1,16 @@
import {ListFactory} from '../../factories/list'
import {UserFactory} from '../../factories/user'
import {NamespaceFactory} from '../../factories/namespace'
import {TaskFactory} from '../../factories/task'
export function prepareLists(setLists = () => {}) {
beforeEach(() => {
UserFactory.create(1)
NamespaceFactory.create(1)
const lists = ListFactory.create(1, {
title: 'First List'
})
setLists(lists)
TaskFactory.truncate()
})
}

View File

@ -116,6 +116,7 @@ describe('Task', () => {
.should('be.visible') .should('be.visible')
.should('contain', 'Done') .should('contain', 'Done')
cy.get('.task-view .action-buttons p.created') cy.get('.task-view .action-buttons p.created')
.scrollIntoView()
.should('be.visible') .should('be.visible')
.should('contain', 'Done') .should('contain', 'Done')
}) })
@ -209,7 +210,7 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`) cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button') cy.get('.task-view .action-buttons .button')
.contains('Move task') .contains('Move')
.click() .click()
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input') cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
.type(`${lists[1].title}{enter}`) .type(`${lists[1].title}{enter}`)
@ -236,7 +237,7 @@ describe('Task', () => {
cy.get('.task-view .action-buttons .button') cy.get('.task-view .action-buttons .button')
.should('be.visible') .should('be.visible')
.contains('Delete task') .contains('Delete')
.click() .click()
cy.get('.modal-mask .modal-container .modal-content .header') cy.get('.modal-mask .modal-container .modal-content .header')
.should('contain', 'Delete this task') .should('contain', 'Delete this task')
@ -316,7 +317,7 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`) cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button') cy.get('.task-view .action-buttons .button')
.contains('Add labels') .contains('Add Labels')
.should('be.visible') .should('be.visible')
.click() .click()
cy.get('.task-view .details.labels-list .multiselect input') cy.get('.task-view .details.labels-list .multiselect input')
@ -343,7 +344,7 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`) cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button') cy.get('.task-view .action-buttons .button')
.contains('Add labels') .contains('Add Labels')
.click() .click()
cy.get('.task-view .details.labels-list .multiselect input') cy.get('.task-view .details.labels-list .multiselect input')
.type(labels[0].title) .type(labels[0].title)
@ -372,13 +373,13 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`) cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .details.labels-list .multiselect .input-wrapper') cy.getSettled('.task-view .details.labels-list .multiselect .input-wrapper')
.should('be.visible') .should('be.visible')
.should('contain', labels[0].title) .should('contain', labels[0].title)
cy.get('.task-view .details.labels-list .multiselect .input-wrapper') cy.getSettled('.task-view .details.labels-list .multiselect .input-wrapper')
.children() .children()
.first() .first()
.get('a.delete') .get('[data-cy="taskDetail.removeLabel"]')
.click() .click()
cy.get('.global-notification') cy.get('.global-notification')

View File

@ -6,7 +6,7 @@ describe('Log out', () => {
cy.get('.navbar .user .username') cy.get('.navbar .user .username')
.click() .click()
cy.get('.navbar .user .dropdown-menu a.dropdown-item') cy.get('.navbar .user .dropdown-menu .dropdown-item')
.contains('Logout') .contains('Logout')
.click() .click()

View File

@ -25,7 +25,6 @@ context('Registration', () => {
cy.get('#username').type(fixture.username) cy.get('#username').type(fixture.username)
cy.get('#email').type(fixture.email) cy.get('#email').type(fixture.email)
cy.get('#password').type(fixture.password) cy.get('#password').type(fixture.password)
cy.get('#passwordValidation').type(fixture.password)
cy.get('#register-submit').click() cy.get('#register-submit').click()
cy.url().should('include', '/') cy.url().should('include', '/')
cy.clock(1625656161057) // 13:00 cy.clock(1625656161057) // 13:00
@ -43,7 +42,6 @@ context('Registration', () => {
cy.get('#username').type(fixture.username) cy.get('#username').type(fixture.username)
cy.get('#email').type(fixture.email) cy.get('#email').type(fixture.email)
cy.get('#password').type(fixture.password) cy.get('#password').type(fixture.password)
cy.get('#passwordValidation').type(fixture.password)
cy.get('#register-submit').click() cy.get('#register-submit').click()
cy.get('div.message.danger').contains('A user with this username already exists.') cy.get('div.message.danger').contains('A user with this username already exists.')
}) })

View File

@ -8,12 +8,14 @@ describe('User Settings', () => {
}) })
it('Changes the user avatar', () => { it('Changes the user avatar', () => {
cy.intercept(`${Cypress.env('API_URL')}/user/settings/avatar/upload`).as('uploadAvatar')
cy.visit('/user/settings/avatar') cy.visit('/user/settings/avatar')
cy.get('input[name=avatarProvider][value=upload]') cy.get('input[name=avatarProvider][value=upload]')
.click() .click()
cy.get('input[type=file]', { timeout: 1000 }) cy.get('input[type=file]', {timeout: 1000})
.attachFile('image.jpg') .selectFile('cypress/fixtures/image.jpg', {force: true}) // The input is not visible, but on purpose
cy.get('.vue-handler-wrapper.vue-handler-wrapper--south .vue-simple-handler.vue-simple-handler--south') cy.get('.vue-handler-wrapper.vue-handler-wrapper--south .vue-simple-handler.vue-simple-handler--south')
.trigger('mousedown', {which: 1}) .trigger('mousedown', {which: 1})
.trigger('mousemove', {clientY: 100}) .trigger('mousemove', {clientY: 100})
@ -22,7 +24,7 @@ describe('User Settings', () => {
.contains('Upload Avatar') .contains('Upload Avatar')
.click() .click()
cy.wait(3000) // Wait for the request to finish cy.wait('@uploadAvatar')
cy.get('.global-notification') cy.get('.global-notification')
.should('contain', 'Success') .should('contain', 'Success')
}) })

View File

@ -1,6 +1,5 @@
import './commands' import './commands'
import 'cypress-file-upload'
import '@4tw/cypress-drag-drop' import '@4tw/cypress-drag-drop'
// see https://github.com/cypress-io/cypress/issues/702#issuecomment-587127275 // see https://github.com/cypress-io/cypress/issues/702#issuecomment-587127275

View File

@ -1,6 +1,6 @@
[build] [build]
command = "yarn build" command = "yarn build"
publish = "dist" publish = "dist-preview"
[[redirects]] [[redirects]]
from = "/*" from = "/*"

View File

@ -5,7 +5,7 @@
"scripts": { "scripts": {
"serve": "vite", "serve": "vite",
"serve:dist-dev": "node scripts/serve-dist.js", "serve:dist-dev": "node scripts/serve-dist.js",
"serve:dist": "vite preview", "serve:dist": "vite preview --port 4173",
"build": "vite build && workbox copyLibraries dist/", "build": "vite build && workbox copyLibraries dist/",
"build:modern-only": "BUILD_MODERN_ONLY=true vite build && workbox copyLibraries dist/", "build:modern-only": "BUILD_MODERN_ONLY=true vite build && workbox copyLibraries dist/",
"build:dev": "vite build -m development --outDir dist-dev/", "build:dev": "vite build -m development --outDir dist-dev/",
@ -18,82 +18,80 @@
"browserslist:update": "npx browserslist@latest --update-db" "browserslist:update": "npx browserslist@latest --update-db"
}, },
"dependencies": { "dependencies": {
"@github/hotkey": "1.6.1", "@github/hotkey": "2.0.0",
"@kyvg/vue3-notification": "2.3.4", "@kyvg/vue3-notification": "2.3.4",
"@sentry/tracing": "6.16.1", "@sentry/tracing": "6.19.3",
"@sentry/vue": "6.16.1", "@sentry/vue": "6.19.3",
"@types/is-touch-device": "1.0.0", "@types/is-touch-device": "1.0.0",
"@vue/compat": "3.2.27", "@vue/compat": "3.2.31",
"@vueuse/core": "7.5.2", "@vueuse/core": "8.2.3",
"@vueuse/router": "7.5.3", "@vueuse/router": "8.2.3",
"blurhash": "^1.1.4", "blurhash": "^1.1.4",
"bulma-css-variables": "0.9.33", "bulma-css-variables": "0.9.33",
"camel-case": "4.1.2", "camel-case": "4.1.2",
"codemirror": "5.65.0", "codemirror": "5.65.2",
"copy-to-clipboard": "3.3.1", "copy-to-clipboard": "3.3.1",
"date-fns": "2.28.0", "date-fns": "2.28.0",
"dompurify": "2.3.4", "dompurify": "2.3.6",
"easymde": "2.16.1", "easymde": "2.16.1",
"flatpickr": "4.6.9", "flatpickr": "4.6.11",
"flexsearch": "0.7.21", "flexsearch": "0.7.21",
"highlight.js": "11.4.0", "highlight.js": "11.5.0",
"is-touch-device": "1.0.1", "is-touch-device": "1.0.1",
"lodash.clonedeep": "4.5.0", "lodash.clonedeep": "4.5.0",
"lodash.debounce": "4.0.8", "lodash.debounce": "4.0.8",
"marked": "4.0.10", "marked": "4.0.12",
"register-service-worker": "1.7.2", "register-service-worker": "1.7.2",
"snake-case": "3.0.4", "snake-case": "3.0.4",
"ufo": "0.7.9", "ufo": "0.8.3",
"v-tooltip": "4.0.0-beta.17", "v-tooltip": "4.0.0-beta.17",
"vue": "3.2.27", "vue": "3.2.31",
"vue-advanced-cropper": "2.7.1", "vue-advanced-cropper": "2.8.1",
"vue-drag-resize": "2.0.3", "vue-drag-resize": "2.0.3",
"vue-flatpickr-component": "9.0.5", "vue-flatpickr-component": "9.0.5",
"vue-i18n": "9.2.0-beta.28", "vue-i18n": "9.2.0-beta.30",
"vue-router": "4.0.12", "vue-router": "4.0.14",
"vuedraggable": "4.1.0", "vuedraggable": "4.1.0",
"vuex": "4.0.2", "vuex": "4.0.2",
"workbox-precaching": "6.4.2" "workbox-precaching": "6.5.2"
}, },
"devDependencies": { "devDependencies": {
"@4tw/cypress-drag-drop": "2.1.0", "@4tw/cypress-drag-drop": "2.1.0",
"@fortawesome/fontawesome-svg-core": "1.2.36", "@faker-js/faker": "6.1.1",
"@fortawesome/free-regular-svg-icons": "5.15.4", "@fortawesome/fontawesome-svg-core": "6.1.1",
"@fortawesome/free-solid-svg-icons": "5.15.4", "@fortawesome/free-regular-svg-icons": "6.1.1",
"@fortawesome/free-solid-svg-icons": "6.1.1",
"@fortawesome/vue-fontawesome": "3.0.0-5", "@fortawesome/vue-fontawesome": "3.0.0-5",
"@types/flexsearch": "0.7.2", "@types/flexsearch": "0.7.3",
"@typescript-eslint/eslint-plugin": "5.9.1", "@typescript-eslint/eslint-plugin": "5.17.0",
"@typescript-eslint/parser": "5.9.1", "@typescript-eslint/parser": "5.17.0",
"@vitejs/plugin-legacy": "1.6.4", "@vitejs/plugin-legacy": "1.8.0",
"@vitejs/plugin-vue": "2.0.1", "@vitejs/plugin-vue": "2.3.1",
"@vue/eslint-config-typescript": "10.0.0", "@vue/eslint-config-typescript": "10.0.0",
"autoprefixer": "10.4.2", "autoprefixer": "10.4.4",
"axios": "0.24.0", "axios": "0.26.1",
"browserslist": "4.19.1", "browserslist": "4.20.2",
"caniuse-lite": "1.0.30001299", "caniuse-lite": "1.0.30001324",
"cypress": "9.2.1", "cypress": "9.5.3",
"cypress-file-upload": "5.0.8", "esbuild": "0.14.30",
"esbuild": "0.14.11", "eslint": "8.12.0",
"eslint": "8.7.0", "eslint-plugin-vue": "8.5.0",
"eslint-plugin-vue": "8.3.0", "express": "4.17.3",
"express": "4.17.2", "happy-dom": "2.55.0",
"faker": "5.5.3", "netlify-cli": "9.13.5",
"netlify-cli": "8.8.2", "postcss": "8.4.12",
"happy-dom": "2.25.2", "postcss-preset-env": "7.4.3",
"postcss": "8.4.5", "rollup": "2.70.1",
"postcss-preset-env": "7.2.3", "rollup-plugin-visualizer": "5.6.0",
"rollup": "2.64.0", "sass": "1.49.11",
"rollup-plugin-visualizer": "5.5.4", "typescript": "4.6.3",
"sass": "1.48.0", "vite": "2.9.1",
"slugify": "1.6.5",
"typescript": "4.5.4",
"vite": "2.7.12",
"vite-plugin-pwa": "0.11.13", "vite-plugin-pwa": "0.11.13",
"vite-svg-loader": "3.1.2", "vite-svg-loader": "3.2.0",
"vitest": "0.1.17", "vitest": "0.8.2",
"vue-tsc": "0.30.4", "vue-tsc": "0.33.9",
"wait-on": "6.0.0", "wait-on": "6.0.1",
"workbox-cli": "6.4.2" "workbox-cli": "6.5.2"
}, },
"eslintConfig": { "eslintConfig": {
"root": true, "root": true,
@ -131,7 +129,7 @@
"parser": "vue-eslint-parser", "parser": "vue-eslint-parser",
"parserOptions": { "parserOptions": {
"parser": "@typescript-eslint/parser", "parser": "@typescript-eslint/parser",
"ecmaVersion": 2021 "ecmaVersion": 2022
}, },
"ignorePatterns": [ "ignorePatterns": [
"*.test.*", "*.test.*",
@ -147,5 +145,5 @@
} }
}, },
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"packageManager": "yarn@1.22.17" "packageManager": "yarn@1.22.18"
} }

View File

@ -1,20 +1,24 @@
const slugify = require('slugify')
const {exec} = require('child_process') const {exec} = require('child_process')
const axios = require('axios') const axios = require('axios')
const BOT_USER_ID = 513 const BOT_USER_ID = 513
const giteaToken = process.env.GITEA_TOKEN const giteaToken = process.env.GITEA_TOKEN
const siteId = process.env.NETLIFY_SITE_ID const siteId = process.env.NETLIFY_SITE_ID
const branchSlug = slugify(process.env.DRONE_SOURCE_BRANCH) const branchSlug = String(process.env.DRONE_SOURCE_BRANCH)
.trim()
.normalize('NFKD')
.toLowerCase()
.replace(/[.\s/]/g, '-')
.replace(/[^A-Za-z\d-]/g, '')
const prNumber = process.env.DRONE_PULL_REQUEST const prNumber = process.env.DRONE_PULL_REQUEST
const prIssueCommentsUrl = `https://kolaente.dev/api/v1/repos/vikunja/frontend/issues/${prNumber}/comments` const prIssueCommentsUrl = `https://kolaente.dev/api/v1/repos/vikunja/frontend/issues/${prNumber}/comments`
const alias = `${prNumber}-${branchSlug}` const alias = `${prNumber}-${branchSlug}`.substring(0,37)
const fullPreviewUrl = `https://${alias}--vikunja-frontend-preview.netlify.app` const fullPreviewUrl = `https://${alias}--vikunja-frontend-preview.netlify.app`
const promiseExec = cmd => { const promiseExec = cmd => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
exec(cmd, (error, stdout, stderr) => { exec(cmd, (error, stdout) => {
if (error) { if (error) {
reject(error) reject(error)
return return

View File

@ -1 +1 @@
55ce0faaa2c1919341617ccfaeccbb6029ac12107964ff488985cff13dd952f1a991df3ab0d4b0705deb761e508e6434 ./scripts/deploy-preview-netlify.js bb46342a0a08105b340ba7976cff9d80ef89901120ec0639669caa70bb7d2dbc43e78b1f635a7654ab2456e8358c98a4 ./scripts/deploy-preview-netlify.js

View File

@ -3,7 +3,7 @@ const express = require('express')
const app = express() const app = express()
const p = path.join(__dirname, '..', 'dist-dev') const p = path.join(__dirname, '..', 'dist-dev')
const port = 5000 const port = 4173
app.use(express.static(p)) app.use(express.static(p))
// Handle urls set by the frontend // Handle urls set by the frontend

View File

@ -1,7 +1,7 @@
<template> <template>
<ready> <ready>
<template v-if="authUser"> <template v-if="authUser">
<top-navigation/> <TheNavigation/>
<content-auth/> <content-auth/>
</template> </template>
<content-link-share v-else-if="authLinkShare"/> <content-link-share v-else-if="authLinkShare"/>
@ -27,7 +27,7 @@ import {success} from '@/message'
import Notification from '@/components/misc/notification.vue' import Notification from '@/components/misc/notification.vue'
import KeyboardShortcuts from './components/misc/keyboard-shortcuts/index.vue' import KeyboardShortcuts from './components/misc/keyboard-shortcuts/index.vue'
import TopNavigation from './components/home/topNavigation.vue' import TheNavigation from '@/components/home/TheNavigation.vue'
import ContentAuth from './components/home/contentAuth.vue' import ContentAuth from './components/home/contentAuth.vue'
import ContentLinkShare from './components/home/contentLinkShare.vue' import ContentLinkShare from './components/home/contentLinkShare.vue'
import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue' import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue'
@ -42,7 +42,7 @@ import {useBodyClass} from '@/composables/useBodyClass'
const store = useStore() const store = useStore()
const router = useRouter() const router = useRouter()
useBodyClass('is-touch', isTouchDevice) useBodyClass('is-touch', isTouchDevice())
const keyboardShortcutsActive = computed(() => store.state.keyboardShortcutsActive) const keyboardShortcutsActive = computed(() => store.state.keyboardShortcutsActive)
const authUser = computed(() => store.getters['auth/authUser']) const authUser = computed(() => store.getters['auth/authUser'])

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@ -69,10 +69,10 @@ watchEffect(() => {
} }
// if there is a href we assume the user wants an external link via a link element // if there is a href we assume the user wants an external link via a link element
// we also set the attribute rel to "noopener" but make it possible to overwrite this by the user. // we also set a predefined value for the attribute rel, but make it possible to overwrite this by the user.
if ('href' in attrs) { if ('href' in attrs) {
nodeName = 'a' nodeName = 'a'
bindings = {rel: 'noopener'} bindings = {rel: 'noreferrer noopener nofollow'}
} }
componentNodeName.value = nodeName componentNodeName.value = nodeName

View File

@ -0,0 +1,21 @@
export const DATE_RANGES = {
// Format:
// Key is the title, as a translation string, the first entry of the value array
// is the "from" date, the second one is the "to" date.
'today': ['now/d', 'now/d+1d'],
'lastWeek': ['now/w-1w', 'now/w-2w'],
'thisWeek': ['now/w', 'now/w+1w'],
'restOfThisWeek': ['now', 'now/w+1w'],
'nextWeek': ['now/w+1w', 'now/w+2w'],
'next7Days': ['now', 'now+7d'],
'lastMonth': ['now/M-1M', 'now/M-2M'],
'thisMonth': ['now/M', 'now/M+1M'],
'restOfThisMonth': ['now', 'now/M+1M'],
'nextMonth': ['now/M+1M', 'now/M+2M'],
'next30Days': ['now', 'now+30d'],
'thisYear': ['now/y', 'now/y+1y'],
'restOfThisYear': ['now', 'now/y+1y'],
}

View File

@ -0,0 +1,131 @@
<template>
<card
class="has-no-shadow how-it-works-modal"
:title="$t('input.datemathHelp.title')">
<p>
{{ $t('input.datemathHelp.intro') }}
</p>
<p>
<i18n-t keypath="input.datemathHelp.expression">
<code>now</code>
<code>||</code>
</i18n-t>
</p>
<p>
<i18n-t keypath="input.datemathHelp.similar">
<BaseButton
href="https://grafana.com/docs/grafana/latest/dashboards/time-range-controls/"
target="_blank">
Grafana
</BaseButton>
<BaseButton
href="https://www.elastic.co/guide/en/elasticsearch/reference/7.3/common-options.html#date-math"
target="_blank">
Elasticsearch
</BaseButton>
</i18n-t>
</p>
<p>{{ $t('misc.forExample') }}</p>
<ul>
<li><code>+1d</code>{{ $t('input.datemathHelp.add1Day') }}</li>
<li><code>-1d</code>{{ $t('input.datemathHelp.minus1Day') }}</li>
<li><code>/d</code>{{ $t('input.datemathHelp.roundDay') }}</li>
</ul>
<p>{{ $t('input.datemathHelp.supportedUnits') }}</p>
<table class="table">
<tbody>
<tr>
<td><code>s</code></td>
<td>{{ $t('input.datemathHelp.units.seconds') }}</td>
</tr>
<tr>
<td><code>m</code></td>
<td>{{ $t('input.datemathHelp.units.minutes') }}</td>
</tr>
<tr>
<td><code>h</code></td>
<td>{{ $t('input.datemathHelp.units.hours') }}</td>
</tr>
<tr>
<td><code>H</code></td>
<td>{{ $t('input.datemathHelp.units.hours') }}</td>
</tr>
<tr>
<td><code>d</code></td>
<td>{{ $t('input.datemathHelp.units.days') }}</td>
</tr>
<tr>
<td><code>w</code></td>
<td>{{ $t('input.datemathHelp.units.weeks') }}</td>
</tr>
<tr>
<td><code>M</code></td>
<td>{{ $t('input.datemathHelp.units.months') }}</td>
</tr>
<tr>
<td><code>y</code></td>
<td>{{ $t('input.datemathHelp.units.years') }}</td>
</tr>
</tbody>
</table>
<p>{{ $t('input.datemathHelp.someExamples') }}</p>
<table class="table">
<tbody>
<tr>
<td><code>now</code></td>
<td>{{ $t('input.datemathHelp.examples.now') }}</td>
</tr>
<tr>
<td><code>now+24h</code></td>
<td>{{ $t('input.datemathHelp.examples.in24h') }}</td>
</tr>
<tr>
<td><code>now/d</code></td>
<td>{{ $t('input.datemathHelp.examples.today') }}</td>
</tr>
<tr>
<td><code>now/w</code></td>
<td>{{ $t('input.datemathHelp.examples.beginningOfThisWeek') }}</td>
</tr>
<tr>
<td><code>now/w+1w</code></td>
<td>{{ $t('input.datemathHelp.examples.endOfThisWeek') }}</td>
</tr>
<tr>
<td><code>now+30d</code></td>
<td>{{ $t('input.datemathHelp.examples.in30Days') }}</td>
</tr>
<tr>
<td><code>{{ exampleDate }}||+1M/d</code></td>
<td>
<i18n-t keypath="input.datemathHelp.examples.datePlusMonth">
<code>{{ exampleDate }}</code>
</i18n-t>
</td>
</tr>
</tbody>
</table>
</card>
</template>
<script lang="ts" setup>
import {format} from 'date-fns'
import BaseButton from '@/components/base/BaseButton.vue'
const exampleDate = format(new Date(), 'yyyy-MM-dd')
</script>
<style scoped>
.how-it-works-modal {
font-size: 1rem;
}
p {
display: inline-block !important;
}
.base-button {
display: inline;
}
</style>

View File

@ -0,0 +1,254 @@
<template>
<div class="datepicker-with-range-container">
<popup>
<template #trigger="{toggle}">
<slot name="trigger" :toggle="toggle" :buttonText="buttonText"></slot>
</template>
<template #content="{isOpen}">
<div class="datepicker-with-range" :class="{'is-open': isOpen}">
<div class="selections">
<BaseButton @click="setDateRange(null)" :class="{'is-active': customRangeActive}">
{{ $t('misc.custom') }}
</BaseButton>
<BaseButton
v-for="(value, text) in DATE_RANGES"
:key="text"
@click="setDateRange(value)"
:class="{'is-active': from === value[0] && to === value[1]}">
{{ $t(`input.datepickerRange.ranges.${text}`) }}
</BaseButton>
</div>
<div class="flatpickr-container input-group">
<label class="label">
{{ $t('input.datepickerRange.from') }}
<div class="field has-addons">
<div class="control is-fullwidth">
<input class="input" type="text" v-model="from"/>
</div>
<div class="control">
<x-button icon="calendar" variant="secondary" data-toggle/>
</div>
</div>
</label>
<label class="label">
{{ $t('input.datepickerRange.to') }}
<div class="field has-addons">
<div class="control is-fullwidth">
<input class="input" type="text" v-model="to"/>
</div>
<div class="control">
<x-button icon="calendar" variant="secondary" data-toggle/>
</div>
</div>
</label>
<flat-pickr
:config="flatPickerConfig"
v-model="flatpickrRange"
/>
<p>
{{ $t('input.datemathHelp.canuse') }}
<BaseButton class="has-text-primary" @click="showHowItWorks = true">
{{ $t('input.datemathHelp.learnhow') }}
</BaseButton>
</p>
<modal
@close="() => showHowItWorks = false"
:enabled="showHowItWorks"
transition-name="fade"
:overflow="true"
variant="hint-modal"
>
<DatemathHelp/>
</modal>
</div>
</div>
</template>
</popup>
</div>
</template>
<script lang="ts" setup>
import {computed, ref, watch} from 'vue'
import {useStore} from 'vuex'
import {useI18n} from 'vue-i18n'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import Popup from '@/components/misc/popup.vue'
import {DATE_RANGES} from '@/components/date/dateRanges'
import BaseButton from '@/components/base/BaseButton.vue'
import DatemathHelp from '@/components/date/datemathHelp.vue'
const store = useStore()
const {t} = useI18n()
const emit = defineEmits(['dateChanged'])
// FIXME: This seems to always contain the default value - that breaks the picker
const weekStart = computed<number>(() => store.state.auth.settings.weekStart ?? 0)
const flatPickerConfig = computed(() => ({
altFormat: t('date.altFormatLong'),
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: false,
wrap: true,
mode: 'range',
locale: {
firstDayOf7Days: weekStart.value,
},
}))
const showHowItWorks = ref(false)
const flatpickrRange = ref('')
const from = ref('')
const to = ref('')
function emitChanged() {
emit('dateChanged', {
dateFrom: from.value === '' ? null : from.value,
dateTo: to.value === '' ? null : to.value,
})
}
watch(
() => flatpickrRange.value,
(newVal: string | null) => {
if (newVal === null) {
return
}
const [fromDate, toDate] = newVal.split(' to ')
if (typeof fromDate === 'undefined' || typeof toDate === 'undefined') {
return
}
from.value = fromDate
to.value = toDate
emitChanged()
},
)
watch(() => from.value, emitChanged)
watch(() => to.value, emitChanged)
function setDateRange(range: string[] | null) {
if (range === null) {
from.value = ''
to.value = ''
return
}
from.value = range[0]
to.value = range[1]
}
const customRangeActive = computed<boolean>(() => {
return !Object.values(DATE_RANGES).some(range => from.value === range[0] && to.value === range[1])
})
const buttonText = computed<string>(() => {
if (from.value !== '' && to.value !== '') {
return t('input.datepickerRange.fromto', {
from: from.value,
to: to.value,
})
}
return t('task.show.select')
})
</script>
<style lang="scss" scoped>
.datepicker-with-range-container {
position: relative;
}
:deep(.popup) {
z-index: 10;
margin-top: 1rem;
border-radius: $radius;
border: 1px solid var(--grey-200);
background-color: var(--white);
box-shadow: $shadow;
&.is-open {
width: 500px;
height: 320px;
}
}
.datepicker-with-range {
display: flex;
width: 100%;
height: 100%;
position: absolute;
}
:deep(.flatpickr-calendar) {
margin: 0 auto 8px;
box-shadow: none;
}
.flatpickr-container {
width: 70%;
border-left: 1px solid var(--grey-200);
padding: 1rem;
font-size: .9rem;
// Flatpickr has no option to use it without an input field so we're hiding it instead
:deep(input.form-control.input) {
height: 0;
padding: 0;
border: 0;
}
.field .control :deep(.button) {
border: 1px solid var(--input-border-color);
height: 2.25rem;
&:hover {
border: 1px solid var(--input-hover-border-color);
}
}
.label, .input, :deep(.button) {
font-size: .9rem;
}
}
.selections {
width: 30%;
display: flex;
flex-direction: column;
padding-top: .5rem;
overflow-y: scroll;
button {
display: block;
width: 100%;
text-align: left;
padding: .5rem 1rem;
transition: $transition;
font-size: .9rem;
color: var(--text);
background: transparent;
border: 0;
cursor: pointer;
&.is-active {
color: var(--primary);
}
&:hover, &.is-active {
background-color: var(--grey-100);
}
}
}
</style>

View File

@ -1,9 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useNow } from '@vueuse/core'
import LogoFull from '@/assets/logo-full.svg?component' import LogoFull from '@/assets/logo-full.svg?component'
import LogoFullPride from '@/assets/logo-full-pride.svg?component' import LogoFullPride from '@/assets/logo-full-pride.svg?component'
const Logo = computed(() => new Date().getMonth() === 5 ? LogoFullPride : LogoFull) const now = useNow()
const Logo = computed(() => now.value.getMonth() === 5 ? LogoFullPride : LogoFull)
</script> </script>
<template> <template>

View File

@ -1,8 +1,7 @@
<template> <template>
<button <BaseButton
type="button"
@click="$store.commit('toggleMenu')"
class="menu-show-button" class="menu-show-button"
@click="$store.commit('toggleMenu')"
@shortkey="() => $store.commit('toggleMenu')" @shortkey="() => $store.commit('toggleMenu')"
v-shortcut="'Control+e'" v-shortcut="'Control+e'"
:title="$t('keyboardShortcuts.toggleMenu')" :title="$t('keyboardShortcuts.toggleMenu')"
@ -10,11 +9,14 @@
/> />
</template> </template>
<script setup> <script setup lang="ts">
import {computed} from 'vue' import {computed} from 'vue'
import {store} from '@/store' import {useStore} from 'vuex'
const menuActive = computed(() => store.menuActive) import BaseButton from '@/components/base/BaseButton.vue'
const store = useStore()
const menuActive = computed(() => store.state.menuActive)
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -22,11 +24,6 @@ $lineWidth: 2rem;
$size: $lineWidth + 1rem; $size: $lineWidth + 1rem;
.menu-show-button { .menu-show-button {
// FIXME: create general button component
appearance: none;
background-color: transparent;
border: 0;
min-height: $size; min-height: $size;
width: $size; width: $size;

View File

@ -1,9 +1,8 @@
<template> <template>
<nav <header
:class="{'has-background': background}" :class="{'has-background': background}"
aria-label="main navigation" aria-label="main navigation"
class="navbar main-theme is-fixed-top" class="navbar main-theme is-fixed-top"
role="navigation"
> >
<router-link :to="{name: 'home'}" class="logo-link"> <router-link :to="{name: 'home'}" class="logo-link">
<Logo width="164" height="48"/> <Logo width="164" height="48"/>
@ -33,12 +32,13 @@
</a> </a>
<notifications/> <notifications/>
<div class="user"> <div class="user">
<img :src="userAvatar" alt="" class="avatar" width="40" height="40"/>
<dropdown class="is-right" ref="usernameDropdown"> <dropdown class="is-right" ref="usernameDropdown">
<template #trigger> <template #trigger>
<x-button <x-button
variant="secondary" variant="secondary"
:shadow="false"> :shadow="false"
>
<img :src="userAvatar" alt="" class="avatar" width="40" height="40"/>
<span class="username">{{ userInfo.name !== '' ? userInfo.name : userInfo.username }}</span> <span class="username">{{ userInfo.name !== '' ? userInfo.name : userInfo.username }}</span>
<span class="icon is-small"> <span class="icon is-small">
<icon icon="chevron-down"/> <icon icon="chevron-down"/>
@ -46,92 +46,96 @@
</x-button> </x-button>
</template> </template>
<router-link :to="{name: 'user.settings'}" class="dropdown-item"> <BaseButton
:to="{name: 'user.settings'}"
class="dropdown-item"
>
{{ $t('user.settings.title') }} {{ $t('user.settings.title') }}
</router-link> </BaseButton>
<a <BaseButton
v-if="imprintUrl"
:href="imprintUrl" :href="imprintUrl"
class="dropdown-item" class="dropdown-item"
target="_blank" >
rel="noreferrer noopener nofollow"
v-if="imprintUrl">
{{ $t('navigation.imprint') }} {{ $t('navigation.imprint') }}
</a> </BaseButton>
<a <BaseButton
v-if="privacyPolicyUrl"
:href="privacyPolicyUrl" :href="privacyPolicyUrl"
class="dropdown-item" class="dropdown-item"
target="_blank" >
rel="noreferrer noopener nofollow"
v-if="privacyPolicyUrl">
{{ $t('navigation.privacy') }} {{ $t('navigation.privacy') }}
</a> </BaseButton>
<a @click="$store.commit('keyboardShortcutsActive', true)" class="dropdown-item"> <BaseButton
@click="$store.commit('keyboardShortcutsActive', true)"
class="dropdown-item"
>
{{ $t('keyboardShortcuts.title') }} {{ $t('keyboardShortcuts.title') }}
</a> </BaseButton>
<router-link :to="{name: 'about'}" class="dropdown-item"> <BaseButton
:to="{name: 'about'}"
class="dropdown-item"
>
{{ $t('about.title') }} {{ $t('about.title') }}
</router-link> </BaseButton>
<a @click="logout()" class="dropdown-item"> <BaseButton
@click="logout()"
class="dropdown-item"
>
{{ $t('user.auth.logout') }} {{ $t('user.auth.logout') }}
</a> </BaseButton>
</dropdown> </dropdown>
</div> </div>
</div> </div>
</nav> </header>
</template> </template>
<script> <script setup langs="ts">
import {mapState} from 'vuex' import {ref, computed, onMounted, nextTick} from 'vue'
import {CURRENT_LIST, QUICK_ACTIONS_ACTIVE} from '@/store/mutation-types' import {useStore} from 'vuex'
import {useRouter} from 'vue-router'
import {QUICK_ACTIONS_ACTIVE} from '@/store/mutation-types'
import Rights from '@/models/constants/rights.json' import Rights from '@/models/constants/rights.json'
import Update from '@/components/home/update.vue' import Update from '@/components/home/update.vue'
import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue' import ListSettingsDropdown from '@/components/list/list-settings-dropdown.vue'
import Dropdown from '@/components/misc/dropdown.vue' import Dropdown from '@/components/misc/dropdown.vue'
import Notifications from '@/components/notifications/notifications.vue' import Notifications from '@/components/notifications/notifications.vue'
import Logo from '@/components/home/Logo.vue' import Logo from '@/components/home/Logo.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import MenuButton from '@/components/home/MenuButton.vue' import MenuButton from '@/components/home/MenuButton.vue'
export default { const store = useStore()
name: 'topNavigation',
components: {
Notifications,
Dropdown,
ListSettingsDropdown,
Update,
Logo,
MenuButton,
},
computed: {
...mapState({
userInfo: state => state.auth.info,
userAvatar: state => state.auth.avatarUrl,
userAuthenticated: state => state.auth.authenticated,
currentList: CURRENT_LIST,
background: 'background',
imprintUrl: state => state.config.legal.imprintUrl,
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 const userInfo = computed(() => store.state.auth.info)
this.$refs.listTitle.style.setProperty('--nav-username-width', `${usernameWidth}px`) const userAvatar = computed(() => store.state.auth.avatarUrl)
}) const currentList = computed(() => store.state.currentList)
}, const background = computed(() => store.state.background)
methods: { const imprintUrl = computed(() => store.state.config.legal.imprintUrl)
logout() { const privacyPolicyUrl = computed(() => store.state.config.legal.privacyPolicyUrl)
this.$store.dispatch('auth/logout') const canWriteCurrentList = computed(() => store.state.currentList.maxRight > Rights.READ)
this.$router.push({name: 'user.login'})
}, const usernameDropdown = ref()
openQuickActions() { const listTitle = ref()
this.$store.commit(QUICK_ACTIONS_ACTIVE, true) onMounted(async () => {
}, await nextTick()
}, if (typeof usernameDropdown.value === 'undefined' || typeof listTitle.value === 'undefined') {
return
}
const usernameWidth = usernameDropdown.value.$el.clientWidth
listTitle.value.style.setProperty('--nav-username-width', `${usernameWidth}px`)
})
const router = useRouter()
function logout() {
store.dispatch('auth/logout')
router.push({name: 'user.login'})
}
function openQuickActions() {
store.commit(QUICK_ACTIONS_ACTIVE, true)
} }
</script> </script>
@ -247,6 +251,7 @@ $hamburger-menu-icon-width: 28px;
border-radius: 100%; border-radius: 100%;
vertical-align: middle; vertical-align: middle;
height: 40px; height: 40px;
margin-right: var(--button-padding-horizontal);
} }
:deep(.dropdown-trigger .button) { :deep(.dropdown-trigger .button) {

View File

@ -1,8 +1,12 @@
<template> <template>
<div> <div>
<a @click="$store.commit('menuActive', false)" class="menu-hide-button" v-if="menuActive"> <BaseButton
v-if="menuActive"
@click="$store.commit('menuActive', false)"
class="menu-hide-button"
>
<icon icon="times"/> <icon icon="times"/>
</a> </BaseButton>
<div <div
:class="{'has-background': background || blurHash}" :class="{'has-background': background || blurHash}"
:style="{'background-image': blurHash && `url(${blurHash})`}" :style="{'background-image': blurHash && `url(${blurHash})`}"
@ -13,25 +17,39 @@
class="app-container-background background-fade-in" class="app-container-background background-fade-in"
:style="{'background-image': background && `url(${background})`}"></div> :style="{'background-image': background && `url(${background})`}"></div>
<navigation/> <navigation/>
<div <main
:class="[ :class="[
{ 'is-menu-enabled': menuActive }, { 'is-menu-enabled': menuActive },
$route.name, $route.name,
]" ]"
class="app-content" class="app-content"
> >
<a @click="$store.commit('menuActive', false)" class="mobile-overlay" v-if="menuActive"></a> <BaseButton
v-if="menuActive"
@click="$store.commit('menuActive', false)"
class="mobile-overlay"
/>
<quick-actions/> <quick-actions/>
<router-view/>
<router-view name="popup" v-slot="{ Component }"> <router-view :route="routeWithModal" v-slot="{ Component }">
<transition name="modal"> <keep-alive :include="['list.list', 'list.gantt', 'list.table', 'list.kanban']">
<component :is="Component"/> <component :is="Component"/>
</transition> </keep-alive>
</router-view> </router-view>
<transition name="modal">
<modal
v-if="currentModal"
@close="closeModal()"
variant="scrolling"
class="task-detail-view-modal"
>
<component :is="currentModal" />
</modal>
</transition>
<a <a
class="keyboard-shortcuts-button" class="keyboard-shortcuts-button"
@click="showKeyboardShortcuts()" @click="showKeyboardShortcuts()"
@ -39,13 +57,13 @@
> >
<icon icon="keyboard"/> <icon icon="keyboard"/>
</a> </a>
</div> </main>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import {watch, computed} from 'vue' import {watch, computed, shallowRef, watchEffect, VNode, h} from 'vue'
import {useStore} from 'vuex' import {useStore} from 'vuex'
import {useRoute, useRouter} from 'vue-router' import {useRoute, useRouter} from 'vue-router'
import {useEventListener} from '@vueuse/core' import {useEventListener} from '@vueuse/core'
@ -53,6 +71,59 @@ import {useEventListener} from '@vueuse/core'
import {CURRENT_LIST, KEYBOARD_SHORTCUTS_ACTIVE, 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 Navigation from '@/components/home/navigation.vue'
import QuickActions from '@/components/quick-actions/quick-actions.vue' import QuickActions from '@/components/quick-actions/quick-actions.vue'
import BaseButton from '@/components/base/BaseButton.vue'
function useRouteWithModal() {
const router = useRouter()
const route = useRoute()
const backdropView = computed(() => route.fullPath && window.history.state.backdropView)
const routeWithModal = computed(() => {
return backdropView.value
? router.resolve(backdropView.value)
: route
})
const currentModal = shallowRef<VNode>()
watchEffect(() => {
if (!backdropView.value) {
currentModal.value = undefined
return
}
// logic from vue-router
// https://github.com/vuejs/vue-router-next/blob/798cab0d1e21f9b4d45a2bd12b840d2c7415f38a/src/RouterView.ts#L125
const routePropsOption = route.matched[0]?.props.default
const routeProps = routePropsOption
? routePropsOption === true
? route.params
: typeof routePropsOption === 'function'
? routePropsOption(route)
: routePropsOption
: null
currentModal.value = h(
route.matched[0]?.components.default,
routeProps,
)
})
function closeModal() {
const historyState = computed(() => route.fullPath && window.history.state)
if (historyState.value) {
router.back()
} else {
const backdropRoute = historyState.value?.backdropView && router.resolve(historyState.value.backdropView)
router.push(backdropRoute)
}
}
return { routeWithModal, currentModal, closeModal }
}
const { routeWithModal, currentModal, closeModal } = useRouteWithModal()
const store = useStore() const store = useStore()
@ -167,10 +238,15 @@ store.dispatch('labels/loadAllLabels')
padding-top: $navbar-height; padding-top: $navbar-height;
} }
.app-content { .app-content {
padding: $navbar-height + 1.5rem 1.5rem 1rem 1.5rem; padding: $navbar-height + 1.5rem 1.5rem 1rem 1.5rem;
z-index: 10; z-index: 10;
position: relative; position: relative;
// Used to make sure the spinner is always in the middle while loading
> .loader-container {
min-height: calc(100vh - #{$navbar-height + 1.5rem + 1rem});
}
@media screen and (max-width: $tablet) { @media screen and (max-width: $tablet) {
margin-left: 0; margin-left: 0;
@ -187,15 +263,10 @@ store.dispatch('labels/loadAllLabels')
} }
} }
&.task\.detail { .card {
padding-left: 0; background: var(--white);
padding-right: 0; }
} }
.card {
background: var(--white);
}
}
} }
.mobile-overlay { .mobile-overlay {
@ -205,7 +276,9 @@ store.dispatch('labels/loadAllLabels')
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
background: rgba(250, 250, 250, 0.8); height: 100vh;
width: 100vw;
background: hsla(var(--grey-100-hsl), 0.8);
z-index: 5; z-index: 5;
opacity: 0; opacity: 0;
transition: all $transition; transition: all $transition;
@ -229,4 +302,6 @@ store.dispatch('labels/loadAllLabels')
display: none; display: none;
} }
} }
@include modal-transition();
</style> </style>

View File

@ -1,12 +1,12 @@
<template> <template>
<div :class="{'is-active': menuActive}" class="namespace-container"> <aside :class="{'is-active': menuActive}" class="namespace-container">
<div class="menu top-menu"> <nav class="menu top-menu">
<router-link :to="{name: 'home'}" class="logo"> <router-link :to="{name: 'home'}" class="logo">
<Logo width="164" height="48" /> <Logo width="164" height="48"/>
</router-link> </router-link>
<ul class="menu-list"> <ul class="menu-list">
<li> <li>
<router-link :to="{ name: 'home'}"> <router-link :to="{ name: 'home'}" v-shortcut="'g o'">
<span class="icon"> <span class="icon">
<icon icon="calendar"/> <icon icon="calendar"/>
</span> </span>
@ -14,7 +14,7 @@
</router-link> </router-link>
</li> </li>
<li> <li>
<router-link :to="{ name: 'tasks.range'}"> <router-link :to="{ name: 'tasks.range'}" v-shortcut="'g u'">
<span class="icon"> <span class="icon">
<icon :icon="['far', 'calendar-alt']"/> <icon :icon="['far', 'calendar-alt']"/>
</span> </span>
@ -22,7 +22,7 @@
</router-link> </router-link>
</li> </li>
<li> <li>
<router-link :to="{ name: 'namespaces.index'}"> <router-link :to="{ name: 'namespaces.index'}" v-shortcut="'g n'">
<span class="icon"> <span class="icon">
<icon icon="layer-group"/> <icon icon="layer-group"/>
</span> </span>
@ -30,7 +30,7 @@
</router-link> </router-link>
</li> </li>
<li> <li>
<router-link :to="{ name: 'labels.index'}"> <router-link :to="{ name: 'labels.index'}" v-shortcut="'g a'">
<span class="icon"> <span class="icon">
<icon icon="tags"/> <icon icon="tags"/>
</span> </span>
@ -38,7 +38,7 @@
</router-link> </router-link>
</li> </li>
<li> <li>
<router-link :to="{ name: 'teams.index'}"> <router-link :to="{ name: 'teams.index'}" v-shortcut="'g m'">
<span class="icon"> <span class="icon">
<icon icon="users"/> <icon icon="users"/>
</span> </span>
@ -46,31 +46,35 @@
</router-link> </router-link>
</li> </li>
</ul> </ul>
</div> </nav>
<aside class="menu namespaces-lists loader-container is-loading-small" :class="{'is-loading': loading}"> <nav class="menu namespaces-lists loader-container is-loading-small" :class="{'is-loading': loading}">
<template v-for="(n, nk) in namespaces" :key="n.id" > <template v-for="(n, nk) in namespaces" :key="n.id">
<div class="namespace-title" :class="{'has-menu': n.id > 0}"> <div class="namespace-title" :class="{'has-menu': n.id > 0}">
<span <span
@click="toggleLists(n.id)" @click="toggleLists(n.id)"
class="menu-label" class="menu-label"
v-tooltip="namespaceTitles[nk]"> v-tooltip="namespaceTitles[nk]"
>
<span
v-if="n.hexColor !== ''"
:style="{ backgroundColor: n.hexColor }"
class="color-bubble"
/>
<span class="name"> <span class="name">
<span
:style="{ backgroundColor: n.hexColor }"
class="color-bubble"
v-if="n.hexColor !== ''">
</span>
{{ namespaceTitles[nk] }} {{ namespaceTitles[nk] }}
</span> </span>
<a
class="icon is-small toggle-lists-icon pl-2"
:class="{'active': typeof listsVisible[n.id] !== 'undefined' ? listsVisible[n.id] : true}"
@click="toggleLists(n.id)"
>
<icon icon="chevron-down"/>
</a>
<span class="count" :class="{'ml-2 mr-0': n.id > 0}">
({{ namespaceListsCount[nk] }})
</span>
</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"/> <namespace-settings-dropdown :namespace="n" v-if="n.id > 0"/>
</div> </div>
<div <div
@ -81,18 +85,20 @@
<!-- <!--
NOTE: a v-model / computed setter is not possible, since the updateActiveLists function NOTE: a v-model / computed setter is not possible, since the updateActiveLists function
triggered by the change needs to have access to the current namespace triggered by the change needs to have access to the current namespace
--> -->
<draggable <draggable
v-bind="dragOptions" v-bind="dragOptions"
:modelValue="activeLists[nk]" :modelValue="activeLists[nk]"
@update:modelValue="(lists) => updateActiveLists(n, lists)" @update:modelValue="(lists) => updateActiveLists(n, lists)"
:group="`namespace-${n.id}-lists`" group="namespace-lists"
@start="() => drag = true" @start="() => drag = true"
@end="e => saveListPosition(e, nk)" @end="saveListPosition"
handle=".handle" handle=".handle"
:disabled="n.id < 0 || null" :disabled="n.id < 0 || null"
tag="transition-group" tag="transition-group"
item-key="id" item-key="id"
:data-namespace-id="n.id"
:data-namespace-index="nk"
:component-data="{ :component-data="{
type: 'transition', type: 'transition',
tag: 'ul', tag: 'ul',
@ -134,7 +140,7 @@
:class="{'is-favorite': l.isFavorite}" :class="{'is-favorite': l.isFavorite}"
@click.prevent.stop="toggleFavoriteList(l)" @click.prevent.stop="toggleFavoriteList(l)"
class="favorite"> class="favorite">
<icon :icon="l.isFavorite ? 'star' : ['far', 'star']" /> <icon :icon="l.isFavorite ? 'star' : ['far', 'star']"/>
</span> </span>
</a> </a>
</router-link> </router-link>
@ -145,9 +151,9 @@
</draggable> </draggable>
</div> </div>
</template> </template>
</aside> </nav>
<PoweredByLink /> <PoweredByLink/>
</div> </aside>
</template> </template>
<script> <script>
@ -194,13 +200,13 @@ export default {
loading: state => state[LOADING] && state[LOADING_MODULE] === 'namespaces', loading: state => state[LOADING] && state[LOADING_MODULE] === 'namespaces',
}), }),
activeLists() { activeLists() {
return this.namespaces.map(({lists}) => lists?.filter(item => !item.isArchived)) return this.namespaces.map(({lists}) => lists?.filter(item => typeof item !== 'undefined' && !item.isArchived))
}, },
namespaceTitles() { namespaceTitles() {
return this.namespaces.map((namespace, index) => { return this.namespaces.map((namespace) => this.getNamespaceTitle(namespace))
const title = this.getNamespaceTitle(namespace) },
return `${title} (${this.activeLists[index]?.length ?? 0})` namespaceListsCount() {
}) return this.namespaces.map((_, index) => this.activeLists[index]?.length ?? 0)
}, },
}, },
beforeCreate() { beforeCreate() {
@ -237,15 +243,15 @@ export default {
this.listsVisible[namespaceId] = !this.listsVisible[namespaceId] this.listsVisible[namespaceId] = !this.listsVisible[namespaceId]
}, },
updateActiveLists(namespace, activeLists) { updateActiveLists(namespace, activeLists) {
// this is a bit hacky: since we do have to filter out the archived items from the list // This is a bit hacky: since we do have to filter out the archived items from the list
// for vue draggable updating it is not as simple as replacing it. // for vue draggable updating it is not as simple as replacing it.
// instead we iterate over the non archived items in the old list and replace them with the ones in their new order // To work around this, we merge the active lists with the archived ones. Doing so breaks the order
const lists = namespace.lists.map((item) => { // because now all archived lists are sorted after the active ones. This is fine because they are sorted
if (item.isArchived) { // later when showing them anyway, and it makes the merging happening here a lot easier.
return item const lists = [
} ...activeLists,
return activeLists.shift() ...namespace.lists.filter(l => l.isArchived),
}) ]
const newNamespace = { const newNamespace = {
...namespace, ...namespace,
@ -255,8 +261,11 @@ export default {
this.$store.commit('namespaces/setNamespaceById', newNamespace) this.$store.commit('namespaces/setNamespaceById', newNamespace)
}, },
async saveListPosition(e, namespaceIndex) { async saveListPosition(e) {
const listsActive = this.activeLists[namespaceIndex] const namespaceId = parseInt(e.to.dataset.namespaceId)
const newNamespaceIndex = parseInt(e.to.dataset.namespaceIndex)
const listsActive = this.activeLists[newNamespaceIndex]
const list = listsActive[e.newIndex] const list = listsActive[e.newIndex]
const listBefore = listsActive[e.newIndex - 1] ?? null const listBefore = listsActive[e.newIndex - 1] ?? null
const listAfter = listsActive[e.newIndex + 1] ?? null const listAfter = listsActive[e.newIndex + 1] ?? null
@ -269,6 +278,7 @@ export default {
await this.$store.dispatch('lists/updateList', { await this.$store.dispatch('lists/updateList', {
...list, ...list,
position, position,
namespaceId,
}) })
} finally { } finally {
this.listUpdating[list.id] = false this.listUpdating[list.id] = false
@ -365,8 +375,9 @@ $vikunja-nav-selected-width: 0.4rem;
.menu-label { .menu-label {
.color-bubble { .color-bubble {
width: 14px !important; width: 14px;
height: 14px !important; height: 14px;
flex-basis: auto;
} }
.is-archived { .is-archived {
@ -387,6 +398,12 @@ $vikunja-nav-selected-width: 0.4rem;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
margin-right: auto;
}
.count {
color: var(--grey-500);
margin-right: .5rem;
} }
} }
@ -482,7 +499,7 @@ $vikunja-nav-selected-width: 0.4rem;
height: 1rem; height: 1rem;
vertical-align: middle; vertical-align: middle;
padding-right: 0.5rem; padding-right: 0.5rem;
&.handle { &.handle {
opacity: 0; opacity: 0;
transition: opacity $transition; transition: opacity $transition;
@ -490,7 +507,7 @@ $vikunja-nav-selected-width: 0.4rem;
cursor: grab; cursor: grab;
} }
} }
&:hover .icon.handle { &:hover .icon.handle {
opacity: 1; opacity: 1;
} }
@ -542,7 +559,7 @@ $vikunja-nav-selected-width: 0.4rem;
span.list-menu-link, li > a { span.list-menu-link, li > a {
padding-left: 2rem; padding-left: 2rem;
display: inline-block; display: inline-block;
.icon { .icon {
padding-bottom: .25rem; padding-bottom: .25rem;
} }

View File

@ -85,4 +85,8 @@ export default {
margin-left: .5rem; margin-left: .5rem;
} }
} }
.dark .update-notification {
color: var(--grey-200);
}
</style> </style>

View File

@ -66,7 +66,7 @@ const showIconOnly = computed(() => props.icon !== '' && typeof slots.default ==
text-transform: uppercase; text-transform: uppercase;
font-size: 0.85rem; font-size: 0.85rem;
font-weight: bold; font-weight: bold;
height: $button-height; min-height: $button-height;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
display: inline-flex; display: inline-flex;

View File

@ -183,6 +183,10 @@ export default {
this.updateData() this.updateData()
}, },
get() { get() {
if(!this.date) {
return ''
}
return format(this.date, 'yyy-LL-dd H:mm') return format(this.date, 'yyy-LL-dd H:mm')
}, },
}, },

View File

@ -326,7 +326,7 @@ export default {
} }
&.has-checkbox { &.has-checkbox {
margin-left: -2em; margin-left: -1.25rem;
list-style: none; list-style: none;
} }
} }

View File

@ -450,7 +450,6 @@ export default {
button { button {
background: transparent; background: transparent;
display: block;
text-align: left; text-align: left;
box-shadow: none; box-shadow: none;
border-radius: 0; border-radius: 0;
@ -460,6 +459,7 @@ export default {
padding: .5rem; padding: .5rem;
border: none; border: none;
cursor: pointer; cursor: pointer;
color: var(--grey-800);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

View File

@ -0,0 +1,85 @@
<template>
<div class="password-field">
<input
class="input"
id="password"
name="password"
:placeholder="$t('user.auth.passwordPlaceholder')"
required
:type="passwordFieldType"
autocomplete="current-password"
@keyup.enter="e => $emit('submit', e)"
:tabindex="props.tabindex"
@focusout="validate"
@input="handleInput"
/>
<a
@click="togglePasswordFieldType"
class="password-field-type-toggle"
:aria-label="passwordFieldType === 'password' ? $t('user.auth.showPassword') : $t('user.auth.hidePassword')"
v-tooltip="passwordFieldType === 'password' ? $t('user.auth.showPassword') : $t('user.auth.hidePassword')">
<icon :icon="passwordFieldType === 'password' ? 'eye' : 'eye-slash'"/>
</a>
</div>
<p class="help is-danger" v-if="!isValid">
{{ $t('user.auth.passwordRequired') }}
</p>
</template>
<script lang="ts" setup>
import {ref, watch} from 'vue'
import {useDebounceFn} from '@vueuse/core'
const props = defineProps({
tabindex: String,
modelValue: String,
// This prop is a workaround to trigger validation from the outside when the user never had focus in the input.
validateInitially: Boolean,
})
const emit = defineEmits(['submit', 'update:modelValue'])
const passwordFieldType = ref<String>('password')
const password = ref<String>('')
const isValid = ref<Boolean>(!props.validateInitially)
watch(
() => props.validateInitially,
(doValidate: Boolean) => {
if (doValidate) {
validate()
}
},
)
function validate() {
useDebounceFn(() => {
isValid.value = password.value !== ''
}, 100)()
}
function togglePasswordFieldType() {
passwordFieldType.value = passwordFieldType.value === 'password'
? 'text'
: 'password'
}
function handleInput(e) {
password.value = e.target.value
emit('update:modelValue', e.target.value)
}
</script>
<style scoped>
.password-field {
position: relative;
}
.password-field-type-toggle {
position: absolute;
color: var(--grey-400);
top: 50%;
right: 1rem;
transform: translateY(-50%);
}
</style>

View File

@ -2,21 +2,22 @@
<dropdown> <dropdown>
<template v-if="isSavedFilter"> <template v-if="isSavedFilter">
<dropdown-item <dropdown-item
:to="{ name: `${listRoutePrefix}.edit`, params: { listId: list.id } }" :to="{ name: 'filter.settings.edit', params: { listId: list.id } }"
icon="pen" icon="pen"
> >
{{ $t('menu.edit') }} {{ $t('menu.edit') }}
</dropdown-item> </dropdown-item>
<dropdown-item <dropdown-item
:to="{ name: `${listRoutePrefix}.delete`, params: { listId: list.id } }" :to="{ name: 'filter.settings.delete', params: { listId: list.id } }"
icon="trash-alt" icon="trash-alt"
> >
{{ $t('misc.delete') }} {{ $t('misc.delete') }}
</dropdown-item> </dropdown-item>
</template> </template>
<template v-else-if="list.isArchived"> <template v-else-if="list.isArchived">
<dropdown-item <dropdown-item
:to="{ name: `${listRoutePrefix}.archive`, params: { listId: list.id } }" :to="{ name: 'list.settings.archive', params: { listId: list.id } }"
icon="archive" icon="archive"
> >
{{ $t('menu.unarchive') }} {{ $t('menu.unarchive') }}
@ -24,37 +25,38 @@
</template> </template>
<template v-else> <template v-else>
<dropdown-item <dropdown-item
:to="{ name: `${listRoutePrefix}.edit`, params: { listId: list.id } }" :to="{ name: 'list.settings.edit', params: { listId: list.id } }"
icon="pen" icon="pen"
> >
{{ $t('menu.edit') }} {{ $t('menu.edit') }}
</dropdown-item> </dropdown-item>
<dropdown-item <dropdown-item
:to="{ name: `${listRoutePrefix}.background`, params: { listId: list.id } }"
v-if="backgroundsEnabled" v-if="backgroundsEnabled"
:to="{ name: 'list.settings.background', params: { listId: list.id } }"
icon="image" icon="image"
> >
{{ $t('menu.setBackground') }} {{ $t('menu.setBackground') }}
</dropdown-item> </dropdown-item>
<dropdown-item <dropdown-item
:to="{ name: `${listRoutePrefix}.share`, params: { listId: list.id } }" :to="{ name: 'list.settings.share', params: { listId: list.id } }"
icon="share-alt" icon="share-alt"
> >
{{ $t('menu.share') }} {{ $t('menu.share') }}
</dropdown-item> </dropdown-item>
<dropdown-item <dropdown-item
:to="{ name: `${listRoutePrefix}.duplicate`, params: { listId: list.id } }" :to="{ name: 'list.settings.duplicate', params: { listId: list.id } }"
icon="paste" icon="paste"
> >
{{ $t('menu.duplicate') }} {{ $t('menu.duplicate') }}
</dropdown-item> </dropdown-item>
<dropdown-item <dropdown-item
:to="{ name: `${listRoutePrefix}.archive`, params: { listId: list.id } }" :to="{ name: 'list.settings.archive', params: { listId: list.id } }"
icon="archive" icon="archive"
> >
{{ $t('menu.archive') }} {{ $t('menu.archive') }}
</dropdown-item> </dropdown-item>
<task-subscription <task-subscription
v-if="subscription"
class="dropdown-item has-no-shadow" class="dropdown-item has-no-shadow"
:is-button="false" :is-button="false"
entity="list" entity="list"
@ -63,7 +65,7 @@
@change="sub => subscription = sub" @change="sub => subscription = sub"
/> />
<dropdown-item <dropdown-item
:to="{ name: `${listRoutePrefix}.delete`, params: { listId: list.id } }" :to="{ name: 'list.settings.delete', params: { listId: list.id } }"
icon="trash-alt" icon="trash-alt"
class="has-text-danger" class="has-text-danger"
> >
@ -73,56 +75,32 @@
</dropdown> </dropdown>
</template> </template>
<script> <script setup lang="ts">
import {ref, computed, watchEffect} from 'vue'
import {useStore} from 'vuex'
import {getSavedFilterIdFromListId} from '@/helpers/savedFilter' import {getSavedFilterIdFromListId} from '@/helpers/savedFilter'
import Dropdown from '@/components/misc/dropdown.vue' import Dropdown from '@/components/misc/dropdown.vue'
import DropdownItem from '@/components/misc/dropdown-item.vue' import DropdownItem from '@/components/misc/dropdown-item.vue'
import TaskSubscription from '@/components/misc/subscription.vue' import TaskSubscription from '@/components/misc/subscription.vue'
import ListModel from '@/models/list'
import SubscriptionModel from '@/models/subscription'
export default { const props = defineProps({
name: 'list-settings-dropdown', list: {
data() { type: ListModel,
return { required: true,
subscription: null,
}
}, },
components: { })
TaskSubscription,
DropdownItem,
Dropdown,
},
props: {
list: {
required: true,
},
},
mounted() {
this.subscription = this.list.subscription
},
computed: {
backgroundsEnabled() {
return this.$store.state.config.enabledBackgroundProviders !== null && this.$store.state.config.enabledBackgroundProviders.length > 0
},
listRoutePrefix() {
let name = 'list'
const subscription = ref<SubscriptionModel>()
watchEffect(() => {
if (props.list.subscription) {
subscription.value = props.list.subscription
}
})
if (this.$route.name !== null && this.$route.name.startsWith('list.')) { const store = useStore()
// HACK: we should implement a better routing for the modals const backgroundsEnabled = computed(() => store.state.config.enabledBackgroundProviders?.length > 0)
const settingsRoutes = ['edit', 'delete', 'archive', 'background', 'share', 'duplicate'] const isSavedFilter = computed(() => getSavedFilterIdFromListId(props.list.id) > 0)
const suffix = settingsRoutes.find((route) => this.$route.name.endsWith(`.settings.${route}`))
name = this.$route.name.replace(`.settings.${suffix}`,'')
}
if (this.isSavedFilter) {
name = name.replace('list.', 'filter.')
}
return `${name}.settings`
},
isSavedFilter() {
return getSavedFilterIdFromListId(this.list.id) > 0
},
},
}
</script> </script>

View File

@ -6,7 +6,7 @@
> >
{{ $t('filters.clear') }} {{ $t('filters.clear') }}
</x-button> </x-button>
<popup> <popup :has-overflow="true">
<template #trigger="{toggle}"> <template #trigger="{toggle}">
<x-button <x-button
@click.prevent.stop="toggle()" @click.prevent.stop="toggle()"
@ -29,9 +29,10 @@
<script> <script>
import Filters from '@/components/list/partials/filters' import Filters from '@/components/list/partials/filters'
import {getDefaultParams} from '@/components/tasks/mixins/taskList'
import Popup from '@/components/misc/popup' import Popup from '@/components/misc/popup'
import {getDefaultParams} from '@/composables/taskList'
export default { export default {
name: 'filter-popup', name: 'filter-popup',
components: { components: {

View File

@ -67,49 +67,49 @@
<div class="field"> <div class="field">
<label class="label">{{ $t('task.attributes.dueDate') }}</label> <label class="label">{{ $t('task.attributes.dueDate') }}</label>
<div class="control"> <div class="control">
<flat-pickr <datepicker-with-range @dateChanged="values => setDateFilter('due_date', values)">
:config="flatPickerConfig" <template #trigger="{toggle, buttonText}">
@on-close="setDueDateFilter" <x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
class="input" {{ buttonText }}
:placeholder="$t('filters.attributes.dueDateRange')" </x-button>
v-model="filters.dueDate" </template>
/> </datepicker-with-range>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<label class="label">{{ $t('task.attributes.startDate') }}</label> <label class="label">{{ $t('task.attributes.startDate') }}</label>
<div class="control"> <div class="control">
<flat-pickr <datepicker-with-range @dateChanged="values => setDateFilter('start_date', values)">
:config="flatPickerConfig" <template #trigger="{toggle, buttonText}">
@on-close="setStartDateFilter" <x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
class="input" {{ buttonText }}
:placeholder="$t('filters.attributes.startDateRange')" </x-button>
v-model="filters.startDate" </template>
/> </datepicker-with-range>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<label class="label">{{ $t('task.attributes.endDate') }}</label> <label class="label">{{ $t('task.attributes.endDate') }}</label>
<div class="control"> <div class="control">
<flat-pickr <datepicker-with-range @dateChanged="values => setDateFilter('end_date', values)">
:config="flatPickerConfig" <template #trigger="{toggle, buttonText}">
@on-close="setEndDateFilter" <x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
class="input" {{ buttonText }}
:placeholder="$t('filters.attributes.endDateRange')" </x-button>
v-model="filters.endDate" </template>
/> </datepicker-with-range>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<label class="label">{{ $t('task.attributes.reminders') }}</label> <label class="label">{{ $t('task.attributes.reminders') }}</label>
<div class="control"> <div class="control">
<flat-pickr <datepicker-with-range @dateChanged="values => setDateFilter('reminders', values)">
:config="flatPickerConfig" <template #trigger="{toggle, buttonText}">
@on-close="setReminderFilter" <x-button @click.prevent.stop="toggle()" variant="secondary" :shadow="false" class="mb-2">
class="input" {{ buttonText }}
:placeholder="$t('filters.attributes.reminderRange')" </x-button>
v-model="filters.reminders" </template>
/> </datepicker-with-range>
</div> </div>
</div> </div>
@ -175,15 +175,14 @@
</template> </template>
<script> <script>
import DatepickerWithRange from '@/components/date/datepickerWithRange'
import Fancycheckbox from '../../input/fancycheckbox' import Fancycheckbox from '../../input/fancycheckbox'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import {includesById} from '@/helpers/utils' import {includesById} from '@/helpers/utils'
import {formatISO} from 'date-fns'
import PrioritySelect from '@/components/tasks/partials/prioritySelect.vue' import PrioritySelect from '@/components/tasks/partials/prioritySelect.vue'
import PercentDoneSelect from '@/components/tasks/partials/percentDoneSelect.vue' import PercentDoneSelect from '@/components/tasks/partials/percentDoneSelect.vue'
import Multiselect from '@/components/input/multiselect.vue' import Multiselect from '@/components/input/multiselect.vue'
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
import UserService from '@/services/user' import UserService from '@/services/user'
import ListService from '@/services/list' import ListService from '@/services/list'
@ -191,7 +190,7 @@ import NamespaceService from '@/services/namespace'
import EditLabels from '@/components/tasks/partials/editLabels.vue' import EditLabels from '@/components/tasks/partials/editLabels.vue'
import {objectToSnakeCase} from '@/helpers/case' import {objectToSnakeCase} from '@/helpers/case'
import {getDefaultParams} from '@/components/tasks/mixins/taskList' import {getDefaultParams} from '@/composables/taskList'
// FIXME: merge with DEFAULT_PARAMS in taskList.js // FIXME: merge with DEFAULT_PARAMS in taskList.js
const DEFAULT_PARAMS = { const DEFAULT_PARAMS = {
@ -222,15 +221,15 @@ const DEFAULT_FILTERS = {
namespace: '', namespace: '',
} }
export const ALPHABETICAL_SORT = 'title' export const ALPHABETICAL_SORT = 'title'
export default { export default {
name: 'filters', name: 'filters',
components: { components: {
DatepickerWithRange,
EditLabels, EditLabels,
PrioritySelect, PrioritySelect,
Fancycheckbox, Fancycheckbox,
flatPickr,
PercentDoneSelect, PercentDoneSelect,
Multiselect, Multiselect,
}, },
@ -281,7 +280,7 @@ export default {
return this.params?.sort_by?.find(sortBy => sortBy === ALPHABETICAL_SORT) !== undefined return this.params?.sort_by?.find(sortBy => sortBy === ALPHABETICAL_SORT) !== undefined
}, },
set(sortAlphabetically) { set(sortAlphabetically) {
this.params.sort_by = sortAlphabetically this.params.sort_by = sortAlphabetically
? [ALPHABETICAL_SORT] ? [ALPHABETICAL_SORT]
: getDefaultParams().sort_by : getDefaultParams().sort_by
@ -291,19 +290,6 @@ export default {
foundLabels() { foundLabels() {
return this.$store.getters['labels/filterLabelsByQuery'](this.labels, this.query) return this.$store.getters['labels/filterLabelsByQuery'](this.labels, this.query)
}, },
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: { methods: {
change() { change() {
@ -343,19 +329,12 @@ export default {
} }
} }
}, },
setDateFilter(filterName, variableName = null) { setDateFilter(filterName, {dateFrom, dateTo}) {
if (variableName === null) { dateFrom = parseDateOrString(dateFrom, null)
variableName = filterName dateTo = parseDateOrString(dateTo, null)
}
// Only filter if we have a start and end due date
if (this.filters[variableName] !== '') {
const parts = this.filters[variableName].split(' to ') // Only filter if we have a date
if (dateFrom !== null && dateTo !== null) {
if (parts.length < 2) {
return
}
// Check if we already have values in params and only update them if we do // Check if we already have values in params and only update them if we do
let foundStart = false let foundStart = false
@ -363,23 +342,23 @@ export default {
this.params.filter_by.forEach((f, i) => { this.params.filter_by.forEach((f, i) => {
if (f === filterName && this.params.filter_comparator[i] === 'greater_equals') { if (f === filterName && this.params.filter_comparator[i] === 'greater_equals') {
foundStart = true foundStart = true
this.params.filter_value[i] = formatISO(new Date(parts[0])) this.params.filter_value[i] = dateFrom
} }
if (f === filterName && this.params.filter_comparator[i] === 'less_equals') { if (f === filterName && this.params.filter_comparator[i] === 'less_equals') {
foundEnd = true foundEnd = true
this.params.filter_value[i] = formatISO(new Date(parts[1])) this.params.filter_value[i] = dateTo
} }
}) })
if (!foundStart) { if (!foundStart) {
this.params.filter_by.push(filterName) this.params.filter_by.push(filterName)
this.params.filter_comparator.push('greater_equals') this.params.filter_comparator.push('greater_equals')
this.params.filter_value.push(formatISO(new Date(parts[0]))) this.params.filter_value.push(dateFrom)
} }
if (!foundEnd) { if (!foundEnd) {
this.params.filter_by.push(filterName) this.params.filter_by.push(filterName)
this.params.filter_comparator.push('less_equals') this.params.filter_comparator.push('less_equals')
this.params.filter_value.push(formatISO(new Date(parts[1]))) this.params.filter_value.push(dateTo)
} }
this.change() this.change()
return return
@ -513,24 +492,12 @@ export default {
this.params.filter_concat = 'or' this.params.filter_concat = 'or'
} }
}, },
setDueDateFilter() {
this.setDateFilter('due_date', 'dueDate')
},
setPriority() { setPriority() {
this.setSingleValueFilter('priority', 'priority', 'usePriority') this.setSingleValueFilter('priority', 'priority', 'usePriority')
}, },
setStartDateFilter() {
this.setDateFilter('start_date', 'startDate')
},
setEndDateFilter() {
this.setDateFilter('end_date', 'endDate')
},
setPercentDoneFilter() { setPercentDoneFilter() {
this.setSingleValueFilter('percent_done', 'percentDone', 'usePercentDone') this.setSingleValueFilter('percent_done', 'percentDone', 'usePercentDone')
}, },
setReminderFilter() {
this.setDateFilter('reminders')
},
clear(kind) { clear(kind) {
this[`found${kind}`] = [] this[`found${kind}`] = []
}, },
@ -609,7 +576,7 @@ export default {
} }
</script> </script>
<style lang="scss"> <style lang="scss" scoped>
.single-value-control { .single-value-control {
display: flex; display: flex;
align-items: center; align-items: center;
@ -618,4 +585,8 @@ export default {
margin-left: .5rem; margin-left: .5rem;
} }
} }
:deep(.datepicker-with-range-container .popup) {
right: 0;
}
</style> </style>

View File

@ -34,13 +34,14 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import {ref, watch} from 'vue' import {PropType, ref, watch} from 'vue'
import {useStore} from 'vuex' import {useStore} from 'vuex'
import ListService from '@/services/list' import ListService from '@/services/list'
import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash' import {getBlobFromBlurHash} from '@/helpers/getBlobFromBlurHash'
import {colorIsDark} from '@/helpers/color/colorIsDark' import {colorIsDark} from '@/helpers/color/colorIsDark'
import ListModel from '@/models/list'
const background = ref<string | null>(null) const background = ref<string | null>(null)
const backgroundLoading = ref(false) const backgroundLoading = ref(false)
@ -48,7 +49,7 @@ const blurHashUrl = ref('')
const props = defineProps({ const props = defineProps({
list: { list: {
type: Object, type: Object as PropType<ListModel>,
required: true, required: true,
}, },
showArchived: { showArchived: {
@ -81,7 +82,7 @@ async function loadBackground() {
const store = useStore() const store = useStore()
function toggleFavoriteList(list) { function toggleFavoriteList(list: ListModel) {
// The favorites pseudo list is always favorite // The favorites pseudo list is always favorite
// Archived lists cannot be marked favorite // Archived lists cannot be marked favorite
if (list.id === -1 || list.isArchived) { if (list.id === -1 || list.isArchived) {

View File

@ -39,79 +39,68 @@
</div> </div>
</template> </template>
<script> <script setup lang="ts">
import Message from '@/components/misc/message' import {ref, computed, watch} from 'vue'
import {useI18n} from 'vue-i18n'
import {parseURL} from 'ufo' import {parseURL} from 'ufo'
import {checkAndSetApiUrl} from '@/helpers/checkAndSetApiUrl' import {checkAndSetApiUrl} from '@/helpers/checkAndSetApiUrl'
import {success} from '@/message'
export default { import Message from '@/components/misc/message.vue'
name: 'apiConfig',
components: { const props = defineProps({
Message, configureOpen: {
type: Boolean,
required: false,
default: false,
}, },
data() { })
return { const emit = defineEmits(['foundApi'])
configureApi: false,
apiUrl: window.API_URL, const apiUrl = ref(window.API_URL)
errorMsg: '', const configureApi = ref(apiUrl.value === '')
successMsg: '',
// Because we're only using this to parse the hostname, it should be fine to just prefix with http://
// regardless of whether the url is actually reachable under http.
const apiDomain = computed(() => parseURL(apiUrl.value, 'http://').host || parseURL(window.location.href).host)
watch(() => props.configureOpen, (value) => {
configureApi.value = value
}, {immediate: true})
const {t} = useI18n()
const errorMsg = ref('')
const successMsg = ref('')
async function setApiUrl() {
if (apiUrl.value === '') {
// Don't try to check and set an empty url
errorMsg.value = t('apiConfig.urlRequired')
return
}
try {
const url = await checkAndSetApiUrl(apiUrl.value)
if (url === '') {
// If the config setter function could not figure out a url
throw new Error('URL cannot be empty.')
} }
},
emits: ['foundApi'],
created() {
if (this.apiUrl === '') {
this.configureApi = true
}
},
computed: {
apiDomain() {
return parseURL(this.apiUrl).host || parseURL(window.location.href).host
},
},
props: {
configureOpen: {
type: Boolean,
required: false,
default: false,
},
},
watch: {
configureOpen: {
handler(value) {
this.configureApi = value
},
immediate: true,
},
},
methods: {
async setApiUrl() {
if (this.apiUrl === '') {
// Don't try to check and set an empty url
this.errorMsg = this.$t('apiConfig.urlRequired')
return
}
try { // Set it + save it to local storage to save us the hoops
const url = await checkAndSetApiUrl(this.apiUrl) errorMsg.value = ''
apiUrl.value = url
if (url === '') { success({message: t('apiConfig.success', {domain: apiDomain.value})})
// If the config setter function could not figure out a url configureApi.value = false
throw new Error('URL cannot be empty.') emit('foundApi', apiUrl.value)
} } catch (e) {
// Still not found, url is still invalid
// Set it + save it to local storage to save us the hoops successMsg.value = ''
this.errorMsg = '' errorMsg.value = t('apiConfig.error', {domain: apiDomain.value})
this.$message.success({message: this.$t('apiConfig.success', {domain: this.apiDomain})}) }
this.configureApi = false
this.apiUrl = url
this.$emit('foundApi', this.apiUrl)
} catch (e) {
// Still not found, url is still invalid
this.successMsg = ''
this.errorMsg = this.$t('apiConfig.error', {domain: this.apiDomain})
}
},
},
} }
</script> </script>

View File

@ -4,9 +4,15 @@
<template v-for="(s, i) in shortcuts" :key="i"> <template v-for="(s, i) in shortcuts" :key="i">
<h3>{{ $t(s.title) }}</h3> <h3>{{ $t(s.title) }}</h3>
<message> <message class="mb-4" v-if="s.available">
{{ {{
s.available($route) ? $t('keyboardShortcuts.currentPageOnly') : $t('keyboardShortcuts.allPages') typeof s.available === 'undefined' ?
$t('keyboardShortcuts.allPages') :
(
s.available($route)
? $t('keyboardShortcuts.currentPageOnly')
: $t('keyboardShortcuts.somePagesOnly')
)
}} }}
</message> </message>
@ -17,7 +23,8 @@
class="shortcut-keys" class="shortcut-keys"
is="dd" is="dd"
:keys="sc.keys" :keys="sc.keys"
:combination="typeof sc.combination !== 'undefined' ? $t(`keyboardShortcuts.${sc.combination}`) : null"/> :combination="sc.combination && $t(`keyboardShortcuts.${sc.combination}`)"
/>
</template> </template>
</dl> </dl>
</template> </template>
@ -25,28 +32,19 @@
</modal> </modal>
</template> </template>
<script> <script lang="ts" setup>
import {KEYBOARD_SHORTCUTS_ACTIVE} from '@/store/mutation-types' import {useStore} from 'vuex'
import Shortcut from '@/components/misc/shortcut.vue'
import Message from '@/components/misc/message'
import {KEYBOARD_SHORTCUTS} from './shortcuts'
export default { import Shortcut from '@/components/misc/shortcut.vue'
name: 'keyboard-shortcuts', import Message from '@/components/misc/message.vue'
components: {
Message, import {KEYBOARD_SHORTCUTS_ACTIVE} from '@/store/mutation-types'
Shortcut, import {KEYBOARD_SHORTCUTS as shortcuts} from './shortcuts'
},
data() { const store = useStore()
return {
shortcuts: KEYBOARD_SHORTCUTS, function close() {
} store.commit(KEYBOARD_SHORTCUTS_ACTIVE, false)
},
methods: {
close() {
this.$store.commit(KEYBOARD_SHORTCUTS_ACTIVE, false)
},
},
} }
</script> </script>

View File

@ -1,11 +1,24 @@
import {RouteLocation} from 'vue-router'
import {isAppleDevice} from '@/helpers/isAppleDevice' import {isAppleDevice} from '@/helpers/isAppleDevice'
const ctrl = isAppleDevice() ? '⌘' : 'ctrl' const ctrl = isAppleDevice() ? '⌘' : 'ctrl'
export const KEYBOARD_SHORTCUTS = [ interface Shortcut {
title: string
keys: string[]
combination?: 'then'
}
interface ShortcutGroup {
title: string
available?: (route: RouteLocation) => boolean
shortcuts: Shortcut[]
}
export const KEYBOARD_SHORTCUTS : ShortcutGroup[] = [
{ {
title: 'keyboardShortcuts.general', title: 'keyboardShortcuts.general',
available: () => null,
shortcuts: [ shortcuts: [
{ {
title: 'keyboardShortcuts.toggleMenu', title: 'keyboardShortcuts.toggleMenu',
@ -17,6 +30,36 @@ export const KEYBOARD_SHORTCUTS = [
}, },
], ],
}, },
{
title: 'keyboardShortcuts.navigation.title',
shortcuts: [
{
title: 'keyboardShortcuts.navigation.overview',
keys: ['g', 'o'],
combination: 'then',
},
{
title: 'keyboardShortcuts.navigation.upcoming',
keys: ['g', 'u'],
combination: 'then',
},
{
title: 'keyboardShortcuts.navigation.namespaces',
keys: ['g', 'n'],
combination: 'then',
},
{
title: 'keyboardShortcuts.navigation.labels',
keys: ['g', 'a'],
combination: 'then',
},
{
title: 'keyboardShortcuts.navigation.teams',
keys: ['g', 'm'],
combination: 'then',
},
],
},
{ {
title: 'list.kanban.title', title: 'list.kanban.title',
available: (route) => route.name === 'list.kanban', available: (route) => route.name === 'list.kanban',
@ -29,7 +72,7 @@ export const KEYBOARD_SHORTCUTS = [
}, },
{ {
title: 'keyboardShortcuts.list.title', title: 'keyboardShortcuts.list.title',
available: (route) => route.name.startsWith('list.'), available: (route) => (route.name as string)?.startsWith('list.'),
shortcuts: [ shortcuts: [
{ {
title: 'keyboardShortcuts.list.switchToListView', title: 'keyboardShortcuts.list.switchToListView',
@ -55,14 +98,12 @@ export const KEYBOARD_SHORTCUTS = [
}, },
{ {
title: 'keyboardShortcuts.task.title', title: 'keyboardShortcuts.task.title',
available: (route) => [ available: (route) => route.name === 'task.detail',
'task.detail',
'task.list.detail',
'task.gantt.detail',
'task.kanban.detail',
'task.detail',
].includes(route.name),
shortcuts: [ shortcuts: [
{
title: 'keyboardShortcuts.task.done',
keys: ['t'],
},
{ {
title: 'keyboardShortcuts.task.assign', title: 'keyboardShortcuts.task.assign',
keys: ['a'], keys: ['a'],
@ -83,6 +124,14 @@ export const KEYBOARD_SHORTCUTS = [
title: 'keyboardShortcuts.task.related', title: 'keyboardShortcuts.task.related',
keys: ['r'], keys: ['r'],
}, },
{
title: 'keyboardShortcuts.task.move',
keys: ['m'],
},
{
title: 'keyboardShortcuts.task.color',
keys: ['c'],
},
], ],
}, },
] ]

View File

@ -1,18 +1,35 @@
<template> <template>
<div class="message-wrapper"> <div class="message-wrapper">
<div class="message" :class="variant"> <div class="message" :class="[variant, textAlignClass]">
<slot/> <slot/>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
defineProps({ import {computed, PropType} from 'vue'
const TEXT_ALIGN_MAP = Object.freeze({
left: '',
center: 'has-text-centered',
right: 'has-text-right',
})
type textAlignVariants = keyof typeof TEXT_ALIGN_MAP
const props = defineProps({
variant: { variant: {
type: String, type: String,
default: 'info', default: 'info',
}, },
textAlign: {
type: String as PropType<textAlignVariants>,
default: 'left',
},
}) })
const textAlignClass = computed(() => TEXT_ALIGN_MAP[props.textAlign])
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -14,6 +14,9 @@
<div> <div>
<h2 class="title" v-if="title">{{ title }}</h2> <h2 class="title" v-if="title">{{ title }}</h2>
<api-config/> <api-config/>
<Message v-if="motd !== ''" class="is-hidden-tablet mb-4">
{{ motd }}
</Message>
<slot/> <slot/>
</div> </div>
<legal/> <legal/>
@ -38,8 +41,8 @@ const store = useStore()
const {t} = useI18n() const {t} = useI18n()
const motd = computed(() => store.state.config.motd) const motd = computed(() => store.state.config.motd)
// @ts-ignore
const title = computed(() => t(route.meta.title ?? '')) const title = computed(() => t(route.meta?.title as string || ''))
useTitle(() => title.value) useTitle(() => title.value)
</script> </script>

View File

@ -1,6 +1,6 @@
<template> <template>
<slot name="trigger" :isOpen="open" :toggle="toggle"></slot> <slot name="trigger" :isOpen="open" :toggle="toggle"></slot>
<div class="popup" :class="{'is-open': open}" ref="popup"> <div class="popup" :class="{'is-open': open, 'has-overflow': props.hasOverflow && open}" ref="popup">
<slot name="content" :isOpen="open"/> <slot name="content" :isOpen="open"/>
</div> </div>
</template> </template>
@ -16,6 +16,13 @@ const toggle = () => {
open.value = !open.value open.value = !open.value
} }
const props = defineProps({
hasOverflow: {
type: Boolean,
default: false,
},
})
function hidePopup(e) { function hidePopup(e) {
if (!open.value) { if (!open.value) {
return return

View File

@ -52,9 +52,15 @@ import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue'
import {ERROR_NO_API_URL} from '@/helpers/checkAndSetApiUrl' import {ERROR_NO_API_URL} from '@/helpers/checkAndSetApiUrl'
import {useOnline} from '@/composables/useOnline' import {useOnline} from '@/composables/useOnline'
import {useRouter, useRoute} from 'vue-router'
import {getAuthForRoute} from '@/router'
const router = useRouter()
const route = useRoute()
const store = useStore() const store = useStore()
const ready = computed(() => store.state.vikunjaReady) const ready = ref(false)
const online = useOnline() const online = useOnline()
const error = ref('') const error = ref('')
@ -63,7 +69,12 @@ const showLoading = computed(() => !ready.value && error.value === '')
async function load() { async function load() {
try { try {
await store.dispatch('loadApp') await store.dispatch('loadApp')
} catch(e: any) { const redirectTo = getAuthForRoute(route)
if (typeof redirectTo !== 'undefined') {
await router.push(redirectTo)
}
ready.value = true
} catch (e: any) {
error.value = e error.value = e
} }
} }

View File

@ -1,53 +1,51 @@
<template> <template>
<x-button <x-button
v-if="isButton"
variant="secondary" variant="secondary"
:icon="icon" :icon="iconName"
v-tooltip="tooltipText" v-tooltip="tooltipText"
@click="changeSubscription" @click="changeSubscription"
:disabled="disabled || null" :disabled="disabled || null"
v-if="isButton"
> >
{{ buttonText }} {{ buttonText }}
</x-button> </x-button>
<a <BaseButton
v-else
v-tooltip="tooltipText" v-tooltip="tooltipText"
@click="changeSubscription" @click="changeSubscription"
:class="{'is-disabled': disabled}" :class="{'is-disabled': disabled}"
v-else
> >
<span class="icon"> <span class="icon">
<icon :icon="icon"/> <icon :icon="iconName"/>
</span> </span>
{{ buttonText }} {{ buttonText }}
</a> </BaseButton>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import {computed, shallowRef} from 'vue' import {computed, shallowRef} from 'vue'
import {useI18n} from 'vue-i18n' import {useI18n} from 'vue-i18n'
import BaseButton from '@/components/base/BaseButton.vue'
import SubscriptionService from '@/services/subscription' import SubscriptionService from '@/services/subscription'
import SubscriptionModel from '@/models/subscription' import SubscriptionModel from '@/models/subscription'
import {success} from '@/message' import {success} from '@/message'
const props = defineProps({ interface Props {
entity: { entity: string
required: true, entityId: number
type: String, subscription: SubscriptionModel
}, isButton?: boolean
subscription: { }
required: true,
}, const props = withDefaults(defineProps<Props>(), {
entityId: { isButton: true,
required: true,
},
isButton: {
type: Boolean,
default: true,
},
}) })
const subscriptionEntity = computed<string>(() => props.subscription.entity)
const emit = defineEmits(['change']) const emit = defineEmits(['change'])
const subscriptionService = shallowRef(new SubscriptionService()) const subscriptionService = shallowRef(new SubscriptionService())
@ -57,7 +55,7 @@ const tooltipText = computed(() => {
if (disabled.value) { if (disabled.value) {
return t('task.subscription.subscribedThroughParent', { return t('task.subscription.subscribedThroughParent', {
entity: props.entity, entity: props.entity,
parent: props.subscription.entity, parent: subscriptionEntity.value,
}) })
} }
@ -67,13 +65,13 @@ const tooltipText = computed(() => {
}) })
const buttonText = computed(() => props.subscription !== null ? t('task.subscription.unsubscribe') : t('task.subscription.subscribe')) const buttonText = computed(() => props.subscription !== null ? t('task.subscription.unsubscribe') : t('task.subscription.subscribe'))
const icon = computed(() => props.subscription !== null ? ['far', 'bell-slash'] : 'bell') const iconName = computed(() => props.subscription !== null ? ['far', 'bell-slash'] : 'bell')
const disabled = computed(() => { const disabled = computed(() => {
if (props.subscription === null) { if (props.subscription === null) {
return false return false
} }
return props.subscription.entity !== props.entity return subscriptionEntity.value !== props.entity
}) })
function changeSubscription() { function changeSubscription() {

View File

@ -1,4 +1,5 @@
<template> <template>
<!-- FIXME: transition should not be included in the modal -->
<transition name="modal"> <transition name="modal">
<section <section
v-if="enabled" v-if="enabled"
@ -7,10 +8,10 @@
{ 'has-overflow': overflow }, { 'has-overflow': overflow },
variant, variant,
]" ]"
ref="modal"
> >
<div <div
class="modal-container" class="modal-container"
:class="{'has-overflow': overflow}"
@click.self.prevent.stop="$emit('close')" @click.self.prevent.stop="$emit('close')"
v-shortcut="'Escape'" v-shortcut="'Escape'"
> >
@ -21,6 +22,13 @@
'is-wide': wide 'is-wide': wide
}" }"
> >
<BaseButton
@click="$emit('close')"
class="close"
>
<icon icon="times"/>
</BaseButton>
<slot> <slot>
<div class="header"> <div class="header">
<slot name="header"></slot> <slot name="header"></slot>
@ -52,58 +60,37 @@
</transition> </transition>
</template> </template>
<script> <script lang="ts" setup>
export const TRANSITION_NAMES = { import BaseButton from '@/components/base/BaseButton.vue'
MODAL: 'modal', import {ref, watch} from 'vue'
FADE: 'fade', import {useScrollLock} from '@vueuse/core'
}
export const VARIANTS = { const props = withDefaults(defineProps<{
DEFAULT: 'default', enabled?: boolean,
HINT_MODAL: 'hint-modal', overflow?: boolean,
SCROLLING: 'scrolling', wide?: boolean,
} transitionName?: 'modal' | 'fade',
variant?: 'default' | 'hint-modal' | 'scrolling',
}>(), {
enabled: true,
transitionName: 'modal',
variant: 'default',
})
function validValue(values) { defineEmits(['close', 'submit'])
return (value) => Object.values(values).includes(value)
}
export default { const modal = ref<HTMLElement | null>(null)
name: 'modal', const scrollLock = useScrollLock(modal)
mounted() {
document.addEventListener('keydown', (e) => { watch(
// Close the model when escape is pressed () => props.enabled,
if (e.keyCode === 27) { enabled => {
this.$emit('close') scrollLock.value = enabled
}
})
}, },
props: { {
enabled: { immediate: true,
type: Boolean,
default: true,
},
overflow: {
type: Boolean,
default: false,
},
wide: {
type: Boolean,
default: false,
},
transitionName: {
type: String,
default: TRANSITION_NAMES.MODAL,
validator: validValue(TRANSITION_NAMES),
},
variant: {
type: String,
default: VARIANTS.DEFAULT,
validator: validValue(VARIANTS),
},
}, },
emits: ['close', 'submit'], )
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -192,22 +179,26 @@ export default {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
} }
} }
.close {
position: fixed;
top: 5px;
right: 26px;
color: var(--white);
font-size: 2rem;
@media screen and (max-width: $desktop) {
/* Transitions */ color: var(--grey-900);
}
.modal-enter,
.modal-leave-active {
opacity: 0;
} }
</style>
.modal-enter .modal-container, <style lang="scss">
.modal-leave-active .modal-container { // Close icon SVG uses currentColor, change the color to keep it visible
transform: scale(0.9); .dark .close {
color: var(--grey-900);
} }
</style> </style>

View File

@ -13,6 +13,7 @@
import {ref, computed} from 'vue' import {ref, computed} from 'vue'
import {useStore} from 'vuex' import {useStore} from 'vuex'
import Multiselect from '@/components/input/multiselect.vue' import Multiselect from '@/components/input/multiselect.vue'
import NamespaceModel from '@/models/namespace'
const emit = defineEmits(['selected']) const emit = defineEmits(['selected'])
@ -25,7 +26,7 @@ function findNamespaces(newQuery: string) {
query.value = newQuery query.value = newQuery
} }
function select(namespace) { function select(namespace: NamespaceModel) {
emit('selected', namespace) emit('selected', namespace)
} }
</script> </script>

View File

@ -16,13 +16,13 @@
{{ $t('menu.edit') }} {{ $t('menu.edit') }}
</dropdown-item> </dropdown-item>
<dropdown-item <dropdown-item
:to="{ name: 'namespace.settings.share', params: { id: namespace.id } }" :to="{ name: 'namespace.settings.share', params: { namespaceId: namespace.id } }"
icon="share-alt" icon="share-alt"
> >
{{ $t('menu.share') }} {{ $t('menu.share') }}
</dropdown-item> </dropdown-item>
<dropdown-item <dropdown-item
:to="{ name: 'list.create', params: { id: namespace.id } }" :to="{ name: 'list.create', params: { namespaceId: namespace.id } }"
icon="plus" icon="plus"
> >
{{ $t('menu.newList') }} {{ $t('menu.newList') }}
@ -34,6 +34,7 @@
{{ $t('menu.archive') }} {{ $t('menu.archive') }}
</dropdown-item> </dropdown-item>
<task-subscription <task-subscription
v-if="subscription"
class="dropdown-item has-no-shadow" class="dropdown-item has-no-shadow"
:is-button="false" :is-button="false"
entity="namespace" entity="namespace"

View File

@ -81,7 +81,7 @@ export default {
return this.notifications.filter(n => n.readAt === null).length return this.notifications.filter(n => n.readAt === null).length
}, },
notifications() { notifications() {
return this.allNotifications.filter(n => n.name !== '') return this.allNotifications ? this.allNotifications.filter(n => n.name !== '') : []
}, },
...mapState({ ...mapState({
userInfo: state => state.auth.info, userInfo: state => state.auth.info,

View File

@ -264,4 +264,6 @@ export default {
.sharables-list:not(.card-content) { .sharables-list:not(.card-content) {
overflow-y: auto overflow-y: auto
} }
@include modal-transition();
</style> </style>

View File

@ -365,3 +365,7 @@ export default {
}, },
} }
</script> </script>
<style lang="scss" scoped>
@include modal-transition();
</style>

View File

@ -5,12 +5,13 @@
<textarea <textarea
:disabled="taskService.loading || undefined" :disabled="taskService.loading || undefined"
class="add-task-textarea input" class="add-task-textarea input"
:class="{'textarea-empty': newTaskTitle === ''}"
:placeholder="$t('list.list.addPlaceholder')" :placeholder="$t('list.list.addPlaceholder')"
rows="1" rows="1"
v-focus v-focus
v-model="newTaskTitle" v-model="newTaskTitle"
ref="newTaskInput" ref="newTaskInput"
@keyup="errorMessage = ''" @keyup="resetEmptyTitleError"
@keydown.enter="handleEnter" @keydown.enter="handleEnter"
/> />
<span class="icon is-small is-left"> <span class="icon is-small is-left">
@ -24,15 +25,18 @@
@click="addTask()" @click="addTask()"
icon="plus" icon="plus"
:loading="taskService.loading" :loading="taskService.loading"
:aria-label="$t('list.list.add')"
> >
{{ $t('list.list.add') }} <span class="button-text">
{{ $t('list.list.add') }}
</span>
</x-button> </x-button>
</p> </p>
</div> </div>
<p class="help is-danger" v-if="errorMessage !== ''"> <p class="help is-danger" v-if="errorMessage !== ''">
{{ errorMessage }} {{ errorMessage }}
</p> </p>
<quick-add-magic v-else /> <quick-add-magic v-else/>
</div> </div>
</template> </template>
@ -40,7 +44,7 @@
import {ref, watch, unref, shallowReactive} from 'vue' import {ref, watch, unref, shallowReactive} from 'vue'
import {useI18n} from 'vue-i18n' import {useI18n} from 'vue-i18n'
import {useStore} from 'vuex' import {useStore} from 'vuex'
import { tryOnMounted, debouncedWatch, useWindowSize, MaybeRef } from '@vueuse/core' import {tryOnMounted, debouncedWatch, useWindowSize, MaybeRef} from '@vueuse/core'
import TaskService from '@/services/task' import TaskService from '@/services/task'
import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue' import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue'
@ -54,12 +58,12 @@ function useAutoHeightTextarea(value: MaybeRef<string>) {
const minHeight = ref(0) const minHeight = ref(0)
// adapted from https://github.com/LeaVerou/stretchy/blob/47f5f065c733029acccb755cae793009645809e2/src/stretchy.js#L34 // adapted from https://github.com/LeaVerou/stretchy/blob/47f5f065c733029acccb755cae793009645809e2/src/stretchy.js#L34
function resize(textareaEl: HTMLInputElement|undefined) { function resize(textareaEl: HTMLInputElement | undefined) {
if (!textareaEl) return if (!textareaEl) return
let empty let empty
// the value here is the the attribute value // the value here is the attribute value
if (!textareaEl.value && textareaEl.placeholder) { if (!textareaEl.value && textareaEl.placeholder) {
empty = true empty = true
textareaEl.value = textareaEl.placeholder textareaEl.value = textareaEl.placeholder
@ -95,12 +99,12 @@ function useAutoHeightTextarea(value: MaybeRef<string>) {
} }
}) })
const { width: windowWidth } = useWindowSize() const {width: windowWidth} = useWindowSize()
debouncedWatch( debouncedWatch(
windowWidth, windowWidth,
() => resize(textarea.value), () => resize(textarea.value),
{ debounce: 200 }, {debounce: 200},
) )
// It is not possible to get notified of a change of the value attribute of a textarea without workarounds (setTimeout) // It is not possible to get notified of a change of the value attribute of a textarea without workarounds (setTimeout)
@ -129,12 +133,18 @@ const emit = defineEmits(['taskAdded'])
const newTaskTitle = ref('') const newTaskTitle = ref('')
const newTaskInput = useAutoHeightTextarea(newTaskTitle) const newTaskInput = useAutoHeightTextarea(newTaskTitle)
const { t } = useI18n() const {t} = useI18n()
const store = useStore() const store = useStore()
const taskService = shallowReactive(new TaskService()) const taskService = shallowReactive(new TaskService())
const errorMessage = ref('') const errorMessage = ref('')
function resetEmptyTitleError() {
if (newTaskTitle.value !== '') {
errorMessage.value = ''
}
}
async function addTask() { async function addTask() {
if (newTaskTitle.value === '') { if (newTaskTitle.value === '') {
errorMessage.value = t('list.create.addTitleRequired') errorMessage.value = t('list.create.addTitleRequired')
@ -194,9 +204,26 @@ function handleEnter(e: KeyboardEvent) {
.add-task-button { .add-task-button {
height: 2.5rem; height: 2.5rem;
@media screen and (max-width: $mobile) {
.button-text {
display: none;
}
:deep(.icon) {
margin: 0 !important;
}
}
} }
.add-task-textarea { .add-task-textarea {
transition: border-color $transition; transition: border-color $transition;
resize: none; resize: none;
} }
// Adding this class when the textarea has no text prevents the textarea from wrapping the placeholder.
.textarea-empty {
white-space: nowrap;
text-overflow: ellipsis;
}
</style> </style>

View File

@ -67,7 +67,7 @@
<router-link <router-link
class="mt-2 has-text-centered is-block" class="mt-2 has-text-centered is-block"
:to="{name: 'task.detail', params: {id: taskEditTask.id}}" :to="taskDetailRoute"
> >
{{ $t('task.openDetail') }} {{ $t('task.openDetail') }}
</router-link> </router-link>
@ -97,6 +97,15 @@ export default {
taskEditTask: TaskModel, taskEditTask: TaskModel,
} }
}, },
computed: {
taskDetailRoute() {
return {
name: 'task.detail',
params: { id: this.taskEditTask.id },
state: { backdropView: this.$router.currentRoute.value.fullPath },
}
},
},
components: { components: {
ColorPicker, ColorPicker,
Reminders, Reminders,

View File

@ -1,101 +0,0 @@
import TaskCollectionService from '@/services/taskCollection'
// FIXME: merge with DEFAULT_PARAMS in filters.vue
export const getDefaultParams = () => ({
sort_by: ['position', 'id'],
order_by: ['asc', 'desc'],
filter_by: ['done'],
filter_value: ['false'],
filter_comparator: ['equals'],
filter_concat: 'and',
})
/**
* This mixin provides a base set of methods and properties to get tasks on a list.
*/
export default {
data() {
return {
taskCollectionService: new TaskCollectionService(),
tasks: [],
currentPage: 0,
loadedList: null,
searchTerm: '',
showTaskFilter: false,
params: {...getDefaultParams()},
}
},
watch: {
// Only listen for query path changes
'$route.query': {
handler: 'loadTasksForPage',
immediate: true,
},
'$route.path': 'loadTasksOnSavedFilter',
},
methods: {
async loadTasks(
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' &&
!forceLoading
) {
return
}
if (params === null) {
params = this.params
}
if (search !== '') {
params.s = search
}
const list = {listId: parseInt(this.$route.params.listId)}
const currentList = {
id: list.listId,
params,
search,
page,
}
if (JSON.stringify(currentList) === JSON.stringify(this.loadedList) && !forceLoading) {
return
}
this.tasks = []
this.tasks = await this.taskCollectionService.getAll(list, params, page)
this.currentPage = page
this.loadedList = JSON.parse(JSON.stringify(currentList))
},
loadTasksForPage(e) {
// The page parameter can be undefined, in the case where the user loads a new list from the side bar menu
let page = Number(e.page)
if (typeof e.page === 'undefined') {
page = 1
}
let search = e.search
if (typeof e.search === 'undefined') {
search = ''
}
this.initTasks(page, search)
},
loadTasksOnSavedFilter() {
if (typeof this.$route.params.listId !== 'undefined' && parseInt(this.$route.params.listId) < 0) {
this.loadTasks(1, '', null, true)
}
},
},
}

View File

@ -34,7 +34,7 @@
> >
<div class="filename">{{ a.file.name }}</div> <div class="filename">{{ a.file.name }}</div>
<div class="info"> <div class="info">
<p class="collapses"> <p class="attachment-info-meta">
<i18n-t keypath="task.attachment.createdBy"> <i18n-t keypath="task.attachment.createdBy">
<span v-tooltip="formatDate(a.created)"> <span v-tooltip="formatDate(a.created)">
{{ formatDateSince(a.created) }} {{ formatDateSince(a.created) }}
@ -289,21 +289,6 @@ export default {
content: '·'; content: '·';
padding: 0 .25rem; padding: 0 .25rem;
} }
@media screen and (max-width: $mobile) {
&.collapses {
flex-direction: column;
> span:not(:last-child):after,
> a:not(:last-child):after {
display: none;
}
.user .username {
display: none;
}
}
}
} }
} }
} }
@ -341,6 +326,10 @@ export default {
height: auto; height: auto;
text-shadow: var(--shadow-md); text-shadow: var(--shadow-md);
animation: bounce 2s infinite; animation: bounce 2s infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
} }
.hint { .hint {
@ -357,6 +346,35 @@ export default {
} }
} }
.attachment-info-meta {
display: flex;
align-items: center;
:deep(.user) {
display: flex !important;
align-items: center;
margin: 0 .5rem;
}
@media screen and (max-width: $mobile) {
flex-direction: column;
align-items: flex-start;
:deep(.user) {
margin: .5rem 0;
}
> span:not(:last-child):after,
> a:not(:last-child):after {
display: none;
}
.user .username {
display: none;
}
}
}
@keyframes bounce { @keyframes bounce {
from, from,
20%, 20%,
@ -382,4 +400,6 @@ export default {
transform: translate3d(0, -4px, 0); transform: translate3d(0, -4px, 0);
} }
} }
@include modal-transition();
</style> </style>

View File

@ -162,7 +162,7 @@ import {mapState} from 'vuex'
export default { export default {
name: 'comments', name: 'comments',
components: { components: {
editor: AsyncEditor, Editor: AsyncEditor,
}, },
props: { props: {
taskId: { taskId: {
@ -339,4 +339,6 @@ export default {
.media-content { .media-content {
width: calc(100% - 48px - 2rem); width: calc(100% - 48px - 2rem);
} }
@include modal-transition();
</style> </style>

View File

@ -0,0 +1,47 @@
<template>
<p class="created">
<time :datetime="formatISO(task.created)" v-tooltip="formatDate(task.created)">
<i18n-t keypath="task.detail.created">
<span>{{ formatDateSince(task.created) }}</span>
{{ task.createdBy.getDisplayName() }}
</i18n-t>
</time>
<template v-if="+new Date(task.created) !== +new Date(task.updated)">
<br/>
<!-- Computed properties to show the actual date every time it gets updated -->
<time :datetime="formatISO(task.updated)" v-tooltip="updatedFormatted">
<i18n-t keypath="task.detail.updated">
<span>{{ updatedSince }}</span>
</i18n-t>
</time>
</template>
<template v-if="task.done">
<br/>
<time :datetime="formatISO(task.doneAt)" v-tooltip="doneFormatted">
<i18n-t keypath="task.detail.doneAt">
<span>{{ doneSince }}</span>
</i18n-t>
</time>
</template>
</p>
</template>
<script lang="ts" setup>
import {computed, toRefs} from 'vue'
import TaskModel from '@/models/task'
import {formatDateLong, formatDateSince} from '@/helpers/time/formatDate'
const props = defineProps({
task: {
type: TaskModel,
required: true,
},
})
const {task} = toRefs(props)
const updatedSince = computed(() => formatDateSince(task.value.updated))
const updatedFormatted = computed(() => formatDateLong(task.value.updated))
const doneSince = computed(() => formatDateSince(task.value.doneAt))
const doneFormatted = computed(() => formatDateLong(task.value.doneAt))
</script>

View File

@ -1,6 +1,8 @@
<template> <template>
<td v-tooltip="+date === 0 ? '' : formatDate(date)"> <td v-tooltip="+date === 0 ? '' : formatDate(date)">
{{ +date === 0 ? '-' : formatDateSince(date) }} <time :datetime="date ? formatISO(date) : null">
{{ +date === 0 ? '-' : formatDateSince(date) }}
</time>
</td> </td>
</template> </template>

View File

@ -38,7 +38,7 @@ import {mapState} from 'vuex'
export default { export default {
name: 'description', name: 'description',
components: { components: {
editor: AsyncEditor, Editor: AsyncEditor,
}, },
data() { data() {
return { return {

View File

@ -19,19 +19,19 @@
:style="{'background': props.item.hexColor, 'color': props.item.textColor}" :style="{'background': props.item.hexColor, 'color': props.item.textColor}"
class="tag"> class="tag">
<span>{{ props.item.title }}</span> <span>{{ props.item.title }}</span>
<a @click="removeLabel(props.item)" class="delete is-small"></a> <button type="button" v-cy="'taskDetail.removeLabel'" @click="removeLabel(props.item)" class="delete is-small" />
</span> </span>
</template> </template>
<template #searchResult="props"> <template #searchResult="props">
<span <span
v-if="typeof props.option === 'string'" v-if="typeof props.option === 'string'"
class="tag"> class="tag search-result">
<span>{{ props.option }}</span> <span>{{ props.option }}</span>
</span> </span>
<span <span
v-else v-else
:style="{'background': props.option.hexColor, 'color': props.option.textColor}" :style="{'background': props.option.hexColor, 'color': props.option.textColor}"
class="tag"> class="tag search-result">
<span>{{ props.option.title }}</span> <span>{{ props.option.title }}</span>
</span> </span>
</template> </template>
@ -114,23 +114,17 @@ export default {
}, },
async removeLabel(label) { async removeLabel(label) {
const removeFromState = () => { if (this.taskId !== 0) {
for (const l in this.labels) { await this.$store.dispatch('tasks/removeLabel', {label: label, taskId: this.taskId})
if (this.labels[l].id === label.id) { }
this.labels.splice(l, 1)
} for (const l in this.labels) {
if (this.labels[l].id === label.id) {
this.labels.splice(l, 1)
} }
this.$emit('update:modelValue', this.labels)
this.$emit('change', this.labels)
} }
this.$emit('update:modelValue', this.labels)
if (this.taskId === 0) { this.$emit('change', this.labels)
removeFromState()
return
}
await this.$store.dispatch('tasks/removeLabel', {label: label, taskId: this.taskId})
removeFromState()
this.$message.success({message: this.$t('task.label.removeSuccess')}) this.$message.success({message: this.$t('task.label.removeSuccess')})
}, },
@ -152,6 +146,18 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
.tag { .tag {
margin: .5rem 0 0 .5rem; margin: .25rem !important;
}
.tag.search-result {
margin: 0 !important;
}
:deep(.input-wrapper) {
padding: .25rem !important;
}
:deep(input.input) {
padding: 0 .5rem;
} }
</style> </style>

View File

@ -7,8 +7,8 @@
'has-light-text': !colorIsDark(task.hexColor) && task.hexColor !== `#${task.defaultColor}` && task.hexColor !== task.defaultColor, 'has-light-text': !colorIsDark(task.hexColor) && task.hexColor !== `#${task.defaultColor}` && task.hexColor !== task.defaultColor,
}" }"
:style="{'background-color': task.hexColor !== '#' && task.hexColor !== `#${task.defaultColor}` ? task.hexColor : false}" :style="{'background-color': task.hexColor !== '#' && task.hexColor !== `#${task.defaultColor}` ? task.hexColor : false}"
@click.exact="openTaskDetail()"
@click.ctrl="() => toggleTaskDone(task)" @click.ctrl="() => toggleTaskDone(task)"
@click.exact="() => $router.push({ name: 'task.kanban.detail', params: { id: task.id } })"
@click.meta="() => toggleTaskDone(task)" @click.meta="() => toggleTaskDone(task)"
> >
<span class="task-id"> <span class="task-id">
@ -28,9 +28,9 @@
<span class="icon"> <span class="icon">
<icon :icon="['far', 'calendar-alt']"/> <icon :icon="['far', 'calendar-alt']"/>
</span> </span>
<span> <time :datetime="formatISO(task.dueDate)">
{{ formatDateSince(task.dueDate) }} {{ formatDateSince(task.dueDate) }}
</span> </time>
</span> </span>
<h3>{{ task.title }}</h3> <h3>{{ task.title }}</h3>
<progress <progress
@ -104,17 +104,25 @@ export default {
async toggleTaskDone(task) { async toggleTaskDone(task) {
this.loadingInternal = true this.loadingInternal = true
try { try {
const done = !task.done
await this.$store.dispatch('tasks/update', { await this.$store.dispatch('tasks/update', {
...task, ...task,
done: !task.done, done,
}) })
if (task.done) { if (done) {
playPop() playPop()
} }
} finally { } finally {
this.loadingInternal = false this.loadingInternal = false
} }
}, },
openTaskDetail() {
this.$router.push({
name: 'task.detail',
params: { id: this.task.id },
state: { backdropView: this.$router.currentRoute.value.fullPath },
})
},
}, },
} }
</script> </script>
@ -131,7 +139,6 @@ $task-background: var(--white);
border: 3px solid transparent; border: 3px solid transparent;
font-size: .9rem; font-size: .9rem;
margin: .5rem;
padding: .4rem; padding: .4rem;
border-radius: $radius; border-radius: $radius;
background: $task-background; background: $task-background;

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="task-relations"> <div class="task-relations">
<x-button <x-button
v-if="Object.keys(relatedTasks).length > 0" v-if="editEnabled && Object.keys(relatedTasks).length > 0"
@click="showNewRelationForm = !showNewRelationForm" @click="showNewRelationForm = !showNewRelationForm"
class="is-pulled-right add-task-relation-button" class="is-pulled-right add-task-relation-button"
:class="{'is-active': showNewRelationForm}" :class="{'is-active': showNewRelationForm}"
@ -83,7 +83,8 @@
<span class="title">{{ rts.title }}</span> <span class="title">{{ rts.title }}</span>
<div class="tasks"> <div class="tasks">
<div :key="t.id" class="task" v-for="t in rts.tasks"> <div :key="t.id" class="task" v-for="t in rts.tasks">
<router-link :to="{ name: $route.name, params: { id: t.id } }" :class="{ 'done': t.done}"> <router-link :to="{ name: $route.name, params: { id: t.id } }"
:class="{ 'is-strikethrough': t.done}">
<span <span
class="different-list" class="different-list"
v-if="t.listId !== listId" v-if="t.listId !== listId"
@ -156,6 +157,7 @@ export default {
relationToDelete: {}, relationToDelete: {},
saved: false, saved: false,
showNewRelationForm: false, showNewRelationForm: false,
query: '',
} }
}, },
components: { components: {
@ -211,10 +213,20 @@ export default {
}, },
methods: { methods: {
async findTasks(query) { async findTasks(query) {
this.query = query
this.foundTasks = await this.taskService.getAll({}, {s: query}) this.foundTasks = await this.taskService.getAll({}, {s: query})
}, },
async addTaskRelation() { async addTaskRelation() {
if (this.newTaskRelationTask.id === 0 && this.query !== '') {
return this.createAndRelateTask(this.query)
}
if (this.newTaskRelationTask.id === 0) {
this.$message.error({message: this.$t('task.relation.taskRequired')})
return
}
const rel = new TaskRelationModel({ const rel = new TaskRelationModel({
taskId: this.taskId, taskId: this.taskId,
otherTaskId: this.newTaskRelationTask.id, otherTaskId: this.newTaskRelationTask.id,
@ -274,10 +286,11 @@ export default {
return tasks return tasks
.map(task => { .map(task => {
// by doing this here once we can save a lot of duplicate calls in the template // by doing this here once we can save a lot of duplicate calls in the template
const listAndNamespace = this.$store.getters['namespaces/getListAndNamespaceById'](task.listId, true)
const { const {
list, list,
namespace, namespace,
} = this.$store.getters['namespaces/getListAndNamespaceById'](task.listId, true) } = listAndNamespace === null ? {list: null, namespace: null} : listAndNamespace
return { return {
...task, ...task,
@ -364,4 +377,6 @@ export default {
:deep(.multiselect .search-results button) { :deep(.multiselect .search-results button) {
padding: 0.5rem; padding: 0.5rem;
} }
@include modal-transition();
</style> </style>

View File

@ -8,7 +8,7 @@
> >
</span> </span>
<router-link <router-link
:to="{ name: taskDetailRoute, params: { id: task.id } }" :to="taskDetailRoute"
:class="{ 'done': task.done}" :class="{ 'done': task.done}"
class="tasktext"> class="tasktext">
<span> <span>
@ -39,14 +39,17 @@
:user="a" :user="a"
v-for="(a, i) in task.assignees" v-for="(a, i) in task.assignees"
/> />
<i <time
:datetime="formatISO(task.dueDate)"
:class="{'overdue': task.dueDate <= new Date() && !task.done}" :class="{'overdue': task.dueDate <= new Date() && !task.done}"
class="is-italic"
@click.prevent.stop="showDefer = !showDefer" @click.prevent.stop="showDefer = !showDefer"
v-if="+new Date(task.dueDate) > 0" v-if="+new Date(task.dueDate) > 0"
v-tooltip="formatDate(task.dueDate)" v-tooltip="formatDate(task.dueDate)"
:aria-expanded="showDefer ? 'true' : 'false'"
> >
- {{ $t('task.detail.due', {at: formatDateSince(task.dueDate)}) }} - {{ $t('task.detail.due', {at: formatDateSince(task.dueDate)}) }}
</i> </time>
<transition name="fade"> <transition name="fade">
<defer-task v-if="+new Date(task.dueDate) > 0 && showDefer" v-model="task" ref="deferDueDate"/> <defer-task v-if="+new Date(task.dueDate) > 0 && showDefer" v-model="task" ref="deferDueDate"/>
</transition> </transition>
@ -126,10 +129,6 @@ export default {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
taskDetailRoute: {
type: String,
default: 'task.list.detail',
},
showList: { showList: {
type: Boolean, type: Boolean,
default: false, default: false,
@ -167,6 +166,14 @@ export default {
title: '', title: '',
} : this.$store.state.currentList } : this.$store.state.currentList
}, },
taskDetailRoute() {
return {
name: 'task.detail',
params: { id: this.task.id },
// TODO: re-enable opening task detail in modal
// state: { backdropView: this.$router.currentRoute.value.fullPath },
}
},
}, },
methods: { methods: {
async markAsDone(checked) { async markAsDone(checked) {
@ -183,10 +190,7 @@ export default {
this.$t('task.undoneSuccess'), this.$t('task.undoneSuccess'),
}, [{ }, [{
title: 'Undo', title: 'Undo',
callback() { callback: () => this.undoDone(checked),
this.task.done = !this.task.done
this.markAsDone(!checked)
},
}]) }])
} }
@ -196,6 +200,11 @@ export default {
await updateFunc() // Don't delay it when un-marking it as it doesn't have an animation the other way around await updateFunc() // Don't delay it when un-marking it as it doesn't have an animation the other way around
} }
}, },
undoDone(checked) {
this.task.done = !this.task.done
this.markAsDone(!checked)
},
async toggleFavorite() { async toggleFavorite() {
this.task.isFavorite = !this.task.isFavorite this.task.isFavorite = !this.task.isFavorite

View File

@ -1,20 +1,21 @@
<template> <template>
<a @click="$emit('click')"> <BaseButton>
<icon icon="sort-up" v-if="order === 'asc'"/> <icon icon="sort-up" v-if="order === 'asc'"/>
<icon icon="sort-up" rotation="180" v-else-if="order === 'desc'"/> <icon icon="sort-up" v-else-if="order === 'desc'" rotation="180"/>
<icon icon="sort" v-else/> <icon icon="sort" v-else/>
</a> </BaseButton>
</template> </template>
<script> <script setup lang="ts">
export default { import {PropType} from 'vue'
name: 'sort', import BaseButton from '@/components/base/BaseButton.vue'
props: {
order: { type Order = 'asc' | 'desc' | 'none'
type: String,
default: 'none', defineProps({
}, order: {
type: String as PropType<Order>,
default: 'none',
}, },
emits: ['click'], })
}
</script> </script>

111
src/composables/taskList.js Normal file
View File

@ -0,0 +1,111 @@
import { ref, shallowReactive, watch, computed } from 'vue'
import {useRoute} from 'vue-router'
import TaskCollectionService from '@/services/taskCollection'
// FIXME: merge with DEFAULT_PARAMS in filters.vue
export const getDefaultParams = () => ({
sort_by: ['position', 'id'],
order_by: ['asc', 'desc'],
filter_by: ['done'],
filter_value: ['false'],
filter_comparator: ['equals'],
filter_concat: 'and',
})
const SORT_BY_DEFAULT = {
id: 'desc',
}
/**
* This mixin provides a base set of methods and properties to get tasks on a list.
*/
export function useTaskList(listId) {
const params = ref({...getDefaultParams()})
const search = ref('')
const page = ref(1)
const sortBy = ref({ ...SORT_BY_DEFAULT })
// This makes sure an id sort order is always sorted last.
// When tasks would be sorted first by id and then by whatever else was specified, the id sort takes
// precedence over everything else, making any other sort columns pretty useless.
function formatSortOrder(params) {
let hasIdFilter = false
const sortKeys = Object.keys(sortBy.value)
for (const s of sortKeys) {
if (s === 'id') {
sortKeys.splice(s, 1)
hasIdFilter = true
break
}
}
if (hasIdFilter) {
sortKeys.push('id')
}
params.sort_by = sortKeys
params.order_by = sortKeys.map(s => sortBy.value[s])
return params
}
const getAllTasksParams = computed(() => {
let loadParams = {...params.value}
if (search.value !== '') {
loadParams.s = search.value
}
loadParams = formatSortOrder(loadParams)
return [
{listId: listId.value},
loadParams,
page.value || 1,
]
})
const taskCollectionService = shallowReactive(new TaskCollectionService())
const loading = computed(() => taskCollectionService.loading)
const totalPages = computed(() => taskCollectionService.totalPages)
const tasks = ref([])
async function loadTasks() {
tasks.value = await taskCollectionService.getAll(...getAllTasksParams.value)
return tasks.value
}
const route = useRoute()
watch(() => route.query, (query) => {
const { page: pageQueryValue, search: searchQuery } = query
if (searchQuery !== undefined) {
search.value = searchQuery
}
if (pageQueryValue !== undefined) {
page.value = parseInt(pageQueryValue)
}
}, { immediate: true })
// Only listen for query path changes
watch(() => JSON.stringify(getAllTasksParams.value), (newParams, oldParams) => {
if (oldParams === newParams) {
return
}
loadTasks()
}, { immediate: true })
return {
tasks,
loading,
totalPages,
currentPage: page,
loadTasks,
searchTerm: search,
params,
}
}

View File

@ -1,9 +1,9 @@
import {computed, watch, readonly} from 'vue' import {computed, watch, readonly} from 'vue'
import {useStorage, createSharedComposable, ColorSchema, usePreferredColorScheme, tryOnMounted} from '@vueuse/core' import {useStorage, createSharedComposable, BasicColorSchema, usePreferredColorScheme, tryOnMounted} from '@vueuse/core'
const STORAGE_KEY = 'color-scheme' const STORAGE_KEY = 'color-scheme'
const DEFAULT_COLOR_SCHEME_SETTING: ColorSchema = 'light' const DEFAULT_COLOR_SCHEME_SETTING: BasicColorSchema = 'light'
const CLASS_DARK = 'dark' const CLASS_DARK = 'dark'
const CLASS_LIGHT = 'light' const CLASS_LIGHT = 'light'
@ -16,7 +16,7 @@ const CLASS_LIGHT = 'light'
// - value is synced via `createSharedComposable` // - value is synced via `createSharedComposable`
// https://github.com/vueuse/vueuse/blob/main/packages/core/useDark/index.ts // https://github.com/vueuse/vueuse/blob/main/packages/core/useDark/index.ts
export const useColorScheme = createSharedComposable(() => { export const useColorScheme = createSharedComposable(() => {
const store = useStorage<ColorSchema>(STORAGE_KEY, DEFAULT_COLOR_SCHEME_SETTING) const store = useStorage<BasicColorSchema>(STORAGE_KEY, DEFAULT_COLOR_SCHEME_SETTING)
const preferredColorScheme = usePreferredColorScheme() const preferredColorScheme = usePreferredColorScheme()

View File

@ -1,9 +1,9 @@
import { computed, watchEffect } from 'vue' import { computed, watchEffect } from 'vue'
import { setTitle } from '@/helpers/setTitle' import { setTitle } from '@/helpers/setTitle'
import { ComputedGetter, ComputedRef } from '@vue/reactivity' import { ComputedGetter } from '@vue/reactivity'
export function useTitle<T>(titleGetter: ComputedGetter<T>) : ComputedRef<T> { export function useTitle(titleGetter: ComputedGetter<string>) {
const titleRef = computed(titleGetter) const titleRef = computed(titleGetter)
watchEffect(() => setTitle(titleRef.value)) watchEffect(() => setTitle(titleRef.value))

View File

@ -1,4 +1,4 @@
import {HTTPFactory} from '@/http-common' import {AuthenticatedHTTPFactory} from '@/http-common'
import {AxiosResponse} from 'axios' import {AxiosResponse} from 'axios'
let savedToken: string | null = null let savedToken: string | null = null
@ -6,8 +6,6 @@ let savedToken: string | null = null
/** /**
* Saves a token while optionally saving it to lacal storage. This is used when viewing a link share: * 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. * 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) => { export const saveToken = (token: string, persist: boolean) => {
savedToken = token savedToken = token
@ -18,7 +16,6 @@ export const saveToken = (token: string, persist: boolean) => {
/** /**
* Returns a saved token. If there is one saved in memory it will use that before anything else. * 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 => { export const getToken = (): string | null => {
if (savedToken !== null) { if (savedToken !== null) {
@ -39,20 +36,16 @@ export const removeToken = () => {
/** /**
* Refreshes an auth token while ensuring it is updated everywhere. * Refreshes an auth token while ensuring it is updated everywhere.
* @returns {Promise<AxiosResponse<any>>}
*/ */
export async function refreshToken(persist: boolean): Promise<AxiosResponse> { export async function refreshToken(persist: boolean): Promise<AxiosResponse> {
const HTTP = HTTPFactory() const HTTP = AuthenticatedHTTPFactory()
try { try {
const response = await HTTP.post('user/token', null, { const response = await HTTP.post('user/token')
headers: {
Authorization: `Bearer ${getToken()}`,
},
})
saveToken(response.data.token, persist) saveToken(response.data.token, persist)
return response return response
} catch(e) { } catch(e) {
// @ts-ignore
throw new Error('Error renewing token: ', { cause: e }) throw new Error('Error renewing token: ', { cause: e })
} }
} }

6
src/helpers/isEmail.ts Normal file
View File

@ -0,0 +1,6 @@
export function isEmail(email: string): Boolean {
const format = /^.+@.+$/
const match = email.match(format)
return match === null ? false : match.length > 0
}

View File

@ -3,11 +3,15 @@ import popSoundFile from '@/assets/audio/pop.mp3'
export const playSoundWhenDoneKey = 'playSoundWhenTaskDone' export const playSoundWhenDoneKey = 'playSoundWhenTaskDone'
export function playPop() { export function playPop() {
const enabled = Boolean(localStorage.getItem(playSoundWhenDoneKey)) const enabled = localStorage.getItem(playSoundWhenDoneKey) === 'true'
if(!enabled) { if (!enabled) {
return return
} }
playPopSound()
}
export function playPopSound() {
const popSound = new Audio(popSoundFile) const popSound = new Audio(popSoundFile)
popSound.play() popSound.play()
} }

View File

@ -1,3 +1,5 @@
// Save the current list view to local storage
// We use local storage and not vuex here to make it persistent across reloads.
export const saveListView = (listId, routeName) => { export const saveListView = (listId, routeName) => {
if (routeName.includes('settings.')) { if (routeName.includes('settings.')) {
return return

View File

@ -1,4 +1,4 @@
export function setTitle(title) { export function setTitle(title : undefined | string) {
document.title = (typeof title === 'undefined' || title === '') document.title = (typeof title === 'undefined' || title === '')
? 'Vikunja' ? 'Vikunja'
: `${title} | Vikunja` : `${title} | Vikunja`

View File

@ -1,5 +1,5 @@
import {createDateFromString} from '@/helpers/time/createDateFromString' import {createDateFromString} from '@/helpers/time/createDateFromString'
import {format, formatDistanceToNow} from 'date-fns' import {format, formatDistanceToNow, formatISO as formatISOfns} from 'date-fns'
import {enGB, de, fr, ru} from 'date-fns/locale' import {enGB, de, fr, ru} from 'date-fns/locale'
import {i18n} from '@/i18n' import {i18n} from '@/i18n'
@ -44,3 +44,7 @@ export const formatDateSince = (date) => {
addSuffix: true, addSuffix: true,
}) })
} }
export function formatISO(date) {
return date ? formatISOfns(date) : ''
}

View File

@ -0,0 +1,3 @@
export function getNextWeekDate(): Date {
return new Date((new Date()).getTime() + 7 * 24 * 60 * 60 * 1000)
}

View File

@ -288,7 +288,7 @@ const getDateFromWeekday = (text: string): dateFoundResult => {
} }
const getDayFromText = (text: string) => { const getDayFromText = (text: string) => {
const matcher = /(([1-2][0-9])|(3[01])|(0?[1-9]))(st|nd|rd|th|\.)/ig const matcher = /($| )(([1-2][0-9])|(3[01])|(0?[1-9]))(st|nd|rd|th|\.)($| )/ig
const results = matcher.exec(text) const results = matcher.exec(text)
if (results === null) { if (results === null) {
return { return {
@ -302,17 +302,17 @@ const getDayFromText = (text: string) => {
const day = parseInt(results[0]) const day = parseInt(results[0])
date.setDate(day) date.setDate(day)
// If the parsed day is the 31st but the next month only has 30 days, setting the day to 31 will "overflow" the // If the parsed day is the 31st (or 29+ and the next month is february) but the next month only has 30 days,
// date to the next month, but the first. // setting the day to 31 will "overflow" the date to the next month, but the first.
// This would look like a very weired bug. Now, to prevent that, we check if the day is the same as parsed after // This would look like a very weired bug. Now, to prevent that, we check if the day is the same as parsed after
// setting it for the first time and set it again if it isn't - that would mean the month overflowed. // setting it for the first time and set it again if it isn't - that would mean the month overflowed.
if (day === 31 && date.getDate() !== day) { while (date < now) {
date.setDate(day)
}
if (date < now) {
date.setMonth(date.getMonth() + 1) date.setMonth(date.getMonth() + 1)
} }
if (date.getDate() !== day) {
date.setDate(day)
}
return { return {
foundText: results[0], foundText: results[0],

View File

@ -0,0 +1,12 @@
export function parseDateOrString(rawValue: string | undefined, fallback: any): string | Date {
if (typeof rawValue === 'undefined') {
return fallback
}
const d = new Date(rawValue)
// @ts-ignore if rawValue is an invalid date, isNan will return false.
return !isNaN(d)
? d
: rawValue
}

View File

@ -1,7 +1,18 @@
import axios from 'axios' import axios from 'axios'
import {getToken} from '@/helpers/auth'
export const HTTPFactory = () => { export function HTTPFactory() {
return axios.create({ return axios.create({
baseURL: window.API_URL, baseURL: window.API_URL,
}) })
} }
export function AuthenticatedHTTPFactory(token = getToken()) {
return axios.create({
baseURL: window.API_URL,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
})
}

View File

@ -1,4 +1,4 @@
import { createI18n } from 'vue-i18n' import {createI18n} from 'vue-i18n'
import langEN from './lang/en.json' import langEN from './lang/en.json'
export const i18n = createI18n({ export const i18n = createI18n({
@ -19,6 +19,9 @@ export const availableLanguages = {
'vi-VN': 'Tiếng Việt', 'vi-VN': 'Tiếng Việt',
'it-IT': 'Italiano', 'it-IT': 'Italiano',
'cs-CZ': 'Čeština', 'cs-CZ': 'Čeština',
'pl-PL': 'Polski',
'nl-NL': 'Nederlands',
'pt-PT': 'Português',
} }
const loadedLanguages = ['en'] // our default language that is preloaded const loadedLanguages = ['en'] // our default language that is preloaded
@ -30,6 +33,10 @@ const setI18nLanguage = lang => {
} }
export const loadLanguageAsync = lang => { export const loadLanguageAsync = lang => {
if (!lang) {
return
}
if ( if (
// If the same language // If the same language
i18n.global.locale === lang || i18n.global.locale === lang ||

View File

@ -31,10 +31,9 @@
"username": "Uživatelské jméno", "username": "Uživatelské jméno",
"usernameEmail": "Uživatelské jméno nebo e-mail", "usernameEmail": "Uživatelské jméno nebo e-mail",
"usernamePlaceholder": "např. Jarmil", "usernamePlaceholder": "např. Jarmil",
"email": "E-mailová adresa", "email": "Email address",
"emailPlaceholder": "např. jarmil{'@'}vikunja.io", "emailPlaceholder": "např. jarmil{'@'}vikunja.io",
"password": "Heslo", "password": "Heslo",
"passwordRepeat": "Zopakovat heslo",
"passwordPlaceholder": "např. • • • • • • • •", "passwordPlaceholder": "např. • • • • • • • •",
"forgotPassword": "Zapomenuté heslo?", "forgotPassword": "Zapomenuté heslo?",
"resetPassword": "Obnovit heslo", "resetPassword": "Obnovit heslo",
@ -45,12 +44,20 @@
"totpTitle": "Kód dvoufaktorového ověření", "totpTitle": "Kód dvoufaktorového ověření",
"totpPlaceholder": "např. 123456", "totpPlaceholder": "např. 123456",
"login": "Přihlásit se", "login": "Přihlásit se",
"register": "Registrovat", "createAccount": "Create account",
"loginWith": "Přihlásit se pomocí {provider}", "loginWith": "Přihlásit se pomocí {provider}",
"authenticating": "Ověřování…", "authenticating": "Ověřování…",
"openIdStateError": "Stav neodpovídá, odmítám pokračovat!", "openIdStateError": "Stav neodpovídá, odmítám pokračovat!",
"openIdGeneralError": "Došlo k chybě při ověřování proti třetí straně.", "openIdGeneralError": "Došlo k chybě při ověřování proti třetí straně.",
"logout": "Odhlásit se" "logout": "Odhlásit se",
"emailInvalid": "Please enter a valid email address.",
"usernameRequired": "Please provide a username.",
"passwordRequired": "Please provide a password.",
"showPassword": "Show the password",
"hidePassword": "Hide the password",
"noAccountYet": "Don't have an account yet?",
"alreadyHaveAnAccount": "Already have an account?",
"remember": "Stay logged in"
}, },
"settings": { "settings": {
"title": "Nastavení", "title": "Nastavení",
@ -61,7 +68,7 @@
"currentPasswordPlaceholder": "Vaše současné heslo", "currentPasswordPlaceholder": "Vaše současné heslo",
"passwordsDontMatch": "Nové heslo se neshoduje s potvrzením hesla.", "passwordsDontMatch": "Nové heslo se neshoduje s potvrzením hesla.",
"passwordUpdateSuccess": "Heslo bylo úspěšně změněno.", "passwordUpdateSuccess": "Heslo bylo úspěšně změněno.",
"updateEmailTitle": "Aktualizovat Vaši e-mailovou adresu", "updateEmailTitle": "Update Your Email Address",
"updateEmailNew": "Nová e-mailová adresa", "updateEmailNew": "Nová e-mailová adresa",
"updateEmailSuccess": "Vaše e-mailová adresa byla úspěšně aktualizována. Poslali jsme vám odkaz pro její potvrzení.", "updateEmailSuccess": "Vaše e-mailová adresa byla úspěšně aktualizována. Poslali jsme vám odkaz pro její potvrzení.",
"general": { "general": {
@ -78,7 +85,8 @@
"weekStartSunday": "Neděle", "weekStartSunday": "Neděle",
"weekStartMonday": "Pondělí", "weekStartMonday": "Pondělí",
"language": "Jazyk", "language": "Jazyk",
"defaultList": "Výchozí seznam" "defaultList": "Výchozí seznam",
"timezone": "Time Zone"
}, },
"totp": { "totp": {
"title": "Dvoufaktorové ověření", "title": "Dvoufaktorové ověření",
@ -327,6 +335,7 @@
"archiveText": "Nebudete moci upravovat tento jmenný prostor ani vytvářet nové seznamy, dokud jej neodarchivujete. Všechny seznamy v tomto prostoru budou také archivovány.", "archiveText": "Nebudete moci upravovat tento jmenný prostor ani vytvářet nové seznamy, dokud jej neodarchivujete. Všechny seznamy v tomto prostoru budou také archivovány.",
"unarchiveText": "Budete moci vytvářet nové úkoly nebo je upravovat.", "unarchiveText": "Budete moci vytvářet nové úkoly nebo je upravovat.",
"success": "Prostor byl úspěšně archivován.", "success": "Prostor byl úspěšně archivován.",
"unarchiveSuccess": "The namespace was successfully un-archived.",
"description": "Pokud je prostor archivován, nelze vytvořit nové seznamy nebo je upravit." "description": "Pokud je prostor archivován, nelze vytvořit nové seznamy nebo je upravit."
}, },
"delete": { "delete": {
@ -376,7 +385,7 @@
"showDoneTasks": "Zobrazit dokončené úkoly", "showDoneTasks": "Zobrazit dokončené úkoly",
"sortAlphabetically": "Řadit podle abecedy", "sortAlphabetically": "Řadit podle abecedy",
"enablePriority": "Povolit filtrování podle priority", "enablePriority": "Povolit filtrování podle priority",
"enablePercentDone": "Povolit filtrování dle dokončenosti", "enablePercentDone": "Enable Filter By Progress",
"dueDateRange": "Rozsah termínu", "dueDateRange": "Rozsah termínu",
"startDateRange": "Začátek období", "startDateRange": "Začátek období",
"endDateRange": "Konec období", "endDateRange": "Konec období",
@ -476,7 +485,8 @@
"showMenu": "Zobrazit nabídku", "showMenu": "Zobrazit nabídku",
"hideMenu": "Skrýt nabídku", "hideMenu": "Skrýt nabídku",
"forExample": "Například:", "forExample": "Například:",
"welcomeBack": "Vítejte zpět!" "welcomeBack": "Vítejte zpět!",
"custom": "Custom"
}, },
"input": { "input": {
"resetColor": "Obnovit barvu", "resetColor": "Obnovit barvu",
@ -515,6 +525,57 @@
"multiselect": { "multiselect": {
"createPlaceholder": "Vytvořit nový", "createPlaceholder": "Vytvořit nový",
"selectPlaceholder": "Kliknutím nebo stisknutím klávesy Enter vyberte" "selectPlaceholder": "Kliknutím nebo stisknutím klávesy Enter vyberte"
},
"datepickerRange": {
"to": "To",
"from": "From",
"fromto": "{from} to {to}",
"ranges": {
"today": "Today",
"thisWeek": "This Week",
"restOfThisWeek": "The Rest of This Week",
"nextWeek": "Next Week",
"next7Days": "Next 7 Days",
"lastWeek": "Last Week",
"thisMonth": "This Month",
"restOfThisMonth": "The Rest of This Month",
"nextMonth": "Next Month",
"next30Days": "Next 30 Days",
"lastMonth": "Last Month",
"thisYear": "This Year",
"restOfThisYear": "The Rest of This Year"
}
},
"datemathHelp": {
"canuse": "You can use date math to filter for relative dates.",
"learnhow": "Check out how it works",
"title": "Date Math",
"intro": "Date Math allows you to specifiy relative dates which are resolved on the fly by Vikunja when applying the filter.",
"expression": "Each Date Math expression starts with an anchor date, which can either be {0}, or a date string ending with {1}. This anchor date can optionally be followed by one or more maths expressions.",
"similar": "These expressions are similar to the ones provided by {0} and {1}.",
"add1Day": "Add one day",
"minus1Day": "Subtract one day",
"roundDay": "Round down to the nearest day",
"supportedUnits": "Supported time units are:",
"someExamples": "Some examples of time expressions:",
"units": {
"seconds": "Seconds",
"minutes": "Minutes",
"hours": "Hours",
"days": "Days",
"weeks": "Weeks",
"months": "Months",
"years": "Years"
},
"examples": {
"now": "Right now",
"in24h": "In 24h",
"today": "Today at 00:00",
"beginningOfThisWeek": "The beginning of this week at 00:00",
"endOfThisWeek": "The end of this week",
"in30Days": "In 30 days",
"datePlusMonth": "{0} plus one month at 00:00 of that day"
}
} }
}, },
"task": { "task": {
@ -532,12 +593,9 @@
"titleCurrent": "Aktuální úkoly", "titleCurrent": "Aktuální úkoly",
"titleDates": "Úkoly od {from} do {to}", "titleDates": "Úkoly od {from} do {to}",
"noDates": "Zobrazit úkoly bez datumu", "noDates": "Zobrazit úkoly bez datumu",
"current": "Aktuální úkoly", "overdue": "Show overdue tasks",
"from": "Úkoly od", "fromuntil": "Tasks from {from} until {until}",
"until": "do", "select": "Select a date range",
"today": "Dnes",
"nextWeek": "Příští týden",
"nextMonth": "Příští měsíc",
"noTasks": "Nic na práci - užij si pěkný den!" "noTasks": "Nic na práci - užij si pěkný den!"
}, },
"detail": { "detail": {
@ -561,22 +619,22 @@
"text2": "Tímto také odstraníte všechny přílohy, připomenutí a vztahy spojené s tímto úkolem a nelze je vrátit zpět!" "text2": "Tímto také odstraníte všechny přílohy, připomenutí a vztahy spojené s tímto úkolem a nelze je vrátit zpět!"
}, },
"actions": { "actions": {
"assign": "Přiřadit uživateli", "assign": "Assign to User",
"label": "Přidat štítky", "label": "Add Labels",
"priority": "Nastavit prioritu", "priority": "Nastavit prioritu",
"dueDate": "Nastavit termín", "dueDate": "Nastavit termín",
"startDate": "Nastavit počáteční datum", "startDate": "Set Start Date",
"endDate": "Nastavit koncové datum", "endDate": "Set End Date",
"reminders": "Nastavit připomenutí", "reminders": "Nastavit připomenutí",
"repeatAfter": "Nastavit interval opakování", "repeatAfter": "Set Repeating Interval",
"percentDone": "Nastavit procenta dokončeno", "percentDone": "Set Progress",
"attachments": "Přidat přílohy", "attachments": "Add Attachments",
"relatedTasks": "Přidat vztahy úkolu", "relatedTasks": "Add Relation",
"moveList": "Přesunout úkol", "moveList": "Move",
"color": "Nastavit barvu úkolu", "color": "Set Color",
"delete": "Smazat úkol", "delete": "Delete",
"favorite": "Uložit jako oblíbené", "favorite": "Add to Favorites",
"unfavorite": "Odebrat z oblíbených" "unfavorite": "Remove from Favorites"
} }
}, },
"attributes": { "attributes": {
@ -589,7 +647,7 @@
"dueDate": "Termín", "dueDate": "Termín",
"endDate": "Datum ukončení", "endDate": "Datum ukončení",
"labels": "Štítky", "labels": "Štítky",
"percentDone": "% Hotovo", "percentDone": "Progress",
"priority": "Priorita", "priority": "Priorita",
"relatedTasks": "Související úkoly", "relatedTasks": "Související úkoly",
"reminders": "Připomínky", "reminders": "Připomínky",
@ -776,17 +834,20 @@
"general": "Obecné", "general": "Obecné",
"allPages": "Tyto zkratky fungují na všech stránkách.", "allPages": "Tyto zkratky fungují na všech stránkách.",
"currentPageOnly": "Tyto zkratky fungují pouze na aktuální stránce.", "currentPageOnly": "Tyto zkratky fungují pouze na aktuální stránce.",
"somePagesOnly": "These shortcuts work only on some pages.",
"toggleMenu": "Přepnout nabídku", "toggleMenu": "Přepnout nabídku",
"quickSearch": "Otevřít vyhledávání / panel rychlých akcí", "quickSearch": "Otevřít vyhledávání / panel rychlých akcí",
"then": "potom", "then": "potom",
"task": { "task": {
"title": "Stránka úkolů", "title": "Stránka úkolů",
"done": "Hotovo", "done": "Mark task done / undone",
"assign": "Přiřadit uživateli", "assign": "Assign this task to a user",
"labels": "Přidat štítky k tomuto úkolu", "labels": "Přidat štítky k tomuto úkolu",
"dueDate": "Změnit termín tohoto úkolu", "dueDate": "Změnit termín tohoto úkolu",
"attachment": "Přidat přílohu k tomuto úkolu", "attachment": "Přidat přílohu k tomuto úkolu",
"related": "Upravit související úkoly tohoto úkolu" "related": "Upravit související úkoly tohoto úkolu",
"color": "Change the color of this task",
"move": "Move this task to another list"
}, },
"list": { "list": {
"title": "Zobrazení seznamů", "title": "Zobrazení seznamů",
@ -794,6 +855,14 @@
"switchToGanttView": "Přepnout na zobrazení gantt", "switchToGanttView": "Přepnout na zobrazení gantt",
"switchToKanbanView": "Přepnout na zobrazení kanbanu", "switchToKanbanView": "Přepnout na zobrazení kanbanu",
"switchToTableView": "Přepnout na zobrazení tabulky" "switchToTableView": "Přepnout na zobrazení tabulky"
},
"navigation": {
"title": "Navigation",
"overview": "Navigato to overview",
"upcoming": "Navigato to upcoming taks",
"namespaces": "Navigate to namepaces & lists",
"labels": "Navigate to labels",
"teams": "Navigate to teams"
} }
}, },
"update": { "update": {

View File

@ -7,7 +7,7 @@
"lastViewed": "Zuletzt angesehen", "lastViewed": "Zuletzt angesehen",
"list": { "list": {
"newText": "Du kannst eine neue Liste für deine neuen Aufgaben erstellen:", "newText": "Du kannst eine neue Liste für deine neuen Aufgaben erstellen:",
"new": "New list", "new": "Neue Liste",
"importText": "Oder importiere deine Listen und Aufgaben aus anderen Diensten in Vikunja:", "importText": "Oder importiere deine Listen und Aufgaben aus anderen Diensten in Vikunja:",
"import": "Deine Daten in Vikunja importieren" "import": "Deine Daten in Vikunja importieren"
} }
@ -34,7 +34,6 @@
"email": "E-Mail-Adresse", "email": "E-Mail-Adresse",
"emailPlaceholder": "z.B. frederic{'@'}vikunja.io", "emailPlaceholder": "z.B. frederic{'@'}vikunja.io",
"password": "Passwort", "password": "Passwort",
"passwordRepeat": "Gib dein Passwort erneut ein",
"passwordPlaceholder": "z.B. •••••••••••", "passwordPlaceholder": "z.B. •••••••••••",
"forgotPassword": "Passwort vergessen?", "forgotPassword": "Passwort vergessen?",
"resetPassword": "Setze dein Passwort zurück", "resetPassword": "Setze dein Passwort zurück",
@ -45,12 +44,20 @@
"totpTitle": "Zwei-Faktor-Authentifizierungscode", "totpTitle": "Zwei-Faktor-Authentifizierungscode",
"totpPlaceholder": "z.B. 123456", "totpPlaceholder": "z.B. 123456",
"login": "Anmelden", "login": "Anmelden",
"register": "Registrieren", "createAccount": "Account erstellen",
"loginWith": "Mit {provider} anmelden", "loginWith": "Mit {provider} anmelden",
"authenticating": "Authentifizierung…", "authenticating": "Authentifizierung…",
"openIdStateError": "Zustand stimmt nicht überein, fahre nicht fort!", "openIdStateError": "Zustand stimmt nicht überein, fahre nicht fort!",
"openIdGeneralError": "Es ist ein Fehler bei der externen Authentisierung aufgetreten.", "openIdGeneralError": "Es ist ein Fehler bei der externen Authentisierung aufgetreten.",
"logout": "Abmelden" "logout": "Abmelden",
"emailInvalid": "Bitte gib eine gültige E-Mail-Adresse ein.",
"usernameRequired": "Bitte gib einen Anmeldenamen ein.",
"passwordRequired": "Bitte gib ein Passwort ein.",
"showPassword": "Passwort anzeigen",
"hidePassword": "Passwort verbergen",
"noAccountYet": "Noch kein Account?",
"alreadyHaveAnAccount": "Hast du bereits einen Account?",
"remember": "Angemeldet bleiben"
}, },
"settings": { "settings": {
"title": "Einstellungen", "title": "Einstellungen",
@ -78,7 +85,8 @@
"weekStartSunday": "Sonntag", "weekStartSunday": "Sonntag",
"weekStartMonday": "Montag", "weekStartMonday": "Montag",
"language": "Sprache", "language": "Sprache",
"defaultList": "Standard-Liste" "defaultList": "Standard-Liste",
"timezone": "Zeitzone"
}, },
"totp": { "totp": {
"title": "Zwei-Faktor-Authentifizierung", "title": "Zwei-Faktor-Authentifizierung",
@ -157,7 +165,7 @@
"searchSelect": "Klicke auf oder drücke die Eingabetaste, um diese Liste auszuwählen", "searchSelect": "Klicke auf oder drücke die Eingabetaste, um diese Liste auszuwählen",
"shared": "Geteilte Listen", "shared": "Geteilte Listen",
"create": { "create": {
"header": "New list", "header": "Neue Liste",
"titlePlaceholder": "Der Titel der Liste steht hier…", "titlePlaceholder": "Der Titel der Liste steht hier…",
"addTitleRequired": "Bitte gebe einen Namen an.", "addTitleRequired": "Bitte gebe einen Namen an.",
"createdSuccess": "Die Liste wurde erfolgreich erstellt.", "createdSuccess": "Die Liste wurde erfolgreich erstellt.",
@ -315,7 +323,7 @@
"namespaces": "Namespaces", "namespaces": "Namespaces",
"search": "Beginne zu schreiben, um einen Namespace zu suchen…", "search": "Beginne zu schreiben, um einen Namespace zu suchen…",
"create": { "create": {
"title": "New namespace", "title": "Neuer Namespace",
"titleRequired": "Bitte gebe einen Titel an.", "titleRequired": "Bitte gebe einen Titel an.",
"explanation": "Ein Namespace ist eine Sammlung von Listen, die du teilen und zur Organisation verwenden kannst. Jede Liste zu einem Namespace.", "explanation": "Ein Namespace ist eine Sammlung von Listen, die du teilen und zur Organisation verwenden kannst. Jede Liste zu einem Namespace.",
"tooltip": "Was ist ein Namespace?", "tooltip": "Was ist ein Namespace?",
@ -327,6 +335,7 @@
"archiveText": "Du kannst diesen Namespace nicht mehr bearbeiten oder neue Listen erstellen, bis du die Archivierung rückgängig machst. Das gilt auch für alle Listen in diesem Namespace.", "archiveText": "Du kannst diesen Namespace nicht mehr bearbeiten oder neue Listen erstellen, bis du die Archivierung rückgängig machst. Das gilt auch für alle Listen in diesem Namespace.",
"unarchiveText": "Du kannst neue Aufgaben erstellen oder diese bearbeiten.", "unarchiveText": "Du kannst neue Aufgaben erstellen oder diese bearbeiten.",
"success": "Der Namespace wurde erfolgreich archiviert.", "success": "Der Namespace wurde erfolgreich archiviert.",
"unarchiveSuccess": "Der Namespace wurde erfolgreich wiederhergestellt.",
"description": "In einem archivierten Namespace können Listen weder angelegt noch editiert werden." "description": "In einem archivierten Namespace können Listen weder angelegt noch editiert werden."
}, },
"delete": { "delete": {
@ -376,14 +385,14 @@
"showDoneTasks": "Erledigte Aufgaben anzeigen", "showDoneTasks": "Erledigte Aufgaben anzeigen",
"sortAlphabetically": "Alphabetisch sortieren", "sortAlphabetically": "Alphabetisch sortieren",
"enablePriority": "Filter nach Priorität aktivieren", "enablePriority": "Filter nach Priorität aktivieren",
"enablePercentDone": "Filter nach % Erledigt aktivieren", "enablePercentDone": "Filter nach Fortschritt aktivieren",
"dueDateRange": "Fälligkeitsbereich", "dueDateRange": "Fälligkeitsbereich",
"startDateRange": "Startdatumsbereich", "startDateRange": "Startdatumsbereich",
"endDateRange": "Enddatumsbereich", "endDateRange": "Enddatumsbereich",
"reminderRange": "Erinnerungs-Datumsbereich" "reminderRange": "Erinnerungs-Datumsbereich"
}, },
"create": { "create": {
"title": "New Saved Filter", "title": "Neuer gespeicherter Filter",
"description": "Ein gespeicherter Filter ist eine virtuelle Liste, die bei jedem Zugriff aus einem Satz von Filtern errechnet wird. Einmal erstellt, erscheint diese in einem speziellen Namespace.", "description": "Ein gespeicherter Filter ist eine virtuelle Liste, die bei jedem Zugriff aus einem Satz von Filtern errechnet wird. Einmal erstellt, erscheint diese in einem speziellen Namespace.",
"action": "Neuen gespeicherten Filter erstellen" "action": "Neuen gespeicherten Filter erstellen"
}, },
@ -476,7 +485,8 @@
"showMenu": "Menü anzeigen", "showMenu": "Menü anzeigen",
"hideMenu": "Menü ausblenden", "hideMenu": "Menü ausblenden",
"forExample": "Zum Beispiel:", "forExample": "Zum Beispiel:",
"welcomeBack": "Willkommen zurück!" "welcomeBack": "Willkommen zurück!",
"custom": "Benutzerdefiniert"
}, },
"input": { "input": {
"resetColor": "Farbe zurücksetzen", "resetColor": "Farbe zurücksetzen",
@ -515,6 +525,57 @@
"multiselect": { "multiselect": {
"createPlaceholder": "Neu erstellen", "createPlaceholder": "Neu erstellen",
"selectPlaceholder": "Klicken oder Enter drücken zum Auswählen" "selectPlaceholder": "Klicken oder Enter drücken zum Auswählen"
},
"datepickerRange": {
"to": "Bis",
"from": "Von",
"fromto": "{from} bis {to}",
"ranges": {
"today": "Heute",
"thisWeek": "Diese Woche",
"restOfThisWeek": "Der Rest dieser Woche",
"nextWeek": "Nächste Woche",
"next7Days": "Nächsten 7 Tage",
"lastWeek": "Letzte Woche",
"thisMonth": "Dieser Monat",
"restOfThisMonth": "Der Rest dieses Monats",
"nextMonth": "Nächster Monat",
"next30Days": "Nächsten 30 Tage",
"lastMonth": "Letzter Monat",
"thisYear": "Dieses Jahr",
"restOfThisYear": "Der Rest des Jahres"
}
},
"datemathHelp": {
"canuse": "Du kannst Datumsberechnung verwenden, um nach relativen Daten zu filtern.",
"learnhow": "Sieh dir an, wie es funktioniert",
"title": "Datumsberechnung",
"intro": "Die Datumsberechnung erlaubt es, relative Daten anzugeben, die bei der Anwendung des Filters von Vikunja aufgelöst werden.",
"expression": "Jeder Ausdruck der Datumsberechnung beginnt mit einem Datumswert, welcher entweder {0} sein kann oder mit {1} endet. Auf diesen Datumswert kann optional ein oder mehrere mathematische Ausdrücke folgen.",
"similar": "Diese Ausdrücke ähneln denen von {0} und {1}.",
"add1Day": "Einen Tag hinzufügen",
"minus1Day": "Einen Tag abziehen",
"roundDay": "Auf den nächsten Tag abrunden",
"supportedUnits": "Unterstützte Zeiteinheiten sind:",
"someExamples": "Einige Beispiele für Zeitausdrücke:",
"units": {
"seconds": "Sekunden",
"minutes": "Minuten",
"hours": "Stunden",
"days": "Tage",
"weeks": "Wochen",
"months": "Monate",
"years": "Jahre"
},
"examples": {
"now": "Jetzt",
"in24h": "In 24 Stunden",
"today": "Heute um 00:00 Uhr",
"beginningOfThisWeek": "Der Anfang dieser Woche um 00:00 Uhr",
"endOfThisWeek": "Das Ende dieser Woche",
"in30Days": "In 30 Tagen",
"datePlusMonth": "{0} plus einen Monat um 00:00 des Tages"
}
} }
}, },
"task": { "task": {
@ -532,12 +593,9 @@
"titleCurrent": "Aktuelle Aufgaben", "titleCurrent": "Aktuelle Aufgaben",
"titleDates": "Aufgaben von {from} bis {to}", "titleDates": "Aufgaben von {from} bis {to}",
"noDates": "Aufgaben ohne Datum anzeigen", "noDates": "Aufgaben ohne Datum anzeigen",
"current": "Aktuelle Aufgaben", "overdue": "Überfällige Aufgaben anzeigen",
"from": "Aufgaben von", "fromuntil": "Aufgaben von {from} bis {until}",
"until": "bis", "select": "Datumsbereich wählen",
"today": "Heute",
"nextWeek": "Nächste Woche",
"nextMonth": "Nächster Monat",
"noTasks": "Nichts zu tun Einen schönen Tag noch!" "noTasks": "Nichts zu tun Einen schönen Tag noch!"
}, },
"detail": { "detail": {
@ -545,7 +603,7 @@
"chooseStartDate": "Klicke hier, um ein Startdatum zu setzen", "chooseStartDate": "Klicke hier, um ein Startdatum zu setzen",
"chooseEndDate": "Klicke hier, um ein Enddatum zu setzen", "chooseEndDate": "Klicke hier, um ein Enddatum zu setzen",
"move": "Aufgabe in eine andere Liste verschieben", "move": "Aufgabe in eine andere Liste verschieben",
"done": "Mark task done!", "done": "Als erledigt markieren!",
"undone": "Als nicht erledigt markieren", "undone": "Als nicht erledigt markieren",
"created": "Erstellt {0} von {1}", "created": "Erstellt {0} von {1}",
"updated": "Aktualisiert {0}", "updated": "Aktualisiert {0}",
@ -569,13 +627,13 @@
"endDate": "Enddatum setzen", "endDate": "Enddatum setzen",
"reminders": "Erinnerungen setzen", "reminders": "Erinnerungen setzen",
"repeatAfter": "Wiederholung setzen", "repeatAfter": "Wiederholung setzen",
"percentDone": "Prozent erledigt setzen", "percentDone": "Fortschritt einstellen",
"attachments": "Anhänge hinzufügen", "attachments": "Anhänge hinzufügen",
"relatedTasks": "Aufgabenbeziehungen hinzufügen", "relatedTasks": "Beziehung hinzufügen",
"moveList": "Aufgabe verschieben", "moveList": "Verschieben",
"color": "Taskfarbe setzen", "color": "Farbe setzen",
"delete": "Aufgabe löschen", "delete": "Löschen",
"favorite": "In Favoriten speichern", "favorite": "Zu Favoriten hinzufügen",
"unfavorite": "Aus Favoriten entfernen" "unfavorite": "Aus Favoriten entfernen"
} }
}, },
@ -589,7 +647,7 @@
"dueDate": "Fälligkeitsdatum", "dueDate": "Fälligkeitsdatum",
"endDate": "Enddatum", "endDate": "Enddatum",
"labels": "Labels", "labels": "Labels",
"percentDone": "% erledigt", "percentDone": "Fortschritt",
"priority": "Priorität", "priority": "Priorität",
"relatedTasks": "Verwandte Aufgaben", "relatedTasks": "Verwandte Aufgaben",
"reminders": "Erinnerungen", "reminders": "Erinnerungen",
@ -776,17 +834,20 @@
"general": "Allgemein", "general": "Allgemein",
"allPages": "Diese Tastenkürzel funktionieren auf allen Seiten.", "allPages": "Diese Tastenkürzel funktionieren auf allen Seiten.",
"currentPageOnly": "Diese Tastenkürzel funktionieren nur auf der aktuellen Seite.", "currentPageOnly": "Diese Tastenkürzel funktionieren nur auf der aktuellen Seite.",
"somePagesOnly": "Funktioniert nur auf manchen Seiten.",
"toggleMenu": "Das Menü umschalten", "toggleMenu": "Das Menü umschalten",
"quickSearch": "Such-/Schnellaktionsleiste öffnen", "quickSearch": "Such-/Schnellaktionsleiste öffnen",
"then": "dann", "then": "dann",
"task": { "task": {
"title": "Aufgabenseite", "title": "Aufgabenseite",
"done": "Done", "done": "Aufgabe als erledigt / unerledigt markieren",
"assign": "Benutzer:in zuweisen", "assign": "Diese Aufgabe jemandem zuweisen",
"labels": "Dieser Aufgabe ein Label hinzufügen", "labels": "Dieser Aufgabe ein Label hinzufügen",
"dueDate": "Ändere das Fälligkeitsdatum dieser Aufgabe", "dueDate": "Ändere das Fälligkeitsdatum dieser Aufgabe",
"attachment": "Einen Anhang dieser Aufgabe hinzufügen", "attachment": "Einen Anhang dieser Aufgabe hinzufügen",
"related": "Ändere die Abhängigen Aufgaben dieser Aufgabe" "related": "Ändere die Abhängigen Aufgaben dieser Aufgabe",
"color": "Die Farbe dieser Aufgabe ändern",
"move": "Diese Aufgabe in eine andere Liste verschieben"
}, },
"list": { "list": {
"title": "Listenansicht", "title": "Listenansicht",
@ -794,6 +855,14 @@
"switchToGanttView": "Zur Ganttansicht wechseln", "switchToGanttView": "Zur Ganttansicht wechseln",
"switchToKanbanView": "Zur Kanbanansicht wechseln", "switchToKanbanView": "Zur Kanbanansicht wechseln",
"switchToTableView": "Zur Tabellenansicht wechseln" "switchToTableView": "Zur Tabellenansicht wechseln"
},
"navigation": {
"title": "Navigation",
"overview": "Die Startseite aufrufen",
"upcoming": "Anstehende Aufgaben aufrufen",
"namespaces": "Namespaces & Listen aufrufen",
"labels": "Labels aufrufen",
"teams": "Teams aufrufen"
} }
}, },
"update": { "update": {
@ -899,7 +968,7 @@
"4015": "Dieser Aufgabenkommentar existiert nicht.", "4015": "Dieser Aufgabenkommentar existiert nicht.",
"4016": "Ungültiges Aufgabenfeld.", "4016": "Ungültiges Aufgabenfeld.",
"4017": "Ungültiger Aufgabenfilter (Vergleichskriterium).", "4017": "Ungültiger Aufgabenfilter (Vergleichskriterium).",
"4018": "Invalid task filter concatenator.", "4018": "Ungültige Verkettung von Aufgabenfiltern.",
"4019": "Ungültiger Aufgabenfilter (Wert).", "4019": "Ungültiger Aufgabenfilter (Wert).",
"5001": "Dieser Namespace existiert nicht.", "5001": "Dieser Namespace existiert nicht.",
"5003": "Du hast keinen Zugriff auf den Namespace.", "5003": "Du hast keinen Zugriff auf den Namespace.",

View File

@ -7,7 +7,7 @@
"lastViewed": "Zletscht ahglueget", "lastViewed": "Zletscht ahglueget",
"list": { "list": {
"newText": "Du chasch e Liste für dini neue Uufgabe erstelle:", "newText": "Du chasch e Liste für dini neue Uufgabe erstelle:",
"new": "New list", "new": "Neue Liste",
"importText": "Oder importier dini Liste und Uufgabe us anderne Dienst nach Vikunja:", "importText": "Oder importier dini Liste und Uufgabe us anderne Dienst nach Vikunja:",
"import": "Dini Date in Vikunja importiere" "import": "Dini Date in Vikunja importiere"
} }
@ -31,10 +31,9 @@
"username": "Benutzernamä", "username": "Benutzernamä",
"usernameEmail": "Benutzernamä oder E-Mail Adrässe", "usernameEmail": "Benutzernamä oder E-Mail Adrässe",
"usernamePlaceholder": "z.B. Hansruedi", "usernamePlaceholder": "z.B. Hansruedi",
"email": "E-Mail Adrässe", "email": "E-Mail-Adresse",
"emailPlaceholder": "z.B. frederic{'@'}vikunja.io", "emailPlaceholder": "z.B. frederic{'@'}vikunja.io",
"password": "Passwort", "password": "Passwort",
"passwordRepeat": "Gib dis Passwort nomal iih",
"passwordPlaceholder": "z.B. •••••••••••", "passwordPlaceholder": "z.B. •••••••••••",
"forgotPassword": "Passwort vergessen?", "forgotPassword": "Passwort vergessen?",
"resetPassword": "Setz diis Passwort zrugg", "resetPassword": "Setz diis Passwort zrugg",
@ -45,12 +44,20 @@
"totpTitle": "Zweifaktor Authentifizierigs Ziffere", "totpTitle": "Zweifaktor Authentifizierigs Ziffere",
"totpPlaceholder": "z.B. 123456", "totpPlaceholder": "z.B. 123456",
"login": "Iihlogge", "login": "Iihlogge",
"register": "Registriere", "createAccount": "Account erstellen",
"loginWith": "Iihlogge mit {provider}", "loginWith": "Iihlogge mit {provider}",
"authenticating": "Authentifiziere…", "authenticating": "Authentifiziere…",
"openIdStateError": "Status stimmt nid überiih, ich verweigerä wiiter zmache!", "openIdStateError": "Status stimmt nid überiih, ich verweigerä wiiter zmache!",
"openIdGeneralError": "Es ist ein Fehler bei der externen Authentisierung aufgetreten.", "openIdGeneralError": "Es ist ein Fehler bei der externen Authentisierung aufgetreten.",
"logout": "Uuslogge" "logout": "Uuslogge",
"emailInvalid": "Bitte gib eine gültige E-Mail-Adresse ein.",
"usernameRequired": "Bitte gib einen Anmeldenamen ein.",
"passwordRequired": "Bitte gib ein Passwort ein.",
"showPassword": "Passwort anzeigen",
"hidePassword": "Passwort verbergen",
"noAccountYet": "Noch kein Account?",
"alreadyHaveAnAccount": "Hast du bereits einen Account?",
"remember": "Angemeldet bleiben"
}, },
"settings": { "settings": {
"title": "Iihstellige", "title": "Iihstellige",
@ -61,7 +68,7 @@
"currentPasswordPlaceholder": "Diis jetzige Passwort", "currentPasswordPlaceholder": "Diis jetzige Passwort",
"passwordsDontMatch": "Dis neue Passwort und siini Bestätigung stimmed nid überiih.", "passwordsDontMatch": "Dis neue Passwort und siini Bestätigung stimmed nid überiih.",
"passwordUpdateSuccess": "Dis Passwort isch erfolgriich aktualisiert wordä.", "passwordUpdateSuccess": "Dis Passwort isch erfolgriich aktualisiert wordä.",
"updateEmailTitle": "Dini E-Mail Adrässä änderä", "updateEmailTitle": "Aktualisiere deine E-Mail-Adresse",
"updateEmailNew": "Neui E-Mail Adrässä", "updateEmailNew": "Neui E-Mail Adrässä",
"updateEmailSuccess": "Dini E-Mail Adrässä isch erfolgriich gänderet worde. Mir hend dir en Link gschickt, um si zu bestätigä.", "updateEmailSuccess": "Dini E-Mail Adrässä isch erfolgriich gänderet worde. Mir hend dir en Link gschickt, um si zu bestätigä.",
"general": { "general": {
@ -78,7 +85,8 @@
"weekStartSunday": "Sunntig", "weekStartSunday": "Sunntig",
"weekStartMonday": "Määntig", "weekStartMonday": "Määntig",
"language": "Sproch", "language": "Sproch",
"defaultList": "Standard Liste" "defaultList": "Standard Liste",
"timezone": "Zeitzone"
}, },
"totp": { "totp": {
"title": "Zweifaktor Authentifizierig", "title": "Zweifaktor Authentifizierig",
@ -95,9 +103,9 @@
"disableSuccess": "Zweifaktor Authentifizierig isch erfolgriich uusgschalte wore." "disableSuccess": "Zweifaktor Authentifizierig isch erfolgriich uusgschalte wore."
}, },
"caldav": { "caldav": {
"title": "Caldav", "title": "CalDAV",
"howTo": "Du chasch Vikunja zu Caldav Applikatione verbinde, um dini Uufgabe vo verschidene Gräät zgseh. Gib die Url i dim Client iih:", "howTo": "Du chasch Vikunja zu CalDAV Applikatione verbinde, um dini Uufgabe vo verschidene Gräät zgseh. Gib die Url i dim Client iih:",
"more": "Meh Informatione über Caldav in Vikunja" "more": "Meh Informatione über CalDAV in Vikunja"
}, },
"avatar": { "avatar": {
"title": "Herr Der Elemente", "title": "Herr Der Elemente",
@ -157,7 +165,7 @@
"searchSelect": "Druck uf Enter um die Liste uuszwähle", "searchSelect": "Druck uf Enter um die Liste uuszwähle",
"shared": "Teilti Liste", "shared": "Teilti Liste",
"create": { "create": {
"header": "New list", "header": "Neue Liste",
"titlePlaceholder": "Listetitl da ahgeh…", "titlePlaceholder": "Listetitl da ahgeh…",
"addTitleRequired": "Bitte gib en Titl ah.", "addTitleRequired": "Bitte gib en Titl ah.",
"createdSuccess": "Liste erfolgriich erstellt.", "createdSuccess": "Liste erfolgriich erstellt.",
@ -315,7 +323,7 @@
"namespaces": "Namensrüüm", "namespaces": "Namensrüüm",
"search": "Schriib, um nachemne Namensruum z'sueche…", "search": "Schriib, um nachemne Namensruum z'sueche…",
"create": { "create": {
"title": "New namespace", "title": "Neuer Namespace",
"titleRequired": "Bitte gib en Titl ah.", "titleRequired": "Bitte gib en Titl ah.",
"explanation": "En Namensruum isch e Gruppe vo Liste, wo du chasch zur Organisation benutze. Tatsächlich sind alli Listene emne Namensruum zuegwise.", "explanation": "En Namensruum isch e Gruppe vo Liste, wo du chasch zur Organisation benutze. Tatsächlich sind alli Listene emne Namensruum zuegwise.",
"tooltip": "Was isch en Namensruum?", "tooltip": "Was isch en Namensruum?",
@ -327,6 +335,7 @@
"archiveText": "Du hesch kei möglichkeit meh de Namensruum z'bearbeite oder neui Listene drin z'erstelle, bis du si wider ent-archiviert hesch. Das archiviert au grad alli Liste im Namensruum.", "archiveText": "Du hesch kei möglichkeit meh de Namensruum z'bearbeite oder neui Listene drin z'erstelle, bis du si wider ent-archiviert hesch. Das archiviert au grad alli Liste im Namensruum.",
"unarchiveText": "Du chasch neui Liste erstelle oder bearbeite.", "unarchiveText": "Du chasch neui Liste erstelle oder bearbeite.",
"success": "De Namensruum isch erfolgriich archiviert worde.", "success": "De Namensruum isch erfolgriich archiviert worde.",
"unarchiveSuccess": "Der Namespace wurde erfolgreich wiederhergestellt.",
"description": "Wenn en Namensruum archiviert isch, chasch du kei neui Liste erstelle oder die bearbeite." "description": "Wenn en Namensruum archiviert isch, chasch du kei neui Liste erstelle oder die bearbeite."
}, },
"delete": { "delete": {
@ -376,14 +385,14 @@
"showDoneTasks": "Zeig die fertige Uufgabe", "showDoneTasks": "Zeig die fertige Uufgabe",
"sortAlphabetically": "Alphabetisch sortieren", "sortAlphabetically": "Alphabetisch sortieren",
"enablePriority": "Filter nach Priorität aktiviere", "enablePriority": "Filter nach Priorität aktiviere",
"enablePercentDone": "Filter nach Prozent iihschalte", "enablePercentDone": "Filter nach Fortschritt aktivieren",
"dueDateRange": "Fälligkeitsberiich", "dueDateRange": "Fälligkeitsberiich",
"startDateRange": "Startdatumsbreiich", "startDateRange": "Startdatumsbreiich",
"endDateRange": "Enddatumsberiich", "endDateRange": "Enddatumsberiich",
"reminderRange": "Errinnerigs Datumbereich" "reminderRange": "Errinnerigs Datumbereich"
}, },
"create": { "create": {
"title": "New Saved Filter", "title": "Neuer gespeicherter Filter",
"description": "En gspeicherete Filter isch e virtuelli Liste, welche vomene Satz a Filter zemmegsetzt wird, sobald me uf sie zuegriift. Wenn sie mal erstellt worde isch, erhaltet si ihren eigene Namensruum.", "description": "En gspeicherete Filter isch e virtuelli Liste, welche vomene Satz a Filter zemmegsetzt wird, sobald me uf sie zuegriift. Wenn sie mal erstellt worde isch, erhaltet si ihren eigene Namensruum.",
"action": "Neue gspeicherete Filter erstelle" "action": "Neue gspeicherete Filter erstelle"
}, },
@ -476,7 +485,8 @@
"showMenu": "Menü anzeigen", "showMenu": "Menü anzeigen",
"hideMenu": "Menü ausblenden", "hideMenu": "Menü ausblenden",
"forExample": "Zum Beispiel:", "forExample": "Zum Beispiel:",
"welcomeBack": "Willkommen zurück!" "welcomeBack": "Willkommen zurück!",
"custom": "Benutzerdefiniert"
}, },
"input": { "input": {
"resetColor": "Farb zruggsetze", "resetColor": "Farb zruggsetze",
@ -515,6 +525,57 @@
"multiselect": { "multiselect": {
"createPlaceholder": "Neu erstelle", "createPlaceholder": "Neu erstelle",
"selectPlaceholder": "Druck uf Enter zum uuswähle" "selectPlaceholder": "Druck uf Enter zum uuswähle"
},
"datepickerRange": {
"to": "Bis",
"from": "Von",
"fromto": "{from} bis {to}",
"ranges": {
"today": "Heute",
"thisWeek": "Diese Woche",
"restOfThisWeek": "Der Rest dieser Woche",
"nextWeek": "Nächste Woche",
"next7Days": "Nächsten 7 Tage",
"lastWeek": "Letzte Woche",
"thisMonth": "Dieser Monat",
"restOfThisMonth": "Der Rest dieses Monats",
"nextMonth": "Nächster Monat",
"next30Days": "Nächsten 30 Tage",
"lastMonth": "Letzter Monat",
"thisYear": "Dieses Jahr",
"restOfThisYear": "Der Rest des Jahres"
}
},
"datemathHelp": {
"canuse": "Du kannst Datumsberechnung verwenden, um nach relativen Daten zu filtern.",
"learnhow": "Sieh dir an, wie es funktioniert",
"title": "Datumsberechnung",
"intro": "Die Datumsberechnung erlaubt es, relative Daten anzugeben, die bei der Anwendung des Filters von Vikunja aufgelöst werden.",
"expression": "Jeder Ausdruck der Datumsberechnung beginnt mit einem Datumswert, welcher entweder {0} sein kann oder mit {1} endet. Auf diesen Datumswert kann optional ein oder mehrere mathematische Ausdrücke folgen.",
"similar": "Diese Ausdrücke ähneln denen von {0} und {1}.",
"add1Day": "Einen Tag hinzufügen",
"minus1Day": "Einen Tag abziehen",
"roundDay": "Auf den nächsten Tag abrunden",
"supportedUnits": "Unterstützte Zeiteinheiten sind:",
"someExamples": "Einige Beispiele für Zeitausdrücke:",
"units": {
"seconds": "Sekunden",
"minutes": "Minuten",
"hours": "Stunden",
"days": "Tage",
"weeks": "Wochen",
"months": "Monate",
"years": "Jahre"
},
"examples": {
"now": "Jetzt",
"in24h": "In 24 Stunden",
"today": "Heute um 00:00 Uhr",
"beginningOfThisWeek": "Der Anfang dieser Woche um 00:00 Uhr",
"endOfThisWeek": "Das Ende dieser Woche",
"in30Days": "In 30 Tagen",
"datePlusMonth": "{0} plus einen Monat um 00:00 des Tages"
}
} }
}, },
"task": { "task": {
@ -532,12 +593,9 @@
"titleCurrent": "Momentani Uufgabe", "titleCurrent": "Momentani Uufgabe",
"titleDates": "Uufgabe vo {from} bis {to}", "titleDates": "Uufgabe vo {from} bis {to}",
"noDates": "Zeig Uufgabe ohni Datum", "noDates": "Zeig Uufgabe ohni Datum",
"current": "Momentani Uufgabe", "overdue": "Überfällige Aufgaben anzeigen",
"from": "Uufgabe vo", "fromuntil": "Aufgaben von {from} bis {until}",
"until": "bis", "select": "Datumsbereich wählen",
"today": "Hütt",
"nextWeek": "Negst Wuchä",
"nextMonth": "Negste Monet",
"noTasks": "Nichts zu tun Einen schönen Tag noch!" "noTasks": "Nichts zu tun Einen schönen Tag noch!"
}, },
"detail": { "detail": {
@ -545,7 +603,7 @@
"chooseStartDate": "Druck dah, um es Startdatum z'setze", "chooseStartDate": "Druck dah, um es Startdatum z'setze",
"chooseEndDate": "Druck da, um es Enddatum z'setze", "chooseEndDate": "Druck da, um es Enddatum z'setze",
"move": "Schieb die Uufgab in e anderi Liste", "move": "Schieb die Uufgab in e anderi Liste",
"done": "Mark task done!", "done": "Als erledigt markieren!",
"undone": "Als unerledigt markierä", "undone": "Als unerledigt markierä",
"created": "Erstellt am {0} vo {1}", "created": "Erstellt am {0} vo {1}",
"updated": "{0} g'updatet", "updated": "{0} g'updatet",
@ -562,20 +620,20 @@
}, },
"actions": { "actions": {
"assign": "Benutzer:in zuweisen", "assign": "Benutzer:in zuweisen",
"label": "Label hinzueege", "label": "Label hinzufügen",
"priority": "Priorität setzä", "priority": "Priorität setzä",
"dueDate": "Fälligkeitsdatum setze", "dueDate": "Fälligkeitsdatum setze",
"startDate": "Startdatum setze", "startDate": "Startdatum setzen",
"endDate": "Enddatum setze", "endDate": "Enddatum setzen",
"reminders": "Errinnerig iihstelle", "reminders": "Errinnerig iihstelle",
"repeatAfter": "En wiederholende Intervall setze", "repeatAfter": "Wiederholung setzen",
"percentDone": "Prozentuelli Erledigung setze", "percentDone": "Fortschritt einstellen",
"attachments": "Aahang hinzuefüege", "attachments": "Anhänge hinzufügen",
"relatedTasks": "Uufgabsbeziehig hinzufüege", "relatedTasks": "Beziehung hinzufügen",
"moveList": "Uufgab verschiebe", "moveList": "Verschieben",
"color": "Uufgab Farb setze", "color": "Farbe setzen",
"delete": "Uufgab chüble", "delete": "Löschen",
"favorite": "In Favoriten speichern", "favorite": "Zu Favoriten hinzufügen",
"unfavorite": "Aus Favoriten entfernen" "unfavorite": "Aus Favoriten entfernen"
} }
}, },
@ -589,7 +647,7 @@
"dueDate": "Fälligkeitsdatum", "dueDate": "Fälligkeitsdatum",
"endDate": "Enddatum", "endDate": "Enddatum",
"labels": "Labels", "labels": "Labels",
"percentDone": "% fertig", "percentDone": "Fortschritt",
"priority": "Priorität", "priority": "Priorität",
"relatedTasks": "Verwandti Uufgabe", "relatedTasks": "Verwandti Uufgabe",
"reminders": "Errinnerige", "reminders": "Errinnerige",
@ -776,17 +834,20 @@
"general": "Allgemein", "general": "Allgemein",
"allPages": "Die Chürzl funktioniered uf allne Siitene.", "allPages": "Die Chürzl funktioniered uf allne Siitene.",
"currentPageOnly": "Die Chürzl funktioniered nur uf de momentane Siite.", "currentPageOnly": "Die Chürzl funktioniered nur uf de momentane Siite.",
"somePagesOnly": "Funktioniert nur auf manchen Seiten.",
"toggleMenu": "Menü umschalte", "toggleMenu": "Menü umschalte",
"quickSearch": "Suechi und Schnellaktionsliste öffne", "quickSearch": "Suechi und Schnellaktionsliste öffne",
"then": "dann", "then": "dann",
"task": { "task": {
"title": "Uufgabesiite", "title": "Uufgabesiite",
"done": "Done", "done": "Aufgabe als erledigt / unerledigt markieren",
"assign": "Benutzer:in zuweisen", "assign": "Diese Aufgabe jemandem zuweisen",
"labels": "Labels ennere Uufgab hinzuefüege", "labels": "Labels ennere Uufgab hinzuefüege",
"dueDate": "S'Fälligkeitsdatum für die Uufgab ändere", "dueDate": "S'Fälligkeitsdatum für die Uufgab ändere",
"attachment": "En Aahang dere Uufgab hinzuefüege", "attachment": "En Aahang dere Uufgab hinzuefüege",
"related": "Beziehige vo dere Uufgab bearbeite" "related": "Beziehige vo dere Uufgab bearbeite",
"color": "Die Farbe dieser Aufgabe ändern",
"move": "Diese Aufgabe in eine andere Liste verschieben"
}, },
"list": { "list": {
"title": "Listenansicht", "title": "Listenansicht",
@ -794,6 +855,14 @@
"switchToGanttView": "Zur Ganttansicht wechseln", "switchToGanttView": "Zur Ganttansicht wechseln",
"switchToKanbanView": "Zur Kanbanansicht wechseln", "switchToKanbanView": "Zur Kanbanansicht wechseln",
"switchToTableView": "Zur Tabellenansicht wechseln" "switchToTableView": "Zur Tabellenansicht wechseln"
},
"navigation": {
"title": "Navigation",
"overview": "Die Startseite aufrufen",
"upcoming": "Anstehende Aufgaben aufrufen",
"namespaces": "Namespaces & Listen aufrufen",
"labels": "Labels aufrufen",
"teams": "Teams aufrufen"
} }
}, },
"update": { "update": {
@ -899,7 +968,7 @@
"4015": "De Uufgabe Kommentar giz nid.", "4015": "De Uufgabe Kommentar giz nid.",
"4016": "Ungültigs Uufgabefeld.", "4016": "Ungültigs Uufgabefeld.",
"4017": "Ungültige Uufgabefilter vergliich.", "4017": "Ungültige Uufgabefilter vergliich.",
"4018": "Invalid task filter concatenator.", "4018": "Ungültige Verkettung von Aufgabenfiltern.",
"4019": "Ungültigi Uufgabe Filter Wert.", "4019": "Ungültigi Uufgabe Filter Wert.",
"5001": "De Namensruum existiert nid.", "5001": "De Namensruum existiert nid.",
"5003": "Du hesch kei Zuegriff zu dem Namensruum.", "5003": "Du hesch kei Zuegriff zu dem Namensruum.",

View File

@ -31,10 +31,9 @@
"username": "Username", "username": "Username",
"usernameEmail": "Username Or Email Address", "usernameEmail": "Username Or Email Address",
"usernamePlaceholder": "e.g. frederick", "usernamePlaceholder": "e.g. frederick",
"email": "E-mail address", "email": "Email address",
"emailPlaceholder": "e.g. frederic{'@'}vikunja.io", "emailPlaceholder": "e.g. frederic{'@'}vikunja.io",
"password": "Password", "password": "Password",
"passwordRepeat": "Retype your password",
"passwordPlaceholder": "e.g. •••••••••••", "passwordPlaceholder": "e.g. •••••••••••",
"forgotPassword": "Forgot your password?", "forgotPassword": "Forgot your password?",
"resetPassword": "Reset your password", "resetPassword": "Reset your password",
@ -45,12 +44,20 @@
"totpTitle": "Two Factor Authentication Code", "totpTitle": "Two Factor Authentication Code",
"totpPlaceholder": "e.g. 123456", "totpPlaceholder": "e.g. 123456",
"login": "Login", "login": "Login",
"register": "Register", "createAccount": "Create account",
"loginWith": "Log in with {provider}", "loginWith": "Log in with {provider}",
"authenticating": "Authenticating…", "authenticating": "Authenticating…",
"openIdStateError": "State does not match, refusing to continue!", "openIdStateError": "State does not match, refusing to continue!",
"openIdGeneralError": "An error occured while authenticating against the third party.", "openIdGeneralError": "An error occured while authenticating against the third party.",
"logout": "Logout" "logout": "Logout",
"emailInvalid": "Please enter a valid email address.",
"usernameRequired": "Please provide a username.",
"passwordRequired": "Please provide a password.",
"showPassword": "Show the password",
"hidePassword": "Hide the password",
"noAccountYet": "Don't have an account yet?",
"alreadyHaveAnAccount": "Already have an account?",
"remember": "Stay logged in"
}, },
"settings": { "settings": {
"title": "Settings", "title": "Settings",
@ -61,7 +68,7 @@
"currentPasswordPlaceholder": "Your current password", "currentPasswordPlaceholder": "Your current password",
"passwordsDontMatch": "The new password and its confirmation don't match.", "passwordsDontMatch": "The new password and its confirmation don't match.",
"passwordUpdateSuccess": "The password was successfully updated.", "passwordUpdateSuccess": "The password was successfully updated.",
"updateEmailTitle": "Update Your E-Mail Address", "updateEmailTitle": "Update Your Email Address",
"updateEmailNew": "New Email Address", "updateEmailNew": "New Email Address",
"updateEmailSuccess": "Your email address was successfully updated. We've sent you a link to confirm it.", "updateEmailSuccess": "Your email address was successfully updated. We've sent you a link to confirm it.",
"general": { "general": {
@ -78,7 +85,8 @@
"weekStartSunday": "Sunday", "weekStartSunday": "Sunday",
"weekStartMonday": "Monday", "weekStartMonday": "Monday",
"language": "Language", "language": "Language",
"defaultList": "Default List" "defaultList": "Default List",
"timezone": "Time Zone"
}, },
"totp": { "totp": {
"title": "Two Factor Authentication", "title": "Two Factor Authentication",
@ -327,6 +335,7 @@
"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.", "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.", "unarchiveText": "You will be able to create new lists or edit it.",
"success": "The namespace was successfully archived.", "success": "The namespace was successfully archived.",
"unarchiveSuccess": "The namespace was successfully un-archived.",
"description": "If a namespace is archived, you cannot create new lists or edit it." "description": "If a namespace is archived, you cannot create new lists or edit it."
}, },
"delete": { "delete": {
@ -376,7 +385,7 @@
"showDoneTasks": "Show Done Tasks", "showDoneTasks": "Show Done Tasks",
"sortAlphabetically": "Sort Alphabetically", "sortAlphabetically": "Sort Alphabetically",
"enablePriority": "Enable Filter By Priority", "enablePriority": "Enable Filter By Priority",
"enablePercentDone": "Enable Filter By Percent Done", "enablePercentDone": "Enable Filter By Progress",
"dueDateRange": "Due Date Range", "dueDateRange": "Due Date Range",
"startDateRange": "Start Date Range", "startDateRange": "Start Date Range",
"endDateRange": "End Date Range", "endDateRange": "End Date Range",
@ -476,7 +485,8 @@
"showMenu": "Show the menu", "showMenu": "Show the menu",
"hideMenu": "Hide the menu", "hideMenu": "Hide the menu",
"forExample": "For example:", "forExample": "For example:",
"welcomeBack": "Welcome Back!" "welcomeBack": "Welcome Back!",
"custom": "Custom"
}, },
"input": { "input": {
"resetColor": "Reset Color", "resetColor": "Reset Color",
@ -515,6 +525,60 @@
"multiselect": { "multiselect": {
"createPlaceholder": "Create new", "createPlaceholder": "Create new",
"selectPlaceholder": "Click or press enter to select" "selectPlaceholder": "Click or press enter to select"
},
"datepickerRange": {
"to": "To",
"from": "From",
"fromto": "{from} to {to}",
"ranges": {
"today": "Today",
"thisWeek": "This Week",
"restOfThisWeek": "The Rest of This Week",
"nextWeek": "Next Week",
"next7Days": "Next 7 Days",
"lastWeek": "Last Week",
"thisMonth": "This Month",
"restOfThisMonth": "The Rest of This Month",
"nextMonth": "Next Month",
"next30Days": "Next 30 Days",
"lastMonth": "Last Month",
"thisYear": "This Year",
"restOfThisYear": "The Rest of This Year"
}
},
"datemathHelp": {
"canuse": "You can use date math to filter for relative dates.",
"learnhow": "Check out how it works",
"title": "Date Math",
"intro": "Date Math allows you to specifiy relative dates which are resolved on the fly by Vikunja when applying the filter.",
"expression": "Each Date Math expression starts with an anchor date, which can either be {0}, or a date string ending with {1}. This anchor date can optionally be followed by one or more maths expressions.",
"similar": "These expressions are similar to the ones provided by {0} and {1}.",
"add1Day": "Add one day",
"minus1Day": "Subtract one day",
"roundDay": "Round down to the nearest day",
"supportedUnits": "Supported time units are:",
"someExamples": "Some examples of time expressions:",
"units": {
"seconds": "Seconds",
"minutes": "Minutes",
"hours": "Hours",
"days": "Days",
"weeks": "Weeks",
"months": "Months",
"years": "Years"
},
"examples": {
"now": "Right now",
"in24h": "In 24h",
"today": "Today at 00:00",
"beginningOfThisWeek": "The beginning of this week at 00:00",
"endOfThisWeek": "The end of this week",
"in30Days": "In 30 days",
"datePlusMonth": "{0} plus one month at 00:00 of that day"
}
} }
}, },
"task": { "task": {
@ -532,12 +596,9 @@
"titleCurrent": "Current Tasks", "titleCurrent": "Current Tasks",
"titleDates": "Tasks from {from} until {to}", "titleDates": "Tasks from {from} until {to}",
"noDates": "Show tasks without dates", "noDates": "Show tasks without dates",
"current": "Current tasks", "overdue": "Show overdue tasks",
"from": "Tasks from", "fromuntil": "Tasks from {from} until {until}",
"until": "until", "select": "Select a date range",
"today": "Today",
"nextWeek": "Next Week",
"nextMonth": "Next Month",
"noTasks": "Nothing to do — Have a nice day!" "noTasks": "Nothing to do — Have a nice day!"
}, },
"detail": { "detail": {
@ -561,22 +622,22 @@
"text2": "This will also remove all attachments, reminders and relations associated with this task and cannot be undone!" "text2": "This will also remove all attachments, reminders and relations associated with this task and cannot be undone!"
}, },
"actions": { "actions": {
"assign": "Assign to a user", "assign": "Assign to User",
"label": "Add labels", "label": "Add Labels",
"priority": "Set Priority", "priority": "Set Priority",
"dueDate": "Set Due Date", "dueDate": "Set Due Date",
"startDate": "Set a Start Date", "startDate": "Set Start Date",
"endDate": "Set an End Date", "endDate": "Set End Date",
"reminders": "Set Reminders", "reminders": "Set Reminders",
"repeatAfter": "Set a repeating interval", "repeatAfter": "Set Repeating Interval",
"percentDone": "Set Percent Done", "percentDone": "Set Progress",
"attachments": "Add attachments", "attachments": "Add Attachments",
"relatedTasks": "Add task relations", "relatedTasks": "Add Relation",
"moveList": "Move task", "moveList": "Move",
"color": "Set task color", "color": "Set Color",
"delete": "Delete task", "delete": "Delete",
"favorite": "Save as favorite", "favorite": "Add to Favorites",
"unfavorite": "Remove from favorites" "unfavorite": "Remove from Favorites"
} }
}, },
"attributes": { "attributes": {
@ -589,7 +650,7 @@
"dueDate": "Due Date", "dueDate": "Due Date",
"endDate": "End Date", "endDate": "End Date",
"labels": "Labels", "labels": "Labels",
"percentDone": "% Done", "percentDone": "Progress",
"priority": "Priority", "priority": "Priority",
"relatedTasks": "Related Tasks", "relatedTasks": "Related Tasks",
"reminders": "Reminders", "reminders": "Reminders",
@ -676,6 +737,7 @@
"deleteText1": "Are you sure you want to delete this task relation?", "deleteText1": "Are you sure you want to delete this task relation?",
"deleteText2": "This cannot be undone!", "deleteText2": "This cannot be undone!",
"select": "Select a relation kind", "select": "Select a relation kind",
"taskRequired": "Please select a task or enter a new task title.",
"kinds": { "kinds": {
"subtask": "Subtask | Subtasks", "subtask": "Subtask | Subtasks",
"parenttask": "Parent Task | Parent Tasks", "parenttask": "Parent Task | Parent Tasks",
@ -776,17 +838,20 @@
"general": "General", "general": "General",
"allPages": "These shortcuts work on all pages.", "allPages": "These shortcuts work on all pages.",
"currentPageOnly": "These shortcuts work only on the current page.", "currentPageOnly": "These shortcuts work only on the current page.",
"somePagesOnly": "These shortcuts work only on some pages.",
"toggleMenu": "Toggle The Menu", "toggleMenu": "Toggle The Menu",
"quickSearch": "Open the search/quick action bar", "quickSearch": "Open the search/quick action bar",
"then": "then", "then": "then",
"task": { "task": {
"title": "Task Page", "title": "Task Page",
"done": "Done", "done": "Mark task done / undone",
"assign": "Assign to a user", "assign": "Assign this task to a user",
"labels": "Add labels to this task", "labels": "Add labels to this task",
"dueDate": "Change the due date of this task", "dueDate": "Change the due date of this task",
"attachment": "Add an attachment to this task", "attachment": "Add an attachment to this task",
"related": "Modify related tasks of this task" "related": "Modify related tasks of this task",
"color": "Change the color of this task",
"move": "Move this task to another list"
}, },
"list": { "list": {
"title": "List Views", "title": "List Views",
@ -794,6 +859,14 @@
"switchToGanttView": "Switch to gantt view", "switchToGanttView": "Switch to gantt view",
"switchToKanbanView": "Switch to kanban view", "switchToKanbanView": "Switch to kanban view",
"switchToTableView": "Switch to table view" "switchToTableView": "Switch to table view"
},
"navigation": {
"title": "Navigation",
"overview": "Navigato to overview",
"upcoming": "Navigato to upcoming taks",
"namespaces": "Navigate to namepaces & lists",
"labels": "Navigate to labels",
"teams": "Navigate to teams"
} }
}, },
"update": { "update": {

View File

@ -31,10 +31,9 @@
"username": "Username", "username": "Username",
"usernameEmail": "Username Or Email Address", "usernameEmail": "Username Or Email Address",
"usernamePlaceholder": "e.g. frederick", "usernamePlaceholder": "e.g. frederick",
"email": "E-mail address", "email": "Email address",
"emailPlaceholder": "e.g. frederic{'@'}vikunja.io", "emailPlaceholder": "e.g. frederic{'@'}vikunja.io",
"password": "Password", "password": "Password",
"passwordRepeat": "Retype your password",
"passwordPlaceholder": "e.g. •••••••••••", "passwordPlaceholder": "e.g. •••••••••••",
"forgotPassword": "Forgot your password?", "forgotPassword": "Forgot your password?",
"resetPassword": "Reset your password", "resetPassword": "Reset your password",
@ -45,12 +44,20 @@
"totpTitle": "Two Factor Authentication Code", "totpTitle": "Two Factor Authentication Code",
"totpPlaceholder": "e.g. 123456", "totpPlaceholder": "e.g. 123456",
"login": "Login", "login": "Login",
"register": "Register", "createAccount": "Create account",
"loginWith": "Log in with {provider}", "loginWith": "Log in with {provider}",
"authenticating": "Authenticating…", "authenticating": "Authenticating…",
"openIdStateError": "State does not match, refusing to continue!", "openIdStateError": "State does not match, refusing to continue!",
"openIdGeneralError": "An error occured while authenticating against the third party.", "openIdGeneralError": "An error occured while authenticating against the third party.",
"logout": "Logout" "logout": "Logout",
"emailInvalid": "Please enter a valid email address.",
"usernameRequired": "Please provide a username.",
"passwordRequired": "Please provide a password.",
"showPassword": "Show the password",
"hidePassword": "Hide the password",
"noAccountYet": "Don't have an account yet?",
"alreadyHaveAnAccount": "Already have an account?",
"remember": "Stay logged in"
}, },
"settings": { "settings": {
"title": "Settings", "title": "Settings",
@ -61,7 +68,7 @@
"currentPasswordPlaceholder": "Your current password", "currentPasswordPlaceholder": "Your current password",
"passwordsDontMatch": "The new password and its confirmation don't match.", "passwordsDontMatch": "The new password and its confirmation don't match.",
"passwordUpdateSuccess": "The password was successfully updated.", "passwordUpdateSuccess": "The password was successfully updated.",
"updateEmailTitle": "Update Your E-Mail Address", "updateEmailTitle": "Update Your Email Address",
"updateEmailNew": "New Email Address", "updateEmailNew": "New Email Address",
"updateEmailSuccess": "Your email address was successfully updated. We've sent you a link to confirm it.", "updateEmailSuccess": "Your email address was successfully updated. We've sent you a link to confirm it.",
"general": { "general": {
@ -78,7 +85,8 @@
"weekStartSunday": "Sunday", "weekStartSunday": "Sunday",
"weekStartMonday": "Monday", "weekStartMonday": "Monday",
"language": "Language", "language": "Language",
"defaultList": "Default List" "defaultList": "Default List",
"timezone": "Time Zone"
}, },
"totp": { "totp": {
"title": "Two Factor Authentication", "title": "Two Factor Authentication",
@ -327,6 +335,7 @@
"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.", "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.", "unarchiveText": "You will be able to create new lists or edit it.",
"success": "The namespace was successfully archived.", "success": "The namespace was successfully archived.",
"unarchiveSuccess": "The namespace was successfully un-archived.",
"description": "If a namespace is archived, you cannot create new lists or edit it." "description": "If a namespace is archived, you cannot create new lists or edit it."
}, },
"delete": { "delete": {
@ -376,7 +385,7 @@
"showDoneTasks": "Show Done Tasks", "showDoneTasks": "Show Done Tasks",
"sortAlphabetically": "Sort Alphabetically", "sortAlphabetically": "Sort Alphabetically",
"enablePriority": "Enable Filter By Priority", "enablePriority": "Enable Filter By Priority",
"enablePercentDone": "Enable Filter By Percent Done", "enablePercentDone": "Enable Filter By Progress",
"dueDateRange": "Due Date Range", "dueDateRange": "Due Date Range",
"startDateRange": "Start Date Range", "startDateRange": "Start Date Range",
"endDateRange": "End Date Range", "endDateRange": "End Date Range",
@ -476,7 +485,8 @@
"showMenu": "Show the menu", "showMenu": "Show the menu",
"hideMenu": "Hide the menu", "hideMenu": "Hide the menu",
"forExample": "For example:", "forExample": "For example:",
"welcomeBack": "Welcome Back!" "welcomeBack": "Welcome Back!",
"custom": "Custom"
}, },
"input": { "input": {
"resetColor": "Reset Color", "resetColor": "Reset Color",
@ -515,6 +525,57 @@
"multiselect": { "multiselect": {
"createPlaceholder": "Create new", "createPlaceholder": "Create new",
"selectPlaceholder": "Click or press enter to select" "selectPlaceholder": "Click or press enter to select"
},
"datepickerRange": {
"to": "To",
"from": "From",
"fromto": "{from} to {to}",
"ranges": {
"today": "Today",
"thisWeek": "This Week",
"restOfThisWeek": "The Rest of This Week",
"nextWeek": "Next Week",
"next7Days": "Next 7 Days",
"lastWeek": "Last Week",
"thisMonth": "This Month",
"restOfThisMonth": "The Rest of This Month",
"nextMonth": "Next Month",
"next30Days": "Next 30 Days",
"lastMonth": "Last Month",
"thisYear": "This Year",
"restOfThisYear": "The Rest of This Year"
}
},
"datemathHelp": {
"canuse": "You can use date math to filter for relative dates.",
"learnhow": "Check out how it works",
"title": "Date Math",
"intro": "Date Math allows you to specifiy relative dates which are resolved on the fly by Vikunja when applying the filter.",
"expression": "Each Date Math expression starts with an anchor date, which can either be {0}, or a date string ending with {1}. This anchor date can optionally be followed by one or more maths expressions.",
"similar": "These expressions are similar to the ones provided by {0} and {1}.",
"add1Day": "Add one day",
"minus1Day": "Subtract one day",
"roundDay": "Round down to the nearest day",
"supportedUnits": "Supported time units are:",
"someExamples": "Some examples of time expressions:",
"units": {
"seconds": "Seconds",
"minutes": "Minutes",
"hours": "Hours",
"days": "Days",
"weeks": "Weeks",
"months": "Months",
"years": "Years"
},
"examples": {
"now": "Right now",
"in24h": "In 24h",
"today": "Today at 00:00",
"beginningOfThisWeek": "The beginning of this week at 00:00",
"endOfThisWeek": "The end of this week",
"in30Days": "In 30 days",
"datePlusMonth": "{0} plus one month at 00:00 of that day"
}
} }
}, },
"task": { "task": {
@ -532,12 +593,9 @@
"titleCurrent": "Current Tasks", "titleCurrent": "Current Tasks",
"titleDates": "Tasks from {from} until {to}", "titleDates": "Tasks from {from} until {to}",
"noDates": "Show tasks without dates", "noDates": "Show tasks without dates",
"current": "Current tasks", "overdue": "Show overdue tasks",
"from": "Tasks from", "fromuntil": "Tasks from {from} until {until}",
"until": "until", "select": "Select a date range",
"today": "Today",
"nextWeek": "Next Week",
"nextMonth": "Next Month",
"noTasks": "Nothing to do — Have a nice day!" "noTasks": "Nothing to do — Have a nice day!"
}, },
"detail": { "detail": {
@ -561,22 +619,22 @@
"text2": "This will also remove all attachments, reminders and relations associated with this task and cannot be undone!" "text2": "This will also remove all attachments, reminders and relations associated with this task and cannot be undone!"
}, },
"actions": { "actions": {
"assign": "Assign to a user", "assign": "Assign to User",
"label": "Add labels", "label": "Add Labels",
"priority": "Set Priority", "priority": "Set Priority",
"dueDate": "Set Due Date", "dueDate": "Set Due Date",
"startDate": "Set a Start Date", "startDate": "Set Start Date",
"endDate": "Set an End Date", "endDate": "Set End Date",
"reminders": "Set Reminders", "reminders": "Set Reminders",
"repeatAfter": "Set a repeating interval", "repeatAfter": "Set Repeating Interval",
"percentDone": "Set Percent Done", "percentDone": "Set Progress",
"attachments": "Add attachments", "attachments": "Add Attachments",
"relatedTasks": "Add task relations", "relatedTasks": "Add Relation",
"moveList": "Move task", "moveList": "Move",
"color": "Set task color", "color": "Set Color",
"delete": "Delete task", "delete": "Delete",
"favorite": "Save as favorite", "favorite": "Add to Favorites",
"unfavorite": "Remove from favorites" "unfavorite": "Remove from Favorites"
} }
}, },
"attributes": { "attributes": {
@ -589,7 +647,7 @@
"dueDate": "Due Date", "dueDate": "Due Date",
"endDate": "End Date", "endDate": "End Date",
"labels": "Labels", "labels": "Labels",
"percentDone": "% Done", "percentDone": "Progress",
"priority": "Priority", "priority": "Priority",
"relatedTasks": "Related Tasks", "relatedTasks": "Related Tasks",
"reminders": "Reminders", "reminders": "Reminders",
@ -776,17 +834,20 @@
"general": "General", "general": "General",
"allPages": "These shortcuts work on all pages.", "allPages": "These shortcuts work on all pages.",
"currentPageOnly": "These shortcuts work only on the current page.", "currentPageOnly": "These shortcuts work only on the current page.",
"somePagesOnly": "These shortcuts work only on some pages.",
"toggleMenu": "Toggle The Menu", "toggleMenu": "Toggle The Menu",
"quickSearch": "Open the search/quick action bar", "quickSearch": "Open the search/quick action bar",
"then": "then", "then": "then",
"task": { "task": {
"title": "Task Page", "title": "Task Page",
"done": "Done", "done": "Mark task done / undone",
"assign": "Assign to a user", "assign": "Assign this task to a user",
"labels": "Add labels to this task", "labels": "Add labels to this task",
"dueDate": "Change the due date of this task", "dueDate": "Change the due date of this task",
"attachment": "Add an attachment to this task", "attachment": "Add an attachment to this task",
"related": "Modify related tasks of this task" "related": "Modify related tasks of this task",
"color": "Change the color of this task",
"move": "Move this task to another list"
}, },
"list": { "list": {
"title": "List Views", "title": "List Views",
@ -794,6 +855,14 @@
"switchToGanttView": "Switch to gantt view", "switchToGanttView": "Switch to gantt view",
"switchToKanbanView": "Switch to kanban view", "switchToKanbanView": "Switch to kanban view",
"switchToTableView": "Switch to table view" "switchToTableView": "Switch to table view"
},
"navigation": {
"title": "Navigation",
"overview": "Navigato to overview",
"upcoming": "Navigato to upcoming taks",
"namespaces": "Navigate to namepaces & lists",
"labels": "Navigate to labels",
"teams": "Navigate to teams"
} }
}, },
"update": { "update": {

View File

@ -7,7 +7,7 @@
"lastViewed": "Dernière consultation", "lastViewed": "Dernière consultation",
"list": { "list": {
"newText": "Tu peux créer une nouvelle liste pour tes nouvelles tâches :", "newText": "Tu peux créer une nouvelle liste pour tes nouvelles tâches :",
"new": "New list", "new": "Nouvelle liste",
"importText": "Ou importe tes listes et tâches dautres services dans Vikunja :", "importText": "Ou importe tes listes et tâches dautres services dans Vikunja :",
"import": "Importer tes données dans Vikunja" "import": "Importer tes données dans Vikunja"
} }
@ -34,9 +34,8 @@
"email": "Adresse courriel", "email": "Adresse courriel",
"emailPlaceholder": "p. ex. frederic{'@'}vikunja.io", "emailPlaceholder": "p. ex. frederic{'@'}vikunja.io",
"password": "Mot de passe", "password": "Mot de passe",
"passwordRepeat": "Retape ton mot de passe",
"passwordPlaceholder": "p. ex. •••••••••••", "passwordPlaceholder": "p. ex. •••••••••••",
"forgotPassword": "Forgot your password?", "forgotPassword": "Mot de passe oublié ?",
"resetPassword": "Réinitialiser ton mot de passe", "resetPassword": "Réinitialiser ton mot de passe",
"resetPasswordAction": "Menvoyer un lien de réinitialisation du mot de passe", "resetPasswordAction": "Menvoyer un lien de réinitialisation du mot de passe",
"resetPasswordSuccess": "Vérifie ta boîte de réception ! Tu devrais avoir un courriel contenant les instructions sur la manière de réinitialiser ton mot de passe.", "resetPasswordSuccess": "Vérifie ta boîte de réception ! Tu devrais avoir un courriel contenant les instructions sur la manière de réinitialiser ton mot de passe.",
@ -45,12 +44,20 @@
"totpTitle": "Code dauthentification à deux facteurs", "totpTitle": "Code dauthentification à deux facteurs",
"totpPlaceholder": "p. ex. 123456", "totpPlaceholder": "p. ex. 123456",
"login": "Se connecter", "login": "Se connecter",
"register": "Sinscrire", "createAccount": "Créer un compte",
"loginWith": "Se connecter avec {provider}", "loginWith": "Se connecter avec {provider}",
"authenticating": "Authentification…", "authenticating": "Authentification…",
"openIdStateError": "Létat ne correspond pas, impossible de continuer !", "openIdStateError": "Létat ne correspond pas, impossible de continuer !",
"openIdGeneralError": "Une erreur s'est produite lors de l'authentification contre un tiers.", "openIdGeneralError": "Une erreur s'est produite lors de l'authentification contre un tiers.",
"logout": "Se déconnecter" "logout": "Se déconnecter",
"emailInvalid": "Veuillez saisir une adresse courriel valide.",
"usernameRequired": "Veuillez saisir un nom d'utilisateur.",
"passwordRequired": "Veuillez fournir un mot de passe.",
"showPassword": "Afficher le mot de passe",
"hidePassword": "Masquer le mot de passe",
"noAccountYet": "Vous n'avez pas encore de compte?",
"alreadyHaveAnAccount": "Vous avez déjà un compte?",
"remember": "Rester connecté(e)"
}, },
"settings": { "settings": {
"title": "Paramètres", "title": "Paramètres",
@ -61,7 +68,7 @@
"currentPasswordPlaceholder": "Ton mot de passe actuel", "currentPasswordPlaceholder": "Ton mot de passe actuel",
"passwordsDontMatch": "Le nouveau mot de passe et sa confirmation ne correspondent pas.", "passwordsDontMatch": "Le nouveau mot de passe et sa confirmation ne correspondent pas.",
"passwordUpdateSuccess": "Mot de passe mis à jour.", "passwordUpdateSuccess": "Mot de passe mis à jour.",
"updateEmailTitle": "Mets à jour ton adresse électronique", "updateEmailTitle": "Mettre à jour votre adresse courriel",
"updateEmailNew": "Nouvelle adresse courriel", "updateEmailNew": "Nouvelle adresse courriel",
"updateEmailSuccess": "Mise à jour de ladresse électronique. Clique sur le lien dans le courriel qui ta été envoyé pour le confirmer.", "updateEmailSuccess": "Mise à jour de ladresse électronique. Clique sur le lien dans le courriel qui ta été envoyé pour le confirmer.",
"general": { "general": {
@ -78,7 +85,8 @@
"weekStartSunday": "dimanche", "weekStartSunday": "dimanche",
"weekStartMonday": "lundi", "weekStartMonday": "lundi",
"language": "Langue", "language": "Langue",
"defaultList": "Liste par défaut" "defaultList": "Liste par défaut",
"timezone": "Fuseau horaire"
}, },
"totp": { "totp": {
"title": "Authentification à deux facteurs", "title": "Authentification à deux facteurs",
@ -103,7 +111,7 @@
"title": "Avatar", "title": "Avatar",
"initials": "Initiales", "initials": "Initiales",
"gravatar": "Gravatar", "gravatar": "Gravatar",
"marble": "Marble", "marble": "Bille",
"upload": "Téléverser", "upload": "Téléverser",
"uploadAvatar": "Téléverser lavatar", "uploadAvatar": "Téléverser lavatar",
"statusUpdateSuccess": "Statut de lavatar mis à jour.", "statusUpdateSuccess": "Statut de lavatar mis à jour.",
@ -116,12 +124,12 @@
"vikunja": "Vikunja" "vikunja": "Vikunja"
}, },
"appearance": { "appearance": {
"title": "Color Scheme", "title": "Jeu de couleurs",
"setSuccess": "Saved change of color scheme to {colorScheme}", "setSuccess": "Changement du jeu de couleurs enregistré vers {colorScheme}",
"colorScheme": { "colorScheme": {
"light": "Light", "light": "Clair",
"system": "System", "system": "Système",
"dark": "Dark" "dark": "Sombre"
} }
} }
}, },
@ -157,7 +165,7 @@
"searchSelect": "Clique ou appuie sur la touche Entrée pour sélectionner cette liste", "searchSelect": "Clique ou appuie sur la touche Entrée pour sélectionner cette liste",
"shared": "Listes partagées", "shared": "Listes partagées",
"create": { "create": {
"header": "New list", "header": "Nouvelle liste",
"titlePlaceholder": "Entre le nom de la liste…", "titlePlaceholder": "Entre le nom de la liste…",
"addTitleRequired": "Indique un nom.", "addTitleRequired": "Indique un nom.",
"createdSuccess": "Liste créée.", "createdSuccess": "Liste créée.",
@ -315,7 +323,7 @@
"namespaces": "Espaces de noms", "namespaces": "Espaces de noms",
"search": "Écris pour rechercher un espace de noms…", "search": "Écris pour rechercher un espace de noms…",
"create": { "create": {
"title": "New namespace", "title": "Nouvel espace de noms",
"titleRequired": "Indique un nom.", "titleRequired": "Indique un nom.",
"explanation": "Des collections de listes pour partager et organiser vos listes. En fait, chaque liste appartient à un espace de noms.", "explanation": "Des collections de listes pour partager et organiser vos listes. En fait, chaque liste appartient à un espace de noms.",
"tooltip": "Quest-ce quun espace de noms ?", "tooltip": "Quest-ce quun espace de noms ?",
@ -327,6 +335,7 @@
"archiveText": "Tu ne pourras pas modifier cet espace de noms ou créer de nouvelles listes tant que tu ne lauras pas désarchivé. Ceci archivera également toutes les listes de cet espace de noms.", "archiveText": "Tu ne pourras pas modifier cet espace de noms ou créer de nouvelles listes tant que tu ne lauras pas désarchivé. Ceci archivera également toutes les listes de cet espace de noms.",
"unarchiveText": "Tu pourras créer de nouvelles listes ou les modifier.", "unarchiveText": "Tu pourras créer de nouvelles listes ou les modifier.",
"success": "Espace de noms archivé.", "success": "Espace de noms archivé.",
"unarchiveSuccess": "Espace de noms archivé.",
"description": "Larchivage dun espace de noms signifie quon ne peut pas créer de nouvelles listes dans cet espace, ni le modifier." "description": "Larchivage dun espace de noms signifie quon ne peut pas créer de nouvelles listes dans cet espace, ni le modifier."
}, },
"delete": { "delete": {
@ -374,16 +383,16 @@
"includeNulls": "Inclure les tâches sans valeurs", "includeNulls": "Inclure les tâches sans valeurs",
"requireAll": "Exiger tous les filtres pour quune tâche saffiche", "requireAll": "Exiger tous les filtres pour quune tâche saffiche",
"showDoneTasks": "Afficher les tâches terminées", "showDoneTasks": "Afficher les tâches terminées",
"sortAlphabetically": "Sort Alphabetically", "sortAlphabetically": "Trier par ordre alphabétique",
"enablePriority": "Activer le filtre par priorité", "enablePriority": "Activer le filtre par priorité",
"enablePercentDone": "Par % dachèvement", "enablePercentDone": "Activer le filtre par progression",
"dueDateRange": "Plage de dates déchéance", "dueDateRange": "Plage de dates déchéance",
"startDateRange": "Plage de dates de début", "startDateRange": "Plage de dates de début",
"endDateRange": "Plage de dates de fin", "endDateRange": "Plage de dates de fin",
"reminderRange": "Plage de dates de rappel" "reminderRange": "Plage de dates de rappel"
}, },
"create": { "create": {
"title": "New Saved Filter", "title": "Nouveau filtre enregistré",
"description": "Un filtre enregistré est une liste virtuelle qui est calculée à partir dun ensemble de filtres à chaque fois quon y accède. Une fois créé, il apparaît dans un espace de noms spécial.", "description": "Un filtre enregistré est une liste virtuelle qui est calculée à partir dun ensemble de filtres à chaque fois quon y accède. Une fois créé, il apparaît dans un espace de noms spécial.",
"action": "Créer un nouveau filtre enregistré" "action": "Créer un nouveau filtre enregistré"
}, },
@ -475,8 +484,9 @@
"download": "Télécharger", "download": "Télécharger",
"showMenu": "Afficher le menu", "showMenu": "Afficher le menu",
"hideMenu": "Masquer le menu", "hideMenu": "Masquer le menu",
"forExample": "For example:", "forExample": "Par exemple :",
"welcomeBack": "Welcome Back!" "welcomeBack": "Heureux de vous revoir !",
"custom": "Custom"
}, },
"input": { "input": {
"resetColor": "Réinitialiser la couleur", "resetColor": "Réinitialiser la couleur",
@ -515,6 +525,57 @@
"multiselect": { "multiselect": {
"createPlaceholder": "Créer un nouveau", "createPlaceholder": "Créer un nouveau",
"selectPlaceholder": "Clique ou appuie sur la touche Entrée pour sélectionner" "selectPlaceholder": "Clique ou appuie sur la touche Entrée pour sélectionner"
},
"datepickerRange": {
"to": "To",
"from": "From",
"fromto": "{from} to {to}",
"ranges": {
"today": "Today",
"thisWeek": "This Week",
"restOfThisWeek": "The Rest of This Week",
"nextWeek": "Next Week",
"next7Days": "Next 7 Days",
"lastWeek": "Last Week",
"thisMonth": "This Month",
"restOfThisMonth": "The Rest of This Month",
"nextMonth": "Next Month",
"next30Days": "Next 30 Days",
"lastMonth": "Last Month",
"thisYear": "This Year",
"restOfThisYear": "The Rest of This Year"
}
},
"datemathHelp": {
"canuse": "You can use date math to filter for relative dates.",
"learnhow": "Check out how it works",
"title": "Date Math",
"intro": "Date Math allows you to specifiy relative dates which are resolved on the fly by Vikunja when applying the filter.",
"expression": "Each Date Math expression starts with an anchor date, which can either be {0}, or a date string ending with {1}. This anchor date can optionally be followed by one or more maths expressions.",
"similar": "These expressions are similar to the ones provided by {0} and {1}.",
"add1Day": "Add one day",
"minus1Day": "Subtract one day",
"roundDay": "Round down to the nearest day",
"supportedUnits": "Supported time units are:",
"someExamples": "Some examples of time expressions:",
"units": {
"seconds": "Seconds",
"minutes": "Minutes",
"hours": "Hours",
"days": "Days",
"weeks": "Weeks",
"months": "Months",
"years": "Years"
},
"examples": {
"now": "Right now",
"in24h": "In 24h",
"today": "Today at 00:00",
"beginningOfThisWeek": "The beginning of this week at 00:00",
"endOfThisWeek": "The end of this week",
"in30Days": "In 30 days",
"datePlusMonth": "{0} plus one month at 00:00 of that day"
}
} }
}, },
"task": { "task": {
@ -532,12 +593,9 @@
"titleCurrent": "Tâches actuelles", "titleCurrent": "Tâches actuelles",
"titleDates": "Tâches du {from} au {to}", "titleDates": "Tâches du {from} au {to}",
"noDates": "Afficher les tâches sans date", "noDates": "Afficher les tâches sans date",
"current": "Tâches actuelles", "overdue": "Show overdue tasks",
"from": "Tâches du", "fromuntil": "Tasks from {from} until {until}",
"until": "au", "select": "Select a date range",
"today": "Aujourdhui",
"nextWeek": "La semaine prochaine",
"nextMonth": "Le mois prochain",
"noTasks": "Rien à faire — Passe une bonne journée !" "noTasks": "Rien à faire — Passe une bonne journée !"
}, },
"detail": { "detail": {
@ -545,7 +603,7 @@
"chooseStartDate": "Clique ici pour fixer une date de début", "chooseStartDate": "Clique ici pour fixer une date de début",
"chooseEndDate": "Clique ici pour fixer une date de fin", "chooseEndDate": "Clique ici pour fixer une date de fin",
"move": "Déplacer une tâche vers une autre liste", "move": "Déplacer une tâche vers une autre liste",
"done": "Mark task done!", "done": "Marquer la tâche comme terminée !",
"undone": "Marquer comme inachevé", "undone": "Marquer comme inachevé",
"created": "Créé {0} par {1}", "created": "Créé {0} par {1}",
"updated": "Mis à jour {0}", "updated": "Mis à jour {0}",
@ -561,21 +619,21 @@
"text2": "Ceci supprimera également toutes les pièces jointes, les rappels et les relations associés à cette tâche et ne pourra pas être annulé !" "text2": "Ceci supprimera également toutes les pièces jointes, les rappels et les relations associés à cette tâche et ne pourra pas être annulé !"
}, },
"actions": { "actions": {
"assign": "Assign to a user", "assign": "Assigner à l'utilisateur",
"label": "Ajouter des étiquettes", "label": "Ajouter des étiquettes",
"priority": "Définir la priorité", "priority": "Définir la priorité",
"dueDate": "Définir léchéance", "dueDate": "Définir léchéance",
"startDate": "Définir une date de début", "startDate": "Définir la date de début",
"endDate": "Fixer une date de fin", "endDate": "Définir la date de fin",
"reminders": "Définir des rappels", "reminders": "Définir des rappels",
"repeatAfter": "Définir un intervalle de répétition", "repeatAfter": "Définir un intervalle de répétition",
"percentDone": "Définir le pourcentage dachèvement", "percentDone": "Définir la progression",
"attachments": "Ajouter des pièces jointes", "attachments": "Ajouter des pièces jointes",
"relatedTasks": "Ajouter des relations de tâches", "relatedTasks": "Ajouter une relation",
"moveList": "Déplacer la tâche", "moveList": "Déplacer",
"color": "Définir la couleur de la tâche", "color": "Définir la couleur",
"delete": "Supprimer la tâche", "delete": "Supprimer",
"favorite": "Enregistrer comme favori", "favorite": "Ajouter aux favoris",
"unfavorite": "Retirer des favoris" "unfavorite": "Retirer des favoris"
} }
}, },
@ -589,7 +647,7 @@
"dueDate": "Date déchéance", "dueDate": "Date déchéance",
"endDate": "Date de fin", "endDate": "Date de fin",
"labels": "Étiquettes", "labels": "Étiquettes",
"percentDone": "% terminé", "percentDone": "Progression",
"priority": "Priorité", "priority": "Priorité",
"relatedTasks": "Tâches connexes", "relatedTasks": "Tâches connexes",
"reminders": "Rappels", "reminders": "Rappels",
@ -726,8 +784,8 @@
"dateCurrentYear": "utilisera lannée en cours", "dateCurrentYear": "utilisera lannée en cours",
"dateNth": "utilisera le {day}e du mois en cours", "dateNth": "utilisera le {day}e du mois en cours",
"dateTime": "Combinez nimporte lequel des formats de date avec « {time} » (ou {timePM}) pour définir une heure.", "dateTime": "Combinez nimporte lequel des formats de date avec « {time} » (ou {timePM}) pour définir une heure.",
"repeats": "Repeating tasks", "repeats": "Tâches répétitives",
"repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)." "repeatsDescription": "Pour définir une tâche comme répétitive dans un intervalle, il suffit d'ajouter « {suffix} » au texte de la tâche. Le montant doit être un nombre et peut être omis pour utiliser uniquement le type (voir exemples)."
} }
}, },
"team": { "team": {
@ -776,17 +834,20 @@
"general": "Général", "general": "Général",
"allPages": "Fonctionne sur toutes les pages.", "allPages": "Fonctionne sur toutes les pages.",
"currentPageOnly": "Fonctionnent uniquement sur la page en cours.", "currentPageOnly": "Fonctionnent uniquement sur la page en cours.",
"somePagesOnly": "Ces raccourcis fonctionnent seulement sur certaines pages.",
"toggleMenu": "Basculer le menu", "toggleMenu": "Basculer le menu",
"quickSearch": "Ouvrir la barre de recherche/action rapide", "quickSearch": "Ouvrir la barre de recherche/action rapide",
"then": "puis", "then": "puis",
"task": { "task": {
"title": "Page de tâche", "title": "Page de tâche",
"done": "Done", "done": "Mark task done / undone",
"assign": "Assign to a user", "assign": "Assigner cette tâche à un utilisateur",
"labels": "Ajouter des étiquettes à cette tâche", "labels": "Ajouter des étiquettes à cette tâche",
"dueDate": "Modifier la date déchéance de cette tâche", "dueDate": "Modifier la date déchéance de cette tâche",
"attachment": "Ajouter une pièce jointe à cette tâche", "attachment": "Ajouter une pièce jointe à cette tâche",
"related": "Modifier les tâches connexes de cette tâche" "related": "Modifier les tâches connexes de cette tâche",
"color": "Changer la couleur de cette tâche",
"move": "Déplacer cette tâche dans une autre liste"
}, },
"list": { "list": {
"title": "Vues en liste", "title": "Vues en liste",
@ -794,6 +855,14 @@
"switchToGanttView": "Passer en vue Gantt", "switchToGanttView": "Passer en vue Gantt",
"switchToKanbanView": "Passer en vue kanban", "switchToKanbanView": "Passer en vue kanban",
"switchToTableView": "Passer en vue tableau" "switchToTableView": "Passer en vue tableau"
},
"navigation": {
"title": "Navigation",
"overview": "Accéder à la vue d'ensemble",
"upcoming": "Accéder aux prochaines tâches",
"namespaces": "Accéder aux espaces de noms et listes",
"labels": "Accéder aux étiquettes",
"teams": "Accéder aux équipes"
} }
}, },
"update": { "update": {
@ -814,7 +883,7 @@
"url": "URL Vikunja", "url": "URL Vikunja",
"urlPlaceholder": "Par exemple : https://localhost:3456", "urlPlaceholder": "Par exemple : https://localhost:3456",
"change": "changer", "change": "changer",
"use": "Using Vikunja installation at {0}", "use": "Utiliser linstallation de Vikunja à {0}",
"error": "Impossible de trouver ou d'utiliser l'installation de Vikunja sur « {domain} ». Veuillez essayer une autre URL.", "error": "Impossible de trouver ou d'utiliser l'installation de Vikunja sur « {domain} ». Veuillez essayer une autre URL.",
"success": "Utilisation de linstallation Vikunja à « {domain} ».", "success": "Utilisation de linstallation Vikunja à « {domain} ».",
"urlRequired": "Une URL est requise." "urlRequired": "Une URL est requise."
@ -899,7 +968,7 @@
"4015": "Le commentaire de la tâche nexiste pas.", "4015": "Le commentaire de la tâche nexiste pas.",
"4016": "Champ de tâche invalide.", "4016": "Champ de tâche invalide.",
"4017": "Comparateur de filtre de tâche invalide.", "4017": "Comparateur de filtre de tâche invalide.",
"4018": "Invalid task filter concatenator.", "4018": "Concaténateur de filtre de tâche invalide.",
"4019": "Valeur de filtre de tâche invalide.", "4019": "Valeur de filtre de tâche invalide.",
"5001": "Lespace de noms nexiste pas.", "5001": "Lespace de noms nexiste pas.",
"5003": "Tu nas pas accès à lespace de noms indiqué.", "5003": "Tu nas pas accès à lespace de noms indiqué.",
@ -908,7 +977,7 @@
"5010": "Cette équipe na pas accès à cet espace de noms.", "5010": "Cette équipe na pas accès à cet espace de noms.",
"5011": "Cet·e utilisateur·rice a déjà accès à cet espace de noms.", "5011": "Cet·e utilisateur·rice a déjà accès à cet espace de noms.",
"5012": "Lespace de noms est archivé et ne peut donc être consulté quen lecture seule.", "5012": "Lespace de noms est archivé et ne peut donc être consulté quen lecture seule.",
"6001": "The team name cannot be empty.", "6001": "Le nom de l'équipe ne peut pas être vide.",
"6002": "Léquipe nexiste pas.", "6002": "Léquipe nexiste pas.",
"6004": "Léquipe a déjà accès à cet espace de noms ou à cette liste.", "6004": "Léquipe a déjà accès à cet espace de noms ou à cette liste.",
"6005": "Lutilisateur·rice est déjà membre de cette équipe.", "6005": "Lutilisateur·rice est déjà membre de cette équipe.",

View File

@ -7,7 +7,7 @@
"lastViewed": "Ultima visualizzazione", "lastViewed": "Ultima visualizzazione",
"list": { "list": {
"newText": "È possibile creare una nuova lista per le nuove attività:", "newText": "È possibile creare una nuova lista per le nuove attività:",
"new": "New list", "new": "Nuova lista",
"importText": "O importare le liste e le attività da altri servizi in Vikunja:", "importText": "O importare le liste e le attività da altri servizi in Vikunja:",
"import": "Importa i tuoi dati in Vikunja" "import": "Importa i tuoi dati in Vikunja"
} }
@ -17,14 +17,14 @@
"text": "La pagina richiesta non esiste." "text": "La pagina richiesta non esiste."
}, },
"ready": { "ready": {
"loading": "Vikunja is loading…", "loading": "Vikunja sta caricando…",
"errorOccured": "An error occured:", "errorOccured": "Si è verificato un errore:",
"checkApiUrl": "Please check if the api url is correct.", "checkApiUrl": "Controlla se l'URL API è corretto.",
"noApiUrlConfigured": "No API url was configured. Please set one below:" "noApiUrlConfigured": "Nessun URL API configurato. Impostane uno qui sotto:"
}, },
"offline": { "offline": {
"title": "You are offline.", "title": "Sei offline.",
"text": "Please check your network connection and try again." "text": "Controlla la connessione di rete e riprova."
}, },
"user": { "user": {
"auth": { "auth": {
@ -34,9 +34,8 @@
"email": "Indirizzo e-mail", "email": "Indirizzo e-mail",
"emailPlaceholder": "per es. frederic{'@'}vikunja.io", "emailPlaceholder": "per es. frederic{'@'}vikunja.io",
"password": "Password", "password": "Password",
"passwordRepeat": "Digita di nuovo la tua password",
"passwordPlaceholder": "es. ••••••••••••", "passwordPlaceholder": "es. ••••••••••••",
"forgotPassword": "Forgot your password?", "forgotPassword": "Password dimenticata?",
"resetPassword": "Reimposta la tua password", "resetPassword": "Reimposta la tua password",
"resetPasswordAction": "Inviami il link per reimpostare la password", "resetPasswordAction": "Inviami il link per reimpostare la password",
"resetPasswordSuccess": "Controlla la tua casella di posta! Dovresti avere un'e-mail con le istruzioni su come reimpostare la password.", "resetPasswordSuccess": "Controlla la tua casella di posta! Dovresti avere un'e-mail con le istruzioni su come reimpostare la password.",
@ -45,12 +44,20 @@
"totpTitle": "Codice di autenticazione a due fattori", "totpTitle": "Codice di autenticazione a due fattori",
"totpPlaceholder": "es. 123456", "totpPlaceholder": "es. 123456",
"login": "Accedi", "login": "Accedi",
"register": "Registrati", "createAccount": "Crea account",
"loginWith": "Accedi con {provider}", "loginWith": "Accedi con {provider}",
"authenticating": "Autenticazione…", "authenticating": "Autenticazione…",
"openIdStateError": "State does not match, refusing to continue!", "openIdStateError": "Stato non corrispondente, impossibile continuare!",
"openIdGeneralError": "Si è verificato un errore durante l'autenticazione con terze parti.", "openIdGeneralError": "Si è verificato un errore durante l'autenticazione con terze parti.",
"logout": "Esci" "logout": "Esci",
"emailInvalid": "Inserisci un indirizzo e-mail valido.",
"usernameRequired": "Inserisci un nome utente.",
"passwordRequired": "Inserisci una password.",
"showPassword": "Mostra la password",
"hidePassword": "Nascondi la password",
"noAccountYet": "Non hai un account?",
"alreadyHaveAnAccount": "Hai già un account?",
"remember": "Resta connesso"
}, },
"settings": { "settings": {
"title": "Impostazioni", "title": "Impostazioni",
@ -61,7 +68,7 @@
"currentPasswordPlaceholder": "La tua password attuale", "currentPasswordPlaceholder": "La tua password attuale",
"passwordsDontMatch": "La nuova password e la conferma non coincidono.", "passwordsDontMatch": "La nuova password e la conferma non coincidono.",
"passwordUpdateSuccess": "Password aggiornata con successo.", "passwordUpdateSuccess": "Password aggiornata con successo.",
"updateEmailTitle": "Inserisci il tuo indirizzo e-mail", "updateEmailTitle": "Aggiorna l'indirizzo e-mail",
"updateEmailNew": "Nuovo indirizzo e-mail", "updateEmailNew": "Nuovo indirizzo e-mail",
"updateEmailSuccess": "Il tuo indirizzo e-mail è stato aggiornato correttamente. Ti abbiamo inviato un collegamento per confermarlo.", "updateEmailSuccess": "Il tuo indirizzo e-mail è stato aggiornato correttamente. Ti abbiamo inviato un collegamento per confermarlo.",
"general": { "general": {
@ -78,7 +85,8 @@
"weekStartSunday": "Domenica", "weekStartSunday": "Domenica",
"weekStartMonday": "Lunedì", "weekStartMonday": "Lunedì",
"language": "Lingua", "language": "Lingua",
"defaultList": "Lista predefinita" "defaultList": "Lista predefinita",
"timezone": "Fuso Orario"
}, },
"totp": { "totp": {
"title": "Autenticazione a due fattori", "title": "Autenticazione a due fattori",
@ -95,39 +103,39 @@
"disableSuccess": "L'autenticazione a due fattori è stata disattivata." "disableSuccess": "L'autenticazione a due fattori è stata disattivata."
}, },
"caldav": { "caldav": {
"title": "CalDav", "title": "CalDAV",
"howTo": "Puoi connettere Vikunja ai client caldav per visualizzare e gestire tutte le attività da diversi client. Inserisci questo URL nel tuo client:", "howTo": "Puoi connettere Vikunja ai client CalDAV per visualizzare e gestire tutte le attività da diversi client. Inserisci questo URL nel tuo client:",
"more": "Ulteriori informazioni su caldav in Vikunja" "more": "Ulteriori informazioni su CalDAV in Vikunja"
}, },
"avatar": { "avatar": {
"title": "Avatar", "title": "Avatar",
"initials": "Iniziali", "initials": "Iniziali",
"gravatar": "Gravatar", "gravatar": "Gravatar",
"marble": "Marble", "marble": "Marmo",
"upload": "Carica", "upload": "Carica",
"uploadAvatar": "Carica Avatar", "uploadAvatar": "Carica Avatar",
"statusUpdateSuccess": "Avatar status was updated successfully!", "statusUpdateSuccess": "Avatar aggiornato!",
"setSuccess": "L'avatar è stato impostato con successo!" "setSuccess": "L'avatar è stato impostato con successo!"
}, },
"quickAddMagic": { "quickAddMagic": {
"title": "Quick Add Magic Mode", "title": "Modalità Aggiunta Rapida Magica",
"disabled": "Disabilitato", "disabled": "Disabilitato",
"todoist": "Todoist", "todoist": "Todoist",
"vikunja": "Vikunja" "vikunja": "Vikunja"
}, },
"appearance": { "appearance": {
"title": "Color Scheme", "title": "Tema",
"setSuccess": "Saved change of color scheme to {colorScheme}", "setSuccess": "Tema cambiato in {colorScheme}",
"colorScheme": { "colorScheme": {
"light": "Light", "light": "Chiaro",
"system": "System", "system": "Sistema",
"dark": "Dark" "dark": "Scuro"
} }
} }
}, },
"deletion": { "deletion": {
"title": "Delete your Vikunja Account", "title": "Elimina il tuo Account Vikunja",
"text1": "The deletion of your account is permanent and cannot be undone. We will delete all your namespaces, lists, tasks and everything associated with it.", "text1": "La cancellazione del tuo account è permanente e non può essere annullata. Elimineremo tutti i tuoi namespace, liste, attività e tutto ciò che è ad esso associato.",
"text2": "Per continuare, inserisci la tua password. Riceverai un'e-mail con ulteriori istruzioni.", "text2": "Per continuare, inserisci la tua password. Riceverai un'e-mail con ulteriori istruzioni.",
"confirm": "Elimina il mio profilo", "confirm": "Elimina il mio profilo",
"requestSuccess": "Richiesta riuscita. Riceverai un'e-mail con ulteriori istruzioni.", "requestSuccess": "Richiesta riuscita. Riceverai un'e-mail con ulteriori istruzioni.",
@ -141,7 +149,7 @@
}, },
"export": { "export": {
"title": "Esporta i tuoi dati Vikunja", "title": "Esporta i tuoi dati Vikunja",
"description": "You can request a copy of all your Vikunja data. This include Namespaces, Lists, Tasks and everything associated to them. You can import this data in any Vikunja instance through the migration function.", "description": "Puoi richiedere una copia di tutti i tuoi dati all'interno di Vikunja. Questo include i Namespace, le Liste, le Attività e tutto ciò che è loro associato. È possibile importare questi dati in qualsiasi istanza Vikunja attraverso la funzione di migrazione.",
"descriptionPasswordRequired": "Inserisci la tua password per procedere:", "descriptionPasswordRequired": "Inserisci la tua password per procedere:",
"request": "Richiedi una copia dei miei dati Vikunja", "request": "Richiedi una copia dei miei dati Vikunja",
"success": "Hai richiesto con successo i tuoi dati Vikunja! Ti invieremo un'e-mail una volta che saranno pronti da scaricare.", "success": "Hai richiesto con successo i tuoi dati Vikunja! Ti invieremo un'e-mail una volta che saranno pronti da scaricare.",
@ -157,7 +165,7 @@
"searchSelect": "Fare clic o premere invio per selezionare questa lista", "searchSelect": "Fare clic o premere invio per selezionare questa lista",
"shared": "Liste Condivise", "shared": "Liste Condivise",
"create": { "create": {
"header": "New list", "header": "Nuova lista",
"titlePlaceholder": "Il titolo della lista va qui…", "titlePlaceholder": "Il titolo della lista va qui…",
"addTitleRequired": "Specifica un titolo.", "addTitleRequired": "Specifica un titolo.",
"createdSuccess": "La lista è stata creata correttamente.", "createdSuccess": "La lista è stata creata correttamente.",
@ -191,7 +199,7 @@
"duplicate": { "duplicate": {
"title": "Duplica questa lista", "title": "Duplica questa lista",
"label": "Duplica", "label": "Duplica",
"text": "Select a namespace which should hold the duplicated list:", "text": "Seleziona un namespace che dovrebbe contenere l'elenco duplicato:",
"success": "Lista duplicata." "success": "Lista duplicata."
}, },
"edit": { "edit": {
@ -279,23 +287,23 @@
"title": "Kanban", "title": "Kanban",
"limit": "Limite: {limit}", "limit": "Limite: {limit}",
"noLimit": "Non Impostato", "noLimit": "Non Impostato",
"doneBucket": "Done bucket", "doneBucket": "Colonna attività completate",
"doneBucketHint": "All tasks moved into this bucket will automatically marked as done.", "doneBucketHint": "Tutte le attività spostate in questa colonna verranno automaticamente contrassegnate come completate.",
"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.", "doneBucketHintExtended": "Tutte le attività spostate nella colonna attività completate saranno contrassegnate automaticamente come completate. Tutte le attività contrassegnate come completate altrove verranno anche spostate.",
"doneBucketSavedSuccess": "The done bucket has been saved successfully.", "doneBucketSavedSuccess": "Colonna attività completate salvata.",
"deleteLast": "You cannot remove the last bucket.", "deleteLast": "Impossibile eliminare l'ultima colonna.",
"addTaskPlaceholder": "Enter the new task title…", "addTaskPlaceholder": "Inserisci il nuovo titolo dell'attività…",
"addTask": "Aggiungi un'attività", "addTask": "Aggiungi un'attività",
"addAnotherTask": "Aggiungi un'altra attività", "addAnotherTask": "Aggiungi un'altra attività",
"addBucket": "Create a new bucket", "addBucket": "Crea una nuova colonna",
"addBucketPlaceholder": "Enter the new bucket title…", "addBucketPlaceholder": "Inserisci il titolo della nuova colonna…",
"deleteHeaderBucket": "Delete the bucket", "deleteHeaderBucket": "Elimina la colonna",
"deleteBucketText1": "Are you sure you want to delete this bucket?", "deleteBucketText1": "Confermi di voler eliminare questa colonna?",
"deleteBucketText2": "This will not delete any tasks but move them into the default bucket.", "deleteBucketText2": "Questo non eliminerà nessuna attività, ma la sposterà nel bucket predefinito.",
"deleteBucketSuccess": "The bucket has been deleted successfully.", "deleteBucketSuccess": "Colonna eliminata.",
"bucketTitleSavedSuccess": "The bucket title has been saved successfully.", "bucketTitleSavedSuccess": "Titolo della colonna salvato.",
"bucketLimitSavedSuccess": "The bucket limit been saved successfully.", "bucketLimitSavedSuccess": "Limite della colonna salvato.",
"collapse": "Collapse this bucket" "collapse": "Comprimi questa colonna"
}, },
"pseudo": { "pseudo": {
"favorites": { "favorites": {
@ -304,52 +312,53 @@
} }
}, },
"namespace": { "namespace": {
"title": "Namespaces & Lists", "title": "Namespace e Liste",
"namespace": "Namespace", "namespace": "Namespace",
"showArchived": "Show Archived", "showArchived": "Mostra Archiviati",
"noneAvailable": "You don't have any namespaces right now.", "noneAvailable": "Non hai alcun namespace in questo momento.",
"unarchive": "Un-Archive", "unarchive": "De-Archivia",
"archived": "Archived", "archived": "Archiviato",
"noLists": "This namespace does not contain any lists.", "noLists": "Questo namespace non contiene alcuna lista.",
"createList": "Create a new list in this namespace.", "createList": "Crea una nuova lista in questo namespace.",
"namespaces": "Namespaces", "namespaces": "Namespace",
"search": "Type to search for a namespace…", "search": "Digita per cercare un namespace…",
"create": { "create": {
"title": "New namespace", "title": "Nuovo namespace",
"titleRequired": "Please specify a title.", "titleRequired": "Specifica un titolo.",
"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.", "explanation": "Un namespace è una raccolta di liste che puoi condividere e che puoi usare per organizzare le tue liste. Infatti, ogni lista appartiene a un namespace.",
"tooltip": "What's a namespace?", "tooltip": "Che cos'è un namespace?",
"success": "The namespace was successfully created." "success": "Namespace creato."
}, },
"archive": { "archive": {
"titleArchive": "Archivia \"{namespace}\"", "titleArchive": "Archivia \"{namespace}\"",
"titleUnarchive": "Un-Archive \"{namespace}\"", "titleUnarchive": "Disarchivia \"{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.", "archiveText": "Non sarà possibile modificare questo namespace o creare nuove liste fino a quando non verrà disarchiviato. Questo archivierà anche tutte le liste in questo namespace.",
"unarchiveText": "You will be able to create new lists or edit it.", "unarchiveText": "Potrai creare nuove liste o modificarle.",
"success": "The namespace was successfully archived.", "success": "Namespace creato.",
"description": "If a namespace is archived, you cannot create new lists or edit it." "unarchiveSuccess": "Namespace estratto dall'archivio.",
"description": "Se un namespace è archiviato, non è possibile creare nuove liste o modificarlo."
}, },
"delete": { "delete": {
"title": "Delete \"{namespace}\"", "title": "Elimina \"{namespace}\"",
"text1": "Are you sure you want to delete this namespace and all of its contents?", "text1": "Sei sicuro di voler rimuovere questo namespace e tutto il relativo contenuto?",
"text2": "Questo include tutte le liste e le attività e NON PUÒ ESSERE RIPRISTINATO!", "text2": "Questo include tutte le liste e le attività e NON PUÒ ESSERE RIPRISTINATO!",
"success": "The namespace was successfully deleted." "success": "Namespace eliminato."
}, },
"edit": { "edit": {
"title": "Modifica \"{namespace}\"", "title": "Modifica \"{namespace}\"",
"success": "The namespace was successfully updated." "success": "Namespace aggiornato."
}, },
"share": { "share": {
"title": "Condividi \"{namespace}\"" "title": "Condividi \"{namespace}\""
}, },
"attributes": { "attributes": {
"title": "Namespace Title", "title": "Titolo del Namespace",
"titlePlaceholder": "The namespace title goes here…", "titlePlaceholder": "Il titolo del namespace va qui…",
"description": "Descrizione", "description": "Descrizione",
"descriptionPlaceholder": "The namespaces description goes here…", "descriptionPlaceholder": "La descrizione del namespace va qui…",
"color": "Colore", "color": "Colore",
"archived": "Is Archived", "archived": "Archiviato",
"isArchived": "This namespace is archived" "isArchived": "Questo namespace è archiviato"
}, },
"pseudo": { "pseudo": {
"sharedLists": { "sharedLists": {
@ -365,7 +374,7 @@
}, },
"filters": { "filters": {
"title": "Filtri", "title": "Filtri",
"clear": "Clear Filters", "clear": "Pulisci Filtri",
"attributes": { "attributes": {
"title": "Titolo", "title": "Titolo",
"titlePlaceholder": "Il titolo del filtro salvato va qui…", "titlePlaceholder": "Il titolo del filtro salvato va qui…",
@ -374,17 +383,17 @@
"includeNulls": "Includi attività che non hanno un valore impostato", "includeNulls": "Includi attività che non hanno un valore impostato",
"requireAll": "Tutti i filtri devono essere veri affinché l'attività venga mostrata", "requireAll": "Tutti i filtri devono essere veri affinché l'attività venga mostrata",
"showDoneTasks": "Mostra Attività Fatte", "showDoneTasks": "Mostra Attività Fatte",
"sortAlphabetically": "Sort Alphabetically", "sortAlphabetically": "Ordine alfabetico",
"enablePriority": "Abilita Filtro Per Priorità", "enablePriority": "Abilita Filtro Per Priorità",
"enablePercentDone": "Abilitare Filtro Per Percentuale Fatta", "enablePercentDone": "Abilita Filtro Per Progresso",
"dueDateRange": "Intervallo Data Di Scadenza", "dueDateRange": "Intervallo Data Di Scadenza",
"startDateRange": "Intervallo Data Iniziale", "startDateRange": "Intervallo Data Iniziale",
"endDateRange": "Intervallo Data Finale", "endDateRange": "Intervallo Data Finale",
"reminderRange": "Reminder Date Range" "reminderRange": "Intervallo date dei promemoria"
}, },
"create": { "create": {
"title": "New Saved Filter", "title": "Nuovo Filtro Salvato",
"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.", "description": "Un filtro salvato è una lista virtuale che viene calcolata da un insieme di filtri di volta in volta. Una volta creato, apparirà in un namespace speciale.",
"action": "Crea nuovo filtro salvato" "action": "Crea nuovo filtro salvato"
}, },
"delete": { "delete": {
@ -446,9 +455,9 @@
}, },
"navigation": { "navigation": {
"overview": "Panoramica", "overview": "Panoramica",
"upcoming": "Upcoming", "upcoming": "Prossimamente",
"settings": "Impostazioni", "settings": "Impostazioni",
"imprint": "Imprint", "imprint": "Informazioni legali",
"privacy": "Politica sulla Privacy" "privacy": "Politica sulla Privacy"
}, },
"misc": { "misc": {
@ -464,19 +473,20 @@
"searchPlaceholder": "Digita per cercare…", "searchPlaceholder": "Digita per cercare…",
"previous": "Precedente", "previous": "Precedente",
"next": "Successivo", "next": "Successivo",
"poweredBy": "Powered by Vikunja", "poweredBy": "Creato con Vikunja",
"info": "Info", "info": "Info",
"create": "Create", "create": "Crea",
"doit": "Fallo!", "doit": "Fallo!",
"saving": "Salvataggio…", "saving": "Salvataggio…",
"saved": "Salvato!", "saved": "Salvato!",
"default": "Predefinito", "default": "Predefinito",
"close": "Chiudi", "close": "Chiudi",
"download": "Scarica", "download": "Scarica",
"showMenu": "Show the menu", "showMenu": "Mostra il menu",
"hideMenu": "Hide the menu", "hideMenu": "Nascondi il menù",
"forExample": "For example:", "forExample": "Ad esempio:",
"welcomeBack": "Welcome Back!" "welcomeBack": "Bentornato!",
"custom": "Custom"
}, },
"input": { "input": {
"resetColor": "Ripristina Colore", "resetColor": "Ripristina Colore",
@ -485,9 +495,9 @@
"tomorrow": "Domani", "tomorrow": "Domani",
"nextMonday": "Lunedì Prossimo", "nextMonday": "Lunedì Prossimo",
"thisWeekend": "Questo fine settimana", "thisWeekend": "Questo fine settimana",
"laterThisWeek": "Later This Week", "laterThisWeek": "Alla fine di questa settimana",
"nextWeek": "Prossima Settimana", "nextWeek": "Prossima Settimana",
"chooseDate": "Choose a date" "chooseDate": "Seleziona una data"
}, },
"editor": { "editor": {
"edit": "Modifica", "edit": "Modifica",
@ -504,17 +514,68 @@
"quote": "Citazione", "quote": "Citazione",
"unorderedList": "Elenco puntato", "unorderedList": "Elenco puntato",
"orderedList": "Elenco numerato", "orderedList": "Elenco numerato",
"cleanBlock": "Clean Block", "cleanBlock": "Pulisci Blocco",
"link": "Link", "link": "Link",
"image": "Immagine", "image": "Immagine",
"table": "Tabella", "table": "Tabella",
"horizontalRule": "Horizontal Rule", "horizontalRule": "Divisore Orizzontale",
"sideBySide": "Side By Side", "sideBySide": "Affianca",
"guide": "Guide" "guide": "Guida"
}, },
"multiselect": { "multiselect": {
"createPlaceholder": "Create new", "createPlaceholder": "Crea nuovo",
"selectPlaceholder": "Clicca o premere invio per selezionare" "selectPlaceholder": "Clicca o premere invio per selezionare"
},
"datepickerRange": {
"to": "To",
"from": "From",
"fromto": "{from} to {to}",
"ranges": {
"today": "Today",
"thisWeek": "This Week",
"restOfThisWeek": "The Rest of This Week",
"nextWeek": "Next Week",
"next7Days": "Next 7 Days",
"lastWeek": "Last Week",
"thisMonth": "This Month",
"restOfThisMonth": "The Rest of This Month",
"nextMonth": "Next Month",
"next30Days": "Next 30 Days",
"lastMonth": "Last Month",
"thisYear": "This Year",
"restOfThisYear": "The Rest of This Year"
}
},
"datemathHelp": {
"canuse": "You can use date math to filter for relative dates.",
"learnhow": "Check out how it works",
"title": "Date Math",
"intro": "Date Math allows you to specifiy relative dates which are resolved on the fly by Vikunja when applying the filter.",
"expression": "Each Date Math expression starts with an anchor date, which can either be {0}, or a date string ending with {1}. This anchor date can optionally be followed by one or more maths expressions.",
"similar": "These expressions are similar to the ones provided by {0} and {1}.",
"add1Day": "Add one day",
"minus1Day": "Subtract one day",
"roundDay": "Round down to the nearest day",
"supportedUnits": "Supported time units are:",
"someExamples": "Some examples of time expressions:",
"units": {
"seconds": "Seconds",
"minutes": "Minutes",
"hours": "Hours",
"days": "Days",
"weeks": "Weeks",
"months": "Months",
"years": "Years"
},
"examples": {
"now": "Right now",
"in24h": "In 24h",
"today": "Today at 00:00",
"beginningOfThisWeek": "The beginning of this week at 00:00",
"endOfThisWeek": "The end of this week",
"in30Days": "In 30 days",
"datePlusMonth": "{0} plus one month at 00:00 of that day"
}
} }
}, },
"task": { "task": {
@ -532,20 +593,17 @@
"titleCurrent": "Attività Attuali", "titleCurrent": "Attività Attuali",
"titleDates": "Attività dal {from} al {to}", "titleDates": "Attività dal {from} al {to}",
"noDates": "Mostra attività senza date", "noDates": "Mostra attività senza date",
"current": "Attività attuali", "overdue": "Show overdue tasks",
"from": "Tasks from", "fromuntil": "Tasks from {from} until {until}",
"until": "until", "select": "Select a date range",
"today": "Oggi", "noTasks": "Nessuna attività — Buona giornata!"
"nextWeek": "Settimana Prossima",
"nextMonth": "Prossimo Mese",
"noTasks": "Nothing to do — Have a nice day!"
}, },
"detail": { "detail": {
"chooseDueDate": "Clicca qui per impostare una data di scadenza", "chooseDueDate": "Clicca qui per impostare una data di scadenza",
"chooseStartDate": "Clicca qui per impostare una data di inizio", "chooseStartDate": "Clicca qui per impostare una data di inizio",
"chooseEndDate": "Clicca qui per impostare una data di fine", "chooseEndDate": "Clicca qui per impostare una data di fine",
"move": "Sposta attività in un'altra lista", "move": "Sposta attività in un'altra lista",
"done": "Mark task done!", "done": "Segna attività fatta!",
"undone": "Segna come non completato", "undone": "Segna come non completato",
"created": "Creato {0} da {1}", "created": "Creato {0} da {1}",
"updated": "Aggiornato {0}", "updated": "Aggiornato {0}",
@ -554,29 +612,29 @@
"deleteSuccess": "L'attività è stata eliminata con successo.", "deleteSuccess": "L'attività è stata eliminata con successo.",
"belongsToList": "Questa attività appartiene alla lista '{list}'", "belongsToList": "Questa attività appartiene alla lista '{list}'",
"due": "Scadenza {at}", "due": "Scadenza {at}",
"closePopup": "Close popup", "closePopup": "Chiudi popup",
"delete": { "delete": {
"header": "Elimina questa attività", "header": "Elimina questa attività",
"text1": "Sei sicuro di voler eliminare questa attività?", "text1": "Sei sicuro di voler eliminare questa attività?",
"text2": "Questo rimuoverà anche tutti gli allegati, i promemoria e le relazioni associati a questa attività e non può essere ripristinato!" "text2": "Questo rimuoverà anche tutti gli allegati, i promemoria e le relazioni associati a questa attività e non può essere ripristinato!"
}, },
"actions": { "actions": {
"assign": "Assign to a user", "assign": "Assegna all'Utente",
"label": "Aggiungi etichette", "label": "Aggiungi Etichette",
"priority": "Imposta Priorità", "priority": "Imposta Priorità",
"dueDate": "Imposta data di scadenza", "dueDate": "Imposta data di scadenza",
"startDate": "Imposta una data di inizio", "startDate": "Imposta Data Inizio",
"endDate": "Imposta una data di fine", "endDate": "Imposta Data Fine",
"reminders": "Imposta promemoria", "reminders": "Imposta promemoria",
"repeatAfter": "Set a repeating interval", "repeatAfter": "Imposta Intervallo Ripetizione",
"percentDone": "Imposta Percentuale Completata", "percentDone": "Imposta Progresso",
"attachments": "Aggiungi allegati", "attachments": "Aggiungi Allegati",
"relatedTasks": "Aggiungi attività collegate", "relatedTasks": "Aggiungi Relazione",
"moveList": "Sposta attività", "moveList": "Sposta",
"color": "Imposta colore attività", "color": "Imposta Colore",
"delete": "Elimina attività", "delete": "Elimina",
"favorite": "Salva come preferito", "favorite": "Aggiungi ai Preferiti",
"unfavorite": "Rimuovi dai preferiti" "unfavorite": "Rimuovi dai Preferiti"
} }
}, },
"attributes": { "attributes": {
@ -589,7 +647,7 @@
"dueDate": "Data di scadenza", "dueDate": "Data di scadenza",
"endDate": "Data di fine", "endDate": "Data di fine",
"labels": "Etichette", "labels": "Etichette",
"percentDone": "% Completata", "percentDone": "Progresso",
"priority": "Priorità", "priority": "Priorità",
"relatedTasks": "Attività Collegate", "relatedTasks": "Attività Collegate",
"reminders": "Promemoria", "reminders": "Promemoria",
@ -599,13 +657,13 @@
"updated": "Aggiornato" "updated": "Aggiornato"
}, },
"subscription": { "subscription": {
"subscribedThroughParent": "You can't unsubscribe here because you are subscribed to this {entity} through its {parent}.", "subscribedThroughParent": "Non puoi annullare l'iscrizione qui perché sei iscritto a questo {entity} attraverso il suo {parent}.",
"subscribed": "You are currently subscribed to this {entity} and will receive notifications for changes.", "subscribed": "Sei attualmente iscritto a questo {entity} e riceverai notifiche per le modifiche.",
"notSubscribed": "You are not subscribed to this {entity} and won't receive notifications for changes.", "notSubscribed": "Non sei iscritto a questo {entity} e non riceverai notifiche per le modifiche.",
"subscribe": "Subscribe", "subscribe": "Iscriviti",
"unsubscribe": "Unsubscribe", "unsubscribe": "Disiscriviti",
"subscribeSuccess": "You are now subscribed to this {entity}", "subscribeSuccess": "Ti sei iscritto a questo {entity}",
"unsubscribeSuccess": "You are now unsubscribed to this {entity}" "unsubscribeSuccess": "Ti sei disiscritto a questo {entity}"
}, },
"attachment": { "attachment": {
"title": "Allegati", "title": "Allegati",
@ -623,41 +681,41 @@
"comment": { "comment": {
"title": "Commenti", "title": "Commenti",
"loading": "Caricamento commenti…", "loading": "Caricamento commenti…",
"edited": "edited {date}", "edited": "modificato il {date}",
"creating": "Creazione del commento…", "creating": "Creazione del commento…",
"placeholder": "Aggiungi un commento…", "placeholder": "Aggiungi un commento…",
"comment": "Comment", "comment": "Commenta",
"delete": "Elimina questo commento", "delete": "Elimina questo commento",
"deleteText1": "Sei sicuro di voler eliminare questo commento?", "deleteText1": "Sei sicuro di voler eliminare questo commento?",
"deleteText2": "Questa azione non può essere annullata!", "deleteText2": "Questa azione non può essere annullata!",
"addedSuccess": "Il commento è stato aggiunto correttamente." "addedSuccess": "Il commento è stato aggiunto correttamente."
}, },
"deferDueDate": { "deferDueDate": {
"title": "Defer due date", "title": "Rinvia data di scadenza",
"1day": "1 giorno", "1day": "1 giorno",
"3days": "3 giorni", "3days": "3 giorni",
"1week": "1 settimana" "1week": "1 settimana"
}, },
"description": { "description": {
"placeholder": "Click here to enter a description…", "placeholder": "Clicca qui per inserire una descrizione…",
"empty": "No description available yet." "empty": "Nessuna descrizione."
}, },
"assignee": { "assignee": {
"placeholder": "Type to assign a user…", "placeholder": "Digita per assegnare un utente…",
"selectPlaceholder": "Assegna questo utente", "selectPlaceholder": "Assegna questo utente",
"assignSuccess": "The user has been assigned successfully.", "assignSuccess": "Utente assegnato.",
"unassignSuccess": "The user has been unassigned successfully." "unassignSuccess": "Utente disassegnato."
}, },
"label": { "label": {
"placeholder": "Type to add a new label…", "placeholder": "Digita per aggiungere una nuova etichetta…",
"createPlaceholder": "Add this as new label", "createPlaceholder": "Aggiungila come nuova etichetta",
"addSuccess": "Etichetta aggiunta.", "addSuccess": "Etichetta aggiunta.",
"createSuccess": "Etichetta creata.", "createSuccess": "Etichetta creata.",
"removeSuccess": "Etichetta eliminata.", "removeSuccess": "Etichetta eliminata.",
"addCreateSuccess": "Etichetta creata e aggiunta." "addCreateSuccess": "Etichetta creata e aggiunta."
}, },
"priority": { "priority": {
"unset": "Unset", "unset": "Azzera",
"low": "Bassa", "low": "Bassa",
"medium": "Media", "medium": "Media",
"high": "Alta", "high": "Alta",
@ -665,38 +723,38 @@
"doNow": "FARE ORA" "doNow": "FARE ORA"
}, },
"relation": { "relation": {
"add": "Add a New Task Relation", "add": "Aggiungi Attività Collegata",
"new": "New Task Relation", "new": "Nuova Attività Collegata",
"searchPlaceholder": "Type search for a new task to add as related…", "searchPlaceholder": "Digita per cercare un'attività da aggiungere come collegata…",
"createPlaceholder": "Add this as new related task", "createPlaceholder": "Aggiungi come attività collegata",
"differentList": "This task belongs to a different list.", "differentList": "Questa attività è di una lista diversa.",
"differentNamespace": "This task belongs to a different namespace.", "differentNamespace": "Questa attività appartiene ad un namespace diverso.",
"noneYet": "No task relations yet.", "noneYet": "Nessuna attività collegata.",
"delete": "Delete Task Relation", "delete": "Elimina Collegamento Attività",
"deleteText1": "Are you sure you want to delete this task relation?", "deleteText1": "Confermi di voler eliminare questo collegamento attività?",
"deleteText2": "Questa azione non può essere annullata!", "deleteText2": "Questa azione non può essere annullata!",
"select": "Select a relation kind", "select": "Seleziona un tipo di collegamento",
"kinds": { "kinds": {
"subtask": "Subtask | Subtasks", "subtask": "Sotto-attività | Sotto-attività",
"parenttask": "Parent Task | Parent Tasks", "parenttask": "Attività Principale | Attività Principale",
"related": "Related Task | Related Tasks", "related": "Attività Correlata | Attività Correlata",
"duplicateof": "Duplicato Di | Duplicati Di", "duplicateof": "Duplicato Di | Duplicati Di",
"duplicates": "Duplicates | Duplicates", "duplicates": "Duplicato | Duplicati",
"blocking": "Blocking | Blocking", "blocking": "Bloccante | Bloccanti",
"blocked": "Blocked By | Blocked By", "blocked": "Bloccato Da | Bloccati Da",
"precedes": "Precedes | Precedes", "precedes": "Precede | Precede",
"follows": "Follows | Follows", "follows": "Segue | Segue",
"copiedfrom": "Copied From | Copied From", "copiedfrom": "Copiata Da | Copiate Da",
"copiedto": "Copied To | Copied To" "copiedto": "Copiata In | Copiate In"
} }
}, },
"repeat": { "repeat": {
"everyDay": "Ogni Giorno", "everyDay": "Ogni Giorno",
"everyWeek": "Ogni Settimana", "everyWeek": "Ogni Settimana",
"everyMonth": "Ogni Mese", "everyMonth": "Ogni Mese",
"mode": "Repeat mode", "mode": "Modalità Ripetizione",
"monthly": "Mensilmente", "monthly": "Mensilmente",
"fromCurrentDate": "From Current Date", "fromCurrentDate": "Dalla Data Attuale",
"each": "Ogni", "each": "Ogni",
"specifyAmount": "Specifica una quantità…", "specifyAmount": "Specifica una quantità…",
"hours": "Ore", "hours": "Ore",
@ -706,32 +764,32 @@
"years": "Anni" "years": "Anni"
}, },
"quickAddMagic": { "quickAddMagic": {
"hint": "You can use Quick Add Magic", "hint": "Puoi usare l'Aggiunta Rapida Magica",
"what": "Cosa?", "what": "Cosa?",
"title": "Quick Add Magic", "title": "Aggiunta Rapida Magica",
"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.", "intro": "Quando si crea un'attività, è possibile utilizzare parole chiave speciali per aggiungere direttamente attributi all'attività appena creata. Questo permette di aggiungere gli attributi comuni molto più velocemente.",
"multiple": "Puoi usarlo più volte.", "multiple": "Puoi usarlo più volte.",
"label1": "To add a label, simply prefix the name of the label with {prefix}.", "label1": "Per aggiungere un'etichetta, basta aggiungere il nome dell'etichetta preceduto da {prefix}.",
"label2": "Vikunja will first check if the label already exist and create it if not.", "label2": "Vikunja controllerà prima se l'etichetta esiste già e nel caso la creerà.",
"label3": "To use spaces, simply add a \" around the label name.", "label3": "Per usare gli spazi, basta \" prima e dopo del nome dell'etichetta.",
"label4": "For example: {prefix}\"Label with spaces\".", "label4": "Per esempio: {prefix}\"Etichetta con spazi\".",
"priority1": "To set a task's priority, add a number 1-5, prefixed with a {prefix}.", "priority1": "Per impostare la priorità di un'attività, aggiungi un numero 1-5, preceduto da {prefix}.",
"priority2": "The higher the number, the higher the priority.", "priority2": "Più alto è il numero, più alta è la priorità.",
"assignees": "To directly assign the task to a user, add their username prefixed with {prefix} to the task.", "assignees": "Per assegnare direttamente l'attività a un utente, aggiungere il suo nome utente preceduto da {prefix} all'attività.",
"list1": "To set a list for the task to appear in, enter its name prefixed with {prefix}.", "list1": "Per impostare una lista di appartenenza all'attività, inserisci il suo nome prefisso con {prefix}.",
"list2": "This will return an error if the list does not exist.", "list2": "Ciò restituirà un errore se la lista non esiste.",
"dateAndTime": "Data e ora", "dateAndTime": "Data e ora",
"date": "Any date will be used as the due date of the new task. You can use dates in any of these formats:", "date": "Qualsiasi data verrà utilizzata come data di scadenza della nuova attività. È possibile utilizzare le date in uno qualsiasi di questi formati:",
"dateWeekday": "any weekday, will use the next date with that date", "dateWeekday": "qualsiasi giorno della settimana, userà la data più vicina",
"dateCurrentYear": "will use the current year", "dateCurrentYear": "userà lanno corrente",
"dateNth": "will use the {day}th of the current month", "dateNth": "userà il {day} del mese corrente",
"dateTime": "Combine any of the date formats with \"{time}\" (or {timePM}) to set a time.", "dateTime": "Combina uno qualsiasi dei formati di data con \"{time}\" (o {timePM}) per impostare un orario.",
"repeats": "Repeating tasks", "repeats": "Attività ricorrenti",
"repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)." "repeatsDescription": "Per impostare un'attività come ricorrente in un intervallo, basta aggiungere '{suffix}' al testo dell'attività. La quantità deve essere un numero e può essere omesso per usare solo il tipo (vedi esempi)."
} }
}, },
"team": { "team": {
"title": "Teams", "title": "Gruppi",
"noTeams": "Non fai parte di nessun gruppo.", "noTeams": "Non fai parte di nessun gruppo.",
"create": { "create": {
"title": "Crea un nuovo gruppo", "title": "Crea un nuovo gruppo",
@ -746,23 +804,23 @@
"makeAdmin": "Rendi Amministratore", "makeAdmin": "Rendi Amministratore",
"success": "Gruppo aggiornato.", "success": "Gruppo aggiornato.",
"userAddedSuccess": "Membro del gruppo aggiunto.", "userAddedSuccess": "Membro del gruppo aggiunto.",
"madeMember": "The team member was successfully made member.", "madeMember": "Membro del gruppo reso membro.",
"madeAdmin": "The team member was successfully made admin.", "madeAdmin": "Membro del gruppo reso amministratore.",
"delete": { "delete": {
"header": "Elimina il gruppo", "header": "Elimina il gruppo",
"text1": "Sei sicuro di voler eliminare questo gruppo e tutti i suoi membri?", "text1": "Sei sicuro di voler eliminare questo gruppo e tutti i suoi membri?",
"text2": "All team members will lose access to lists and namespaces shared with this team. This CANNOT BE UNDONE!", "text2": "Tutti i membri del gruppo perderanno l'accesso alle liste e ai namespace condivisi con questo gruppo. NON PUÒ ESSERE RIPRISTINATO!",
"success": "Gruppo eliminato." "success": "Gruppo eliminato."
}, },
"deleteUser": { "deleteUser": {
"header": "Rimuovi un utente dal gruppo", "header": "Rimuovi un utente dal gruppo",
"text1": "Confermi di voler rimuovere questo utente dal gruppo?", "text1": "Confermi di voler rimuovere questo utente dal gruppo?",
"text2": "They will lose access to all lists and namespaces this team has access to. This CANNOT BE UNDONE!", "text2": "Perderanno l'accesso a tutte le liste e i namespace a cui questo gruppo ha accesso. NON PUÒ ESSERE RIPRISTINATO!",
"success": "Utente rimosso dal gruppo." "success": "Utente rimosso dal gruppo."
} }
}, },
"attributes": { "attributes": {
"name": "Team Name", "name": "Nome Gruppo",
"namePlaceholder": "Il nome del gruppo va qui…", "namePlaceholder": "Il nome del gruppo va qui…",
"nameRequired": "Specifica un nome.", "nameRequired": "Specifica un nome.",
"description": "Descrizione", "description": "Descrizione",
@ -772,32 +830,43 @@
} }
}, },
"keyboardShortcuts": { "keyboardShortcuts": {
"title": "Keyboard Shortcuts", "title": "Tasti Rapidi",
"general": "General", "general": "Generali",
"allPages": "Queste scorciatoie funzionano in tutte le pagine.", "allPages": "Queste scorciatoie funzionano in tutte le pagine.",
"currentPageOnly": "Queste scorciatoie funzionano solo nella pagina attuale.", "currentPageOnly": "Queste scorciatoie funzionano solo nella pagina attuale.",
"somePagesOnly": "These shortcuts work only on some pages.",
"toggleMenu": "Attiva/Disattiva Menu", "toggleMenu": "Attiva/Disattiva Menu",
"quickSearch": "Apri la barra di ricerca/azione rapida", "quickSearch": "Apri la barra di ricerca/azione rapida",
"then": "then", "then": "e dopo",
"task": { "task": {
"title": "Task Page", "title": "Pagina Attività",
"done": "Done", "done": "Mark task done / undone",
"assign": "Assign to a user", "assign": "Assign this task to a user",
"labels": "Add labels to this task", "labels": "Aggiungi etichette a questa attività",
"dueDate": "Change the due date of this task", "dueDate": "Modifica la data di scadenza di questa attività",
"attachment": "Add an attachment to this task", "attachment": "Aggiungi un allegato a questa attività",
"related": "Modify related tasks of this task" "related": "Modifica le attività collegate a questa",
"color": "Change the color of this task",
"move": "Move this task to another list"
}, },
"list": { "list": {
"title": "List Views", "title": "Viste Liste",
"switchToListView": "Switch to list view", "switchToListView": "Passa alla vista Lista",
"switchToGanttView": "Switch to gantt view", "switchToGanttView": "Passa alla vista Gantt",
"switchToKanbanView": "Switch to kanban view", "switchToKanbanView": "Passa alla vista Kanban",
"switchToTableView": "Switch to table view" "switchToTableView": "Passa alla vista Tabella"
},
"navigation": {
"title": "Navigation",
"overview": "Navigato to overview",
"upcoming": "Navigato to upcoming taks",
"namespaces": "Navigate to namepaces & lists",
"labels": "Navigate to labels",
"teams": "Navigate to teams"
} }
}, },
"update": { "update": {
"available": "There is an update for Vikunja available!", "available": "È disponibile un aggiornamento per Vikunja!",
"do": "Aggiorna Adesso" "do": "Aggiorna Adesso"
}, },
"menu": { "menu": {
@ -805,136 +874,136 @@
"archive": "Archivia", "archive": "Archivia",
"duplicate": "Duplica", "duplicate": "Duplica",
"delete": "Elimina", "delete": "Elimina",
"unarchive": "Un-Archive", "unarchive": "Disarchivia",
"setBackground": "Set background", "setBackground": "Imposta sfondo",
"share": "Condividi", "share": "Condividi",
"newList": "Nuova lista" "newList": "Nuova lista"
}, },
"apiConfig": { "apiConfig": {
"url": "URL Vikunja", "url": "URL Vikunja",
"urlPlaceholder": "es. http://localhost:8080", "urlPlaceholder": "es. http://localhost:8080",
"change": "change", "change": "modifica",
"use": "Using Vikunja installation at {0}", "use": "Usa l'installazione di Vikunja a {0}",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please try a different url.", "error": "Impossibile trovare o usare l'installazione di Vikunja su \"{domain}\". Prova per favore con un altro Url.",
"success": "Using Vikunja installation at \"{domain}\".", "success": "Utilizzando l'installazione di Vikunja su \"{domain}\".",
"urlRequired": "A url is required." "urlRequired": "L'URL è obbligatorio."
}, },
"loadingError": { "loadingError": {
"failed": "Loading failed, please {0}. If the error persists, please {1}.", "failed": "Caricamento non riuscito, si prega di {0}. Se l'errore persiste, per favore {1}.",
"tryAgain": "try again", "tryAgain": "riprova",
"contact": "contact us" "contact": "Contattaci"
}, },
"notification": { "notification": {
"title": "Notifications", "title": "Notifiche",
"none": "You don't have any notifications. Have a nice day!", "none": "Nessuna notifica. Buona giornata!",
"explainer": "Notifications will appear here when actions on namespaces, lists or tasks you subscribed to happen." "explainer": "Le notifiche appariranno qui quando le azioni su Namespace, liste o attività a cui hai sottoscritto la sottoscrizione avvengono."
}, },
"quickActions": { "quickActions": {
"commands": "Commands", "commands": "Comandi",
"placeholder": "Type a command or search…", "placeholder": "Digita un comando o cerca…",
"hint": "You can use {list} to limit the search to a list. Combine {list} or {label} (labels) with a search query to search for a task with these labels or on that list. Use {assignee} to only search for teams.", "hint": "Puoi usare {list} per limitare la ricerca a una lista. Unisci {list} o {label} (etichette) alla ricerca per trovare un'attività con quelle etichette o in quella lista. Usa {assignee} per cercare solo i gruppi.",
"tasks": "Tasks", "tasks": "Attivitá",
"lists": "Liste", "lists": "Liste",
"teams": "Teams", "teams": "Gruppi",
"newList": "Enter the title of the new list…", "newList": "Inserisci il titolo della nuova lista…",
"newTask": "Enter the title of the new task…", "newTask": "Inserisci il titolo della nuova attività…",
"newNamespace": "Enter the title of the new namespace…", "newNamespace": "Inserisci il titolo del nuovo namespace…",
"newTeam": "Enter the name of the new team…", "newTeam": "Inserisci il nome del nuovo gruppo…",
"createTask": "Create a task in the current list ({title})", "createTask": "Crea un'attività nella lista attuale ({title})",
"createList": "Create a list in the current namespace ({title})", "createList": "Crea una lista nel namespace attuale ({title})",
"cmds": { "cmds": {
"newTask": "New task", "newTask": "Nuova attività",
"newList": "New list", "newList": "Nuova lista",
"newNamespace": "New namespace", "newNamespace": "Nuovo Namespace",
"newTeam": "New team" "newTeam": "Nuovo gruppo"
} }
}, },
"date": { "date": {
"locale": "en", "locale": "it",
"altFormatLong": "j M Y H:i", "altFormatLong": "j M Y H:i",
"altFormatShort": "j M Y" "altFormatShort": "j M Y"
}, },
"error": { "error": {
"error": "Errore", "error": "Errore",
"success": "Success", "success": "Fatto",
"0001": "Non ti è permesso farlo.", "0001": "Non ti è permesso farlo.",
"1001": "A user with this username already exists.", "1001": "Esiste già un utente con questo nome utente.",
"1002": "Un utente con questo indirizzo e-mail esiste già.", "1002": "Un utente con questo indirizzo e-mail esiste già.",
"1004": "No username and password specified.", "1004": "Nessun nome utente e password specificati.",
"1005": "L'utente non esiste.", "1005": "L'utente non esiste.",
"1006": "Impossibile ottenere l'id utente.", "1006": "Impossibile ottenere l'id utente.",
"1008": "No password reset token provided.", "1008": "Nessun codice di reimpostazione password fornito.",
"1009": "Invalid password reset token.", "1009": "Codice di reimpostazione password non valido.",
"1010": "Token di conferma dell'e-mail non valido.", "1010": "Token di conferma dell'e-mail non valido.",
"1011": "Wrong username or password.", "1011": "Nome utente o password errati.",
"1012": "Indirizzo e-mail dell'utente non confermato.", "1012": "Indirizzo e-mail dell'utente non confermato.",
"1013": "La nuova password è vuota.", "1013": "La nuova password è vuota.",
"1014": "La vecchia password è vuota.", "1014": "La vecchia password è vuota.",
"1015": "Autenticazione TOTP già abilitata per questo utente.", "1015": "Autenticazione TOTP già abilitata per questo utente.",
"1016": "Autenticazione TOTP non abilitata per questo utente.", "1016": "Autenticazione TOTP non abilitata per questo utente.",
"1017": "Codice TOTP non valido.", "1017": "Codice TOTP non valido.",
"1018": "The user avatar type setting is invalid.", "1018": "L'impostazione del tipo di avatar utente non è valida.",
"2001": "L'ID non può essere vuoto o 0.", "2001": "L'ID non può essere vuoto o 0.",
"2002": "Alcuni dati della richiesta non erano validi.", "2002": "Alcuni dati della richiesta non erano validi.",
"3001": "La lista non esiste.", "3001": "La lista non esiste.",
"3004": "You need to have read permissions on that list to perform that action.", "3004": "Devi avere i permessi di lettura su quella lista per eseguire quell'azione.",
"3005": "Il titolo della lista non può essere vuoto.", "3005": "Il titolo della lista non può essere vuoto.",
"3006": "The list share does not exist.", "3006": "La condivisione della lista non esiste.",
"3007": "Esiste già una lista con questo identificatore.", "3007": "Esiste già una lista con questo identificatore.",
"3008": "The list is archived and can therefore only be accessed read only. This is also true for all tasks associated with this list.", "3008": "La lista è archiviata e può quindi essere consultata solo in sola lettura. Questo vale anche per tutte le attività associate a questa lista.",
"4001": "The list task text cannot be empty.", "4001": "Il testo delle attività della lista non può essere vuoto.",
"4002": "The list task does not exist.", "4002": "Lista di attività non esistente.",
"4003": "Tutte le attività di modifica in blocco devono appartenere alla stessa lista.", "4003": "Tutte le attività di modifica in blocco devono appartenere alla stessa lista.",
"4004": "Hai bisogno di almeno un'attività quando si modificano in blocco le attività.", "4004": "Hai bisogno di almeno un'attività quando si modificano in blocco le attività.",
"4005": "Non hai il permesso di vedere l'attività.", "4005": "Non hai il permesso di vedere l'attività.",
"4006": "You can't set a parent task as the task itself.", "4006": "Non è possibile impostare un'attività principale come l'attività stessa.",
"4007": "You can't create a task relation with an invalid kind of relation.", "4007": "Non è possibile creare una relazione di attività con un tipo di relazione non valido.",
"4008": "You can't create a task relation which already exists.", "4008": "Non è possibile creare una relazione di attività già esistente.",
"4009": "The task relation does not exist.", "4009": "La relazione di attività non esiste.",
"4010": "Cannot relate a task with itself.", "4010": "Non è possibile relazionare un'attività con se stessa.",
"4011": "The task attachment does not exist.", "4011": "L'allegato dell'attività non esiste.",
"4012": "The task attachment is too large.", "4012": "L'allegato dell'attività è troppo grande.",
"4013": "The task sort param is invalid.", "4013": "Il parametro di ordinamento dei task non è valido.",
"4014": "The task sort order is invalid.", "4014": "L' ordinamento dei task non è valido.",
"4015": "The task comment does not exist.", "4015": "Il commento all'attività non esiste.",
"4016": "Invalid task field.", "4016": "Campo attività non valido.",
"4017": "Invalid task filter comparator.", "4017": "Comparatore di filtri attività non valido.",
"4018": "Invalid task filter concatenator.", "4018": "Concatenatore filtro attività non valido.",
"4019": "Invalid task filter value.", "4019": "Filtro attività non valido.",
"5001": "The namespace does not exist.", "5001": "Il namespace non esiste.",
"5003": "You do not have access to the specified namespace.", "5003": "Non hai accesso a questo namespace.",
"5006": "The namespace name cannot be empty.", "5006": "Il nome del namespace non può essere vuoto.",
"5009": "You need to have namespace read access to perform that action.", "5009": "Devi avere accesso in lettura al namespace per effettuare questa operazione.",
"5010": "This team does not have access to that namespace.", "5010": "Il tuo gruppo non ha accesso a questo namespace.",
"5011": "This user has already access to that namespace.", "5011": "Questo utente ha già accesso a quel namespace.",
"5012": "The namespace is archived and can therefore only be accessed read only.", "5012": "Il namespace è archiviato e può quindi essere accessibile solo in sola lettura.",
"6001": "The team name cannot be empty.", "6001": "Il nome del gruppo non può essere vuoto.",
"6002": "The team does not exist.", "6002": "Gruppo non esistente.",
"6004": "The team already has access to that namespace or list.", "6004": "Il team ha già accesso a questo namespace o lista.",
"6005": "The user is already a member of that team.", "6005": "L'utente è già membro di quel gruppo.",
"6006": "Cannot delete the last team member.", "6006": "Non è possibile eliminare l'ultimo membro del gruppo.",
"6007": "The team does not have access to the list to perform that action.", "6007": "Il gruppo non ha accesso alla lista per eseguire quell'azione.",
"7002": "The user already has access to that list.", "7002": "L'utente ha già accesso a quella lista.",
"7003": "Non hai accesso a quella lista.", "7003": "Non hai accesso a quella lista.",
"8001": "Questa etichetta esiste già in quell'attività.", "8001": "Questa etichetta esiste già in quell'attività.",
"8002": "L'etichetta non esiste.", "8002": "L'etichetta non esiste.",
"8003": "Non hai accesso a questa etichetta.", "8003": "Non hai accesso a questa etichetta.",
"9001": "The right is invalid.", "9001": "Permesso non valido.",
"10001": "The bucket does not exist.", "10001": "Colonna non esistente.",
"10002": "The bucket does not belong to that list.", "10002": "La colonna non appartiene a quella lista.",
"10003": "You cannot remove the last bucket on a list.", "10003": "Non puoi rimuovere l'ultima colonna di una lista.",
"10004": "You cannot add the task to this bucket as it already exceeded the limit of tasks it can hold.", "10004": "Non puoi aggiungere l'attività a questa colonna perché ha già superato il limite di attività che può contenere.",
"10005": "There can be only one done bucket per list.", "10005": "Ci può essere solo una colonna completati per lista.",
"11001": "The saved filter does not exist.", "11001": "Filtro salvato non esistente.",
"11002": "Saved filters are not available for link shares.", "11002": "I filtri salvati non sono disponibili per i link di condivisione.",
"12001": "The subscription entity type is invalid.", "12001": "Il tipo di entità sottoscritto non è valido.",
"12002": "You are already subscribed to the entity itself or a parent entity.", "12002": "Sei già iscritto all'entità stessa o a un'entità principale.",
"13001": "This link share requires a password for authentication, but none was provided.", "13001": "Questa condivisione di link richiede una password per l'autenticazione, ma non è stato inserita.",
"13002": "The provided link share password was invalid." "13002": "La password inserita per il link di condivisione è valida."
}, },
"about": { "about": {
"title": "About", "title": "Informazioni",
"frontendVersion": "Frontend Version: {version}", "frontendVersion": "Versione Frontend: {version}",
"apiVersion": "API Version: {version}" "apiVersion": "Versione API: {version}"
} }
} }

File diff suppressed because it is too large Load Diff

1009
src/i18n/lang/pl-PL.json Normal file

File diff suppressed because it is too large Load Diff

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