1
0

Merge branch 'main' into fix/upcoming

# Conflicts:
#	src/views/tasks/ShowTasks.vue
This commit is contained in:
kolaente
2022-02-05 20:02:30 +01:00
123 changed files with 3637 additions and 3309 deletions

View File

@ -42,7 +42,7 @@ import {useBodyClass} from '@/composables/useBodyClass'
const store = useStore()
const router = useRouter()
useBodyClass('is-touch', isTouchDevice)
useBodyClass('is-touch', isTouchDevice())
const keyboardShortcutsActive = computed(() => store.state.keyboardShortcutsActive)
const authUser = computed(() => store.getters['auth/authUser'])

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@ -1,33 +1,51 @@
<template>
<div>
<a @click="$store.commit('menuActive', false)" class="menu-hide-button" v-if="menuActive">
<BaseButton
v-if="menuActive"
@click="$store.commit('menuActive', false)"
class="menu-hide-button"
>
<icon icon="times" />
</a>
</BaseButton>
<div
:class="{'has-background': background}"
:style="{'background-image': background && `url(${background})`}"
class="app-container"
>
<navigation/>
<div
<main
:class="[
{ 'is-menu-enabled': menuActive },
$route.name,
]"
class="app-content"
>
<a @click="$store.commit('menuActive', false)" class="mobile-overlay" v-if="menuActive"></a>
<BaseButton
v-if="menuActive"
@click="$store.commit('menuActive', false)"
class="mobile-overlay"
/>
<quick-actions/>
<router-view/>
<router-view name="popup" v-slot="{ Component }">
<transition name="modal">
<router-view :route="routeWithModal" v-slot="{ Component }">
<keep-alive :include="['list.list', 'list.gantt', 'list.table', 'list.kanban']">
<component :is="Component" />
</transition>
</keep-alive>
</router-view>
<transition name="modal">
<modal
v-if="currentModal"
@close="closeModal()"
variant="scrolling"
class="task-detail-view-modal"
>
<component :is="currentModal" />
</modal>
</transition>
<a
class="keyboard-shortcuts-button"
@click="showKeyboardShortcuts()"
@ -35,13 +53,13 @@
>
<icon icon="keyboard"/>
</a>
</div>
</main>
</div>
</div>
</template>
<script lang="ts" setup>
import {watch, computed} from 'vue'
import {watch, computed, shallowRef, watchEffect, VNode, h} from 'vue'
import {useStore} from 'vuex'
import {useRoute, useRouter} from 'vue-router'
import {useEventListener} from '@vueuse/core'
@ -49,6 +67,59 @@ import {useEventListener} from '@vueuse/core'
import {CURRENT_LIST, KEYBOARD_SHORTCUTS_ACTIVE, MENU_ACTIVE} from '@/store/mutation-types'
import Navigation from '@/components/home/navigation.vue'
import QuickActions from '@/components/quick-actions/quick-actions.vue'
import BaseButton from '@/components/base/BaseButton.vue'
function useRouteWithModal() {
const router = useRouter()
const route = useRoute()
const backdropView = computed(() => route.fullPath && window.history.state.backdropView)
const routeWithModal = computed(() => {
return backdropView.value
? router.resolve(backdropView.value)
: route
})
const currentModal = shallowRef<VNode>()
watchEffect(() => {
if (!backdropView.value) {
currentModal.value = undefined
return
}
// logic from vue-router
// https://github.com/vuejs/vue-router-next/blob/798cab0d1e21f9b4d45a2bd12b840d2c7415f38a/src/RouterView.ts#L125
const routePropsOption = route.matched[0]?.props.default
const routeProps = routePropsOption
? routePropsOption === true
? route.params
: typeof routePropsOption === 'function'
? routePropsOption(route)
: routePropsOption
: null
currentModal.value = h(
route.matched[0]?.components.default,
routeProps,
)
})
function closeModal() {
const historyState = computed(() => route.fullPath && window.history.state)
if (historyState.value) {
router.back()
} else {
const backdropRoute = historyState.value?.backdropView && router.resolve(historyState.value.backdropView)
router.push(backdropRoute)
}
}
return { routeWithModal, currentModal, closeModal }
}
const { routeWithModal, currentModal, closeModal } = useRouteWithModal()
const store = useStore()
@ -223,4 +294,6 @@ store.dispatch('labels/loadAllLabels')
display: none;
}
}
@include modal-transition();
</style>

View File

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

View File

@ -1,9 +1,8 @@
<template>
<nav
<header
:class="{'has-background': background}"
aria-label="main navigation"
class="navbar main-theme is-fixed-top"
role="navigation"
>
<router-link :to="{name: 'home'}" class="logo-link">
<Logo width="164" height="48"/>
@ -77,7 +76,7 @@
</dropdown>
</div>
</div>
</nav>
</header>
</template>
<script>

View File

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

View File

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

View File

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

View File

@ -29,9 +29,10 @@
<script>
import Filters from '@/components/list/partials/filters'
import {getDefaultParams} from '@/components/tasks/mixins/taskList'
import Popup from '@/components/misc/popup'
import {getDefaultParams} from '@/composables/taskList'
export default {
name: 'filter-popup',
components: {

View File

@ -191,7 +191,7 @@ import NamespaceService from '@/services/namespace'
import EditLabels from '@/components/tasks/partials/editLabels.vue'
import {objectToSnakeCase} from '@/helpers/case'
import {getDefaultParams} from '@/components/tasks/mixins/taskList'
import {getDefaultParams} from '@/composables/taskList'
// FIXME: merge with DEFAULT_PARAMS in taskList.js
const DEFAULT_PARAMS = {

View File

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

View File

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

View File

@ -1,11 +1,24 @@
import {RouteLocation} from 'vue-router'
import {isAppleDevice} from '@/helpers/isAppleDevice'
const ctrl = isAppleDevice() ? '⌘' : 'ctrl'
export const KEYBOARD_SHORTCUTS = [
interface Shortcut {
title: string
keys: string[]
combination?: 'then'
}
interface ShortcutGroup {
title: string
available?: (route: RouteLocation) => boolean
shortcuts: Shortcut[]
}
export const KEYBOARD_SHORTCUTS : ShortcutGroup[] = [
{
title: 'keyboardShortcuts.general',
available: () => null,
shortcuts: [
{
title: 'keyboardShortcuts.toggleMenu',
@ -29,7 +42,7 @@ export const KEYBOARD_SHORTCUTS = [
},
{
title: 'keyboardShortcuts.list.title',
available: (route) => route.name.startsWith('list.'),
available: (route) => (route.name as string)?.startsWith('list.'),
shortcuts: [
{
title: 'keyboardShortcuts.list.switchToListView',
@ -55,13 +68,7 @@ export const KEYBOARD_SHORTCUTS = [
},
{
title: 'keyboardShortcuts.task.title',
available: (route) => [
'task.detail',
'task.list.detail',
'task.gantt.detail',
'task.kanban.detail',
'task.detail',
].includes(route.name),
available: (route) => route.name === 'task.detail',
shortcuts: [
{
title: 'keyboardShortcuts.task.assign',

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
<template>
<!-- FIXME: transition should not be included in the modal -->
<transition name="modal">
<section
v-if="enabled"
@ -21,6 +22,13 @@
'is-wide': wide
}"
>
<BaseButton
@click="emit('close')"
class="close"
>
<icon icon="times"/>
</BaseButton>
<slot>
<div class="header">
<slot name="header"></slot>
@ -53,6 +61,8 @@
</template>
<script>
import BaseButton from '@/components/base/BaseButton.vue'
export const TRANSITION_NAMES = {
MODAL: 'modal',
FADE: 'fade',
@ -70,6 +80,11 @@ function validValue(values) {
export default {
name: 'modal',
components: {
BaseButton,
},
mounted() {
document.addEventListener('keydown', (e) => {
// Close the model when escape is pressed
@ -197,17 +212,22 @@ export default {
}
}
.close {
position: fixed;
top: 5px;
right: 26px;
color: var(--white);
font-size: 2rem;
/* Transitions */
.modal-enter,
.modal-leave-active {
opacity: 0;
@media screen and (max-width: $desktop) {
color: var(--dark);
}
}
</style>
.modal-enter .modal-container,
.modal-leave-active .modal-container {
transform: scale(0.9);
<style lang="scss">
// Close icon SVG uses currentColor, change the color to keep it visible
.dark .task-detail-view-modal .close {
color: var(--grey-900);
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,8 +7,8 @@
'has-light-text': !colorIsDark(task.hexColor) && task.hexColor !== `#${task.defaultColor}` && task.hexColor !== task.defaultColor,
}"
:style="{'background-color': task.hexColor !== '#' && task.hexColor !== `#${task.defaultColor}` ? task.hexColor : false}"
@click.exact="openTaskDetail()"
@click.ctrl="() => toggleTaskDone(task)"
@click.exact="() => $router.push({ name: 'task.kanban.detail', params: { id: task.id } })"
@click.meta="() => toggleTaskDone(task)"
>
<span class="task-id">
@ -28,9 +28,9 @@
<span class="icon">
<icon :icon="['far', 'calendar-alt']"/>
</span>
<span>
<time :datetime="formatISO(task.dueDate)">
{{ formatDateSince(task.dueDate) }}
</span>
</time>
</span>
<h3>{{ task.title }}</h3>
<progress
@ -115,6 +115,13 @@ export default {
this.loadingInternal = false
}
},
openTaskDetail() {
this.$router.push({
name: 'task.detail',
params: { id: this.task.id },
state: { backdropView: this.$router.currentRoute.value.fullPath },
})
},
},
}
</script>

View File

@ -274,10 +274,11 @@ export default {
return tasks
.map(task => {
// by doing this here once we can save a lot of duplicate calls in the template
const listAndNamespace = this.$store.getters['namespaces/getListAndNamespaceById'](task.listId, true)
const {
list,
namespace,
} = this.$store.getters['namespaces/getListAndNamespaceById'](task.listId, true)
} = listAndNamespace === null ? {list: null, namespace: null} : listAndNamespace
return {
...task,
@ -364,4 +365,6 @@ export default {
:deep(.multiselect .search-results button) {
padding: 0.5rem;
}
@include modal-transition();
</style>

View File

@ -8,7 +8,7 @@
>
</span>
<router-link
:to="{ name: taskDetailRoute, params: { id: task.id } }"
:to="taskDetailRoute"
:class="{ 'done': task.done}"
class="tasktext">
<span>
@ -39,14 +39,17 @@
:user="a"
v-for="(a, i) in task.assignees"
/>
<i
<time
:datetime="formatISO(task.dueDate)"
:class="{'overdue': task.dueDate <= new Date() && !task.done}"
class="is-italic"
@click.prevent.stop="showDefer = !showDefer"
v-if="+new Date(task.dueDate) > 0"
v-tooltip="formatDate(task.dueDate)"
:aria-expanded="showDefer ? 'true' : 'false'"
>
- {{ $t('task.detail.due', {at: formatDateSince(task.dueDate)}) }}
</i>
</time>
<transition name="fade">
<defer-task v-if="+new Date(task.dueDate) > 0 && showDefer" v-model="task" ref="deferDueDate"/>
</transition>
@ -126,10 +129,6 @@ export default {
type: Boolean,
default: false,
},
taskDetailRoute: {
type: String,
default: 'task.list.detail',
},
showList: {
type: Boolean,
default: false,
@ -167,6 +166,14 @@ export default {
title: '',
} : this.$store.state.currentList
},
taskDetailRoute() {
return {
name: 'task.detail',
params: { id: this.task.id },
// TODO: re-enable opening task detail in modal
// state: { backdropView: this.$router.currentRoute.value.fullPath },
}
},
},
methods: {
async markAsDone(checked) {

View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -899,7 +899,7 @@
"4015": "Komentář k úkolu neexistuje.",
"4016": "Neplatné pole úkolu.",
"4017": "Neplatný komparátor filtru úkolů.",
"4018": "Neplatný koncatinátor filtru úkolů.",
"4018": "Invalid task filter concatenator.",
"4019": "Neplatná hodnota filtru úkolů.",
"5001": "Prostor neexistuje.",
"5003": "Nemáte přístup ke zvolenému prostoru.",

View File

@ -7,7 +7,7 @@
"lastViewed": "Zuletzt angesehen",
"list": {
"newText": "Du kannst eine neue Liste für deine neuen Aufgaben erstellen:",
"new": "New list",
"new": "Neue Liste",
"importText": "Oder importiere deine Listen und Aufgaben aus anderen Diensten in Vikunja:",
"import": "Deine Daten in Vikunja importieren"
}
@ -157,7 +157,7 @@
"searchSelect": "Klicke auf oder drücke die Eingabetaste, um diese Liste auszuwählen",
"shared": "Geteilte Listen",
"create": {
"header": "New list",
"header": "Neue Liste",
"titlePlaceholder": "Der Titel der Liste steht hier…",
"addTitleRequired": "Bitte gebe einen Namen an.",
"createdSuccess": "Die Liste wurde erfolgreich erstellt.",
@ -315,7 +315,7 @@
"namespaces": "Namespaces",
"search": "Beginne zu schreiben, um einen Namespace zu suchen…",
"create": {
"title": "New namespace",
"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.",
"tooltip": "Was ist ein Namespace?",
@ -383,7 +383,7 @@
"reminderRange": "Erinnerungs-Datumsbereich"
},
"create": {
"title": "New Saved Filter",
"title": "Neuer gespeicherter Filter",
"description": "Ein gespeicherter Filter ist eine virtuelle Liste, die bei jedem Zugriff aus einem Satz von Filtern errechnet wird. Einmal erstellt, erscheint diese in einem speziellen Namespace.",
"action": "Neuen gespeicherten Filter erstellen"
},
@ -545,7 +545,7 @@
"chooseStartDate": "Klicke hier, um ein Startdatum zu setzen",
"chooseEndDate": "Klicke hier, um ein Enddatum zu setzen",
"move": "Aufgabe in eine andere Liste verschieben",
"done": "Mark task done!",
"done": "Als erledigt markieren!",
"undone": "Als nicht erledigt markieren",
"created": "Erstellt {0} von {1}",
"updated": "Aktualisiert {0}",
@ -781,7 +781,7 @@
"then": "dann",
"task": {
"title": "Aufgabenseite",
"done": "Done",
"done": "Fertig",
"assign": "Benutzer:in zuweisen",
"labels": "Dieser Aufgabe ein Label hinzufügen",
"dueDate": "Ändere das Fälligkeitsdatum dieser Aufgabe",
@ -899,7 +899,7 @@
"4015": "Dieser Aufgabenkommentar existiert nicht.",
"4016": "Ungültiges Aufgabenfeld.",
"4017": "Ungültiger Aufgabenfilter (Vergleichskriterium).",
"4018": "Ungültiger Aufgabenfilter (Kombination).",
"4018": "Ungültige Verkettung von Aufgabenfiltern.",
"4019": "Ungültiger Aufgabenfilter (Wert).",
"5001": "Dieser Namespace existiert nicht.",
"5003": "Du hast keinen Zugriff auf den Namespace.",

View File

@ -7,7 +7,7 @@
"lastViewed": "Zletscht ahglueget",
"list": {
"newText": "Du chasch e Liste für dini neue Uufgabe erstelle:",
"new": "New list",
"new": "Neue Liste",
"importText": "Oder importier dini Liste und Uufgabe us anderne Dienst nach Vikunja:",
"import": "Dini Date in Vikunja importiere"
}
@ -157,7 +157,7 @@
"searchSelect": "Druck uf Enter um die Liste uuszwähle",
"shared": "Teilti Liste",
"create": {
"header": "New list",
"header": "Neue Liste",
"titlePlaceholder": "Listetitl da ahgeh…",
"addTitleRequired": "Bitte gib en Titl ah.",
"createdSuccess": "Liste erfolgriich erstellt.",
@ -315,7 +315,7 @@
"namespaces": "Namensrüüm",
"search": "Schriib, um nachemne Namensruum z'sueche…",
"create": {
"title": "New namespace",
"title": "Neuer Namespace",
"titleRequired": "Bitte gib en Titl ah.",
"explanation": "En Namensruum isch e Gruppe vo Liste, wo du chasch zur Organisation benutze. Tatsächlich sind alli Listene emne Namensruum zuegwise.",
"tooltip": "Was isch en Namensruum?",
@ -383,7 +383,7 @@
"reminderRange": "Errinnerigs Datumbereich"
},
"create": {
"title": "New Saved Filter",
"title": "Neuer gespeicherter Filter",
"description": "En gspeicherete Filter isch e virtuelli Liste, welche vomene Satz a Filter zemmegsetzt wird, sobald me uf sie zuegriift. Wenn sie mal erstellt worde isch, erhaltet si ihren eigene Namensruum.",
"action": "Neue gspeicherete Filter erstelle"
},
@ -545,7 +545,7 @@
"chooseStartDate": "Druck dah, um es Startdatum z'setze",
"chooseEndDate": "Druck da, um es Enddatum z'setze",
"move": "Schieb die Uufgab in e anderi Liste",
"done": "Mark task done!",
"done": "Als erledigt markieren!",
"undone": "Als unerledigt markierä",
"created": "Erstellt am {0} vo {1}",
"updated": "{0} g'updatet",
@ -781,7 +781,7 @@
"then": "dann",
"task": {
"title": "Uufgabesiite",
"done": "Done",
"done": "Fertig",
"assign": "Benutzer:in zuweisen",
"labels": "Labels ennere Uufgab hinzuefüege",
"dueDate": "S'Fälligkeitsdatum für die Uufgab ändere",
@ -899,7 +899,7 @@
"4015": "De Uufgabe Kommentar giz nid.",
"4016": "Ungültigs Uufgabefeld.",
"4017": "Ungültige Uufgabefilter vergliich.",
"4018": "Ungültige Uufgabefilter Zemmezug.",
"4018": "Ungültige Verkettung von Aufgabenfiltern.",
"4019": "Ungültigi Uufgabe Filter Wert.",
"5001": "De Namensruum existiert nid.",
"5003": "Du hesch kei Zuegriff zu dem Namensruum.",

View File

@ -31,10 +31,9 @@
"username": "Username",
"usernameEmail": "Username Or Email Address",
"usernamePlaceholder": "e.g. frederick",
"email": "E-mail address",
"email": "Email address",
"emailPlaceholder": "e.g. frederic{'@'}vikunja.io",
"password": "Password",
"passwordRepeat": "Retype your password",
"passwordPlaceholder": "e.g. •••••••••••",
"forgotPassword": "Forgot your password?",
"resetPassword": "Reset your password",
@ -45,12 +44,19 @@
"totpTitle": "Two Factor Authentication Code",
"totpPlaceholder": "e.g. 123456",
"login": "Login",
"register": "Register",
"createAccount": "Create account",
"loginWith": "Log in with {provider}",
"authenticating": "Authenticating…",
"openIdStateError": "State does not match, refusing to continue!",
"openIdGeneralError": "An error occured while authenticating against the third party.",
"logout": "Logout"
"logout": "Logout",
"emailInvalid": "Please enter a valid email address.",
"usernameRequired": "Please provide a username.",
"passwordRequired": "Please provide a password.",
"showPassword": "Show the password",
"hidePassword": "Hide the password",
"noAccountYet": "Don't have an account yet?",
"alreadyHaveAnAccount": "Already have an account?"
},
"settings": {
"title": "Settings",
@ -61,7 +67,7 @@
"currentPasswordPlaceholder": "Your current password",
"passwordsDontMatch": "The new password and its confirmation don't match.",
"passwordUpdateSuccess": "The password was successfully updated.",
"updateEmailTitle": "Update Your E-Mail Address",
"updateEmailTitle": "Update Your Email Address",
"updateEmailNew": "New Email Address",
"updateEmailSuccess": "Your email address was successfully updated. We've sent you a link to confirm it.",
"general": {
@ -904,7 +910,7 @@
"4015": "The task comment does not exist.",
"4016": "Invalid task field.",
"4017": "Invalid task filter comparator.",
"4018": "Invalid task filter concatinator.",
"4018": "Invalid task filter concatenator.",
"4019": "Invalid task filter value.",
"5001": "The namespace does not exist.",
"5003": "You do not have access to the specified namespace.",

View File

@ -899,7 +899,7 @@
"4015": "The task comment does not exist.",
"4016": "Invalid task field.",
"4017": "Invalid task filter comparator.",
"4018": "Invalid task filter concatinator.",
"4018": "Invalid task filter concatenator.",
"4019": "Invalid task filter value.",
"5001": "The namespace does not exist.",
"5003": "You do not have access to the specified namespace.",

View File

@ -116,12 +116,12 @@
"vikunja": "Vikunja"
},
"appearance": {
"title": "Color Scheme",
"setSuccess": "Saved change of color scheme to {colorScheme}",
"title": "Jeu de couleurs",
"setSuccess": "Changement du jeu de couleurs enregistré vers {colorScheme}",
"colorScheme": {
"light": "Light",
"system": "System",
"dark": "Dark"
"light": "Clair",
"system": "Système",
"dark": "Sombre"
}
}
},
@ -475,7 +475,7 @@
"download": "Télécharger",
"showMenu": "Afficher le menu",
"hideMenu": "Masquer le menu",
"forExample": "For example:",
"forExample": "Par exemple :",
"welcomeBack": "Welcome Back!"
},
"input": {
@ -561,7 +561,7 @@
"text2": "Ceci supprimera également toutes les pièces jointes, les rappels et les relations associés à cette tâche et ne pourra pas être annulé !"
},
"actions": {
"assign": "Assign to a user",
"assign": "Attribuer à un utilisateur",
"label": "Ajouter des étiquettes",
"priority": "Définir la priorité",
"dueDate": "Définir léchéance",
@ -726,8 +726,8 @@
"dateCurrentYear": "utilisera lannée en cours",
"dateNth": "utilisera le {day}e du mois en cours",
"dateTime": "Combinez nimporte lequel des formats de date avec « {time} » (ou {timePM}) pour définir une heure.",
"repeats": "Repeating tasks",
"repeatsDescription": "To set a task as repeating in an interval, simply add '{suffix}' to the task text. The amount needs to be a number and can be omitted to use just the type (see examples)."
"repeats": "Tâches répétitives",
"repeatsDescription": "Pour définir une tâche comme répétitive dans un intervalle, il suffit d'ajouter « {suffix} » au texte de la tâche. Le montant doit être un nombre et peut être omis pour utiliser uniquement le type (voir exemples)."
}
},
"team": {
@ -782,7 +782,7 @@
"task": {
"title": "Page de tâche",
"done": "Done",
"assign": "Assign to a user",
"assign": "Attribuer à un utilisateur",
"labels": "Ajouter des étiquettes à cette tâche",
"dueDate": "Modifier la date déchéance de cette tâche",
"attachment": "Ajouter une pièce jointe à cette tâche",
@ -899,7 +899,7 @@
"4015": "Le commentaire de la tâche nexiste pas.",
"4016": "Champ de tâche invalide.",
"4017": "Comparateur de filtre de tâche invalide.",
"4018": "Concaténateur de filtre de tâche invalide.",
"4018": "Invalid task filter concatenator.",
"4019": "Valeur de filtre de tâche invalide.",
"5001": "Lespace de noms nexiste pas.",
"5003": "Tu nas pas accès à lespace de noms indiqué.",
@ -908,7 +908,7 @@
"5010": "Cette équipe na pas accès à cet espace de noms.",
"5011": "Cet·e utilisateur·rice a déjà accès à cet espace de noms.",
"5012": "Lespace de noms est archivé et ne peut donc être consulté quen lecture seule.",
"6001": "The team name cannot be empty.",
"6001": "Le nom de l'équipe ne peut pas être vide.",
"6002": "Léquipe nexiste pas.",
"6004": "Léquipe a déjà accès à cet espace de noms ou à cette liste.",
"6005": "Lutilisateur·rice est déjà membre de cette équipe.",

View File

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

View File

@ -899,7 +899,7 @@
"4015": "The task comment does not exist.",
"4016": "Invalid task field.",
"4017": "Invalid task filter comparator.",
"4018": "Invalid task filter concatinator.",
"4018": "Invalid task filter concatenator.",
"4019": "Invalid task filter value.",
"5001": "The namespace does not exist.",
"5003": "You do not have access to the specified namespace.",

View File

@ -899,7 +899,7 @@
"4015": "The task comment does not exist.",
"4016": "Invalid task field.",
"4017": "Invalid task filter comparator.",
"4018": "Invalid task filter concatinator.",
"4018": "Invalid task filter concatenator.",
"4019": "Invalid task filter value.",
"5001": "The namespace does not exist.",
"5003": "You do not have access to the specified namespace.",

View File

@ -899,7 +899,7 @@
"4015": "The task comment does not exist.",
"4016": "Invalid task field.",
"4017": "Invalid task filter comparator.",
"4018": "Invalid task filter concatinator.",
"4018": "Invalid task filter concatenator.",
"4019": "Invalid task filter value.",
"5001": "The namespace does not exist.",
"5003": "You do not have access to the specified namespace.",

View File

@ -899,7 +899,7 @@
"4015": "The task comment does not exist.",
"4016": "Invalid task field.",
"4017": "Invalid task filter comparator.",
"4018": "Invalid task filter concatinator.",
"4018": "Invalid task filter concatenator.",
"4019": "Invalid task filter value.",
"5001": "The namespace does not exist.",
"5003": "You do not have access to the specified namespace.",

View File

@ -899,7 +899,7 @@
"4015": "Комментарий не существует.",
"4016": "Неверное поле задачи.",
"4017": "Неверный сравнитель фильтров задач.",
"4018": "Неверный соединитель фильтров задач.",
"4018": "Invalid task filter concatenator.",
"4019": "Неверное значение фильтра задач.",
"5001": "Пространство имён не существует.",
"5003": "Нет доступа к указанному пространству имён.",

View File

@ -899,7 +899,7 @@
"4015": "The task comment does not exist.",
"4016": "Invalid task field.",
"4017": "Invalid task filter comparator.",
"4018": "Invalid task filter concatinator.",
"4018": "Invalid task filter concatenator.",
"4019": "Invalid task filter value.",
"5001": "The namespace does not exist.",
"5003": "You do not have access to the specified namespace.",

View File

@ -899,7 +899,7 @@
"4015": "The task comment does not exist.",
"4016": "Invalid task field.",
"4017": "Invalid task filter comparator.",
"4018": "Invalid task filter concatinator.",
"4018": "Invalid task filter concatenator.",
"4019": "Invalid task filter value.",
"5001": "The namespace does not exist.",
"5003": "You do not have access to the specified namespace.",

View File

@ -899,7 +899,7 @@
"4015": "Bình luận không tồn tại.",
"4016": "Trường công việc không hợp lệ.",
"4017": "Bộ so sánh bộ lọc công việc không hợp lệ.",
"4018": "Bộ lọc kết hợp không hợp lệ.",
"4018": "Invalid task filter concatenator.",
"4019": "Giá trị bộ lọc công việc không hợp lệ.",
"5001": "Góc làm việc không có nữa.",
"5003": "Bạn chưa được phép bước vào vào góc làm việc được chỉ định.",

View File

@ -16,6 +16,8 @@ import {
faCocktail,
faCoffee,
faCog,
faEye,
faEyeSlash,
faEllipsisH,
faEllipsisV,
faExclamation,
@ -87,6 +89,8 @@ library.add(faCocktail)
library.add(faCoffee)
library.add(faCog)
library.add(faComments)
library.add(faEye)
library.add(faEyeSlash)
library.add(faEllipsisH)
library.add(faEllipsisV)
library.add(faExclamation)

View File

@ -18,7 +18,7 @@ declare global {
}
}
import {formatDate, formatDateShort, formatDateLong, formatDateSince} from '@/helpers/time/formatDate'
import {formatDate, formatDateShort, formatDateLong, formatDateSince, formatISO} from '@/helpers/time/formatDate'
// @ts-ignore
import {VERSION} from './version.json'
@ -52,6 +52,7 @@ app.use(Notifications)
// directives
import focus from '@/directives/focus'
// @ts-ignore The export does exist, ts just doesn't find it.
import { VTooltip } from 'v-tooltip'
import 'v-tooltip/dist/v-tooltip.css'
import shortcut from '@/directives/shortcut'
@ -84,6 +85,7 @@ app.mixin({
format: formatDate,
formatDate: formatDateLong,
formatDateShort: formatDateShort,
formatISO,
getNamespaceTitle,
getListTitle,
setTitle,

View File

@ -10,9 +10,6 @@ import {parseDateOrNull} from '@/helpers/parseDateOrNull'
const SUPPORTS_TRIGGERED_NOTIFICATION = 'Notification' in window && 'showTrigger' in Notification.prototype
export default class TaskModel extends AbstractModel {
defaultColor = '198CFF'
constructor(data) {
super(data)

View File

@ -1,4 +1,4 @@
import {describe, it, expect} from 'vitest'
import {beforeEach, afterEach, describe, it, expect, vi} from 'vitest'
import {parseTaskText} from './parseTaskText'
import {getDateFromText, getDateFromTextIn} from '../helpers/time/parseDate'
@ -6,6 +6,14 @@ import {calculateDayInterval} from '../helpers/time/calculateDayInterval'
import priorities from '../models/constants/priorities.json'
describe('Parse Task Text', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('should return text with no intents as is', () => {
expect(parseTaskText('Lorem Ipsum').text).toBe('Lorem Ipsum')
})
@ -32,7 +40,7 @@ describe('Parse Task Text', () => {
expect(result.assignees).toHaveLength(1)
expect(result.assignees[0]).toBe('user')
})
it('should ignore email addresses', () => {
const text = 'Lorem Ipsum email@example.com'
const result = parseTaskText(text)
@ -211,17 +219,36 @@ describe('Parse Task Text', () => {
expect(`${result.date.getHours()}:${result.date.getMinutes()}`).toBe('14:0')
})
it('should recognize dates of the month in the past but next month', () => {
const date = new Date()
date.setDate(date.getDate() - 1)
const result = parseTaskText(`Lorem Ipsum ${date.getDate()}nd`)
const time = new Date(2022, 0, 15)
vi.setSystemTime(time)
const result = parseTaskText(`Lorem Ipsum ${time.getDate() - 1}th`)
expect(result.text).toBe('Lorem Ipsum')
expect(result.date.getDate()).toBe(date.getDate())
expect(result.date.getDate()).toBe(time.getDate() - 1)
expect(result.date.getMonth()).toBe(time.getMonth() + 1)
})
it('should recognize dates of the month in the past but next month when february is the next month', () => {
const jan = new Date(2022, 0, 30)
vi.setSystemTime(jan)
const nextMonthWithDate = result.date.getDate() === 31
? (date.getMonth() + 2) % 12
: (date.getMonth() + 1) % 12
expect(result.date.getMonth()).toBe(nextMonthWithDate)
const result = parseTaskText(`Lorem Ipsum ${jan.getDate() - 1}th`)
const expectedDate = new Date(2022, 2, jan.getDate() - 1)
expect(result.text).toBe('Lorem Ipsum')
expect(result.date.getDate()).toBe(expectedDate.getDate())
expect(result.date.getMonth()).toBe(expectedDate.getMonth())
})
it('should recognize dates of the month in the past but next month when the next month has less days than this one', () => {
const mar = new Date(2022, 2, 32)
vi.setSystemTime(mar)
const result = parseTaskText(`Lorem Ipsum 31st`)
const expectedDate = new Date(2022, 4, 31)
expect(result.text).toBe('Lorem Ipsum')
expect(result.date.getDate()).toBe(expectedDate.getDate())
expect(result.date.getMonth()).toBe(expectedDate.getMonth())
})
it('should recognize dates of the month in the future', () => {
const nextDay = new Date(+new Date() + 60 * 60 * 24 * 1000)
@ -242,6 +269,12 @@ describe('Parse Task Text', () => {
expect(result.text).toBe('Lorem Ipsum github')
expect(result.date).toBeNull()
})
it('should not recognize date number with no spacing around them', () => {
const result = parseTaskText('Lorem Ispum v1.1.1')
expect(result.text).toBe('Lorem Ispum v1.1.1')
expect(result.date).toBeNull()
})
describe('Parse weekdays', () => {

View File

@ -2,6 +2,8 @@ import { createRouter, createWebHistory, RouteLocation } from 'vue-router'
import {saveLastVisited} from '@/helpers/saveLastVisited'
import {store} from '@/store'
import {saveListView, getListView} from '@/helpers/saveListView'
import HomeComponent from '../views/Home.vue'
import NotFoundComponent from '../views/404.vue'
import About from '../views/About.vue'
@ -13,9 +15,8 @@ import DataExportDownload from '../views/user/DataExportDownload.vue'
// Tasks
import ShowTasksInRangeComponent from '../views/tasks/ShowTasksInRange.vue'
import LinkShareAuthComponent from '../views/sharing/LinkSharingAuth.vue'
import TaskDetailViewModal from '../views/tasks/TaskDetailViewModal.vue'
import TaskDetailView from '../views/tasks/TaskDetailView.vue'
import ListNamespaces from '../views/namespaces/ListNamespaces.vue'
import TaskDetailView from '../views/tasks/TaskDetailView.vue'
// Team Handling
import ListTeamsComponent from '../views/teams/ListTeams.vue'
// Label Handling
@ -25,11 +26,11 @@ import NewLabelComponent from '../views/labels/NewLabel.vue'
import MigrationComponent from '../views/migrator/Migrate.vue'
import MigrateServiceComponent from '../views/migrator/MigrateService.vue'
// List Views
import ShowListComponent from '../views/list/ShowList.vue'
import Kanban from '../views/list/views/Kanban.vue'
import List from '../views/list/views/List.vue'
import Gantt from '../views/list/views/Gantt.vue'
import Table from '../views/list/views/Table.vue'
import ListList from '../views/list/ListList.vue'
import ListGantt from '../views/list/ListGantt.vue'
import ListTable from '../views/list/ListTable.vue'
import ListKanban from '../views/list/ListKanban.vue'
// List Settings
import ListSettingEdit from '../views/list/settings/edit.vue'
import ListSettingBackground from '../views/list/settings/background.vue'
@ -80,7 +81,7 @@ const router = createRouter({
// Scroll to anchor should still work
if (to.hash) {
return {el: document.getElementById(to.hash.slice(1))}
return {el: to.hash}
}
// Otherwise just scroll to the top
@ -132,7 +133,7 @@ const router = createRouter({
name: 'user.register',
component: RegisterComponent,
meta: {
title: 'user.auth.register',
title: 'user.auth.createAccount',
},
},
{
@ -201,320 +202,170 @@ const router = createRouter({
{
path: '/namespaces/new',
name: 'namespace.create',
components: {
popup: NewNamespaceComponent,
},
},
{
path: '/namespaces/:id/list',
name: 'list.create',
components: {
popup: NewListComponent,
component: NewNamespaceComponent,
meta: {
showAsModal: true,
},
},
{
path: '/namespaces/:id/settings/edit',
name: 'namespace.settings.edit',
components: {
popup: NamespaceSettingEdit,
component: NamespaceSettingEdit,
meta: {
showAsModal: true,
},
},
{
path: '/namespaces/:id/settings/share',
path: '/namespaces/:namespaceId/settings/share',
name: 'namespace.settings.share',
components: {
popup: NamespaceSettingShare,
component: NamespaceSettingShare,
meta: {
showAsModal: true,
},
},
{
path: '/namespaces/:id/settings/archive',
name: 'namespace.settings.archive',
components: {
popup: NamespaceSettingArchive,
component: NamespaceSettingArchive,
meta: {
showAsModal: true,
},
},
{
path: '/namespaces/:id/settings/delete',
name: 'namespace.settings.delete',
components: {
popup: NamespaceSettingDelete,
component: NamespaceSettingDelete,
meta: {
showAsModal: true,
},
},
{
path: '/tasks/:id',
name: 'task.detail',
component: TaskDetailView,
props: route => ({ taskId: parseInt(route.params.id as string) }),
},
{
path: '/tasks/by/upcoming',
name: 'tasks.range',
component: ShowTasksInRangeComponent,
},
{
path: '/lists/new/:namespaceId/',
name: 'list.create',
component: NewListComponent,
meta: {
showAsModal: true,
},
},
{
path: '/lists/:listId/settings/edit',
name: 'list.settings.edit',
components: {
popup: ListSettingEdit,
component: ListSettingEdit,
meta: {
showAsModal: true,
},
},
{
path: '/lists/:listId/settings/background',
name: 'list.settings.background',
components: {
popup: ListSettingBackground,
component: ListSettingBackground,
meta: {
showAsModal: true,
},
},
{
path: '/lists/:listId/settings/duplicate',
name: 'list.settings.duplicate',
components: {
popup: ListSettingDuplicate,
component: ListSettingDuplicate,
meta: {
showAsModal: true,
},
},
{
path: '/lists/:listId/settings/share',
name: 'list.settings.share',
components: {
popup: ListSettingShare,
component: ListSettingShare,
meta: {
showAsModal: true,
},
},
{
path: '/lists/:listId/settings/delete',
name: 'list.settings.delete',
components: {
popup: ListSettingDelete,
component: ListSettingDelete,
meta: {
showAsModal: true,
},
},
{
path: '/lists/:listId/settings/archive',
name: 'list.settings.archive',
components: {
popup: ListSettingArchive,
component: ListSettingArchive,
meta: {
showAsModal: true,
},
},
{
path: '/lists/:listId/settings/edit',
name: 'filter.settings.edit',
components: {
popup: FilterEdit,
component: FilterEdit,
meta: {
showAsModal: true,
},
},
{
path: '/lists/:listId/settings/delete',
name: 'filter.settings.delete',
components: {
popup: FilterDelete,
component: FilterDelete,
meta: {
showAsModal: true,
},
},
{
path: '/lists/:listId',
name: 'list.index',
component: ShowListComponent,
children: [
{
path: '/lists/:listId/list',
name: 'list.list',
component: List,
children: [
{
path: '/tasks/:id',
name: 'task.list.detail',
component: TaskDetailViewModal,
},
{
path: '/lists/:listId/settings/edit',
name: 'list.list.settings.edit',
component: ListSettingEdit,
},
{
path: '/lists/:listId/settings/background',
name: 'list.list.settings.background',
component: ListSettingBackground,
},
{
path: '/lists/:listId/settings/duplicate',
name: 'list.list.settings.duplicate',
component: ListSettingDuplicate,
},
{
path: '/lists/:listId/settings/share',
name: 'list.list.settings.share',
component: ListSettingShare,
},
{
path: '/lists/:listId/settings/delete',
name: 'list.list.settings.delete',
component: ListSettingDelete,
},
{
path: '/lists/:listId/settings/archive',
name: 'list.list.settings.archive',
component: ListSettingArchive,
},
{
path: '/lists/:listId/settings/edit',
name: 'filter.list.settings.edit',
component: FilterEdit,
},
{
path: '/lists/:listId/settings/delete',
name: 'filter.list.settings.delete',
component: FilterDelete,
},
],
},
{
path: '/lists/:listId/gantt',
name: 'list.gantt',
component: Gantt,
children: [
{
path: '/tasks/:id',
name: 'task.gantt.detail',
component: TaskDetailViewModal,
},
{
path: '/lists/:listId/settings/edit',
name: 'list.gantt.settings.edit',
component: ListSettingEdit,
},
{
path: '/lists/:listId/settings/background',
name: 'list.gantt.settings.background',
component: ListSettingBackground,
},
{
path: '/lists/:listId/settings/duplicate',
name: 'list.gantt.settings.duplicate',
component: ListSettingDuplicate,
},
{
path: '/lists/:listId/settings/share',
name: 'list.gantt.settings.share',
component: ListSettingShare,
},
{
path: '/lists/:listId/settings/delete',
name: 'list.gantt.settings.delete',
component: ListSettingDelete,
},
{
path: '/lists/:listId/settings/archive',
name: 'list.gantt.settings.archive',
component: ListSettingArchive,
},
{
path: '/lists/:listId/settings/edit',
name: 'filter.gantt.settings.edit',
component: FilterEdit,
},
{
path: '/lists/:listId/settings/delete',
name: 'filter.gantt.settings.delete',
component: FilterDelete,
},
],
},
{
path: '/lists/:listId/table',
name: 'list.table',
component: Table,
children: [
{
path: '/lists/:listId/settings/edit',
name: 'list.table.settings.edit',
component: ListSettingEdit,
},
{
path: '/lists/:listId/settings/background',
name: 'list.table.settings.background',
component: ListSettingBackground,
},
{
path: '/lists/:listId/settings/duplicate',
name: 'list.table.settings.duplicate',
component: ListSettingDuplicate,
},
{
path: '/lists/:listId/settings/share',
name: 'list.table.settings.share',
component: ListSettingShare,
},
{
path: '/lists/:listId/settings/delete',
name: 'list.table.settings.delete',
component: ListSettingDelete,
},
{
path: '/lists/:listId/settings/archive',
name: 'list.table.settings.archive',
component: ListSettingArchive,
},
{
path: '/lists/:listId/settings/edit',
name: 'filter.table.settings.edit',
component: FilterEdit,
},
{
path: '/lists/:listId/settings/delete',
name: 'filter.table.settings.delete',
component: FilterDelete,
},
],
},
{
path: '/lists/:listId/kanban',
name: 'list.kanban',
component: Kanban,
children: [
{
path: '/tasks/:id',
name: 'task.kanban.detail',
component: TaskDetailViewModal,
},
{
path: '/lists/:listId/settings/edit',
name: 'list.kanban.settings.edit',
component: ListSettingEdit,
},
{
path: '/lists/:listId/settings/background',
name: 'list.kanban.settings.background',
component: ListSettingBackground,
},
{
path: '/lists/:listId/settings/duplicate',
name: 'list.kanban.settings.duplicate',
component: ListSettingDuplicate,
},
{
path: '/lists/:listId/settings/share',
name: 'list.kanban.settings.share',
component: ListSettingShare,
},
{
path: '/lists/:listId/settings/delete',
name: 'list.kanban.settings.delete',
component: ListSettingDelete,
},
{
path: '/lists/:listId/settings/archive',
name: 'list.kanban.settings.archive',
component: ListSettingArchive,
},
{
path: '/lists/:listId/settings/edit',
name: 'filter.kanban.settings.edit',
component: FilterEdit,
},
{
path: '/lists/:listId/settings/delete',
name: 'filter.kanban.settings.delete',
component: FilterDelete,
},
],
},
],
redirect(to) {
// Redirect the user to list view by default
const savedListView = getListView(to.params.listId)
console.debug('Replaced list view with', savedListView)
return {
name: router.hasRoute(savedListView)
? savedListView
: 'list.list',
params: {listId: to.params.listId},
}
},
},
{
path: '/lists/:listId/list',
name: 'list.list',
component: ListList,
beforeEnter: (to) => saveListView(to.params.listId, to.name),
props: route => ({ listId: parseInt(route.params.listId as string) }),
},
{
path: '/lists/:listId/gantt',
name: 'list.gantt',
component: ListGantt,
beforeEnter: (to) => saveListView(to.params.listId, to.name),
props: route => ({ listId: parseInt(route.params.listId as string) }),
},
{
path: '/lists/:listId/table',
name: 'list.table',
component: ListTable,
beforeEnter: (to) => saveListView(to.params.listId, to.name),
props: route => ({ listId: parseInt(route.params.listId as string) }),
},
{
path: '/lists/:listId/kanban',
name: 'list.kanban',
component: ListKanban,
beforeEnter: (to) => saveListView(to.params.listId, to.name),
props: route => ({ listId: parseInt(route.params.listId as string) }),
},
{
path: '/teams',
@ -524,8 +375,9 @@ const router = createRouter({
{
path: '/teams/new',
name: 'teams.create',
components: {
popup: NewTeamComponent,
component: NewTeamComponent,
meta: {
showAsModal: true,
},
},
{
@ -541,8 +393,9 @@ const router = createRouter({
{
path: '/labels/new',
name: 'labels.create',
components: {
popup: NewLabelComponent,
component: NewLabelComponent,
meta: {
showAsModal: true,
},
},
{
@ -558,8 +411,9 @@ const router = createRouter({
{
path: '/filters/new',
name: 'filters.create',
components: {
popup: FilterNew,
component: FilterNew,
meta: {
showAsModal: true,
},
},
{
@ -575,11 +429,7 @@ const router = createRouter({
],
})
router.beforeEach((to) => {
return checkAuth(to)
})
function checkAuth(route: RouteLocation) {
export function getAuthForRoute(route: RouteLocation) {
const authUser = store.getters['auth/authUser']
const authLinkShare = store.getters['auth/authLinkShare']

View File

@ -18,6 +18,8 @@ import lists from './modules/lists'
import attachments from './modules/attachments'
import labels from './modules/labels'
import ListModel from '@/models/list'
import ListService from '../services/list'
import {checkAndSetApiUrl} from '@/helpers/checkAndSetApiUrl'
@ -37,13 +39,15 @@ export const store = createStore({
loading: false,
loadingModule: null,
// This is used to highlight the current list in menu for all list related views
currentList: {id: 0},
currentList: new ListModel({
id: 0,
isArchived: false,
}),
background: '',
hasTasks: false,
menuActive: true,
keyboardShortcutsActive: false,
quickActionsActive: false,
vikunjaReady: false,
},
mutations: {
[LOADING](state, loading) {
@ -79,9 +83,6 @@ export const store = createStore({
[BACKGROUND](state, background) {
state.background = background
},
vikunjaReady(state, ready) {
state.vikunjaReady = ready
},
},
actions: {
async [CURRENT_LIST]({state, commit}, currentList) {
@ -136,10 +137,9 @@ export const store = createStore({
commit(CURRENT_LIST, currentList)
},
async loadApp({commit, dispatch}) {
async loadApp({dispatch}) {
await checkAndSetApiUrl(window.API_URL)
await dispatch('auth/checkAuth')
commit('vikunjaReady', true)
},
},
})

View File

@ -1,12 +1,12 @@
import {HTTPFactory} from '@/http-common'
import {getCurrentLanguage, saveLanguage} from '@/i18n'
import {i18n, getCurrentLanguage, saveLanguage} from '@/i18n'
import {LOADING} from '../mutation-types'
import UserModel from '@/models/user'
import UserSettingsService from '@/services/userSettings'
import {getToken, refreshToken, removeToken, saveToken} from '@/helpers/auth'
import {setLoading} from '@/store/helper'
import {i18n} from '@/i18n'
import {success} from '@/message'
import {redirectToProvider} from '@/helpers/redirectToProvider'
const AUTH_TYPES = {
'UNKNOWN': 0,
@ -201,7 +201,19 @@ export default {
ctx.commit('authenticated', authenticated)
if (!authenticated) {
ctx.commit('info', null)
ctx.dispatch('config/redirectToProviderIfNothingElseIsEnabled', null, {root: true})
ctx.dispatch('redirectToProviderIfNothingElseIsEnabled')
}
},
redirectToProviderIfNothingElseIsEnabled({rootState}) {
const {auth} = rootState.config
if (
auth.local.enabled === false &&
auth.openidConnect.enabled &&
auth.openidConnect.providers?.length === 1 &&
window.location.pathname.startsWith('/login') // Kinda hacky, but prevents an endless loop.
) {
redirectToProvider(auth.openidConnect.providers[0], auth.openidConnect.redirectUrl)
}
},
@ -226,7 +238,7 @@ export default {
commit('info', info)
commit('lastUserRefresh')
if (typeof info.settings.language !== 'undefined') {
if (typeof info.settings.language === 'undefined' || info.settings.language === '') {
// save current language
await dispatch('saveUserSettings', {
settings: {

View File

@ -1,7 +1,6 @@
import {CONFIG} from '../mutation-types'
import {HTTPFactory} from '@/http-common'
import {objectToCamelCase} from '@/helpers/case'
import {redirectToProvider} from '../../helpers/redirectToProvider'
import {parseURL} from 'ufo'
export default {
@ -75,16 +74,5 @@ export default {
ctx.commit(CONFIG, info)
return info
},
redirectToProviderIfNothingElseIsEnabled(ctx) {
if (ctx.state.auth.local.enabled === false &&
ctx.state.auth.openidConnect.enabled &&
ctx.state.auth.openidConnect.providers &&
ctx.state.auth.openidConnect.providers.length === 1 &&
window.location.pathname.startsWith('/login') // Kinda hacky, but prevents an endless loop.
) {
redirectToProvider(ctx.state.auth.openidConnect.providers[0])
}
},
},
}

View File

@ -23,8 +23,6 @@ export default {
return
}
// FIXME: direct manipulation of the prop
// might not be a problem since this is happening in the mutation
if (!namespace.lists || namespace.lists.length === 0) {
namespace.lists = state.namespaces[namespaceIndex].lists
}
@ -136,8 +134,8 @@ export default {
},
loadNamespacesIfFavoritesDontExist(ctx) {
// The first namespace should be the one holding all favorites
if (ctx.state.namespaces[0].id !== -2) {
// The first or second namespace should be the one holding all favorites
if (ctx.state.namespaces[0].id !== -2 && ctx.state.namespaces[1]?.id !== -2) {
return ctx.dispatch('loadNamespaces')
}
},

View File

@ -179,9 +179,18 @@ export default {
console.debug('Could not add label to task in kanban, task not found', t)
return r
}
// FIXME: direct store manipulation (task)
t.task.labels.push(label)
ctx.commit('kanban/setTaskInBucketByIndex', t, { root: true })
const labels = [...t.task.labels]
labels.push(label)
ctx.commit('kanban/setTaskInBucketByIndex', {
task: {
labels,
...t.task,
},
...t,
}, { root: true })
return r
},
@ -200,15 +209,21 @@ export default {
}
// Remove the label from the list
for (const l in t.task.labels) {
if (t.task.labels[l].id === label.id) {
// FIXME: direct store manipulation (task)
t.task.labels.splice(l, 1)
const labels = [...t.task.labels]
for (const l in labels) {
if (labels[l].id === label.id) {
labels.splice(l, 1)
break
}
}
ctx.commit('kanban/setTaskInBucketByIndex', t, {root: true})
ctx.commit('kanban/setTaskInBucketByIndex', {
task: {
labels,
...t.task,
},
...t,
}, {root: true})
return response
},

View File

@ -16,6 +16,8 @@
// since $tablet is defined by bulma we can just define it after importing the utilities
$mobile: math.div($tablet, 2);
@import "mixins";
$family-sans-serif: 'Open Sans', Helvetica, Arial, sans-serif;
$vikunja-font: 'Quicksand', sans-serif;

View File

@ -2,4 +2,4 @@
@import "labels";
@import "list";
@import "task";
@import "tasks";
@import "tasks";

View File

@ -60,9 +60,9 @@
--danger: hsla(var(--danger-h), var(--danger-s), var(--danger-l), var(--danger-a));
// var(--primary) / $blue is #1973ff
--primary-h: 216.5deg;
--primary-s: 100%;
--primary-l: 54.9%;
--primary-h: 217deg;
--primary-s: 98%;
--primary-l: 53%;
--primary-a: 1;
--primary-hsl: var(--primary-h), var(--primary-s), var(--primary-l);
--primary: hsla(var(--primary-h), var(--primary-s), var(--primary-l), var(--primary-a));
@ -122,5 +122,10 @@
// Custom color variables we need to override
--card-border-color: hsla(var(--grey-100-hsl), 0.3);
--logo-text-color: var(--grey-700);
// Slightly different primary color to make sure it has a sufficent contrast ratio
--primary-h: 217deg;
--primary-s: 98%;
--primary-l: 58%;
}
}

12
src/styles/mixins.scss Normal file
View File

@ -0,0 +1,12 @@
/* Transitions */
@mixin modal-transition() {
.modal-enter,
.modal-leave-active {
opacity: 0;
}
.modal-enter .modal-container,
.modal-leave-active .modal-container {
transform: scale(0.9);
}
}

View File

@ -14,7 +14,7 @@
.box,
.card,
.switch-view,
.table-view .button,
.list-table .button,
.filter-container .button,
.search .button {
box-shadow: none;

4
src/types/faker.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
declare module '@faker-js/faker' {
import faker from 'faker'
export default faker
}

View File

@ -1,8 +1,11 @@
declare module 'vue' {
import { CompatVue } from '@vue/runtime-dom'
const Vue: CompatVue
export default Vue
export * from '@vue/runtime-dom'
export default Vue
export * from '@vue/runtime-dom'
const { configureCompat } = Vue
export { configureCompat }
}
// https://next.vuex.vuejs.org/guide/migrating-to-4-0-from-3-x.html#typescript-support

View File

@ -0,0 +1 @@
declare module 'vue-flatpickr-component';

View File

@ -23,7 +23,7 @@
<template v-if="defaultNamespaceId > 0">
<p class="mt-4">{{ $t('home.list.newText') }}</p>
<x-button
:to="{ name: 'list.create', params: { id: defaultNamespaceId } }"
:to="{ name: 'list.create', params: { namespaceId: defaultNamespaceId } }"
:shadow="false"
class="ml-2"
>

View File

@ -1,6 +1,6 @@
<template>
<div class="gantt-chart-container">
<card :padding="false" class="has-overflow">
<ListWrapper class="list-gantt" :list-id="props.listId" viewName="gantt">
<template #header>
<div class="gantt-options p-4">
<fancycheckbox class="is-block" v-model="showTaskswithoutDates">
{{ $t('list.gantt.showTasksWithoutDates') }}
@ -44,65 +44,64 @@
</div>
</div>
</div>
</template>
<template #default>
<div class="gantt-chart-container">
<card :padding="false" class="has-overflow">
<gantt-chart
:date-from="dateFrom"
:date-to="dateTo"
:day-width="dayWidth"
:list-id="Number($route.params.listId)"
:list-id="props.listId"
:show-taskswithout-dates="showTaskswithoutDates"
/>
<!-- This router view is used to show the task popup while keeping the gantt chart itself -->
<router-view v-slot="{ Component }">
<transition name="modal">
<component :is="Component" />
</transition>
</router-view>
</card>
</div>
</div>
</template>
</ListWrapper>
</template>
<script>
import GanttChart from '../../../components/tasks/gantt-component'
<script setup lang="ts">
import { ref, computed } from 'vue'
import flatPickr from 'vue-flatpickr-component'
import Fancycheckbox from '../../../components/input/fancycheckbox'
import {saveListView} from '@/helpers/saveListView'
export default {
name: 'Gantt',
components: {
Fancycheckbox,
flatPickr,
GanttChart,
import { useI18n } from 'vue-i18n'
import { useStore } from 'vuex'
import ListWrapper from './ListWrapper.vue'
import GanttChart from '@/components/tasks/gantt-component.vue'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
const props = defineProps({
listId: {
type: Number,
required: true,
},
created() {
// Save the current list view to local storage
// We use local storage and not vuex here to make it persistent across reloads.
saveListView(this.$route.params.listId, this.$route.name)
})
const DEFAULT_DAY_COUNT = 35
const showTaskswithoutDates = ref(false)
const dayWidth = ref(DEFAULT_DAY_COUNT)
const now = ref(new Date())
const dateFrom = ref(new Date((new Date()).setDate(now.value.getDate() - 15)))
const dateTo = ref(new Date((new Date()).setDate(now.value.getDate() + 30)))
const {t} = useI18n()
const store = useStore()
const flatPickerConfig = computed(() => ({
altFormat: t('date.altFormatShort'),
altInput: true,
dateFormat: 'Y-m-d',
enableTime: false,
locale: {
firstDayOfWeek: store.state.auth.settings.weekStart,
},
data() {
return {
showTaskswithoutDates: false,
dayWidth: 35,
dateFrom: new Date((new Date()).setDate((new Date()).getDate() - 15)),
dateTo: new Date((new Date()).setDate((new Date()).getDate() + 30)),
}
},
computed: {
flatPickerConfig() {
return {
altFormat: this.$t('date.altFormatShort'),
altInput: true,
dateFormat: 'Y-m-d',
enableTime: false,
locale: {
firstDayOfWeek: this.$store.state.auth.settings.weekStart,
},
}
},
},
}
}))
</script>
<style lang="scss">

View File

@ -1,17 +1,22 @@
<template>
<div class="kanban-view">
<div class="filter-container" v-if="isSavedFilter">
<ListWrapper class="list-kanban" :list-id="listId" viewName="kanban">
<template #header>
<div class="filter-container" v-if="isSavedFilter">
<div class="items">
<filter-popup
v-model="params"
@update:modelValue="loadBuckets"
/>
</div>
</div>
<div
:class="{ 'is-loading': loading && !oneTaskUpdating}"
class="kanban kanban-bucket-container loader-container"
>
</div>
</template>
<template #default>
<div class="kanban-view">
<div
:class="{ 'is-loading': loading && !oneTaskUpdating}"
class="kanban kanban-bucket-container loader-container"
>
<draggable
v-bind="dragOptions"
:modelValue="buckets"
@ -204,18 +209,11 @@
</div>
</div>
<!-- This router view is used to show the task popup while keeping the kanban board itself -->
<router-view v-slot="{ Component }">
<transition name="modal">
<component :is="Component"/>
</transition>
</router-view>
<transition name="modal">
<modal
v-if="showBucketDeleteModal"
@close="showBucketDeleteModal = false"
@submit="deleteBucket()"
v-if="showBucketDeleteModal"
>
<template #header><span>{{ $t('list.kanban.deleteHeaderBucket') }}</span></template>
@ -225,22 +223,24 @@
</template>
</modal>
</transition>
</div>
</div>
</template>
</ListWrapper>
</template>
<script>
import draggable from 'vuedraggable'
import cloneDeep from 'lodash.clonedeep'
import BucketModel from '../../../models/bucket'
import BucketModel from '../../models/bucket'
import {mapState} from 'vuex'
import {saveListView} from '@/helpers/saveListView'
import Rights from '../../../models/constants/rights.json'
import Rights from '../../models/constants/rights.json'
import {LOADING, LOADING_MODULE} from '@/store/mutation-types'
import ListWrapper from './ListWrapper'
import FilterPopup from '@/components/list/partials/filter-popup.vue'
import Dropdown from '@/components/misc/dropdown.vue'
import {getCollapsedBucketState, saveCollapsedBucketState} from '@/helpers/saveCollapsedBucketState'
import {calculateItemPosition} from '../../../helpers/calculateItemPosition'
import {calculateItemPosition} from '../../helpers/calculateItemPosition'
import KanbanCard from '@/components/tasks/partials/kanban-card'
const DRAG_OPTIONS = {
@ -257,11 +257,20 @@ const MIN_SCROLL_HEIGHT_PERCENT = 0.25
export default {
name: 'Kanban',
components: {
ListWrapper,
KanbanCard,
Dropdown,
FilterPopup,
draggable,
},
props: {
listId: {
type: Number,
required: true,
},
},
data() {
return {
taskContainerRefs: {},
@ -296,11 +305,7 @@ export default {
},
}
},
created() {
// Save the current list view to local storage
// We use local storage and not vuex here to make it persistent across reloads.
saveListView(this.$route.params.listId, this.$route.name)
},
watch: {
loadBucketParameter: {
handler: 'loadBuckets',
@ -313,7 +318,7 @@ export default {
},
loadBucketParameter() {
return {
listId: this.$route.params.listId,
listId: this.listId,
params: this.params,
}
},
@ -353,16 +358,11 @@ export default {
methods: {
loadBuckets() {
// Prevent trying to load buckets if the task popup view is active
if (this.$route.name !== 'list.kanban') {
return
}
const {listId, params} = this.loadBucketParameter
this.collapsedBuckets = getCollapsedBucketState(listId)
console.debug(`Loading buckets, loadedListId = ${this.loadedListId}, $route.params =`, this.$route.params)
console.debug(`Loading buckets, loadedListId = ${this.loadedListId}, $attrs = ${this.$attrs} $route.params =`, this.$route.params)
this.$store.dispatch('kanban/loadBucketsForList', {listId, params})
},
@ -437,7 +437,7 @@ export default {
const task = await this.$store.dispatch('tasks/createNewTask', {
title: this.newTaskText,
bucketId,
listId: this.$route.params.listId,
listId: this.listId,
})
this.newTaskText = ''
this.$store.commit('kanban/addTaskToBucket', task)
@ -459,7 +459,7 @@ export default {
const newBucket = new BucketModel({
title: this.newBucketTitle,
listId: parseInt(this.$route.params.listId),
listId: this.listId,
})
await this.$store.dispatch('kanban/createBucket', newBucket)
@ -479,7 +479,7 @@ export default {
async deleteBucket() {
const bucket = new BucketModel({
id: this.bucketToDelete,
listId: parseInt(this.$route.params.listId),
listId: this.listId,
})
try {
@ -567,7 +567,7 @@ export default {
collapseBucket(bucket) {
this.collapsedBuckets[bucket.id] = true
saveCollapsedBucketState(this.$route.params.listId, this.collapsedBuckets)
saveCollapsedBucketState(this.listId, this.collapsedBuckets)
},
unCollapseBucket(bucket) {
if (!this.collapsedBuckets[bucket.id]) {
@ -575,7 +575,7 @@ export default {
}
this.collapsedBuckets[bucket.id] = false
saveCollapsedBucketState(this.$route.params.listId, this.collapsedBuckets)
saveCollapsedBucketState(this.listId, this.collapsedBuckets)
},
},
}
@ -746,4 +746,6 @@ $filter-container-height: '1rem - #{$switch-view-height}';
.move-card-leave-active {
display: none;
}
@include modal-transition();
</style>

View File

@ -1,8 +1,6 @@
<template>
<div
:class="{ 'is-loading': taskCollectionService.loading }"
class="loader-container is-max-width-desktop list-view"
>
<ListWrapper class="list-list" :list-id="listId" viewName="list">
<template #header>
<div
class="filter-container"
v-if="list.isSavedFilter && !list.isSavedFilter()"
@ -26,7 +24,7 @@
</div>
<div class="control">
<x-button
:loading="taskCollectionService.loading"
:loading="loading"
@click="searchTasks"
:shadow="false"
>
@ -47,7 +45,13 @@
/>
</div>
</div>
</template>
<template #default>
<div
:class="{ 'is-loading': loading }"
class="loader-container is-max-width-desktop list-view"
>
<card :padding="false" :has-content="false" class="has-overflow">
<template
v-if="!list.isArchived && canWrite && list.id > 0"
@ -59,7 +63,7 @@
/>
</template>
<nothing v-if="ctaVisible && tasks.length === 0 && !taskCollectionService.loading">
<nothing v-if="ctaVisible && tasks.length === 0 && !loading">
{{ $t('list.list.empty') }}
<a @click="focusNewTaskInput()">
{{ $t('list.list.newTaskCta') }}
@ -90,7 +94,6 @@
:disabled="!canWrite"
:the-task="t"
@taskUpdated="updateTasks"
task-detail-route="task.detail"
>
<template v-if="canWrite">
<span class="icon handle">
@ -118,40 +121,33 @@
/>
</div>
<Pagination
:total-pages="taskCollectionService.totalPages"
<Pagination
:total-pages="totalPages"
:current-page="currentPage"
/>
</card>
<!-- This router view is used to show the task popup while keeping the kanban board itself -->
<router-view v-slot="{ Component }">
<transition name="modal">
<component :is="Component"/>
</transition>
</router-view>
</div>
</div>
</template>
</ListWrapper>
</template>
<script>
import TaskService from '../../../services/task'
import TaskModel from '../../../models/task'
import { ref, toRef, defineComponent } from 'vue'
import EditTask from '../../../components/tasks/edit-task'
import AddTask from '../../../components/tasks/add-task'
import SingleTaskInList from '../../../components/tasks/partials/singleTaskInList'
import taskList from '../../../components/tasks/mixins/taskList'
import {saveListView} from '@/helpers/saveListView'
import Rights from '../../../models/constants/rights.json'
import ListWrapper from './ListWrapper.vue'
import EditTask from '@/components/tasks/edit-task'
import AddTask from '@/components/tasks/add-task'
import SingleTaskInList from '@/components/tasks/partials/singleTaskInList'
import { useTaskList } from '@/composables/taskList'
import Rights from '../../models/constants/rights.json'
import FilterPopup from '@/components/list/partials/filter-popup.vue'
import {HAS_TASKS} from '@/store/mutation-types'
import Nothing from '@/components/misc/nothing.vue'
import Pagination from '@/components/misc/pagination.vue'
import Popup from '@/components/misc/popup'
import { ALPHABETICAL_SORT } from '@/components/list/partials/filters'
import {ALPHABETICAL_SORT} from '@/components/list/partials/filters.vue'
import draggable from 'vuedraggable'
import {calculateItemPosition} from '../../../helpers/calculateItemPosition'
import {calculateItemPosition} from '../../helpers/calculateItemPosition'
function sortTasks(tasks) {
if (tasks === null || tasks === []) {
@ -171,13 +167,18 @@ function sortTasks(tasks) {
})
}
export default {
export default defineComponent({
name: 'List',
props: {
listId: {
type: Number,
required: true,
},
},
data() {
return {
taskService: new TaskService(),
isTaskEdit: false,
taskEditTask: TaskModel,
ctaVisible: false,
showTaskSearch: false,
@ -188,11 +189,8 @@ export default {
},
}
},
mixins: [
taskList,
],
components: {
Popup,
ListWrapper,
Nothing,
FilterPopup,
SingleTaskInList,
@ -201,10 +199,24 @@ export default {
draggable,
Pagination,
},
created() {
// Save the current list view to local storage
// We use local storage and not vuex here to make it persistent across reloads.
saveListView(this.$route.params.listId, this.$route.name)
setup(props) {
const taskEditTask = ref(null)
const isTaskEdit = ref(false)
// This function initializes the tasks page and loads the first page of tasks
// function beforeLoad() {
// taskEditTask.value = null
// isTaskEdit.value = false
// }
const taskList = useTaskList(toRef(props, 'listId'))
return {
taskEditTask,
isTaskEdit,
...taskList,
}
},
computed: {
isAlphabeticalSorting() {
@ -244,17 +256,11 @@ export default {
// When clicking on the search button, @blur from the input is fired. If we
// would then directly hide the whole search bar directly, no click event
// from the button gets fired. To prevent this, we wait 200ms until we hide
// everything so the button has a chance of firering the search event.
// everything so the button has a chance of firing the search event.
setTimeout(() => {
this.showTaskSearch = false
}, 200)
},
// This function initializes the tasks page and loads the first page of tasks
initTasks(page, search = '') {
this.taskEditTask = null
this.isTaskEdit = false
this.loadTasks(page, search)
},
focusNewTaskInput() {
this.$refs.newTaskInput.$refs.newTaskInput.focus()
},
@ -312,7 +318,7 @@ export default {
this.tasks[e.newIndex] = updatedTask
},
},
}
})
</script>
<style lang="scss" scoped>

View File

@ -0,0 +1,311 @@
<template>
<ListWrapper class="list-table" :list-id="listId" viewName="table">
<template #header>
<div class="filter-container">
<div class="items">
<popup>
<template #trigger="{toggle}">
<x-button
@click.prevent.stop="toggle()"
icon="th"
variant="secondary"
>
{{ $t('list.table.columns') }}
</x-button>
</template>
<template #content="{isOpen}">
<card class="columns-filter" :class="{'is-open': isOpen}">
<fancycheckbox v-model="activeColumns.id">#</fancycheckbox>
<fancycheckbox v-model="activeColumns.done">
{{ $t('task.attributes.done') }}
</fancycheckbox>
<fancycheckbox v-model="activeColumns.title">
{{ $t('task.attributes.title') }}
</fancycheckbox>
<fancycheckbox v-model="activeColumns.priority">
{{ $t('task.attributes.priority') }}
</fancycheckbox>
<fancycheckbox v-model="activeColumns.labels">
{{ $t('task.attributes.labels') }}
</fancycheckbox>
<fancycheckbox v-model="activeColumns.assignees">
{{ $t('task.attributes.assignees') }}
</fancycheckbox>
<fancycheckbox v-model="activeColumns.dueDate">
{{ $t('task.attributes.dueDate') }}
</fancycheckbox>
<fancycheckbox v-model="activeColumns.startDate">
{{ $t('task.attributes.startDate') }}
</fancycheckbox>
<fancycheckbox v-model="activeColumns.endDate">
{{ $t('task.attributes.endDate') }}
</fancycheckbox>
<fancycheckbox v-model="activeColumns.percentDone">
{{ $t('task.attributes.percentDone') }}
</fancycheckbox>
<fancycheckbox v-model="activeColumns.created">
{{ $t('task.attributes.created') }}
</fancycheckbox>
<fancycheckbox v-model="activeColumns.updated">
{{ $t('task.attributes.updated') }}
</fancycheckbox>
<fancycheckbox v-model="activeColumns.createdBy">
{{ $t('task.attributes.createdBy') }}
</fancycheckbox>
</card>
</template>
</popup>
<filter-popup v-model="params" />
</div>
</div>
</template>
<template #default>
<div :class="{'is-loading': loading}" class="loader-container">
<card :padding="false" :has-content="false">
<div class="has-horizontal-overflow">
<table class="table has-actions is-hoverable is-fullwidth mb-0">
<thead>
<tr>
<th v-if="activeColumns.id">
#
<Sort :order="sortBy.id" @click="sort('id')"/>
</th>
<th v-if="activeColumns.done">
{{ $t('task.attributes.done') }}
<Sort :order="sortBy.done" @click="sort('done')"/>
</th>
<th v-if="activeColumns.title">
{{ $t('task.attributes.title') }}
<Sort :order="sortBy.title" @click="sort('title')"/>
</th>
<th v-if="activeColumns.priority">
{{ $t('task.attributes.priority') }}
<Sort :order="sortBy.priority" @click="sort('priority')"/>
</th>
<th v-if="activeColumns.labels">
{{ $t('task.attributes.labels') }}
</th>
<th v-if="activeColumns.assignees">
{{ $t('task.attributes.assignees') }}
</th>
<th v-if="activeColumns.dueDate">
{{ $t('task.attributes.dueDate') }}
<Sort :order="sortBy.due_date" @click="sort('due_date')"/>
</th>
<th v-if="activeColumns.startDate">
{{ $t('task.attributes.startDate') }}
<Sort :order="sortBy.start_date" @click="sort('start_date')"/>
</th>
<th v-if="activeColumns.endDate">
{{ $t('task.attributes.endDate') }}
<Sort :order="sortBy.end_date" @click="sort('end_date')"/>
</th>
<th v-if="activeColumns.percentDone">
{{ $t('task.attributes.percentDone') }}
<Sort :order="sortBy.percent_done" @click="sort('percent_done')"/>
</th>
<th v-if="activeColumns.created">
{{ $t('task.attributes.created') }}
<Sort :order="sortBy.created" @click="sort('created')"/>
</th>
<th v-if="activeColumns.updated">
{{ $t('task.attributes.updated') }}
<Sort :order="sortBy.updated" @click="sort('updated')"/>
</th>
<th v-if="activeColumns.createdBy">
{{ $t('task.attributes.createdBy') }}
</th>
</tr>
</thead>
<tbody>
<tr :key="t.id" v-for="t in tasks">
<td v-if="activeColumns.id">
<router-link :to="taskDetailRoutes[t.id]">
<template v-if="t.identifier === ''">
#{{ t.index }}
</template>
<template v-else>
{{ t.identifier }}
</template>
</router-link>
</td>
<td v-if="activeColumns.done">
<Done :is-done="t.done" variant="small" />
</td>
<td v-if="activeColumns.title">
<router-link :to="taskDetailRoutes[t.id]">{{ t.title }}</router-link>
</td>
<td v-if="activeColumns.priority">
<priority-label :priority="t.priority" :done="t.done" :show-all="true"/>
</td>
<td v-if="activeColumns.labels">
<labels :labels="t.labels"/>
</td>
<td v-if="activeColumns.assignees">
<user
:avatar-size="27"
:is-inline="true"
:key="t.id + 'assignee' + a.id + i"
:show-username="false"
:user="a"
v-for="(a, i) in t.assignees"
/>
</td>
<date-table-cell :date="t.dueDate" v-if="activeColumns.dueDate"/>
<date-table-cell :date="t.startDate" v-if="activeColumns.startDate"/>
<date-table-cell :date="t.endDate" v-if="activeColumns.endDate"/>
<td v-if="activeColumns.percentDone">{{ t.percentDone * 100 }}%</td>
<date-table-cell :date="t.created" v-if="activeColumns.created"/>
<date-table-cell :date="t.updated" v-if="activeColumns.updated"/>
<td v-if="activeColumns.createdBy">
<user
:avatar-size="27"
:show-username="false"
:user="t.createdBy"/>
</td>
</tr>
</tbody>
</table>
</div>
<Pagination
:total-pages="totalPages"
:current-page="currentPage"
/>
</card>
</div>
</template>
</ListWrapper>
</template>
<script setup lang="ts">
import { toRef, computed, Ref } from 'vue'
import { useStorage } from '@vueuse/core'
import ListWrapper from './ListWrapper.vue'
import Done from '@/components/misc/Done.vue'
import User from '@/components/misc/user.vue'
import PriorityLabel from '@/components/tasks/partials/priorityLabel.vue'
import Labels from '@/components/tasks/partials/labels.vue'
import DateTableCell from '@/components/tasks/partials/date-table-cell.vue'
import Fancycheckbox from '@/components/input/fancycheckbox.vue'
import Sort from '@/components/tasks/partials/sort.vue'
import FilterPopup from '@/components/list/partials/filter-popup.vue'
import Pagination from '@/components/misc/pagination.vue'
import Popup from '@/components/misc/popup.vue'
import { useTaskList } from '@/composables/taskList'
import TaskModel from '@/models/task'
const ACTIVE_COLUMNS_DEFAULT = {
id: true,
done: true,
title: true,
priority: false,
labels: true,
assignees: true,
dueDate: true,
startDate: false,
endDate: false,
percentDone: false,
created: false,
updated: false,
createdBy: false,
}
const props = defineProps({
listId: {
type: Number,
required: true,
},
})
type Order = 'asc' | 'desc' | 'none'
interface SortBy {
id : Order
done? : Order
title? : Order
priority? : Order
due_date? : Order
start_date? : Order
end_date? : Order
percent_done? : Order
created? : Order
updated? : Order
}
const SORT_BY_DEFAULT : SortBy = {
id: 'desc',
}
const activeColumns = useStorage('tableViewColumns', { ...ACTIVE_COLUMNS_DEFAULT })
const sortBy = useStorage<SortBy>('tableViewSortBy', { ...SORT_BY_DEFAULT })
const taskList = useTaskList(toRef(props, 'listId'))
const {
loading,
params,
totalPages,
currentPage,
} = taskList
const tasks : Ref<TaskModel[]> = taskList.tasks
Object.assign(params.value, {
filter_by: [],
filter_value: [],
filter_comparator: [],
})
// FIXME: by doing this we can have multiple sort orders
function sort(property : keyof SortBy) {
const order = sortBy.value[property]
if (typeof order === 'undefined' || order === 'none') {
sortBy.value[property] = 'desc'
} else if (order === 'desc') {
sortBy.value[property] = 'asc'
} else {
delete sortBy.value[property]
}
}
// TODO: re-enable opening task detail in modal
// const router = useRouter()
const taskDetailRoutes = computed(() => Object.fromEntries(
tasks.value.map(({id}) => ([
id,
{
name: 'task.detail',
params: { id },
// state: { backdropView: router.currentRoute.value.fullPath },
},
])),
))
</script>
<style lang="scss" scoped>
.table {
background: transparent;
overflow-x: auto;
overflow-y: hidden;
th {
white-space: nowrap;
}
.user {
margin: 0;
}
}
.columns-filter {
margin: 0;
&.is-open {
margin: 2rem 0 1rem;
}
}
</style>

View File

@ -0,0 +1,186 @@
<template>
<div
:class="{ 'is-loading': listService.loading, 'is-archived': currentList.isArchived}"
class="loader-container"
>
<div class="switch-view-container">
<div class="switch-view">
<router-link
v-shortcut="'g l'"
:title="$t('keyboardShortcuts.list.switchToListView')"
:class="{'is-active': viewName === 'list'}"
:to="{ name: 'list.list', params: { listId } }">
{{ $t('list.list.title') }}
</router-link>
<router-link
v-shortcut="'g g'"
:title="$t('keyboardShortcuts.list.switchToGanttView')"
:class="{'is-active': viewName === 'gantt'}"
:to="{ name: 'list.gantt', params: { listId } }">
{{ $t('list.gantt.title') }}
</router-link>
<router-link
v-shortcut="'g t'"
:title="$t('keyboardShortcuts.list.switchToTableView')"
:class="{'is-active': viewName === 'table'}"
:to="{ name: 'list.table', params: { listId } }">
{{ $t('list.table.title') }}
</router-link>
<router-link
v-shortcut="'g k'"
:title="$t('keyboardShortcuts.list.switchToKanbanView')"
:class="{'is-active': viewName === 'kanban'}"
:to="{ name: 'list.kanban', params: { listId } }">
{{ $t('list.kanban.title') }}
</router-link>
</div>
<slot name="header" />
</div>
<transition name="fade">
<Message variant="warning" v-if="currentList.isArchived" class="mb-4">
{{ $t('list.archived') }}
</Message>
</transition>
<slot />
</div>
</template>
<script setup lang="ts">
import {ref, shallowRef, computed, watchEffect} from 'vue'
import {useRoute} from 'vue-router'
import Message from '@/components/misc/message.vue'
import ListModel from '@/models/list'
import ListService from '@/services/list'
import {store} from '@/store'
import {CURRENT_LIST} from '@/store/mutation-types'
import {getListTitle} from '@/helpers/getListTitle'
import {saveListToHistory} from '@/modules/listHistory'
import { useTitle } from '@/composables/useTitle'
const props = defineProps({
listId: {
type: Number,
required: true,
},
viewName: {
type: String,
required: true,
},
})
const route = useRoute()
const listService = shallowRef(new ListService())
const loadedListId = ref(0)
const currentList = computed(() => {
return typeof store.state.currentList === 'undefined' ? {
id: 0,
title: '',
isArchived: false,
maxRight: null,
} : store.state.currentList
})
// call again the method if the listId changes
watchEffect(() => loadList(props.listId))
useTitle(() => currentList.value.id ? getListTitle(currentList.value) : '')
async function loadList(listIdToLoad: number) {
const listData = {id: listIdToLoad}
saveListToHistory(listData)
// This invalidates the loaded list at the kanban board which lets it reload its content when
// switched to it. This ensures updates done to tasks in the gantt or list views are consistently
// shown in all views while preventing reloads when closing a task popup.
// We don't do this for the table view because that does not change tasks.
// FIXME: remove this
if (
props.viewName === 'list.list' ||
props.viewName === 'list.gantt'
) {
store.commit('kanban/setListId', 0)
}
// Don't load the list if we either already loaded it or aren't dealing with a list at all currently and
// the currently loaded list has the right set.
if (
(
listIdToLoad === loadedListId.value ||
typeof listIdToLoad === 'undefined' ||
listIdToLoad === currentList.value.id
)
&& typeof currentList.value !== 'undefined' && currentList.value.maxRight !== null
) {
return
}
console.debug(`Loading list, props.viewName = ${props.viewName}, $route.params =`, route.params, `, loadedListId = ${loadedListId.value}, currentList = `, currentList.value)
// We create an extra list object instead of creating it in list.value because that would trigger a ui update which would result in bad ux.
const list = new ListModel(listData)
try {
const loadedList = await listService.value.get(list)
await store.dispatch(CURRENT_LIST, loadedList)
} finally {
loadedListId.value = props.listId
}
}
</script>
<style lang="scss" scoped>
.switch-view-container {
@media screen and (max-width: $tablet) {
display: flex;
justify-content: center;
}
}
.switch-view {
background: var(--white);
display: inline-flex;
border-radius: $radius;
font-size: .75rem;
box-shadow: var(--shadow-sm);
height: $switch-view-height;
margin-bottom: 1rem;
padding: .5rem;
a {
padding: .25rem .5rem;
display: block;
border-radius: $radius;
transition: all 100ms;
&:not(:last-child) {
margin-right: .5rem;
}
&.is-active,
&:hover {
color: var(--switch-view-color);
}
&.is-active {
background: var(--primary);
font-weight: bold;
box-shadow: var(--shadow-xs);
}
&:hover {
background: var(--primary);
}
}
}
.is-archived .notification.is-warning {
margin-bottom: 1rem;
}
</style>

View File

@ -61,7 +61,7 @@ export default {
}
this.showError = false
this.list.namespaceId = parseInt(this.$route.params.id)
this.list.namespaceId = parseInt(this.$route.params.namespaceId)
const list = await this.$store.dispatch('lists/createList', this.list)
this.$message.success({message: this.$t('list.create.createdSuccess') })
this.$router.push({

View File

@ -1,211 +0,0 @@
<template>
<div
:class="{ 'is-loading': listService.loading, 'is-archived': currentList.isArchived}"
class="loader-container"
>
<div class="switch-view-container">
<div class="switch-view">
<router-link
v-shortcut="'g l'"
:title="$t('keyboardShortcuts.list.switchToListView')"
:class="{'is-active': $route.name.includes('list.list')}"
:to="{ name: 'list.list', params: { listId: listId } }">
{{ $t('list.list.title') }}
</router-link>
<router-link
v-shortcut="'g g'"
:title="$t('keyboardShortcuts.list.switchToGanttView')"
:class="{'is-active': $route.name.includes('list.gantt')}"
:to="{ name: 'list.gantt', params: { listId: listId } }">
{{ $t('list.gantt.title') }}
</router-link>
<router-link
v-shortcut="'g t'"
:title="$t('keyboardShortcuts.list.switchToTableView')"
:class="{'is-active': $route.name.includes('list.table')}"
:to="{ name: 'list.table', params: { listId: listId } }">
{{ $t('list.table.title') }}
</router-link>
<router-link
v-shortcut="'g k'"
:title="$t('keyboardShortcuts.list.switchToKanbanView')"
:class="{'is-active': $route.name.includes('list.kanban')}"
:to="{ name: 'list.kanban', params: { listId: listId } }">
{{ $t('list.kanban.title') }}
</router-link>
</div>
</div>
<transition name="fade">
<message variant="warning" v-if="currentList.isArchived" class="mb-4">
{{ $t('list.archived') }}
</message>
</transition>
<router-view/>
</div>
</template>
<script>
import Message from '@/components/misc/message'
import ListModel from '../../models/list'
import ListService from '../../services/list'
import {CURRENT_LIST} from '../../store/mutation-types'
import {getListView} from '../../helpers/saveListView'
import {saveListToHistory} from '../../modules/listHistory'
export default {
components: {Message},
data() {
return {
listService: new ListService(),
listLoaded: 0,
}
},
watch: {
// call again the method if the route changes
'$route.path': {
handler: 'loadList',
immediate: true,
},
},
computed: {
// Computed property to let "listId" always have a value
listId() {
return typeof this.$route.params.listId === 'undefined' ? 0 : this.$route.params.listId
},
background() {
return this.$store.state.background
},
currentList() {
return typeof this.$store.state.currentList === 'undefined' ? {
id: 0,
title: '',
isArchived: false,
} : this.$store.state.currentList
},
},
methods: {
replaceListView() {
const savedListView = getListView(this.$route.params.listId)
this.$router.replace({name: savedListView, params: {id: this.$route.params.listId}})
console.debug('Replaced list view with', savedListView)
},
async loadList() {
if (this.$route.name.includes('.settings.')) {
return
}
const listData = {id: parseInt(this.$route.params.listId)}
saveListToHistory(listData)
this.setTitle(this.currentList.id ? this.getListTitle(this.currentList) : '')
// This invalidates the loaded list at the kanban board which lets it reload its content when
// switched to it. This ensures updates done to tasks in the gantt or list views are consistently
// shown in all views while preventing reloads when closing a task popup.
// We don't do this for the table view because that does not change tasks.
if (
this.$route.name === 'list.list' ||
this.$route.name === 'list.gantt'
) {
this.$store.commit('kanban/setListId', 0)
}
// When clicking again on a list in the menu, there would be no list view selected which means no list
// at all. Users will then have to click on the list view menu again which is quite confusing.
if (this.$route.name === 'list.index') {
return this.replaceListView()
}
// Don't load the list if we either already loaded it or aren't dealing with a list at all currently and
// the currently loaded list has the right set.
if (
(
this.$route.params.listId === this.listLoaded ||
typeof this.$route.params.listId === 'undefined' ||
this.$route.params.listId === this.currentList.id ||
parseInt(this.$route.params.listId) === this.currentList.id
)
&& typeof this.currentList !== 'undefined' && this.currentList.maxRight !== null
) {
return
}
// Redirect the user to list view by default
if (
this.$route.name !== 'list.list' &&
this.$route.name !== 'list.gantt' &&
this.$route.name !== 'list.table' &&
this.$route.name !== 'list.kanban'
) {
return this.replaceListView()
}
console.debug(`Loading list, $route.name = ${this.$route.name}, $route.params =`, this.$route.params, `, listLoaded = ${this.listLoaded}, currentList = `, this.currentList)
// We create an extra list object instead of creating it in this.list because that would trigger a ui update which would result in bad ux.
const list = new ListModel(listData)
try {
const loadedList = await this.listService.get(list)
await this.$store.dispatch(CURRENT_LIST, loadedList)
this.setTitle(this.getListTitle(loadedList))
} finally {
this.listLoaded = this.$route.params.listId
}
},
},
}
</script>
<style lang="scss" scoped>
.switch-view-container {
@media screen and (max-width: $tablet) {
display: flex;
justify-content: center;
}
}
.switch-view {
background: var(--white);
display: inline-flex;
border-radius: $radius;
font-size: .75rem;
box-shadow: var(--shadow-sm);
height: $switch-view-height;
margin-bottom: 1rem;
padding: .5rem;
a {
padding: .25rem .5rem;
display: block;
border-radius: $radius;
transition: all 100ms;
&:not(:last-child) {
margin-right: .5rem;
}
&.is-active,
&:hover {
color: var(--switch-view-color);
}
&.is-active {
background: var(--primary);
font-weight: bold;
box-shadow: var(--shadow-xs);
}
&:hover {
background: var(--primary);
}
}
}
.is-archived .notification.is-warning {
margin-bottom: 1rem;
}
</style>

View File

@ -3,24 +3,38 @@
:title="$t('list.share.header')"
primary-label=""
>
<component
:id="list.id"
:is="manageUsersComponent"
:userIsAdmin="userIsAdmin"
shareType="user"
type="list"/>
<component
:id="list.id"
:is="manageTeamsComponent"
:userIsAdmin="userIsAdmin"
shareType="team"
type="list"/>
<template v-if="list">
<userTeam
:id="list.id"
:userIsAdmin="userIsAdmin"
shareType="user"
type="list"
/>
<userTeam
:id="list.id"
:userIsAdmin="userIsAdmin"
shareType="team"
type="list"
/>
</template>
<link-sharing :list-id="$route.params.listId" v-if="linkSharingEnabled" class="mt-4"/>
<link-sharing :list-id="listId" v-if="linkSharingEnabled" class="mt-4"/>
</create-edit>
</template>
<script>
<script lang="ts">
export default {
name: 'list-setting-share',
}
</script>
<script lang="ts" setup>
import {ref, computed, watchEffect} from 'vue'
import {useStore} from 'vuex'
import {useRoute} from 'vue-router'
import {useI18n} from 'vue-i18n'
import {useTitle} from '@vueuse/core'
import ListService from '@/services/list'
import ListModel from '@/models/list'
import {CURRENT_LIST} from '@/store/mutation-types'
@ -29,43 +43,30 @@ import CreateEdit from '@/components/misc/create-edit.vue'
import LinkSharing from '@/components/sharing/linkSharing.vue'
import userTeam from '@/components/sharing/userTeam.vue'
export default {
name: 'list-setting-share',
data() {
return {
list: ListModel,
listService: new ListService(),
manageUsersComponent: '',
manageTeamsComponent: '',
}
},
components: {
CreateEdit,
LinkSharing,
userTeam,
},
computed: {
linkSharingEnabled() {
return this.$store.state.config.linkSharingEnabled
},
userIsAdmin() {
return this.list.owner && this.list.owner.id === this.$store.state.auth.info.id
},
},
created() {
this.loadList()
},
methods: {
async loadList() {
const list = new ListModel({id: this.$route.params.listId})
const {t} = useI18n()
this.list = await this.listService.get(list)
await this.$store.dispatch(CURRENT_LIST, this.list)
// This will trigger the dynamic loading of components once we actually have all the data to pass to them
this.manageTeamsComponent = 'userTeam'
this.manageUsersComponent = 'userTeam'
this.setTitle(this.$t('list.share.title', {list: this.list.title}))
},
},
const list = ref()
const title = computed(() => list.value?.title
? t('list.share.title', {list: list.value.title})
: '',
)
useTitle(title)
const store = useStore()
const linkSharingEnabled = computed(() => store.state.config.linkSharingEnabled)
const userIsAdmin = computed(() => 'owner' in list.value && list.value.owner.id === store.state.auth.info.id)
async function loadList(listId: number) {
const listService = new ListService()
const newList = await listService.get(new ListModel({id: listId}))
await store.dispatch(CURRENT_LIST, newList)
list.value = newList
}
const route = useRoute()
const listId = computed(() => route.params.listId !== undefined
? parseInt(route.params.listId as string)
: undefined,
)
watchEffect(() => listId.value !== undefined && loadList(listId.value))
</script>

View File

@ -1,331 +0,0 @@
<template>
<div :class="{'is-loading': taskCollectionService.loading}" class="table-view loader-container">
<div class="filter-container">
<div class="items">
<popup>
<template #trigger="{toggle}">
<x-button
@click.prevent.stop="toggle()"
icon="th"
variant="secondary"
>
{{ $t('list.table.columns') }}
</x-button>
</template>
<template #content="{isOpen}">
<card class="columns-filter" :class="{'is-open': isOpen}">
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.id">#</fancycheckbox>
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.done">
{{ $t('task.attributes.done') }}
</fancycheckbox>
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.title">
{{ $t('task.attributes.title') }}
</fancycheckbox>
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.priority">
{{ $t('task.attributes.priority') }}
</fancycheckbox>
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.labels">
{{ $t('task.attributes.labels') }}
</fancycheckbox>
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.assignees">
{{ $t('task.attributes.assignees') }}
</fancycheckbox>
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.dueDate">
{{ $t('task.attributes.dueDate') }}
</fancycheckbox>
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.startDate">
{{ $t('task.attributes.startDate') }}
</fancycheckbox>
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.endDate">
{{ $t('task.attributes.endDate') }}
</fancycheckbox>
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.percentDone">
{{ $t('task.attributes.percentDone') }}
</fancycheckbox>
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.created">
{{ $t('task.attributes.created') }}
</fancycheckbox>
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.updated">
{{ $t('task.attributes.updated') }}
</fancycheckbox>
<fancycheckbox @change="saveTaskColumns" v-model="activeColumns.createdBy">
{{ $t('task.attributes.createdBy') }}
</fancycheckbox>
</card>
</template>
</popup>
<filter-popup
v-model="params"
@update:modelValue="loadTasks()"
/>
</div>
</div>
<card :padding="false" :has-content="false">
<div class="has-horizontal-overflow">
<table class="table has-actions is-hoverable is-fullwidth mb-0">
<thead>
<tr>
<th v-if="activeColumns.id">
#
<sort :order="sortBy.id" @click="sort('id')"/>
</th>
<th v-if="activeColumns.done">
{{ $t('task.attributes.done') }}
<sort :order="sortBy.done" @click="sort('done')"/>
</th>
<th v-if="activeColumns.title">
{{ $t('task.attributes.title') }}
<sort :order="sortBy.title" @click="sort('title')"/>
</th>
<th v-if="activeColumns.priority">
{{ $t('task.attributes.priority') }}
<sort :order="sortBy.priority" @click="sort('priority')"/>
</th>
<th v-if="activeColumns.labels">
{{ $t('task.attributes.labels') }}
</th>
<th v-if="activeColumns.assignees">
{{ $t('task.attributes.assignees') }}
</th>
<th v-if="activeColumns.dueDate">
{{ $t('task.attributes.dueDate') }}
<sort :order="sortBy.due_date" @click="sort('due_date')"/>
</th>
<th v-if="activeColumns.startDate">
{{ $t('task.attributes.startDate') }}
<sort :order="sortBy.start_date" @click="sort('start_date')"/>
</th>
<th v-if="activeColumns.endDate">
{{ $t('task.attributes.endDate') }}
<sort :order="sortBy.end_date" @click="sort('end_date')"/>
</th>
<th v-if="activeColumns.percentDone">
{{ $t('task.attributes.percentDone') }}
<sort :order="sortBy.percent_done" @click="sort('percent_done')"/>
</th>
<th v-if="activeColumns.created">
{{ $t('task.attributes.created') }}
<sort :order="sortBy.created" @click="sort('created')"/>
</th>
<th v-if="activeColumns.updated">
{{ $t('task.attributes.updated') }}
<sort :order="sortBy.updated" @click="sort('updated')"/>
</th>
<th v-if="activeColumns.createdBy">
{{ $t('task.attributes.createdBy') }}
</th>
</tr>
</thead>
<tbody>
<tr :key="t.id" v-for="t in tasks">
<td v-if="activeColumns.id">
<router-link :to="{name: 'task.detail', params: { id: t.id }}">
<template v-if="t.identifier === ''">
#{{ t.index }}
</template>
<template v-else>
{{ t.identifier }}
</template>
</router-link>
</td>
<td v-if="activeColumns.done">
<Done :is-done="t.done" variant="small" />
</td>
<td v-if="activeColumns.title">
<router-link :to="{name: 'task.detail', params: { id: t.id }}">{{ t.title }}</router-link>
</td>
<td v-if="activeColumns.priority">
<priority-label :priority="t.priority" :done="t.done" :show-all="true"/>
</td>
<td v-if="activeColumns.labels">
<labels :labels="t.labels"/>
</td>
<td v-if="activeColumns.assignees">
<user
:avatar-size="27"
:is-inline="true"
:key="t.id + 'assignee' + a.id + i"
:show-username="false"
:user="a"
v-for="(a, i) in t.assignees"
/>
</td>
<date-table-cell :date="t.dueDate" v-if="activeColumns.dueDate"/>
<date-table-cell :date="t.startDate" v-if="activeColumns.startDate"/>
<date-table-cell :date="t.endDate" v-if="activeColumns.endDate"/>
<td v-if="activeColumns.percentDone">{{ t.percentDone * 100 }}%</td>
<date-table-cell :date="t.created" v-if="activeColumns.created"/>
<date-table-cell :date="t.updated" v-if="activeColumns.updated"/>
<td v-if="activeColumns.createdBy">
<user
:avatar-size="27"
:show-username="false"
:user="t.createdBy"/>
</td>
</tr>
</tbody>
</table>
</div>
<Pagination
:total-pages="taskCollectionService.totalPages"
:current-page="currentPage"
/>
</card>
<!-- This router view is used to show the task popup while keeping the table view itself -->
<router-view v-slot="{ Component }">
<transition name="modal">
<component :is="Component" />
</transition>
</router-view>
</div>
</template>
<script>
import taskList from '@/components/tasks/mixins/taskList'
import Done from '@/components/misc/Done.vue'
import User from '@/components/misc/user'
import PriorityLabel from '@/components/tasks/partials/priorityLabel'
import Labels from '@/components/tasks/partials/labels'
import DateTableCell from '@/components/tasks/partials/date-table-cell'
import Fancycheckbox from '@/components/input/fancycheckbox'
import Sort from '@/components/tasks/partials/sort'
import {saveListView} from '@/helpers/saveListView'
import FilterPopup from '@/components/list/partials/filter-popup.vue'
import Pagination from '@/components/misc/pagination.vue'
import Popup from '@/components/misc/popup'
export default {
name: 'Table',
components: {
Popup,
Done,
FilterPopup,
Sort,
Fancycheckbox,
DateTableCell,
Labels,
PriorityLabel,
User,
Pagination,
},
mixins: [
taskList,
],
data() {
return {
activeColumns: {
id: true,
done: true,
title: true,
priority: false,
labels: true,
assignees: true,
dueDate: true,
startDate: false,
endDate: false,
percentDone: false,
created: false,
updated: false,
createdBy: false,
},
sortBy: {
id: 'desc',
},
}
},
created() {
const savedShowColumns = localStorage.getItem('tableViewColumns')
if (savedShowColumns !== null) {
this.activeColumns = JSON.parse(savedShowColumns)
}
const savedSortBy = localStorage.getItem('tableViewSortBy')
if (savedSortBy !== null) {
this.sortBy = JSON.parse(savedSortBy)
}
this.params.filter_by = []
this.params.filter_value = []
this.params.filter_comparator = []
this.initTasks(1)
// Save the current list view to local storage
// We use local storage and not vuex here to make it persistent across reloads.
saveListView(this.$route.params.listId, this.$route.name)
},
methods: {
initTasks(page, search = '') {
// This makes sure an id sort order is always sorted last.
// When tasks would be sorted first by id and then by whatever else was specified, the id sort takes
// precedence over everything else, making any other sort columns pretty useless.
const sortKeys = Object.keys(this.sortBy)
let hasIdFilter = false
for (const s of sortKeys) {
if (s === 'id') {
sortKeys.splice(s, 1)
hasIdFilter = true
break
}
}
if (hasIdFilter) {
sortKeys.push('id')
}
const params = this.params
params.sort_by = []
params.order_by = []
sortKeys.map(s => {
params.sort_by.push(s)
params.order_by.push(this.sortBy[s])
})
this.loadTasks(page, search, params)
},
sort(property) {
const order = this.sortBy[property]
if (typeof order === 'undefined' || order === 'none') {
this.sortBy[property] = 'desc'
} else if (order === 'desc') {
this.sortBy[property] = 'asc'
} else {
delete this.sortBy[property]
}
this.initTasks(this.currentPage, this.searchTerm)
// Save the order to be able to retrieve them later
localStorage.setItem('tableViewSortBy', JSON.stringify(this.sortBy))
},
saveTaskColumns() {
localStorage.setItem('tableViewColumns', JSON.stringify(this.activeColumns))
},
},
}
</script>
<style lang="scss" scoped>
.table-view {
.table {
background: transparent;
overflow-x: auto;
overflow-y: hidden;
th {
white-space: nowrap;
}
.user {
margin: 0;
}
}
}
.columns-filter {
margin: 0;
&.is-open {
margin: 2rem 0 1rem;
}
}
</style>

View File

@ -254,4 +254,13 @@ export default {
background-color: var(--primary-dark);
}
}
@media (prefers-reduced-motion: reduce) {
@keyframes wave {
10% {
transform: translate(0, 0);
background-color: var(--primary);
}
}
}
</style>

View File

@ -22,9 +22,9 @@
</router-link>
</p>
<div :key="`n${n.id}`" class="namespace" v-for="n in namespaces">
<section :key="`n${n.id}`" class="namespace" v-for="n in namespaces">
<x-button
:to="{name: 'list.create', params: {id: n.id}}"
:to="{name: 'list.create', params: {namespaceId: n.id}}"
class="is-pulled-right"
variant="secondary"
v-if="n.id > 0 && n.lists.length > 0"
@ -51,7 +51,7 @@
<p class="has-text-centered has-text-grey mt-4 is-italic" v-if="n.lists.length === 0">
{{ $t('namespace.noLists') }}
<router-link :to="{name: 'list.create', params: {id: n.id}}">
<router-link :to="{name: 'list.create', params: {namespaceId: n.id}}">
{{ $t('namespace.createList') }}
</router-link>
</p>
@ -64,7 +64,7 @@
:show-archived="showArchived"
/>
</div>
</div>
</section>
</div>
</template>

View File

@ -4,9 +4,11 @@
@submit="archiveNamespace()"
>
<template #header><span>{{ title }}</span></template>
<template #text>
<p>{{ list.isArchived ? $t('namespace.archive.unarchiveText') : $t('namespace.archive.archiveText') }}</p>
<p>
{{ namespace.isArchived ? $t('namespace.archive.unarchiveText') : $t('namespace.archive.archiveText')}}
</p>
</template>
</modal>
</template>
@ -27,17 +29,18 @@ export default {
created() {
this.namespace = this.$store.getters['namespaces/getNamespaceById'](this.$route.params.id)
this.title = this.namespace.isArchived ?
this.$t('namespace.archive.titleUnarchive', { namespace: this.namespace.title }) :
this.$t('namespace.archive.titleArchive', { namespace: this.namespace.title })
this.$t('namespace.archive.titleUnarchive', {namespace: this.namespace.title}) :
this.$t('namespace.archive.titleArchive', {namespace: this.namespace.title})
this.setTitle(this.title)
},
methods: {
async archiveNamespace() {
this.namespace.isArchived = !this.namespace.isArchived
try {
const namespace = await this.namespaceService.update(this.namespace)
const namespace = await this.namespaceService.update({
...this.namespace,
isArchived: !this.namespace.isArchived,
})
this.$store.commit('namespaces/setNamespaceById', namespace)
this.$message.success({message: this.$t('namespace.archive.success')})
} finally {

View File

@ -3,69 +3,67 @@
:title="title"
primary-label=""
>
<component
:id="namespace.id"
:is="manageUsersComponent"
:userIsAdmin="userIsAdmin"
shareType="user"
type="namespace"/>
<component
:id="namespace.id"
:is="manageTeamsComponent"
:userIsAdmin="userIsAdmin"
shareType="team"
type="namespace"/>
<template v-if="namespace">
<manageSharing
:id="namespace.id"
:userIsAdmin="userIsAdmin"
shareType="user"
type="namespace"
/>
<manageSharing
:id="namespace.id"
:userIsAdmin="userIsAdmin"
shareType="team"
type="namespace"
/>
</template>
</create-edit>
</template>
<script>
import manageSharing from '@/components/sharing/userTeam.vue'
import CreateEdit from '@/components/misc/create-edit.vue'
<script lang="ts">
export default {
name: 'namespace-setting-share',
}
</script>
<script lang="ts" setup>
import {ref, computed, watchEffect} from 'vue'
import {useStore} from 'vuex'
import {useRoute} from 'vue-router'
import {useI18n} from 'vue-i18n'
import {useTitle} from '@vueuse/core'
import NamespaceService from '@/services/namespace'
import NamespaceModel from '@/models/namespace'
export default {
name: 'namespace-setting-share',
data() {
return {
namespaceService: new NamespaceService(),
namespace: new NamespaceModel(),
manageUsersComponent: '',
manageTeamsComponent: '',
title: '',
}
},
components: {
CreateEdit,
manageSharing,
},
beforeMount() {
this.namespace.id = this.$route.params.id
},
watch: {
// call again the method if the route changes
'$route': {
handler: 'loadNamespace',
deep: true,
immediate: true,
},
},
computed: {
userIsAdmin() {
return this.namespace.owner && this.namespace.owner.id === this.$store.state.auth.info.id
},
},
methods: {
async loadNamespace() {
const namespace = new NamespaceModel({id: this.$route.params.id})
this.namespace = await this.namespaceService.get(namespace)
// This will trigger the dynamic loading of components once we actually have all the data to pass to them
this.manageTeamsComponent = 'manageSharing'
this.manageUsersComponent = 'manageSharing'
this.title = this.$t('namespace.share.title', { namespace: this.namespace.title })
this.setTitle(this.title)
},
},
import CreateEdit from '@/components/misc/create-edit.vue'
import manageSharing from '@/components/sharing/userTeam.vue'
const {t} = useI18n()
const namespace = ref()
const title = computed(() => namespace.value?.title
? t('namespace.share.title', { namespace: namespace.value.title })
: '',
)
useTitle(title)
const store = useStore()
const userIsAdmin = computed(() => 'owner' in namespace.value && namespace.value.owner.id === store.state.auth.info.id)
async function loadNamespace(namespaceId: number) {
if (!namespaceId) return
const namespaceService = new NamespaceService()
namespace.value = await namespaceService.get(new NamespaceModel({id: namespaceId}))
// TODO: set namespace in store
}
const route = useRoute()
const namespaceId = computed(() => route.params.namespaceId !== undefined
? parseInt(route.params.namespaceId as string)
: undefined,
)
watchEffect(() => namespaceId.value !== undefined && loadNamespace(namespaceId.value))
</script>

View File

@ -44,7 +44,7 @@ import Message from '@/components/misc/message.vue'
const {t} = useI18n()
useTitle(t('sharing.authenticating'))
async function useAuth() {
function useAuth() {
const store = useStore()
const route = useRoute()
const router = useRouter()
@ -75,21 +75,21 @@ async function useAuth() {
password: password.value,
})
router.push({name: 'list.list', params: {listId}})
} catch (e) {
} catch (e: any) {
if (e.response?.data?.code === 13001) {
authenticateWithPassword.value = true
return
}
// TODO: Put this logic in a global errorMessage handler method which checks all auth codes
let errorMessage = t('sharing.error')
let err = t('sharing.error')
if (e.response?.data?.message) {
errorMessage = e.response.data.message
err = e.response.data.message
}
if (e.response?.data?.code === 13002) {
errorMessage = t('sharing.invalidPassword')
err = t('sharing.invalidPassword')
}
errorMessage.value = errorMessage
errorMessage.value = err
} finally {
loading.value = false
}

View File

@ -263,6 +263,7 @@
{{ task.done ? $t('task.detail.undone') : $t('task.detail.done') }}
</x-button>
<task-subscription
v-if="task.subscription"
entity="task"
:entity-id="task.id"
:subscription="task.subscription"
@ -386,22 +387,28 @@
<!-- Created / Updated [by] -->
<p class="created">
<i18n-t keypath="task.detail.created">
<span v-tooltip="formatDate(task.created)">{{ formatDateSince(task.created) }}</span>
{{ task.createdBy.getDisplayName() }}
</i18n-t>
<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 -->
<i18n-t keypath="task.detail.updated">
<span v-tooltip="updatedFormatted">{{ updatedSince }}</span>
</i18n-t>
<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/>
<i18n-t keypath="task.detail.doneAt">
<span v-tooltip="doneFormatted">{{ doneSince }}</span>
</i18n-t>
<time :datetime="formatISO(task.doneAt)" v-tooltip="doneFormatted">
<i18n-t keypath="task.detail.doneAt">
<span>{{ doneSince }}</span>
</i18n-t>
</time>
</template>
</p>
</div>
@ -453,8 +460,10 @@ import {CURRENT_LIST} from '@/store/mutation-types'
import {uploadFile} from '@/helpers/attachments'
import ChecklistSummary from '../../components/tasks/partials/checklist-summary'
export default {
name: 'TaskDetailView',
compatConfig: { ATTR_FALSE_VALUE: false },
components: {
ChecklistSummary,
TaskSubscription,
@ -473,6 +482,14 @@ export default {
description,
heading,
},
props: {
taskId: {
type: Number,
required: true,
},
},
data() {
return {
taskService: new TaskService(),
@ -523,10 +540,6 @@ export default {
},
},
computed: {
taskId() {
const {id} = this.$route.params
return id === undefined ? id : Number(id)
},
currentList() {
return this.$store.state[CURRENT_LIST]
},
@ -589,6 +602,9 @@ export default {
}
},
scrollToHeading() {
if(!this.$refs?.heading?.$el) {
return
}
this.$refs.heading.$el.scrollIntoView({block: 'center'})
},
setActiveFields() {
@ -931,4 +947,14 @@ $flash-background-duration: 750ms;
background: transparent;
}
}
@media (prefers-reduced-motion: reduce) {
@keyframes flash-background {
0% {
background: transparent;
}
}
}
@include modal-transition();
</style>

View File

@ -1,71 +0,0 @@
<template>
<modal
@close="close()"
variant="scrolling"
class="task-detail-view-modal"
>
<a @click="close()" class="close">
<icon icon="times"/>
</a>
<task-detail-view/>
</modal>
</template>
<script>
import TaskDetailView from './TaskDetailView'
export default {
name: 'TaskDetailViewModal',
components: {
TaskDetailView,
},
data() {
return {
lastRoute: null,
}
},
beforeRouteEnter(to, from, next) {
next(vm => {
vm.lastRoute = from
})
},
beforeRouteLeave(to, from, next) {
if (from.name === 'task.kanban.detail' && to.name === 'task.detail') {
this.$router.replace({name: 'task.kanban.detail', params: to.params})
return
}
next()
},
methods: {
close() {
if (this.lastRoute === null) {
this.$router.back()
} else {
this.$router.push(this.lastRoute)
}
},
},
}
</script>
<style lang="scss" scoped>
.close {
position: fixed;
top: 5px;
right: 26px;
color: var(--white);
font-size: 2rem;
@media screen and (max-width: $desktop) {
color: var(--dark);
}
}
</style>
<style lang="scss">
// Close icon SVG uses currentColor, change the color to keep it visible
.dark .task-detail-view-modal .close {
color: var(--grey-900);
}
</style>

View File

@ -308,4 +308,6 @@ export default {
padding: 0;
}
}
@include modal-transition();
</style>

View File

@ -1,9 +1,9 @@
<template>
<div>
<message variant="success" class="has-text-centered" v-if="confirmedEmailSuccess">
<message variant="success" text-align="center" class="mb-4" v-if="confirmedEmailSuccess">
{{ $t('user.auth.confirmEmailSuccess') }}
</message>
<message variant="danger" v-if="errorMessage">
<message variant="danger" v-if="errorMessage" class="mb-4">
{{ errorMessage }}
</message>
<form @submit.prevent="submit" id="loginform" v-if="localAuthEnabled">
@ -20,24 +20,26 @@
autocomplete="username"
v-focus
@keyup.enter="submit"
tabindex="1"
@focusout="validateField('username')"
/>
</div>
<p class="help is-danger" v-if="!usernameValid">
{{ $t('user.auth.usernameRequired') }}
</p>
</div>
<div class="field">
<label class="label" for="password">{{ $t('user.auth.password') }}</label>
<div class="control">
<input
class="input"
id="password"
name="password"
:placeholder="$t('user.auth.passwordPlaceholder')"
ref="password"
required
type="password"
autocomplete="current-password"
@keyup.enter="submit"
/>
<div class="label-with-link">
<label class="label" for="password">{{ $t('user.auth.password') }}</label>
<router-link
:to="{ name: 'user.password-reset.request' }"
class="reset-password-link"
tabindex="6"
>
{{ $t('user.auth.forgotPassword') }}
</router-link>
</div>
<password tabindex="2" @submit="submit" v-model="password" :validate-initially="validatePasswordInitially"/>
</div>
<div class="field" v-if="needsTotpPasscode">
<label class="label" for="totpPasscode">{{ $t('user.auth.totpTitle') }}</label>
@ -52,32 +54,28 @@
type="text"
v-focus
@keyup.enter="submit"
tabindex="3"
/>
</div>
</div>
<div class="field is-grouped login-buttons">
<div class="control is-expanded">
<x-button
@click="submit"
:loading="loading"
>
{{ $t('user.auth.login') }}
</x-button>
<x-button
:to="{ name: 'user.register' }"
v-if="registrationEnabled"
variant="secondary"
>
{{ $t('user.auth.register') }}
</x-button>
</div>
<div class="control">
<router-link :to="{ name: 'user.password-reset.request' }" class="reset-password-link">
{{ $t('user.auth.forgotPassword') }}
</router-link>
</div>
</div>
<x-button
@click="submit"
:loading="loading"
tabindex="4"
>
{{ $t('user.auth.login') }}
</x-button>
<p class="mt-2" v-if="registrationEnabled">
{{ $t('user.auth.noAccountYet') }}
<router-link
:to="{ name: 'user.register' }"
type="secondary"
tabindex="5"
>
{{ $t('user.auth.createAccount') }}
</router-link>
</p>
</form>
<div
@ -97,6 +95,7 @@
</template>
<script>
import {useDebounceFn} from '@vueuse/core'
import {mapState} from 'vuex'
import {HTTPFactory} from '@/http-common'
@ -105,15 +104,20 @@ import {getErrorText} from '@/message'
import Message from '@/components/misc/message'
import {redirectToProvider} from '../../helpers/redirectToProvider'
import {getLastVisited, clearLastVisited} from '../../helpers/saveLastVisited'
import Password from '@/components/input/password'
export default {
components: {
Password,
Message,
},
data() {
return {
confirmedEmailSuccess: false,
errorMessage: '',
usernameValid: true,
password: '',
validatePasswordInitially: false,
}
},
beforeMount() {
@ -166,6 +170,13 @@ export default {
localAuthEnabled: state => state.config.auth.local.enabled,
openidConnect: state => state.config.auth.openidConnect,
}),
validateField() {
// using computed so that debounced function definition stays
return useDebounceFn((field) => {
this[`${field}Valid`] = this.$refs[field].value !== ''
}, 100)
},
},
methods: {
setLoading() {
@ -185,7 +196,14 @@ export default {
// For more info, see https://kolaente.dev/vikunja/frontend/issues/78
const credentials = {
username: this.$refs.username.value,
password: this.$refs.password.value,
password: this.password,
}
if (credentials.username === '' || credentials.password === '') {
// Trigger the validation error messages
this.validateField('username')
this.validatePasswordInitially = true
return
}
if (this.needsTotpPasscode) {
@ -196,7 +214,7 @@ export default {
await this.$store.dispatch('auth/login', credentials)
this.$store.commit('auth/needsTotpPasscode', false)
} catch (e) {
if (e.response && e.response.data.code === 1017 && !credentials.totpPasscode) {
if (e.response?.data.code === 1017 && !this.credentials.totpPasscode) {
return
}
@ -211,22 +229,21 @@ export default {
</script>
<style lang="scss" scoped>
.login-buttons {
@media screen and (max-width: 450px) {
flex-direction: column;
.control:first-child {
margin-bottom: 1rem;
}
}
}
.button {
margin: 0 0.4rem 0 0;
}
.reset-password-link {
display: inline-block;
padding-top: 5px;
}
.label-with-link {
display: flex;
justify-content: space-between;
margin-bottom: .5rem;
.label {
margin-bottom: 0;
}
}
</style>

View File

@ -1,9 +1,9 @@
<template>
<div>
<message v-if="errorMsg">
<message v-if="errorMsg" class="mb-4">
{{ errorMsg }}
</message>
<div class="has-text-centered" v-if="successMessage">
<div class="has-text-centered mb-4" v-if="successMessage">
<message variant="success">
{{ successMessage }}
</message>

View File

@ -1,6 +1,6 @@
<template>
<div>
<message variant="danger" v-if="errorMessage !== ''">
<message variant="danger" v-if="errorMessage !== ''" class="mb-4">
{{ errorMessage }}
</message>
<form @submit.prevent="submit" id="registerform">
@ -18,8 +18,12 @@
v-focus
v-model="credentials.username"
@keyup.enter="submit"
@focusout="validateUsername"
/>
</div>
<p class="help is-danger" v-if="!usernameValid">
{{ $t('user.auth.usernameRequired') }}
</p>
</div>
<div class="field">
<label class="label" for="email">{{ $t('user.auth.email') }}</label>
@ -33,68 +37,46 @@
type="email"
v-model="credentials.email"
@keyup.enter="submit"
@focusout="validateEmail"
/>
</div>
<p class="help is-danger" v-if="!emailValid">
{{ $t('user.auth.emailInvalid') }}
</p>
</div>
<div class="field">
<label class="label" for="password">{{ $t('user.auth.password') }}</label>
<div class="control">
<input
class="input"
id="password"
name="password"
:placeholder="$t('user.auth.passwordPlaceholder')"
required
type="password"
autocomplete="new-password"
v-model="credentials.password"
@keyup.enter="submit"
/>
</div>
</div>
<div class="field">
<label class="label" for="passwordValidation">{{ $t('user.auth.passwordRepeat') }}</label>
<div class="control">
<input
class="input"
id="passwordValidation"
name="passwordValidation"
:placeholder="$t('user.auth.passwordPlaceholder')"
required
type="password"
autocomplete="new-password"
v-model="passwordValidation"
@keyup.enter="submit"
/>
</div>
<password @submit="submit" @update:modelValue="v => credentials.password = v" :validate-initially="validatePasswordInitially"/>
</div>
<div class="field is-grouped">
<div class="control">
<x-button
:loading="loading"
id="register-submit"
@click="submit"
class="mr-2"
>
{{ $t('user.auth.register') }}
</x-button>
<x-button :to="{ name: 'user.login' }" variant="secondary">
{{ $t('user.auth.login') }}
</x-button>
</div>
</div>
<x-button
:loading="loading"
id="register-submit"
@click="submit"
class="mr-2"
:disabled="!everythingValid"
>
{{ $t('user.auth.createAccount') }}
</x-button>
<p class="mt-2">
{{ $t('user.auth.alreadyHaveAnAccount') }}
<router-link :to="{ name: 'user.login' }">
{{ $t('user.auth.login') }}
</router-link>
</p>
</form>
</div>
</template>
<script setup>
import {useDebounceFn} from '@vueuse/core'
import {ref, reactive, toRaw, computed, onBeforeMount} from 'vue'
import {useI18n} from 'vue-i18n'
import router from '@/router'
import {store} from '@/store'
import Message from '@/components/misc/message'
import {isEmail} from '@/helpers/isEmail'
import Password from '@/components/input/password'
// FIXME: use the `beforeEnter` hook of vue-router
// Check if the user is already logged in, if so, redirect them to the homepage
@ -104,27 +86,45 @@ onBeforeMount(() => {
}
})
const {t} = useI18n()
const credentials = reactive({
username: '',
email: '',
password: '',
})
const passwordValidation = ref('')
const loading = computed(() => store.state.loading)
const errorMessage = ref('')
const validatePasswordInitially = ref(false)
const DEBOUNCE_TIME = 100
// debouncing to prevent error messages when clicking on the log in button
const emailValid = ref(true)
const validateEmail = useDebounceFn(() => {
emailValid.value = isEmail(credentials.email)
}, DEBOUNCE_TIME)
const usernameValid = ref(true)
const validateUsername = useDebounceFn(() => {
usernameValid.value = credentials.username !== ''
}, DEBOUNCE_TIME)
const everythingValid = computed(() => {
return credentials.username !== '' &&
credentials.email !== '' &&
credentials.password !== '' &&
emailValid.value &&
usernameValid.value
})
async function submit() {
errorMessage.value = ''
validatePasswordInitially.value = true
if (credentials.password !== passwordValidation.value) {
errorMessage.value = t('user.auth.passwordsDontMatch')
if (!everythingValid.value) {
return
}
try {
await store.dispatch('auth/register', toRaw(credentials))
} catch (e) {

View File

@ -1,9 +1,9 @@
<template>
<div>
<message variant="danger" v-if="errorMsg">
<message variant="danger" v-if="errorMsg" class="mb-4">
{{ errorMsg }}
</message>
<div class="has-text-centered" v-if="isSuccess">
<div class="has-text-centered mb-4" v-if="isSuccess">
<message variant="success">
{{ $t('user.auth.resetPasswordSuccess') }}
</message>