Files
vikunja/frontend/src/components/tasks/partials/KanbanCard.vue
kolaente 4aae270694 feat(tasks): move tasks between projects with drag and drop (#1945)
Drag and drop tasks between projects from list and kanban views, with cross-project move handling and success notification. With visual drop-target highlighting when hovering a project during a drag.
2025-12-10 18:59:38 +00:00

406 lines
8.6 KiB
Vue

<template>
<div
class="task loader-container draggable"
:class="{
'is-loading': loadingInternal || loading,
'draggable': !(loadingInternal || loading),
'has-light-text': !colorIsDark(color),
'has-custom-background-color': color ?? undefined,
}"
:style="{'background-color': color ?? undefined}"
:data-task-id="task.id"
:data-project-id="task.projectId"
@click.exact="openTaskDetail()"
@click.ctrl="() => toggleTaskDone(task)"
@click.meta="() => toggleTaskDone(task)"
>
<img
v-if="coverImageBlobUrl"
:src="coverImageBlobUrl"
alt=""
class="tw-w-full"
>
<div class="p-2">
<div class="tw-flex tw-justify-between">
<span class="task-id">
<Done
class="kanban-card__done"
:is-done="task.done"
variant="small"
/>
<template v-if="task.identifier === ''">
#{{ task.index }}
</template>
<template v-else>
{{ task.identifier }}
</template>
<span
v-if="showTaskPosition"
class="tw-text-red-600 tw-ps-2"
>
{{ task.position }}
</span>
</span>
<span
v-if="task.dueDate > 0"
v-tooltip="formatDateLong(task.dueDate)"
:class="{'overdue': isOverdue}"
class="due-date"
>
<span class="icon">
<Icon :icon="['far', 'calendar-alt']" />
</span>
<time :datetime="formatISO(task.dueDate)">
{{ formatDisplayDate(task.dueDate) }}
</time>
</span>
</div>
<h3>{{ task.title }}</h3>
<span
v-if="projectTitle"
class="project-title"
>
{{ projectTitle }}
</span>
<ProgressBar
v-if="task.percentDone > 0"
class="task-progress"
:value="task.percentDone * 100"
/>
<div class="footer">
<Labels :labels="task.labels" />
<PriorityLabel
:priority="task.priority"
:done="task.done"
class="is-inline-flex is-align-items-center"
/>
<span
v-if="task.attachments.length > 0"
class="icon"
>
<Icon icon="paperclip" />
</span>
<CommentCount
:task="task"
class="project-task-icon"
/>
<span
v-if="!isEditorContentEmpty(task.description)"
class="icon"
>
<Icon icon="align-left" />
</span>
<span
v-if="task.repeatAfter.amount > 0"
class="icon"
>
<Icon icon="history" />
</span>
<AssigneeList
v-if="task.assignees.length > 0"
:assignees="task.assignees"
:avatar-size="24"
/>
<ChecklistSummary
:task="task"
class="checklist"
/>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import {computed, ref, watch} from 'vue'
import {useRouter} from 'vue-router'
import {useGlobalNow} from '@/composables/useGlobalNow'
import PriorityLabel from '@/components/tasks/partials/PriorityLabel.vue'
import ProgressBar from '@/components/misc/ProgressBar.vue'
import Done from '@/components/misc/Done.vue'
import Labels from '@/components/tasks/partials/Labels.vue'
import ChecklistSummary from './ChecklistSummary.vue'
import CommentCount from './CommentCount.vue'
import {getHexColor} from '@/models/task'
import type {ITask} from '@/modelTypes/ITask'
import type {IProject} from '@/modelTypes/IProject'
import {SUPPORTED_IMAGE_SUFFIX} from '@/models/attachment'
import AttachmentService, {PREVIEW_SIZE} from '@/services/attachment'
import {formatDateLong, formatDisplayDate, formatISO} from '@/helpers/time/formatDate'
import {colorIsDark} from '@/helpers/color/colorIsDark'
import {useTaskStore} from '@/stores/tasks'
import AssigneeList from '@/components/tasks/partials/AssigneeList.vue'
import {playPopSound} from '@/helpers/playPop'
import {isEditorContentEmpty} from '@/helpers/editorContentEmpty'
import {useProjectStore} from '@/stores/projects'
import {TASK_REPEAT_MODES} from '@/types/IRepeatMode'
const props = withDefaults(defineProps<{
task: ITask,
projectId: IProject['id'],
loading?: boolean,
}>(), {
loading: false,
})
const emit = defineEmits<{
'taskCompletedRecurring': [task: ITask]
}>()
const router = useRouter()
const loadingInternal = ref(false)
const color = computed(() => getHexColor(props.task.hexColor))
const projectStore = useProjectStore()
const projectTitle = computed(() => {
if (props.projectId === props.task.projectId) {
return
}
const project = projectStore.projects[props.task.projectId]
return project?.title
})
const showTaskPosition = computed(() => window.DEBUG_TASK_POSITION)
const {now} = useGlobalNow()
const isOverdue = computed(() => (
!props.task.done &&
props.task.dueDate !== null &&
props.task.dueDate.getTime() > 0 &&
props.task.dueDate.getTime() <= now.value.getTime()
))
async function toggleTaskDone(task: ITask) {
const isRecurringTask = task.repeatAfter.amount > 0 || task.repeatMode === TASK_REPEAT_MODES.REPEAT_MODE_MONTH
const wasBeingMarkedDone = !task.done
loadingInternal.value = true
try {
const updatedTask = await useTaskStore().update({
...task,
done: !task.done,
})
if (updatedTask.done) {
playPopSound()
}
// Emit event if this was a recurring task being marked as done
if (isRecurringTask && wasBeingMarkedDone && updatedTask.done) {
emit('taskCompletedRecurring', updatedTask)
}
} finally {
loadingInternal.value = false
}
}
function openTaskDetail() {
router.push({
name: 'task.detail',
params: {id: props.task.id},
state: {backdropView: router.currentRoute.value.fullPath},
})
}
const coverImageBlobUrl = ref<string | null>(null)
async function maybeDownloadCoverImage() {
if (!props.task.coverImageAttachmentId) {
coverImageBlobUrl.value = null
return
}
const attachment = props.task.attachments.find(a => a.id === props.task.coverImageAttachmentId)
if (!attachment || !SUPPORTED_IMAGE_SUFFIX.some((suffix) => attachment.file.name.toLowerCase().endsWith(suffix))) {
return
}
const attachmentService = new AttachmentService()
coverImageBlobUrl.value = await attachmentService.getBlobUrl(attachment, PREVIEW_SIZE.LG)
}
watch(
() => props.task.coverImageAttachmentId,
maybeDownloadCoverImage,
{immediate: true},
)
</script>
<style lang="scss" scoped>
$task-background: var(--white);
.task {
-webkit-touch-callout: none; // iOS Safari
user-select: none;
cursor: pointer;
box-shadow: var(--shadow-xs);
display: block;
font-size: .9rem;
border-radius: $radius;
background: $task-background;
overflow: hidden;
&.loader-container.is-loading::after {
inline-size: 1.5rem;
block-size: 1.5rem;
inset-block-start: calc(50% - .75rem);
inset-inline-start: calc(50% - .75rem);
border-width: 2px;
}
h3 {
font-family: $family-sans-serif;
font-size: .85rem;
word-break: break-word;
}
.due-date {
float: inline-end;
display: flex;
align-items: center;
padding: 0 .25rem;
font-size: .85rem;
.icon {
margin-inline-end: .25rem;
}
&.overdue {
color: var(--danger);
}
}
.label-wrapper .tag {
margin: .5rem .5rem 0 0;
}
.footer {
background: transparent;
padding: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .25rem;
margin-block-start: .25rem;
:deep(.checklist-summary) {
padding-inline-start: 0;
}
.assignees {
display: flex;
.user {
display: inline;
margin: 0;
img {
margin: 0;
}
}
}
.priority-label {
font-size: .75rem;
padding: 0 .5rem 0 .25rem;
.icon {
block-size: 1rem;
padding: 0 .25rem;
margin-block-start: 0;
}
}
}
.footer .icon,
.due-date,
.priority-label {
background: var(--grey-100);
border-radius: $radius;
padding: 0 .5rem;
}
.task-id, .project-title {
color: var(--grey-500);
font-size: .8rem;
margin-block-end: .25rem;
display: flex;
}
&.is-moving {
opacity: .5;
}
span {
inline-size: auto;
}
&.has-custom-background-color {
color: hsl(215, 27.9%, 16.9%); // copied from grey-800 to avoid different values in dark mode
.footer .icon,
.due-date,
.priority-label {
background: hsl(220, 13%, 91%);
}
.footer :deep(.checklist-summary) {
color: hsl(216.9, 19.1%, 26.7%); // grey-700
}
}
&.has-light-text {
--white: hsla(var(--white-h), var(--white-s), var(--white-l), var(--white-a)) !important;
color: var(--white);
.task-id {
color: hsl(220, 13%, 91%); // grey-200;
}
.footer .icon,
.due-date,
.priority-label {
background: hsl(215, 27.9%, 16.9%); // grey-800
}
.footer {
.icon svg {
fill: var(--white);
}
:deep(.checklist-summary) {
color: hsl(220, 13%, 91%); // grey-200
}
}
}
}
.kanban-card__done {
// Spacing handled by parent flex gap
}
.task-progress {
margin: 8px 0 0;
inline-size: 100%;
block-size: 0.5rem;
}
:deep(.comment-count) {
background: var(--grey-100);
border-radius: $radius;
padding: 0.25rem;
}
</style>