Browse Source

Merge branch 'main' into feature/date-math

pull/1342/head
kolaente 4 months ago
parent
commit
0b6a74d11e
Signed by: konrad
GPG Key ID: F40E70337AB24C9B
  1. 2
      .drone.yml
  2. 2
      cypress.json
  3. 4
      cypress/integration/list/list-view-kanban.spec.js
  4. 8
      cypress/integration/task/task.spec.js
  5. 2
      cypress/integration/user/logout.spec.js
  6. 63
      package.json
  7. 12
      scripts/deploy-preview-netlify.js
  8. 2
      scripts/deploy-preview-netlify.js.sha384
  9. 2
      scripts/serve-dist.js
  10. 4
      src/App.vue
  11. 4
      src/components/base/BaseButton.vue
  12. 5
      src/components/home/Logo.vue
  13. 19
      src/components/home/MenuButton.vue
  14. 132
      src/components/home/TheNavigation.vue
  15. 2
      src/components/input/button.vue
  16. 123
      src/components/misc/api-config.vue
  17. 2
      src/components/modal/modal.vue
  18. 47
      src/components/tasks/partials/createdUpdated.vue
  19. 1
      src/components/tasks/partials/kanban-card.vue
  20. 2
      src/components/tasks/partials/relatedTasks.vue
  21. 14
      src/helpers/auth.ts
  22. 13
      src/http-common/index.js
  23. 11
      src/i18n/index.js
  24. 27
      src/i18n/lang/cs-CZ.json
  25. 25
      src/i18n/lang/de-DE.json
  26. 37
      src/i18n/lang/de-swiss.json
  27. 37
      src/i18n/lang/en.json
  28. 13
      src/i18n/lang/es-ES.json
  29. 25
      src/i18n/lang/fr-FR.json
  30. 33
      src/i18n/lang/it-IT.json
  31. 967
      src/i18n/lang/nl-NL.json
  32. 1461
      src/i18n/lang/pl-PL.json
  33. 13
      src/i18n/lang/pt-BR.json
  34. 1469
      src/i18n/lang/pt-PT.json
  35. 13
      src/i18n/lang/ro-RO.json
  36. 15
      src/i18n/lang/ru-RU.json
  37. 13
      src/i18n/lang/sv-SE.json
  38. 13
      src/i18n/lang/tr-TR.json
  39. 15
      src/i18n/lang/vi-VN.json
  40. 2
      src/models/emailUpdate.js
  41. 1
      src/models/userSettings.js
  42. 22
      src/store/modules/auth.js
  43. 28
      src/store/modules/config.js
  44. 58
      src/store/modules/tasks.js
  45. 246
      src/views/list/ListKanban.vue
  46. 5
      src/views/namespaces/settings/archive.vue
  47. 74
      src/views/tasks/TaskDetailView.vue
  48. 8
      src/views/user/Login.vue
  49. 36
      src/views/user/settings/General.vue
  50. 1
      tsconfig.json
  51. 2
      vite.config.ts
  52. 1844
      yarn.lock

2
.drone.yml

@ -120,7 +120,7 @@ steps:
from_secret: cypress_project_key
commands:
- sed -i 's/localhost/api/g' dist/index.html
- yarn serve:dist & npx wait-on http://localhost:5000
- yarn serve:dist & npx wait-on http://localhost:4173
- yarn test:frontend --browser chrome --record
depends_on:
- dependencies

2
cypress.json

@ -1,5 +1,5 @@
{
"baseUrl": "http://localhost:5000",
"baseUrl": "http://localhost:4173",
"env": {
"API_URL": "http://localhost:3456/api/v1",
"TEST_SECRET": "averyLongSecretToSe33dtheDB"

4
cypress/integration/list/list-view-kanban.spec.js

@ -132,7 +132,7 @@ describe('List View Kanban', () => {
cy.getSettled('.kanban .bucket .tasks .task')
.contains(tasks[0].title)
.first()
.drag('.kanban .bucket:nth-child(2) .tasks .dropper')
.drag('.kanban .bucket:nth-child(2) .tasks')
cy.get('.kanban .bucket:nth-child(2) .tasks')
.should('contain', tasks[0].title)
@ -176,7 +176,7 @@ describe('List View Kanban', () => {
.click()
cy.get('.task-view .action-buttons .button', { timeout: 3000 })
.contains('Move task')
.contains('Move')
.click()
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
.type(`${lists[1].title}{enter}`)

8
cypress/integration/task/task.spec.js

@ -210,7 +210,7 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Move task')
.contains('Move')
.click()
cy.get('.task-view .content.details .field .multiselect.control .input-wrapper input')
.type(`${lists[1].title}{enter}`)
@ -237,7 +237,7 @@ describe('Task', () => {
cy.get('.task-view .action-buttons .button')
.should('be.visible')
.contains('Delete task')
.contains('Delete')
.click()
cy.get('.modal-mask .modal-container .modal-content .header')
.should('contain', 'Delete this task')
@ -317,7 +317,7 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Add labels')
.contains('Add Labels')
.should('be.visible')
.click()
cy.get('.task-view .details.labels-list .multiselect input')
@ -344,7 +344,7 @@ describe('Task', () => {
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .action-buttons .button')
.contains('Add labels')
.contains('Add Labels')
.click()
cy.get('.task-view .details.labels-list .multiselect input')
.type(labels[0].title)

2
cypress/integration/user/logout.spec.js

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

63
package.json

@ -5,7 +5,7 @@
"scripts": {
"serve": "vite",
"serve:dist-dev": "node scripts/serve-dist.js",
"serve:dist": "vite preview",
"serve:dist": "vite preview --port 4173",
"build": "vite build && workbox copyLibraries dist/",
"build:modern-only": "BUILD_MODERN_ONLY=true vite build && workbox copyLibraries dist/",
"build:dev": "vite build -m development --outDir dist-dev/",
@ -20,18 +20,18 @@
"dependencies": {
"@github/hotkey": "2.0.0",
"@kyvg/vue3-notification": "2.3.4",
"@sentry/tracing": "6.17.4",
"@sentry/vue": "6.17.4",
"@sentry/tracing": "6.17.9",
"@sentry/vue": "6.17.9",
"@types/is-touch-device": "1.0.0",
"@vue/compat": "3.2.29",
"@vueuse/core": "7.5.5",
"@vueuse/router": "7.5.5",
"@vue/compat": "3.2.31",
"@vueuse/core": "7.6.2",
"@vueuse/router": "7.6.2",
"bulma-css-variables": "0.9.33",
"camel-case": "4.1.2",
"codemirror": "5.65.1",
"copy-to-clipboard": "3.3.1",
"date-fns": "2.28.0",
"dompurify": "2.3.5",
"dompurify": "2.3.6",
"easymde": "2.16.1",
"flatpickr": "4.6.9",
"flexsearch": "0.7.21",
@ -44,8 +44,8 @@
"snake-case": "3.0.4",
"ufo": "0.7.10",
"v-tooltip": "4.0.0-beta.17",
"vue": "3.2.29",
"vue-advanced-cropper": "2.8.0",
"vue": "3.2.31",
"vue-advanced-cropper": "2.8.1",
"vue-drag-resize": "2.0.3",
"vue-flatpickr-component": "9.0.5",
"vue-i18n": "9.2.0-beta.30",
@ -56,41 +56,40 @@
},
"devDependencies": {
"@4tw/cypress-drag-drop": "2.1.0",
"@faker-js/faker": "6.0.0-alpha.5",
"@fortawesome/fontawesome-svg-core": "1.2.36",
"@faker-js/faker": "6.0.0-alpha.7",
"@fortawesome/fontawesome-svg-core": "1.3.0",
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/vue-fontawesome": "3.0.0-5",
"@types/flexsearch": "0.7.2",
"@typescript-eslint/eslint-plugin": "5.10.2",
"@typescript-eslint/parser": "5.10.2",
"@vitejs/plugin-legacy": "1.6.4",
"@vitejs/plugin-vue": "2.1.0",
"@typescript-eslint/eslint-plugin": "5.12.0",
"@typescript-eslint/parser": "5.12.0",
"@vitejs/plugin-legacy": "1.7.1",
"@vitejs/plugin-vue": "2.2.2",
"@vue/eslint-config-typescript": "10.0.0",
"autoprefixer": "10.4.2",
"axios": "0.25.0",
"browserslist": "4.19.1",
"caniuse-lite": "1.0.30001307",
"cypress": "9.4.1",
"esbuild": "0.14.18",
"eslint": "8.8.0",
"axios": "0.26.0",
"browserslist": "4.19.3",
"caniuse-lite": "1.0.30001312",
"cypress": "9.5.0",
"esbuild": "0.14.23",
"eslint": "8.9.0",
"eslint-plugin-vue": "8.4.1",
"express": "4.17.2",
"happy-dom": "2.31.1",
"netlify-cli": "8.15.0",
"express": "4.17.3",
"happy-dom": "2.39.1",
"netlify-cli": "8.16.1",
"postcss": "8.4.6",
"postcss-preset-env": "7.3.1",
"rollup": "2.67.0",
"postcss-preset-env": "7.4.1",
"rollup": "2.67.3",
"rollup-plugin-visualizer": "5.5.4",
"sass": "1.49.7",
"slugify": "1.6.5",
"sass": "1.49.8",
"typescript": "4.5.5",
"vite": "2.7.13",
"vite": "2.8.4",
"vite-plugin-pwa": "0.11.13",
"vite-svg-loader": "3.1.2",
"vitest": "0.2.7",
"vue-tsc": "0.31.1",
"wait-on": "6.0.0",
"vitest": "0.4.2",
"vue-tsc": "0.31.4",
"wait-on": "6.0.1",
"workbox-cli": "6.4.2"
},
"eslintConfig": {

12
scripts/deploy-preview-netlify.js

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

2
scripts/deploy-preview-netlify.js.sha384

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

2
scripts/serve-dist.js

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

4
src/App.vue

@ -1,7 +1,7 @@
<template>
<ready>
<template v-if="authUser">
<top-navigation/>
<TheNavigation/>
<content-auth/>
</template>
<content-link-share v-else-if="authLinkShare"/>
@ -27,7 +27,7 @@ import {success} from '@/message'
import Notification from '@/components/misc/notification.vue'
import KeyboardShortcuts from './components/misc/keyboard-shortcuts/index.vue'
import TopNavigation from './components/home/topNavigation.vue'
import TheNavigation from '@/components/home/TheNavigation.vue'
import ContentAuth from './components/home/contentAuth.vue'
import ContentLinkShare from './components/home/contentLinkShare.vue'
import NoAuthWrapper from '@/components/misc/no-auth-wrapper.vue'

4
src/components/base/BaseButton.vue

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

5
src/components/home/Logo.vue

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

19
src/components/home/MenuButton.vue

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

132
src/components/home/topNavigation.vue → src/components/home/TheNavigation.vue

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

2
src/components/input/button.vue

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

123
src/components/misc/api-config.vue

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

2
src/components/modal/modal.vue

@ -23,7 +23,7 @@
}"
>
<BaseButton
@click="emit('close')"
@click="$emit('close')"
class="close"
>
<icon icon="times"/>

47
src/components/tasks/partials/createdUpdated.vue

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

1
src/components/tasks/partials/kanban-card.vue

@ -138,7 +138,6 @@ $task-background: var(--white);
border: 3px solid transparent;
font-size: .9rem;
margin: .5rem;
padding: .4rem;
border-radius: $radius;
background: $task-background;

2
src/components/tasks/partials/relatedTasks.vue

@ -1,7 +1,7 @@
<template>
<div class="task-relations">
<x-button
v-if="Object.keys(relatedTasks).length > 0"
v-if="editEnabled && Object.keys(relatedTasks).length > 0"
@click="showNewRelationForm = !showNewRelationForm"
class="is-pulled-right add-task-relation-button"
:class="{'is-active': showNewRelationForm}"

14
src/helpers/auth.ts

@ -1,4 +1,4 @@
import {HTTPFactory} from '@/http-common'
import {AuthenticatedHTTPFactory} from '@/http-common'
import {AxiosResponse} from 'axios'
let savedToken: string | null = null
@ -6,8 +6,6 @@ let savedToken: string | null = null
/**
* Saves a token while optionally saving it to lacal storage. This is used when viewing a link share:
* It enables viewing multiple link shares indipendently from each in multiple tabs other without overriding any other open ones.
* @param token
* @param persist
*/
export const saveToken = (token: string, persist: boolean) => {
savedToken = token
@ -18,7 +16,6 @@ export const saveToken = (token: string, persist: boolean) => {
/**
* Returns a saved token. If there is one saved in memory it will use that before anything else.
* @returns {string|null}
*/
export const getToken = (): string | null => {
if (savedToken !== null) {
@ -39,16 +36,11 @@ export const removeToken = () => {
/**
* Refreshes an auth token while ensuring it is updated everywhere.
* @returns {Promise<AxiosResponse<any>>}
*/
export async function refreshToken(persist: boolean): Promise<AxiosResponse> {
const HTTP = HTTPFactory()
const HTTP = AuthenticatedHTTPFactory()
try {
const response = await HTTP.post('user/token', null, {
headers: {
Authorization: `Bearer ${getToken()}`,
},
})
const response = await HTTP.post('user/token')
saveToken(response.data.token, persist)
return response

13
src/http-common/index.js

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

11
src/i18n/index.js

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

27
src/i18n/lang/cs-CZ.json

@ -31,10 +31,9 @@
"username": "Uživatelské jméno",
"usernameEmail": "Uživatelské jméno nebo e-mail",
"usernamePlaceholder": "např. Jarmil",
"email": "E-mailová adresa",
"email": "Email address",
"emailPlaceholder": "např. jarmil{'@'}vikunja.io",
"password": "Heslo",
"passwordRepeat": "Zopakovat heslo",
"passwordPlaceholder": "např. • • • • • • • •",
"forgotPassword": "Zapomenuté heslo?",
"resetPassword": "Obnovit heslo",
@ -45,12 +44,20 @@
"totpTitle": "Kód dvoufaktorového ověření",
"totpPlaceholder": "např. 123456",
"login": "Přihlásit se",
"register": "Registrovat",
"createAccount": "Create account",
"loginWith": "Přihlásit se pomocí {provider}",
"authenticating": "Ověřování…",
"openIdStateError": "Stav neodpovídá, odmítám pokračovat!",
"openIdGeneralError": "Došlo k chybě při ověřování proti třetí straně.",
"logout": "Odhlásit se"
"logout": "Odhlásit se",
"emailInvalid": "Please enter a valid email address.",
"usernameRequired": "Please provide a username.",
"passwordRequired": "Please provide a password.",
"showPassword": "Show the password",
"hidePassword": "Hide the password",
"noAccountYet": "Don't have an account yet?",
"alreadyHaveAnAccount": "Already have an account?",
"remember": "Stay logged in"
},
"settings": {
"title": "Nastavení",
@ -61,7 +68,7 @@
"currentPasswordPlaceholder": "Vaše současné heslo",
"passwordsDontMatch": "Nové heslo se neshoduje s potvrzením hesla.",
"passwordUpdateSuccess": "Heslo bylo úspěšně změněno.",
"updateEmailTitle": "Aktualizovat Vaši e-mailovou adresu",
"updateEmailTitle": "Update Your Email Address",
"updateEmailNew": "Nová e-mailová adresa",
"updateEmailSuccess": "Vaše e-mailová adresa byla úspěšně aktualizována. Poslali jsme vám odkaz pro její potvrzení.",
"general": {
@ -78,7 +85,8 @@
"weekStartSunday": "Neděle",
"weekStartMonday": "Pondělí",
"language": "Jazyk",
"defaultList": "Výchozí seznam"
"defaultList": "Výchozí seznam",
"timezone": "Time Zone"
},
"totp": {
"title": "Dvoufaktorové ověření",
@ -327,6 +335,7 @@
"archiveText": "Nebudete moci upravovat tento jmenný prostor ani vytvářet nové seznamy, dokud jej neodarchivujete. Všechny seznamy v tomto prostoru budou také archivovány.",
"unarchiveText": "Budete moci vytvářet nové úkoly nebo je upravovat.",
"success": "Prostor byl úspěšně archivován.",
"unarchiveSuccess": "The namespace was successfully un-archived.",
"description": "Pokud je prostor archivován, nelze vytvořit nové seznamy nebo je upravit."
},
"delete": {
@ -376,7 +385,7 @@
"showDoneTasks": "Zobrazit dokončené úkoly",
"sortAlphabetically": "Řadit podle abecedy",
"enablePriority": "Povolit filtrování podle priority",
"enablePercentDone": "Povolit filtrování dle dokončenosti",
"enablePercentDone": "Enable Filter By Progress",
"dueDateRange": "Rozsah termínu",
"startDateRange": "Začátek období",
"endDateRange": "Konec období",
@ -569,7 +578,7 @@
"endDate": "Nastavit koncové datum",
"reminders": "Nastavit připomenutí",
"repeatAfter": "Nastavit interval opakování",
"percentDone": "Nastavit procenta dokončeno",
"percentDone": "Set Progress",
"attachments": "Přidat přílohy",
"relatedTasks": "Přidat vztahy úkolu",
"moveList": "Přesunout úkol",
@ -589,7 +598,7 @@
"dueDate": "Termín",
"endDate": "Datum ukončení",
"labels": "Štítky",
"percentDone": "% Hotovo",
"percentDone": "Progress",
"priority": "Priorita",
"relatedTasks": "Související úkoly",
"reminders": "Připomínky",

25
src/i18n/lang/de-DE.json

@ -31,10 +31,9 @@
"username": "Anmeldename",
"usernameEmail": "Anmeldename oder E-Mail-Adresse",
"usernamePlaceholder": "z.B. frederick",
"email": "Email address",
"email": "E-Mail-Adresse",
"emailPlaceholder": "z.B. frederic{'@'}vikunja.io",
"password": "Passwort",
"passwordRepeat": "Gib dein Passwort erneut ein",
"passwordPlaceholder": "z.B. •••••••••••",
"forgotPassword": "Passwort vergessen?",
"resetPassword": "Setze dein Passwort zurück",
@ -45,12 +44,20 @@
"totpTitle": "Zwei-Faktor-Authentifizierungscode",
"totpPlaceholder": "z.B. 123456",
"login": "Anmelden",
"register": "Registrieren",
"createAccount": "Account erstellen",
"loginWith": "Mit {provider} anmelden",
"authenticating": "Authentifizierung…",
"openIdStateError": "Zustand stimmt nicht überein, fahre nicht fort!",
"openIdGeneralError": "Es ist ein Fehler bei der externen Authentisierung aufgetreten.",
"logout": "Abmelden"
"logout": "Abmelden",
"emailInvalid": "Bitte gib eine gültige E-Mail-Adresse ein.",
"usernameRequired": "Bitte gib einen Anmeldenamen ein.",
"passwordRequired": "Bitte gib ein Passwort ein.",
"showPassword": "Passwort anzeigen",
"hidePassword": "Passwort verbergen",
"noAccountYet": "Noch kein Account?",
"alreadyHaveAnAccount": "Hast du bereits einen Account?",
"remember": "Angemeldet bleiben"
},
"settings": {
"title": "Einstellungen",
@ -78,7 +85,8 @@
"weekStartSunday": "Sonntag",
"weekStartMonday": "Montag",
"language": "Sprache",
"defaultList": "Standard-Liste"
"defaultList": "Standard-Liste",
"timezone": "Zeitzone"
},
"totp": {
"title": "Zwei-Faktor-Authentifizierung",
@ -327,6 +335,7 @@
"archiveText": "Du kannst diesen Namespace nicht mehr bearbeiten oder neue Listen erstellen, bis du die Archivierung rückgängig machst. Das gilt auch für alle Listen in diesem Namespace.",
"unarchiveText": "Du kannst neue Aufgaben erstellen oder diese bearbeiten.",
"success": "Der Namespace wurde erfolgreich archiviert.",
"unarchiveSuccess": "Der Namespace wurde erfolgreich wiederhergestellt.",
"description": "In einem archivierten Namespace können Listen weder angelegt noch editiert werden."
},
"delete": {
@ -376,7 +385,7 @@
"showDoneTasks": "Erledigte Aufgaben anzeigen",
"sortAlphabetically": "Alphabetisch sortieren",
"enablePriority": "Filter nach Priorität aktivieren",
"enablePercentDone": "Filter nach % Erledigt aktivieren",
"enablePercentDone": "Filter nach Fortschritt aktivieren",
"dueDateRange": "Fälligkeitsbereich",
"startDateRange": "Startdatumsbereich",
"endDateRange": "Enddatumsbereich",
@ -569,7 +578,7 @@
"endDate": "Enddatum setzen",
"reminders": "Erinnerungen setzen",
"repeatAfter": "Wiederholung setzen",
"percentDone": "Prozent erledigt setzen",
"percentDone": "Fortschritt einstellen",
"attachments": "Anhänge hinzufügen",
"relatedTasks": "Aufgabenbeziehungen hinzufügen",
"moveList": "Aufgabe verschieben",
@ -589,7 +598,7 @@
"dueDate": "Fälligkeitsdatum",
"endDate": "Enddatum",
"labels": "Labels",
"percentDone": "% erledigt",
"percentDone": "Fortschritt",
"priority": "Priorität",
"relatedTasks": "Verwandte Aufgaben",
"reminders": "Erinnerungen",

37
src/i18n/lang/de-swiss.json

@ -31,7 +31,7 @@
"username": "Benutzernamä",
"usernameEmail": "Benutzernamä oder E-Mail Adrässe",
"usernamePlaceholder": "z.B. Hansruedi",
"email": "Email address",
"email": "E-Mail-Adresse",
"emailPlaceholder": "z.B. frederic{'@'}vikunja.io",
"password": "Passwort",
"passwordPlaceholder": "z.B. •••••••••••",
@ -44,19 +44,20 @@
"totpTitle": "Zweifaktor Authentifizierigs Ziffere",
"totpPlaceholder": "z.B. 123456",
"login": "Iihlogge",
"createAccount": "Create account",
"createAccount": "Account erstellen",
"loginWith": "Iihlogge mit {provider}",
"authenticating": "Authentifiziere…",
"openIdStateError": "Status stimmt nid überiih, ich verweigerä wiiter zmache!",
"openIdGeneralError": "Es ist ein Fehler bei der externen Authentisierung aufgetreten.",
"logout": "Uuslogge",
"emailInvalid": "Please enter a valid email address.",
"usernameRequired": "Please provide a username.",
"passwordRequired": "Please provide a password.",
"showPassword": "Show the password",
"hidePassword": "Hide the password",
"noAccountYet": "Don't have an account yet?",
"alreadyHaveAnAccount": "Already have an account?"
"emailInvalid": "Bitte gib eine gültige E-Mail-Adresse ein.",
"usernameRequired": "Bitte gib einen Anmeldenamen ein.",
"passwordRequired": "Bitte gib ein Passwort ein.",
"showPassword": "Passwort anzeigen",
"hidePassword": "Passwort verbergen",
"noAccountYet": "Noch kein Account?",
"alreadyHaveAnAccount": "Hast du bereits einen Account?",
"remember": "Angemeldet bleiben"
},
"settings": {
"title": "Iihstellige",
@ -67,7 +68,7 @@
"currentPasswordPlaceholder": "Diis jetzige Passwort",
"passwordsDontMatch": "Dis neue Passwort und siini Bestätigung stimmed nid überiih.",
"passwordUpdateSuccess": "Dis Passwort isch erfolgriich aktualisiert wordä.",
"updateEmailTitle": "Update Your Email Address",
"updateEmailTitle": "Aktualisiere deine E-Mail-Adresse",
"updateEmailNew": "Neui E-Mail Adrässä",
"updateEmailSuccess": "Dini E-Mail Adrässä isch erfolgriich gänderet worde. Mir hend dir en Link gschickt, um si zu bestätigä.",
"general": {
@ -84,7 +85,8 @@
"weekStartSunday": "Sunntig",
"weekStartMonday": "Määntig",
"language": "Sproch",
"defaultList": "Standard Liste"
"defaultList": "Standard Liste",
"timezone": "Zeitzone"
},
"totp": {
"title": "Zweifaktor Authentifizierig",
@ -101,9 +103,9 @@
"disableSuccess": "Zweifaktor Authentifizierig isch erfolgriich uusgschalte wore."
},
"caldav": {
"title": "Caldav",
"howTo": "Du chasch Vikunja zu Caldav Applikatione verbinde, um dini Uufgabe vo verschidene Gräät zgseh. Gib die Url i dim Client iih:",
"more": "Meh Informatione über Caldav in Vikunja"
"title": "CalDAV",
"howTo": "Du chasch Vikunja zu CalDAV Applikatione verbinde, um dini Uufgabe vo verschidene Gräät zgseh. Gib die Url i dim Client iih:",
"more": "Meh Informatione über CalDAV in Vikunja"
},
"avatar": {
"title": "Herr Der Elemente",
@ -333,6 +335,7 @@
"archiveText": "Du hesch kei möglichkeit meh de Namensruum z'bearbeite oder neui Listene drin z'erstelle, bis du si wider ent-archiviert hesch. Das archiviert au grad alli Liste im Namensruum.",
"unarchiveText": "Du chasch neui Liste erstelle oder bearbeite.",
"success": "De Namensruum isch erfolgriich archiviert worde.",
"unarchiveSuccess": "Der Namespace wurde erfolgreich wiederhergestellt.",
"description": "Wenn en Namensruum archiviert isch, chasch du kei neui Liste erstelle oder die bearbeite."
},
"delete": {
@ -382,7 +385,7 @@
"showDoneTasks": "Zeig die fertige Uufgabe",
"sortAlphabetically": "Alphabetisch sortieren",
"enablePriority": "Filter nach Priorität aktiviere",
"enablePercentDone": "Filter nach Prozent iihschalte",
"enablePercentDone": "Filter nach Fortschritt aktivieren",
"dueDateRange": "Fälligkeitsberiich",
"startDateRange": "Startdatumsbreiich",
"endDateRange": "Enddatumsberiich",
@ -575,7 +578,7 @@
"endDate": "Enddatum setze",
"reminders": "Errinnerig iihstelle",
"repeatAfter": "En wiederholende Intervall setze",
"percentDone": "Prozentuelli Erledigung setze",
"percentDone": "Fortschritt einstellen",
"attachments": "Aahang hinzuefüege",
"relatedTasks": "Uufgabsbeziehig hinzufüege",
"moveList": "Uufgab verschiebe",
@ -595,7 +598,7 @@
"dueDate": "Fälligkeitsdatum",
"endDate": "Enddatum",
"labels": "Labels",
"percentDone": "% fertig",
"percentDone": "Fortschritt",
"priority": "Priorität",
"relatedTasks": "Verwandti Uufgabe",
"reminders": "Errinnerige",

37
src/i18n/lang/en.json

@ -56,7 +56,8 @@
"showPassword": "Show the password",
"hidePassword": "Hide the password",
"noAccountYet": "Don't have an account yet?",
"alreadyHaveAnAccount": "Already have an account?"
"alreadyHaveAnAccount": "Already have an account?",
"remember": "Stay logged in"
},
"settings": {
"title": "Settings",
@ -84,7 +85,8 @@
"weekStartSunday": "Sunday",
"weekStartMonday": "Monday",
"language": "Language",
"defaultList": "Default List"
"defaultList": "Default List",
"timezone": "Time Zone"
},
"totp": {
"title": "Two Factor Authentication",
@ -333,6 +335,7 @@
"archiveText": "You won't be able to edit this namespace or create new lists until you un-archive it. This will also archive all lists in this namespace.",
"unarchiveText": "You will be able to create new lists or edit it.",
"success": "The namespace was successfully archived.",
"unarchiveSuccess": "The namespace was successfully un-archived.",
"description": "If a namespace is archived, you cannot create new lists or edit it."
},
"delete": {
@ -382,7 +385,7 @@
"showDoneTasks": "Show Done Tasks",
"sortAlphabetically": "Sort Alphabetically",
"enablePriority": "Enable Filter By Priority",
"enablePercentDone": "Enable Filter By Percent Done",
"enablePercentDone": "Enable Filter By Progress",
"dueDateRange": "Due Date Range",
"startDateRange": "Start Date Range",
"endDateRange": "End Date Range",
@ -619,22 +622,22 @@
"text2": "This will also remove all attachments, reminders and relations associated with this task and cannot be undone!"
},
"actions": {
"assign": "Assign to a user",
"label": "Add labels",
"assign": "Assign to User",
"label": "Add Labels",
"priority": "Set Priority",
"dueDate": "Set Due Date",
"startDate": "Set a Start Date",
"endDate": "Set an End Date",
"startDate": "Set Start Date",
"endDate": "Set End Date",
"reminders": "Set Reminders",