Compare commits

...

35 Commits

Author SHA1 Message Date
kolaente 6083301d1f
fix: wait until everything is loaded before replacing the current view with the last or login view 2022-10-23 16:12:48 +02:00
renovate 3f04571e43 chore(deps): update dependency vue-tsc to v1.0.9 (#2566)
Reviewed-on: vikunja/frontend#2566
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-10-23 13:41:01 +00:00
kolaente d7ac2ad697
fix(task): scroll the task field into view after activating it 2022-10-23 15:39:27 +02:00
kolaente 820823b5c3
fix(task): focusing on assignee search field when activating it 2022-10-23 15:27:29 +02:00
kolaente d7fb1a1e14
fix(task): marking checklist items as done 2022-10-23 14:39:28 +02:00
kolaente 7e218e03b2
fix(task): only show create list or import cta when there are no tasks 2022-10-23 14:01:20 +02:00
kolaente d7048d589e
fix(task): stop loading when no list was specified while creating a task 2022-10-23 13:58:40 +02:00
kolaente 80230069c6
fix: make sure the filter button is always shown on the kanban board 2022-10-23 13:48:45 +02:00
kolaente a695719128
fix: task detail view top spacing on mobile 2022-10-23 13:14:07 +02:00
kolaente f61723dac2
fix: redirect with query parameters 2022-10-23 13:12:04 +02:00
kolaente ae27502022
fix: make sure share modals don't have a create button
Resolves F-869
2022-10-23 13:03:09 +02:00
kolaente 8fdd3e785d
fix: make sure services without a modelFactory override still return data
Resolves F-850 and F-879
2022-10-23 12:56:44 +02:00
drone 5d038dc79f [skip ci] Updated translations via Crowdin 2022-10-23 00:13:07 +00:00
renovate b2ef66e5df chore(deps): update pnpm to v7.14.0 (#2565)
Reviewed-on: vikunja/frontend#2565
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-10-22 14:13:03 +00:00
renovate af819f9539 chore(deps): update dependency eslint to v8.26.0 (#2564)
Reviewed-on: vikunja/frontend#2564
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-10-22 06:38:41 +00:00
drone 9cf0a9a89e [skip ci] Updated translations via Crowdin 2022-10-22 00:13:04 +00:00
renovate 91b70c2de4 fix(deps): update dependency vue-flatpickr-component to v10 (#2563)
Reviewed-on: vikunja/frontend#2563
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-10-21 09:01:29 +00:00
kolaente 643a5b6d7d
fix: lint 2022-10-20 16:23:01 +02:00
kolaente e6f7ddc9ce
fix: email confirmation 2022-10-20 16:19:19 +02:00
kolaente 73575302de
fix: password reset 2022-10-20 16:15:58 +02:00
kolaente 4ed665fbd9
feat: refactor password reset to use a single password field 2022-10-20 16:07:36 +02:00
renovate f30e948abd chore(deps): update pnpm to v7.13.6 (#2562)
Reviewed-on: vikunja/frontend#2562
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-10-20 13:01:11 +00:00
drone ea758f0c58 [skip ci] Updated translations via Crowdin 2022-10-20 00:13:05 +00:00
renovate 617a48157d chore(deps): update dependency esbuild to v0.15.12 (#2561)
Reviewed-on: vikunja/frontend#2561
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-10-19 19:13:21 +00:00
renovate 419c0b2d96 fix(deps): update sentry-javascript monorepo to v7.16.0 (#2560)
Reviewed-on: vikunja/frontend#2560
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-10-19 07:39:55 +00:00
renovate 449b11c1ff chore(deps): update dependency @types/node to v16.11.68 (#2558)
Reviewed-on: vikunja/frontend#2558
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-10-19 05:07:29 +00:00
drone f818c207c2 [skip ci] Updated translations via Crowdin 2022-10-19 00:13:00 +00:00
renovate 4ee8f600a3 chore(deps): update typescript-eslint monorepo to v5.40.1 (#2557)
Reviewed-on: vikunja/frontend#2557
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-10-18 09:22:00 +00:00
konrad 29f68747bb feat: make salutation i18n static (#2546)
Reviewed-on: vikunja/frontend#2546
Reviewed-by: konrad <k@knt.li>
2022-10-18 08:35:52 +00:00
renovate ce201e0880 chore(deps): update dependency rollup to v3.2.3 (#2556)
Reviewed-on: vikunja/frontend#2556
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-10-18 08:19:53 +00:00
renovate bd26b81318 chore(deps): pin dependency @types/postcss-preset-env to 7.7.0 (#2555)
Reviewed-on: vikunja/frontend#2555
Reviewed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
Co-authored-by: renovate <renovatebot@kolaente.de>
Co-committed-by: renovate <renovatebot@kolaente.de>
2022-10-18 08:07:53 +00:00
Dominik Pschenitschni 5afafb7c82
fix: move hourToDaytime to separate file in order to pass tests 2022-10-17 12:35:47 +02:00
Dominik Pschenitschni 9de20b4c54
feat: use getter and helper in other components as well 2022-10-17 12:35:47 +02:00
Dominik Pschenitschni c4d7f6fdfa
feat: get username from store getter 2022-10-16 19:36:04 +02:00
Dominik Pschenitschni c20de51a3c
feat: make salutation i18n static 2022-10-16 15:28:58 +02:00
54 changed files with 1740 additions and 604 deletions

View File

@ -193,4 +193,15 @@ describe('List View Kanban', () => {
cy.get('.kanban .bucket')
.should('not.contain', task.title)
})
it('Shows a button to filter the kanban board', () => {
const data = TaskFactory.create(10, {
list_id: 1,
bucket_id: 1,
})
cy.visit('/lists/1/kanban')
cy.get('.list-kanban .filter-container .base-button')
.should('exist')
})
})

View File

@ -128,4 +128,24 @@ describe('Home Page Task Overview', () => {
.last()
.should('contain.text', newTaskTitle)
})
it('Should show the cta buttons for new list when there are no tasks', () => {
TaskFactory.truncate()
cy.visit('/')
cy.get('.home.app-content .content')
.should('contain.text', 'You can create a new list for your new tasks:')
.should('contain.text', 'Or import your lists and tasks from other services into Vikunja:')
})
it('Should not show the cta buttons for new list when there are tasks', () => {
seedTasks()
cy.visit('/')
cy.get('.home.app-content .content')
.should('not.contain.text', 'You can create a new list for your new tasks:')
.should('not.contain.text', 'Or import your lists and tasks from other services into Vikunja:')
})
})

View File

@ -546,5 +546,35 @@ describe('Task', () => {
cy.get('.bucket .task .footer .icon svg.fa-paperclip')
.should('exist')
})
it('Can check items off a checklist', () => {
const tasks = TaskFactory.create(1, {
id: 1,
description: `
This is a checklist:
* [ ] one item
* [ ] another item
* [ ] third item
* [ ] fourth item
* [x] and this one is already done
`,
})
cy.visit(`/tasks/${tasks[0].id}`)
cy.get('.task-view .checklist-summary')
.should('contain.text', '1 of 5 tasks')
cy.get('.editor .content ul > li input[type=checkbox]')
.eq(2)
.click()
cy.get('.editor .content ul > li input[type=checkbox]')
.eq(2)
.should('be.checked')
cy.get('.editor .content input[type=checkbox]')
.should('have.length', 5)
cy.get('.task-view .checklist-summary')
.should('contain.text', '2 of 5 tasks')
})
})
})

View File

@ -55,4 +55,9 @@ context('Login', () => {
testAndAssertFailed(fixture)
})
it('Should redirect to /login when no user is logged in', () => {
cy.visit('/')
cy.url().should('include', '/login')
})
})

View File

@ -24,8 +24,8 @@
"@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.16.0",
"@sentry/vue": "7.16.0",
"@types/is-touch-device": "1.0.0",
"@types/lodash.clonedeep": "4.5.7",
"@types/sortablejs": "1.15.0",
@ -56,7 +56,7 @@
"vue": "3.2.41",
"vue-advanced-cropper": "2.8.6",
"vue-drag-resize": "2.0.3",
"vue-flatpickr-component": "9.0.8",
"vue-flatpickr-component": "10.0.0",
"vue-i18n": "9.2.2",
"vue-router": "4.1.5",
"workbox-precaching": "6.5.4",
@ -72,10 +72,10 @@
"@types/flexsearch": "0.7.3",
"@types/lodash.debounce": "4.0.7",
"@types/marked": "4.0.7",
"@types/node": "16.11.66",
"@types/postcss-preset-env": "^7.7.0",
"@typescript-eslint/eslint-plugin": "5.40.0",
"@typescript-eslint/parser": "5.40.0",
"@types/node": "16.11.68",
"@types/postcss-preset-env": "7.7.0",
"@typescript-eslint/eslint-plugin": "5.40.1",
"@typescript-eslint/parser": "5.40.1",
"@vitejs/plugin-legacy": "2.2.0",
"@vitejs/plugin-vue": "3.1.2",
"@vue/eslint-config-typescript": "11.0.2",
@ -85,15 +85,15 @@
"browserslist": "4.21.4",
"caniuse-lite": "1.0.30001420",
"cypress": "10.10.0",
"esbuild": "0.15.11",
"eslint": "8.25.0",
"esbuild": "0.15.12",
"eslint": "8.26.0",
"eslint-plugin-vue": "9.6.0",
"express": "4.18.2",
"happy-dom": "7.5.12",
"netlify-cli": "12.0.9",
"postcss": "8.4.18",
"postcss-preset-env": "7.8.2",
"rollup": "3.2.2",
"rollup": "3.2.3",
"rollup-plugin-visualizer": "5.8.3",
"sass": "1.55.0",
"typescript": "4.8.4",
@ -101,10 +101,10 @@
"vite-plugin-pwa": "0.13.1",
"vite-svg-loader": "3.6.0",
"vitest": "0.24.3",
"vue-tsc": "1.0.8",
"vue-tsc": "1.0.9",
"wait-on": "6.0.1",
"workbox-cli": "6.5.4"
},
"license": "AGPL-3.0-or-later",
"packageManager": "pnpm@7.13.5"
"packageManager": "pnpm@7.14.0"
}

File diff suppressed because it is too large Load Diff

View File

@ -44,8 +44,8 @@
variant="secondary"
:shadow="false"
>
<img :src="userAvatar" alt="" class="avatar" width="40" height="40"/>
<span class="username">{{ userInfo.name !== '' ? userInfo.name : userInfo.username }}</span>
<img :src="authStore.avatarUrl" alt="" class="avatar" width="40" height="40"/>
<span class="username">{{ authStore.userDisplayName }}</span>
<span class="icon is-small">
<icon icon="chevron-down"/>
</span>
@ -80,7 +80,7 @@
{{ $t('about.title') }}
</dropdown-item>
<dropdown-item
@click="logout()"
@click="authStore.logout()"
>
{{ $t('user.auth.logout') }}
</dropdown-item>
@ -117,8 +117,6 @@ const canWriteCurrentList = computed(() => baseStore.currentList.maxRight > Righ
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)
@ -136,10 +134,6 @@ onMounted(async () => {
listTitle.value.style.setProperty('--nav-username-width', `${usernameWidth}px`)
})
function logout() {
authStore.logout()
}
function openQuickActions() {
baseStore.setQuickActionsActive(true)
}

View File

@ -285,9 +285,9 @@ function handleCheckboxClick(e: Event) {
console.debug('no index found')
return
}
console.debug(index, text.value.slice(index, 9))
const listPrefix = text.value.slice(index, 1)
const listPrefix = text.value.substring(index, index + 1)
console.debug({index, listPrefix, checked, text: text.value})
text.value = replaceAt(text.value, index, `${listPrefix} ${checked ? '[x]' : '[ ]'} `)
bubble()

View File

@ -10,7 +10,7 @@
:loading="loading"
>
<div class="p-4">
<slot />
<slot/>
</div>
<template #footer>
@ -30,10 +30,12 @@
{{ $t('misc.cancel') }}
</x-button>
<x-button
v-if="hasPrimaryAction"
variant="primary"
@click.prevent.stop="primary()"
:icon="primaryIcon"
:disabled="primaryDisabled || loading"
class="ml-2"
>
{{ primaryLabel || $t('misc.create') }}
</x-button>
@ -60,6 +62,10 @@ defineProps({
type: Boolean,
default: false,
},
hasPrimaryAction: {
type: Boolean,
default: true,
},
tertiary: {
type: String,
default: '',

View File

@ -61,7 +61,7 @@ const route = useRoute()
const baseStore = useBaseStore()
const ready = ref(false)
const ready = computed(() => baseStore.ready)
const online = useOnline()
const error = ref('')
@ -70,11 +70,11 @@ const showLoading = computed(() => !ready.value && error.value === '')
async function load() {
try {
await baseStore.loadApp()
const redirectTo = getAuthForRoute(route)
baseStore.setReady(true)
const redirectTo = await getAuthForRoute(route)
if (typeof redirectTo !== 'undefined') {
await router.push(redirectTo)
}
ready.value = true
} catch (e: unknown) {
error.value = String(e)
}

View File

@ -1,30 +1,25 @@
<template>
<div
tabindex="-1"
@focus="focus"
<Multiselect
:loading="listUserService.loading"
:placeholder="$t('task.assignee.placeholder')"
:multiple="true"
@search="findUser"
:search-results="foundUsers"
@select="addAssignee"
label="name"
:select-placeholder="$t('task.assignee.selectPlaceholder')"
v-model="assignees"
ref="userSearchInputRef"
>
<Multiselect
:loading="listUserService.loading"
:placeholder="$t('task.assignee.placeholder')"
:multiple="true"
@search="findUser"
:search-results="foundUsers"
@select="addAssignee"
label="name"
:select-placeholder="$t('task.assignee.selectPlaceholder')"
v-model="assignees"
ref="multiselect"
>
<template #tag="{item: user}">
<span class="assignee">
<user :avatar-size="32" :show-username="false" :user="user"/>
<BaseButton @click="removeAssignee(user)" class="remove-assignee" v-if="!disabled">
<icon icon="times"/>
</BaseButton>
</span>
</template>
</Multiselect>
</div>
<template #tag="{item: user}">
<span class="assignee">
<user :avatar-size="32" :show-username="false" :user="user"/>
<BaseButton @click="removeAssignee(user)" class="remove-assignee" v-if="!disabled">
<icon icon="times"/>
</BaseButton>
</span>
</template>
</Multiselect>
</template>
<script setup lang="ts">
@ -41,6 +36,7 @@ import {success} from '@/message'
import {useTaskStore} from '@/stores/tasks'
import type {IUser} from '@/modelTypes/IUser'
import {getDisplayName} from '@/models/user'
const props = defineProps({
taskId: {
@ -65,7 +61,7 @@ const taskStore = useTaskStore()
const {t} = useI18n({useScope: 'global'})
const listUserService = shallowReactive(new ListUserService())
const foundUsers = ref([])
const foundUsers = ref<IUser[]>([])
const assignees = ref<IUser[]>([])
let isAdding = false
@ -114,13 +110,14 @@ async function findUser(query: string) {
return
}
const response = await listUserService.getAll({listId: props.listId}, {s: query})
const response = await listUserService.getAll({listId: props.listId}, {s: query}) as IUser[]
// Filter the results to not include users who are already assigned
foundUsers.value = response.filter(({id}) => !includesById(assignees.value, id))
foundUsers.value = response
.filter(({id}) => !includesById(assignees.value, id))
.map(u => {
// Users may not have a display name set, so we fall back on the username in that case
u.name = u.name === '' ? u.username : u.name
u.name = getDisplayName(u)
return u
})
}
@ -128,12 +125,6 @@ async function findUser(query: string) {
function clearAllFoundUsers() {
foundUsers.value = []
}
const multiselect = ref()
function focus() {
multiselect.value.focus()
}
</script>
<style lang="scss" scoped>

View File

@ -1,31 +0,0 @@
import {describe, it, expect} from 'vitest'
import {hourToSalutation} from './useDateTimeSalutation'
const dateWithHour = (hours: number): Date => {
const date = new Date()
date.setHours(hours)
return date
}
describe('Salutation', () => {
it('shows the right salutation in the night', () => {
const salutation = hourToSalutation(dateWithHour(4))
expect(salutation).toBe('home.welcomeNight')
})
it('shows the right salutation in the morning', () => {
const salutation = hourToSalutation(dateWithHour(8))
expect(salutation).toBe('home.welcomeMorning')
})
it('shows the right salutation in the day', () => {
const salutation = hourToSalutation(dateWithHour(13))
expect(salutation).toBe('home.welcomeDay')
})
it('shows the right salutation in the night', () => {
const salutation = hourToSalutation(dateWithHour(20))
expect(salutation).toBe('home.welcomeEvening')
})
it('shows the right salutation in the night again', () => {
const salutation = hourToSalutation(dateWithHour(23))
expect(salutation).toBe('home.welcomeNight')
})
})

View File

@ -1,31 +0,0 @@
import {computed} from 'vue'
import {useNow} from '@vueuse/core'
const TRANSLATION_KEY_PREFIX = 'home.welcome'
export function hourToSalutation(now: Date) {
const hours = now.getHours()
if (hours < 5) {
return `${TRANSLATION_KEY_PREFIX}Night`
}
if (hours < 11) {
return `${TRANSLATION_KEY_PREFIX}Morning`
}
if (hours < 18) {
return `${TRANSLATION_KEY_PREFIX}Day`
}
if (hours < 23) {
return `${TRANSLATION_KEY_PREFIX}Evening`
}
return `${TRANSLATION_KEY_PREFIX}Night`
}
export function useDateTimeSalutation() {
const now = useNow()
return computed(() => hourToSalutation(now.value))
}

View File

@ -0,0 +1,26 @@
import {computed} from 'vue'
import {useI18n} from 'vue-i18n'
import {useNow} from '@vueuse/core'
import {useAuthStore} from '@/stores/auth'
import {hourToDaytime} from '@/helpers/hourToDaytime'
export type Daytime = 'night' | 'morning' | 'day' | 'evening'
export function useDaytimeSalutation() {
const {t} = useI18n({useScope: 'global'})
const now = useNow()
const authStore = useAuthStore()
const name = computed(() => authStore.userDisplayName)
const daytime = computed(() => hourToDaytime(now.value))
const salutations = {
'night': () => t('home.welcomeNight', {username: name.value}),
'morning': () => t('home.welcomeMorning', {username: name.value}),
'day': () => t('home.welcomeDay', {username: name.value}),
'evening': () => t('home.welcomeEvening', {username: name.value}),
} as Record<Daytime, () => string>
return computed(() => name.value ? salutations[daytime.value]() : undefined)
}

View File

@ -0,0 +1,26 @@
import {useRouter} from 'vue-router'
import {getLastVisited, clearLastVisited} from '@/helpers/saveLastVisited'
export function useRedirectToLastVisited() {
const router = useRouter()
function redirectIfSaved() {
const last = getLastVisited()
if (last !== null) {
router.push({
name: last.name,
params: last.params,
query: last.query,
})
clearLastVisited()
return
}
router.push({name: 'home'})
}
return {
redirectIfSaved,
}
}

View File

@ -16,7 +16,7 @@ export function useRenewTokenOnFocus() {
authStore.renewToken()
// Check if the token is still valid if the window gets focus again to maybe renew it
useEventListener('focus', () => {
useEventListener('focus', async () => {
if (!authenticated.value) {
return
}
@ -26,8 +26,8 @@ export function useRenewTokenOnFocus() {
// 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'})
await authStore.checkAuth()
await router.push({name: 'user.login'})
return
}

View File

@ -0,0 +1,31 @@
import {describe, it, expect} from 'vitest'
import {hourToDaytime} from "./hourToDaytime"
function dateWithHour(hours: number): Date {
const newDate = new Date()
newDate.setHours(hours, 0, 0,0 )
return newDate
}
describe('Salutation', () => {
it('shows the right salutation in the night', () => {
const salutation = hourToDaytime(dateWithHour(4))
expect(salutation).toBe('night')
})
it('shows the right salutation in the morning', () => {
const salutation = hourToDaytime(dateWithHour(8))
expect(salutation).toBe('morning')
})
it('shows the right salutation in the day', () => {
const salutation = hourToDaytime(dateWithHour(13))
expect(salutation).toBe('day')
})
it('shows the right salutation in the night', () => {
const salutation = hourToDaytime(dateWithHour(20))
expect(salutation).toBe('evening')
})
it('shows the right salutation in the night again', () => {
const salutation = hourToDaytime(dateWithHour(23))
expect(salutation).toBe('night')
})
})

View File

@ -0,0 +1,14 @@
import type { Daytime } from '@/composables/useDaytimeSalutation'
export function hourToDaytime(now: Date): Daytime {
const hours = now.getHours()
const daytimeMap = {
night: hours < 5 || hours > 23,
morning: hours < 11,
day: hours < 18,
evening: hours < 23,
} as Record<Daytime, boolean>
return (Object.keys(daytimeMap) as Daytime[]).find((daytime) => daytimeMap[daytime]) || 'night'
}

View File

@ -1,7 +1,7 @@
const LAST_VISITED_KEY = 'lastVisited'
export const saveLastVisited = (name: string, params: object) => {
localStorage.setItem(LAST_VISITED_KEY, JSON.stringify({name, params}))
export const saveLastVisited = (name: string, params: object, query: object) => {
localStorage.setItem(LAST_VISITED_KEY, JSON.stringify({name, params, query}))
}
export const getLastVisited = () => {

View File

@ -1,9 +1,9 @@
{
"home": {
"welcomeNight": "Good Night {username}",
"welcomeMorning": "Good Morning {username}",
"welcomeDay": "Hi {username}",
"welcomeEvening": "Good Evening {username}",
"welcomeNight": "Good Night {username}!",
"welcomeMorning": "Good Morning {username}!",
"welcomeDay": "Hi {username}!",
"welcomeEvening": "Good Evening {username}!",
"lastViewed": "Last viewed",
"list": {
"newText": "You can create a new list for your new tasks:",

View File

@ -1,9 +1,9 @@
{
"home": {
"welcomeNight": "Dobrou noc {username}",
"welcomeMorning": "Dobré ráno {username}",
"welcomeDay": "Ahoj {username}",
"welcomeEvening": "Dobrý večer {username}",
"welcomeNight": "Dobrou noc {username}!",
"welcomeMorning": "Dobré ráno {username}!",
"welcomeDay": "Ahoj {username}!",
"welcomeEvening": "Dobrý večer {username}!",
"lastViewed": "Naposledy zobrazeno",
"list": {
"newText": "Můžete vytvořit nový seznam pro své nové úkoly:",
@ -77,7 +77,7 @@
"newName": "Nové jméno",
"savedSuccess": "Nastavení bylo úspěšně aktualizováno.",
"emailReminders": "Posílat mi připomenutí pro úkoly e-mailem",
"overdueReminders": "Send me a summary of my undone overdue tasks every day",
"overdueReminders": "Pošlete mi každý den shrnutí mých zpožděných úkolů",
"discoverableByName": "Nechat ostatní uživatele mě najít podle jména",
"discoverableByEmail": "Nechat ostatní uživatele mě najít podle e-mailu",
"playSoundWhenDone": "Přehrát zvuk při označení úkolů jako hotovo",
@ -87,7 +87,7 @@
"language": "Jazyk",
"defaultList": "Výchozí seznam",
"timezone": "Časové pásmo",
"overdueTasksRemindersTime": "Overdue tasks reminder email time"
"overdueTasksRemindersTime": "Čas odeslání emailu o zpožděných úkolech"
},
"totp": {
"title": "Dvoufaktorové ověření",
@ -180,7 +180,7 @@
"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",
"noDescriptionAvailable": "No list description is available.",
"noDescriptionAvailable": "Popis seznamu není k dispozici.",
"create": {
"header": "Nový seznam",
"titlePlaceholder": "Název seznamu přijde sem…",
@ -252,8 +252,8 @@
"removeText": "Jste si jisti, že chcete odstranit tento sdílený odkaz? K tomuto seznamu již nebude možné přistupovat s tímto sdíleným odkazem. Tuto akci nelze vrátit zpět!",
"createSuccess": "Sdílený odkaz byl úspěšně vytvořen.",
"deleteSuccess": "Sdílený odkaz byl úspěšně smazán",
"view": "View",
"sharedBy": "Shared by {0}"
"view": "Zobrazit",
"sharedBy": "Sdíleno {0}"
},
"userTeam": {
"typeUser": "uživatel | uživatelé",
@ -297,23 +297,23 @@
"title": "Kanban",
"limit": "Limit: {limit}",
"noLimit": "Nenastaveno",
"doneBucket": "Kbelík \"Hotovo\"",
"doneBucketHint": "Všechny úkoly přesunuté do tohoto kbelíku budou automaticky označeny jako dokončené.",
"doneBucketHintExtended": "Všechny úkoly přesunuté do kbelíku \"Hotovo\" budou označeny jako dokončené automaticky. Všechny úkoly označené jako dokončené odjinud sem budou přesunuty také.",
"doneBucketSavedSuccess": "Kbelík \"Hotovo\" byl úspěšně uložen.",
"deleteLast": "Nelze odstranit poslední kbelík.",
"doneBucket": "Sloupec \"Hotovo\"",
"doneBucketHint": "Všechny úkoly přesunuté do tohoto sloupce budou automaticky označeny jako dokončené.",
"doneBucketHintExtended": "Všechny úkoly přesunuté do sloupce \"Hotovo\" budou označeny jako dokončené automaticky. Všechny úkoly označené jako dokončené jinde sem budou přesunuty také.",
"doneBucketSavedSuccess": "Sloupec \"Hotovo\" byl úspěšně uložen.",
"deleteLast": "Nelze odstranit poslední sloupec.",
"addTaskPlaceholder": "Zadejte nový název úkolu…",
"addTask": "Přidat úkol",
"addAnotherTask": "Přidat další úkol",
"addBucket": "Vytvořit nový kbelík",
"addBucketPlaceholder": "Zadejte název nového kbelíku…",
"deleteHeaderBucket": "Smazat kbelík",
"deleteBucketText1": "Opravdu chcete smazat tento kbelík?",
"deleteBucketText2": "Toto neodstraní žádné úkoly, ale přesune je do výchozího kbelíku.",
"deleteBucketSuccess": "Kbelík byl úspěšně smazán.",
"bucketTitleSavedSuccess": "Název kbelíku byl úspěšně uložen.",
"bucketLimitSavedSuccess": "Limit kbelíku byl úspěšně uložen.",
"collapse": "Sbalit tento kbelík"
"addBucket": "Vytvořit nový sloupec",
"addBucketPlaceholder": "Zadejte název nového sloupce…",
"deleteHeaderBucket": "Smazat sloupec",
"deleteBucketText1": "Opravdu chcete smazat tento sloupec?",
"deleteBucketText2": "Toto nesmaže žádné úkoly, ale přesune je do výchozího sloupce.",
"deleteBucketSuccess": "Sloupec byl úspěšně smazán.",
"bucketTitleSavedSuccess": "Název sloupce byl úspěšně uložen.",
"bucketLimitSavedSuccess": "Limit sloupce byl úspěšně uložen.",
"collapse": "Sbalit tento sloupec"
},
"pseudo": {
"favorites": {
@ -672,23 +672,23 @@
"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.",
"subscribedListThroughParentNamespace": "Zde se nemůžete odhlásit, protože jste přihlášeni k odběru tohoto seznamu prostřednictvím jeho prostoru.",
"subscribedTaskThroughParentNamespace": "Zde se nemůžete odhlásit, protože jste přihlášeni k odběru tohoto úkolu prostřednictvím jeho prostoru.",
"subscribedTaskThroughParentList": "Zde se nemůžete odhlásit, protože jste přihlášeni k odběru tohoto úkolu prostřednictvím jeho seznamu.",
"subscribedNamespace": "Nyní jste přihlášeni k odběru tohoto prostoru a budete dostávat oznámení o změnách.",
"notSubscribedNamespace": "Nejste přihlášeni k odběru tohoto prostoru, takže nebudete dostávat upozornění na změny.",
"subscribedList": "Nyní jste přihlášeni k odběru tohoto seznamu a budete dostávat oznámení o změnách.",
"notSubscribedList": "Nejste přihlášeni k odběru tohoto seznamu, takže nebudete dostávat upozornění na změny.",
"subscribedTask": "Nyní jste přihlášeni k odběru tohoto úkolu a budete dostávat oznámení o změnách.",
"notSubscribedTask": "Nejste přihlášeni k odběru tohoto úkolu, takže 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"
"subscribeSuccessNamespace": "Nyní jste přihlášeni k tomuto prostoru",
"unsubscribeSuccessNamespace": "Nyní jste odhlášeni od tohoto prostoru",
"subscribeSuccessList": "Nyní jste přihlášeni k tomuto seznamu",
"unsubscribeSuccessList": "Nyní jste odhlášeni od tohoto seznamu",
"subscribeSuccessTask": "Nyní jste přihlášeni k tomuto úkolu",
"unsubscribeSuccessTask": "Nyní jste odhlášeni od tohoto úkolu"
},
"attachment": {
"title": "Přílohy",
@ -701,10 +701,10 @@
"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"
"setAsCover": "Použij titulní obrázek",
"unsetAsCover": "Odstraň titulní obrázek",
"successfullyChangedCoverImage": "Titulní obrázek byl úspěšně změněn.",
"usedAsCover": "Titulní obrázek"
},
"comment": {
"title": "Komentáře",
@ -715,7 +715,7 @@
"comment": "Komentář",
"delete": "Smazat tento komentář",
"deleteText1": "Opravdu chcete smazat tento komentář?",
"deleteSuccess": "The comment was deleted successfully.",
"deleteSuccess": "Komentář byl úspěšně odstraněn.",
"addedSuccess": "Komentář byl úspěšně přidán."
},
"deferDueDate": {
@ -742,16 +742,16 @@
"removeSuccess": "Štítek byl úspěšně odstraněn.",
"addCreateSuccess": "Štítek byl úspěšně vytvořen a přidán.",
"delete": {
"header": "Delete this label",
"text1": "Are you sure you want to delete this label?",
"text2": "This will remove it from all tasks and cannot be restored."
"header": "Smazat tento štítek",
"text1": "Opravdu chcete smazat tento štítek?",
"text2": "Odebere štítek ze všech úkolů a nelze to vrátit zpět."
}
},
"priority": {
"unset": "Zrušit nastavení",
"low": "Nízká",
"medium": "Střední",
"high": "High",
"high": "Vysoká",
"urgent": "Naléhavé",
"doNow": "UDĚLEJ TO HNED"
},
@ -795,7 +795,7 @@
"weeks": "Týdny",
"months": "Měsíce",
"years": "Roky",
"invalidAmount": "Please enter more than 0."
"invalidAmount": "Zadejte prosím více než 0."
},
"quickAddMagic": {
"hint": "Můžeš použít Kouzelné rychlé přidání",
@ -805,15 +805,15 @@
"multiple": "Toto můžete použít několikrát.",
"label1": "Chcete-li přidat štítek, jednoduše napište před název štítku {prefix}.",
"label2": "Vikunja nejprve zkontroluje, zda štítek již existuje a vytvoří jej, pokud ne.",
"label3": "To use spaces, simply add a \" or ' around the label name.",
"label3": "Chcete-li použít mezery, uzavřete název štítku do \" nebo '.",
"label4": "Například: {prefix}\"Štítek s mezerami\".",
"priority1": "Chcete-li nastavit prioritu úkolu, přidejte číslo 1-5, s prefixem {prefix}.",
"priority2": "Čím vyšší číslo, tím vyšší priorita.",
"assignees": "Chcete-li přímo přiřadit úkol k uživateli, přidejte k úkolu jejich uživatelské jméno s prefixem {prefix}.",
"list1": "Chcete-li nastavit seznam, ve kterém se má úkol zobrazit, zadejte jeho název s předponou {prefix}.",
"list2": "Toto vrátí chybu, pokud seznam neexistuje.",
"list3": "To use spaces, simply add a \" or ' around the list name.",
"list4": "For example: {prefix}\"List with spaces\".",
"list3": "Chcete-li použít mezery, uzavřete název seznamu do \" nebo '.",
"list4": "Například: {prefix}\"Štítek s mezerami\".",
"dateAndTime": "Datum a čas",
"date": "Jakékoliv datum bude použito jako datum dokončení nového úkolu. Můžete použít data v kterémkoli z těchto formátů:",
"dateWeekday": "každý pracovní den použije další datum s tímto datem",
@ -855,10 +855,10 @@
"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."
"title": "Opustit tým",
"text1": "Opravdu chcete opustit tento tým?",
"text2": "Ztratíte přístup ke všem seznamům a prostorům, k nimž má tento tým přístup. Pokud změníte názor, budete potřebovat správce týmu, aby vás znovu přidal.",
"success": "Úspěšně jste opustili tým."
}
},
"attributes": {
@ -890,8 +890,8 @@
"related": "Upravit související úkoly tohoto úkolu",
"color": "Změnit barvu tohoto úkolu",
"move": "Přesunout tento úkol do jiného seznamu",
"reminder": "Manage reminders of this task",
"description": "Toggle editing of the task description"
"reminder": "Spravovat připomenutí této úlohy",
"description": "Přepnout úpravy popisu úkolu"
},
"list": {
"title": "Zobrazení seznamů",
@ -963,7 +963,7 @@
}
},
"date": {
"locale": "cs-CZ",
"locale": "cs",
"altFormatLong": "j M Y H:i",
"altFormatShort": "j M Y"
},
@ -1033,11 +1033,11 @@
"8002": "Štítek neexistuje.",
"8003": "K tomuto štítku nemáte přístup.",
"9001": "Právo je neplatné.",
"10001": "Kbelík neexistuje.",
"10002": "Kbelík nepatří do tohoto seznamu.",
"10003": "Poslední kbelík v seznamu nelze odstranit.",
"10004": "Nemůžete přidat úkol do tohoto kbelíku, protože již překročil limit úkolů, které do něj můžete uložit.",
"10005": "Na seznam může být pouze jeden kbelík \"Hotovo\".",
"10001": "Sloupec neexistuje.",
"10002": "Sloupec nepatří do tohoto seznamu.",
"10003": "Poslední sloupec v seznamu nelze odstranit.",
"10004": "Nemůžete přidat úkol do tohoto sloupce, protože již překročil limit úkolů, které do něj můžete uložit.",
"10005": "Pro seznam může být pouze jeden sloupec \"Hotovo\".",
"11001": "Uložený filtr neexistuje.",
"11002": "Uložené filtry nejsou k dispozici pro sdílení odkazů.",
"12001": "Typ předplatného je neplatný.",

View File

@ -1,9 +1,9 @@
{
"home": {
"welcomeNight": "Gute Nacht, {username}",
"welcomeMorning": "Guten Morgen, {username}",
"welcomeDay": "Hallo, {username}",
"welcomeEvening": "Guten Abend, {username}",
"welcomeNight": "Gute Nacht, {username}!",
"welcomeMorning": "Guten Morgen, {username}!",
"welcomeDay": "Hallo {username}!",
"welcomeEvening": "Guten Abend, {username}!",
"lastViewed": "Zuletzt angesehen",
"list": {
"newText": "Du kannst eine neue Liste für deine neuen Aufgaben erstellen:",
@ -335,7 +335,7 @@
"create": {
"title": "Neuer Namespace",
"titleRequired": "Bitte gebe einen Titel an.",
"explanation": "Ein Namespace ist eine Sammlung von Listen, die du teilen und zur Organisation verwenden kannst. Jede Liste zu einem Namespace.",
"explanation": "Ein Namespace ist eine Sammlung von Listen, die du teilen und zur Organisation verwenden kannst. Jede Liste gehört zu einem Namespace.",
"tooltip": "Was ist ein Namespace?",
"success": "Der Namespace wurde erfolgreich erstellt."
},

View File

@ -1,9 +1,9 @@
{
"home": {
"welcomeNight": "Guet Nacht, {username}",
"welcomeMorning": "Guete Morgä, {username}",
"welcomeDay": "Hoi {username}",
"welcomeEvening": "Guete Abig, {username}",
"welcomeNight": "Gute Nacht, {username}!",
"welcomeMorning": "Guten Morgen, {username}!",
"welcomeDay": "Hallo {username}!",
"welcomeEvening": "Guten Abend, {username}!",
"lastViewed": "Zletscht ahglueget",
"list": {
"newText": "Du chasch e Liste für dini neue Uufgabe erstelle:",
@ -335,7 +335,7 @@
"create": {
"title": "Neuer Namespace",
"titleRequired": "Bitte gib en Titl ah.",
"explanation": "Ein Namespace ist eine Sammlung von Listen, die du teilen und zur Organisation verwenden kannst. Jede Liste zu einem Namespace.",
"explanation": "Ein Namespace ist eine Sammlung von Listen, die du teilen und zur Organisation verwenden kannst. Jede Liste gehört zu einem Namespace.",
"tooltip": "Was isch en Namensruum?",
"success": "Namensruum erstellt."
},

View File

@ -1,9 +1,9 @@
{
"home": {
"welcomeNight": "Good Night {username}",
"welcomeMorning": "Good Morning {username}",
"welcomeDay": "Hi {username}",
"welcomeEvening": "Good Evening {username}",
"welcomeNight": "Good Night {username}!",
"welcomeMorning": "Good Morning {username}!",
"welcomeDay": "Hi {username}!",
"welcomeEvening": "Good Evening {username}!",
"lastViewed": "Last viewed",
"list": {
"newText": "You can create a new list for your new tasks:",

View File

@ -1,9 +1,9 @@
{
"home": {
"welcomeNight": "Good Night {username}",
"welcomeMorning": "Good Morning {username}",
"welcomeDay": "Hi {username}",
"welcomeEvening": "Good Evening {username}",
"welcomeNight": "Good Night {username}!",
"welcomeMorning": "Good Morning {username}!",
"welcomeDay": "Hi {username}!",
"welcomeEvening": "Good Evening {username}!",
"lastViewed": "Last viewed",
"list": {
"newText": "You can create a new list for your new tasks:",
@ -43,7 +43,7 @@
"confirmEmailSuccess": "You successfully confirmed your email! You can log in now.",
"totpTitle": "Two Factor Authentication Code",
"totpPlaceholder": "e.g. 123456",
"login": "Login",
"login": "Iniciar sesión",
"createAccount": "Create account",
"loginWith": "Log in with {provider}",
"authenticating": "Authenticating…",

View File

@ -1,9 +1,9 @@
{
"home": {
"welcomeNight": "Bonne nuit {username}",
"welcomeMorning": "Bonjour {username}",
"welcomeDay": "Salut {username}",
"welcomeEvening": "Bonsoir {username}",
"welcomeNight": "Good Night {username}!",
"welcomeMorning": "Good Morning {username}!",
"welcomeDay": "Hi {username}!",
"welcomeEvening": "Good Evening {username}!",
"lastViewed": "Dernière consultation",
"list": {
"newText": "Tu peux créer une nouvelle liste pour tes nouvelles tâches :",

View File

@ -1,9 +1,9 @@
{
"home": {
"welcomeNight": "Buonanotte {username}",
"welcomeMorning": "Buongiorno {username}",
"welcomeDay": "Ciao {username}",
"welcomeEvening": "Buonasera {username}",
"welcomeNight": "Good Night {username}!",
"welcomeMorning": "Good Morning {username}!",
"welcomeDay": "Hi {username}!",
"welcomeEvening": "Good Evening {username}!",
"lastViewed": "Ultima visualizzazione",
"list": {
"newText": "È possibile creare una nuova lista per le nuove attività:",

View File

@ -1,9 +1,9 @@
{
"home": {
"welcomeNight": "Goede nacht {username}",
"welcomeMorning": "Goedemorgen {username}",
"welcomeDay": "Hallo {username}",
"welcomeEvening": "Goedenavond {username}",
"welcomeNight": "Good Night {username}!",
"welcomeMorning": "Good Morning {username}!",
"welcomeDay": "Hi {username}!",
"welcomeEvening": "Good Evening {username}!",
"lastViewed": "Laatst bekeken",
"list": {
"newText": "Je kan een nieuwe lijst maken voor je nieuwe taken:",

View File

@ -1,9 +1,9 @@
{
"home": {
"welcomeNight": "Dobrej nocy {username}",
"welcomeMorning": "Dzień dobry {username}",
"welcomeDay": "Cześć {username}",
"welcomeEvening": "Dobry wieczór {username}",
"welcomeNight": "Good Night {username}!",
"welcomeMorning": "Good Morning {username}!",
"welcomeDay": "Hi {username}!",
"welcomeEvening": "Good Evening {username}!",
"lastViewed": "Ostatnio oglądane",
"list": {
"newText": "Możesz stworzyć nową listę dla swoich nowych zadań:",

View File

@ -1,9 +1,9 @@
{
"home": {
"welcomeNight": "Good Night {username}",
"welcomeMorning": "Good Morning {username}",
"welcomeDay": "Hi {username}",
"welcomeEvening": "Good Evening {username}",
"welcomeNight": "Good Night {username}!",
"welcomeMorning": "Good Morning {username}!",
"welcomeDay": "Hi {username}!",
"welcomeEvening": "Good Evening {username}!",
"lastViewed": "Last viewed",
"list": {
"newText": "You can create a new list for your new tasks:",

View File

@ -1,9 +1,9 @@
{
"home": {
"welcomeNight": "Boa Noite {username}",
"welcomeMorning": "Bom Dia {username}",
"welcomeDay": "Olá {username}",
"welcomeEvening": "Boa Tarde {username}",
"welcomeNight": "Boa Noite {username}!",
"welcomeMorning": "Bom Dia {username}!",
"welcomeDay": "Olá {username}!",
"welcomeEvening": "Boa Tarde {username}!",
"lastViewed": "Visto recentemente",
"list": {
"newText": "Podes criar uma nova lista para as tuas novas tarefas:",

View File

@ -1,9 +1,9 @@
{
"home": {
"welcomeNight": "Good Night {username}",
"welcomeMorning": "Good Morning {username}",
"welcomeDay": "Hi {username}",
"welcomeEvening": "Good Evening {username}",
"welcomeNight": "Good Night {username}!",
"welcomeMorning": "Good Morning {username}!",
"welcomeDay": "Hi {username}!",
"welcomeEvening": "Good Evening {username}!",
"lastViewed": "Last viewed",
"list": {
"newText": "You can create a new list for your new tasks:",

View File

@ -1,9 +1,9 @@
{
"home": {
"welcomeNight": "Доброй ночи, {username}",
"welcomeMorning": "Доброе утро, {username}",
"welcomeDay": "Привет, {username}",
"welcomeEvening": "Добрый вечер, {username}",
"welcomeNight": "Good Night {username}!",
"welcomeMorning": "Good Morning {username}!",
"welcomeDay": "Hi {username}!",
"welcomeEvening": "Good Evening {username}!",
"lastViewed": "Последние просмотренные",
"list": {
"newText": "Ты можешь создать новый список для своих задач:",

View File

@ -1,9 +1,9 @@
{
"home": {
"welcomeNight": "Good Night {username}",
"welcomeMorning": "Good Morning {username}",
"welcomeDay": "Hi {username}",
"welcomeEvening": "Good Evening {username}",
"welcomeNight": "Good Night {username}!",
"welcomeMorning": "Good Morning {username}!",
"welcomeDay": "Hi {username}!",
"welcomeEvening": "Good Evening {username}!",
"lastViewed": "Last viewed",
"list": {
"newText": "You can create a new list for your new tasks:",

View File

@ -1,9 +1,9 @@
{
"home": {
"welcomeNight": "Good Night {username}",
"welcomeMorning": "Good Morning {username}",
"welcomeDay": "Hi {username}",
"welcomeEvening": "Good Evening {username}",
"welcomeNight": "Good Night {username}!",
"welcomeMorning": "Good Morning {username}!",
"welcomeDay": "Hi {username}!",
"welcomeEvening": "Good Evening {username}!",
"lastViewed": "Last viewed",
"list": {
"newText": "You can create a new list for your new tasks:",

View File

@ -1,9 +1,9 @@
{
"home": {
"welcomeNight": "Good Night {username}",
"welcomeMorning": "Good Morning {username}",
"welcomeDay": "Hi {username}",
"welcomeEvening": "Good Evening {username}",
"welcomeNight": "Good Night {username}!",
"welcomeMorning": "Good Morning {username}!",
"welcomeDay": "Hi {username}!",
"welcomeEvening": "Good Evening {username}!",
"lastViewed": "Last viewed",
"list": {
"newText": "You can create a new list for your new tasks:",

View File

@ -1,9 +1,9 @@
{
"home": {
"welcomeNight": "Ngủ ngon nhé, {username}",
"welcomeMorning": "Chào buổi sáng, {username}",
"welcomeDay": "Hi {username}",
"welcomeEvening": "Chào buổi tối, {username}",
"welcomeNight": "Good Night {username}!",
"welcomeMorning": "Good Morning {username}!",
"welcomeDay": "Hi {username}!",
"welcomeEvening": "Good Evening {username}!",
"lastViewed": "Xem gần đây",
"list": {
"newText": "Bạn có thể tạo một danh sách công việc mới cho mình:",

View File

@ -1,9 +1,9 @@
{
"home": {
"welcomeNight": "Good Night {username}",
"welcomeMorning": "Good Morning {username}",
"welcomeDay": "Hi {username}",
"welcomeEvening": "Good Evening {username}",
"welcomeNight": "Good Night {username}!",
"welcomeMorning": "Good Morning {username}!",
"welcomeDay": "Hi {username}!",
"welcomeEvening": "Good Evening {username}!",
"lastViewed": "Last viewed",
"list": {
"newText": "You can create a new list for your new tasks:",

1053
src/i18n/lang/zh-TW.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,7 @@ export const AUTH_TYPES = {
'LINK_SHARE': 2,
} as const
type AuthType = typeof AUTH_TYPES[keyof typeof AUTH_TYPES]
export type AuthType = typeof AUTH_TYPES[keyof typeof AUTH_TYPES]
export interface IUser extends IAbstract {
id: number

View File

@ -9,6 +9,7 @@ import {setTitle} from '@/helpers/setTitle'
import {useListStore} from '@/stores/lists'
import {useAuthStore} from '@/stores/auth'
import {useBaseStore} from '@/stores/base'
import HomeComponent from '../views/Home.vue'
import NotFoundComponent from '../views/404.vue'
@ -464,11 +465,18 @@ const router = createRouter({
],
})
export function getAuthForRoute(route: RouteLocation) {
export async function getAuthForRoute(route: RouteLocation) {
const authStore = useAuthStore()
if (authStore.authUser || authStore.authLinkShare) {
return
}
const baseStore = useBaseStore()
// When trying this before the current user was fully loaded we might get a flash of the login screen
// in the user shell. To make shure this does not happen we check if everything is ready before trying.
if (!baseStore.ready) {
return
}
// Check if the user is already logged in and redirect them to the home page if not
if (
@ -481,14 +489,23 @@ export function getAuthForRoute(route: RouteLocation) {
'openid.auth',
].includes(route.name as string) &&
localStorage.getItem('passwordResetToken') === null &&
localStorage.getItem('emailConfirmToken') === null
localStorage.getItem('emailConfirmToken') === null &&
!(route.name === 'home' && (typeof route.query.userPasswordReset !== 'undefined' || typeof route.query.userEmailConfirm !== 'undefined'))
) {
saveLastVisited(route.name as string, route.params)
saveLastVisited(route.name as string, route.params, route.query)
return {name: 'user.login'}
}
if(localStorage.getItem('passwordResetToken') !== null && route.name !== 'user.password-reset.reset') {
return {name: 'user.password-reset.reset'}
}
if(localStorage.getItem('emailConfirmToken') !== null && route.name !== 'user.login') {
return {name: 'user.login'}
}
}
router.beforeEach((to) => {
router.beforeEach(async (to) => {
return getAuthForRoute(to)
})

View File

@ -182,11 +182,11 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
////////////////
/**
* The modelFactory returns an model from an object.
* The modelFactory returns a model from an object.
* This one here is the default one, usually the service definitions for a model will override this.
*/
modelFactory(data : Partial<Model>) {
return new AbstractModel(data)
return data as Model
}
/**

View File

@ -3,7 +3,7 @@ import {defineStore, acceptHMRUpdate} from 'pinia'
import {HTTPFactory, AuthenticatedHTTPFactory} from '@/http-common'
import {i18n, getCurrentLanguage, saveLanguage} from '@/i18n'
import {objectToSnakeCase} from '@/helpers/case'
import UserModel, { getAvatarUrl } from '@/models/user'
import UserModel, { getAvatarUrl, getDisplayName } from '@/models/user'
import UserSettingsService from '@/services/userSettings'
import {getToken, refreshToken, removeToken, saveToken} from '@/helpers/auth'
import {setModuleLoading} from '@/stores/helper'
@ -54,6 +54,9 @@ export const useAuthStore = defineStore('auth', {
state.info.type === AUTH_TYPES.LINK_SHARE
)
},
userDisplayName(state) {
return state.info ? getDisplayName(state.info) : undefined
},
},
actions: {
setIsLoading(isLoading: boolean) {
@ -114,7 +117,7 @@ export const useAuthStore = defineStore('auth', {
saveToken(response.data.token, true)
// Tell others the user is autheticated
this.checkAuth()
await this.checkAuth()
} catch (e) {
if (
e.response &&
@ -165,7 +168,7 @@ export const useAuthStore = defineStore('auth', {
saveToken(response.data.token, true)
// Tell others the user is autheticated
this.checkAuth()
await this.checkAuth()
} finally {
this.setIsLoading(false)
}
@ -177,12 +180,12 @@ export const useAuthStore = defineStore('auth', {
password: password,
})
saveToken(response.data.token, false)
this.checkAuth()
await this.checkAuth()
return response.data
},
// Populates user information from jwt token saved in local storage in store
checkAuth() {
async checkAuth() {
// This function can be called from multiple places at the same time and shortly after one another.
// To prevent hitting the api too frequently or race conditions, we check at most once per minute.
@ -206,7 +209,7 @@ export const useAuthStore = defineStore('auth', {
this.setUser(info)
if (authenticated) {
this.refreshUserInfo()
await this.refreshUserInfo()
}
}
@ -215,6 +218,8 @@ export const useAuthStore = defineStore('auth', {
this.setUser(null)
this.redirectToProviderIfNothingElseIsEnabled()
}
return Promise.resolve(authenticated)
},
redirectToProviderIfNothingElseIsEnabled() {
@ -285,11 +290,11 @@ export const useAuthStore = defineStore('auth', {
const stopLoading = setModuleLoading(this)
try {
await HTTPFactory().post('user/confirm', {token: emailVerifyToken})
localStorage.removeItem('emailConfirmToken')
return true
} catch(e) {
throw new Error(e.response.data.message)
} finally {
localStorage.removeItem('emailConfirmToken')
stopLoading()
}
}
@ -332,22 +337,22 @@ export const useAuthStore = defineStore('auth', {
try {
await refreshToken(!this.isLinkShareAuth)
this.checkAuth()
await this.checkAuth()
} catch (e) {
// Don't logout on network errors as the user would then get logged out if they don't have
// internet for a short period of time - such as when the laptop is still reconnecting
if (e?.request?.status) {
this.logout()
await this.logout()
}
}
}, 5000)
},
logout() {
async logout() {
removeToken()
window.localStorage.clear() // Clear all settings and history we might have saved in local storage.
router.push({name: 'user.login'})
this.checkAuth()
await router.push({name: 'user.login'})
await this.checkAuth()
},
},
})

View File

@ -11,6 +11,7 @@ import type {IList} from '@/modelTypes/IList'
export interface RootStoreState {
loading: boolean,
ready: boolean,
currentList: IList | null,
background: string,
@ -26,6 +27,7 @@ export interface RootStoreState {
export const useBaseStore = defineStore('base', {
state: () : RootStoreState => ({
loading: false,
ready: false,
// This is used to highlight the current list in menu for all list related views
currentList: new ListModel({
@ -95,6 +97,10 @@ export const useBaseStore = defineStore('base', {
setLogoVisible(visible: boolean) {
this.logoVisible = visible
},
setReady(ready: boolean) {
this.ready = ready
},
async handleSetCurrentList({list, forceUpdate = false} : {list: IList | null, forceUpdate: boolean}) {
if (list === null) {
@ -133,7 +139,8 @@ export const useBaseStore = defineStore('base', {
async loadApp() {
await checkAndSetApiUrl(window.API_URL)
useAuthStore().checkAuth()
await useAuthStore().checkAuth()
this.ready = true
},
},
})

View File

@ -28,6 +28,7 @@ import {useLabelStore} from '@/stores/labels'
import {useListStore} from '@/stores/lists'
import {useAttachmentStore} from '@/stores/attachments'
import {useKanbanStore} from '@/stores/kanban'
import {useBaseStore} from '@/stores/base'
// IDEA: maybe use a small fuzzy search here to prevent errors
function findPropertyByValue(object, key, value) {
@ -105,6 +106,7 @@ export const useTaskStore = defineStore('task', {
const cancel = setModuleLoading(this)
try {
this.tasks = await taskService.getAll({}, params)
useBaseStore().setHasTasks(this.tasks.length > 0)
return this.tasks
} finally {
cancel()
@ -385,6 +387,7 @@ export const useTaskStore = defineStore('task', {
})
if(foundListId === null || foundListId === 0) {
cancel()
throw new Error('NO_LIST')
}

View File

@ -1,8 +1,7 @@
<template>
<div class="content has-text-centered">
<h2 v-if="userInfo">
{{ $t(welcome, {username: userInfo.name !== '' ? userInfo.name : userInfo.username}) }}!
</h2>
<h2 v-if="salutation">{{ salutation }}</h2>
<message variant="danger" v-if="deletionScheduledAt !== null" class="mb-4">
{{
$t('user.deletion.scheduled', {
@ -69,7 +68,7 @@ import AddTask from '@/components/tasks/add-task.vue'
import {getHistory} from '@/modules/listHistory'
import {parseDateOrNull} from '@/helpers/parseDateOrNull'
import {formatDateShort, formatDateSince} from '@/helpers/time/formatDate'
import {useDateTimeSalutation} from '@/composables/useDateTimeSalutation'
import {useDaytimeSalutation} from '@/composables/useDaytimeSalutation'
import {useBaseStore} from '@/stores/base'
import {useListStore} from '@/stores/lists'
@ -78,7 +77,7 @@ import {useNamespaceStore} from '@/stores/namespaces'
import {useAuthStore} from '@/stores/auth'
import {useTaskStore} from '@/stores/tasks'
const welcome = useDateTimeSalutation()
const salutation = useDaytimeSalutation()
const baseStore = useBaseStore()
const authStore = useAuthStore()
@ -99,7 +98,6 @@ const listHistory = computed(() => {
})
const migratorsEnabled = computed(() => configStore.availableMigrators?.length > 0)
const userInfo = computed(() => authStore.info)
const hasTasks = computed(() => baseStore.hasTasks)
const defaultListId = computed(() => authStore.settings.defaultListId)
const defaultNamespaceId = computed(() => namespaceStore.namespaces?.[0]?.id || 0)

View File

@ -1,7 +1,7 @@
<template>
<ListWrapper class="list-kanban" :list-id="listId" viewName="kanban">
<template #header>
<div class="filter-container" v-if="isSavedFilter(list)">
<div class="filter-container" v-if="!isSavedFilter(listId)">
<div class="items">
<filter-popup
v-model="params"

View File

@ -1,7 +1,7 @@
<template>
<create-edit
:title="$t('list.share.header')"
primary-label=""
:has-primary-action="false"
>
<template v-if="list">
<userTeam

View File

@ -1,7 +1,7 @@
<template>
<create-edit
:title="title"
primary-label=""
:has-primary-action="false"
>
<template v-if="namespace">
<manageSharing

View File

@ -26,7 +26,7 @@
:disabled="!canWrite"
:list-id="task.listId"
:task-id="task.id"
ref="assignees"
:ref="e => setFieldRef('assignees', e)"
v-model="task.assignees"
/>
</div>
@ -40,7 +40,7 @@
<priority-select
:disabled="!canWrite"
@update:model-value="setPriority"
ref="priority"
:ref="e => setFieldRef('priority', e)"
v-model="task.priority"/>
</div>
</transition>
@ -57,7 +57,7 @@
@close-on-change="() => saveTask()"
:choose-date-label="$t('task.detail.chooseDueDate')"
:disabled="taskService.loading || !canWrite"
ref="dueDate"
:ref="e => setFieldRef('dueDate', e)"
/>
<BaseButton
@click="() => {task.dueDate = null;saveTask()}"
@ -80,7 +80,7 @@
<percent-done-select
:disabled="!canWrite"
@update:model-value="setPercentDone"
ref="percentDone"
:ref="e => setFieldRef('percentDone', e)"
v-model="task.percentDone"/>
</div>
</transition>
@ -97,7 +97,7 @@
@close-on-change="() => saveTask()"
:choose-date-label="$t('task.detail.chooseStartDate')"
:disabled="taskService.loading || !canWrite"
ref="startDate"
:ref="e => setFieldRef('startDate', e)"
/>
<BaseButton
@click="() => {task.startDate = null;saveTask()}"
@ -124,7 +124,7 @@
@close-on-change="() => saveTask()"
:choose-date-label="$t('task.detail.chooseEndDate')"
:disabled="taskService.loading || !canWrite"
ref="endDate"
:ref="e => setFieldRef('endDate', e)"
/>
<BaseButton
@click="() => {task.endDate = null;saveTask()}"
@ -146,7 +146,7 @@
</div>
<reminders
:disabled="!canWrite"
ref="reminders"
:ref="e => setFieldRef('reminders', e)"
v-model="task.reminderDates"
@update:model-value="saveTask"
/>
@ -171,7 +171,7 @@
</div>
<repeat-after
:disabled="!canWrite"
ref="repeatAfter"
:ref="e => setFieldRef('repeatAfter', e)"
v-model="task"
@update:model-value="saveTask"
/>
@ -186,7 +186,7 @@
</div>
<color-picker
menu-position="bottom"
ref="color"
:ref="e => setFieldRef('color', e)"
v-model="taskColor"
@update:model-value="saveTask"
/>
@ -202,7 +202,11 @@
</span>
{{ $t('task.attributes.labels') }}
</div>
<edit-labels :disabled="!canWrite" :task-id="taskId" ref="labels" v-model="task.labels"/>
<edit-labels
:disabled="!canWrite"
:task-id="taskId"
:ref="e => setFieldRef('labels', e)"
v-model="task.labels"/>
</div>
<!-- Description -->
@ -220,7 +224,7 @@
:edit-enabled="canWrite"
:task="task"
@task-changed="({coverImageAttachmentId}) => task.coverImageAttachmentId = coverImageAttachmentId"
ref="attachments"
:ref="e => setFieldRef('attachments', e)"
/>
</div>
@ -238,7 +242,7 @@
:list-id="task.listId"
:show-no-relations-notice="true"
:task-id="taskId"
ref="relatedTasks"
:ref="e => setFieldRef('relatedTasks', e)"
/>
</div>
@ -252,7 +256,10 @@
</h3>
<div class="field has-addons">
<div class="control is-expanded">
<list-search @update:modelValue="changeList" ref="moveList"/>
<list-search
@update:modelValue="changeList"
:ref="e => setFieldRef('moveList', e)"
/>
</div>
</div>
</div>
@ -659,10 +666,14 @@ const activeFieldElements : {[id in FieldType]: HTMLElement | null} = reactive({
startDate: null,
})
function setFieldRef(name, e) {
activeFieldElements[name] = unrefElement(e)
}
function setFieldActive(fieldName: keyof typeof activeFields) {
activeFields[fieldName] = true
nextTick(() => {
const el = unrefElement(activeFieldElements[fieldName])
const el = activeFieldElements[fieldName]
if (!el) {
return
@ -782,8 +793,6 @@ async function setPercentDone(percentDone: number) {
$flash-background-duration: 750ms;
.task-view {
// This is a workaround to hide the llama background from the top on the task detail page
margin-top: -1.5rem;
padding: 1rem;
background-color: var(--site-background);

View File

@ -104,7 +104,6 @@
<script setup lang="ts">
import {computed, onBeforeMount, ref} from 'vue'
import {useI18n} from 'vue-i18n'
import {useRouter} from 'vue-router'
import {useDebounceFn} from '@vueuse/core'
import Message from '@/components/misc/message.vue'
@ -112,19 +111,19 @@ import Password from '@/components/input/password.vue'
import {getErrorText} from '@/message'
import {redirectToProvider} from '@/helpers/redirectToProvider'
import {getLastVisited, clearLastVisited} from '@/helpers/saveLastVisited'
import {useRedirectToLastVisited} from '@/composables/useRedirectToLastVisited'
import {useAuthStore} from '@/stores/auth'
import {useConfigStore} from '@/stores/config'
import {useTitle} from '@/composables/useTitle'
const router = useRouter()
const {t} = useI18n({useScope: 'global'})
useTitle(() => t('user.auth.login'))
const authStore = useAuthStore()
const configStore = useConfigStore()
const {redirectIfSaved} = useRedirectToLastVisited()
const registrationEnabled = computed(() => configStore.registrationEnabled)
const localAuthEnabled = computed(() => configStore.auth.local.enabled)
@ -151,16 +150,7 @@ onBeforeMount(() => {
// Check if the user is already logged in, if so, redirect them to the homepage
if (authenticated.value) {
const last = getLastVisited()
if (last !== null) {
router.push({
name: last.name,
params: last.params,
})
clearLastVisited()
} else {
router.push({name: 'home'})
}
redirectIfSaved()
}
})

View File

@ -18,19 +18,19 @@ export default { name: 'Auth' }
<script setup lang="ts">
import {ref, computed, onMounted} from 'vue'
import {useRoute, useRouter} from 'vue-router'
import {useRoute} from 'vue-router'
import {useI18n} from 'vue-i18n'
import {getErrorText} from '@/message'
import Message from '@/components/misc/message.vue'
import {clearLastVisited, getLastVisited} from '@/helpers/saveLastVisited'
import {useRedirectToLastVisited} from '@/composables/useRedirectToLastVisited'
import {useAuthStore} from '@/stores/auth'
const {t} = useI18n({useScope: 'global'})
const router = useRouter()
const route = useRoute()
const {redirectIfSaved} = useRedirectToLastVisited()
const authStore = useAuthStore()
@ -74,16 +74,7 @@ async function authenticateWithCode() {
provider: route.params.provider,
code: route.query.code,
})
const last = getLastVisited()
if (last !== null) {
router.push({
name: last.name,
params: last.params,
})
clearLastVisited()
} else {
router.push({name: 'home'})
}
redirectIfSaved()
} catch(e) {
const err = getErrorText(e)
errorMessage.value = typeof err[1] !== 'undefined' ? err[1] : err[0]

View File

@ -7,41 +7,14 @@
<message variant="success">
{{ successMessage }}
</message>
<x-button :to="{ name: 'user.login' }">
<x-button :to="{ name: 'user.login' }" class="mt-4">
{{ $t('user.auth.login') }}
</x-button>
</div>
<form @submit.prevent="submit" id="form" v-if="!successMessage">
<div class="field">
<label class="label" for="password1">{{ $t('user.auth.password') }}</label>
<div class="control">
<input
class="input"
id="password1"
name="password1"
:placeholder="$t('user.auth.passwordPlaceholder')"
required
type="password"
autocomplete="new-password"
v-focus
v-model="credentials.password"/>
</div>
</div>
<div class="field">
<label class="label" for="password2">{{ $t('user.auth.passwordRepeat') }}</label>
<div class="control">
<input
class="input"
id="password2"
name="password2"
:placeholder="$t('user.auth.passwordPlaceholder')"
required
type="password"
autocomplete="new-password"
v-model="credentials.password2"
@keyup.enter="submit"
/>
</div>
<label class="label" for="password">{{ $t('user.auth.password') }}</label>
<Password @submit="submit" @update:modelValue="v => credentials.password = v"/>
</div>
<div class="field is-grouped">
@ -60,17 +33,14 @@
<script setup lang="ts">
import {ref, reactive} from 'vue'
import {useI18n} from 'vue-i18n'
import PasswordResetModel from '@/models/passwordReset'
import PasswordResetService from '@/services/passwordReset'
import Message from '@/components/misc/message.vue'
const {t} = useI18n({useScope: 'global'})
import Password from '@/components/input/password.vue'
const credentials = reactive({
password: '',
password2: '',
})
const passwordResetService = reactive(new PasswordResetService())
@ -79,15 +49,14 @@ const successMessage = ref('')
async function submit() {
errorMsg.value = ''
if (credentials.password2 !== credentials.password) {
errorMsg.value = t('user.auth.passwordsDontMatch')
if(credentials.password === '') {
return
}
const passwordReset = new PasswordResetModel({newPassword: credentials.password})
try {
const {message} = passwordResetService.resetPassword(passwordReset)
const {message} = await passwordResetService.resetPassword(passwordReset)
successMessage.value = message
localStorage.removeItem('passwordResetToken')
} catch (e) {

View File

@ -7,7 +7,7 @@
<message variant="success">
{{ $t('user.auth.resetPasswordSuccess') }}
</message>
<x-button :to="{ name: 'user.login' }">
<x-button :to="{ name: 'user.login' }" class="mt-4">
{{ $t('user.auth.login') }}
</x-button>
</div>