Compare commits

..

12 commits

181 changed files with 4651 additions and 5513 deletions

View file

@ -46,7 +46,7 @@ steps:
PNPM_CACHE_FOLDER: .cache/pnpm
CYPRESS_CACHE_FOLDER: .cache/cypress
commands:
- corepack enable && pnpm config set store-dir .cache/pnpm
- corepack enable && corepack prepare pnpm@7.9.3 --activate && pnpm config set store-dir .cache/pnpm
- pnpm install --fetch-timeout 100000
# depends_on:
# - restore-cache
@ -57,7 +57,7 @@ steps:
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
commands:
- corepack enable && pnpm config set store-dir .cache/pnpm
- corepack enable && corepack prepare pnpm@7.9.3 --activate && pnpm config set store-dir .cache/pnpm
- pnpm run lint
depends_on:
- dependencies
@ -68,7 +68,7 @@ steps:
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
commands:
- corepack enable && pnpm config set store-dir .cache/pnpm
- corepack enable && corepack prepare pnpm@7.9.3 --activate && pnpm config set store-dir .cache/pnpm
- pnpm run build
depends_on:
- dependencies
@ -77,7 +77,7 @@ steps:
image: node:18-alpine
pull: true
commands:
- corepack enable && pnpm config set store-dir .cache/pnpm
- corepack enable && corepack prepare pnpm@7.9.3 --activate && pnpm config set store-dir .cache/pnpm
- pnpm run test:unit
depends_on:
- dependencies
@ -89,7 +89,7 @@ steps:
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
commands:
- corepack enable && pnpm config set store-dir .cache/pnpm
- corepack enable && corepack prepare pnpm@7.9.3 --activate && pnpm config set store-dir .cache/pnpm
- pnpm run typecheck
depends_on:
- dependencies
@ -107,7 +107,7 @@ steps:
from_secret: cypress_project_key
commands:
- sed -i 's/localhost/api/g' dist/index.html
- corepack enable && pnpm config set store-dir .cache/pnpm
- corepack enable && corepack prepare pnpm@7.9.3 --activate && pnpm config set store-dir .cache/pnpm
- pnpm cypress install
- pnpm run serve:dist & npx wait-on http://localhost:4173
- pnpm run test:frontend --browser chrome --record
@ -147,7 +147,7 @@ steps:
commands:
- cp -r dist dist-preview
# Override the default api url used for preview
- sed -i 's|http://localhost:3456|https://try.vikunja.io|g' dist-preview/index.html
- sed -i 's|localhost:3456|try.vikunja.io|g' dist-preview/index.html
- apk add --no-cache perl-utils
- shasum -a 384 -c ./scripts/deploy-preview-netlify.js.sha384
- node ./scripts/deploy-preview-netlify.js
@ -202,7 +202,7 @@ steps:
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
commands:
- corepack enable && pnpm config set store-dir .cache/.pnp
- corepack enable && corepack prepare pnpm@7.9.3 --activate && pnpm config set store-dir .cache/.pnp
- pnpm install --fetch-timeout 100000
- pnpm run lint
- "echo '{\"VERSION\": \"'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'\"}' > src/version.json"
@ -278,7 +278,7 @@ steps:
environment:
PNPM_CACHE_FOLDER: .cache/pnpm
commands:
- corepack enable && pnpm config set store-dir .cache/pnpm
- corepack enable && corepack prepare pnpm@7.9.3 --activate && pnpm config set store-dir .cache/pnpm
- pnpm install --fetch-timeout 100000
- pnpm run lint
- "echo '{\"VERSION\": \"'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'\"}' > src/version.json"
@ -659,6 +659,6 @@ steps:
from_secret: crowdin_key
---
kind: signature
hmac: c885a0e50db729842402494aa645dd3ac662828b691108550f6bf302158295ba
hmac: 9e9585222c466c67b426df40c76670f1a539c363a52075ff34e7f4518e67da2d
...

1
.envrc
View file

@ -1 +0,0 @@
use flake

View file

@ -1,6 +1,3 @@
/* eslint-env node */
require("@rushstack/eslint-patch/modern-module-resolution")
module.exports = {
'root': true,
'env': {
@ -12,7 +9,7 @@ module.exports = {
'extends': [
'eslint:recommended',
'plugin:vue/vue3-essential',
'@vue/eslint-config-typescript/recommended',
'@vue/typescript',
],
'rules': {
'vue/html-quotes': [
@ -31,6 +28,7 @@ module.exports = {
'error',
'never',
],
'vue/script-setup-uses-vars': 'error',
// see https://segmentfault.com/q/1010000040813116/a-1020000041134455 (original in chinese)
'no-unused-vars': 'off',
@ -42,7 +40,6 @@ module.exports = {
'parserOptions': {
'parser': '@typescript-eslint/parser',
'ecmaVersion': 2022,
'sourceType': 'module',
},
'ignorePatterns': [
'*.test.*',

View file

@ -1,58 +0,0 @@
name: Bug Report
description: Found something you weren't expecting? Report it here!
labels: kind/bug
body:
- type: markdown
attributes:
value: |
NOTE: If your issue is a security concern, please send an email to security@vikunja.io instead of opening a public issue.
- type: markdown
attributes:
value: |
Please fill out this issue template to report a bug.
1. If you want to propose a new feature, please open a discussion thread in the forum: https://community.vikunja.io
2. Please ask questions or configuration/deploy problems on our [Matrix Room](https://matrix.to/#/#vikunja:matrix.org) or forum (https://community.vikunja.io).
3. Make sure you are using the latest release and
take a moment to check that your issue hasn't been reported before.
4. Please give all relevant information below for bug reports, because
incomplete details will be handled as an invalid report and closed.
- type: textarea
id: description
attributes:
label: Description
description: |
Please provide a description of your issue here, with a URL if you were able to reproduce the issue (see below).
- type: input
id: frontend-version
attributes:
label: Vikunja Frontend Version
description: Vikunja frontend version (or commit reference) of your instance
validations:
required: true
- type: input
id: api-version
attributes:
label: Vikunja API Version
description: Vikunja API version (or commit reference) of your instance
validations:
required: true
- type: input
id: browser-version
attributes:
label: Browser and version
description: If your issue is related to a frontend problem, please provide the browser and version you used to reproduce it.
- type: dropdown
id: can-reproduce
attributes:
label: Can you reproduce the bug on the Vikunja demo site?
options:
- "Yes"
- "No"
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: If this issue involves the Web Interface, please provide one or more screenshots

View file

@ -1,17 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: API issues
url: https://code.vikunja.io/api/issues
about: This is the frontend repo. Please open api-related bug reports and discussions in the api 0repo. Not sure if your issue is frontend or api? Ask in Matrix or the forum first.
- name: Forum
url: https://community.vikunja.io/
about: Feature Requests, Questions, configuration or deployment problems should be discussed in the forum.
- name: Security-related issues
url: https://vikunja.io/contact/#security
about: For security concerns, please send a mail to security@vikunja.io instead of opening a public issue.
- name: Chat on Matrix
url: https://matrix.to/#/#vikunja:matrix.org
about: Please ask any quick questions here.
- name: Translations
url: https://crowdin.com/project/vikunja
about: Any problems or requests for new languages about translations should be handled in crowdin.

1
.gitignore vendored
View file

@ -2,7 +2,6 @@
node_modules
/dist*
*.zip
.direnv/
# local env files
.env.local

View file

@ -6,24 +6,33 @@ WORKDIR /build
ARG USE_RELEASE=false
ARG RELEASE_VERSION=main
ENV PNPM_CACHE_FOLDER .cache/pnpm/
ADD . ./
RUN \
if [ $USE_RELEASE = true ]; then \
wget https://dl.vikunja.io/frontend/vikunja-frontend-$RELEASE_VERSION.zip -O frontend-release.zip && \
unzip frontend-release.zip -d dist/ && \
exit 0; \
fi && \
fi
ENV PNPM_CACHE_FOLDER .cache/pnpm/
# pnpm fetch does require only lockfile
COPY pnpm-lock.yaml ./
RUN \
# https://pnpm.io/installation#using-corepack
corepack enable && \
# we don't use corepack prepare here by intend since
# we have renovate to keep our dependencies up to date
corepack prepare pnpm@7.9.3 --activate && \
# Build the frontend
pnpm install && \
apk add --no-cache git && \
echo '{"VERSION": "'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'"}' > src/version.json && \
pnpm run build
pnpm fetch
ADD . ./
RUN apk add --no-cache git
RUN \
pnpm install --offline && \
echo '{"VERSION": "'$(git describe --tags --always --abbrev=10 | sed 's/-/+/' | sed 's/^v//' | sed 's/-g/-/')'"}' > src/version.json && \
pnpm run build
# Stage 2: copy
FROM nginx:alpine

View file

@ -16,12 +16,10 @@ describe('List View Gantt', () => {
})
it('Shows tasks from the current and next month', () => {
const now = Date.UTC(2022, 8, 25)
cy.clock(now, ['Date'])
const nextMonth = new Date(now)
const now = new Date()
const nextMonth = now
nextMonth.setDate(1)
nextMonth.setMonth(9)
nextMonth.setMonth(now.getMonth() + 1)
cy.visit('/lists/1/gantt')
@ -34,7 +32,7 @@ describe('List View Gantt', () => {
const now = new Date()
const tasks = TaskFactory.create(1, {
start_date: formatISO(now),
end_date: formatISO(now.setDate(now.getDate() + 4)),
end_date: formatISO(now.setDate(now.getDate() + 4))
})
cy.visit('/lists/1/gantt')
@ -65,7 +63,7 @@ describe('List View Gantt', () => {
const now = new Date()
TaskFactory.create(1, {
start_date: formatISO(now),
end_date: formatISO(now.setDate(now.getDate() + 4)),
end_date: formatISO(now.setDate(now.getDate() + 4))
})
cy.visit('/lists/1/gantt')

View file

@ -12,51 +12,15 @@ import {LabelTaskFactory} from '../../factories/label_task'
import {BucketFactory} from '../../factories/bucket'
import '../../support/authenticateUser'
import {TaskAttachmentFactory} from '../../factories/task_attachments'
function addLabelToTaskAndVerify(labelTitle: string) {
cy.get('.task-view .action-buttons .button')
.contains('Add Labels')
.click()
cy.get('.task-view .details.labels-list .multiselect input')
.type(labelTitle)
cy.get('.task-view .details.labels-list .multiselect .search-results')
.children()
.first()
.click()
cy.get('.global-notification', { timeout: 4000 })
.should('contain', 'Success')
cy.get('.task-view .details.labels-list .multiselect .input-wrapper span.tag')
.should('exist')
.should('contain', labelTitle)
}
function uploadAttachmentAndVerify(taskId: number) {
cy.intercept(`${Cypress.env('API_URL')}/tasks/${taskId}/attachments`).as('uploadAttachment')
cy.get('.task-view .action-buttons .button')
.contains('Add Attachments')
.click()
cy.get('input[type=file]', {timeout: 1000})
.selectFile('cypress/fixtures/image.jpg', {force: true}) // The input is not visible, but on purpose
cy.wait('@uploadAttachment')
cy.get('.attachments .attachments .files a.attachment')
.should('exist')
}
describe('Task', () => {
let namespaces
let lists
let buckets
beforeEach(() => {
UserFactory.create(1)
namespaces = NamespaceFactory.create(1)
lists = ListFactory.create(1)
buckets = BucketFactory.create(1, {
list_id: lists[0].id,
})
TaskFactory.truncate()
UserListFactory.truncate()
})
@ -116,7 +80,6 @@ describe('Task', () => {
describe('Task Detail View', () => {
beforeEach(() => {
TaskCommentFactory.truncate()
LabelTaskFactory.truncate()
})
it('Shows all task details', () => {
@ -381,31 +344,21 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`)
addLabelToTaskAndVerify(labels[0].title)
})
it('Can add a label to a task and it shows up on the kanban board afterwards', () => {
const tasks = TaskFactory.create(1, {
id: 1,
list_id: lists[0].id,
bucket_id: buckets[0].id,
})
const labels = LabelFactory.create(1)
LabelTaskFactory.truncate()
cy.visit(`/lists/${lists[0].id}/kanban`)
cy.get('.bucket .task')
.contains(tasks[0].title)
cy.get('.task-view .action-buttons .button')
.contains('Add Labels')
.click()
cy.get('.task-view .details.labels-list .multiselect input')
.type(labels[0].title)
cy.get('.task-view .details.labels-list .multiselect .search-results')
.children()
.first()
.click()
addLabelToTaskAndVerify(labels[0].title)
cy.get('.modal-content .close')
.click()
cy.get('.bucket .task')
.should('contain.text', labels[0].title)
cy.get('.global-notification', { timeout: 4000 })
.should('contain', 'Success')
cy.get('.task-view .details.labels-list .multiselect .input-wrapper span.tag')
.should('exist')
.should('contain', labels[0].title)
})
it('Can remove a label from a task', () => {
@ -464,87 +417,5 @@ describe('Task', () => {
cy.get('.global-notification')
.should('contain', 'Success')
})
it('Can set a priority for a task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Set Priority')
.click()
cy.get('.task-view .columns.details .column')
.contains('Priority')
.get('.select select')
.select('Urgent')
cy.get('.global-notification')
.should('contain', 'Success')
cy.get('.task-view .columns.details .column')
.contains('Priority')
.get('.select select')
.should('have.value', '4')
})
it('Can set the progress for a task', () => {
const tasks = TaskFactory.create(1, {
id: 1,
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Set Progress')
.click()
cy.get('.task-view .columns.details .column')
.contains('Progress')
.get('.select select')
.select('50%')
cy.get('.global-notification')
.should('contain', 'Success')
cy.wait(200)
cy.get('.task-view .columns.details .column')
.contains('Progress')
.get('.select select')
.should('be.visible')
.should('have.value', '0.5')
})
it('Can add an attachment to a task', () => {
TaskAttachmentFactory.truncate()
const tasks = TaskFactory.create(1, {
id: 1,
})
cy.visit(`/tasks/${tasks[0].id}`)
uploadAttachmentAndVerify(tasks[0].id)
})
it('Can add an attachment to a task and see it appearing on kanban', () => {
TaskAttachmentFactory.truncate()
const tasks = TaskFactory.create(1, {
id: 1,
list_id: lists[0].id,
bucket_id: buckets[0].id,
})
const labels = LabelFactory.create(1)
LabelTaskFactory.truncate()
cy.visit(`/lists/${lists[0].id}/kanban`)
cy.get('.bucket .task')
.contains(tasks[0].title)
.click()
uploadAttachmentAndVerify(tasks[0].id)
cy.get('.modal-content .close')
.click()
cy.get('.bucket .task .footer .icon svg.fa-paperclip')
.should('exist')
})
})
})

View file

@ -1,17 +0,0 @@
import {Factory} from '../support/factory'
import {formatISO} from 'date-fns'
export class TaskAttachmentFactory extends Factory {
static table = 'task_attachments'
static factory() {
const now = new Date()
return {
id: '{increment}',
task_id: 1,
file_id: 1,
created: formatISO(now),
}
}
}

View file

@ -1,25 +0,0 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1664753041,
"narHash": "sha256-0ogaD8PaGHluARFeupofvk1Nq9gpVeZdlFM0Kcwguys=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a62844b302507c7531ad68a86cb7aa54704c9cb4",
"type": "github"
},
"original": {
"id": "nixpkgs",
"type": "indirect"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

View file

@ -1,10 +0,0 @@
{
description = "Vikunja frontend dev environment";
outputs = { self, nixpkgs }:
let pkgs = nixpkgs.legacyPackages.x86_64-linux;
in {
defaultPackage.x86_64-linux =
pkgs.mkShell { buildInputs = [ pkgs.nodePackages.pnpm pkgs.cypress ]; };
};
}

View file

@ -1,9 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Vikunja</title>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<meta name="description" content="Vikunja (/vɪˈkuːnjə/) - The to-do app to organize your life.">
<meta name="theme-color" content="#1973ff"/>

View file

@ -24,15 +24,15 @@
"@fortawesome/vue-fontawesome": "3.0.1",
"@github/hotkey": "2.0.1",
"@kyvg/vue3-notification": "2.4.1",
"@sentry/tracing": "7.15.0",
"@sentry/vue": "7.15.0",
"@sentry/tracing": "7.13.0",
"@sentry/vue": "7.13.0",
"@types/is-touch-device": "1.0.0",
"@types/lodash.clonedeep": "4.5.7",
"@types/sortablejs": "1.15.0",
"@vueuse/core": "9.3.0",
"@vueuse/router": "9.3.0",
"@vueuse/core": "9.2.0",
"@vueuse/router": "9.2.0",
"axios": "0.27.2",
"blurhash": "2.0.3",
"blurhash": "2.0.0",
"bulma-css-variables": "0.9.33",
"camel-case": "4.1.2",
"codemirror": "5.65.9",
@ -41,66 +41,63 @@
"easymde": "2.18.0",
"flatpickr": "4.6.13",
"flexsearch": "0.7.21",
"floating-vue": "2.0.0-beta.20",
"highlight.js": "11.6.0",
"is-touch-device": "1.0.1",
"lodash.clonedeep": "4.5.0",
"lodash.debounce": "4.0.8",
"marked": "4.1.1",
"minimist": "1.2.7",
"pinia": "2.0.23",
"marked": "4.1.0",
"minimist": "1.2.6",
"pinia": "2.0.22",
"register-service-worker": "1.7.2",
"snake-case": "3.0.4",
"sortablejs": "1.15.0",
"ufo": "0.8.5",
"vue": "3.2.40",
"vue-advanced-cropper": "2.8.6",
"v-tooltip": "4.0.0-beta.17",
"vue": "3.2.39",
"vue-advanced-cropper": "2.8.3",
"vue-drag-resize": "2.0.3",
"vue-flatpickr-component": "9.0.8",
"vue-flatpickr-component": "9.0.6",
"vue-i18n": "9.2.2",
"vue-router": "4.1.5",
"vuex": "4.0.2",
"workbox-precaching": "6.5.4",
"zhyswan-vuedraggable": "4.1.3"
},
"devDependencies": {
"@4tw/cypress-drag-drop": "2.2.1",
"@cypress/vite-dev-server": "3.3.1",
"@cypress/vite-dev-server": "3.1.1",
"@cypress/vue": "4.2.0",
"@faker-js/faker": "7.5.0",
"@rushstack/eslint-patch": "1.2.0",
"@types/dompurify": "2.3.4",
"@types/flexsearch": "0.7.3",
"@types/lodash.debounce": "4.0.7",
"@types/marked": "4.0.7",
"@types/node": "16.11.65",
"@typescript-eslint/eslint-plugin": "5.40.0",
"@typescript-eslint/parser": "5.40.0",
"@typescript-eslint/eslint-plugin": "5.38.0",
"@typescript-eslint/parser": "5.38.0",
"@vitejs/plugin-legacy": "2.2.0",
"@vitejs/plugin-vue": "3.1.2",
"@vitejs/plugin-vue": "3.1.0",
"@vue/eslint-config-typescript": "11.0.2",
"@vue/test-utils": "2.1.0",
"@vue/test-utils": "2.0.2",
"@vue/tsconfig": "0.1.3",
"autoprefixer": "10.4.12",
"browserslist": "4.21.4",
"caniuse-lite": "1.0.30001418",
"cypress": "10.10.0",
"esbuild": "0.15.10",
"eslint": "8.25.0",
"eslint-plugin-vue": "9.6.0",
"express": "4.18.2",
"happy-dom": "7.4.0",
"netlify-cli": "12.0.7",
"postcss": "8.4.17",
"caniuse-lite": "1.0.30001406",
"cypress": "10.8.0",
"esbuild": "0.15.8",
"eslint": "8.23.1",
"eslint-plugin-vue": "9.5.1",
"express": "4.18.1",
"happy-dom": "6.0.4",
"netlify-cli": "11.8.0",
"postcss": "8.4.16",
"postcss-preset-env": "7.8.2",
"rollup": "3.0.0",
"rollup-plugin-visualizer": "5.8.2",
"rollup": "2.79.1",
"rollup-plugin-visualizer": "5.8.1",
"sass": "1.55.0",
"typescript": "4.8.4",
"vite": "3.1.7",
"typescript": "4.8.3",
"vite": "3.1.3",
"vite-plugin-pwa": "0.13.1",
"vite-svg-loader": "3.6.0",
"vitest": "0.24.1",
"vue-tsc": "1.0.5",
"vitest": "0.23.4",
"vue-tsc": "0.40.13",
"wait-on": "6.0.1",
"workbox-cli": "6.5.4"
},
@ -110,5 +107,5 @@
}
},
"license": "AGPL-3.0-or-later",
"packageManager": "pnpm@7.13.4"
"packageManager": "pnpm@7.12.1"
}

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,7 @@
],
"packageRules": [
{
"matchPackageNames": ["netlify-cli", "happy-dom"],
"matchPackageNames": ["netlify-cli"],
"extends": ["schedule:weekly"]
},
{

View file

@ -18,6 +18,7 @@
import {computed, watch, type Ref} from 'vue'
import {useRouter} from 'vue-router'
import {useRouteQuery} from '@vueuse/router'
import {useStore} from '@/store'
import {useI18n} from 'vue-i18n'
import isTouchDevice from 'is-touch-device'
import {success} from '@/message'
@ -33,20 +34,17 @@ import Ready from '@/components/misc/ready.vue'
import {setLanguage} from './i18n'
import AccountDeleteService from '@/services/accountDelete'
import {useBaseStore} from '@/stores/base'
import {useColorScheme} from '@/composables/useColorScheme'
import {useBodyClass} from '@/composables/useBodyClass'
import {useAuthStore} from './stores/auth'
const baseStore = useBaseStore()
const authStore = useAuthStore()
const store = useStore()
const router = useRouter()
useBodyClass('is-touch', isTouchDevice())
const keyboardShortcutsActive = computed(() => baseStore.keyboardShortcutsActive)
const keyboardShortcutsActive = computed(() => store.state.keyboardShortcutsActive)
const authUser = computed(() => authStore.authUser)
const authLinkShare = computed(() => authStore.authLinkShare)
const authUser = computed(() => store.getters['auth/authUser'])
const authLinkShare = computed(() => store.getters['auth/authLinkShare'])
const {t} = useI18n({useScope: 'global'})
@ -60,7 +58,7 @@ watch(accountDeletionConfirm, async (accountDeletionConfirm) => {
const accountDeletionService = new AccountDeleteService()
await accountDeletionService.confirm(accountDeletionConfirm)
success({message: t('user.deletion.confirmSuccess')})
authStore.refreshUserInfo()
store.dispatch('auth/refreshUserInfo')
}, { immediate: true })
// setup password reset redirect

View file

@ -6,13 +6,13 @@
{{ $t('input.datemathHelp.intro') }}
</p>
<p>
<i18n-t keypath="input.datemathHelp.expression" scope="global">
<i18n-t keypath="input.datemathHelp.expression">
<code>now</code>
<code>||</code>
</i18n-t>
</p>
<p>
<i18n-t keypath="input.datemathHelp.similar" scope="global">
<i18n-t keypath="input.datemathHelp.similar">
<BaseButton
href="https://grafana.com/docs/grafana/latest/dashboards/time-range-controls/"
target="_blank">
@ -99,7 +99,7 @@
<tr>
<td><code>{{ exampleDate }}||+1M/d</code></td>
<td>
<i18n-t keypath="input.datemathHelp.examples.datePlusMonth" scope="global">
<i18n-t keypath="input.datemathHelp.examples.datePlusMonth">
<code>{{ exampleDate }}</code>
</i18n-t>
</td>

View file

@ -71,6 +71,7 @@
<script lang="ts" setup>
import {computed, ref, watch} from 'vue'
import {useStore} from '@/store'
import {useI18n} from 'vue-i18n'
import flatPickr from 'vue-flatpickr-component'
@ -80,9 +81,8 @@ import Popup from '@/components/misc/popup.vue'
import {DATE_RANGES} from '@/components/date/dateRanges'
import BaseButton from '@/components/base/BaseButton.vue'
import DatemathHelp from '@/components/date/datemathHelp.vue'
import {useAuthStore} from '@/stores/auth'
const authStore = useAuthStore()
const store = useStore()
const {t} = useI18n({useScope: 'global'})
const emit = defineEmits(['update:modelValue'])
@ -93,7 +93,7 @@ const props = defineProps({
})
// FIXME: This seems to always contain the default value - that breaks the picker
const weekStart = computed(() => authStore.settings.weekStart ?? 0)
const weekStart = computed<number>(() => store.state.auth.settings.weekStart ?? 0)
const flatPickerConfig = computed(() => ({
altFormat: t('date.altFormatLong'),
altInput: true,
@ -118,13 +118,7 @@ watch(
newValue => {
from.value = newValue.dateFrom
to.value = newValue.dateTo
// Only set the date back to flatpickr when it's an actual date.
// Otherwise flatpickr runs in an endless loop and slows down the browser.
const dateFrom = new Date(from.value)
const dateTo = new Date(to.value)
if (dateTo.getTime() === dateTo.getTime() && dateFrom.getTime() === dateFrom.getTime()) {
flatpickrRange.value = `${from.value} to ${to.value}`
}
flatpickrRange.value = `${from.value} to ${to.value}`
},
)

View file

@ -1,8 +1,8 @@
<template>
<BaseButton
class="menu-show-button"
@click="baseStore.toggleMenu()"
@shortkey="() => baseStore.toggleMenu()"
@click="$store.commit('toggleMenu')"
@shortkey="() => $store.commit('toggleMenu')"
v-shortcut="'Control+e'"
:title="$t('keyboardShortcuts.toggleMenu')"
:aria-label="menuActive ? $t('misc.hideMenu') : $t('misc.showMenu')"
@ -11,12 +11,12 @@
<script setup lang="ts">
import {computed} from 'vue'
import {useBaseStore} from '@/stores/base'
import {useStore} from '@/store'
import BaseButton from '@/components/base/BaseButton.vue'
const baseStore = useBaseStore()
const menuActive = computed(() => baseStore.menuActive)
const store = useStore()
const menuActive = computed(() => store.state.menuActive)
</script>
<style lang="scss" scoped>

View file

@ -70,7 +70,7 @@
{{ $t('navigation.privacy') }}
</dropdown-item>
<dropdown-item
@click="baseStore.setKeyboardShortcutsActive(true)"
@click="$store.commit('keyboardShortcutsActive', true)"
>
{{ $t('keyboardShortcuts.title') }}
</dropdown-item>
@ -92,7 +92,10 @@
<script setup lang="ts">
import {ref, computed, onMounted, nextTick} from 'vue'
import {useStore} from '@/store'
import {useRouter} from 'vue-router'
import {QUICK_ACTIONS_ACTIVE} from '@/store/mutation-types'
import {RIGHTS as Rights} from '@/constants/rights'
import Update from '@/components/home/update.vue'
@ -106,23 +109,16 @@ import MenuButton from '@/components/home/MenuButton.vue'
import {getListTitle} from '@/helpers/getListTitle'
import {useBaseStore} from '@/stores/base'
import {useConfigStore} from '@/stores/config'
import {useAuthStore} from '@/stores/auth'
const store = useStore()
const baseStore = useBaseStore()
const currentList = computed(() => baseStore.currentList)
const background = computed(() => baseStore.background)
const canWriteCurrentList = computed(() => baseStore.currentList.maxRight > Rights.READ)
const menuActive = computed(() => baseStore.menuActive)
const authStore = useAuthStore()
const userInfo = computed(() => authStore.info)
const userAvatar = computed(() => authStore.avatarUrl)
const configStore = useConfigStore()
const imprintUrl = computed(() => configStore.legal.imprintUrl)
const privacyPolicyUrl = computed(() => configStore.legal.privacyPolicyUrl)
const userInfo = computed(() => store.state.auth.info)
const userAvatar = computed(() => store.state.auth.avatarUrl)
const currentList = computed(() => store.state.currentList)
const background = computed(() => store.state.background)
const imprintUrl = computed(() => store.state.config.legal.imprintUrl)
const privacyPolicyUrl = computed(() => store.state.config.legal.privacyPolicyUrl)
const canWriteCurrentList = computed(() => store.state.currentList.maxRight > Rights.READ)
const menuActive = computed(() => store.state.menuActive)
const usernameDropdown = ref()
const listTitle = ref()
@ -136,12 +132,15 @@ onMounted(async () => {
listTitle.value.style.setProperty('--nav-username-width', `${usernameWidth}px`)
})
const router = useRouter()
function logout() {
authStore.logout()
store.dispatch('auth/logout')
router.push({name: 'user.login'})
}
function openQuickActions() {
baseStore.setQuickActionsActive(true)
store.commit(QUICK_ACTIONS_ACTIVE, true)
}
</script>

View file

@ -2,7 +2,7 @@
<div class="content-auth">
<BaseButton
v-if="menuActive"
@click="baseStore.setMenuActive(false)"
@click="$store.commit('menuActive', false)"
class="menu-hide-button d-print-none"
>
<icon icon="times"/>
@ -26,7 +26,7 @@
>
<BaseButton
v-if="menuActive"
@click="baseStore.setMenuActive(false)"
@click="$store.commit('menuActive', false)"
class="mobile-overlay d-print-none"
/>
@ -60,34 +60,82 @@
</template>
<script lang="ts" setup>
import {watch, computed} from 'vue'
import {useRoute} from 'vue-router'
import {watch, computed, shallowRef, watchEffect, type VNode, h} from 'vue'
import {useStore} from '@/store'
import {useRoute, useRouter} from 'vue-router'
import {useEventListener} from '@vueuse/core'
import {CURRENT_LIST, KEYBOARD_SHORTCUTS_ACTIVE, MENU_ACTIVE} from '@/store/mutation-types'
import {useLabelStore} from '@/stores/labels'
import Navigation from '@/components/home/navigation.vue'
import QuickActions from '@/components/quick-actions/quick-actions.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import {useBaseStore} from '@/stores/base'
import {useLabelStore} from '@/stores/labels'
function useRouteWithModal() {
const router = useRouter()
const route = useRoute()
const backdropView = computed(() => route.fullPath && window.history.state.backdropView)
import {useRouteWithModal} from '@/composables/useRouteWithModal'
import {useRenewTokenOnFocus} from '@/composables/useRenewTokenOnFocus'
const routeWithModal = computed(() => {
return backdropView.value
? router.resolve(backdropView.value)
: route
})
const currentModal = shallowRef<VNode>()
watchEffect(() => {
if (!backdropView.value) {
currentModal.value = undefined
return
}
// logic from vue-router
// https://github.com/vuejs/vue-router-next/blob/798cab0d1e21f9b4d45a2bd12b840d2c7415f38a/src/RouterView.ts#L125
const routePropsOption = route.matched[0]?.props.default
const routeProps = routePropsOption
? routePropsOption === true
? route.params
: typeof routePropsOption === 'function'
? routePropsOption(route)
: routePropsOption
: null
currentModal.value = h(
route.matched[0]?.components.default,
routeProps,
)
})
function closeModal() {
const historyState = computed(() => route.fullPath && window.history.state)
if (historyState.value) {
router.back()
} else {
const backdropRoute = historyState.value?.backdropView && router.resolve(historyState.value.backdropView)
router.push(backdropRoute)
}
}
return {routeWithModal, currentModal, closeModal}
}
const {routeWithModal, currentModal, closeModal} = useRouteWithModal()
const baseStore = useBaseStore()
const background = computed(() => baseStore.background)
const blurHash = computed(() => baseStore.blurHash)
const menuActive = computed(() => baseStore.menuActive)
const store = useStore()
const background = computed(() => store.state.background)
const blurHash = computed(() => store.state.blurHash)
const menuActive = computed(() => store.state.menuActive)
function showKeyboardShortcuts() {
baseStore.setKeyboardShortcutsActive(true)
store.commit(KEYBOARD_SHORTCUTS_ACTIVE, true)
}
const route = useRoute()
// hide menu on mobile
watch(() => route.fullPath, () => window.innerWidth < 769 && baseStore.setMenuActive(false))
watch(() => route.fullPath, () => window.innerWidth < 769 && store.commit(MENU_ACTIVE, false))
// FIXME: this is really error prone
// Reset the current list highlight in menu if the current route is not list related.
@ -109,14 +157,47 @@ watch(() => route.name as string, (routeName) => {
routeName.startsWith('user.settings')
)
) {
baseStore.handleSetCurrentList({list: null})
store.dispatch(CURRENT_LIST, {list: null})
}
})
// TODO: Reset the title if the page component does not set one itself
useRenewTokenOnFocus()
function useRenewTokenOnFocus() {
const router = useRouter()
const userInfo = computed(() => store.state.auth.info)
const authenticated = computed(() => store.state.auth.authenticated)
// Try renewing the token every time vikunja is loaded initially
// (When opening the browser the focus event is not fired)
store.dispatch('auth/renewToken')
// Check if the token is still valid if the window gets focus again to maybe renew it
useEventListener('focus', () => {
if (!authenticated.value) {
return
}
const expiresIn = (userInfo.value !== null ? userInfo.value.exp : 0) - +new Date() / 1000
// If the token expiry is negative, it is already expired and we have no choice but to redirect
// the user to the login page
if (expiresIn < 0) {
store.dispatch('auth/checkAuth')
router.push({name: 'user.login'})
return
}
// Check if the token is valid for less than 60 hours and renew if thats the case
if (expiresIn < 60 * 3600) {
store.dispatch('auth/renewToken')
console.debug('renewed token')
}
})
}
useRenewTokenOnFocus()
const labelStore = useLabelStore()
labelStore.loadAllLabels()
</script>

View file

@ -24,16 +24,15 @@
<script lang="ts" setup>
import {computed} from 'vue'
import {useBaseStore} from '@/stores/base'
import {useStore} from '@/store'
import Logo from '@/components/home/Logo.vue'
import PoweredByLink from './PoweredByLink.vue'
const baseStore = useBaseStore()
const currentList = computed(() => baseStore.currentList)
const background = computed(() => baseStore.background)
const logoVisible = computed(() => baseStore.logoVisible)
const store = useStore()
const currentList = computed(() => store.state.currentList)
const background = computed(() => store.state.background)
const logoVisible = computed(() => store.state.logoVisible)
</script>
<style lang="scss" scoped>

View file

@ -141,6 +141,7 @@
<script setup lang="ts">
import {ref, computed, onMounted, onBeforeMount} from 'vue'
import {useStore} from '@/store'
import draggable from 'zhyswan-vuedraggable'
import type {SortableEvent} from 'sortablejs'
@ -150,6 +151,7 @@ import NamespaceSettingsDropdown from '@/components/namespace/namespace-settings
import PoweredByLink from '@/components/home/PoweredByLink.vue'
import Logo from '@/components/home/Logo.vue'
import {MENU_ACTIVE} from '@/store/mutation-types'
import {calculateItemPosition} from '@/helpers/calculateItemPosition'
import {getNamespaceTitle} from '@/helpers/getNamespaceTitle'
import {getListTitle} from '@/helpers/getListTitle'
@ -157,8 +159,6 @@ import {useEventListener} from '@vueuse/core'
import type {IList} from '@/modelTypes/IList'
import type {INamespace} from '@/modelTypes/INamespace'
import ColorBubble from '@/components/misc/colorBubble.vue'
import {useBaseStore} from '@/stores/base'
import {useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces'
@ -168,10 +168,10 @@ const dragOptions = {
ghostClass: 'ghost',
}
const baseStore = useBaseStore()
const store = useStore()
const namespaceStore = useNamespaceStore()
const currentList = computed(() => baseStore.currentList)
const menuActive = computed(() => baseStore.menuActive)
const currentList = computed(() => store.state.currentList)
const menuActive = computed(() => store.state.menuActive)
const loading = computed(() => namespaceStore.isLoading)
@ -202,7 +202,7 @@ const listStore = useListStore()
function resize() {
// Hide the menu by default on mobile
baseStore.setMenuActive(window.innerWidth >= 770)
store.commit(MENU_ACTIVE, window.innerWidth >= 770)
}
function toggleLists(namespaceId: INamespace['id']) {
@ -262,7 +262,7 @@ async function saveListPosition(e: SortableEvent) {
)
try {
// create a copy of the list in order to not violate pinia manipulation
// create a copy of the list in order to not violate vuex mutations
await listStore.updateList({
...list,
position,

View file

@ -34,8 +34,9 @@
</div>
</template>
<script setup lang="ts">
import {computed, ref, toRef, watch} from 'vue'
<script lang="ts">
import {defineComponent} from 'vue'
import {createRandomID} from '@/helpers/randomId'
const DEFAULT_COLORS = [
@ -47,57 +48,66 @@ const DEFAULT_COLORS = [
'#00db60',
]
const color = ref('')
const lastChangeTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
const defaultColors = ref(DEFAULT_COLORS)
const colorListID = ref(createRandomID())
const props = defineProps({
modelValue: {
type: String,
required: true,
export default defineComponent({
name: 'colorPicker',
data() {
return {
color: '',
lastChangeTimeout: null,
defaultColors: DEFAULT_COLORS,
colorListID: createRandomID(),
}
},
menuPosition: {
type: String,
default: 'top',
props: {
modelValue: {
type: String,
required: true,
},
menuPosition: {
type: String,
default: 'top',
},
},
emits: ['update:modelValue'],
watch: {
modelValue: {
handler(modelValue) {
this.color = modelValue
},
immediate: true,
},
color() {
this.update()
},
},
computed: {
isEmpty() {
return this.color === '#000000' || this.color === ''
},
},
methods: {
update(force = false) {
if(this.isEmpty && !force) {
return
}
if (this.lastChangeTimeout !== null) {
clearTimeout(this.lastChangeTimeout)
}
this.lastChangeTimeout = setTimeout(() => {
this.$emit('update:modelValue', this.color)
}, 500)
},
reset() {
// FIXME: I havn't found a way to make it clear to the user the color war reset.
// Not sure if verte is capable of this - it does not show the change when setting this.color = ''
this.color = ''
this.update(true)
},
},
})
const emit = defineEmits(['update:modelValue'])
const modelValue = toRef(props, 'modelValue')
watch(
modelValue,
(newValue) => {
color.value = newValue
},
{immediate: true},
)
watch(color, () => update())
const isEmpty = computed(() => color.value === '#000000' || color.value === '')
function update(force = false) {
if(isEmpty.value && !force) {
return
}
if (lastChangeTimeout.value !== null) {
clearTimeout(lastChangeTimeout.value)
}
lastChangeTimeout.value = setTimeout(() => {
emit('update:modelValue', color.value)
}, 500)
}
function reset() {
// FIXME: I havn't found a way to make it clear to the user the color war reset.
// Not sure if verte is capable of this - it does not show the change when setting this.color = ''
color.value = ''
update(true)
}
</script>
<style lang="scss" scoped>

View file

@ -1,8 +1,10 @@
<template>
<div class="datepicker">
<BaseButton @click.stop="toggleDatePopup" class="show" :disabled="disabled || undefined">
{{ date === null ? chooseDateLabel : formatDateShort(date) }}
</BaseButton>
<slot :date="date" :openPopup="toggleDatePopup">
<BaseButton @click.stop="toggleDatePopup" class="show" :disabled="disabled || undefined">
{{ date === null ? chooseDateLabel : formatDateShort(date) }}
</BaseButton>
</slot>
<transition name="fade">
<div v-if="show" class="datepicker-popup" ref="datepickerPopup">
@ -88,10 +90,12 @@
</div>
</template>
<script setup lang="ts">
import {ref, onMounted, onBeforeUnmount, toRef, watch, computed, type PropType} from 'vue'
<script lang="ts">
import {defineComponent} from 'vue'
import flatPickr from 'vue-flatpickr-component'
import 'flatpickr/dist/flatpickr.css'
import {i18n} from '@/i18n'
import BaseButton from '@/components/base/BaseButton.vue'
@ -100,140 +104,141 @@ import {calculateDayInterval} from '@/helpers/time/calculateDayInterval'
import {calculateNearestHours} from '@/helpers/time/calculateNearestHours'
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import {createDateFromString} from '@/helpers/time/createDateFromString'
import {useAuthStore} from '@/stores/auth'
import {useI18n} from 'vue-i18n'
const props = defineProps({
modelValue: {
type: [Date, null, String] as PropType<Date | null | string>,
validator: prop => prop instanceof Date || prop === null || typeof prop === 'string',
default: null,
export default defineComponent({
name: 'datepicker',
data() {
return {
date: null,
show: false,
changed: false,
}
},
chooseDateLabel: {
type: String,
default() {
const {t} = useI18n({useScope: 'global'})
return t('input.datepicker.chooseDate')
components: {
flatPickr,
BaseButton,
},
props: {
modelValue: {
validator: prop => prop instanceof Date || prop === null || typeof prop === 'string',
},
chooseDateLabel: {
type: String,
default() {
return i18n.global.t('input.datepicker.chooseDate')
},
},
disabled: {
type: Boolean,
default: false,
},
},
disabled: {
type: Boolean,
default: false,
emits: ['update:modelValue', 'close', 'close-on-change'],
mounted() {
document.addEventListener('click', this.hideDatePopup)
},
beforeUnmount() {
document.removeEventListener('click', this.hideDatePopup)
},
watch: {
modelValue: {
handler: 'setDateValue',
immediate: true,
},
},
computed: {
flatPickerConfig() {
return {
altFormat: this.$t('date.altFormatLong'),
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
inline: true,
locale: {
firstDayOfWeek: this.$store.state.auth.settings.weekStart,
},
}
},
// Since flatpickr dates are strings, we need to convert them to native date objects.
// To make that work, we need a separate variable since flatpickr does not have a change event.
flatPickrDate: {
set(newValue) {
this.date = createDateFromString(newValue)
this.updateData()
},
get() {
if (!this.date) {
return ''
}
return formatDate(this.date, 'yyy-LL-dd H:mm')
},
},
},
methods: {
formatDateShort,
setDateValue(newVal) {
if (newVal === null) {
this.date = null
return
}
this.date = createDateFromString(newVal)
},
updateData() {
this.changed = true
this.$emit('update:modelValue', this.date)
},
toggleDatePopup() {
if (this.disabled) {
return
}
this.show = !this.show
},
hideDatePopup(e) {
if (this.show) {
closeWhenClickedOutside(e, this.$refs.datepickerPopup, this.close)
}
},
close() {
// Kind of dirty, but the timeout allows us to enter a time and click on "confirm" without
// having to click on another input field before it is actually used.
setTimeout(() => {
this.show = false
this.$emit('close', this.changed)
if (this.changed) {
this.changed = false
this.$emit('close-on-change', this.changed)
}
}, 200)
},
setDate(date) {
if (this.date === null) {
this.date = new Date()
}
const interval = calculateDayInterval(date)
const newDate = new Date()
newDate.setDate(newDate.getDate() + interval)
newDate.setHours(calculateNearestHours(newDate))
newDate.setMinutes(0)
newDate.setSeconds(0)
this.date = newDate
this.flatPickrDate = newDate
this.updateData()
},
getDayIntervalFromString(date) {
return calculateDayInterval(date)
},
getWeekdayFromStringInterval(date) {
const interval = calculateDayInterval(date)
const newDate = new Date()
newDate.setDate(newDate.getDate() + interval)
return formatDate(newDate, 'E')
},
},
})
const emit = defineEmits(['update:modelValue', 'close', 'close-on-change'])
const {t} = useI18n({useScope: 'global'})
const date = ref<Date | null>()
const show = ref(false)
const changed = ref(false)
onMounted(() => document.addEventListener('click', hideDatePopup))
onBeforeUnmount(() =>document.removeEventListener('click', hideDatePopup))
const modelValue = toRef(props, 'modelValue')
watch(
modelValue,
setDateValue,
{immediate: true},
)
const authStore = useAuthStore()
const weekStart = computed(() => authStore.settings.weekStart)
const flatPickerConfig = computed(() => ({
altFormat: t('date.altFormatLong'),
altInput: true,
dateFormat: 'Y-m-d H:i',
enableTime: true,
time_24hr: true,
inline: true,
locale: {
firstDayOfWeek: weekStart.value,
},
}))
// Since flatpickr dates are strings, we need to convert them to native date objects.
// To make that work, we need a separate variable since flatpickr does not have a change event.
const flatPickrDate = computed({
set(newValue: string | Date) {
date.value = createDateFromString(newValue)
updateData()
},
get() {
if (!date.value) {
return ''
}
return formatDate(date.value, 'yyy-LL-dd H:mm')
},
})
function setDateValue(dateString: string | Date | null) {
if (dateString === null) {
date.value = null
return
}
date.value = createDateFromString(dateString)
}
function updateData() {
changed.value = true
emit('update:modelValue', date.value)
}
function toggleDatePopup() {
if (props.disabled) {
return
}
show.value = !show.value
}
const datepickerPopup = ref<HTMLElement | null>(null)
function hideDatePopup(e) {
if (show.value) {
closeWhenClickedOutside(e, datepickerPopup.value, close)
}
}
function close() {
// Kind of dirty, but the timeout allows us to enter a time and click on "confirm" without
// having to click on another input field before it is actually used.
setTimeout(() => {
show.value = false
emit('close', changed.value)
if (changed.value) {
changed.value = false
emit('close-on-change', changed.value)
}
}, 200)
}
function setDate(dateString: string) {
if (date.value === null) {
date.value = new Date()
}
const interval = calculateDayInterval(dateString)
const newDate = new Date()
newDate.setDate(newDate.getDate() + interval)
newDate.setHours(calculateNearestHours(newDate))
newDate.setMinutes(0)
newDate.setSeconds(0)
date.value = newDate
flatPickrDate.value = newDate
updateData()
}
function getWeekdayFromStringInterval(dateString: string) {
const interval = calculateDayInterval(dateString)
const newDate = new Date()
newDate.setDate(newDate.getDate() + interval)
return formatDate(newDate, 'E')
}
</script>
<style lang="scss" scoped>

View file

@ -4,7 +4,7 @@
<vue-easymde
:configs="config"
@change="() => bubble()"
@change="bubble"
@update:modelValue="handleInput"
class="content"
v-if="isEditActive"
@ -66,245 +66,245 @@
</div>
</template>
<script setup lang="ts">
import {computed, nextTick, onMounted, ref, toRefs, watch} from 'vue'
<script lang="ts">
import {defineComponent} from 'vue'
import VueEasymde from './vue-easymde.vue'
import {marked} from 'marked'
import DOMPurify from 'dompurify'
import {setupMarkdownRenderer} from '@/helpers/markdownRenderer'
import {createEasyMDEConfig} from './editorConfig'
import AttachmentModel from '@/models/attachment'
import AttachmentService from '@/services/attachment'
import {setupMarkdownRenderer} from '@/helpers/markdownRenderer'
import {findCheckboxesInText} from '@/helpers/checklistFromText'
import AttachmentModel from '../../models/attachment'
import AttachmentService from '../../services/attachment'
import {findCheckboxesInText} from '../../helpers/checklistFromText'
import {createRandomID} from '@/helpers/randomId'
import BaseButton from '@/components/base/BaseButton.vue'
import ButtonLink from '@/components/misc/ButtonLink.vue'
import type { IAttachment } from '@/modelTypes/IAttachment'
import type { ITask } from '@/modelTypes/ITask'
const props = defineProps({
modelValue: {
type: String,
default: '',
export default defineComponent({
name: 'editor',
components: {
VueEasymde,
BaseButton,
ButtonLink,
},
placeholder: {
type: String,
default: '',
props: {
modelValue: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
uploadEnabled: {
type: Boolean,
default: false,
},
uploadCallback: {
type: Function,
},
hasPreview: {
type: Boolean,
default: true,
},
previewIsDefault: {
type: Boolean,
default: true,
},
isEditEnabled: {
default: true,
},
bottomActions: {
default: () => [],
},
emptyText: {
type: String,
default: '',
},
showSave: {
type: Boolean,
default: false,
},
// If a key is passed the editor will go in "edit" mode when the key is pressed.
// Disabled if an empty string is passed.
editShortcut: {
type: String,
default: '',
},
},
uploadEnabled: {
type: Boolean,
default: false,
emits: ['update:modelValue'],
computed: {
showPreviewText() {
return this.isPreviewActive && this.text === '' && this.emptyText !== ''
},
showEditButton() {
return !this.isEditActive && this.text !== ''
},
},
uploadCallback: {
type: Function,
},
hasPreview: {
type: Boolean,
default: true,
},
previewIsDefault: {
type: Boolean,
default: true,
},
isEditEnabled: {
default: true,
},
bottomActions: {
default: () => [],
},
emptyText: {
type: String,
default: '',
},
showSave: {
type: Boolean,
default: false,
},
// If a key is passed the editor will go in "edit" mode when the key is pressed.
// Disabled if an empty string is passed.
editShortcut: {
type: String,
default: '',
},
})
data() {
return {
text: '',
changeTimeout: null,
isEditActive: false,
isPreviewActive: true,
const emit = defineEmits(['update:modelValue'])
const text = ref('')
const changeTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
const isEditActive = ref(false)
const isPreviewActive = ref(true)
const showPreviewText = computed(() => isPreviewActive.value && text.value === '' && props.emptyText !== '')
const showEditButton = computed(() => !isEditActive.value && text.value !== '')
const preview = ref('')
const attachmentService = new AttachmentService()
type CacheKey = `${ITask['id']}-${IAttachment['id']}`
const loadedAttachments = ref<{[key: CacheKey]: string}>({})
const config = ref(createEasyMDEConfig({
placeholder: props.placeholder,
uploadImage: props.uploadEnabled,
imageUploadFunction: props.uploadCallback,
}))
const checkboxId = ref(createRandomID())
const {modelValue} = toRefs(props)
watch(
modelValue,
async (value) => {
text.value = value
await nextTick()
renderPreview()
preview: '',
attachmentService: null,
loadedAttachments: {},
config: createEasyMDEConfig({
placeholder: this.placeholder,
uploadImage: this.uploadEnabled,
imageUploadFunction: this.uploadCallback,
}),
checkboxId: createRandomID(),
}
},
)
watch: {
modelValue(modelValue) {
this.text = modelValue
this.$nextTick(this.renderPreview)
},
text(newVal, oldVal) {
// Only bubble the new value if it actually changed, but not if the component just got mounted and the text changed from the outside.
if (oldVal === '' && this.text === this.modelValue) {
return
}
this.bubble()
},
},
mounted() {
if (this.modelValue !== '') {
this.text = this.modelValue
}
watch(
text,
(newVal, oldVal) => {
// Only bubble the new value if it actually changed, but not if the component just got mounted and the text changed from the outside.
if (oldVal === '' && text.value === modelValue.value) {
if (this.previewIsDefault && this.hasPreview) {
this.$nextTick(this.renderPreview)
return
}
bubble()
this.isPreviewActive = false
this.isEditActive = true
},
)
methods: {
// This gets triggered when only pasting content into the editor.
// A change event would not get generated by that, an input event does.
// Therefore, we're using this handler to catch paste events.
// But because this also gets triggered when typing into the editor, we give
// it a higher timeout to make the timouts cancel each other in that case so
// that in the end, only one change event is triggered to the outside per change.
handleInput(val) {
// Don't bubble if the text is up to date
if (val === this.text) {
return
}
this.text = val
this.bubble(1000)
},
bubble(timeout = 500) {
if (this.changeTimeout !== null) {
clearTimeout(this.changeTimeout)
}
onMounted(() => {
if (modelValue.value !== '') {
text.value = modelValue.value
}
this.changeTimeout = setTimeout(() => {
this.$emit('update:modelValue', this.text)
}, timeout)
},
replaceAt(str, index, replacement) {
return str.substr(0, index) + replacement + str.substr(index + replacement.length)
},
findNthIndex(str, n) {
const checkboxes = findCheckboxesInText(str)
return checkboxes[n]
},
renderPreview() {
setupMarkdownRenderer(this.checkboxId)
if (props.previewIsDefault && props.hasPreview) {
nextTick(() => renderPreview())
return
}
this.preview = DOMPurify.sanitize(marked(this.text), {ADD_ATTR: ['target']})
isPreviewActive.value = false
isEditActive.value = true
})
// Since the render function is synchronous, we can't do async http requests in it.
// Therefore, we can't resolve the blob url at (markdown) compile time.
// To work around this, we modify the url after rendering it in the vue component.
// We're doing the whole thing in the next tick to ensure the image elements are available in the
// dom tree. If we're calling this right after setting this.preview it could be the images were
// not already made available.
// Some docs at https://stackoverflow.com/q/62865160/10924593
this.$nextTick(async () => {
const attachmentImage = document.getElementsByClassName('attachment-image')
if (attachmentImage) {
for (const img of attachmentImage) {
// The url is something like /tasks/<id>/attachments/<id>
const parts = img.dataset.src.substr(window.API_URL.length + 1).split('/')
const taskId = parseInt(parts[1])
const attachmentId = parseInt(parts[3])
const cacheKey = `${taskId}-${attachmentId}`
if (typeof this.loadedAttachments[cacheKey] !== 'undefined') {
img.src = this.loadedAttachments[cacheKey]
continue
}
// This gets triggered when only pasting content into the editor.
// A change event would not get generated by that, an input event does.
// Therefore, we're using this handler to catch paste events.
// But because this also gets triggered when typing into the editor, we give
// it a higher timeout to make the timouts cancel each other in that case so
// that in the end, only one change event is triggered to the outside per change.
function handleInput(val: string) {
// Don't bubble if the text is up to date
if (val === text.value) {
return
}
const attachment = new AttachmentModel({taskId: taskId, id: attachmentId})
text.value = val
bubble(1000)
}
if (this.attachmentService === null) {
this.attachmentService = new AttachmentService()
}
function bubble(timeout = 500) {
if (changeTimeout.value !== null) {
clearTimeout(changeTimeout.value)
}
changeTimeout.value = setTimeout(() => {
emit('update:modelValue', text.value)
}, timeout)
}
function replaceAt(str: string, index: number, replacement: string) {
return str.slice(0, index) + replacement + str.slice(index + replacement.length)
}
function findNthIndex(str: string, n: number) {
const checkboxes = findCheckboxesInText(str)
return checkboxes[n]
}
function renderPreview() {
setupMarkdownRenderer(checkboxId.value)
preview.value = DOMPurify.sanitize(marked(text.value), {ADD_ATTR: ['target']})
// Since the render function is synchronous, we can't do async http requests in it.
// Therefore, we can't resolve the blob url at (markdown) compile time.
// To work around this, we modify the url after rendering it in the vue component.
// We're doing the whole thing in the next tick to ensure the image elements are available in the
// dom tree. If we're calling this right after setting this.preview it could be the images were
// not already made available.
// Some docs at https://stackoverflow.com/q/62865160/10924593
nextTick().then(async () => {
const attachmentImage = document.querySelectorAll<HTMLImageElement>('.attachment-image')
if (attachmentImage) {
Array.from(attachmentImage).forEach(async (img) => {
// The url is something like /tasks/<id>/attachments/<id>
const parts = img.dataset.src?.slice(window.API_URL.length + 1).split('/')
const taskId = Number(parts[1])
const attachmentId = Number(parts[3])
const cacheKey: CacheKey = `${taskId}-${attachmentId}`
if (typeof loadedAttachments.value[cacheKey] !== 'undefined') {
img.src = loadedAttachments.value[cacheKey]
return
const url = await this.attachmentService.getBlobUrl(attachment)
img.src = url
this.loadedAttachments[cacheKey] = url
}
}
const attachment = new AttachmentModel({taskId: taskId, id: attachmentId})
const url = await attachmentService.getBlobUrl(attachment)
img.src = url
loadedAttachments.value[cacheKey] = url
const textCheckbox = document.getElementsByClassName(`text-checkbox-${this.checkboxId}`)
if (textCheckbox) {
for (const check of textCheckbox) {
check.removeEventListener('change', this.handleCheckboxClick)
check.addEventListener('change', this.handleCheckboxClick)
check.parentElement.classList.add('has-checkbox')
}
}
})
}
},
handleCheckboxClick(e) {
// Find the original markdown checkbox this is targeting
const checked = e.target.checked
const numMarkdownCheck = parseInt(e.target.dataset.checkboxNum)
const textCheckbox = document.querySelectorAll<HTMLInputElement>(`.text-checkbox-${checkboxId.value}`)
if (textCheckbox) {
Array.from(textCheckbox).forEach(check => {
check.removeEventListener('change', handleCheckboxClick)
check.addEventListener('change', handleCheckboxClick)
check.parentElement?.classList.add('has-checkbox')
})
}
})
}
const index = this.findNthIndex(this.text, numMarkdownCheck)
if (index < 0 || typeof index === 'undefined') {
console.debug('no index found')
return
}
console.debug(index, this.text.substr(index, 9))
function handleCheckboxClick(e: Event) {
// Find the original markdown checkbox this is targeting
const checked = (e.target as HTMLInputElement).checked
const numMarkdownCheck = Number((e.target as HTMLInputElement).dataset.checkboxNum)
const listPrefix = this.text.substr(index, 1)
const index = findNthIndex(text.value, numMarkdownCheck)
if (index < 0 || typeof index === 'undefined') {
console.debug('no index found')
return
}
console.debug(index, text.value.slice(index, 9))
const listPrefix = text.value.slice(index, 1)
text.value = replaceAt(text.value, index, `${listPrefix} ${checked ? '[x]' : '[ ]'} `)
bubble()
renderPreview()
}
function toggleEdit() {
if (isEditActive.value) {
isPreviewActive.value = true
isEditActive.value = false
renderPreview()
bubble(0) // save instantly
} else {
isPreviewActive.value = false
isEditActive.value = true
}
}
if (checked) {
this.text = this.replaceAt(this.text, index, `${listPrefix} [x] `)
} else {
this.text = this.replaceAt(this.text, index, `${listPrefix} [ ] `)
}
this.bubble()
this.renderPreview()
},
toggleEdit() {
if (this.isEditActive) {
this.isPreviewActive = true
this.isEditActive = false
this.renderPreview()
this.bubble(0) // save instantly
} else {
this.isPreviewActive = false
this.isEditActive = true
}
},
},
})
</script>
<style lang="scss">

View file

@ -4,9 +4,8 @@
:checked="checked"
:disabled="disabled || undefined"
:id="checkBoxId"
@change="(event: Event) => updateData((event.target as HTMLInputElement).checked)"
type="checkbox"
/>
@change="(event) => updateData(event.target.checked)"
type="checkbox"/>
<label :for="checkBoxId" class="check">
<svg height="18px" viewBox="0 0 18 18" width="18px">
<path
@ -20,42 +19,47 @@
</div>
</template>
<script setup lang="ts">
import {ref, toRef, watch} from 'vue'
<script lang="ts">
import {defineComponent} from 'vue'
import {createRandomID} from '@/helpers/randomId'
const checked = ref(false)
const checkBoxId = `fancycheckbox_${createRandomID()}`
const props = defineProps({
modelValue: {
type: Boolean,
required: false,
export default defineComponent({
name: 'fancycheckbox',
data() {
return {
checked: false,
checkBoxId: `fancycheckbox_${createRandomID()}`,
}
},
disabled: {
type: Boolean,
required: false,
default: false,
props: {
modelValue: {
required: false,
},
disabled: {
type: Boolean,
required: false,
default: false,
},
},
emits: ['update:modelValue', 'change'],
watch: {
modelValue: {
handler(modelValue) {
this.checked = modelValue
},
immediate: true,
},
},
methods: {
updateData(checked: boolean) {
this.checked = checked
this.$emit('update:modelValue', checked)
this.$emit('change', checked)
},
},
})
const emit = defineEmits(['update:modelValue', 'change'])
const modelValue = toRef(props, 'modelValue')
watch(
modelValue,
newValue => {
checked.value = newValue
},
{immediate: true},
)
function updateData(newChecked: boolean) {
checked.value = newChecked
emit('update:modelValue', newChecked)
emit('change', newChecked)
}
</script>

View file

@ -39,11 +39,11 @@
<div class="search-results" :class="{'search-results-inline': inline}" v-if="searchResultsVisible">
<BaseButton
class="is-fullwidth"
v-for="(data, index) in filteredSearchResults"
:key="index"
:ref="(el) => setResult(el, index)"
@keydown.up.prevent="() => preSelect(index - 1)"
@keydown.down.prevent="() => preSelect(index + 1)"
v-for="(data, key) in filteredSearchResults"
:key="key"
:ref="`result-${key}`"
@keydown.up.prevent="() => preSelect(key - 1)"
@keydown.down.prevent="() => preSelect(key + 1)"
@click.prevent.stop="() => select(data)"
>
<span>
@ -59,7 +59,7 @@
<BaseButton
v-if="creatableAvailable"
class="is-fullwidth"
:ref="(el) => setResult(el, filteredSearchResults.length)"
:ref="`result-${filteredSearchResults.length}`"
@keydown.up.prevent="() => preSelect(filteredSearchResults.length - 1)"
@keydown.down.prevent="() => preSelect(filteredSearchResults.length + 1)"
@keyup.enter.prevent="create"
@ -82,10 +82,9 @@
</div>
</template>
<script setup lang="ts">
import {computed, onBeforeUnmount, onMounted, ref, toRefs, watch, type ComponentPublicInstance, type PropType} from 'vue'
import {useI18n} from 'vue-i18n'
<script lang="ts">
import {defineComponent} from 'vue'
import {i18n} from '@/i18n'
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import BaseButton from '@/components/base/BaseButton.vue'
@ -99,298 +98,294 @@ function elementInResults(elem: string | any, label: string, query: string): boo
return elem === query
}
const props = defineProps({
// When true, shows a loading spinner
loading: {
type: Boolean,
default: false,
export default defineComponent({
name: 'multiselect',
components: {
BaseButton,
},
// The placeholder of the search input
placeholder: {
type: String,
default: '',
data() {
return {
query: '',
searchTimeout: null,
localLoading: false,
showSearchResults: false,
internalValue: null,
}
},
// The search results where the @search listener needs to put the results into
searchResults: {
type: Array as PropType<{[id: string]: any}>,
default: () => [],
},
// The name of the property of the searched object to show the user.
// If empty the component will show all raw data of an entry.
label: {
type: String,
default: '',
},
// The object with the value, updated every time an entry is selected.
modelValue: {
default: null,
},
// If true, will provide an "add this as a new value" entry which fires an @create event when clicking on it.
creatable: {
type: Boolean,
default: false,
},
// The text shown next to the new value option.
createPlaceholder: {
type: String,
default() {
const {t} = useI18n({useScope: 'global'})
return t('input.multiselect.createPlaceholder')
props: {
// When true, shows a loading spinner
loading: {
type: Boolean,
default() {
return false
},
},
// The placeholder of the search input
placeholder: {
type: String,
default() {
return ''
},
},
// The search results where the @search listener needs to put the results into
searchResults: {
type: Array,
default() {
return []
},
},
// The name of the property of the searched object to show the user.
// If empty the component will show all raw data of an entry.
label: {
type: String,
default() {
return ''
},
},
// The object with the value, updated every time an entry is selected.
modelValue: {
default() {
return null
},
},
// If true, will provide an "add this as a new value" entry which fires an @create event when clicking on it.
creatable: {
type: Boolean,
default: false,
},
// The text shown next to the new value option.
createPlaceholder: {
type: String,
default() {
return i18n.global.t('input.multiselect.createPlaceholder')
},
},
// The text shown next to an option.
selectPlaceholder: {
type: String,
default() {
return i18n.global.t('input.multiselect.selectPlaceholder')
},
},
// If true, allows for selecting multiple items. v-model will be an array with all selected values in that case.
multiple: {
type: Boolean,
default: false,
},
// If true, displays the search results inline instead of using a dropdown.
inline: {
type: Boolean,
default: false,
},
// If true, shows search results when no query is specified.
showEmpty: {
type: Boolean,
default: true,
},
// The delay in ms after which the search event will be fired. Used to avoid hitting the network on every keystroke.
searchDelay: {
type: Number,
default() {
return 200
},
},
closeAfterSelect: {
type: Boolean,
default: true,
},
},
// The text shown next to an option.
selectPlaceholder: {
type: String,
default() {
const {t} = useI18n({useScope: 'global'})
return t('input.multiselect.selectPlaceholder')
/**
* Available events:
* @search: Triggered every time the search query input changes
* @select: Triggered every time an option from the search results is selected. Also triggers a change in v-model.
* @create: If nothing or no exact match was found and `creatable` is true, this event is triggered with the current value of the search query.
* @remove: If `multiple` is enabled, this will be fired every time an item is removed from the array of selected items.
*/
emits: ['update:modelValue', 'search', 'select', 'create', 'remove'],
mounted() {
document.addEventListener('click', this.hideSearchResultsHandler)
},
beforeUnmount() {
document.removeEventListener('click', this.hideSearchResultsHandler)
},
watch: {
modelValue: {
handler(value) {
this.setSelectedObject(value)
},
immediate: true,
deep: true,
},
},
// If true, allows for selecting multiple items. v-model will be an array with all selected values in that case.
multiple: {
type: Boolean,
default: false,
computed: {
searchResultsVisible() {
if (this.query === '' && !this.showEmpty) {
return false
}
return this.showSearchResults && (
(this.filteredSearchResults.length > 0) ||
(this.creatable && this.query !== '')
)
},
creatableAvailable() {
const hasResult = this.filteredSearchResults.some(elem => elementInResults(elem, this.label, this.query))
const hasQueryAlreadyAdded = Array.isArray(this.internalValue) && this.internalValue.some(elem => elementInResults(elem, this.label, this.query))
return this.creatable
&& this.query !== ''
&& !(hasResult || hasQueryAlreadyAdded)
},
filteredSearchResults() {
if (this.multiple && this.internalValue !== null && Array.isArray(this.internalValue)) {
return this.searchResults.filter(item => !this.internalValue.some(e => e === item))
}
return this.searchResults
},
hasMultiple() {
return this.multiple && Array.isArray(this.internalValue) && this.internalValue.length > 0
},
},
// If true, displays the search results inline instead of using a dropdown.
inline: {
type: Boolean,
default: false,
},
// If true, shows search results when no query is specified.
showEmpty: {
type: Boolean,
default: true,
},
// The delay in ms after which the search event will be fired. Used to avoid hitting the network on every keystroke.
searchDelay: {
type: Number,
default: 200,
},
closeAfterSelect: {
type: Boolean,
default: true,
methods: {
// Searching will be triggered with a 200ms delay to avoid searching on every keyup event.
search() {
// Updating the query with a binding does not work on mobile for some reason,
// getting the value manual does.
this.query = this.$refs.searchInput.value
if (this.searchTimeout !== null) {
clearTimeout(this.searchTimeout)
this.searchTimeout = null
}
this.localLoading = true
this.searchTimeout = setTimeout(() => {
this.$emit('search', this.query)
setTimeout(() => {
this.localLoading = false
}, 100) // The duration of the loading timeout of the services
this.showSearchResults = true
}, this.searchDelay)
},
hideSearchResultsHandler(e) {
closeWhenClickedOutside(e, this.$refs.multiselectRoot, this.closeSearchResults)
},
closeSearchResults() {
this.showSearchResults = false
},
handleFocus() {
// We need the timeout to avoid the hideSearchResultsHandler hiding the search results right after the input
// is focused. That would lead to flickering pre-loaded search results and hiding them right after showing.
setTimeout(() => {
this.showSearchResults = true
}, 10)
},
select(object) {
if (this.multiple) {
if (this.internalValue === null) {
this.internalValue = []
}
this.internalValue.push(object)
} else {
this.internalValue = object
}
this.$emit('update:modelValue', this.internalValue)
this.$emit('select', object)
this.setSelectedObject(object)
if (this.closeAfterSelect && this.filteredSearchResults.length > 0 && !this.creatableAvailable) {
this.closeSearchResults()
}
},
setSelectedObject(object, resetOnly = false) {
this.internalValue = object
// We assume we're getting an array when multiple is enabled and can therefore leave the query
// value etc as it is
if (this.multiple) {
this.query = ''
return
}
if (object === null) {
this.query = ''
return
}
if (resetOnly) {
return
}
this.query = this.label !== '' ? object[this.label] : object
},
preSelect(index) {
if (index < 0) {
this.$refs.searchInput.focus()
return
}
const elems = this.$refs[`result-${index}`]
if (typeof elems === 'undefined' || elems.length === 0) {
return
}
if (Array.isArray(elems)) {
elems[0].focus()
return
}
elems.focus()
},
create() {
if (this.query === '') {
return
}
this.$emit('create', this.query)
this.setSelectedObject(this.query, true)
this.closeSearchResults()
},
createOrSelectOnEnter() {
if (!this.creatableAvailable && this.searchResults.length === 1) {
this.select(this.searchResults[0])
return
}
if (!this.creatableAvailable) {
// Check if there's an exact match for our search term
const exactMatch = this.filteredSearchResults.find(elem => elementInResults(elem, this.label, this.query))
if(exactMatch) {
this.select(exactMatch)
}
return
}
this.create()
},
remove(item) {
for (const ind in this.internalValue) {
if (this.internalValue[ind] === item) {
this.internalValue.splice(ind, 1)
break
}
}
this.$emit('update:modelValue', this.internalValue)
this.$emit('remove', item)
},
focus() {
this.$refs.searchInput.focus()
},
},
})
const emit = defineEmits<{
(e: 'update:modelValue', value: null): void
// @search: Triggered every time the search query input changes
(e: 'search', query: string): void
// @select: Triggered every time an option from the search results is selected. Also triggers a change in v-model.
(e: 'select', value: null): void
// @create: If nothing or no exact match was found and `creatable` is true, this event is triggered with the current value of the search query.
(e: 'create', query: string): void
// @remove: If `multiple` is enabled, this will be fired every time an item is removed from the array of selected items.
(e: 'remove', value: null): void
}>()
const query = ref('')
const searchTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
const localLoading = ref(false)
const showSearchResults = ref(false)
const internalValue = ref<string | {[key: string]: any} | any[] | null>(null)
onMounted(() => document.addEventListener('click', hideSearchResultsHandler))
onBeforeUnmount(() => document.removeEventListener('click', hideSearchResultsHandler))
const {modelValue, searchResults} = toRefs(props)
watch(
modelValue,
(value) => setSelectedObject(value),
{
immediate: true,
deep: true,
},
)
const searchResultsVisible = computed(() => {
if (query.value === '' && !props.showEmpty) {
return false
}
return showSearchResults.value && (
(filteredSearchResults.value.length > 0) ||
(props.creatable && query.value !== '')
)
})
const creatableAvailable = computed(() => {
const hasResult = filteredSearchResults.value.some((elem: any) => elementInResults(elem, props.label, query.value))
const hasQueryAlreadyAdded = Array.isArray(internalValue.value) && internalValue.value.some(elem => elementInResults(elem, props.label, query.value))
return props.creatable
&& query.value !== ''
&& !(hasResult || hasQueryAlreadyAdded)
})
const filteredSearchResults = computed(() => {
const currentInternal = internalValue.value
if (props.multiple && currentInternal !== null && Array.isArray(currentInternal)) {
return searchResults.value.filter((item: any) => !currentInternal.some(e => e === item))
}
return searchResults.value
})
const hasMultiple = computed(() => {
return props.multiple && Array.isArray(internalValue.value) && internalValue.value.length > 0
})
const searchInput = ref<HTMLInputElement | null>(null)
// Searching will be triggered with a 200ms delay to avoid searching on every keyup event.
function search() {
// Updating the query with a binding does not work on mobile for some reason,
// getting the value manual does.
query.value = searchInput.value?.value || ''
if (searchTimeout.value !== null) {
clearTimeout(searchTimeout.value)
searchTimeout.value = null
}
localLoading.value = true
searchTimeout.value = setTimeout(() => {
emit('search', query.value)
setTimeout(() => {
localLoading.value = false
}, 100) // The duration of the loading timeout of the services
showSearchResults.value = true
}, props.searchDelay)
}
const multiselectRoot = ref<HTMLElement | null>(null)
function hideSearchResultsHandler(e: MouseEvent) {
closeWhenClickedOutside(e, multiselectRoot.value, closeSearchResults)
}
function closeSearchResults() {
showSearchResults.value = false
}
function handleFocus() {
// We need the timeout to avoid the hideSearchResultsHandler hiding the search results right after the input
// is focused. That would lead to flickering pre-loaded search results and hiding them right after showing.
setTimeout(() => {
showSearchResults.value = true
}, 10)
}
function select(object: {[key: string]: any}) {
if (props.multiple) {
if (internalValue.value === null) {
internalValue.value = []
}
(internalValue.value as any[]).push(object)
} else {
internalValue.value = object
}
emit('update:modelValue', internalValue.value)
emit('select', object)
setSelectedObject(object)
if (props.closeAfterSelect && filteredSearchResults.value.length > 0 && !creatableAvailable.value) {
closeSearchResults()
}
}
function setSelectedObject(object: string | {[id: string]: any} | null, resetOnly = false) {
internalValue.value = object
// We assume we're getting an array when multiple is enabled and can therefore leave the query
// value etc as it is
if (props.multiple) {
query.value = ''
return
}
if (object === null) {
query.value = ''
return
}
if (resetOnly) {
return
}
query.value = props.label !== '' ? object[props.label] : object
}
const results = ref<(Element | ComponentPublicInstance)[]>([])
function setResult(el: Element | ComponentPublicInstance | null, index: number) {
if (el === null) {
delete results.value[index]
} else {
results.value[index] = el
}
}
function preSelect(index: number) {
if (index < 0) {
searchInput.value?.focus()
return
}
const elems = results.value[index]
if (typeof elems === 'undefined' || elems.length === 0) {
return
}
if (Array.isArray(elems)) {
elems[0].focus()
return
}
elems.focus()
}
function create() {
if (query.value === '') {
return
}
emit('create', query.value)
setSelectedObject(query.value, true)
closeSearchResults()
}
function createOrSelectOnEnter() {
if (!creatableAvailable.value && searchResults.value.length === 1) {
select(searchResults.value[0])
return
}
if (!creatableAvailable.value) {
// Check if there's an exact match for our search term
const exactMatch = filteredSearchResults.value.find((elem: any) => elementInResults(elem, props.label, query.value))
if(exactMatch) {
select(exactMatch)
}
return
}
create()
}
function remove(item: any) {
for (const ind in internalValue.value) {
if (internalValue.value[ind] === item) {
internalValue.value.splice(ind, 1)
break
}
}
emit('update:modelValue', internalValue.value)
emit('remove', item)
}
function focus() {
searchInput.value?.focus()
}
</script>
<style lang="scss" scoped>

View file

@ -1,6 +1,6 @@
<template>
<dropdown>
<template v-if="isSavedFilter(list)">
<template v-if="isSavedFilter">
<dropdown-item
:to="{ name: 'filter.settings.edit', params: { listId: list.id } }"
icon="pen"
@ -55,13 +55,13 @@
>
{{ $t('menu.archive') }}
</dropdown-item>
<Subscription
<task-subscription
class="has-no-shadow"
:is-button="false"
entity="list"
:entity-id="list.id"
:model-value="list.subscription"
@update:model-value="setSubscriptionInStore"
@update:model-value="sub => subscription = sub"
type="dropdown"
/>
<dropdown-item
@ -77,16 +77,14 @@
<script setup lang="ts">
import {ref, computed, watchEffect, type PropType} from 'vue'
import {useStore} from '@/store'
import {isSavedFilter} from '@/helpers/savedFilter'
import {getSavedFilterIdFromListId} from '@/helpers/savedFilter'
import Dropdown from '@/components/misc/dropdown.vue'
import DropdownItem from '@/components/misc/dropdown-item.vue'
import Subscription from '@/components/misc/subscription.vue'
import TaskSubscription from '@/components/misc/subscription.vue'
import type {IList} from '@/modelTypes/IList'
import type {ISubscription} from '@/modelTypes/ISubscription'
import {useConfigStore} from '@/stores/config'
import {useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces'
const props = defineProps({
list: {
@ -95,23 +93,12 @@ const props = defineProps({
},
})
const listStore = useListStore()
const namespaceStore = useNamespaceStore()
const subscription = ref<ISubscription | null>(null)
watchEffect(() => {
subscription.value = props.list.subscription ?? null
})
const configStore = useConfigStore()
const backgroundsEnabled = computed(() => configStore.enabledBackgroundProviders?.length > 0)
function setSubscriptionInStore(sub: ISubscription) {
subscription.value = sub
const updatedList = {
...props.list,
subscription: sub,
}
listStore.setList(updatedList)
namespaceStore.setListInNamespaceById(updatedList)
}
const store = useStore()
const backgroundsEnabled = computed(() => store.state.config.enabledBackgroundProviders?.length > 0)
const isSavedFilter = computed(() => getSavedFilterIdFromListId(props.list.id) > 0)
</script>

View file

@ -29,60 +29,72 @@
</modal>
</template>
<script setup lang="ts">
import {computed, ref, watch} from 'vue'
<script lang="ts">
import {defineComponent, ref} from 'vue'
import Filters from '@/components/list/partials/filters.vue'
import {getDefaultParams} from '@/composables/taskList'
const props = defineProps({
modelValue: {
required: true,
export default defineComponent({
name: 'filter-popup',
components: {
Filters,
},
props: {
modelValue: {
required: true,
},
},
emits: ['update:modelValue'],
computed: {
value: {
get() {
return this.modelValue
},
set(value) {
this.$emit('update:modelValue', value)
},
},
hasFilters() {
// this.value also contains the page parameter which we don't want to include in filters
// eslint-disable-next-line no-unused-vars
const {filter_by, filter_value, filter_comparator, filter_concat, s} = this.value
const def = {...getDefaultParams()}
const params = {filter_by, filter_value, filter_comparator, filter_concat, s}
const defaultParams = {
filter_by: def.filter_by,
filter_value: def.filter_value,
filter_comparator: def.filter_comparator,
filter_concat: def.filter_concat,
s: s ? def.s : undefined,
}
return JSON.stringify(params) !== JSON.stringify(defaultParams)
},
},
watch: {
modelValue: {
handler(value) {
this.value = value
},
immediate: true,
},
},
setup() {
const modalOpen = ref(false)
return {
modalOpen,
}
},
methods: {
clearFilters() {
this.value = {...getDefaultParams()}
},
},
})
const emit = defineEmits(['update:modelValue'])
const value = computed({
get() {
return props.modelValue
},
set(value) {
emit('update:modelValue', value)
},
})
watch(
() => props.modelValue,
(modelValue) => {
value.value = modelValue
},
{immediate: true},
)
const hasFilters = computed(() => {
// this.value also contains the page parameter which we don't want to include in filters
// eslint-disable-next-line no-unused-vars
const {filter_by, filter_value, filter_comparator, filter_concat, s} = value.value
const def = {...getDefaultParams()}
const params = {filter_by, filter_value, filter_comparator, filter_concat, s}
const defaultParams = {
filter_by: def.filter_by,
filter_value: def.filter_value,
filter_comparator: def.filter_comparator,
filter_concat: def.filter_concat,
s: s ? def.s : undefined,
}
return JSON.stringify(params) !== JSON.stringify(defaultParams)
})
const modalOpen = ref(false)
function clearFilters() {
value.value = {...getDefaultParams()}
}
</script>
<style scoped lang="scss">

View file

@ -1,25 +1,21 @@
<template>
<card class="filters has-overflow" :title="hasTitle ? $t('filters.title') : ''">
<div class="field is-flex is-flex-direction-column">
<fancycheckbox
v-model="params.filter_include_nulls"
@update:model-value="change()"
>
<div class="field">
<fancycheckbox v-model="params.filter_include_nulls">
{{ $t('filters.attributes.includeNulls') }}
</fancycheckbox>
<fancycheckbox
v-model="filters.requireAllFilters"
@update:model-value="setFilterConcat()"
@change="setFilterConcat()"
>
{{ $t('filters.attributes.requireAll') }}
</fancycheckbox>
<fancycheckbox v-model="filters.done" @update:model-value="setDoneFilter">
<fancycheckbox @change="setDoneFilter" v-model="filters.done">
{{ $t('filters.attributes.showDoneTasks') }}
</fancycheckbox>
<fancycheckbox
v-if="!$route.name.includes('list.kanban') || !$route.name.includes('list.table')"
v-model="sortAlphabetically"
@update:model-value="change()"
>
{{ $t('filters.attributes.sortAlphabetically') }}
</fancycheckbox>
@ -46,7 +42,7 @@
/>
<fancycheckbox
v-model="filters.usePriority"
@update:model-value="setPriority"
@change="setPriority"
>
{{ $t('filters.attributes.enablePriority') }}
</fancycheckbox>
@ -62,7 +58,7 @@
/>
<fancycheckbox
v-model="filters.usePercentDone"
@update:model-value="setPercentDoneFilter"
@change="setPercentDoneFilter"
>
{{ $t('filters.attributes.enablePercentDone') }}
</fancycheckbox>
@ -210,7 +206,6 @@ import ListService from '@/services/list'
import NamespaceService from '@/services/namespace'
import EditLabels from '@/components/tasks/partials/editLabels.vue'
import {dateIsValid, formatISO} from '@/helpers/time/formatDate'
import {objectToSnakeCase} from '@/helpers/case'
import {getDefaultParams} from '@/composables/taskList'
import {camelCase} from 'camel-case'
@ -392,14 +387,7 @@ export default defineComponent({
this.params.filter_value.push(dateTo)
}
this.filters[camelCase(filterName)] = {
// Passing the dates as string values avoids an endless loop between values changing
// in the datepicker (bubbling up to here) and changing here and bubbling down to the
// datepicker (because there's a new date instance every time this function gets called).
// See https://kolaente.dev/vikunja/frontend/issues/2384
dateFrom: dateIsValid(dateFrom) ? formatISO(dateFrom) : dateFrom,
dateTo: dateIsValid(dateTo) ? formatISO(dateTo) : dateTo,
}
this.filters[camelCase(filterName)] = {dateFrom, dateTo}
this.change()
return
}
@ -524,7 +512,7 @@ export default defineComponent({
// This is not the most ideal solution because it prevents a re-population when filters are changed
// from the outside. It is still fine because we're not changing them from the outside, other than
// loading them initially.
if (this[kind].length > 0) {
if(this[kind].length > 0) {
return
}
@ -546,7 +534,6 @@ export default defineComponent({
} else {
this.params.filter_concat = 'or'
}
this.change()
},
setPriority() {
this.setSingleValueFilter('priority', 'priority', 'usePriority')
@ -586,7 +573,7 @@ export default defineComponent({
return
}
const ids = []
let ids = []
this[kind].forEach(u => {
ids.push(kind === 'users' ? u.username : u.id)
})
@ -621,7 +608,7 @@ export default defineComponent({
return
}
const labelIDs = []
let labelIDs = []
this.labels.forEach(u => {
labelIDs.push(u.id)
})

View file

@ -1,8 +1,8 @@
<template>
<router-link
:class="{
'has-light-text': !colorIsDark(list.hexColor) || background !== null,
'has-background': blurHashUrl !== '' || background !== null,
'has-light-text': !colorIsDark(list.hexColor),
'has-background': blurHashUrl !== ''
}"
:style="{
'background-color': list.hexColor,
@ -101,7 +101,7 @@ const listStore = useListStore()
overflow: hidden;
&.has-light-text .title {
color: var(--grey-100) !important;
color: var(--grey-100);
}
&.has-background,

View file

@ -23,14 +23,17 @@
</div>
</div>
<div class="api-url-info" v-else>
<i18n-t keypath="apiConfig.use" scope="global">
<i18n-t keypath="apiConfig.use">
<span class="url" v-tooltip="apiUrl"> {{ apiDomain }} </span>
</i18n-t>
<br/>
<ButtonLink class="api-config__change-button" @click="() => (configureApi = true)">{{ $t('apiConfig.change') }}</ButtonLink>
</div>
<message variant="danger" v-if="errorMsg !== ''" class="mt-2">
<message variant="success" v-if="successMsg !== '' && errorMsg === ''" class="mt-2">
{{ successMsg }}
</message>
<message variant="danger" v-if="errorMsg !== '' && successMsg === ''" class="mt-2">
{{ errorMsg }}
</message>
</div>
@ -71,6 +74,7 @@ watch(() => props.configureOpen, (value) => {
const {t} = useI18n({useScope: 'global'})
const errorMsg = ref('')
const successMsg = ref('')
async function setApiUrl() {
if (apiUrl.value === '') {
@ -95,6 +99,7 @@ async function setApiUrl() {
emit('foundApi', apiUrl.value)
} catch (e) {
// Still not found, url is still invalid
successMsg.value = ''
errorMsg.value = t('apiConfig.error', {domain: apiDomain.value})
}
}

View file

@ -9,7 +9,7 @@
@close="$router.back()"
:loading="loading"
>
<div class="p-4">
<div :class="{'p-4': padding}">
<slot />
</div>
@ -72,6 +72,10 @@ defineProps({
type: Boolean,
default: false,
},
padding: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['create', 'primary', 'tertiary'])

View file

@ -1,6 +1,6 @@
<template>
<message variant="danger">
<i18n-t keypath="loadingError.failed" scope="global">
<i18n-t keypath="loadingError.failed">
<ButtonLink @click="reload">{{ $t('loadingError.tryAgain') }}</ButtonLink>
<ButtonLink href="https://vikunja.io/contact/">{{ $t('loadingError.contact') }}</ButtonLink>
</i18n-t>

View file

@ -33,15 +33,18 @@
</template>
<script lang="ts" setup>
import {useBaseStore} from '@/stores/base'
import {useStore} from '@/store'
import Shortcut from '@/components/misc/shortcut.vue'
import Message from '@/components/misc/message.vue'
import {KEYBOARD_SHORTCUTS_ACTIVE} from '@/store/mutation-types'
import {KEYBOARD_SHORTCUTS as shortcuts} from './shortcuts'
const store = useStore()
function close() {
useBaseStore().setKeyboardShortcutsActive(false)
store.commit(KEYBOARD_SHORTCUTS_ACTIVE, false)
}
</script>

View file

@ -8,13 +8,14 @@
<script lang="ts" setup>
import {computed} from 'vue'
import {useStore} from '@/store'
import BaseButton from '@/components/base/BaseButton.vue'
import {useConfigStore} from '@/stores/config'
const configStore = useConfigStore()
const store = useStore()
const imprintUrl = computed(() => configStore.legal.imprintUrl)
const privacyPolicyUrl = computed(() => configStore.legal.privacyPolicyUrl)
const imprintUrl = computed(() => store.state.config.legal.imprintUrl)
const privacyPolicyUrl = computed(() => store.state.config.legal.privacyPolicyUrl)
</script>
<style lang="scss" scoped>

View file

@ -71,9 +71,10 @@ export default {
<script lang="ts" setup>
import BaseButton from '@/components/base/BaseButton.vue'
import {ref, useAttrs, watchEffect} from 'vue'
import {onUnmounted, ref, useAttrs, watch} from 'vue'
import {useScrollLock} from '@vueuse/core'
const props = withDefaults(defineProps<{
enabled?: boolean,
overflow?: boolean,
@ -93,9 +94,14 @@ const attrs = useAttrs()
const modal = ref<HTMLElement | null>(null)
const scrollLock = useScrollLock(modal)
watchEffect(() => {
scrollLock.value = props.enabled
})
watch(
() => props.enabled,
enabled => {
scrollLock.value = enabled
},
)
onUnmounted(() => scrollLock.value = false)
</script>
<style lang="scss" scoped>

View file

@ -26,23 +26,22 @@
</template>
<script lang="ts" setup>
import {computed} from 'vue'
import {useRoute} from 'vue-router'
import {useI18n} from 'vue-i18n'
import Logo from '@/components/home/Logo.vue'
import Message from '@/components/misc/message.vue'
import Legal from '@/components/misc/legal.vue'
import ApiConfig from '@/components/misc/api-config.vue'
import {useStore} from '@/store'
import {computed} from 'vue'
import {useRoute} from 'vue-router'
import {useI18n} from 'vue-i18n'
import {useTitle} from '@/composables/useTitle'
import {useConfigStore} from '@/stores/config'
const configStore = useConfigStore()
const motd = computed(() => configStore.motd)
const route = useRoute()
const store = useStore()
const {t} = useI18n({useScope: 'global'})
const motd = computed(() => store.state.config.motd)
const title = computed(() => t(route.meta?.title as string || ''))
useTitle(() => title.value)
</script>

View file

@ -42,7 +42,7 @@
<script lang="ts" setup>
import {ref, computed} from 'vue'
import {useRouter, useRoute} from 'vue-router'
import {useStore} from '@/store'
import Logo from '@/assets/logo.svg?component'
import ApiConfig from '@/components/misc/api-config.vue'
@ -52,14 +52,13 @@ import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue'
import {ERROR_NO_API_URL} from '@/helpers/checkAndSetApiUrl'
import {useOnline} from '@/composables/useOnline'
import {useRouter, useRoute} from 'vue-router'
import {getAuthForRoute} from '@/router'
import {useBaseStore} from '@/stores/base'
const router = useRouter()
const route = useRoute()
const baseStore = useBaseStore()
const store = useStore()
const ready = ref(false)
const online = useOnline()
@ -69,14 +68,14 @@ const showLoading = computed(() => !ready.value && error.value === '')
async function load() {
try {
await baseStore.loadApp()
await store.dispatch('loadApp')
const redirectTo = getAuthForRoute(route)
if (typeof redirectTo !== 'undefined') {
await router.push(redirectTo)
}
ready.value = true
} catch (e: unknown) {
error.value = String(e)
} catch (e: any) {
error.value = e
}
}

View file

@ -69,38 +69,17 @@ const emit = defineEmits(['update:modelValue'])
const subscriptionService = shallowRef(new SubscriptionService())
const {t} = useI18n({useScope: 'global'})
const tooltipText = computed(() => {
if (disabled.value) {
if (props.entity === 'list' && subscriptionEntity.value === 'namespace') {
return t('task.subscription.subscribedListThroughParentNamespace')
}
if (props.entity === 'task' && subscriptionEntity.value === 'namespace') {
return t('task.subscription.subscribedTaskThroughParentNamespace')
}
if (props.entity === 'task' && subscriptionEntity.value === 'list') {
return t('task.subscription.subscribedTaskThroughParentList')
}
return ''
return t('task.subscription.subscribedThroughParent', {
entity: props.entity,
parent: subscriptionEntity.value,
})
}
switch (props.entity) {
case 'namespace':
return props.modelValue !== null ?
t('task.subscription.subscribedNamespace') :
t('task.subscription.notSubscribedNamespace')
case 'list':
return props.modelValue !== null ?
t('task.subscription.subscribedList') :
t('task.subscription.notSubscribedList')
case 'task':
return props.modelValue !== null ?
t('task.subscription.subscribedTask') :
t('task.subscription.notSubscribedTask')
}
return ''
return props.modelValue !== null ?
t('task.subscription.subscribed', {entity: props.entity}) :
t('task.subscription.notSubscribed', {entity: props.entity})
})
const buttonText = computed(() => props.modelValue ? t('task.subscription.unsubscribe') : t('task.subscription.subscribe'))
@ -126,20 +105,7 @@ async function subscribe() {
})
await subscriptionService.value.create(subscription)
emit('update:modelValue', subscription)
let message = ''
switch (props.entity) {
case 'namespace':
message = t('task.subscription.subscribeSuccessNamespace')
break
case 'list':
message = t('task.subscription.subscribeSuccessList')
break
case 'task':
message = t('task.subscription.subscribeSuccessTask')
break
}
success({message})
success({message: t('task.subscription.subscribeSuccess', {entity: props.entity})})
}
async function unsubscribe() {
@ -149,19 +115,6 @@ async function unsubscribe() {
})
await subscriptionService.value.delete(subscription)
emit('update:modelValue', null)
let message = ''
switch (props.entity) {
case 'namespace':
message = t('task.subscription.unsubscribeSuccessNamespace')
break
case 'list':
message = t('task.subscription.unsubscribeSuccessList')
break
case 'task':
message = t('task.subscription.unsubscribeSuccessTask')
break
}
success({message})
success({message: t('task.subscription.unsubscribeSuccess', {entity: props.entity})})
}
</script>

View file

@ -2,39 +2,34 @@
<div :class="{'is-inline': isInline}" class="user">
<img
:height="avatarSize"
:src="getAvatarUrl(user, avatarSize)"
:src="user.getAvatarUrl(avatarSize)"
:width="avatarSize"
alt=""
class="avatar"
v-tooltip="getDisplayName(user)"/>
<span class="username" v-if="showUsername">{{ getDisplayName(user) }}</span>
v-tooltip="user.getDisplayName()"/>
<span class="username" v-if="showUsername">{{ user.getDisplayName() }}</span>
</div>
</template>
<script lang="ts" setup>
import type {PropType} from 'vue'
import {getAvatarUrl, getDisplayName} from '@/models/user'
import type {IUser} from '@/modelTypes/IUser'
defineProps({
user: {
type: Object as PropType<IUser>,
required: true,
type: Object,
},
showUsername: {
type: Boolean,
required: false,
type: Boolean,
default: true,
},
avatarSize: {
type: Number,
required: false,
type: Number,
default: 50,
},
isInline: {
type: Boolean,
required: false,
type: Boolean,
default: false,
},
})

View file

@ -33,13 +33,14 @@
>
{{ $t('menu.archive') }}
</dropdown-item>
<Subscription
<task-subscription
v-if="subscription"
class="has-no-shadow"
:is-button="false"
entity="namespace"
:entity-id="namespace.id"
:model-value="subscription"
@update:model-value="setSubscriptionInStore"
@update:model-value="sub => subscription = sub"
type="dropdown"
/>
<dropdown-item
@ -58,10 +59,9 @@ import {ref, onMounted, type PropType} from 'vue'
import Dropdown from '@/components/misc/dropdown.vue'
import DropdownItem from '@/components/misc/dropdown-item.vue'
import Subscription from '@/components/misc/subscription.vue'
import TaskSubscription from '@/components/misc/subscription.vue'
import type {INamespace} from '@/modelTypes/INamespace'
import type {ISubscription} from '@/modelTypes/ISubscription'
import {useNamespaceStore} from '@/stores/namespaces'
const props = defineProps({
namespace: {
@ -70,20 +70,8 @@ const props = defineProps({
},
})
const namespaceStore = useNamespaceStore()
const subscription = ref<ISubscription | null>(null)
onMounted(() => {
subscription.value = props.namespace.subscription
})
function setSubscriptionInStore(sub: ISubscription) {
subscription.value = sub
namespaceStore.setNamespaces([
{
...props.namespace,
subscription: sub,
},
])
}
</script>

View file

@ -24,7 +24,7 @@
<div class="detail">
<div>
<span class="has-text-weight-bold mr-1" v-if="n.notification.doer">
{{ getDisplayName(n.notification.doer) }}
{{ n.notification.doer.getDisplayName() }}
</span>
<BaseButton @click="() => to(n, index)()">
{{ n.toText(userInfo) }}
@ -48,20 +48,19 @@
<script lang="ts" setup>
import {computed, onMounted, onUnmounted, ref} from 'vue'
import {useRouter} from 'vue-router'
import NotificationService from '@/services/notification'
import BaseButton from '@/components/base/BaseButton.vue'
import User from '@/components/misc/user.vue'
import { NOTIFICATION_NAMES as names, type INotification} from '@/modelTypes/INotification'
import {closeWhenClickedOutside} from '@/helpers/closeWhenClickedOutside'
import {useStore} from '@/store'
import {useRouter} from 'vue-router'
import {formatDateLong, formatDateSince} from '@/helpers/time/formatDate'
import {getDisplayName} from '@/models/user'
import {useAuthStore} from '@/stores/auth'
const LOAD_NOTIFICATIONS_INTERVAL = 10000
const authStore = useAuthStore()
const store = useStore()
const router = useRouter()
const allNotifications = ref<INotification[]>([])
@ -74,7 +73,7 @@ const unreadNotifications = computed(() => {
const notifications = computed(() => {
return allNotifications.value ? allNotifications.value.filter(n => n.name !== '') : []
})
const userInfo = computed(() => authStore.info)
const userInfo = computed(() => store.state.auth.info)
let interval: number

View file

@ -61,6 +61,7 @@ import TeamService from '@/services/team'
import NamespaceModel from '@/models/namespace'
import TeamModel from '@/models/team'
import {CURRENT_LIST, LOADING, LOADING_MODULE, QUICK_ACTIONS_ACTIVE} from '@/store/mutation-types'
import ListModel from '@/models/list'
import BaseButton from '@/components/base/BaseButton.vue'
@ -69,12 +70,7 @@ import {getHistory} from '@/modules/listHistory'
import {parseTaskText, PrefixMode} from '@/modules/parseTaskText'
import {getQuickAddMagicMode} from '@/helpers/quickAddMagicMode'
import {PREFIXES} from '@/modules/parseTaskText'
import {useBaseStore} from '@/stores/base'
import {useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces'
import {useLabelStore} from '@/stores/labels'
import {useTaskStore} from '@/stores/tasks'
const TYPE_LIST = 'list'
const TYPE_TASK = 'task'
@ -113,10 +109,8 @@ export default defineComponent({
},
computed: {
active() {
const active = useBaseStore().quickActionsActive
const active = this.$store.state[QUICK_ACTIONS_ACTIVE]
if (!active) {
// FIXME: computeds shouldn't have side effects.
// create a watcher instead
this.reset()
}
return active
@ -145,7 +139,7 @@ export default defineComponent({
}
if (typeof ncache[l.namespaceId] === 'undefined') {
ncache[l.namespaceId] = useNamespaceStore().getNamespaceById(l.namespaceId)
ncache[l.namespaceId] = this.$store.getters['namespaces/getNamespaceById'](l.namespaceId)
}
return !ncache[l.namespaceId].isArchived
@ -184,14 +178,15 @@ export default defineComponent({
},
loading() {
return this.taskService.loading ||
useNamespaceStore().isLoading || useListStore().isLoading ||
(this.$store.state[LOADING] && this.$store.state[LOADING_MODULE] === 'namespaces') ||
(this.$store.state[LOADING] && this.$store.state[LOADING_MODULE] === 'lists') ||
this.teamService.loading
},
placeholder() {
if (this.selectedCmd !== null) {
switch (this.selectedCmd.action) {
case CMD_NEW_TASK:
return this.$t('quickActions.newTask')
return this.$t('task.create.title')
case CMD_NEW_LIST:
return this.$t('quickActions.newList')
case CMD_NEW_NAMESPACE:
@ -211,7 +206,7 @@ export default defineComponent({
case CMD_NEW_TASK:
return this.$t('quickActions.createTask', {title: this.currentList.title})
case CMD_NEW_LIST:
namespace = useNamespaceStore().getNamespaceById(this.currentList.namespaceId)
namespace = this.$store.getters['namespaces/getNamespaceById'](this.currentList.namespaceId)
return this.$t('quickActions.createList', {title: namespace.title})
}
}
@ -221,8 +216,7 @@ export default defineComponent({
return this.$t('quickActions.hint', prefixes)
},
currentList() {
const currentList = useBaseStore().currentList
return Object.keys(currentList).length === 0 ? null : currentList
return Object.keys(this.$store.state[CURRENT_LIST]).length === 0 ? null : this.$store.state[CURRENT_LIST]
},
availableCmds() {
const cmds = []
@ -315,7 +309,7 @@ export default defineComponent({
}
if (labels.length > 0) {
const labelIds = useLabelStore().getLabelsByExactTitles(labels).map(l => l.id)
const labelIds = this.$store.getters['labels/getLabelsByExactTitles'](labels).map(l => l.id)
if (labelIds.length > 0) {
params.filter_by.push('labels')
params.filter_value.push(labelIds.join())
@ -363,7 +357,7 @@ export default defineComponent({
}, 150)
},
closeQuickActions() {
useBaseStore().setQuickActionsActive(false)
this.$store.commit(QUICK_ACTIONS_ACTIVE, false)
},
doAction(type, item) {
switch (type) {
@ -416,8 +410,7 @@ export default defineComponent({
return
}
const taskStore = useTaskStore()
const task = await taskStore.createNewTask({
const task = await this.$store.dispatch('tasks/createNewTask', {
title: this.query,
listId: this.currentList.id,
})
@ -443,7 +436,7 @@ export default defineComponent({
async newNamespace() {
const newNamespace = new NamespaceModel({title: this.query})
await useNamespaceStore().createNamespace(newNamespace)
await this.$store.dispatch('namespaces/createNamespace', newNamespace)
this.$message.success({message: this.$t('namespace.create.success')})
this.closeQuickActions()
},

View file

@ -92,8 +92,8 @@
</p>
<p class="mb-2">
<i18n-t keypath="list.share.links.sharedBy" scope="global">
<strong>{{ getDisplayName(s.sharedBy) }}</strong>
<i18n-t keypath="list.share.links.sharedBy">
<strong>{{ s.sharedBy.getDisplayName() }}</strong>
</i18n-t>
</p>
@ -189,6 +189,7 @@
<script setup lang="ts">
import {ref, watch, computed, shallowReactive} from 'vue'
import {useStore} from '@/store'
import {useI18n} from 'vue-i18n'
import {RIGHTS} from '@/constants/rights'
@ -201,10 +202,8 @@ import LinkShareService from '@/services/linkShare'
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
import {success} from '@/message'
import {getDisplayName} from '@/models/user'
import type {ListView} from '@/types/ListView'
import {LIST_VIEWS} from '@/types/ListView'
import {useConfigStore} from '@/stores/config'
const props = defineProps({
listId: {
@ -242,8 +241,8 @@ watch(
{immediate: true},
)
const configStore = useConfigStore()
const frontendUrl = computed(() => configStore.frontendUrl)
const store = useStore()
const frontendUrl = computed(() => store.state.config.frontendUrl)
async function load(listId: IList['id']) {
// If listId == 0 the list on the calling component wasn't already loaded, so we just bail out here

View file

@ -28,7 +28,7 @@
<tbody>
<tr :key="s.id" v-for="s in sharables">
<template v-if="shareType === 'user'">
<td>{{ getDisplayName(s) }}</td>
<td>{{ s.getDisplayName() }}</td>
<td>
<template v-if="s.id === userInfo.id">
<b class="is-success">{{ $t('list.share.userTeam.you') }}</b>
@ -139,6 +139,7 @@ export default {name: 'userTeamShare'}
<script setup lang="ts">
import {ref, reactive, computed, shallowReactive, type Ref} from 'vue'
import type {PropType} from 'vue'
import {useStore} from '@/store'
import {useI18n} from 'vue-i18n'
import UserNamespaceService from '@/services/userNamespace'
@ -150,7 +151,7 @@ import UserListModel from '@/models/userList'
import type {IUserList} from '@/modelTypes/IUserList'
import UserService from '@/services/user'
import UserModel, { getDisplayName } from '@/models/user'
import UserModel from '@/models/user'
import type {IUser} from '@/modelTypes/IUser'
import TeamNamespaceService from '@/services/teamNamespace'
@ -170,7 +171,6 @@ import {RIGHTS} from '@/constants/rights'
import Multiselect from '@/components/input/multiselect.vue'
import Nothing from '@/components/misc/nothing.vue'
import {success} from '@/message'
import {useAuthStore} from '@/stores/auth'
const props = defineProps({
type: {
@ -208,8 +208,8 @@ const sharables = ref([])
const showDeleteModal = ref(false)
const authStore = useAuthStore()
const userInfo = computed(() => authStore.info)
const store = useStore()
const userInfo = computed(() => store.state.auth.info)
function createShareTypeNameComputed(count: number) {
return computed(() => {
@ -365,7 +365,7 @@ async function toggleType(sharable) {
const found = ref([])
const currentUserId = computed(() => authStore.info.id)
const currentUserId = computed(() => store.state.auth.info.id)
async function find(query: string) {
if (query === '') {
found.value = []

View file

@ -3,7 +3,7 @@
<div class="field is-grouped">
<p class="control has-icons-left is-expanded">
<textarea
:disabled="loading || undefined"
:disabled="taskService.loading || undefined"
class="add-task-textarea input"
:class="{'textarea-empty': newTaskTitle === ''}"
:placeholder="$t('list.list.addPlaceholder')"
@ -21,10 +21,10 @@
<p class="control">
<x-button
class="add-task-button"
:disabled="newTaskTitle === '' || loading || undefined"
:disabled="newTaskTitle === '' || taskService.loading || undefined"
@click="addTask()"
icon="plus"
:loading="loading"
:loading="taskService.loading"
:aria-label="$t('list.list.add')"
>
<span class="button-text">
@ -41,18 +41,17 @@
</template>
<script setup lang="ts">
import {computed, ref, unref, watch} from 'vue'
import {ref, watch, unref, shallowReactive} from 'vue'
import {useI18n} from 'vue-i18n'
import {debouncedWatch, type MaybeRef, tryOnMounted, useWindowSize} from '@vueuse/core'
import {useStore} from '@/store'
import {tryOnMounted, debouncedWatch, useWindowSize, type MaybeRef} from '@vueuse/core'
import TaskService from '@/services/task'
import QuickAddMagic from '@/components/tasks/partials/quick-add-magic.vue'
import type {ITask} from '@/modelTypes/ITask'
import {parseSubtasksViaIndention} from '@/helpers/parseSubtasksViaIndention'
import TaskRelationService from '@/services/taskRelation'
import TaskRelationModel from '@/models/taskRelation'
import {RELATION_KIND} from '@/types/IRelationKind'
import {useAuthStore} from '@/stores/auth'
import {useTaskStore} from '@/stores/tasks'
function cleanupTitle(title: string) {
return title.replace(/^((\* |\+ |- )(\[ \] )?)/g, '')
}
function useAutoHeightTextarea(value: MaybeRef<string>) {
const textarea = ref<HTMLInputElement>()
@ -135,9 +134,9 @@ const newTaskTitle = ref('')
const newTaskInput = useAutoHeightTextarea(newTaskTitle)
const {t} = useI18n({useScope: 'global'})
const authStore = useAuthStore()
const taskStore = useTaskStore()
const store = useStore()
const taskService = shallowReactive(new TaskService())
const errorMessage = ref('')
function resetEmptyTitleError(e) {
@ -149,7 +148,6 @@ function resetEmptyTitleError(e) {
}
}
const loading = computed(() => taskStore.isLoading)
async function addTask() {
if (newTaskTitle.value === '') {
errorMessage.value = t('list.create.addTitleRequired')
@ -157,64 +155,30 @@ async function addTask() {
}
errorMessage.value = ''
if (loading.value) {
if (taskService.loading) {
return
}
const taskTitleBackup = newTaskTitle.value
// This allows us to find the tasks with the title they had before being parsed
// by quick add magic.
const createdTasks: { [key: ITask['title']]: ITask } = {}
const tasksToCreate = parseSubtasksViaIndention(newTaskTitle.value)
const newTasks = tasksToCreate.map(async ({title}) => {
const newTasks = newTaskTitle.value.split(/[\r\n]+/).map(async uncleanedTitle => {
const title = cleanupTitle(uncleanedTitle)
if (title === '') {
return
}
const task = await taskStore.createNewTask({
const task = await store.dispatch('tasks/createNewTask', {
title,
listId: authStore.settings.defaultListId,
listId: store.state.auth.settings.defaultListId,
position: props.defaultPosition,
})
createdTasks[title] = task
emit('taskAdded', task)
return task
})
try {
newTaskTitle.value = ''
await Promise.all(newTasks)
const taskRelationService = new TaskRelationService()
const relations = tasksToCreate.map(async t => {
const createdTask = createdTasks[t.title]
if (typeof createdTask === 'undefined') {
return
}
if (t.parent === null) {
emit('taskAdded', createdTask)
return
}
const createdParentTask = createdTasks[t.parent]
if (typeof createdTask === 'undefined' || typeof createdParentTask === 'undefined') {
return
}
const rel = await taskRelationService.create(new TaskRelationModel({
taskId: createdTask.id,
otherTaskId: createdParentTask.id,
relationKind: RELATION_KIND.PARENTTASK,
}))
createdTask.relatedTasks[RELATION_KIND.PARENTTASK] = [createdParentTask]
// we're only emitting here so that the relation shows up in the task list
emit('taskAdded', createdTask)
return rel
})
await Promise.all(relations)
} catch (e: { message?: string }) {
} catch (e: any) {
newTaskTitle.value = taskTitleBackup
if (e?.message === 'NO_LIST') {
errorMessage.value = t('list.create.addListRequired')
@ -250,7 +214,7 @@ defineExpose({
}
.add-task-button {
height: 100% !important;
height: 2.5rem;
@media screen and (max-width: $mobile) {
.button-text {

View file

@ -173,7 +173,6 @@
<script lang="ts">
import {defineComponent} from 'vue'
import {mapState} from 'pinia'
import VueDragResize from 'vue-drag-resize'
import EditTask from './edit-task.vue'
@ -183,6 +182,7 @@ import TaskModel from '../../models/task'
import {PRIORITIES as priorities} from '@/constants/priorities'
import PriorityLabel from './partials/priorityLabel.vue'
import TaskCollectionService from '../../services/taskCollection'
import {mapState} from 'vuex'
import {RIGHTS as Rights} from '@/constants/rights'
import FilterPopup from '@/components/list/partials/filter-popup.vue'
import BaseButton from '@/components/base/BaseButton.vue'
@ -190,8 +190,6 @@ import BaseButton from '@/components/base/BaseButton.vue'
import {colorIsDark} from '@/helpers/color/colorIsDark'
import {formatDate} from '@/helpers/time/formatDate'
import {useBaseStore} from '@/stores/base'
export default defineComponent({
name: 'GanttChart',
components: {
@ -258,7 +256,7 @@ export default defineComponent({
mounted() {
this.buildTheGanttChart()
},
computed: mapState(useBaseStore, {
computed: mapState({
canWrite: (state) => state.currentList.maxRight > Rights.READ,
}),
methods: {
@ -278,13 +276,13 @@ export default defineComponent({
prepareGanttDays() {
console.debug('prepareGanttDays; start date: ', this.startDate, 'end date:', this.endDate)
// Layout: years => [months => [days]]
const years = {}
let years = {}
for (
let d = this.startDate;
d <= this.endDate;
d.setDate(d.getDate() + 1)
) {
const date = new Date(d)
let date = new Date(d)
if (years[date.getFullYear() + ''] === undefined) {
years[date.getFullYear() + ''] = {}
}
@ -353,7 +351,7 @@ export default defineComponent({
const didntHaveDates = newTask.startDate === null ? true : false
const startDate = new Date(this.startDate)
let startDate = new Date(this.startDate)
startDate.setDate(
startDate.getDate() + newRect.left / this.dayWidth,
)
@ -362,7 +360,7 @@ export default defineComponent({
startDate.setUTCSeconds(0)
startDate.setUTCMilliseconds(0)
newTask.startDate = startDate
const endDate = new Date(startDate)
let endDate = new Date(startDate)
endDate.setDate(
startDate.getDate() + newRect.width / this.dayWidth,
)
@ -430,7 +428,7 @@ export default defineComponent({
if (!this.newTaskFieldActive) {
return
}
const task = new TaskModel({
let task = new TaskModel({
title: this.newTaskTitle,
listId: this.listId,
})

View file

@ -9,7 +9,7 @@
<input
v-if="editEnabled"
:disabled="loading || undefined"
:disabled="attachmentService.loading || undefined"
@change="uploadNewAttachment()"
id="files"
multiple
@ -35,18 +35,10 @@
:key="a.id"
@click="viewOrDownload(a)"
>
<div class="filename">
{{ a.file.name }}
<span
v-if="task.coverImageAttachmentId === a.id"
class="is-task-cover"
>
{{ $t('task.attachment.usedAsCover') }}
</span>
</div>
<div class="filename">{{ a.file.name }}</div>
<div class="info">
<p class="attachment-info-meta">
<i18n-t keypath="task.attachment.createdBy" scope="global">
<i18n-t keypath="task.attachment.createdBy">
<span v-tooltip="formatDateLong(a.created)">
{{ formatDateSince(a.created) }}
</span>
@ -86,17 +78,6 @@
>
{{ $t('misc.delete') }}
</BaseButton>
<BaseButton
v-if="editEnabled"
class="attachment-info-meta-button"
@click.prevent.stop="setCoverImage(task.coverImageAttachmentId === a.id ? null : a)"
>
{{
task.coverImageAttachmentId === a.id
? $t('task.attachment.unsetAsCover')
: $t('task.attachment.setAsCover')
}}
</BaseButton>
</p>
</div>
</a>
@ -104,7 +85,7 @@
<x-button
v-if="editEnabled"
:disabled="loading"
:disabled="attachmentService.loading"
@click="filesRef?.click()"
class="mb-4"
icon="cloud-upload-alt"
@ -159,45 +140,38 @@
<script setup lang="ts">
import {ref, shallowReactive, computed} from 'vue'
import {useDropZone} from '@vueuse/core'
import {useStore} from '@/store'
import User from '@/components/misc/user.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import AttachmentService from '@/services/attachment'
import {SUPPORTED_IMAGE_SUFFIX} from '@/models/attachment'
import type AttachmentModel from '@/models/attachment'
import type {IAttachment} from '@/modelTypes/IAttachment'
import type {ITask} from '@/modelTypes/ITask'
import {useAttachmentStore} from '@/stores/attachments'
import {formatDateSince, formatDateLong} from '@/helpers/time/formatDate'
import {uploadFiles, generateAttachmentUrl} from '@/helpers/attachments'
import {getHumanSize} from '@/helpers/getHumanSize'
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
import {error, success} from '@/message'
import {useTaskStore} from '@/stores/tasks'
import {useI18n} from 'vue-i18n'
const taskStore = useTaskStore()
const {t} = useI18n({useScope: 'global'})
const props = withDefaults(defineProps<{
task: ITask,
initialAttachments?: IAttachment[],
editEnabled: boolean,
}>(), {
editEnabled: true,
const props = defineProps({
taskId: {
type: Number,
required: true,
},
initialAttachments: {
type: Array,
},
editEnabled: {
default: true,
},
})
// FIXME: this should go through the store
const emit = defineEmits(['task-changed'])
const attachmentService = shallowReactive(new AttachmentService())
const attachmentStore = useAttachmentStore()
const attachments = computed(() => attachmentStore.attachments)
const loading = computed(() => attachmentService.loading || taskStore.isLoading)
const store = useStore()
const attachments = computed(() => store.state.attachments.attachments)
function onDrop(files: File[] | null) {
if (files && files.length !== 0) {
@ -205,14 +179,13 @@ function onDrop(files: File[] | null) {
}
}
const {isOverDropZone} = useDropZone(document, onDrop)
const { isOverDropZone } = useDropZone(document, onDrop)
function downloadAttachment(attachment: IAttachment) {
attachmentService.download(attachment)
}
const filesRef = ref<HTMLInputElement | null>(null)
function uploadNewAttachment() {
const files = filesRef.value?.files
@ -224,7 +197,7 @@ function uploadNewAttachment() {
}
function uploadFilesToTask(files: File[] | FileList) {
uploadFiles(attachmentService, props.task.id, files)
uploadFiles(attachmentService, props.taskId, files)
}
const attachmentToDelete = ref<AttachmentModel | null>(null)
@ -240,18 +213,22 @@ async function deleteAttachment() {
try {
const r = await attachmentService.delete(attachmentToDelete.value)
attachmentStore.removeById(attachmentToDelete.value.id)
store.commit(
'attachments/removeById',
attachmentToDelete.value.id,
)
success(r)
setAttachmentToDelete(null)
} catch (e) {
} catch(e) {
error(e)
}
}
const attachmentImageBlobUrl = ref<string | null>(null)
const SUPPORTED_SUFFIX = ['.jpg', '.png', '.bmp', '.gif']
async function viewOrDownload(attachment: AttachmentModel) {
if (SUPPORTED_IMAGE_SUFFIX.some((suffix) => attachment.file.name.endsWith(suffix))) {
if (SUPPORTED_SUFFIX.some((suffix) => attachment.file.name.endsWith(suffix)) ) {
attachmentImageBlobUrl.value = await attachmentService.getBlobUrl(attachment)
} else {
downloadAttachment(attachment)
@ -259,15 +236,8 @@ async function viewOrDownload(attachment: AttachmentModel) {
}
const copy = useCopyToClipboard()
function copyUrl(attachment: IAttachment) {
copy(generateAttachmentUrl(props.task.id, attachment.id))
}
async function setCoverImage(attachment: IAttachment | null) {
const task = await taskStore.setCoverImage(props.task, attachment)
emit('task-changed', task)
success({message: t('task.attachment.successfullyChangedCoverImage')})
function copyUrl(attachment: AttachmentModel) {
copy(generateAttachmentUrl(props.taskId, attachment.id))
}
</script>
@ -426,13 +396,5 @@ async function setCoverImage(attachment: IAttachment | null) {
}
}
.is-task-cover {
background: var(--primary);
color: var(--white);
padding: .25rem .35rem;
border-radius: 4px;
font-size: .75rem;
}
@include modal-transition();
</style>

View file

@ -17,7 +17,7 @@
<div :key="c.id" class="media comment" v-for="c in comments">
<figure class="media-left is-hidden-mobile">
<img
:src="getAvatarUrl(c.author, 48)"
:src="c.author.getAvatarUrl(48)"
alt=""
class="image is-avatar"
height="48"
@ -27,13 +27,13 @@
<div class="media-content">
<div class="comment-info">
<img
:src="getAvatarUrl(c.author, 20)"
:src="c.author.getAvatarUrl(20)"
alt=""
class="image is-avatar d-print-none"
height="20"
width="20"
/>
<strong>{{ getDisplayName(c.author) }}</strong>&nbsp;
<strong>{{ c.author.getDisplayName() }}</strong>&nbsp;
<span v-tooltip="formatDateLong(c.created)" class="has-text-grey">
{{ formatDateSince(c.created) }}
</span>
@ -153,6 +153,7 @@
<script setup lang="ts">
import {ref, reactive, computed, shallowReactive, watch, nextTick} from 'vue'
import {useStore} from '@/store'
import {useI18n} from 'vue-i18n'
import Editor from '@/components/input/AsyncEditor'
@ -166,9 +167,6 @@ import type {ITask} from '@/modelTypes/ITask'
import {uploadFile} from '@/helpers/attachments'
import {success} from '@/message'
import {formatDateLong, formatDateSince} from '@/helpers/time/formatDate'
import {getAvatarUrl, getDisplayName} from '@/models/user'
import {useConfigStore} from '@/stores/config'
import {useAuthStore} from '@/stores/auth'
const props = defineProps({
taskId: {
@ -181,8 +179,7 @@ const props = defineProps({
})
const {t} = useI18n({useScope: 'global'})
const configStore = useConfigStore()
const authStore = useAuthStore()
const store = useStore()
const comments = ref<ITaskComment[]>([])
@ -197,9 +194,9 @@ const newComment = reactive(new TaskCommentModel())
const saved = ref<ITask['id'] | null>(null)
const saving = ref<ITask['id'] | null>(null)
const userAvatar = computed(() => getAvatarUrl(authStore.info, 48))
const currentUserId = computed(() => authStore.info.id)
const enabled = computed(() => configStore.taskCommentsEnabled)
const userAvatar = computed(() => store.state.auth.info.getAvatarUrl(48))
const currentUserId = computed(() => store.state.auth.info.id)
const enabled = computed(() => store.state.config.taskCommentsEnabled)
const actions = computed(() => {
if (!props.canWrite) {
return {}

View file

@ -1,16 +1,16 @@
<template>
<p class="created">
<time :datetime="formatISO(task.created)" v-tooltip="formatDateLong(task.created)">
<i18n-t keypath="task.detail.created" scope="global">
<i18n-t keypath="task.detail.created">
<span>{{ formatDateSince(task.created) }}</span>
{{ getDisplayName(task.createdBy) }}
{{ task.createdBy.getDisplayName() }}
</i18n-t>
</time>
<template v-if="+new Date(task.created) !== +new Date(task.updated)">
<br/>
<!-- Computed properties to show the actual date every time it gets updated -->
<time :datetime="formatISO(task.updated)" v-tooltip="updatedFormatted">
<i18n-t keypath="task.detail.updated" scope="global">
<i18n-t keypath="task.detail.updated">
<span>{{ updatedSince }}</span>
</i18n-t>
</time>
@ -18,7 +18,7 @@
<template v-if="task.done">
<br/>
<time :datetime="formatISO(task.doneAt)" v-tooltip="doneFormatted">
<i18n-t keypath="task.detail.doneAt" scope="global">
<i18n-t keypath="task.detail.doneAt">
<span>{{ doneSince }}</span>
</i18n-t>
</time>
@ -30,7 +30,6 @@
import {computed, toRefs, type PropType} from 'vue'
import type {ITask} from '@/modelTypes/ITask'
import {formatISO, formatDateLong, formatDateSince} from '@/helpers/time/formatDate'
import {getDisplayName} from '@/models/user'
const props = defineProps({
task: {

View file

@ -38,13 +38,13 @@
</template>
<script setup lang="ts">
import {ref, shallowReactive, computed, watch, onMounted, onBeforeUnmount, toRef, type PropType} from 'vue'
import {ref, shallowReactive, computed, watch, onMounted, onBeforeUnmount, type PropType} from 'vue'
import {useStore} from '@/store'
import {useI18n} from 'vue-i18n'
import flatPickr from 'vue-flatpickr-component'
import TaskService from '@/services/task'
import type {ITask} from '@/modelTypes/ITask'
import {useAuthStore} from '@/stores/auth'
const props = defineProps({
modelValue: {
@ -55,7 +55,7 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue'])
const {t} = useI18n({useScope: 'global'})
const authStore = useAuthStore()
const store = useStore()
const taskService = shallowReactive(new TaskService())
const task = ref<ITask>()
@ -63,12 +63,12 @@ const task = ref<ITask>()
// We're saving the due date seperately to prevent null errors in very short periods where the task is null.
const dueDate = ref<Date>()
const lastValue = ref<Date>()
const changeInterval = ref<ReturnType<typeof setInterval>>()
const changeInterval = ref<number>()
watch(
toRef(props, 'modelValue'),
() => props.modelValue,
(value) => {
task.value = { ...value }
task.value = value
dueDate.value = value.dueDate
lastValue.value = value.dueDate
},
@ -103,7 +103,7 @@ const flatPickerConfig = computed(() => ({
time_24hr: true,
inline: true,
locale: {
firstDayOfWeek: authStore.settings.weekStart,
firstDayOfWeek: store.state.auth.settings.weekStart,
},
}))
@ -123,10 +123,9 @@ async function updateDueDate() {
return
}
const newTask = await taskService.update({
...task.value,
dueDate: new Date(dueDate.value),
})
// FIXME: direct prop manipulation
task.value.dueDate = new Date(dueDate.value)
const newTask = await taskService.update(task.value)
lastValue.value = newTask.dueDate
task.value = newTask
emit('update:modelValue', newTask)

View file

@ -32,12 +32,11 @@
<script setup lang="ts">
import {ref,computed, watch, type PropType} from 'vue'
import {useStore} from '@/store'
import Editor from '@/components/input/AsyncEditor'
import type {ITask} from '@/modelTypes/ITask'
import {useTaskStore} from '@/stores/tasks'
import TaskModel from '@/models/task'
const props = defineProps({
@ -56,14 +55,14 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue'])
const task = ref<ITask>(new TaskModel())
const task = ref<ITask>({description: ''})
const saved = ref(false)
// Since loading is global state, this variable ensures we're only showing the saving icon when saving the description.
const saving = ref(false)
const taskStore = useTaskStore()
const loading = computed(() => taskStore.isLoading)
const store = useStore()
const loading = computed(() => store.state.loading)
watch(
() => props.modelValue,
@ -78,7 +77,7 @@ async function save() {
try {
// FIXME: don't update state from internal.
task.value = await taskStore.update(task.value)
task.value = await store.dispatch('tasks/update', task.value)
emit('update:modelValue', task.value)
saved.value = true

View file

@ -28,7 +28,8 @@
</template>
<script setup lang="ts">
import {ref, shallowReactive, watch, nextTick, type PropType} from 'vue'
import {ref, shallowReactive, watch, type PropType} from 'vue'
import {useStore} from '@/store'
import {useI18n} from 'vue-i18n'
import User from '@/components/misc/user.vue'
@ -38,36 +39,33 @@ import BaseButton from '@/components/base/BaseButton.vue'
import {includesById} from '@/helpers/utils'
import ListUserService from '@/services/listUsers'
import {success} from '@/message'
import {useTaskStore} from '@/stores/tasks'
import type {IUser} from '@/modelTypes/IUser'
import type { IUser } from '@/modelTypes/IUser'
const props = defineProps({
taskId: {
type: Number,
required: true,
},
listId: {
type: Number,
required: true,
},
disabled: {
default: false,
},
modelValue: {
type: Array as PropType<IUser[]>,
default: () => [],
},
})
taskId: {
type: Number,
required: true,
},
listId: {
type: Number,
required: true,
},
disabled: {
default: false,
},
modelValue: {
type: Array as PropType<IUser[]>,
default: () => [],
},
})
const emit = defineEmits(['update:modelValue'])
const taskStore = useTaskStore()
const store = useStore()
const {t} = useI18n({useScope: 'global'})
const listUserService = shallowReactive(new ListUserService())
const foundUsers = ref([])
const assignees = ref<IUser[]>([])
let isAdding = false
watch(
() => props.modelValue,
@ -81,23 +79,13 @@ watch(
)
async function addAssignee(user: IUser) {
if (isAdding) {
return
}
try {
nextTick(() => isAdding = true)
await taskStore.addAssignee({user: user, taskId: props.taskId})
emit('update:modelValue', assignees.value)
success({message: t('task.assignee.assignSuccess')})
} finally {
nextTick(() => isAdding = false)
}
await store.dispatch('tasks/addAssignee', {user: user, taskId: props.taskId})
emit('update:modelValue', assignees.value)
success({message: t('task.assignee.assignSuccess')})
}
async function removeAssignee(user: IUser) {
await taskStore.removeAssignee({user: user, taskId: props.taskId})
await store.dispatch('tasks/removeAssignee', {user: user, taskId: props.taskId})
// Remove the assignee from the list
for (const a in assignees.value) {
@ -130,7 +118,6 @@ function clearAllFoundUsers() {
}
const multiselect = ref()
function focus() {
multiselect.value.focus()
}

View file

@ -40,6 +40,7 @@
<script setup lang="ts">
import {type PropType, ref, computed, shallowReactive, watch} from 'vue'
import {useStore} from '@/store'
import {useI18n} from 'vue-i18n'
import LabelModel from '@/models/label'
@ -48,9 +49,8 @@ import {success} from '@/message'
import BaseButton from '@/components/base/BaseButton.vue'
import Multiselect from '@/components/input/multiselect.vue'
import type {ILabel} from '@/modelTypes/ILabel'
import {useLabelStore} from '@/stores/labels'
import {useTaskStore} from '@/stores/tasks'
import type { ILabel } from '@/modelTypes/ILabel'
import { useLabelStore } from '@/stores/labels'
const props = defineProps({
modelValue: {
@ -69,6 +69,7 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue'])
const store = useStore()
const {t} = useI18n({useScope: 'global'})
const labelTaskService = shallowReactive(new LabelTaskService())
@ -86,7 +87,6 @@ watch(
},
)
const taskStore = useTaskStore()
const labelStore = useLabelStore()
const foundLabels = computed(() => labelStore.filterLabelsByQuery(labels.value, query.value))
@ -97,13 +97,17 @@ function findLabel(newQuery: string) {
}
async function addLabel(label: ILabel, showNotification = true) {
if (props.taskId === 0) {
const bubble = () => {
emit('update:modelValue', labels.value)
}
if (props.taskId === 0) {
bubble()
return
}
await taskStore.addLabel({label, taskId: props.taskId})
emit('update:modelValue', labels.value)
await store.dispatch('tasks/addLabel', {label, taskId: props.taskId})
bubble()
if (showNotification) {
success({message: t('task.label.addSuccess')})
}
@ -111,7 +115,7 @@ async function addLabel(label: ILabel, showNotification = true) {
async function removeLabel(label: ILabel) {
if (props.taskId !== 0) {
await taskStore.removeLabel({label, taskId: props.taskId})
await store.dispatch('tasks/removeLabel', {label, taskId: props.taskId})
}
for (const l in labels.value) {

View file

@ -38,16 +38,16 @@
<script setup lang="ts">
import {ref, computed, type PropType} from 'vue'
import {useStore} from '@/store'
import {useRouter} from 'vue-router'
import BaseButton from '@/components/base/BaseButton.vue'
import ColorBubble from '@/components/misc/colorBubble.vue'
import Done from '@/components/misc/Done.vue'
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
import {useTaskStore} from '@/stores/tasks'
import type {ITask} from '@/modelTypes/ITask'
import ColorBubble from '@/components/misc/colorBubble.vue'
const props = defineProps({
task: {
@ -72,8 +72,8 @@ async function copyUrl() {
await copy(absoluteURL)
}
const taskStore = useTaskStore()
const loading = computed(() => taskStore.isLoading)
const store = useStore()
const loading = computed(() => store.state.loading)
const textIdentifier = computed(() => props.task?.getTextIdentifier() || '')
@ -93,7 +93,7 @@ async function save(title: string) {
try {
saving.value = true
const newTask = await taskStore.update({
const newTask = await store.dispatch('tasks/update', {
...props.task,
title,
})

View file

@ -6,147 +6,138 @@
'draggable': !(loadingInternal || loading),
'has-light-text': color !== TASK_DEFAULT_COLOR && !colorIsDark(color),
}"
:style="{'background-color': color !== TASK_DEFAULT_COLOR ? color : undefined}"
:style="{'background-color': color !== TASK_DEFAULT_COLOR ? color : false}"
@click.exact="openTaskDetail()"
@click.ctrl="() => toggleTaskDone(task)"
@click.meta="() => toggleTaskDone(task)"
>
<img
v-if="coverImageBlobUrl"
:src="coverImageBlobUrl"
alt=""
class="cover-image"
/>
<div class="p-2">
<span class="task-id">
<Done class="kanban-card__done" :is-done="task.done" variant="small"/>
<template v-if="task.identifier === ''">
#{{ task.index }}
</template>
<template v-else>
{{ task.identifier }}
</template>
<span class="task-id">
<Done class="kanban-card__done" :is-done="task.done" variant="small" />
<template v-if="task.identifier === ''">
#{{ task.index }}
</template>
<template v-else>
{{ task.identifier }}
</template>
</span>
<span
:class="{'overdue': task.dueDate <= new Date() && !task.done}"
class="due-date"
v-if="task.dueDate > 0"
v-tooltip="formatDateLong(task.dueDate)">
<span class="icon">
<icon :icon="['far', 'calendar-alt']"/>
</span>
<span
:class="{'overdue': task.dueDate <= new Date() && !task.done}"
class="due-date"
v-if="task.dueDate > 0"
v-tooltip="formatDateLong(task.dueDate)">
<span class="icon">
<icon :icon="['far', 'calendar-alt']"/>
</span>
<time :datetime="formatISO(task.dueDate)">
{{ formatDateSince(task.dueDate) }}
</time>
</span>
<h3>{{ task.title }}</h3>
<progress
class="progress is-small"
v-if="task.percentDone > 0"
:value="task.percentDone * 100" max="100">
{{ task.percentDone * 100 }}%
</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>
<checklist-summary :task="task"/>
<span class="icon" v-if="task.attachments.length > 0">
<icon icon="paperclip"/>
</span>
<span v-if="task.description" class="icon">
<icon icon="align-left"/>
</span>
<span class="icon" v-if="task.repeatAfter.amount > 0">
<icon icon="history"/>
</span>
<time :datetime="formatISO(task.dueDate)">
{{ formatDateSince(task.dueDate) }}
</time>
</span>
<h3>{{ task.title }}</h3>
<progress
class="progress is-small"
v-if="task.percentDone > 0"
:value="task.percentDone * 100" max="100">
{{ task.percentDone * 100 }}%
</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
:avatar-size="24"
:key="task.id + 'assignee' + u.id"
:show-username="false"
:user="u"
v-for="u in task.assignees"
/>
</div>
<checklist-summary :task="task"/>
<span class="icon" v-if="task.attachments.length > 0">
<icon icon="paperclip"/>
</span>
<span v-if="task.description" class="icon">
<icon icon="align-left"/>
</span>
<span class="icon" v-if="task.repeatAfter.amount > 0">
<icon icon="history"/>
</span>
</div>
</div>
</template>
<script lang="ts" setup>
import {ref, computed, watch} from 'vue'
import {useRouter} from 'vue-router'
<script lang="ts">
import {defineComponent, type PropType} from 'vue'
import PriorityLabel from '@/components/tasks/partials/priorityLabel.vue'
import User from '@/components/misc/user.vue'
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 Labels from '../../../components/tasks/partials/labels.vue'
import ChecklistSummary from './checklist-summary.vue'
import {TASK_DEFAULT_COLOR, getHexColor} from '@/models/task'
import {TASK_DEFAULT_COLOR} from '@/models/task'
import type {ITask} from '@/modelTypes/ITask'
import {SUPPORTED_IMAGE_SUFFIX} from '@/models/attachment'
import AttachmentService from '@/services/attachment'
import {formatDateLong, formatISO, formatDateSince} from '@/helpers/time/formatDate'
import {colorIsDark} from '@/helpers/color/colorIsDark'
import {useTaskStore} from '@/stores/tasks'
const router = useRouter()
const loadingInternal = ref(false)
const props = withDefaults(defineProps<{
task: ITask,
loading: boolean,
}>(), {
loading: false,
export default defineComponent({
name: 'kanban-card',
components: {
ChecklistSummary,
Done,
PriorityLabel,
User,
Labels,
},
data() {
return {
loadingInternal: false,
TASK_DEFAULT_COLOR,
}
},
props: {
task: {
type: Object as PropType<ITask>,
required: true,
},
loading: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
color() {
return this.task.getHexColor
? this.task.getHexColor()
: TASK_DEFAULT_COLOR
},
},
methods: {
formatDateLong,
formatISO,
formatDateSince,
colorIsDark,
async toggleTaskDone(task: ITask) {
this.loadingInternal = true
try {
const done = !task.done
await this.$store.dispatch('tasks/update', {
...task,
done,
})
} finally {
this.loadingInternal = false
}
},
openTaskDetail() {
this.$router.push({
name: 'task.detail',
params: { id: this.task.id },
state: { backdropView: this.$router.currentRoute.value.fullPath },
})
},
},
})
const color = computed(() => getHexColor(props.task.hexColor))
async function toggleTaskDone(task: ITask) {
loadingInternal.value = true
try {
await useTaskStore().update({
...task,
done: !task.done,
})
} finally {
loadingInternal.value = false
}
}
function openTaskDetail() {
router.push({
name: 'task.detail',
params: {id: props.task.id},
state: {backdropView: router.currentRoute.value.fullPath},
})
}
const coverImageBlobUrl = ref<string | null>(null)
async function maybeDownloadCoverImage() {
if (!props.task.coverImageAttachmentId) {
coverImageBlobUrl.value = null
return
}
const attachment = props.task.attachments.find(a => a.id === props.task.coverImageAttachmentId)
if (!attachment || !SUPPORTED_IMAGE_SUFFIX.some((suffix) => attachment.file.name.endsWith(suffix))) {
return
}
const attachmentService = new AttachmentService()
coverImageBlobUrl.value = await attachmentService.getBlobUrl(attachment)
}
watch(
() => props.task.coverImageAttachmentId,
maybeDownloadCoverImage,
{immediate: true},
)
</script>
<style lang="scss" scoped>
@ -158,11 +149,12 @@ $task-background: var(--white);
cursor: pointer;
box-shadow: var(--shadow-xs);
display: block;
border: 3px solid transparent;
font-size: .9rem;
padding: .4rem;
border-radius: $radius;
background: $task-background;
overflow: hidden;
&.loader-container.is-loading::after {
width: 1.5rem;

View file

@ -37,11 +37,7 @@
@create="createAndRelateTask"
>
<template #searchResult="{option: task}">
<span
v-if="typeof task !== 'string'"
class="search-result"
:class="{'is-strikethrough': task.done}"
>
<span v-if="typeof task !== 'string'" class="search-result">
<span
class="different-list"
v-if="task.listId !== listId"
@ -153,6 +149,7 @@
import {ref, reactive, shallowReactive, watch, computed, type PropType} from 'vue'
import {useI18n} from 'vue-i18n'
import {useRoute} from 'vue-router'
import {useStore} from '@/store'
import TaskService from '@/services/task'
import TaskModel from '@/models/task'
@ -170,7 +167,6 @@ import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import {useNamespaceStore} from '@/stores/namespaces'
import {error, success} from '@/message'
import {useTaskStore} from '@/stores/tasks'
const props = defineProps({
taskId: {
@ -194,7 +190,7 @@ const props = defineProps({
},
})
const taskStore = useTaskStore()
const store = useStore()
const namespaceStore = useNamespaceStore()
const route = useRoute()
const {t} = useI18n({useScope: 'global'})
@ -348,11 +344,11 @@ async function createAndRelateTask(title: string) {
}
async function toggleTaskDone(task: ITask) {
await taskStore.update(task)
await store.dispatch('tasks/update', task)
// Find the task in the list and update it so that it is correctly strike through
Object.entries(relatedTasks.value).some(([kind, tasks]) => {
return (tasks as ITask[]).some((t, key) => {
return tasks.some((t, key) => {
const found = t.id === task.id
if (found) {
relatedTasks.value[kind as IRelationKind]![key] = task

View file

@ -35,7 +35,7 @@
{{ task.title }}
</span>
<labels class="labels ml-2 mr-1" :labels="task.labels" v-if="task.labels.length > 0" />
<labels class="labels ml-2 mr-1" :labels="task.labels" v-if="task.labels.length > 0"/>
<user
:avatar-size="27"
:is-inline="true"
@ -119,8 +119,6 @@ import {formatDateSince, formatISO, formatDateLong} from '@/helpers/time/formatD
import ColorBubble from '@/components/misc/colorBubble.vue'
import {useListStore} from '@/stores/lists'
import {useNamespaceStore} from '@/stores/namespaces'
import {useBaseStore} from '@/stores/base'
import {useTaskStore} from '@/stores/tasks'
export default defineComponent({
name: 'singleTaskInList',
@ -189,11 +187,10 @@ export default defineComponent({
return list !== null ? list.hexColor : ''
},
currentList() {
const baseStore = useBaseStore()
return typeof baseStore.currentList === 'undefined' ? {
return typeof this.$store.state.currentList === 'undefined' ? {
id: 0,
title: '',
} : baseStore.currentList
} : this.$store.state.currentList
},
taskDetailRoute() {
return {
@ -211,7 +208,7 @@ export default defineComponent({
async markAsDone(checked: boolean) {
const updateFunc = async () => {
const task = await useTaskStore().update(this.task)
const task = await this.$store.dispatch('tasks/update', this.task)
this.task = task
this.$emit('task-updated', task)
this.$message.success({
@ -240,7 +237,8 @@ export default defineComponent({
this.task.isFavorite = !this.task.isFavorite
this.task = await this.taskService.update(this.task)
this.$emit('task-updated', this.task)
useNamespaceStore().loadNamespacesIfFavoritesDontExist()
const namespaceStore = useNamespaceStore()
namespaceStore.loadNamespacesIfFavoritesDontExist()
},
hideDeferDueDatePopup(e) {
if (!this.showDefer) {

View file

@ -2,7 +2,6 @@ import {ref, shallowReactive, watch, computed} from 'vue'
import {useRoute} from 'vue-router'
import TaskCollectionService from '@/services/taskCollection'
import type { ITask } from '@/modelTypes/ITask'
// FIXME: merge with DEFAULT_PARAMS in filters.vue
export const getDefaultParams = () => ({
@ -71,7 +70,7 @@ export function useTaskList(listId, sortByDefault = SORT_BY_DEFAULT) {
const loading = computed(() => taskCollectionService.loading)
const totalPages = computed(() => taskCollectionService.totalPages)
const tasks = ref<ITask[]>([])
const tasks = ref([])
async function loadTasks() {
tasks.value = []
tasks.value = await taskCollectionService.getAll(...getAllTasksParams.value)
@ -82,10 +81,10 @@ export function useTaskList(listId, sortByDefault = SORT_BY_DEFAULT) {
watch(() => route.query, (query) => {
const { page: pageQueryValue, search: searchQuery } = query
if (searchQuery !== undefined) {
search.value = searchQuery as string
search.value = searchQuery
}
if (pageQueryValue !== undefined) {
page.value = Number(pageQueryValue)
page.value = parseInt(pageQueryValue)
}
}, { immediate: true })

View file

@ -1,40 +0,0 @@
import {computed} from 'vue'
import {useRouter} from 'vue-router'
import {useEventListener} from '@vueuse/core'
import {useAuthStore} from '@/stores/auth'
export function useRenewTokenOnFocus() {
const router = useRouter()
const authStore = useAuthStore()
const userInfo = computed(() => authStore.info)
const authenticated = computed(() => authStore.authenticated)
// Try renewing the token every time vikunja is loaded initially
// (When opening the browser the focus event is not fired)
authStore.renewToken()
// Check if the token is still valid if the window gets focus again to maybe renew it
useEventListener('focus', () => {
if (!authenticated.value) {
return
}
const expiresIn = (userInfo.value !== null ? userInfo.value.exp : 0) - +new Date() / 1000
// If the token expiry is negative, it is already expired and we have no choice but to redirect
// the user to the login page
if (expiresIn < 0) {
authStore.checkAuth()
router.push({name: 'user.login'})
return
}
// Check if the token is valid for less than 60 hours and renew if thats the case
if (expiresIn < 60 * 3600) {
authStore.renewToken()
console.debug('renewed token')
}
})
}

View file

@ -1,54 +0,0 @@
import { computed, shallowRef, watchEffect, h, type VNode } from 'vue'
import { useRoute, useRouter } from 'vue-router'
export function useRouteWithModal() {
const router = useRouter()
const route = useRoute()
const backdropView = computed(() => route.fullPath && window.history.state.backdropView)
const routeWithModal = computed(() => {
return backdropView.value
? router.resolve(backdropView.value)
: route
})
const currentModal = shallowRef<VNode>()
watchEffect(() => {
if (!backdropView.value) {
currentModal.value = undefined
return
}
// logic from vue-router
// https://github.com/vuejs/vue-router-next/blob/798cab0d1e21f9b4d45a2bd12b840d2c7415f38a/src/RouterView.ts#L125
const routePropsOption = route.matched[0]?.props.default
const routeProps = routePropsOption
? routePropsOption === true
? route.params
: typeof routePropsOption === 'function'
? routePropsOption(route)
: routePropsOption
: null
const component = route.matched[0]?.components?.default
if (!component) {
currentModal.value = undefined
return
}
currentModal.value = h(component, routeProps)
})
function closeModal() {
const historyState = computed(() => route.fullPath && window.history.state)
if (historyState.value) {
router.back()
} else {
const backdropRoute = historyState.value?.backdropView && router.resolve(historyState.value.backdropView)
router.push(backdropRoute)
}
}
return {routeWithModal, currentModal, closeModal}
}

View file

@ -1,8 +1,6 @@
import type {Directive} from 'vue'
const focus = <Directive<HTMLElement,string>>{
export default {
// When the bound element is inserted into the DOM...
mounted(el, {modifiers}) {
mounted: (el, {modifiers}) => {
// Focus the element only if the viewport is big enough
// auto focusing elements on mobile can be annoying since in these cases the
// keyboard always pops up and takes half of the available space on the screen.
@ -12,5 +10,3 @@ const focus = <Directive<HTMLElement,string>>{
}
},
}
export default focus

View file

@ -2,9 +2,9 @@ import AttachmentModel from '@/models/attachment'
import type {IAttachment} from '@/modelTypes/IAttachment'
import AttachmentService from '@/services/attachment'
import {useTaskStore} from '@/stores/tasks'
import { store } from '@/store'
export function uploadFile(taskId: number, file: File, onSuccess?: (url: string) => void) {
export function uploadFile(taskId: number, file: File, onSuccess: (url: string) => void) {
const attachmentService = new AttachmentService()
const files = [file]
@ -15,18 +15,18 @@ export async function uploadFiles(
attachmentService: AttachmentService,
taskId: number,
files: File[] | FileList,
onSuccess?: (attachmentUrl: string) => void,
onSuccess: Function = () => {},
) {
const attachmentModel = new AttachmentModel({taskId})
const response = await attachmentService.create(attachmentModel, files)
console.debug(`Uploaded attachments for task ${taskId}, response was`, response)
response.success?.map((attachment: IAttachment) => {
useTaskStore().addTaskAttachment({
store.dispatch('tasks/addTaskAttachment', {
taskId,
attachment,
})
onSuccess?.(generateAttachmentUrl(taskId, attachment.id))
onSuccess(generateAttachmentUrl(taskId, attachment.id))
})
if (response.errors !== null) {

View file

@ -45,6 +45,7 @@ export async function refreshToken(persist: boolean): Promise<AxiosResponse> {
return response
} catch(e) {
// @ts-ignore
throw new Error('Error renewing token: ', { cause: e })
}
}

View file

@ -1,18 +1,19 @@
export const calculateItemPosition = (positionBefore: number | null, positionAfter: number | null): number => {
if (positionBefore === null) {
if (positionAfter === null) {
return 0
}
if (positionBefore === null && positionAfter === null) {
return 0
}
// If there is no task after it, we just add 2^16 to the last position to have enough room in the future
// If there is no task before, our task is the first task in which case we let it have half of the position of the task after it
if (positionBefore === null && positionAfter !== null) {
return positionAfter / 2
}
// If there is no task after it, we just add 2^16 to the last position to have enough room in the future
if (positionAfter === null) {
if (positionBefore !== null && positionAfter === null) {
return positionBefore + Math.pow(2, 16)
}
// If we have both a task before and after it, we acually calculate the position
// @ts-ignore - can never be null but TS does not seem to understand that
return positionBefore + (positionAfter - positionBefore) / 2
}

View file

@ -1,9 +1,10 @@
import {useConfigStore} from '@/stores/config'
import {store} from '@/store'
const API_DEFAULT_PORT = '3456'
export const ERROR_NO_API_URL = 'noApiUrlProvided'
const updateConfig = () => store.dispatch('config/update')
export const checkAndSetApiUrl = (url: string): Promise<string> => {
if(url.startsWith('/')) {
@ -24,9 +25,6 @@ export const checkAndSetApiUrl = (url: string): Promise<string> => {
const oldUrl = window.API_URL
window.API_URL = urlToCheck.toString()
const configStore = useConfigStore()
const updateConfig = () => configStore.update()
// Check if the api is reachable at the provided url
return updateConfig()
.catch(e => {

View file

@ -0,0 +1,23 @@
import UserService from '@/services/user'
import {findPropertyByValue} from '@/helpers/findPropertyByValue'
// Check if the user exists
function validateUsername(users: IUser[], username: IUser['username']) {
return findPropertyByValue(users, 'username', username)
}
export async function findAssignees(parsedTaskAssignees: string[]) {
if (parsedTaskAssignees.length <= 0) {
return []
}
const userService = new UserService()
const assignees = parsedTaskAssignees.map(async a => {
const users = await userService.getAll({}, {s: a})
return validateUsername(users, a)
})
const validatedUsers = await Promise.all(assignees)
return validatedUsers.filter((item) => Boolean(item))
}

View file

@ -0,0 +1,7 @@
// IDEA: maybe use a small fuzzy search here to prevent errors
export function findPropertyByValue(object, key, value) {
return Object.values(object).find(
(l) => l[key]?.toLowerCase() === value.toLowerCase(),
)
}

View file

@ -8,34 +8,33 @@ export function setupMarkdownRenderer(checkboxId: string) {
let checkboxNum = -1
marked.use({
renderer: {
image(src: string, title: string, text: string) {
image: (src, title, text) => {
title = title ? ` title="${title}` : ''
// If the url starts with the api url, the image is likely an attachment and
// we'll need to download and parse it properly.
if (src.slice(0, window.API_URL.length + 7) === `${window.API_URL}/tasks/`) {
if (src.substr(0, window.API_URL.length + 7) === `${window.API_URL}/tasks/`) {
return `<img data-src="${src}" alt="${text}" ${title} class="attachment-image"/>`
}
return `<img src="${src}" alt="${text}" ${title}/>`
},
checkbox(checked: boolean) {
let checkedString = ''
checkbox: (checked) => {
if (checked) {
checkedString = 'checked'
checked = ' checked="checked"'
}
checkboxNum++
return `<input type="checkbox" data-checkbox-num="${checkboxNum}" ${checkedString} class="text-checkbox-${checkboxId}"/>`
return `<input type="checkbox" data-checkbox-num="${checkboxNum}" ${checked} class="text-checkbox-${checkboxId}"/>`
},
link(href: string, title: string, text: string) {
link: (href, title, text) => {
const isLocal = href.startsWith(`${location.protocol}//${location.hostname}`)
const html = linkRenderer.call(renderer, href, title, text)
return isLocal ? html : html.replace(/^<a /, '<a target="_blank" rel="noreferrer noopener nofollow" ')
},
},
highlight(code, language) {
highlight: function (code, language) {
const validLanguage = hljs.getLanguage(language) ? language : 'plaintext'
return hljs.highlight(code, {language: validLanguage}).value
},

View file

@ -1,5 +1,5 @@
// https://stackoverflow.com/a/32108184/10924593
export function objectIsEmpty(obj: Record<string, unknown>): boolean {
export function objectIsEmpty(obj: any): boolean {
return obj
&& Object.keys(obj).length === 0
&& Object.getPrototypeOf(obj) === Object.prototype

View file

@ -1,109 +0,0 @@
import {describe, it, expect} from 'vitest'
import {parseSubtasksViaIndention} from '@/helpers/parseSubtasksViaIndention'
describe('Parse Subtasks via Relation', () => {
it('Should not return a parent for a single task', () => {
const tasks = parseSubtasksViaIndention('single task')
expect(tasks).to.have.length(1)
expect(tasks[0].parent).toBeNull()
})
it('Should not return a parent for multiple tasks without indention', () => {
const tasks = parseSubtasksViaIndention(`task one
task two`)
expect(tasks).to.have.length(2)
expect(tasks[0].parent).toBeNull()
expect(tasks[1].parent).toBeNull()
})
it('Should return a parent for two tasks with indention', () => {
const tasks = parseSubtasksViaIndention(`parent task
sub task`)
expect(tasks).to.have.length(2)
expect(tasks[0].parent).toBeNull()
expect(tasks[0].title).to.eq('parent task')
expect(tasks[1].parent).to.eq('parent task')
expect(tasks[1].title).to.eq('sub task')
})
it('Should return a parent for multiple subtasks', () => {
const tasks = parseSubtasksViaIndention(`parent task
sub task one
sub task two`)
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).to.eq('parent task')
expect(tasks[2].title).to.eq('sub task two')
expect(tasks[2].parent).to.eq('parent task')
})
it('Should work with multiple indention levels', () => {
const tasks = parseSubtasksViaIndention(`parent task
sub task
sub sub task`)
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')
expect(tasks[1].parent).to.eq('parent task')
expect(tasks[2].title).to.eq('sub sub task')
expect(tasks[2].parent).to.eq('sub task')
})
it('Should work with multiple indention levels and multiple tasks', () => {
const tasks = parseSubtasksViaIndention(`parent task
sub task
sub sub task one
sub sub task two`)
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')
expect(tasks[1].parent).to.eq('parent task')
expect(tasks[2].title).to.eq('sub sub task one')
expect(tasks[2].parent).to.eq('sub task')
expect(tasks[3].title).to.eq('sub sub task two')
expect(tasks[3].parent).to.eq('sub task')
})
it('Should work with multiple indention levels and multiple tasks', () => {
const tasks = parseSubtasksViaIndention(`parent task
sub task
sub sub task one
sub sub sub task
sub sub task two`)
expect(tasks).to.have.length(5)
expect(tasks[0].parent).toBeNull()
expect(tasks[0].title).to.eq('parent task')
expect(tasks[1].title).to.eq('sub task')
expect(tasks[1].parent).to.eq('parent task')
expect(tasks[2].title).to.eq('sub sub task one')
expect(tasks[2].parent).to.eq('sub task')
expect(tasks[3].title).to.eq('sub sub sub task')
expect(tasks[3].parent).to.eq('sub sub task one')
expect(tasks[4].title).to.eq('sub sub task two')
expect(tasks[4].parent).to.eq('sub task')
})
it('Should return a parent for multiple subtasks with special stuff', () => {
const tasks = parseSubtasksViaIndention(`* parent task
* sub task one
sub task two`)
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).to.eq('parent task')
expect(tasks[2].title).to.eq('sub task two')
expect(tasks[2].parent).to.eq('parent task')
})
it('Should not break when the first line is indented', () => {
const tasks = parseSubtasksViaIndention(' single task')
expect(tasks).to.have.length(1)
expect(tasks[0].parent).toBeNull()
})
})

View file

@ -1,48 +0,0 @@
export interface TaskWithParent {
title: string,
parent: string | null,
}
function cleanupTitle(title: string) {
return title.replace(/^((\* |\+ |- )(\[ \] )?)/g, '')
}
const spaceRegex = /^ */
/**
* @param taskTitles should be multiple lines of task tiles with indention to declare their parent/subtask
* relation between each other.
*/
export function parseSubtasksViaIndention(taskTitles: string): TaskWithParent[] {
const titles = taskTitles.split(/[\r\n]+/)
return titles.map((title, index) => {
const task: TaskWithParent = {
title: cleanupTitle(title),
parent: null,
}
if (index === 0) {
return task
}
const matched = spaceRegex.exec(title)
const matchedSpaces = matched ? matched[0].length : 0
if (matchedSpaces > 0) {
// Go up the tree to find the first task with less indention than the current one
let pi = 1
let parentSpaces = 0
do {
task.parent = cleanupTitle(titles[index - pi])
pi++
const parentMatched = spaceRegex.exec(task.parent)
parentSpaces = parentMatched ? parentMatched[0].length : 0
} while (parentSpaces >= matchedSpaces)
task.title = cleanupTitle(title.replace(spaceRegex, ''))
task.parent = task.parent.replace(spaceRegex, '')
}
return task
})
}

View file

@ -1,5 +1,5 @@
const DEFAULT_ID_LENGTH = 9
export function createRandomID(idLength = DEFAULT_ID_LENGTH) {
return Math.random().toString(36).slice(2, idLength)
return Math.random().toString(36).substr(2, idLength)
}

View file

@ -1,9 +1,14 @@
import {createRandomID} from '@/helpers/randomId'
import {parseURL} from 'ufo'
import {createRandomID} from '@/helpers/randomId'
import type {IProvider} from '@/types/IProvider'
export interface Provider {
name: string
key: string
authUrl: string
clientId: string
}
export const redirectToProvider = (provider: IProvider, redirectUrl = '') => {
export const redirectToProvider = (provider: Provider, redirectUrl: string = '') => {
// We're not using the redirect url provided by the server to allow redirects when using the electron app.
// The implications are not quite clear yet hence the logic to pass in another redirect url still exists.

View file

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

View file

@ -8,7 +8,3 @@ export function getSavedFilterIdFromListId(listId: IList['id']) {
}
return filterId
}
export function isSavedFilter(list: IList) {
return getSavedFilterIdFromListId(list.id) > 0
}

View file

@ -1,19 +0,0 @@
export function scrollIntoView(el: HTMLElement | null | undefined) {
if (!el) {
return
}
const boundingRect = el.getBoundingClientRect()
const scrollY = window.scrollY
if (
boundingRect.top > (scrollY + window.innerHeight) ||
boundingRect.top < scrollY
) {
el.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest',
})
}
}

View file

@ -1,5 +1,5 @@
export function calculateDayInterval(dateString: string, currentDay = (new Date().getDay())) {
switch (dateString) {
export function calculateDayInterval(date, currentDay = (new Date().getDay())) {
switch (date) {
case 'today':
return 0
case 'tomorrow':

View file

@ -6,7 +6,7 @@ import {i18n} from '@/i18n'
const locales = {en: enGB, de, ch: de, fr, ru}
export function dateIsValid(date) {
const dateIsValid = date => {
if (date === null) {
return false
}

View file

@ -125,16 +125,16 @@ const addTimeToDate = (text: string, date: Date, previousMatch: string | null):
}
export const getDateFromText = (text: string, now: Date = new Date()) => {
const fullDateRegex = / ([0-9][0-9]?\/[0-9][0-9]?\/[0-9][0-9]([0-9][0-9])?|[0-9][0-9][0-9][0-9]\/[0-9][0-9]?\/[0-9][0-9]?|[0-9][0-9][0-9][0-9]-[0-9][0-9]?-[0-9][0-9]?)/ig
const fullDateRegex: RegExp = / ([0-9][0-9]?\/[0-9][0-9]?\/[0-9][0-9]([0-9][0-9])?|[0-9][0-9][0-9][0-9]\/[0-9][0-9]?\/[0-9][0-9]?|[0-9][0-9][0-9][0-9]-[0-9][0-9]?-[0-9][0-9]?)/ig
// 1. Try parsing the text as a "usual" date, like 2021-06-24 or 06/24/2021
let results: string[] | null = fullDateRegex.exec(text)
let result: string | null = results === null ? null : results[0]
let foundText: string | null = result
let containsYear = true
let containsYear: boolean = true
if (result === null) {
// 2. Try parsing the date as something like "jan 21" or "21 jan"
const monthRegex = new RegExp(` (${monthsRegexGroup} [0-9][0-9]?|[0-9][0-9]? ${monthsRegexGroup})`, 'ig')
const monthRegex: RegExp = new RegExp(` (${monthsRegexGroup} [0-9][0-9]?|[0-9][0-9]? ${monthsRegexGroup})`, 'ig')
results = monthRegex.exec(text)
result = results === null ? null : `${results[0]} ${now.getFullYear()}`.trim()
foundText = results === null ? '' : results[0].trim()
@ -142,7 +142,7 @@ export const getDateFromText = (text: string, now: Date = new Date()) => {
if (result === null) {
// 3. Try parsing the date as "27/01" or "01/27"
const monthNumericRegex = / ([0-9][0-9]?\/[0-9][0-9]?)/ig
const monthNumericRegex: RegExp = / ([0-9][0-9]?\/[0-9][0-9]?)/ig
results = monthNumericRegex.exec(text)
// Put the year before or after the date, depending on what works
@ -229,7 +229,7 @@ export const getDateFromTextIn = (text: string, now: Date = new Date()) => {
}
const getDateFromWeekday = (text: string): dateFoundResult => {
const matcher = / (next )?(monday|mon|tuesday|tue|wednesday|wed|thursday|thu|friday|fri|saturday|sat|sunday|sun)($| )/g
const matcher: RegExp = / (next )?(monday|mon|tuesday|tue|wednesday|wed|thursday|thu|friday|fri|saturday|sat|sunday|sun)($| )/g
const results: string[] | null = matcher.exec(text.toLowerCase()) // The i modifier does not seem to work.
if (results === null) {
return {
@ -240,7 +240,7 @@ const getDateFromWeekday = (text: string): dateFoundResult => {
const date: Date = new Date()
const currentDay: number = date.getDay()
let day = 0
let day: number = 0
switch (results[2]) {
case 'mon':
@ -285,7 +285,7 @@ const getDateFromWeekday = (text: string): dateFoundResult => {
// matched string comes with a space at the end (last part of the regex).
let foundText = results[0]
if (foundText.endsWith(' ')) {
foundText = foundText.slice(0, foundText.length - 1)
foundText = foundText.substr(0, foundText.length - 1)
}
return {

View file

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

View file

@ -15,7 +15,7 @@ export function isNil(value: unknown) {
return value == null
}
export function omitBy(obj: Record<string, unknown>, check: (value: unknown) => boolean) {
export function omitBy(obj: {}, check: (value: unknown) => boolean) {
if (isNil(obj)) {
return {}
}

View file

@ -169,14 +169,6 @@
"title": "List Title",
"color": "Color",
"lists": "Lists",
"list": {
"title": "List",
"add": "Add",
"addPlaceholder": "Add a new task…",
"empty": "This list is currently empty.",
"newTaskCta": "Create a new task.",
"editTask": "Edit Task"
},
"search": "Type to search for a list…",
"searchSelect": "Click or press enter to select this list",
"shared": "Shared Lists",
@ -278,6 +270,14 @@
"delete": "Delete"
}
},
"list": {
"title": "List",
"add": "Add",
"addPlaceholder": "Add a new task…",
"empty": "This list is currently empty.",
"newTaskCta": "Create a new task.",
"editTask": "Edit Task"
},
"gantt": {
"title": "Gantt",
"showTasksWithoutDates": "Show tasks which don't have dates set",
@ -672,23 +672,13 @@
"updated": "Updated"
},
"subscription": {
"subscribedListThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this list through its namespace.",
"subscribedTaskThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this task through its namespace.",
"subscribedTaskThroughParentList": "You can't unsubscribe here because you are subscribed to this task through its list.",
"subscribedNamespace": "You are currently subscribed to this namespace and will receive notifications for changes.",
"notSubscribedNamespace": "You are not subscribed to this namespace and won't receive notifications for changes.",
"subscribedList": "You are currently subscribed to this list and will receive notifications for changes.",
"notSubscribedList": "You are not subscribed to this list and won't receive notifications for changes.",
"subscribedTask": "You are currently subscribed to this task and will receive notifications for changes.",
"notSubscribedTask": "You are not subscribed to this task and won't receive notifications for changes.",
"subscribedThroughParent": "You can't unsubscribe here because you are subscribed to this {entity} through its {parent}.",
"subscribed": "You are currently subscribed to this {entity} and will receive notifications for changes.",
"notSubscribed": "You are not subscribed to this {entity} and won't receive notifications for changes.",
"subscribe": "Subscribe",
"unsubscribe": "Unsubscribe",
"subscribeSuccessNamespace": "You are now subscribed to this namespace",
"unsubscribeSuccessNamespace": "You are now unsubscribed to this namespace",
"subscribeSuccessList": "You are now subscribed to this list",
"unsubscribeSuccessList": "You are now unsubscribed to this list",
"subscribeSuccessTask": "You are now subscribed to this task",
"unsubscribeSuccessTask": "You are now unsubscribed to this task"
"subscribeSuccess": "You are now subscribed to this {entity}",
"unsubscribeSuccess": "You are now unsubscribed to this {entity}"
},
"attachment": {
"title": "Attachments",
@ -700,11 +690,7 @@
"deleteTooltip": "Delete this attachment",
"deleteText1": "Are you sure you want to delete the attachment {filename}?",
"copyUrl": "Copy URL",
"copyUrlTooltip": "Copy the url of this attachment for usage in text",
"setAsCover": "Make cover",
"unsetAsCover": "Remove cover",
"successfullyChangedCoverImage": "The cover image was successfully changed.",
"usedAsCover": "Cover image"
"copyUrlTooltip": "Copy the url of this attachment for usage in text"
},
"comment": {
"title": "Comments",
@ -853,12 +839,6 @@
"text1": "Are you sure you want to remove this user from the team?",
"text2": "They will lose access to all lists and namespaces this team has access to. This CANNOT BE UNDONE!",
"success": "The user was successfully deleted from the team."
},
"leave": {
"title": "Leave team",
"text1": "Are you sure you want to leave this team?",
"text2": "You will loose access to all lists and namespaces this team has access to. If you change your mind you'll need a team admin to add you again.",
"success": "You have successfully left the team."
}
},
"attributes": {

View file

@ -169,14 +169,6 @@
"title": "Název seznamu",
"color": "Barva",
"lists": "Seznamy",
"list": {
"title": "Seznam",
"add": "Přidat",
"addPlaceholder": "Přidat nový úkol…",
"empty": "Tento seznam je nyní prázdný.",
"newTaskCta": "Vytvořit nový úkol.",
"editTask": "Upravit úkol"
},
"search": "Začni psát pro vyhledání seznamu…",
"searchSelect": "Klikněte nebo stiskněte Enter pro výběr tohoto seznamu",
"shared": "Sdílené seznamy",
@ -278,6 +270,14 @@
"delete": "Smazat"
}
},
"list": {
"title": "Seznam",
"add": "Přidat",
"addPlaceholder": "Přidat nový úkol…",
"empty": "Tento seznam je nyní prázdný.",
"newTaskCta": "Vytvořit nový úkol.",
"editTask": "Upravit úkol"
},
"gantt": {
"title": "Gantt",
"showTasksWithoutDates": "Zobrazit úkoly, které nemají nastavené datum",
@ -672,23 +672,13 @@
"updated": "Aktualizováno"
},
"subscription": {
"subscribedListThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this list through its namespace.",
"subscribedTaskThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this task through its namespace.",
"subscribedTaskThroughParentList": "You can't unsubscribe here because you are subscribed to this task through its list.",
"subscribedNamespace": "You are currently subscribed to this namespace and will receive notifications for changes.",
"notSubscribedNamespace": "You are not subscribed to this namespace and won't receive notifications for changes.",
"subscribedList": "You are currently subscribed to this list and will receive notifications for changes.",
"notSubscribedList": "You are not subscribed to this list and won't receive notifications for changes.",
"subscribedTask": "You are currently subscribed to this task and will receive notifications for changes.",
"notSubscribedTask": "You are not subscribed to this task and won't receive notifications for changes.",
"subscribedThroughParent": "Zde se nemůžete odhlásit, protože jste přihlášeni k odběru {entity} prostřednictvím jeho {parent}.",
"subscribed": "Aktuálně jste přihlášeni k odběru {entity} a budete dostávat oznámení o změnách.",
"notSubscribed": "Nejste přihlášeni k odběru {entity} a nebudete dostávat upozornění na změny.",
"subscribe": "Odebírat",
"unsubscribe": "Odhlásit odběr",
"subscribeSuccessNamespace": "You are now subscribed to this namespace",
"unsubscribeSuccessNamespace": "You are now unsubscribed to this namespace",
"subscribeSuccessList": "You are now subscribed to this list",
"unsubscribeSuccessList": "You are now unsubscribed to this list",
"subscribeSuccessTask": "You are now subscribed to this task",
"unsubscribeSuccessTask": "You are now unsubscribed to this task"
"subscribeSuccess": "Nyní jste přihlášeni k odběru {entity}",
"unsubscribeSuccess": "Nyní jste odhlášeni od odběru {entity}"
},
"attachment": {
"title": "Přílohy",
@ -700,11 +690,7 @@
"deleteTooltip": "Smazat tuto přílohu",
"deleteText1": "Opravdu chcete odstranit přílohu {filename}?",
"copyUrl": "Kopírovat URL",
"copyUrlTooltip": "Kopírovat URL této přílohy pro použití v textu",
"setAsCover": "Make cover",
"unsetAsCover": "Remove cover",
"successfullyChangedCoverImage": "The cover image was successfully changed.",
"usedAsCover": "Cover image"
"copyUrlTooltip": "Kopírovat URL této přílohy pro použití v textu"
},
"comment": {
"title": "Komentáře",
@ -853,12 +839,6 @@
"text1": "Opravdu chcete odebrat tohoto uživatele z týmu?",
"text2": "Ztratí přístup ke všem seznamům a prostorům, k nimž má tento tým přístup. To NEMŮŽE BÝT VZATO ZPĚT!",
"success": "Uživatel byl úspěšně odstraněn z týmu."
},
"leave": {
"title": "Leave team",
"text1": "Are you sure you want to leave this team?",
"text2": "You will loose access to all lists and namespaces this team has access to. If you change your mind you'll need a team admin to add you again.",
"success": "You have successfully left the team."
}
},
"attributes": {

View file

@ -169,14 +169,6 @@
"title": "Listentitel",
"color": "Farbe",
"lists": "Listen",
"list": {
"title": "Liste",
"add": "Hinzufügen",
"addPlaceholder": "Eine neue Aufgabe hinzufügen …",
"empty": "Diese Liste ist derzeit leer.",
"newTaskCta": "Eine neue Aufgabe erstellen.",
"editTask": "Aufgabe bearbeiten"
},
"search": "Tippe, um nach einer Liste zu suchen…",
"searchSelect": "Klicke auf oder drücke die Eingabetaste, um diese Liste auszuwählen",
"shared": "Geteilte Listen",
@ -278,6 +270,14 @@
"delete": "Löschen"
}
},
"list": {
"title": "Liste",
"add": "Hinzufügen",
"addPlaceholder": "Eine neue Aufgabe hinzufügen …",
"empty": "Diese Liste ist derzeit leer.",
"newTaskCta": "Eine neue Aufgabe erstellen.",
"editTask": "Aufgabe bearbeiten"
},
"gantt": {
"title": "Gantt",
"showTasksWithoutDates": "Aufgaben anzeigen, für die keine Daten festgelegt sind",
@ -672,23 +672,13 @@
"updated": "Aktualisiert"
},
"subscription": {
"subscribedListThroughParentNamespace": "Du kannst hier nicht de-abonnieren, da du diese Liste über ihren Namespace abonniert hast.",
"subscribedTaskThroughParentNamespace": "Du kannst hier nicht de-abonnieren, da du diese Aufgabe über ihren Namespace abonniert hast.",
"subscribedTaskThroughParentList": "Du kannst hier nicht de-abonnieren, da du diese Aufgabe über ihre Liste abonniert hast.",
"subscribedNamespace": "Du hast diesen Namespace abonniert und erhältst Benachrichtigungen über Änderungen.",
"notSubscribedNamespace": "Du hast diesen Namespace nicht abonniert und erhältst keine Benachrichtigungen über Änderungen.",
"subscribedList": "Du hast diese Liste abonniert und erhältst Benachrichtigungen über Änderungen.",
"notSubscribedList": "Du hast diese Liste nicht abonniert und erhältst keine Benachrichtigungen über Änderungen.",
"subscribedTask": "Du hast diese Aufgabe abonniert und erhältst Benachrichtigungen über Änderungen.",
"notSubscribedTask": "Du hast diese Aufgabe nicht abonniert und erhältst keine Benachrichtigungen über Änderungen.",
"subscribedThroughParent": "Du kannst hier nicht de-abonnieren, da du diese {entity} durch {parent} abonniert hast.",
"subscribed": "Du erhältst Benachrichtigungen zu dieser {entity}.",
"notSubscribed": "Du hast diese {entity} nicht abonniert und erhältst keine Benachrichtigungen über Änderungen.",
"subscribe": "Abonnieren",
"unsubscribe": "Abbestellen",
"subscribeSuccessNamespace": "Du hast diesen Namespace jetzt abonniert",
"unsubscribeSuccessNamespace": "Du hast diesen Namespace jetzt nicht mehr abonniert",
"subscribeSuccessList": "Du hast diese Liste jetzt abonniert",
"unsubscribeSuccessList": "Du hast diese Liste jetzt nicht mehr abonniert",
"subscribeSuccessTask": "Du hast diese Aufgabe jetzt abonniert",
"unsubscribeSuccessTask": "Du hast diese Aufgabe jetzt nicht mehr abonniert"
"subscribeSuccess": "Du hast jetzt diese {entity} abonniert",
"unsubscribeSuccess": "Du hast diese {entity} jetzt abbestellt"
},
"attachment": {
"title": "Anhänge",
@ -700,11 +690,7 @@
"deleteTooltip": "Diesen Anhang löschen",
"deleteText1": "Soll der Anhang {filename} gelöscht werden?",
"copyUrl": "URL kopieren",
"copyUrlTooltip": "Die URL dieses Anhangs zur Verwendung im Text kopieren",
"setAsCover": "Als Titelbild setzen",
"unsetAsCover": "Titelbild entfernen",
"successfullyChangedCoverImage": "Das Titelbild wurde erfolgreich geändert.",
"usedAsCover": "Titelbild"
"copyUrlTooltip": "Die URL dieses Anhangs zur Verwendung im Text kopieren"
},
"comment": {
"title": "Kommentare",
@ -853,12 +839,6 @@
"text1": "Bist du sicher, dass du diese:n Benutzer:in aus dem Team entfernen willst?",
"text2": "Diese:r Benutzer:in verliert den Zugriff auf alle Listen und Namespaces auf die dieses Team Zugriff hat. Dies kann nicht rückgängig gemacht werden!",
"success": "Der:die Benutzer:in wurde erfolgreich aus dem Team gelöscht."
},
"leave": {
"title": "Team verlassen",
"text1": "Bist du sicher, dass du dieses Team verlassen willst?",
"text2": "Du wirst Zugriff auf alle Listen und Namespaces verlieren, auf die dieses Team Zugriff hat. Wenn du deine Meinung änderst, musst du durch einen Team-Admin wieder hinzugefügt werden.",
"success": "Du hast das Team erfolgreich verlassen."
}
},
"attributes": {

View file

@ -169,14 +169,6 @@
"title": "Liste Titl",
"color": "Farb",
"lists": "Listene",
"list": {
"title": "Liste",
"add": "Hinzuefüege",
"addPlaceholder": "E neui Uufgab erstelle…",
"empty": "D'Liste isch momentan leer.",
"newTaskCta": "Neui Uufgab erstelle.",
"editTask": "Uufgab bearbeite"
},
"search": "Schriib, um nachere Liste z'sueche…",
"searchSelect": "Druck uf Enter um die Liste uuszwähle",
"shared": "Teilti Liste",
@ -278,6 +270,14 @@
"delete": "Chüble"
}
},
"list": {
"title": "Liste",
"add": "Hinzuefüege",
"addPlaceholder": "E neui Uufgab erstelle…",
"empty": "D'Liste isch momentan leer.",
"newTaskCta": "Neui Uufgab erstelle.",
"editTask": "Uufgab bearbeite"
},
"gantt": {
"title": "Gantt",
"showTasksWithoutDates": "Zeig Uufgabe, wo kei Date hend",
@ -672,23 +672,13 @@
"updated": "Aktualisiert"
},
"subscription": {
"subscribedListThroughParentNamespace": "Du kannst hier nicht de-abonnieren, da du diese Liste über ihren Namespace abonniert hast.",
"subscribedTaskThroughParentNamespace": "Du kannst hier nicht de-abonnieren, da du diese Aufgabe über ihren Namespace abonniert hast.",
"subscribedTaskThroughParentList": "Du kannst hier nicht de-abonnieren, da du diese Aufgabe über ihre Liste abonniert hast.",
"subscribedNamespace": "Du hast diesen Namespace abonniert und erhältst Benachrichtigungen über Änderungen.",
"notSubscribedNamespace": "Du hast diesen Namespace nicht abonniert und erhältst keine Benachrichtigungen über Änderungen.",
"subscribedList": "Du hast diese Liste abonniert und erhältst Benachrichtigungen über Änderungen.",
"notSubscribedList": "Du hast diese Liste nicht abonniert und erhältst keine Benachrichtigungen über Änderungen.",
"subscribedTask": "Du hast diese Aufgabe abonniert und erhältst Benachrichtigungen über Änderungen.",
"notSubscribedTask": "Du hast diese Aufgabe nicht abonniert und erhältst keine Benachrichtigungen über Änderungen.",
"subscribedThroughParent": "Du chasch da nid deabonnierä, weil du zu dere {entity} durch {parent} dezue abonniert bisch.",
"subscribed": "Du bisch momentan zu dere {entity} abonniert und bechunsch Benachrichtigunge für Änderige.",
"notSubscribed": "Du bisch momentan nid zu dere {entity} abonniert und bechunsch kei Benachrichtigunge für Änderige.",
"subscribe": "Abooniere",
"unsubscribe": "Deabonniere",
"subscribeSuccessNamespace": "Du hast diesen Namespace jetzt abonniert",
"unsubscribeSuccessNamespace": "Du hast diesen Namespace jetzt nicht mehr abonniert",
"subscribeSuccessList": "Du hast diese Liste jetzt abonniert",
"unsubscribeSuccessList": "Du hast diese Liste jetzt nicht mehr abonniert",
"subscribeSuccessTask": "Du hast diese Aufgabe jetzt abonniert",
"unsubscribeSuccessTask": "Du hast diese Aufgabe jetzt nicht mehr abonniert"
"subscribeSuccess": "Du hesch die {entity} abonniert",
"unsubscribeSuccess": "Du hesch die {entity} deabonniert"
},
"attachment": {
"title": "Aahhäng",
@ -700,11 +690,7 @@
"deleteTooltip": "De Aahhang lösche",
"deleteText1": "Bisch du dir sicher, dass du de Aahang {filename} lösche wetsch?",
"copyUrl": "URL Kopierä",
"copyUrlTooltip": "D'Url vo dem Aahang kopiere, um sie im Text zbruuche",
"setAsCover": "Als Titelbild setzen",
"unsetAsCover": "Titelbild entfernen",
"successfullyChangedCoverImage": "Das Titelbild wurde erfolgreich geändert.",
"usedAsCover": "Titelbild"
"copyUrlTooltip": "D'Url vo dem Aahang kopiere, um sie im Text zbruuche"
},
"comment": {
"title": "Kommentär",
@ -853,12 +839,6 @@
"text1": "Bisch du dir sicher, dass du de Benutzer usm Team werfe wetsch?",
"text2": "Diese:r Benutzer:in verliert den Zugriff auf alle Listen und Namespaces auf die dieses Team Zugriff hat. Dies kann nicht rückgängig gemacht werden!",
"success": "Benutzer erfolgriich usegworfe."
},
"leave": {
"title": "Team verlassen",
"text1": "Bist du sicher, dass du dieses Team verlassen willst?",
"text2": "Du wirst Zugriff auf alle Listen und Namespaces verlieren, auf die dieses Team Zugriff hat. Wenn du deine Meinung änderst, musst du durch einen Team-Admin wieder hinzugefügt werden.",
"success": "Du hast das Team erfolgreich verlassen."
}
},
"attributes": {

View file

@ -169,7 +169,6 @@
"title": "List Title",
"color": "Color",
"lists": "Lists",
"list": "List",
"search": "Type to search for a list…",
"searchSelect": "Click or press enter to select this list",
"shared": "Shared Lists",
@ -617,6 +616,12 @@
"select": "Select a date range",
"noTasks": "Nothing to do — Have a nice day!"
},
"create": {
"heading": "Create a task in {list}",
"title": "Enter the title of the new task…",
"descriptionPlaceholder": "Enter your description here…",
"description": "Add description…"
},
"detail": {
"chooseDueDate": "Click here to set a due date",
"chooseStartDate": "Click here to set a start date",
@ -676,23 +681,13 @@
"updated": "Updated"
},
"subscription": {
"subscribedListThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this list through its namespace.",
"subscribedTaskThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this task through its namespace.",
"subscribedTaskThroughParentList": "You can't unsubscribe here because you are subscribed to this task through its list.",
"subscribedNamespace": "You are currently subscribed to this namespace and will receive notifications for changes.",
"notSubscribedNamespace": "You are not subscribed to this namespace and won't receive notifications for changes.",
"subscribedList": "You are currently subscribed to this list and will receive notifications for changes.",
"notSubscribedList": "You are not subscribed to this list and won't receive notifications for changes.",
"subscribedTask": "You are currently subscribed to this task and will receive notifications for changes.",
"notSubscribedTask": "You are not subscribed to this task and won't receive notifications for changes.",
"subscribedThroughParent": "You can't unsubscribe here because you are subscribed to this {entity} through its {parent}.",
"subscribed": "You are currently subscribed to this {entity} and will receive notifications for changes.",
"notSubscribed": "You are not subscribed to this {entity} and won't receive notifications for changes.",
"subscribe": "Subscribe",
"unsubscribe": "Unsubscribe",
"subscribeSuccessNamespace": "You are now subscribed to this namespace",
"unsubscribeSuccessNamespace": "You are now unsubscribed to this namespace",
"subscribeSuccessList": "You are now subscribed to this list",
"unsubscribeSuccessList": "You are now unsubscribed to this list",
"subscribeSuccessTask": "You are now subscribed to this task",
"unsubscribeSuccessTask": "You are now unsubscribed to this task"
"subscribeSuccess": "You are now subscribed to this {entity}",
"unsubscribeSuccess": "You are now unsubscribed to this {entity}"
},
"attachment": {
"title": "Attachments",
@ -704,11 +699,7 @@
"deleteTooltip": "Delete this attachment",
"deleteText1": "Are you sure you want to delete the attachment {filename}?",
"copyUrl": "Copy URL",
"copyUrlTooltip": "Copy the url of this attachment for usage in text",
"setAsCover": "Make cover",
"unsetAsCover": "Remove cover",
"successfullyChangedCoverImage": "The cover image was successfully changed.",
"usedAsCover": "Cover image"
"copyUrlTooltip": "Copy the url of this attachment for usage in text"
},
"comment": {
"title": "Comments",
@ -857,12 +848,6 @@
"text1": "Are you sure you want to remove this user from the team?",
"text2": "They will lose access to all lists and namespaces this team has access to. This CANNOT BE UNDONE!",
"success": "The user was successfully deleted from the team."
},
"leave": {
"title": "Leave team",
"text1": "Are you sure you want to leave this team?",
"text2": "You will loose access to all lists and namespaces this team has access to. If you change your mind you'll need a team admin to add you again.",
"success": "You have successfully left the team."
}
},
"attributes": {
@ -954,7 +939,6 @@
"lists": "Lists",
"teams": "Teams",
"newList": "Enter the title of the new list…",
"newTask": "Enter the title of the new task…",
"newNamespace": "Enter the title of the new namespace…",
"newTeam": "Enter the name of the new team…",
"createTask": "Create a task in the current list ({title})",

View file

@ -169,14 +169,6 @@
"title": "List Title",
"color": "Color",
"lists": "Lists",
"list": {
"title": "List",
"add": "Add",
"addPlaceholder": "Add a new task…",
"empty": "This list is currently empty.",
"newTaskCta": "Create a new task.",
"editTask": "Edit Task"
},
"search": "Type to search for a list…",
"searchSelect": "Click or press enter to select this list",
"shared": "Shared Lists",
@ -278,6 +270,14 @@
"delete": "Delete"
}
},
"list": {
"title": "List",
"add": "Add",
"addPlaceholder": "Add a new task…",
"empty": "This list is currently empty.",
"newTaskCta": "Create a new task.",
"editTask": "Edit Task"
},
"gantt": {
"title": "Gantt",
"showTasksWithoutDates": "Show tasks which don't have dates set",
@ -672,23 +672,13 @@
"updated": "Updated"
},
"subscription": {
"subscribedListThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this list through its namespace.",
"subscribedTaskThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this task through its namespace.",
"subscribedTaskThroughParentList": "You can't unsubscribe here because you are subscribed to this task through its list.",
"subscribedNamespace": "You are currently subscribed to this namespace and will receive notifications for changes.",
"notSubscribedNamespace": "You are not subscribed to this namespace and won't receive notifications for changes.",
"subscribedList": "You are currently subscribed to this list and will receive notifications for changes.",
"notSubscribedList": "You are not subscribed to this list and won't receive notifications for changes.",
"subscribedTask": "You are currently subscribed to this task and will receive notifications for changes.",
"notSubscribedTask": "You are not subscribed to this task and won't receive notifications for changes.",
"subscribedThroughParent": "You can't unsubscribe here because you are subscribed to this {entity} through its {parent}.",
"subscribed": "You are currently subscribed to this {entity} and will receive notifications for changes.",
"notSubscribed": "You are not subscribed to this {entity} and won't receive notifications for changes.",
"subscribe": "Subscribe",
"unsubscribe": "Unsubscribe",
"subscribeSuccessNamespace": "You are now subscribed to this namespace",
"unsubscribeSuccessNamespace": "You are now unsubscribed to this namespace",
"subscribeSuccessList": "You are now subscribed to this list",
"unsubscribeSuccessList": "You are now unsubscribed to this list",
"subscribeSuccessTask": "You are now subscribed to this task",
"unsubscribeSuccessTask": "You are now unsubscribed to this task"
"subscribeSuccess": "You are now subscribed to this {entity}",
"unsubscribeSuccess": "You are now unsubscribed to this {entity}"
},
"attachment": {
"title": "Attachments",
@ -700,11 +690,7 @@
"deleteTooltip": "Delete this attachment",
"deleteText1": "Are you sure you want to delete the attachment {filename}?",
"copyUrl": "Copy URL",
"copyUrlTooltip": "Copy the url of this attachment for usage in text",
"setAsCover": "Make cover",
"unsetAsCover": "Remove cover",
"successfullyChangedCoverImage": "The cover image was successfully changed.",
"usedAsCover": "Cover image"
"copyUrlTooltip": "Copy the url of this attachment for usage in text"
},
"comment": {
"title": "Comments",
@ -853,12 +839,6 @@
"text1": "Are you sure you want to remove this user from the team?",
"text2": "They will lose access to all lists and namespaces this team has access to. This CANNOT BE UNDONE!",
"success": "The user was successfully deleted from the team."
},
"leave": {
"title": "Leave team",
"text1": "Are you sure you want to leave this team?",
"text2": "You will loose access to all lists and namespaces this team has access to. If you change your mind you'll need a team admin to add you again.",
"success": "You have successfully left the team."
}
},
"attributes": {

View file

@ -169,14 +169,6 @@
"title": "Nom de la liste",
"color": "Couleur",
"lists": "Listes",
"list": {
"title": "Liste",
"add": "Ajouter",
"addPlaceholder": "Ajouter une nouvelle tâche…",
"empty": "Cette liste est actuellement vide.",
"newTaskCta": "Créer une nouvelle tâche.",
"editTask": "Modifier la tâche"
},
"search": "Écris pour rechercher une liste…",
"searchSelect": "Clique ou appuie sur la touche Entrée pour sélectionner cette liste",
"shared": "Listes partagées",
@ -278,6 +270,14 @@
"delete": "Supprimer"
}
},
"list": {
"title": "Liste",
"add": "Ajouter",
"addPlaceholder": "Ajouter une nouvelle tâche…",
"empty": "Cette liste est actuellement vide.",
"newTaskCta": "Créer une nouvelle tâche.",
"editTask": "Modifier la tâche"
},
"gantt": {
"title": "Gantt",
"showTasksWithoutDates": "Afficher les tâches pour lesquelles aucune date na été fixée",
@ -672,23 +672,13 @@
"updated": "Mis à jour"
},
"subscription": {
"subscribedListThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this list through its namespace.",
"subscribedTaskThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this task through its namespace.",
"subscribedTaskThroughParentList": "You can't unsubscribe here because you are subscribed to this task through its list.",
"subscribedNamespace": "You are currently subscribed to this namespace and will receive notifications for changes.",
"notSubscribedNamespace": "You are not subscribed to this namespace and won't receive notifications for changes.",
"subscribedList": "You are currently subscribed to this list and will receive notifications for changes.",
"notSubscribedList": "You are not subscribed to this list and won't receive notifications for changes.",
"subscribedTask": "You are currently subscribed to this task and will receive notifications for changes.",
"notSubscribedTask": "You are not subscribed to this task and won't receive notifications for changes.",
"subscribedThroughParent": "Tu ne peux pas te désabonner ici car tu es abonné·e à cette {entity} par le biais de son {parent}.",
"subscribed": "Tu es actuellement abonné·e à cette {entity} et recevras des notifications pour les changements.",
"notSubscribed": "Tu nes pas abonné·e à cette {entity} et ne recevras pas de notifications pour les changements.",
"subscribe": "Sabonner",
"unsubscribe": "Se désabonner",
"subscribeSuccessNamespace": "You are now subscribed to this namespace",
"unsubscribeSuccessNamespace": "You are now unsubscribed to this namespace",
"subscribeSuccessList": "You are now subscribed to this list",
"unsubscribeSuccessList": "You are now unsubscribed to this list",
"subscribeSuccessTask": "You are now subscribed to this task",
"unsubscribeSuccessTask": "You are now unsubscribed to this task"
"subscribeSuccess": "Tu es maintenant abonné·e à cette {entity}",
"unsubscribeSuccess": "Tu es maintenant désabonné·e de cette {entity}"
},
"attachment": {
"title": "Pièces jointes",
@ -700,11 +690,7 @@
"deleteTooltip": "Supprimer cette pièce jointe",
"deleteText1": "Supprimer la pièce jointe {filename} ?",
"copyUrl": "Copier lURL",
"copyUrlTooltip": "Copier lURL de cette pièce jointe pour lutiliser dans le texte",
"setAsCover": "Make cover",
"unsetAsCover": "Remove cover",
"successfullyChangedCoverImage": "The cover image was successfully changed.",
"usedAsCover": "Cover image"
"copyUrlTooltip": "Copier lURL de cette pièce jointe pour lutiliser dans le texte"
},
"comment": {
"title": "Commentaires",
@ -853,12 +839,6 @@
"text1": "Retirer cette personne de léquipe ?",
"text2": "Ils perdront l'accès à toutes les listes et espaces de noms auxquels cette équipe a accès. Ceci NE PEUT PAS ÊTRE ANNULÉ !",
"success": "Utilisateur·rice retiré·e de léquipe."
},
"leave": {
"title": "Leave team",
"text1": "Are you sure you want to leave this team?",
"text2": "You will loose access to all lists and namespaces this team has access to. If you change your mind you'll need a team admin to add you again.",
"success": "You have successfully left the team."
}
},
"attributes": {

View file

@ -169,14 +169,6 @@
"title": "Titolo della Lista",
"color": "Colore",
"lists": "Liste",
"list": {
"title": "Lista",
"add": "Aggiungi",
"addPlaceholder": "Aggiungi una nuova attività…",
"empty": "Questa lista è attualmente vuota.",
"newTaskCta": "Crea una nuova attività.",
"editTask": "Modifica Attività"
},
"search": "Digita per cercare una lista…",
"searchSelect": "Fare clic o premere invio per selezionare questa lista",
"shared": "Liste Condivise",
@ -278,6 +270,14 @@
"delete": "Elimina"
}
},
"list": {
"title": "Lista",
"add": "Aggiungi",
"addPlaceholder": "Aggiungi una nuova attività…",
"empty": "Questa lista è attualmente vuota.",
"newTaskCta": "Crea una nuova attività.",
"editTask": "Modifica Attività"
},
"gantt": {
"title": "Gantt",
"showTasksWithoutDates": "Mostra attività che non hanno date impostate",
@ -672,23 +672,13 @@
"updated": "Aggiornato"
},
"subscription": {
"subscribedListThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this list through its namespace.",
"subscribedTaskThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this task through its namespace.",
"subscribedTaskThroughParentList": "You can't unsubscribe here because you are subscribed to this task through its list.",
"subscribedNamespace": "You are currently subscribed to this namespace and will receive notifications for changes.",
"notSubscribedNamespace": "You are not subscribed to this namespace and won't receive notifications for changes.",
"subscribedList": "You are currently subscribed to this list and will receive notifications for changes.",
"notSubscribedList": "You are not subscribed to this list and won't receive notifications for changes.",
"subscribedTask": "You are currently subscribed to this task and will receive notifications for changes.",
"notSubscribedTask": "You are not subscribed to this task and won't receive notifications for changes.",
"subscribedThroughParent": "Non puoi annullare l'iscrizione qui perché sei iscritto a questo {entity} attraverso il suo {parent}.",
"subscribed": "Sei attualmente iscritto a questo {entity} e riceverai notifiche per le modifiche.",
"notSubscribed": "Non sei iscritto a questo {entity} e non riceverai notifiche per le modifiche.",
"subscribe": "Iscriviti",
"unsubscribe": "Disiscriviti",
"subscribeSuccessNamespace": "You are now subscribed to this namespace",
"unsubscribeSuccessNamespace": "You are now unsubscribed to this namespace",
"subscribeSuccessList": "You are now subscribed to this list",
"unsubscribeSuccessList": "You are now unsubscribed to this list",
"subscribeSuccessTask": "You are now subscribed to this task",
"unsubscribeSuccessTask": "You are now unsubscribed to this task"
"subscribeSuccess": "Ti sei iscritto a questo {entity}",
"unsubscribeSuccess": "Ti sei disiscritto a questo {entity}"
},
"attachment": {
"title": "Allegati",
@ -700,11 +690,7 @@
"deleteTooltip": "Elimina questo allegato",
"deleteText1": "Sei sicuro di voler eliminare l'allegato {filename}?",
"copyUrl": "Copia URL",
"copyUrlTooltip": "Copia l'URL di questo allegato per usarlo nel testo",
"setAsCover": "Make cover",
"unsetAsCover": "Remove cover",
"successfullyChangedCoverImage": "The cover image was successfully changed.",
"usedAsCover": "Cover image"
"copyUrlTooltip": "Copia l'URL di questo allegato per usarlo nel testo"
},
"comment": {
"title": "Commenti",
@ -853,12 +839,6 @@
"text1": "Confermi di voler rimuovere questo utente dal gruppo?",
"text2": "Perderanno l'accesso a tutte le liste e i namespace a cui questo gruppo ha accesso. NON PUÒ ESSERE RIPRISTINATO!",
"success": "Utente rimosso dal gruppo."
},
"leave": {
"title": "Leave team",
"text1": "Are you sure you want to leave this team?",
"text2": "You will loose access to all lists and namespaces this team has access to. If you change your mind you'll need a team admin to add you again.",
"success": "You have successfully left the team."
}
},
"attributes": {

View file

@ -169,14 +169,6 @@
"title": "Lijst titel",
"color": "Kleur",
"lists": "Lijsten",
"list": {
"title": "Lijst",
"add": "Toevoegen",
"addPlaceholder": "Voeg een nieuwe taak toe…",
"empty": "Deze lijst is momenteel leeg.",
"newTaskCta": "Creëer een nieuwe taak.",
"editTask": "Taak bewerken"
},
"search": "Typ om naar een lijst te zoeken…",
"searchSelect": "Klik of druk op enter om deze lijst te selecteren",
"shared": "Gedeelde lijsten",
@ -278,6 +270,14 @@
"delete": "Verwijderen"
}
},
"list": {
"title": "Lijst",
"add": "Toevoegen",
"addPlaceholder": "Voeg een nieuwe taak toe…",
"empty": "Deze lijst is momenteel leeg.",
"newTaskCta": "Creëer een nieuwe taak.",
"editTask": "Taak bewerken"
},
"gantt": {
"title": "Gantt",
"showTasksWithoutDates": "Toon taken waarvoor geen datums zijn ingesteld",
@ -672,23 +672,13 @@
"updated": "Bijgewerkt"
},
"subscription": {
"subscribedListThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this list through its namespace.",
"subscribedTaskThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this task through its namespace.",
"subscribedTaskThroughParentList": "You can't unsubscribe here because you are subscribed to this task through its list.",
"subscribedNamespace": "You are currently subscribed to this namespace and will receive notifications for changes.",
"notSubscribedNamespace": "You are not subscribed to this namespace and won't receive notifications for changes.",
"subscribedList": "You are currently subscribed to this list and will receive notifications for changes.",
"notSubscribedList": "You are not subscribed to this list and won't receive notifications for changes.",
"subscribedTask": "You are currently subscribed to this task and will receive notifications for changes.",
"notSubscribedTask": "You are not subscribed to this task and won't receive notifications for changes.",
"subscribedThroughParent": "You can't unsubscribe here because you are subscribed to this {entity} through its {parent}.",
"subscribed": "You are currently subscribed to this {entity} and will receive notifications for changes.",
"notSubscribed": "You are not subscribed to this {entity} and won't receive notifications for changes.",
"subscribe": "Subscribe",
"unsubscribe": "Unsubscribe",
"subscribeSuccessNamespace": "You are now subscribed to this namespace",
"unsubscribeSuccessNamespace": "You are now unsubscribed to this namespace",
"subscribeSuccessList": "You are now subscribed to this list",
"unsubscribeSuccessList": "You are now unsubscribed to this list",
"subscribeSuccessTask": "You are now subscribed to this task",
"unsubscribeSuccessTask": "You are now unsubscribed to this task"
"subscribeSuccess": "You are now subscribed to this {entity}",
"unsubscribeSuccess": "You are now unsubscribed to this {entity}"
},
"attachment": {
"title": "Bijlagen",
@ -700,11 +690,7 @@
"deleteTooltip": "Verwijder deze bijlage",
"deleteText1": "Weet je zeker dat je de bijlage {filename} wilt verwijderen?",
"copyUrl": "URL kopiëren",
"copyUrlTooltip": "Copy the url of this attachment for usage in text",
"setAsCover": "Make cover",
"unsetAsCover": "Remove cover",
"successfullyChangedCoverImage": "The cover image was successfully changed.",
"usedAsCover": "Cover image"
"copyUrlTooltip": "Copy the url of this attachment for usage in text"
},
"comment": {
"title": "Reacties",
@ -853,12 +839,6 @@
"text1": "Weet je zeker dat je deze gebruiker wilt verwijderen uit het team?",
"text2": "They will lose access to all lists and namespaces this team has access to. This CANNOT BE UNDONE!",
"success": "The user was successfully deleted from the team."
},
"leave": {
"title": "Leave team",
"text1": "Are you sure you want to leave this team?",
"text2": "You will loose access to all lists and namespaces this team has access to. If you change your mind you'll need a team admin to add you again.",
"success": "You have successfully left the team."
}
},
"attributes": {

View file

@ -169,14 +169,6 @@
"title": "Tytuł listy",
"color": "Kolor",
"lists": "Listy",
"list": {
"title": "Lista",
"add": "Dodaj",
"addPlaceholder": "Dodaj nowe zadanie…",
"empty": "Ta lista jest obecnie pusta.",
"newTaskCta": "Utwórz nowe zadanie.",
"editTask": "Edytuj zadanie"
},
"search": "Wpisz, aby wyszukać listę…",
"searchSelect": "Kliknij lub naciśnij Enter, aby wybrać tę listę",
"shared": "Współdzielone listy",
@ -278,6 +270,14 @@
"delete": "Usuń"
}
},
"list": {
"title": "Lista",
"add": "Dodaj",
"addPlaceholder": "Dodaj nowe zadanie…",
"empty": "Ta lista jest obecnie pusta.",
"newTaskCta": "Utwórz nowe zadanie.",
"editTask": "Edytuj zadanie"
},
"gantt": {
"title": "Gantt",
"showTasksWithoutDates": "Pokaż zadania, które nie mają ustawionych dat",
@ -672,23 +672,13 @@
"updated": "Zaktualizowano"
},
"subscription": {
"subscribedListThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this list through its namespace.",
"subscribedTaskThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this task through its namespace.",
"subscribedTaskThroughParentList": "You can't unsubscribe here because you are subscribed to this task through its list.",
"subscribedNamespace": "You are currently subscribed to this namespace and will receive notifications for changes.",
"notSubscribedNamespace": "You are not subscribed to this namespace and won't receive notifications for changes.",
"subscribedList": "You are currently subscribed to this list and will receive notifications for changes.",
"notSubscribedList": "You are not subscribed to this list and won't receive notifications for changes.",
"subscribedTask": "You are currently subscribed to this task and will receive notifications for changes.",
"notSubscribedTask": "You are not subscribed to this task and won't receive notifications for changes.",
"subscribedThroughParent": "Nie możesz zrezygnować z subskrypcji, ponieważ subskrybujesz {entity} za pośrednictwem {parent}.",
"subscribed": "Obecnie subskrybujesz {entity} i będziesz otrzymywać powiadomienia o zmianach.",
"notSubscribed": "Nie subskrybujesz {entity} i nie będziesz otrzymywać powiadomień o zmianach.",
"subscribe": "Subskrybuj",
"unsubscribe": "Anuluj subskrypcję",
"subscribeSuccessNamespace": "You are now subscribed to this namespace",
"unsubscribeSuccessNamespace": "You are now unsubscribed to this namespace",
"subscribeSuccessList": "You are now subscribed to this list",
"unsubscribeSuccessList": "You are now unsubscribed to this list",
"subscribeSuccessTask": "You are now subscribed to this task",
"unsubscribeSuccessTask": "You are now unsubscribed to this task"
"subscribeSuccess": "Od teraz subskrybujesz {entity}",
"unsubscribeSuccess": "Już nie subskrybujesz {entity}"
},
"attachment": {
"title": "Załączniki",
@ -700,11 +690,7 @@
"deleteTooltip": "Usuń ten załącznik",
"deleteText1": "Czy na pewno chcesz usunąć załącznik {filename}?",
"copyUrl": "Kopiuj URL",
"copyUrlTooltip": "Skopiuj adres URL tego załącznika do użycia w tekście",
"setAsCover": "Make cover",
"unsetAsCover": "Remove cover",
"successfullyChangedCoverImage": "The cover image was successfully changed.",
"usedAsCover": "Cover image"
"copyUrlTooltip": "Skopiuj adres URL tego załącznika do użycia w tekście"
},
"comment": {
"title": "Komentarze",
@ -853,12 +839,6 @@
"text1": "Czy na pewno chcesz usunąć tego użytkownika z zespołu?",
"text2": "Utraci on dostęp do wszystkich list i sekcji, do których ma dostęp ten zespół. Tego NIE DA SIĘ COFNĄĆ!",
"success": "Użytkownik został pomyślnie usunięty z zespołu."
},
"leave": {
"title": "Leave team",
"text1": "Are you sure you want to leave this team?",
"text2": "You will loose access to all lists and namespaces this team has access to. If you change your mind you'll need a team admin to add you again.",
"success": "You have successfully left the team."
}
},
"attributes": {

View file

@ -169,14 +169,6 @@
"title": "List Title",
"color": "Color",
"lists": "Lists",
"list": {
"title": "List",
"add": "Add",
"addPlaceholder": "Add a new task…",
"empty": "This list is currently empty.",
"newTaskCta": "Create a new task.",
"editTask": "Edit Task"
},
"search": "Type to search for a list…",
"searchSelect": "Click or press enter to select this list",
"shared": "Shared Lists",
@ -278,6 +270,14 @@
"delete": "Delete"
}
},
"list": {
"title": "List",
"add": "Add",
"addPlaceholder": "Add a new task…",
"empty": "This list is currently empty.",
"newTaskCta": "Create a new task.",
"editTask": "Edit Task"
},
"gantt": {
"title": "Gantt",
"showTasksWithoutDates": "Show tasks which don't have dates set",
@ -672,23 +672,13 @@
"updated": "Updated"
},
"subscription": {
"subscribedListThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this list through its namespace.",
"subscribedTaskThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this task through its namespace.",
"subscribedTaskThroughParentList": "You can't unsubscribe here because you are subscribed to this task through its list.",
"subscribedNamespace": "You are currently subscribed to this namespace and will receive notifications for changes.",
"notSubscribedNamespace": "You are not subscribed to this namespace and won't receive notifications for changes.",
"subscribedList": "You are currently subscribed to this list and will receive notifications for changes.",
"notSubscribedList": "You are not subscribed to this list and won't receive notifications for changes.",
"subscribedTask": "You are currently subscribed to this task and will receive notifications for changes.",
"notSubscribedTask": "You are not subscribed to this task and won't receive notifications for changes.",
"subscribedThroughParent": "You can't unsubscribe here because you are subscribed to this {entity} through its {parent}.",
"subscribed": "You are currently subscribed to this {entity} and will receive notifications for changes.",
"notSubscribed": "You are not subscribed to this {entity} and won't receive notifications for changes.",
"subscribe": "Subscribe",
"unsubscribe": "Unsubscribe",
"subscribeSuccessNamespace": "You are now subscribed to this namespace",
"unsubscribeSuccessNamespace": "You are now unsubscribed to this namespace",
"subscribeSuccessList": "You are now subscribed to this list",
"unsubscribeSuccessList": "You are now unsubscribed to this list",
"subscribeSuccessTask": "You are now subscribed to this task",
"unsubscribeSuccessTask": "You are now unsubscribed to this task"
"subscribeSuccess": "You are now subscribed to this {entity}",
"unsubscribeSuccess": "You are now unsubscribed to this {entity}"
},
"attachment": {
"title": "Attachments",
@ -700,11 +690,7 @@
"deleteTooltip": "Delete this attachment",
"deleteText1": "Are you sure you want to delete the attachment {filename}?",
"copyUrl": "Copy URL",
"copyUrlTooltip": "Copy the url of this attachment for usage in text",
"setAsCover": "Make cover",
"unsetAsCover": "Remove cover",
"successfullyChangedCoverImage": "The cover image was successfully changed.",
"usedAsCover": "Cover image"
"copyUrlTooltip": "Copy the url of this attachment for usage in text"
},
"comment": {
"title": "Comments",
@ -853,12 +839,6 @@
"text1": "Are you sure you want to remove this user from the team?",
"text2": "They will lose access to all lists and namespaces this team has access to. This CANNOT BE UNDONE!",
"success": "The user was successfully deleted from the team."
},
"leave": {
"title": "Leave team",
"text1": "Are you sure you want to leave this team?",
"text2": "You will loose access to all lists and namespaces this team has access to. If you change your mind you'll need a team admin to add you again.",
"success": "You have successfully left the team."
}
},
"attributes": {

View file

@ -169,14 +169,6 @@
"title": "Título da Lista",
"color": "Cor",
"lists": "Listas",
"list": {
"title": "Lista",
"add": "Adicionar",
"addPlaceholder": "Adicionar uma nova tarefa…",
"empty": "Esta lista está atualmente vazia.",
"newTaskCta": "Cria uma nova tarefa.",
"editTask": "Editar Tarefa"
},
"search": "Escreve para pesquisar por uma lista…",
"searchSelect": "Clica ou pressiona Enter para selecionar esta lista",
"shared": "Listas Partilhadas",
@ -278,6 +270,14 @@
"delete": "Eliminar"
}
},
"list": {
"title": "Lista",
"add": "Adicionar",
"addPlaceholder": "Adicionar uma nova tarefa…",
"empty": "Esta lista está atualmente vazia.",
"newTaskCta": "Cria uma nova tarefa.",
"editTask": "Editar Tarefa"
},
"gantt": {
"title": "Gantt",
"showTasksWithoutDates": "Mostrar tarefas que não têm datas atríbuidas",
@ -672,23 +672,13 @@
"updated": "Atualizado"
},
"subscription": {
"subscribedListThroughParentNamespace": "Não podes cancelar a tua subscrição aqui porque estás subscrito nesta lista através do seu espaço.",
"subscribedTaskThroughParentNamespace": "Não podes cancelar a tua subscrição aqui porque estás subscrito nesta tarefa através do seu espaço.",
"subscribedTaskThroughParentList": "Não podes cancelar a tua subscrição aqui porque estás subscrito nesta tarefa através da sua lista.",
"subscribedNamespace": "Estás atualmente subscrito a este espaço e serás notificado de alterações.",
"notSubscribedNamespace": "Não estás subscrito a este espaço e não serás notificado de alterações.",
"subscribedList": "Estás atualmente subscrito a esta lista e serás notificado de alterações.",
"notSubscribedList": "Não estás subscrito a esta lista e não serás notificado de alterações.",
"subscribedTask": "Estás atualmente subscrito a esta tarefa e serás notificado de alterações.",
"notSubscribedTask": "Não estás subscrito a esta tarefa e não serás notificado de alterações.",
"subscribedThroughParent": "Não podes cancelar a tua subscrição aqui porque estás subscrito nesta {entity} através de {parent}.",
"subscribed": "Estás atualmente subscrito a esta {entity} e serás notificado de alterações.",
"notSubscribed": "Não estás subscrito a esta {entity} e não serás notificado de alterações.",
"subscribe": "Subscrever",
"unsubscribe": "Remover Subscrição",
"subscribeSuccessNamespace": "Estás agora subscrito a este espaço",
"unsubscribeSuccessNamespace": "Não estás mais subcrito a este espaço",
"subscribeSuccessList": "Estás agora subscrito a esta lista",
"unsubscribeSuccessList": "Não estás mais subcrito a esta lista",
"subscribeSuccessTask": "Estás agora subscrito a esta tarefa",
"unsubscribeSuccessTask": "Não estás mais subcrito a esta tarefa"
"subscribeSuccess": "Estás agora subscrito a esta {entity}",
"unsubscribeSuccess": "Não estás mais subcrito a esta {entity}"
},
"attachment": {
"title": "Anexos",
@ -700,11 +690,7 @@
"deleteTooltip": "Eliminar este anexo",
"deleteText1": "Tens a certeza que pretendes eliminar o anexo {filename}?",
"copyUrl": "Copiar URL",
"copyUrlTooltip": "Copia o url deste anexo para o utilizar no texto",
"setAsCover": "Criar capa",
"unsetAsCover": "Remover capa",
"successfullyChangedCoverImage": "A imagem de capa foi alterada com sucesso.",
"usedAsCover": "Imagem de capa"
"copyUrlTooltip": "Copia o url deste anexo para o utilizar no texto"
},
"comment": {
"title": "Comentários",
@ -853,12 +839,6 @@
"text1": "Tens a certeza que pretendes remover este utilizador da equipa?",
"text2": "Eles perderão o acesso a todas as listas e espaços a que esta equipa tem acesso. Isto NÃO PODER SER REVERTIDO!",
"success": "O utilizador foi removido da equipa com sucesso."
},
"leave": {
"title": "Sair da equipa",
"text1": "Tens a certeza de que queres sair desta equipa?",
"text2": "Vais perder acesso a todas as listas e espaços a que esta equipa tem acesso. Se mudares de ideias, vais necessitar que um administrador da equipa te adicione novamente.",
"success": "Saíste da equipa com sucesso."
}
},
"attributes": {

View file

@ -169,14 +169,6 @@
"title": "List Title",
"color": "Color",
"lists": "Lists",
"list": {
"title": "List",
"add": "Add",
"addPlaceholder": "Add a new task…",
"empty": "This list is currently empty.",
"newTaskCta": "Create a new task.",
"editTask": "Edit Task"
},
"search": "Type to search for a list…",
"searchSelect": "Click or press enter to select this list",
"shared": "Shared Lists",
@ -278,6 +270,14 @@
"delete": "Delete"
}
},
"list": {
"title": "List",
"add": "Add",
"addPlaceholder": "Add a new task…",
"empty": "This list is currently empty.",
"newTaskCta": "Create a new task.",
"editTask": "Edit Task"
},
"gantt": {
"title": "Gantt",
"showTasksWithoutDates": "Show tasks which don't have dates set",
@ -672,23 +672,13 @@
"updated": "Updated"
},
"subscription": {
"subscribedListThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this list through its namespace.",
"subscribedTaskThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this task through its namespace.",
"subscribedTaskThroughParentList": "You can't unsubscribe here because you are subscribed to this task through its list.",
"subscribedNamespace": "You are currently subscribed to this namespace and will receive notifications for changes.",
"notSubscribedNamespace": "You are not subscribed to this namespace and won't receive notifications for changes.",
"subscribedList": "You are currently subscribed to this list and will receive notifications for changes.",
"notSubscribedList": "You are not subscribed to this list and won't receive notifications for changes.",
"subscribedTask": "You are currently subscribed to this task and will receive notifications for changes.",
"notSubscribedTask": "You are not subscribed to this task and won't receive notifications for changes.",
"subscribedThroughParent": "You can't unsubscribe here because you are subscribed to this {entity} through its {parent}.",
"subscribed": "You are currently subscribed to this {entity} and will receive notifications for changes.",
"notSubscribed": "You are not subscribed to this {entity} and won't receive notifications for changes.",
"subscribe": "Subscribe",
"unsubscribe": "Unsubscribe",
"subscribeSuccessNamespace": "You are now subscribed to this namespace",
"unsubscribeSuccessNamespace": "You are now unsubscribed to this namespace",
"subscribeSuccessList": "You are now subscribed to this list",
"unsubscribeSuccessList": "You are now unsubscribed to this list",
"subscribeSuccessTask": "You are now subscribed to this task",
"unsubscribeSuccessTask": "You are now unsubscribed to this task"
"subscribeSuccess": "You are now subscribed to this {entity}",
"unsubscribeSuccess": "You are now unsubscribed to this {entity}"
},
"attachment": {
"title": "Attachments",
@ -700,11 +690,7 @@
"deleteTooltip": "Delete this attachment",
"deleteText1": "Are you sure you want to delete the attachment {filename}?",
"copyUrl": "Copy URL",
"copyUrlTooltip": "Copy the url of this attachment for usage in text",
"setAsCover": "Make cover",
"unsetAsCover": "Remove cover",
"successfullyChangedCoverImage": "The cover image was successfully changed.",
"usedAsCover": "Cover image"
"copyUrlTooltip": "Copy the url of this attachment for usage in text"
},
"comment": {
"title": "Comments",
@ -853,12 +839,6 @@
"text1": "Are you sure you want to remove this user from the team?",
"text2": "They will lose access to all lists and namespaces this team has access to. This CANNOT BE UNDONE!",
"success": "The user was successfully deleted from the team."
},
"leave": {
"title": "Leave team",
"text1": "Are you sure you want to leave this team?",
"text2": "You will loose access to all lists and namespaces this team has access to. If you change your mind you'll need a team admin to add you again.",
"success": "You have successfully left the team."
}
},
"attributes": {

View file

@ -169,14 +169,6 @@
"title": "Название списка",
"color": "Цвет",
"lists": "Списки",
"list": {
"title": "Список",
"add": "Добавить",
"addPlaceholder": "Добавить новую задачу…",
"empty": "Список сейчас пуст.",
"newTaskCta": "Создать новую задачу.",
"editTask": "Изменить задачу"
},
"search": "Введи запрос для поиска списка…",
"searchSelect": "Кликни или нажми Enter для выбора этого списка",
"shared": "Общие списки",
@ -278,6 +270,14 @@
"delete": "Удалить"
}
},
"list": {
"title": "Список",
"add": "Добавить",
"addPlaceholder": "Добавить новую задачу…",
"empty": "Список сейчас пуст.",
"newTaskCta": "Создать новую задачу.",
"editTask": "Изменить задачу"
},
"gantt": {
"title": "Гант",
"showTasksWithoutDates": "Показать задачи без установленной даты",
@ -672,23 +672,13 @@
"updated": "Дата изменения"
},
"subscription": {
"subscribedListThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this list through its namespace.",
"subscribedTaskThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this task through its namespace.",
"subscribedTaskThroughParentList": "You can't unsubscribe here because you are subscribed to this task through its list.",
"subscribedNamespace": "You are currently subscribed to this namespace and will receive notifications for changes.",
"notSubscribedNamespace": "You are not subscribed to this namespace and won't receive notifications for changes.",
"subscribedList": "You are currently subscribed to this list and will receive notifications for changes.",
"notSubscribedList": "You are not subscribed to this list and won't receive notifications for changes.",
"subscribedTask": "You are currently subscribed to this task and will receive notifications for changes.",
"notSubscribedTask": "You are not subscribed to this task and won't receive notifications for changes.",
"subscribedThroughParent": "Ты не можешь отписаться здесь, потому что ты подписан на {entity} через {parent}.",
"subscribed": "Ты подписан на {entity} и будешь получать уведомления об изменениях.",
"notSubscribed": "Ты не подписан на {entity} и не будешь получать уведомления об изменениях.",
"subscribe": "Подписаться",
"unsubscribe": "Отписаться",
"subscribeSuccessNamespace": "You are now subscribed to this namespace",
"unsubscribeSuccessNamespace": "You are now unsubscribed to this namespace",
"subscribeSuccessList": "You are now subscribed to this list",
"unsubscribeSuccessList": "You are now unsubscribed to this list",
"subscribeSuccessTask": "You are now subscribed to this task",
"unsubscribeSuccessTask": "You are now unsubscribed to this task"
"subscribeSuccess": "Ты подписался на {entity}",
"unsubscribeSuccess": "Ты отписался от {entity}"
},
"attachment": {
"title": "Вложения",
@ -700,11 +690,7 @@
"deleteTooltip": "Удалить это вложение",
"deleteText1": "Удалить вложение {filename}?",
"copyUrl": "Скопировать URL",
"copyUrlTooltip": "Скопировать ссылку на это вложение для использования в тексте",
"setAsCover": "Make cover",
"unsetAsCover": "Remove cover",
"successfullyChangedCoverImage": "The cover image was successfully changed.",
"usedAsCover": "Cover image"
"copyUrlTooltip": "Скопировать ссылку на это вложение для использования в тексте"
},
"comment": {
"title": "Комментарии",
@ -853,12 +839,6 @@
"text1": "Удалить этого пользователя из команды?",
"text2": "Пользователь потеряет доступ ко всем спискам и пространствам имён, к котором есть доступ у команды. Это действие отменить нельзя!",
"success": "Пользователь удалён из команды."
},
"leave": {
"title": "Leave team",
"text1": "Are you sure you want to leave this team?",
"text2": "You will loose access to all lists and namespaces this team has access to. If you change your mind you'll need a team admin to add you again.",
"success": "You have successfully left the team."
}
},
"attributes": {

View file

@ -169,14 +169,6 @@
"title": "List Title",
"color": "Color",
"lists": "Lists",
"list": {
"title": "List",
"add": "Add",
"addPlaceholder": "Add a new task…",
"empty": "This list is currently empty.",
"newTaskCta": "Create a new task.",
"editTask": "Edit Task"
},
"search": "Type to search for a list…",
"searchSelect": "Click or press enter to select this list",
"shared": "Shared Lists",
@ -278,6 +270,14 @@
"delete": "Delete"
}
},
"list": {
"title": "List",
"add": "Add",
"addPlaceholder": "Add a new task…",
"empty": "This list is currently empty.",
"newTaskCta": "Create a new task.",
"editTask": "Edit Task"
},
"gantt": {
"title": "Gantt",
"showTasksWithoutDates": "Show tasks which don't have dates set",
@ -672,23 +672,13 @@
"updated": "Updated"
},
"subscription": {
"subscribedListThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this list through its namespace.",
"subscribedTaskThroughParentNamespace": "You can't unsubscribe here because you are subscribed to this task through its namespace.",
"subscribedTaskThroughParentList": "You can't unsubscribe here because you are subscribed to this task through its list.",
"subscribedNamespace": "You are currently subscribed to this namespace and will receive notifications for changes.",
"notSubscribedNamespace": "You are not subscribed to this namespace and won't receive notifications for changes.",
"subscribedList": "You are currently subscribed to this list and will receive notifications for changes.",
"notSubscribedList": "You are not subscribed to this list and won't receive notifications for changes.",
"subscribedTask": "You are currently subscribed to this task and will receive notifications for changes.",
"notSubscribedTask": "You are not subscribed to this task and won't receive notifications for changes.",
"subscribedThroughParent": "You can't unsubscribe here because you are subscribed to this {entity} through its {parent}.",
"subscribed": "You are currently subscribed to this {entity} and will receive notifications for changes.",
"notSubscribed": "You are not subscribed to this {entity} and won't receive notifications for changes.",
"subscribe": "Subscribe",
"unsubscribe": "Unsubscribe",
"subscribeSuccessNamespace": "You are now subscribed to this namespace",
"unsubscribeSuccessNamespace": "You are now unsubscribed to this namespace",
"subscribeSuccessList": "You are now subscribed to this list",
"unsubscribeSuccessList": "You are now unsubscribed to this list",
"subscribeSuccessTask": "You are now subscribed to this task",
"unsubscribeSuccessTask": "You are now unsubscribed to this task"
"subscribeSuccess": "You are now subscribed to this {entity}",
"unsubscribeSuccess": "You are now unsubscribed to this {entity}"
},
"attachment": {
"title": "Attachments",
@ -700,11 +690,7 @@
"deleteTooltip": "Delete this attachment",
"deleteText1": "Are you sure you want to delete the attachment {filename}?",
"copyUrl": "Copy URL",
"copyUrlTooltip": "Copy the url of this attachment for usage in text",
"setAsCover": "Make cover",
"unsetAsCover": "Remove cover",
"successfullyChangedCoverImage": "The cover image was successfully changed.",
"usedAsCover": "Cover image"
"copyUrlTooltip": "Copy the url of this attachment for usage in text"
},
"comment": {
"title": "Comments",
@ -853,12 +839,6 @@
"text1": "Are you sure you want to remove this user from the team?",
"text2": "They will lose access to all lists and namespaces this team has access to. This CANNOT BE UNDONE!",
"success": "The user was successfully deleted from the team."
},
"leave": {
"title": "Leave team",
"text1": "Are you sure you want to leave this team?",
"text2": "You will loose access to all lists and namespaces this team has access to. If you change your mind you'll need a team admin to add you again.",
"success": "You have successfully left the team."
}
},
"attributes": {

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