Compare commits

..

85 Commits

Author SHA1 Message Date
renovate b0102eb039 fix(deps): update dependency @infectoone/vue-ganttastic to v2.2.0
continuous-integration/drone/pr Build is failing Details
2023-09-06 14:09:13 +00:00
kolaente 3fec92283b
fix(task): priority label sizing and positioning in different environments
continuous-integration/drone/push Build is failing Details
2023-09-06 15:58:52 +02:00
kolaente beb016400e
feat(task): move task priority to the front when showing tasks inline
continuous-integration/drone/push Build is failing Details
2023-09-06 15:53:40 +02:00
kolaente 7746d39161
fix(task): remove wrong repeat types
continuous-integration/drone/push Build is failing Details
Repeating "monthly" or "yearly" was never what people expected, only 30 or 365 days which is not always correct. This change removes these settings since the repeating modes will be re-done anyway.

Related to #3585 (comment)
2023-09-06 15:41:48 +02:00
kolaente b187e8c1b6
fix(ci): pin used node version to 20.5 to avoid build issues
continuous-integration/drone/push Build is failing Details
Related https://github.com/vitejs/vite/issues/14299
2023-09-06 15:33:04 +02:00
kolaente 0ecda46af9
chore(deps): update dependencies
continuous-integration/drone/push Build is failing Details
2023-09-06 15:30:00 +02:00
kolaente 59dc927b5c
feat(i18n): update translations only once a day
continuous-integration/drone/push Build is failing Details
2023-09-06 15:24:44 +02:00
kolaente a13953ee14
fix(i18n): add upload files config
continuous-integration/drone/push Build is failing Details
2023-09-06 15:22:51 +02:00
kolaente a4b836d395
feat(i18n): run translation update directly
continuous-integration/drone/push Build was killed Details
2023-09-06 15:19:32 +02:00
kolaente 16b46b0f4d
feat(i18n): update crowdin sync to use v2 api
continuous-integration/drone/push Build is failing Details
2023-09-06 15:18:27 +02:00
kolaente 184110b986
fix(gantt): update the gantt view when switching between projects
continuous-integration/drone/push Build is failing Details
Resolves https://community.vikunja.io/t/listing-subprojects-tasks/1567/5
2023-09-06 13:25:27 +02:00
kolaente 1918947c0b
fix(tasks): reset page number when applying filters
continuous-integration/drone/push Build is failing Details
Resolves https://community.vikunja.io/t/when-filter-conditions-change-pages-arent-updated-according-to-new-list-length/1601
2023-09-06 10:50:52 +02:00
kolaente 4e5823183e
fix(tasks): update api route
continuous-integration/drone/push Build is failing Details
2023-09-06 10:41:39 +02:00
kolaente b9e17ea870
fix(api tokens): show a token after it was created 2023-09-06 09:59:27 +02:00
Frederick [Bot] a8a6ec5ab0 [skip ci] Updated translations via Crowdin 2023-09-06 00:29:43 +00:00
Frederick [Bot] 3e9b872894 [skip ci] Updated translations via Crowdin 2023-09-05 00:29:24 +00:00
kolaente c4adcf4655
chore: include version json string in release zip
continuous-integration/drone/push Build is passing Details
2023-09-04 22:19:37 +02:00
kolaente b1fe3fe29b
fix: don't render route modal when no properties are defined
continuous-integration/drone/push Build is passing Details
2023-09-04 21:33:50 +02:00
kolaente 5720a86bc3
fix(task): don't reload the kanban board when opening a task
continuous-integration/drone/push Build is passing Details
2023-09-04 21:01:42 +02:00
kolaente 86eff7d49e
fix(task): don't reload the kanban board when opening a task
continuous-integration/drone/push Build is failing Details
2023-09-04 20:27:55 +02:00
kolaente 7a9aa7771b
fix(tasks): play pop sound directly and not from store
continuous-integration/drone/push Build is passing Details
This solves two problems:

1. Previously, changing anything on a done task would play the pop sound all the time, because the store only knew the new done status was "done" and not if it was done previously already.
2. Safari will prevent playing a sound without user interaction. This means the user has to interact directly with the method playing the sound which was not the case when the sound was played from the store.

Resolves #3292
2023-09-04 20:14:43 +02:00
kolaente abbc11528e
feat(tasks): update due date text every minute
continuous-integration/drone/push Build is passing Details
Related discussion: https://community.vikunja.io/t/text-describing-time-past-due-date-is-never-refreshed/1376/3
2023-09-04 14:00:22 +02:00
kolaente 725fd1ad46
feat: improve error message for invalid API url
continuous-integration/drone/push Build is passing Details
Resolves #3680
2023-09-04 13:37:17 +02:00
kolaente 44754fac0f
chore(ci): sign drone config
continuous-integration/drone/push Build is passing Details
2023-09-04 13:11:59 +02:00
kolaente 7f2d92138e
fix: lint
continuous-integration/drone/push Build is pending Details
2023-09-04 13:11:31 +02:00
kolaente 95be0d1d32
fix(build): don't download Puppeteer when building for prod
continuous-integration/drone/push Build is pending Details
2023-09-04 13:07:48 +02:00
kolaente f63c39a578
feat(assignees): improve avatar list consistency
continuous-integration/drone/push Build is failing Details
Resolves #3354
2023-09-04 13:03:39 +02:00
kolaente 270e32290a
fix(quick add magic): ignore common task indention when adding multiple tasks at once
continuous-integration/drone/push Build is failing Details
Resolves #3732
2023-09-04 11:24:10 +02:00
kolaente 9cf8696b84
fix(docker): set correct default value for custom logo url
continuous-integration/drone/push Build is failing Details
2023-09-04 10:22:44 +02:00
Frederick [Bot] b97e13b6b4 [skip ci] Updated translations via Crowdin 2023-09-04 00:28:15 +00:00
konrad 04ba1011cc feat: add setting for default bucket
continuous-integration/drone/push Build is passing Details
Reviewed-on: #3735
2023-09-03 15:14:44 +00:00
kolaente 52c0efe0ce
feat(kanban): add icon for bucket collapse
continuous-integration/drone/pr Build is passing Details
2023-09-03 16:32:29 +02:00
kolaente c803020537
feat(kanban): add setting for default bucket 2023-09-03 16:32:29 +02:00
kolaente 3373b5fc45
feat(kanban): save done bucket with project instead of bucket 2023-09-03 16:32:29 +02:00
kolaente f6d1db3595
fix: tests
continuous-integration/drone/push Build is passing Details
2023-09-03 16:30:36 +02:00
Frederick [Bot] ce6f099912 [skip ci] Updated translations via Crowdin 2023-09-03 00:29:23 +00:00
kolaente ed8fb71ff0
feat: add demo mode warning message
continuous-integration/drone/push Build is passing Details
Resolves #2453
2023-09-01 18:09:19 +02:00
konrad 28f2551d87 feat: api tokens
continuous-integration/drone/push Build is failing Details
Reviewed-on: #3733
2023-09-01 14:34:56 +00:00
kolaente cec480ad80
fix(api tokens): lint
continuous-integration/drone/pr Build is passing Details
2023-09-01 15:59:16 +02:00
kolaente 830a3745ba
feat(api tokens): show warning if token has expired
continuous-integration/drone/pr Build was killed Details
2023-09-01 13:32:00 +02:00
kolaente 49104c65b6
fix(api tokens): expiry of tokens in a number of days 2023-09-01 13:28:32 +02:00
kolaente 984978fe6d
feat(api tokens): format permissions and groups human-readable 2023-09-01 13:25:37 +02:00
kolaente bd7b973559
feat(api tokens): add deleting api tokens 2023-09-01 13:18:00 +02:00
kolaente 0bb85870db
feat(api tokens): allow custom selection of expiry dates 2023-09-01 13:07:20 +02:00
kolaente 021f92303d
feat(api tokens): validate title field when creating a new token 2023-09-01 12:56:23 +02:00
kolaente e47ad021a3
feat(api tokens): add token creation form 2023-09-01 12:47:32 +02:00
kolaente a20eef2453
feat(api tokens): add basic api token overview 2023-09-01 11:15:48 +02:00
Frederick [Bot] 7b57b10804 [skip ci] Updated translations via Crowdin 2023-08-31 00:29:36 +00:00
Frederick [Bot] 83a7032b6f [skip ci] Updated translations via Crowdin 2023-08-30 00:29:17 +00:00
renovate 49261a6fcc chore(deps): update dev-dependencies (#3726)
continuous-integration/drone/push Build is passing Details
Reviewed-on: #3726
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-08-29 11:59:08 +00:00
kolaente 5630c90dee
fix(task): show related tasks form with shortcut even when there are already other related tasks
continuous-integration/drone/push Build is passing Details
Resolves https://github.com/go-vikunja/frontend/issues/122
2023-08-29 13:57:12 +02:00
konrad 47d589002c feat: quick actions improvments
continuous-integration/drone/push Build is passing Details
Reviewed-on: #3728
2023-08-29 11:24:00 +00:00
kolaente 99e2161c09
fix: lint
continuous-integration/drone/pr Build is passing Details
2023-08-29 12:46:30 +02:00
kolaente 20f61baf03
fix(quick actions): search for tasks within a project when specifying a project with quick add magic
continuous-integration/drone/pr Build is failing Details
2023-08-29 12:45:05 +02:00
kolaente 4e6b99544e
fix(quick actions): don't show projects when searching for labels or tasks
continuous-integration/drone/pr Build is failing Details
2023-08-29 12:38:59 +02:00
kolaente d57e1909c4
feat(quick actions): show labels as labels and tasks with all of their details 2023-08-29 12:33:43 +02:00
kolaente 99d8fbdfa7
feat(quick actions): show tasks for a label when selecting it
continuous-integration/drone/pr Build is failing Details
2023-08-29 11:11:37 +02:00
kolaente 442d0342a9
fix(quick actions): project search
continuous-integration/drone/pr Build is passing Details
2023-08-29 10:08:47 +02:00
kolaente a4b369470a
fix(quick actions): invalid class prop 2023-08-29 09:57:13 +02:00
kolaente 0ca73e0851
fix(quick actions): always search for projects 2023-08-29 09:41:53 +02:00
kolaente 9fc829115f
fix(quick actions): project filter 2023-08-29 09:34:08 +02:00
kolaente 1e19548563
chore(quick actions): format 2023-08-29 09:33:56 +02:00
kolaente c327d86a71
feat(quick actions): show task identifier 2023-08-29 09:33:41 +02:00
kolaente 3044560759
feat(quick actions): show done tasks last 2023-08-29 09:21:11 +02:00
kolaente c3f85fcb19
chore: format 2023-08-29 09:19:52 +02:00
renovate 53434952d3 chore(deps): update dev-dependencies (#3721)
continuous-integration/drone/push Build is passing Details
Reviewed-on: #3721
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-08-27 15:27:00 +00:00
renovate e9b0640660 fix(deps): update dependency @vueuse/core to v10.4.0 (#3723)
continuous-integration/drone/push Build is passing Details
Reviewed-on: #3723
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2023-08-27 15:00:38 +00:00
renovate ae57e5d314
chore(deps): update pnpm to v8.7.0
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2023-08-27 16:33:22 +02:00
kolaente 6e7928b2e4
fix(i18n): hungarian translation
continuous-integration/drone/push Build is passing Details
2023-08-27 16:32:06 +02:00
kolaente 47639b00f8
feat(i18n): add hungarian translation for selection
continuous-integration/drone/push Build is failing Details
2023-08-27 10:28:31 +02:00
Frederick [Bot] e63cecceca [skip ci] Updated translations via Crowdin 2023-08-27 00:29:16 +00:00
Frederick [Bot] 55e2e323ed [skip ci] Updated translations via Crowdin 2023-08-26 00:29:30 +00:00
kolaente f7e22c8c56
fix(auth): correctly redirect the user to the last visited page after login
continuous-integration/drone/push Build is passing Details
Resolves #3682
2023-08-24 12:15:45 +02:00
kolaente a9fb306e46
fix(i18n): fall back to browser language if the configured user language is invalid
continuous-integration/drone/push Build is passing Details
2023-08-24 11:37:23 +02:00
kolaente 58a1f46668
fix(projects): don't suggest to create a new task in an empty filter
continuous-integration/drone/push Build is passing Details
2023-08-24 11:32:28 +02:00
kolaente 6cbbe17bd8
fix(filters): don't allow marking a filter as favorite
continuous-integration/drone/push Build is passing Details
2023-08-24 11:30:57 +02:00
kolaente c01957aae2
fix: lint
continuous-integration/drone/push Build is passing Details
2023-08-24 11:27:31 +02:00
kolaente 1ad03877fb
fix(menu): separate favorite and saved filter projects from other projects
continuous-integration/drone/push Build is failing Details
Resolves #3710
Resolves https://github.com/go-vikunja/frontend/issues/119
2023-08-24 11:27:20 +02:00
kolaente fc72a82a2a
fix(task): duplicate attribute
continuous-integration/drone/push Build is failing Details
2023-08-24 11:18:17 +02:00
kolaente 63ef09b020
fix(filters): incorrect translation string 2023-08-24 11:18:03 +02:00
DIMITRIOS CHRYSOCHERIS 311b1d7594
chore: improve checking for API url '/' suffix (#121)
continuous-integration/drone/push Build is failing Details
2023-08-23 21:56:08 +02:00
davidangel cade3df3e9 feat: allow custom logo via environment variable (#3685)
continuous-integration/drone/push Build is passing Details
Related discussion: https://community.vikunja.io/t/change-vikunja-logo-and-color-scheme/621

Reviewed-on: #3685
Reviewed-by: konrad <k@knt.li>
Co-authored-by: davidangel <david@davidangel.net>
Co-committed-by: davidangel <david@davidangel.net>
2023-08-23 16:13:29 +00:00
kolaente 37975c1931 chore(deps): update lockfile
continuous-integration/drone/push Build is passing Details
2023-08-23 06:37:45 +00:00
renovate 0d500182e7 chore(deps): update dev-dependencies 2023-08-23 06:37:45 +00:00
Frederick [Bot] f647d6e9b4 [skip ci] Updated translations via Crowdin 2023-08-23 00:28:13 +00:00
91 changed files with 3682 additions and 968 deletions

View File

@ -42,7 +42,7 @@ steps:
# - .cache
- name: dependencies
image: node:20-alpine
image: node:20.5-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -55,7 +55,7 @@ steps:
# - restore-cache
- name: lint
image: node:20-alpine
image: node:20.5-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -66,7 +66,7 @@ steps:
- dependencies
- name: build-prod
image: node:20-alpine
image: node:20.5-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -77,7 +77,7 @@ steps:
- dependencies
- name: test-unit
image: node:20-alpine
image: node:20.5-alpine
pull: always
commands:
- corepack enable && pnpm config set store-dir .cache/pnpm
@ -87,7 +87,7 @@ steps:
- name: typecheck
failure: ignore
image: node:20-alpine
image: node:20.5-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -202,7 +202,7 @@ steps:
# - .cache
- name: build
image: node:20-alpine
image: node:20.5-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -210,6 +210,7 @@ steps:
from_secret: sentry_auth_token
SENTRY_ORG: vikunja
SENTRY_PROJECT: frontend-oss
PUPPETEER_SKIP_DOWNLOAD: true
commands:
- apk add git
- corepack enable && pnpm config set store-dir .cache/pnpm
@ -225,6 +226,7 @@ steps:
image: kolaente/zip
pull: always
commands:
- cp src/version.json dist
- cd dist
- zip -r ../vikunja-frontend-unstable.zip *
- cd ..
@ -283,7 +285,7 @@ steps:
# - .cache
- name: build
image: node:20-alpine
image: node:20.5-alpine
pull: always
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
@ -306,6 +308,7 @@ steps:
image: kolaente/zip
pull: always
commands:
- cp src/version.json dist
- cd dist
- zip -r ../vikunja-frontend-${DRONE_TAG##v}.zip *
- cd ..
@ -472,24 +475,25 @@ name: update-translations
trigger:
branch:
- main
include:
- main
event:
- cron
include:
- cron
cron:
- update_translations
steps:
- name: download
pull: always
image: jonasfranz/crowdin
image: git.lcomrade.su/root/drone-crowdin-v2
settings:
download: true
export_dir: src/i18n/lang/
ignore_branch: true
project_identifier: vikunja
environment:
CROWDIN_KEY:
crowdin_key:
from_secret: crowdin_key
project_id: 462614
target: download
download_to: src/i18n/lang/
download_export_approved_only: true
- name: move-files
pull: always
@ -516,19 +520,18 @@ steps:
- name: upload
pull: always
image: jonasfranz/crowdin
image: git.lcomrade.su/root/drone-crowdin-v2
depends_on:
- clone
settings:
files:
en.json: src/i18n/lang/en.json
ignore_branch: true
project_identifier: vikunja
environment:
CROWDIN_KEY:
from_secret: crowdin_key
crowdin_key:
from_secret: crowdin_key
project_id: 462614
target: upload
upload_files:
src/i18n/lang/en.json: en.json
---
kind: signature
hmac: 6a566550cac03e9f3f9bbccab95fda4b342233bd63a1409cb5f634b1c744c326
hmac: c5517d5fc49e327984177144aa195d4418a5769c25deb40f1c211e05735bc863
...

View File

@ -3,13 +3,14 @@
# │─││ │││ │ │
# ┘─┘┘─┘┘┘─┘┘─┘
FROM --platform=$BUILDPLATFORM node:20-alpine AS builder
FROM --platform=$BUILDPLATFORM node:20.5-alpine AS builder
WORKDIR /build
ARG USE_RELEASE=false
ARG RELEASE_VERSION=unstable
ENV PNPM_CACHE_FOLDER .cache/pnpm/
ENV PUPPETEER_SKIP_DOWNLOAD true
COPY package.json ./
COPY pnpm-lock.yaml ./
@ -57,6 +58,7 @@ ENV VIKUNJA_SENTRY_ENABLED false
ENV VIKUNJA_SENTRY_DSN https://85694a2d757547cbbc90cd4b55c5a18d@o1047380.ingest.sentry.io/6024480
ENV VIKUNJA_PROJECT_INFINITE_NESTING_ENABLED false
ENV VIKUNJA_ALLOW_ICON_CHANGES true
ENV VIKUNJA_CUSTOM_LOGO_URL "''"
COPY docker/injector.sh /docker-entrypoint.d/50-injector.sh
COPY docker/ipv6-disable.sh /docker-entrypoint.d/60-ipv6-disable.sh

View File

View File

@ -1,4 +1,5 @@
import {UserFactory} from '../../factories/user'
import {ProjectFactory} from '../../factories/project'
const testAndAssertFailed = fixture => {
cy.intercept(Cypress.env('API_URL') + '/login*').as('login')
@ -13,26 +14,28 @@ const testAndAssertFailed = fixture => {
cy.get('div.message.danger').contains('Wrong username or password.')
}
const username = 'test'
const credentials = {
username: 'test',
password: '1234',
}
function login() {
cy.get('input[id=username]').type(credentials.username)
cy.get('input[id=password]').type(credentials.password)
cy.get('.button').contains('Login').click()
cy.url().should('include', '/')
}
context('Login', () => {
beforeEach(() => {
UserFactory.create(1, {username})
UserFactory.create(1, {username: credentials.username})
})
it('Should log in with the right credentials', () => {
const fixture = {
username: 'test',
password: '1234',
}
cy.visit('/login')
cy.get('input[id=username]').type(fixture.username)
cy.get('input[id=password]').type(fixture.password)
cy.get('.button').contains('Login').click()
cy.url().should('include', '/')
login()
cy.clock(1625656161057) // 13:00
cy.get('h2').should('contain', `Hi ${fixture.username}!`)
cy.get('h2').should('contain', `Hi ${credentials.username}!`)
})
it('Should fail with a bad password', () => {
@ -57,4 +60,15 @@ context('Login', () => {
cy.visit('/')
cy.url().should('include', '/login')
})
it('Should redirect to the previous route after logging in', () => {
const projects = ProjectFactory.create(1)
cy.visit(`/projects/${projects[0].id}/list`)
cy.url().should('include', '/login')
login()
cy.url().should('include', `/projects/${projects[0].id}/list`)
})
})

View File

@ -17,7 +17,7 @@ context('Registration', () => {
it('Should work without issues', () => {
const fixture = {
username: 'testuser',
password: '123456',
password: '12345678',
email: 'testuser@example.com',
}
@ -31,10 +31,10 @@ context('Registration', () => {
cy.get('h2').should('contain', `Hi ${fixture.username}!`)
})
it.only('Should fail', () => {
it('Should fail', () => {
const fixture = {
username: 'test',
password: '123456',
password: '12345678',
email: 'testuser@example.com',
}

View File

@ -13,5 +13,6 @@ sed -ri "s:^(\s*window.SENTRY_ENABLED\s*=)\s*.+:\1 ${VIKUNJA_SENTRY_ENABLED}:g"
sed -ri "s:^(\s*window.SENTRY_DSN\s*=)\s*.+:\1 '${VIKUNJA_SENTRY_DSN}':g" /usr/share/nginx/html/index.html
sed -ri "s:^(\s*window.PROJECT_INFINITE_NESTING_ENABLED\s*=)\s*.+:\1 '${VIKUNJA_PROJECT_INFINITE_NESTING_ENABLED}':g" /usr/share/nginx/html/index.html
sed -ri "s:^(\s*window.ALLOW_ICON_CHANGES\s*=)\s*.+:\1 ${VIKUNJA_ALLOW_ICON_CHANGES}:g" /usr/share/nginx/html/index.html
sed -ri "s:^(\s*window.CUSTOM_LOGO_URL\s*=)\s*.+:\1 ${VIKUNJA_CUSTOM_LOGO_URL}:g" /usr/share/nginx/html/index.html
date -uIseconds | xargs echo 'info: started at'

View File

@ -32,6 +32,8 @@
window.PROJECT_INFINITE_NESTING_ENABLED = false
// Allow changing the logo and other icons based on various occasions throughout the year.
window.ALLOW_ICON_CHANGES = true
// Allow using a custom logo via external URL.
window.CUSTOM_LOGO_URL = ''
</script>
</body>
</html>

View File

@ -13,7 +13,7 @@
},
"homepage": "https://vikunja.io/",
"funding": "https://opencollective.com/vikunja",
"packageManager": "pnpm@8.6.12",
"packageManager": "pnpm@8.7.4",
"keywords": [
"todo",
"productivity",
@ -51,16 +51,17 @@
"@fortawesome/vue-fontawesome": "3.0.3",
"@github/hotkey": "2.0.1",
"@infectoone/vue-ganttastic": "2.2.0",
"@intlify/unplugin-vue-i18n": "0.12.2",
"@intlify/unplugin-vue-i18n": "0.13.0",
"@kyvg/vue3-notification": "2.9.1",
"@sentry/tracing": "7.60.0",
"@sentry/vue": "7.60.0",
"@vueuse/core": "10.3.0",
"axios": "1.4.0",
"@sentry/tracing": "7.68.0",
"@sentry/vue": "7.68.0",
"@vueuse/core": "10.4.1",
"@vueuse/router": "10.4.1",
"axios": "1.5.0",
"blurhash": "2.0.5",
"bulma-css-variables": "0.9.33",
"camel-case": "4.1.2",
"codemirror": "5.65.14",
"codemirror": "5.65.15",
"date-fns": "2.30.0",
"dayjs": "1.11.9",
"dompurify": "3.0.5",
@ -73,12 +74,12 @@
"is-touch-device": "1.0.1",
"klona": "2.0.6",
"lodash.debounce": "4.0.8",
"marked": "5.1.1",
"marked": "5.1.2",
"pinia": "2.1.6",
"register-service-worker": "1.7.2",
"snake-case": "3.0.4",
"sortablejs": "1.15.0",
"ufo": "1.2.0",
"ufo": "1.3.0",
"vue": "3.3.4",
"vue-advanced-cropper": "2.8.8",
"vue-flatpickr-component": "11.0.3",
@ -92,53 +93,53 @@
"@cypress/vite-dev-server": "5.0.5",
"@cypress/vue": "5.0.5",
"@faker-js/faker": "8.0.2",
"@histoire/plugin-screenshot": "0.16.5",
"@histoire/plugin-vue": "0.16.5",
"@histoire/plugin-screenshot": "0.17.0",
"@histoire/plugin-vue": "0.17.1",
"@rushstack/eslint-patch": "1.3.3",
"@tsconfig/node18": "18.2.0",
"@types/codemirror": "5.60.8",
"@tsconfig/node18": "18.2.1",
"@types/codemirror": "5.60.9",
"@types/dompurify": "3.0.2",
"@types/flexsearch": "0.7.3",
"@types/is-touch-device": "1.0.0",
"@types/lodash.debounce": "4.0.7",
"@types/marked": "5.0.1",
"@types/node": "18.17.6",
"@types/node": "18.17.12",
"@types/postcss-preset-env": "7.7.0",
"@types/sortablejs": "1.15.1",
"@typescript-eslint/eslint-plugin": "6.4.0",
"@typescript-eslint/parser": "6.4.0",
"@types/sortablejs": "1.15.2",
"@typescript-eslint/eslint-plugin": "6.5.0",
"@typescript-eslint/parser": "6.5.0",
"@vitejs/plugin-legacy": "4.1.1",
"@vitejs/plugin-vue": "4.3.2",
"@vitejs/plugin-vue": "4.3.4",
"@vue/eslint-config-typescript": "11.0.3",
"@vue/test-utils": "2.4.1",
"@vue/tsconfig": "0.4.0",
"autoprefixer": "10.4.15",
"browserslist": "4.21.10",
"caniuse-lite": "1.0.30001522",
"caniuse-lite": "1.0.30001524",
"css-has-pseudo": "6.0.0",
"csstype": "3.1.2",
"cypress": "12.17.4",
"esbuild": "0.19.2",
"eslint": "8.47.0",
"eslint": "8.48.0",
"eslint-plugin-vue": "9.17.0",
"happy-dom": "10.10.4",
"histoire": "0.16.5",
"happy-dom": "10.11.1",
"histoire": "0.17.0",
"postcss": "8.4.28",
"postcss-easing-gradients": "3.0.1",
"postcss-easings": "4.0.0",
"postcss-focus-within": "8.0.0",
"postcss-preset-env": "9.1.1",
"rollup": "3.28.0",
"postcss-preset-env": "9.1.2",
"rollup": "3.28.1",
"rollup-plugin-visualizer": "5.9.2",
"sass": "1.66.1",
"start-server-and-test": "2.0.0",
"typescript": "5.1.6",
"typescript": "5.2.2",
"vite": "4.4.9",
"vite-plugin-inject-preload": "1.3.2",
"vite-plugin-inject-preload": "1.3.3",
"vite-plugin-pwa": "0.16.4",
"vite-plugin-sentry": "1.3.0",
"vite-svg-loader": "4.0.0",
"vitest": "0.34.2",
"vitest": "0.34.3",
"vue-tsc": "1.8.8",
"wait-on": "7.0.1",
"workbox-cli": "7.0.0"

File diff suppressed because it is too large Load Diff

View File

@ -15,6 +15,7 @@
<AddToHomeScreen/>
<UpdateNotification/>
<Notification/>
<DemoMode/>
</Teleport>
</ready>
</template>
@ -45,6 +46,7 @@ import {useBaseStore} from '@/stores/base'
import {useColorScheme} from '@/composables/useColorScheme'
import {useBodyClass} from '@/composables/useBodyClass'
import AddToHomeScreen from '@/components/home/AddToHomeScreen.vue'
import DemoMode from '@/components/home/DemoMode.vue'
const baseStore = useBaseStore()
const authStore = useAuthStore()

View File

@ -0,0 +1,49 @@
<script setup lang="ts">
import {computed, ref} from 'vue'
import {useConfigStore} from '@/stores/config'
import BaseButton from '@/components/base/BaseButton.vue'
const configStore = useConfigStore()
const hide = ref(false)
const enabled = computed(() => configStore.demoModeEnabled && !hide.value)
</script>
<template>
<div
v-if="enabled"
class="demo-mode-banner"
>
<p>
{{ $t('demo.title') }}
<strong class="is-uppercase">{{ $t('demo.everythingWillBeDeleted') }}</strong>
</p>
<BaseButton @click="() => hide = true" class="hide-button">
<icon icon="times"/>
</BaseButton>
</div>
</template>
<style scoped lang="scss">
.demo-mode-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--danger);
z-index: 100;
padding: .5rem;
text-align: center;
&, strong {
color: hsl(220, 13%, 91%) !important; // --grey-200 in light mode, hardcoded because the color should not change
}
}
.hide-button {
padding: .25rem .5rem;
cursor: pointer;
position: absolute;
right: .5rem;
top: .25rem;
}
</style>

View File

@ -9,15 +9,21 @@ import {MILLISECONDS_A_HOUR} from '@/constants/date'
const now = useNow({
interval: MILLISECONDS_A_HOUR,
})
const Logo = computed(() => window.ALLOW_ICON_CHANGES && now.value.getMonth() === 5 ? LogoFullPride : LogoFull)
const Logo = computed(() => window.ALLOW_ICON_CHANGES && now.value.getMonth() === 6 ? LogoFullPride : LogoFull)
const CustomLogo = computed(() => window.CUSTOM_LOGO_URL)
</script>
<template>
<Logo alt="Vikunja" class="logo" />
<div>
<Logo v-if="!CustomLogo" alt="Vikunja" class="logo" />
<img v-show="CustomLogo" :src="CustomLogo" alt="Vikunja" class="logo" />
</div>
</template>
<style lang="scss" scoped>
.logo {
color: var(--logo-text-color);
max-width: 168px;
max-height: 48px;
}
</style>

View File

@ -60,6 +60,14 @@
:can-collapse="false"
/>
</nav>
<nav class="menu" v-if="savedFilterProjects">
<ProjectsNavigation
:model-value="savedFilterProjects"
:can-edit-order="false"
:can-collapse="false"
/>
</nav>
<nav class="menu">
<ProjectsNavigation
@ -91,6 +99,7 @@ const projectStore = useProjectStore()
const projects = computed(() => projectStore.notArchivedRootProjects)
const favoriteProjects = computed(() => projectStore.favoriteProjects)
const savedFilterProjects = computed(() => projectStore.savedFilterProjects)
</script>
<style lang="scss" scoped>

View File

@ -11,7 +11,12 @@
class="input-wrapper input"
:class="{'has-multiple': hasMultiple}"
>
<template v-if="Array.isArray(internalValue)">
<slot
v-if="Array.isArray(internalValue)"
name="items"
:items="internalValue"
:remove="remove"
>
<template v-for="(item, key) in internalValue">
<slot name="tag" :item="item">
<span :key="`item${key}`" class="tag ml-2 mt-2">
@ -20,7 +25,7 @@
</span>
</slot>
</template>
</template>
</slot>
<input
type="text"
@ -85,7 +90,9 @@
</template>
<script setup lang="ts">
import {computed, onBeforeUnmount, onMounted, ref, toRefs, watch, type ComponentPublicInstance, type PropType} from 'vue'
import {
computed, onBeforeUnmount, onMounted, ref, toRefs, watch, type ComponentPublicInstance, type PropType,
} from 'vue'
import {useI18n} from 'vue-i18n'
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'

View File

@ -2,6 +2,7 @@ import {library} from '@fortawesome/fontawesome-svg-core'
import {
faAlignLeft,
faAngleRight,
faAnglesUp,
faArchive,
faArrowLeft,
faArrowUpFromBracket,
@ -142,6 +143,7 @@ library.add(faUser)
library.add(faUsers)
library.add(faArrowUpFromBracket)
library.add(faX)
library.add(faAnglesUp)
// overwriting the wrong types
export default FontAwesomeIcon as unknown as FontAwesomeIconFixedTypes

View File

@ -1,21 +1,26 @@
<template>
<BaseButton class="dropdown-item">
<span class="icon" v-if="icon">
<span
v-if="icon"
class="icon is-small"
:class="iconClass"
>
<Icon :icon="icon"/>
</span>
<span>
<slot />
<slot/>
</span>
</BaseButton>
</template>
<script lang="ts" setup>
import BaseButton, { type BaseButtonProps } from '@/components/base//BaseButton.vue'
import BaseButton, {type BaseButtonProps} from '@/components/base//BaseButton.vue'
import Icon from '@/components/misc/Icon'
import type { IconProp } from '@fortawesome/fontawesome-svg-core'
import type {IconProp} from '@fortawesome/fontawesome-svg-core'
export interface DropDownItemProps extends /* @vue-ignore */ BaseButtonProps {
icon?: IconProp,
iconClass?: object | string,
}
defineProps<DropDownItemProps>()
@ -24,7 +29,6 @@ defineProps<DropDownItemProps>()
<style scoped lang="scss">
.dropdown-item {
color: var(--text);
display: block;
font-size: 0.875rem;
line-height: 1.5;
padding: $item-padding;
@ -52,10 +56,7 @@ defineProps<DropDownItemProps>()
.icon {
padding-right: .5rem;
&:not(.has-text-success) {
color: var(--grey-300) !important;
}
color: var(--grey-300);
}
.has-text-danger .icon {

View File

@ -1,10 +1,10 @@
<template>
<input
type="text"
data-input
:disabled="disabled"
data-input
:disabled="disabled"
v-bind="attrs"
ref="root"
ref="root"
/>
</template>
@ -20,39 +20,39 @@ type Options = flatpickr.Options.Options
type DateOption = flatpickr.Options.DateOption
function camelToKebab(string: string) {
return string.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
return string.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
}
function arrayify<T = unknown>(obj: T) {
return obj instanceof Array
return obj instanceof Array
? obj
: [obj]
}
function nullify<T = unknown>(value: T) {
return (value && (value as unknown[]).length)
return (value && (value as unknown[]).length)
? value
: null
}
// Events to emit, copied from flatpickr source
const includedEvents = [
'onChange',
'onClose',
'onDestroy',
'onMonthChange',
'onOpen',
'onYearChange',
'onChange',
'onClose',
'onDestroy',
'onMonthChange',
'onOpen',
'onYearChange',
] as HookKey[]
// Let's not emit these events by default
const excludedEvents = [
'onValueUpdate',
'onDayCreate',
'onParseConfig',
'onReady',
'onPreCalendarPosition',
'onKeyDown',
'onValueUpdate',
'onDayCreate',
'onParseConfig',
'onReady',
'onPreCalendarPosition',
'onKeyDown',
] as HookKey[]
// Keep a copy of all events for later use
@ -100,19 +100,19 @@ const attrs = useAttrs()
const root = ref<HTMLInputElement | null>(null)
const fp = ref<flatpickr.Instance | null>(null)
const safeConfig = ref<Options>({ ...props.config })
const safeConfig = ref<Options>({...props.config})
function prepareConfig() {
// Don't mutate original object on parent component
const newConfig: Options = { ...props.config }
const newConfig: Options = {...props.config}
props.events.forEach((hook) => {
// Respect global callbacks registered via setDefault() method
const globalCallbacks = flatpickr.defaultConfig[hook] || []
// Inject our own method along with user callback
const localCallback: Hook = (...args) => emit(camelToKebab(hook), ...args)
// Overwrite with merged array
newConfig[hook] = arrayify(newConfig[hook] || []).concat(
globalCallbacks,
@ -147,9 +147,9 @@ onMounted(() => {
prepareConfig()
/**
* Get the HTML node where flatpickr to be attached
* Bind on parent element if wrap is true
*/
* Get the HTML node where flatpickr to be attached
* Bind on parent element if wrap is true
*/
const element = props.config.wrap
? root.value.parentNode
: root.value
@ -179,7 +179,7 @@ watch(config, () => {
fp.value.set(name, safeConfig.value[name])
}
})
}, {deep:true})
}, {deep: true})
const fpInput = computed(() => {
if (!fp.value) return
@ -198,8 +198,8 @@ watchEffect(() => fpInput.value?.addEventListener('blur', onBlur))
onBeforeUnmount(() => fpInput.value?.removeEventListener('blur', onBlur))
/**
* Watch for the disabled property and sets the value to the real input.
*/
* Watch for the disabled property and sets the value to the real input.
*/
watchEffect(() => {
if (disabled.value) {
fpInput.value?.setAttribute('disabled', '')

View File

@ -48,13 +48,14 @@ import Message from '@/components/misc/message.vue'
import CustomTransition from '@/components/misc/CustomTransition.vue'
import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue'
import {ERROR_NO_API_URL} from '@/helpers/checkAndSetApiUrl'
import {ERROR_NO_API_URL, InvalidApiUrlProvidedError, NoApiUrlProvidedError} from '@/helpers/checkAndSetApiUrl'
import {useOnline} from '@/composables/useOnline'
import {getAuthForRoute} from '@/router'
import {useBaseStore} from '@/stores/base'
import {useAuthStore} from '@/stores/auth'
import {useI18n} from 'vue-i18n'
const router = useRouter()
const route = useRoute()
@ -68,6 +69,8 @@ const online = useOnline()
const error = ref('')
const showLoading = computed(() => !ready.value && error.value === '')
const {t} = useI18n()
async function load() {
try {
await baseStore.loadApp()
@ -77,7 +80,15 @@ async function load() {
await router.push(redirectTo)
}
} catch (e: unknown) {
error.value = String(e)
if (e instanceof NoApiUrlProvidedError) {
error.value = ERROR_NO_API_URL
return
}
if (e instanceof InvalidApiUrlProvidedError) {
error.value = t('apiConfig.error')
return
}
error.value = String(e.message)
}
}

View File

@ -33,7 +33,7 @@
}"
/>
<BaseButton
v-if="!project.isArchived"
v-if="!project.isArchived && project.id > -1"
class="favorite"
:class="{'is-favorite': project.isFavorite}"
@click.prevent.stop="projectStore.toggleProjectFavorite(project)"

View File

@ -157,7 +157,7 @@
<template
v-if="['filters.create', 'project.edit', 'filter.settings.edit'].includes($route.name as string)">
<div class="field">
<label class="label">{{ $t('project.lists') }}</label>
<label class="label">{{ $t('project.projects') }}</label>
<div class="control">
<SelectProject
v-model="entities.projects"

View File

@ -24,7 +24,7 @@
{{ hintText }}
</div>
<quick-add-magic class="p-2 modal-container-smaller" v-if="isNewTaskCommand"/>
<quick-add-magic v-if="isNewTaskCommand"/>
<div class="results" v-if="selectedCmd === null">
<div v-for="(r, k) in results" :key="k" class="result">
@ -44,7 +44,18 @@
@keyup.prevent.enter="doAction(r.type, i)"
@keyup.prevent.esc="searchInput?.focus()"
>
{{ i.title }}
<template v-if="r.type === ACTION_TYPE.LABELS">
<x-label :label="i"/>
</template>
<template v-else-if="r.type === ACTION_TYPE.TASK">
<single-task-inline-readonly
:task="i"
:show-project="true"
/>
</template>
<template v-else>
{{ i.title }}
</template>
</BaseButton>
</div>
</div>
@ -66,6 +77,8 @@ import ProjectModel from '@/models/project'
import BaseButton from '@/components/base/BaseButton.vue'
import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue'
import XLabel from '@/components/tasks/partials/label.vue'
import SingleTaskInlineReadonly from '@/components/tasks/partials/singleTaskInlineReadonly.vue'
import {useBaseStore} from '@/stores/base'
import {useProjectStore} from '@/stores/projects'
@ -97,6 +110,7 @@ enum ACTION_TYPE {
TASK = 'task',
PROJECT = 'project',
TEAM = 'team',
LABELS = 'labels',
}
enum COMMAND_TYPE {
@ -134,24 +148,38 @@ function closeQuickActions() {
}
const foundProjects = computed(() => {
const { project } = parsedQuery.value
if (
searchMode.value === SEARCH_MODE.ALL ||
searchMode.value === SEARCH_MODE.PROJECTS ||
project === null
) {
const {project, text, labels, assignees} = parsedQuery.value
if (project !== null) {
return projectStore.searchProject(project ?? text)
.filter(p => Boolean(p))
}
if (labels.length > 0 || assignees.length > 0) {
return []
}
const history = getHistory()
const allProjects = [
...new Set([
...history.map((l) => projectStore.projects[l.id]),
...projectStore.searchProject(project),
]),
]
if (text === '') {
const history = getHistory()
return history.map((p) => projectStore.projects[p.id])
.filter(p => Boolean(p))
}
return allProjects.filter(l => Boolean(l))
return projectStore.searchProject(project ?? text)
.filter(p => Boolean(p))
})
const foundLabels = computed(() => {
const {labels, text} = parsedQuery.value
if (text === '' && labels.length === 0) {
return []
}
if (labels.length > 0) {
return labelStore.filterLabelsByQuery([], labels[0])
}
return labelStore.filterLabelsByQuery([], text)
})
// FIXME: use fuzzysearch
@ -172,15 +200,20 @@ const results = computed<Result[]>(() => {
title: t('quickActions.commands'),
items: foundCommands.value,
},
{
type: ACTION_TYPE.PROJECT,
title: t('quickActions.projects'),
items: foundProjects.value,
},
{
type: ACTION_TYPE.TASK,
title: t('quickActions.tasks'),
items: foundTasks.value,
},
{
type: ACTION_TYPE.PROJECT,
title: t('quickActions.projects'),
items: foundProjects.value,
type: ACTION_TYPE.LABELS,
title: t('quickActions.labels'),
items: foundLabels.value,
},
{
type: ACTION_TYPE.TEAM,
@ -190,7 +223,7 @@ const results = computed<Result[]>(() => {
].filter((i) => i.items.length > 0)
})
const loading = computed(() =>
const loading = computed(() =>
taskService.loading ||
projectStore.isLoading ||
teamService.loading,
@ -262,10 +295,12 @@ const searchMode = computed(() => {
if (query.value === '') {
return SEARCH_MODE.ALL
}
const { text, project, labels, assignees } = parsedQuery.value
const {text, project, labels, assignees} = parsedQuery.value
if (assignees.length === 0 && text !== '') {
return SEARCH_MODE.TASKS
}
if (
assignees.length === 0 &&
project !== null &&
@ -274,6 +309,7 @@ const searchMode = computed(() => {
) {
return SEARCH_MODE.PROJECTS
}
if (
assignees.length > 0 &&
project === null &&
@ -282,6 +318,7 @@ const searchMode = computed(() => {
) {
return SEARCH_MODE.TEAMS
}
return SEARCH_MODE.ALL
})
@ -292,12 +329,12 @@ const isNewTaskCommand = computed(() => (
const taskSearchTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
type Filter = {by: string, value: string | number, comparator: string}
type Filter = { by: string, value: string | number, comparator: string }
function filtersToParams(filters: Filter[]) {
const filter_by : Filter['by'][] = []
const filter_value : Filter['value'][] = []
const filter_comparator : Filter['comparator'][] = []
const filter_by: Filter['by'][] = []
const filter_value: Filter['value'][] = []
const filter_comparator: Filter['comparator'][] = []
filters.forEach(({by, value, comparator}) => {
filter_by.push(by)
@ -315,7 +352,8 @@ function filtersToParams(filters: Filter[]) {
function searchTasks() {
if (
searchMode.value !== SEARCH_MODE.ALL &&
searchMode.value !== SEARCH_MODE.TASKS
searchMode.value !== SEARCH_MODE.TASKS &&
searchMode.value !== SEARCH_MODE.PROJECTS
) {
foundTasks.value = []
return
@ -330,7 +368,7 @@ function searchTasks() {
taskSearchTimeout.value = null
}
const { text, project: projectName, labels } = parsedQuery.value
const {text, project: projectName, labels} = parsedQuery.value
const filters: Filter[] = []
@ -349,8 +387,9 @@ function searchTasks() {
if (projectName !== null) {
const project = projectStore.findProjectByExactname(projectName)
console.log({project})
if (project !== null) {
addFilter('projectId', project.id, 'equals')
addFilter('project_id', project.id, 'equals')
}
}
@ -361,19 +400,16 @@ function searchTasks() {
}
}
const params = {
s: text,
...filtersToParams(filters),
}
const params = {
s: text,
sort_by: 'done',
...filtersToParams(filters),
}
taskSearchTimeout.value = setTimeout(async () => {
const r = await taskService.getAll({}, params) as DoAction<ITask>[]
const r = await taskService.getAll({}, params) as DoAction<ITask>[]
foundTasks.value = r.map((t) => {
t.type = ACTION_TYPE.TASK
const project = projectStore.projects[t.projectId]
if (project !== null) {
t.title = `${t.title} (${project.title})`
}
return t
})
}, 150)
@ -396,10 +432,10 @@ function searchTeams() {
clearTimeout(teamSearchTimeout.value)
teamSearchTimeout.value = null
}
const { assignees } = parsedQuery.value
const {assignees} = parsedQuery.value
teamSearchTimeout.value = setTimeout(async () => {
const teamSearchPromises = assignees.map((t) =>
teamService.getAll({}, { s: t }),
teamService.getAll({}, {s: t}),
)
const teamsResult = await Promise.all(teamSearchPromises)
foundTeams.value = teamsResult.flat().map((team) => {
@ -422,21 +458,21 @@ async function doAction(type: ACTION_TYPE, item: DoAction) {
closeQuickActions()
await router.push({
name: 'project.index',
params: { projectId: (item as DoAction<IProject>).id },
params: {projectId: (item as DoAction<IProject>).id},
})
break
case ACTION_TYPE.TASK:
closeQuickActions()
await router.push({
name: 'task.detail',
params: { id: (item as DoAction<ITask>).id },
params: {id: (item as DoAction<ITask>).id},
})
break
case ACTION_TYPE.TEAM:
closeQuickActions()
await router.push({
name: 'teams.edit',
params: { id: (item as DoAction<ITeam>).id },
params: {id: (item as DoAction<ITeam>).id},
})
break
case ACTION_TYPE.CMD:
@ -444,6 +480,11 @@ async function doAction(type: ACTION_TYPE, item: DoAction) {
selectedCmd.value = item as DoAction<Command>
searchInput.value?.focus()
break
case ACTION_TYPE.LABELS:
query.value = '*' + item.title
searchInput.value?.focus()
searchTasks()
break
}
}
@ -470,8 +511,8 @@ async function newTask() {
title: query.value,
projectId: currentProject.value.id,
})
success({ message: t('task.createSuccess') })
await router.push({ name: 'task.detail', params: { id: task.id } })
success({message: t('task.createSuccess')})
await router.push({name: 'task.detail', params: {id: task.id}})
}
async function newProject() {
@ -481,17 +522,17 @@ async function newProject() {
await projectStore.createProject(new ProjectModel({
title: query.value,
}))
success({ message: t('project.create.createdSuccess')})
success({message: t('project.create.createdSuccess')})
}
async function newTeam() {
const newTeam = new TeamModel({ name: query.value })
const newTeam = new TeamModel({name: query.value})
const team = await teamService.create(newTeam)
await router.push({
name: 'teams.edit',
params: { id: team.id },
params: {id: team.id},
})
success({ message: t('team.create.success') })
success({message: t('team.create.success')})
}
type BaseButtonInstance = InstanceType<typeof BaseButton>
@ -502,7 +543,7 @@ function setResultRefs(el: Element | ComponentPublicInstance | null, index: numb
resultRefs.value[index] = []
}
resultRefs.value[index][key] = el as (BaseButtonInstance | null)
resultRefs.value[index][key] = el as (BaseButtonInstance | null)
}
function select(parentIndex: number, index: number) {
@ -547,7 +588,7 @@ function reset() {
<style lang="scss" scoped>
.quick-actions {
overflow: hidden;
// FIXME: changed position should be an option of the modal
:deep(.modal-content) {
top: 3rem;
@ -569,6 +610,7 @@ function reset() {
}
}
.active-cmd {
font-size: 1.25rem;
margin-left: .5rem;
@ -614,10 +656,4 @@ function reset() {
background: var(--grey-100);
}
}
// HACK:
// FIXME:
.modal-container-smaller :deep(.hint-modal .modal-container) {
height: calc(100vh - 5rem);
}
</style>

View File

@ -0,0 +1,93 @@
<script setup lang="ts">
import type {IUser} from '@/modelTypes/IUser'
import BaseButton from '@/components/base/BaseButton.vue'
import User from '@/components/misc/user.vue'
import {computed} from 'vue'
type removeFunction = (item: any) => void
const {
assignees,
remove,
disabled,
avatarSize = 30,
inline = false,
} = defineProps<{
assignees: IUser[],
remove?: removeFunction,
disabled?: boolean,
avatarSize?: number,
inline?: boolean,
}>()
const hasDelete = computed(() => typeof remove !== 'undefined' && !disabled)
</script>
<template>
<div class="assignees-list" :class="{'is-inline': inline}">
<span
v-for="user in assignees"
class="assignee"
:key="user.id"
>
<User
:key="'user'+user.id"
:avatar-size="avatarSize"
:show-username="false"
:user="user"
:class="{'m-2': hasDelete, 'mr-3': !hasDelete}"
/>
<BaseButton
:key="'delete'+user.id"
v-if="hasDelete"
@click="remove(user)"
class="remove-assignee"
>
<icon icon="times"/>
</BaseButton>
</span>
</div>
</template>
<style scoped lang="scss">
.assignees-list {
display: flex;
&.is-inline :deep(.user) {
display: inline;
}
&:hover .assignee:not(:first-child) {
margin-left: -1rem;
}
}
.assignee {
position: relative;
transition: all $transition;
&:not(:first-child) {
margin-left: -1.5rem;
}
:deep(.user img) {
border: 2px solid var(--white);
margin-right: 0;
}
}
.remove-assignee {
position: absolute;
top: 4px;
left: 2px;
color: var(--danger);
background: var(--white);
padding: 0 4px;
display: block;
border-radius: 100%;
font-size: .75rem;
width: 18px;
height: 18px;
z-index: 100;
}
</style>

View File

@ -11,13 +11,8 @@
v-model="assignees"
:autocomplete-enabled="false"
>
<template #tag="{item: user}">
<span class="assignee">
<user :avatar-size="32" :show-username="false" :user="user" class="m-2"/>
<BaseButton @click="removeAssignee(user)" class="remove-assignee" v-if="!disabled">
<icon icon="times"/>
</BaseButton>
</span>
<template #items="{items}">
<assignee-list :assignees="items" :remove="removeAssignee"/>
</template>
<template #searchResult="{option: user}">
<user :avatar-size="24" :show-username="true" :user="user"/>
@ -31,7 +26,6 @@ import {useI18n} from 'vue-i18n'
import User from '@/components/misc/user.vue'
import Multiselect from '@/components/input/multiselect.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import {includesById} from '@/helpers/utils'
import ProjectUserService from '@/services/projectUsers'
@ -40,6 +34,7 @@ import {useTaskStore} from '@/stores/tasks'
import type {IUser} from '@/modelTypes/IUser'
import {getDisplayName} from '@/models/user'
import AssigneeList from '@/components/tasks/partials/assigneeList.vue'
const props = defineProps({
taskId: {
@ -120,34 +115,3 @@ async function findUser(query: string) {
})
}
</script>
<style lang="scss" scoped>
.assignee {
position: relative;
&:not(:first-child) {
margin-left: -1.5rem;
}
:deep(.user img) {
border: 2px solid var(--white);
margin-right: 0;
}
}
.remove-assignee {
position: absolute;
top: 4px;
left: 2px;
color: var(--danger);
background: var(--white);
padding: 0 4px;
display: block;
border-radius: 100%;
font-size: .75rem;
width: 18px;
height: 18px;
z-index: 100;
}
</style>

View File

@ -48,16 +48,14 @@
</progress>
<div class="footer">
<labels :labels="task.labels"/>
<priority-label :priority="task.priority" :done="task.done"/>
<div class="assignees" v-if="task.assignees.length > 0">
<user
v-for="u in task.assignees"
:avatar-size="24"
:key="task.id + 'assignee' + u.id"
:show-username="false"
:user="u"
/>
</div>
<priority-label :priority="task.priority" :done="task.done" class="is-inline-flex is-align-items-center"/>
<assignee-list
v-if="task.assignees.length > 0"
:assignees="task.assignees"
:avatar-size="24"
class="ml-1"
:inline="true"
/>
<checklist-summary :task="task"/>
<span class="icon" v-if="task.attachments.length > 0">
<icon icon="paperclip"/>
@ -78,7 +76,6 @@ import {ref, computed, watch} from 'vue'
import {useRouter} from 'vue-router'
import PriorityLabel from '@/components/tasks/partials/priorityLabel.vue'
import User from '@/components/misc/user.vue'
import Done from '@/components/misc/Done.vue'
import Labels from '@/components/tasks/partials/labels.vue'
import ChecklistSummary from './checklist-summary.vue'
@ -91,6 +88,9 @@ import AttachmentService from '@/services/attachment'
import {formatDateLong, formatISO, formatDateSince} from '@/helpers/time/formatDate'
import {colorIsDark} from '@/helpers/color/colorIsDark'
import {useTaskStore} from '@/stores/tasks'
import AssigneeList from '@/components/tasks/partials/assigneeList.vue'
import {useAuthStore} from '@/stores/auth'
import {playPopSound} from '@/helpers/playPop'
const router = useRouter()
@ -109,10 +109,14 @@ const color = computed(() => getHexColor(task.hexColor))
async function toggleTaskDone(task: ITask) {
loadingInternal.value = true
try {
await useTaskStore().update({
const updatedTask = await useTaskStore().update({
...task,
done: !task.done,
})
if (updatedTask.done && useAuthStore().settings.frontendSettings.playSoundWhenDone) {
playPopSound()
}
} finally {
loadingInternal.value = false
}
@ -238,7 +242,7 @@ $task-background: var(--white);
.priority-label {
font-size: .75rem;
height: 2rem;
padding: 0 .5rem 0 .25rem;
.icon {
height: 1rem;

View File

@ -0,0 +1,25 @@
<script setup lang="ts">
import type {ILabel} from '@/modelTypes/ILabel'
defineProps<{
label: ILabel
}>()
</script>
<template>
<span
:key="label.id"
:style="{'background': label.hexColor, 'color': label.textColor}"
class="tag"
>
<span>{{ label.title }}</span>
</span>
</template>
<style scoped lang="scss">
.tag {
& + & {
margin-left: 0.5rem;
}
}
</style>

View File

@ -1,12 +1,10 @@
<template>
<div class="label-wrapper">
<span
<XLabel
v-for="label in labels"
:label="label"
:key="label.id"
:style="{'background': label.hexColor, 'color': label.textColor}"
class="tag"
v-for="label in labels">
<span>{{ label.title }}</span>
</span>
/>
</div>
</template>
@ -14,6 +12,8 @@
import type {PropType} from 'vue'
import type {ILabel} from '@/modelTypes/ILabel'
import XLabel from '@/components/tasks/partials/label.vue'
defineProps({
labels: {
type: Array as PropType<ILabel[]>,
@ -26,10 +26,4 @@ defineProps({
.label-wrapper {
display: inline;
}
.tag {
& + & {
margin-left: 0.5rem;
}
}
</style>

View File

@ -40,17 +40,12 @@ defineProps({
</script>
<style lang="scss" scoped>
.priority-label {
display: inline-flex;
align-items: center;
}
span.high-priority {
color: var(--danger);
width: auto !important; // To override the width set in tasks
.icon {
vertical-align: middle;
vertical-align: top;
width: auto !important;
padding: 0 .5rem;
}

View File

@ -108,7 +108,7 @@ const visible = ref(false)
const mode = computed(() => authStore.settings.frontendSettings.quickAddMagicMode)
defineProps<{
highlightHintIcon: boolean,
highlightHintIcon?: boolean,
}>()
const prefixes = computed(() => PREFIXES[mode.value])

View File

@ -9,6 +9,7 @@
variant="secondary"
icon="plus"
:shadow="false"
id="showRelatedTasksFormButton"
/>
<transition-group name="fade">
<template v-if="editEnabled && showCreate">
@ -161,6 +162,8 @@ import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import {error, success} from '@/message'
import {useTaskStore} from '@/stores/tasks'
import {useProjectStore} from '@/stores/projects'
import {useAuthStore} from '@/stores/auth'
import {playPopSound} from '@/helpers/playPop'
const props = defineProps({
taskId: {
@ -329,6 +332,10 @@ async function createAndRelateTask(title: string) {
async function toggleTaskDone(task: ITask) {
await taskStore.update(task)
if (task.done && useAuthStore().settings.frontendSettings.playSoundWhenDone) {
playPopSound()
}
// Find the task in the project and update it so that it is correctly strike through
Object.entries(relatedTasks.value).some(([kind, tasks]) => {
return (tasks as ITask[]).some((t, key) => {

View File

@ -7,8 +7,8 @@
<x-button variant="secondary" class="is-small" @click="() => setRepeatAfter(1, 'weeks')">
{{ $t('task.repeat.everyWeek') }}
</x-button>
<x-button variant="secondary" class="is-small" @click="() => setRepeatAfter(1, 'months')">
{{ $t('task.repeat.everyMonth') }}
<x-button variant="secondary" class="is-small" @click="() => setRepeatAfter(30, 'days')">
{{ $t('task.repeat.every30d') }}
</x-button>
</div>
<div class="is-flex is-align-items-center mb-2">
@ -51,8 +51,6 @@
<option value="hours">{{ $t('task.repeat.hours') }}</option>
<option value="days">{{ $t('task.repeat.days') }}</option>
<option value="weeks">{{ $t('task.repeat.weeks') }}</option>
<option value="months">{{ $t('task.repeat.months') }}</option>
<option value="years">{{ $t('task.repeat.years') }}</option>
</select>
</div>
</div>

View File

@ -32,6 +32,8 @@
:color="getHexColor(task.hexColor)"
class="mr-1"
/>
<priority-label :priority="task.priority" :done="task.done"/>
<!-- Show any parent tasks to make it clear this task is a sub task of something -->
<span class="parent-tasks" v-if="typeof task.relatedTasks?.parenttask !== 'undefined'">
@ -49,14 +51,12 @@
:labels="task.labels"
/>
<User
v-for="(a, i) in task.assignees"
:avatar-size="27"
:is-inline="true"
:key="task.id + 'assignee' + a.id + i"
:show-username="false"
:user="a"
class="m-2"
<assignee-list
v-if="task.assignees.length > 0"
:assignees="task.assignees"
:avatar-size="25"
class="ml-1"
:inline="true"
/>
<!-- FIXME: use popup -->
@ -72,15 +72,13 @@
class="is-italic"
:aria-expanded="showDefer ? 'true' : 'false'"
>
{{ $t('task.detail.due', {at: formatDateSince(task.dueDate)}) }}
{{ $t('task.detail.due', {at: dueDateFormatted}) }}
</time>
</BaseButton>
<CustomTransition name="fade">
<defer-task v-if="+new Date(task.dueDate) > 0 && showDefer" v-model="task" ref="deferDueDate"/>
</CustomTransition>
<priority-label :priority="task.priority" :done="task.done"/>
<span>
<span class="project-task-icon" v-if="task.attachments.length > 0">
<icon icon="paperclip"/>
@ -121,7 +119,7 @@
<icon icon="star" v-if="task.isFavorite"/>
<icon :icon="['far', 'star']" v-else/>
</BaseButton>
<slot />
<slot/>
</div>
</template>
@ -129,7 +127,7 @@
import {ref, watch, shallowReactive, onMounted, onBeforeUnmount, computed} from 'vue'
import {useI18n} from 'vue-i18n'
import TaskModel, { getHexColor } from '@/models/task'
import TaskModel, {getHexColor} from '@/models/task'
import type {ITask} from '@/modelTypes/ITask'
import PriorityLabel from '@/components/tasks/partials/priorityLabel.vue'
@ -137,7 +135,6 @@ import Labels from '@/components/tasks/partials//labels.vue'
import DeferTask from '@/components/tasks/partials//defer-task.vue'
import ChecklistSummary from '@/components/tasks/partials/checklist-summary.vue'
import User from '@/components/misc/user.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import ColorBubble from '@/components/misc/colorBubble.vue'
@ -152,6 +149,10 @@ import {success} from '@/message'
import {useProjectStore} from '@/stores/projects'
import {useBaseStore} from '@/stores/base'
import {useTaskStore} from '@/stores/tasks'
import AssigneeList from '@/components/tasks/partials/assigneeList.vue'
import {useIntervalFn} from '@vueuse/core'
import {playPopSound} from '@/helpers/playPop'
import {useAuthStore} from '@/stores/auth'
const {
theTask,
@ -214,11 +215,28 @@ const taskDetailRoute = computed(() => ({
// state: { backdropView: router.currentRoute.value.fullPath },
}))
function updateDueDate() {
if (!task.value.dueDate) {
return
}
dueDateFormatted.value = formatDateSince(task.value.dueDate)
}
const dueDateFormatted = ref('')
useIntervalFn(updateDueDate, 60_000, {
immediateCallback: true,
})
onMounted(updateDueDate)
async function markAsDone(checked: boolean) {
const updateFunc = async () => {
const newTask = await taskStore.update(task.value)
task.value = newTask
if (checked && useAuthStore().settings.frontendSettings.playSoundWhenDone) {
playPopSound()
}
emit('task-updated', newTask)
success({
message: task.value.done ?
@ -248,6 +266,7 @@ async function toggleFavorite() {
}
const deferDueDate = ref<typeof DeferTask | null>(null)
function hideDeferDueDatePopup(e) {
if (!showDefer.value) {
return
@ -283,7 +302,7 @@ function hideDeferDueDatePopup(e) {
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
flex: 1 0 50%;
.dueDate {
@ -393,7 +412,7 @@ function hideDeferDueDatePopup(e) {
color: var(--danger);
}
input[type="checkbox"] {
input[type='checkbox'] {
vertical-align: middle;
}

View File

@ -0,0 +1,190 @@
<template>
<div class="task">
<span>
<span
v-if="showProject && typeof project !== 'undefined'"
class="task-project"
:class="{'mr-2': task.hexColor !== ''}"
v-tooltip="$t('task.detail.belongsToProject', {project: project.title})"
>
{{ project.title }}
</span>
<ColorBubble
v-if="task.hexColor !== ''"
:color="getHexColor(task.hexColor)"
class="mr-1"
/>
<priority-label :priority="task.priority" :done="task.done"/>
<!-- Show any parent tasks to make it clear this task is a sub task of something -->
<span class="parent-tasks" v-if="typeof task.relatedTasks?.parenttask !== 'undefined'">
<template v-for="(pt, i) in task.relatedTasks.parenttask">
{{ pt.title }}<template v-if="(i + 1) < task.relatedTasks.parenttask.length">,&nbsp;</template>
</template>
&rsaquo;
</span>
{{ task.title }}
</span>
<labels
v-if="task.labels.length > 0"
class="labels ml-2 mr-1"
:labels="task.labels"
/>
<assignee-list
v-if="task.assignees.length > 0"
:assignees="task.assignees"
:avatar-size="20"
class="ml-1"
:inline="true"
/>
<span
v-if="+new Date(task.dueDate) > 0"
class="dueDate"
v-tooltip="formatDateLong(task.dueDate)"
>
<time
:datetime="formatISO(task.dueDate)"
:class="{'overdue': task.dueDate <= new Date() && !task.done}"
class="is-italic"
>
{{ $t('task.detail.due', {at: formatDateSince(task.dueDate)}) }}
</time>
</span>
<span>
<span class="project-task-icon" v-if="task.attachments.length > 0">
<icon icon="paperclip"/>
</span>
<span class="project-task-icon" v-if="task.description">
<icon icon="align-left"/>
</span>
<span class="project-task-icon" v-if="task.repeatAfter.amount > 0">
<icon icon="history"/>
</span>
</span>
<checklist-summary :task="task"/>
<progress
class="progress is-small"
v-if="task.percentDone > 0"
:value="task.percentDone * 100" max="100"
>
{{ task.percentDone * 100 }}%
</progress>
</div>
</template>
<script setup lang="ts">
import {computed} from 'vue'
import {getHexColor} from '@/models/task'
import type {ITask} from '@/modelTypes/ITask'
import PriorityLabel from '@/components/tasks/partials/priorityLabel.vue'
import Labels from '@/components/tasks/partials//labels.vue'
import ChecklistSummary from '@/components/tasks/partials/checklist-summary.vue'
import ColorBubble from '@/components/misc/colorBubble.vue'
import {formatDateSince, formatISO, formatDateLong} from '@/helpers/time/formatDate'
import {useProjectStore} from '@/stores/projects'
import AssigneeList from '@/components/tasks/partials/assigneeList.vue'
const {
task,
showProject = false,
} = defineProps<{
task: ITask,
showProject?: boolean,
}>()
const projectStore = useProjectStore()
const project = computed(() => projectStore.projects[task.projectId])
</script>
<style lang="scss" scoped>
.task {
display: flex;
flex-wrap: wrap;
transition: background-color $transition;
align-items: center;
cursor: pointer;
border-radius: $radius;
border: 2px solid transparent;
text-overflow: ellipsis;
word-wrap: break-word;
word-break: break-word;
//display: -webkit-box;
hyphens: auto;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
//flex: 1 0 50%;
.dueDate {
display: inline-block;
margin-left: 5px;
}
.overdue {
color: var(--danger);
}
.task-project {
width: auto;
color: var(--grey-400);
font-size: .9rem;
white-space: nowrap;
}
.avatar {
border-radius: 50%;
vertical-align: bottom;
margin-left: .5rem;
height: 21px;
width: 21px;
}
.project-task-icon {
margin-left: 6px;
&:not(:first-of-type) {
margin-left: 8px;
}
}
a {
color: var(--text);
transition: color ease $transition-duration;
&:hover {
color: var(--grey-900);
}
}
.tasktext.done {
text-decoration: line-through;
color: var(--grey-500);
}
span.parent-tasks {
color: var(--grey-500);
width: auto;
}
.progress {
margin-bottom: 0;
}
}
</style>

View File

@ -17,7 +17,7 @@ export function useRouteFilters<CurrentFilters extends Filters>(
const routeFromFiltersFullPath = computed(() => router.resolve(filtersToRoute(filters.value)).fullPath)
watch(
route,
route.value,
(route, oldRoute) => {
if (
route?.name !== oldRoute?.name ||

View File

@ -32,6 +32,11 @@ export function useRouteWithModal() {
: routePropsOption
: {}
if (typeof routeProps === 'undefined') {
currentModal.value = undefined
return
}
routeProps.backdropView = backdropView.value
const component = route.matched[0]?.components?.default

View File

@ -1,5 +1,6 @@
import {ref, shallowReactive, watch, computed, type ComputedGetter} from 'vue'
import {useRoute} from 'vue-router'
import {useRouteQuery} from '@vueuse/router'
import TaskCollectionService from '@/services/taskCollection'
import type {ITask} from '@/modelTypes/ITask'
@ -68,23 +69,33 @@ export function useTaskList(projectIdGetter: ComputedGetter<IProject['id']>, sor
const params = ref({...getDefaultParams()})
const search = ref('')
const page = ref(1)
const page = useRouteQuery('page', '1', { transform: Number })
const sortBy = ref({ ...sortByDefault })
const getAllTasksParams = computed(() => {
let loadParams = {...params.value}
const allParams = computed(() => {
const loadParams = {...params.value}
if (search.value !== '') {
loadParams.s = search.value
}
loadParams = formatSortOrder(sortBy.value, loadParams)
return formatSortOrder(sortBy.value, loadParams)
})
watch(
() => allParams.value,
() => {
// When parameters change, the page should always be the first
page.value = 1
},
)
const getAllTasksParams = computed(() => {
return [
{projectId: projectId.value},
loadParams,
page.value || 1,
allParams.value,
page.value,
]
})

View File

@ -4,8 +4,27 @@ const API_DEFAULT_PORT = '3456'
export const ERROR_NO_API_URL = 'noApiUrlProvided'
export class NoApiUrlProvidedError extends Error {
constructor() {
super()
this.message = 'No API URL provided'
this.name = 'NoApiUrlProvidedError'
}
}
export class InvalidApiUrlProvidedError extends Error {
constructor() {
super()
this.message = 'The provided API URL is invalid.'
this.name = 'InvalidApiUrlProvidedError'
}
}
export const checkAndSetApiUrl = (url: string | undefined | null): Promise<string> => {
if (url === '' || url === null || typeof url === 'undefined') {
throw new NoApiUrlProvidedError()
}
export const checkAndSetApiUrl = (url: string): Promise<string> => {
if (url.startsWith('/')) {
url = window.location.host + url
}
@ -17,8 +36,14 @@ export const checkAndSetApiUrl = (url: string): Promise<string> => {
) {
url = `${window.location.protocol}//${url}`
}
let urlToCheck: URL
try {
urlToCheck = new URL(url)
} catch (e) {
throw new InvalidApiUrlProvidedError()
}
const urlToCheck: URL = new URL(url)
const origUrlToCheck = urlToCheck
const oldUrl = window.API_URL
@ -86,6 +111,6 @@ export const checkAndSetApiUrl = (url: string): Promise<string> => {
return window.API_URL
}
throw new Error(ERROR_NO_API_URL)
throw new InvalidApiUrlProvidedError()
})
}

View File

@ -4,7 +4,7 @@ import {PrefixMode} from '@/modules/parseTaskText'
describe('Parse Subtasks via Relation', () => {
it('Should not return a parent for a single task', () => {
const tasks = parseSubtasksViaIndention('single task')
const tasks = parseSubtasksViaIndention('single task', PrefixMode.Default)
expect(tasks).to.have.length(1)
expect(tasks[0].parent).toBeNull()
@ -118,4 +118,52 @@ task two`, PrefixMode.Default)
expect(tasks[1].project).to.eq('list')
expect(tasks[2].project).to.eq('list')
})
it('Should clean the indention if there is indention on the first line', () => {
const tasks = parseSubtasksViaIndention(
` parent task
sub task one
sub task two`, PrefixMode.Default)
expect(tasks).to.have.length(3)
expect(tasks[0].parent).toBeNull()
expect(tasks[0].title).to.eq('parent task')
expect(tasks[1].title).to.eq('sub task one')
expect(tasks[1].parent).toBeNull()
expect(tasks[2].title).to.eq('sub task two')
expect(tasks[2].parent).to.eq('sub task one')
})
it('Should clean the indention if there is indention on the first line but not for subsequent tasks', () => {
const tasks = parseSubtasksViaIndention(
` parent task
sub task one
first level task one
sub task two`, PrefixMode.Default)
expect(tasks).to.have.length(4)
expect(tasks[0].parent).toBeNull()
expect(tasks[0].title).to.eq('parent task')
expect(tasks[1].title).to.eq('sub task one')
expect(tasks[1].parent).toBeNull()
expect(tasks[2].title).to.eq('first level task one')
expect(tasks[2].parent).toBeNull()
expect(tasks[3].title).to.eq('sub task two')
expect(tasks[3].parent).to.eq('first level task one')
})
it('Should clean the indention if there is indention on the first line for subsequent tasks with less indention', () => {
const tasks = parseSubtasksViaIndention(
` parent task
sub task one
first level task one
sub task two`, PrefixMode.Default)
expect(tasks).to.have.length(4)
expect(tasks[0].parent).toBeNull()
expect(tasks[0].title).to.eq('parent task')
expect(tasks[1].title).to.eq('sub task one')
expect(tasks[1].parent).toBeNull()
expect(tasks[2].title).to.eq('first level task one')
expect(tasks[2].parent).toBeNull()
expect(tasks[3].title).to.eq('sub task two')
expect(tasks[3].parent).to.eq('first level task one')
})
})

View File

@ -17,7 +17,29 @@ const spaceRegex = /^ */
* relation between each other.
*/
export function parseSubtasksViaIndention(taskTitles: string, prefixMode: PrefixMode): TaskWithParent[] {
const titles = taskTitles.split(/[\r\n]+/)
let titles = taskTitles.split(/[\r\n]+/)
if (titles.length == 0) {
return []
}
const spaceOnFirstLine = /^(\t| )+/
const spaces = spaceOnFirstLine.exec(titles[0])
if (spaces !== null) {
let spacesToCut = spaces[0].length
titles = titles.map(title => {
const spacesOnThisLine = spaceOnFirstLine.exec(title)
if (spacesOnThisLine === null) {
// This means the current task title does not start with indention, but the very first one did
// To prevent cutting actual task data we now need to update the number of spaces to cut
spacesToCut = 0
}
if (spacesOnThisLine !== null && spacesOnThisLine[0].length < spacesToCut) {
spacesToCut = spacesOnThisLine[0].length
}
return title.substring(spacesToCut)
})
}
return titles.map((title, index) => {
const task: TaskWithParent = {
@ -32,7 +54,7 @@ export function parseSubtasksViaIndention(taskTitles: string, prefixMode: Prefix
return task
}
const matched = spaceRegex.exec(title)
const matched = spaceRegex.exec(task.title)
const matchedSpaces = matched ? matched[0].length : 0
if (matchedSpaces > 0) {
@ -45,7 +67,7 @@ export function parseSubtasksViaIndention(taskTitles: string, prefixMode: Prefix
const parentMatched = spaceRegex.exec(task.parent)
parentSpaces = parentMatched ? parentMatched[0].length : 0
} while (parentSpaces >= matchedSpaces)
task.title = cleanupTitle(title.replace(spaceRegex, ''))
task.title = cleanupTitle(task.title.replace(spaceRegex, ''))
task.parent = task.parent.replace(spaceRegex, '')
if (task.project === null) {
// This allows to specify a project once for the parent task and inherit it to all subtasks

View File

@ -1,6 +1,10 @@
const LAST_VISITED_KEY = 'lastVisited'
export const saveLastVisited = (name: string, params: object, query: object) => {
export const saveLastVisited = (name: string | undefined, params: object, query: object) => {
if (typeof name === 'undefined') {
return
}
localStorage.setItem(LAST_VISITED_KEY, JSON.stringify({name, params, query}))
}
@ -9,7 +13,7 @@ export const getLastVisited = () => {
if (lastVisited === null) {
return null
}
return JSON.parse(lastVisited)
}

View File

@ -5,8 +5,8 @@ import {format, formatDistanceToNow} from 'date-fns'
import {enGB, de, fr, ru} from 'date-fns/locale'
import {i18n} from '@/i18n'
import { createSharedComposable, type MaybeRef } from '@vueuse/core'
import { computed, unref } from 'vue'
import {createSharedComposable, type MaybeRef} from '@vueuse/core'
import {computed, unref} from 'vue'
const locales = {en: enGB, de, ch: de, fr, ru}
@ -62,7 +62,7 @@ export const useDateTimeFormatter = createSharedComposable((options?: MaybeRef<I
})
export function useWeekDayFromDate() {
const dateTimeFormatter = useDateTimeFormatter({ weekday: 'short' })
const dateTimeFormatter = useDateTimeFormatter({weekday: 'short'})
return computed(() => (date: Date) => dateTimeFormatter.value.format(date))
}

View File

@ -16,10 +16,6 @@ export function secondsToPeriod(seconds: number): { unit: PeriodUnit, amount: nu
if (seconds % SECONDS_A_DAY === 0) {
if (seconds % SECONDS_A_WEEK === 0) {
return {unit: 'weeks', amount: seconds / SECONDS_A_WEEK}
} else if (seconds % SECONDS_A_MONTH === 0) {
return {unit: 'days', amount: seconds / SECONDS_A_MONTH * 30}
} else if (seconds % SECONDS_A_YEAR === 0) {
return {unit: 'years', amount: seconds / SECONDS_A_YEAR}
} else {
return {unit: 'days', amount: seconds / SECONDS_A_DAY}
}

View File

@ -18,6 +18,7 @@ export const SUPPORTED_LOCALES = {
'es-ES': 'Español',
'da-DK': 'Dansk',
'ja-JP': '日本語',
'hu-HU': 'Magyar',
} as const
export type SupportedLocale = keyof typeof SUPPORTED_LOCALES
@ -47,8 +48,13 @@ export async function setLanguage(lang: SupportedLocale): Promise<SupportedLocal
// If the language hasn't been loaded yet
if (!i18n.global.availableLocales.includes(lang)) {
const messages = await import(`./lang/${lang}.json`)
i18n.global.setLocaleMessage(lang, messages.default)
try {
const messages = await import(`./lang/${lang}.json`)
i18n.global.setLocaleMessage(lang, messages.default)
} catch (e) {
console.error(`Failed to load language ${lang}:`, e)
return setLanguage(getBrowserLanguage())
}
}
i18n.global.locale.value = lang

View File

@ -11,6 +11,11 @@
"import": "Import your data into Vikunja"
}
},
"demo": {
"title": "This instance is in demo mode. Do not use this for real data!",
"everythingWillBeDeleted": "Everything will be deleted in regular intervals!",
"accountWillBeDeleted": "Your account will be deleted, including all projects, tasks and attachments you might create."
},
"404": {
"title": "Not found",
"text": "The page you requested does not exist."
@ -139,6 +144,30 @@
"system": "System",
"dark": "Dark"
}
},
"apiTokens": {
"title": "API Tokens",
"general": "API tokens allow you to use Vikunja's API without user credentials.",
"apiDocs": "Check out the api docs",
"createAToken": "Create a token",
"createToken": "Create token",
"30d": "30 Days",
"60d": "60 Days",
"90d": "90 Days",
"permissionExplanation": "Permissions allow you to scope what an api token is allowed to do.",
"titleRequired": "The title is required",
"expired": "This token has expired {ago}.",
"delete": {
"header": "Delete this token",
"text1": "Are you sure you want to delete the token \"{token}\"?",
"text2": "This will revoke access to all applications or integrations using it. You cannot undo this."
},
"attributes": {
"title": "Title",
"titlePlaceholder": "Enter a title you will recognize later",
"expiresAt": "Expires at",
"permissions": "Permissions"
}
}
},
"deletion": {
@ -305,6 +334,9 @@
"doneBucketHint": "All tasks moved into this bucket will automatically marked as done.",
"doneBucketHintExtended": "All tasks moved into the done bucket will be marked as done automatically. All tasks marked as done from elsewhere will be moved as well.",
"doneBucketSavedSuccess": "The done bucket has been saved successfully.",
"defaultBucket": "Default bucket",
"defaultBucketHint": "When creating tasks without specifying a bucket, they will be added to this bucket.",
"defaultBucketSavedSuccess": "The default bucket has been saved successfully.",
"deleteLast": "You cannot remove the last bucket.",
"addTaskPlaceholder": "Enter the new task title…",
"addTask": "Add a task",
@ -881,7 +913,7 @@
"urlPlaceholder": "eg. https://localhost:3456",
"change": "change",
"use": "Using Vikunja installation at {0}",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please try a different url.",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please check if the url has the correct format and you can reach it when accessing it directly and try again.",
"success": "Using Vikunja installation at \"{domain}\".",
"urlRequired": "A url is required."
},
@ -902,6 +934,7 @@
"tasks": "Tasks",
"projects": "Projects",
"teams": "Teams",
"labels": "Labels",
"newProject": "Enter the title of the new project…",
"newTask": "Enter the title of the new task…",
"newTeam": "Enter the name of the new team…",

View File

@ -11,6 +11,11 @@
"import": "Import your data into Vikunja"
}
},
"demo": {
"title": "This instance is in demo mode. Do not use this for real data!",
"everythingWillBeDeleted": "Everything will be deleted in regular intervals!",
"accountWillBeDeleted": "Your account will be deleted, including all projects, tasks and attachments you might create."
},
"404": {
"title": "Nenalezeno",
"text": "Požadovaná stránka neexistuje."
@ -139,6 +144,30 @@
"system": "Systém",
"dark": "Tmavý"
}
},
"apiTokens": {
"title": "API Tokens",
"general": "API tokens allow you to use Vikunja's API without user credentials.",
"apiDocs": "Check out the api docs",
"createAToken": "Create a token",
"createToken": "Create token",
"30d": "30 Days",
"60d": "60 Days",
"90d": "90 Days",
"permissionExplanation": "Permissions allow you to scope what an api token is allowed to do.",
"titleRequired": "The title is required",
"expired": "This token has expired {ago}.",
"delete": {
"header": "Delete this token",
"text1": "Are you sure you want to delete the token \"{token}\"?",
"text2": "This will revoke access to all applications or integrations using it. You cannot undo this."
},
"attributes": {
"title": "Title",
"titlePlaceholder": "Enter a title you will recognize later",
"expiresAt": "Expires at",
"permissions": "Permissions"
}
}
},
"deletion": {
@ -305,6 +334,9 @@
"doneBucketHint": "All tasks moved into this bucket will automatically marked as done.",
"doneBucketHintExtended": "All tasks moved into the done bucket will be marked as done automatically. All tasks marked as done from elsewhere will be moved as well.",
"doneBucketSavedSuccess": "The done bucket has been saved successfully.",
"defaultBucket": "Default bucket",
"defaultBucketHint": "When creating tasks without specifying a bucket, they will be added to this bucket.",
"defaultBucketSavedSuccess": "The default bucket has been saved successfully.",
"deleteLast": "You cannot remove the last bucket.",
"addTaskPlaceholder": "Enter the new task title…",
"addTask": "Add a task",
@ -881,7 +913,7 @@
"urlPlaceholder": "např. https://localhost:3456",
"change": "změnit",
"use": "Používá se instalace Vikunja v {0}",
"error": "Nelze najít nebo použít instalaci Vikunja na \"{domain}\". Zkuste prosím jinou url.",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please check if the url has the correct format and you can reach it when accessing it directly and try again.",
"success": "Pomocí instalace Vikunja na \"{domain}\".",
"urlRequired": "Je vyžadována adresa URL."
},
@ -902,6 +934,7 @@
"tasks": "Úkoly",
"projects": "Projects",
"teams": "Týmy",
"labels": "Labels",
"newProject": "Enter the title of the new project…",
"newTask": "Zadejte název nového úkolu…",
"newTeam": "Zadejte název nového týmu…",

View File

@ -11,6 +11,11 @@
"import": "Import your data into Vikunja"
}
},
"demo": {
"title": "This instance is in demo mode. Do not use this for real data!",
"everythingWillBeDeleted": "Everything will be deleted in regular intervals!",
"accountWillBeDeleted": "Your account will be deleted, including all projects, tasks and attachments you might create."
},
"404": {
"title": "Ikke fundet",
"text": "Den ønskede side findes ikke."
@ -139,6 +144,30 @@
"system": "System",
"dark": "Mørk"
}
},
"apiTokens": {
"title": "API Tokens",
"general": "API tokens allow you to use Vikunja's API without user credentials.",
"apiDocs": "Check out the api docs",
"createAToken": "Create a token",
"createToken": "Create token",
"30d": "30 Days",
"60d": "60 Days",
"90d": "90 Days",
"permissionExplanation": "Permissions allow you to scope what an api token is allowed to do.",
"titleRequired": "The title is required",
"expired": "This token has expired {ago}.",
"delete": {
"header": "Delete this token",
"text1": "Are you sure you want to delete the token \"{token}\"?",
"text2": "This will revoke access to all applications or integrations using it. You cannot undo this."
},
"attributes": {
"title": "Title",
"titlePlaceholder": "Enter a title you will recognize later",
"expiresAt": "Expires at",
"permissions": "Permissions"
}
}
},
"deletion": {
@ -305,6 +334,9 @@
"doneBucketHint": "All tasks moved into this bucket will automatically marked as done.",
"doneBucketHintExtended": "All tasks moved into the done bucket will be marked as done automatically. All tasks marked as done from elsewhere will be moved as well.",
"doneBucketSavedSuccess": "The done bucket has been saved successfully.",
"defaultBucket": "Default bucket",
"defaultBucketHint": "When creating tasks without specifying a bucket, they will be added to this bucket.",
"defaultBucketSavedSuccess": "The default bucket has been saved successfully.",
"deleteLast": "You cannot remove the last bucket.",
"addTaskPlaceholder": "Enter the new task title…",
"addTask": "Add a task",
@ -881,7 +913,7 @@
"urlPlaceholder": "f.eks. https://localhost:3456",
"change": "ændr",
"use": "Brug Vikunja-installationen på {0}",
"error": "Kunne ikke finde eller bruge Vikunja-installationen på \"{domain}\". Prøv venligst en anden url.",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please check if the url has the correct format and you can reach it when accessing it directly and try again.",
"success": "Bruger Vikunja-installationen på \"{domain}\".",
"urlRequired": "En url er påkrævet."
},
@ -902,6 +934,7 @@
"tasks": "Opgaver",
"projects": "Projects",
"teams": "Hold",
"labels": "Labels",
"newProject": "Enter the title of the new project…",
"newTask": "Indtast titlen på den nye opgave…",
"newTeam": "Indtast navnet på det nye hold…",

View File

@ -11,6 +11,11 @@
"import": "Importiere deine Daten in Vikunja"
}
},
"demo": {
"title": "Diese Instanz ist im Demo-Modus. Verwende sie nicht mit echten Daten!",
"everythingWillBeDeleted": "Alles wird in regelmäßigen Abständen gelöscht!",
"accountWillBeDeleted": "Dein Account wird gelöscht, einschließlich aller Projekte, Aufgaben und Anhänge, die du möglicherweise erstellst."
},
"404": {
"title": "Nicht gefunden",
"text": "Die angeforderte Seite existiert nicht."
@ -139,6 +144,30 @@
"system": "System",
"dark": "Dunkel"
}
},
"apiTokens": {
"title": "API-Tokens",
"general": "Mit API-Token kannst du die API von Vikunja ohne Login-Daten verwenden.",
"apiDocs": "Schaue dir die API-Dokumentation an",
"createAToken": "Token erstellen",
"createToken": "Token erstellen",
"30d": "30 Tage",
"60d": "60 Tage",
"90d": "90 Tage",
"permissionExplanation": "Mit Berechtigungen kannst du einschränken, was ein API-Token tun darf.",
"titleRequired": "Titel ist erforderlich",
"expired": "Dieses Token ist {ago} abgelaufen.",
"delete": {
"header": "Dieses Token löschen",
"text1": "Bist Du sicher, dass Du das Token \"{token}\" löschen möchtest?",
"text2": "Dies wird den Zugriff des Tokens auf alle Anwendungen oder Integrationen aufheben. Du kannst dies nicht rückgängig machen."
},
"attributes": {
"title": "Titel",
"titlePlaceholder": "Gib einen Titel ein, den du später erkennen wirst",
"expiresAt": "Läuft ab am",
"permissions": "Berechtigungen"
}
}
},
"deletion": {
@ -305,6 +334,9 @@
"doneBucketHint": "Alle Aufgaben, die in diese Spalte verschoben werden, werden automatisch als erledigt markiert.",
"doneBucketHintExtended": "Alle Aufgaben, die in die Erledigt Spalte verschoben wurden, werden automatisch als erledigt markiert. Aufgaben, die in einer anderen Spalte als Erledigt markiert wurden, werden auch in diese Spalte verschoben.",
"doneBucketSavedSuccess": "Erledigt Spalte gespeichert.",
"defaultBucket": "Standard-Spalte",
"defaultBucketHint": "Wenn Aufgaben ohne Angabe einer Spalte erstellt werden, werden sie zu dieser Spalte hinzugefügt.",
"defaultBucketSavedSuccess": "Die Standardspalte wurde erfolgreich gespeichert.",
"deleteLast": "Du kannst die letzte Spalte nicht entfernen.",
"addTaskPlaceholder": "Gebe einen Aufgabentitel ein …",
"addTask": "Eine Aufgabe hinzufügen",
@ -881,7 +913,7 @@
"urlPlaceholder": "z.B. https://localhost:3456",
"change": "ändern",
"use": "Verwende die Vikunja-Installation unter „{0}“",
"error": "Konnte keine Vikunja-Installation unter „{domain}“ finden oder verwenden. Bitte probiere eine andere Url.",
"error": "Vikunja Installation unter \"{domain}\" konnte nicht gefunden oder verwendet werden. Bitte prüfe, ob die URL das richtige Format hat und direkt darauf zugreifen kannst und versuche es erneut.",
"success": "Verwende die Vikunja-Installation unter „{domain}“.",
"urlRequired": "Eine Url ist erforderlich."
},
@ -902,6 +934,7 @@
"tasks": "Aufgaben",
"projects": "Projekte",
"teams": "Teams",
"labels": "Labels",
"newProject": "Gib den Titel des neuen Projekts ein…",
"newTask": "Gib den Titel der neuen Aufgabe ein …",
"newTeam": "Gib den Namen des neuen Teams ein …",

View File

@ -11,6 +11,11 @@
"import": "Importiere deine Daten in Vikunja"
}
},
"demo": {
"title": "Diese Instanz ist im Demo-Modus. Verwende sie nicht mit echten Daten!",
"everythingWillBeDeleted": "Alles wird in regelmäßigen Abständen gelöscht!",
"accountWillBeDeleted": "Dein Account wird gelöscht, einschließlich aller Projekte, Aufgaben und Anhänge, die du möglicherweise erstellst."
},
"404": {
"title": "Nid gfunde",
"text": "Dini gsuechti Siite giz nid."
@ -139,6 +144,30 @@
"system": "System",
"dark": "Dunkel"
}
},
"apiTokens": {
"title": "API-Tokens",
"general": "Mit API-Token kannst du die API von Vikunja ohne Login-Daten verwenden.",
"apiDocs": "Schaue dir die API-Dokumentation an",
"createAToken": "Token erstellen",
"createToken": "Token erstellen",
"30d": "30 Tage",
"60d": "60 Tage",
"90d": "90 Tage",
"permissionExplanation": "Mit Berechtigungen kannst du einschränken, was ein API-Token tun darf.",
"titleRequired": "Titel ist erforderlich",
"expired": "Dieses Token ist {ago} abgelaufen.",
"delete": {
"header": "Dieses Token löschen",
"text1": "Bist Du sicher, dass Du das Token \"{token}\" löschen möchtest?",
"text2": "Dies wird den Zugriff des Tokens auf alle Anwendungen oder Integrationen aufheben. Du kannst dies nicht rückgängig machen."
},
"attributes": {
"title": "Titel",
"titlePlaceholder": "Gib einen Titel ein, den du später erkennen wirst",
"expiresAt": "Läuft ab am",
"permissions": "Berechtigungen"
}
}
},
"deletion": {
@ -305,6 +334,9 @@
"doneBucketHint": "Alle Aufgaben, die in diese Spalte verschoben werden, werden automatisch als erledigt markiert.",
"doneBucketHintExtended": "Alle Aufgaben, die in die Erledigt Spalte verschoben wurden, werden automatisch als erledigt markiert. Aufgaben, die in einer anderen Spalte als Erledigt markiert wurden, werden auch in diese Spalte verschoben.",
"doneBucketSavedSuccess": "Erledigt Spalte gespeichert.",
"defaultBucket": "Standard-Spalte",
"defaultBucketHint": "Wenn Aufgaben ohne Angabe einer Spalte erstellt werden, werden sie zu dieser Spalte hinzugefügt.",
"defaultBucketSavedSuccess": "Die Standardspalte wurde erfolgreich gespeichert.",
"deleteLast": "Du kannst die letzte Spalte nicht entfernen.",
"addTaskPlaceholder": "Gebe einen Aufgabentitel ein …",
"addTask": "Eine Aufgabe hinzufügen",
@ -881,7 +913,7 @@
"urlPlaceholder": "z.B. https://localhost:3456",
"change": "ändere",
"use": "Verwende die Vikunja-Installation unter „{0}“",
"error": "Konnte keine Vikunja-Installation unter „{domain}“ finden oder verwenden. Bitte probiere eine andere Url.",
"error": "Vikunja Installation unter \"{domain}\" konnte nicht gefunden oder verwendet werden. Bitte prüfe, ob die URL das richtige Format hat und direkt darauf zugreifen kannst und versuche es erneut.",
"success": "Benutze d'Vikunja Installation uf \"{domain}\".",
"urlRequired": "Eine Url ist erforderlich."
},
@ -902,6 +934,7 @@
"tasks": "Uufgabe",
"projects": "Projekte",
"teams": "Teams",
"labels": "Labels",
"newProject": "Gib den Titel des neuen Projekts ein…",
"newTask": "Gib en Titl für die neu Uufgab iih…",
"newTeam": "Gib en Name für da neui Team iih…",

View File

@ -11,6 +11,11 @@
"import": "Import your data into Vikunja"
}
},
"demo": {
"title": "This instance is in demo mode. Do not use this for real data!",
"everythingWillBeDeleted": "Everything will be deleted in regular intervals!",
"accountWillBeDeleted": "Your account will be deleted, including all projects, tasks and attachments you might create."
},
"404": {
"title": "Not found",
"text": "The page you requested does not exist."
@ -139,6 +144,32 @@
"system": "System",
"dark": "Dark"
}
},
"apiTokens": {
"title": "API Tokens",
"general": "API tokens allow you to use Vikunja's API without user credentials.",
"apiDocs": "Check out the api docs",
"createAToken": "Create a token",
"createToken": "Create token",
"30d": "30 Days",
"60d": "60 Days",
"90d": "90 Days",
"permissionExplanation": "Permissions allow you to scope what an api token is allowed to do.",
"titleRequired": "The title is required",
"expired": "This token has expired {ago}.",
"tokenCreatedSuccess": "Here is your new api token: {token}",
"tokenCreatedNotSeeAgain": "Store it in a secure location, you won't see it again!",
"delete": {
"header": "Delete this token",
"text1": "Are you sure you want to delete the token \"{token}\"?",
"text2": "This will revoke access to all applications or integrations using it. You cannot undo this."
},
"attributes": {
"title": "Title",
"titlePlaceholder": "Enter a title you will recognize later",
"expiresAt": "Expires at",
"permissions": "Permissions"
}
}
},
"deletion": {
@ -305,6 +336,9 @@
"doneBucketHint": "All tasks moved into this bucket will automatically marked as done.",
"doneBucketHintExtended": "All tasks moved into the done bucket will be marked as done automatically. All tasks marked as done from elsewhere will be moved as well.",
"doneBucketSavedSuccess": "The done bucket has been saved successfully.",
"defaultBucket": "Default bucket",
"defaultBucketHint": "When creating tasks without specifying a bucket, they will be added to this bucket.",
"defaultBucketSavedSuccess": "The default bucket has been saved successfully.",
"deleteLast": "You cannot remove the last bucket.",
"addTaskPlaceholder": "Enter the new task title…",
"addTask": "Add a task",
@ -736,7 +770,7 @@
"repeat": {
"everyDay": "Every Day",
"everyWeek": "Every Week",
"everyMonth": "Every Month",
"every30d": "Every 30 Days",
"mode": "Repeat mode",
"monthly": "Monthly",
"fromCurrentDate": "From Current Date",
@ -884,7 +918,7 @@
"urlPlaceholder": "eg. https://localhost:3456",
"change": "change",
"use": "Using Vikunja installation at {0}",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please try a different url.",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please check if the url has the correct format and you can reach it when accessing it directly and try again.",
"success": "Using Vikunja installation at \"{domain}\".",
"urlRequired": "A url is required."
},
@ -905,6 +939,7 @@
"tasks": "Tasks",
"projects": "Projects",
"teams": "Teams",
"labels": "Labels",
"newProject": "Enter the title of the new project…",
"newTask": "Enter the title of the new task…",
"newTeam": "Enter the name of the new team…",

View File

@ -11,6 +11,11 @@
"import": "Import your data into Vikunja"
}
},
"demo": {
"title": "This instance is in demo mode. Do not use this for real data!",
"everythingWillBeDeleted": "Everything will be deleted in regular intervals!",
"accountWillBeDeleted": "Your account will be deleted, including all projects, tasks and attachments you might create."
},
"404": {
"title": "Not found",
"text": "The page you requested does not exist."
@ -139,6 +144,30 @@
"system": "System",
"dark": "Dark"
}
},
"apiTokens": {
"title": "API Tokens",
"general": "API tokens allow you to use Vikunja's API without user credentials.",
"apiDocs": "Check out the api docs",
"createAToken": "Create a token",
"createToken": "Create token",
"30d": "30 Days",
"60d": "60 Days",
"90d": "90 Days",
"permissionExplanation": "Permissions allow you to scope what an api token is allowed to do.",
"titleRequired": "The title is required",
"expired": "This token has expired {ago}.",
"delete": {
"header": "Delete this token",
"text1": "Are you sure you want to delete the token \"{token}\"?",
"text2": "This will revoke access to all applications or integrations using it. You cannot undo this."
},
"attributes": {
"title": "Title",
"titlePlaceholder": "Enter a title you will recognize later",
"expiresAt": "Expires at",
"permissions": "Permissions"
}
}
},
"deletion": {
@ -305,6 +334,9 @@
"doneBucketHint": "All tasks moved into this bucket will automatically marked as done.",
"doneBucketHintExtended": "All tasks moved into the done bucket will be marked as done automatically. All tasks marked as done from elsewhere will be moved as well.",
"doneBucketSavedSuccess": "The done bucket has been saved successfully.",
"defaultBucket": "Default bucket",
"defaultBucketHint": "When creating tasks without specifying a bucket, they will be added to this bucket.",
"defaultBucketSavedSuccess": "The default bucket has been saved successfully.",
"deleteLast": "You cannot remove the last bucket.",
"addTaskPlaceholder": "Enter the new task title…",
"addTask": "Add a task",
@ -881,7 +913,7 @@
"urlPlaceholder": "eg. https://localhost:3456",
"change": "change",
"use": "Using Vikunja installation at {0}",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please try a different url.",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please check if the url has the correct format and you can reach it when accessing it directly and try again.",
"success": "Using Vikunja installation at \"{domain}\".",
"urlRequired": "A url is required."
},
@ -902,6 +934,7 @@
"tasks": "Tasks",
"projects": "Projects",
"teams": "Teams",
"labels": "Labels",
"newProject": "Enter the title of the new project…",
"newTask": "Enter the title of the new task…",
"newTeam": "Enter the name of the new team…",

View File

@ -11,6 +11,11 @@
"import": "Importa tus datos a Vikunja"
}
},
"demo": {
"title": "This instance is in demo mode. Do not use this for real data!",
"everythingWillBeDeleted": "Everything will be deleted in regular intervals!",
"accountWillBeDeleted": "Your account will be deleted, including all projects, tasks and attachments you might create."
},
"404": {
"title": "No encontrado",
"text": "La página solicitada no existe."
@ -139,6 +144,30 @@
"system": "Sistema",
"dark": "Oscuro"
}
},
"apiTokens": {
"title": "API Tokens",
"general": "API tokens allow you to use Vikunja's API without user credentials.",
"apiDocs": "Check out the api docs",
"createAToken": "Create a token",
"createToken": "Create token",
"30d": "30 Days",
"60d": "60 Days",
"90d": "90 Days",
"permissionExplanation": "Permissions allow you to scope what an api token is allowed to do.",
"titleRequired": "The title is required",
"expired": "This token has expired {ago}.",
"delete": {
"header": "Delete this token",
"text1": "Are you sure you want to delete the token \"{token}\"?",
"text2": "This will revoke access to all applications or integrations using it. You cannot undo this."
},
"attributes": {
"title": "Title",
"titlePlaceholder": "Enter a title you will recognize later",
"expiresAt": "Expires at",
"permissions": "Permissions"
}
}
},
"deletion": {
@ -305,6 +334,9 @@
"doneBucketHint": "Todas las tareas movidas a este contenedor se marcarán automáticamente como finalizadas.",
"doneBucketHintExtended": "Todas las tareas movidas al contenedor completado se marcarán como finalizadas automáticamente. Todas las tareas marcadas como finalizadas desde otro lugar también se moverán.",
"doneBucketSavedSuccess": "El contenedor completado se ha guardado correctamente.",
"defaultBucket": "Default bucket",
"defaultBucketHint": "When creating tasks without specifying a bucket, they will be added to this bucket.",
"defaultBucketSavedSuccess": "The default bucket has been saved successfully.",
"deleteLast": "No puedes eliminar el último contenedor.",
"addTaskPlaceholder": "Introduce el nuevo título de la tarea…",
"addTask": "Añadir una tarea",
@ -881,7 +913,7 @@
"urlPlaceholder": "ej. https://localhost:3456",
"change": "cambiar",
"use": "Utilizando la instalación de Vikunja en {0}",
"error": "No se pudo encontrar o usar la instalación de Vikunja en \"{domain}\". Por favor, prueba con una url diferente.",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please check if the url has the correct format and you can reach it when accessing it directly and try again.",
"success": "Usando la instalación de Vikunja en \"{domain}\".",
"urlRequired": "Se requiere una url."
},
@ -902,6 +934,7 @@
"tasks": "Tareas",
"projects": "Proyectos",
"teams": "Equipos",
"labels": "Labels",
"newProject": "Introduzca el título del nuevo proyecto…",
"newTask": "Introduzca el título de la nueva tarea…",
"newTeam": "Introduzca el nombre del nuevo equipo…",

View File

@ -11,6 +11,11 @@
"import": "Importer vos données dans Vikunja"
}
},
"demo": {
"title": "This instance is in demo mode. Do not use this for real data!",
"everythingWillBeDeleted": "Everything will be deleted in regular intervals!",
"accountWillBeDeleted": "Your account will be deleted, including all projects, tasks and attachments you might create."
},
"404": {
"title": "Non trouvé",
"text": "La page que vous avez demandée nexiste pas."
@ -139,6 +144,30 @@
"system": "Système",
"dark": "Sombre"
}
},
"apiTokens": {
"title": "API Tokens",
"general": "API tokens allow you to use Vikunja's API without user credentials.",
"apiDocs": "Check out the api docs",
"createAToken": "Create a token",
"createToken": "Create token",
"30d": "30 Days",
"60d": "60 Days",
"90d": "90 Days",
"permissionExplanation": "Permissions allow you to scope what an api token is allowed to do.",
"titleRequired": "The title is required",
"expired": "This token has expired {ago}.",
"delete": {
"header": "Delete this token",
"text1": "Are you sure you want to delete the token \"{token}\"?",
"text2": "This will revoke access to all applications or integrations using it. You cannot undo this."
},
"attributes": {
"title": "Title",
"titlePlaceholder": "Enter a title you will recognize later",
"expiresAt": "Expires at",
"permissions": "Permissions"
}
}
},
"deletion": {
@ -305,6 +334,9 @@
"doneBucketHint": "Toute tâche déplacée dans cette colonne sera automatiquement marquée comme terminée.",
"doneBucketHintExtended": "Toute tâche déplacée dans cette colonne sera automatiquement marquée comme terminée. Toute tâche marquée comme terminée ailleurs sera également déplacée.",
"doneBucketSavedSuccess": "La colonne des tâches terminées a bien été enregistrée.",
"defaultBucket": "Default bucket",
"defaultBucketHint": "When creating tasks without specifying a bucket, they will be added to this bucket.",
"defaultBucketSavedSuccess": "The default bucket has been saved successfully.",
"deleteLast": "Vous ne pouvez pas retirer la dernière colonne.",
"addTaskPlaceholder": "Saisir le nouveau nom de la tâche…",
"addTask": "Ajouter une tâche",
@ -881,7 +913,7 @@
"urlPlaceholder": "Par exemple : https://localhost:3456",
"change": "changer",
"use": "Utiliser linstallation de Vikunja à {0}",
"error": "Impossible de trouver ou d'utiliser l'installation de Vikunja sur « {domain} ». Veuillez essayer une autre adresse.",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please check if the url has the correct format and you can reach it when accessing it directly and try again.",
"success": "Utilisation de linstallation Vikunja sur « {domain} ».",
"urlRequired": "Une adresse est requise."
},
@ -902,6 +934,7 @@
"tasks": "Tâches",
"projects": "Projets",
"teams": "Équipes",
"labels": "Labels",
"newProject": "Saisissez le nom du nouveau projet…",
"newTask": "Saisir le nom de la nouvelle tâche…",
"newTeam": "Saisir le nom de la nouvelle équipe…",

1041
src/i18n/lang/hu-HU.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -5,12 +5,17 @@
"welcomeDay": "Ciao {username}!",
"welcomeEvening": "Buonasera {username}!",
"lastViewed": "Ultima visualizzazione",
"addToHomeScreen": "Add this app to your home screen for faster access and improved experience.",
"addToHomeScreen": "Aggiungi questa app alla tua schermata iniziale per un accesso più veloce e un'esperienza migliore.",
"project": {
"importText": "Import your projects and tasks from other services into Vikunja:",
"import": "Importa i tuoi dati in Vikunja"
}
},
"demo": {
"title": "This instance is in demo mode. Do not use this for real data!",
"everythingWillBeDeleted": "Everything will be deleted in regular intervals!",
"accountWillBeDeleted": "Your account will be deleted, including all projects, tasks and attachments you might create."
},
"404": {
"title": "Non trovato",
"text": "La pagina richiesta non esiste."
@ -139,6 +144,30 @@
"system": "Sistema",
"dark": "Scuro"
}
},
"apiTokens": {
"title": "API Tokens",
"general": "API tokens allow you to use Vikunja's API without user credentials.",
"apiDocs": "Check out the api docs",
"createAToken": "Create a token",
"createToken": "Create token",
"30d": "30 Days",
"60d": "60 Days",
"90d": "90 Days",
"permissionExplanation": "Permissions allow you to scope what an api token is allowed to do.",
"titleRequired": "The title is required",
"expired": "This token has expired {ago}.",
"delete": {
"header": "Delete this token",
"text1": "Are you sure you want to delete the token \"{token}\"?",
"text2": "This will revoke access to all applications or integrations using it. You cannot undo this."
},
"attributes": {
"title": "Title",
"titlePlaceholder": "Enter a title you will recognize later",
"expiresAt": "Expires at",
"permissions": "Permissions"
}
}
},
"deletion": {
@ -166,8 +195,8 @@
},
"project": {
"archivedMessage": "This project is archived. It is not possible to create new or edit tasks for it.",
"archived": "Archived",
"showArchived": "Show Archived",
"archived": "Archiviati",
"showArchived": "Mostra Archiviati",
"title": "Titolo Progetto",
"color": "Colore",
"projects": "Progetti",
@ -305,6 +334,9 @@
"doneBucketHint": "All tasks moved into this bucket will automatically marked as done.",
"doneBucketHintExtended": "All tasks moved into the done bucket will be marked as done automatically. All tasks marked as done from elsewhere will be moved as well.",
"doneBucketSavedSuccess": "The done bucket has been saved successfully.",
"defaultBucket": "Default bucket",
"defaultBucketHint": "When creating tasks without specifying a bucket, they will be added to this bucket.",
"defaultBucketSavedSuccess": "The default bucket has been saved successfully.",
"deleteLast": "You cannot remove the last bucket.",
"addTaskPlaceholder": "Enter the new task title…",
"addTask": "Add a task",
@ -881,7 +913,7 @@
"urlPlaceholder": "es. http://localhost:8080",
"change": "modifica",
"use": "Usa l'installazione di Vikunja a {0}",
"error": "Impossibile trovare o usare l'installazione di Vikunja su \"{domain}\". Prova per favore con un altro Url.",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please check if the url has the correct format and you can reach it when accessing it directly and try again.",
"success": "Utilizzando l'installazione di Vikunja su \"{domain}\".",
"urlRequired": "L'URL è obbligatorio."
},
@ -902,6 +934,7 @@
"tasks": "Attivitá",
"projects": "Projects",
"teams": "Gruppi",
"labels": "Labels",
"newProject": "Enter the title of the new project…",
"newTask": "Inserisci il titolo della nuova attività…",
"newTeam": "Inserisci il nome del nuovo gruppo…",

View File

@ -11,6 +11,11 @@
"import": "Vikunjaへのデータのインポート"
}
},
"demo": {
"title": "This instance is in demo mode. Do not use this for real data!",
"everythingWillBeDeleted": "Everything will be deleted in regular intervals!",
"accountWillBeDeleted": "Your account will be deleted, including all projects, tasks and attachments you might create."
},
"404": {
"title": "Not found",
"text": "リクエストされたページは存在しません。"
@ -139,6 +144,30 @@
"system": "システム既定",
"dark": "ダーク"
}
},
"apiTokens": {
"title": "API Tokens",
"general": "API tokens allow you to use Vikunja's API without user credentials.",
"apiDocs": "Check out the api docs",
"createAToken": "Create a token",
"createToken": "Create token",
"30d": "30 Days",
"60d": "60 Days",
"90d": "90 Days",
"permissionExplanation": "Permissions allow you to scope what an api token is allowed to do.",
"titleRequired": "The title is required",
"expired": "This token has expired {ago}.",
"delete": {
"header": "Delete this token",
"text1": "Are you sure you want to delete the token \"{token}\"?",
"text2": "This will revoke access to all applications or integrations using it. You cannot undo this."
},
"attributes": {
"title": "Title",
"titlePlaceholder": "Enter a title you will recognize later",
"expiresAt": "Expires at",
"permissions": "Permissions"
}
}
},
"deletion": {
@ -305,6 +334,9 @@
"doneBucketHint": "All tasks moved into this bucket will automatically marked as done.",
"doneBucketHintExtended": "All tasks moved into the done bucket will be marked as done automatically. All tasks marked as done from elsewhere will be moved as well.",
"doneBucketSavedSuccess": "The done bucket has been saved successfully.",
"defaultBucket": "Default bucket",
"defaultBucketHint": "When creating tasks without specifying a bucket, they will be added to this bucket.",
"defaultBucketSavedSuccess": "The default bucket has been saved successfully.",
"deleteLast": "You cannot remove the last bucket.",
"addTaskPlaceholder": "Enter the new task title…",
"addTask": "タスクの追加",
@ -329,12 +361,12 @@
"title": "絞り込み",
"clear": "絞り込みの解除",
"attributes": {
"title": "条件名",
"titlePlaceholder": "条件名を入力…",
"title": "絞り込み条件名",
"titlePlaceholder": "絞り込み条件名を入力…",
"description": "説明",
"descriptionPlaceholder": "説明を入力…",
"includeNulls": "値を設定していないタスクを含める",
"requireAll": "Require all filters to be true for a task to show up",
"descriptionPlaceholder": "絞り込み条件の説明を入力…",
"includeNulls": "値が設定されていないタスクを含める",
"requireAll": "すべての条件に一致するタスクのみ表示",
"showDoneTasks": "完了したタスクを表示",
"sortAlphabetically": "アルファベット順に並べ替える",
"enablePriority": "優先度による絞り込みを有効化",
@ -346,18 +378,18 @@
},
"create": {
"title": "新しい絞り込み条件の作成",
"description": "A saved filter is a virtual project which is computed from a set of filters each time it is accessed.",
"description": "絞り込み条件は、複数の条件を組み合わせて保存できる仮想のプロジェクトです。",
"action": "新しい絞り込み条件を作成",
"titleRequired": "Please provide a title for the filter."
"titleRequired": "絞り込み条件名を入力してください。"
},
"delete": {
"header": "Delete this saved filter",
"text": "Are you sure you want to delete this saved filter?",
"success": "The filter was deleted successfully."
"header": "絞り込み条件の削除",
"text": "絞り込み条件を削除して本当によろしいですか?",
"success": "絞り込み条件は正常に削除されました。"
},
"edit": {
"title": "Edit This Saved Filter",
"success": "The filter was saved successfully."
"title": "絞り込み条件の編集",
"success": "絞り込み条件は正常に保存されました。"
}
},
"migrate": {
@ -815,7 +847,7 @@
"namePlaceholder": "The team's name goes here…",
"nameRequired": "Please specify a name.",
"description": "説明",
"descriptionPlaceholder": "The teams description goes here…",
"descriptionPlaceholder": "チームの説明を入力…",
"admin": "管理者",
"member": "メンバー"
}
@ -881,7 +913,7 @@
"urlPlaceholder": "例: https://localhost:3456",
"change": "変更",
"use": "{0} に設置されたVikunjaを使用します。",
"error": "\"{domain}\" にはVikunjaは存在しないか使用できない状態です。別のURLでお試しください。",
"error": "\"{domain}\" にはVikunjaは存在しないか使用できない状態です。URLの形式が正しいかどうか、そして直接アクセスして到達きるかどうかを確認し、もう一度お試しください。",
"success": "\"{domain}\" に設置されたVikunjaを使用します。",
"urlRequired": "URLは必須です。"
},
@ -902,6 +934,7 @@
"tasks": "タスク",
"projects": "プロジェクト",
"teams": "チーム",
"labels": "Labels",
"newProject": "新しいプロジェクト名を入力…",
"newTask": "新しいタスク名を入力…",
"newTeam": "新しいチーム名を入力…",
@ -983,7 +1016,7 @@
"10004": "You cannot add the task to this bucket as it already exceeded the limit of tasks it can hold.",
"10005": "There can be only one done bucket per project.",
"11001": "The saved filter does not exist.",
"11002": "Saved filters are not available for link shares.",
"11002": "絞り込み条件はリンクの共有には使用できません。",
"12001": "The subscription entity type is invalid.",
"12002": "You are already subscribed to the entity itself or a parent entity.",
"13001": "This link share requires a password for authentication, but none was provided.",

View File

@ -11,6 +11,11 @@
"import": "데이터를 Vikunja로 가져오기"
}
},
"demo": {
"title": "This instance is in demo mode. Do not use this for real data!",
"everythingWillBeDeleted": "Everything will be deleted in regular intervals!",
"accountWillBeDeleted": "Your account will be deleted, including all projects, tasks and attachments you might create."
},
"404": {
"title": "찾을 수 없습니다.",
"text": "요청하신 페이지가 존재하지 않습니다."
@ -139,6 +144,30 @@
"system": "시스템",
"dark": "어두운 테마"
}
},
"apiTokens": {
"title": "API Tokens",
"general": "API tokens allow you to use Vikunja's API without user credentials.",
"apiDocs": "Check out the api docs",
"createAToken": "Create a token",
"createToken": "Create token",
"30d": "30 Days",
"60d": "60 Days",
"90d": "90 Days",
"permissionExplanation": "Permissions allow you to scope what an api token is allowed to do.",
"titleRequired": "The title is required",
"expired": "This token has expired {ago}.",
"delete": {
"header": "Delete this token",
"text1": "Are you sure you want to delete the token \"{token}\"?",
"text2": "This will revoke access to all applications or integrations using it. You cannot undo this."
},
"attributes": {
"title": "Title",
"titlePlaceholder": "Enter a title you will recognize later",
"expiresAt": "Expires at",
"permissions": "Permissions"
}
}
},
"deletion": {
@ -305,6 +334,9 @@
"doneBucketHint": "All tasks moved into this bucket will automatically marked as done.",
"doneBucketHintExtended": "All tasks moved into the done bucket will be marked as done automatically. All tasks marked as done from elsewhere will be moved as well.",
"doneBucketSavedSuccess": "The done bucket has been saved successfully.",
"defaultBucket": "Default bucket",
"defaultBucketHint": "When creating tasks without specifying a bucket, they will be added to this bucket.",
"defaultBucketSavedSuccess": "The default bucket has been saved successfully.",
"deleteLast": "You cannot remove the last bucket.",
"addTaskPlaceholder": "Enter the new task title…",
"addTask": "작업 추가",
@ -881,7 +913,7 @@
"urlPlaceholder": "eg. https://localhost:3456",
"change": "change",
"use": "Using Vikunja installation at {0}",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please try a different url.",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please check if the url has the correct format and you can reach it when accessing it directly and try again.",
"success": "Using Vikunja installation at \"{domain}\".",
"urlRequired": "A url is required."
},
@ -902,6 +934,7 @@
"tasks": "Tasks",
"projects": "Projects",
"teams": "Teams",
"labels": "Labels",
"newProject": "Enter the title of the new project…",
"newTask": "Enter the title of the new task…",
"newTeam": "Enter the name of the new team…",

View File

@ -11,6 +11,11 @@
"import": "Import your data into Vikunja"
}
},
"demo": {
"title": "This instance is in demo mode. Do not use this for real data!",
"everythingWillBeDeleted": "Everything will be deleted in regular intervals!",
"accountWillBeDeleted": "Your account will be deleted, including all projects, tasks and attachments you might create."
},
"404": {
"title": "Niet gevonden",
"text": "De opgevraagde pagina bestaat niet."
@ -139,6 +144,30 @@
"system": "Systeem",
"dark": "Donker"
}
},
"apiTokens": {
"title": "API Tokens",
"general": "API tokens allow you to use Vikunja's API without user credentials.",
"apiDocs": "Check out the api docs",
"createAToken": "Create a token",
"createToken": "Create token",
"30d": "30 Days",
"60d": "60 Days",
"90d": "90 Days",
"permissionExplanation": "Permissions allow you to scope what an api token is allowed to do.",
"titleRequired": "The title is required",
"expired": "This token has expired {ago}.",
"delete": {
"header": "Delete this token",
"text1": "Are you sure you want to delete the token \"{token}\"?",
"text2": "This will revoke access to all applications or integrations using it. You cannot undo this."
},
"attributes": {
"title": "Title",
"titlePlaceholder": "Enter a title you will recognize later",
"expiresAt": "Expires at",
"permissions": "Permissions"
}
}
},
"deletion": {
@ -305,6 +334,9 @@
"doneBucketHint": "All tasks moved into this bucket will automatically marked as done.",
"doneBucketHintExtended": "All tasks moved into the done bucket will be marked as done automatically. All tasks marked as done from elsewhere will be moved as well.",
"doneBucketSavedSuccess": "The done bucket has been saved successfully.",
"defaultBucket": "Default bucket",
"defaultBucketHint": "When creating tasks without specifying a bucket, they will be added to this bucket.",
"defaultBucketSavedSuccess": "The default bucket has been saved successfully.",
"deleteLast": "You cannot remove the last bucket.",
"addTaskPlaceholder": "Enter the new task title…",
"addTask": "Add a task",
@ -881,7 +913,7 @@
"urlPlaceholder": "bv. https://localhost:3456",
"change": "wijzigen",
"use": "Using Vikunja installation at {0}",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please try a different url.",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please check if the url has the correct format and you can reach it when accessing it directly and try again.",
"success": "Using Vikunja installation at \"{domain}\".",
"urlRequired": "A url is required."
},
@ -902,6 +934,7 @@
"tasks": "Taken",
"projects": "Projects",
"teams": "Teams",
"labels": "Labels",
"newProject": "Enter the title of the new project…",
"newTask": "Enter the title of the new task…",
"newTeam": "Enter the name of the new team…",

View File

@ -11,6 +11,11 @@
"import": "Importer dine data til Vikunja"
}
},
"demo": {
"title": "This instance is in demo mode. Do not use this for real data!",
"everythingWillBeDeleted": "Everything will be deleted in regular intervals!",
"accountWillBeDeleted": "Your account will be deleted, including all projects, tasks and attachments you might create."
},
"404": {
"title": "Ikke funnet",
"text": "Siden du ba om, finnes ikke."
@ -139,6 +144,30 @@
"system": "System",
"dark": "Mørk"
}
},
"apiTokens": {
"title": "API Tokens",
"general": "API tokens allow you to use Vikunja's API without user credentials.",
"apiDocs": "Check out the api docs",
"createAToken": "Create a token",
"createToken": "Create token",
"30d": "30 Days",
"60d": "60 Days",
"90d": "90 Days",
"permissionExplanation": "Permissions allow you to scope what an api token is allowed to do.",
"titleRequired": "The title is required",
"expired": "This token has expired {ago}.",
"delete": {
"header": "Delete this token",
"text1": "Are you sure you want to delete the token \"{token}\"?",
"text2": "This will revoke access to all applications or integrations using it. You cannot undo this."
},
"attributes": {
"title": "Title",
"titlePlaceholder": "Enter a title you will recognize later",
"expiresAt": "Expires at",
"permissions": "Permissions"
}
}
},
"deletion": {
@ -305,6 +334,9 @@
"doneBucketHint": "Alle oppgaver som flyttet til denne bøtte vil automatisk bli markert som ferdig.",
"doneBucketHintExtended": "Alle oppgaver som er flyttet inn i den utførte bøtten, vil bli merket som utført automatisk. Alle oppgaver merket som gjort fra andre steder vil også bli flyttet.",
"doneBucketSavedSuccess": "Bøtten er lagret.",
"defaultBucket": "Default bucket",
"defaultBucketHint": "When creating tasks without specifying a bucket, they will be added to this bucket.",
"defaultBucketSavedSuccess": "The default bucket has been saved successfully.",
"deleteLast": "Du kan ikke fjerne den siste bøtten.",
"addTaskPlaceholder": "Angi den nye oppgavens tittel…",
"addTask": "Legg til oppgave",
@ -881,7 +913,7 @@
"urlPlaceholder": "f.eks. http://localhost:3456",
"change": "endre",
"use": "Bruker Vikunja installasjonen på {0}",
"error": "Kunne ikke finne eller bruke Vikunja installasjon på{domain}\". Prøv en annen Url.",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please check if the url has the correct format and you can reach it when accessing it directly and try again.",
"success": "Bruker Vikunja installasjonen på \"{domain}.",
"urlRequired": "Url'en er tom, vennligst legg til."
},
@ -902,6 +934,7 @@
"tasks": "Oppgaver",
"projects": "Prosjekter",
"teams": "Grupper",
"labels": "Labels",
"newProject": "Skriv tittelen på det nye prosjektet…",
"newTask": "Skriv tittelen på den nye oppgaven…",
"newTeam": "Skriv inn navnet på den nye gruppen…",

View File

@ -11,6 +11,11 @@
"import": "Import your data into Vikunja"
}
},
"demo": {
"title": "This instance is in demo mode. Do not use this for real data!",
"everythingWillBeDeleted": "Everything will be deleted in regular intervals!",
"accountWillBeDeleted": "Your account will be deleted, including all projects, tasks and attachments you might create."
},
"404": {
"title": "Nie znaleziono",
"text": "Żądana strona nie istnieje."
@ -139,6 +144,30 @@
"system": "Systemowy",
"dark": "Ciemny"
}
},
"apiTokens": {
"title": "API Tokens",
"general": "API tokens allow you to use Vikunja's API without user credentials.",
"apiDocs": "Check out the api docs",
"createAToken": "Create a token",
"createToken": "Create token",
"30d": "30 Days",
"60d": "60 Days",
"90d": "90 Days",
"permissionExplanation": "Permissions allow you to scope what an api token is allowed to do.",
"titleRequired": "The title is required",
"expired": "This token has expired {ago}.",
"delete": {
"header": "Delete this token",
"text1": "Are you sure you want to delete the token \"{token}\"?",
"text2": "This will revoke access to all applications or integrations using it. You cannot undo this."
},
"attributes": {
"title": "Title",
"titlePlaceholder": "Enter a title you will recognize later",
"expiresAt": "Expires at",
"permissions": "Permissions"
}
}
},
"deletion": {
@ -305,6 +334,9 @@
"doneBucketHint": "All tasks moved into this bucket will automatically marked as done.",
"doneBucketHintExtended": "All tasks moved into the done bucket will be marked as done automatically. All tasks marked as done from elsewhere will be moved as well.",
"doneBucketSavedSuccess": "The done bucket has been saved successfully.",
"defaultBucket": "Default bucket",
"defaultBucketHint": "When creating tasks without specifying a bucket, they will be added to this bucket.",
"defaultBucketSavedSuccess": "The default bucket has been saved successfully.",
"deleteLast": "You cannot remove the last bucket.",
"addTaskPlaceholder": "Enter the new task title…",
"addTask": "Add a task",
@ -881,7 +913,7 @@
"urlPlaceholder": "np. https://localhost:3456",
"change": "zmień",
"use": "Użyj instalacji Vikunji z {0}",
"error": "Nie można znaleźć lub użyć instalacji Vikunji z \"{domain}\". Wypróbuj inny adres URL.",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please check if the url has the correct format and you can reach it when accessing it directly and try again.",
"success": "Używasz instalacji Vikunji z \"{domain}\".",
"urlRequired": "URL jest wymagany."
},
@ -902,6 +934,7 @@
"tasks": "Zadania",
"projects": "Projects",
"teams": "Zespoły",
"labels": "Labels",
"newProject": "Enter the title of the new project…",
"newTask": "Wpisz tytuł nowego zadania…",
"newTeam": "Wpisz nazwę nowego zespołu…",

View File

@ -11,6 +11,11 @@
"import": "Import your data into Vikunja"
}
},
"demo": {
"title": "This instance is in demo mode. Do not use this for real data!",
"everythingWillBeDeleted": "Everything will be deleted in regular intervals!",
"accountWillBeDeleted": "Your account will be deleted, including all projects, tasks and attachments you might create."
},
"404": {
"title": "Não encontrado",
"text": "The page you requested does not exist."
@ -139,6 +144,30 @@
"system": "System",
"dark": "Dark"
}
},
"apiTokens": {
"title": "API Tokens",
"general": "API tokens allow you to use Vikunja's API without user credentials.",
"apiDocs": "Check out the api docs",
"createAToken": "Create a token",
"createToken": "Create token",
"30d": "30 Days",
"60d": "60 Days",
"90d": "90 Days",
"permissionExplanation": "Permissions allow you to scope what an api token is allowed to do.",
"titleRequired": "The title is required",
"expired": "This token has expired {ago}.",
"delete": {
"header": "Delete this token",
"text1": "Are you sure you want to delete the token \"{token}\"?",
"text2": "This will revoke access to all applications or integrations using it. You cannot undo this."
},
"attributes": {
"title": "Title",
"titlePlaceholder": "Enter a title you will recognize later",
"expiresAt": "Expires at",
"permissions": "Permissions"
}
}
},
"deletion": {
@ -305,6 +334,9 @@
"doneBucketHint": "All tasks moved into this bucket will automatically marked as done.",
"doneBucketHintExtended": "All tasks moved into the done bucket will be marked as done automatically. All tasks marked as done from elsewhere will be moved as well.",
"doneBucketSavedSuccess": "The done bucket has been saved successfully.",
"defaultBucket": "Default bucket",
"defaultBucketHint": "When creating tasks without specifying a bucket, they will be added to this bucket.",
"defaultBucketSavedSuccess": "The default bucket has been saved successfully.",
"deleteLast": "You cannot remove the last bucket.",
"addTaskPlaceholder": "Enter the new task title…",
"addTask": "Add a task",
@ -881,7 +913,7 @@
"urlPlaceholder": "eg. https://localhost:3456",
"change": "change",
"use": "Using Vikunja installation at {0}",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please try a different url.",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please check if the url has the correct format and you can reach it when accessing it directly and try again.",
"success": "Usando a instalação Vikunja em \"{domain}\".",
"urlRequired": "Uma url é necessária."
},
@ -902,6 +934,7 @@
"tasks": "Tarefas",
"projects": "Projects",
"teams": "Equipes",
"labels": "Labels",
"newProject": "Enter the title of the new project…",
"newTask": "Enter the title of the new task…",
"newTeam": "Enter the name of the new team…",

View File

@ -11,6 +11,11 @@
"import": "Importar os teus dados para o Vikunja"
}
},
"demo": {
"title": "This instance is in demo mode. Do not use this for real data!",
"everythingWillBeDeleted": "Everything will be deleted in regular intervals!",
"accountWillBeDeleted": "Your account will be deleted, including all projects, tasks and attachments you might create."
},
"404": {
"title": "Não encontrado",
"text": "A página solicitada não existe."
@ -139,6 +144,30 @@
"system": "Sistema",
"dark": "Escuro"
}
},
"apiTokens": {
"title": "API Tokens",
"general": "API tokens allow you to use Vikunja's API without user credentials.",
"apiDocs": "Check out the api docs",
"createAToken": "Create a token",
"createToken": "Create token",
"30d": "30 Days",
"60d": "60 Days",
"90d": "90 Days",
"permissionExplanation": "Permissions allow you to scope what an api token is allowed to do.",
"titleRequired": "The title is required",
"expired": "This token has expired {ago}.",
"delete": {
"header": "Delete this token",
"text1": "Are you sure you want to delete the token \"{token}\"?",
"text2": "This will revoke access to all applications or integrations using it. You cannot undo this."
},
"attributes": {
"title": "Title",
"titlePlaceholder": "Enter a title you will recognize later",
"expiresAt": "Expires at",
"permissions": "Permissions"
}
}
},
"deletion": {
@ -305,6 +334,9 @@
"doneBucketHint": "Todas as tarefas movidas para este conjunto serão automaticamente marcadas como concluídas.",
"doneBucketHintExtended": "Todas as tarefas movidas para o conjunto concluído serão marcadas automaticamente como concluídas. Todas as tarefas marcadas como concluídas em outro lugar também serão movidas.",
"doneBucketSavedSuccess": "O conjunto concluído foi salvo com sucesso.",
"defaultBucket": "Default bucket",
"defaultBucketHint": "When creating tasks without specifying a bucket, they will be added to this bucket.",
"defaultBucketSavedSuccess": "The default bucket has been saved successfully.",
"deleteLast": "Não podes remover o ultimo conjunto.",
"addTaskPlaceholder": "Introduz o título da nova tarefa…",
"addTask": "Adicionar uma tarefa",
@ -881,7 +913,7 @@
"urlPlaceholder": "ex.: https://localhost:3456",
"change": "alterar",
"use": "A utilizar a instalação do Vikunja em {0}",
"error": "Não foi possível encontrar ou utilizar a instalação do Vikunja em \"{domain}\". Por favor, tenta um url diferente.",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please check if the url has the correct format and you can reach it when accessing it directly and try again.",
"success": "A utilizar a instalação do Vikunja em \"{domain}\".",
"urlRequired": "É necessário um url."
},
@ -902,6 +934,7 @@
"tasks": "Tarefas",
"projects": "Projetos",
"teams": "Equipas",
"labels": "Etiquetas",
"newProject": "Insere o título do novo espaço…",
"newTask": "Insere o título da nova tarefa…",
"newTeam": "Insere o nome da nova equipa…",

View File

@ -11,6 +11,11 @@
"import": "Import your data into Vikunja"
}
},
"demo": {
"title": "This instance is in demo mode. Do not use this for real data!",
"everythingWillBeDeleted": "Everything will be deleted in regular intervals!",
"accountWillBeDeleted": "Your account will be deleted, including all projects, tasks and attachments you might create."
},
"404": {
"title": "Not found",
"text": "The page you requested does not exist."
@ -139,6 +144,30 @@
"system": "System",
"dark": "Dark"
}
},
"apiTokens": {
"title": "API Tokens",
"general": "API tokens allow you to use Vikunja's API without user credentials.",
"apiDocs": "Check out the api docs",
"createAToken": "Create a token",
"createToken": "Create token",
"30d": "30 Days",
"60d": "60 Days",
"90d": "90 Days",
"permissionExplanation": "Permissions allow you to scope what an api token is allowed to do.",
"titleRequired": "The title is required",
"expired": "This token has expired {ago}.",
"delete": {
"header": "Delete this token",
"text1": "Are you sure you want to delete the token \"{token}\"?",
"text2": "This will revoke access to all applications or integrations using it. You cannot undo this."
},
"attributes": {
"title": "Title",
"titlePlaceholder": "Enter a title you will recognize later",
"expiresAt": "Expires at",
"permissions": "Permissions"
}
}
},
"deletion": {
@ -305,6 +334,9 @@
"doneBucketHint": "All tasks moved into this bucket will automatically marked as done.",
"doneBucketHintExtended": "All tasks moved into the done bucket will be marked as done automatically. All tasks marked as done from elsewhere will be moved as well.",
"doneBucketSavedSuccess": "The done bucket has been saved successfully.",
"defaultBucket": "Default bucket",
"defaultBucketHint": "When creating tasks without specifying a bucket, they will be added to this bucket.",
"defaultBucketSavedSuccess": "The default bucket has been saved successfully.",
"deleteLast": "You cannot remove the last bucket.",
"addTaskPlaceholder": "Enter the new task title…",
"addTask": "Add a task",
@ -881,7 +913,7 @@
"urlPlaceholder": "eg. https://localhost:3456",
"change": "change",
"use": "Using Vikunja installation at {0}",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please try a different url.",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please check if the url has the correct format and you can reach it when accessing it directly and try again.",
"success": "Using Vikunja installation at \"{domain}\".",
"urlRequired": "A url is required."
},
@ -902,6 +934,7 @@
"tasks": "Tasks",
"projects": "Projects",
"teams": "Teams",
"labels": "Labels",
"newProject": "Enter the title of the new project…",
"newTask": "Enter the title of the new task…",
"newTeam": "Enter the name of the new team…",

View File

@ -11,6 +11,11 @@
"import": "Импорт данных в Vikunja"
}
},
"demo": {
"title": "This instance is in demo mode. Do not use this for real data!",
"everythingWillBeDeleted": "Everything will be deleted in regular intervals!",
"accountWillBeDeleted": "Your account will be deleted, including all projects, tasks and attachments you might create."
},
"404": {
"title": "Не найдено",
"text": "Запрашиваемая страница не существует."
@ -139,6 +144,30 @@
"system": "Системная",
"dark": "Тёмная"
}
},
"apiTokens": {
"title": "API Tokens",
"general": "API tokens allow you to use Vikunja's API without user credentials.",
"apiDocs": "Check out the api docs",
"createAToken": "Create a token",
"createToken": "Create token",
"30d": "30 Days",
"60d": "60 Days",
"90d": "90 Days",
"permissionExplanation": "Permissions allow you to scope what an api token is allowed to do.",
"titleRequired": "The title is required",
"expired": "This token has expired {ago}.",
"delete": {
"header": "Delete this token",
"text1": "Are you sure you want to delete the token \"{token}\"?",
"text2": "This will revoke access to all applications or integrations using it. You cannot undo this."
},
"attributes": {
"title": "Title",
"titlePlaceholder": "Enter a title you will recognize later",
"expiresAt": "Expires at",
"permissions": "Permissions"
}
}
},
"deletion": {
@ -305,6 +334,9 @@
"doneBucketHint": "Все задачи, помещённые в эту колонку, автоматически отмечаются как завершённые.",
"doneBucketHintExtended": "Все задачи, перенесённые в колонку завершённых, будут помечены как завершённые. Все задачи, помеченные как завершённые, также будут перемещены в эту колонку.",
"doneBucketSavedSuccess": "Колонка завершённых была успешно сохранена.",
"defaultBucket": "Default bucket",
"defaultBucketHint": "When creating tasks without specifying a bucket, they will be added to this bucket.",
"defaultBucketSavedSuccess": "The default bucket has been saved successfully.",
"deleteLast": "Нельзя удалить последнюю колонку.",
"addTaskPlaceholder": "Введите название задачи…",
"addTask": "Добавить задачу",
@ -881,7 +913,7 @@
"urlPlaceholder": "напр. https://localhost:3456",
"change": "изменить",
"use": "Используется Vikunja на {0}",
"error": "Не удалось подключиться к Vikunja по адресу \"{domain}\". Попробуйте указать другой url.",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please check if the url has the correct format and you can reach it when accessing it directly and try again.",
"success": "Используется Vikunja на \"{domain}\".",
"urlRequired": "Требуется url."
},
@ -902,6 +934,7 @@
"tasks": "Задачи",
"projects": "Проекты",
"teams": "Команды",
"labels": "Labels",
"newProject": "Введите название проекта…",
"newTask": "Введите название задачи…",
"newTeam": "Введите название новой команды…",

View File

@ -11,6 +11,11 @@
"import": "Import your data into Vikunja"
}
},
"demo": {
"title": "This instance is in demo mode. Do not use this for real data!",
"everythingWillBeDeleted": "Everything will be deleted in regular intervals!",
"accountWillBeDeleted": "Your account will be deleted, including all projects, tasks and attachments you might create."
},
"404": {
"title": "Not found",
"text": "The page you requested does not exist."
@ -139,6 +144,30 @@
"system": "System",
"dark": "Dark"
}
},
"apiTokens": {
"title": "API Tokens",
"general": "API tokens allow you to use Vikunja's API without user credentials.",
"apiDocs": "Check out the api docs",
"createAToken": "Create a token",
"createToken": "Create token",
"30d": "30 Days",
"60d": "60 Days",
"90d": "90 Days",
"permissionExplanation": "Permissions allow you to scope what an api token is allowed to do.",
"titleRequired": "The title is required",
"expired": "This token has expired {ago}.",
"delete": {
"header": "Delete this token",
"text1": "Are you sure you want to delete the token \"{token}\"?",
"text2": "This will revoke access to all applications or integrations using it. You cannot undo this."
},
"attributes": {
"title": "Title",
"titlePlaceholder": "Enter a title you will recognize later",
"expiresAt": "Expires at",
"permissions": "Permissions"
}
}
},
"deletion": {
@ -305,6 +334,9 @@
"doneBucketHint": "All tasks moved into this bucket will automatically marked as done.",
"doneBucketHintExtended": "All tasks moved into the done bucket will be marked as done automatically. All tasks marked as done from elsewhere will be moved as well.",
"doneBucketSavedSuccess": "The done bucket has been saved successfully.",
"defaultBucket": "Default bucket",
"defaultBucketHint": "When creating tasks without specifying a bucket, they will be added to this bucket.",
"defaultBucketSavedSuccess": "The default bucket has been saved successfully.",
"deleteLast": "You cannot remove the last bucket.",
"addTaskPlaceholder": "Enter the new task title…",
"addTask": "Add a task",
@ -881,7 +913,7 @@
"urlPlaceholder": "eg. https://localhost:3456",
"change": "change",
"use": "Using Vikunja installation at {0}",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please try a different url.",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please check if the url has the correct format and you can reach it when accessing it directly and try again.",
"success": "Using Vikunja installation at \"{domain}\".",
"urlRequired": "A url is required."
},
@ -902,6 +934,7 @@
"tasks": "Tasks",
"projects": "Projects",
"teams": "Teams",
"labels": "Labels",
"newProject": "Enter the title of the new project…",
"newTask": "Enter the title of the new task…",
"newTeam": "Enter the name of the new team…",

View File

@ -11,6 +11,11 @@
"import": "Import your data into Vikunja"
}
},
"demo": {
"title": "This instance is in demo mode. Do not use this for real data!",
"everythingWillBeDeleted": "Everything will be deleted in regular intervals!",
"accountWillBeDeleted": "Your account will be deleted, including all projects, tasks and attachments you might create."
},
"404": {
"title": "Not found",
"text": "The page you requested does not exist."
@ -139,6 +144,30 @@
"system": "System",
"dark": "Dark"
}
},
"apiTokens": {
"title": "API Tokens",
"general": "API tokens allow you to use Vikunja's API without user credentials.",
"apiDocs": "Check out the api docs",
"createAToken": "Create a token",
"createToken": "Create token",
"30d": "30 Days",
"60d": "60 Days",
"90d": "90 Days",
"permissionExplanation": "Permissions allow you to scope what an api token is allowed to do.",
"titleRequired": "The title is required",
"expired": "This token has expired {ago}.",
"delete": {
"header": "Delete this token",
"text1": "Are you sure you want to delete the token \"{token}\"?",
"text2": "This will revoke access to all applications or integrations using it. You cannot undo this."
},
"attributes": {
"title": "Title",
"titlePlaceholder": "Enter a title you will recognize later",
"expiresAt": "Expires at",
"permissions": "Permissions"
}
}
},
"deletion": {
@ -305,6 +334,9 @@
"doneBucketHint": "All tasks moved into this bucket will automatically marked as done.",
"doneBucketHintExtended": "All tasks moved into the done bucket will be marked as done automatically. All tasks marked as done from elsewhere will be moved as well.",
"doneBucketSavedSuccess": "The done bucket has been saved successfully.",
"defaultBucket": "Default bucket",
"defaultBucketHint": "When creating tasks without specifying a bucket, they will be added to this bucket.",
"defaultBucketSavedSuccess": "The default bucket has been saved successfully.",
"deleteLast": "You cannot remove the last bucket.",
"addTaskPlaceholder": "Enter the new task title…",
"addTask": "Add a task",
@ -881,7 +913,7 @@
"urlPlaceholder": "eg. https://localhost:3456",
"change": "change",
"use": "Using Vikunja installation at {0}",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please try a different url.",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please check if the url has the correct format and you can reach it when accessing it directly and try again.",
"success": "Using Vikunja installation at \"{domain}\".",
"urlRequired": "A url is required."
},
@ -902,6 +934,7 @@
"tasks": "Tasks",
"projects": "Projects",
"teams": "Teams",
"labels": "Labels",
"newProject": "Enter the title of the new project…",
"newTask": "Enter the title of the new task…",
"newTeam": "Enter the name of the new team…",

View File

@ -11,6 +11,11 @@
"import": "Importera din data till Vikunja"
}
},
"demo": {
"title": "This instance is in demo mode. Do not use this for real data!",
"everythingWillBeDeleted": "Everything will be deleted in regular intervals!",
"accountWillBeDeleted": "Your account will be deleted, including all projects, tasks and attachments you might create."
},
"404": {
"title": "Hittades inte",
"text": "The page you requested does not exist."
@ -139,6 +144,30 @@
"system": "System",
"dark": "Mörkt"
}
},
"apiTokens": {
"title": "API Tokens",
"general": "API tokens allow you to use Vikunja's API without user credentials.",
"apiDocs": "Check out the api docs",
"createAToken": "Create a token",
"createToken": "Create token",
"30d": "30 dagar",
"60d": "60 dagar",
"90d": "90 dagar",
"permissionExplanation": "Permissions allow you to scope what an api token is allowed to do.",
"titleRequired": "The title is required",
"expired": "This token has expired {ago}.",
"delete": {
"header": "Delete this token",
"text1": "Are you sure you want to delete the token \"{token}\"?",
"text2": "This will revoke access to all applications or integrations using it. You cannot undo this."
},
"attributes": {
"title": "Title",
"titlePlaceholder": "Enter a title you will recognize later",
"expiresAt": "Expires at",
"permissions": "Permissions"
}
}
},
"deletion": {
@ -172,8 +201,8 @@
"color": "Färg",
"projects": "Projekt",
"parent": "Parent Project",
"search": "Type to search for a project…",
"searchSelect": "Click or press enter to select this project",
"search": "Skriv för att söka efter ett projekt…",
"searchSelect": "Klicka eller tryck på enter för att välja detta projekt",
"shared": "Delade projekt",
"noDescriptionAvailable": "No project description is available.",
"inboxTitle": "Inkorg",
@ -294,7 +323,7 @@
"noDates": "This task has no dates set."
},
"table": {
"title": "Table",
"title": "Tabell",
"columns": "Kolumner"
},
"kanban": {
@ -305,6 +334,9 @@
"doneBucketHint": "All tasks moved into this bucket will automatically marked as done.",
"doneBucketHintExtended": "All tasks moved into the done bucket will be marked as done automatically. All tasks marked as done from elsewhere will be moved as well.",
"doneBucketSavedSuccess": "The done bucket has been saved successfully.",
"defaultBucket": "Default bucket",
"defaultBucketHint": "When creating tasks without specifying a bucket, they will be added to this bucket.",
"defaultBucketSavedSuccess": "The default bucket has been saved successfully.",
"deleteLast": "You cannot remove the last bucket.",
"addTaskPlaceholder": "Enter the new task title…",
"addTask": "Lägg till en uppgift",
@ -881,7 +913,7 @@
"urlPlaceholder": "t. ex. https://localhost:3456",
"change": "change",
"use": "Using Vikunja installation at {0}",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please try a different url.",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please check if the url has the correct format and you can reach it when accessing it directly and try again.",
"success": "Using Vikunja installation at \"{domain}\".",
"urlRequired": "A url is required."
},
@ -902,6 +934,7 @@
"tasks": "Uppgifter",
"projects": "Projekt",
"teams": "Teams",
"labels": "Etiketter",
"newProject": "Enter the title of the new project…",
"newTask": "Enter the title of the new task…",
"newTeam": "Enter the name of the new team…",

View File

@ -11,6 +11,11 @@
"import": "Import your data into Vikunja"
}
},
"demo": {
"title": "This instance is in demo mode. Do not use this for real data!",
"everythingWillBeDeleted": "Everything will be deleted in regular intervals!",
"accountWillBeDeleted": "Your account will be deleted, including all projects, tasks and attachments you might create."
},
"404": {
"title": "Not found",
"text": "The page you requested does not exist."
@ -139,6 +144,30 @@
"system": "System",
"dark": "Dark"
}
},
"apiTokens": {
"title": "API Tokens",
"general": "API tokens allow you to use Vikunja's API without user credentials.",
"apiDocs": "Check out the api docs",
"createAToken": "Create a token",
"createToken": "Create token",
"30d": "30 Days",
"60d": "60 Days",
"90d": "90 Days",
"permissionExplanation": "Permissions allow you to scope what an api token is allowed to do.",
"titleRequired": "The title is required",
"expired": "This token has expired {ago}.",
"delete": {
"header": "Delete this token",
"text1": "Are you sure you want to delete the token \"{token}\"?",
"text2": "This will revoke access to all applications or integrations using it. You cannot undo this."
},
"attributes": {
"title": "Title",
"titlePlaceholder": "Enter a title you will recognize later",
"expiresAt": "Expires at",
"permissions": "Permissions"
}
}
},
"deletion": {
@ -305,6 +334,9 @@
"doneBucketHint": "All tasks moved into this bucket will automatically marked as done.",
"doneBucketHintExtended": "All tasks moved into the done bucket will be marked as done automatically. All tasks marked as done from elsewhere will be moved as well.",
"doneBucketSavedSuccess": "The done bucket has been saved successfully.",
"defaultBucket": "Default bucket",
"defaultBucketHint": "When creating tasks without specifying a bucket, they will be added to this bucket.",
"defaultBucketSavedSuccess": "The default bucket has been saved successfully.",
"deleteLast": "You cannot remove the last bucket.",
"addTaskPlaceholder": "Enter the new task title…",
"addTask": "Add a task",
@ -881,7 +913,7 @@
"urlPlaceholder": "eg. https://localhost:3456",
"change": "change",
"use": "Using Vikunja installation at {0}",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please try a different url.",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please check if the url has the correct format and you can reach it when accessing it directly and try again.",
"success": "Using Vikunja installation at \"{domain}\".",
"urlRequired": "A url is required."
},
@ -902,6 +934,7 @@
"tasks": "Tasks",
"projects": "Projects",
"teams": "Teams",
"labels": "Labels",
"newProject": "Enter the title of the new project…",
"newTask": "Enter the title of the new task…",
"newTeam": "Enter the name of the new team…",

View File

@ -11,6 +11,11 @@
"import": "Import your data into Vikunja"
}
},
"demo": {
"title": "This instance is in demo mode. Do not use this for real data!",
"everythingWillBeDeleted": "Everything will be deleted in regular intervals!",
"accountWillBeDeleted": "Your account will be deleted, including all projects, tasks and attachments you might create."
},
"404": {
"title": "Không tìm thấy gì cả",
"text": "Trang bạn yêu cầu không tồn tại."
@ -139,6 +144,30 @@
"system": "Hệ thống",
"dark": "Tối"
}
},
"apiTokens": {
"title": "API Tokens",
"general": "API tokens allow you to use Vikunja's API without user credentials.",
"apiDocs": "Check out the api docs",
"createAToken": "Create a token",
"createToken": "Create token",
"30d": "30 Days",
"60d": "60 Days",
"90d": "90 Days",
"permissionExplanation": "Permissions allow you to scope what an api token is allowed to do.",
"titleRequired": "The title is required",
"expired": "This token has expired {ago}.",
"delete": {
"header": "Delete this token",
"text1": "Are you sure you want to delete the token \"{token}\"?",
"text2": "This will revoke access to all applications or integrations using it. You cannot undo this."
},
"attributes": {
"title": "Title",
"titlePlaceholder": "Enter a title you will recognize later",
"expiresAt": "Expires at",
"permissions": "Permissions"
}
}
},
"deletion": {
@ -305,6 +334,9 @@
"doneBucketHint": "All tasks moved into this bucket will automatically marked as done.",
"doneBucketHintExtended": "All tasks moved into the done bucket will be marked as done automatically. All tasks marked as done from elsewhere will be moved as well.",
"doneBucketSavedSuccess": "The done bucket has been saved successfully.",
"defaultBucket": "Default bucket",
"defaultBucketHint": "When creating tasks without specifying a bucket, they will be added to this bucket.",
"defaultBucketSavedSuccess": "The default bucket has been saved successfully.",
"deleteLast": "You cannot remove the last bucket.",
"addTaskPlaceholder": "Enter the new task title…",
"addTask": "Add a task",
@ -881,7 +913,7 @@
"urlPlaceholder": "ví dụ: https://localhost:3456",
"change": "thay đổi",
"use": "Sử dụng cài đặt Vikunja tại {0}",
"error": "Không thể tìm thấy hoặc sử dụng cài đặt Vikunja tại \"{domain}\". Vui lòng thử một url khác.",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please check if the url has the correct format and you can reach it when accessing it directly and try again.",
"success": "Sử dụng cài đặt Vikunja tại \"{domain}\".",
"urlRequired": "Cần có một url."
},
@ -902,6 +934,7 @@
"tasks": "Tác vụ",
"projects": "Projects",
"teams": "Team",
"labels": "Labels",
"newProject": "Enter the title of the new project…",
"newTask": "Đặt tên cho tác vụ mới…",
"newTeam": "Đặt tên cho đội nhóm mới…",

View File

@ -11,6 +11,11 @@
"import": "Import your data into Vikunja"
}
},
"demo": {
"title": "This instance is in demo mode. Do not use this for real data!",
"everythingWillBeDeleted": "Everything will be deleted in regular intervals!",
"accountWillBeDeleted": "Your account will be deleted, including all projects, tasks and attachments you might create."
},
"404": {
"title": "未找到数据",
"text": "您请求的页面不存在。"
@ -139,6 +144,30 @@
"system": "跟随系统",
"dark": "暗色"
}
},
"apiTokens": {
"title": "API Tokens",
"general": "API tokens allow you to use Vikunja's API without user credentials.",
"apiDocs": "Check out the api docs",
"createAToken": "Create a token",
"createToken": "Create token",
"30d": "30 Days",
"60d": "60 Days",
"90d": "90 Days",
"permissionExplanation": "Permissions allow you to scope what an api token is allowed to do.",
"titleRequired": "The title is required",
"expired": "This token has expired {ago}.",
"delete": {
"header": "Delete this token",
"text1": "Are you sure you want to delete the token \"{token}\"?",
"text2": "This will revoke access to all applications or integrations using it. You cannot undo this."
},
"attributes": {
"title": "Title",
"titlePlaceholder": "Enter a title you will recognize later",
"expiresAt": "Expires at",
"permissions": "Permissions"
}
}
},
"deletion": {
@ -305,6 +334,9 @@
"doneBucketHint": "All tasks moved into this bucket will automatically marked as done.",
"doneBucketHintExtended": "All tasks moved into the done bucket will be marked as done automatically. All tasks marked as done from elsewhere will be moved as well.",
"doneBucketSavedSuccess": "The done bucket has been saved successfully.",
"defaultBucket": "Default bucket",
"defaultBucketHint": "When creating tasks without specifying a bucket, they will be added to this bucket.",
"defaultBucketSavedSuccess": "The default bucket has been saved successfully.",
"deleteLast": "You cannot remove the last bucket.",
"addTaskPlaceholder": "Enter the new task title…",
"addTask": "Add a task",
@ -881,7 +913,7 @@
"urlPlaceholder": "例如: http://localhost:3456",
"change": "换一换",
"use": "在 {0} 使用 Vikunja 安装程序",
"error": "无法在 “{domain}” 上找到或使用 Vikunja 安装程序。 请尝试其他网址。",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please check if the url has the correct format and you can reach it when accessing it directly and try again.",
"success": "在 “{domain}” 上使用 Vikunja 安装程序。",
"urlRequired": "Url 是必需的。"
},
@ -902,6 +934,7 @@
"tasks": "任务",
"projects": "Projects",
"teams": "团队",
"labels": "Labels",
"newProject": "Enter the title of the new project…",
"newTask": "输入新任务的标题...",
"newTeam": "输入新团队的名称...",

View File

@ -11,6 +11,11 @@
"import": "Import your data into Vikunja"
}
},
"demo": {
"title": "This instance is in demo mode. Do not use this for real data!",
"everythingWillBeDeleted": "Everything will be deleted in regular intervals!",
"accountWillBeDeleted": "Your account will be deleted, including all projects, tasks and attachments you might create."
},
"404": {
"title": "Not found",
"text": "The page you requested does not exist."
@ -139,6 +144,30 @@
"system": "System",
"dark": "Dark"
}
},
"apiTokens": {
"title": "API Tokens",
"general": "API tokens allow you to use Vikunja's API without user credentials.",
"apiDocs": "Check out the api docs",
"createAToken": "Create a token",
"createToken": "Create token",
"30d": "30 Days",
"60d": "60 Days",
"90d": "90 Days",
"permissionExplanation": "Permissions allow you to scope what an api token is allowed to do.",
"titleRequired": "The title is required",
"expired": "This token has expired {ago}.",
"delete": {
"header": "Delete this token",
"text1": "Are you sure you want to delete the token \"{token}\"?",
"text2": "This will revoke access to all applications or integrations using it. You cannot undo this."
},
"attributes": {
"title": "Title",
"titlePlaceholder": "Enter a title you will recognize later",
"expiresAt": "Expires at",
"permissions": "Permissions"
}
}
},
"deletion": {
@ -305,6 +334,9 @@
"doneBucketHint": "All tasks moved into this bucket will automatically marked as done.",
"doneBucketHintExtended": "All tasks moved into the done bucket will be marked as done automatically. All tasks marked as done from elsewhere will be moved as well.",
"doneBucketSavedSuccess": "The done bucket has been saved successfully.",
"defaultBucket": "Default bucket",
"defaultBucketHint": "When creating tasks without specifying a bucket, they will be added to this bucket.",
"defaultBucketSavedSuccess": "The default bucket has been saved successfully.",
"deleteLast": "You cannot remove the last bucket.",
"addTaskPlaceholder": "Enter the new task title…",
"addTask": "Add a task",
@ -881,7 +913,7 @@
"urlPlaceholder": "eg. https://localhost:3456",
"change": "change",
"use": "Using Vikunja installation at {0}",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please try a different url.",
"error": "Could not find or use Vikunja installation at \"{domain}\". Please check if the url has the correct format and you can reach it when accessing it directly and try again.",
"success": "Using Vikunja installation at \"{domain}\".",
"urlRequired": "A url is required."
},
@ -902,6 +934,7 @@
"tasks": "Tasks",
"projects": "Projects",
"teams": "Teams",
"labels": "Labels",
"newProject": "Enter the title of the new project…",
"newTask": "Enter the title of the new task…",
"newTeam": "Enter the name of the new team…",

View File

@ -23,6 +23,7 @@ declare global {
SENTRY_DSN: string;
PROJECT_INFINITE_NESTING_ENABLED: boolean;
ALLOW_ICON_CHANGES: boolean;
CUSTOM_LOGO_URL?: string;
}
}
@ -35,8 +36,8 @@ if (apiUrlFromStorage !== null) {
}
// Make sure the api url does not contain a / at the end
if (window.API_URL.slice(window.API_URL.length - 1, window.API_URL.length) === '/') {
window.API_URL = window.API_URL.slice(0, window.API_URL.length - 1)
if (window.API_URL.endsWith('/')) {
window.API_URL = window.API_URL.slice(0, -1)
}
// directives

View File

@ -0,0 +1,14 @@
import type {IAbstract} from '@/modelTypes/IAbstract'
export interface IApiPermission {
[key: string]: string[]
}
export interface IApiToken extends IAbstract {
id: number
title: string
token: string
permissions: IApiPermission
expiresAt: Date
created: Date
}

View File

@ -8,7 +8,6 @@ export interface IBucket extends IAbstract {
projectId: number
limit: number
tasks: ITask[]
isDoneBucket: boolean
position: number
count: number

View File

@ -19,6 +19,8 @@ export interface IProject extends IAbstract {
position: number
backgroundBlurHash: string
parentProjectId: number
doneBucketId: number
defaultBucketId: number
created: Date
updated: Date

View File

@ -0,0 +1,21 @@
import AbstractModel from '@/models/abstractModel'
import type {IApiToken} from '@/modelTypes/IApiToken'
export default class ApiTokenModel extends AbstractModel<IApiToken> {
id = 0
title = ''
token = ''
permissions = null
expiresAt: Date = null
created: Date = null
constructor(data: Partial<IApiToken> = {}) {
super()
this.assignData(data)
this.expiresAt = new Date(this.expiresAt)
this.created = new Date(this.created)
this.updated = new Date(this.updated)
}
}

View File

@ -12,7 +12,6 @@ export default class BucketModel extends AbstractModel<IBucket> implements IBuck
projectId = ''
limit = 0
tasks: ITask[] = []
isDoneBucket = false
position = 0
count = 0

View File

@ -23,6 +23,8 @@ export default class ProjectModel extends AbstractModel<IProject> implements IPr
position = 0
backgroundBlurHash = ''
parentProjectId = 0
doneBucketId = 0
defaultBucketId = 0
created: Date = null
updated: Date = null

View File

@ -85,7 +85,6 @@ export default class TaskModel extends AbstractModel<ITask> implements ITask {
index = 0
isFavorite = false
subscription: ISubscription = null
coverImageAttachmentId: IAttachment['id'] = null
position = 0
kanbanPosition = 0

View File

@ -65,6 +65,7 @@ const UserSettingsEmailUpdateComponent = () => import('@/views/user/settings/Ema
const UserSettingsGeneralComponent = () => import('@/views/user/settings/General.vue')
const UserSettingsPasswordUpdateComponent = () => import('@/views/user/settings/PasswordUpdate.vue')
const UserSettingsTOTPComponent = () => import('@/views/user/settings/TOTP.vue')
const UserSettingsApiTokensComponent = () => import('@/views/user/settings/ApiTokens.vue')
// Project Handling
const NewProjectComponent = () => import('@/views/project/NewProject.vue')
@ -183,6 +184,11 @@ const router = createRouter({
name: 'user.settings.totp',
component: UserSettingsTOTPComponent,
},
{
path: '/user/settings/api-tokens',
name: 'user.settings.apiTokens',
component: UserSettingsApiTokensComponent,
},
],
},
{
@ -448,16 +454,9 @@ export async function getAuthForRoute(to: RouteLocation, authStore) {
return
}
const baseStore = useBaseStore()
// When trying this before the current user was fully loaded we might get a flash of the login screen
// in the user shell. To make shure this does not happen we check if everything is ready before trying.
if (!baseStore.ready) {
return
}
// Check if the user is already logged in and redirect them to the home page if not
if (
![
// Check if the route the user wants to go to is a route which needs authentication. We use this to
// redirect the user after successful login.
const isValidUserAppRoute = ![
'user.login',
'user.password-reset.request',
'user.password-reset.reset',
@ -468,8 +467,19 @@ export async function getAuthForRoute(to: RouteLocation, authStore) {
localStorage.getItem('passwordResetToken') === null &&
localStorage.getItem('emailConfirmToken') === null &&
!(to.name === 'home' && (typeof to.query.userPasswordReset !== 'undefined' || typeof to.query.userEmailConfirm !== 'undefined'))
) {
if (isValidUserAppRoute) {
saveLastVisited(to.name as string, to.params, to.query)
}
const baseStore = useBaseStore()
// When trying this before the current user was fully loaded we might get a flash of the login screen
// in the user shell. To make sure this does not happen we check if everything is ready before trying.
if (!baseStore.ready) {
return
}
if (isValidUserAppRoute) {
return {name: 'user.login'}
}

36
src/services/apiToken.ts Normal file
View File

@ -0,0 +1,36 @@
import AbstractService from '@/services/abstractService'
import type {IApiToken} from '@/modelTypes/IApiToken'
import ApiTokenModel from '@/models/apiTokenModel'
export default class ApiTokenService extends AbstractService<IApiToken> {
constructor() {
super({
create: '/tokens',
getAll: '/tokens',
delete: '/tokens/{id}',
})
}
processModel(model: IApiToken) {
return {
...model,
expiresAt: new Date(model.expiresAt).toISOString(),
created: new Date(model.created).toISOString(),
}
}
modelFactory(data: Partial<IApiToken>) {
return new ApiTokenModel(data)
}
async getAvailableRoutes() {
const cancel = this.setLoading()
try {
const response = await this.http.get('/routes')
return response.data
} finally {
cancel()
}
}
}

View File

@ -18,7 +18,7 @@ const parseDate = date => {
export default class TaskService extends AbstractService<ITask> {
constructor() {
super({
create: '/projects/{projectId}',
create: '/projects/{projectId}/tasks',
getAll: '/tasks/all',
get: '/tasks/{id}',
update: '/tasks/{id}',
@ -81,12 +81,6 @@ export default class TaskService extends AbstractService<ITask> {
case 'weeks':
repeatAfterSeconds = model.repeatAfter.amount * SECONDS_A_WEEK
break
case 'months':
repeatAfterSeconds = model.repeatAfter.amount * SECONDS_A_MONTH
break
case 'years':
repeatAfterSeconds = model.repeatAfter.amount * SECONDS_A_YEAR
break
}
}
model.repeatAfter = repeatAfterSeconds

View File

@ -26,6 +26,7 @@ export interface ConfigState {
caldavEnabled: boolean,
userDeletionEnabled: boolean,
taskCommentsEnabled: boolean,
demoModeEnabled: boolean,
auth: {
local: {
enabled: boolean,
@ -58,6 +59,7 @@ export const useConfigStore = defineStore('config', () => {
caldavEnabled: false,
userDeletionEnabled: true,
taskCommentsEnabled: true,
demoModeEnabled: false,
auth: {
local: {
enabled: true,

View File

@ -19,7 +19,7 @@ import {success} from '@/message'
import {useBaseStore} from '@/stores/base'
import {getSavedFilterIdFromProjectId} from '@/services/savedFilter'
const {remove, search, update} = createNewIndexer('projects', ['title', 'description'])
const {add, remove, search, update} = createNewIndexer('projects', ['title', 'description'])
export interface ProjectState {
[id: IProject['id']]: IProject
@ -36,9 +36,11 @@ export const useProjectStore = defineStore('project', () => {
const projectsArray = computed(() => Object.values(projects.value)
.sort((a, b) => a.position - b.position))
const notArchivedRootProjects = computed(() => projectsArray.value
.filter(p => p.parentProjectId === 0 && !p.isArchived))
.filter(p => p.parentProjectId === 0 && !p.isArchived && p.id > 0))
const favoriteProjects = computed(() => projectsArray.value
.filter(p => !p.isArchived && p.isFavorite))
const savedFilterProjects = computed(() => projectsArray.value
.filter(p => !p.isArchived && p.id < -1))
const hasProjects = computed(() => projectsArray.value.length > 0)
const getChildProjects = computed(() => {
@ -172,6 +174,7 @@ export const useProjectStore = defineStore('project', () => {
const loadedProjects = await projectService.getAll({}, {is_archived: true}) as IProject[]
projects.value = {}
setProjects(loadedProjects)
loadedProjects.forEach(p => add(p))
return loadedProjects
} finally {
@ -198,6 +201,7 @@ export const useProjectStore = defineStore('project', () => {
notArchivedRootProjects: readonly(notArchivedRootProjects),
favoriteProjects: readonly(favoriteProjects),
hasProjects: readonly(hasProjects),
savedFilterProjects: readonly(savedFilterProjects),
getChildProjects,
findProjectByExactname,

View File

@ -6,7 +6,6 @@ import TaskService from '@/services/task'
import TaskAssigneeService from '@/services/taskAssignee'
import LabelTaskService from '@/services/labelTask'
import {playPopSound} from '@/helpers/playPop'
import {cleanupItemText, parseTaskText, PREFIXES} from '@/modules/parseTaskText'
import TaskAssigneeModel from '@/models/taskAssignee'
@ -149,9 +148,6 @@ export const useTaskStore = defineStore('task', () => {
try {
const updatedTask = await taskService.update(task)
kanbanStore.setTaskInBucket(updatedTask)
if (task.done && useAuthStore().settings.frontendSettings.playSoundWhenDone) {
playPopSound()
}
return updatedTask
} finally {
cancel()

View File

@ -40,7 +40,7 @@
:default-task-end-date="defaultTaskEndDate"
@update:task="updateTask"
/>
<TaskForm v-if="canWrite" @create-task="addGanttTask" />
<TaskForm v-if="canWrite" @create-task="addGanttTask"/>
</card>
</div>
</template>
@ -115,7 +115,7 @@ const flatPickerDateRange = computed<Date[]>({
]),
set(newVal) {
const [dateFrom, dateTo] = newVal.map((date) => date?.toISOString())
// only set after whole range has been selected
if (!dateTo) return

View File

@ -37,7 +37,7 @@
>
<div class="bucket-header" @click="() => unCollapseBucket(bucket)">
<span
v-if="bucket.isDoneBucket"
v-if="project.doneBucketId === bucket.id"
class="icon is-small has-text-success mr-2"
v-tooltip="$t('project.kanban.doneBucketHint')"
>
@ -97,26 +97,32 @@
<dropdown-item
@click.stop="toggleDoneBucket(bucket)"
v-tooltip="$t('project.kanban.doneBucketHintExtended')"
:icon-class="{'has-text-success': bucket.id === project.doneBucketId}"
icon="check-double"
>
<span class="icon is-small" :class="{'has-text-success': bucket.isDoneBucket}">
<icon icon="check-double"/>
</span>
{{ $t('project.kanban.doneBucket') }}
</dropdown-item>
<dropdown-item
@click.stop="toggleDefaultBucket(bucket)"
v-tooltip="$t('project.kanban.defaultBucketHint')"
:icon-class="{'has-text-primary': bucket.id === project.defaultBucketId}"
icon="th"
>
{{ $t('project.kanban.defaultBucket') }}
</dropdown-item>
<dropdown-item
@click.stop="() => collapseBucket(bucket)"
icon="angles-up"
>
{{ $t('project.kanban.collapse') }}
</dropdown-item>
<dropdown-item
:class="{'is-disabled': buckets.length <= 1}"
@click.stop="() => deleteBucketModal(bucket.id)"
class="has-text-danger"
v-tooltip="buckets.length <= 1 ? $t('project.kanban.deleteLast') : ''"
icon-class="has-text-danger"
icon="trash-alt"
>
<span class="icon is-small">
<icon icon="trash-alt"/>
</span>
{{ $t('misc.delete') }}
</dropdown-item>
</dropdown>
@ -251,6 +257,7 @@ import {calculateItemPosition} from '@/helpers/calculateItemPosition'
import {isSavedFilter} from '@/services/savedFilter'
import {success} from '@/message'
import {useProjectStore} from '@/stores/projects'
const DRAG_OPTIONS = {
// sortable options
@ -268,6 +275,7 @@ const {t} = useI18n({useScope: 'global'})
const baseStore = useBaseStore()
const kanbanStore = useKanbanStore()
const taskStore = useTaskStore()
const projectStore = useProjectStore()
const taskContainerRefs = ref<{[id: IBucket['id']]: HTMLElement}>({})
@ -422,10 +430,9 @@ async function updateTaskPosition(e) {
)
if (
oldBucket !== undefined && // This shouldn't actually be `undefined`, but let's play it safe.
newBucket.id !== oldBucket.id &&
newBucket.isDoneBucket !== oldBucket.isDoneBucket
newBucket.id !== oldBucket.id
) {
newTask.done = newBucket.isDoneBucket
newTask.done = project.value.doneBucketId === newBucket.id
}
if (
oldBucket !== undefined && // This shouldn't actually be `undefined`, but let's play it safe.
@ -596,10 +603,26 @@ function dragstart(bucket: IBucket) {
sourceBucket.value = bucket.id
}
async function toggleDefaultBucket(bucket: IBucket) {
const defaultBucketId = project.value.defaultBucketId === bucket.id
? 0
: bucket.id
await projectStore.updateProject({
...project.value,
defaultBucketId,
})
success({message: t('project.kanban.defaultBucketSavedSuccess')})
}
async function toggleDoneBucket(bucket: IBucket) {
await kanbanStore.updateBucket({
...bucket,
isDoneBucket: !bucket.isDoneBucket,
const doneBucketId = project.value.doneBucketId === bucket.id
? 0
: bucket.id
await projectStore.updateProject({
...project.value,
doneBucketId,
})
success({message: t('project.kanban.doneBucketSavedSuccess')})
}

View File

@ -63,7 +63,7 @@
<nothing v-if="ctaVisible && tasks.length === 0 && !loading">
{{ $t('project.list.empty') }}
<ButtonLink @click="focusNewTaskInput()">
<ButtonLink @click="focusNewTaskInput()" v-if="project.id > 0">
{{ $t('project.list.newTaskCta') }}
</ButtonLink>
</nothing>

View File

@ -143,13 +143,12 @@
<labels :labels="t.labels"/>
</td>
<td v-if="activeColumns.assignees">
<user
:avatar-size="27"
:is-inline="true"
:key="t.id + 'assignee' + a.id + i"
:show-username="false"
:user="a"
v-for="(a, i) in t.assignees"
<assignee-list
v-if="t.assignees.length > 0"
:assignees="t.assignees"
:avatar-size="28"
class="ml-1"
:inline="true"
/>
</td>
<date-table-cell :date="t.dueDate" v-if="activeColumns.dueDate"/>
@ -201,6 +200,7 @@ import {useTaskList} from '@/composables/useTaskList'
import type {SortBy} from '@/composables/useTaskList'
import type {ITask} from '@/modelTypes/ITask'
import type {IProject} from '@/modelTypes/IProject'
import AssigneeList from '@/components/tasks/partials/assigneeList.vue'
const ACTIVE_COLUMNS_DEFAULT = {
index: true,

View File

@ -378,7 +378,7 @@
{{ $t('task.detail.actions.attachments') }}
</x-button>
<x-button
@click="setFieldActive('relatedTasks')"
@click="setRelatedTasksActive()"
variant="secondary"
icon="sitemap"
v-shortcut="'r'"
@ -447,7 +447,7 @@
</template>
<script lang="ts" setup>
import {ref, reactive, toRef, shallowReactive, computed, watch, watchEffect, nextTick} from 'vue'
import {ref, reactive, toRef, shallowReactive, computed, watch, nextTick} from 'vue'
import {useRouter, type RouteLocation} from 'vue-router'
import {useI18n} from 'vue-i18n'
import {unrefElement} from '@vueuse/core'
@ -488,7 +488,6 @@ import {uploadFile} from '@/helpers/attachments'
import {getProjectTitle} from '@/helpers/getProjectTitle'
import {scrollIntoView} from '@/helpers/scrollIntoView'
import {useBaseStore} from '@/stores/base'
import {useAttachmentStore} from '@/stores/attachments'
import {useTaskStore} from '@/stores/tasks'
import {useKanbanStore} from '@/stores/kanban'
@ -499,6 +498,8 @@ import {success} from '@/message'
import type {Action as MessageAction} from '@/message'
import {useProjectStore} from '@/stores/projects'
import {TASK_REPEAT_MODES} from '@/types/IRepeatMode'
import {useAuthStore} from '@/stores/auth'
import {playPopSound} from '@/helpers/playPop'
const {
taskId,
@ -513,7 +514,6 @@ defineEmits(['close'])
const router = useRouter()
const {t} = useI18n({useScope: 'global'})
const baseStore = useBaseStore()
const projectStore = useProjectStore()
const attachmentStore = useAttachmentStore()
const taskStore = useTaskStore()
@ -534,17 +534,6 @@ const taskColor = ref<ITask['hexColor']>('')
const visible = ref(false)
const project = computed(() => projectStore.projects[task.value.projectId])
watchEffect(() => {
if (typeof project.value === 'undefined') {
// assuming the task has not been loaded completely and thus the project id is 0.
// This avoids flickering between a project background and none when opening the task detail view from
// any the project views.
return
}
baseStore.handleSetCurrentProject({
project: project.value,
})
})
const canWrite = computed(() => (
task.value.maxRight !== null &&
@ -735,6 +724,10 @@ function toggleTaskDone() {
done: !task.value.done,
}
if (newTask.done && useAuthStore().settings.frontendSettings.playSoundWhenDone) {
playPopSound()
}
saveTask(
newTask,
toggleTaskDone,
@ -777,6 +770,19 @@ async function removeRepeatAfter() {
task.value.repeatMode = TASK_REPEAT_MODES.REPEAT_MODE_DEFAULT
await saveTask()
}
function setRelatedTasksActive() {
setFieldActive('relatedTasks')
// If the related tasks are already available, show the form again
const el = activeFieldElements['relatedTasks']
for (const child in el?.children) {
if (el?.children[child]?.id === 'showRelatedTasksFormButton') {
el?.children[child]?.click()
break
}
}
}
</script>
<style lang="scss" scoped>

View File

@ -58,6 +58,17 @@
>
{{ $t('user.auth.createAccount') }}
</x-button>
<message
v-if="configStore.demoModeEnabled"
variant="warning"
class="mt-4"
>
{{ $t('demo.title') }}
{{ $t('demo.accountWillBeDeleted') }}<br/>
<strong class="is-uppercase">{{ $t('demo.everythingWillBeDeleted') }}</strong>
</message>
<p class="mt-2">
{{ $t('user.auth.alreadyHaveAnAccount') }}
<router-link :to="{ name: 'user.login' }">
@ -78,8 +89,10 @@ import {isEmail} from '@/helpers/isEmail'
import Password from '@/components/input/password.vue'
import {useAuthStore} from '@/stores/auth'
import {useConfigStore} from '@/stores/config'
const authStore = useAuthStore()
const configStore = useConfigStore()
// FIXME: use the `beforeEnter` hook of vue-router
// Check if the user is already logged in, if so, redirect them to the homepage

View File

@ -75,6 +75,10 @@ const navigationItems = computed(() => {
routeName: 'user.settings.caldav',
condition: caldavEnabled.value,
},
{
title: t('user.settings.apiTokens.title'),
routeName: 'user.settings.apiTokens',
},
{
title: t('user.deletion.title'),
routeName: 'user.settings.deletion',

View File

@ -0,0 +1,262 @@
<script setup lang="ts">
import ApiTokenService from '@/services/apiToken'
import {computed, onMounted, ref} from 'vue'
import {formatDateShort, formatDateSince} from '@/helpers/time/formatDate'
import XButton from '@/components/input/button.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import ApiTokenModel from '@/models/apiTokenModel'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import {MILLISECONDS_A_DAY} from '@/constants/date'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import {useI18n} from 'vue-i18n'
import {useAuthStore} from '@/stores/auth'
import Message from '@/components/misc/message.vue'
const service = new ApiTokenService()
const tokens = ref([])
const apiDocsUrl = window.API_URL + '/docs'
const showCreateForm = ref(false)
const availableRoutes = ref(null)
const newToken = ref(new ApiTokenModel())
const newTokenExpiry = ref<string | number>(30)
const newTokenExpiryCustom = ref(new Date())
const newTokenPermissions = ref({})
const newTokenTitleValid = ref(true)
const apiTokenTitle = ref()
const tokenCreatedSuccessMessage = ref('')
const showDeleteModal = ref(false)
const tokenToDelete = ref(null)
const {t} = useI18n()
const authStore = useAuthStore()
const now = new Date()
const flatPickerConfig = computed(() => ({
altFormat: t('date.altFormatLong'),
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
locale: {
firstDayOfWeek: authStore.settings.weekStart,
},
minDate: now,
}))
onMounted(async () => {
tokens.value = await service.getAll()
availableRoutes.value = await service.getAvailableRoutes()
resetPermissions()
})
function resetPermissions() {
newTokenPermissions.value = {}
Object.entries(availableRoutes.value).forEach(entry => {
const [group, routes] = entry
newTokenPermissions.value[group] = {}
Object.keys(routes).forEach(r => {
newTokenPermissions.value[group][r] = false
})
})
}
async function deleteToken() {
await service.delete(tokenToDelete.value)
showDeleteModal.value = false
tokenToDelete.value = null
const index = tokens.value.findIndex(el => el.id === tokenToDelete.value.id)
if (index === -1) {
return
}
tokens.value.splice(index, 1)
}
async function createToken() {
if (!newTokenTitleValid.value) {
apiTokenTitle.value.focus()
return
}
const expiry = Number(newTokenExpiry.value)
if (!isNaN(expiry)) {
// if it's a number, we assume it's the number of days in the future
newToken.value.expiresAt = new Date((+new Date()) + expiry * MILLISECONDS_A_DAY)
} else {
newToken.value.expiresAt = new Date(newTokenExpiryCustom.value)
}
newToken.value.permissions = {}
Object.entries(newTokenPermissions.value).forEach(([key, ps]) => {
const all = Object.entries(ps)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.filter(([_, v]) => v)
.map(p => p[0])
if (all.length > 0) {
newToken.value.permissions[key] = all
}
})
const token = await service.create(newToken.value)
tokenCreatedSuccessMessage.value = t('user.settings.apiTokens.tokenCreatedSuccess', {token: token.token})
newToken.value = new ApiTokenModel()
newTokenExpiry.value = 30
newTokenExpiryCustom.value = new Date()
resetPermissions()
tokens.value.push(token)
showCreateForm.value = false
}
function formatPermissionTitle(title: string): string {
return title.replaceAll('_', ' ')
}
</script>
<template>
<card :title="$t('user.settings.apiTokens.title')">
<message v-if="tokenCreatedSuccessMessage !== ''" class="has-text-centered mb-4">
{{ tokenCreatedSuccessMessage }}<br/>
{{ $t('user.settings.apiTokens.tokenCreatedNotSeeAgain') }}
</message>
<p>
{{ $t('user.settings.apiTokens.general') }}
<BaseButton :href="apiDocsUrl">{{ $t('user.settings.apiTokens.apiDocs') }}</BaseButton>
.
</p>
<table class="table" v-if="tokens.length > 0">
<tr>
<th>{{ $t('misc.id') }}</th>
<th>{{ $t('user.settings.apiTokens.attributes.title') }}</th>
<th>{{ $t('user.settings.apiTokens.attributes.permissions') }}</th>
<th>{{ $t('user.settings.apiTokens.attributes.expiresAt') }}</th>
<th>{{ $t('misc.created') }}</th>
<th class="has-text-right">{{ $t('misc.actions') }}</th>
</tr>
<tr v-for="tk in tokens" :key="tk.id">
<td>{{ tk.id }}</td>
<td>{{ tk.title }}</td>
<td class="is-capitalized">
<template v-for="(v, p) in tk.permissions" :key="'permission-' + p">
<strong>{{ formatPermissionTitle(p) }}:</strong>
{{ v.map(formatPermissionTitle).join(', ') }}
<br/>
</template>
</td>
<td>
{{ formatDateShort(tk.expiresAt) }}
<p v-if="tk.expiresAt < new Date()" class="has-text-danger">
{{ $t('user.settings.apiTokens.expired', {ago: formatDateSince(tk.expiresAt)}) }}
</p>
</td>
<td>{{ formatDateShort(tk.created) }}</td>
<td class="has-text-right">
<x-button variant="secondary" @click="() => {tokenToDelete = tk; showDeleteModal = true}">
{{ $t('misc.delete') }}
</x-button>
</td>
</tr>
</table>
<form
v-if="showCreateForm"
@submit.prevent="createToken"
>
<!-- Title -->
<div class="field">
<label class="label" for="apiTokenTitle">{{ $t('user.settings.apiTokens.attributes.title') }}</label>
<div class="control">
<input
class="input"
id="apiTokenTitle"
ref="apiTokenTitle"
type="text"
v-focus
:placeholder="$t('user.settings.apiTokens.attributes.titlePlaceholder')"
v-model="newToken.title"
@keyup="() => newTokenTitleValid = newToken.title !== ''"
@focusout="() => newTokenTitleValid = newToken.title !== ''"
/>
</div>
<p class="help is-danger" v-if="!newTokenTitleValid">
{{ $t('user.settings.apiTokens.titleRequired') }}
</p>
</div>
<!-- Expiry -->
<div class="field">
<label class="label" for="apiTokenExpiry">
{{ $t('user.settings.apiTokens.attributes.expiresAt') }}
</label>
<div class="is-flex">
<div class="control select">
<select class="select" v-model="newTokenExpiry" id="apiTokenExpiry">
<option value="30">{{ $t('user.settings.apiTokens.30d') }}</option>
<option value="60">{{ $t('user.settings.apiTokens.60d') }}</option>
<option value="90">{{ $t('user.settings.apiTokens.90d') }}</option>
<option value="custom">{{ $t('misc.custom') }}</option>
</select>
</div>
<flat-pickr
v-if="newTokenExpiry === 'custom'"
class="ml-2"
:config="flatPickerConfig"
v-model="newTokenExpiryCustom"
/>
</div>
</div>
<!-- Permissions -->
<div class="field">
<label class="label">{{ $t('user.settings.apiTokens.attributes.permissions') }}</label>
<p>{{ $t('user.settings.apiTokens.permissionExplanation') }}</p>
<div v-for="(routes, group) in availableRoutes" class="mb-2" :key="group">
<strong class="is-capitalized">{{ formatPermissionTitle(group) }}</strong><br/>
<fancycheckbox
v-for="(paths, route) in routes"
:key="group+'-'+route"
class="mr-2 is-capitalized"
v-model="newTokenPermissions[group][route]"
>
{{ formatPermissionTitle(route) }}
</fancycheckbox>
<br/>
</div>
</div>
<x-button :loading="service.loading" @click="createToken">
{{ $t('user.settings.apiTokens.createToken') }}
</x-button>
</form>
<x-button
v-else
icon="plus"
class="mb-4"
@click="() => showCreateForm = true"
:loading="service.loading"
>
{{ $t('user.settings.apiTokens.createAToken') }}
</x-button>
<modal
:enabled="showDeleteModal"
@close="showDeleteModal = false"
@submit="deleteToken()"
>
<template #header>
{{ $t('user.settings.apiTokens.delete.header') }}
</template>
<template #text>
<p>
{{ $t('user.settings.apiTokens.delete.text1', {token: tokenToDelete.title}) }}<br/>
{{ $t('user.settings.apiTokens.delete.text2') }}
</p>
</template>
</modal>
</card>
</template>