Merge branch 'main' into fix/upcoming
# Conflicts: # src/views/tasks/ShowTasks.vue
This commit is contained in:
@ -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>
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -85,4 +85,8 @@ export default {
|
||||
margin-left: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.dark .update-notification {
|
||||
color: var(--grey-200);
|
||||
}
|
||||
</style>
|
85
src/components/input/password.vue
Normal file
85
src/components/input/password.vue
Normal 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>
|
@ -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>
|
||||
|
@ -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: {
|
||||
|
@ -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 = {
|
||||
|
@ -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) {
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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',
|
@ -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>
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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() {
|
||||
|
@ -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>
|
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -264,4 +264,6 @@ export default {
|
||||
.sharables-list:not(.card-content) {
|
||||
overflow-y: auto
|
||||
}
|
||||
|
||||
@include modal-transition();
|
||||
</style>
|
@ -365,3 +365,7 @@ export default {
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@include modal-transition();
|
||||
</style>
|
@ -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,
|
||||
|
@ -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)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
@ -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>
|
@ -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>
|
@ -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>
|
||||
|
||||
|
@ -38,7 +38,7 @@ import {mapState} from 'vuex'
|
||||
export default {
|
||||
name: 'description',
|
||||
components: {
|
||||
editor: AsyncEditor,
|
||||
Editor: AsyncEditor,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
@ -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) {
|
||||
|
@ -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>
|
||||
|
Reference in New Issue
Block a user