feat(task): cover image for tasks (#2460)
continuous-integration/drone/push Build is failing Details

Reviewed-on: #2460
Reviewed-by: Dominik Pschenitschni <dpschen@noreply.kolaente.de>
This commit is contained in:
konrad 2022-10-05 13:28:09 +00:00
commit 31e39aa6c8
7 changed files with 169 additions and 81 deletions

View File

@ -9,7 +9,7 @@
<input
v-if="editEnabled"
:disabled="attachmentService.loading || undefined"
:disabled="loading || undefined"
@change="uploadNewAttachment()"
id="files"
multiple
@ -35,7 +35,15 @@
:key="a.id"
@click="viewOrDownload(a)"
>
<div class="filename">{{ a.file.name }}</div>
<div class="filename">
{{ a.file.name }}
<span
v-if="task.coverImageAttachmentId === a.id"
class="is-task-cover"
>
{{ $t('task.attachment.usedAsCover') }}
</span>
</div>
<div class="info">
<p class="attachment-info-meta">
<i18n-t keypath="task.attachment.createdBy" scope="global">
@ -78,6 +86,17 @@
>
{{ $t('misc.delete') }}
</BaseButton>
<BaseButton
v-if="editEnabled"
class="attachment-info-meta-button"
@click.prevent.stop="setCoverImage(task.coverImageAttachmentId === a.id ? null : a)"
>
{{
task.coverImageAttachmentId === a.id
? $t('task.attachment.unsetAsCover')
: $t('task.attachment.setAsCover')
}}
</BaseButton>
</p>
</div>
</a>
@ -85,7 +104,7 @@
<x-button
v-if="editEnabled"
:disabled="attachmentService.loading"
:disabled="loading"
@click="filesRef?.click()"
class="mb-4"
icon="cloud-upload-alt"
@ -118,7 +137,7 @@
<template #header>
<span>{{ $t('task.attachment.delete') }}</span>
</template>
<template #text>
<p>
{{ $t('task.attachment.deleteText1', {filename: attachmentToDelete.file.name}) }}<br/>
@ -138,13 +157,14 @@
</template>
<script setup lang="ts">
import {ref, shallowReactive, computed, type PropType} from 'vue'
import {ref, shallowReactive, computed} from 'vue'
import {useDropZone} from '@vueuse/core'
import User from '@/components/misc/user.vue'
import BaseButton from '@/components/base/BaseButton.vue'
import AttachmentService from '@/services/attachment'
import {SUPPORTED_IMAGE_SUFFIX} from '@/models/attachment'
import type AttachmentModel from '@/models/attachment'
import type {IAttachment} from '@/modelTypes/IAttachment'
import type {ITask} from '@/modelTypes/ITask'
@ -155,38 +175,44 @@ import {uploadFiles, generateAttachmentUrl} from '@/helpers/attachments'
import {getHumanSize} from '@/helpers/getHumanSize'
import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
import {error, success} from '@/message'
import {useTaskStore} from '@/stores/tasks'
import {useI18n} from 'vue-i18n'
const props = defineProps({
taskId: {
type: Number as PropType<ITask['id']>,
required: true,
},
initialAttachments: {
type: Array,
},
editEnabled: {
default: true,
},
const taskStore = useTaskStore()
const {t} = useI18n({useScope: 'global'})
const props = withDefaults(defineProps<{
task: ITask,
initialAttachments?: IAttachment[],
editEnabled: boolean,
}>(), {
editEnabled: true,
})
// FIXME: this should go through the store
const emit = defineEmits(['task-changed'])
const attachmentService = shallowReactive(new AttachmentService())
const attachmentStore = useAttachmentStore()
const attachments = computed(() => attachmentStore.attachments)
const loading = computed(() => attachmentService.loading || taskStore.isLoading)
function onDrop(files: File[] | null) {
if (files && files.length !== 0) {
uploadFilesToTask(files)
}
}
const { isOverDropZone } = useDropZone(document, onDrop)
const {isOverDropZone} = useDropZone(document, onDrop)
function downloadAttachment(attachment: IAttachment) {
attachmentService.download(attachment)
}
const filesRef = ref<HTMLInputElement | null>(null)
function uploadNewAttachment() {
const files = filesRef.value?.files
@ -198,7 +224,7 @@ function uploadNewAttachment() {
}
function uploadFilesToTask(files: File[] | FileList) {
uploadFiles(attachmentService, props.taskId, files)
uploadFiles(attachmentService, props.task.id, files)
}
const attachmentToDelete = ref<AttachmentModel | null>(null)
@ -217,16 +243,15 @@ async function deleteAttachment() {
attachmentStore.removeById(attachmentToDelete.value.id)
success(r)
setAttachmentToDelete(null)
} catch(e) {
} catch (e) {
error(e)
}
}
const attachmentImageBlobUrl = ref<string | null>(null)
const SUPPORTED_SUFFIX = ['.jpg', '.png', '.bmp', '.gif']
async function viewOrDownload(attachment: AttachmentModel) {
if (SUPPORTED_SUFFIX.some((suffix) => attachment.file.name.endsWith(suffix)) ) {
if (SUPPORTED_IMAGE_SUFFIX.some((suffix) => attachment.file.name.endsWith(suffix))) {
attachmentImageBlobUrl.value = await attachmentService.getBlobUrl(attachment)
} else {
downloadAttachment(attachment)
@ -234,8 +259,15 @@ async function viewOrDownload(attachment: AttachmentModel) {
}
const copy = useCopyToClipboard()
function copyUrl(attachment: IAttachment) {
copy(generateAttachmentUrl(props.taskId, attachment.id))
copy(generateAttachmentUrl(props.task.id, attachment.id))
}
async function setCoverImage(attachment: IAttachment | null) {
const task = await taskStore.setCoverImage(props.task, attachment)
emit('task-changed', task)
success({message: t('task.attachment.successfullyChangedCoverImage')})
}
</script>
@ -316,7 +348,7 @@ function copyUrl(attachment: IAttachment) {
height: auto;
text-shadow: var(--shadow-md);
animation: bounce 2s infinite;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
@ -338,7 +370,7 @@ function copyUrl(attachment: IAttachment) {
.attachment-info-meta {
display: flex;
align-items: center;
:deep(.user) {
display: flex !important;
align-items: center;
@ -348,7 +380,7 @@ function copyUrl(attachment: IAttachment) {
@media screen and (max-width: $mobile) {
flex-direction: column;
align-items: flex-start;
:deep(.user) {
margin: .5rem 0;
}
@ -394,5 +426,13 @@ function copyUrl(attachment: IAttachment) {
}
}
.is-task-cover {
background: var(--primary);
color: var(--white);
padding: .25rem .35rem;
border-radius: 4px;
font-size: .75rem;
}
@include modal-transition();
</style>

View File

@ -11,62 +11,70 @@
@click.ctrl="() => toggleTaskDone(task)"
@click.meta="() => toggleTaskDone(task)"
>
<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>
<span
:class="{'overdue': task.dueDate <= new Date() && !task.done}"
class="due-date"
v-if="task.dueDate > 0"
v-tooltip="formatDateLong(task.dueDate)">
<span class="icon">
<icon :icon="['far', 'calendar-alt']"/>
<img
v-if="coverImageBlobUrl"
:src="coverImageBlobUrl"
alt=""
class="cover-image"
/>
<div class="p-2">
<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>
<time :datetime="formatISO(task.dueDate)">
{{ formatDateSince(task.dueDate) }}
</time>
</span>
<h3>{{ task.title }}</h3>
<progress
class="progress is-small"
v-if="task.percentDone > 0"
:value="task.percentDone * 100" max="100">
{{ task.percentDone * 100 }}%
</progress>
<div class="footer">
<labels :labels="task.labels"/>
<priority-label :priority="task.priority" :done="task.done"/>
<div class="assignees" v-if="task.assignees.length > 0">
<user
v-for="u in task.assignees"
:avatar-size="24"
:key="task.id + 'assignee' + u.id"
:show-username="false"
:user="u"
/>
<span
:class="{'overdue': task.dueDate <= new Date() && !task.done}"
class="due-date"
v-if="task.dueDate > 0"
v-tooltip="formatDateLong(task.dueDate)">
<span class="icon">
<icon :icon="['far', 'calendar-alt']"/>
</span>
<time :datetime="formatISO(task.dueDate)">
{{ formatDateSince(task.dueDate) }}
</time>
</span>
<h3>{{ task.title }}</h3>
<progress
class="progress is-small"
v-if="task.percentDone > 0"
:value="task.percentDone * 100" max="100">
{{ task.percentDone * 100 }}%
</progress>
<div class="footer">
<labels :labels="task.labels"/>
<priority-label :priority="task.priority" :done="task.done"/>
<div class="assignees" v-if="task.assignees.length > 0">
<user
v-for="u in task.assignees"
:avatar-size="24"
:key="task.id + 'assignee' + u.id"
:show-username="false"
:user="u"
/>
</div>
<checklist-summary :task="task"/>
<span class="icon" v-if="task.attachments.length > 0">
<icon icon="paperclip"/>
</span>
<span v-if="task.description" class="icon">
<icon icon="align-left"/>
</span>
<span class="icon" v-if="task.repeatAfter.amount > 0">
<icon icon="history"/>
</span>
</div>
<checklist-summary :task="task"/>
<span class="icon" v-if="task.attachments.length > 0">
<icon icon="paperclip"/>
</span>
<span v-if="task.description" class="icon">
<icon icon="align-left"/>
</span>
<span class="icon" v-if="task.repeatAfter.amount > 0">
<icon icon="history"/>
</span>
</div>
</div>
</template>
<script lang="ts" setup>
import {ref, computed} from 'vue'
import {ref, computed, watch} from 'vue'
import {useRouter} from 'vue-router'
import PriorityLabel from '@/components/tasks/partials/priorityLabel.vue'
@ -77,6 +85,8 @@ import ChecklistSummary from './checklist-summary.vue'
import {TASK_DEFAULT_COLOR, getHexColor} from '@/models/task'
import type {ITask} from '@/modelTypes/ITask'
import {SUPPORTED_IMAGE_SUFFIX} from '@/models/attachment'
import AttachmentService from '@/services/attachment'
import {formatDateLong, formatISO, formatDateSince} from '@/helpers/time/formatDate'
import {colorIsDark} from '@/helpers/color/colorIsDark'
@ -114,6 +124,29 @@ function openTaskDetail() {
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.endsWith(suffix))) {
return
}
const attachmentService = new AttachmentService()
coverImageBlobUrl.value = await attachmentService.getBlobUrl(attachment)
}
watch(
() => props.task.coverImageAttachmentId,
maybeDownloadCoverImage,
{immediate: true},
)
</script>
<style lang="scss" scoped>
@ -125,12 +158,11 @@ $task-background: var(--white);
cursor: pointer;
box-shadow: var(--shadow-xs);
display: block;
border: 3px solid transparent;
font-size: .9rem;
padding: .4rem;
border-radius: $radius;
background: $task-background;
overflow: hidden;
&.loader-container.is-loading::after {
width: 1.5rem;

View File

@ -693,7 +693,11 @@
"deleteTooltip": "Delete this attachment",
"deleteText1": "Are you sure you want to delete the attachment {filename}?",
"copyUrl": "Copy URL",
"copyUrlTooltip": "Copy the url of this attachment for usage in text"
"copyUrlTooltip": "Copy the url of this attachment for usage in text",
"setAsCover": "Make cover",
"unsetAsCover": "Remove cover",
"successfullyChangedCoverImage": "The cover image was successfully changed.",
"usedAsCover": "Cover image"
},
"comment": {
"title": "Comments",

View File

@ -1,4 +1,4 @@
import type { Priority } from '@/constants/priorities'
import type {Priority} from '@/constants/priorities'
import type {IAbstract} from './IAbstract'
import type {IUser} from './IUser'
@ -11,6 +11,7 @@ import type {IBucket} from './IBucket'
import type {IRelationKind} from '@/types/IRelationKind'
import type {IRepeatAfter} from '@/types/IRepeatAfter'
import type {IRepeatMode} from '@/types/IRepeatMode'
export interface ITask extends IAbstract {
id: number
title: string
@ -31,8 +32,9 @@ export interface ITask extends IAbstract {
parentTaskId: ITask['id']
hexColor: string
percentDone: number
relatedTasks: Partial<Record<IRelationKind, ITask[]>>,
relatedTasks: Partial<Record<IRelationKind, ITask[]>>
attachments: IAttachment[]
coverImageAttachmentId: IAttachment['id']
identifier: string
index: number
isFavorite: boolean

View File

@ -5,6 +5,8 @@ import type { IUser } from '@/modelTypes/IUser'
import type { IFile } from '@/modelTypes/IFile'
import type { IAttachment } from '@/modelTypes/IAttachment'
export const SUPPORTED_IMAGE_SUFFIX = ['.jpg', '.png', '.bmp', '.gif']
export default class AttachmentModel extends AbstractModel<IAttachment> implements IAttachment {
id = 0
taskId = 0

View File

@ -409,6 +409,13 @@ export const useTaskStore = defineStore('task', {
cancel()
}
},
async setCoverImage(task: ITask, attachment: IAttachment | null) {
return this.update({
...task,
coverImageAttachmentId: attachment ? attachment.id : 0,
})
},
},
})

View File

@ -218,7 +218,8 @@
<div class="content attachments" v-if="activeFields.attachments || hasAttachments">
<attachments
:edit-enabled="canWrite"
:task-id="taskId"
:task="task"
@task-changed="({coverImageAttachmentId}) => task.coverImageAttachmentId = coverImageAttachmentId"
ref="attachments"
/>
</div>
@ -500,7 +501,7 @@ const attachmentStore = useAttachmentStore()
const taskStore = useTaskStore()
const kanbanStore = useKanbanStore()
const task = reactive(new TaskModel())
const task = reactive<ITask>(new TaskModel())
useTitle(toRef(task, 'title'))
// We doubled the task color property here because verte does not have a real change property, leading